@getpipher/armory-fleet 0.11.0 → 0.12.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 +1 -1
- package/src/engine/concurrency-lock.ts +20 -15
- package/src/engine/spawnSubagent.ts +8 -2
- package/src/index.ts +112 -8
- package/src/panel/fleet-panel.ts +165 -11
- package/src/runtime/async-runner.ts +79 -25
- package/src/runtime/reconcile.ts +12 -9
- package/src/runtime/resume.ts +21 -15
- package/src/runtime/run-journal.ts +2 -2
- package/src/scheduling/scheduler.ts +4 -0
- package/src/tools/fleet.ts +179 -0
- package/src/tools/subagent.ts +8 -2
- 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/worktree/worktree-service.ts +10 -0
|
@@ -23,8 +23,8 @@ export interface FakeLifecycleResult {
|
|
|
23
23
|
|
|
24
24
|
export interface RunLifecycleOpts {
|
|
25
25
|
runId: string;
|
|
26
|
-
worktreePath
|
|
27
|
-
branch
|
|
26
|
+
worktreePath?: string;
|
|
27
|
+
branch?: string;
|
|
28
28
|
mode: "auto" | "checkpointed";
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -49,12 +49,22 @@ export interface RunBackgroundOpts {
|
|
|
49
49
|
deps: AsyncRunnerDeps;
|
|
50
50
|
lifecycle: string;
|
|
51
51
|
mode: "auto" | "checkpointed";
|
|
52
|
+
/** v0.11.1: edit isolation for background runs. Default "auto" (worktree when cwd is a git repo, in-place otherwise). */
|
|
53
|
+
isolation?: Isolation;
|
|
52
54
|
}
|
|
53
55
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
/** The success shape (back-compat: existing external refs to RunBackgroundHandle still typecheck). */
|
|
57
|
+
export interface RunBackgroundHandle { runId: string; status: "background"; }
|
|
58
|
+
|
|
59
|
+
/** v0.11.1: a background dispatch either starts (runId + background) or fails synchronously (error, no runId). */
|
|
60
|
+
export type RunBackgroundResult =
|
|
61
|
+
| { runId: string; status: "background" }
|
|
62
|
+
| { status: "failed"; error: string };
|
|
63
|
+
|
|
64
|
+
export type Isolation = "worktree" | "none" | "auto";
|
|
65
|
+
|
|
66
|
+
/** Per-session dedup flag for the auto-fallback in-place notify (resets on process restart = new session). */
|
|
67
|
+
let inPlaceFallbackWarned = false;
|
|
58
68
|
|
|
59
69
|
function emitProgress(deps: AsyncRunnerDeps, runId: string, partial: Partial<import("../panel/rows.ts").BgRunStatus> & { status: import("../panel/rows.ts").BgStatus; phase: string; phaseIndex: number; phaseTotal: number }): void {
|
|
60
70
|
if (!deps.onProgress) return;
|
|
@@ -72,54 +82,98 @@ function sh(cmd: string, cwd: string): void {
|
|
|
72
82
|
execSync(cmd, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
|
|
73
83
|
}
|
|
74
84
|
|
|
75
|
-
|
|
85
|
+
/** The git-agnostic core: pool slot → journal → runLifecycle in ctx.cwd (or a worktree, when `isolated` is set) → inbox + notify.
|
|
86
|
+
* Fire-and-forget. When `isolated` is present, journals the worktree field, commits on completion, and removes the worktree. */
|
|
87
|
+
function runBackgroundInPlace(runId: string, task: string, opts: RunBackgroundOpts, isolated?: { worktreePath: string; branch: string }): void {
|
|
76
88
|
const { deps } = opts;
|
|
77
|
-
const runId = deps.genRunId();
|
|
78
|
-
const baseRef = "HEAD";
|
|
79
|
-
|
|
80
|
-
// Fire-and-forget: the pool gates concurrency; the journal records the run.
|
|
81
89
|
void deps.pool.withSlot(async () => {
|
|
82
|
-
let wt: { path: string; branch: string } | null = null;
|
|
83
90
|
try {
|
|
84
|
-
|
|
85
|
-
|
|
91
|
+
const ev0: JournalEvent = isolated
|
|
92
|
+
? { type: "run:started", runId, task, lifecycle: opts.lifecycle, worktree: { path: isolated.worktreePath, branch: isolated.branch }, mode: opts.mode, ts: Date.now() }
|
|
93
|
+
: { type: "run:started", runId, task, lifecycle: opts.lifecycle, mode: opts.mode, ts: Date.now() };
|
|
86
94
|
deps.journal.append(runId, ev0);
|
|
87
95
|
emitProgress(deps, runId, { status: "running", phase: "", phaseIndex: 0, phaseTotal: 0, lifecycle: opts.lifecycle, mode: opts.mode, task });
|
|
88
96
|
|
|
89
|
-
const res = await deps.runLifecycle(task, opts.lifecycle, { runId, worktreePath:
|
|
97
|
+
const res = await deps.runLifecycle(task, opts.lifecycle, { runId, worktreePath: isolated?.worktreePath, branch: isolated?.branch, mode: opts.mode });
|
|
90
98
|
|
|
91
99
|
if (res.status === "completed") {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
100
|
+
if (isolated) {
|
|
101
|
+
try { sh("git add -A && git commit -m 'fleet run complete'", isolated.worktreePath); } catch { /* nothing to commit */ }
|
|
102
|
+
}
|
|
103
|
+
deps.journal.append(runId, isolated
|
|
104
|
+
? { type: "run:completed", runId, branch: isolated.branch, ts: Date.now() }
|
|
105
|
+
: { type: "run:completed", runId, ts: Date.now() });
|
|
95
106
|
const total = res.phases.length;
|
|
96
|
-
const lastIdx = total;
|
|
97
|
-
emitProgress(deps, runId, { status: "completed", phase: res.phases[total - 1]?.name ?? "finish", phaseIndex: lastIdx, phaseTotal: total, lifecycle: opts.lifecycle, mode: opts.mode, task, branch:
|
|
107
|
+
const lastIdx = total;
|
|
108
|
+
emitProgress(deps, runId, { status: "completed", phase: res.phases[total - 1]?.name ?? "finish", phaseIndex: lastIdx, phaseTotal: total, lifecycle: opts.lifecycle, mode: opts.mode, task, ...(isolated ? { branch: isolated.branch } : {}) });
|
|
98
109
|
const lastPhase = res.phases[res.phases.length - 1];
|
|
99
110
|
const result: RunResult = {
|
|
100
111
|
runId, task, status: "completed",
|
|
101
112
|
summary: lastPhase?.summary ?? "",
|
|
102
113
|
paths: res.phases.flatMap((p) => p.paths),
|
|
103
|
-
|
|
114
|
+
completedAt: Date.now(),
|
|
115
|
+
...(isolated ? { branch: isolated.branch } : {}),
|
|
104
116
|
};
|
|
105
117
|
deps.inbox.push(result);
|
|
106
118
|
deps.notify(`fleet run ${runId} completed`, "info");
|
|
107
|
-
|
|
108
|
-
deps.worktree.removeWorktree(runId);
|
|
119
|
+
if (isolated) deps.worktree.removeWorktree(runId);
|
|
109
120
|
} else {
|
|
110
121
|
deps.journal.append(runId, { type: "run:aborted", runId, reason: res.error ?? res.status, ts: Date.now() });
|
|
111
|
-
deps.worktree.remove(runId);
|
|
122
|
+
if (isolated) deps.worktree.remove(runId);
|
|
112
123
|
emitProgress(deps, runId, { status: "failed", phase: "", phaseIndex: 0, phaseTotal: res.phases.length, lifecycle: opts.lifecycle, mode: opts.mode, task });
|
|
113
124
|
deps.notify(`fleet run ${runId} ${res.status}: ${res.error ?? ""}`, "warning");
|
|
114
125
|
}
|
|
115
126
|
} catch (e) {
|
|
116
127
|
const msg = (e as Error).message;
|
|
117
128
|
deps.journal.append(runId, { type: "run:aborted", runId, reason: msg, ts: Date.now() });
|
|
118
|
-
if (
|
|
129
|
+
if (isolated) deps.worktree.remove(runId);
|
|
119
130
|
deps.notify(`fleet run ${runId} failed: ${msg}`, "error");
|
|
120
131
|
emitProgress(deps, runId, { status: "failed", phase: "", phaseIndex: 0, phaseTotal: 0, lifecycle: opts.lifecycle, mode: opts.mode, task });
|
|
121
132
|
}
|
|
122
133
|
});
|
|
134
|
+
}
|
|
123
135
|
|
|
136
|
+
/** The worktree wrapper: SYNCHRONOUS pre-flight + worktree create, then core-with-isolation. */
|
|
137
|
+
function runBackgroundIsolated(task: string, opts: RunBackgroundOpts): RunBackgroundResult {
|
|
138
|
+
const { deps } = opts;
|
|
139
|
+
if (!deps.worktree.isGitRepo()) {
|
|
140
|
+
return { status: "failed", error: "isolation: 'worktree' requires a git repo; cwd is not one — use isolation: 'none' or run in a git repo" };
|
|
141
|
+
}
|
|
142
|
+
const runId = deps.genRunId();
|
|
143
|
+
const baseRef = "HEAD";
|
|
144
|
+
let wt: { path: string; branch: string };
|
|
145
|
+
try {
|
|
146
|
+
wt = deps.worktree.create(runId, baseRef);
|
|
147
|
+
} catch (e) {
|
|
148
|
+
return { status: "failed", error: (e as Error).message };
|
|
149
|
+
}
|
|
150
|
+
runBackgroundInPlace(runId, task, opts, { worktreePath: wt.path, branch: wt.branch });
|
|
124
151
|
return { runId, status: "background" };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The auto router: isolated when cwd is a git repo, in-place + one per-session notify when not. */
|
|
155
|
+
function runBackgroundAuto(task: string, opts: RunBackgroundOpts): RunBackgroundResult {
|
|
156
|
+
const { deps } = opts;
|
|
157
|
+
if (deps.worktree.isGitRepo()) {
|
|
158
|
+
return runBackgroundIsolated(task, opts);
|
|
159
|
+
}
|
|
160
|
+
if (!inPlaceFallbackWarned) {
|
|
161
|
+
inPlaceFallbackWarned = true;
|
|
162
|
+
deps.notify("background run in-place (no worktree isolation — parallel edits may conflict)", "warning");
|
|
163
|
+
}
|
|
164
|
+
const runId = deps.genRunId();
|
|
165
|
+
runBackgroundInPlace(runId, task, opts);
|
|
166
|
+
return { runId, status: "background" };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Public dispatcher (keeps the `runBackground` name + RunBackgroundHandle success shape for back-compat). */
|
|
170
|
+
export function runBackground(task: string, opts: RunBackgroundOpts): RunBackgroundResult {
|
|
171
|
+
const isolation = opts.isolation ?? "auto";
|
|
172
|
+
if (isolation === "worktree") return runBackgroundIsolated(task, opts);
|
|
173
|
+
if (isolation === "none") {
|
|
174
|
+
const runId = opts.deps.genRunId();
|
|
175
|
+
runBackgroundInPlace(runId, task, opts);
|
|
176
|
+
return { runId, status: "background" };
|
|
177
|
+
}
|
|
178
|
+
return runBackgroundAuto(task, opts);
|
|
125
179
|
}
|
package/src/runtime/reconcile.ts
CHANGED
|
@@ -1,12 +1,7 @@
|
|
|
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";
|
|
12
7
|
|
|
@@ -52,4 +47,12 @@ export function reconcileRuns(log: RunLog, opts: ReconcileOpts = {}): string[] {
|
|
|
52
47
|
aborted.push(meta.runId);
|
|
53
48
|
}
|
|
54
49
|
return aborted;
|
|
55
|
-
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** SPEC-6-3 — on pi start, scan .pi/fleet/workflows/ for non-terminal workflow journals.
|
|
53
|
+
* Returns runIds whose journal has no wf:completed/wf:aborted event — these are
|
|
54
|
+
* interrupted workflows that the Workflows view surfaces as resume candidates. */
|
|
55
|
+
export function scanWorkflowResumeCandidates(workflowsDir: string): string[] {
|
|
56
|
+
const journal = new WorkflowJournal(workflowsDir);
|
|
57
|
+
return journal.scanNonTerminal();
|
|
58
|
+
}
|
package/src/runtime/resume.ts
CHANGED
|
@@ -8,8 +8,10 @@ export interface ResumeCandidate {
|
|
|
8
8
|
runId: string;
|
|
9
9
|
task: string;
|
|
10
10
|
lifecycle: string;
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
/** Present only for isolated (worktree) runs. */
|
|
12
|
+
worktreePath?: string;
|
|
13
|
+
/** Present only for isolated (worktree) runs. */
|
|
14
|
+
branch?: string;
|
|
13
15
|
lastPhase: string | null;
|
|
14
16
|
canResume: boolean;
|
|
15
17
|
}
|
|
@@ -26,23 +28,27 @@ export function scanResumeCandidates(_projectDir: string, opts: ScanResumeOpts):
|
|
|
26
28
|
for (const runId of ids) {
|
|
27
29
|
const events = journal.replay(runId);
|
|
28
30
|
const started = events.find((e) => e.type === "run:started") as
|
|
29
|
-
| (JournalEvent & { type: "run:started" }) | undefined;
|
|
31
|
+
| (JournalEvent & { type: "run:started"; worktree?: { path: string; branch: string } }) | undefined;
|
|
30
32
|
if (!started) continue;
|
|
31
33
|
const phaseEvents = events.filter((e) => e.type === "phase:completed" || e.type === "phase:started" || e.type === "phase:failed") as Array<{ phase: string }>;
|
|
32
34
|
const lastPhase = phaseEvents.length > 0 ? phaseEvents[phaseEvents.length - 1]!.phase : null;
|
|
33
|
-
|
|
34
|
-
if (
|
|
35
|
-
|
|
35
|
+
|
|
36
|
+
if (started.worktree) {
|
|
37
|
+
// isolated run: resume iff the worktree still exists
|
|
38
|
+
const wtExists = opts.worktree.exists(runId);
|
|
39
|
+
if (!wtExists) {
|
|
40
|
+
journal.append(runId, { type: "run:aborted", runId, reason: "worktree-missing", ts: Date.now() });
|
|
41
|
+
}
|
|
42
|
+
cands.push({
|
|
43
|
+
runId, task: started.task, lifecycle: started.lifecycle,
|
|
44
|
+
worktreePath: started.worktree.path, branch: started.worktree.branch,
|
|
45
|
+
lastPhase, canResume: wtExists,
|
|
46
|
+
});
|
|
47
|
+
} else {
|
|
48
|
+
// v0.11.1: in-place interrupted run — no worktree to clean; abort (partial edits may remain in cwd).
|
|
49
|
+
journal.append(runId, { type: "run:aborted", runId, reason: "in-place interrupted (partial edits may remain in cwd)", ts: Date.now() });
|
|
50
|
+
cands.push({ runId, task: started.task, lifecycle: started.lifecycle, lastPhase, canResume: false });
|
|
36
51
|
}
|
|
37
|
-
cands.push({
|
|
38
|
-
runId,
|
|
39
|
-
task: started.task,
|
|
40
|
-
lifecycle: started.lifecycle,
|
|
41
|
-
worktreePath: started.worktree.path,
|
|
42
|
-
branch: started.worktree.branch,
|
|
43
|
-
lastPhase,
|
|
44
|
-
canResume: wtExists,
|
|
45
|
-
});
|
|
46
52
|
}
|
|
47
53
|
return cands;
|
|
48
54
|
}
|
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
|
|
7
|
-
export interface RunStartedEvent { type: "run:started"; runId: string; task: string; lifecycle: string; worktree
|
|
7
|
+
export interface RunStartedEvent { type: "run:started"; runId: string; task: string; lifecycle: string; worktree?: { path: string; branch: string }; mode: "auto" | "checkpointed"; ts: number; }
|
|
8
8
|
export interface PhaseStartedEvent { type: "phase:started"; phase: string; ts: number; }
|
|
9
9
|
export interface PhaseCompletedEvent { type: "phase:completed"; phase: string; summary: string; paths: string[]; ts: number; }
|
|
10
10
|
export interface PhaseFailedEvent { type: "phase:failed"; phase: string; error: string; ts: number; }
|
|
11
11
|
export interface CheckpointEvent { type: "checkpoint"; phase: string; decision: "continue" | "revise" | "abort"; ts: number; }
|
|
12
|
-
export interface RunCompletedEvent { type: "run:completed"; runId: string; branch
|
|
12
|
+
export interface RunCompletedEvent { type: "run:completed"; runId: string; branch?: string; ts: number; }
|
|
13
13
|
export interface RunAbortedEvent { type: "run:aborted"; runId: string; reason: string; ts: number; }
|
|
14
14
|
|
|
15
15
|
export type JournalEvent =
|
|
@@ -11,6 +11,8 @@ export interface ScheduleSpec {
|
|
|
11
11
|
expression: string;
|
|
12
12
|
lifecycle?: string; // default "default"
|
|
13
13
|
auto?: boolean;
|
|
14
|
+
/** v0.11.1: edit isolation for the background run on fire. Default "auto". */
|
|
15
|
+
isolation?: "worktree" | "none" | "auto";
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export interface Schedule extends ScheduleSpec {
|
|
@@ -71,6 +73,7 @@ export class Scheduler {
|
|
|
71
73
|
expression: spec.expression,
|
|
72
74
|
lifecycle: spec.lifecycle ?? "default",
|
|
73
75
|
auto: spec.auto ?? true,
|
|
76
|
+
isolation: spec.isolation,
|
|
74
77
|
paused: false,
|
|
75
78
|
};
|
|
76
79
|
this.schedules.set(id, { spec: stored, expr, timer: null });
|
|
@@ -86,6 +89,7 @@ export class Scheduler {
|
|
|
86
89
|
expression: e.spec.expression,
|
|
87
90
|
lifecycle: e.spec.lifecycle,
|
|
88
91
|
auto: e.spec.auto,
|
|
92
|
+
isolation: e.spec.isolation,
|
|
89
93
|
paused: e.spec.paused,
|
|
90
94
|
nextFire: e.spec.paused ? null : e.expr.nextFire(new Date()),
|
|
91
95
|
}));
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// SPEC-6-3 §9 — the model-callable fleet tool surface for workflows.
|
|
2
|
+
// Thin delegation to WorkflowController via a getter (prevents duplicate registration).
|
|
3
|
+
import { Type, type Static } from "typebox"
|
|
4
|
+
|
|
5
|
+
import type { WorkflowController } from "../workflows/runtime/controller.ts"
|
|
6
|
+
import type { WorkflowStartInput, WorkflowRunState, WorkflowStartReceipt } from "../workflows/runtime/types.ts"
|
|
7
|
+
import type { WorkflowRunResult } from "../workflows/runner.ts"
|
|
8
|
+
|
|
9
|
+
export interface FleetToolDeps {
|
|
10
|
+
getController: () => WorkflowController
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const fleetParams = Type.Object({
|
|
14
|
+
action: Type.Union([Type.Literal("workflow"), Type.Literal("workflow_control")], { description: "The fleet workflow action." }),
|
|
15
|
+
// workflow action
|
|
16
|
+
script: Type.Optional(Type.String({ description: "The JS workflow script (for action: 'workflow')." })),
|
|
17
|
+
name: Type.Optional(Type.String({ description: "Save-as name (for action: 'workflow')." })),
|
|
18
|
+
workflowName: Type.Optional(Type.String({ description: "Run a saved workflow by name." })),
|
|
19
|
+
overwrite: Type.Optional(Type.Boolean({ description: "Overwrite an existing saved workflow." })),
|
|
20
|
+
args: Type.Optional(Type.Unknown({ description: "Args passed to the script as `args`." })),
|
|
21
|
+
background: Type.Optional(Type.Boolean({ description: "Non-blocking (default true)." })),
|
|
22
|
+
resumeFromRunId: Type.Optional(Type.String({ description: "Edit-and-resume: replay the unchanged prefix, re-run the edited suffix." })),
|
|
23
|
+
maxAgents: Type.Optional(Type.Number({ description: "Hard cap on total agent() calls." })),
|
|
24
|
+
concurrency: Type.Optional(Type.Number({ description: "Parallel agent() concurrency." })),
|
|
25
|
+
agentRetries: Type.Optional(Type.Number({ description: "Default per-agent retries." })),
|
|
26
|
+
agentTimeoutMs: Type.Optional(Type.Number({ description: "Default per-agent timeoutMs." })),
|
|
27
|
+
tokenBudget: Type.Optional(Type.Number({ description: "Run-level token budget." })),
|
|
28
|
+
// workflow_control action
|
|
29
|
+
control: Type.Optional(Type.Union([Type.Literal("list"), Type.Literal("status"), Type.Literal("pause"), Type.Literal("resume"), Type.Literal("stop")], { description: "The control operation." })),
|
|
30
|
+
runId: Type.Optional(Type.String({ description: "The workflow runId (for status/pause/resume/stop)." })),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
export type FleetInput = Static<typeof fleetParams>
|
|
34
|
+
|
|
35
|
+
export function createFleetTool(deps: FleetToolDeps) {
|
|
36
|
+
return {
|
|
37
|
+
name: "fleet",
|
|
38
|
+
label: "Fleet",
|
|
39
|
+
description: "Run + control armory-fleet workflows (JS orchestration with agent/parallel/pipeline/phase + journaled resume).",
|
|
40
|
+
promptSnippet: "Run or control a fleet workflow",
|
|
41
|
+
promptGuidelines: [
|
|
42
|
+
"Use action 'workflow' to run a JS workflow script or a saved workflow by name.",
|
|
43
|
+
"Use action 'workflow_control' with control 'list'/'status'/'pause'/'resume'/'stop' to manage a running workflow by runId.",
|
|
44
|
+
"Pass resumeFromRunId to edit-and-resume: the unchanged agent() prefix replays from cache; edited + new calls re-run.",
|
|
45
|
+
],
|
|
46
|
+
parameters: fleetParams,
|
|
47
|
+
async execute(_id: string, params: FleetInput, signal: AbortSignal | null, _onUpdate: unknown, _ctx: unknown) {
|
|
48
|
+
let controller: WorkflowController
|
|
49
|
+
try {
|
|
50
|
+
controller = deps.getController()
|
|
51
|
+
} catch {
|
|
52
|
+
return { isError: true, content: [{ type: "text" as const, text: "workflow runtime not initialized for this session" }] }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
if (params.action === "workflow") {
|
|
57
|
+
return await handleWorkflowAction(controller, params, signal)
|
|
58
|
+
}
|
|
59
|
+
return await handleControlAction(controller, params)
|
|
60
|
+
} catch (e) {
|
|
61
|
+
return { isError: true, content: [{ type: "text" as const, text: (e as Error).message }] }
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function handleWorkflowAction(controller: WorkflowController, params: FleetInput, signal: AbortSignal | null) {
|
|
68
|
+
const hasScript = params.script !== undefined
|
|
69
|
+
const hasName = params.workflowName !== undefined
|
|
70
|
+
|
|
71
|
+
if (hasScript && hasName) {
|
|
72
|
+
return { isError: true, content: [{ type: "text" as const, text: "provide exactly one of script or workflowName" }] }
|
|
73
|
+
}
|
|
74
|
+
if (!hasScript && !hasName) {
|
|
75
|
+
return { isError: true, content: [{ type: "text" as const, text: "action 'workflow' requires `script` or `workflowName`" }] }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// resumeFromRunId → editAndResume
|
|
79
|
+
if (params.resumeFromRunId) {
|
|
80
|
+
const result = await controller.editAndResume(params.resumeFromRunId, params.script ?? "")
|
|
81
|
+
return serializeRunResult(result)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Build the delegated WorkflowStartInput with conditional spreads (exact deepEqual match).
|
|
85
|
+
const input: WorkflowStartInput = {
|
|
86
|
+
mode: "auto",
|
|
87
|
+
...(params.script !== undefined ? { script: params.script } : {}),
|
|
88
|
+
...(params.workflowName !== undefined ? { workflowName: params.workflowName } : {}),
|
|
89
|
+
...(params.name !== undefined ? { name: params.name } : {}),
|
|
90
|
+
...(params.overwrite !== undefined ? { overwrite: params.overwrite } : {}),
|
|
91
|
+
...(params.args !== undefined ? { args: params.args } : {}),
|
|
92
|
+
...(params.background !== undefined ? { background: params.background } : {}),
|
|
93
|
+
...(params.concurrency !== undefined ? { concurrency: params.concurrency } : {}),
|
|
94
|
+
...(params.agentRetries !== undefined ? { agentRetries: params.agentRetries } : {}),
|
|
95
|
+
...(params.agentTimeoutMs !== undefined ? { agentTimeoutMs: params.agentTimeoutMs } : {}),
|
|
96
|
+
...(params.tokenBudget !== undefined ? { tokenBudget: params.tokenBudget } : {}),
|
|
97
|
+
...(params.maxAgents !== undefined ? { maxAgents: params.maxAgents } : {}),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Foreground: forward the tool signal. Background: detach (no signal).
|
|
101
|
+
const isForeground = params.background === false
|
|
102
|
+
const ctx = isForeground && signal ? { signal } : undefined
|
|
103
|
+
|
|
104
|
+
const result = await controller.start(input, ctx)
|
|
105
|
+
return serializeRunResult(result)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function handleControlAction(controller: WorkflowController, params: FleetInput) {
|
|
109
|
+
const control = params.control ?? "list"
|
|
110
|
+
|
|
111
|
+
if (control === "list") {
|
|
112
|
+
const runs = controller.runs()
|
|
113
|
+
return {
|
|
114
|
+
content: [{ type: "text" as const, text: `workflows: ${runs.length ? runs.map((r) => r.runId).join(", ") : "(none running)"}` }],
|
|
115
|
+
details: { runs: runs.map(summarizeRun) },
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!params.runId) {
|
|
120
|
+
return { isError: true, content: [{ type: "text" as const, text: `control '${control}' requires \`runId\`` }] }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (control === "status") {
|
|
124
|
+
const run = controller.getRun(params.runId)
|
|
125
|
+
if (!run) {
|
|
126
|
+
return { isError: true, content: [{ type: "text" as const, text: `workflow '${params.runId}' not found` }] }
|
|
127
|
+
}
|
|
128
|
+
return { content: [{ type: "text" as const, text: `workflow ${run.runId}: ${run.status}` }], details: { run: summarizeRun(run) } }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (control === "pause") {
|
|
132
|
+
controller.pause(params.runId)
|
|
133
|
+
return { content: [{ type: "text" as const, text: `workflow ${params.runId}: paused` }], details: { runId: params.runId, status: "paused" } }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (control === "resume") {
|
|
137
|
+
const receipt = await controller.resume(params.runId)
|
|
138
|
+
return { content: [{ type: "text" as const, text: `workflow ${params.runId}: resumed` }], details: { receipt } }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (control === "stop") {
|
|
142
|
+
await controller.stop(params.runId)
|
|
143
|
+
return { content: [{ type: "text" as const, text: `workflow ${params.runId}: stopped` }], details: { runId: params.runId, status: "aborted" } }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { isError: true, content: [{ type: "text" as const, text: `unknown control '${control}'` }] }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function summarizeRun(run: WorkflowRunState): Record<string, unknown> {
|
|
150
|
+
return {
|
|
151
|
+
runId: run.runId,
|
|
152
|
+
name: run.name,
|
|
153
|
+
status: run.status,
|
|
154
|
+
startedAt: run.startedAt,
|
|
155
|
+
...(run.endedAt !== undefined ? { endedAt: run.endedAt } : {}),
|
|
156
|
+
currentPhase: run.currentPhase,
|
|
157
|
+
phases: run.phases,
|
|
158
|
+
childRunIds: run.childRunIds,
|
|
159
|
+
tokenTotal: run.tokenTotal,
|
|
160
|
+
costTotal: run.costTotal,
|
|
161
|
+
...(run.error ? { error: run.error } : {}),
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function serializeRunResult(result: WorkflowStartReceipt | WorkflowRunResult) {
|
|
166
|
+
if ("status" in result && result.status === "background") {
|
|
167
|
+
return {
|
|
168
|
+
content: [{ type: "text" as const, text: `workflow ${result.runId}: background` }],
|
|
169
|
+
details: { runId: result.runId, status: "background" },
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const r = result as WorkflowRunResult
|
|
173
|
+
const isError = r.status === "aborted" || r.status === "failed"
|
|
174
|
+
return {
|
|
175
|
+
content: [{ type: "text" as const, text: isError ? (r.error ?? r.status) : `workflow ${r.runId}: ${r.status}` }],
|
|
176
|
+
details: { runId: r.runId, status: r.status },
|
|
177
|
+
isError: isError || undefined,
|
|
178
|
+
}
|
|
179
|
+
}
|
package/src/tools/subagent.ts
CHANGED
|
@@ -22,6 +22,11 @@ export const subagentParams = Type.Object({
|
|
|
22
22
|
lifecycle: Type.Optional(Type.String({ description: "Run a multi-phase superpowers lifecycle by name (e.g. 'default') instead of a single delegate. Tool-driven lifecycles run end-to-end (auto) — checkpoints are a /fleet panel feature." })),
|
|
23
23
|
auto: Type.Optional(Type.Boolean({ description: "Only relevant with `lifecycle`. Tool-driven is always auto; this flag is forward-compat. Panel-driven uses --auto on /fleet-implement." })),
|
|
24
24
|
background: Type.Optional(Type.Boolean({ description: "Fire without awaiting. The run goes to the async/bg pool on an isolated git worktree; this returns { runId, status: 'background' } immediately. Foreground (default) awaits the result." })),
|
|
25
|
+
isolation: Type.Optional(Type.Union([
|
|
26
|
+
Type.Literal("worktree"),
|
|
27
|
+
Type.Literal("none"),
|
|
28
|
+
Type.Literal("auto"),
|
|
29
|
+
], { description: "Edit isolation for background runs. 'worktree' = git worktree (requires a git repo; fails sync if not). 'none' = in-place in cwd (no isolation; parallel edits may conflict). 'auto' (default) = worktree when cwd is a git repo, in-place otherwise." })),
|
|
25
30
|
schedule: Type.Optional(Type.String({ description: 'Schedule the run instead of firing now: a cron string ("0 9 * * 1-5"), an interval ("30m"/"2h"), or a one-shot ISO datetime ("2026-07-25T14:00"). Returns { scheduleId, nextFire }. Session-scoped (fires only while pi is open); no catch-up.' })),
|
|
26
31
|
maxTurns: Type.Optional(Type.Number({ description: 'Per-run turn budget (default 20). Raise for complex multi-step tasks (e.g. 40) so the subagent doesn\'t hit the budget mid-task; lower for trivial lookups.' })),
|
|
27
32
|
});
|
|
@@ -80,13 +85,14 @@ export function createSubagentTool(deps: SubagentToolDeps) {
|
|
|
80
85
|
}
|
|
81
86
|
if (params.schedule) {
|
|
82
87
|
if (!deps.scheduler) return { isError: true, content: [{ type: "text" as const, text: "scheduling not configured (scheduler missing)" }] };
|
|
83
|
-
const id = deps.scheduler.register({ task: params.task, expression: params.schedule, lifecycle: params.lifecycle ?? "default", auto: params.auto ?? true });
|
|
88
|
+
const id = deps.scheduler.register({ task: params.task, expression: params.schedule, lifecycle: params.lifecycle ?? "default", auto: params.auto ?? true, isolation: params.isolation });
|
|
84
89
|
const entry = deps.scheduler.list().find((s) => s.id === id);
|
|
85
90
|
return { content: [{ type: "text" as const, text: `scheduled: ${id} · next fire: ${entry?.nextFire?.toISOString() ?? "(paused)"}` }], details: { scheduleId: id, nextFire: entry?.nextFire ?? null } };
|
|
86
91
|
}
|
|
87
92
|
if (params.background) {
|
|
88
93
|
if (!deps.asyncRunner) return { isError: true, content: [{ type: "text" as const, text: "background runs not configured (asyncRunner missing)" }] };
|
|
89
|
-
const handle = runBackground(params.task, { deps: deps.asyncRunner, lifecycle: params.lifecycle ?? "default", mode: "auto" });
|
|
94
|
+
const handle = runBackground(params.task, { deps: deps.asyncRunner, lifecycle: params.lifecycle ?? "default", mode: "auto", isolation: params.isolation });
|
|
95
|
+
if (handle.status === "failed") return { isError: true, content: [{ type: "text" as const, text: handle.error }] };
|
|
90
96
|
return { content: [{ type: "text" as const, text: `background run: ${handle.runId}` }], details: handle };
|
|
91
97
|
}
|
|
92
98
|
if (params.lifecycle) {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const meta = {
|
|
2
|
+
name: 'adversarial-review',
|
|
3
|
+
description: 'Red-team + blue-team review with judge panel',
|
|
4
|
+
phases: [{ title: 'Attack' }, { title: 'Defend' }, { title: 'Judge' }],
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
phase('Attack')
|
|
8
|
+
const vulns = await parallel([
|
|
9
|
+
() => agent('Find injection vulnerabilities in this code.', { tier: 'medium' }),
|
|
10
|
+
() => agent('Find logic errors and race conditions.', { tier: 'medium' }),
|
|
11
|
+
() => agent('Find authentication bypass vectors.', { tier: 'medium' }),
|
|
12
|
+
])
|
|
13
|
+
|
|
14
|
+
phase('Defend')
|
|
15
|
+
const defenses = await parallel(vulns.map((v) => () => agent(`Propose a fix for: ${v}`, { tier: 'low' })))
|
|
16
|
+
|
|
17
|
+
phase('Judge')
|
|
18
|
+
const winner = await judgePanel([...vulns, ...defenses], { judges: 3, rubric: 'severity and fixability' })
|
|
19
|
+
return { vulnerabilities: vulns, defenses, verdict: winner }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const meta = {
|
|
2
|
+
name: 'code-review',
|
|
3
|
+
description: '7 parallel review angles plus verification',
|
|
4
|
+
phases: [{ title: 'Review' }, { title: 'Verify' }],
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
phase('Review')
|
|
8
|
+
const angles = ['security', 'correctness', 'performance', 'readability', 'edge-cases', 'tests', 'api-design']
|
|
9
|
+
const findings = await parallel(angles.map((angle) => () => agent(`Review this diff for ${angle} issues. Report concrete findings only.`, { tier: 'medium' })))
|
|
10
|
+
|
|
11
|
+
phase('Verify')
|
|
12
|
+
const verified = await verify(findings.join('\n---\n'), { reviewers: 2, lens: 'false positives' })
|
|
13
|
+
return { findings, verified }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const meta = {
|
|
2
|
+
name: 'codebase-audit',
|
|
3
|
+
description: 'File-tree scan with completeness check',
|
|
4
|
+
phases: [{ title: 'Scan' }, { title: 'Audit' }],
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
phase('Scan')
|
|
8
|
+
const files = await loopUntilDry({
|
|
9
|
+
round: (n) => n < 4 ? agent(`List files in dir ${n}. Return a JSON array of file path strings.`, { tier: 'low', schema: { type: 'array' }, retries: 1 }) : [],
|
|
10
|
+
consecutiveEmpty: 2,
|
|
11
|
+
maxRounds: 6,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
phase('Audit')
|
|
15
|
+
const checked = await verify(`Found ${files.length} files.`, { reviewers: 1, lens: 'missing test coverage' })
|
|
16
|
+
return { files, checked }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const meta = {
|
|
2
|
+
name: 'deep-research',
|
|
3
|
+
description: '3-round discovery loop with de-duplication',
|
|
4
|
+
phases: [{ title: 'Discover' }, { title: 'Synthesize' }],
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
phase('Discover')
|
|
8
|
+
const topics = await loopUntilDry({ round: (n) => n < 3 ? agent(`Find unique sources for round ${n}. Return a JSON array of source strings.`, { tier: 'low', schema: { type: 'array' }, retries: 1 }) : [], consecutiveEmpty: 2, maxRounds: 5 })
|
|
9
|
+
|
|
10
|
+
phase('Synthesize')
|
|
11
|
+
const summary = await agent(`Synthesize these ${topics.length} sources into a coherent report.`, { tier: 'medium' })
|
|
12
|
+
return { sources: topics, summary }
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const meta = {
|
|
2
|
+
name: 'multi-perspective',
|
|
3
|
+
description: '4 personas review the same artifact',
|
|
4
|
+
phases: [{ title: 'Review' }, { title: 'Merge' }],
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
phase('Review')
|
|
8
|
+
const personas = ['product manager', 'security engineer', 'UX designer', 'dev-ops lead']
|
|
9
|
+
const reviews = await parallel(personas.map((p) => () => agent(`Review this artifact as a ${p}. Focus on your domain only.`, { tier: 'medium' })))
|
|
10
|
+
|
|
11
|
+
phase('Merge')
|
|
12
|
+
const merged = await gate(
|
|
13
|
+
async (feedback, n) => n === 0 ? agent(`Initial synthesis of ${reviews.length} reviews.`, { tier: 'low' }) : agent(`Revise synthesis: ${feedback}`, { tier: 'low' }),
|
|
14
|
+
(v) => typeof v === 'string' && v.length > 100 ? { ok: true } : { ok: false, feedback: 'more detail needed' },
|
|
15
|
+
{ attempts: 3 },
|
|
16
|
+
)
|
|
17
|
+
return { reviews, synthesis: merged }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { HelperCtx } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/** SPEC-6-3 §3.3 — script-level human gate. Standalone (NOT CheckpointFn reuse).
|
|
4
|
+
* Interactive: resolves via ctx.onCheckpoint. Headless: returns opts.default (or aborts). */
|
|
5
|
+
export async function checkpoint(
|
|
6
|
+
prompt: string,
|
|
7
|
+
opts: { kind?: "confirm" | "input" | "select"; choices?: string[]; default?: unknown; headless?: "default" | "abort"; timeoutMs?: number } = {},
|
|
8
|
+
ctx: HelperCtx,
|
|
9
|
+
): Promise<unknown> {
|
|
10
|
+
// Interactive bridge present → use it (the Workflows view resolves the pending checkpoint).
|
|
11
|
+
if (ctx.onCheckpoint) return ctx.onCheckpoint(prompt, opts as Record<string, unknown>);
|
|
12
|
+
const headless = opts.headless ?? "default";
|
|
13
|
+
if (headless === "abort") throw new Error("checkpoint abort: headless mode with no UI");
|
|
14
|
+
return opts.default ?? true;
|
|
15
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { HelperCtx } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/** SPEC-6-3 §3.3 — judge whether results fully satisfy taskArgs via one agent. Returns null if agent can't judge. */
|
|
4
|
+
export async function completenessCheck(
|
|
5
|
+
taskArgs: unknown,
|
|
6
|
+
results: unknown,
|
|
7
|
+
ctx: HelperCtx,
|
|
8
|
+
opts: { tier?: string; model?: string; skills?: string[]; backend?: "pi" | "claude"; retries?: number; timeoutMs?: number } = {},
|
|
9
|
+
): Promise<{ complete: boolean; missing?: string[] } | null> {
|
|
10
|
+
const prompt = `You are a completeness judge. Given the task args and the results, decide if the results fully satisfy the task.\nTask args: ${JSON.stringify(taskArgs)}\nResults: ${JSON.stringify(results)}\nRespond as JSON: {"complete": boolean, "missing": string[]}`;
|
|
11
|
+
const res = await ctx.spawn(prompt, { agent: "reviewer", ...(opts.tier ? { tier: opts.tier } : {}), ...(opts.model ? { model: opts.model } : {}), ...(opts.skills ? { skills: opts.skills } : {}), ...(opts.backend ? { backend: opts.backend } : {}), ...(opts.retries ? { retries: opts.retries } : {}), ...(opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {}) });
|
|
12
|
+
if (!res || res.status !== "completed") return null;
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(res.finalText) as { complete?: boolean; missing?: string[] };
|
|
15
|
+
if (typeof parsed.complete !== "boolean") return null;
|
|
16
|
+
return { complete: parsed.complete, ...(Array.isArray(parsed.missing) ? { missing: parsed.missing } : {}) };
|
|
17
|
+
} catch { return null; }
|
|
18
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { HelperCtx } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/** SPEC-6-3 §3.3 — run thunk, validate; on fail re-run with feedback up to `attempts`. */
|
|
4
|
+
export async function gate(
|
|
5
|
+
thunk: (feedback: string | undefined, attempt: number) => unknown | Promise<unknown>,
|
|
6
|
+
validator: (value: unknown) => { ok: boolean; feedback?: string } | Promise<{ ok: boolean; feedback?: string }>,
|
|
7
|
+
opts: { attempts?: number } = {},
|
|
8
|
+
ctx: HelperCtx,
|
|
9
|
+
): Promise<{ ok: boolean; value: unknown; attempts: number }> {
|
|
10
|
+
const attempts = opts.attempts ?? 3;
|
|
11
|
+
let feedback: string | undefined;
|
|
12
|
+
let lastValue: unknown;
|
|
13
|
+
for (let n = 0; n < attempts; n++) {
|
|
14
|
+
const value = await thunk(feedback, n);
|
|
15
|
+
lastValue = value;
|
|
16
|
+
const verdict = await validator(value);
|
|
17
|
+
if (verdict.ok) return { ok: true, value, attempts: n + 1 };
|
|
18
|
+
feedback = verdict.feedback;
|
|
19
|
+
}
|
|
20
|
+
// exhausted: return the last value from within the attempts budget (no extra run).
|
|
21
|
+
return { ok: false, value: lastValue, attempts };
|
|
22
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type { HelperCtx, HelperSpawnResult } from "./types.ts";
|
|
2
|
+
export { retry } from "./retry.ts";
|
|
3
|
+
export { gate } from "./gate.ts";
|
|
4
|
+
export { completenessCheck } from "./completeness-check.ts";
|
|
5
|
+
export { loopUntilDry } from "./loop-until-dry.ts";
|
|
6
|
+
export { verify } from "./verify.ts";
|
|
7
|
+
export { judgePanel } from "./judge-panel.ts";
|
|
8
|
+
export { checkpoint } from "./checkpoint.ts";
|