@getpipher/armory-fleet 0.12.0 → 0.12.2
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/child-loader.ts +26 -8
- package/src/engine/spawnSubagent.ts +85 -15
- package/src/index.ts +10 -4
- package/src/panel/fleet-panel.ts +18 -6
- package/src/panel/fleet-widget.ts +5 -1
- package/src/runtime/reconcile.ts +20 -2
- package/src/todo-sync/adapter.ts +22 -3
- package/src/tools/subagent.ts +57 -6
package/README.md
CHANGED
|
@@ -1,31 +1,388 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
<img src="assets/armory-fleet-logo.png" alt="@getpipher/armory-fleet" width="220" height="220" />
|
|
4
|
+
|
|
1
5
|
# @getpipher/armory-fleet
|
|
2
6
|
|
|
3
|
-
|
|
7
|
+
**The armory suite's subagent orchestrator for the [pi coding agent](https://github.com/earendil-works/pi-coding-agent) — a cross-harness, superpowers-native fleet where every agent is armory-native from birth.**
|
|
8
|
+
|
|
9
|
+
[](https://www.npmjs.com/package/@getpipher/armory-fleet)
|
|
10
|
+
[](https://www.npmjs.com/package/@getpipher/armory-fleet)
|
|
11
|
+
[](https://github.com/earendil-works/pi-coding-agent)
|
|
12
|
+
[](./LICENSE)
|
|
13
|
+
[](#testing)
|
|
14
|
+
[](https://github.com/getpipher/armory-fleet/releases)
|
|
15
|
+
[](#compatibility)
|
|
4
16
|
|
|
5
|
-
|
|
17
|
+
[Why](#why) · [Features](#features-at-a-glance) · [Quick start](#quick-start) · [Architecture](#architecture) · [The fleet panel](#the-fleet-panel) · [Workflows-as-code](#workflows-as-code) · [Cost-aware tiers](#cost-aware-tiers) · [Roadmap](#roadmap) · [Ecosystem](#ecosystem)
|
|
18
|
+
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
---
|
|
6
22
|
|
|
7
23
|
## Why
|
|
8
24
|
|
|
9
|
-
The pi-subagent ecosystem is crowded
|
|
25
|
+
The pi-subagent ecosystem is crowded — `nicobailon/pi-subagents` (2688⭐), `tintinweb/pi-subagents` (702⭐), `QuintinShaw/pi-dynamic-workflows` (287⭐), `kky42/pi-flow` (66⭐), `teelicht/pi-superagents` (54⭐). Three structural gaps remain open, and `armory-fleet` owns all three — then reaches parity on the rest to be the best pi-subagent package in the ecosystem.
|
|
26
|
+
|
|
27
|
+
| Gap | Status quo | armory-fleet |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| **Armory-native integration** | No external package can integrate with the armory suite; they don't own it. | Agents that sync to [armory-todo](https://github.com/getpipher/armory-todo), hydrate from [armory-memory](https://github.com/getpipher/armory-memory), see via [vision](https://github.com/getpipher/vision), and edit via [cursor](https://github.com/getpipher/cursor) **by default** — uncopyable. |
|
|
30
|
+
| **Cross-harness peers** | Only one early attempt runs Claude Code + Pi as peer backends, foreground-only. | First-class dual-arsenal topology — pi and Claude Code spawn as sibling backends from one fleet. |
|
|
31
|
+
| **Superpowers-native lifecycle** | Only one attempt wraps the superpowers skill pipeline, synchronous-only. | The full superpowers lifecycle (brainstorm → plan → implement → review → finish) with checkpoints, quality gates, and lifecycle hooks baked in. |
|
|
32
|
+
|
|
33
|
+
Beyond those: a fleet TUI, cron/interval scheduling, git worktree isolation, cost accounting, quality gates, workflows-as-code, and a journaled event-bus — all in one package.
|
|
34
|
+
|
|
35
|
+
> **Vision, spine, and the 7-SPEC roadmap** live in [`PRD.md`](./PRD.md). The landscape deep-read (11+ packages mapped, 5 contenders deep-read) is in [`research/`](./research).
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Features at a glance
|
|
40
|
+
|
|
41
|
+
| Capability | What you get |
|
|
42
|
+
|---|---|
|
|
43
|
+
| 🧬 **Armory-native agents** | Every child syncs to `armory-todo`, hydrates from `armory-memory`, sees via `vision`, edits via `cursor` — by default, from birth. No bolt-on. |
|
|
44
|
+
| 🏛️ **Cross-harness backends** | Spawn pi **and** Claude Code sessions as peer backends from one fleet. Auto-detect Claude; hook-parity keeps both on equal footing. |
|
|
45
|
+
| 🦸 **Superpowers lifecycle** | `brainstorm → plan → implement → review → finish`, checkpoint-driven, skill-loaded per phase. `/fleet-implement <task>` runs the whole pipeline. |
|
|
46
|
+
| 🎚️ **Cost-aware tiers** | `economy` / `standard` / `frontier` model tiers with cost caps + context floors. Route cheap work to cheap models, escalate when it matters. Live cost $ + context % per run. |
|
|
47
|
+
| 🚦 **Quality gates** | `verification-before-completion`, `completeness-check`, `gate`, `verify` — built-in. Register your own. Composite helpers: `judgePanel`, `loopUntilDry`, `retry`. |
|
|
48
|
+
| 🧩 **Workflows-as-code** | Author multi-phase workflows in a JS DSL with `agent()`, `pipeline()`, `phase()`, `checkpoint()`. 5 builtins ship: adversarial-review, code-review, codebase-audit, deep-research, multi-perspective. Journaled + resumable. |
|
|
49
|
+
| 🖥️ **Fleet TUI** | `/fleet` opens an interactive panel: Runs, Tiers, Lifecycle, Workflows, Conversation viewer. Live widget, mid-run Steer/Stop, edit-resume, save-as. |
|
|
50
|
+
| ⏱️ **Scheduling** | Cron expressions, intervals, one-shot ISO datetimes. PID-locked scheduler, session-scoped (no catch-up). Background runs on isolated git worktrees. |
|
|
51
|
+
| 🔒 **Worktree isolation** | Background runs get isolated git worktrees (in-place fallback for non-git cwds). Foreground runs share the session cwd. |
|
|
52
|
+
| 📒 **RunLog + journaling** | Every run is journaled; interrupted workflows recover on restart. A results inbox lets the model pull completed background runs. |
|
|
53
|
+
| 🔄 **Edit-and-resume** | Re-run a workflow by replaying the unchanged prefix and re-running only the edited suffix. |
|
|
54
|
+
| 📡 **Vision built-in** | `describe_image` tool is wired into child sessions — agents can see screenshots and diagrams without leaving the fleet. |
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Quick start
|
|
59
|
+
|
|
60
|
+
### Install
|
|
61
|
+
|
|
62
|
+
armory-fleet is a [pi extension](https://pi-coding-agent.dev/docs/extensions) — it loads inside pi, no build step.
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# 1. Add to your pi packages (~/local-dev/arsenal or your package dir)
|
|
66
|
+
pnpm add @getpipher/armory-fleet
|
|
67
|
+
|
|
68
|
+
# 2. Register in ~/.pi/agent/settings.json
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```jsonc
|
|
72
|
+
// ~/.pi/agent/settings.json
|
|
73
|
+
{
|
|
74
|
+
"packages": [
|
|
75
|
+
"@getpipher/armory-fleet@0.12.0"
|
|
76
|
+
// + its armory siblings: armory-todo, armory-memory, vision, cursor
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
# 3. Reload pi (/reload) and open the panel
|
|
83
|
+
pi
|
|
84
|
+
# inside pi → /fleet
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Your first subagent (model-callable tool)
|
|
88
|
+
|
|
89
|
+
The `subagent` tool is what the model calls to delegate a focused task. Every child is armory-native by default.
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
// the agent calls this — not you
|
|
93
|
+
subagent({
|
|
94
|
+
agent: "general-purpose",
|
|
95
|
+
task: "Audit src/auth/ for token-handling bugs; report findings.",
|
|
96
|
+
// optional: model, lifecycle, todoId, background, isolation, schedule, maxTurns
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
| Param | Effect |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `agent` | Agent definition to spawn (from `agents/` or discovered). |
|
|
103
|
+
| `task` | The prompt handed to the child. |
|
|
104
|
+
| `model` | Override the session model. **Tip:** omit to inherit the session model, or use `Ollama/...` when the session is on Ollama — don't cross providers. |
|
|
105
|
+
| `lifecycle` | Run the task through a superpowers lifecycle (e.g. `default`) instead of a single delegate. |
|
|
106
|
+
| `todoId` | Link the run to an existing armory-todo entry. |
|
|
107
|
+
| `track` | Default `true` (syncs to armory-todo). Pass `false` only for throwaway lookups. |
|
|
108
|
+
| `background` | Fire without awaiting — run goes to the async pool on an isolated git worktree. |
|
|
109
|
+
| `isolation` | `worktree` (default for bg in a git repo) · `none` (in-place) · `auto`. |
|
|
110
|
+
| `schedule` | Cron (`0 9 * * 1-5`), interval (`30m`), or one-shot ISO datetime. Session-scoped, no catch-up. |
|
|
111
|
+
| `maxTurns` | Per-run turn budget (default 20). Raise for complex multi-step tasks. |
|
|
112
|
+
|
|
113
|
+
### Your first workflow
|
|
114
|
+
|
|
115
|
+
Workflows are plain JS files evaluated in a sandboxed vm realm. The orchestration primitives — `agent`, `parallel`, `pipeline`, `phase`, `gate`, `judgePanel`, `loopUntilDry`, `retry`, `checkpoint`, `verify`, `workflow`, `log` — are **injected globals** (no imports). The only thing you `export` is `meta`.
|
|
116
|
+
|
|
117
|
+
```js
|
|
118
|
+
// ship-feature.js — drop into a workflows/ dir discovered by WorkflowRegistry
|
|
119
|
+
export const meta = {
|
|
120
|
+
name: 'ship-feature',
|
|
121
|
+
description: 'Plan → implement → 3 parallel review angles with a gate',
|
|
122
|
+
phases: [{ title: 'Plan' }, { title: 'Implement' }, { title: 'Review' }],
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
phase('Plan')
|
|
126
|
+
const plan = await agent('Plan this feature: ' + args.task, { tier: 'low' })
|
|
127
|
+
|
|
128
|
+
phase('Implement')
|
|
129
|
+
const impl = await agent(`Implement the plan:\n${plan}`, { tier: 'medium' })
|
|
130
|
+
|
|
131
|
+
phase('Review')
|
|
132
|
+
const angles = ['security', 'performance', 'correctness']
|
|
133
|
+
const reviews = await parallel(
|
|
134
|
+
angles.map((a) => () => agent(`Review the implementation for ${a} issues.`, { tier: 'low' })),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
// gate: revise the synthesis until it passes a validator
|
|
138
|
+
const synthesis = await gate(
|
|
139
|
+
async (_feedback, n) => n === 0
|
|
140
|
+
? agent(`Synthesize ${reviews.length} reviews.`, { tier: 'low' })
|
|
141
|
+
: agent('Revise synthesis per feedback.', { tier: 'low' }),
|
|
142
|
+
(v) => typeof v === 'string' && v.length > 200 ? { ok: true } : { ok: false, feedback: 'more detail' },
|
|
143
|
+
{ attempts: 3 },
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return { plan, impl, reviews, synthesis }
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Open `/fleet → Workflows`, pick `ship-feature`, run it. The panel shows live phase progress; mid-run you can Steer (inject a message) or Stop. The realm also exposes `args`, `cwd`, and a `budget` object (`{ total, spent(), remaining() }`) so workflows can self-limit.
|
|
150
|
+
|
|
151
|
+
### Your first lifecycle run
|
|
152
|
+
|
|
153
|
+
```text
|
|
154
|
+
/fleet-implement Refactor the auth module to use the new session API --auto
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Runs `brainstorm → plan → implement → review → finish` autonomously. Drop `--auto` for checkpointed mode (pauses at each checkpoint; continue/revise/abort from `/fleet → Lifecycle`).
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Architecture
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
┌─────────────────────────────────────────────┐
|
|
165
|
+
│ pi host session │
|
|
166
|
+
│ (loads @getpipher/armory-fleet extension) │
|
|
167
|
+
└───────────────────────┬─────────────────────┘
|
|
168
|
+
│
|
|
169
|
+
┌───────────────────────────────────────┼───────────────────────────────────────┐
|
|
170
|
+
▼ ▼ ▼
|
|
171
|
+
subagent tool fleet tool /fleet panel
|
|
172
|
+
(model-callable) (workflow runner) (FleetView TUI)
|
|
173
|
+
│ │ │
|
|
174
|
+
▼ ▼ ▼
|
|
175
|
+
createAgentSession() WorkflowController Runs · Tiers · Lifecycle
|
|
176
|
+
(pi SDK child) + ConcurrencyPool Workflows · Conversation
|
|
177
|
+
│ + adapters + live widget
|
|
178
|
+
├─→ armory-todo sync + journal/resume
|
|
179
|
+
├─→ armory-memory hydrate │
|
|
180
|
+
├─→ vision (describe_image) ▼
|
|
181
|
+
├─→ lifecycle + gates backend registry
|
|
182
|
+
└─→ tier routing (pi | Claude Code)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Core engine
|
|
186
|
+
|
|
187
|
+
- **Engine primitive:** `createAgentSession()` from the pi SDK — child Pi sessions, in-memory or file-backed `SessionManager`, `ResourceLoader`. Each child is wrapped to emit `session_init` on subscribe so the fleet can track it from the first event.
|
|
188
|
+
- **Child loader:** `buildChildLoader()` threads armory-todo, armory-memory, and vision into every child's resource + tool set — armory-native from birth, cwd-agnostic.
|
|
189
|
+
- **Concurrency:** a single-slot lock for foreground runs + a `ConcurrencyPool` for parallel workflow branches.
|
|
190
|
+
- **Turn budget:** `engine/turn-budget.ts` caps each child's run; the `subagent` tool surfaces exhaustion as a structured status (not a silent truncation).
|
|
191
|
+
|
|
192
|
+
### Armory integration (the uncopyable layer)
|
|
193
|
+
|
|
194
|
+
| Sibling | What the fleet wires in | Where |
|
|
195
|
+
|---|---|---|
|
|
196
|
+
| [armory-todo](https://github.com/getpipher/armory-todo) | Every run syncs to the cross-session TODO store. Pass `todoId` to link. | `src/todo-sync/` |
|
|
197
|
+
| [armory-memory](https://github.com/getpipher/armory-memory) | Children hydrate project memory on spawn. Shared port, cwd-agnostic. | `src/memory-hydrate/` |
|
|
198
|
+
| [vision](https://github.com/getpipher/vision) | `describe_image` tool is wired into child sessions. | `src/vision/` |
|
|
199
|
+
| [cursor](https://github.com/getpipher/cursor) | Children edit through the cursor extension when present. | (via child loader) |
|
|
200
|
+
|
|
201
|
+
### Cross-harness backends
|
|
202
|
+
|
|
203
|
+
`src/backend/` ships a backend registry with **pi** (default) and **Claude Code** as peer backends. `detectClaude()` auto-discovers Claude; `PI_HOOK_PARITY` / `CLAUDE_HOOK_PARITY` tables keep both backends on equal footing. `hook-parity.ts` normalizes lifecycle/event hooks across harnesses. A `ResumeStore` persists backend session IDs so cross-harness runs can resume.
|
|
204
|
+
|
|
205
|
+
### Superpowers lifecycle
|
|
10
206
|
|
|
11
|
-
|
|
12
|
-
2. **Cross-harness peers** — only one attempt (kky42/pi-flow, 66⭐) runs Claude Code + Pi as peer subagent backends, and it's early. A dual-arsenal topology deserves a first-class implementation.
|
|
13
|
-
3. **Superpowers-native lifecycle** — only one attempt (teelicht/pi-superagents, 54⭐) wraps the superpowers skill pipeline, and it's synchronous-only.
|
|
207
|
+
The default lifecycle (`src/lifecycle/default.ts`) is the superpowers-native 5-phase pipeline:
|
|
14
208
|
|
|
15
|
-
|
|
209
|
+
| Phase | Skills loaded | Checkpoint? | Gates |
|
|
210
|
+
|---|---|---|---|
|
|
211
|
+
| `brainstorm` | `brainstorming` | ✅ | — |
|
|
212
|
+
| `plan` | `writing-plans` | ✅ | `completenessCheck` |
|
|
213
|
+
| `implement` | `executing-plans`, `test-driven-development`, `verification-before-completion` | ❌ | `verification-before-completion`, `completenessCheck`, `gate` |
|
|
214
|
+
| `review` | `requesting-code-review`, `receiving-code-review` | ✅ | — |
|
|
215
|
+
| `finish` | `finishing-a-development-branch` | — | — |
|
|
16
216
|
|
|
17
|
-
|
|
217
|
+
Custom lifecycles: drop a YAML file in your `lifecycles/` dir, register via `discoverLifecycles()`. Gates are registered on a `GateRegistry` (`fleet-register-gate` command for runtime extensibility).
|
|
18
218
|
|
|
19
|
-
|
|
219
|
+
### Quality gates
|
|
20
220
|
|
|
21
|
-
-
|
|
22
|
-
|
|
23
|
-
|
|
221
|
+
Built-in (`src/lifecycle/gates/`): `verification-before-completion`, `completeness-check`, `gate`, `verify`.
|
|
222
|
+
|
|
223
|
+
Composite helpers (`src/workflows/helpers/`) — usable from any workflow:
|
|
224
|
+
|
|
225
|
+
| Helper | What it does |
|
|
226
|
+
|---|---|
|
|
227
|
+
| `judgePanel` | Run N judge agents; majority/weighted verdict. |
|
|
228
|
+
| `loopUntilDry` | Re-run an agent until a dry-run gate passes. |
|
|
229
|
+
| `retry` | Retry an agent with backoff on failure. |
|
|
230
|
+
| `checkpoint` | Pause a workflow for human review. |
|
|
231
|
+
| `completeness-check` / `gate` / `verify` | Gate wrappers for workflow use. |
|
|
232
|
+
|
|
233
|
+
### Cost-aware tiers
|
|
234
|
+
|
|
235
|
+
`src/tiers/` ships three built-in tiers:
|
|
236
|
+
|
|
237
|
+
| Tier | Models | Cost cap | Context floor |
|
|
238
|
+
|---|---|---|---|
|
|
239
|
+
| `economy` | `Ollama/minimax-m3:cloud` | — | — |
|
|
240
|
+
| `standard` | `Ollama/glm-5.2:cloud`, `Ollama/minimax-m3:cloud` | — | — |
|
|
241
|
+
| `frontier` | `anthropic/claude-sonnet-4`, `Ollama/glm-5.2:cloud` | $5 | 200k ctx |
|
|
242
|
+
|
|
243
|
+
Live cost $ and context % are tracked per run and surfaced in the Tiers view. Override per-run with `model`, or let the tier registry route based on the task class.
|
|
244
|
+
|
|
245
|
+
### Operational runtime
|
|
246
|
+
|
|
247
|
+
`src/runtime/` — the async/scheduling spine:
|
|
248
|
+
|
|
249
|
+
- `async-runner.ts` — background dispatch (fire-and-forget).
|
|
250
|
+
- `run-journal.ts` + `run-log.ts` — durable run records; `reconcile.ts` reattaches orphaned runs on restart.
|
|
251
|
+
- `concurrency-pool.ts` — bounded parallel branches.
|
|
252
|
+
- `results-inbox.ts` — the model pulls completed background runs via the `fleet_results` tool.
|
|
253
|
+
- `resume.ts` — scan for resumable runs + workflows.
|
|
254
|
+
|
|
255
|
+
### Scheduling + worktree
|
|
256
|
+
|
|
257
|
+
`src/scheduling/` — cron expressions (`expressions.ts`), a `Scheduler` with PID-locking (`pid-lock.ts`), session-scoped (no catch-up). `src/worktree/` — `WorktreeService` for isolated bg-run worktrees + `DiffService` for reviewable diffs.
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## The fleet panel
|
|
262
|
+
|
|
263
|
+
`/fleet` opens an interactive TUI panel (TUI-only; in non-interactive modes use the `subagent` tool).
|
|
264
|
+
|
|
265
|
+
| View | What it shows |
|
|
266
|
+
|---|---|
|
|
267
|
+
| **Runs** | Running + recent subagents; status, cost, context %, agent, model. Action submenu: Steer, Stop, View conversation. |
|
|
268
|
+
| **Tiers** | Per-tier model lists, cost caps, context floors. Configure routing. |
|
|
269
|
+
| **Lifecycle** | Active lifecycle runs; Continue/Revise/Abort at checkpoints. |
|
|
270
|
+
| **Workflows** | Registered workflows + live runs. Run, edit-resume, save-as, view result, checkpoint. |
|
|
271
|
+
| **Conversation** | The full message timeline for any selected run. |
|
|
272
|
+
|
|
273
|
+
A live `FleetWidget` can render in the pi footer/overlay for at-a-glance fleet status while you work.
|
|
274
|
+
|
|
275
|
+
### Slash commands
|
|
276
|
+
|
|
277
|
+
| Command | Purpose |
|
|
278
|
+
|---|---|
|
|
279
|
+
| `/fleet` | Open the interactive fleet panel (TUI). |
|
|
280
|
+
| `/fleet-implement <task> [--lifecycle <name>] [--auto]` | Run a task through the superpowers lifecycle. |
|
|
281
|
+
| `/fleet-register-gate` | Register a custom gate on the fleet gate registry (extensibility). |
|
|
282
|
+
|
|
283
|
+
### Model-callable tools
|
|
284
|
+
|
|
285
|
+
| Tool | Purpose |
|
|
286
|
+
|---|---|
|
|
287
|
+
| `subagent` | Delegate a focused task to a child agent (sync foreground or async background). |
|
|
288
|
+
| `fleet` | Run + control fleet workflows (JS orchestration: `agent`, `pipeline`, `phase`, checkpoints). |
|
|
289
|
+
| `fleet_results` | Pull completed background run results from the inbox. |
|
|
290
|
+
|
|
291
|
+
---
|
|
292
|
+
|
|
293
|
+
## Workflows-as-code
|
|
294
|
+
|
|
295
|
+
Workflows are authored in a JS DSL (`src/workflows/source.ts` parses; `vm-realm.ts` evaluates). 5 builtins ship in `src/workflows/builtin/`:
|
|
296
|
+
|
|
297
|
+
| Workflow | Description | Phases |
|
|
298
|
+
|---|---|---|
|
|
299
|
+
| `adversarial-review` | Red-team + blue-team review with judge panel | Attack → Defend → Judge |
|
|
300
|
+
| `code-review` | 7 parallel review angles plus verification | Review → Verify |
|
|
301
|
+
| `codebase-audit` | File-tree scan with completeness check | Scan → Audit |
|
|
302
|
+
| `deep-research` | 3-round discovery loop with de-duplication | Discover → Synthesize |
|
|
303
|
+
| `multi-perspective` | 4 personas review the same artifact | Review → Merge |
|
|
304
|
+
|
|
305
|
+
Every workflow run is **journaled** (`workflows/journal.ts`) and **resumable**. `edit-resume` replays the unchanged prefix from cache and re-runs only the edited suffix. `runtime/controller.ts` orchestrates; `runtime/pause-gate.ts` handles checkpoints; `runtime/adapters.ts` binds the controller to the fleet's spawn + accounting.
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
## Roadmap
|
|
310
|
+
|
|
311
|
+
armory-fleet follows a PRD → SPEC-N (brainstorm → spec → plan → implementation) pipeline. **16/16 phases done through v0.12.0.**
|
|
312
|
+
|
|
313
|
+
| SPEC | Headline | Status | Artifact |
|
|
314
|
+
|---|---|---|---|
|
|
315
|
+
| PRD | Master PRD | ✅ done | `PRD.md` |
|
|
316
|
+
| RESEARCH | Landscape research (11+ packages, 5 deep-reads) | ✅ done | `research/` |
|
|
317
|
+
| SPEC-1 | Core engine + armory-todo sync | ✅ done | PR #1 · `547319b` |
|
|
318
|
+
| SPEC-2 | Deep armory integration (memory/vision/cursor) | ✅ done · @0.2.0 | PR #2 · `c6e727c` |
|
|
319
|
+
| SPEC-3 | Cross-harness peers (pi + Claude Code) | ✅ done · @0.3.0 | PR #4 · `5bb75fb` |
|
|
320
|
+
| SPEC-4 | Superpowers-native lifecycle | ✅ done · @0.4.0 | PR #5 · `67ff9b4` |
|
|
321
|
+
| SPEC-5a | Operational runtime (async/scheduling/worktree) | ✅ done · @0.5.2 | PR #6 · `52e3477` |
|
|
322
|
+
| SPEC-5b-1 | RunLog seam + Runs view | ✅ done · @0.6.0 | PR #7 · `54b1b10` |
|
|
323
|
+
| SPEC-5b-2 | Live widget + FleetView + Q9 | ✅ done · @0.7.0 | PR #8 · `9266a7` |
|
|
324
|
+
| SPEC-5b-3 | Conversation viewer + timeline fix | ✅ done · @0.8.0 | PR #9 · `adc0034` |
|
|
325
|
+
| SPEC-5b-4 | Mid-run steering (Steer) + Stop | ✅ done · @0.9.1 | PR #10 + #11 + #12 |
|
|
326
|
+
| SPEC-6-1 | Cost-aware tiers + cost $ + context % + Tiers view | ✅ done · @0.10.x | PR #15/#16/#17 |
|
|
327
|
+
| SPEC-6-2 | Quality gates + lifecycle hooks | ✅ done · @0.11.0 | PR #18 · `cda5e2b` |
|
|
328
|
+
| v0.11.1 | bg dispatch isolation split (non-git cwd fix) | ✅ done · @0.11.1 | PR #19 · `51956e0` |
|
|
329
|
+
| **SPEC-6-3** | **Workflows-as-code (release-gate completion)** | ✅ done · @0.12.0 | PR #21 · `9986ad1` |
|
|
330
|
+
| SPEC-6-4 | Event-bus RPC + live conversation viewer → v1.0 | 🚧 next | — |
|
|
331
|
+
|
|
332
|
+
See the [full release history](https://github.com/getpipher/armory-fleet/releases) and the [PRD](./PRD.md) §8 for the roadmap rationale.
|
|
333
|
+
|
|
334
|
+
---
|
|
335
|
+
|
|
336
|
+
## Ecosystem
|
|
337
|
+
|
|
338
|
+
armory-fleet is the orchestrator in the [getpipher](https://github.com/getpipher) armory suite — the default substrate it runs agents on:
|
|
339
|
+
|
|
340
|
+
| Package | Role |
|
|
341
|
+
|---|---|
|
|
342
|
+
| [armory-todo](https://github.com/getpipher/armory-todo) | Global cross-session TODO store (the fleet syncs every run to it). |
|
|
343
|
+
| [armory-memory](https://github.com/getpipher/armory-memory) | Project memory hydration for child agents. |
|
|
344
|
+
| [vision](https://github.com/getpipher/vision) | The `describe_image` tool, wired into fleet children. |
|
|
345
|
+
| [cursor](https://github.com/getpipher/cursor) | Custom editor component for the pi TUI. |
|
|
346
|
+
|
|
347
|
+
---
|
|
348
|
+
|
|
349
|
+
## Conventions
|
|
350
|
+
|
|
351
|
+
- **No build step** — extensions ship raw `.ts` via tsx at pi runtime. `pnpm typecheck` + `pnpm test:run` before release.
|
|
352
|
+
- **Tests** — `node:test` via tsx in `test/*.test.mts`, importing from `../src/...`. 593 passing.
|
|
353
|
+
- **Publish** — CI on `v*` tags using the getpipher `NPM_TOKEN` org secret (`release.yml` mirrors armory-todo: idempotent npm publish + GitHub Release).
|
|
354
|
+
- **Interactive-first UX** — every capability lands as a `/fleet` panel tab/view + action submenu first, then the model-callable tool action.
|
|
355
|
+
|
|
356
|
+
<a id="testing"></a>
|
|
357
|
+
### Verify locally
|
|
358
|
+
|
|
359
|
+
```bash
|
|
360
|
+
pnpm install
|
|
361
|
+
pnpm typecheck
|
|
362
|
+
pnpm test:run --test-timeout=30000 # 593/593
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
### Release-gate smoke (mandatory before any release)
|
|
366
|
+
|
|
367
|
+
```bash
|
|
368
|
+
pi --no-extensions -e ./src/index.ts --no-session --approve
|
|
369
|
+
# inside: /fleet → Workflows → verify the 5 builtins render + a workflow runs end-to-end
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
---
|
|
24
373
|
|
|
25
374
|
## Compatibility
|
|
26
375
|
|
|
27
|
-
pi `^0.81.1
|
|
376
|
+
- pi `^0.81.1`
|
|
377
|
+
- Node `>=22` (tsx runtime)
|
|
378
|
+
- Platform: macOS, Linux, WSL
|
|
28
379
|
|
|
29
380
|
## License
|
|
30
381
|
|
|
31
|
-
MIT — see [LICENSE](./LICENSE).
|
|
382
|
+
MIT — see [LICENSE](./LICENSE). © RECTOR ([@rz1989s](https://github.com/rz1989s)).
|
|
383
|
+
|
|
384
|
+
<div align="center">
|
|
385
|
+
|
|
386
|
+
Built with Ihsan · Maintained by [RECTOR](https://github.com/rz1989s) · [getpipher](https://github.com/getpipher)
|
|
387
|
+
|
|
388
|
+
</div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getpipher/armory-fleet",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The armory suite's subagent orchestrator for the pi coding agent — a cross-harness, superpowers-native fleet where every agent is armory-native from birth.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -7,6 +7,30 @@ import { dirname } from "node:path";
|
|
|
7
7
|
import type { AgentDef } from "../registry/frontmatter.ts";
|
|
8
8
|
import type { MemoryHydratePort } from "../memory-hydrate/port.ts";
|
|
9
9
|
|
|
10
|
+
/** Shape of the pi resource-loader's current-skills context (the `cur` arg of skillsOverride).
|
|
11
|
+
* Generic over the skill + diagnostics types so the pi SDK's full `Skill`/`ResourceDiagnostic`
|
|
12
|
+
* types flow through unchanged. */
|
|
13
|
+
export interface InstalledSkills<S = { name: string }, D = unknown> {
|
|
14
|
+
skills: S[];
|
|
15
|
+
diagnostics: D;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** #32: resolve the child's skill bundle from the agent's declared skills + the installed arsenal.
|
|
19
|
+
*
|
|
20
|
+
* - Agent declares specific skills → load only those (filtered from the installed arsenal).
|
|
21
|
+
* - Agent declares NO skills → load NO skills (lean substrate). This is the #32 fix: previously
|
|
22
|
+
* an agent with no `skills` field loaded ALL installed skills (~42 → ~570K substrate on every
|
|
23
|
+
* `general-purpose` dispatch, ~59% of a 976K context window from turn 1). Callers opt in to
|
|
24
|
+
* skills via the `subagent` tool's `skills` param (threaded as `skillsOverride`, which clones
|
|
25
|
+
* `agent.skills` before this runs).
|
|
26
|
+
* - Returning `[]` (not `cur.skills`) is the deliberate behavior change. */
|
|
27
|
+
export function resolveChildSkills<S extends { name: string }, D>(agent: Pick<AgentDef, "skills">, cur: InstalledSkills<S, D>): InstalledSkills<S, D> {
|
|
28
|
+
const selected = agent.skills && agent.skills.length
|
|
29
|
+
? cur.skills.filter((s) => agent.skills!.includes(s.name))
|
|
30
|
+
: [];
|
|
31
|
+
return { skills: selected, diagnostics: cur.diagnostics };
|
|
32
|
+
}
|
|
33
|
+
|
|
10
34
|
/** Fixed pseudo-cwd for the global cross-project user memory scope. */
|
|
11
35
|
export const USER_PSEUDO_CWD = "/__armory-fleet-user__";
|
|
12
36
|
|
|
@@ -36,12 +60,6 @@ export function buildChildLoader(opts: ChildLoaderOpts): DefaultResourceLoader {
|
|
|
36
60
|
noExtensions: true,
|
|
37
61
|
systemPromptOverride: (base) =>
|
|
38
62
|
composeChildPrompt({ rolePrompt: opts.agent.rolePrompt, memoryBlock, base: base ?? "" }),
|
|
39
|
-
skillsOverride: (cur) => (
|
|
40
|
-
skills:
|
|
41
|
-
opts.agent.skills && opts.agent.skills.length
|
|
42
|
-
? cur.skills.filter((s) => opts.agent.skills!.includes(s.name))
|
|
43
|
-
: cur.skills,
|
|
44
|
-
diagnostics: cur.diagnostics,
|
|
45
|
-
}),
|
|
63
|
+
skillsOverride: (cur) => resolveChildSkills(opts.agent, cur),
|
|
46
64
|
});
|
|
47
|
-
}
|
|
65
|
+
}
|
|
@@ -142,6 +142,11 @@ export interface SpawnOptions {
|
|
|
142
142
|
modelRegistry?: ModelRegistryLike;
|
|
143
143
|
/** SPEC-6-3: workflow adapter tier override — replaces agent.tier before model resolution. */
|
|
144
144
|
tierOverride?: string;
|
|
145
|
+
/** #31: when true, the caller asserts this dispatch will NOT mutate the working directory
|
|
146
|
+
* (review/audit/research). Read-only dispatches bypass the foreground single-slot lock so
|
|
147
|
+
* multiple readOnly dispatches — and/or a readOnly dispatch alongside a write dispatch — can
|
|
148
|
+
* run in parallel. Mislabeling a write dispatch as readOnly risks in-place edit conflicts. */
|
|
149
|
+
readOnly?: boolean;
|
|
145
150
|
}
|
|
146
151
|
|
|
147
152
|
export interface SpawnResult {
|
|
@@ -158,6 +163,11 @@ export interface SpawnResult {
|
|
|
158
163
|
/** SPEC-6-1: final context tokens (calcContextTokens of the last usage). */
|
|
159
164
|
contextTokens?: number;
|
|
160
165
|
error?: string;
|
|
166
|
+
/** #39: true when the failure is a retryable provider rate-limit / auth failure
|
|
167
|
+
* (stopReason "error"). The subagent tool retries once with the configured `modelFallback`
|
|
168
|
+
* when this is set. Non-retryable failures (turn budget, agent-not-found, EMPTY_RESULT,
|
|
169
|
+
* abort, lock busy) leave this unset. */
|
|
170
|
+
retryable?: boolean;
|
|
161
171
|
}
|
|
162
172
|
|
|
163
173
|
export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
@@ -165,15 +175,25 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
165
175
|
const maxTurns = opts.maxTurns ?? DEFAULT_MAX_TURNS;
|
|
166
176
|
const startedAt = Date.now();
|
|
167
177
|
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
178
|
+
// #31: read-only dispatches (review/audit/research) bypass the foreground single-slot lock —
|
|
179
|
+
// the caller asserts no cwd mutation, so the in-place edit-conflict guard doesn't apply and
|
|
180
|
+
// multiple readOnly dispatches (and/or a readOnly alongside a write dispatch) may run in
|
|
181
|
+
// parallel. The lock is still acquired for write dispatches (default), preserving concurrency=1.
|
|
182
|
+
const readOnly = opts.readOnly ?? false;
|
|
183
|
+
let runId: string;
|
|
184
|
+
if (readOnly) {
|
|
185
|
+
runId = genRunId();
|
|
186
|
+
} else {
|
|
187
|
+
// concurrency=1 (SPEC-1 §9.2) — peek before generating a runId so a rejected
|
|
188
|
+
// call doesn't mint a discarded id; the held id is named in the message.
|
|
189
|
+
const busyId = opts.lock.current();
|
|
190
|
+
if (busyId !== null) {
|
|
191
|
+
return fail("", startedAt, `a subagent is already running (concurrency=1 in v0.1); wait for ${busyId} to finish or abort it first`, opts.agent);
|
|
192
|
+
}
|
|
193
|
+
runId = genRunId();
|
|
194
|
+
if (!opts.lock.tryAcquire(runId)) {
|
|
195
|
+
return fail("", startedAt, "concurrency lock unexpectedly unavailable", opts.agent);
|
|
196
|
+
}
|
|
177
197
|
}
|
|
178
198
|
|
|
179
199
|
try {
|
|
@@ -282,6 +302,10 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
282
302
|
let costTotal = 0;
|
|
283
303
|
let contextTokens = 0;
|
|
284
304
|
let turnIdx = -1;
|
|
305
|
+
// #26/#22: declared before subscribe() because some child sessions emit events
|
|
306
|
+
// synchronously inside subscribe() (temporal-dead-zone guard).
|
|
307
|
+
let modelError: string | undefined; // model-call failure surfaced via stopReason "error"
|
|
308
|
+
let sawAssistantMessage = false; // #22: did the child emit any assistant message_end at all?
|
|
285
309
|
|
|
286
310
|
const onSignalAbort = (): void => { aborted = true; void session.abort(); };
|
|
287
311
|
opts.signal?.addEventListener("abort", onSignalAbort);
|
|
@@ -297,8 +321,18 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
297
321
|
} else if (e.type === "turn_end") {
|
|
298
322
|
if (budget.consume()) void session.abort();
|
|
299
323
|
} else if (e.type === "message_end" && e.message?.role === "assistant") {
|
|
324
|
+
sawAssistantMessage = true;
|
|
300
325
|
const text = e.message.content?.map((c) => (c.type === "text" ? c.text ?? "" : "")).join("") ?? "";
|
|
301
|
-
|
|
326
|
+
// #26/#22: a model-call failure (401, provider down, rate limit) surfaces as
|
|
327
|
+
// stopReason "error". The SDK retries internally; if it still ends with an error
|
|
328
|
+
// stopReason, capture it so the run is marked failed (not completed-with-empty) —
|
|
329
|
+
// the controller gets an actionable error instead of "(no tool output)".
|
|
330
|
+
const stopReason = (e.message as { stopReason?: string }).stopReason;
|
|
331
|
+
if (stopReason === "error") {
|
|
332
|
+
modelError = text || `model call ended with stopReason 'error' (provider/auth failure or rate limit) for model '${model}'`;
|
|
333
|
+
} else {
|
|
334
|
+
if (text) finalText = text;
|
|
335
|
+
}
|
|
302
336
|
// SPEC-5b-2 (Q9): accumulate REAL tokens (input+output+cacheRead+cacheWrite), not cost.total (dollars).
|
|
303
337
|
const u = e.message.usage;
|
|
304
338
|
const turnTokens = (u?.input ?? 0) + (u?.output ?? 0) + (u?.cacheRead ?? 0) + (u?.cacheWrite ?? 0);
|
|
@@ -343,18 +377,53 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
343
377
|
status = "aborted";
|
|
344
378
|
error = tier?.costCap && costTotal > tier.costCap ? `budget_exceeded (cost $${costTotal.toFixed(4)} > cap $${tier.costCap})` : "aborted by user";
|
|
345
379
|
} else if (budget.count() >= maxTurns) {
|
|
380
|
+
// #25: surface a coherent partial, not a mid-sentence 200-char cut. The controller reads
|
|
381
|
+
// `res.error` (the tool surfaces error, not finalText, for failed runs), so the partial must
|
|
382
|
+
// live here. 4000 chars (~600 tokens) is enough for any structured summary the model emitted;
|
|
383
|
+
// a truncation marker names the run log for the full output. (The wind-down nudge — injecting
|
|
384
|
+
// a "you have ~N turns left, emit your partial now" message before the hard cut — is a
|
|
385
|
+
// future enhancement tracked in #25; it needs mid-loop injection semantics.)
|
|
386
|
+
const PARTIAL_WINDOW = 4000;
|
|
387
|
+
const partial = finalText.length > PARTIAL_WINDOW
|
|
388
|
+
? finalText.slice(0, PARTIAL_WINDOW) + "\n…(partial truncated — see run log for full output)"
|
|
389
|
+
: finalText;
|
|
346
390
|
status = "failed";
|
|
347
|
-
error = `hit turn budget (${maxTurns}) mid-task; partial result
|
|
391
|
+
error = `hit turn budget (${maxTurns}) mid-task; partial result:\n${partial}`;
|
|
348
392
|
} else if (runError) {
|
|
349
393
|
status = "failed";
|
|
350
394
|
error = runError;
|
|
395
|
+
} else if (modelError) {
|
|
396
|
+
// #26: a 401/provider/rate-limit failure that the SDK surfaced via stopReason "error"
|
|
397
|
+
// after exhausting retries. Without this, the run fell through to `completed` with an
|
|
398
|
+
// empty finalText — the controller saw "(no tool output)" and couldn't tell a broken
|
|
399
|
+
// model from a no-op run.
|
|
400
|
+
//
|
|
401
|
+
// Precedence note (PR #30 review): a late error-stop overrides a prior successful turn.
|
|
402
|
+
// If turn 1 set finalText (valid output) and turn 2 hit stopReason "error", the run is
|
|
403
|
+
// marked failed with the error — the run IS incomplete, and the error is more actionable
|
|
404
|
+
// to the controller than a partial result. finalText is preserved (not cleared) so
|
|
405
|
+
// finishRun + the run log still carry the partial; only the surfaced status is failed.
|
|
406
|
+
// Gating this on `!finalText` (only fail if no prior output) is a future design call, not
|
|
407
|
+
// this fix — the current "last error wins" is the defensible default.
|
|
408
|
+
status = "failed";
|
|
409
|
+
error = modelError;
|
|
410
|
+
} else if (!sawAssistantMessage) {
|
|
411
|
+
// #22: prompt() resolved cleanly but the child produced NO assistant message_end at all.
|
|
412
|
+
// A real agent loop always emits at least one assistant message; zero means a silent
|
|
413
|
+
// failure (provider hung, empty response, premature exit). Treat as a structured
|
|
414
|
+
// EMPTY_RESULT so orchestration can escalate models or retry, rather than silently
|
|
415
|
+
// succeeding with empty output the controller can't distinguish from a no-op.
|
|
416
|
+
status = "failed";
|
|
417
|
+
error = `EMPTY_RESULT: child session produced no assistant output for model '${model}' (possible provider/auth failure, empty response, or premature exit)`;
|
|
351
418
|
} else {
|
|
352
419
|
status = "completed";
|
|
353
420
|
}
|
|
354
421
|
|
|
355
|
-
return await finishRun(opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentDef.name, model, tokenTotal, costTotal, contextTokens);
|
|
422
|
+
return await finishRun(opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentDef.name, model, tokenTotal, costTotal, contextTokens, modelError ? true : undefined);
|
|
356
423
|
} finally {
|
|
357
|
-
|
|
424
|
+
// #31: a readOnly dispatch never acquired the lock — don't release what it didn't take
|
|
425
|
+
// (releasing a lock held by another concurrent write dispatch would corrupt serialization).
|
|
426
|
+
if (!readOnly) opts.lock.release();
|
|
358
427
|
}
|
|
359
428
|
}
|
|
360
429
|
|
|
@@ -372,6 +441,7 @@ async function finishRun(
|
|
|
372
441
|
opts: SpawnOptions, runId: string, startedAt: number,
|
|
373
442
|
status: FleetRunStatus, finalText: string, todoId: string | null, priorStatus: string | undefined,
|
|
374
443
|
error: string | undefined, agentName: string, model: string, tokenTotal = 0, costTotal = 0, contextTokens = 0,
|
|
444
|
+
retryable?: boolean,
|
|
375
445
|
): Promise<SpawnResult> {
|
|
376
446
|
if (finalizedRunIds.has(runId)) {
|
|
377
447
|
// Already finalized — return the existing registry record's result without re-appending.
|
|
@@ -380,7 +450,7 @@ async function finishRun(
|
|
|
380
450
|
status: existing?.status ?? status, finalText: existing?.resultSummary ?? finalText,
|
|
381
451
|
runId, todoId, agent: agentName, model,
|
|
382
452
|
durationMs: existing?.endedAt ? existing.endedAt - startedAt : Date.now() - startedAt,
|
|
383
|
-
tokenTotal, costTotal, contextTokens, error,
|
|
453
|
+
tokenTotal, costTotal, contextTokens, error, retryable,
|
|
384
454
|
};
|
|
385
455
|
}
|
|
386
456
|
finalizedRunIds.add(runId);
|
|
@@ -414,6 +484,6 @@ async function finishRun(
|
|
|
414
484
|
}
|
|
415
485
|
return {
|
|
416
486
|
status, finalText, runId, todoId, agent: agentName, model,
|
|
417
|
-
durationMs: endedAt - startedAt, tokenTotal, costTotal, contextTokens, error,
|
|
487
|
+
durationMs: endedAt - startedAt, tokenTotal, costTotal, contextTokens, error, retryable,
|
|
418
488
|
};
|
|
419
489
|
}
|
package/src/index.ts
CHANGED
|
@@ -294,10 +294,15 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
294
294
|
deps.runLog = new RunLog(join(dir, "conversations"));
|
|
295
295
|
// v0.10.2: pass the in-memory RunRegistry so reconcile syncs it too — otherwise orphaned
|
|
296
296
|
// (process-gone) runs keep status:"running" in memory and the live widget shows a stale ▶ forever.
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
}
|
|
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
|
+
});
|
|
301
306
|
deps.asyncRunner = {
|
|
302
307
|
worktree: new WorktreeService({ rootDir: ctx.cwd }),
|
|
303
308
|
diff: new DiffService(),
|
|
@@ -340,6 +345,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
340
345
|
getModelContextWindow,
|
|
341
346
|
cwd: ctx.cwd,
|
|
342
347
|
runLog: deps.runLog,
|
|
348
|
+
todoSync: deps.todoSync, // #22 bg-watchdog: periodic probe reverts process-gone runs' TODOs
|
|
343
349
|
});
|
|
344
350
|
fleetWidget.start();
|
|
345
351
|
|
package/src/panel/fleet-panel.ts
CHANGED
|
@@ -697,19 +697,31 @@ export class FleetPanel extends Container {
|
|
|
697
697
|
}
|
|
698
698
|
// SPEC-6-3: Workflows view — direct p/u/x controls + host-only intent completion
|
|
699
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
|
+
}
|
|
700
717
|
const sel = this.list.getSelectedItem()
|
|
701
|
-
if (!sel) return
|
|
718
|
+
if (!sel) return // an action key was pressed but there's no row to act on
|
|
702
719
|
const parsed = parseWorkflowPanelKey(sel.value)
|
|
703
720
|
const item: WorkflowPanelItem = parsed.kind === "definition"
|
|
704
721
|
? { kind: "definition", definition: this.deps.workflowRegistry.get(parsed.name) ?? { name: parsed.name, description: "", phases: [], sourceText: "", body: "", executable: "", source: "builtin", filePath: "" } }
|
|
705
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 } }
|
|
706
723
|
const available = actionsForWorkflowItem(item)
|
|
707
724
|
|
|
708
|
-
const keyAction: Record<string, WorkflowPanelAction> = {
|
|
709
|
-
r: "run", e: "edit-resume", o: "open", p: "pause", u: "resume", x: "stop", s: "save", v: "view-result", c: "respond",
|
|
710
|
-
}
|
|
711
|
-
const action = keyAction[data]
|
|
712
|
-
if (!action) return
|
|
713
725
|
if (!available.includes(action)) {
|
|
714
726
|
this.onNotify(`action '${action}' not available for this item`, "warning")
|
|
715
727
|
return
|
|
@@ -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
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { WorkflowJournal } from "../workflows/journal.ts";
|
|
5
5
|
import type { RunLog } from "./run-log.ts";
|
|
6
6
|
import type { RunRegistry, RunRecord } from "../engine/run-registry.ts";
|
|
7
|
+
import type { TodoSyncPort } from "../todo-sync/port.ts";
|
|
7
8
|
|
|
8
9
|
export type Liveness = "alive" | "dead";
|
|
9
10
|
|
|
@@ -25,13 +26,20 @@ export interface ReconcileOpts {
|
|
|
25
26
|
graceMs?: number;
|
|
26
27
|
now?: number;
|
|
27
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;
|
|
28
34
|
}
|
|
29
35
|
|
|
30
|
-
/** Returns the runIds it marked aborted. Probe-driven (SPEC-6-2); idempotent.
|
|
31
|
-
|
|
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[]> {
|
|
32
39
|
const grace = opts.graceMs ?? 60_000;
|
|
33
40
|
const now = opts.now ?? Date.now();
|
|
34
41
|
const reg = opts.runRegistry;
|
|
42
|
+
const todoSync = opts.todoSync;
|
|
35
43
|
const aborted: string[] = [];
|
|
36
44
|
for (const meta of log.scanMeta()) {
|
|
37
45
|
if (meta.status !== "running") continue;
|
|
@@ -44,6 +52,16 @@ export function reconcileRuns(log: RunLog, opts: ReconcileOpts = {}): string[] {
|
|
|
44
52
|
endedAt: now, resultSummary: "process-gone (probe)", tokenTotal: meta.tokenTotal,
|
|
45
53
|
});
|
|
46
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
|
+
}
|
|
47
65
|
aborted.push(meta.runId);
|
|
48
66
|
}
|
|
49
67
|
return aborted;
|
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> {
|
package/src/tools/subagent.ts
CHANGED
|
@@ -29,10 +29,21 @@ export const subagentParams = Type.Object({
|
|
|
29
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." })),
|
|
30
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.' })),
|
|
31
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.' })),
|
|
32
|
+
readOnly: Type.Optional(Type.Boolean({ description: 'Default false. Pass true ONLY for dispatches that will NOT mutate the working directory (review/audit, or research that writes no scratch files). A readOnly dispatch bypasses the foreground single-slot lock so multiple readOnly dispatches — and/or a readOnly alongside a write dispatch — can run in parallel. The caller is responsible for the assertion: mislabeling a dispatch that edits as readOnly risks in-place edit conflicts. Has no effect on background/scheduled runs (they use their own locks).' })),
|
|
33
|
+
skills: Type.Optional(Type.Array(Type.String(), { description: 'Skills to load for this dispatch (opt-in). By default a dispatch loads NO skills (#32 — lean substrate; previously an agent with no skills field loaded ALL ~42 installed skills, ~570K tokens / ~59% of context). Pass skill names from the installed arsenal (e.g. ["executing-plans", "test-driven-development"]) to opt in. For a direct dispatch, this replaces the agent\'s frontmatter skills (pass [] to load zero). For a lifecycle dispatch, this is ADDITIVE — the phase\'s designed skill bundle always loads and these are merged on top (a caller cannot strip a phase\'s required skills).' })),
|
|
34
|
+
modelFallback: Type.Optional(Type.String({ description: 'Model to retry with if the primary dispatch fails with a retryable provider rate-limit / auth failure (stopReason "error"). The fleet retries ONCE on this model and relinks the same tracked todo. Surface the model that served the retry in the result details (retriedWithModel). Per the AGENTS.md "Ollama primary + OpenRouter fallback" pattern. No effect on non-retryable failures (turn budget, agent-not-found, abort). Direct foreground dispatches only — background/scheduled/lifecycle retries are a follow-up.' })),
|
|
32
35
|
});
|
|
33
36
|
|
|
34
37
|
export type SubagentInput = Static<typeof subagentParams>;
|
|
35
38
|
|
|
39
|
+
/** #32: merge a lifecycle phase's skill bundle with the caller's `skills` param.
|
|
40
|
+
* Additive + deduped — the phase's designed skills always load; the caller can add extras but
|
|
41
|
+
* cannot strip phase skills (avoids the footgun where a caller passing `skills: ["tdd"]` with
|
|
42
|
+
* `lifecycle: "default"` would silently drop `brainstorming` from the brainstorm phase). */
|
|
43
|
+
export function mergeLifecycleSkills(phaseSkills: string[] | undefined, callerSkills: string[] | undefined): string[] {
|
|
44
|
+
return [...new Set([...(phaseSkills ?? []), ...(callerSkills ?? [])])];
|
|
45
|
+
}
|
|
46
|
+
|
|
36
47
|
export interface SubagentToolDeps {
|
|
37
48
|
registry: Map<string, AgentDef>;
|
|
38
49
|
runRegistry: RunRegistry;
|
|
@@ -76,6 +87,9 @@ export function createSubagentTool(deps: SubagentToolDeps) {
|
|
|
76
87
|
"Use subagent to delegate an isolated, well-scoped task to a named agent; it runs in the foreground and returns the result + a runId.",
|
|
77
88
|
"Pass todoId to link the run to an existing open todo you see in the Open TODOs block; otherwise fleet creates a tracked fleet task.",
|
|
78
89
|
"Pass track:false only for trivial throwaway lookups that don't represent real work.",
|
|
90
|
+
"Pass readOnly:true for dispatches that will NOT edit the working directory (review/audit, or research that writes no scratch files). It bypasses the foreground single-slot lock so multiple readOnly dispatches can run in parallel. Only use it when you are certain the child won't mutate cwd — mislabeling risks edit conflicts.",
|
|
91
|
+
"By default a dispatch loads NO skills (lean substrate). If the task needs a skill (e.g. test-driven-development for a TDD task, executing-plans for a plan-execution task), pass its name in the `skills` array to opt in — loading all skills by default wastes ~59% of the context window.",
|
|
92
|
+
"Pass `modelFallback` so a transient provider rate-limit / auth failure (stopReason 'error') auto-retries once on the fallback model instead of failing the dispatch. Per the AGENTS.md 'Ollama primary + OpenRouter fallback' pattern — don't let infra limits break a dispatch chain.",
|
|
79
93
|
],
|
|
80
94
|
parameters: subagentParams,
|
|
81
95
|
async execute(_toolCallId: string, params: SubagentInput, signal: AbortSignal, _onUpdate: unknown, _ctx: any) {
|
|
@@ -101,11 +115,12 @@ export function createSubagentTool(deps: SubagentToolDeps) {
|
|
|
101
115
|
...deps.lifecycleDeps,
|
|
102
116
|
spawn: async (o) => spawnSubagent({
|
|
103
117
|
agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
|
|
104
|
-
skillsOverride: o.skills, backendOverride: o.backend,
|
|
118
|
+
skillsOverride: mergeLifecycleSkills(o.skills, params.skills), backendOverride: o.backend,
|
|
105
119
|
registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
|
|
106
120
|
backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: deps.parentCwd, runLog: deps.runLog, signal,
|
|
107
121
|
maxTurns: params.maxTurns,
|
|
108
122
|
tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
|
|
123
|
+
readOnly: params.readOnly,
|
|
109
124
|
}),
|
|
110
125
|
};
|
|
111
126
|
const res = await runLifecycle(params.task, params.lifecycle, {
|
|
@@ -127,6 +142,8 @@ export function createSubagentTool(deps: SubagentToolDeps) {
|
|
|
127
142
|
todoId: params.todoId,
|
|
128
143
|
track: params.track,
|
|
129
144
|
model: params.model,
|
|
145
|
+
readOnly: params.readOnly,
|
|
146
|
+
skillsOverride: params.skills,
|
|
130
147
|
registry: deps.registry,
|
|
131
148
|
todoSync: deps.todoSync,
|
|
132
149
|
runRegistry: deps.runRegistry,
|
|
@@ -139,15 +156,49 @@ export function createSubagentTool(deps: SubagentToolDeps) {
|
|
|
139
156
|
maxTurns: params.maxTurns,
|
|
140
157
|
tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
|
|
141
158
|
});
|
|
142
|
-
|
|
159
|
+
// #39: auto-retry on a retryable provider rate-limit / auth failure (stopReason "error").
|
|
160
|
+
// The primary run reverted its linked todo to open (finishRun -> markRunTodoReverted), so the
|
|
161
|
+
// retry relinks the SAME todoId to continue the tracked task. Retry ONCE, only on the direct
|
|
162
|
+
// foreground path, only if a distinct fallback model was provided. The retry's runId differs
|
|
163
|
+
// from the primary's (each spawnSubagent call mints its own); details.retriedWithModel marks it.
|
|
164
|
+
let retriedWithModel: string | undefined;
|
|
165
|
+
let finalRes = res;
|
|
166
|
+
if (
|
|
167
|
+
res.status === "failed" && res.retryable && params.modelFallback &&
|
|
168
|
+
params.modelFallback !== res.model && !signal.aborted
|
|
169
|
+
) {
|
|
170
|
+
finalRes = await spawnSubagent({
|
|
171
|
+
agent: params.agent,
|
|
172
|
+
task: params.task,
|
|
173
|
+
todoId: res.todoId ?? undefined,
|
|
174
|
+
track: params.track,
|
|
175
|
+
model: params.modelFallback,
|
|
176
|
+
readOnly: params.readOnly,
|
|
177
|
+
skillsOverride: params.skills,
|
|
178
|
+
registry: deps.registry,
|
|
179
|
+
todoSync: deps.todoSync,
|
|
180
|
+
runRegistry: deps.runRegistry,
|
|
181
|
+
lock: deps.lock,
|
|
182
|
+
backendRegistry: deps.backendRegistry,
|
|
183
|
+
parentModel: deps.parentModel,
|
|
184
|
+
parentCwd: deps.parentCwd,
|
|
185
|
+
runLog: deps.runLog,
|
|
186
|
+
signal,
|
|
187
|
+
maxTurns: params.maxTurns,
|
|
188
|
+
tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
|
|
189
|
+
});
|
|
190
|
+
retriedWithModel = params.modelFallback;
|
|
191
|
+
}
|
|
192
|
+
const isError = finalRes.status === "failed" || finalRes.status === "aborted";
|
|
143
193
|
return {
|
|
144
|
-
content: [{ type: "text" as const, text: isError ? (
|
|
194
|
+
content: [{ type: "text" as const, text: isError ? (finalRes.error ?? finalRes.status) : finalRes.finalText }],
|
|
145
195
|
details: {
|
|
146
|
-
runId:
|
|
147
|
-
status:
|
|
196
|
+
runId: finalRes.runId, todoId: finalRes.todoId, agent: finalRes.agent, model: finalRes.model,
|
|
197
|
+
status: finalRes.status, durationMs: finalRes.durationMs, tokenTotal: finalRes.tokenTotal,
|
|
198
|
+
retriedWithModel,
|
|
148
199
|
},
|
|
149
200
|
isError,
|
|
150
201
|
};
|
|
151
202
|
},
|
|
152
203
|
};
|
|
153
|
-
}
|
|
204
|
+
}
|