@getpipher/armory-fleet 0.12.4 → 0.12.5
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/child-loader.ts +25 -1
- package/src/engine/concurrency-lock.ts +72 -20
- package/src/engine/retry-fallback.ts +26 -0
- package/src/engine/spawnSubagent.ts +76 -13
- package/src/index.ts +27 -6
- package/src/tools/subagent.ts +23 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getpipher/armory-fleet",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.5",
|
|
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",
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// noExtensions (deterministic child, no host-extension leakage), composed
|
|
4
4
|
// systemPromptOverride (rolePrompt + memoryBlock + base), scoped skills.
|
|
5
5
|
import { DefaultResourceLoader, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { dirname } from "node:path";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { existsSync as fsExistsSync } from "node:fs";
|
|
7
9
|
import type { AgentDef } from "../registry/frontmatter.ts";
|
|
8
10
|
import type { MemoryHydratePort } from "../memory-hydrate/port.ts";
|
|
9
11
|
|
|
@@ -44,6 +46,25 @@ export function memoryScopesFor(cwd: string): { project: string; local: string;
|
|
|
44
46
|
return { project: cwd, local: dirname(cwd) || cwd, user: USER_PSEUDO_CWD };
|
|
45
47
|
}
|
|
46
48
|
|
|
49
|
+
/** #40: resolve extra skill dirs to scan for the child, beyond the default `~/.pi/agent/skills`.
|
|
50
|
+
*
|
|
51
|
+
* The parent's pi package-manager scans `~/.agents/skills` (user) + `<cwd>/.agents/skills`
|
|
52
|
+
* (project) and feeds them to the loader via `extendResources` — that's how cross-harness skills
|
|
53
|
+
* like firecrawl (which live in `~/.agents/skills`, NOT `~/.pi/agent/skills`) reach the parent.
|
|
54
|
+
* The child's bare `DefaultResourceLoader` skips that, so a `skills: ["firecrawl-scrape"]` opt-in
|
|
55
|
+
* can't resolve. We restore the parent's skill surface by passing these dirs as `additionalSkillPaths`.
|
|
56
|
+
*
|
|
57
|
+
* Returns absolute paths only for dirs that exist (no hard dependency on the `~/.agents` layout —
|
|
58
|
+
* machines without it get []). Inject `homedir`/`cwd`/`existsSync` for testability. */
|
|
59
|
+
export function resolveAdditionalSkillPaths(opts: { homedir?: string; cwd: string; existsSync?: (p: string) => boolean }): string[] {
|
|
60
|
+
const home = opts.homedir ?? homedir();
|
|
61
|
+
const exists = opts.existsSync ?? ((p: string) => { try { return fsExistsSync(p); } catch { return false; } });
|
|
62
|
+
const candidates = [join(home, ".agents", "skills"), join(opts.cwd, ".agents", "skills")];
|
|
63
|
+
const out: string[] = [];
|
|
64
|
+
for (const p of candidates) if (!out.includes(p) && exists(p)) out.push(p);
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
47
68
|
export interface ChildLoaderOpts {
|
|
48
69
|
cwd: string;
|
|
49
70
|
agent: AgentDef;
|
|
@@ -58,6 +79,9 @@ export function buildChildLoader(opts: ChildLoaderOpts): DefaultResourceLoader {
|
|
|
58
79
|
cwd: opts.cwd,
|
|
59
80
|
agentDir: getAgentDir(),
|
|
60
81
|
noExtensions: true,
|
|
82
|
+
// #40: scan the shared `~/.agents/skills` + `<cwd>/.agents/skills` dirs so cross-harness skills
|
|
83
|
+
// (firecrawl, etc.) are discoverable by a caller's `skills` opt-in, matching the parent surface.
|
|
84
|
+
additionalSkillPaths: resolveAdditionalSkillPaths({ cwd: opts.cwd }),
|
|
61
85
|
systemPromptOverride: (base) =>
|
|
62
86
|
composeChildPrompt({ rolePrompt: opts.agent.rolePrompt, memoryBlock, base: base ?? "" }),
|
|
63
87
|
skillsOverride: (cur) => resolveChildSkills(opts.agent, cur),
|
|
@@ -1,29 +1,81 @@
|
|
|
1
1
|
// src/engine/concurrency-lock.ts
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
// #31 tail: the foreground concurrency lock. A cap-based semaphore shared across all
|
|
3
|
+
// foreground subagent dispatches (serializes writes to prevent in-place edit conflicts).
|
|
4
|
+
//
|
|
5
|
+
// - cap=1 (default, backward-compat): FAIL-FAST. A 2nd dispatch while one is running is
|
|
6
|
+
// rejected with `{ ok: false, busy }` so the orchestrator gets an actionable error (the
|
|
7
|
+
// held runId is named) instead of blocking. This preserves the pre-#31 behavior exactly.
|
|
8
|
+
// - cap>1 (opt-in via ARMORY_FLEET_FOREGROUND_CONCURRENCY): QUEUE. Up to `cap` write
|
|
9
|
+
// dispatches run in parallel; the (cap+1)th WAITS for a slot (not rejected).
|
|
10
|
+
//
|
|
11
|
+
// #31 design note: concurrency is SESSION-LEVEL (env/settings), NOT a per-dispatch param.
|
|
12
|
+
// A shared lock serializes across dispatches, so a per-dispatch `concurrency` param can't
|
|
13
|
+
// re-size it per-call (two dispatches requesting different caps have no consistent global
|
|
14
|
+
// cap). The cap is fixed at init. The subagent tool's promptGuidelines surface this to the
|
|
15
|
+
// orchestrator so it understands the behavior + how to change it.
|
|
16
|
+
//
|
|
17
|
+
// readOnly dispatches bypass the lock entirely (#41) — they assert no cwd mutation.
|
|
18
|
+
|
|
19
|
+
export type AcquireResult =
|
|
20
|
+
| { ok: true }
|
|
21
|
+
| { ok: false; busy: string[] };
|
|
22
|
+
|
|
23
|
+
export interface ForegroundLock {
|
|
24
|
+
/** The configured cap (1 = fail-fast; >1 = queue). */
|
|
25
|
+
readonly cap: number;
|
|
26
|
+
/** Acquire a slot for `runId`. At cap=1: fail-fast if busy (returns the held runIds).
|
|
27
|
+
* At cap>1: waits (queues) if at cap, then acquires. */
|
|
28
|
+
acquire(runId: string): Promise<AcquireResult>;
|
|
29
|
+
/** Release a held slot. No-op if `runId` isn't held (guard against double-release). */
|
|
30
|
+
release(runId: string): void;
|
|
31
|
+
/** RunIds currently holding a slot (for diagnostics + error messages). */
|
|
32
|
+
holders(): string[];
|
|
7
33
|
}
|
|
8
34
|
|
|
9
|
-
export class
|
|
10
|
-
private
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
35
|
+
export class ForegroundLockImpl implements ForegroundLock {
|
|
36
|
+
private active: string[] = [];
|
|
37
|
+
private waiters: Array<() => void> = [];
|
|
38
|
+
constructor(readonly cap = 1) {}
|
|
39
|
+
|
|
40
|
+
acquire(runId: string): Promise<AcquireResult> {
|
|
41
|
+
if (this.active.length >= this.cap) {
|
|
42
|
+
if (this.cap === 1) {
|
|
43
|
+
// Backward-compat: fail-fast at cap=1. The orchestrator gets the held runId + the cap
|
|
44
|
+
// + the env hint, so it can wait/abort or raise the cap.
|
|
45
|
+
return Promise.resolve({ ok: false, busy: [...this.active] });
|
|
46
|
+
}
|
|
47
|
+
// cap>1: queue — resolve when a slot frees (FIFO).
|
|
48
|
+
return new Promise<AcquireResult>((resolve) => {
|
|
49
|
+
this.waiters.push(() => { this.active.push(runId); resolve({ ok: true }); });
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
this.active.push(runId);
|
|
53
|
+
return Promise.resolve({ ok: true });
|
|
18
54
|
}
|
|
19
|
-
|
|
20
|
-
|
|
55
|
+
|
|
56
|
+
release(runId: string): void {
|
|
57
|
+
const i = this.active.indexOf(runId);
|
|
58
|
+
if (i < 0) return; // no-op if not held (don't wake a waiter without freeing a slot)
|
|
59
|
+
this.active.splice(i, 1);
|
|
60
|
+
const next = this.waiters.shift();
|
|
61
|
+
if (next) next();
|
|
21
62
|
}
|
|
63
|
+
|
|
64
|
+
holders(): string[] { return [...this.active]; }
|
|
22
65
|
}
|
|
23
66
|
|
|
24
|
-
/**
|
|
25
|
-
export
|
|
67
|
+
/** Configurable foreground lock. cap from `ARMORY_FLEET_FOREGROUND_CONCURRENCY` (default 1). */
|
|
68
|
+
export function createForegroundLock(cap = 1): ForegroundLock {
|
|
69
|
+
return new ForegroundLockImpl(cap);
|
|
70
|
+
}
|
|
26
71
|
|
|
27
|
-
|
|
28
|
-
|
|
72
|
+
/** Backward-compat factory: a cap=1 fail-fast lock. Existing call sites pass this to
|
|
73
|
+
* spawnSubagent; it now returns a ForegroundLock(1) with the same cap=1 semantics. */
|
|
74
|
+
export function createSingleSlotLock(): ForegroundLock {
|
|
75
|
+
return new ForegroundLockImpl(1);
|
|
29
76
|
}
|
|
77
|
+
|
|
78
|
+
/** @deprecated alias kept for call sites that construct `new SingleSlotLock()` or type
|
|
79
|
+
* `lock: SingleSlotLock` (workflow adapters, fleet-panel). Same as ForegroundLock(1). */
|
|
80
|
+
export type SingleSlotLock = ForegroundLock;
|
|
81
|
+
export const SingleSlotLock = ForegroundLockImpl;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// src/engine/retry-fallback.ts
|
|
2
|
+
// #39 tail: a reusable retry wrapper for the lifecycle + background spawn sites, where the
|
|
3
|
+
// direct-foreground path's inline retry (subagent.ts) doesn't reach. Mirrors that path's
|
|
4
|
+
// contract: retry ONCE, only on a retryable provider rate-limit / auth failure (stopReason
|
|
5
|
+
// "error" → SpawnResult.retryable), only with a DISTINCT fallback model, only when not aborted.
|
|
6
|
+
// Non-retryable failures (turn budget, agent-not-found, EMPTY_RESULT, abort, lock busy) pass
|
|
7
|
+
// through unchanged. Lifecycle/bg retries are transparent — the retry's SpawnResult (with
|
|
8
|
+
// model = fallback) wins; the direct-foreground path separately surfaces `retriedWithModel`
|
|
9
|
+
// in the tool details, which lifecycle/bg don't need.
|
|
10
|
+
import type { SpawnResult } from "./spawnSubagent.ts";
|
|
11
|
+
import type { SpawnFn } from "../lifecycle/run-lifecycle.ts";
|
|
12
|
+
|
|
13
|
+
/** Wrap a lifecycle/bg SpawnFn so a retryable failure retries once on `fallback`. `fallback`
|
|
14
|
+
* being undefined returns the spawn unchanged (no global default + no per-dispatch param → no
|
|
15
|
+
* retry). `signal` optional — background/panel sites have no AbortSignal; the direct path checks
|
|
16
|
+
* abort inline. */
|
|
17
|
+
export function withModelFallbackRetry(spawn: SpawnFn, fallback: string | undefined, signal?: AbortSignal): SpawnFn {
|
|
18
|
+
if (!fallback) return spawn;
|
|
19
|
+
return async (opts) => {
|
|
20
|
+
const first = await spawn(opts);
|
|
21
|
+
if (first.status === "failed" && first.retryable && fallback !== first.model && !signal?.aborted) {
|
|
22
|
+
return spawn({ ...opts, model: fallback });
|
|
23
|
+
}
|
|
24
|
+
return first;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -6,7 +6,7 @@ import type { VisionPort } from "../vision/port.ts";
|
|
|
6
6
|
import type { BackendRegistry } from "../backend/port.ts";
|
|
7
7
|
import { genRunId, RunRegistry } from "./run-registry.ts";
|
|
8
8
|
import { createTurnBudget, DEFAULT_MAX_TURNS } from "./turn-budget.ts";
|
|
9
|
-
import type {
|
|
9
|
+
import type { ForegroundLock } from "./concurrency-lock.ts";
|
|
10
10
|
import type { RunLog } from "../runtime/run-log.ts";
|
|
11
11
|
import { buildToolEvent } from "../runtime/run-log.ts";
|
|
12
12
|
import { resolveAgentModel, type ModelRegistryLike } from "../tiers/resolve.ts";
|
|
@@ -112,7 +112,7 @@ export interface SpawnOptions {
|
|
|
112
112
|
registry: Map<string, AgentDef>;
|
|
113
113
|
todoSync: TodoSyncPort;
|
|
114
114
|
runRegistry: RunRegistry;
|
|
115
|
-
lock:
|
|
115
|
+
lock: ForegroundLock;
|
|
116
116
|
backendRegistry: BackendRegistry; // SPEC-3: replaces childFactory — engine looks up by agentDef.backend
|
|
117
117
|
parentModel: { provider: string; id: string };
|
|
118
118
|
parentCwd: string;
|
|
@@ -168,6 +168,47 @@ export interface SpawnResult {
|
|
|
168
168
|
* when this is set. Non-retryable failures (turn budget, agent-not-found, EMPTY_RESULT,
|
|
169
169
|
* abort, lock busy) leave this unset. */
|
|
170
170
|
retryable?: boolean;
|
|
171
|
+
/** #49: file paths the child mutated before the run ended (edit/write `path`; bash
|
|
172
|
+
* redirections/tee best-effort), deduped + sorted. Lets the controller re-inspect only
|
|
173
|
+
* what changed after a turn-budget cut, instead of the whole repo. Populated on every
|
|
174
|
+
* run (completed runs too); most useful on the turn-budget branch. */
|
|
175
|
+
filesTouched?: string[];
|
|
176
|
+
/** #49: did the child emit a trailing assistant message after its last tool (a summary),
|
|
177
|
+
* or was it cut mid-tool-work? Surfaced on turn-budget exhaustion so the controller knows
|
|
178
|
+
* whether finalText is a partial summary or a mid-thought. Undefined for non-turn-budget paths. */
|
|
179
|
+
reachedSummary?: boolean;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** #49: extract file paths a tool event touched, for the structured partial-result report.
|
|
183
|
+
* edit/write carry a `path` arg reliably; bash is best-effort (redirections `>`/`>>` + `tee` to a
|
|
184
|
+
* path-like token). Reads are NOT mutations and are excluded. */
|
|
185
|
+
function extractTouchedFiles(toolName: string, args: unknown): string[] {
|
|
186
|
+
if (!args || typeof args !== "object") return [];
|
|
187
|
+
const a = args as Record<string, unknown>;
|
|
188
|
+
if (toolName === "edit" || toolName === "write") {
|
|
189
|
+
const p = typeof a.path === "string" ? a.path : typeof a.file_path === "string" ? a.file_path : undefined;
|
|
190
|
+
return p ? [p] : [];
|
|
191
|
+
}
|
|
192
|
+
if (toolName === "bash") {
|
|
193
|
+
const cmd = typeof a.command === "string" ? a.command : undefined;
|
|
194
|
+
if (!cmd) return [];
|
|
195
|
+
const out: string[] = [];
|
|
196
|
+
// `> path` / `>> path` / `tee path` — only tokens that look like a path (contain `/` or `.`)
|
|
197
|
+
// to avoid false positives on bare words ("echo done", "exit 0").
|
|
198
|
+
const redir = />>?\s+([^\s|;&<>]+)/g;
|
|
199
|
+
let m: RegExpExecArray | null;
|
|
200
|
+
while ((m = redir.exec(cmd)) !== null) {
|
|
201
|
+
const tok = m[1];
|
|
202
|
+
if (tok && /[/.]/.test(tok)) out.push(tok);
|
|
203
|
+
}
|
|
204
|
+
const tee = /\btee\s+(?:-a\s+)?([^\s|;&<>]+)/g;
|
|
205
|
+
while ((m = tee.exec(cmd)) !== null) {
|
|
206
|
+
const tok = m[1];
|
|
207
|
+
if (tok && /[/.]/.test(tok)) out.push(tok);
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
return [];
|
|
171
212
|
}
|
|
172
213
|
|
|
173
214
|
export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
@@ -184,15 +225,17 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
184
225
|
if (readOnly) {
|
|
185
226
|
runId = genRunId();
|
|
186
227
|
} else {
|
|
187
|
-
// concurrency=1 (
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
}
|
|
228
|
+
// #31 tail: foreground concurrency lock. cap=1 (default) is FAIL-FAST (backward-compat — a
|
|
229
|
+
// 2nd dispatch is rejected with the held runId + the cap + the env hint); cap>1 (opt-in via
|
|
230
|
+
// ARMORY_FLEET_FOREGROUND_CONCURRENCY) QUEUES — up to `cap` writes run in parallel, the rest wait.
|
|
231
|
+
// The cap is session-level (a shared lock can't be re-sized per dispatch); see concurrency-lock.ts.
|
|
193
232
|
runId = genRunId();
|
|
194
|
-
|
|
195
|
-
|
|
233
|
+
const acq = await opts.lock.acquire(runId);
|
|
234
|
+
if (!acq.ok) {
|
|
235
|
+
const hint = opts.lock.cap === 1
|
|
236
|
+
? ` (foreground concurrency=1; ${acq.busy.join(", ")} is running — wait for it to finish or abort it, or raise ARMORY_FLEET_FOREGROUND_CONCURRENCY to allow parallel write dispatches)`
|
|
237
|
+
: ` (foreground concurrency=${opts.lock.cap}; all ${opts.lock.cap} slots held by ${acq.busy.join(", ")}); wait for one to finish or raise ARMORY_FLEET_FOREGROUND_CONCURRENCY)`;
|
|
238
|
+
return fail("", startedAt, `a subagent is already running${hint}`, opts.agent);
|
|
196
239
|
}
|
|
197
240
|
}
|
|
198
241
|
|
|
@@ -306,6 +349,10 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
306
349
|
// synchronously inside subscribe() (temporal-dead-zone guard).
|
|
307
350
|
let modelError: string | undefined; // model-call failure surfaced via stopReason "error"
|
|
308
351
|
let sawAssistantMessage = false; // #22: did the child emit any assistant message_end at all?
|
|
352
|
+
// #49: structured partial-result tracking — what the child mutated, and whether it emitted
|
|
353
|
+
// a trailing assistant message after its last tool (a summary) vs being cut mid-tool-work.
|
|
354
|
+
const filesTouched = new Set<string>();
|
|
355
|
+
let sawAssistantAfterLastTool = true; // no tools yet = trivially "reached a summary"
|
|
309
356
|
|
|
310
357
|
// #23: liveness — classify events into a short, content-free class string for the widget.
|
|
311
358
|
// Names the tool (safe — tool name is not args/result) so the operator sees "what's happening"
|
|
@@ -334,6 +381,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
334
381
|
if (budget.consume()) void session.abort();
|
|
335
382
|
} else if (e.type === "message_end" && e.message?.role === "assistant") {
|
|
336
383
|
sawAssistantMessage = true;
|
|
384
|
+
sawAssistantAfterLastTool = true; // #49: a trailing assistant message = (so far) reached a summary
|
|
337
385
|
const text = e.message.content?.map((c) => (c.type === "text" ? c.text ?? "" : "")).join("") ?? "";
|
|
338
386
|
// #26/#22: a model-call failure (401, provider down, rate limit) surfaces as
|
|
339
387
|
// stopReason "error". The SDK retries internally; if it still ends with an error
|
|
@@ -365,6 +413,9 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
365
413
|
void session.abort();
|
|
366
414
|
}
|
|
367
415
|
} else if (e.type === "tool_execution_end") {
|
|
416
|
+
// #49: track mutated files for the structured partial-result report.
|
|
417
|
+
sawAssistantAfterLastTool = false; // cut mid-tool-work unless a message follows
|
|
418
|
+
for (const f of extractTouchedFiles((e as { toolName?: string }).toolName ?? "", (e as { args?: unknown }).args)) filesTouched.add(f);
|
|
368
419
|
try {
|
|
369
420
|
opts.runLog?.append(runId, buildToolEvent((e as any).toolName, (e as any).args, (e as any).result, (e as any).isError ?? false, turnIdx));
|
|
370
421
|
} catch { /* best-effort */ }
|
|
@@ -394,6 +445,8 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
394
445
|
|
|
395
446
|
let status: FleetRunStatus;
|
|
396
447
|
let error: string | undefined;
|
|
448
|
+
const filesTouchedList = [...filesTouched].sort(); // #49: deduped + sorted
|
|
449
|
+
let reachedSummary: boolean | undefined; // #49: only set on the turn-budget branch
|
|
397
450
|
if (aborted) {
|
|
398
451
|
status = "aborted";
|
|
399
452
|
// #23: distinguish TODO-status reversion from filesystem rollback. A foreground run is in-place
|
|
@@ -415,7 +468,14 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
415
468
|
? finalText.slice(0, PARTIAL_WINDOW) + "\n…(partial truncated — see run log for full output)"
|
|
416
469
|
: finalText;
|
|
417
470
|
status = "failed";
|
|
418
|
-
|
|
471
|
+
// #49: surface WHAT was modified + whether the partial is a summary or a mid-thought, so the
|
|
472
|
+
// controller can re-inspect only the touched files instead of the whole repo, and knows
|
|
473
|
+
// whether to trust finalText as a partial summary.
|
|
474
|
+
reachedSummary = sawAssistantAfterLastTool;
|
|
475
|
+
const filesLine = filesTouchedList.length
|
|
476
|
+
? `\n\nFiles modified before the cut: ${filesTouchedList.join(", ")}`
|
|
477
|
+
: "\n\nFiles modified before the cut: (none detected)";
|
|
478
|
+
error = `hit turn budget (${maxTurns}) mid-task; partial result:\n${partial}${filesLine}\nReached summary: ${reachedSummary ? "yes" : "no (cut mid-tool-work)"}`;
|
|
419
479
|
} else if (runError) {
|
|
420
480
|
status = "failed";
|
|
421
481
|
error = runError;
|
|
@@ -446,11 +506,11 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
446
506
|
status = "completed";
|
|
447
507
|
}
|
|
448
508
|
|
|
449
|
-
return await finishRun(opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentDef.name, model, tokenTotal, costTotal, contextTokens, modelError ? true : undefined);
|
|
509
|
+
return await finishRun(opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentDef.name, model, tokenTotal, costTotal, contextTokens, modelError ? true : undefined, filesTouchedList, reachedSummary);
|
|
450
510
|
} finally {
|
|
451
511
|
// #31: a readOnly dispatch never acquired the lock — don't release what it didn't take
|
|
452
512
|
// (releasing a lock held by another concurrent write dispatch would corrupt serialization).
|
|
453
|
-
if (!readOnly) opts.lock.release();
|
|
513
|
+
if (!readOnly) opts.lock.release(runId);
|
|
454
514
|
}
|
|
455
515
|
}
|
|
456
516
|
|
|
@@ -469,6 +529,7 @@ async function finishRun(
|
|
|
469
529
|
status: FleetRunStatus, finalText: string, todoId: string | null, priorStatus: string | undefined,
|
|
470
530
|
error: string | undefined, agentName: string, model: string, tokenTotal = 0, costTotal = 0, contextTokens = 0,
|
|
471
531
|
retryable?: boolean,
|
|
532
|
+
filesTouched?: string[], reachedSummary?: boolean,
|
|
472
533
|
): Promise<SpawnResult> {
|
|
473
534
|
if (finalizedRunIds.has(runId)) {
|
|
474
535
|
// Already finalized — return the existing registry record's result without re-appending.
|
|
@@ -478,6 +539,7 @@ async function finishRun(
|
|
|
478
539
|
runId, todoId, agent: agentName, model,
|
|
479
540
|
durationMs: existing?.endedAt ? existing.endedAt - startedAt : Date.now() - startedAt,
|
|
480
541
|
tokenTotal, costTotal, contextTokens, error, retryable,
|
|
542
|
+
filesTouched, reachedSummary,
|
|
481
543
|
};
|
|
482
544
|
}
|
|
483
545
|
finalizedRunIds.add(runId);
|
|
@@ -512,5 +574,6 @@ async function finishRun(
|
|
|
512
574
|
return {
|
|
513
575
|
status, finalText, runId, todoId, agent: agentName, model,
|
|
514
576
|
durationMs: endedAt - startedAt, tokenTotal, costTotal, contextTokens, error, retryable,
|
|
577
|
+
filesTouched, reachedSummary,
|
|
515
578
|
};
|
|
516
579
|
}
|
package/src/index.ts
CHANGED
|
@@ -12,11 +12,12 @@ import { createSubagentTool, type SubagentToolDeps } from "./tools/subagent.ts";
|
|
|
12
12
|
// SPEC-6-3: /fleet uses openWorkflowPanelLoop (Task 12) instead of the raw openFleetPanel factory.
|
|
13
13
|
import { discoverAgents } from "./registry/discovery.ts";
|
|
14
14
|
import { RunRegistry } from "./engine/run-registry.ts";
|
|
15
|
-
import { createSingleSlotLock } from "./engine/concurrency-lock.ts";
|
|
15
|
+
import { createSingleSlotLock, createForegroundLock } from "./engine/concurrency-lock.ts";
|
|
16
16
|
import { ArmoryTodoAdapter } from "./todo-sync/adapter.ts";
|
|
17
17
|
import { ArmoryMemoryAdapter } from "./memory-hydrate/adapter.ts";
|
|
18
18
|
import { ArmoryVisionAdapter } from "./vision/adapter.ts";
|
|
19
19
|
import { buildChildLoader } from "./engine/child-loader.ts";
|
|
20
|
+
import { withModelFallbackRetry } from "./engine/retry-fallback.ts";
|
|
20
21
|
import { createDescribeImageTool } from "./vision/describe-image-tool.ts";
|
|
21
22
|
import type { MemoryHydratePort } from "./memory-hydrate/port.ts";
|
|
22
23
|
import type { VisionPort } from "./vision/port.ts";
|
|
@@ -152,12 +153,23 @@ export function createChildSessionFactory(modelRuntime: ModelRuntime, memoryPort
|
|
|
152
153
|
};
|
|
153
154
|
}
|
|
154
155
|
|
|
156
|
+
/** #31 tail: parse the foreground-concurrency cap from `ARMORY_FLEET_FOREGROUND_CONCURRENCY`.
|
|
157
|
+
* Default 1 (fail-fast, backward-compat). >1 enables a queueing pool (up to N parallel write
|
|
158
|
+
* dispatches). Clamped to [1, 64]; invalid/empty → 1. */
|
|
159
|
+
function parseForegroundConcurrency(): number {
|
|
160
|
+
const raw = process.env.ARMORY_FLEET_FOREGROUND_CONCURRENCY;
|
|
161
|
+
if (!raw) return 1;
|
|
162
|
+
const n = Number.parseInt(raw, 10);
|
|
163
|
+
if (!Number.isFinite(n) || n < 1) return 1;
|
|
164
|
+
return Math.min(n, 64);
|
|
165
|
+
}
|
|
166
|
+
|
|
155
167
|
export default async function (pi: ExtensionAPI): Promise<void> {
|
|
156
168
|
const modelRuntime = await ModelRuntime.create();
|
|
157
169
|
const deps: SubagentToolDeps = {
|
|
158
170
|
registry: new Map(),
|
|
159
171
|
runRegistry: new RunRegistry(),
|
|
160
|
-
lock:
|
|
172
|
+
lock: createForegroundLock(parseForegroundConcurrency()),
|
|
161
173
|
todoSync: new ArmoryTodoAdapter(),
|
|
162
174
|
backendRegistry: await buildDefaultBackendRegistry(modelRuntime),
|
|
163
175
|
parentModel: { provider: "", id: "" },
|
|
@@ -189,6 +201,15 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
189
201
|
// Builtin-only placeholder tier registry so spawn works before session_start rebuilds with merged tiers.
|
|
190
202
|
deps.tierRegistry = new TierRegistry({ tiers: BUILTIN_TIERS, agents: deps.registry });
|
|
191
203
|
|
|
204
|
+
// #39 tail: global default fallback model (env-driven for now; a settings.json field is a follow-up).
|
|
205
|
+
// A retryable provider failure (stopReason "error") retries once on this model even without a
|
|
206
|
+
// per-dispatch `modelFallback`. Per the AGENTS.md "Ollama primary + OpenRouter fallback" pattern.
|
|
207
|
+
deps.defaultModelFallback = process.env.ARMORY_FLEET_MODEL_FALLBACK || undefined;
|
|
208
|
+
|
|
209
|
+
// #31 tail: foreground concurrency is SESSION-LEVEL (a shared lock can't be re-sized per
|
|
210
|
+
// dispatch). cap=1 (default) is fail-fast (backward-compat); cap>1 enables a queueing pool so
|
|
211
|
+
// up to `cap` write dispatches run in parallel. readOnly dispatches bypass the lock (#41).
|
|
212
|
+
|
|
192
213
|
// SPEC-6-2: gate registry + builtin gate registration.
|
|
193
214
|
const gateRegistry = new GateRegistry();
|
|
194
215
|
registerBuiltinGates(gateRegistry);
|
|
@@ -234,14 +255,14 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
234
255
|
...deps.lifecycleDeps,
|
|
235
256
|
genRunId: () => opts.runId, // override: use the async runner's runId
|
|
236
257
|
...(isolated ? { artifactDiscovery: ({ finalText, cwd, baseRef }: { finalText: string; cwd: string; baseRef: string }) => (deps.asyncRunner as AsyncRunnerDeps).diff.diffPhase(cwd, baseRef, finalText) } : {}),
|
|
237
|
-
spawn: async (o) => spawnSubagent({
|
|
258
|
+
spawn: withModelFallbackRetry(async (o) => spawnSubagent({
|
|
238
259
|
agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
|
|
239
260
|
skillsOverride: o.skills, backendOverride: o.backend,
|
|
240
261
|
registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: bgLock,
|
|
241
262
|
backendRegistry: deps.backendRegistry, parentModel: deps.parentModel,
|
|
242
263
|
parentCwd: isolated ? opts.worktreePath! : deps.parentCwd,
|
|
243
264
|
runLog: deps.runLog, tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
|
|
244
|
-
}),
|
|
265
|
+
}), deps.defaultModelFallback),
|
|
245
266
|
};
|
|
246
267
|
const res = await runLifecycle(task, lifecycleName, { deps: lifecycleFullDeps, mode: opts.mode, worktreePath: opts.worktreePath, baseRef: "HEAD", onCheckpoint: async (p) => p.status === "failed" ? { action: "abort" } : { action: "continue" } });
|
|
247
268
|
return res as unknown as import("./runtime/async-runner.ts").FakeLifecycleResult;
|
|
@@ -508,13 +529,13 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
508
529
|
};
|
|
509
530
|
const lifecycleFullDeps: import("./lifecycle/run-lifecycle.ts").LifecycleRunDeps = {
|
|
510
531
|
...deps.lifecycleDeps,
|
|
511
|
-
spawn: async (o) => spawnSubagent({
|
|
532
|
+
spawn: withModelFallbackRetry(async (o) => spawnSubagent({
|
|
512
533
|
agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
|
|
513
534
|
skillsOverride: o.skills, backendOverride: o.backend,
|
|
514
535
|
registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
|
|
515
536
|
backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: deps.parentCwd,
|
|
516
537
|
tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry, // SPEC-6-1
|
|
517
|
-
}),
|
|
538
|
+
}), deps.defaultModelFallback),
|
|
518
539
|
};
|
|
519
540
|
const res = await runLifecycle(parsed.task, lcName, { deps: lifecycleFullDeps, mode: parsed.auto ? "auto" : "checkpointed", onCheckpoint });
|
|
520
541
|
deps.lifecycleRuns.set(res.runId, res);
|
package/src/tools/subagent.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { Type, type Static } from "typebox";
|
|
|
3
3
|
import type { AgentDef } from "../registry/frontmatter.ts";
|
|
4
4
|
import type { TodoSyncPort } from "../todo-sync/port.ts";
|
|
5
5
|
import type { RunRegistry } from "../engine/run-registry.ts";
|
|
6
|
-
import type {
|
|
6
|
+
import type { ForegroundLock } from "../engine/concurrency-lock.ts";
|
|
7
7
|
import type { SpawnResult } from "../engine/spawnSubagent.ts";
|
|
8
8
|
import { spawnSubagent } from "../engine/spawnSubagent.ts";
|
|
9
9
|
import type { BackendRegistry } from "../backend/port.ts";
|
|
@@ -47,7 +47,7 @@ export function mergeLifecycleSkills(phaseSkills: string[] | undefined, callerSk
|
|
|
47
47
|
export interface SubagentToolDeps {
|
|
48
48
|
registry: Map<string, AgentDef>;
|
|
49
49
|
runRegistry: RunRegistry;
|
|
50
|
-
lock:
|
|
50
|
+
lock: ForegroundLock;
|
|
51
51
|
todoSync: TodoSyncPort;
|
|
52
52
|
backendRegistry: BackendRegistry; // SPEC-3: replaces childFactory
|
|
53
53
|
parentModel: { provider: string; id: string };
|
|
@@ -74,6 +74,12 @@ export interface SubagentToolDeps {
|
|
|
74
74
|
reloadTiers?: () => void;
|
|
75
75
|
/** SPEC-6-1: model contextWindow resolver for Runs-tab ctx% (Surface C). Optional — ctx% hidden when absent. */
|
|
76
76
|
getModelContextWindow?: (model: string) => number | undefined;
|
|
77
|
+
/** #39 tail: a global default fallback model so a retryable provider failure (stopReason "error")
|
|
78
|
+
* retries once even without a per-dispatch `modelFallback` param. Wired from the
|
|
79
|
+
* `ARMORY_FLEET_MODEL_FALLBACK` env var in index.ts; a settings.json field is a follow-up.
|
|
80
|
+
* Per-dispatch `modelFallback` (when passed) takes precedence. Applies to the direct foreground
|
|
81
|
+
* path, the foreground lifecycle spawn, and the background/scheduled spawn. */
|
|
82
|
+
defaultModelFallback?: string;
|
|
77
83
|
}
|
|
78
84
|
|
|
79
85
|
/** Build the pi.registerTool definition. Thin wrapper over spawnSubagent. */
|
|
@@ -90,6 +96,8 @@ export function createSubagentTool(deps: SubagentToolDeps) {
|
|
|
90
96
|
"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
97
|
"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
98
|
"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.",
|
|
99
|
+
"For web tasks, dispatch with the firecrawl skill the child needs (e.g. skills: [\"firecrawl-scrape\"] / [\"firecrawl-search\"]) — children now discover skills from `~/.agents/skills` + `~/.pi/agent/skills`, matching your surface. Curl stays the right call for raw HTTP probes, smoke tests, and plain JSON/text APIs where JS rendering isn't needed (per the AGENTS.md firecrawl-first/curl-fallback preference).",
|
|
100
|
+
"Foreground concurrency is SESSION-LEVEL, not per-dispatch: write dispatches serialize through one shared lock sized by ARMORY_FLEET_FOREGROUND_CONCURRENCY. At the default (1) a 2nd write dispatch is rejected fail-fast (the error names the held runId) — dispatch sequentially (await each) or use readOnly:true for parallel read-only work. Raise the env cap only if you accept parallel in-place edits (conflict risk).",
|
|
93
101
|
],
|
|
94
102
|
parameters: subagentParams,
|
|
95
103
|
async execute(_toolCallId: string, params: SubagentInput, signal: AbortSignal, _onUpdate: unknown, _ctx: any) {
|
|
@@ -111,9 +119,12 @@ export function createSubagentTool(deps: SubagentToolDeps) {
|
|
|
111
119
|
}
|
|
112
120
|
if (params.lifecycle) {
|
|
113
121
|
const { runLifecycle } = await import("../lifecycle/run-lifecycle.ts");
|
|
122
|
+
const { withModelFallbackRetry } = await import("../engine/retry-fallback.ts");
|
|
114
123
|
const lifecycleFullDeps: LifecycleRunDeps = {
|
|
115
124
|
...deps.lifecycleDeps,
|
|
116
|
-
|
|
125
|
+
// #39 tail: wrap the phase spawn so a retryable provider failure retries once on the
|
|
126
|
+
// fallback (per-dispatch `modelFallback` param OR the global `defaultModelFallback` dep).
|
|
127
|
+
spawn: withModelFallbackRetry(async (o) => spawnSubagent({
|
|
117
128
|
agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
|
|
118
129
|
skillsOverride: mergeLifecycleSkills(o.skills, params.skills), backendOverride: o.backend,
|
|
119
130
|
registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
|
|
@@ -121,7 +132,7 @@ export function createSubagentTool(deps: SubagentToolDeps) {
|
|
|
121
132
|
maxTurns: params.maxTurns,
|
|
122
133
|
tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
|
|
123
134
|
readOnly: params.readOnly,
|
|
124
|
-
}),
|
|
135
|
+
}), params.modelFallback ?? deps.defaultModelFallback, signal),
|
|
125
136
|
};
|
|
126
137
|
const res = await runLifecycle(params.task, params.lifecycle, {
|
|
127
138
|
deps: lifecycleFullDeps, mode: "auto",
|
|
@@ -161,18 +172,21 @@ export function createSubagentTool(deps: SubagentToolDeps) {
|
|
|
161
172
|
// retry relinks the SAME todoId to continue the tracked task. Retry ONCE, only on the direct
|
|
162
173
|
// foreground path, only if a distinct fallback model was provided. The retry's runId differs
|
|
163
174
|
// from the primary's (each spawnSubagent call mints its own); details.retriedWithModel marks it.
|
|
175
|
+
// #39 tail: the fallback comes from the per-dispatch `modelFallback` param OR the global
|
|
176
|
+
// `defaultModelFallback` dep (wired from ARMORY_FLEET_MODEL_FALLBACK) — per-dispatch wins.
|
|
177
|
+
const fallback = params.modelFallback ?? deps.defaultModelFallback;
|
|
164
178
|
let retriedWithModel: string | undefined;
|
|
165
179
|
let finalRes = res;
|
|
166
180
|
if (
|
|
167
|
-
res.status === "failed" && res.retryable &&
|
|
168
|
-
|
|
181
|
+
res.status === "failed" && res.retryable && fallback &&
|
|
182
|
+
fallback !== res.model && !signal.aborted
|
|
169
183
|
) {
|
|
170
184
|
finalRes = await spawnSubagent({
|
|
171
185
|
agent: params.agent,
|
|
172
186
|
task: params.task,
|
|
173
187
|
todoId: res.todoId ?? undefined,
|
|
174
188
|
track: params.track,
|
|
175
|
-
model:
|
|
189
|
+
model: fallback,
|
|
176
190
|
readOnly: params.readOnly,
|
|
177
191
|
skillsOverride: params.skills,
|
|
178
192
|
registry: deps.registry,
|
|
@@ -187,7 +201,7 @@ export function createSubagentTool(deps: SubagentToolDeps) {
|
|
|
187
201
|
maxTurns: params.maxTurns,
|
|
188
202
|
tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
|
|
189
203
|
});
|
|
190
|
-
retriedWithModel =
|
|
204
|
+
retriedWithModel = fallback;
|
|
191
205
|
}
|
|
192
206
|
const isError = finalRes.status === "failed" || finalRes.status === "aborted";
|
|
193
207
|
return {
|
|
@@ -196,6 +210,7 @@ export function createSubagentTool(deps: SubagentToolDeps) {
|
|
|
196
210
|
runId: finalRes.runId, todoId: finalRes.todoId, agent: finalRes.agent, model: finalRes.model,
|
|
197
211
|
status: finalRes.status, durationMs: finalRes.durationMs, tokenTotal: finalRes.tokenTotal,
|
|
198
212
|
retriedWithModel,
|
|
213
|
+
filesTouched: finalRes.filesTouched, reachedSummary: finalRes.reachedSummary,
|
|
199
214
|
},
|
|
200
215
|
isError,
|
|
201
216
|
};
|