@getpipher/armory-fleet 0.12.5 → 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 +18 -0
- package/package.json +1 -1
- package/src/backend/claude-events.ts +32 -13
- package/src/backend/claude-factory.ts +1 -1
- package/src/backend/claude-session.ts +10 -10
- package/src/engine/auto-fallback.ts +19 -0
- package/src/engine/child-loader.ts +6 -4
- package/src/engine/retry-fallback.ts +8 -1
- package/src/engine/run-registry.ts +9 -0
- package/src/engine/spawnSubagent.ts +63 -12
- package/src/index.ts +17 -1
- package/src/lifecycle/lifecycle-types.ts +4 -0
- package/src/lifecycle/registry.ts +2 -1
- package/src/lifecycle/run-lifecycle.ts +8 -1
- package/src/memory-hydrate/adapter.ts +1 -1
- package/src/memory-hydrate/port.ts +3 -2
- package/src/panel/fleet-panel.ts +30 -10
- package/src/panel/widget-rows.ts +30 -2
- package/src/registry/frontmatter.ts +6 -0
- package/src/runtime/run-log.ts +11 -1
- package/src/tools/subagent.ts +58 -1
package/README.md
CHANGED
|
@@ -308,6 +308,24 @@ Every workflow run is **journaled** (`workflows/journal.ts`) and **resumable**.
|
|
|
308
308
|
|
|
309
309
|
---
|
|
310
310
|
|
|
311
|
+
## Migration (v0.13.0 — SPEC-6-5 cwd isolation)
|
|
312
|
+
|
|
313
|
+
- **`cwd` param on the `subagent` tool** (default = the session cwd, backward-compat). Pass it to scope a child's working dir + context (AGENTS.md cascade, skills, memory) to a dispatch target outside the session cwd — the #20 confabulation fix. Cross-cwd dispatches surface a `↗<basename>` glyph in the fleet widget + a spawn-time notify.
|
|
314
|
+
- **`userMemory` default flip:** the global cross-project user memory scope (`/__armory-fleet-user__`) is no longer hydrated by default. If you populated that dir + relied on it, add `userMemory: true` to the agent frontmatter (only meaningful with `memoryHydrate: true`). TS consumers constructing `AgentDef` literals must now include `userMemory: boolean` (required field; use `false` for the old default behavior).
|
|
315
|
+
- **Lifecycle `cwd` field:** lifecycles accept an optional `cwd` frontmatter field to pin a target repo; absent → the entry-point cwd (the panel's chosen cwd, or the dispatching `subagent` tool's cwd/session cwd). When present, it overrides the entry-point cwd for all phases.
|
|
316
|
+
- **Panel Run-action:** a 3rd `cwd` input step (task → name → cwd), prefilled with the session cwd; Enter accepts, Escape cancels.
|
|
317
|
+
- **Deferred:** bg/scheduled + worktree cwd-isolation (the `cwd` param is honored by foreground dispatches only for now) — tracked in #62.
|
|
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
|
+
|
|
311
329
|
## Roadmap
|
|
312
330
|
|
|
313
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
|
}
|
|
@@ -22,7 +22,7 @@ export function createClaudeChildFactory(
|
|
|
22
22
|
if (!detector?.schemaOk) {
|
|
23
23
|
throw new Error(`claude backend unavailable: ${detector?.note ?? "schema not ok"}`);
|
|
24
24
|
}
|
|
25
|
-
const memoryBlock = opts.agent.memoryHydrate ? opts.memoryPort.renderScopes(memoryScopesFor(opts.cwd)) : "";
|
|
25
|
+
const memoryBlock = opts.agent.memoryHydrate ? opts.memoryPort.renderScopes(memoryScopesFor(opts.cwd, { includeUser: opts.agent.userMemory ?? false })) : "";
|
|
26
26
|
const sys = memoryBlock ? `${opts.rolePrompt}\n\n${memoryBlock}` : opts.rolePrompt;
|
|
27
27
|
const resumeId = resumeStore.get("claude", opts.agent.sessionKey);
|
|
28
28
|
|
|
@@ -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
|
+
}
|
|
@@ -41,9 +41,11 @@ export function composeChildPrompt(args: { rolePrompt: string; memoryBlock: stri
|
|
|
41
41
|
return [args.rolePrompt, args.memoryBlock, args.base].filter((s) => s && s.trim().length > 0).join("\n\n");
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
/** Build the
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
/** Build the memory scopes for a child: project=cwd, local=parent dir; user only when opted in.
|
|
45
|
+
* #20/SPEC-6-5: the user pseudo-scope (`/__armory-fleet-user__`) is a cross-project bleed by
|
|
46
|
+
* construction — omit it unless the agent declares `userMemory: true`. */
|
|
47
|
+
export function memoryScopesFor(cwd: string, opts?: { includeUser?: boolean }): { project: string; local: string; user?: string } {
|
|
48
|
+
return { project: cwd, local: dirname(cwd) || cwd, ...(opts?.includeUser ? { user: USER_PSEUDO_CWD } : {}) };
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
/** #40: resolve extra skill dirs to scan for the child, beyond the default `~/.pi/agent/skills`.
|
|
@@ -73,7 +75,7 @@ export interface ChildLoaderOpts {
|
|
|
73
75
|
|
|
74
76
|
/** Build the fleet CustomResourceLoader for a child session. */
|
|
75
77
|
export function buildChildLoader(opts: ChildLoaderOpts): DefaultResourceLoader {
|
|
76
|
-
const scopes = memoryScopesFor(opts.cwd);
|
|
78
|
+
const scopes = memoryScopesFor(opts.cwd, { includeUser: opts.agent.userMemory ?? false });
|
|
77
79
|
const memoryBlock = opts.agent.memoryHydrate ? opts.memoryPort.renderScopes(scopes) : "";
|
|
78
80
|
return new DefaultResourceLoader({
|
|
79
81
|
cwd: opts.cwd,
|
|
@@ -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
|
};
|
|
@@ -28,10 +28,19 @@ export interface RunRecord {
|
|
|
28
28
|
costTotal?: number;
|
|
29
29
|
/** SPEC-6-1: latest context tokens (calcContextTokens(usage)) — live snapshot. */
|
|
30
30
|
contextTokens?: number;
|
|
31
|
+
/** #32: context-token snapshot at the end of turn 1 (the armory substrate baseline).
|
|
32
|
+
* Set once on the first assistant message_end; live-only (not journaled). The widget
|
|
33
|
+
* compares current contextTokens against this to label the tok/ctx% segment as
|
|
34
|
+
* "substrate" (flat across turns) vs "work" (growing) — see src/panel/widget-rows.ts. */
|
|
35
|
+
substrateBaseline?: number;
|
|
31
36
|
/** SPEC-6-1: the tier name this run used (for Tiers-view "used by" + per-tier spend). */
|
|
32
37
|
tier?: string;
|
|
33
38
|
/** SPEC-6-2: the cwd this run belongs to (widget cross-cwd filter + reconcile ownership). */
|
|
34
39
|
cwd: string;
|
|
40
|
+
/** SPEC-6-5: the session cwd the dispatch originated from (live; = parentCwd). Set at spawn.
|
|
41
|
+
* Lets the widget compute cross-cwd (`cwd !== sessionCwd`) for the ↗ glyph without re-reading
|
|
42
|
+
* the journal. Live-only counterpart to RunMetaEvent.sessionCwd. */
|
|
43
|
+
sessionCwd?: string;
|
|
35
44
|
/** SPEC-6-2: the backend (probe dispatch: pi→handle, claude→pid). */
|
|
36
45
|
backend: BackendId;
|
|
37
46
|
/** SPEC-6-2: claude-backend child PID (cross-process liveness probe). */
|
|
@@ -5,6 +5,7 @@ import type { MemoryHydratePort } from "../memory-hydrate/port.ts";
|
|
|
5
5
|
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
|
+
import type { RunRecord } from "./run-registry.ts";
|
|
8
9
|
import { createTurnBudget, DEFAULT_MAX_TURNS } from "./turn-budget.ts";
|
|
9
10
|
import type { ForegroundLock } from "./concurrency-lock.ts";
|
|
10
11
|
import type { RunLog } from "../runtime/run-log.ts";
|
|
@@ -43,6 +44,13 @@ export interface ChildSessionEvent {
|
|
|
43
44
|
};
|
|
44
45
|
/** Emitted by a backend on session init (SPEC-3). Drives runRecord.backendSessionId. */
|
|
45
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;
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
export interface ChildSession {
|
|
@@ -116,6 +124,10 @@ export interface SpawnOptions {
|
|
|
116
124
|
backendRegistry: BackendRegistry; // SPEC-3: replaces childFactory — engine looks up by agentDef.backend
|
|
117
125
|
parentModel: { provider: string; id: string };
|
|
118
126
|
parentCwd: string;
|
|
127
|
+
/** SPEC-6-5: the dispatch target's working directory. Default = parentCwd (the session cwd, backward-compat).
|
|
128
|
+
* When set, all child-scoped sites use this cwd (factory.create, RunRecord.cwd, run:meta cwd);
|
|
129
|
+
* session-scoped audit (`sessionCwd`) keeps parentCwd. */
|
|
130
|
+
cwd?: string;
|
|
119
131
|
memoryPort?: MemoryHydratePort;
|
|
120
132
|
visionPort?: VisionPort;
|
|
121
133
|
signal?: AbortSignal;
|
|
@@ -177,6 +189,9 @@ export interface SpawnResult {
|
|
|
177
189
|
* or was it cut mid-tool-work? Surfaced on turn-budget exhaustion so the controller knows
|
|
178
190
|
* whether finalText is a partial summary or a mid-thought. Undefined for non-turn-budget paths. */
|
|
179
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;
|
|
180
195
|
}
|
|
181
196
|
|
|
182
197
|
/** #49: extract file paths a tool event touched, for the structured partial-result report.
|
|
@@ -185,11 +200,13 @@ export interface SpawnResult {
|
|
|
185
200
|
function extractTouchedFiles(toolName: string, args: unknown): string[] {
|
|
186
201
|
if (!args || typeof args !== "object") return [];
|
|
187
202
|
const a = args as Record<string, unknown>;
|
|
188
|
-
|
|
203
|
+
// Case-insensitive: pi tools are lowercase ("edit"), claude's are capitalized ("Edit").
|
|
204
|
+
const name = toolName.toLowerCase();
|
|
205
|
+
if (name === "edit" || name === "write") {
|
|
189
206
|
const p = typeof a.path === "string" ? a.path : typeof a.file_path === "string" ? a.file_path : undefined;
|
|
190
207
|
return p ? [p] : [];
|
|
191
208
|
}
|
|
192
|
-
if (
|
|
209
|
+
if (name === "bash") {
|
|
193
210
|
const cmd = typeof a.command === "string" ? a.command : undefined;
|
|
194
211
|
if (!cmd) return [];
|
|
195
212
|
const out: string[] = [];
|
|
@@ -215,6 +232,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
215
232
|
const track = opts.track ?? true;
|
|
216
233
|
const maxTurns = opts.maxTurns ?? DEFAULT_MAX_TURNS;
|
|
217
234
|
const startedAt = Date.now();
|
|
235
|
+
const childCwd = opts.cwd ?? opts.parentCwd;
|
|
218
236
|
|
|
219
237
|
// #31: read-only dispatches (review/audit/research) bypass the foreground single-slot lock —
|
|
220
238
|
// the caller asserts no cwd mutation, so the in-place edit-conflict guard doesn't apply and
|
|
@@ -287,7 +305,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
287
305
|
runId, agent: agentDef.name, model, task: opts.task, track,
|
|
288
306
|
todoId: null, status: "running", startedAt,
|
|
289
307
|
tier: tier?.name, costTotal: 0, contextTokens: 0,
|
|
290
|
-
cwd: opts.parentCwd, backend: backendId,
|
|
308
|
+
cwd: childCwd, sessionCwd: opts.parentCwd, backend: backendId,
|
|
291
309
|
});
|
|
292
310
|
|
|
293
311
|
// todo-sync (before) — only when both caller tracks AND agent allows todoSync
|
|
@@ -311,7 +329,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
311
329
|
for (const cand of candidates) {
|
|
312
330
|
try {
|
|
313
331
|
const result = await backend.factory.create({
|
|
314
|
-
cwd:
|
|
332
|
+
cwd: childCwd,
|
|
315
333
|
model: cand,
|
|
316
334
|
thinkingLevel: childAgent.thinkingLevel,
|
|
317
335
|
tools,
|
|
@@ -345,6 +363,10 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
345
363
|
let costTotal = 0;
|
|
346
364
|
let contextTokens = 0;
|
|
347
365
|
let turnIdx = -1;
|
|
366
|
+
// #32: substrate baseline — the turn-1 context-token snapshot (armory substrate overhead).
|
|
367
|
+
// Captured once on the first assistant message_end (turnIdx === 0); threaded to the RunRecord
|
|
368
|
+
// so the widget can classify the tok/ctx% segment as "substrate" (flat) vs "work" (growing).
|
|
369
|
+
let substrateBaseline: number | undefined;
|
|
348
370
|
// #26/#22: declared before subscribe() because some child sessions emit events
|
|
349
371
|
// synchronously inside subscribe() (temporal-dead-zone guard).
|
|
350
372
|
let modelError: string | undefined; // model-call failure surfaced via stopReason "error"
|
|
@@ -353,6 +375,12 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
353
375
|
// a trailing assistant message after its last tool (a summary) vs being cut mid-tool-work.
|
|
354
376
|
const filesTouched = new Set<string>();
|
|
355
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>();
|
|
356
384
|
|
|
357
385
|
// #23: liveness — classify events into a short, content-free class string for the widget.
|
|
358
386
|
// Names the tool (safe — tool name is not args/result) so the operator sees "what's happening"
|
|
@@ -373,7 +401,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
373
401
|
if (e.type === "session_init" && e.backendSessionId) {
|
|
374
402
|
opts.runRegistry.update(runId, { backendSessionId: e.backendSessionId, sessionKey: agentDef.sessionKey });
|
|
375
403
|
try {
|
|
376
|
-
opts.runLog?.append(runId, { type: "run:meta", runId, agent: agentDef.name, model, task: opts.task, startedAt, track, todoId, backendSessionId: e.backendSessionId, sessionKey: agentDef.sessionKey, cwd: opts.parentCwd, pid: (session as { proc?: { pid?: number } }).proc?.pid });
|
|
404
|
+
opts.runLog?.append(runId, { type: "run:meta", runId, agent: agentDef.name, model, task: opts.task, startedAt, track, todoId, backendSessionId: e.backendSessionId, sessionKey: agentDef.sessionKey, cwd: childCwd, sessionCwd: opts.parentCwd, pid: (session as { proc?: { pid?: number } }).proc?.pid });
|
|
377
405
|
} catch { /* best-effort */ }
|
|
378
406
|
} else if (e.type === "turn_start") {
|
|
379
407
|
turnIdx++;
|
|
@@ -403,7 +431,15 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
403
431
|
const cost = u?.cost?.total ?? 0;
|
|
404
432
|
costTotal += cost;
|
|
405
433
|
contextTokens = calcContextTokens(u ?? {});
|
|
406
|
-
|
|
434
|
+
// #32: capture the substrate baseline at the end of turn 1 (the first assistant
|
|
435
|
+
// message_end). The turn-1 context is dominated by the armory substrate (system prompt +
|
|
436
|
+
// skills + memory); subsequent turns barely grow it unless real work adds tool results.
|
|
437
|
+
const patch: Partial<RunRecord> = { costTotal, contextTokens, tokenTotal };
|
|
438
|
+
if (substrateBaseline === undefined && turnIdx === 0 && contextTokens > 0) {
|
|
439
|
+
substrateBaseline = contextTokens;
|
|
440
|
+
patch.substrateBaseline = substrateBaseline;
|
|
441
|
+
}
|
|
442
|
+
opts.runRegistry.update(runId, patch);
|
|
407
443
|
try {
|
|
408
444
|
opts.runLog?.append(runId, { type: "message", role: "assistant", text, usage: { total: turnTokens, input: u?.input, output: u?.output, cacheRead: u?.cacheRead, cacheWrite: u?.cacheWrite, cost: u?.cost }, turnIndex: turnIdx });
|
|
409
445
|
} catch { /* best-effort */ }
|
|
@@ -412,12 +448,20 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
412
448
|
aborted = true;
|
|
413
449
|
void session.abort();
|
|
414
450
|
}
|
|
451
|
+
} else if (e.type === "tool_execution_start") {
|
|
452
|
+
pendingToolArgs.set((e as { toolCallId?: string }).toolCallId ?? "", (e as { args?: unknown }).args); // #60
|
|
415
453
|
} else if (e.type === "tool_execution_end") {
|
|
416
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);
|
|
417
461
|
sawAssistantAfterLastTool = false; // cut mid-tool-work unless a message follows
|
|
418
|
-
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);
|
|
419
463
|
try {
|
|
420
|
-
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));
|
|
421
465
|
} catch { /* best-effort */ }
|
|
422
466
|
}
|
|
423
467
|
// #23: liveness heartbeat — update the run record on meaningful events so the fleet widget
|
|
@@ -506,7 +550,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
506
550
|
status = "completed";
|
|
507
551
|
}
|
|
508
552
|
|
|
509
|
-
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);
|
|
510
554
|
} finally {
|
|
511
555
|
// #31: a readOnly dispatch never acquired the lock — don't release what it didn't take
|
|
512
556
|
// (releasing a lock held by another concurrent write dispatch would corrupt serialization).
|
|
@@ -529,7 +573,7 @@ async function finishRun(
|
|
|
529
573
|
status: FleetRunStatus, finalText: string, todoId: string | null, priorStatus: string | undefined,
|
|
530
574
|
error: string | undefined, agentName: string, model: string, tokenTotal = 0, costTotal = 0, contextTokens = 0,
|
|
531
575
|
retryable?: boolean,
|
|
532
|
-
filesTouched?: string[], reachedSummary?: boolean,
|
|
576
|
+
filesTouched?: string[], reachedSummary?: boolean, toolCallCount = 0,
|
|
533
577
|
): Promise<SpawnResult> {
|
|
534
578
|
if (finalizedRunIds.has(runId)) {
|
|
535
579
|
// Already finalized — return the existing registry record's result without re-appending.
|
|
@@ -539,7 +583,7 @@ async function finishRun(
|
|
|
539
583
|
runId, todoId, agent: agentName, model,
|
|
540
584
|
durationMs: existing?.endedAt ? existing.endedAt - startedAt : Date.now() - startedAt,
|
|
541
585
|
tokenTotal, costTotal, contextTokens, error, retryable,
|
|
542
|
-
filesTouched, reachedSummary,
|
|
586
|
+
filesTouched, reachedSummary, toolCallCount,
|
|
543
587
|
};
|
|
544
588
|
}
|
|
545
589
|
finalizedRunIds.add(runId);
|
|
@@ -556,6 +600,13 @@ async function finishRun(
|
|
|
556
600
|
type: "run:ended", runId, status, endedAt,
|
|
557
601
|
resultSummary: finalText.slice(0, 120), tokenTotal, costTotal, contextTokens,
|
|
558
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,
|
|
559
610
|
});
|
|
560
611
|
} catch { /* best-effort: journal is the index, not the product */ }
|
|
561
612
|
// SPEC-4: lifecycle phase children skip the per-run todo reconciliation — the lifecycle
|
|
@@ -574,6 +625,6 @@ async function finishRun(
|
|
|
574
625
|
return {
|
|
575
626
|
status, finalText, runId, todoId, agent: agentName, model,
|
|
576
627
|
durationMs: endedAt - startedAt, tokenTotal, costTotal, contextTokens, error, retryable,
|
|
577
|
-
filesTouched, reachedSummary,
|
|
628
|
+
filesTouched, reachedSummary, toolCallCount,
|
|
578
629
|
};
|
|
579
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,12 @@ 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;
|
|
212
|
+
// SPEC-6-5: cross-cwd dispatch notify hook (wired per-session in session_start below).
|
|
213
|
+
// Placeholder; the real wiring happens in session_start where ctx is in scope.
|
|
208
214
|
|
|
209
215
|
// #31 tail: foreground concurrency is SESSION-LEVEL (a shared lock can't be re-sized per
|
|
210
216
|
// dispatch). cap=1 (default) is fail-fast (backward-compat); cap>1 enables a queueing pool so
|
|
@@ -307,7 +313,17 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
307
313
|
refreshLifecycles(ctx);
|
|
308
314
|
const m = ctx.model;
|
|
309
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
|
+
}
|
|
310
325
|
deps.parentCwd = ctx.cwd;
|
|
326
|
+
deps.onNotify = (m, k) => ctx.ui.notify(m, k ?? "info");
|
|
311
327
|
// SPEC-5a: build the per-session async runner + scheduler, start firing, scan for interrupted runs.
|
|
312
328
|
const dir = fleetDir(ctx.cwd);
|
|
313
329
|
// SPEC-5b-1: per-session RunLog at .pi/fleet/conversations/ (separate from the
|
|
@@ -34,6 +34,10 @@ export interface LifecycleDef {
|
|
|
34
34
|
description: string;
|
|
35
35
|
/** Lifecycle-wide default backend; absent → "pi". */
|
|
36
36
|
backend: BackendId;
|
|
37
|
+
/** SPEC-6-5: pin this lifecycle to a target working directory. Absent → the entry-point cwd
|
|
38
|
+
* (the panel's chosen cwd, or the dispatching `subagent` tool's cwd/session cwd). When present,
|
|
39
|
+
* overrides the entry-point cwd for all phases. */
|
|
40
|
+
cwd?: string;
|
|
37
41
|
phases: PhaseDef[];
|
|
38
42
|
source: AgentSource;
|
|
39
43
|
filePath: string;
|
|
@@ -38,6 +38,7 @@ export function parseLifecycleFile(content: string, filePath: string, source: Ag
|
|
|
38
38
|
throw new LifecycleParseError(`${filePath}: invalid backend '${rawBackend}' (must be 'pi' | 'claude')`);
|
|
39
39
|
}
|
|
40
40
|
const backend = rawBackend as BackendId;
|
|
41
|
+
const cwd = typeof raw.cwd === "string" && raw.cwd.trim() ? raw.cwd.trim() : undefined;
|
|
41
42
|
|
|
42
43
|
if (!Array.isArray(raw.phases) || raw.phases.length === 0) {
|
|
43
44
|
throw new LifecycleParseError(`${filePath}: phases must be a non-empty array`);
|
|
@@ -100,7 +101,7 @@ export function parseLifecycleFile(content: string, filePath: string, source: Ag
|
|
|
100
101
|
// The terminal phase never checkpoints after it (the lifecycle is done) — §5.4.
|
|
101
102
|
if (phases.length > 0) phases[phases.length - 1]!.checkpoint = false;
|
|
102
103
|
|
|
103
|
-
return { name, description, backend, phases, source, filePath };
|
|
104
|
+
return { name, description, backend, phases, source, filePath, ...(cwd ? { cwd } : {}) };
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
/** Split the markdown body into a map of phase-name → prompt-template, by `## <name>` H2 headings. */
|
|
@@ -25,6 +25,8 @@ export interface PhaseSpawnOpts {
|
|
|
25
25
|
skills: string[];
|
|
26
26
|
/** The resolved backend for this phase (phase.backend → lifecycle.backend → "pi"). */
|
|
27
27
|
backend: BackendId;
|
|
28
|
+
/** SPEC-6-5: the resolved lifecycle cwd (lifecycle.cwd ?? entryCwd) the phase child runs in. */
|
|
29
|
+
cwd?: string;
|
|
28
30
|
model?: string;
|
|
29
31
|
}
|
|
30
32
|
export type SpawnFn = (opts: PhaseSpawnOpts) => Promise<SpawnResult>;
|
|
@@ -57,6 +59,9 @@ export interface LifecycleRunOpts {
|
|
|
57
59
|
worktreePath?: string;
|
|
58
60
|
/** SPEC-5a: the base ref to diff against (default "HEAD"). */
|
|
59
61
|
baseRef?: string;
|
|
62
|
+
/** SPEC-6-5: the entry-point cwd (the panel's chosen cwd, or the dispatching subagent tool's
|
|
63
|
+
* cwd/session cwd). The lifecycle's `cwd` field, if present, overrides this. */
|
|
64
|
+
entryCwd?: string;
|
|
60
65
|
}
|
|
61
66
|
|
|
62
67
|
export interface LifecycleRunResult {
|
|
@@ -86,6 +91,8 @@ export async function runLifecycle(task: string, lifecycleName: string, opts: Li
|
|
|
86
91
|
const available = [...deps.registry.keys()].sort().join(", ");
|
|
87
92
|
return failResult("", startedAt, `lifecycle '${lifecycleName}' not found; available: ${available}`, lifecycleName, task, opts.mode, [], null);
|
|
88
93
|
}
|
|
94
|
+
// SPEC-6-5: lifecycle cwd field overrides the entry-point cwd (entryCwd).
|
|
95
|
+
const lifecycleCwd = lifecycle.cwd ?? opts.entryCwd;
|
|
89
96
|
|
|
90
97
|
const runId = deps.genRunId();
|
|
91
98
|
const lifecycleBackend = lifecycle.backend;
|
|
@@ -151,7 +158,7 @@ export async function runLifecycle(task: string, lifecycleName: string, opts: Li
|
|
|
151
158
|
// phase's skills (Q1=B) and routes to the phase's backend (Q4=C) — not the agent's defaults.
|
|
152
159
|
let spawnRes: import("../engine/spawnSubagent.ts").SpawnResult;
|
|
153
160
|
try {
|
|
154
|
-
spawnRes = await deps.spawn({ agent: agentName, task: prompt, lifecycleTodoId: todoId, skills, backend });
|
|
161
|
+
spawnRes = await deps.spawn({ agent: agentName, task: prompt, lifecycleTodoId: todoId, skills, backend, cwd: lifecycleCwd });
|
|
155
162
|
} catch (e) {
|
|
156
163
|
// spawn should return a failed result, not throw — but guard anyway so a throwing spawn
|
|
157
164
|
// can't orphan the lifecycle (treat as a phase failure).
|
|
@@ -5,7 +5,7 @@ import type { MemoryHydratePort, MemoryScopes } from "./port.ts";
|
|
|
5
5
|
export class ArmoryMemoryAdapter implements MemoryHydratePort {
|
|
6
6
|
renderScopes(scopes: MemoryScopes): string {
|
|
7
7
|
return [scopes.project, scopes.local, scopes.user]
|
|
8
|
-
.filter((cwd) => listMemory(cwd).length > 0) // skip empty scopes cleanly (no placeholder to render)
|
|
8
|
+
.filter((cwd): cwd is string => cwd != null && listMemory(cwd).length > 0) // skip empty scopes cleanly (no placeholder to render)
|
|
9
9
|
.map((cwd) => renderMemoryBlock(cwd)) // armory-memory's existing cwd-keyed primitive
|
|
10
10
|
.join("\n\n"); // → "" when all three empty
|
|
11
11
|
}
|
|
@@ -4,8 +4,9 @@ export interface MemoryScopes {
|
|
|
4
4
|
project: string;
|
|
5
5
|
/** Immediate parent directory of the project cwd (workspace/org level). */
|
|
6
6
|
local: string;
|
|
7
|
-
/**
|
|
8
|
-
user
|
|
7
|
+
/** Optional — only present when the agent opted in via `userMemory: true` (SPEC-6-5).
|
|
8
|
+
* The user scope is a cross-project memory bleed by construction; omitted unless explicitly enabled. */
|
|
9
|
+
user?: string;
|
|
9
10
|
}
|
|
10
11
|
export interface MemoryHydratePort {
|
|
11
12
|
/** Render the three-scope memory block (project → local → user), concatenated. Empty string when all scopes empty. */
|
package/src/panel/fleet-panel.ts
CHANGED
|
@@ -96,7 +96,8 @@ export class FleetPanel extends Container {
|
|
|
96
96
|
private lcRunMode = false;
|
|
97
97
|
private lcTaskInput: Input | null = null;
|
|
98
98
|
private lcNameInput: Input | null = null;
|
|
99
|
-
private
|
|
99
|
+
private lcCwdInput: Input | null = null;
|
|
100
|
+
private lcPhase: "task" | "name" | "cwd" = "task";
|
|
100
101
|
// SPEC-4: pending checkpoint (interactive Continue/Revise/Abort)
|
|
101
102
|
private pendingCheckpoint: { phase: PhaseRecord; resolve: (d: CheckpointDecision) => void } | null = null;
|
|
102
103
|
private lcReviseInput: Input | null = null;
|
|
@@ -359,10 +360,10 @@ export class FleetPanel extends Container {
|
|
|
359
360
|
this.addChild(new Text(this.theme.fg("accent", prompt), 0, 0));
|
|
360
361
|
this.addChild(this.schedPhase === "task" ? this.schedTaskInput! : this.schedPhase === "expr" ? this.schedExprInput! : this.schedNameInput!);
|
|
361
362
|
this.addChild(new Text(this.theme.fg("dim", " enter submit • esc cancel"), 0, 0));
|
|
362
|
-
} else if (this.lcRunMode && (this.lcTaskInput || this.lcNameInput)) {
|
|
363
|
-
const prompt = this.lcPhase === "task" ? " task> " : " lifecycle name (blank=default)> ";
|
|
363
|
+
} else if (this.lcRunMode && (this.lcTaskInput || this.lcNameInput || this.lcCwdInput)) {
|
|
364
|
+
const prompt = this.lcPhase === "task" ? " task> " : this.lcPhase === "name" ? " lifecycle name (blank=default)> " : " cwd (blank=session cwd)> ";
|
|
364
365
|
this.addChild(new Text(this.theme.fg("accent", prompt), 0, 0));
|
|
365
|
-
this.addChild(this.lcPhase === "task" ? this.lcTaskInput! : this.lcNameInput!);
|
|
366
|
+
this.addChild(this.lcPhase === "task" ? this.lcTaskInput! : this.lcPhase === "name" ? this.lcNameInput! : this.lcCwdInput!);
|
|
366
367
|
this.addChild(new Text(this.theme.fg("dim", " enter submit • esc cancel"), 0, 0));
|
|
367
368
|
} else if (this.pendingCheckpoint && !this.lcRevising) {
|
|
368
369
|
const pc = this.pendingCheckpoint;
|
|
@@ -586,9 +587,9 @@ export class FleetPanel extends Container {
|
|
|
586
587
|
this.invalidate();
|
|
587
588
|
return;
|
|
588
589
|
}
|
|
589
|
-
if (this.lcRunMode && (this.lcTaskInput || this.lcNameInput)) {
|
|
590
|
+
if (this.lcRunMode && (this.lcTaskInput || this.lcNameInput || this.lcCwdInput)) {
|
|
590
591
|
if (matchesKey(data, "escape")) { this.cancelLifecycleRun(); return; }
|
|
591
|
-
(this.lcPhase === "task" ? this.lcTaskInput! : this.lcNameInput!).handleInput(data);
|
|
592
|
+
(this.lcPhase === "task" ? this.lcTaskInput! : this.lcPhase === "name" ? this.lcNameInput! : this.lcCwdInput!).handleInput(data);
|
|
592
593
|
this.invalidate();
|
|
593
594
|
return;
|
|
594
595
|
}
|
|
@@ -1131,9 +1132,18 @@ export class FleetPanel extends Container {
|
|
|
1131
1132
|
this.lcNameInput = new Input();
|
|
1132
1133
|
this.lcNameInput.onSubmit = (name: string) => {
|
|
1133
1134
|
const lcName = name.trim() || "default";
|
|
1134
|
-
|
|
1135
|
+
this.lcPhase = "cwd";
|
|
1136
|
+
this.lcCwdInput = new Input();
|
|
1137
|
+
// SPEC-6-5: 3rd input step — the dispatch cwd. Prefilled with the session cwd; Enter
|
|
1138
|
+
// accepts it, Escape accepts the default (mirrors the name step's Escape-accepts-default).
|
|
1139
|
+
this.lcCwdInput.onSubmit = (cwd: string) => {
|
|
1140
|
+
const picked = cwd.trim() || this.deps.parentCwd;
|
|
1141
|
+
void this.executeLifecycleRun(task.trim(), lcName, picked);
|
|
1142
|
+
};
|
|
1143
|
+
this.lcCwdInput.onEscape = () => { void this.executeLifecycleRun(task.trim(), lcName, this.deps.parentCwd); };
|
|
1144
|
+
this.renderShell();
|
|
1135
1145
|
};
|
|
1136
|
-
this.lcNameInput.onEscape = () => { void this.executeLifecycleRun(task.trim(), "default"); };
|
|
1146
|
+
this.lcNameInput.onEscape = () => { void this.executeLifecycleRun(task.trim(), "default", this.deps.parentCwd); };
|
|
1137
1147
|
this.renderShell();
|
|
1138
1148
|
};
|
|
1139
1149
|
this.lcTaskInput.onEscape = () => this.cancelLifecycleRun();
|
|
@@ -1145,18 +1155,27 @@ export class FleetPanel extends Container {
|
|
|
1145
1155
|
this.lcRunMode = false;
|
|
1146
1156
|
this.lcTaskInput = null;
|
|
1147
1157
|
this.lcNameInput = null;
|
|
1158
|
+
this.lcCwdInput = null;
|
|
1148
1159
|
this.renderShell();
|
|
1149
1160
|
}
|
|
1150
1161
|
|
|
1151
|
-
private async executeLifecycleRun(task: string, lifecycleName: string): Promise<void> {
|
|
1162
|
+
private async executeLifecycleRun(task: string, lifecycleName: string, cwd: string): Promise<void> {
|
|
1152
1163
|
this.lcRunMode = false;
|
|
1153
1164
|
this.lcTaskInput = null;
|
|
1154
1165
|
this.lcNameInput = null;
|
|
1166
|
+
this.lcCwdInput = null;
|
|
1155
1167
|
this.renderShell();
|
|
1156
1168
|
if (!this.deps.lifecycleRegistry.has(lifecycleName)) {
|
|
1157
1169
|
this.onNotify(`lifecycle '${lifecycleName}' not found; available: ${[...this.deps.lifecycleRegistry.keys()].sort().join(", ")}`, "error");
|
|
1158
1170
|
return;
|
|
1159
1171
|
}
|
|
1172
|
+
// SPEC-6-5: validate the chosen cwd (exists + is a dir) before spawning; surface cross-cwd.
|
|
1173
|
+
const { resolveDispatchCwd } = await import("../tools/subagent.ts");
|
|
1174
|
+
const { cwd: resolvedCwd, error: cwdErr } = resolveDispatchCwd(cwd, this.deps.parentCwd);
|
|
1175
|
+
if (cwdErr) { this.onNotify(cwdErr, "error"); return; }
|
|
1176
|
+
if (resolvedCwd && resolvedCwd !== this.deps.parentCwd) {
|
|
1177
|
+
this.onNotify("scoped to " + resolvedCwd + " (≠ session " + this.deps.parentCwd + ")", "info");
|
|
1178
|
+
}
|
|
1160
1179
|
const onCheckpoint: CheckpointFn = (phase) => new Promise<CheckpointDecision>((resolve) => {
|
|
1161
1180
|
this.pendingCheckpoint = { phase, resolve };
|
|
1162
1181
|
this.renderShell();
|
|
@@ -1171,10 +1190,11 @@ export class FleetPanel extends Container {
|
|
|
1171
1190
|
registry: this.deps.registry, todoSync: this.deps.todoSync, runRegistry: this.deps.runRegistry, lock: this.deps.lock,
|
|
1172
1191
|
backendRegistry: this.deps.backendRegistry, parentModel: this.deps.parentModel, parentCwd: this.deps.parentCwd,
|
|
1173
1192
|
runLog: this.deps.runLog,
|
|
1193
|
+
cwd: o.cwd,
|
|
1174
1194
|
});
|
|
1175
1195
|
},
|
|
1176
1196
|
};
|
|
1177
|
-
const res = await runLifecycle(task, lifecycleName, { deps: lifecycleFullDeps, mode: "checkpointed", onCheckpoint });
|
|
1197
|
+
const res = await runLifecycle(task, lifecycleName, { deps: lifecycleFullDeps, mode: "checkpointed", onCheckpoint, entryCwd: resolvedCwd });
|
|
1178
1198
|
this.pendingCheckpoint = null;
|
|
1179
1199
|
// record the run so the Lifecycle view shows it
|
|
1180
1200
|
this.deps.lifecycleRuns.set(res.runId, res);
|
package/src/panel/widget-rows.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// list below editor" intent was never achievable via pi widgets (editor keeps keyboard focus).
|
|
9
9
|
// `/fleet` is the navigable action surface; this one above-editor widget is the glance surface.
|
|
10
10
|
import { fmtDuration, fmtTokens } from "./rows.ts";
|
|
11
|
+
import { basename } from "node:path";
|
|
11
12
|
import type { RunRecord } from "../engine/run-registry.ts";
|
|
12
13
|
import type { BgRunStatus } from "./rows.ts";
|
|
13
14
|
|
|
@@ -16,6 +17,11 @@ import type { BgRunStatus } from "./rows.ts";
|
|
|
16
17
|
export const LIVENESS_THRESHOLD_MS = 30_000;
|
|
17
18
|
/** #23: a run whose last event is older than this is flagged stale ("are events still arriving?"). */
|
|
18
19
|
export const STALE_THRESHOLD_MS = 60_000;
|
|
20
|
+
/** #32: once past turn 1, if contextTokens has grown by less than this fraction of the
|
|
21
|
+
* turn-1 substrate baseline, the tok/ctx% segment is labeled "substrate" (flat overhead)
|
|
22
|
+
* rather than "work" (growing from tool results). 5% — the dogfood evidence showed ~0.2%/turn
|
|
23
|
+
* growth on a substrate-dominated run vs tens-of-K (multi-%) once real tool output lands. */
|
|
24
|
+
export const SUBSTRATE_GROWTH_THRESHOLD = 0.05;
|
|
19
25
|
|
|
20
26
|
export interface WidgetRun {
|
|
21
27
|
runId: string;
|
|
@@ -34,6 +40,10 @@ export interface WidgetRun {
|
|
|
34
40
|
task?: string;
|
|
35
41
|
/** SPEC-6-1: latest context-token snapshot (for ctx% segment). */
|
|
36
42
|
contextTokens?: number;
|
|
43
|
+
/** #32: context-token baseline at end of turn 1 (armory substrate overhead). When the
|
|
44
|
+
* current contextTokens has grown little beyond this baseline across turns, the tok/ctx%
|
|
45
|
+
* segment is labeled "substrate" (flat overhead) vs "work" (growing from tool results). */
|
|
46
|
+
substrateBaseline?: number;
|
|
37
47
|
/** SPEC-6-1: max context window for the resolved model (set by controller — Task 7). */
|
|
38
48
|
maxContext?: number;
|
|
39
49
|
/** SPEC-6-1: cumulative $ (for the $ segment). */
|
|
@@ -46,6 +56,10 @@ export interface WidgetRun {
|
|
|
46
56
|
lastEventClass?: string;
|
|
47
57
|
/** #23: liveness — timestamp (ms) of the last event ("events still arriving?"). */
|
|
48
58
|
lastEventAt?: number;
|
|
59
|
+
/** SPEC-6-5: the run's (child) cwd — from RunRecord.cwd. */
|
|
60
|
+
cwd?: string;
|
|
61
|
+
/** SPEC-6-5: the session cwd (parentCwd) — from RunRecord.sessionCwd. When cwd !== sessionCwd the widget shows a ↗ glyph. */
|
|
62
|
+
sessionCwd?: string;
|
|
49
63
|
}
|
|
50
64
|
|
|
51
65
|
export function toWidgetRun(r: RunRecord): WidgetRun {
|
|
@@ -53,8 +67,9 @@ export function toWidgetRun(r: RunRecord): WidgetRun {
|
|
|
53
67
|
runId: r.runId, agent: r.agent, status: r.status,
|
|
54
68
|
startedAt: r.startedAt, endedAt: r.endedAt, tokenTotal: r.tokenTotal,
|
|
55
69
|
kind: "fg",
|
|
56
|
-
task: r.task, costTotal: r.costTotal, contextTokens: r.contextTokens,
|
|
70
|
+
task: r.task, costTotal: r.costTotal, contextTokens: r.contextTokens, substrateBaseline: r.substrateBaseline,
|
|
57
71
|
turnCount: r.turnCount, turnMax: r.turnMax, lastEventClass: r.lastEventClass, lastEventAt: r.lastEventAt,
|
|
72
|
+
cwd: r.cwd, sessionCwd: r.sessionCwd,
|
|
58
73
|
};
|
|
59
74
|
}
|
|
60
75
|
|
|
@@ -102,6 +117,9 @@ function widgetLine(r: WidgetRun, now: number): string {
|
|
|
102
117
|
|
|
103
118
|
// fg: task excerpt as primary label (fallback to runId if no task)
|
|
104
119
|
const label = r.task ? `"${r.task.slice(0, 40)}"` : r.runId;
|
|
120
|
+
// SPEC-6-5: cross-cwd glyph — when the run's cwd differs from the session cwd, mark it so the
|
|
121
|
+
// operator sees "this run is scoped to a different project" at a glance. Same-cwd → no glyph.
|
|
122
|
+
const crossCwd = (r.cwd && r.sessionCwd && r.cwd !== r.sessionCwd) ? ` ↗${basename(r.cwd)}` : "";
|
|
105
123
|
const agentSeg = r.agent && r.agent !== "general-purpose" ? ` · ${r.agent}` : "";
|
|
106
124
|
// #23: liveness — only after LIVENESS_THRESHOLD_MS, to keep short runs concise (per acceptance).
|
|
107
125
|
// turn N/max + last-event class (no prompt content, no args/results — only the tool name)
|
|
@@ -114,7 +132,17 @@ function widgetLine(r: WidgetRun, now: number): string {
|
|
|
114
132
|
const stale = (r.lastEventAt != null && now - r.lastEventAt > STALE_THRESHOLD_MS) ? " ⏰stale" : "";
|
|
115
133
|
liveness = `${turn}${ev}${stale}`;
|
|
116
134
|
}
|
|
117
|
-
|
|
135
|
+
// #32: substrate vs work — once past turn 1, classify the tok/ctx% segment. The armory substrate
|
|
136
|
+
// (system prompt + skills + memory) dominates turn-1 context; on substrate-dominated runs the
|
|
137
|
+
// ctx% barely moves across turns and reads as "frozen". Label it "substrate" (flat overhead) so
|
|
138
|
+
// that's distinguishable from "work" (context growing from tool results). Needs ≥2 turns of
|
|
139
|
+
// data (a baseline + a current snapshot); before that there's nothing to compare.
|
|
140
|
+
let substrate = "";
|
|
141
|
+
if ((r.turnCount ?? 0) >= 2 && r.substrateBaseline != null && r.contextTokens != null && r.substrateBaseline > 0) {
|
|
142
|
+
const growth = (r.contextTokens - r.substrateBaseline) / r.substrateBaseline;
|
|
143
|
+
substrate = growth <= SUBSTRATE_GROWTH_THRESHOLD ? " substrate" : " work";
|
|
144
|
+
}
|
|
145
|
+
return `${glyph} ${label}${crossCwd}${agentSeg}${dur}${liveness}${tok}${ctx}${substrate}${cost}`;
|
|
118
146
|
}
|
|
119
147
|
|
|
120
148
|
/** Above-editor widget: one line per active run, cap 5, overflow → "+N more in /fleet".
|
|
@@ -16,6 +16,10 @@ export interface AgentDef {
|
|
|
16
16
|
todoSync: boolean;
|
|
17
17
|
memoryHydrate: boolean;
|
|
18
18
|
vision: boolean;
|
|
19
|
+
/** #20/SPEC-6-5: opt in to the global cross-project user memory scope (`/__armory-fleet-user__`).
|
|
20
|
+
* Default false — the user scope is a cross-project bleed by construction; hydrate it only when
|
|
21
|
+
* an agent explicitly declares `userMemory: true`. Only meaningful when `memoryHydrate: true`. */
|
|
22
|
+
userMemory: boolean;
|
|
19
23
|
/** Cross-harness backend routing (SPEC-3). Invalid value → FrontmatterError. */
|
|
20
24
|
backend: "pi" | "claude";
|
|
21
25
|
/** Stable id for backend-native resume (SPEC-3). Defaults to name. */
|
|
@@ -57,6 +61,7 @@ export function parseAgentFile(content: string, filePath: string, source: AgentS
|
|
|
57
61
|
const todoSync = raw.todoSync === undefined ? true : Boolean(raw.todoSync);
|
|
58
62
|
const memoryHydrate = raw.memoryHydrate === undefined ? true : Boolean(raw.memoryHydrate);
|
|
59
63
|
const vision = raw.vision === undefined ? true : Boolean(raw.vision);
|
|
64
|
+
const userMemory = raw.userMemory === undefined ? false : Boolean(raw.userMemory);
|
|
60
65
|
|
|
61
66
|
const rawBackend = typeof raw.backend === "string" ? raw.backend.trim() : "pi";
|
|
62
67
|
if (rawBackend !== "pi" && rawBackend !== "claude") {
|
|
@@ -77,6 +82,7 @@ export function parseAgentFile(content: string, filePath: string, source: AgentS
|
|
|
77
82
|
todoSync,
|
|
78
83
|
memoryHydrate,
|
|
79
84
|
vision,
|
|
85
|
+
userMemory,
|
|
80
86
|
backend,
|
|
81
87
|
sessionKey,
|
|
82
88
|
source,
|
package/src/runtime/run-log.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface RunMetaEvent {
|
|
|
15
15
|
pid?: number;
|
|
16
16
|
/** SPEC-6-2: the cwd this run belongs to. */
|
|
17
17
|
cwd?: string;
|
|
18
|
+
/** SPEC-6-5: the session cwd the dispatch originated from (= parentCwd). */
|
|
19
|
+
sessionCwd?: string;
|
|
18
20
|
}
|
|
19
21
|
export interface MessageEvent {
|
|
20
22
|
type: "message"; role: string; text: string;
|
|
@@ -31,6 +33,12 @@ export interface RunEndedEvent {
|
|
|
31
33
|
costTotal?: number;
|
|
32
34
|
/** SPEC-6-1: latest context-token snapshot at run end. */
|
|
33
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[];
|
|
34
42
|
}
|
|
35
43
|
export type RunLogEvent = RunMetaEvent | MessageEvent | ToolEvent | RunEndedEvent;
|
|
36
44
|
|
|
@@ -49,6 +57,8 @@ export interface RunMeta {
|
|
|
49
57
|
pid?: number;
|
|
50
58
|
/** SPEC-6-2: the cwd this run belongs to. */
|
|
51
59
|
cwd?: string;
|
|
60
|
+
/** SPEC-6-5: the session cwd the dispatch originated from (= parentCwd). */
|
|
61
|
+
sessionCwd?: string;
|
|
52
62
|
}
|
|
53
63
|
|
|
54
64
|
const ARGS_LIMIT = 200;
|
|
@@ -104,7 +114,7 @@ export class RunLog {
|
|
|
104
114
|
if (!meta) {
|
|
105
115
|
meta = { runId: e.runId, agent: e.agent, model: e.model, task: e.task, startedAt: e.startedAt,
|
|
106
116
|
track: e.track, todoId: e.todoId, backendSessionId: e.backendSessionId, sessionKey: e.sessionKey,
|
|
107
|
-
status: "running", tokenTotal: 0, pid: e.pid, cwd: e.cwd };
|
|
117
|
+
status: "running", tokenTotal: 0, pid: e.pid, cwd: e.cwd, sessionCwd: e.sessionCwd };
|
|
108
118
|
} else {
|
|
109
119
|
// latest binding wins
|
|
110
120
|
if (e.backendSessionId) meta.backendSessionId = e.backendSessionId;
|
package/src/tools/subagent.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// src/tools/subagent.ts
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { statSync } from "node:fs";
|
|
2
4
|
import { Type, type Static } from "typebox";
|
|
3
5
|
import type { AgentDef } from "../registry/frontmatter.ts";
|
|
4
6
|
import type { TodoSyncPort } from "../todo-sync/port.ts";
|
|
@@ -32,6 +34,7 @@ export const subagentParams = Type.Object({
|
|
|
32
34
|
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
35
|
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
36
|
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.' })),
|
|
37
|
+
cwd: Type.Optional(Type.String({ description: 'The dispatch target\'s working directory. Default: the session cwd (backward-compat). Scoped to this path: the child\'s working dir, context-file cascade, skill discovery, and memory scopes. Accepts paths OUTSIDE the session cwd (a sibling repo) — that\'s the #20 fix. Relative paths resolve against the session cwd.' })),
|
|
35
38
|
});
|
|
36
39
|
|
|
37
40
|
export type SubagentInput = Static<typeof subagentParams>;
|
|
@@ -44,6 +47,19 @@ export function mergeLifecycleSkills(phaseSkills: string[] | undefined, callerSk
|
|
|
44
47
|
return [...new Set([...(phaseSkills ?? []), ...(callerSkills ?? [])])];
|
|
45
48
|
}
|
|
46
49
|
|
|
50
|
+
/** SPEC-6-5: validate + resolve a dispatch cwd. Returns { cwd } on success or { error } on failure. */
|
|
51
|
+
export function resolveDispatchCwd(raw: string | undefined, parentCwd: string): { cwd?: string; error?: string } {
|
|
52
|
+
if (raw === undefined || raw === "") return { cwd: undefined }; // default → parentCwd (handled by spawnSubagent)
|
|
53
|
+
const abs = resolve(parentCwd, raw);
|
|
54
|
+
try {
|
|
55
|
+
const st = statSync(abs);
|
|
56
|
+
if (!st.isDirectory()) return { error: `cwd is not a directory: ${abs}` };
|
|
57
|
+
return { cwd: abs };
|
|
58
|
+
} catch {
|
|
59
|
+
return { error: `cwd does not exist: ${abs}` };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
47
63
|
export interface SubagentToolDeps {
|
|
48
64
|
registry: Map<string, AgentDef>;
|
|
49
65
|
runRegistry: RunRegistry;
|
|
@@ -80,6 +96,8 @@ export interface SubagentToolDeps {
|
|
|
80
96
|
* Per-dispatch `modelFallback` (when passed) takes precedence. Applies to the direct foreground
|
|
81
97
|
* path, the foreground lifecycle spawn, and the background/scheduled spawn. */
|
|
82
98
|
defaultModelFallback?: string;
|
|
99
|
+
/** SPEC-6-5: notify hook for cross-cwd dispatch surfacing. Wired from ctx.ui.notify in index.ts. */
|
|
100
|
+
onNotify?: (message: string, kind?: "info" | "warning" | "error") => void;
|
|
83
101
|
}
|
|
84
102
|
|
|
85
103
|
/** Build the pi.registerTool definition. Thin wrapper over spawnSubagent. */
|
|
@@ -101,6 +119,12 @@ export function createSubagentTool(deps: SubagentToolDeps) {
|
|
|
101
119
|
],
|
|
102
120
|
parameters: subagentParams,
|
|
103
121
|
async execute(_toolCallId: string, params: SubagentInput, signal: AbortSignal, _onUpdate: unknown, _ctx: any) {
|
|
122
|
+
// SPEC-6-5: validate + resolve the dispatch cwd before any routing.
|
|
123
|
+
const { cwd: resolvedCwd, error: cwdErr } = resolveDispatchCwd(params.cwd, deps.parentCwd);
|
|
124
|
+
if (cwdErr) return { isError: true, content: [{ type: "text" as const, text: cwdErr }] };
|
|
125
|
+
if (resolvedCwd && resolvedCwd !== deps.parentCwd) {
|
|
126
|
+
deps.onNotify?.(`scoped to ${resolvedCwd} (≠ session ${deps.parentCwd})`, "info");
|
|
127
|
+
}
|
|
104
128
|
// SPEC-5a: background + schedule routing (Q1/Q2/Q5).
|
|
105
129
|
if (params.background && params.schedule) {
|
|
106
130
|
return { isError: true, content: [{ type: "text" as const, text: "A scheduled run is inherently background — pass only one of `background` or `schedule`, not both." }] };
|
|
@@ -132,10 +156,12 @@ export function createSubagentTool(deps: SubagentToolDeps) {
|
|
|
132
156
|
maxTurns: params.maxTurns,
|
|
133
157
|
tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
|
|
134
158
|
readOnly: params.readOnly,
|
|
159
|
+
cwd: o.cwd,
|
|
135
160
|
}), params.modelFallback ?? deps.defaultModelFallback, signal),
|
|
136
161
|
};
|
|
137
162
|
const res = await runLifecycle(params.task, params.lifecycle, {
|
|
138
163
|
deps: lifecycleFullDeps, mode: "auto",
|
|
164
|
+
entryCwd: resolvedCwd,
|
|
139
165
|
onCheckpoint: async (phase) => phase.status === "failed" ? { action: "abort" } : { action: "continue" },
|
|
140
166
|
});
|
|
141
167
|
const isError = res.status === "failed" || res.status === "aborted";
|
|
@@ -166,6 +192,7 @@ export function createSubagentTool(deps: SubagentToolDeps) {
|
|
|
166
192
|
signal,
|
|
167
193
|
maxTurns: params.maxTurns,
|
|
168
194
|
tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
|
|
195
|
+
cwd: resolvedCwd,
|
|
169
196
|
});
|
|
170
197
|
// #39: auto-retry on a retryable provider rate-limit / auth failure (stopReason "error").
|
|
171
198
|
// The primary run reverted its linked todo to open (finishRun -> markRunTodoReverted), so the
|
|
@@ -200,17 +227,47 @@ export function createSubagentTool(deps: SubagentToolDeps) {
|
|
|
200
227
|
signal,
|
|
201
228
|
maxTurns: params.maxTurns,
|
|
202
229
|
tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
|
|
230
|
+
cwd: resolvedCwd,
|
|
203
231
|
});
|
|
204
232
|
retriedWithModel = fallback;
|
|
205
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
|
+
}
|
|
206
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;
|
|
207
263
|
return {
|
|
208
|
-
content: [{ type: "text" as const, text:
|
|
264
|
+
content: [{ type: "text" as const, text: resultText }],
|
|
209
265
|
details: {
|
|
210
266
|
runId: finalRes.runId, todoId: finalRes.todoId, agent: finalRes.agent, model: finalRes.model,
|
|
211
267
|
status: finalRes.status, durationMs: finalRes.durationMs, tokenTotal: finalRes.tokenTotal,
|
|
212
268
|
retriedWithModel,
|
|
213
269
|
filesTouched: finalRes.filesTouched, reachedSummary: finalRes.reachedSummary,
|
|
270
|
+
toolCallCount: finalRes.toolCallCount, // #61: zero = the premature-return signal
|
|
214
271
|
},
|
|
215
272
|
isError,
|
|
216
273
|
};
|