@getpipher/armory-fleet 0.5.1 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpipher/armory-fleet",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
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",
@@ -60,6 +60,6 @@
60
60
  },
61
61
  "scripts": {
62
62
  "typecheck": "tsc --noEmit",
63
- "test:run": "node --import tsx --test test/*.test.mts"
63
+ "test:run": "node --import tsx --test --test-timeout=30000 test/*.test.mts"
64
64
  }
65
65
  }
@@ -16,6 +16,10 @@ export interface RunRecord {
16
16
  backendSessionId?: string | null;
17
17
  /** The sessionKey whose resume this run belongs to (SPEC-3). */
18
18
  sessionKey?: string | null;
19
+ /** SPEC-5b-1: runId this run resumed from (rehydrated the prior sessionKey + a follow-up). */
20
+ resumedFrom?: string;
21
+ /** SPEC-5b-1: runId this run forked from (fresh re-run with same agent+task). */
22
+ forkedFrom?: string;
19
23
  }
20
24
 
21
25
  /** runId format: fl-<base36 ms>-<6 random> (SPEC-1 §5.1). */
@@ -7,6 +7,8 @@ import type { BackendRegistry } from "../backend/port.ts";
7
7
  import { genRunId, RunRegistry } from "./run-registry.ts";
8
8
  import { createTurnBudget, DEFAULT_MAX_TURNS } from "./turn-budget.ts";
9
9
  import type { SingleSlotLock } from "./concurrency-lock.ts";
10
+ import type { RunLog } from "../runtime/run-log.ts";
11
+ import { buildToolEvent } from "../runtime/run-log.ts";
10
12
 
11
13
  const PI_DEFAULT_TOOLS = ["read", "bash", "edit", "write"];
12
14
 
@@ -81,6 +83,13 @@ export interface SpawnOptions {
81
83
  * (Q4=C) instead of the agent's `backend`. */
82
84
  skillsOverride?: string[];
83
85
  backendOverride?: "pi" | "claude";
86
+ /** SPEC-5b-1: per-run conversation journal. When set, spawnSubagent writes run:meta →
87
+ * message/tool → run:ended events. Absent = no journal (unit tests stay clean). */
88
+ runLog?: RunLog;
89
+ /** SPEC-5b-1: when set, the new run is a resume of this prior runId (written to run:ended + RunRecord.resumedFrom). */
90
+ resumeLink?: string;
91
+ /** SPEC-5b-1: when set, the new run is a fork of this prior runId (written to run:ended + RunRecord.forkedFrom). */
92
+ forkLink?: string;
84
93
  }
85
94
 
86
95
  export interface SpawnResult {
@@ -145,6 +154,9 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
145
154
  runId, agent: agentDef.name, model, task: opts.task, track,
146
155
  todoId: null, status: "running", startedAt,
147
156
  });
157
+ try {
158
+ opts.runLog?.append(runId, { type: "run:meta", runId, agent: agentDef.name, model, task: opts.task, startedAt, track, todoId: null });
159
+ } catch { /* best-effort: journal is the index, not the product */ }
148
160
 
149
161
  // todo-sync (before) — only when both caller tracks AND agent allows todoSync
150
162
  let priorStatus: string | undefined;
@@ -179,6 +191,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
179
191
  let finalText = "";
180
192
  let tokenTotal = 0;
181
193
  let aborted = false;
194
+ let turnIdx = -1;
182
195
 
183
196
  const onSignalAbort = (): void => { aborted = true; void session.abort(); };
184
197
  opts.signal?.addEventListener("abort", onSignalAbort);
@@ -186,6 +199,11 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
186
199
  const unsub = session.subscribe((e) => {
187
200
  if (e.type === "session_init" && e.backendSessionId) {
188
201
  opts.runRegistry.update(runId, { backendSessionId: e.backendSessionId, sessionKey: agentDef.sessionKey });
202
+ try {
203
+ opts.runLog?.append(runId, { type: "run:meta", runId, agent: agentDef.name, model, task: opts.task, startedAt, track, todoId, backendSessionId: e.backendSessionId, sessionKey: agentDef.sessionKey });
204
+ } catch { /* best-effort */ }
205
+ } else if (e.type === "turn_start") {
206
+ turnIdx++;
189
207
  } else if (e.type === "turn_end") {
190
208
  if (budget.consume()) void session.abort();
191
209
  } else if (e.type === "message_end" && e.message?.role === "assistant") {
@@ -193,6 +211,13 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
193
211
  if (text) finalText = text;
194
212
  const total = e.message.usage?.cost?.total;
195
213
  if (typeof total === "number") tokenTotal += total;
214
+ try {
215
+ opts.runLog?.append(runId, { type: "message", role: "assistant", text, usage: { total }, turnIndex: turnIdx });
216
+ } catch { /* best-effort */ }
217
+ } else if (e.type === "tool_execution_end") {
218
+ try {
219
+ opts.runLog?.append(runId, buildToolEvent((e as any).toolName, (e as any).args, (e as any).result, (e as any).isError ?? false, turnIdx));
220
+ } catch { /* best-effort */ }
196
221
  }
197
222
  opts.onEvent?.(e);
198
223
  });
@@ -242,7 +267,17 @@ async function finishRun(
242
267
  error: string | undefined, agentName: string, model: string, tokenTotal = 0,
243
268
  ): Promise<SpawnResult> {
244
269
  const endedAt = Date.now();
245
- opts.runRegistry.update(runId, { status, endedAt, resultSummary: finalText.slice(0, 120) });
270
+ opts.runRegistry.update(runId, {
271
+ status, endedAt, resultSummary: finalText.slice(0, 120),
272
+ resumedFrom: opts.resumeLink, forkedFrom: opts.forkLink,
273
+ });
274
+ try {
275
+ opts.runLog?.append(runId, {
276
+ type: "run:ended", runId, status, endedAt,
277
+ resultSummary: finalText.slice(0, 120), tokenTotal,
278
+ resumedFrom: opts.resumeLink, forkedFrom: opts.forkLink,
279
+ });
280
+ } catch { /* best-effort: journal is the index, not the product */ }
246
281
  // SPEC-4: lifecycle phase children skip the per-run todo reconciliation — the lifecycle
247
282
  // engine owns the lifecycle todo's status + progress block (Q7=C).
248
283
  if (!opts.lifecycleTodoId) {
package/src/index.ts CHANGED
@@ -37,6 +37,8 @@ import { ConcurrencyPool } from "./runtime/concurrency-pool.ts";
37
37
  import { ResultsInbox } from "./runtime/results-inbox.ts";
38
38
  import { runBackground, type AsyncRunnerDeps } from "./runtime/async-runner.ts";
39
39
  import { scanResumeCandidates } from "./runtime/resume.ts";
40
+ import { RunLog } from "./runtime/run-log.ts";
41
+ import { reconcileRuns } from "./runtime/reconcile.ts";
40
42
  import { Scheduler } from "./scheduling/scheduler.ts";
41
43
  import { createFleetResultsTool } from "./tools/fleet-results.ts";
42
44
  import { BgRunsStore } from "./panel/bg-runs-store.ts";
@@ -170,6 +172,13 @@ export default async function (pi: ExtensionAPI): Promise<void> {
170
172
  const asyncRunLifecycle: AsyncRunnerDeps["runLifecycle"] = async (task, lifecycleName, opts) => {
171
173
  const { runLifecycle } = await import("./lifecycle/run-lifecycle.ts");
172
174
  const { spawnSubagent } = await import("./engine/spawnSubagent.ts");
175
+ // SPEC-5a §8.1 (Q4=A): bg runs must NOT compete with the foreground single-slot lock.
176
+ // The ConcurrencyPool gates bg-RUN concurrency (N-slot); within a run the lifecycle loop
177
+ // serializes phases, so a fresh per-run lock just satisfies spawnSubagent's tryAcquire API
178
+ // without ever contending (foreground holds deps.lock; bg holds its own). Without this, a
179
+ // foreground subagent holding deps.lock made every bg run's first-phase spawn fail fast
180
+ // (tryAcquire → "concurrency lock unexpectedly unavailable" → 6ms run:aborted).
181
+ const bgLock = createSingleSlotLock();
173
182
  const lifecycleFullDeps: LifecycleRunDeps = {
174
183
  ...deps.lifecycleDeps,
175
184
  genRunId: () => opts.runId, // override: use the async runner's runId
@@ -178,8 +187,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
178
187
  spawn: async (o) => spawnSubagent({
179
188
  agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
180
189
  skillsOverride: o.skills, backendOverride: o.backend,
181
- registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
182
- backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: opts.worktreePath, // child runs in the worktree
190
+ registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: bgLock,
191
+ backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: opts.worktreePath, runLog: deps.runLog, // child runs in the worktree
183
192
  }),
184
193
  };
185
194
  const res = await runLifecycle(task, lifecycleName, { deps: lifecycleFullDeps, mode: opts.mode, worktreePath: opts.worktreePath, baseRef: "HEAD", onCheckpoint: async (p) => p.status === "failed" ? { action: "abort" } : { action: "continue" } });
@@ -189,6 +198,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
189
198
  // At init they're undefined — the subagent tool's `if (!deps.asyncRunner)` guard returns an
190
199
  // actionable "not configured" error if called before session_start (can't happen in practice).
191
200
  deps.bgRuns = bgRuns;
201
+ // SPEC-5b-1: RunLog is constructed per-session (needs the cwd) in session_start; the
202
+ // shared `deps` reference is mutated there so the subagent tool + panel pick it up live.
203
+ deps.runLog = undefined as RunLog | undefined;
192
204
 
193
205
  const refresh = (ctx: { cwd: string; ui: { notify: (m: string, t?: "info" | "warning" | "error") => void } }): void => {
194
206
  const r = discoverAgents({
@@ -225,6 +237,13 @@ export default async function (pi: ExtensionAPI): Promise<void> {
225
237
  deps.parentCwd = ctx.cwd;
226
238
  // SPEC-5a: build the per-session async runner + scheduler, start firing, scan for interrupted runs.
227
239
  const dir = fleetDir(ctx.cwd);
240
+ // SPEC-5b-1: per-session RunLog at .pi/fleet/conversations/ (separate from the
241
+ // SPEC-5a phase journal at .pi/fleet/runs/ — different granularity, no filename collision).
242
+ deps.runLog = new RunLog(join(dir, "conversations"));
243
+ const reconciled = reconcileRuns(deps.runLog);
244
+ if (reconciled.length > 0) {
245
+ ctx.ui.notify(`reconciled ${reconciled.length} interrupted fleet run${reconciled.length > 1 ? "s" : ""} (marked aborted)`, "info");
246
+ }
228
247
  deps.asyncRunner = {
229
248
  worktree: new WorktreeService({ rootDir: ctx.cwd }),
230
249
  diff: new DiffService(),
@@ -203,7 +203,13 @@ export async function runLifecycle(task: string, lifecycleName: string, opts: Li
203
203
  // A failed phase that's aborted = lifecycle failed (the work failed); a healthy phase
204
204
  // aborted at a checkpoint = user-aborted (§12).
205
205
  const status: LifecycleStatus = phaseRec.status === "failed" ? "failed" : "aborted";
206
- return doneResult(runId, startedAt, status, lifecycleName, task, lifecycleBackend, opts.mode, phaseRecords, todoId);
206
+ // SPEC-5a fix: surface the failed phase's summary as the lifecycle error so the async
207
+ // runner's run:aborted journal event + notify show the real cause (spawnRes.error etc.),
208
+ // not just the bare status. Guard on non-empty summary so an empty summary still falls
209
+ // back to the status in the async runner (res.error ?? res.status). A healthy phase
210
+ // aborted at a checkpoint has no error.
211
+ const error = phaseRec.status === "failed" && phaseRec.summary ? phaseRec.summary : undefined;
212
+ return doneResult(runId, startedAt, status, lifecycleName, task, lifecycleBackend, opts.mode, phaseRecords, todoId, error);
207
213
  }
208
214
  // decision.action === "revise"
209
215
  reviseCount++;
@@ -12,6 +12,9 @@ import {
12
12
  import type { AgentDef } from "../registry/frontmatter.ts";
13
13
  import { agentsRow, agentInfo, backendsRow, backendInfo, lifecycleRow, lifecyclePhaseTimeline, scheduleRow } from "./rows.ts";
14
14
  import { buildFleetItems } from "./fleet-items.ts";
15
+ import { runsRow, runTimelineRow } from "./runs-rows.ts";
16
+ import { buildRunsIndex } from "./runs-index.ts";
17
+ import type { RunLog, RunMeta, RunLogEvent } from "../runtime/run-log.ts";
15
18
  import type { Scheduler, Schedule } from "../scheduling/scheduler.ts";
16
19
  import type { BgRunsStore } from "./bg-runs-store.ts";
17
20
  import { spawnSubagent, type SpawnResult } from "../engine/spawnSubagent.ts";
@@ -23,7 +26,7 @@ import type { LifecycleDef, LifecycleRunRecord, CheckpointDecision, PhaseRecord
23
26
  import type { LifecycleRunDeps, CheckpointFn } from "../lifecycle/run-lifecycle.ts";
24
27
  import { runLifecycle } from "../lifecycle/run-lifecycle.ts";
25
28
 
26
- type View = "fleet" | "lifecycle" | "agents" | "backends" | "scheduled";
29
+ type View = "fleet" | "lifecycle" | "runs" | "agents" | "backends" | "scheduled";
27
30
 
28
31
  export interface FleetPanelDeps {
29
32
  registry: Map<string, AgentDef>;
@@ -41,6 +44,8 @@ export interface FleetPanelDeps {
41
44
  scheduler?: Scheduler;
42
45
  /** SPEC-5a: live bg run status rows for the fleet tab. Optional. */
43
46
  bgRuns?: BgRunsStore;
47
+ /** SPEC-5b-1: durable per-run conversation log. Optional — Runs tab degrades to empty when absent. */
48
+ runLog?: RunLog;
44
49
  }
45
50
 
46
51
  export interface FleetPanelOpts {
@@ -80,6 +85,11 @@ export class FleetPanel extends Container {
80
85
  private schedNameInput: Input | null = null;
81
86
  private schedPhase: "task" | "expr" | "name" = "task";
82
87
  private selectedSchedule: Schedule | null = null;
88
+ // SPEC-5b-1: Runs tab — replay overlay state + resume/fork input state
89
+ private selectedRun: RunMeta | null = null;
90
+ private runTimeline: RunLogEvent[] | null = null;
91
+ private resumeInput: Input | null = null;
92
+ private resumeMode = false;
83
93
  /** SPEC-5a proper-fix: store-change subscriptions — fired by RunRegistry + BgRunsStore
84
94
  * so the panel re-renders the moment a (fore- or back-ground) run mutates, without a keypress. */
85
95
  private readonly unsubs: (() => void)[] = [];
@@ -111,8 +121,10 @@ export class FleetPanel extends Container {
111
121
  ? buildFleetItems({ runRegistry: this.deps.runRegistry, bgRuns: this.deps.bgRuns })
112
122
  : this.view === "lifecycle"
113
123
  ? [...this.deps.lifecycleRuns.values()].map((l: LifecycleRunRecord) => ({ value: l.runId, label: lifecycleRow(l) }))
114
- : this.view === "agents"
115
- ? [...this.deps.registry.values()].map((a: AgentDef) => ({ value: a.name, label: agentsRow(a) }))
124
+ : this.view === "runs"
125
+ ? buildRunsIndex(this.deps.runLog?.dir ?? "").map((r: RunMeta) => ({ value: r.runId, label: runsRow(r) }))
126
+ : this.view === "agents"
127
+ ? [...this.deps.registry.values()].map((a: AgentDef) => ({ value: a.name, label: agentsRow(a) }))
116
128
  : this.view === "scheduled"
117
129
  ? (this.deps.scheduler?.list() ?? []).map((s: Schedule) => ({ value: s.id, label: scheduleRow(s) }))
118
130
  : this.deps.backendRegistry.list().map((b: Backend) => ({ value: b.id, label: backendsRow(b) }));
@@ -153,7 +165,7 @@ export class FleetPanel extends Container {
153
165
  this.children.length = 0;
154
166
  this.children.push(...keep);
155
167
  const accent = (s: string): string => this.theme.fg("accent", s);
156
- const tabs = (["fleet", "lifecycle", "agents", "backends", "scheduled"] as View[])
168
+ const tabs = (["fleet", "lifecycle", "runs", "agents", "backends", "scheduled"] as View[])
157
169
  .map((v) => (v === this.view ? this.theme.fg("accent", this.theme.bold(`[${v}]`)) : this.theme.fg("dim", v)))
158
170
  .join(" ");
159
171
  this.addChild(new Text(accent(this.theme.bold(" FLEET")) + " " + tabs, 0, 0));
@@ -196,6 +208,33 @@ export class FleetPanel extends Container {
196
208
  `nextFire: ${s.nextFire?.toLocaleString() ?? "(none)"}`,
197
209
  ]) this.addChild(new Text(this.theme.fg("text", line), 0, 0));
198
210
  this.addChild(new Text(this.theme.fg("dim", " esc:Back"), 0, 0));
211
+ } else if (this.selectedRun) {
212
+ // SPEC-5b-1: Runs tab — per-turn timeline replay (read-only); enter reserved for 5b-3 overlay.
213
+ this.addChild(new Text(this.theme.fg("dim", ` ── run ${this.selectedRun.runId} — timeline ──`), 0, 0));
214
+ const events = (this.runTimeline ?? []).filter((e) => e.type === "message" || e.type === "tool") as Array<RunLogEvent>;
215
+ if (events.length === 0) {
216
+ this.addChild(new Text(this.theme.fg("dim", " (no conversation events)"), 0, 0));
217
+ } else {
218
+ const tl = new SelectList(
219
+ events.map((e) => ({ value: "", label: runTimelineRow(e as never) })),
220
+ Math.min(events.length, 12),
221
+ {
222
+ selectedPrefix: (s: string) => this.theme.fg("accent", s),
223
+ selectedText: (s: string) => this.theme.fg("accent", s),
224
+ description: (s: string) => this.theme.fg("muted", s),
225
+ scrollInfo: (s: string) => this.theme.fg("dim", s),
226
+ noMatch: (s: string) => this.theme.fg("warning", s),
227
+ },
228
+ );
229
+ tl.onCancel = () => { this.selectedRun = null; this.runTimeline = null; this.renderShell(); };
230
+ this.addChild(tl);
231
+ }
232
+ this.addChild(new Text(this.theme.fg("dim", " enter: (5b-3 full message) esc:Back"), 0, 0));
233
+ } else if (this.resumeMode && this.resumeInput) {
234
+ // SPEC-5b-1: Runs tab — resume follow-up input.
235
+ this.addChild(new Text(this.theme.fg("accent", " follow-up> "), 0, 0));
236
+ this.addChild(this.resumeInput);
237
+ this.addChild(new Text(this.theme.fg("dim", " enter submit • esc cancel"), 0, 0));
199
238
  } else if (this.schedRunMode && (this.schedTaskInput || this.schedExprInput || this.schedNameInput)) {
200
239
  const prompt = this.schedPhase === "task" ? " task> " : this.schedPhase === "expr" ? " schedule (cron | interval | one-shot ISO)> " : " lifecycle (blank=default)> ";
201
240
  this.addChild(new Text(this.theme.fg("accent", prompt), 0, 0));
@@ -221,8 +260,8 @@ export class FleetPanel extends Container {
221
260
 
222
261
  this.addChild(new Spacer(1));
223
262
  const hint =
224
- this.infoAgent || this.selectedBackend || this.selectedLifecycle || this.selectedSchedule
225
- ? " esc:Back"
263
+ this.infoAgent || this.selectedBackend || this.selectedLifecycle || this.selectedSchedule || this.selectedRun
264
+ ? (this.selectedRun ? " enter:(5b-3) esc:Back" : " esc:Back")
226
265
  : this.pendingCheckpoint
227
266
  ? " c:Continue v:Revise a:Abort"
228
267
  : this.lcRevising
@@ -230,8 +269,10 @@ export class FleetPanel extends Container {
230
269
  : this.view === "fleet"
231
270
  ? " r:Run-new s:Stop o:Open-todo tab:Lifecycle q:Quit"
232
271
  : this.view === "lifecycle"
233
- ? " r:Run-lifecycle i:Info tab:Agents q:Quit"
234
- : this.view === "agents"
272
+ ? " r:Run-lifecycle i:Info tab:Runs q:Quit"
273
+ : this.view === "runs"
274
+ ? " enter:Replay r:Resume f:Fork tab:Agents q:Quit"
275
+ : this.view === "agents"
235
276
  ? " r:Run e:Edit i:Info d:Reload tab:Backends q:Quit"
236
277
  : this.view === "scheduled"
237
278
  ? " a:Add p:Pause/resume d:Delete i:Info tab:Fleet q:Quit"
@@ -277,6 +318,7 @@ export class FleetPanel extends Container {
277
318
  runRegistry: this.deps.runRegistry, lock: this.deps.lock,
278
319
  backendRegistry: this.deps.backendRegistry,
279
320
  parentModel: this.deps.parentModel, parentCwd: this.deps.parentCwd,
321
+ runLog: this.deps.runLog,
280
322
  // live Fleet row during the run (SPEC-1 §4c) — re-render on each turn_end
281
323
  onEvent: (e) => {
282
324
  if (e.type === "turn_end") {
@@ -302,12 +344,15 @@ export class FleetPanel extends Container {
302
344
 
303
345
  private switchView(): void {
304
346
  this.view = this.view === "fleet" ? "lifecycle"
305
- : this.view === "lifecycle" ? "agents"
347
+ : this.view === "lifecycle" ? "runs"
348
+ : this.view === "runs" ? "agents"
306
349
  : this.view === "agents" ? "backends"
307
350
  : this.view === "backends" ? "scheduled" : "fleet";
308
351
  this.selectedBackend = null;
309
352
  this.selectedLifecycle = null;
310
353
  this.selectedSchedule = null;
354
+ this.selectedRun = null;
355
+ this.runTimeline = null;
311
356
  this.list = this.buildList();
312
357
  this.renderShell();
313
358
  }
@@ -329,6 +374,17 @@ export class FleetPanel extends Container {
329
374
  if (matchesKey(data, "escape")) { this.selectedSchedule = null; this.renderShell(); }
330
375
  return;
331
376
  }
377
+ if (this.selectedRun) {
378
+ // SPEC-5b-1: Runs timeline replay overlay — esc back; enter is a 5b-3 placeholder.
379
+ if (matchesKey(data, "escape")) { this.selectedRun = null; this.runTimeline = null; this.renderShell(); }
380
+ return;
381
+ }
382
+ if (this.resumeMode && this.resumeInput) {
383
+ if (matchesKey(data, "escape")) { this.cancelResume(); return; }
384
+ this.resumeInput.handleInput(data);
385
+ this.invalidate();
386
+ return;
387
+ }
332
388
  if (this.schedRunMode && (this.schedTaskInput || this.schedExprInput || this.schedNameInput)) {
333
389
  if (matchesKey(data, "escape")) { this.cancelScheduleAdd(); return; }
334
390
  (this.schedPhase === "task" ? this.schedTaskInput! : this.schedPhase === "expr" ? this.schedExprInput! : this.schedNameInput!).handleInput(data);
@@ -379,6 +435,20 @@ export class FleetPanel extends Container {
379
435
  this.renderShell();
380
436
  return;
381
437
  }
438
+ // SPEC-5b-1: Runs view — enter/i:Replay R:Resume F:Fork
439
+ if (this.view === "runs" && this.deps.runLog) {
440
+ if (matchesKey(data, "enter") || matchesKey(data, "i")) {
441
+ const sel = this.list.getSelectedItem();
442
+ if (sel) {
443
+ this.selectedRun = buildRunsIndex(this.deps.runLog.dir).find((r) => r.runId === sel.value) ?? null;
444
+ this.runTimeline = this.deps.runLog.replay(sel.value);
445
+ this.renderShell();
446
+ }
447
+ return;
448
+ }
449
+ if (matchesKey(data, "r")) { this.startResume(); return; }
450
+ if (matchesKey(data, "f")) { this.startFork(); return; }
451
+ }
382
452
  // SPEC-4: Lifecycle view — i:Info + r:Run-lifecycle
383
453
  if (matchesKey(data, "i") && this.view === "lifecycle") {
384
454
  const sel = this.list.getSelectedItem();
@@ -493,6 +563,87 @@ export class FleetPanel extends Container {
493
563
  this.renderShell();
494
564
  }
495
565
 
566
+ /** SPEC-5b-1: Resume — rehydrate the prior session (same agent sessionKey) + a follow-up. */
567
+ private startResume(): void {
568
+ const sel = this.list.getSelectedItem();
569
+ if (!sel) return;
570
+ const run = buildRunsIndex(this.deps.runLog!.dir).find((r) => r.runId === sel.value);
571
+ if (!run) return;
572
+ if (!run.backendSessionId) { this.onNotify("no resumable session for this run", "warning"); return; }
573
+ if (run.status === "running") { this.onNotify("run still running; stop it first", "warning"); return; }
574
+ this.resumeInput = new Input();
575
+ this.resumeInput.onSubmit = (followUp: string) => {
576
+ if (!followUp.trim()) { this.cancelResume(); return; }
577
+ void this.executeResume(run, followUp.trim());
578
+ };
579
+ this.resumeInput.onEscape = () => this.cancelResume();
580
+ this.resumeMode = true;
581
+ this.renderShell();
582
+ }
583
+
584
+ private cancelResume(): void {
585
+ this.resumeMode = false;
586
+ this.resumeInput = null;
587
+ this.renderShell();
588
+ }
589
+
590
+ private async executeResume(prior: RunMeta, followUp: string): Promise<void> {
591
+ this.resumeMode = false;
592
+ this.resumeInput = null;
593
+ this.renderShell();
594
+ const res: SpawnResult = await spawnSubagent({
595
+ agent: prior.agent, task: followUp, track: true, resumeLink: prior.runId, runLog: this.deps.runLog,
596
+ registry: this.deps.registry, todoSync: this.deps.todoSync, runRegistry: this.deps.runRegistry,
597
+ lock: this.deps.lock, backendRegistry: this.deps.backendRegistry,
598
+ parentModel: this.deps.parentModel, parentCwd: this.deps.parentCwd,
599
+ onEvent: (e) => { if (e.type === "turn_end") { this.list = this.buildList(); this.renderShell(); } },
600
+ });
601
+ this.list = this.buildList();
602
+ this.renderShell();
603
+ this.onNotify(`resume ${res.status}: ${res.runId}${res.error ? " — " + res.error : ""}`, res.status === "completed" ? "info" : "warning");
604
+ }
605
+
606
+ /** SPEC-5b-1: Fork — fresh re-run with the same agent + task (no session rehydration). */
607
+ private startFork(): void {
608
+ const sel = this.list.getSelectedItem();
609
+ if (!sel) return;
610
+ const run = buildRunsIndex(this.deps.runLog!.dir).find((r) => r.runId === sel.value);
611
+ if (!run) return;
612
+ if (run.status === "running") { this.onNotify("run still running; stop it first", "warning"); return; }
613
+ this.linkPhase = "task";
614
+ this.taskInput = new Input();
615
+ this.taskInput.onSubmit = (task: string) => {
616
+ const finalTask = task.trim() || run.task;
617
+ this.linkPhase = "link";
618
+ this.linkInput = new Input();
619
+ this.linkInput.onSubmit = (todoIdRaw: string) => {
620
+ void this.executeFork(run.agent, finalTask, todoIdRaw.trim() || undefined, run.runId);
621
+ };
622
+ this.linkInput.onEscape = () => { void this.executeFork(run.agent, finalTask, undefined, run.runId); };
623
+ this.renderShell();
624
+ };
625
+ this.taskInput.onEscape = () => this.cancelRun();
626
+ this.runMode = true;
627
+ this.renderShell();
628
+ }
629
+
630
+ private async executeFork(agent: string, task: string, todoId: string | undefined, priorRunId: string): Promise<void> {
631
+ this.runMode = false;
632
+ this.taskInput = null;
633
+ this.linkInput = null;
634
+ this.renderShell();
635
+ const res: SpawnResult = await spawnSubagent({
636
+ agent, task, todoId, track: true, forkLink: priorRunId, runLog: this.deps.runLog,
637
+ registry: this.deps.registry, todoSync: this.deps.todoSync, runRegistry: this.deps.runRegistry,
638
+ lock: this.deps.lock, backendRegistry: this.deps.backendRegistry,
639
+ parentModel: this.deps.parentModel, parentCwd: this.deps.parentCwd,
640
+ onEvent: (e) => { if (e.type === "turn_end") { this.list = this.buildList(); this.renderShell(); } },
641
+ });
642
+ this.list = this.buildList();
643
+ this.renderShell();
644
+ this.onNotify(`fork ${res.status}: ${res.runId}${res.error ? " — " + res.error : ""}`, res.status === "completed" ? "info" : "warning");
645
+ }
646
+
496
647
  /** SPEC-4: open the Run-lifecycle inline inputs (task → lifecycle name → start runLifecycle). */
497
648
  private startLifecycleRun(): void {
498
649
  this.lcPhase = "task";
@@ -542,6 +693,7 @@ export class FleetPanel extends Container {
542
693
  skillsOverride: o.skills, backendOverride: o.backend,
543
694
  registry: this.deps.registry, todoSync: this.deps.todoSync, runRegistry: this.deps.runRegistry, lock: this.deps.lock,
544
695
  backendRegistry: this.deps.backendRegistry, parentModel: this.deps.parentModel, parentCwd: this.deps.parentCwd,
696
+ runLog: this.deps.runLog,
545
697
  });
546
698
  },
547
699
  };
@@ -0,0 +1,8 @@
1
+ // src/panel/runs-index.ts
2
+ // SPEC-5b-1 — pure scan of the conversations dir → newest-first run list. Pure so the
3
+ // Runs tab + unit tests share one path (mirrors buildFleetItems from v0.5.0).
4
+ import { RunLog, type RunMeta } from "../runtime/run-log.ts";
5
+
6
+ export function buildRunsIndex(logDir: string): RunMeta[] {
7
+ return new RunLog(logDir).scanMeta().sort((a, b) => b.startedAt - a.startedAt);
8
+ }
@@ -0,0 +1,29 @@
1
+ // src/panel/runs-rows.ts
2
+ // SPEC-5b-1 — pure renderers for the Runs tab + per-turn timeline. Reuses the glyph
3
+ // language (▶ ✓ ✗) so the Runs tab is visually consistent with Fleet/Lifecycle.
4
+ import { fmtDuration } from "./rows.ts";
5
+ import type { RunMeta, MessageEvent, ToolEvent } from "../runtime/run-log.ts";
6
+
7
+ const STATUS_GLYPH: Record<RunMeta["status"], string> = {
8
+ running: "▶", completed: "✓", failed: "✗", aborted: "✗",
9
+ };
10
+
11
+ export function runsRow(r: RunMeta): string {
12
+ const dur = r.endedAt ? fmtDuration(r.endedAt - r.startedAt) : "—";
13
+ const tok = r.tokenTotal > 0 ? ` ${r.tokenTotal} tok` : "";
14
+ const summary = r.resultSummary ? ` "${r.resultSummary}"` : "";
15
+ const prov = r.resumedFrom ? ` ← resumed:${r.resumedFrom}` : r.forkedFrom ? ` ← forked:${r.forkedFrom}` : "";
16
+ return `${STATUS_GLYPH[r.status]} ${r.runId} ${r.agent} ${r.status} ${dur}${tok}${summary}${prov}`;
17
+ }
18
+
19
+ export function runTimelineRow(e: MessageEvent | ToolEvent): string {
20
+ const turn = Math.max(0, e.turnIndex);
21
+ if (e.type === "message") {
22
+ const text = e.text.length > 80 ? e.text.slice(0, 79) + "…" : e.text;
23
+ const tok = e.usage?.total != null ? ` ${e.usage.total} tok` : "";
24
+ return `[a] "${text}"${tok} ·t${turn}`;
25
+ }
26
+ const glyph = e.isError ? "✗" : "✓";
27
+ const err = e.isError ? ` "${e.result}"` : "";
28
+ return `[t] ${e.toolName} ${e.args} ${glyph}${err} ·t${turn}`;
29
+ }
@@ -0,0 +1,29 @@
1
+ // src/runtime/reconcile.ts
2
+ // SPEC-5b-1 — on pi boot, mark orphan RunLog runs (run:meta with no run:ended whose process
3
+ // is gone) as aborted so the Runs tab doesn't show stale "running" rows across restarts.
4
+ // Foreground orphans; bg/lifecycle orphans are already handled by scanResumeCandidates (SPEC-5a).
5
+ import type { RunLog } from "./run-log.ts";
6
+
7
+ export interface ReconcileOpts {
8
+ /** Orphans whose startedAt is older than (now - graceMs) are marked aborted. Default 60000. */
9
+ graceMs?: number;
10
+ /** Test injection. Default Date.now(). */
11
+ now?: number;
12
+ }
13
+
14
+ /** Returns the runIds it marked aborted. Idempotent: a run already ended is skipped. */
15
+ export function reconcileRuns(log: RunLog, opts: ReconcileOpts = {}): string[] {
16
+ const grace = opts.graceMs ?? 60_000;
17
+ const now = opts.now ?? Date.now();
18
+ const aborted: string[] = [];
19
+ for (const meta of log.scanMeta()) {
20
+ if (meta.status !== "running") continue;
21
+ if (now - meta.startedAt <= grace) continue;
22
+ log.append(meta.runId, {
23
+ type: "run:ended", runId: meta.runId, status: "aborted",
24
+ endedAt: now, resultSummary: "process-gone", tokenTotal: meta.tokenTotal,
25
+ });
26
+ aborted.push(meta.runId);
27
+ }
28
+ return aborted;
29
+ }
@@ -0,0 +1,124 @@
1
+ // src/runtime/run-log.ts
2
+ // SPEC-5b-1 — self-describing per-run conversation log. Append-only JSONL (crash-safe:
3
+ // partial last line discarded). One file per run in .pi/fleet/conversations/<runId>.jsonl.
4
+ // The Runs tab rebuilds the run list across restarts via scanMeta().
5
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
6
+ import { join } from "node:path";
7
+
8
+ export type FleetRunStatus = "running" | "completed" | "failed" | "aborted";
9
+
10
+ export interface RunMetaEvent {
11
+ type: "run:meta"; runId: string; agent: string; model: string; task: string;
12
+ startedAt: number; track: boolean; todoId: string | null;
13
+ backendSessionId?: string; sessionKey?: string;
14
+ }
15
+ export interface MessageEvent {
16
+ type: "message"; role: string; text: string;
17
+ usage?: { total?: number }; turnIndex: number;
18
+ }
19
+ export interface ToolEvent {
20
+ type: "tool"; toolName: string; args: string; result: string; isError: boolean; turnIndex: number;
21
+ }
22
+ export interface RunEndedEvent {
23
+ type: "run:ended"; runId: string; status: FleetRunStatus; endedAt: number;
24
+ resultSummary?: string; tokenTotal: number; resumedFrom?: string; forkedFrom?: string;
25
+ }
26
+ export type RunLogEvent = RunMetaEvent | MessageEvent | ToolEvent | RunEndedEvent;
27
+
28
+ /** Resolved view of a run for the Runs tab (scanMeta result). */
29
+ export interface RunMeta {
30
+ runId: string; agent: string; model: string; task: string;
31
+ startedAt: number; track: boolean; todoId: string | null;
32
+ backendSessionId?: string; sessionKey?: string;
33
+ status: FleetRunStatus; endedAt?: number; resultSummary?: string; tokenTotal: number;
34
+ resumedFrom?: string; forkedFrom?: string;
35
+ }
36
+
37
+ const ARGS_LIMIT = 200;
38
+ const RESULT_LIMIT = 500;
39
+
40
+ /** Truncate to `n` chars with a single `…` ellipsis. Non-string → "". */
41
+ export function excerpt(str: string, n: number): string {
42
+ if (typeof str !== "string" || str.length === 0) return "";
43
+ if (str.length <= n) return str;
44
+ return str.slice(0, Math.max(0, n - 1)) + "…";
45
+ }
46
+
47
+ export class RunLog {
48
+ /** Public so buildRunsIndex + FleetPanel can read `runLog.dir` for the scan. */
49
+ constructor(readonly dir: string) {}
50
+
51
+ private file(runId: string): string { return join(this.dir, `${runId}.jsonl`); }
52
+
53
+ append(runId: string, event: RunLogEvent): void {
54
+ try {
55
+ mkdirSync(this.dir, { recursive: true });
56
+ appendFileSync(this.file(runId), JSON.stringify(event) + "\n", "utf8");
57
+ } catch {
58
+ // best-effort: the run is the product; the journal is the index. Never fail the run.
59
+ }
60
+ }
61
+
62
+ replay(runId: string): RunLogEvent[] {
63
+ const f = this.file(runId);
64
+ if (!existsSync(f)) return [];
65
+ const events: RunLogEvent[] = [];
66
+ for (const line of readFileSync(f, "utf8").split("\n")) {
67
+ if (!line) continue;
68
+ try { events.push(JSON.parse(line) as RunLogEvent); } catch { /* partial last line */ }
69
+ }
70
+ return events;
71
+ }
72
+
73
+ /** Rebuild the durable run list across restarts. Reads each file's run:meta events
74
+ * (latest binding wins for backendSessionId/sessionKey; first wins for startedAt) + the
75
+ * last run:ended (if present). */
76
+ scanMeta(): RunMeta[] {
77
+ if (!existsSync(this.dir)) return [];
78
+ const out: RunMeta[] = [];
79
+ for (const f of readdirSync(this.dir)) {
80
+ if (!f.endsWith(".jsonl")) continue;
81
+ const runId = f.slice(0, -".jsonl".length);
82
+ const events = this.replay(runId);
83
+ let meta: RunMeta | null = null;
84
+ let ended: RunEndedEvent | null = null;
85
+ for (const e of events) {
86
+ if (e.type === "run:meta") {
87
+ if (!meta) {
88
+ meta = { runId: e.runId, agent: e.agent, model: e.model, task: e.task, startedAt: e.startedAt,
89
+ track: e.track, todoId: e.todoId, backendSessionId: e.backendSessionId, sessionKey: e.sessionKey,
90
+ status: "running", tokenTotal: 0 };
91
+ } else {
92
+ // latest binding wins
93
+ if (e.backendSessionId) meta.backendSessionId = e.backendSessionId;
94
+ if (e.sessionKey) meta.sessionKey = e.sessionKey;
95
+ }
96
+ } else if (e.type === "run:ended") {
97
+ ended = e; // keep the last one
98
+ }
99
+ }
100
+ if (!meta) continue;
101
+ if (ended) {
102
+ meta.status = ended.status; meta.endedAt = ended.endedAt;
103
+ meta.resultSummary = ended.resultSummary; meta.tokenTotal = ended.tokenTotal;
104
+ meta.resumedFrom = ended.resumedFrom; meta.forkedFrom = ended.forkedFrom;
105
+ }
106
+ out.push(meta);
107
+ }
108
+ return out;
109
+ }
110
+ }
111
+
112
+ /** Build a tool event with the excerpt policy: errors-in-full, non-errors excerpted. */
113
+ export function buildToolEvent(
114
+ toolName: string, args: unknown, result: unknown, isError: boolean, turnIndex: number,
115
+ ): ToolEvent {
116
+ const argsStr = typeof args === "string" ? args : JSON.stringify(args);
117
+ const resultStr = typeof result === "string" ? result : JSON.stringify(result);
118
+ return {
119
+ type: "tool", toolName,
120
+ args: excerpt(argsStr, ARGS_LIMIT),
121
+ result: isError ? resultStr : excerpt(resultStr, RESULT_LIMIT),
122
+ isError, turnIndex,
123
+ };
124
+ }
@@ -45,6 +45,8 @@ export interface SubagentToolDeps {
45
45
  scheduler?: Scheduler;
46
46
  /** SPEC-5a: live bg run status rows for the /fleet panel. Optional. */
47
47
  bgRuns?: import("../panel/bg-runs-store.ts").BgRunsStore;
48
+ /** SPEC-5b-1: durable per-run conversation log. Optional — Runs tab + journaling disabled when absent. */
49
+ runLog?: import("../runtime/run-log.ts").RunLog;
48
50
  }
49
51
 
50
52
  /** Build the pi.registerTool definition. Thin wrapper over spawnSubagent. */
@@ -84,7 +86,7 @@ export function createSubagentTool(deps: SubagentToolDeps) {
84
86
  agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
85
87
  skillsOverride: o.skills, backendOverride: o.backend,
86
88
  registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
87
- backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: deps.parentCwd, signal,
89
+ backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: deps.parentCwd, runLog: deps.runLog, signal,
88
90
  }),
89
91
  };
90
92
  const res = await runLifecycle(params.task, params.lifecycle, {
@@ -113,6 +115,7 @@ export function createSubagentTool(deps: SubagentToolDeps) {
113
115
  backendRegistry: deps.backendRegistry,
114
116
  parentModel: deps.parentModel,
115
117
  parentCwd: deps.parentCwd,
118
+ runLog: deps.runLog,
116
119
  signal,
117
120
  onEvent: (e) => {
118
121
  if (ctx?.ui?.setWidget && e.type === "turn_end") {
@@ -8,6 +8,13 @@ export interface PhaseArtifacts {
8
8
  summary: string;
9
9
  }
10
10
 
11
+ /** SPEC-5a robustness: the error variant when the worktree git state is corrupted (e.g. child
12
+ * ran rm -rf .git). runLifecycle treats this as a failed phase (clean abort) instead of an
13
+ * unhandled throw. Matches the artifactDiscovery union in run-lifecycle.ts. */
14
+ export interface PhaseArtifactsError {
15
+ error: string;
16
+ }
17
+
11
18
  function sh(cmd: string, cwd: string): string {
12
19
  return execSync(cmd, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).toString();
13
20
  }
@@ -22,11 +29,19 @@ export class DiffService {
22
29
  *
23
30
  * @param childFinalText the child's final text, truncated to MAX_SUMMARY chars as the prose summary.
24
31
  */
25
- diffPhase(worktreePath: string, baseRef: string, childFinalText = ""): PhaseArtifacts {
26
- const tracked = sh(`git diff --name-only ${baseRef} --`, worktreePath)
27
- .split("\n")
28
- .filter(Boolean);
29
- const status = sh("git status --porcelain", worktreePath);
32
+ diffPhase(worktreePath: string, baseRef: string, childFinalText = ""): PhaseArtifacts | PhaseArtifactsError {
33
+ // SPEC-5a robustness: a child can corrupt its worktree's git state (e.g. rm -rf .git,
34
+ // git init over the worktree link). Catch git failures + return {error} so runLifecycle
35
+ // treats it as a failed phase (clean abort with surfaced cause) instead of an unhandled
36
+ // throw crashing the async runner. Empty diff (no changes) is NOT an error — returns paths: [].
37
+ let tracked: string[] = [];
38
+ let status = "";
39
+ try {
40
+ tracked = sh(`git diff --name-only ${baseRef} --`, worktreePath).split("\n").filter(Boolean);
41
+ status = sh("git status --porcelain", worktreePath);
42
+ } catch (e) {
43
+ return { error: `worktree diff failed: ${(e as Error).message.split("\n").filter(Boolean).pop() ?? (e as Error).message}` };
44
+ }
30
45
  const untracked = status
31
46
  .split("\n")
32
47
  .filter((l) => l.startsWith("?? "))