pi-legion 3.1.0 → 4.0.0
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 +215 -2
- package/CONTRIBUTING.md +4 -17
- package/README.md +25 -21
- package/agents/worker.md +1 -1
- package/docs/contracts/display-liveness-v1.md +373 -0
- package/docs/contracts/drop-non-pi-kinds-v4.md +198 -0
- package/docs/contracts/esc-abort-v1.md +4 -4
- package/docs/contracts/esc-no-retire-v3.md +2 -2
- package/docs/contracts/launch-admission-queue-v1.md +839 -0
- package/docs/contracts/status-panel-v1.md +174 -0
- package/docs/inprocess-supervisor.md +37 -4
- package/docs/legion-v2-contract.md +18 -2
- package/docs/performance-2026-10-19.md +199 -0
- package/docs/vexillatio-contract.md +3 -1
- package/index.ts +557 -76
- package/package.json +4 -2
- package/skills/pi-legion/SKILL.md +2 -3
- package/src/agents/agents.ts +17 -10
- package/src/agents/overrides.ts +0 -1
- package/src/agents/presets.ts +14 -40
- package/src/agents/settings.ts +26 -77
- package/src/agents/step-model.ts +7 -22
- package/src/api.ts +1 -35
- package/src/extension/event-bus.ts +80 -8
- package/src/extension/legion-commands.ts +41 -17
- package/src/extension/legion-doctor.ts +1 -1
- package/src/extension/legion-stop.ts +39 -13
- package/src/extension/legion-tools.ts +21 -4
- package/src/extension/liveness.ts +200 -0
- package/src/extension/playbook.ts +2 -2
- package/src/extension/recycle.ts +4 -0
- package/src/extension/runtime.ts +379 -15
- package/src/extension/slash.ts +0 -21
- package/src/extension/summary.ts +1 -1
- package/src/legion/db.ts +165 -1
- package/src/legion/events.ts +81 -16
- package/src/legion/nodes.ts +13 -2
- package/src/legion/rebuild.ts +7 -1
- package/src/legion/usage.ts +58 -4
- package/src/runs/orchestrator.ts +1165 -62
- package/src/runs/worktree.ts +190 -21
- package/src/shared/ignored-kind.ts +57 -0
- package/src/shared/progress.ts +11 -0
- package/src/shared/session.ts +199 -19
- package/src/shared/types.ts +50 -66
- package/src/supervisor/admission-queue.ts +516 -0
- package/src/supervisor/inprocess-supervisor.ts +108 -33
- package/src/supervisor/proc-memory.ts +67 -0
- package/src/supervisor/routing-supervisor.ts +58 -1
- package/src/tui/session-messages-file.ts +100 -0
- package/src/tui/status.ts +91 -18
- package/src/tui/tree-view.ts +28 -3
- package/src/tui/viz/layout.ts +54 -3
- package/src/tui/viz/model.ts +67 -14
- package/src/tui/viz/palette.ts +11 -0
- package/src/tui/viz/panels.ts +222 -8
- package/src/tui/viz/view.ts +110 -23
- package/src/backends/legacy/client.ts +0 -638
- package/src/backends/legacy/cursor-chat.ts +0 -348
- package/src/backends/legacy/runner.ts +0 -101
- package/src/backends/legacy/types.ts +0 -138
- package/src/runs/kind.ts +0 -260
package/src/extension/runtime.ts
CHANGED
|
@@ -33,7 +33,9 @@ import {
|
|
|
33
33
|
type WidgetTui,
|
|
34
34
|
} from "../tui/status.ts";
|
|
35
35
|
import { progressFromSessionFile } from "../shared/progress.ts";
|
|
36
|
+
import { readProcRss } from "../supervisor/proc-memory.ts";
|
|
36
37
|
import { isTransientConnectionFailure } from "../shared/session.ts";
|
|
38
|
+
import { isTerminalState, liveness } from "./liveness.ts";
|
|
37
39
|
import {
|
|
38
40
|
DEFAULTS,
|
|
39
41
|
type AgentKind,
|
|
@@ -50,6 +52,7 @@ import { createDbEventSource, type DbEventSource } from "./event-bus.ts";
|
|
|
50
52
|
import { openLegionDb, type DatabaseSync } from "../legion/db.ts";
|
|
51
53
|
import { getSubtree } from "../legion/nodes.ts";
|
|
52
54
|
import { attachUsageTotals } from "../legion/usage.ts";
|
|
55
|
+
import { maxEventId } from "../legion/events.ts";
|
|
53
56
|
import type { TreeNodeSnapshot } from "../tui/tree-view.ts";
|
|
54
57
|
|
|
55
58
|
export interface CollectSnapshot {
|
|
@@ -87,6 +90,12 @@ export interface TrackedJobInput {
|
|
|
87
90
|
* unknown rather than a guessed `rpc`.
|
|
88
91
|
*/
|
|
89
92
|
spawnMode?: SpawnMode;
|
|
93
|
+
/**
|
|
94
|
+
* OS process of an `rpc` child (`Handle.pid`), for the memory probe only.
|
|
95
|
+
* Optional: an `inprocess` child has no process of its own, and a legacy
|
|
96
|
+
* record has no value. Never persisted — a pid is host-scoped.
|
|
97
|
+
*/
|
|
98
|
+
pid?: number;
|
|
90
99
|
/** Legion tree identity supplied by the host when the M2 ChildRecord is available. */
|
|
91
100
|
nodeId?: string;
|
|
92
101
|
parentId?: string | null;
|
|
@@ -162,6 +171,16 @@ export interface SessionRuntimeDeps {
|
|
|
162
171
|
}
|
|
163
172
|
|
|
164
173
|
const DEFAULT_REFRESH_MS = 500;
|
|
174
|
+
/**
|
|
175
|
+
* How long a ledger heartbeat stays trustworthy (D3/D7).
|
|
176
|
+
*
|
|
177
|
+
* Mirrors `DEFAULT_ORPHAN_STALE_MS` in `legion-tools.ts` and the `orphanStaleMs`
|
|
178
|
+
* setting default (`src/shared/types.ts`). Duplicated as a literal on purpose:
|
|
179
|
+
* the TUI refresh path must not read settings on every repaint, and this value is
|
|
180
|
+
* the horizon for "a row that stopped heartbeating", which is exactly the orphan
|
|
181
|
+
* definition. Keep the three in sync.
|
|
182
|
+
*/
|
|
183
|
+
const DEFAULT_ORPHAN_STALE_MS = 90_000;
|
|
165
184
|
/**
|
|
166
185
|
* Hard cap on how long a completion notice may stay hidden behind a live
|
|
167
186
|
* parent agent run (D-B). 80s = 2× the (1+3)×10s that the old join window
|
|
@@ -245,6 +264,20 @@ export interface LiveChildStatusInput {
|
|
|
245
264
|
* by the owner marker instead. Only consulted when `alive` is false.
|
|
246
265
|
*/
|
|
247
266
|
owner?: "live" | "dead" | "unknown";
|
|
267
|
+
/**
|
|
268
|
+
* Own OS process of an `rpc` child (`Handle.pid`), when this session owns it.
|
|
269
|
+
*
|
|
270
|
+
* Only a pid of THIS host is ever supplied — pids are not persisted, so a
|
|
271
|
+
* record from a previous session has none. `inprocess` children have none by
|
|
272
|
+
* definition: they share this process.
|
|
273
|
+
*/
|
|
274
|
+
pid?: number;
|
|
275
|
+
/**
|
|
276
|
+
* Context size of the last assistant message. Explicit override; otherwise
|
|
277
|
+
* derived from `sessionFile` through `progressFromSessionFile` — the same
|
|
278
|
+
* `accumulateUsage` source of truth the widget reads.
|
|
279
|
+
*/
|
|
280
|
+
contextTokens?: number;
|
|
248
281
|
now?: number;
|
|
249
282
|
}
|
|
250
283
|
|
|
@@ -313,6 +346,52 @@ function ownerLine(owner: "live" | "dead" | "unknown"): string {
|
|
|
313
346
|
return `owner: ${owner}`;
|
|
314
347
|
}
|
|
315
348
|
|
|
349
|
+
/** Compact token count: `950`, `24.3K`, `1.4M` — absolute, never a percentage. */
|
|
350
|
+
export function formatContextTokens(value: number): string {
|
|
351
|
+
if (!Number.isFinite(value) || value <= 0) return "0";
|
|
352
|
+
const unit = value >= 1_000_000 ? "M" : value >= 1_000 ? "K" : "";
|
|
353
|
+
const divisor = unit === "M" ? 1_000_000 : unit === "K" ? 1_000 : 1;
|
|
354
|
+
if (!unit) return String(Math.round(value));
|
|
355
|
+
return `${(value / divisor).toFixed(1).replace(/\.0$/, "")}${unit}`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Context size of the child's own session, when it has one to read. */
|
|
359
|
+
function contextTokensFor(sessionFile: string): number | undefined {
|
|
360
|
+
if (!sessionFile) return undefined;
|
|
361
|
+
const live = progressFromSessionFile(sessionFile);
|
|
362
|
+
return live.contextTokens;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Memory line for one child, following D2: only an `rpc` child owns a process,
|
|
367
|
+
* so only it gets a per-child RSS. An `inprocess` child runs INSIDE this host
|
|
368
|
+
* process — apportioning the host RSS across children would be invented data,
|
|
369
|
+
* so it reports the shared host figure and says so.
|
|
370
|
+
*
|
|
371
|
+
* A memory figure describes a process that is running NOW, so a child that the
|
|
372
|
+
* liveness check reports gone gets no line at all (§6). Its pid may already
|
|
373
|
+
* belong to an unrelated process: reading `/proc/<pid>` then would print a
|
|
374
|
+
* stranger's RSS under this child's name — fabricated attribution that looks
|
|
375
|
+
* real, the exact thing D2 forbids. v1 keeps no last-known cache, so there is
|
|
376
|
+
* nothing honest left to show.
|
|
377
|
+
*
|
|
378
|
+
* `undefined` when there is nothing honest to print (child not alive, no pid,
|
|
379
|
+
* non-Linux host, pid already gone).
|
|
380
|
+
*/
|
|
381
|
+
function memoryLine(input: LiveChildStatusInput): string | undefined {
|
|
382
|
+
// §6 pid-reuse race: never probe a pid whose process is already gone.
|
|
383
|
+
if (!input.alive) return undefined;
|
|
384
|
+
if (input.spawnMode === "inprocess") {
|
|
385
|
+
const host = readProcRss(process.pid);
|
|
386
|
+
if (!host) return undefined;
|
|
387
|
+
return `memory: host rss=${host.rssMb}m (shared by inprocess children)`;
|
|
388
|
+
}
|
|
389
|
+
if (input.pid === undefined) return undefined;
|
|
390
|
+
const own = readProcRss(input.pid);
|
|
391
|
+
if (!own) return undefined;
|
|
392
|
+
return `memory: rss=${own.rssMb}m${own.peakMb !== undefined ? ` peak=${own.peakMb}m` : ""}`;
|
|
393
|
+
}
|
|
394
|
+
|
|
316
395
|
/**
|
|
317
396
|
* Status text for one child (`/legion status <name>` and `subagent status`).
|
|
318
397
|
*
|
|
@@ -365,6 +444,12 @@ export function renderLiveChildStatus(input: LiveChildStatusInput): string {
|
|
|
365
444
|
`${acceptanceLabel}: ${input.acceptance.status}${input.acceptance.level ? ` (${input.acceptance.level})` : ""}`,
|
|
366
445
|
);
|
|
367
446
|
}
|
|
447
|
+
// Live resource facts. Appended as their own lines: every line above keeps
|
|
448
|
+
// its text and position, so existing readers of this block stay valid.
|
|
449
|
+
const contextTokens = input.contextTokens ?? contextTokensFor(input.sessionFile);
|
|
450
|
+
if (contextTokens !== undefined) lines.push(`context: ctx=${formatContextTokens(contextTokens)} tok`);
|
|
451
|
+
const memory = memoryLine(input);
|
|
452
|
+
if (memory) lines.push(memory);
|
|
368
453
|
lines.push(...meta);
|
|
369
454
|
if (stale) {
|
|
370
455
|
const persistedExecution = input.execution
|
|
@@ -616,6 +701,35 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
|
|
|
616
701
|
});
|
|
617
702
|
join.setConfig(joinConfig);
|
|
618
703
|
|
|
704
|
+
/**
|
|
705
|
+
* D3/D8: the orphan horizon, matching `legion-tools.ts`'s DEFAULT_ORPHAN_STALE_MS.
|
|
706
|
+
*
|
|
707
|
+
* `runtime.ts` has no settings dependency (the TUI layer must not read
|
|
708
|
+
* settings per repaint), so this mirrors the documented default. A ledger row
|
|
709
|
+
* whose heartbeat is older than this cannot describe a live child.
|
|
710
|
+
*/
|
|
711
|
+
const orphanStaleMs = (): number => DEFAULT_ORPHAN_STALE_MS;
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* D3/D8: the in-process facts for a node, keyed by tree id.
|
|
715
|
+
*
|
|
716
|
+
* A tracked job is both the strongest liveness evidence (rules 1/2: this very
|
|
717
|
+
* process holds the handle) and the scheduler record. Resolved once per node
|
|
718
|
+
* rather than by scanning `jobs` twice.
|
|
719
|
+
*/
|
|
720
|
+
const inProcessFactsFor = (
|
|
721
|
+
nodeId: string,
|
|
722
|
+
): { inMemory?: { state: string }; record?: { state: string } } => {
|
|
723
|
+
for (const job of jobs.values()) {
|
|
724
|
+
if (job.state === undefined) continue;
|
|
725
|
+
const matches =
|
|
726
|
+
job.nodeId === nodeId ||
|
|
727
|
+
(job.nodeId === undefined && deps.getTreeIdentity?.(job.name)?.nodeId === nodeId);
|
|
728
|
+
if (matches) return { inMemory: { state: job.state }, record: { state: job.state } };
|
|
729
|
+
}
|
|
730
|
+
return {};
|
|
731
|
+
};
|
|
732
|
+
|
|
619
733
|
const snapshotNodes = (): TreeNodeSnapshot[] => {
|
|
620
734
|
const ledger = ledgerDb;
|
|
621
735
|
const provided = deps.getTreeNodes
|
|
@@ -624,18 +738,72 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
|
|
|
624
738
|
if (provided !== undefined) {
|
|
625
739
|
tree.clear();
|
|
626
740
|
for (const node of provided) {
|
|
741
|
+
// D3: no timestamp race. `override.ts` comes from THIS process's event
|
|
742
|
+
// bus while `node.updatedAt` comes from the CHILD's heartbeat, and a
|
|
743
|
+
// dead child stops heartbeating (`legion-tools.ts` only heartbeats
|
|
744
|
+
// `isAlive` children), so its `updatedAt` freezes. Comparing the two
|
|
745
|
+
// therefore ranked two different clocks, and when the override looked
|
|
746
|
+
// older the code DELETED it and trusted the frozen row — which pulled
|
|
747
|
+
// finished nodes back to `running`. That was the whole reported bug.
|
|
748
|
+
//
|
|
749
|
+
// D8: render is a projection of facts, not a replay of events. The
|
|
750
|
+
// lifecycle verdict now comes from `liveness()`; the override only
|
|
751
|
+
// contributes fields that are NOT a lifecycle claim (`phase`), plus an
|
|
752
|
+
// optimistic status that must still agree with `liveness()`.
|
|
627
753
|
const override = eventOverrides.get(node.id);
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
754
|
+
const verdict = liveness({
|
|
755
|
+
ledger: { status: node.status, updatedAt: node.updatedAt },
|
|
756
|
+
...inProcessFactsFor(node.id),
|
|
757
|
+
now: now(),
|
|
758
|
+
staleMs: orphanStaleMs(),
|
|
759
|
+
});
|
|
760
|
+
// What the verdict decides, and what it does NOT decide:
|
|
761
|
+
//
|
|
762
|
+
// - `dead` wins over a non-terminal row. A row that stopped
|
|
763
|
+
// heartbeating cannot describe a live child, so it is rendered as
|
|
764
|
+
// failed rather than left looking alive.
|
|
765
|
+
// - `unknown` never promotes a row to `running` and never claims death.
|
|
766
|
+
// - `live` says the child IS alive; it does not say WHICH alive state.
|
|
767
|
+
// The ledger row only advances on heartbeat (coarse), while an event
|
|
768
|
+
// says exactly what happened (`node_launched` → running). So a live
|
|
769
|
+
// verdict keeps the MORE SPECIFIC of the two: an override status from
|
|
770
|
+
// this process's own bus wins over the row, because it is the same
|
|
771
|
+
// lifecycle seen at a finer grain. Only a terminal row outranks it —
|
|
772
|
+
// a settled/failed row is a fact that no later "running" may erase.
|
|
773
|
+
//
|
|
774
|
+
// Two override cases outrank the row, and they cover both directions
|
|
775
|
+
// the coarse heartbeat cannot express:
|
|
776
|
+
// - the override is TERMINAL (`node_settled`) while the row has not
|
|
777
|
+
// caught up: the event ends the child now, not on the next heartbeat;
|
|
778
|
+
// - both are non-terminal and the event is more specific
|
|
779
|
+
// (`node_launched` → running vs a stale `starting` row).
|
|
780
|
+
// A terminal ROW always wins over a non-terminal override: once the
|
|
781
|
+
// ledger says settled/failed, no later "running" may erase that.
|
|
782
|
+
const overrideStatus = override?.status;
|
|
783
|
+
const rowTerminal = isTerminalState(node.status);
|
|
784
|
+
const overrideTerminal = overrideStatus !== undefined && isTerminalState(overrideStatus);
|
|
785
|
+
const status =
|
|
786
|
+
verdict === "dead" && !rowTerminal
|
|
787
|
+
? "failed"
|
|
788
|
+
: overrideStatus !== undefined && !rowTerminal && (overrideTerminal || verdict === "live")
|
|
789
|
+
? overrideStatus
|
|
790
|
+
: node.status;
|
|
791
|
+
tree.set(node.id, {
|
|
792
|
+
...node,
|
|
793
|
+
status,
|
|
794
|
+
...(override?.phase === undefined ? {} : { phase: override.phase }),
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
// Only overrides whose optimistic STATUS has now been superseded are
|
|
798
|
+
// dropped: the tree row above is the authoritative projection, so keeping
|
|
799
|
+
// a lifecycle claim around would resurrect it on the next event. `phase`
|
|
800
|
+
// is not a lifecycle claim and must survive — it carries no
|
|
801
|
+
// live/dead meaning and has no other source between events.
|
|
802
|
+
for (const [nodeId, override] of eventOverrides) {
|
|
803
|
+
if (!tree.has(nodeId)) continue;
|
|
804
|
+
if (override.status === undefined) continue;
|
|
805
|
+
const current = tree.get(nodeId);
|
|
806
|
+
if (current !== undefined && current.status !== override.status) eventOverrides.delete(nodeId);
|
|
639
807
|
}
|
|
640
808
|
for (const nodeId of activity.keys()) if (!tree.has(nodeId)) activity.delete(nodeId);
|
|
641
809
|
for (const nodeId of eventOverrides.keys()) if (!tree.has(nodeId)) eventOverrides.delete(nodeId);
|
|
@@ -760,16 +928,29 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
|
|
|
760
928
|
const data = event.data && typeof event.data === "object" && !Array.isArray(event.data)
|
|
761
929
|
? event.data as Record<string, unknown>
|
|
762
930
|
: undefined;
|
|
763
|
-
if (node
|
|
931
|
+
if (node) {
|
|
932
|
+
// D3: no cross-writer timestamp race.
|
|
933
|
+
//
|
|
934
|
+
// `event.ts` is stamped by THIS process's bus; `node.updatedAt` is stamped
|
|
935
|
+
// by the CHILD's heartbeat. A dead child stops heartbeating, so its
|
|
936
|
+
// `updatedAt` freezes and a later real event would fail `event.ts >=
|
|
937
|
+
// updatedAt` — silently dropped, leaving the panel stuck. Only the
|
|
938
|
+
// SAME-writer ordering is still meaningful: an event must not undo a
|
|
939
|
+
// NEWER event from the same bus (that is `previous.ts`, below).
|
|
940
|
+
//
|
|
941
|
+
// D8: this is an optimistic update only. `snapshotNodes()` recomputes every
|
|
942
|
+
// row from facts via `liveness()`, so a stale optimistic status cannot
|
|
943
|
+
// survive a refresh — which is what makes dropping the clock guard safe.
|
|
764
944
|
const previous = eventOverrides.get(event.nodeId);
|
|
765
945
|
if (!previous || event.ts >= previous.ts) {
|
|
766
946
|
const override = { ...previous, ts: event.ts };
|
|
767
|
-
const updated = { ...node
|
|
947
|
+
const updated = { ...node };
|
|
768
948
|
if (event.type === "node_launched" || event.type === "node_resumed") updated.status = override.status = "running";
|
|
769
949
|
else if (event.type === "node_settled") updated.status = override.status = "settled";
|
|
770
950
|
else if (event.type === "node_failed") updated.status = override.status = "failed";
|
|
771
951
|
else if (event.type === "node_retired" && node.status !== "settled" && node.status !== "failed") updated.status = override.status = "retired";
|
|
772
952
|
else if (event.type === "node_blocked") updated.status = override.status = "blocked";
|
|
953
|
+
else if (event.type === "node_abandoned") updated.status = override.status = "failed";
|
|
773
954
|
else if (event.type === "phase_change" && typeof data?.to === "string") updated.phase = override.phase = data.to;
|
|
774
955
|
eventOverrides.set(event.nodeId, override);
|
|
775
956
|
tree.set(event.nodeId, updated);
|
|
@@ -795,10 +976,156 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
|
|
|
795
976
|
}
|
|
796
977
|
};
|
|
797
978
|
|
|
979
|
+
/**
|
|
980
|
+
* True between the first event of a burst and its trailing flush. It is both
|
|
981
|
+
* the "a flush is already scheduled" guard and the "a burst is open" marker
|
|
982
|
+
* that tells `onWidgetEvent` whether this event needs the leading snapshot.
|
|
983
|
+
*/
|
|
984
|
+
let burstPending = false;
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* #238: a burst of bus events in one tick costs ONE tree snapshot + paint
|
|
988
|
+
* instead of 2×N. `snapshotNodes()` is a synchronous recursive-CTE subtree
|
|
989
|
+
* scan plus a usage batch query, and `refreshUi()` → `statusEntries()` ran it
|
|
990
|
+
* a second time per event.
|
|
991
|
+
*
|
|
992
|
+
* What stays synchronous, per event, in arrival order (D2): `applyTreeEvent`
|
|
993
|
+
* is an incremental state machine, not a render. `event.ts >= node.updatedAt`
|
|
994
|
+
* lets a later event veto an earlier one, `turn_start` increments the turn and
|
|
995
|
+
* clears the frozen endpoint, and `agent_settled` freezes it — so batching or
|
|
996
|
+
* reordering these would change the state they compute. They run in the bus
|
|
997
|
+
* callback exactly as before.
|
|
998
|
+
*
|
|
999
|
+
* What is coalesced: the trailing snapshot + paint only. The *leading*
|
|
1000
|
+
* snapshot is deliberately NOT coalesced: `applyTreeEvent` reads `tree`, which
|
|
1001
|
+
* only `snapshotNodes()` populates, so the first event of a burst must see a
|
|
1002
|
+
* current tree or it would project this tick's status/phase delta against
|
|
1003
|
+
* whatever the previous tick left behind (a node launched since then is simply
|
|
1004
|
+
* absent, and its event is dropped). A burst therefore costs a leading snapshot
|
|
1005
|
+
* plus one flush — a constant 2, down from 2×N.
|
|
1006
|
+
*
|
|
1007
|
+
* Semantics of the mid-burst window — a DOCUMENTED LIMITATION, not a claim of
|
|
1008
|
+
* equivalence. Events 2..N are vetted against the tree as of burst start
|
|
1009
|
+
* instead of being re-read per event (what `master` does).
|
|
1010
|
+
*
|
|
1011
|
+
* The divergence class is: ANY mid-burst write that changes a row in this
|
|
1012
|
+
* subtree. Not just a write to the event's own node — `snapshotNodes()` clears
|
|
1013
|
+
* and rebuilds the whole `tree` while `eventOverrides` survives, so a sibling
|
|
1014
|
+
* process committing any row here can change whether a later event's
|
|
1015
|
+
* `event.ts >= node.updatedAt` guard passes. Reachable in production because
|
|
1016
|
+
* `getTreeNodes()` re-reads SQLite on every call, so a concurrent writer's
|
|
1017
|
+
* commit IS visible to a per-event read but not to a burst; sibling bus
|
|
1018
|
+
* subscribers also read `treeNodes()` inside the burst
|
|
1019
|
+
* (`legion-commands.ts`).
|
|
1020
|
+
*
|
|
1021
|
+
* Sampled rather than exhaustively enumerated: a differential harness over
|
|
1022
|
+
* burst/row combinations (row status x row `updatedAt` x mid-burst bump x event
|
|
1023
|
+
* pair) showed the coalesced paint matching a per-event re-read in every case
|
|
1024
|
+
* with no mid-burst write, and diverging in both directions once there is one —
|
|
1025
|
+
* the coalesced path can miss a veto `master` applied, and can also apply one
|
|
1026
|
+
* `master` correctly skipped. So neither "re-read at the flush" nor "re-read at
|
|
1027
|
+
* burst start" fixes it: reproducing a SEQUENCE of per-event reads is not
|
|
1028
|
+
* possible with a constant number of reads, which is why this is bounded and
|
|
1029
|
+
* documented instead of repaired. `test/unit/runtime.test.ts` pins one concrete
|
|
1030
|
+
* instance (the "KNOWN LIMITATION" test) so the behaviour is asserted rather
|
|
1031
|
+
* than described.
|
|
1032
|
+
*
|
|
1033
|
+
* It IS sticky, and this is not fixed: the override wins on `ts`, exactly as
|
|
1034
|
+
* `master`'s own override does, so the next `snapshotNodes()` does NOT re-vet it
|
|
1035
|
+
* away. It lasts until the row moves again. Fixing it needs per-event re-reads,
|
|
1036
|
+
* i.e. reverting the optimisation.
|
|
1037
|
+
*
|
|
1038
|
+
* One sub-case IS closed, at no cost to the common burst: an event for a node
|
|
1039
|
+
* that is absent from `tree` but belongs to THIS subtree (its row only landed
|
|
1040
|
+
* mid-burst) takes a fresh snapshot before applying, so `applyTreeEvent`'s
|
|
1041
|
+
* `tree.get()` miss cannot silently drop it. Foreign-subtree node ids — the
|
|
1042
|
+
* normal shape of a shared-ledger poll batch — cost zero extra reads.
|
|
1043
|
+
*
|
|
1044
|
+
* Microtask, not macrotask: the burst this targets is one synchronous emit
|
|
1045
|
+
* loop (`legion-tools.ts` emits N `mail_sent` events back-to-back; the db event
|
|
1046
|
+
* source drains a poll batch the same way), and a microtask drains the whole
|
|
1047
|
+
* burst with no timer seam, no extra flush API, and no added latency before the
|
|
1048
|
+
* TUI's next frame. A macrotask would need an explicit `flush()` seam or a
|
|
1049
|
+
* timer and would delay the paint by a tick for no coalescing benefit.
|
|
1050
|
+
*/
|
|
1051
|
+
/**
|
|
1052
|
+
* The subtree this runtime renders, derived from the FIRST populated snapshot.
|
|
1053
|
+
* `snapshotNodes()` calls `deps.getTreeNodes()`, which the extension supplies
|
|
1054
|
+
* as `getSubtree(db, identity.nodeId)` — so the shallowest node id in that
|
|
1055
|
+
* result IS this session's subtree root, and every node it can ever show is
|
|
1056
|
+
* that id or a strict descendant of it (`parent.child`).
|
|
1057
|
+
*
|
|
1058
|
+
* This is what bounds the unknown-node snapshot below (#238 review): the
|
|
1059
|
+
* DbEventSource emits EVERY row on the shared legion.db, so a sibling
|
|
1060
|
+
* subtree's batch routinely lands on this bus with `nodeId`s this session can
|
|
1061
|
+
* never render. Paying a snapshot per such event would be backwards — a
|
|
1062
|
+
* 40-event foreign batch cost 41 reads, worse than master's 80/2 ratio implied.
|
|
1063
|
+
* `undefined` means "not yet known" (empty tree / a host with no tree), in
|
|
1064
|
+
* which case an unknown node is treated as possibly-ours and gets the read.
|
|
1065
|
+
*/
|
|
1066
|
+
const subtreeRootId = (): string | undefined => {
|
|
1067
|
+
let best: string | undefined;
|
|
1068
|
+
for (const id of tree.keys()) {
|
|
1069
|
+
if (best === undefined || id.length < best.length) best = id;
|
|
1070
|
+
}
|
|
1071
|
+
return best;
|
|
1072
|
+
};
|
|
1073
|
+
|
|
1074
|
+
/** True when `nodeId` is the subtree root itself or a strict descendant of it. */
|
|
1075
|
+
const isInOwnSubtree = (nodeId: string): boolean => {
|
|
1076
|
+
const rootId = subtreeRootId();
|
|
1077
|
+
if (rootId === undefined) return true;
|
|
1078
|
+
return nodeId === rootId || nodeId.startsWith(`${rootId}.`);
|
|
1079
|
+
};
|
|
1080
|
+
|
|
1081
|
+
const scheduleTreeFlush = (): void => {
|
|
1082
|
+
if (burstPending) return;
|
|
1083
|
+
burstPending = true;
|
|
1084
|
+
queueMicrotask(() => {
|
|
1085
|
+
burstPending = false;
|
|
1086
|
+
// The old inline path ran `refreshUi()` INSIDE the bus handler, so a throw
|
|
1087
|
+
// from `getTreeNodes()`/`board.paint()` was contained by
|
|
1088
|
+
// `createLegionEventBus`'s per-subscriber try/catch. A microtask has no
|
|
1089
|
+
// such wrapper, so an uncaught throw here would kill the host process. The
|
|
1090
|
+
// bus's job is to isolate a subscriber's failure; report through the same
|
|
1091
|
+
// channel a throwing handler uses instead of escaping onto the microtask
|
|
1092
|
+
// queue. No `disposed` guard: `refreshUi()` already returns early when
|
|
1093
|
+
// disposed, and this callback calls no other paint-path code.
|
|
1094
|
+
try {
|
|
1095
|
+
refreshUi();
|
|
1096
|
+
} catch (error) {
|
|
1097
|
+
// Reporting must never itself break delivery, and must never re-enter
|
|
1098
|
+
// the microtask queue as an uncaught throw: `reportError` is optional
|
|
1099
|
+
// precisely so structural bus stand-ins keep working, and such a stand-in
|
|
1100
|
+
// would otherwise reintroduce the host-killing crash this guard exists
|
|
1101
|
+
// to prevent. Swallowing is the lesser evil — a lost repaint report
|
|
1102
|
+
// never costs the process.
|
|
1103
|
+
try {
|
|
1104
|
+
deps.eventBus?.reportError?.(error, "deferred-handler");
|
|
1105
|
+
} catch {
|
|
1106
|
+
// Isolated reporting failure: the board simply skips this frame.
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
});
|
|
1110
|
+
};
|
|
1111
|
+
|
|
798
1112
|
const onWidgetEvent = (event: LegionEvent): void => {
|
|
799
|
-
|
|
1113
|
+
// Leading snapshot, once per burst (see `scheduleTreeFlush`).
|
|
1114
|
+
if (!burstPending) snapshotNodes();
|
|
1115
|
+
// A node whose row only landed mid-burst is absent from `tree`, and
|
|
1116
|
+
// `applyTreeEvent` returns early on a `tree.get()` miss, so the event is
|
|
1117
|
+
// dropped and the flush cannot repair it: a just-launched child's ledger row
|
|
1118
|
+
// still says whatever the pre-launch write left (`starting` or nothing), so
|
|
1119
|
+
// re-reading cannot produce the `running` transition that a STORED
|
|
1120
|
+
// `node_launched` event carries. Pay one snapshot for exactly this case.
|
|
1121
|
+
//
|
|
1122
|
+
// Gated on subtree ownership: foreign-subtree events are the normal shape of
|
|
1123
|
+
// a shared-ledger poll batch and can never create a node here, so they cost
|
|
1124
|
+
// ZERO extra reads. Without the gate a 40-event foreign batch cost 41 reads;
|
|
1125
|
+
// with it, 2 (leading + flush).
|
|
1126
|
+
else if (!tree.has(event.nodeId) && isInOwnSubtree(event.nodeId)) snapshotNodes();
|
|
800
1127
|
applyTreeEvent(event);
|
|
801
|
-
|
|
1128
|
+
scheduleTreeFlush();
|
|
802
1129
|
};
|
|
803
1130
|
const busUnsubscribers = deps.eventBus
|
|
804
1131
|
? LEGION_EVENT_TYPES
|
|
@@ -810,7 +1137,15 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
|
|
|
810
1137
|
try {
|
|
811
1138
|
ledgerDb = openLegionDb(process.env.PI_LEGION_DB);
|
|
812
1139
|
snapshotNodes();
|
|
1140
|
+
// Seed at the tail for the same reason as the extension's attach path
|
|
1141
|
+
// (`attachLegionRuntime` in index.ts, #238): this branch attaches to a ledger
|
|
1142
|
+
// written by another process, and replaying its history only to reach a state
|
|
1143
|
+
// `snapshotNodes()` already read is pure overhead. It matters for embedders
|
|
1144
|
+
// using the public `createSessionRuntime` with PI_LEGION_DB and no
|
|
1145
|
+
// `getTreeNodes` — unreachable from the extension itself, which always passes
|
|
1146
|
+
// `getTreeNodes` — so keep the two call sites consistent.
|
|
813
1147
|
dbEventSource = createDbEventSource(ledgerDb, {
|
|
1148
|
+
cursor: maxEventId(ledgerDb),
|
|
814
1149
|
onError: deps.onDbEventSourceError,
|
|
815
1150
|
});
|
|
816
1151
|
if (deps.eventBus) dbEventSource.start(deps.eventBus);
|
|
@@ -1452,6 +1787,28 @@ function latestActivityAt(...candidates: Array<number | undefined>): number | un
|
|
|
1452
1787
|
return best;
|
|
1453
1788
|
}
|
|
1454
1789
|
|
|
1790
|
+
/**
|
|
1791
|
+
* Resident memory of a tracked job in MB, or `undefined` when there is no
|
|
1792
|
+
* honest number to report (D2).
|
|
1793
|
+
*
|
|
1794
|
+
* Only an `rpc` child owns a process. An `inprocess` child runs inside this
|
|
1795
|
+
* host, so it has no RSS of its own: apportioning the host figure across
|
|
1796
|
+
* children would invent data the user then decides on. Those rows stay blank
|
|
1797
|
+
* and the host figure is reported once, by name, elsewhere.
|
|
1798
|
+
*
|
|
1799
|
+
* §6 pid-reuse race: a pid outlives nothing — once the child exits, the kernel
|
|
1800
|
+
* may hand the same number to an unrelated process, and its RSS would land on
|
|
1801
|
+
* this row as the child's memory. So the pid is only read while the caller's
|
|
1802
|
+
* liveness check says the child is still up; a job with no `isAlive` hook
|
|
1803
|
+
* (legacy / re-followed record) has unverifiable liveness and stays blank too.
|
|
1804
|
+
*/
|
|
1805
|
+
function rssFor(job: TrackedJob): number | undefined {
|
|
1806
|
+
if (job.spawnMode === "inprocess" || job.pid === undefined) return undefined;
|
|
1807
|
+
// `isAlive` undefined is not permission to read: unknown is not alive.
|
|
1808
|
+
if (!job.isAlive || !job.isAlive()) return undefined;
|
|
1809
|
+
return readProcRss(job.pid)?.rssMb;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1455
1812
|
function statusEntryFromJob(
|
|
1456
1813
|
job: TrackedJob,
|
|
1457
1814
|
identity?: LegionTreeIdentity,
|
|
@@ -1469,6 +1826,7 @@ function statusEntryFromJob(
|
|
|
1469
1826
|
: executionStatus === "failed" || executionStatus === "truncated" || executionStatus === undefined ? "failed"
|
|
1470
1827
|
: "retired";
|
|
1471
1828
|
const finishedAt = terminalNode ? node.updatedAt : job.settledAt;
|
|
1829
|
+
const rss = rssFor(job);
|
|
1472
1830
|
const lastActivityAt = latestActivityAt(
|
|
1473
1831
|
node?.updatedAt,
|
|
1474
1832
|
live.currentTurnStartedAt,
|
|
@@ -1501,6 +1859,10 @@ function statusEntryFromJob(
|
|
|
1501
1859
|
? { tokensOut: live.tokensOut }
|
|
1502
1860
|
: node?.tokensOut ? { tokensOut: node.tokensOut } : {}),
|
|
1503
1861
|
...(live.generationMs ? { generationMs: live.generationMs } : {}),
|
|
1862
|
+
...(live.contextTokens ? { contextTokens: live.contextTokens } : {}),
|
|
1863
|
+
...(job.pid ? { pid: job.pid } : {}),
|
|
1864
|
+
...(job.spawnMode ? { spawnMode: job.spawnMode } : {}),
|
|
1865
|
+
...(rss !== undefined ? { rss } : {}),
|
|
1504
1866
|
...(lastActivityAt !== undefined ? { lastActivityAt } : {}),
|
|
1505
1867
|
...(identity ? {
|
|
1506
1868
|
nodeId: identity.nodeId,
|
|
@@ -1537,9 +1899,11 @@ function statusEntryFromNode(
|
|
|
1537
1899
|
...(node.phase ? { phase: node.phase } : {}),
|
|
1538
1900
|
...(node.kind ? { kind: node.kind } : {}),
|
|
1539
1901
|
...(node.model ? { model: node.model } : {}),
|
|
1902
|
+
...(node.spawnMode ? { spawnMode: node.spawnMode } : {}),
|
|
1540
1903
|
...(live.usageTotal ? { tokensTotal: live.usageTotal } : node.tokensTotal ? { tokensTotal: node.tokensTotal } : {}),
|
|
1541
1904
|
...(tokensOut ? { tokensOut } : {}),
|
|
1542
1905
|
...(live.generationMs ? { generationMs: live.generationMs } : {}),
|
|
1906
|
+
...(live.contextTokens ? { contextTokens: live.contextTokens } : {}),
|
|
1543
1907
|
...(activity?.turn !== undefined ? { turns: activity.turn } : {}),
|
|
1544
1908
|
...(activity?.turnStartedAt !== undefined ? { currentTurnStartedAt: activity.turnStartedAt } : {}),
|
|
1545
1909
|
...(activity?.turnEndedAt !== undefined ? { currentTurnEndedAt: activity.turnEndedAt } : {}),
|
package/src/extension/slash.ts
CHANGED
|
@@ -14,7 +14,6 @@ import type {
|
|
|
14
14
|
} from "../shared/types.ts";
|
|
15
15
|
import { classifyModelOrigin, resolveStepModel } from "../agents/step-model.ts";
|
|
16
16
|
import { checkModelScope } from "../agents/model-scope.ts";
|
|
17
|
-
import { nativeModelFor } from "../runs/kind.ts";
|
|
18
17
|
import {
|
|
19
18
|
DEFAULT_TEAM,
|
|
20
19
|
TEAM_ENV,
|
|
@@ -199,7 +198,6 @@ function renderOneAgent(
|
|
|
199
198
|
lines.push(` ${agent.description}`);
|
|
200
199
|
|
|
201
200
|
const details: string[] = [];
|
|
202
|
-
if (agent.kind !== "pi") details.push(`kind=${agent.kind}`);
|
|
203
201
|
if (agent.alias?.length) details.push(`alias: ${agent.alias.join(", ")}`);
|
|
204
202
|
|
|
205
203
|
// Model resolution mirrors the launch path (resolveStepModel) so the line
|
|
@@ -247,9 +245,6 @@ function renderOneAgent(
|
|
|
247
245
|
* params are empty).
|
|
248
246
|
* - `checkModelScope` — an out-of-scope model is flagged exactly like
|
|
249
247
|
* `launchStep` would flag it (error-severity for explicit models).
|
|
250
|
-
* - `nativeModelFor` — a model the target kind cannot express is either a
|
|
251
|
-
* launch refusal (explicit origin, mirroring `planModelCandidates`) or a
|
|
252
|
-
* documented drop to the CLI's own default (inherited origin).
|
|
253
248
|
*/
|
|
254
249
|
function modelLine(
|
|
255
250
|
agent: AgentConfig,
|
|
@@ -275,22 +270,6 @@ function modelLine(
|
|
|
275
270
|
);
|
|
276
271
|
}
|
|
277
272
|
|
|
278
|
-
// Kind/model coherence: a model the target CLI cannot express.
|
|
279
|
-
if (agent.kind !== "pi") {
|
|
280
|
-
const native = nativeModelFor(agent.kind, model, agent.thinking);
|
|
281
|
-
if (native === undefined) {
|
|
282
|
-
if (origin === "explicit") {
|
|
283
|
-
parts.push(
|
|
284
|
-
`✗ cannot be used with kind '${agent.kind}' — a launch would refuse this`,
|
|
285
|
-
);
|
|
286
|
-
} else {
|
|
287
|
-
parts.push(
|
|
288
|
-
`dropped at launch: kind '${agent.kind}' runs on its own default`,
|
|
289
|
-
);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
273
|
return parts.join(" · ");
|
|
295
274
|
}
|
|
296
275
|
|
package/src/extension/summary.ts
CHANGED
|
@@ -617,7 +617,7 @@ export function formatSubagentDetail(input: SummaryDetailInput): string {
|
|
|
617
617
|
if (child.thinking !== undefined) stateBits.push(`thinking: ${child.thinking}`);
|
|
618
618
|
const lines = [title, ` ${stateBits.join(" · ")}`];
|
|
619
619
|
|
|
620
|
-
const runBits = [`run: ${run.runId}`, `supervisor: ${run.herdr.supervisor ?? "
|
|
620
|
+
const runBits = [`run: ${run.runId}`, `supervisor: ${run.herdr.supervisor ?? "unknown"}`];
|
|
621
621
|
lines.push(` ${runBits.join(" · ")}`);
|
|
622
622
|
|
|
623
623
|
const startedAtMs = parseTimestamp(child.spawnedAt);
|