@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.
- package/README.md +371 -14
- package/package.json +1 -1
- package/src/engine/concurrency-lock.ts +20 -15
- package/src/engine/spawnSubagent.ts +57 -4
- package/src/index.ts +114 -7
- package/src/panel/fleet-panel.ts +177 -11
- package/src/panel/fleet-widget.ts +5 -1
- package/src/runtime/reconcile.ts +32 -11
- package/src/todo-sync/adapter.ts +22 -3
- package/src/tools/fleet.ts +179 -0
- package/src/workflows/builtin/adversarial-review.js +19 -0
- package/src/workflows/builtin/code-review.js +13 -0
- package/src/workflows/builtin/codebase-audit.js +16 -0
- package/src/workflows/builtin/deep-research.js +12 -0
- package/src/workflows/builtin/multi-perspective.js +17 -0
- package/src/workflows/helpers/checkpoint.ts +15 -0
- package/src/workflows/helpers/completeness-check.ts +18 -0
- package/src/workflows/helpers/gate.ts +22 -0
- package/src/workflows/helpers/index.ts +8 -0
- package/src/workflows/helpers/judge-panel.ts +33 -0
- package/src/workflows/helpers/loop-until-dry.ts +21 -0
- package/src/workflows/helpers/retry.ts +17 -0
- package/src/workflows/helpers/types.ts +19 -0
- package/src/workflows/helpers/verify.ts +27 -0
- package/src/workflows/journal.ts +76 -0
- package/src/workflows/keyword.ts +22 -0
- package/src/workflows/panel/workflows-items.ts +150 -0
- package/src/workflows/panel/workflows-rows.ts +3 -0
- package/src/workflows/panel-host.ts +179 -0
- package/src/workflows/registry.ts +68 -0
- package/src/workflows/runner.ts +507 -0
- package/src/workflows/runtime/adapters.ts +182 -0
- package/src/workflows/runtime/controller.ts +493 -0
- package/src/workflows/runtime/hydrate.ts +116 -0
- package/src/workflows/runtime/pause-gate.ts +41 -0
- package/src/workflows/runtime/run-store.ts +31 -0
- package/src/workflows/runtime/save.ts +111 -0
- package/src/workflows/runtime/types.ts +78 -0
- package/src/workflows/source.ts +156 -0
- 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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/panel/fleet-panel.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
378
|
-
|
|
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
|
}
|
package/src/runtime/reconcile.ts
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
// src/runtime/reconcile.ts
|
|
2
|
-
// SPEC-
|
|
3
|
-
//
|
|
4
|
-
|
|
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-3 — restart-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
|
-
|
|
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
|
+
}
|
package/src/todo-sync/adapter.ts
CHANGED
|
@@ -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> {
|