@getpipher/armory-fleet 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/package.json +1 -1
- package/src/backend/claude-events.ts +32 -13
- package/src/backend/claude-session.ts +10 -10
- package/src/engine/auto-fallback.ts +19 -0
- package/src/engine/retry-fallback.ts +8 -1
- package/src/engine/spawnSubagent.ts +41 -8
- package/src/index.ts +14 -1
- package/src/runtime/run-log.ts +6 -0
- package/src/tools/subagent.ts +30 -1
package/README.md
CHANGED
|
@@ -316,6 +316,16 @@ Every workflow run is **journaled** (`workflows/journal.ts`) and **resumable**.
|
|
|
316
316
|
- **Panel Run-action:** a 3rd `cwd` input step (task → name → cwd), prefilled with the session cwd; Enter accepts, Escape cancels.
|
|
317
317
|
- **Deferred:** bg/scheduled + worktree cwd-isolation (the `cwd` param is honored by foreground dispatches only for now) — tracked in #62.
|
|
318
318
|
|
|
319
|
+
## Dogfood reliability (v0.14.0)
|
|
320
|
+
|
|
321
|
+
Four fixes from dogfooding the fleet on itself (issues #58–#61):
|
|
322
|
+
|
|
323
|
+
- **`ARMORY_FLEET_MODEL_FALLBACK=auto`** — resolve the global fallback per session from the configured+available model snapshot: a different **provider** than the session model is preferred, else a different model id; unresolvable (single-model setup) stays off with a one-time warning. Non-`auto` env values are used verbatim; per-dispatch `modelFallback` still wins.
|
|
324
|
+
- **No-fallback hint** — a retryable provider failure (stopReason `error`) with neither a per-dispatch `modelFallback` nor the global default surfaces `no modelFallback configured — pass modelFallback or set ARMORY_FLEET_MODEL_FALLBACK` so silent no-retry failures are visible.
|
|
325
|
+
- **Masked primary errors fixed** — when a fallback retry also fails, the surfaced error now names **both** attempts (`primary '<model>' failed: …; fallback '<model>' failed: …`) instead of only the fallback's.
|
|
326
|
+
- **Zero-tool-call flag (#61)** — a run that "completes" without a single executed tool call (the premature-return shape: narrate a plan, end) is prefixed with `[FLEET] zero-tool-call run — likely a premature return` in the tool result; `details.toolCallCount` exposes the count. Verify (git status/log) before trusting such a result.
|
|
327
|
+
- **Richer run journal** — `run:ended` now carries `error` (failure reason), `filesTouched` (#49 parity in the durable journal — real SDK args are captured from `tool_execution_start`; the end event has none), and `toolCallCount`.
|
|
328
|
+
|
|
319
329
|
## Roadmap
|
|
320
330
|
|
|
321
331
|
armory-fleet follows a PRD → SPEC-N (brainstorm → spec → plan → implementation) pipeline. **16/16 phases done through v0.12.0.**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getpipher/armory-fleet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
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",
|
|
@@ -1,39 +1,58 @@
|
|
|
1
|
-
// src/backend/claude-events.ts — map one CC stream-json NDJSON line → ChildSessionEvent (SPEC-3 §4.2).
|
|
1
|
+
// src/backend/claude-events.ts — map one CC stream-json NDJSON line → ChildSessionEvent(s) (SPEC-3 §4.2).
|
|
2
2
|
// Returns null for: filtered echoes (our own user writes), unknown types, malformed lines.
|
|
3
3
|
// The caller logs null-but-parseable lines at debug (forward-compat: CC may add types we don't need).
|
|
4
4
|
import type { ChildSessionEvent } from "../engine/spawnSubagent.ts";
|
|
5
5
|
|
|
6
|
-
interface
|
|
6
|
+
interface CCContentBlock { type: string; text?: string; id?: string; name?: string; input?: Record<string, unknown>; }
|
|
7
|
+
interface CCMessage { role?: string; content?: CCContentBlock[]; usage?: Record<string, unknown>; }
|
|
7
8
|
interface CCEvent { type: string; subtype?: string; session_id?: string; message?: CCMessage; error?: { message?: string }; }
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
/** Map one line to ALL the ChildSessionEvents it implies. An assistant message carrying tool_use
|
|
11
|
+
* blocks yields the message_end PLUS one tool_execution_end per block (#61: the engine's
|
|
12
|
+
* zero-tool-call premature-return signal must count claude children too — without this, every
|
|
13
|
+
* completed claude run counted 0 tools and was falsely flagged). The per-block end event fires
|
|
14
|
+
* at message time, not per-tool completion (CC stream-json gives no finer granularity) — right
|
|
15
|
+
* enough for "did the child act", which is what the count is for. */
|
|
16
|
+
export function mapClaudeEvents(line: string): ChildSessionEvent[] {
|
|
10
17
|
let ev: CCEvent;
|
|
11
18
|
try {
|
|
12
19
|
ev = JSON.parse(line) as CCEvent;
|
|
13
20
|
} catch {
|
|
14
|
-
return
|
|
21
|
+
return []; // malformed line — resilient
|
|
15
22
|
}
|
|
16
23
|
switch (ev.type) {
|
|
17
|
-
case "system":
|
|
24
|
+
case "system": {
|
|
18
25
|
if (ev.subtype === "init" && typeof ev.session_id === "string") {
|
|
19
|
-
return { type: "session_init", backendSessionId: ev.session_id };
|
|
26
|
+
return [{ type: "session_init", backendSessionId: ev.session_id }];
|
|
20
27
|
}
|
|
21
|
-
return
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
22
30
|
case "assistant": {
|
|
23
31
|
const msg = ev.message;
|
|
24
|
-
if (!msg) return
|
|
32
|
+
if (!msg) return [];
|
|
25
33
|
const content = (msg.content ?? []).map((c) => ({ type: c.type, text: c.text }));
|
|
26
34
|
const usage = msg.usage as { cost?: { total?: number } } | undefined;
|
|
27
|
-
|
|
35
|
+
const events: ChildSessionEvent[] = [{ type: "message_end", message: { role: msg.role ?? "assistant", content, usage } }];
|
|
36
|
+
for (const block of msg.content ?? []) {
|
|
37
|
+
if (block.type === "tool_use") {
|
|
38
|
+
events.push({ type: "tool_execution_end", toolCallId: block.id ?? "", toolName: block.name ?? "unknown", args: block.input, result: "", isError: false });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return events;
|
|
28
42
|
}
|
|
29
43
|
case "result":
|
|
30
44
|
// turn boundary (success or error_max_turns) → turn_end drives the budget
|
|
31
|
-
return { type: "turn_end" };
|
|
45
|
+
return [{ type: "turn_end" }];
|
|
32
46
|
case "error":
|
|
33
|
-
return { type: "error", message: { role: "error", content: [{ type: "text", text: ev.error?.message ?? "claude error" }] } };
|
|
47
|
+
return [{ type: "error", message: { role: "error", content: [{ type: "text", text: ev.error?.message ?? "claude error" }] } }];
|
|
34
48
|
case "user":
|
|
35
|
-
return
|
|
49
|
+
return []; // echo of our own stdin write — filtered
|
|
36
50
|
default:
|
|
37
|
-
return
|
|
51
|
+
return []; // unknown — forward-compat, caller logs at debug
|
|
38
52
|
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Single-event compat wrapper (claude-detector + existing consumers read one event per line). */
|
|
56
|
+
export function mapClaudeEvent(line: string): ChildSessionEvent | null {
|
|
57
|
+
return mapClaudeEvents(line)[0] ?? null;
|
|
39
58
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import type { ChildProcess } from "node:child_process";
|
|
3
3
|
import { createInterface } from "node:readline";
|
|
4
4
|
import type { ChildSession, ChildSessionEvent } from "../engine/spawnSubagent.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { mapClaudeEvents } from "./claude-events.ts";
|
|
6
6
|
import type { ResumeStore } from "./resume-store.ts";
|
|
7
7
|
|
|
8
8
|
export class ClaudeChildSession implements ChildSession {
|
|
@@ -24,16 +24,16 @@ export class ClaudeChildSession implements ChildSession {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
private onLine(line: string): void {
|
|
27
|
-
const ev
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
for (const ev of mapClaudeEvents(line)) {
|
|
28
|
+
if (ev.type === "session_init" && ev.backendSessionId && !this.initCaptured) {
|
|
29
|
+
this.initCaptured = true;
|
|
30
|
+
this.resumeStore.set("claude", this.sessionKey, ev.backendSessionId);
|
|
31
|
+
}
|
|
32
|
+
if (ev.type === "turn_end" || ev.type === "error") {
|
|
33
|
+
if (this.turnResolve) { this.turnResolve(); this.turnResolve = null; }
|
|
34
|
+
}
|
|
35
|
+
for (const h of this.handlers) h(ev);
|
|
32
36
|
}
|
|
33
|
-
if (ev.type === "turn_end" || ev.type === "error") {
|
|
34
|
-
if (this.turnResolve) { const r = this.turnResolve; this.turnResolve = null; r(); }
|
|
35
|
-
}
|
|
36
|
-
for (const h of this.handlers) h(ev);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
async prompt(text: string): Promise<void> {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// src/engine/auto-fallback.ts
|
|
2
|
+
// #58: resolve the ARMORY_FLEET_MODEL_FALLBACK=auto sentinel — pick a fallback model from the
|
|
3
|
+
// runtime's configured+available snapshot without the operator naming one. Prefers a model from
|
|
4
|
+
// a DIFFERENT provider than the session model (a real fallback family, per the "Ollama primary +
|
|
5
|
+
// OpenRouter fallback" pattern the feature was named for); if every available model shares the
|
|
6
|
+
// session's provider, a different model id on that provider. undefined when nothing differs
|
|
7
|
+
// (single-model setups) — the caller keeps auto-retry off and surfaces why.
|
|
8
|
+
|
|
9
|
+
export function resolveAutoFallback(
|
|
10
|
+
available: ReadonlyArray<{ provider: string; id: string }>,
|
|
11
|
+
parentModel: { provider: string; id: string },
|
|
12
|
+
): string | undefined {
|
|
13
|
+
const parentProvider = parentModel.provider || "";
|
|
14
|
+
const parentKey = `${parentModel.provider}/${parentModel.id}`;
|
|
15
|
+
const differentProvider = available.filter((m) => m.provider !== parentProvider);
|
|
16
|
+
const pool = differentProvider.length > 0 ? differentProvider : available;
|
|
17
|
+
const pick = pool.find((m) => `${m.provider}/${m.id}` !== parentKey);
|
|
18
|
+
return pick ? `${pick.provider}/${pick.id}` : undefined;
|
|
19
|
+
}
|
|
@@ -19,7 +19,14 @@ export function withModelFallbackRetry(spawn: SpawnFn, fallback: string | undefi
|
|
|
19
19
|
return async (opts) => {
|
|
20
20
|
const first = await spawn(opts);
|
|
21
21
|
if (first.status === "failed" && first.retryable && fallback !== first.model && !signal?.aborted) {
|
|
22
|
-
|
|
22
|
+
const second = await spawn({ ...opts, model: fallback });
|
|
23
|
+
// #59: when the fallback also fails, compose the primary's failure into the surfaced error
|
|
24
|
+
// (same contract as the direct-foreground path in tools/subagent.ts) — the fallback's error
|
|
25
|
+
// alone masks why the primary failed.
|
|
26
|
+
if (second.status === "failed" && first.error && first.error !== second.error) {
|
|
27
|
+
second.error = `primary '${first.model}' failed: ${first.error}; fallback '${fallback}' failed: ${second.error ?? second.status}`;
|
|
28
|
+
}
|
|
29
|
+
return second;
|
|
23
30
|
}
|
|
24
31
|
return first;
|
|
25
32
|
};
|
|
@@ -44,6 +44,13 @@ export interface ChildSessionEvent {
|
|
|
44
44
|
};
|
|
45
45
|
/** Emitted by a backend on session init (SPEC-3). Drives runRecord.backendSessionId. */
|
|
46
46
|
backendSessionId?: string;
|
|
47
|
+
/** tool_execution_end fields (pi SDK native; claude mapper synthesizes from tool_use blocks, #61).
|
|
48
|
+
* Consumers currently cast — these make the real shape type-visible. */
|
|
49
|
+
toolCallId?: string;
|
|
50
|
+
toolName?: string;
|
|
51
|
+
args?: unknown;
|
|
52
|
+
result?: unknown;
|
|
53
|
+
isError?: boolean;
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
export interface ChildSession {
|
|
@@ -182,6 +189,9 @@ export interface SpawnResult {
|
|
|
182
189
|
* or was it cut mid-tool-work? Surfaced on turn-budget exhaustion so the controller knows
|
|
183
190
|
* whether finalText is a partial summary or a mid-thought. Undefined for non-turn-budget paths. */
|
|
184
191
|
reachedSummary?: boolean;
|
|
192
|
+
/** #61: number of executed tool calls. A "completed" run with 0 is the premature-return
|
|
193
|
+
* shape (the child narrated and ended without acting) — the tool flags it in the result. */
|
|
194
|
+
toolCallCount?: number;
|
|
185
195
|
}
|
|
186
196
|
|
|
187
197
|
/** #49: extract file paths a tool event touched, for the structured partial-result report.
|
|
@@ -190,11 +200,13 @@ export interface SpawnResult {
|
|
|
190
200
|
function extractTouchedFiles(toolName: string, args: unknown): string[] {
|
|
191
201
|
if (!args || typeof args !== "object") return [];
|
|
192
202
|
const a = args as Record<string, unknown>;
|
|
193
|
-
|
|
203
|
+
// Case-insensitive: pi tools are lowercase ("edit"), claude's are capitalized ("Edit").
|
|
204
|
+
const name = toolName.toLowerCase();
|
|
205
|
+
if (name === "edit" || name === "write") {
|
|
194
206
|
const p = typeof a.path === "string" ? a.path : typeof a.file_path === "string" ? a.file_path : undefined;
|
|
195
207
|
return p ? [p] : [];
|
|
196
208
|
}
|
|
197
|
-
if (
|
|
209
|
+
if (name === "bash") {
|
|
198
210
|
const cmd = typeof a.command === "string" ? a.command : undefined;
|
|
199
211
|
if (!cmd) return [];
|
|
200
212
|
const out: string[] = [];
|
|
@@ -363,6 +375,12 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
363
375
|
// a trailing assistant message after its last tool (a summary) vs being cut mid-tool-work.
|
|
364
376
|
const filesTouched = new Set<string>();
|
|
365
377
|
let sawAssistantAfterLastTool = true; // no tools yet = trivially "reached a summary"
|
|
378
|
+
// #61: executed-tool count — a "completed" run with zero tool calls is the premature-return
|
|
379
|
+
// shape (read/narrate/end without acting); the tool flags it so the controller verifies.
|
|
380
|
+
let toolCallCount = 0;
|
|
381
|
+
// #60: args live on tool_execution_start (the SDK's end event carries none) — capture per
|
|
382
|
+
// toolCallId so extractTouchedFiles + the journal see the real args on real runs.
|
|
383
|
+
const pendingToolArgs = new Map<string, unknown>();
|
|
366
384
|
|
|
367
385
|
// #23: liveness — classify events into a short, content-free class string for the widget.
|
|
368
386
|
// Names the tool (safe — tool name is not args/result) so the operator sees "what's happening"
|
|
@@ -430,12 +448,20 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
430
448
|
aborted = true;
|
|
431
449
|
void session.abort();
|
|
432
450
|
}
|
|
451
|
+
} else if (e.type === "tool_execution_start") {
|
|
452
|
+
pendingToolArgs.set((e as { toolCallId?: string }).toolCallId ?? "", (e as { args?: unknown }).args); // #60
|
|
433
453
|
} else if (e.type === "tool_execution_end") {
|
|
434
454
|
// #49: track mutated files for the structured partial-result report.
|
|
455
|
+
toolCallCount += 1; // #61
|
|
456
|
+
const toolCallId = (e as { toolCallId?: string }).toolCallId ?? "";
|
|
457
|
+
// #60: real args from the start event; `?? e.args` keeps hand-rolled fakes that put args
|
|
458
|
+
// on the end event working (the SDK never does).
|
|
459
|
+
const args = pendingToolArgs.get(toolCallId) ?? (e as { args?: unknown }).args;
|
|
460
|
+
pendingToolArgs.delete(toolCallId);
|
|
435
461
|
sawAssistantAfterLastTool = false; // cut mid-tool-work unless a message follows
|
|
436
|
-
for (const f of extractTouchedFiles((e as { toolName?: string }).toolName ?? "",
|
|
462
|
+
for (const f of extractTouchedFiles((e as { toolName?: string }).toolName ?? "", args)) filesTouched.add(f);
|
|
437
463
|
try {
|
|
438
|
-
opts.runLog?.append(runId, buildToolEvent((e as any).toolName,
|
|
464
|
+
opts.runLog?.append(runId, buildToolEvent((e as any).toolName, args, (e as any).result, (e as any).isError ?? false, turnIdx));
|
|
439
465
|
} catch { /* best-effort */ }
|
|
440
466
|
}
|
|
441
467
|
// #23: liveness heartbeat — update the run record on meaningful events so the fleet widget
|
|
@@ -524,7 +550,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
524
550
|
status = "completed";
|
|
525
551
|
}
|
|
526
552
|
|
|
527
|
-
return await finishRun(opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentDef.name, model, tokenTotal, costTotal, contextTokens, modelError ? true : undefined, filesTouchedList, reachedSummary);
|
|
553
|
+
return await finishRun(opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentDef.name, model, tokenTotal, costTotal, contextTokens, modelError ? true : undefined, filesTouchedList, reachedSummary, toolCallCount);
|
|
528
554
|
} finally {
|
|
529
555
|
// #31: a readOnly dispatch never acquired the lock — don't release what it didn't take
|
|
530
556
|
// (releasing a lock held by another concurrent write dispatch would corrupt serialization).
|
|
@@ -547,7 +573,7 @@ async function finishRun(
|
|
|
547
573
|
status: FleetRunStatus, finalText: string, todoId: string | null, priorStatus: string | undefined,
|
|
548
574
|
error: string | undefined, agentName: string, model: string, tokenTotal = 0, costTotal = 0, contextTokens = 0,
|
|
549
575
|
retryable?: boolean,
|
|
550
|
-
filesTouched?: string[], reachedSummary?: boolean,
|
|
576
|
+
filesTouched?: string[], reachedSummary?: boolean, toolCallCount = 0,
|
|
551
577
|
): Promise<SpawnResult> {
|
|
552
578
|
if (finalizedRunIds.has(runId)) {
|
|
553
579
|
// Already finalized — return the existing registry record's result without re-appending.
|
|
@@ -557,7 +583,7 @@ async function finishRun(
|
|
|
557
583
|
runId, todoId, agent: agentName, model,
|
|
558
584
|
durationMs: existing?.endedAt ? existing.endedAt - startedAt : Date.now() - startedAt,
|
|
559
585
|
tokenTotal, costTotal, contextTokens, error, retryable,
|
|
560
|
-
filesTouched, reachedSummary,
|
|
586
|
+
filesTouched, reachedSummary, toolCallCount,
|
|
561
587
|
};
|
|
562
588
|
}
|
|
563
589
|
finalizedRunIds.add(runId);
|
|
@@ -574,6 +600,13 @@ async function finishRun(
|
|
|
574
600
|
type: "run:ended", runId, status, endedAt,
|
|
575
601
|
resultSummary: finalText.slice(0, 120), tokenTotal, costTotal, contextTokens,
|
|
576
602
|
resumedFrom: opts.resumeLink, forkedFrom: opts.forkLink,
|
|
603
|
+
// #59: journal the failure reason — the archived failing runs had run:ended with an empty
|
|
604
|
+
// resultSummary and no error field, making post-hoc diagnosis from the journal impossible.
|
|
605
|
+
error,
|
|
606
|
+
// #61: executed-tool count (the zero-work premature-return signal, post-hoc too).
|
|
607
|
+
toolCallCount,
|
|
608
|
+
// #60: what the run mutated (was only on the SpawnResult; the durable journal lacked it).
|
|
609
|
+
filesTouched,
|
|
577
610
|
});
|
|
578
611
|
} catch { /* best-effort: journal is the index, not the product */ }
|
|
579
612
|
// SPEC-4: lifecycle phase children skip the per-run todo reconciliation — the lifecycle
|
|
@@ -592,6 +625,6 @@ async function finishRun(
|
|
|
592
625
|
return {
|
|
593
626
|
status, finalText, runId, todoId, agent: agentName, model,
|
|
594
627
|
durationMs: endedAt - startedAt, tokenTotal, costTotal, contextTokens, error, retryable,
|
|
595
|
-
filesTouched, reachedSummary,
|
|
628
|
+
filesTouched, reachedSummary, toolCallCount,
|
|
596
629
|
};
|
|
597
630
|
}
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ 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
20
|
import { withModelFallbackRetry } from "./engine/retry-fallback.ts";
|
|
21
|
+
import { resolveAutoFallback } from "./engine/auto-fallback.ts";
|
|
21
22
|
import { createDescribeImageTool } from "./vision/describe-image-tool.ts";
|
|
22
23
|
import type { MemoryHydratePort } from "./memory-hydrate/port.ts";
|
|
23
24
|
import type { VisionPort } from "./vision/port.ts";
|
|
@@ -204,7 +205,10 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
204
205
|
// #39 tail: global default fallback model (env-driven for now; a settings.json field is a follow-up).
|
|
205
206
|
// A retryable provider failure (stopReason "error") retries once on this model even without a
|
|
206
207
|
// per-dispatch `modelFallback`. Per the AGENTS.md "Ollama primary + OpenRouter fallback" pattern.
|
|
207
|
-
|
|
208
|
+
// #39 tail + #58: the global default fallback. Non-"auto" env values are used verbatim;
|
|
209
|
+
// "auto" is resolved per-session in session_start (it needs the session model to differ from).
|
|
210
|
+
const rawModelFallback = process.env.ARMORY_FLEET_MODEL_FALLBACK || undefined;
|
|
211
|
+
deps.defaultModelFallback = rawModelFallback === "auto" ? undefined : rawModelFallback;
|
|
208
212
|
// SPEC-6-5: cross-cwd dispatch notify hook (wired per-session in session_start below).
|
|
209
213
|
// Placeholder; the real wiring happens in session_start where ctx is in scope.
|
|
210
214
|
|
|
@@ -309,6 +313,15 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
309
313
|
refreshLifecycles(ctx);
|
|
310
314
|
const m = ctx.model;
|
|
311
315
|
deps.parentModel = m ? { provider: m.provider, id: m.id } : { provider: "", id: "" };
|
|
316
|
+
// #58: ARMORY_FLEET_MODEL_FALLBACK=auto — pick a fallback from the configured+available
|
|
317
|
+
// snapshot that differs from the session model (different provider preferred). Unresolvable
|
|
318
|
+
// (single-model setup) → stay off + say why once per session.
|
|
319
|
+
if (rawModelFallback === "auto") {
|
|
320
|
+
deps.defaultModelFallback = resolveAutoFallback(modelRuntime.getAvailableSnapshot(), deps.parentModel);
|
|
321
|
+
if (!deps.defaultModelFallback) {
|
|
322
|
+
ctx.ui.notify("ARMORY_FLEET_MODEL_FALLBACK=auto, but no alternative configured model is available — auto-retry stays off", "warning");
|
|
323
|
+
}
|
|
324
|
+
}
|
|
312
325
|
deps.parentCwd = ctx.cwd;
|
|
313
326
|
deps.onNotify = (m, k) => ctx.ui.notify(m, k ?? "info");
|
|
314
327
|
// SPEC-5a: build the per-session async runner + scheduler, start firing, scan for interrupted runs.
|
package/src/runtime/run-log.ts
CHANGED
|
@@ -33,6 +33,12 @@ export interface RunEndedEvent {
|
|
|
33
33
|
costTotal?: number;
|
|
34
34
|
/** SPEC-6-1: latest context-token snapshot at run end. */
|
|
35
35
|
contextTokens?: number;
|
|
36
|
+
/** #59: the failure reason on failed runs (post-hoc diagnosability from the journal). */
|
|
37
|
+
error?: string;
|
|
38
|
+
/** #61: executed-tool count (the zero-work premature-return signal, post-hoc too). */
|
|
39
|
+
toolCallCount?: number;
|
|
40
|
+
/** #60: file paths the run mutated (post-hoc — was previously SpawnResult-only). */
|
|
41
|
+
filesTouched?: string[];
|
|
36
42
|
}
|
|
37
43
|
export type RunLogEvent = RunMetaEvent | MessageEvent | ToolEvent | RunEndedEvent;
|
|
38
44
|
|
package/src/tools/subagent.ts
CHANGED
|
@@ -231,14 +231,43 @@ export function createSubagentTool(deps: SubagentToolDeps) {
|
|
|
231
231
|
});
|
|
232
232
|
retriedWithModel = fallback;
|
|
233
233
|
}
|
|
234
|
+
// #59: when the fallback retry ALSO fails, surface the PRIMARY's failure too — returning
|
|
235
|
+
// only the fallback's error masked why the primary (e.g. an explicit model string) failed
|
|
236
|
+
// at all, making provider diagnosis impossible from the controller's seat.
|
|
237
|
+
if (retriedWithModel && finalRes.status === "failed" && res.error && res.error !== finalRes.error) {
|
|
238
|
+
finalRes = {
|
|
239
|
+
...finalRes,
|
|
240
|
+
error: `primary '${res.model}' failed: ${res.error}; fallback '${retriedWithModel}' failed: ${finalRes.error ?? finalRes.status}`,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
// #58: a retryable failure with NO fallback configured (neither per-dispatch nor global)
|
|
244
|
+
// means the auto-retry silently didn't fire — surface that, and how to enable it, exactly
|
|
245
|
+
// when it matters. Mutually exclusive with the #59 composition above (a retry implies a
|
|
246
|
+
// fallback was configured).
|
|
247
|
+
if (finalRes.status === "failed" && finalRes.retryable && !fallback) {
|
|
248
|
+
finalRes = {
|
|
249
|
+
...finalRes,
|
|
250
|
+
error: `${finalRes.error ?? finalRes.status}\n(no modelFallback configured — pass modelFallback or set ARMORY_FLEET_MODEL_FALLBACK to enable one-shot auto-retry)`,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
234
253
|
const isError = finalRes.status === "failed" || finalRes.status === "aborted";
|
|
254
|
+
// #61: a run that "completed" without a single tool call is usually a premature return
|
|
255
|
+
// (the child narrated a plan and ended without acting) — flag it in-band so the controller
|
|
256
|
+
// verifies (git status/log) instead of trusting a terse planning statement as a completion.
|
|
257
|
+
const zeroToolRun = !isError && (finalRes.toolCallCount ?? 0) === 0;
|
|
258
|
+
const resultText = isError
|
|
259
|
+
? (finalRes.error ?? finalRes.status)
|
|
260
|
+
: zeroToolRun
|
|
261
|
+
? `[FLEET] zero-tool-call run — likely a premature return (#61); verify with git status/log before trusting this result.\n\n${finalRes.finalText}`
|
|
262
|
+
: finalRes.finalText;
|
|
235
263
|
return {
|
|
236
|
-
content: [{ type: "text" as const, text:
|
|
264
|
+
content: [{ type: "text" as const, text: resultText }],
|
|
237
265
|
details: {
|
|
238
266
|
runId: finalRes.runId, todoId: finalRes.todoId, agent: finalRes.agent, model: finalRes.model,
|
|
239
267
|
status: finalRes.status, durationMs: finalRes.durationMs, tokenTotal: finalRes.tokenTotal,
|
|
240
268
|
retriedWithModel,
|
|
241
269
|
filesTouched: finalRes.filesTouched, reachedSummary: finalRes.reachedSummary,
|
|
270
|
+
toolCallCount: finalRes.toolCallCount, // #61: zero = the premature-return signal
|
|
242
271
|
},
|
|
243
272
|
isError,
|
|
244
273
|
};
|