pi-legion 2.0.0 → 2.0.2
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 +136 -0
- package/CONTRIBUTING.md +51 -18
- package/README.md +101 -18
- package/agents/planner.md +1 -1
- package/agents/reviewer.md +2 -3
- package/agents/worker.md +2 -3
- package/docs/contracts/esc-abort-v1.md +2 -0
- package/docs/contracts/esc-cascade-v2.md +5 -1
- package/docs/contracts/esc-no-retire-v3.md +57 -0
- package/docs/design.md +37 -18
- package/docs/vexillatio-contract.md +1 -1
- package/index.ts +145 -134
- package/package.json +8 -4
- package/skills/pi-legion/SKILL.md +22 -8
- package/src/agents/agents.ts +60 -5
- package/src/api.ts +2 -0
- package/src/extension/legion-commands.ts +64 -10
- package/src/extension/legion-doctor.ts +128 -41
- package/src/extension/legion-status.ts +172 -0
- package/src/extension/legion-stop.ts +5 -37
- package/src/extension/playbook.ts +4 -4
- package/src/extension/runtime.ts +97 -22
- package/src/extension/scout-guard.ts +12 -3
- package/src/runs/args.ts +69 -13
- package/src/runs/orchestrator.ts +28 -3
- package/src/runs/provider-pin.ts +59 -0
- package/src/runs/provision.ts +7 -4
- package/src/runs/role-tool-gates.ts +103 -0
- package/src/runs/worktree.ts +32 -0
- package/src/shared/session.ts +37 -2
- package/src/shared/types.ts +8 -0
- package/src/tui/status.ts +12 -14
- package/src/tui/viz/canvas.ts +5 -0
- package/src/tui/viz/panels.ts +16 -8
- package/src/tui/viz/railway.ts +34 -11
- package/src/tui/viz/rate.ts +41 -9
- package/src/tui/viz/view.ts +18 -11
package/src/runs/orchestrator.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
import { canUseCachedCollect } from "../extension/recycle.ts";
|
|
32
32
|
import {
|
|
33
33
|
countAssistantMessages,
|
|
34
|
+
attributeParentSessionShutdown,
|
|
34
35
|
deriveOutcome,
|
|
35
36
|
emptyParsedSession,
|
|
36
37
|
extractVerdict,
|
|
@@ -51,6 +52,7 @@ import {
|
|
|
51
52
|
MAX_NESTED_PATH_ENTRIES,
|
|
52
53
|
type ModelOrigin,
|
|
53
54
|
type NestedPathEntry,
|
|
55
|
+
type RetireOptions,
|
|
54
56
|
type RunRecord,
|
|
55
57
|
SubagentError,
|
|
56
58
|
type Usage,
|
|
@@ -62,6 +64,7 @@ import { nativeModelFor } from "./kind.ts";
|
|
|
62
64
|
import { applyVerification, type VerifyRunner } from "./acceptance.ts";
|
|
63
65
|
import {
|
|
64
66
|
createChildWorktree,
|
|
67
|
+
isInsideChildWorktree,
|
|
65
68
|
removeChildWorktree,
|
|
66
69
|
resolveLaunchWorktree,
|
|
67
70
|
} from "./worktree.ts";
|
|
@@ -239,6 +242,8 @@ export class Orchestrator {
|
|
|
239
242
|
private readonly children = new Map<string, ChildRecord>();
|
|
240
243
|
private readonly nameCounter = new Map<string, number>();
|
|
241
244
|
private readonly pendingWaits = new Map<string, Promise<SettleResult>>();
|
|
245
|
+
/** Children retired because the parent session is shutting down. */
|
|
246
|
+
private readonly parentShutdownRetire = new Set<string>();
|
|
242
247
|
|
|
243
248
|
constructor(deps: OrchestratorDeps) {
|
|
244
249
|
this.supervisor = deps.supervisor ?? new RpcSupervisor();
|
|
@@ -491,6 +496,8 @@ export class Orchestrator {
|
|
|
491
496
|
name?: string;
|
|
492
497
|
team?: string;
|
|
493
498
|
model?: string;
|
|
499
|
+
/** Provider id pinning `--provider` for an ambiguous bare model id (#123). */
|
|
500
|
+
modelProvider?: string;
|
|
494
501
|
thinking?: string | false;
|
|
495
502
|
worktree?: boolean;
|
|
496
503
|
modelOrigin?: ModelOrigin;
|
|
@@ -529,7 +536,11 @@ export class Orchestrator {
|
|
|
529
536
|
? true
|
|
530
537
|
: this.legion && parentRole === "centurion"
|
|
531
538
|
? false
|
|
532
|
-
: resolveLaunchWorktree({
|
|
539
|
+
: resolveLaunchWorktree({
|
|
540
|
+
roleDefault: input.agent.worktree,
|
|
541
|
+
launch: input.worktree,
|
|
542
|
+
parentInWorktree: isInsideChildWorktree(this.cwd),
|
|
543
|
+
});
|
|
533
544
|
let worktreePath = previous?.worktreePath;
|
|
534
545
|
let worktreeBranch = previous?.worktreeBranch;
|
|
535
546
|
let createdWorktree = false;
|
|
@@ -598,6 +609,7 @@ export class Orchestrator {
|
|
|
598
609
|
cwd: worktreePath ?? this.cwd,
|
|
599
610
|
env: childEnv,
|
|
600
611
|
...(model ? { model } : {}),
|
|
612
|
+
...(input.modelProvider ? { modelProvider: input.modelProvider } : {}),
|
|
601
613
|
...(input.stripLegionIdentity ? { stripLegionIdentity: true } : {}),
|
|
602
614
|
...(input.thinking !== undefined ? { thinking: input.thinking } : input.agent.thinking !== undefined ? { thinking: input.agent.thinking } : {}),
|
|
603
615
|
...(input.agent.allowNestedSubagents !== undefined && !input.agent.utility ? { allowNestedSubagents: input.agent.allowNestedSubagents } : {}),
|
|
@@ -860,6 +872,11 @@ export class Orchestrator {
|
|
|
860
872
|
execution = { status: "aborted", reason: "RPC child exited before writing session JSONL", model: child.model ?? null };
|
|
861
873
|
}
|
|
862
874
|
}
|
|
875
|
+
// A parent shutdown can resolve the in-flight wait by killing the child.
|
|
876
|
+
// That is not the child settling a turn with no assistant reply.
|
|
877
|
+
if (this.parentShutdownRetire.has(name) && wait !== "settled") {
|
|
878
|
+
execution = attributeParentSessionShutdown(execution, parsed);
|
|
879
|
+
}
|
|
863
880
|
return this.finishCollect(child, parsed, execution, deadline);
|
|
864
881
|
}
|
|
865
882
|
|
|
@@ -1098,10 +1115,18 @@ export class Orchestrator {
|
|
|
1098
1115
|
return cached;
|
|
1099
1116
|
}
|
|
1100
1117
|
|
|
1101
|
-
async retire(name: string): Promise<ChildRecord> {
|
|
1118
|
+
async retire(name: string, opts?: RetireOptions): Promise<ChildRecord> {
|
|
1102
1119
|
const child = this.requireChild(name);
|
|
1120
|
+
if (opts?.cause === "session-shutdown") this.parentShutdownRetire.add(name);
|
|
1103
1121
|
if (child.state === "retired") return child;
|
|
1104
|
-
|
|
1122
|
+
const sessionExists = fs.existsSync(child.sessionFile);
|
|
1123
|
+
const parsed = sessionExists ? parseSessionFile(child.sessionFile) : emptyParsedSession();
|
|
1124
|
+
if (!child.execution && sessionExists) child.execution = deriveOutcome(parsed);
|
|
1125
|
+
// Stamp the cause before killing so an in-flight collect cannot
|
|
1126
|
+
// overwrite it with "no assistant message" after the process dies.
|
|
1127
|
+
if (opts?.cause === "session-shutdown" && this.supervisor.isAlive(name)) {
|
|
1128
|
+
child.execution = attributeParentSessionShutdown(child.execution ?? deriveOutcome(parsed), parsed);
|
|
1129
|
+
}
|
|
1105
1130
|
if (this.supervisor.isAlive(name)) await this.supervisor.retire(name);
|
|
1106
1131
|
if (child.nodeId && this.legion) {
|
|
1107
1132
|
if (child.execution && child.execution.status !== "unknown" && child.execution.status !== "running") {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider pinning for ambiguous bare model ids (#123).
|
|
3
|
+
*
|
|
4
|
+
* A model id like `workbuddy/hy4-preview-ioa` can exist under more than one
|
|
5
|
+
* provider: one registered live by an extension (any-endpoint) and one static
|
|
6
|
+
* in models.json without usable auth. Children start with `--no-extensions`,
|
|
7
|
+
* so the live provider only exists when the role lists its extension — and a
|
|
8
|
+
* bare `--model workbuddy/...` still resolves into the FIRST provider that
|
|
9
|
+
* declares the id, which may be the auth-less static one. The child then dies
|
|
10
|
+
* instantly with "No API key found for <provider>".
|
|
11
|
+
*
|
|
12
|
+
* Fix: at dispatch time, ask the PARENT's registry which provider actually
|
|
13
|
+
* owns the resolved model and pass it as `--provider` alongside `--model`.
|
|
14
|
+
* The parent registry reflects the live extension providers, so the pinned
|
|
15
|
+
* provider is the one that can authenticate.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Minimal registry surface `providerForModel` needs (subset of ModelRegistry). */
|
|
19
|
+
export interface ProviderLookup {
|
|
20
|
+
getAll(): Array<{ provider: string; id: string }>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Find the provider that owns `model` in the parent's registry.
|
|
25
|
+
*
|
|
26
|
+
* Returns `undefined` when the model cannot be found or when more than one
|
|
27
|
+
* provider declares the same id AND the id carries no provider prefix — in
|
|
28
|
+
* that ambiguous case pinning a wrong provider is worse than leaving the
|
|
29
|
+
* child to resolve it. Models with a `provider/id` prefix are matched on the
|
|
30
|
+
* full prefixed id first (extension providers register such ids verbatim),
|
|
31
|
+
* then on the suffix after the first slash.
|
|
32
|
+
*/
|
|
33
|
+
export function providerForModel(
|
|
34
|
+
registry: ProviderLookup | undefined,
|
|
35
|
+
model: string | undefined,
|
|
36
|
+
): string | undefined {
|
|
37
|
+
if (!registry || !model) return undefined;
|
|
38
|
+
const bare = model.trim();
|
|
39
|
+
if (!bare) return undefined;
|
|
40
|
+
|
|
41
|
+
const models = registry.getAll();
|
|
42
|
+
// Candidates: the id verbatim (extension providers may register prefixed
|
|
43
|
+
// ids) and, when `bare` carries a provider prefix, the id after stripping it
|
|
44
|
+
// (`any-endpoint/workbuddy/x` ↔ registry id `workbuddy/x`).
|
|
45
|
+
const suffix = bare.includes("/") ? bare.slice(bare.indexOf("/") + 1) : undefined;
|
|
46
|
+
const matches = models.filter(
|
|
47
|
+
(m) => m.id === bare || (suffix !== undefined && m.id === suffix),
|
|
48
|
+
);
|
|
49
|
+
if (matches.length === 1) {
|
|
50
|
+
return matches[0]!.provider;
|
|
51
|
+
}
|
|
52
|
+
if (matches.length > 1) {
|
|
53
|
+
// Prefer the provider named by the id's own prefix (`any-endpoint/...`).
|
|
54
|
+
const prefix = bare.split("/")[0]!;
|
|
55
|
+
const prefixed = matches.find((m) => m.provider === prefix);
|
|
56
|
+
return prefixed?.provider;
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
package/src/runs/provision.ts
CHANGED
|
@@ -58,7 +58,13 @@ function writeMarker(worktreePath: string, mode: string): void {
|
|
|
58
58
|
fs.renameSync(tmp, file);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
/** Repo-relative manifests, in resolution order, for the npm-install mode.
|
|
61
|
+
/** Repo-relative manifests, in resolution order, for the npm-install mode.
|
|
62
|
+
*
|
|
63
|
+
* The bare package.json fallback was removed: a package.json without any
|
|
64
|
+
* lockfile is far more often a non-npm repo caught by mistake (Java/Maven
|
|
65
|
+
* repos carry stray package.json files for docs/tooling) than a real npm
|
|
66
|
+
* project (which always has a lockfile). Without a lockfile we skip
|
|
67
|
+
* provisioning instead of firing npm install into a Java checkout. */
|
|
62
68
|
function installCommandFor(worktreePath: string): string | null {
|
|
63
69
|
if (fs.existsSync(path.join(worktreePath, "pnpm-lock.yaml"))) {
|
|
64
70
|
return "pnpm install --frozen-lockfile";
|
|
@@ -72,9 +78,6 @@ function installCommandFor(worktreePath: string): string | null {
|
|
|
72
78
|
if (fs.existsSync(path.join(worktreePath, "package-lock.json"))) {
|
|
73
79
|
return "npm ci";
|
|
74
80
|
}
|
|
75
|
-
if (fs.existsSync(path.join(worktreePath, "package.json"))) {
|
|
76
|
-
return "npm install";
|
|
77
|
-
}
|
|
78
81
|
return null;
|
|
79
82
|
}
|
|
80
83
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extensible doctor gates: role `tools:` that need an extension under
|
|
3
|
+
* `--no-extensions` (#70 lens, #89/#97 codemode, future builtins).
|
|
4
|
+
*
|
|
5
|
+
* Add a row here when a new tool would otherwise false-promise under
|
|
6
|
+
* `--no-extensions`. Keep stable `code` values when tests/docs depend on them.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { CHILD_EXTENSIONS_ENV, BUILTIN_CODEMODE, isCodemodeExtension } from "./args.ts";
|
|
10
|
+
import type { AgentConfig } from "../shared/types.ts";
|
|
11
|
+
|
|
12
|
+
export interface RoleToolExtensionGate {
|
|
13
|
+
/** Stable issue code (lens/codemode keep specific codes for back-compat). */
|
|
14
|
+
code: string;
|
|
15
|
+
/** Match against role `tools:` entries. */
|
|
16
|
+
matchesTools: (tools: readonly string[]) => boolean;
|
|
17
|
+
/** True when the effective child extension list satisfies the requirement. */
|
|
18
|
+
extensionsSatisfy: (exts: readonly string[]) => boolean;
|
|
19
|
+
toolLabel: string;
|
|
20
|
+
extensionLabel: string;
|
|
21
|
+
fix: (roleName: string) => string;
|
|
22
|
+
nextStep: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Configurable tool → extension requirements for `/legion doctor` (#97).
|
|
27
|
+
*
|
|
28
|
+
* Order is the issue emission order. Prefer specific codes over a single
|
|
29
|
+
* `ROLE_TOOL_UNAVAILABLE` so Next steps and existing tests stay precise.
|
|
30
|
+
*/
|
|
31
|
+
export const ROLE_TOOL_EXTENSION_GATES: readonly RoleToolExtensionGate[] = [
|
|
32
|
+
{
|
|
33
|
+
code: "ROLE_MISSING_LENS",
|
|
34
|
+
matchesTools: (tools) =>
|
|
35
|
+
tools.some((t) =>
|
|
36
|
+
/lens|symbol_search|project_report|module_report|read_symbol|read_enclosing/i.test(t),
|
|
37
|
+
),
|
|
38
|
+
extensionsSatisfy: (exts) => exts.some((e) => /pi-lens/i.test(e)),
|
|
39
|
+
toolLabel: "lens tools",
|
|
40
|
+
extensionLabel: "pi-lens",
|
|
41
|
+
fix: (roleName) =>
|
|
42
|
+
`Add pi-lens to ${roleName}'s extensions: frontmatter, or to ${CHILD_EXTENSIONS_ENV}.`,
|
|
43
|
+
nextStep:
|
|
44
|
+
"Add pi-lens extension path to roles that list lens_* tools, or to PI_LEGION_CHILD_EXTENSIONS.",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
code: "ROLE_MISSING_CODEMODE",
|
|
48
|
+
matchesTools: (tools) => tools.some((t) => t === "codemode"),
|
|
49
|
+
extensionsSatisfy: (exts) => exts.some(isCodemodeExtension),
|
|
50
|
+
toolLabel: "codemode",
|
|
51
|
+
extensionLabel: BUILTIN_CODEMODE,
|
|
52
|
+
fix: (roleName) =>
|
|
53
|
+
`Add ${BUILTIN_CODEMODE} to ${roleName}'s extensions: frontmatter, or ensure tools: includes codemode so Legion auto-loads it.`,
|
|
54
|
+
nextStep: `Add ${BUILTIN_CODEMODE} to roles that list codemode (or keep tools: codemode so Legion auto-loads it).`,
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
/** Plain issue shape so this module stays free of doctor imports. */
|
|
59
|
+
export interface RoleToolGateIssue {
|
|
60
|
+
severity: "warning";
|
|
61
|
+
code: string;
|
|
62
|
+
section: "extensions";
|
|
63
|
+
message: string;
|
|
64
|
+
fix: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Collect role-tool / extension mismatch issues for one agent.
|
|
69
|
+
*
|
|
70
|
+
* `effectiveExts` should be the same list the child argv would load
|
|
71
|
+
* (e.g. `collectChildExtensions`). When `inheritsExtensions` is true the
|
|
72
|
+
* child loads host discovery — gates do not fire.
|
|
73
|
+
*/
|
|
74
|
+
export function collectRoleToolExtensionIssues(
|
|
75
|
+
agent: Pick<AgentConfig, "name" | "tools">,
|
|
76
|
+
effectiveExts: readonly string[],
|
|
77
|
+
opts: { inheritsExtensions: boolean },
|
|
78
|
+
): RoleToolGateIssue[] {
|
|
79
|
+
if (opts.inheritsExtensions) return [];
|
|
80
|
+
const tools = agent.tools ?? [];
|
|
81
|
+
const out: RoleToolGateIssue[] = [];
|
|
82
|
+
for (const gate of ROLE_TOOL_EXTENSION_GATES) {
|
|
83
|
+
if (!gate.matchesTools(tools)) continue;
|
|
84
|
+
if (gate.extensionsSatisfy(effectiveExts)) continue;
|
|
85
|
+
out.push({
|
|
86
|
+
severity: "warning",
|
|
87
|
+
code: gate.code,
|
|
88
|
+
section: "extensions",
|
|
89
|
+
message: `Role "${agent.name}" lists ${gate.toolLabel} but would not load ${gate.extensionLabel} under --no-extensions.`,
|
|
90
|
+
fix: gate.fix(agent.name),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Next-step lines for any gate codes present in `issues`. */
|
|
97
|
+
export function roleToolGateNextSteps(issues: readonly { code: string }[]): string[] {
|
|
98
|
+
const steps: string[] = [];
|
|
99
|
+
for (const gate of ROLE_TOOL_EXTENSION_GATES) {
|
|
100
|
+
if (issues.some((i) => i.code === gate.code)) steps.push(gate.nextStep);
|
|
101
|
+
}
|
|
102
|
+
return steps;
|
|
103
|
+
}
|
package/src/runs/worktree.ts
CHANGED
|
@@ -24,17 +24,49 @@ export function worktreePathFor(runDir: string, name: string): string {
|
|
|
24
24
|
* Per-step tool field wins, then the top-level `subagent({ worktree })`
|
|
25
25
|
* argument, then the role default. The parent (group) agent is supposed to
|
|
26
26
|
* pick this; the role default is only the fallback when it omits the field.
|
|
27
|
+
*
|
|
28
|
+
* Nesting suppression: when the parent cwd is itself a child worktree
|
|
29
|
+
* (git-dir ≠ git-common-dir), the role default and launch fallback collapse
|
|
30
|
+
* to false — a worktree of a worktree only re-pays the bootstrap tax.
|
|
31
|
+
* Explicit `launch: true`/`step: true` still wins: the caller saw the
|
|
32
|
+
* suppression and insisted.
|
|
27
33
|
*/
|
|
28
34
|
export function resolveLaunchWorktree(opts: {
|
|
29
35
|
roleDefault?: boolean;
|
|
30
36
|
launch?: boolean;
|
|
31
37
|
step?: boolean;
|
|
38
|
+
parentInWorktree?: boolean;
|
|
32
39
|
}): boolean {
|
|
33
40
|
if (typeof opts.step === "boolean") return opts.step;
|
|
34
41
|
if (typeof opts.launch === "boolean") return opts.launch;
|
|
42
|
+
if (opts.parentInWorktree) return false;
|
|
35
43
|
return opts.roleDefault === true;
|
|
36
44
|
}
|
|
37
45
|
|
|
46
|
+
/**
|
|
47
|
+
* True when cwd is itself a linked git worktree (child of a main checkout).
|
|
48
|
+
* Detected via `git rev-parse --git-dir` vs `--git-common-dir`: they differ
|
|
49
|
+
* inside any linked worktree, including ones created by other agent runs.
|
|
50
|
+
* Non-git directories report both as ".git" and are treated as not-in-worktree;
|
|
51
|
+
* the later createChildWorktree call owns the "not a repo" error.
|
|
52
|
+
*/
|
|
53
|
+
export function isInsideChildWorktree(cwd: string): boolean {
|
|
54
|
+
try {
|
|
55
|
+
const gitDir = execFileSync("git", ["-C", cwd, "rev-parse", "--git-dir"], {
|
|
56
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
57
|
+
encoding: "utf8",
|
|
58
|
+
}).trim();
|
|
59
|
+
const commonDir = execFileSync(
|
|
60
|
+
"git",
|
|
61
|
+
["-C", cwd, "rev-parse", "--git-common-dir"],
|
|
62
|
+
{ stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" },
|
|
63
|
+
).trim();
|
|
64
|
+
return gitDir !== commonDir && gitDir !== "" && commonDir !== "";
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
38
70
|
/** Branch the child commits on. Unique per launch so two workers never collide. */
|
|
39
71
|
export function worktreeBranchFor(name: string, nonce?: string): string {
|
|
40
72
|
const tag = nonce ?? randomBytes(4).toString("hex");
|
package/src/shared/session.ts
CHANGED
|
@@ -513,6 +513,40 @@ export function isTransientConnectionFailure(
|
|
|
513
513
|
return /connection error/i.test(text);
|
|
514
514
|
}
|
|
515
515
|
|
|
516
|
+
/**
|
|
517
|
+
* Session ended with a user prompt and no assistant reply.
|
|
518
|
+
* This is a model/stream failure when the child finished on its own.
|
|
519
|
+
* A parent `session_shutdown` that kills the child first must not reuse it;
|
|
520
|
+
* see `attributeParentSessionShutdown`.
|
|
521
|
+
*/
|
|
522
|
+
export const NO_ASSISTANT_ABORT_REASON = "no assistant message for the last prompt";
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Parent process shutdown retired the child before any assistant message
|
|
526
|
+
* was written. Distinct from {@link NO_ASSISTANT_ABORT_REASON}.
|
|
527
|
+
*/
|
|
528
|
+
export const PARENT_SESSION_SHUTDOWN_ABORT_REASON =
|
|
529
|
+
"parent session shutdown retired the child before it produced an assistant message";
|
|
530
|
+
|
|
531
|
+
/** True when the last turn (or the whole file) has no assistant message. */
|
|
532
|
+
export function lastTurnHasNoAssistant(parsed: ParsedSession): boolean {
|
|
533
|
+
const last = parsed.turns.at(-1);
|
|
534
|
+
return parsed.turns.length === 0 || !last || last.assistants.length === 0;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Rewrite a missing-assistant abort when the parent session shut down and
|
|
539
|
+
* retired the child. A turn the child actually settled keeps `deriveOutcome`.
|
|
540
|
+
*/
|
|
541
|
+
export function attributeParentSessionShutdown(execution: Execution, parsed: ParsedSession): Execution {
|
|
542
|
+
if (execution.status === "success" || !lastTurnHasNoAssistant(parsed)) return execution;
|
|
543
|
+
return {
|
|
544
|
+
...execution,
|
|
545
|
+
status: "aborted",
|
|
546
|
+
reason: PARENT_SESSION_SHUTDOWN_ABORT_REASON,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
516
550
|
/**
|
|
517
551
|
* Derive the execution outcome from a parsed session.
|
|
518
552
|
*
|
|
@@ -542,10 +576,11 @@ export function deriveOutcome(parsed: ParsedSession): Execution {
|
|
|
542
576
|
}
|
|
543
577
|
|
|
544
578
|
// F29: hard kill / interrupt before any reply was persisted.
|
|
579
|
+
// Parent-session cascade shutdown rewrites this in the orchestrator.
|
|
545
580
|
if (last.assistants.length === 0) {
|
|
546
581
|
return {
|
|
547
582
|
status: "aborted",
|
|
548
|
-
reason:
|
|
583
|
+
reason: NO_ASSISTANT_ABORT_REASON,
|
|
549
584
|
turns: turns.length,
|
|
550
585
|
toolErrors: parsed.toolErrors,
|
|
551
586
|
lastTurn: { stopReason: null, toolErrors: last.toolErrors },
|
|
@@ -558,7 +593,7 @@ export function deriveOutcome(parsed: ParsedSession): Execution {
|
|
|
558
593
|
if (!final) {
|
|
559
594
|
return {
|
|
560
595
|
status: "aborted",
|
|
561
|
-
reason:
|
|
596
|
+
reason: NO_ASSISTANT_ABORT_REASON,
|
|
562
597
|
turns: turns.length,
|
|
563
598
|
toolErrors: parsed.toolErrors,
|
|
564
599
|
};
|
package/src/shared/types.ts
CHANGED
|
@@ -246,6 +246,14 @@ export interface Execution {
|
|
|
246
246
|
completedAt?: string;
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Why a still-live child is being retired.
|
|
251
|
+
* `session-shutdown` is the parent process leaving, not a model failure.
|
|
252
|
+
*/
|
|
253
|
+
export interface RetireOptions {
|
|
254
|
+
cause?: "session-shutdown";
|
|
255
|
+
}
|
|
256
|
+
|
|
249
257
|
export interface Usage {
|
|
250
258
|
input: number;
|
|
251
259
|
output: number;
|
package/src/tui/status.ts
CHANGED
|
@@ -598,7 +598,7 @@ export interface StatusVizSnapshot {
|
|
|
598
598
|
nodes: VizNode[];
|
|
599
599
|
sampler: RateSampler;
|
|
600
600
|
now: number;
|
|
601
|
-
/**
|
|
601
|
+
/** Header "now" tok/s (live agents only; 0 when none running). */
|
|
602
602
|
rate: number;
|
|
603
603
|
}
|
|
604
604
|
|
|
@@ -608,8 +608,6 @@ export interface StatusBoard {
|
|
|
608
608
|
vizSnapshot?(now?: number): StatusVizSnapshot;
|
|
609
609
|
paint(entries: StatusEntry[], now: number): void;
|
|
610
610
|
clear(): void;
|
|
611
|
-
/** Show the idle stop hint in the railway header (main agent idle, children live). */
|
|
612
|
-
setIdleHint?(on: boolean): void;
|
|
613
611
|
/** The TUI the widget is mounted in, when registered (for focus/overlay checks). */
|
|
614
612
|
getTui?(): unknown;
|
|
615
613
|
}
|
|
@@ -646,7 +644,6 @@ export function createStatusBoard(options: {
|
|
|
646
644
|
let tui: WidgetTui | undefined;
|
|
647
645
|
let railScroll: RailScroll = { offset: 0 };
|
|
648
646
|
let railWindow: { offset: number; total: number; maxLanes: number } | undefined;
|
|
649
|
-
let idleHint = false;
|
|
650
647
|
|
|
651
648
|
const renderLines = (now: number, theme: Theme, width?: number): {
|
|
652
649
|
lines: string[];
|
|
@@ -655,8 +652,9 @@ export function createStatusBoard(options: {
|
|
|
655
652
|
} => {
|
|
656
653
|
if (strip === "railway" && hasTreeIdentity(entries) && width !== undefined && width >= 20) {
|
|
657
654
|
const nodes = nodesFromEntries(entries);
|
|
658
|
-
|
|
659
|
-
const
|
|
655
|
+
// Live-child gate: railway only renders when lanes exist; always show stop hint.
|
|
656
|
+
const hint = { hint: IDLE_STOP_HINT, hintShort: IDLE_STOP_HINT_SHORT };
|
|
657
|
+
const rendered = railwayLines({ nodes, now, width, rate: sampler.nowRate(now, nodes), scroll: railScroll, ...hint }, theme);
|
|
660
658
|
// Persist the clamped window (anchored on the top lane's agent) so later
|
|
661
659
|
// renders follow ends/starts without jumping; resets to 0 when all fit.
|
|
662
660
|
railWindow = rendered.window;
|
|
@@ -666,10 +664,14 @@ export function createStatusBoard(options: {
|
|
|
666
664
|
railWindow = undefined;
|
|
667
665
|
const visible = visiblePanelEntries(entries);
|
|
668
666
|
const totals = statusTotals(entries);
|
|
669
|
-
|
|
667
|
+
let rows = formatWidgetRows(visible, now, totals);
|
|
668
|
+
// Tree strip + Legion identity: short stop hint on its own row (survives width squeeze).
|
|
669
|
+
if (strip === "tree" && hasTreeIdentity(entries) && rows.length > 0) {
|
|
670
|
+
rows = [rows[0]!, { text: IDLE_STOP_HINT_SHORT }, ...rows.slice(1)];
|
|
671
|
+
}
|
|
670
672
|
const nodeIdsByRow = rows.map((row) => row.nodeId);
|
|
671
673
|
return {
|
|
672
|
-
lines: themeLines(
|
|
674
|
+
lines: themeLines(rows.map((row) => row.text), theme),
|
|
673
675
|
hitAt: (_x, y) => nodeIdsByRow[y],
|
|
674
676
|
};
|
|
675
677
|
};
|
|
@@ -796,16 +798,12 @@ export function createStatusBoard(options: {
|
|
|
796
798
|
if (hasTreeIdentity(next)) sampler.record(_now, nodesFromEntries(next));
|
|
797
799
|
paint();
|
|
798
800
|
},
|
|
799
|
-
setIdleHint(on) {
|
|
800
|
-
if (idleHint === on) return;
|
|
801
|
-
idleHint = on;
|
|
802
|
-
tui?.requestRender();
|
|
803
|
-
},
|
|
804
801
|
getTui() {
|
|
805
802
|
return tui;
|
|
806
803
|
},
|
|
807
804
|
vizSnapshot(now = Date.now()) {
|
|
808
|
-
|
|
805
|
+
const nodes = nodesFromEntries(entries);
|
|
806
|
+
return { nodes, sampler, now, rate: sampler.nowRate(now, nodes) };
|
|
809
807
|
},
|
|
810
808
|
clear() {
|
|
811
809
|
entries = [];
|
package/src/tui/viz/canvas.ts
CHANGED
|
@@ -87,6 +87,11 @@ function isWide(code: number): boolean {
|
|
|
87
87
|
);
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/** Grapheme / cell count (UTF-16-safe). Prefer over `String.length` for layout budgets. */
|
|
91
|
+
export function cellWidth(value: string): number {
|
|
92
|
+
return Array.from(value).length;
|
|
93
|
+
}
|
|
94
|
+
|
|
90
95
|
/** Truncate plain text to `width` single-width cells with an ellipsis. */
|
|
91
96
|
export function ellipsize(value: string, width: number): string {
|
|
92
97
|
const chars = Array.from(value).map(sanitizeChar);
|
package/src/tui/viz/panels.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Panel renderers for the /legion full view. All drawing is clipped to its rect. */
|
|
2
2
|
|
|
3
|
-
import { Canvas, ellipsize } from "./canvas.ts";
|
|
3
|
+
import { Canvas, cellWidth, ellipsize } from "./canvas.ts";
|
|
4
4
|
import type { Rect } from "./layout.ts";
|
|
5
5
|
import { formatCount, formatDuration, isAgent, type VizNode } from "./model.ts";
|
|
6
6
|
import { nodeToken, roleColor } from "./palette.ts";
|
|
@@ -12,7 +12,7 @@ export interface ViewData {
|
|
|
12
12
|
nodes: readonly VizNode[];
|
|
13
13
|
sampler: RateSampler;
|
|
14
14
|
now: number;
|
|
15
|
-
/**
|
|
15
|
+
/** Header "now" tok/s (live agents only; 0 when none running). Chart avg/buckets keep full-series windows. */
|
|
16
16
|
rate: number;
|
|
17
17
|
}
|
|
18
18
|
|
|
@@ -241,21 +241,29 @@ export function drawGalaxy(cv: Canvas, r: Rect, data: ViewData, selectedId: stri
|
|
|
241
241
|
}
|
|
242
242
|
}
|
|
243
243
|
// Legend: roles that actually appear (dynamic), then status hints.
|
|
244
|
+
// Reserve room for failed/retired glyphs first so a narrow panel still matches live scout (#102/#125).
|
|
244
245
|
const ly = r.y + r.h - 1;
|
|
245
246
|
let lx = r.x + 1;
|
|
246
247
|
const roles: string[] = [];
|
|
247
248
|
for (const node of dfsOrder(nodes)) if (isAgent(node) && !roles.includes(node.role)) roles.push(node.role);
|
|
249
|
+
const statuses = new Set(nodes.map((node) => node.status));
|
|
250
|
+
const statusItems = ([["failed", "✗ failed", "error"], ["retired", "⣿ retired", "dim"]] as const)
|
|
251
|
+
.filter(([status]) => statuses.has(status));
|
|
252
|
+
// Budget in grapheme cells (not String.length) so multi-byte / emoji labels do not squeeze roles (#128).
|
|
253
|
+
const statusBudget = statusItems.reduce((sum, [, label]) => sum + cellWidth(label) + 2, 0);
|
|
254
|
+
const roleLimit = r.x + r.w - 1 - statusBudget;
|
|
248
255
|
for (const role of roles) {
|
|
249
|
-
const w =
|
|
250
|
-
if (lx + w >
|
|
256
|
+
const w = cellWidth(role) + 4;
|
|
257
|
+
if (lx + w > roleLimit) break;
|
|
251
258
|
cv.set(lx, ly, "⣿", roleColor(role));
|
|
252
259
|
cv.text(lx + 2, ly, role, "dim");
|
|
253
260
|
lx += w;
|
|
254
261
|
}
|
|
255
|
-
for (const [label, col] of
|
|
256
|
-
|
|
262
|
+
for (const [, label, col] of statusItems) {
|
|
263
|
+
const lw = cellWidth(label);
|
|
264
|
+
if (lx + lw + 1 > r.x + r.w - 1) break;
|
|
257
265
|
cv.text(lx, ly, label, col);
|
|
258
|
-
lx +=
|
|
266
|
+
lx += lw + 2;
|
|
259
267
|
}
|
|
260
268
|
}
|
|
261
269
|
|
|
@@ -300,7 +308,7 @@ export function computeStats(data: ViewData, buckets: number): ChartStats {
|
|
|
300
308
|
}
|
|
301
309
|
return {
|
|
302
310
|
now: data.rate,
|
|
303
|
-
//
|
|
311
|
+
// Full-series window average (includes settled); differs from header "now" on purpose.
|
|
304
312
|
avg: data.sampler.totalRate(to, Math.max(1, to - from)),
|
|
305
313
|
peak,
|
|
306
314
|
peakAt,
|
package/src/tui/viz/railway.ts
CHANGED
|
@@ -39,14 +39,14 @@ export interface RailInput {
|
|
|
39
39
|
nodes: readonly VizNode[];
|
|
40
40
|
now: number;
|
|
41
41
|
width: number;
|
|
42
|
-
/**
|
|
42
|
+
/** Header "now" tok/s (live agents only; 0 when none running). */
|
|
43
43
|
rate: number;
|
|
44
44
|
windowMs?: number;
|
|
45
45
|
/** Lane scroll position when more agents are live than lanes fit. */
|
|
46
46
|
scroll?: RailScroll;
|
|
47
|
-
/** Dim hint on the header rule (e.g. stop
|
|
47
|
+
/** Dim hint on the header rule (e.g. `/legion stop` while children are live). */
|
|
48
48
|
hint?: string;
|
|
49
|
-
/** Shorter variant used when the full hint does not fit. */
|
|
49
|
+
/** Shorter variant used when the full hint does not fit; truncated further under squeeze. */
|
|
50
50
|
hintShort?: string;
|
|
51
51
|
}
|
|
52
52
|
|
|
@@ -234,18 +234,41 @@ export function renderRailway(input: RailInput): RailRender | undefined {
|
|
|
234
234
|
const winLabel = ` −${Math.max(1, Math.round(windowMs / 60000))}m `;
|
|
235
235
|
canvas.text(LX + 1, 0, winLabel, "dim");
|
|
236
236
|
const range = above || below ? ` · ${scrollIndicator(current.offset, rows, live)} ${above ? "▲" : " "}${below ? "▼" : " "}` : "";
|
|
237
|
-
const
|
|
237
|
+
const headerFull = portrait
|
|
238
238
|
? ` ${live} live${range} · ${rateText} `
|
|
239
239
|
: ` legion ${live} live${range} · ${rateText} `;
|
|
240
|
-
|
|
241
|
-
|
|
240
|
+
// Hint sits between the window label and the right-hand summary. Prefer the
|
|
241
|
+
// full string, then short; when width squeezes, shrink the summary so the
|
|
242
|
+
// short `/legion stop` still fits, and only then ellipsize the hint.
|
|
243
|
+
const labelEnd = LX + 1 + winLabel.length;
|
|
244
|
+
const hx = labelEnd + 1;
|
|
245
|
+
let header = headerFull;
|
|
246
|
+
let headerX = Math.max(labelEnd, width - Array.from(header).length - 1);
|
|
247
|
+
let hintPick: string | undefined;
|
|
242
248
|
if (input.hint) {
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
const
|
|
246
|
-
const
|
|
247
|
-
if (
|
|
249
|
+
const short = input.hintShort ?? input.hint;
|
|
250
|
+
const shortBlock = Array.from(short).length + 2; // leading+trailing spaces
|
|
251
|
+
const gap = 1; // rule cell between hint and summary
|
|
252
|
+
const needHeaderX = hx + shortBlock + gap;
|
|
253
|
+
if (headerX < needHeaderX) {
|
|
254
|
+
// Pull/truncate the summary so short hint keeps a slot.
|
|
255
|
+
headerX = Math.min(width - 1, Math.max(needHeaderX, labelEnd));
|
|
256
|
+
const avail = Math.max(0, width - 1 - headerX);
|
|
257
|
+
header = avail >= 3 ? ` ${ellipsize(headerFull.trim(), avail - 2)} ` : "";
|
|
258
|
+
if (header) headerX = Math.max(labelEnd, width - Array.from(header).length - 1);
|
|
259
|
+
// Re-assert short reservation if summary still crowds the hint.
|
|
260
|
+
if (header && headerX < needHeaderX) {
|
|
261
|
+
headerX = needHeaderX;
|
|
262
|
+
const clipped = Math.max(0, width - 1 - headerX);
|
|
263
|
+
header = clipped >= 3 ? ` ${ellipsize(headerFull.trim(), clipped - 2)} ` : "";
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const room = Math.max(0, (header ? headerX - 1 : width - 1) - hx);
|
|
267
|
+
hintPick = [input.hint, input.hintShort].find((text) => text && Array.from(text).length + 2 <= room);
|
|
268
|
+
if (!hintPick && room >= 3) hintPick = ellipsize(short, room - 2);
|
|
248
269
|
}
|
|
270
|
+
if (header) canvas.text(headerX, 0, header, "accent", { bold: true, maxX: width - 1 });
|
|
271
|
+
if (hintPick) canvas.text(hx, 0, ` ${hintPick} `, "dim");
|
|
249
272
|
for (const [c, tick] of headerTicks) {
|
|
250
273
|
if (canvas.get(LX + c, 0)?.ch !== "─") continue; // never draw over header text
|
|
251
274
|
canvas.set(LX + c, 0, "┬", tick.color);
|