pi-legion 3.0.1 → 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 +138 -2
- package/CONTRIBUTING.md +38 -25
- package/README.md +23 -18
- package/agents/worker.md +6 -6
- package/docs/contracts/vexillatio-nonblocking-launch-v1.md +161 -0
- package/docs/design.md +39 -9
- package/docs/inprocess-supervisor.md +67 -5
- package/docs/legion-v2-contract.md +6 -3
- package/docs/subagents-teams.md +8 -4
- package/docs/vexillatio-contract.md +2 -1
- package/index.ts +149 -58
- package/package.json +15 -22
- package/src/agents/agents.ts +5 -5
- package/src/api.ts +6 -1
- package/src/extension/auto-doctor.ts +21 -3
- package/src/extension/join.ts +7 -18
- package/src/extension/legion-doctor.ts +125 -38
- package/src/extension/legion-tools.ts +7 -1
- package/src/extension/playbook.ts +1 -1
- package/src/extension/runtime.ts +206 -8
- package/src/extension/spawn-hints.ts +25 -4
- package/src/runs/orchestrator.ts +78 -8
- package/src/runs/worktree.ts +5 -4
- package/src/shared/session.ts +83 -7
- package/src/shared/types.ts +17 -2
- package/src/supervisor/inprocess-supervisor.ts +449 -16
- package/src/supervisor/routing-supervisor.ts +1 -1
- package/src/supervisor/spawn-mode.ts +25 -0
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,141 @@ 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
|
+
|
|
14
149
|
## [3.0.1] — 2026-10-18
|
|
15
150
|
|
|
16
151
|
### Fixed
|
|
@@ -258,11 +393,12 @@ branch — pin `pi-legion@0.16.x` if you still need that model. See
|
|
|
258
393
|
Earlier 0.11–0.15 history (including the `pi-herdr-swarm` /
|
|
259
394
|
`@zzjcool/pi-herdr-subagents` renames) lives in git tags and older Releases.
|
|
260
395
|
|
|
261
|
-
[Unreleased]: https://github.com/zzjcool/pi-legion/compare/v3.0.
|
|
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
|
|
262
399
|
[3.0.0]: https://github.com/zzjcool/pi-legion/compare/v2.0.4...v3.0.0
|
|
263
400
|
[2.0.4]: https://github.com/zzjcool/pi-legion/compare/v2.0.3...v2.0.4
|
|
264
401
|
[2.0.3]: https://github.com/zzjcool/pi-legion/compare/v2.0.2...v2.0.3
|
|
265
402
|
[2.0.2]: https://github.com/zzjcool/pi-legion/compare/v2.0.1...v2.0.2
|
|
266
|
-
[2.0.1]: https://github.com/zzjcool/pi-legion/compare/v2.0.0...v2.0.1
|
|
267
403
|
[2.0.0]: https://github.com/zzjcool/pi-legion/releases/tag/v2.0.0
|
|
268
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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 `
|
|
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 **
|
|
49
|
-
staging
|
|
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
|
-
|
|
61
|
+
npm install
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
-
Requires Node.js 22+
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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. `
|
|
75
|
-
2. `
|
|
76
|
-
3. `
|
|
77
|
-
4. `
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
|
87
|
-
|
|
88
|
-
`INVALID_PARAMS` (only `kind: pi` children are supported
|
|
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`
|
|
93
|
-
asks for in-process never gets a
|
|
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
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
MOCK_LLM_SCENARIO=spawn-scout
|
|
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
|
|
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:
|
|
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
|
-
-
|
|
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.
|
|
54
|
-
5.
|
|
55
|
-
6. 写报告到指定路径(任务里会给),格式:做了什么 / 测试覆盖 / 验证输出 /
|
|
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
|
|
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 之外的回归,必须以先红后绿用例守住 |
|
package/docs/design.md
CHANGED
|
@@ -219,18 +219,48 @@ launch (async) ──→ 子 pane 跑
|
|
|
219
219
|
│
|
|
220
220
|
├─ TUI:输入框上方 aboveEditor widget(live agent 面板)
|
|
221
221
|
│ (pi-subagents fleet-status;footer 不再重复计数,见 #192)
|
|
222
|
-
└─ collect 在后台等
|
|
222
|
+
└─ collect 在后台等 run 结束
|
|
223
223
|
│
|
|
224
|
-
└─
|
|
225
|
-
{ triggerTurn: true, deliverAs: "followUp" })
|
|
226
|
-
父会话空闲则立刻唤醒;若父会话还在跑当前 turn(工具循环),
|
|
227
|
-
等这一轮结束后再投递,避免 steer 打断正在干的事。
|
|
228
|
-
所有状态都 display:true(transcript 留下完成/失败记录);
|
|
229
|
-
成功由 notice-renderer 压成单行并按 pane 宽度硬截断
|
|
230
|
-
(控制字符已消毒,ctrl+o 展开),
|
|
231
|
-
失败/中止保留完整块,details 只供 TUI 不进 LLM 上下文。
|
|
224
|
+
└─ runtime 完成通道(唯一的延迟源,按 **run** 级判定)
|
|
232
225
|
```
|
|
233
226
|
|
|
227
|
+
完成通知的投递分两段,**只有一处 run 级延迟**:join 窗口是纯批处理延迟,二者串行叠加
|
|
228
|
+
(最坏串联约 10s + 80s)——除此之外不存在第二个"等父会话忙完"的延迟源。
|
|
229
|
+
|
|
230
|
+
1. **join 批处理窗口**(`src/extension/join.ts`,默认 10s):同一 runId 的多个 child
|
|
231
|
+
一起启动、先后结束,窗口把 straggler 合成一条消息(`Background tasks completed
|
|
232
|
+
(N of M)`)。窗口**只做批处理**,到期无条件 flush 到下一个环节,**不**因为父会话忙
|
|
233
|
+
而延长——turn 级的 busy 是错误预测器(工具循环里父会话按定义就是忙的),而且会在
|
|
234
|
+
outbox 之外叠加第二层延迟。
|
|
235
|
+
2. **run 级 outbox**(`src/extension/runtime.ts`,`MAX_PENDING_AGE_MS = 80_000`):
|
|
236
|
+
父 **run** 活跃时不 `pi.sendMessage`,通知入 FIFO 队列;`agent_settled` 到达后
|
|
237
|
+
一次性 burst 发出(一条通知一条消息),并重绘 UI。硬上限 80s(= 2× 旧的
|
|
238
|
+
`(1+3)×10s`)到点强制发送并计入 `noticeQueueStats().forcedFlushes`,宁可回到旧
|
|
239
|
+
行为也不无限隐藏。父 run 不活跃时**立即**发送(含 `mode: "each"`、单成员批次、
|
|
240
|
+
`triggerTurn:false` 项)。
|
|
241
|
+
|
|
242
|
+
上限是 **per-notice** 而不是 per-run:到点判定用 notice 自己的 `enqueuedAt` 算
|
|
243
|
+
(`runtime.ts` 的 `armOutboxTimer` 从 `outbox[0]` 起算,
|
|
244
|
+
`noticeQueueStats().oldestAgeMs` 也报最老一条)。所以父 run 持续 10 分钟时,不是
|
|
245
|
+
给整个 run 记一个 80s 预算、也不是因为 run 还在跑就无限顺延:队列里最老一条到点就
|
|
246
|
+
触发(`flushOutbox` 一次 `splice(0)`,连同队列里更年轻的 notice 一起按 FIFO burst
|
|
247
|
+
发出)。T3d 把「arm 与 oldestAgeMs 都跟最老一条」钉成测试。
|
|
248
|
+
|
|
249
|
+
为什么不能只发 `followUp`:宿主只在 **live run 内部** drain `agent.followUpQueue`,
|
|
250
|
+
所以父 run 活跃时发出去的 followUp 会一直躺在队列里,直到用户再发一条消息才出现。
|
|
251
|
+
这正是「完成通知看起来没来」的根因。发送时形状不变——
|
|
252
|
+
`pi.sendMessage({ customType: "subagent-notify" }, { triggerTurn: true, deliverAs: "followUp" })`
|
|
253
|
+
——只是**时机**改到 run 结束时。
|
|
254
|
+
|
|
255
|
+
所有状态都 display:true(transcript 留下完成/失败记录);
|
|
256
|
+
成功由 notice-renderer 压成单行并按 pane 宽度硬截断
|
|
257
|
+
(控制字符已消毒,ctrl+o 展开),
|
|
258
|
+
失败/中止保留完整块,details 只供 TUI 不进 LLM 上下文。
|
|
259
|
+
|
|
260
|
+
> print/json 模式:`session_shutdown` → `dispose()` 丢弃未投递的 pending。child 的
|
|
261
|
+
> session 文件仍在,可用 `collect` 从磁盘恢复;但 `finished` 缓存**不**保留
|
|
262
|
+
> (`dispose()` 开头就 `jobs.clear()` / `finished.clear()`,此后才丢弃 outbox)。
|
|
263
|
+
|
|
234
264
|
父 agent 的正确用法:`subagent` 工具 launch 之后把控制权交回用户,等 completion
|
|
235
265
|
message,而不是轮询,也不是让孩子来拍自己。
|
|
236
266
|
|