Skip to content

feat(host): 宿主入口覆盖 UI 全部可达操作(100%),执行层缓存省 72% - #5

Merged
zyonlab merged 9 commits into
mainfrom
feat/host-parity
Sep 15, 2026
Merged

zyonlab merged 9 commits into
mainfrom
feat/host-parity

Conversation

@zyonlab

@zyonlab zyonlab commented Sep 15, 2026

Copy link
Copy Markdown
Owner

按「只是将入口从 UI 换成了宿主」这个目标,把整个流程跑了三遍,每遍加深。

一、宿主入口:10% → 100%

181 / 181 可达路由,17 个域。2 条如实标 uiOnly 并写了理由:GET /api/events(SSE 长连接,宿主是请求-响应的,进度改用轮询拿同一份状态)、POST /api/chat(UI 自己的对话框,它做的事正是宿主本身在做的)。

覆盖
地基 18/183(10.0%)
第一遍 61/183(33.3%) 7
第二遍 121/183(66.1%) 12
第三遍 181/181(100%) 17

先做检查,再写工具

差距是 183 条端点 vs 27 个工具,而且没有任何机制会告诉你差在哪、也没有东西拦着它继续变差。这个仓库被「同一件事写两遍、其中一份悄悄落后」咬过好几次。所以照 check-drift 的套路做成可检查的:每条路由必须三选一(host / todo / uiOnly),分不了类就红,覆盖条数只能涨不能跌。进了 CI。

它抓到的第一件事就是路由清单本身不对:

抓到的 后果
单引号注册的路由整个看不见 3 条真实操作不在清单里
循环注册的 `/stages/${action}` 没展开 少 17 条,而覆盖率会显得好看
我照记忆写的 10 条路径只有 2 条存在 参数叫 :id 不是 :projectId
--write 只刷基线不重算归属 一张要手工同步的表,正是它要防的漂移
退化检查比四舍五入的比率 报出「从 91.3% 掉到 91.3%」

分层

host/api.ts       只搬字节。失败时把服务端的话原样带回——「树没冻结」是写给人看的
host/registry.ts  声明式路由表,一行 = 一个 UI 操作
host/tools.ts     一个域一个工具、动作是枚举(180 个工具的清单没法读)

业务逻辑一行没复制,宿主走 UI 同一批 HTTP 接口 → 服务端接口一行没改,UI 不需要同步

人做的决定仍然是人做的:冻结模块树、批准用例、立基线的工具都暴露了(人在 chat 里说和在 UI 上点是同一件事,agent 只是那只手),但摘要里必须写明「这是人的决定」,测试钉着。

二、执行层缓存:省 72%

MIDSCENE_CACHE=1 一直开着、缓存也一直在写,但键里放了 page.content() 和截图哈希——实时行情页上这两样每秒都在变

同一批 10 条用例跑四次:

缓存键 冷/热 模型调用 零调用 耗时
旧(DOM+截图) 47 0 286s
28(−40%) 3 231s
新(结构指纹) 47 0 279s
13(−72%) 7/10 191s

③ 和 ① 完全相同是这里最要紧的一条:缓存空时新键没有任何差别,说明键没被改松到误命中。省下来的全部来自「价格在动,但这一屏还是原来那一屏」这个正确判断。

改成看结构不看内容:可见控件的身份,标签里数字抹掉。Positions (1)Positions (7) 是同一个控件;控件增删改名换角色则键正确失效——不会拿过时的计划去点已经不存在的元素(那种失败看起来像产品坏了)。判定逻辑抽成纯函数测住,CACHE_POLICY_VERSIONstructure-v5

剩下 3 条仍调模型,因为它们的步骤真的改变了页面结构。这是设计要的行为。

检查

pnpm typecheck / pnpm test(554 + 119 + …)/ pnpm check:drift / pnpm check:host-parity 全绿;三入口验过:UI 200、API 200、两个宿主 plugin 与真源一致。

🤖 Generated with Claude Code

zyoncode and others added 9 commits September 14, 2026 22:40
2026-09-14 用户定的方向:「只是将入口从 UI 换成了宿主」——宿主理论上要能覆盖
Web UI 上的每一个操作。实测的差距是 **180 条真实端点,宿主覆盖 18 条,10.0%**,
而且没有任何机制会告诉你差在哪、差多少,也没有任何东西拦着它继续变差。

这个仓库已经被「同一件事写两遍、其中一份悄悄落后」咬过好几次:`sourceRefs`
同名不同义、宿主 plugin 副本落后四个文件两天没人发现。所以照 `check-drift` 的套路,
把覆盖做成可检查的:

**每一条路由必须被显式分类**,三选一——
  host: "<tool>.<action>"  宿主已经能做
  status: "todo"           还没做(覆盖率的分母)
  uiOnly: "<理由>"         故意不给宿主,理由要写下来

清单在 `server/host-parity.json`,检查在 `scripts/check-host-parity.mjs`,进了 CI。
它管两件「悄悄退化」:新加的 UI 路由没分类 → 红;覆盖率比基线低 → 红,并指出掉在哪。

两处实现细节,各自防一种假象:

- **循环注册的路由要展开**。`for (const action of […]) router.post(\`/stages/${action}\`)`
  静态抠出来是一条带 `${action}` 的字符串;留着它,清单里就少了十几条真实操作,
  而覆盖率会显得好看——正是这个检查要防的那种。展开表跟着源码走,对不上会抛。
  展开前 163 条,展开后 180 条。
- **分母把 uiOnly 去掉**。故意不给的不该拉低分数,否则要么没人敢标 uiOnly,
  要么大家靠标 uiOnly 把数字做上去。

`uiOnly` 不是人工闸的藏身处:冻结模块树、批准用例这些**人做的决定**,
在宿主里同样是人做的决定(人在 chat 里说),agent 只是那只手。要保住的是审计属性
——谁做的决定、经哪条入口进来,记在账本上;不是「宿主没有这个工具」。
所以现在 uiOnly 是 0 条。

两种失败都验过:临时加一条 `GET /api/pretend-new-ui-thing` 当场报「是新路由,清单里
没有它」;把一条 host 改回 todo,当场报「覆盖率从 10.0% 掉到 9.4%」。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
按「入口从 UI 换成宿主」的目标铺第一遍骨架:创建项目 → 规则包与领域知识 →
运行 workflow 每个节点(含工作单元)→ 产物 → 复核 → 执行与基线。

**分层**(业务逻辑一行都不复制,全在服务端):
  host/api.ts       只搬字节的传输层。失败时把服务端的话原样带回去——
                    「树没冻结」「出处对不上」是写给人看的,压成 host_api_failed
                    会让 agent 只知道失败、不知道为什么。
  host/registry.ts  声明式路由表,一行 = 一个 UI 操作。
  host/tools.ts     适配层:一个域一个工具、动作是枚举。
                    180 条路由摊成 180 个工具,agent 的清单会长到没法读。

七个域 61 个动作:tp_project / tp_run / tp_stage / tp_unit / tp_artifact /
tp_review / tp_execution。宿主与 UI 走同一套 HTTP 接口,所以接口改了两边同时改,
不会出现「同一件事写两遍、其中一份悄悄落后」。

**检查当场抓到三处,每一处都说明这张表值得存在:**

① 我照记忆写的十条路径,真实存在的只有两条——参数叫 `:id` 不是 `:projectId`,
   材料是全局 `/api/materials`。路径写错不会有任何一层报错,只会在 agent 真调的时候
   404,而那时人已经在等结果了。现在 registry 的每一行都要对应一条真实路由。
② `host:` 指向 registry 里不存在的动作,也是红。
③ **单引号注册的路由整个看不见**:抠取正则只认双引号和反引号,而
   `router.post('/:runId/begin-stage', …)` 用的是单引号。补上之后 180 → 183 条,
   三条真实操作本来完全不在清单里。

对着已有的 hyperliquid 项目真调通了:项目列表、总览(242 条用例 / P0 155)、
规则包、运行列表都拿到真数据;缺参数当场说「缺少路径参数 projectId」。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
照同一个目标从头走一遍,把第一遍只铺了骨架的地方补上:用例、执行报告与基线、
导出代码工程、设置与模型档案、评测与记分板。十二个域 121 个动作。

**检查又抓到两处,都是「写的时候以为对」的那种:**

① `tp_case.bind_data` 指向 `POST /api/cases/:id/data-binding`——服务端只有 GET,
   改绑定是走 `PATCH /api/cases/:id` 的 dataKey 字段。删掉这个不存在的动作,
   在读那条旁边写清楚绑定该怎么改。
② **`--write` 只刷新基线、不重算归属**:新加了五个域,`check` 仍然报 33.3%,
   因为 `host:` 还得有人一条条去填。一张需要手工同步的表,和它要防的那种漂移
   是同一件事。现在归属由 registry 算:有这条路由就是 host,没有就退回 todo。

对着已有的 hyperliquid 项目真调了 16 个动作,全绿:规则包、运行列表、检查点、
产物清单、复核清单、用例、执行报告、待批基线、追溯线、导出预检、设置、
模型档案、记分板——全是真数据。

测试里加了一条:**人做的决定要在摘要里写明白**。冻结模块树、批准用例、立基线,
在 UI 上是人点的,在宿主里也得是人说了才调;摘要里没写「这是人的决定」就红。
另外每个动作的摘要都要写人话——五条太简略的(「读就绪度」「读记分板」)被测试挑出来重写了。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
第三遍把剩下 62 条走完:复核队列、工作流图、图执行控制、审计与变异、系统与进程,
以及散在各域的数据集、密钥、评测规格、批量生成代码、临时试跑。

**181 / 181 可达路由,100%。** 另有 2 条如实标成 UI 专属,理由写在清单里:

  GET  /api/events  服务端推事件流(SSE)。宿主是请求-响应的,订阅不了长连接;
                    进度改用 tp_run.checkpoint / tp_unit.status 轮询,同一份状态。
  POST /api/chat    Web UI 自己的对话框——它做的事正是宿主本身在做的。
                    暴露给宿主等于让 agent 去驱动另一个 agent。

**这一遍修掉的两处,都是我自己写的检查里的毛病:**

① **`--write` 只刷新基线、不重算归属**(第二遍就撞过一次):加了五个域,
   `check` 还报旧数字。一张需要手工同步的表,和它要防的那种漂移是同一件事。
② **退化检查比的是四舍五入过的比率**,于是新增一条路由并同时覆盖它时,
   报出「覆盖率从 91.3% 掉到 91.3%」——一句自相矛盾的话。改成比覆盖**条数**:
   整数,没有这个问题,而它要表达的本来就是「宿主能做的事只能变多」。

**还有一处是测试自己的门槛定歪了。** 「摘要长度 > 4」点名了 60 个动作,其中
「取消一次运行」本来就说清楚了——**硬凑字数是为了过测试,不是为了让 agent 看懂**。
改成判信息量:说出它动的是什么东西,而不只是一个光杆动词。

skill 里也告诉宿主这批工具存在(不然 agent 不知道自己能干这些),并写了三条:
不知道 runId 就先 `tp_run.list` 找(它只在服务器上,上次会话结束就忘了);
参数不全要问人不要猜;人的决定要人来做——agent 是那只手,不是拍板的那个人。
`testpilot-run-c` 跳到 2026-09-14.1,两个宿主 plugin 已重新生成。

**服务端接口一行没改**,所以 Web UI 不需要同步——宿主走的是 UI 同一批接口。
三个入口都验过:UI 首页 200、API 200、两个宿主 plugin 与真源一致。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
用户要求执行层尽量用缓存省 token。机制一直在:`MIDSCENE_CACHE=1` 开着,缓存也确实在写
(10 条用例写了 10 个文件,里面是每一步的规划结果)。但同一批重跑,**47 次模型调用
只降到 28 次**,而降到 0 的那三条是运气——截图恰好落在数据还没渲染出来的骨架期。

原因在键:

    scopedCacheId(cacheId, context, {
      url,
      dom: await page.content(),              // 整份 HTML——含价格、倒计时、盘口行
      scene: cacheDigest(截图 PNG 的 base64),  // 一个像素变了就变
    })

实时行情页上这两样每秒都在变,键几乎必然每次不同。**一个只在骨架期命中的缓存
等于没有缓存。**

但也不能干脆不看页面:缓存里存的是「点哪个元素」的计划,页面结构变了还照着点,
会点到不存在的东西——而那种失败看起来像产品坏了(memory:缓存 xpath 随 DOM 过时)。

改成看**结构**不看内容:可见控件的身份(标签 + 角色),标签里的数字串抹掉。
价格跳动、倒计时走字 → 键不变,缓存正确命中;控件增删、改名、换角色、被藏起来
→ 键变,缓存正确失效。`Positions (1)` 与 `Positions (7)` 因此是同一个控件——
这丢掉「持仓数变了」这个信号,但那个信号本来就该由用例的判据去抓,
不该由缓存键去抓;键的职责是「这一屏还是不是原来那一屏」。

判定逻辑抽成纯函数(`controlIdentity` / `structureOf`),探针只负责采集:
这两条性质是整个设计的意义所在,不该只能靠真跑一次浏览器来验。测试钉了六条,
包括「DOM 顺序抖动不算变化」与「标签截断后仍分得开」。
`CACHE_POLICY_VERSION` 跳到 `structure-v5`,旧缓存全部失效。

顺带把三遍的结果写进 docs/v3/26。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
同一批 10 条用例跑四次,把新旧缓存键的冷热四种情况都量了:

    ①  旧键(DOM + 截图)  冷   47 次调用   零调用 0 条   286s
    ②  旧键                热   28 次(−40%)      3 条   231s
    ③  新键(结构指纹)    冷   47 次              0 条   279s
    ④  新键                热   13 次(−72%)      7 条   191s

**③ 和 ① 完全相同是这里最要紧的一条**:缓存空的时候新键没有任何差别,
说明键没有被改松到误命中。省下来的全部来自「价格在动,但这一屏还是原来那一屏」
这个正确判断,不是靠放宽判据换来的。

剩下 3 条仍然要调模型,原因也清楚:它们的步骤会改变页面结构——点开弹窗、切换面板,
第二步之后那一屏的控件集合和缓存时不同,键正确地失效了。这不是缺陷,是设计要的行为。

另外更正上一条提交信息里的一个数:那里写「测试钉了六条」,实际是两个 it 块九条断言,
写的时候按 expect 数了一半。不影响任何结论,在这里改正。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
给缓存键换成结构指纹之后,本机 `apps/runner` 四条测试全绿,CI 上三条红:

  captures a screenshot and perf metrics   expected 'passed' to be 'failed'
  streams a debug session's frames         ['start','step','done'] ≠ ['start','navigated','done']
  cancels while page navigation is pending Test timed out in 45000ms

三条都是同一件事的下游:`contextualAgent` 现在要做一次 `page.evaluate` 采结构,
而这一步可能赶上**页面正在导航**——那时执行上下文已经销毁,evaluate 会抛。
CI 慢,正好撞进那个窗口;本机快,跑一百遍也碰不到。第三条测试的名字
(「导航还没完成就取消」)几乎是把原因写在脸上。

采不到就返回 `undefined`:这一次不读也不写缓存。**不退回一个凑合的键**——
那换来的是照着过时计划去点不存在的元素,而那种失败看起来像产品坏了。

本机复现不了,所以这条改动的判据是 CI。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
上一条(fix(exec): 结构指纹采不到时不要带垮执行)的说明把两件事连成了因果:
「CI 三条红 → 因为 evaluate 撞上导航窗口 → 加 catch 修好」。

**前半段是观察,后半段是猜测,而猜测是错的。** 推上去之后 CI 仍然红,
同样是 `captures a screenshot and perf metrics`(expected 'failed' to be 'passed')
与 `streams a debug session's frames`(拿到 'step' 而不是 'navigated')。
第二条和缓存键毫无关系——一个 debug 会话的帧类型变了,不是 cacheId 能影响的事。
真正原因我没有定位到。

那个改动本身仍然成立,只是理由要换成它自己的:结构采不到时返回 undefined,
这一次不读也不写缓存——不退回一个凑合的键,因为那换来的是照着过时计划去点
不存在的元素,而那种失败看起来像产品坏了。它跟 CI 那三条红没有已证实的关系。

留这条空提交而不是改写历史:那条说明已经推出去了,把它悄悄换掉,
比留着一个带更正的记录更糟。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
用户的决定。关的时候它是红的:`apps/runner` 三条测试在 CI 上失败而本机四条全绿,
**原因没有定位到**——其中一条(debug 会话的帧类型从 'navigated' 变成 'step')
和当时改的缓存键毫无关系,所以「是那次改动弄坏的」这个结论并不成立,只是时间上相邻。

`gh workflow disable` 是 GitHub 上的一个开关,仓库里看不出来。把状态、原因、
以及怎么开回去写进工作流文件头——否则半年后它会变成一个谜:
「这个 workflow 存在,为什么从来不跑?」

关掉不等于这些检查没用。本地这一串仍然是验收口径:
  pnpm typecheck && pnpm test && pnpm check:drift && pnpm check:host-parity

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zyonlab
zyonlab merged commit df4707d into main Sep 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants