pi-legion 3.0.0 → 3.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,166 @@ summary so you can see **2.x vs 0.16** without leaving the tree.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [3.0.3] — 2026-10-19
15
+
16
+ ### Changed
17
+
18
+ - **`spawnChild` returns as soon as the child accepts the prompt, not when its
19
+ first turn finishes (#227/#228).** The in-process backend used to await the
20
+ whole first agent run, so for 8–18 minutes the launch produced no job: the
21
+ status board showed a single ledger row with no token rate, and the parent
22
+ turn was blocked for the duration. Acceptance is defined in the child
23
+ transcript (a user line past the spawn-time size, `turns.length > 0`);
24
+ `isLastTurnComplete` is deliberately not required, because an accepted prompt
25
+ legitimately has no assistant line yet. The prompt promise is drained in the
26
+ background inside the same ALS child scope, so depth/identity semantics are
27
+ unchanged. A 15 s start bound is not an error — the handle is returned anyway
28
+ and `onSpawnDelayed` fires once. See `docs/inprocess-supervisor.md`.
29
+ - **`worktree: true` is centurion-only (#214).** Members stay in-process; only a
30
+ centurion gets an OS worktree for the squad it leads. Roles that declared
31
+ `worktree: true` on a leaf no longer pay the clone/bootstrap cost, and the
32
+ isolation they still need is the centurion's. See REV-17.
33
+
34
+ ### Fixed
35
+
36
+ - **A first prompt rejected before acceptance no longer produces a ghost
37
+ child (#227 D1a / #230).** The non-blocking spawn above had a regression: when
38
+ the SDK threw before `appendMessage` (no model, no API key, mid-compaction)
39
+ the spawn still returned a handle, which disabled the `fallbackModels` chain —
40
+ `Orchestrator.launch` only retries the next model when `spawnChild` throws. A
41
+ reject **before** acceptance now throws `SubagentError(START_FAILED)` and takes
42
+ the existing rollback path; a reject **after** acceptance keeps the handle and
43
+ reports `SettleResult.reason = "child turn failed: …"`.
44
+ - **An interrupted `vexillatio` keeps the work it already collected (#227/#229).**
45
+ The frozen head line stays first and verbatim, then the collected segments (in
46
+ handle order) and any `✗ <agent>: <error>` launch failures follow, with
47
+ `details.stillRunning` telling you what is still live. A reason-less abort is
48
+ now named (`ABORT_WITHOUT_REASON`) instead of surfacing as bare engine text,
49
+ and `Orchestrator.collect` no longer stamps a deadline on a child whose last
50
+ turn already finished.
51
+ - **Foreign children are judged by their run-directory owner (#209-3 / #215).**
52
+ `status` reported a long-dead child as `working` and `stop` could not reach it,
53
+ because both consulted only the in-memory liveness registry, which is empty by
54
+ construction for a run this process never spawned. Both now resolve the owner
55
+ from the run directory.
56
+ - **The rendered `supervisor=` is the arbitrated backend (#209-2 / #216).**
57
+ `supervisor=rpc` was a literal in three render paths, so an in-process child
58
+ was labeled `rpc`. `RunRecord.herdr.supervisor` widens to `SpawnMode`, handles
59
+ carry `spawnMode`, and legacy runs with no such field render `unknown`.
60
+ - **Extensions that register providers work in the default backend
61
+ (#209-1 / #213).** The in-process session factory loaded additional extension
62
+ paths into `DefaultResourceLoader` but resolved models through a bare
63
+ `ModelRuntime.create()`, so non-builtin providers were unreachable. Registered
64
+ providers are now drained into the model runtime and their catalog refresh is
65
+ awaited before `resolveCliModel()` reads the synchronous snapshot.
66
+ - **`legion-doctor` no longer implies RPC-only remedies for in-process
67
+ children (#212 / #222).** In-process spawns got a false-lead
68
+ `PI_LEGION_CHILD_EXTENSIONS` recommendation and RPC CLI-probe issues
69
+ (`CHILD_NO_MODELS` / `CHILD_FEWER_MODELS`); model gaps now point at
70
+ `models.json`, role extensions, or `inheritExtensions`.
71
+ - **An `orphan detected` notice now carries its detection time.** The notice is
72
+ queued through the outbox and can be delivered tens of minutes later; it used
73
+ to look identical to a fresh scan, so the same dead node was triaged repeatedly.
74
+ It now leads with `detected at <ISO>`. `staleMs` is intentionally unchanged —
75
+ it is the value at scan time, matching the persisted `orphan_detected` event.
76
+ - **`tok/s` is a decode rate again, and in-process children show it while they
77
+ run.** Two independent causes, both fixed:
78
+ - `generationMs` (the denominator) was derived from the nested
79
+ `message.timestamp`, which marks when generation *started*. It captured only
80
+ prompt→first-token latency — a real 1506s session accumulated 616ms — so the
81
+ `MIN_RATE_GENERATION_MS` floor always tripped and `tok/s` silently fell back
82
+ to a 60s wall-clock window that counted tool wait as generation, and the tree
83
+ row's `avg t/s` (gated on `generationMs >= 1000`) never rendered at all. It
84
+ now sums the measured call length (`message.durationMs`) or, when absent,
85
+ entry-to-entry write times. Nested timestamps stay a last resort for
86
+ synthetic sessions, paired so the two clock axes are never mixed.
87
+ - The railway header showed `0 tok/s` for an in-process child's entire
88
+ lifetime while `rpc` looked correct: `spawnChild` awaits the whole first run,
89
+ so no tracked job exists and the status board only had the ledger node row —
90
+ which has no `generationMs` and, because `pollUsage` skips non-`running`
91
+ nodes, no `tokens_out` until settle either. `statusEntryFromNode` now parses
92
+ `node.sessionFile` directly and falls back to the ledger.
93
+ - A live agent stopped by a tool call longer than the 60s window still decays to
94
+ `0 tok/s`. That is the honest answer for the trailing window and is now pinned
95
+ by tests instead of being "fixed" into a stale number later.
96
+ - **Completion notices no longer wait for the user's next message.** A child
97
+ finishing while the parent agent run was still active was sent as a
98
+ `followUp`, and the host only drains that queue inside a live run: the notice
99
+ sat invisible until the next prompt. Notices are now held by the extension and
100
+ flushed as one burst on `agent_settled` (hard cap `MAX_PENDING_AGE_MS`, 80 s,
101
+ after which they are force-sent). The join window no longer extends itself on a
102
+ busy parent — that turn-level signal was the wrong predictor and doubled the
103
+ delay.
104
+ - **No more phantom `working` rows.** `awaiting`/re-armed states repaint
105
+ immediately and `agent_settled` repaints once, so the widget board never holds
106
+ a stale row past a state change.
107
+
108
+ ### Known limits (deliberately out of scope)
109
+
110
+ - **Blocked prompts and root notices are still sent as bare `followUp`.**
111
+ `notifyBlocked` (`index.ts`, the blocked-approval prompt) and `notice()`
112
+ (`index.ts`, the root-level advisory) bypass the run-scoped outbox, so while
113
+ the parent run is active they sit in the host queue exactly like the
114
+ completion notices used to. Only completions are covered by this fix; the
115
+ same treatment for those two paths is a follow-up, not part of this change.
116
+
117
+ ## [3.0.2] — 2026-10-18
118
+
119
+ ### Changed
120
+
121
+ - **Reverted the pnpm migration (#162): npm is the reference path again, and
122
+ the repository no longer enforces any package manager (#210).** Both previous
123
+ attempts to force contributors onto pnpm broke npm consumers: 3.0.0 shipped a
124
+ `preinstall` guard that failed every consumer install, and 3.0.1 replaced it
125
+ with `devEngines.packageManager` (`onFail: "error"`), which npm enforces for
126
+ *every* non-global command in the checkout. The practical effect was that
127
+ `npm run`, `npm pack`, `npm exec`, `npx` (and therefore MCP servers launched
128
+ with `npx`) all failed with `EBADDEVENGINES` inside the repo, and
129
+ `pi install git:<repo>` failed too, because that path is `git clone` +
130
+ `npm install --omit=dev --legacy-peer-deps`.
131
+ - `package.json`: dropped `devEngines` (it makes npm reject the whole checkout),
132
+ `packageManager` (corepack then refuses to run npm at all) and `engines.pnpm`.
133
+ `engines.node` stays `>=22`.
134
+ - `scripts.test:all` and `scripts.prepublishOnly` compose via `node --run`
135
+ instead of `pnpm run`, so `npm run` and `pnpm run` both work and no script
136
+ names a package manager binary. (`node --run` does not execute pre/post hooks;
137
+ there are none in this manifest.)
138
+ - Lockfile: `pnpm-lock.yaml` is replaced by `package-lock.json`, and
139
+ `pnpm-lock.yaml` / `yarn.lock` are gitignored so two lockfiles cannot drift
140
+ apart again. `src/runs/provision.ts` still detects all four lockfiles, so
141
+ contributors who prefer pnpm can keep using it locally.
142
+ - CI and release install with `npm ci` (no `pnpm/action-setup`, no `cache:
143
+ pnpm`), and the release workflow packs with `npm pack` again — the
144
+ `pnpm pack` → scratch-directory workaround for `devEngines` is gone.
145
+ - `test/unit/publish-manifest.test.ts` now forbids `devEngines` and
146
+ `packageManager` in the manifest and asserts the scripts stay package-manager
147
+ neutral. `release.yml` fails the tag build if either field comes back.
148
+
149
+ ## [3.0.1] — 2026-10-18
150
+
151
+ ### Fixed
152
+
153
+ - **Published manifest no longer ships a `preinstall` hook.** 3.0.0 added a
154
+ pnpm-only `preinstall` guard meant for repository contributors, and npm always
155
+ packs `package.json`, so the hook shipped to every consumer. Pi installs
156
+ extensions with `npm install <spec> --prefix <agentDir>/npm --legacy-peer-deps`,
157
+ so `pi update --extensions` failed with
158
+ `Use pnpm to install dependencies (see CONTRIBUTING.md).` — npm exited 1 and
159
+ rolled the previously installed version back, which left the
160
+ "Package Updates Available" notice pointing at the same outdated version on
161
+ every start. The pnpm-only intent now lives in `devEngines.packageManager`,
162
+ which npm enforces for the repository root (`EBADDEVENGINES`) and ignores when
163
+ the package is installed as a dependency. The repository keeps all of its
164
+ contributor tooling declarations: `packageManager`, `engines.pnpm` and
165
+ CONTRIBUTING.md are unchanged. (Note: `pnpm pack` omits `packageManager` from
166
+ the tarball, which only affects that dev-only field in the published manifest —
167
+ npm consumers install and load the extension normally.)
168
+ - `test/unit/publish-manifest.test.ts` guards the published manifest against
169
+ install-phase scripts (`preinstall` / `install` / `postinstall`) and asserts the
170
+ pnpm requirement stays declared, so this cannot ship again silently.
171
+ - `release.yml` now fails the tag build when a published install-phase script was
172
+ reintroduced, before anything is staged.
173
+
14
174
  ## [3.0.0] — 2026-10-18
15
175
 
16
176
  ### Added
@@ -233,11 +393,12 @@ branch — pin `pi-legion@0.16.x` if you still need that model. See
233
393
  Earlier 0.11–0.15 history (including the `pi-herdr-swarm` /
234
394
  `@zzjcool/pi-herdr-subagents` renames) lives in git tags and older Releases.
235
395
 
236
- [Unreleased]: https://github.com/zzjcool/pi-legion/compare/v3.0.0...HEAD
396
+ [Unreleased]: https://github.com/zzjcool/pi-legion/compare/v3.0.2...HEAD
397
+ [3.0.2]: https://github.com/zzjcool/pi-legion/compare/v3.0.1...v3.0.2
398
+ [3.0.1]: https://github.com/zzjcool/pi-legion/compare/v3.0.0...v3.0.1
237
399
  [3.0.0]: https://github.com/zzjcool/pi-legion/compare/v2.0.4...v3.0.0
238
400
  [2.0.4]: https://github.com/zzjcool/pi-legion/compare/v2.0.3...v2.0.4
239
401
  [2.0.3]: https://github.com/zzjcool/pi-legion/compare/v2.0.2...v2.0.3
240
402
  [2.0.2]: https://github.com/zzjcool/pi-legion/compare/v2.0.1...v2.0.2
241
- [2.0.1]: https://github.com/zzjcool/pi-legion/compare/v2.0.0...v2.0.1
242
403
  [2.0.0]: https://github.com/zzjcool/pi-legion/releases/tag/v2.0.0
243
404
  [0.16.x]: https://github.com/zzjcool/pi-legion/releases
package/CONTRIBUTING.md CHANGED
@@ -21,13 +21,13 @@ directly to `master`. Implementers do not merge their own pull requests.
21
21
  **Before you push.** Run:
22
22
 
23
23
  ```bash
24
- pnpm run typecheck
25
- pnpm test
26
- pnpm run test:integration # or pnpm run test:all
24
+ npm run typecheck
25
+ npm test
26
+ npm run test:integration # or npm run test:all
27
27
  ```
28
28
 
29
29
  The required GitHub Actions check `ci` runs typecheck, unit, integration, and
30
- mock-llm. Run `pnpm run test:integration` (or `pnpm run test:all`) locally
30
+ mock-llm. Run `npm run test:integration` (or `npm run test:all`) locally
31
31
  before you push, especially when the change touches `src/runs/`, `src/legion/`,
32
32
  or `src/extension/runtime.ts`.
33
33
 
@@ -45,8 +45,8 @@ timeout the caller is awaiting stays referenced, as with the wait timer in
45
45
  `src/extension/runtime.ts`.
46
46
 
47
47
  **npm release.** A `v*.*.*` tag runs the release workflow
48
- (`.github/workflows/release.yml`). Dependencies are installed with **pnpm**;
49
- staging still uses the npm CLI (`npm stage publish`) and a stage-only token.
48
+ (`.github/workflows/release.yml`). Dependencies are installed with **npm**;
49
+ staging uses the npm CLI (`npm stage publish`) and a stage-only token.
50
50
  A maintainer promotes the staged version on npm with 2FA. Do not use plain
51
51
  `npm publish` in GitHub Actions.
52
52
 
@@ -58,36 +58,49 @@ the maintainer user for a routine review or merge ask.
58
58
  ```bash
59
59
  git clone https://github.com/zzjcool/pi-legion.git
60
60
  cd pi-legion
61
- pnpm install
61
+ npm install
62
62
  ```
63
63
 
64
- Requires Node.js 22+ and **pnpm** 9+ (`packageManager` in `package.json`;
65
- `node --experimental-strip-types` is how tests run; there is no compile step
66
- to ship). Prefer Corepack (`corepack enable`) or install pnpm 9.x. Do not use
67
- `npm install` in this repo.
64
+ Requires Node.js 22+ (`node --experimental-strip-types` is how tests run; there
65
+ is no compile step to ship). **npm is the reference path** — it is what Pi uses
66
+ for `pi install` / `pi update --extensions` and what the git-source install
67
+ path runs, so the repository must stay installable and usable with npm.
68
+
69
+ **Do not add package-manager enforcement.** A `preinstall` guard (3.0.0)
70
+ failed every npm consumer; `devEngines.packageManager` / `packageManager`
71
+ (3.0.1) made every npm command in the checkout fail with `EBADDEVENGINES` and
72
+ broke `pi install git:<repo>`. See issue #210. pnpm may be used locally by
73
+ anyone who prefers it — `node --run` keeps the scripts neutral — but the
74
+ repository does not demand it, and CI uses `npm ci`.
75
+
76
+ > pnpm 12+ users: the manifest no longer pins a pnpm version, so a modern pnpm
77
+ > refuses the install with `ERR_PNPM_IGNORED_BUILDS` (`@google/genai` and
78
+ > `protobufjs` have build scripts). Either run `pnpm approve-builds`, or install
79
+ > with `pnpm install --config.strict-dep-builds=false`. Do not add an `.npmrc`
80
+ > for this — npm warns about the unknown key on every command.
68
81
 
69
82
  ## Checks
70
83
 
71
84
  The required GitHub Actions check `ci` (`.github/workflows/ci.yml`, on pull
72
85
  requests and pushes to `master`) runs, in order:
73
86
 
74
- 1. `pnpm run typecheck`
75
- 2. `pnpm test` (unit)
76
- 3. `pnpm run test:integration`
77
- 4. `pnpm run test:mock-llm`
87
+ 1. `npm run typecheck`
88
+ 2. `npm test` (unit)
89
+ 3. `npm run test:integration`
90
+ 4. `npm run test:mock-llm`
78
91
 
79
92
  A failing integration step fails `ci`, so nesting and orchestrator regressions
80
93
  are blocked before merge. `test:live` and `test:docker` stay out of that job.
81
94
 
82
95
  ```bash
83
- pnpm run typecheck
84
- pnpm test # test/unit/*.test.ts
85
- pnpm run test:integration # FakeSupervisor / nested fakes; no live `pi` binary
86
- pnpm run test:all # typecheck + unit + integration
87
- pnpm run test:mock-llm # also part of the required ci check
96
+ npm run typecheck
97
+ npm test # test/unit/*.test.ts
98
+ npm run test:integration # FakeSupervisor / nested fakes; no live `pi` binary
99
+ npm run test:all # typecheck + unit + integration
100
+ npm run test:mock-llm # also part of the required ci check
88
101
  ```
89
102
 
90
- `pnpm run test:live` is a **pi RPC live** smoke (`test/live/rpc-live.test.ts`).
103
+ `npm run test:live` is a **pi RPC live** smoke (`test/live/rpc-live.test.ts`).
91
104
  It skips itself when the `pi` binary is missing from `PATH` (see the probe at
92
105
  the top of that file). Do not fail CI on it.
93
106
 
@@ -105,8 +118,8 @@ For offline Pi / legion smoke without provider quota, start the scriptable
105
118
  OpenAI-compatible mock (see [`test/mock-llm/README.md`](test/mock-llm/README.md)):
106
119
 
107
120
  ```bash
108
- pnpm run mock-llm # default scenario=echo on :8765
109
- pnpm run test:mock-llm # engine + HTTP + optional pi --print smoke
121
+ npm run mock-llm # default scenario=echo on :8765
122
+ npm run test:mock-llm # engine + HTTP + optional pi --print smoke
110
123
  ```
111
124
 
112
125
  `test/docker/mock-llm.mjs` launches the same CLI (default scenario `tool-once`
@@ -145,12 +158,12 @@ bash test/docker/verify-11-nested-alias.sh # nested + alias + completionGuard
145
158
  Or all at once (RPC mainline **1–11** only):
146
159
 
147
160
  ```bash
148
- pnpm run test:docker
161
+ npm run test:docker
149
162
  ```
150
163
 
151
164
  **Legacy Herdr / Space / kind checks** live under `test/legacy/docker/` (need
152
165
  pi-legion **0.16.x** Herdr-era setup). They are **not** part of
153
- `pnpm run test:docker`:
166
+ `npm run test:docker`:
154
167
 
155
168
  ```bash
156
169
  bash test/legacy/docker/verify-12-workspace.sh # Herdr Space pin (legacy)
package/README.md CHANGED
@@ -83,14 +83,16 @@ trades process isolation for spawn latency.
83
83
 
84
84
  **Automatic fallback to `rpc`:** when `spawnMode` is omitted *and* the resolved
85
85
  spawn cannot be in-process, Legion launches it over `pi --mode rpc` instead.
86
- That happens when the role needs an OS worktree (`worktree: true` — the writer
87
- and centurion default). A `kind !== "pi"` role is rejected earlier with
88
- `INVALID_PARAMS` (only `kind: pi` children are supported at all), so it never
89
- reaches this fallback.
86
+ That happens when the role needs an OS worktree (`worktree: true` — only the
87
+ `centurion` role may hold this; see Isolation below). A `kind !== "pi"` role is
88
+ rejected earlier with `INVALID_PARAMS` (only `kind: pi` children are supported
89
+ at all), so it never reaches this fallback.
90
90
 
91
91
  **Explicit is strict:** `spawnMode: "inprocess"` with `worktree: true` is
92
- refused with `INVALID_PARAMS` rather than silently downgraded, so a role that
93
- asks for in-process never gets a different transport behind its back.
92
+ refused with `INVALID_PARAMS` (message prefers `set worktree: false`) rather
93
+ than silently downgraded, so a role that asks for in-process never gets a
94
+ different transport behind its back. Non-centurion requests for `worktree: true`
95
+ are demoted to `false` with a once-per-run warning instead of throwing.
94
96
 
95
97
  Limits for the in-process backend are separate (`subagents.inprocess.*`) and are
96
98
  not shared with the `legion.*` tree budgets — see
@@ -488,9 +490,12 @@ another connection holds the db.
488
490
  ## Delivery and resume
489
491
 
490
492
  A centurion owns a Git worktree for its subtree; its worker/scout/reviewer
491
- children work in that subtree, and a nested centurion gets a nested worktree.
492
- Each subtree is delivered as a squash MR to its parent level. MRs move upward
493
- through the tree until the root reviews and merges into the default branch.
493
+ children work in that subtree (`worktree: false`, typically in-process), and a
494
+ nested centurion gets a nested worktree. **Parallel isolated writers:** dispatch
495
+ N centurions from the root (each owns one worktree); do not give every worker
496
+ its own worktree. Each subtree is delivered as a squash MR to its parent level.
497
+ MRs move upward through the tree until the root reviews and merges into the
498
+ default branch.
494
499
 
495
500
  Resuming follows the tree's saved sessions and node records. If a supervisor
496
501
  process ends, its child processes do not become detached workers; resuming the
@@ -522,11 +527,11 @@ another CLI, keep it on the 0.16.x line or replace it with a Pi role.
522
527
  ## Development
523
528
 
524
529
  ```bash
525
- pnpm install
526
- pnpm run typecheck
527
- pnpm test # unit tests
528
- pnpm run test:integration # integration tests
529
- pnpm run test:all # typecheck + unit + integration tests
530
+ npm install
531
+ npm run typecheck
532
+ npm test # unit tests
533
+ npm run test:integration # integration tests
534
+ npm run test:all # typecheck + unit + integration tests
530
535
  ```
531
536
 
532
537
  ### Mock LLM (offline pi / legion smoke)
@@ -536,9 +541,9 @@ It registers with Pi like any local gateway (`models.json` → `api: "openai-com
536
541
  so spawn / stop / doctor / verify paths can run without real API keys:
537
542
 
538
543
  ```bash
539
- pnpm run mock-llm # http://127.0.0.1:8765/v1 scenario=echo
540
- pnpm run test:mock-llm # unit + integration smoke for the mock
541
- MOCK_LLM_SCENARIO=spawn-scout pnpm run mock-llm
544
+ npm run mock-llm # http://127.0.0.1:8765/v1 scenario=echo
545
+ npm run test:mock-llm # unit + integration smoke for the mock
546
+ MOCK_LLM_SCENARIO=spawn-scout npm run mock-llm
542
547
  ```
543
548
 
544
549
  Use `writeMockPiHome()` from `test/mock-llm` to generate an isolated
@@ -547,7 +552,7 @@ Use `writeMockPiHome()` from `test/mock-llm` to generate an isolated
547
552
 
548
553
  See [CONTRIBUTING.md](CONTRIBUTING.md#project-workflow) for the project
549
554
  workflow: issues, pull requests, the merge gate, and releases
550
- (deps via pnpm; npm stage publish for staging).
555
+ (deps via npm; npm stage publish for staging).
551
556
 
552
557
  Version history for consumers: [CHANGELOG.md](CHANGELOG.md). Tag-by-tag
553
558
  release bodies: [GitHub Releases](https://github.com/zzjcool/pi-legion/releases).
package/agents/worker.md CHANGED
@@ -19,7 +19,7 @@ acceptance:
19
19
  kind: pi
20
20
  placement: split-down
21
21
  steer: true
22
- worktree: true
22
+ worktree: false
23
23
  maxSubagentDepth: 3
24
24
  ---
25
25
 
@@ -38,7 +38,7 @@ maxSubagentDepth: 3
38
38
  - **跑不动的命令**(交互式、需要确认)走默认放行,不要挂起等待。
39
39
  - **禁止开新的 worker/写操作子 agent**。你唯一允许的嵌套派生是只读的 advisor,
40
40
  且仅限失败升级场景(见 AGENTS.md「失败止损」:重试 2 次仍失败时咨询,按裁决执行)。
41
- - **隔离工作区**:你在独立 git worktree / 分支上干活,不要写父会话的 checkout。改动通过 merge request / pull request 合回默认分支,不要本地 merge,不要 push 到 main/master。
41
+ - **工作区**:在 centurion 子树 worktree(或父会话 cwd)**原地**改代码;隔离与 MR 由 centurion(或根会话)收队。不要本地 merge,不要 push 到 main/master。
42
42
  - **bugfix 先红后绿**:修 bug 类任务,第一步先写一个能复现该 bug 的失败测试并跑出失败,修复后同一测试必须转绿;两次输出都原样贴进报告。不复现就修复的,视为未验收。
43
43
 
44
44
  ## 工作流程
@@ -50,14 +50,14 @@ maxSubagentDepth: 3
50
50
  - 全部步骤完成、交活前才跑一次项目的**全量档**(完整测试套件/CI 等效命令);交活后插件会自动再验证一遍判定验收,不需要中途反复跑全量。全量失败按「失败止损」处理:重试不超过 2 次,仍失败升级咨询 advisor。
51
51
  - 项目找不到任何验证入口时,在报告里写明依据(查过哪些地方),不要自己编命令冒充。
52
52
  3. 把最终那次 full 验证输出**原样**(含命令与结果)放进最终报告。
53
- 4. push 前逐文件重读完整 diff(`git diff main...HEAD`):检查遗留的调试代码、console.log/print、注释掉的老逻辑、无关改动混入;发现问题先修再 push。
54
- 5. push 当前分支并打开 MR/PR;把 URL 写进报告和 verdict reason。MR 由主 agent 审查后统一合回,你不要自己 merge。
55
- 6. 写报告到指定路径(任务里会给),格式:做了什么 / 测试覆盖 / 验证输出 / MR 链接 / 未决问题。
53
+ 4. 交活前逐文件重读完整 diff:检查遗留的调试代码、console.log/print、注释掉的老逻辑、无关改动混入;发现问题先修。
54
+ 5. 不要自己开独立 worktree / 不要自己开 MR;隔离与合回由 centurion(或根会话)收队。把改动说明写进报告。
55
+ 6. 写报告到指定路径(任务里会给),格式:做了什么 / 测试覆盖 / 验证输出 / 未决问题。
56
56
 
57
57
  最后输出 verdict:
58
58
 
59
59
  ```json
60
- {"ok": true, "reason": "N files changed, typecheck+test green, mr: <url>"}
60
+ {"ok": true, "reason": "N files changed, typecheck+test green"}
61
61
  ```
62
62
 
63
63
  无法完成时(如依赖缺失、计划矛盾、无法开 MR):
@@ -0,0 +1,161 @@
1
+ # 冻结契约:非阻塞 spawn / 中断不丢结果 / 死子不空等(#227)
2
+
3
+ 日期:2026-10-19 | 状态:frozen(唯一接口;worker 不得自行解释矛盾,发现矛盾上报修订)
4
+ 依据:issue #227;HEAD `c97cd27`;spawnMode 缺省 inprocess;`docs/contracts/esc-no-retire-v3.md`(仍在生效)
5
+
6
+ ## 0. 已确认事实(worker 直接引用,禁止重新侦察)
7
+
8
+ | # | 事实 | 证据 |
9
+ |---|---|---|
10
+ | F1 | `InProcessSupervisor.spawnChild` 在 `await session.prompt(...)` 上阻塞到**整轮** agent run 结束 | `src/supervisor/inprocess-supervisor.ts:649`;SDK `agent-session.js:1380-1413`(`_runAgentPrompt` 才 resolve) |
11
+ | F2 | `RpcSupervisor.spawnChild` 只等到 prompt **被受理**(`preflightResult` → disposition),不跑完 | `rpc-client.js:135-138`、`rpc-mode.js:299-316` |
12
+ | F3 | `Orchestrator.launch` 在 `spawnChild` 返回**之后**才写 `node_launched` / `recordChild` | `src/runs/orchestrator.ts:765`、`:799`、`:904` |
13
+ | F4 | vexillatio 的 abort 早退丢掉了已收集结果,且 details 是 `{}` | `index.ts:2416`、`index.ts:3206-3208`(`ok()` 的 details 恒为 `{}`) |
14
+ | F5 | `interruptedCollect` 恒返回 `status:"running"` + `reason:"aborted by parent"` | `src/runs/orchestrator.ts:1064-1071` |
15
+ | F6 | `collect()` 的 deadline 从**本次调用**起算 | `src/runs/orchestrator.ts:980` |
16
+ | F7 | 会话文件里 user 行先于 `agent_start` 落盘 | `agent-session.js:1601`(appendMessage) → `:1630`(preflight) → `:1631`(`_runAgentPrompt`) |
17
+ | F8 | `event.type === "error"`(`pi-ai` `lazyStream` 写入的 `stopReason:"error"` 记录)**今天被 inprocess supervisor 直接丢弃** | `inprocess-supervisor.ts:1005`(`turn_end|agent_end` 只吃 `event.message`)、`:1038`(`COARSE_EVENT_TYPES` 无 `error`) |
18
+ | F9 | `FakeSupervisor.spawnChild` 是同步返回的(无 `await prompt`) | `test/helpers/fake-supervisor.ts:139-190` |
19
+ | F10 | `test/unit/inprocess-supervisor.test.ts` 的 `FakeSession.prompt` 也是非阻塞语义 | 同文件 `:42-47` |
20
+ | F11 | 门禁基线:`npm run typecheck` / `npm test`(1113) / `npm run test:integration`(81) 在 HEAD 全绿,各约 33s | 实测 |
21
+ | F12 | `spawnedAt` 在 spawn 前后共写 2 次(`persistParentReservation` 与 `recordChild`/`previous`) | `orchestrator.ts:429/444`、`:777`、`:904` |
22
+
23
+ ## 1. 决策(D 编号,reviewer 必读)
24
+
25
+ **D1 — inprocess `spawnChild` 改为「受理即返回 + 后台 drain」。**
26
+ `session.prompt(...)` 不再 `await` 到整轮结束。`spawnChild` 在下面两个条件**任一**成立时返回 `ChildHandle`:
27
+ - (a) 会话文件已出现属于**本轮**的 user 行(`F7`:prompt 已被受理并落盘);或
28
+ - (b) 时钟推进到 `spawnStartTimeoutMs`(默认 `DEFAULT_INPROCESS_SPAWN_START_TIMEOUT_MS = 15_000`)兜底。
29
+
30
+ 判定实现细则(冻结):
31
+ - 拉起点 = `spawnChild` 调用时刻的 `statSync(sessionFile).size`(文件不存在记为 0)。
32
+ - 出现 user 行 = `parseSessionFile(file).turns.length > 0 && fileSize > baseline`。**不得**用 `isLastTurnComplete`(`F7` 已经证明 prompt resolve 时末轮为「有 user、无 assistant」,用它是自相矛盾的门)。
33
+ - 轮询间隔复用 `POLL_INTERVAL_MS = 500` 的既有 cadence。**勘误(见 §7 v1.1)**:该常量实际是 `src/runs/orchestrator.ts:143` 的 module-private 常量,从未存在于 `src/shared/types.ts`;Worker A 在 `src/supervisor/spawn-mode.ts` 导出同名同值常量(与 `DEFAULT_INPROCESS_*` 并列),inprocess-supervisor 从那里 import,**不要**去改 `orchestrator.ts`。
34
+ - 兜底超时**不是错误**:到点照常返回 handle(会话已创建即持久化了 transcript),只是记录 `onSpawnDelayed` 一次(`InProcessSupervisorOptions` 新增可选回调,用于诊断;无默认实现)。
35
+
36
+ **D1a — 「受理前 reject」仍必须让 spawn 失败(fallback 链保命条款,父会话审查补入)。**
37
+ `session.prompt(...)` 的 reject 分两种,**不得**合并处理:
38
+ - **受理前 reject**(`firstTurnAccepted === false`:会话文件未长过 baseline、无 user turn。真实触发:无 model / 无 API key / 压缩中,见 `agent-session.js:1568-1581` —— 这些抛错发生在 `appendMessage` 与 `preflightResult("started")` **之前**)→ `spawnChild` **必须抛出** `SubagentError(原始 message, ErrorCodes.START_FAILED)`,走既有 `rollbackSpawn` + `onSpawnRefused` + rethrow,使 `Orchestrator.launch` 的 `canFallbackStart`(`orchestrator.ts:235-240`)能继续降级到下一个模型。**不得**返回 handle。
39
+ - **受理后 reject**(`firstTurnAccepted === true`:首轮已开跑才炸)→ 按 D2 返回 handle,`lastTurnResult.reason = "child turn failed: <msg>"`,`onSpawnRefused` 上报。
40
+
41
+ 理由:非阻塞改造若把「受理前拒绝」也吞掉,会静默废掉 `fallbackModels`(已实测:HEAD 抛错 / 首版实现返回 handle,`canFallbackStart` 失效)。这是 #227 之外的回归,必须有先红后绿用例守住。
42
+
43
+ **D2 — 后台 drain 必须保住 ALS 身份/深度与错误可见性(最大风险点)。**
44
+ - 复用现有的 `runAtInProcessChildDepthAsync(...)` + `runInChildContext(registration.context, ...)` 作用域,把「settle 之后的收尾」放进去;`beginTurn` 保持在 prompt 之前(现状即是)。
45
+ - **事件会脱出 ALS 作用域**:`agent_start` 在 `await session.prompt()` 之前同步 emit(`agent-session.js:1601→1631`),但 `agent_settled` 在 `_runAgentPrompt` 的 `finally` 里 emit,那时已在 drain 的后续微任务链上。因此 `updateTurnState` 里做 settle 状态流转时**不得依赖** `currentChildContext()`;`publishEvent` 已有 `runOutsideChildContext`(REV2-7),不要动。
46
+ - drain 的 `catch`:**不得**调用 `rollbackSpawn`(会 `children.delete` 把已返回的 handle 抹掉)。必须:`turnActive=false`、`state="idle"`、`lastTurnResult={settled:false,abnormal:false,reason:"child turn failed: <msg>"}`、`resolveWaiters(...)`,并把 `<msg>` 交给 `onSpawnRefused`(该回调语义扩为「child 运行期失败诊断」,文档同步)。
47
+ - drain 的 `finally` 必须落一件机器事实:把 prompt reject 的**异常**写成一条 `event.type === "error"` 的事件送进 `handleSessionEvent`,好让 D3/D3b 的映射生效(见 D3b)。
48
+
49
+ **D3 — `error` 事件要能进 settle 判定(缺陷 3 的 inprocess 半边)。**
50
+ `updateTurnState` 增加分支:`event.type === "error"` → 记 `child.pendingFailure = <error.message 或 error 的字符串化>`。当随后的 `agent_settled` 到达时,若 `lastTurnResult` 会判成 `aborted:false` 而 `pendingFailure` 非空,则用 `F8` 已存在的事件把它变成可见事实(不得凭空造 stopReason)。
51
+
52
+ **D3b — 中断被写成 `stopReason:"error"` 的读法修正(本仓半边)。**
53
+ `src/shared/session.ts` 的 `deriveOutcome`:当 `stopReason === "error"` 且 `errorMessage` 同时匹配「abort 语义」与「**无 reason 的 AbortController**」时,`reason` 必须给出可判别文案,禁止再与真正的 provider 失败同形。冻结文案:
54
+ - 现有 `/abort/i` 分支保持(`"This operation was aborted"` 已命中,`test/unit/session.test.ts:59-63` 锁死,**不得改动该断言**)。
55
+ - 新增:当 `errorMessage` 匹配 `/^(this operation was aborted|the operation was aborted|operation aborted)$/i` 且**不含** `timeout` 时,`reason` 置为 `ABORT_WITHOUT_REASON`(新增导出常量,值 `"parent turn ended (abort signal without a reason)"`)。`status` 仍为 `aborted`(不变),只改 `reason` 的可读性。
56
+ - 目的:让 issue #227 里「`stopReason:"error"` 被读成 provider 失败」的分析不再成立,同时不改任何 `status` 语义(避免回归 `deriveOutcome` 的既有 511 处断言面)。上游 `pi-ai` 的 `createSetupErrorMessage` 不在本仓,**不在本契约范围**(见 §5)。
57
+
58
+ **D4 — vexillatio 中断不丢结果。**
59
+ `index.ts:2416` 与 execute 层 `index.ts:743-745` 都不得丢弃已算出的结果。冻结返回形状:
60
+
61
+ ```ts
62
+ // 文本:interrupted 头行 + 已收集的每一段(顺序 = handles 顺序)
63
+ `interrupted (parent turn ended; children still running)\n\n${results.join("\n\n")}`
64
+ // details
65
+ { runId, handles: [...{name, agent, sessionFile}], async: false, interrupted: true,
66
+ ...(stillRunning.length ? { stillRunning: string[] } : {}) }
67
+ ```
68
+
69
+ - 头行**必须**保持冻结文案 `interrupted (parent turn ended; children still running)`(`esc-no-retire-v3.md:22`),因为 `test/unit/teams.test.ts:1298` 用 `/interrupted \(parent turn ended; children still running\)/` 匹配——把结果段追加在头行之后不破坏该正则。
70
+ - `ok()` 新增可选 `details` 参数(`ok(text, details?)`,缺省仍是 `{}`),**不要**引入 `isError`(`pi-agent-core` 的 `AgentToolResult` 虽有该字段,但本仓 511 处断言与 TUI 渲染均按「无 isError」编写,改它属于越界)。
71
+ - launch 失败行(`✗ <agent>: <error>`,`index.ts:2364`)**也**必须在中断时返回(现在是先早退、后 push,一并丢)。
72
+ - 真机上 `collect` 内的 `interruptedCollect` 会给 `status:"running"`:渲染时照旧打印 `execution: running (aborted by parent)`,**不要**把它改写成 success(`F5` + `orchestrator.test.ts:153-168` 锁死)。
73
+
74
+ **D5 — 死子 / 早退子不得空等到 collect deadline(缺陷 1 的收尾)。**
75
+ `Orchestrator.collect`:当 `initial` 里**本轮**已有终态 assistant(`isLastTurnComplete(initial) === true`)却因 `liveConnectionBlip` 走进 `awaitSupervisor` 的路径之外——**新增**:若 `wait === "timeout"` 且 `!this.supervisor.isAlive(name)` 且 `isLastTurnComplete(parsed)`,直接按已完成的 turn 收尾(走现有 `deriveOutcome` + `finishCollect`),不得再套 `COLLECT_DEADLINE_REASON`。现有 `collectArtifactGrowing` 的 grace 循环保持不动。
76
+
77
+ **D6 — `collect` 的 deadline 不因本轮非阻塞 launch 而变(显式不做)。**
78
+ `F6` 标明 deadline 从 collect 调用起算;issue 建议「timeoutMs 覆盖 launch」是**另一件事**,本契约**不改** deadline 起点,只删掉 `COLLECT_DEADLINE_REASON` 注释里那句与实现不符的「budget from launch」(`orchestrator.ts:1007`)。理由:改起点会让 `runtime` 的 re-arm watch 预算翻倍(`runtime.ts:1017-1032`),属于 #227 之外的语义变更。
79
+
80
+ **D7 — notify 的 `batch N` 文案(显式不做)。**
81
+ `notify.ts:351` 的 `batch ${entries.length}` 只是 TUI 折叠行的可读摘要(`notice-renderer.ts:69`),不是机器事实,且没有测试断言它。issue 把它列为「顺带观察」,本契约**不修改**,只在 §6 记为已知表述。
82
+
83
+ ## 2. 文件所有权(两 worker 并行,零共享文件)
84
+
85
+ ### Worker A(`spawn` 非阻塞 + drain + error 事件)
86
+
87
+ | 文件 | 动作 |
88
+ |---|---|
89
+ | `src/supervisor/inprocess-supervisor.ts` | D1 / D2 / D3 |
90
+ | `src/supervisor/spawn-mode.ts` | 新增导出常量 `DEFAULT_INPROCESS_SPAWN_START_TIMEOUT_MS = 15_000` 与勘误后的 `POLL_INTERVAL_MS = 500`(均与 `DEFAULT_INPROCESS_MAX_ACTIVE` 并列) |
91
+ | `src/api.ts` | 若导出面需要(`InProcessSupervisorOptions` 已导出);仅在新增类型被外部引用时改 |
92
+ | `docs/inprocess-supervisor.md` | 改写 `spawnChild awaits the whole first agent run` 那段(现 `:130-137`),改为新语义;同步 `runtime.ts:1478-1482` 注释所指事实变化(**只改文档**,`runtime.ts` 属 Worker B 边界外、也不改) |
93
+ | `test/unit/inprocess-supervisor.test.ts` | 新增 D1/D2/D3 用例;**必须保住**既有 `child context is active during the first prompt`(`:569`) 与 `maxDepth holds across generations driven from the root chain (REV2-1)`(`:700`) 与 `onEvent listeners run at root identity`(`:1071`) 三条 |
94
+
95
+ ### Worker B(中断不丢结果 + collect 收尾)
96
+
97
+ | 文件 | 动作 |
98
+ |---|---|
99
+ | `index.ts` | D4(`ok()` 可选 details、`launchUtilityFamily` 返回、execute 两层早退) |
100
+ | `src/runs/orchestrator.ts` | D5、D6 的注释修正 |
101
+ | `src/shared/session.ts` | D3b(`ABORT_WITHOUT_REASON` + `deriveOutcome` 新分支) |
102
+ | `test/unit/session.test.ts` | D3b 新用例(**不得改动** `:59-63` 既有断言) |
103
+ | `test/unit/teams.test.ts` | D4 用例:abort 时 vexillatio 返回已收集结果 + details 带 `interrupted`/`stillRunning`/`runId`(既有 `:1254` 用例的断言不得删减) |
104
+ | `test/integration/orchestrator.test.ts` | D5 用例(死子不空等);`:153` 既有断言不得改动 |
105
+ | `docs/vexillatio-contract.md` | §2.2 步骤 3 补一句中断返回语义(append-only 修订记录 REV-17.9) |
106
+
107
+ **共享文件:无。** 两 worker 各在自己的 worktree,冲突面为零;`test/unit/inprocess-supervisor.test.ts` 只属 A,`test/unit/teams.test.ts` 只属 B。
108
+
109
+ ## 3. 验收(机器可检查,父会话在 collect 时执行)
110
+
111
+ 每个 worker 的 `verify`:
112
+ ```bash
113
+ npm run typecheck
114
+ npm test
115
+ npm run test:integration
116
+ ```
117
+ 首个非零退出即 rejected。另需人工核对的断言锚点(reviewer 用):
118
+
119
+ 1. 新增 inprocess 用例:`prompt` 立即 reject 且**不写会话文件** → `assert.rejects(spawnChild)`,`error.code === START_FAILED`,`children.has(name) === false`(无幽灵子)、`isAlive === false`。**必须在修复前是红的**(D1a)。
120
+ 2. 新增 inprocess 用例(对照):`prompt` 先写一条 user 行再 reject → `spawnChild` 正常返回 handle、`children.has(name) === true`、`waitSettled` 的 `reason` 含 `child turn failed`。
121
+ 3. 新增 inprocess 用例:用一个**故意永不 resolve** 的 `prompt()`(模拟 8~18 分钟的子轮),断言 `spawnChild` 在 `spawnStartTimeoutMs` 量级内返回(用注入的短超时,如 50ms,不真等 15s)。
122
+ 4. 新增 inprocess 用例:`onEvent` 仍收到 `agent_start` / `agent_settled`(后台 drain 不丢事件)。
123
+ 5. 新增 teams 用例:abort 后 vexillatio 文本同时含冻结头行**和** `── <name> ──` / `execution:` / 子结果正文;`details.interrupted === true`;`details.handles.length > 0`;`details.runId` 非空;child 未被 retire。
124
+ 6. 新增 orchestrator 用例:子进程已死且本轮已有终态 assistant → `collect` 不返回 `COLLECT_DEADLINE_REASON`(注:Worker B 已披露该分支在 HEAD 上不可观测,属防御性收尾,非行为修复——reviewer 不应据此判「假覆盖」)。
125
+ 7. 新增 session 用例:`stopReason:"error"` + `"This operation was aborted"` → `reason === ABORT_WITHOUT_REASON`;`+ "The operation was aborted due to timeout"` → 仍是 timeout 语义(不得被新分支吞掉)。
126
+ 8. 既有绿线不回退:`inprocess-supervisor.test.ts` 33 例、`teams.test.ts`、`orchestrator.test.ts` 25 例、`session.test.ts` 全部。
127
+
128
+ ## 4. 明确不做(reviewer 判「缺失」前先读本节)
129
+
130
+ | 不做 | 理由 |
131
+ |---|---|
132
+ | 改 `collect()` 的 deadline 起点为 launch 时刻 | D6:会翻倍 runtime re-arm 预算,属 #227 之外的语义变更 |
133
+ | 给 inprocess 加 `preflightResult`(SDK 有该选项) | D1 用会话文件判定,**不扩 `InProcessSession` 窄接口**:该接口是所有测试 fake 的契约面(`inprocess-supervisor.test.ts:29`),扩参数会让 20 个 fake 全部要改且没有一处需要它 |
134
+ | 改 `notify.ts` 的 `batch N` 文案 | D7:TUI 摘要,非机器事实,无断言 |
135
+ | 引入 `isError` | D4:`AgentToolResult` 有该字段,但本仓渲染链/TUI/511 处断言按「无 isError」工作,改它属越界 |
136
+ | 上游 `pi-ai` 的 `createSetupErrorMessage` | 不在本仓(issue 也建议单独 file) |
137
+ | RPC 侧 `spawnChild` 的 `await client.prompt` | `F2` 已经是非阻塞;**不改** |
138
+ | `previous.spawnedAt` 的 resume 重写 | `F12` 是既有语义(resume 重置计时),与 #227 无关 |
139
+ | `test/unit/legion-integration.test.ts:1595` 那条「因为 spawnChild 阻塞所以直读 sessionFile」的测试 | 断言本身仍成立(台账节点仍可无 job 而直读文件),**不改**;`runtime.ts` 的 `statusEntryFromNode` 注释由 Worker A 在文档层说明,代码不动 |
140
+
141
+ ## 5. 风险与回滚
142
+
143
+ - **R1(最高)**:`F1` 的阻塞是 `child context is active during the first prompt` 与 REV2-1 深度测试的**隐式前提**(ALS 只在被 await 的链上存活)。改为非阻塞后,首轮 turn 的 tool 执行若脱出 ALS,这两个测试会红。D2 要求把 drain 挂在原作用域内;若测试仍红,**停下来上报**,不要改测试断言。
144
+ - **R2**:`agent_settled` 在 `_runAgentPrompt` 的 `finally` 里 emit,可能晚于 drain 的 resolve;`beginTurn` 与 `lastTurnResult` 的时序必须保持「先清后置」,否则 `waitSettled` 会立刻拿到陈旧结果。
145
+ - **R3**:`rollbackSpawn` 在新语义下不能用于「drain 失败」(会删号),见 D2。
146
+ - **R4**:vexillatio 中断返回文本变长,`display`/preview 上限(`PREVIEW_CHARS=4000`)在 notify 侧,不在 tool result 侧;tool result 不截断(`index.ts:2400` 现状即不截断)。
147
+ - 回滚点:两 PR 独立,可分别 `git revert`;D3b 是纯 reason 文案,回滚无副作用。
148
+
149
+ ## 6. 已知但不在本次范围
150
+
151
+ - 上游 `pi-ai` 把 abort 写成 `stopReason:"error"`(issue 缺陷 3 的根因)——建议单独 file 给 `pi-ai`,本契约只做本仓可判别的 reason(D3b)。
152
+ - `notify` 的 `batch N` 表述(D7)。
153
+ - `subagent` 工具(非 vexillatio)的中断路径已保留 `runId`(`index.ts:1751-1754`),本次不动。
154
+
155
+ ## 7. 修订记录
156
+
157
+ | 日期 | 版本 | 修订 | 原因 |
158
+ |---|---|---|---|
159
+ | 2026-10-19 | v1 | 首次冻结 | #227 三缺陷拆分:D1/D2/D3(A)、D4/D3b/D5(B);D6/D7 显式排除 |
160
+ | 2026-10-19 | v1.1 | 勘误 §1 D1 轮询常量位置:`src/shared/types.ts` → 实为 `src/runs/orchestrator.ts:143` 私有常量,改由 `src/supervisor/spawn-mode.ts` 导出同值常量;§2 Worker A 文件表同步 | Worker A 指出契约原文事实错误;`grep` 确认 `POLL_INTERVAL_MS` 从未在 `src/shared/types.ts` 出现过。属笔误型而非设计型矛盾,advisor 裁决按勘误继续(不回退重派),父会话在此落库 |
161
+ | 2026-10-19 | v1.2 | 新增 **D1a**:首次 prompt 在「受理之前」reject 时 `spawnChild` 仍须抛出(保 `fallbackModels`);§3 验收清单同步为 8 条 | 父会话审查 PR #228 实测发现回归:非阻塞改造把受理前 reject 吞成「已 spawn」,`canFallbackStart` 失效。HEAD 抛错 / 首版实现返回 handle,属 #227 之外的回归,必须以先红后绿用例守住 |