@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.2",
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
- error = tier?.costCap && costTotal > tier.costCap ? `budget_exceeded (cost $${costTotal.toFixed(4)} > cap $${tier.costCap})` : "aborted by user";
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
@@ -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
- return `${glyph} ${label}${agentSeg}${dur}${tok}${ctx}${cost}`;
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
- if (active.length <= cap) return active.map((r) => widgetLine(r, now));
99
- const shown = active.slice(0, cap).map((r) => widgetLine(r, now));
100
- shown.push(`+${active.length - cap} more in /fleet`);
101
- return shown;
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