@getpipher/armory-fleet 0.11.1 → 0.12.1

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.
Files changed (40) hide show
  1. package/README.md +371 -14
  2. package/package.json +1 -1
  3. package/src/engine/concurrency-lock.ts +20 -15
  4. package/src/engine/spawnSubagent.ts +57 -4
  5. package/src/index.ts +114 -7
  6. package/src/panel/fleet-panel.ts +177 -11
  7. package/src/panel/fleet-widget.ts +5 -1
  8. package/src/runtime/reconcile.ts +32 -11
  9. package/src/todo-sync/adapter.ts +22 -3
  10. package/src/tools/fleet.ts +179 -0
  11. package/src/workflows/builtin/adversarial-review.js +19 -0
  12. package/src/workflows/builtin/code-review.js +13 -0
  13. package/src/workflows/builtin/codebase-audit.js +16 -0
  14. package/src/workflows/builtin/deep-research.js +12 -0
  15. package/src/workflows/builtin/multi-perspective.js +17 -0
  16. package/src/workflows/helpers/checkpoint.ts +15 -0
  17. package/src/workflows/helpers/completeness-check.ts +18 -0
  18. package/src/workflows/helpers/gate.ts +22 -0
  19. package/src/workflows/helpers/index.ts +8 -0
  20. package/src/workflows/helpers/judge-panel.ts +33 -0
  21. package/src/workflows/helpers/loop-until-dry.ts +21 -0
  22. package/src/workflows/helpers/retry.ts +17 -0
  23. package/src/workflows/helpers/types.ts +19 -0
  24. package/src/workflows/helpers/verify.ts +27 -0
  25. package/src/workflows/journal.ts +76 -0
  26. package/src/workflows/keyword.ts +22 -0
  27. package/src/workflows/panel/workflows-items.ts +150 -0
  28. package/src/workflows/panel/workflows-rows.ts +3 -0
  29. package/src/workflows/panel-host.ts +179 -0
  30. package/src/workflows/registry.ts +68 -0
  31. package/src/workflows/runner.ts +507 -0
  32. package/src/workflows/runtime/adapters.ts +182 -0
  33. package/src/workflows/runtime/controller.ts +493 -0
  34. package/src/workflows/runtime/hydrate.ts +116 -0
  35. package/src/workflows/runtime/pause-gate.ts +41 -0
  36. package/src/workflows/runtime/run-store.ts +31 -0
  37. package/src/workflows/runtime/save.ts +111 -0
  38. package/src/workflows/runtime/types.ts +78 -0
  39. package/src/workflows/source.ts +156 -0
  40. package/src/workflows/vm-realm.ts +106 -0
package/src/index.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  } from "@earendil-works/pi-coding-agent";
10
10
  import type { Model } from "@earendil-works/pi-ai";
11
11
  import { createSubagentTool, type SubagentToolDeps } from "./tools/subagent.ts";
12
- import { openFleetPanel } from "./panel/fleet-panel.ts";
12
+ // SPEC-6-3: /fleet uses openWorkflowPanelLoop (Task 12) instead of the raw openFleetPanel factory.
13
13
  import { discoverAgents } from "./registry/discovery.ts";
14
14
  import { RunRegistry } from "./engine/run-registry.ts";
15
15
  import { createSingleSlotLock } from "./engine/concurrency-lock.ts";
@@ -49,6 +49,16 @@ import { TierRegistry, mergeTiers } from "./tiers/tier-registry.ts";
49
49
  import { BUILTIN_TIERS } from "./tiers/builtin.ts";
50
50
  import { TierStore } from "./tiers/tier-store.ts";
51
51
  import { splitModel } from "./tiers/resolve.ts";
52
+ import { WorkflowJournal } from "./workflows/journal.ts";
53
+ import { discoverWorkflows, WorkflowRegistry, type WorkflowDef } from "./workflows/registry.ts";
54
+ import { runWorkflow, type WorkflowRunDeps, type WorkflowRunResult } from "./workflows/runner.ts";
55
+ import { scanWorkflowResumeCandidates } from "./runtime/reconcile.ts";
56
+ import { WorkflowController } from "./workflows/runtime/controller.ts";
57
+ import { WorkflowRunStore } from "./workflows/runtime/run-store.ts";
58
+ import { createWorkflowAdapters } from "./workflows/runtime/adapters.ts";
59
+ import { openWorkflowPanelLoop } from "./workflows/panel-host.ts";
60
+ import { workflowKeywordHint } from "./workflows/keyword.ts";
61
+ import { createFleetTool } from "./tools/fleet.ts";
52
62
 
53
63
  /** The package builtin agents/ dir, resolved relative to this module. */
54
64
  function builtinAgentsDir(): string {
@@ -200,6 +210,11 @@ export default async function (pi: ExtensionAPI): Promise<void> {
200
210
  // SPEC-5b-2: the live widget (above editor) controller.
201
211
  // Display-only, independent of the /fleet panel; constructed per-session in session_start.
202
212
  let fleetWidget: FleetWidgetController | null = null;
213
+ // SPEC-6-3: hoisted so /fleet command handler + session_shutdown can reach them.
214
+ let wfController: WorkflowController | null = null;
215
+ let wfStore: WorkflowRunStore | null = null;
216
+ let wfRegistry: WorkflowRegistry | null = null;
217
+ let wfSessionAbort: AbortController | null = null;
203
218
  // The async runner's runLifecycle adapter: call the real runLifecycle with the worktree as the
204
219
  // spawn cwd + override genRunId so the lifecycle runId IS the async runner's runId (Q1=B seam).
205
220
  const asyncRunLifecycle: AsyncRunnerDeps["runLifecycle"] = async (task, lifecycleName, opts) => {
@@ -279,10 +294,15 @@ export default async function (pi: ExtensionAPI): Promise<void> {
279
294
  deps.runLog = new RunLog(join(dir, "conversations"));
280
295
  // v0.10.2: pass the in-memory RunRegistry so reconcile syncs it too — otherwise orphaned
281
296
  // (process-gone) runs keep status:"running" in memory and the live widget shows a stale ▶ forever.
282
- const reconciled = reconcileRuns(deps.runLog, { runRegistry: deps.runRegistry });
283
- if (reconciled.length > 0) {
284
- ctx.ui.notify(`reconciled ${reconciled.length} interrupted fleet run${reconciled.length > 1 ? "s" : ""} (marked aborted)`, "info");
285
- }
297
+ // #22 bg-watchdog: pass todoSync so a process-gone run's linked TODO is reverted to open
298
+ // (retryable) with a WORKER_EXITED_WITHOUT_RESULT note, not stuck in_progress forever.
299
+ // Fire-and-forget (async) so the asyncRunner setup below isn't blocked.
300
+ void reconcileRuns(deps.runLog, { runRegistry: deps.runRegistry, todoSync: deps.todoSync })
301
+ .then((reconciled) => {
302
+ if (reconciled.length > 0) {
303
+ ctx.ui.notify(`reconciled ${reconciled.length} interrupted fleet run${reconciled.length > 1 ? "s" : ""} (marked aborted; linked TODOs reverted to open)`, "info");
304
+ }
305
+ });
286
306
  deps.asyncRunner = {
287
307
  worktree: new WorktreeService({ rootDir: ctx.cwd }),
288
308
  diff: new DiffService(),
@@ -325,6 +345,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
325
345
  getModelContextWindow,
326
346
  cwd: ctx.cwd,
327
347
  runLog: deps.runLog,
348
+ todoSync: deps.todoSync, // #22 bg-watchdog: periodic probe reverts process-gone runs' TODOs
328
349
  });
329
350
  fleetWidget.start();
330
351
 
@@ -342,10 +363,86 @@ export default async function (pi: ExtensionAPI): Promise<void> {
342
363
  };
343
364
  deps.reloadTiers = reloadTiers;
344
365
  reloadTiers(); // build the real merged registry (replaces the builtin-only placeholder)
366
+
367
+ // SPEC-6-3: workflow journal + registry + runner + fleet tool wiring.
368
+ const workflowJournal = new WorkflowJournal(join(dir, "workflows"));
369
+ wfRegistry = new WorkflowRegistry(discoverWorkflows({
370
+ projectDir: join(ctx.cwd, ".pi", "fleet", "workflows"),
371
+ globalDir: join(process.env.HOME ?? "", ".pi", "agent", "fleet", "workflows"),
372
+ builtinDir: join(new URL(".", import.meta.url).pathname, "workflows", "builtin"),
373
+ }).workflows);
374
+ const workflowRegistry = wfRegistry;
375
+ // SPEC-6-3: production workflow child/lifecycle adapters (Task 5). Replace the inline spawn stub
376
+ // with the adapter factory so workflow-internal parallel calls use the per-workflow ConcurrencyPool
377
+ // + fresh per-child locks (not the foreground singleton), and lifecycle phase spawns share the pool.
378
+ wfSessionAbort = new AbortController();
379
+ const adapters = createWorkflowAdapters({
380
+ registry: deps.registry as unknown as Map<string, unknown>,
381
+ todoSync: deps.todoSync,
382
+ runRegistry: deps.runRegistry,
383
+ backendRegistry: deps.backendRegistry,
384
+ parentModel: deps.parentModel,
385
+ parentCwd: ctx.cwd,
386
+ runLog: deps.runLog,
387
+ tierRegistry: deps.tierRegistry,
388
+ modelRegistry: deps.modelRegistry,
389
+ lifecycleDeps: deps.lifecycleDeps,
390
+ spawnSubagentFn: async (opts) => { const { spawnSubagent } = await import("./engine/spawnSubagent.ts"); return spawnSubagent(opts); },
391
+ runLifecycleFn: async (task, name, lcOpts) => { const { runLifecycle } = await import("./lifecycle/run-lifecycle.ts"); return runLifecycle(task, name, lcOpts); },
392
+ }, { concurrency: 3, signal: wfSessionAbort.signal });
393
+ const wfRunnerDeps: WorkflowRunDeps = {
394
+ spawn: adapters.spawn,
395
+ runLifecycle: adapters.runLifecycle,
396
+ worktree: deps.asyncRunner.worktree,
397
+ tierRegistry: deps.tierRegistry ?? new TierRegistry({ tiers: BUILTIN_TIERS, agents: deps.registry }),
398
+ journal: workflowJournal,
399
+ runRegistry: deps.runRegistry,
400
+ getModelContextWindow: (m: string) => deps.getModelContextWindow?.(m),
401
+ genRunId: () => "wf-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8),
402
+ notify: (m: string, l?: "info" | "warning" | "error") => ctx.ui.notify(m, l ?? "info"),
403
+ onCheckpoint: async () => undefined,
404
+ resolveWorkflow: (name: string) => workflowRegistry.get(name) as { sourceText: string; executable: string } | undefined,
405
+ };
406
+ wfStore = new WorkflowRunStore();
407
+ wfController = new WorkflowController({
408
+ registry: workflowRegistry,
409
+ projectDir: join(ctx.cwd, ".pi", "fleet", "workflows"),
410
+ store: wfStore,
411
+ journal: workflowJournal,
412
+ runWorkflow,
413
+ runDepsFactory: () => wfRunnerDeps,
414
+ inbox: resultsInbox,
415
+ genRunId: wfRunnerDeps.genRunId,
416
+ notify: (m: string, l?: "info" | "warning" | "error") => ctx.ui.notify(m, l ?? "info"),
417
+ });
418
+ wfController.hydrate();
419
+ const wfCands = scanWorkflowResumeCandidates(join(dir, "workflows"));
420
+ if (wfCands.length > 0) {
421
+ ctx.ui.notify(`${wfCands.length} interrupted workflow${wfCands.length > 1 ? "s" : ""} — open /fleet Workflows to resume`, "info");
422
+ }
423
+ pi.registerTool(createFleetTool({
424
+ getController: () => {
425
+ if (!wfController) throw new Error("workflow runtime not initialized for this session");
426
+ return wfController;
427
+ },
428
+ }) as never);
345
429
  });
346
430
 
347
431
  pi.on("session_shutdown", () => {
348
432
  if (fleetWidget) { fleetWidget.dispose(); fleetWidget = null; }
433
+ // SPEC-6-3: abort in-flight workflow children via the session-wide adapter signal.
434
+ // Terminal runs are not re-journaled — only non-terminal spawns observe the abort.
435
+ wfSessionAbort?.abort();
436
+ });
437
+
438
+ // SPEC-6-3: inject bounded ResultsInbox + workflow-keyword hints into the system prompt without
439
+ // consuming the inbox. renderHint() is read-only; pull() consumes (left to the model's fleet.results).
440
+ pi.on("before_agent_start", async (event) => {
441
+ const hint = resultsInbox.renderHint();
442
+ const kw = workflowKeywordHint(event.prompt) ?? "";
443
+ if (!hint && !kw) return undefined;
444
+ const block = [hint, kw].filter(Boolean).join("\n");
445
+ return { systemPrompt: event.systemPrompt + "\n\n" + block };
349
446
  });
350
447
 
351
448
  pi.on("resources_discover", (event, ctx) => {
@@ -364,7 +461,17 @@ export default async function (pi: ExtensionAPI): Promise<void> {
364
461
  ctx.ui.notify("fleet panel is TUI-only; use the subagent tool in non-interactive modes.", "info");
365
462
  return;
366
463
  }
367
- openFleetPanel(deps, ctx as never);
464
+ await openWorkflowPanelLoop(
465
+ { ...deps, workflowController: wfController!, workflowStore: wfStore!, workflowRegistry: wfRegistry! },
466
+ {
467
+ custom: (factory) => { ctx.ui.custom(factory as never); },
468
+ editor: (initial) => ctx.ui.editor("Edit workflow source", initial) as Promise<string>,
469
+ input: (prompt) => ctx.ui.input(prompt) as Promise<string>,
470
+ confirm: (prompt) => ctx.ui.confirm(prompt, "Proceed?") as Promise<boolean>,
471
+ notify: (m, t) => ctx.ui.notify(m, t ?? "info"),
472
+ sendUserMessage: (text) => { void pi.sendUserMessage(text); },
473
+ },
474
+ );
368
475
  },
369
476
  });
370
477
 
@@ -423,4 +530,4 @@ export default async function (pi: ExtensionAPI): Promise<void> {
423
530
  ctx.ui.notify("fleet-register-gate: custom gates must be registered via the GateRegistry module export (see src/lifecycle/gates/registry.ts).", "info");
424
531
  },
425
532
  });
426
- }
533
+ }
@@ -29,8 +29,13 @@ import { runLifecycle } from "../lifecycle/run-lifecycle.ts";
29
29
  import type { TierRegistry } from "../tiers/tier-registry.ts";
30
30
  import type { TierStore } from "../tiers/tier-store.ts";
31
31
  import { buildTiersItems, setTierCostCap, setTierModels, setTierContextFloor, addTier, deleteTier } from "./tiers-items.ts";
32
+ import { buildWorkflowPanelItems, actionsForWorkflowItem, parseWorkflowPanelKey, type WorkflowPanelItem, type WorkflowPanelAction } from "../workflows/panel/workflows-rows.ts";
33
+ import type { WorkflowController } from "../workflows/runtime/controller.ts";
34
+ import type { WorkflowRunStore } from "../workflows/runtime/run-store.ts";
35
+ import type { WorkflowRegistry } from "../workflows/registry.ts";
36
+ import type { WorkflowPanelIntent } from "../workflows/panel-host.ts";
32
37
 
33
- type View = "fleet" | "lifecycle" | "runs" | "agents" | "backends" | "scheduled" | "tiers";
38
+ type View = "fleet" | "lifecycle" | "runs" | "agents" | "backends" | "scheduled" | "tiers" | "workflows";
34
39
 
35
40
  export interface FleetPanelDeps {
36
41
  registry: Map<string, AgentDef>;
@@ -58,19 +63,25 @@ export interface FleetPanelDeps {
58
63
  reloadTiers?: () => void;
59
64
  /** SPEC-6-1: model contextWindow resolver for Runs-tab ctx% (Surface C). Optional — ctx% hidden when absent. */
60
65
  getModelContextWindow?: (model: string) => number | undefined;
66
+ /** SPEC-6-3: live workflow controller + store + registry for the Workflows view. */
67
+ workflowController: WorkflowController;
68
+ workflowStore: WorkflowRunStore;
69
+ workflowRegistry: WorkflowRegistry;
70
+ /** SPEC-6-3: panel intent callback for host-only actions (Task 12 wires). */
71
+ onWorkflowIntent?: (intent: { action: string; runId?: string; definitionName?: string }) => void;
61
72
  }
62
73
 
63
74
  export interface FleetPanelOpts {
64
75
  theme: Theme;
65
76
  deps: FleetPanelDeps;
66
- onDone: () => void;
77
+ onDone: (intent?: WorkflowPanelIntent | null) => void;
67
78
  onNotify: (msg: string, type?: "info" | "warning" | "error") => void;
68
79
  }
69
80
 
70
81
  export class FleetPanel extends Container {
71
82
  private readonly theme: Theme;
72
83
  private readonly deps: FleetPanelDeps;
73
- private readonly onDone: () => void;
84
+ private readonly onDone: (intent?: WorkflowPanelIntent | null) => void;
74
85
  private readonly onNotify: (msg: string, type?: "info" | "warning" | "error") => void;
75
86
  private view: View = "fleet";
76
87
  private list: SelectList;
@@ -120,6 +131,10 @@ export class FleetPanel extends Container {
120
131
  * so the panel re-renders the moment a (fore- or back-ground) run mutates, without a keypress. */
121
132
  private readonly unsubs: (() => void)[] = [];
122
133
  private closed = false; // SPEC-5a proper-fix: guard against double-close calling onDone twice
134
+ // SPEC-6-3: Workflows view — inline Run prompt input state
135
+ private wfRunMode = false;
136
+ private wfPromptInput: Input | null = null;
137
+ private wfRunDefinitionName = "";
123
138
 
124
139
  constructor(opts: FleetPanelOpts) {
125
140
  super();
@@ -139,6 +154,8 @@ export class FleetPanel extends Container {
139
154
  // and the async/bg case (onProgress mutates BgRunsStore while the parent is idle).
140
155
  this.unsubs.push(this.deps.runRegistry.subscribe(() => this.refresh()));
141
156
  if (this.deps.bgRuns) this.unsubs.push(this.deps.bgRuns.subscribe(() => this.refresh()));
157
+ // SPEC-6-3: subscribe to workflow store mutations → live Workflows view refresh.
158
+ this.unsubs.push(this.deps.workflowStore.subscribe(() => this.refresh()));
142
159
  }
143
160
 
144
161
  private buildList(): SelectList {
@@ -155,7 +172,9 @@ export class FleetPanel extends Container {
155
172
  ? (this.deps.scheduler?.list() ?? []).map((s: Schedule) => ({ value: s.id, label: scheduleRow(s) }))
156
173
  : this.view === "tiers"
157
174
  ? (this.deps.tierRegistry ? buildTiersItems({ tierRegistry: this.deps.tierRegistry, runRegistry: this.deps.runRegistry }) : [])
158
- : this.deps.backendRegistry.list().map((b: Backend) => ({ value: b.id, label: backendsRow(b) }));
175
+ : this.view === "workflows"
176
+ ? buildWorkflowPanelItems({ definitions: this.deps.workflowRegistry.list(), runs: this.deps.workflowStore.values() })
177
+ : this.deps.backendRegistry.list().map((b: Backend) => ({ value: b.id, label: backendsRow(b) }));
159
178
  const fresh = new SelectList(items, 12, {
160
179
  selectedPrefix: (s: string) => this.theme.fg("accent", s),
161
180
  selectedText: (s: string) => this.theme.fg("accent", s),
@@ -180,12 +199,12 @@ export class FleetPanel extends Container {
180
199
  /** SPEC-5a proper-fix: tear down store subscriptions then close the panel.
181
200
  * Every exit path routes here so listeners never leak past the panel's lifetime.
182
201
  * Idempotent — safe to call multiple times (esc + q + pi teardown). */
183
- private close(): void {
202
+ private close(intent?: WorkflowPanelIntent | null): void {
184
203
  if (this.closed) return;
185
204
  this.closed = true;
186
205
  for (const u of this.unsubs) u();
187
206
  this.unsubs.length = 0;
188
- this.onDone();
207
+ this.onDone(intent ?? null);
189
208
  }
190
209
 
191
210
  private renderShell(): void {
@@ -193,7 +212,7 @@ export class FleetPanel extends Container {
193
212
  this.children.length = 0;
194
213
  this.children.push(...keep);
195
214
  const accent = (s: string): string => this.theme.fg("accent", s);
196
- const tabs = (["fleet", "lifecycle", "runs", "agents", "backends", "scheduled", "tiers"] as View[])
215
+ const tabs = (["fleet", "lifecycle", "runs", "agents", "backends", "scheduled", "tiers", "workflows"] as View[])
197
216
  .map((v) => (v === this.view ? this.theme.fg("accent", this.theme.bold(`[${v}]`)) : this.theme.fg("dim", v)))
198
217
  .join(" ");
199
218
  this.addChild(new Text(accent(this.theme.bold(" FLEET")) + " " + tabs, 0, 0));
@@ -309,6 +328,11 @@ export class FleetPanel extends Container {
309
328
  this.addChild(tl);
310
329
  }
311
330
  this.addChild(new Text(this.theme.fg("dim", " enter:Full-message esc:Back"), 0, 0));
331
+ } else if (this.wfRunMode && this.wfPromptInput) {
332
+ // SPEC-6-3: Workflows tab — inline Run prompt input.
333
+ this.addChild(new Text(this.theme.fg("accent", ` run ${this.wfRunDefinitionName}> `), 0, 0));
334
+ this.addChild(this.wfPromptInput);
335
+ this.addChild(new Text(this.theme.fg("dim", " enter submit (blank=direct run) • esc cancel"), 0, 0));
312
336
  } else if (this.steerMode && this.steerInput) {
313
337
  // SPEC-5b-4: Fleet tab — mid-run steer input.
314
338
  this.addChild(new Text(this.theme.fg("accent", " steer> "), 0, 0));
@@ -374,8 +398,10 @@ export class FleetPanel extends Container {
374
398
  : this.view === "scheduled"
375
399
  ? " a:Add p:Pause/resume d:Delete i:Info tab:Tiers q:Quit"
376
400
  : this.view === "tiers"
377
- ? " m:Models c:costCap f:contextFloor a:Add d:Delete g:scope tab:Fleet q:Quit"
378
- : " r:Refresh i:Info tab:Fleet q:Quit";
401
+ ? " m:Models c:costCap f:contextFloor a:Add d:Delete g:scope tab:Workflows q:Quit"
402
+ : this.view === "workflows"
403
+ ? " r:Run e:Edit-and-resume o:Open p:Pause u:Resume x:Stop s:Save-as v:View-result tab:Fleet q:Quit"
404
+ : " r:Refresh i:Info tab:Fleet q:Quit";
379
405
  this.addChild(new Text(this.theme.fg("dim", hint), 0, 0));
380
406
  this.addChild(new Spacer(1));
381
407
  this.addChild(new DynamicBorder(accent));
@@ -446,7 +472,7 @@ export class FleetPanel extends Container {
446
472
  : this.view === "lifecycle" ? "runs"
447
473
  : this.view === "runs" ? "agents"
448
474
  : this.view === "agents" ? "backends"
449
- : this.view === "backends" ? "scheduled" : this.view === "scheduled" ? "tiers" : "fleet";
475
+ : this.view === "backends" ? "scheduled" : this.view === "scheduled" ? "tiers" : this.view === "tiers" ? "workflows" : "fleet";
450
476
  this.selectedBackend = null;
451
477
  this.selectedLifecycle = null;
452
478
  this.selectedSchedule = null;
@@ -530,6 +556,12 @@ export class FleetPanel extends Container {
530
556
  this.invalidate();
531
557
  return;
532
558
  }
559
+ if (this.wfRunMode && this.wfPromptInput) {
560
+ if (matchesKey(data, "escape")) { this.cancelWorkflowRun(); return; }
561
+ this.wfPromptInput.handleInput(data);
562
+ this.invalidate();
563
+ return;
564
+ }
533
565
  if (this.steerMode && this.steerInput) {
534
566
  if (matchesKey(data, "escape")) { this.cancelSteer(); return; }
535
567
  this.steerInput.handleInput(data);
@@ -663,6 +695,107 @@ export class FleetPanel extends Container {
663
695
  return;
664
696
  }
665
697
  }
698
+ // SPEC-6-3: Workflows view — direct p/u/x controls + host-only intent completion
699
+ if (this.view === "workflows") {
700
+ // #27: classify the key FIRST. Non-action keys (Down/Up/PageUp/PageDown) are forwarded
701
+ // to the list BEFORE the sel check, so nav still works when no row is selected (empty-list
702
+ // edge case the reviewer flagged — the prior `if (!sel) return` at the top swallowed nav
703
+ // keys the same way the original `if (!action) return` did).
704
+ const keyAction: Record<string, WorkflowPanelAction> = {
705
+ r: "run", e: "edit-resume", o: "open", p: "pause", u: "resume", x: "stop", s: "save", v: "view-result", c: "respond",
706
+ }
707
+ const action = keyAction[data]
708
+ if (!action) {
709
+ // Not a workflow action key — forward to the list so Down/Up/PageUp/PageDown move the
710
+ // selection cursor (#27). Without this, every non-action key was swallowed here and the
711
+ // bottom-of-handleInput `this.list.handleInput(data)` was never reached for Workflows,
712
+ // so the → cursor could never move off the first row (blocking all run-row actions).
713
+ this.list.handleInput(data)
714
+ this.invalidate()
715
+ return
716
+ }
717
+ const sel = this.list.getSelectedItem()
718
+ if (!sel) return // an action key was pressed but there's no row to act on
719
+ const parsed = parseWorkflowPanelKey(sel.value)
720
+ const item: WorkflowPanelItem = parsed.kind === "definition"
721
+ ? { kind: "definition", definition: this.deps.workflowRegistry.get(parsed.name) ?? { name: parsed.name, description: "", phases: [], sourceText: "", body: "", executable: "", source: "builtin", filePath: "" } }
722
+ : { kind: "run", run: this.deps.workflowStore.get(parsed.runId) ?? { runId: parsed.runId, name: parsed.runId, script: "", mode: "auto", status: "completed", startedAt: 0, currentPhase: "default", phases: [], childRunIds: [], logs: [], tokenTotal: 0, costTotal: 0 } }
723
+ const available = actionsForWorkflowItem(item)
724
+
725
+ if (!available.includes(action)) {
726
+ this.onNotify(`action '${action}' not available for this item`, "warning")
727
+ return
728
+ }
729
+
730
+ // Direct controls (no panel close)
731
+ if (action === "pause") {
732
+ if (parsed.kind === "run") this.deps.workflowController.pause(parsed.runId)
733
+ return
734
+ }
735
+ if (action === "resume") {
736
+ if (parsed.kind === "run") {
737
+ void this.deps.workflowController.resume(parsed.runId).then(() => {
738
+ this.refresh()
739
+ }).catch((e: unknown) => {
740
+ this.onNotify(`resume failed: ${(e as Error).message}`, "error")
741
+ })
742
+ }
743
+ return
744
+ }
745
+ if (action === "stop") {
746
+ if (parsed.kind === "run") {
747
+ void this.deps.workflowController.stop(parsed.runId).then(() => {
748
+ this.refresh()
749
+ }).catch((e: unknown) => {
750
+ this.onNotify(`stop failed: ${(e as Error).message}`, "error")
751
+ })
752
+ }
753
+ return
754
+ }
755
+
756
+ // Run action: inline prompt input for definitions
757
+ if (action === "run" && parsed.kind === "definition") {
758
+ this.startWorkflowRun(parsed.name)
759
+ return
760
+ }
761
+
762
+ // Host-only actions: close panel with intent (Task 12 host loop handles them)
763
+ if (action === "run" && parsed.kind === "run") {
764
+ this.close({ action: "run", definitionName: parsed.runId, prompt: "" })
765
+ return
766
+ }
767
+ if (action === "edit-resume") {
768
+ if (parsed.kind === "run") this.close({ action: "edit-resume", runId: parsed.runId })
769
+ return
770
+ }
771
+ if (action === "open") {
772
+ if (parsed.kind === "definition") {
773
+ this.close({ action: "open-definition", name: parsed.name })
774
+ } else {
775
+ const run = this.deps.workflowStore.get(parsed.runId)
776
+ const childId = run?.childRunIds[0]
777
+ if (childId) {
778
+ this.close({ action: "open-child", runId: parsed.runId, childRunId: childId })
779
+ } else {
780
+ this.onNotify(`run '${parsed.runId}' has no child runs to open`, "info")
781
+ }
782
+ }
783
+ return
784
+ }
785
+ if (action === "save") {
786
+ if (parsed.kind === "run") this.close({ action: "save", runId: parsed.runId })
787
+ return
788
+ }
789
+ if (action === "view-result") {
790
+ if (parsed.kind === "run") this.close({ action: "view-result", runId: parsed.runId })
791
+ return
792
+ }
793
+ if (action === "respond") {
794
+ if (parsed.kind === "run") this.close({ action: "respond", runId: parsed.runId })
795
+ return
796
+ }
797
+ return
798
+ }
666
799
  // SPEC-4: pending checkpoint keys (c/v/a)
667
800
  if (this.pendingCheckpoint && !this.lcRevising) {
668
801
  if (matchesKey(data, "c")) { this.pendingCheckpoint.resolve({ action: "continue" }); this.pendingCheckpoint = null; this.renderShell(); return; }
@@ -840,6 +973,39 @@ export class FleetPanel extends Container {
840
973
  this.renderShell();
841
974
  }
842
975
 
976
+ // ───────────────────────────────── SPEC-6-3: Workflows Run prompt ─────────────────────────────────
977
+
978
+ private startWorkflowRun(definitionName: string): void {
979
+ this.wfRunDefinitionName = definitionName;
980
+ this.wfPromptInput = new Input();
981
+ this.wfPromptInput.onSubmit = (prompt: string) => {
982
+ if (prompt.trim()) {
983
+ this.close({ action: "run", definitionName: this.wfRunDefinitionName, prompt: prompt.trim() });
984
+ } else {
985
+ // Blank prompt → execute the definition directly
986
+ void this.deps.workflowController.start({
987
+ workflowName: this.wfRunDefinitionName,
988
+ mode: "checkpointed",
989
+ }).then(() => {
990
+ this.refresh();
991
+ }).catch((e: unknown) => {
992
+ this.onNotify(`start failed: ${(e as Error).message}`, "error");
993
+ });
994
+ }
995
+ this.cancelWorkflowRun();
996
+ };
997
+ this.wfPromptInput.onEscape = () => this.cancelWorkflowRun();
998
+ this.wfRunMode = true;
999
+ this.renderShell();
1000
+ }
1001
+
1002
+ private cancelWorkflowRun(): void {
1003
+ this.wfRunMode = false;
1004
+ this.wfPromptInput = null;
1005
+ this.wfRunDefinitionName = "";
1006
+ this.renderShell();
1007
+ }
1008
+
843
1009
  private cancelTiersEdit(): void {
844
1010
  this.tiersEditPhase = null;
845
1011
  this.tiersInput = null;
@@ -1031,4 +1197,4 @@ export function openFleetPanel(
1031
1197
  ctx.ui.custom((_tui, theme, _kb, done) => {
1032
1198
  return new FleetPanel({ theme, deps, onDone: done, onNotify: (m, t) => ctx.ui.notify(m, t) });
1033
1199
  });
1034
- }
1200
+ }
@@ -46,6 +46,9 @@ export interface FleetWidgetDeps {
46
46
  cwd?: string;
47
47
  /** SPEC-6-2: RunLog for the periodic liveness probe (reconcileRuns). Optional — absent → no periodic probe. */
48
48
  runLog?: import("../runtime/run-log.ts").RunLog;
49
+ /** #22 bg-watchdog: when wired, the periodic probe reverts a process-gone run's linked TODO to
50
+ * open (retryable) with a WORKER_EXITED_WITHOUT_RESULT note. Optional — absent → no TODO transition. */
51
+ todoSync?: import("../todo-sync/port.ts").TodoSyncPort;
49
52
  }
50
53
 
51
54
  export class FleetWidgetController {
@@ -69,9 +72,10 @@ export class FleetWidgetController {
69
72
  this.unsubs.push(this.deps.runRegistry.subscribe(() => this.render()));
70
73
  if (this.deps.bgRuns) this.unsubs.push(this.deps.bgRuns.subscribe(() => this.render()));
71
74
  // SPEC-6-2: periodic liveness probe — reconciles dead orphans every 60s.
75
+ // #22 bg-watchdog: pass todoSync so a process-gone run's TODO is reverted (not stuck in_progress).
72
76
  if (this.deps.runLog) {
73
77
  this.livenessTimerId = this.setIntervalFn(() => {
74
- reconcileRuns(this.deps.runLog!, { runRegistry: this.deps.runRegistry });
78
+ void reconcileRuns(this.deps.runLog!, { runRegistry: this.deps.runRegistry, todoSync: this.deps.todoSync }).catch(() => {});
75
79
  }, 60_000);
76
80
  (this.livenessTimerId as { unref?: () => void }).unref?.();
77
81
  }
@@ -1,14 +1,10 @@
1
1
  // src/runtime/reconcile.ts
2
- // SPEC-5b-1on 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
- //
6
- // SPEC-6-2: rewritten to be probe-driven — uses `probeRun` (handle/pid/age-fallback) instead of
7
- // age+grace alone. This catches orphans whose process died but whose startedAt is within grace
8
- // (e.g. a crash seconds after start), and avoids aborting runs whose process is alive but old
9
- // (e.g. a long-running build).
2
+ // SPEC-6-3restart-recovery: scan workflow journals for non-terminal runs
3
+ // (interrupted workflows become resume candidates in the Workflows view).
4
+ import { WorkflowJournal } from "../workflows/journal.ts";
10
5
  import type { RunLog } from "./run-log.ts";
11
6
  import type { RunRegistry, RunRecord } from "../engine/run-registry.ts";
7
+ import type { TodoSyncPort } from "../todo-sync/port.ts";
12
8
 
13
9
  export type Liveness = "alive" | "dead";
14
10
 
@@ -30,13 +26,20 @@ export interface ReconcileOpts {
30
26
  graceMs?: number;
31
27
  now?: number;
32
28
  runRegistry?: RunRegistry;
29
+ /** #22 bg-watchdog: when wired, a process-gone run's linked TODO is reverted to open
30
+ * (retryable) with a WORKER_EXITED_WITHOUT_RESULT note, so it doesn't stay in_progress
31
+ * forever after a worker exits without a terminal record. Best-effort (the run is already
32
+ * marked aborted in the log + registry). */
33
+ todoSync?: TodoSyncPort;
33
34
  }
34
35
 
35
- /** Returns the runIds it marked aborted. Probe-driven (SPEC-6-2); idempotent. */
36
- export function reconcileRuns(log: RunLog, opts: ReconcileOpts = {}): string[] {
36
+ /** Returns the runIds it marked aborted. Probe-driven (SPEC-6-2); idempotent.
37
+ * #22: async awaits the best-effort TODO transition for each aborted run with a todoId. */
38
+ export async function reconcileRuns(log: RunLog, opts: ReconcileOpts = {}): Promise<string[]> {
37
39
  const grace = opts.graceMs ?? 60_000;
38
40
  const now = opts.now ?? Date.now();
39
41
  const reg = opts.runRegistry;
42
+ const todoSync = opts.todoSync;
40
43
  const aborted: string[] = [];
41
44
  for (const meta of log.scanMeta()) {
42
45
  if (meta.status !== "running") continue;
@@ -49,7 +52,25 @@ export function reconcileRuns(log: RunLog, opts: ReconcileOpts = {}): string[] {
49
52
  endedAt: now, resultSummary: "process-gone (probe)", tokenTotal: meta.tokenTotal,
50
53
  });
51
54
  reg?.update(meta.runId, { status: "aborted", endedAt: now });
55
+ // #22 bg-watchdog: transition the linked TODO so a worker that exited without a terminal
56
+ // record doesn't leave its fleet TODO stuck in_progress forever. markRunTodoReverted with
57
+ // priorStatus=undefined reverts a fleet-created TODO to open (retryable) + appends the
58
+ // reason note. (The link path only accepts open/in_progress TODOs, so reverting to open is
59
+ // the correct recovery for a linked one too — its prior was open/in_progress.)
60
+ if (meta.todoId && todoSync) {
61
+ try {
62
+ await todoSync.markRunTodoReverted(meta.todoId, undefined, "WORKER_EXITED_WITHOUT_RESULT: process gone (probe)");
63
+ } catch { /* best-effort: the run is already marked aborted in the log + registry */ }
64
+ }
52
65
  aborted.push(meta.runId);
53
66
  }
54
67
  return aborted;
55
- }
68
+ }
69
+
70
+ /** SPEC-6-3 — on pi start, scan .pi/fleet/workflows/ for non-terminal workflow journals.
71
+ * Returns runIds whose journal has no wf:completed/wf:aborted event — these are
72
+ * interrupted workflows that the Workflows view surfaces as resume candidates. */
73
+ export function scanWorkflowResumeCandidates(workflowsDir: string): string[] {
74
+ const journal = new WorkflowJournal(workflowsDir);
75
+ return journal.scanNonTerminal();
76
+ }
@@ -12,11 +12,30 @@ const FLEET_SOURCE = "armory-fleet";
12
12
  const FLEET_TAG = "fleet-run";
13
13
  const OPEN_STATES: Status[] = ["open", "in_progress"];
14
14
 
15
+ /** #34: cap the task excerpt written into the TODO notes well below armory-todo's maxNotesBytes
16
+ * (8192). The full task already lives in the run-log + the fleet run record; the TODO is a
17
+ * tracking stub, not a transcript. 1024 leaves ~7KB headroom for the fleet-run header + the
18
+ * progress/done/reverted appends during the run. */
19
+ const TASK_EXCERPT_CAP = 1024;
20
+ /** Cap appended note lines (done-result / reverted-reason). The #25 turn-budget partial can be
21
+ * ~4000 chars; appending it verbatim would accumulate toward the cap on retries. */
22
+ const NOTE_LINE_CAP = 500;
23
+
15
24
  function titleFor(run: RunMeta): string {
16
25
  const raw = `[${run.agent}] ${run.task}`.trim();
17
26
  return raw.length > 120 ? raw.slice(0, 117) + "…" : raw;
18
27
  }
19
28
 
29
+ function taskExcerptFor(task: string): string {
30
+ return task.length > TASK_EXCERPT_CAP
31
+ ? task.slice(0, TASK_EXCERPT_CAP) + "…[truncated; full task in run-log]"
32
+ : task;
33
+ }
34
+
35
+ function capNoteLine(s: string): string {
36
+ return s.length > NOTE_LINE_CAP ? s.slice(0, NOTE_LINE_CAP) + "…" : s;
37
+ }
38
+
20
39
  /** Append a note line to a todo (read-then-write; updateTodo replaces notes). */
21
40
  function appendNote(id: string, line: string): void {
22
41
  const t = getTodo(id);
@@ -54,7 +73,7 @@ export class ArmoryTodoAdapter implements TodoSyncPort {
54
73
  source: FLEET_SOURCE,
55
74
  priority: "med",
56
75
  tags: [FLEET_TAG],
57
- notes: `fleet-run:${run.runId}\n\nTask: ${run.task}`,
76
+ notes: `fleet-run:${run.runId}\n\nTask: ${taskExcerptFor(run.task)}`,
58
77
  });
59
78
  updateTodo(created.id, { status: "in_progress" });
60
79
  return { todoId: created.id }; // priorStatus undefined -> created
@@ -69,7 +88,7 @@ export class ArmoryTodoAdapter implements TodoSyncPort {
69
88
  // linked -> restore prior (user owns the close)
70
89
  updateTodo(todoId, { status: priorStatus as Status });
71
90
  }
72
- appendNote(todoId, `fleet-run done: ${result}`);
91
+ appendNote(todoId, `fleet-run done: ${capNoteLine(result)}`);
73
92
  }
74
93
 
75
94
  async markRunTodoReverted(todoId: string | null, priorStatus: string | undefined, reason: string): Promise<void> {
@@ -79,7 +98,7 @@ export class ArmoryTodoAdapter implements TodoSyncPort {
79
98
  } else {
80
99
  updateTodo(todoId, { status: priorStatus as Status });
81
100
  }
82
- appendNote(todoId, `fleet-run reverted: ${reason}`);
101
+ appendNote(todoId, `fleet-run reverted: ${capNoteLine(reason)}`);
83
102
  }
84
103
 
85
104
  async updateLifecycleProgress(todoId: string, progressBlock: string): Promise<void> {