pi-legion 3.0.3 → 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 +263 -2
- package/CONTRIBUTING.md +4 -17
- package/README.md +29 -22
- 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/spawn-mode-visibility-v1.md +206 -0
- package/docs/vexillatio-contract.md +3 -1
- package/index.ts +633 -84
- 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 +43 -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/notify.ts +6 -6
- package/src/extension/playbook.ts +6 -6
- package/src/extension/recycle.ts +6 -2
- package/src/extension/runtime.ts +422 -18
- package/src/extension/slash.ts +0 -21
- package/src/extension/spawn-render.ts +183 -0
- 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/live-overlay.ts +14 -0
- package/src/tui/session-messages-file.ts +100 -0
- package/src/tui/status.ts +103 -21
- package/src/tui/tree-view.ts +36 -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/railway.ts +28 -25
- 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,8 +33,15 @@ 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";
|
|
37
|
-
import {
|
|
38
|
+
import { isTerminalState, liveness } from "./liveness.ts";
|
|
39
|
+
import {
|
|
40
|
+
DEFAULTS,
|
|
41
|
+
type AgentKind,
|
|
42
|
+
type RetireOptions,
|
|
43
|
+
type SpawnMode,
|
|
44
|
+
} from "../shared/types.ts";
|
|
38
45
|
import type {
|
|
39
46
|
EventSubscriptionOptions,
|
|
40
47
|
SupervisorEvent,
|
|
@@ -45,6 +52,7 @@ import { createDbEventSource, type DbEventSource } from "./event-bus.ts";
|
|
|
45
52
|
import { openLegionDb, type DatabaseSync } from "../legion/db.ts";
|
|
46
53
|
import { getSubtree } from "../legion/nodes.ts";
|
|
47
54
|
import { attachUsageTotals } from "../legion/usage.ts";
|
|
55
|
+
import { maxEventId } from "../legion/events.ts";
|
|
48
56
|
import type { TreeNodeSnapshot } from "../tui/tree-view.ts";
|
|
49
57
|
|
|
50
58
|
export interface CollectSnapshot {
|
|
@@ -76,6 +84,18 @@ export interface TrackedJobInput {
|
|
|
76
84
|
model?: string;
|
|
77
85
|
thinking?: string | false;
|
|
78
86
|
worktreeBranch?: string;
|
|
87
|
+
/**
|
|
88
|
+
* Arbitrated backend for this child (`Handle.spawnMode`, #209). Optional:
|
|
89
|
+
* a job tracked from a legacy record has no value and must render as
|
|
90
|
+
* unknown rather than a guessed `rpc`.
|
|
91
|
+
*/
|
|
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;
|
|
79
99
|
/** Legion tree identity supplied by the host when the M2 ChildRecord is available. */
|
|
80
100
|
nodeId?: string;
|
|
81
101
|
parentId?: string | null;
|
|
@@ -151,6 +171,16 @@ export interface SessionRuntimeDeps {
|
|
|
151
171
|
}
|
|
152
172
|
|
|
153
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;
|
|
154
184
|
/**
|
|
155
185
|
* Hard cap on how long a completion notice may stay hidden behind a live
|
|
156
186
|
* parent agent run (D-B). 80s = 2× the (1+3)×10s that the old join window
|
|
@@ -234,6 +264,20 @@ export interface LiveChildStatusInput {
|
|
|
234
264
|
* by the owner marker instead. Only consulted when `alive` is false.
|
|
235
265
|
*/
|
|
236
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;
|
|
237
281
|
now?: number;
|
|
238
282
|
}
|
|
239
283
|
|
|
@@ -302,6 +346,52 @@ function ownerLine(owner: "live" | "dead" | "unknown"): string {
|
|
|
302
346
|
return `owner: ${owner}`;
|
|
303
347
|
}
|
|
304
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
|
+
|
|
305
395
|
/**
|
|
306
396
|
* Status text for one child (`/legion status <name>` and `subagent status`).
|
|
307
397
|
*
|
|
@@ -354,6 +444,12 @@ export function renderLiveChildStatus(input: LiveChildStatusInput): string {
|
|
|
354
444
|
`${acceptanceLabel}: ${input.acceptance.status}${input.acceptance.level ? ` (${input.acceptance.level})` : ""}`,
|
|
355
445
|
);
|
|
356
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);
|
|
357
453
|
lines.push(...meta);
|
|
358
454
|
if (stale) {
|
|
359
455
|
const persistedExecution = input.execution
|
|
@@ -605,6 +701,35 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
|
|
|
605
701
|
});
|
|
606
702
|
join.setConfig(joinConfig);
|
|
607
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
|
+
|
|
608
733
|
const snapshotNodes = (): TreeNodeSnapshot[] => {
|
|
609
734
|
const ledger = ledgerDb;
|
|
610
735
|
const provided = deps.getTreeNodes
|
|
@@ -613,23 +738,100 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
|
|
|
613
738
|
if (provided !== undefined) {
|
|
614
739
|
tree.clear();
|
|
615
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()`.
|
|
616
753
|
const override = eventOverrides.get(node.id);
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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);
|
|
628
807
|
}
|
|
629
808
|
for (const nodeId of activity.keys()) if (!tree.has(nodeId)) activity.delete(nodeId);
|
|
630
809
|
for (const nodeId of eventOverrides.keys()) if (!tree.has(nodeId)) eventOverrides.delete(nodeId);
|
|
631
810
|
}
|
|
632
|
-
|
|
811
|
+
const nodes = [...tree.values()].map((node) => ({ ...node }));
|
|
812
|
+
return mergeSpawnModes(nodes);
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* #209/D5: the arbitrated backend lives on the tracked job, never in
|
|
817
|
+
* legion.db (no column, no migration), so it is merged onto the matching
|
|
818
|
+
* snapshot. Unmatched jobs contribute nothing rather than a guess.
|
|
819
|
+
*
|
|
820
|
+
* Shared by `snapshotNodes()` and `treeNodes()`: the latter synthesizes
|
|
821
|
+
* nodes of its own (a job with identity but no DB row yet) and returns that
|
|
822
|
+
* array directly, so a merge that only ran in `snapshotNodes()` would drop
|
|
823
|
+
* the mode in the pre-DB-row window and in a plain session where
|
|
824
|
+
* `getTreeNodes()` yields `[]`.
|
|
825
|
+
*/
|
|
826
|
+
const mergeSpawnModes = (
|
|
827
|
+
nodes: TreeNodeSnapshot[],
|
|
828
|
+
): TreeNodeSnapshot[] => {
|
|
829
|
+
for (const job of jobs.values()) {
|
|
830
|
+
if (job.spawnMode === undefined) continue;
|
|
831
|
+
const match = storedNodeForJob(job, nodes);
|
|
832
|
+
if (match) match.spawnMode = job.spawnMode;
|
|
833
|
+
}
|
|
834
|
+
return nodes;
|
|
633
835
|
};
|
|
634
836
|
|
|
635
837
|
const storedNodeForJob = (
|
|
@@ -726,16 +928,29 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
|
|
|
726
928
|
const data = event.data && typeof event.data === "object" && !Array.isArray(event.data)
|
|
727
929
|
? event.data as Record<string, unknown>
|
|
728
930
|
: undefined;
|
|
729
|
-
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.
|
|
730
944
|
const previous = eventOverrides.get(event.nodeId);
|
|
731
945
|
if (!previous || event.ts >= previous.ts) {
|
|
732
946
|
const override = { ...previous, ts: event.ts };
|
|
733
|
-
const updated = { ...node
|
|
947
|
+
const updated = { ...node };
|
|
734
948
|
if (event.type === "node_launched" || event.type === "node_resumed") updated.status = override.status = "running";
|
|
735
949
|
else if (event.type === "node_settled") updated.status = override.status = "settled";
|
|
736
950
|
else if (event.type === "node_failed") updated.status = override.status = "failed";
|
|
737
951
|
else if (event.type === "node_retired" && node.status !== "settled" && node.status !== "failed") updated.status = override.status = "retired";
|
|
738
952
|
else if (event.type === "node_blocked") updated.status = override.status = "blocked";
|
|
953
|
+
else if (event.type === "node_abandoned") updated.status = override.status = "failed";
|
|
739
954
|
else if (event.type === "phase_change" && typeof data?.to === "string") updated.phase = override.phase = data.to;
|
|
740
955
|
eventOverrides.set(event.nodeId, override);
|
|
741
956
|
tree.set(event.nodeId, updated);
|
|
@@ -761,10 +976,156 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
|
|
|
761
976
|
}
|
|
762
977
|
};
|
|
763
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
|
+
|
|
764
1112
|
const onWidgetEvent = (event: LegionEvent): void => {
|
|
765
|
-
|
|
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();
|
|
766
1127
|
applyTreeEvent(event);
|
|
767
|
-
|
|
1128
|
+
scheduleTreeFlush();
|
|
768
1129
|
};
|
|
769
1130
|
const busUnsubscribers = deps.eventBus
|
|
770
1131
|
? LEGION_EVENT_TYPES
|
|
@@ -776,7 +1137,15 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
|
|
|
776
1137
|
try {
|
|
777
1138
|
ledgerDb = openLegionDb(process.env.PI_LEGION_DB);
|
|
778
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.
|
|
779
1147
|
dbEventSource = createDbEventSource(ledgerDb, {
|
|
1148
|
+
cursor: maxEventId(ledgerDb),
|
|
780
1149
|
onError: deps.onDbEventSourceError,
|
|
781
1150
|
});
|
|
782
1151
|
if (deps.eventBus) dbEventSource.start(deps.eventBus);
|
|
@@ -969,6 +1338,9 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
|
|
|
969
1338
|
existing.model = input.model;
|
|
970
1339
|
existing.thinking = input.thinking;
|
|
971
1340
|
existing.worktreeBranch = input.worktreeBranch;
|
|
1341
|
+
// Only a known value overwrites: a re-follow without the handle must
|
|
1342
|
+
// not erase the mode an earlier launch established.
|
|
1343
|
+
if (input.spawnMode !== undefined) existing.spawnMode = input.spawnMode;
|
|
972
1344
|
if (input.nodeId !== undefined || identity.nodeId !== undefined) existing.nodeId = input.nodeId ?? identity.nodeId;
|
|
973
1345
|
if (input.parentId !== undefined || identity.parentId !== undefined) existing.parentId = input.parentId ?? identity.parentId;
|
|
974
1346
|
if (input.depth !== undefined || identity.depth !== undefined) existing.depth = input.depth ?? identity.depth;
|
|
@@ -1308,7 +1680,10 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
|
|
|
1308
1680
|
if (index >= 0) result[index] = next;
|
|
1309
1681
|
else result.push(next);
|
|
1310
1682
|
}
|
|
1311
|
-
|
|
1683
|
+
// The synthesized nodes above are built from job facts, not from a DB
|
|
1684
|
+
// row, so they carry no `spawnMode` of their own: run the same merge
|
|
1685
|
+
// the snapshot path uses before handing the array out (#209/D5).
|
|
1686
|
+
return mergeSpawnModes(result);
|
|
1312
1687
|
},
|
|
1313
1688
|
|
|
1314
1689
|
statusEntries() {
|
|
@@ -1412,6 +1787,28 @@ function latestActivityAt(...candidates: Array<number | undefined>): number | un
|
|
|
1412
1787
|
return best;
|
|
1413
1788
|
}
|
|
1414
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
|
+
|
|
1415
1812
|
function statusEntryFromJob(
|
|
1416
1813
|
job: TrackedJob,
|
|
1417
1814
|
identity?: LegionTreeIdentity,
|
|
@@ -1429,6 +1826,7 @@ function statusEntryFromJob(
|
|
|
1429
1826
|
: executionStatus === "failed" || executionStatus === "truncated" || executionStatus === undefined ? "failed"
|
|
1430
1827
|
: "retired";
|
|
1431
1828
|
const finishedAt = terminalNode ? node.updatedAt : job.settledAt;
|
|
1829
|
+
const rss = rssFor(job);
|
|
1432
1830
|
const lastActivityAt = latestActivityAt(
|
|
1433
1831
|
node?.updatedAt,
|
|
1434
1832
|
live.currentTurnStartedAt,
|
|
@@ -1461,6 +1859,10 @@ function statusEntryFromJob(
|
|
|
1461
1859
|
? { tokensOut: live.tokensOut }
|
|
1462
1860
|
: node?.tokensOut ? { tokensOut: node.tokensOut } : {}),
|
|
1463
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 } : {}),
|
|
1464
1866
|
...(lastActivityAt !== undefined ? { lastActivityAt } : {}),
|
|
1465
1867
|
...(identity ? {
|
|
1466
1868
|
nodeId: identity.nodeId,
|
|
@@ -1497,9 +1899,11 @@ function statusEntryFromNode(
|
|
|
1497
1899
|
...(node.phase ? { phase: node.phase } : {}),
|
|
1498
1900
|
...(node.kind ? { kind: node.kind } : {}),
|
|
1499
1901
|
...(node.model ? { model: node.model } : {}),
|
|
1902
|
+
...(node.spawnMode ? { spawnMode: node.spawnMode } : {}),
|
|
1500
1903
|
...(live.usageTotal ? { tokensTotal: live.usageTotal } : node.tokensTotal ? { tokensTotal: node.tokensTotal } : {}),
|
|
1501
1904
|
...(tokensOut ? { tokensOut } : {}),
|
|
1502
1905
|
...(live.generationMs ? { generationMs: live.generationMs } : {}),
|
|
1906
|
+
...(live.contextTokens ? { contextTokens: live.contextTokens } : {}),
|
|
1503
1907
|
...(activity?.turn !== undefined ? { turns: activity.turn } : {}),
|
|
1504
1908
|
...(activity?.turnStartedAt !== undefined ? { currentTurnStartedAt: activity.turnStartedAt } : {}),
|
|
1505
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
|
|