Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions docs/v3/25-ClaudeCode宿主端到端实测.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# Claude Code 宿主 · 端到端实测

2026-09-14。这是 Claude Code 作为宿主运行时的**第一次端到端实测**。此前它只有六条接缝单测
(session_id 取值、hook 拦截识别、MCP 事件翻译、`.mcp.json` 写入、CLI 参数、运行时分发),
没有一条会真的 spawn 一个 `claude` 进程——「参数拼对了」「hook 真的被宿主加载了」
「skills 真的被识别了」这三件事此前只有文档没有证据。Codex 在 09-10 有过一次(见 19),
两个宿主的证据强度长期不对等。

**结论:链路通了,17 次 MCP 阶段调用,3 分 10 秒,$1.52。**

## 运行身份

| 项目 | 实测值 |
| --- | --- |
| 脚本 | `server/scripts/verify-host-stages.ts --real --claude --freeze-as-operator` |
| 宿主 | Claude Code 2.1.270 |
| 规划模型 | `claude-opus-5[1m]`(宿主原生,未替换成 Web planner) |
| 数据库 | 一次性临时库(`TP_DATA_DIR` 指向 mkdtemp) |
| 材料 | 脚本自带的 counter 规格,本地、有界、无登录无网络无交易 |
| run | `verify-claude-code-1789387536177` |
| session | `19bcbf7e…` |
| 墙钟 | 12:05:36 → 12:08:46(3 分 10 秒) |
| token | 输入 514 · 缓存读 1,006,004 · 输出 13,277(其中思考 5,491) |
| 花费 | $1.5188 |

## 实际调用路径

```
load_run_instructions → begin_stage(modules) → retrieve_spec → plan_modules
→ module_plan_state → [人工冻结] → begin_stage(stories) → write_stories
→ begin_stage(cases) → write_cases ✗ → write_cases ✓
→ begin_stage(gate) → gate_run → write_cases → gate_run
→ begin_stage(finalize) → finalize_run
```

17 次 MCP 工具调用,内部 `run_pipeline` **0 次**——走的是阶段工具,不是整份流水线。

产物 11 份修订:`counter.md`、`validated/instructions`、`validated/modules`、
`report/module-fanout`、`report/acceptance-index`、`validated/stories`、
`validated/cases`(rev1、rev2)、`validated/gate`(rev1、rev2)、`validated/finalize`。

## 第一次跑:agent 停住了,而不是适配器坏了

第一次跑(不带 `--freeze-as-operator`)脚本判 `passed: false`,报
「agent 中途停下了」。看 trace 才知道它停在哪:

```
begin_stage(modules) → load_run_instructions → retrieve_spec → plan_modules
→ module_plan_state ×3 → 停
```

session 本身是**正常结束**的(`is_error: false, subtype: success`)。它在等人冻结模块树——
而脚本里没有这个人。**不是适配器坏了,是这个脚本比流水线旧**:它写在模块节点与
那道人工闸引入之前。

照 `drive-modules-and-stories.ts` 的先例加了 `--freeze-as-operator`:默认不按,
带这个开关才按,并把「谁按的、什么时候按的」写进证据(`result.json` 的 `freezes`)。
这条闸的意义是「有人为这棵树负责」,不是「必须有个人坐在那儿」——脚本调用者显式声明
自己就是那个人,是成立的;悄悄替他按,不成立。

冻结记录:`2026-09-14T12:06:08.206Z · VERIFY_HOST_STAGES_OPERATOR · openFindings: 0`。

## 顺带验到的两件事

**一、schema 拒收之后 agent 会自己修。** 第一次 `write_cases` 被打回:

```
cases.json 不符合 CaseBundleSchema:cases.0.design.transitionIds: Array must contain
at least 1 element(s)。形状见 skill testpilot-design 的 REFERENCE.md;改完再写一次。
```

它改完重写,第二次 `validated`:2 条用例 / 1 条故事,出处 `trace`,2/2 有据可循。
门禁出结果后又写了一次 `write_cases` 再 `gate_run`——修复回路按设计走通了。

**二、这轮新加的门禁规则在英文材料上也生效。** `gate_run` 点到三条 `acceptance-uncovered`:

```
S-01/AC-1 要求用户动手(When the visitor clicks the visible Inc),却没有任何用例认领它
```

验收准则编号(`S-01/AC-1`)与「这条要不要用户动手」的判定是 2026-09-14 才加的
(见 24 §38),当时是对着中文语料调的。这次在纯英文规格上跑,`When` 子句解析与
英文动作动词(`clicks`)都命中了。最终两轮门禁都 `score: 1`。

## 第二轮:真实站点,走到执行

2026-09-14 同日。上面那轮用的是脚本自带的本地 counter 规格,这一轮换成真东西:
新建项目、目标 `https://app.hyperliquid-testnet.xyz/trade`,并且走完复核 → g2 → 执行。
驱动脚本 `server/scripts/drive-claude-host.ts`。

**分工是被迫的,也是对的。** MCP 里没有探索工具(`register_run` 收的是材料文本),
而且探索要浏览器、宿主 agent 没有。所以:

```
服务端探索(modules 前设断点停住)→ Claude Code 宿主规划 → 人复核 → g2 → execution
```

| 阶段 | 结果 |
| --- | --- |
| 项目 | `prj-mu18xegk-1002` |
| 探索 | 6 屏、32,656 字材料、产品模型 21 功能 / 10 模块,2 分钟 |
| 宿主规划 | 9 模块 / 4 故事 / 11 用例,门禁 `score 1`;冻结由 `CLAUDE_HOST_E2E_OPERATOR` 显式按下 |
| 人工复核 | 10 批准 / 1 驳回 |
| g2 | `ready_to_execute`,代码门禁 `score 1`(3 条 info:措辞漂移、重复步骤序列) |

用例质量与上一批(24 §38 之前那 81 条)完全不同:11 条全是 `tier1 + 机器判据 + ready`,
`acRefs` 全部按编号引用,步骤是真实动作,大多带回退。没有一条是「打开页面 + 看一眼」。

### 驳回的那条,缺陷不在用例

C-12 的判据是 `Account value must be <$2500万 to use portfolio margin in beta mode.`
——一个英文 dapp 不会渲染「万」。查探索材料,**原文就是这句**:用例逐字抄的,没编。
缺陷在上游:材料里这句的数字被本地化了(`$25M` → `$2500万`),而同一份材料里
`No open positions yet`、`Trailing Stop`、`The maximum leverage is 10x.` 都是逐字英文。
拿它当 `text` 判据必然永远不匹配。**`outputLanguage: 'zh'` 的运行都可能中招。**

### 执行:10 条真跑到测试网,9 过 1 败,零基础设施错误

```
✓ C-03 C-05 C-07 C-08 machine:pass(挣来的)
⚠ C-01 C-02 C-06 C-09 C-11 machine:pass(免费)
✗ C-10 failed
通过 9 条里:挣来的 4 条 · 判据在初始页面就成立的 5 条
```

**唯一一条失败是定位失败,不是产品缺陷。** C-10 第 2 步
「点击页面下半部分账户面板标签行里的「Positions」」:

```
AI model failed to locate: The 'Positions' tab in the account panel tab row
at the bottom of the page was not found in the provided element list or screenshot.
```

页面就绪读数是 `30 controls, 1849 text characters`——页面是画完的。未连接钱包时
账户面板那一行标签本来就可能不在,或者不叫这个名字。归 `locate` 而不是 `assert`,
分档是对的:这条要改用例措辞,不该走「产品是坏的」那个出口。

### 两条批了但要标出来的弱判据

C-01「输入数量后可用余额仍为 0.00 USDC」、C-11「切到 Sell / Short 后仓位仍是 0.00 HYPE」
——判据在**动作之前就已经成立**。作为不变性检查勉强成立,但它分不清「没变」和「本来就是」。
执行结果证实了:C-01、C-11 都是 `machine:pass(免费)`,另外 C-02、C-06、C-09 也是——
**9 条通过里只有 4 条是挣来的**。判据是真的机器判据,通过也是真的通过,
但其中 5 条的判据在动作之前就已经成立,这次通过没有证明那几步做成了什么。

## 还没验到的

- **hook 有没有真的被宿主执行**。判据只到「MCP 阶段工具被调用、产物落盘」,
`PreToolUse` / `Stop` 走没走 `hooks/adapters/claude-code.mjs`,trace 里没有直接证据。
- **宿主自己发起探索**。MCP 没有这个工具,目前也不打算有——探索要浏览器。

## 前提

跑这个脚本之前必须先让宿主 plugin 与真源一致(`node scripts/build-claude-plugin.mjs`)。
2026-09-14 调研时发现两个宿主副本落后了四个文件,其中就有当天改过的
`testpilot-stories/SKILL.md`——**拿过期副本跑出来的实测,测的是旧 skill,结论是假的**。
这条现在由 `check-drift` 兜住(见 PR #1)。
54 changes: 54 additions & 0 deletions server/scripts/drive-claude-host.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* 把一次服务端探索出来的材料交给 **Claude Code 宿主**,由它驱动 modules → finalize。
*
* 2026-09-14:这是 PR #3 的实测没覆盖到的那一半——那次用的是脚本自带的本地 counter 规格,
* 这次是真实的重前端页面(hyperliquid 测试网交易页,6 屏、32,656 字材料)。
*
* 探索留在服务端:MCP 里没有探索工具(`register_run` 收的是材料文本),
* 而且探索要浏览器,宿主 agent 没有。所以分工是:服务端探索 → 宿主规划 → 人审核 → 执行。
*
* 冻结模块树那一下仍然必须有人明说:默认不按,`--freeze-as-operator` 才按,并记进日志。
*/
import { config } from "dotenv";
import { mkdirSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
const root = resolve(import.meta.dirname, "../..");
config({ path: join(root, "server/.env"), quiet: true } as never);
const arg = (k: string) => { const i = process.argv.indexOf(`--${k}`); return i < 0 ? undefined : process.argv[i + 1]; };
const projectId = arg("project")!, fromRun = arg("from")!;
const limit = Number(arg("limit") ?? 4);
const freezeAsOperator = process.argv.includes("--freeze-as-operator");

const { runLedger } = await import("../src/runService.js");
const { dataPath } = await import("../src/datadir.js");
const moduleStage = await import("../src/moduleStage.js");
const claude = await import("../src/claudecode.js");
const l = runLedger();

const material = l.readRevision(l.listRevisions(projectId, fromRun).filter((r: any) => r.name === "exploration.md").at(-1)!.id, projectId).content as string;
const runId = `cc-host-${Date.now()}`;
const workspace = dataPath(`host-workspaces/${runId}`), materialsDir = join(workspace, "materials");
mkdirSync(materialsDir, { recursive: true });
writeFileSync(join(materialsDir, "exploration.md"), material);
console.log(`材料 ${material.length} 字符 → ${materialsDir}`);

const freezes: unknown[] = [];
const watcher = setInterval(() => {
if (!freezeAsOperator) return;
try {
const m = moduleStage.modulePlanState(runId, projectId);
if (!m.exists || m.frozen) return;
const r = moduleStage.freezeModulePlan(runId, projectId, { kind: "human", id: "CLAUDE_HOST_E2E_OPERATOR" });
freezes.push({ at: new Date().toISOString(), ...r });
console.log("freeze(operator):", JSON.stringify(r).slice(0, 200));
} catch { /* 还没到能冻结的状态 */ }
}, 2000);

const started = await claude.startRun({ runId, workspace, materialsDir, scopeProjectId: projectId, limit });
console.log("claude session:", started.sessionId);
const result = await new Promise<{ status: string; error?: string }>((done) =>
claude.watchRun({ ...started, scopeProjectId: projectId, timeoutMs: 45 * 60_000,
onEvent: (e: any) => console.log(` ${e.node}:${e.phase}`), onDone: done } as never));
clearInterval(watcher);
claude.cancelRun(runId);
console.log(JSON.stringify({ runId, projectId, status: result.status, error: result.error, freezes }, null, 1));
33 changes: 32 additions & 1 deletion server/scripts/verify-host-stages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ process.env.TP_DATA_DIR = join(dir, "data");
const evidenceDir = resolve(process.env.TP_EVIDENCE_DIR ?? join(root, `docs/v3/evidence/n-04/real-${runtime}`));
mkdirSync(evidenceDir, { recursive: true });
const database = await import("../src/db.js"), service = await import("../src/runService.js"), stages = await import("../src/runStages.js");
const moduleStage = await import("../src/moduleStage.js");
const project = database.createProject(`Host stage verification (${runtime})`, "http://127.0.0.1");
const app = express(); app.use(express.json({ limit: "16mb" }));
app.use("/api/projects/:projectId/workflow-runs", (await import("../src/runRoutes.js")).runRouter());
Expand All @@ -38,6 +39,34 @@ There is no login, network operation, payment or trade. Assertions must use only
`);
const calls: Array<Record<string, unknown>> = [];
const startedAt = new Date().toISOString();

/**
* **模块树的冻结那一下必须是人——脚本里没有这个人,宿主就停在那儿。**
*
* 2026-09-14 第一次真跑 `--real --claude`:agent 走完 begin_stage → load_run_instructions
* → retrieve_spec → plan_modules,然后连查三次 `module_plan_state` 就停了。session 本身
* 是正常结束的(`is_error: false`),它只是在等人按那一下。脚本判成
* 「agent 中途停下了」——**不是适配器坏了,是这个脚本比流水线旧**:
* 它写在模块节点与冻结这道人工闸引入之前。
*
* 照 `drive-modules-and-stories.ts` 的先例:默认不按,带 `--freeze-as-operator`
* 才按,并且把「谁按的、什么时候」写进证据。这条闸的意义是「有人为这棵树负责」,
* 而不是「必须有个人坐在那儿」——脚本调用者显式声明自己就是那个人,是成立的;
* 悄悄替他按,不成立。
*/
const freezeAsOperator = process.argv.includes("--freeze-as-operator");
const freezes: Array<Record<string, unknown>> = [];
const freezeWatcher = setInterval(() => {
if (!freezeAsOperator) return;
try {
const mod = moduleStage.modulePlanState(runId, project.id);
if (!mod.exists || mod.frozen) return;
const r = moduleStage.freezeModulePlan(runId, project.id, { kind: "human", id: "VERIFY_HOST_STAGES_OPERATOR" });
freezes.push({ at: new Date().toISOString(), by: "VERIFY_HOST_STAGES_OPERATOR", ...r });
console.log("freeze(operator):", JSON.stringify(r).slice(0, 160));
} catch { /* 还没到能冻结的状态 */ }
}, 2000);
freezeWatcher.unref?.();
let session: any, failure: string | undefined;
try {
if (web) {
Expand Down Expand Up @@ -87,8 +116,10 @@ finally {
await session?.dispose();
const run = service.runLedger().getRun(runId, project.id);
const artifacts = run.revisions.map(r => service.runLedger().readRevision(r.id, project.id));
clearInterval(freezeWatcher);
// 谁按的冻结、什么时候按的,进证据。宿主自己批准自己的产物这件事,必须一眼看得出来没有发生。
writeFileSync(join(evidenceDir, "result.json"), JSON.stringify({ runtime, runId, startedAt, finishedAt: new Date().toISOString(), passed: !failure, failure,
run, artifacts, privateWorkspace: dir }, null, 2));
freezes, freezeAsOperator, run, artifacts, privateWorkspace: dir }, null, 2));
await new Promise<void>(r => http.close(() => r())); service.runLedger().close(); database.db.close();
}
console.log(JSON.stringify({ runtime, runId, passed: !failure, failure, evidenceDir }));
Expand Down
16 changes: 15 additions & 1 deletion server/test/managed-penguin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,21 @@ afterAll(async () => {
dirs.forEach(d => rmSync(d, { recursive: true, force: true }));
});

const installed = existsSync(node24Path()) && existsSync(penguinBin());
/**
* **「装没装」这个探测自己不能炸。**
*
* 下面四条都是 `it.runIf(installed)`——没装 Penguin SDK 就跳过,机制是齐的。
* 但 `node24Path()` 在找不到 Node 24 时**抛异常**(`penguin_node_24_required`),
* 而它在模块顶层被调用:于是在一台没有 Node 24 的机器上,整个文件加载失败,
* 四条测试连「跳过」都到不了,整个 server 包的测试红。
*
* 2026-09-14:CI 用的是 Node 22,从来没绿过——三条 PR 分支各红了一次,
* 红的都是这一条,和各自改的东西毫无关系。探测的答案本来就该是「没有」,
* 不是「出错」。
*/
const installed = (() => {
try { return existsSync(node24Path()) && existsSync(penguinBin()); } catch { return false; }
})();
function allText(dir: string): string {
return readdirSync(dir, { withFileTypes: true }).map(e => e.isDirectory() ? allText(join(dir, e.name)) : readFileSync(join(dir, e.name)).toString()).join("\n");
}
Expand Down
Loading