@getpipher/armory-fleet 0.12.2 → 0.12.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getpipher/armory-fleet",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The armory suite's subagent orchestrator for the pi coding agent — a cross-harness, superpowers-native fleet where every agent is armory-native from birth.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -40,6 +40,14 @@ export interface RunRecord {
|
|
|
40
40
|
* Transient, in-memory only — never written to RunLog (the journal append constructs
|
|
41
41
|
* a plain object, not RunRecord). */
|
|
42
42
|
session?: LiveSessionHandle;
|
|
43
|
+
/** #23: liveness — current turn count (1-indexed; live, updated on turn_start). */
|
|
44
|
+
turnCount?: number;
|
|
45
|
+
/** #23: the run's max turn budget (set at spawn; for the widget's `turn N/max`). */
|
|
46
|
+
turnMax?: number;
|
|
47
|
+
/** #23: the last event class seen (e.g. "tool:edit", "assistant", "turn") — liveness only, no content. */
|
|
48
|
+
lastEventClass?: string;
|
|
49
|
+
/** #23: timestamp (ms) of the last event — liveness heartbeat ("are events still arriving?"). */
|
|
50
|
+
lastEventAt?: number;
|
|
43
51
|
}
|
|
44
52
|
|
|
45
53
|
/** runId format: fl-<base36 ms>-<6 random> (SPEC-1 §5.1). */
|
|
@@ -76,4 +84,4 @@ export class RunRegistry {
|
|
|
76
84
|
private emit(): void {
|
|
77
85
|
for (const fn of this.listeners) fn();
|
|
78
86
|
}
|
|
79
|
-
}
|
|
87
|
+
}
|
|
@@ -294,7 +294,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
294
294
|
let aborted = false;
|
|
295
295
|
const handle = toLiveHandle(session);
|
|
296
296
|
handle.abort = async () => { aborted = true; await session.abort(); };
|
|
297
|
-
opts.runRegistry.update(runId, { session: handle });
|
|
297
|
+
opts.runRegistry.update(runId, { session: handle, turnMax: maxTurns });
|
|
298
298
|
|
|
299
299
|
const budget = createTurnBudget(maxTurns);
|
|
300
300
|
let finalText = "";
|
|
@@ -307,6 +307,18 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
307
307
|
let modelError: string | undefined; // model-call failure surfaced via stopReason "error"
|
|
308
308
|
let sawAssistantMessage = false; // #22: did the child emit any assistant message_end at all?
|
|
309
309
|
|
|
310
|
+
// #23: liveness — classify events into a short, content-free class string for the widget.
|
|
311
|
+
// Names the tool (safe — tool name is not args/result) so the operator sees "what's happening"
|
|
312
|
+
// without leaking prompt content, secrets, or full tool arguments/results (per #23 acceptance).
|
|
313
|
+
const classifyEvent = (e: ChildSessionEvent): string => {
|
|
314
|
+
if (e.type === "turn_start") return "turn";
|
|
315
|
+
if (e.type === "turn_end") return "turn_end";
|
|
316
|
+
if (e.type === "message_end") return e.message?.role === "assistant" ? "assistant" : (e.message?.role ?? "message");
|
|
317
|
+
if (e.type === "tool_execution_end") return `tool:${(e as { toolName?: string }).toolName ?? "?"}`;
|
|
318
|
+
if (e.type === "session_init") return "init";
|
|
319
|
+
return e.type;
|
|
320
|
+
};
|
|
321
|
+
|
|
310
322
|
const onSignalAbort = (): void => { aborted = true; void session.abort(); };
|
|
311
323
|
opts.signal?.addEventListener("abort", onSignalAbort);
|
|
312
324
|
|
|
@@ -357,6 +369,15 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
357
369
|
opts.runLog?.append(runId, buildToolEvent((e as any).toolName, (e as any).args, (e as any).result, (e as any).isError ?? false, turnIdx));
|
|
358
370
|
} catch { /* best-effort */ }
|
|
359
371
|
}
|
|
372
|
+
// #23: liveness heartbeat — update the run record on meaningful events so the fleet widget
|
|
373
|
+
// can show turn count + last-event class + "events still arriving" without leaking content.
|
|
374
|
+
if (e.type === "turn_start" || e.type === "message_end" || e.type === "tool_execution_end") {
|
|
375
|
+
opts.runRegistry.update(runId, {
|
|
376
|
+
turnCount: Math.max(0, turnIdx + 1),
|
|
377
|
+
lastEventClass: classifyEvent(e),
|
|
378
|
+
lastEventAt: Date.now(),
|
|
379
|
+
});
|
|
380
|
+
}
|
|
360
381
|
opts.onEvent?.(e);
|
|
361
382
|
});
|
|
362
383
|
|
|
@@ -375,7 +396,13 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
375
396
|
let error: string | undefined;
|
|
376
397
|
if (aborted) {
|
|
377
398
|
status = "aborted";
|
|
378
|
-
|
|
399
|
+
// #23: distinguish TODO-status reversion from filesystem rollback. A foreground run is in-place
|
|
400
|
+
// (no worktree isolation — that's a background-run concern), so an abort reverts the linked TODO
|
|
401
|
+
// to open (retryable) but leaves any partial file edits in the working dir for inspection.
|
|
402
|
+
const rollbackNote = " — TODO reverted to open (retryable); in-place file changes NOT rolled back (inspect the working dir for partial work)";
|
|
403
|
+
error = tier?.costCap && costTotal > tier.costCap
|
|
404
|
+
? `budget_exceeded (cost $${costTotal.toFixed(4)} > cap $${tier.costCap})${rollbackNote}`
|
|
405
|
+
: `aborted by user${rollbackNote}`;
|
|
379
406
|
} else if (budget.count() >= maxTurns) {
|
|
380
407
|
// #25: surface a coherent partial, not a mid-sentence 200-char cut. The controller reads
|
|
381
408
|
// `res.error` (the tool surfaces error, not finalText, for failed runs), so the partial must
|
package/src/panel/widget-rows.ts
CHANGED
|
@@ -11,6 +11,12 @@ import { fmtDuration, fmtTokens } from "./rows.ts";
|
|
|
11
11
|
import type { RunRecord } from "../engine/run-registry.ts";
|
|
12
12
|
import type { BgRunStatus } from "./rows.ts";
|
|
13
13
|
|
|
14
|
+
/** #23: liveness segments (turn count, last-event class, abort warning) appear only after a run
|
|
15
|
+
* has been active this long — keeps short foreground runs concise (per #23 acceptance criteria). */
|
|
16
|
+
export const LIVENESS_THRESHOLD_MS = 30_000;
|
|
17
|
+
/** #23: a run whose last event is older than this is flagged stale ("are events still arriving?"). */
|
|
18
|
+
export const STALE_THRESHOLD_MS = 60_000;
|
|
19
|
+
|
|
14
20
|
export interface WidgetRun {
|
|
15
21
|
runId: string;
|
|
16
22
|
agent: string;
|
|
@@ -32,6 +38,14 @@ export interface WidgetRun {
|
|
|
32
38
|
maxContext?: number;
|
|
33
39
|
/** SPEC-6-1: cumulative $ (for the $ segment). */
|
|
34
40
|
costTotal?: number;
|
|
41
|
+
/** #23: liveness — current turn count (1-indexed). */
|
|
42
|
+
turnCount?: number;
|
|
43
|
+
/** #23: liveness — max turn budget (for `turn N/max`). */
|
|
44
|
+
turnMax?: number;
|
|
45
|
+
/** #23: liveness — last event class (e.g. "tool:edit", "assistant", "turn"). */
|
|
46
|
+
lastEventClass?: string;
|
|
47
|
+
/** #23: liveness — timestamp (ms) of the last event ("events still arriving?"). */
|
|
48
|
+
lastEventAt?: number;
|
|
35
49
|
}
|
|
36
50
|
|
|
37
51
|
export function toWidgetRun(r: RunRecord): WidgetRun {
|
|
@@ -40,6 +54,7 @@ export function toWidgetRun(r: RunRecord): WidgetRun {
|
|
|
40
54
|
startedAt: r.startedAt, endedAt: r.endedAt, tokenTotal: r.tokenTotal,
|
|
41
55
|
kind: "fg",
|
|
42
56
|
task: r.task, costTotal: r.costTotal, contextTokens: r.contextTokens,
|
|
57
|
+
turnCount: r.turnCount, turnMax: r.turnMax, lastEventClass: r.lastEventClass, lastEventAt: r.lastEventAt,
|
|
43
58
|
};
|
|
44
59
|
}
|
|
45
60
|
|
|
@@ -88,16 +103,36 @@ function widgetLine(r: WidgetRun, now: number): string {
|
|
|
88
103
|
// fg: task excerpt as primary label (fallback to runId if no task)
|
|
89
104
|
const label = r.task ? `"${r.task.slice(0, 40)}"` : r.runId;
|
|
90
105
|
const agentSeg = r.agent && r.agent !== "general-purpose" ? ` · ${r.agent}` : "";
|
|
91
|
-
|
|
106
|
+
// #23: liveness — only after LIVENESS_THRESHOLD_MS, to keep short runs concise (per acceptance).
|
|
107
|
+
// turn N/max + last-event class (no prompt content, no args/results — only the tool name)
|
|
108
|
+
// + a stale indicator if no event has arrived for STALE_THRESHOLD_MS ("events still arriving?").
|
|
109
|
+
const elapsed = typeof r.startedAt === "number" ? now - r.startedAt : 0;
|
|
110
|
+
let liveness = "";
|
|
111
|
+
if (elapsed > LIVENESS_THRESHOLD_MS) {
|
|
112
|
+
const turn = (r.turnCount != null && r.turnMax != null) ? ` turn ${r.turnCount}/${r.turnMax}` : (r.turnCount != null ? ` turn ${r.turnCount}` : "");
|
|
113
|
+
const ev = r.lastEventClass ? ` ●${r.lastEventClass}` : "";
|
|
114
|
+
const stale = (r.lastEventAt != null && now - r.lastEventAt > STALE_THRESHOLD_MS) ? " ⏰stale" : "";
|
|
115
|
+
liveness = `${turn}${ev}${stale}`;
|
|
116
|
+
}
|
|
117
|
+
return `${glyph} ${label}${agentSeg}${dur}${liveness}${tok}${ctx}${cost}`;
|
|
92
118
|
}
|
|
93
119
|
|
|
94
|
-
/** Above-editor widget: one line per active run, cap 5, overflow → "+N more in /fleet".
|
|
120
|
+
/** Above-editor widget: one line per active run, cap 5, overflow → "+N more in /fleet".
|
|
121
|
+
* #23: when an active foreground run has been running longer than LIVENESS_THRESHOLD_MS, append an
|
|
122
|
+
* explicit abort-warning footer naming its runId (so the controller can distinguish active work
|
|
123
|
+
* from a hang without cancelling, and knows submitting a message will abort it). */
|
|
95
124
|
export function renderWidgetLines(runs: WidgetRun[], now: number = Date.now()): string[] {
|
|
96
125
|
const active = filterActive(runs);
|
|
97
126
|
const cap = 5;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
127
|
+
const lines = active.length <= cap
|
|
128
|
+
? active.map((r) => widgetLine(r, now))
|
|
129
|
+
: [...active.slice(0, cap).map((r) => widgetLine(r, now)), `+${active.length - cap} more in /fleet`];
|
|
130
|
+
// #23: abort-warning footer — only when a RUNNING foreground run is active long enough that
|
|
131
|
+
// a controller might worry it's hung. Paused/queued fg runs aren't aborted by a new message.
|
|
132
|
+
const longFg = active.find((r) => r.kind === "fg" && r.status === "running" && typeof r.startedAt === "number" && now - r.startedAt > LIVENESS_THRESHOLD_MS);
|
|
133
|
+
if (longFg) {
|
|
134
|
+
lines.push(`⚠ submitting a message aborts the foreground run · ${longFg.runId} · /fleet to inspect`);
|
|
135
|
+
}
|
|
136
|
+
return lines;
|
|
102
137
|
}
|
|
103
138
|
|