@getpipher/armory-fleet 0.12.1 → 0.12.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpipher/armory-fleet",
3
- "version": "0.12.1",
3
+ "version": "0.12.3",
4
4
  "private": false,
5
5
  "description": "The armory suite's subagent orchestrator for the pi coding agent — a cross-harness, superpowers-native fleet where every agent is armory-native from birth.",
6
6
  "license": "MIT",
@@ -7,6 +7,30 @@ import { dirname } from "node:path";
7
7
  import type { AgentDef } from "../registry/frontmatter.ts";
8
8
  import type { MemoryHydratePort } from "../memory-hydrate/port.ts";
9
9
 
10
+ /** Shape of the pi resource-loader's current-skills context (the `cur` arg of skillsOverride).
11
+ * Generic over the skill + diagnostics types so the pi SDK's full `Skill`/`ResourceDiagnostic`
12
+ * types flow through unchanged. */
13
+ export interface InstalledSkills<S = { name: string }, D = unknown> {
14
+ skills: S[];
15
+ diagnostics: D;
16
+ }
17
+
18
+ /** #32: resolve the child's skill bundle from the agent's declared skills + the installed arsenal.
19
+ *
20
+ * - Agent declares specific skills → load only those (filtered from the installed arsenal).
21
+ * - Agent declares NO skills → load NO skills (lean substrate). This is the #32 fix: previously
22
+ * an agent with no `skills` field loaded ALL installed skills (~42 → ~570K substrate on every
23
+ * `general-purpose` dispatch, ~59% of a 976K context window from turn 1). Callers opt in to
24
+ * skills via the `subagent` tool's `skills` param (threaded as `skillsOverride`, which clones
25
+ * `agent.skills` before this runs).
26
+ * - Returning `[]` (not `cur.skills`) is the deliberate behavior change. */
27
+ export function resolveChildSkills<S extends { name: string }, D>(agent: Pick<AgentDef, "skills">, cur: InstalledSkills<S, D>): InstalledSkills<S, D> {
28
+ const selected = agent.skills && agent.skills.length
29
+ ? cur.skills.filter((s) => agent.skills!.includes(s.name))
30
+ : [];
31
+ return { skills: selected, diagnostics: cur.diagnostics };
32
+ }
33
+
10
34
  /** Fixed pseudo-cwd for the global cross-project user memory scope. */
11
35
  export const USER_PSEUDO_CWD = "/__armory-fleet-user__";
12
36
 
@@ -36,12 +60,6 @@ export function buildChildLoader(opts: ChildLoaderOpts): DefaultResourceLoader {
36
60
  noExtensions: true,
37
61
  systemPromptOverride: (base) =>
38
62
  composeChildPrompt({ rolePrompt: opts.agent.rolePrompt, memoryBlock, base: base ?? "" }),
39
- skillsOverride: (cur) => ({
40
- skills:
41
- opts.agent.skills && opts.agent.skills.length
42
- ? cur.skills.filter((s) => opts.agent.skills!.includes(s.name))
43
- : cur.skills,
44
- diagnostics: cur.diagnostics,
45
- }),
63
+ skillsOverride: (cur) => resolveChildSkills(opts.agent, cur),
46
64
  });
47
- }
65
+ }
@@ -40,6 +40,14 @@ export interface RunRecord {
40
40
  * Transient, in-memory only — never written to RunLog (the journal append constructs
41
41
  * a plain object, not RunRecord). */
42
42
  session?: LiveSessionHandle;
43
+ /** #23: liveness — current turn count (1-indexed; live, updated on turn_start). */
44
+ turnCount?: number;
45
+ /** #23: the run's max turn budget (set at spawn; for the widget's `turn N/max`). */
46
+ turnMax?: number;
47
+ /** #23: the last event class seen (e.g. "tool:edit", "assistant", "turn") — liveness only, no content. */
48
+ lastEventClass?: string;
49
+ /** #23: timestamp (ms) of the last event — liveness heartbeat ("are events still arriving?"). */
50
+ lastEventAt?: number;
43
51
  }
44
52
 
45
53
  /** runId format: fl-<base36 ms>-<6 random> (SPEC-1 §5.1). */
@@ -76,4 +84,4 @@ export class RunRegistry {
76
84
  private emit(): void {
77
85
  for (const fn of this.listeners) fn();
78
86
  }
79
- }
87
+ }
@@ -142,6 +142,11 @@ export interface SpawnOptions {
142
142
  modelRegistry?: ModelRegistryLike;
143
143
  /** SPEC-6-3: workflow adapter tier override — replaces agent.tier before model resolution. */
144
144
  tierOverride?: string;
145
+ /** #31: when true, the caller asserts this dispatch will NOT mutate the working directory
146
+ * (review/audit/research). Read-only dispatches bypass the foreground single-slot lock so
147
+ * multiple readOnly dispatches — and/or a readOnly dispatch alongside a write dispatch — can
148
+ * run in parallel. Mislabeling a write dispatch as readOnly risks in-place edit conflicts. */
149
+ readOnly?: boolean;
145
150
  }
146
151
 
147
152
  export interface SpawnResult {
@@ -158,6 +163,11 @@ export interface SpawnResult {
158
163
  /** SPEC-6-1: final context tokens (calcContextTokens of the last usage). */
159
164
  contextTokens?: number;
160
165
  error?: string;
166
+ /** #39: true when the failure is a retryable provider rate-limit / auth failure
167
+ * (stopReason "error"). The subagent tool retries once with the configured `modelFallback`
168
+ * when this is set. Non-retryable failures (turn budget, agent-not-found, EMPTY_RESULT,
169
+ * abort, lock busy) leave this unset. */
170
+ retryable?: boolean;
161
171
  }
162
172
 
163
173
  export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
@@ -165,15 +175,25 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
165
175
  const maxTurns = opts.maxTurns ?? DEFAULT_MAX_TURNS;
166
176
  const startedAt = Date.now();
167
177
 
168
- // concurrency=1 (SPEC-1 §9.2) peek before generating a runId so a rejected
169
- // call doesn't mint a discarded id; the held id is named in the message.
170
- const busyId = opts.lock.current();
171
- if (busyId !== null) {
172
- return fail("", startedAt, `a subagent is already running (concurrency=1 in v0.1); wait for ${busyId} to finish or abort it first`, opts.agent);
173
- }
174
- const runId = genRunId();
175
- if (!opts.lock.tryAcquire(runId)) {
176
- return fail("", startedAt, "concurrency lock unexpectedly unavailable", opts.agent);
178
+ // #31: read-only dispatches (review/audit/research) bypass the foreground single-slot lock
179
+ // the caller asserts no cwd mutation, so the in-place edit-conflict guard doesn't apply and
180
+ // multiple readOnly dispatches (and/or a readOnly alongside a write dispatch) may run in
181
+ // parallel. The lock is still acquired for write dispatches (default), preserving concurrency=1.
182
+ const readOnly = opts.readOnly ?? false;
183
+ let runId: string;
184
+ if (readOnly) {
185
+ runId = genRunId();
186
+ } else {
187
+ // concurrency=1 (SPEC-1 §9.2) — peek before generating a runId so a rejected
188
+ // call doesn't mint a discarded id; the held id is named in the message.
189
+ const busyId = opts.lock.current();
190
+ if (busyId !== null) {
191
+ return fail("", startedAt, `a subagent is already running (concurrency=1 in v0.1); wait for ${busyId} to finish or abort it first`, opts.agent);
192
+ }
193
+ runId = genRunId();
194
+ if (!opts.lock.tryAcquire(runId)) {
195
+ return fail("", startedAt, "concurrency lock unexpectedly unavailable", opts.agent);
196
+ }
177
197
  }
178
198
 
179
199
  try {
@@ -274,7 +294,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
274
294
  let aborted = false;
275
295
  const handle = toLiveHandle(session);
276
296
  handle.abort = async () => { aborted = true; await session.abort(); };
277
- opts.runRegistry.update(runId, { session: handle });
297
+ opts.runRegistry.update(runId, { session: handle, turnMax: maxTurns });
278
298
 
279
299
  const budget = createTurnBudget(maxTurns);
280
300
  let finalText = "";
@@ -287,6 +307,18 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
287
307
  let modelError: string | undefined; // model-call failure surfaced via stopReason "error"
288
308
  let sawAssistantMessage = false; // #22: did the child emit any assistant message_end at all?
289
309
 
310
+ // #23: liveness — classify events into a short, content-free class string for the widget.
311
+ // Names the tool (safe — tool name is not args/result) so the operator sees "what's happening"
312
+ // without leaking prompt content, secrets, or full tool arguments/results (per #23 acceptance).
313
+ const classifyEvent = (e: ChildSessionEvent): string => {
314
+ if (e.type === "turn_start") return "turn";
315
+ if (e.type === "turn_end") return "turn_end";
316
+ if (e.type === "message_end") return e.message?.role === "assistant" ? "assistant" : (e.message?.role ?? "message");
317
+ if (e.type === "tool_execution_end") return `tool:${(e as { toolName?: string }).toolName ?? "?"}`;
318
+ if (e.type === "session_init") return "init";
319
+ return e.type;
320
+ };
321
+
290
322
  const onSignalAbort = (): void => { aborted = true; void session.abort(); };
291
323
  opts.signal?.addEventListener("abort", onSignalAbort);
292
324
 
@@ -337,6 +369,15 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
337
369
  opts.runLog?.append(runId, buildToolEvent((e as any).toolName, (e as any).args, (e as any).result, (e as any).isError ?? false, turnIdx));
338
370
  } catch { /* best-effort */ }
339
371
  }
372
+ // #23: liveness heartbeat — update the run record on meaningful events so the fleet widget
373
+ // can show turn count + last-event class + "events still arriving" without leaking content.
374
+ if (e.type === "turn_start" || e.type === "message_end" || e.type === "tool_execution_end") {
375
+ opts.runRegistry.update(runId, {
376
+ turnCount: Math.max(0, turnIdx + 1),
377
+ lastEventClass: classifyEvent(e),
378
+ lastEventAt: Date.now(),
379
+ });
380
+ }
340
381
  opts.onEvent?.(e);
341
382
  });
342
383
 
@@ -355,7 +396,13 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
355
396
  let error: string | undefined;
356
397
  if (aborted) {
357
398
  status = "aborted";
358
- error = tier?.costCap && costTotal > tier.costCap ? `budget_exceeded (cost $${costTotal.toFixed(4)} > cap $${tier.costCap})` : "aborted by user";
399
+ // #23: distinguish TODO-status reversion from filesystem rollback. A foreground run is in-place
400
+ // (no worktree isolation — that's a background-run concern), so an abort reverts the linked TODO
401
+ // to open (retryable) but leaves any partial file edits in the working dir for inspection.
402
+ const rollbackNote = " — TODO reverted to open (retryable); in-place file changes NOT rolled back (inspect the working dir for partial work)";
403
+ error = tier?.costCap && costTotal > tier.costCap
404
+ ? `budget_exceeded (cost $${costTotal.toFixed(4)} > cap $${tier.costCap})${rollbackNote}`
405
+ : `aborted by user${rollbackNote}`;
359
406
  } else if (budget.count() >= maxTurns) {
360
407
  // #25: surface a coherent partial, not a mid-sentence 200-char cut. The controller reads
361
408
  // `res.error` (the tool surfaces error, not finalText, for failed runs), so the partial must
@@ -399,9 +446,11 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
399
446
  status = "completed";
400
447
  }
401
448
 
402
- return await finishRun(opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentDef.name, model, tokenTotal, costTotal, contextTokens);
449
+ return await finishRun(opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentDef.name, model, tokenTotal, costTotal, contextTokens, modelError ? true : undefined);
403
450
  } finally {
404
- opts.lock.release();
451
+ // #31: a readOnly dispatch never acquired the lock — don't release what it didn't take
452
+ // (releasing a lock held by another concurrent write dispatch would corrupt serialization).
453
+ if (!readOnly) opts.lock.release();
405
454
  }
406
455
  }
407
456
 
@@ -419,6 +468,7 @@ async function finishRun(
419
468
  opts: SpawnOptions, runId: string, startedAt: number,
420
469
  status: FleetRunStatus, finalText: string, todoId: string | null, priorStatus: string | undefined,
421
470
  error: string | undefined, agentName: string, model: string, tokenTotal = 0, costTotal = 0, contextTokens = 0,
471
+ retryable?: boolean,
422
472
  ): Promise<SpawnResult> {
423
473
  if (finalizedRunIds.has(runId)) {
424
474
  // Already finalized — return the existing registry record's result without re-appending.
@@ -427,7 +477,7 @@ async function finishRun(
427
477
  status: existing?.status ?? status, finalText: existing?.resultSummary ?? finalText,
428
478
  runId, todoId, agent: agentName, model,
429
479
  durationMs: existing?.endedAt ? existing.endedAt - startedAt : Date.now() - startedAt,
430
- tokenTotal, costTotal, contextTokens, error,
480
+ tokenTotal, costTotal, contextTokens, error, retryable,
431
481
  };
432
482
  }
433
483
  finalizedRunIds.add(runId);
@@ -461,6 +511,6 @@ async function finishRun(
461
511
  }
462
512
  return {
463
513
  status, finalText, runId, todoId, agent: agentName, model,
464
- durationMs: endedAt - startedAt, tokenTotal, costTotal, contextTokens, error,
514
+ durationMs: endedAt - startedAt, tokenTotal, costTotal, contextTokens, error, retryable,
465
515
  };
466
516
  }
@@ -11,6 +11,12 @@ import { fmtDuration, fmtTokens } from "./rows.ts";
11
11
  import type { RunRecord } from "../engine/run-registry.ts";
12
12
  import type { BgRunStatus } from "./rows.ts";
13
13
 
14
+ /** #23: liveness segments (turn count, last-event class, abort warning) appear only after a run
15
+ * has been active this long — keeps short foreground runs concise (per #23 acceptance criteria). */
16
+ export const LIVENESS_THRESHOLD_MS = 30_000;
17
+ /** #23: a run whose last event is older than this is flagged stale ("are events still arriving?"). */
18
+ export const STALE_THRESHOLD_MS = 60_000;
19
+
14
20
  export interface WidgetRun {
15
21
  runId: string;
16
22
  agent: string;
@@ -32,6 +38,14 @@ export interface WidgetRun {
32
38
  maxContext?: number;
33
39
  /** SPEC-6-1: cumulative $ (for the $ segment). */
34
40
  costTotal?: number;
41
+ /** #23: liveness — current turn count (1-indexed). */
42
+ turnCount?: number;
43
+ /** #23: liveness — max turn budget (for `turn N/max`). */
44
+ turnMax?: number;
45
+ /** #23: liveness — last event class (e.g. "tool:edit", "assistant", "turn"). */
46
+ lastEventClass?: string;
47
+ /** #23: liveness — timestamp (ms) of the last event ("events still arriving?"). */
48
+ lastEventAt?: number;
35
49
  }
36
50
 
37
51
  export function toWidgetRun(r: RunRecord): WidgetRun {
@@ -40,6 +54,7 @@ export function toWidgetRun(r: RunRecord): WidgetRun {
40
54
  startedAt: r.startedAt, endedAt: r.endedAt, tokenTotal: r.tokenTotal,
41
55
  kind: "fg",
42
56
  task: r.task, costTotal: r.costTotal, contextTokens: r.contextTokens,
57
+ turnCount: r.turnCount, turnMax: r.turnMax, lastEventClass: r.lastEventClass, lastEventAt: r.lastEventAt,
43
58
  };
44
59
  }
45
60
 
@@ -88,16 +103,36 @@ function widgetLine(r: WidgetRun, now: number): string {
88
103
  // fg: task excerpt as primary label (fallback to runId if no task)
89
104
  const label = r.task ? `"${r.task.slice(0, 40)}"` : r.runId;
90
105
  const agentSeg = r.agent && r.agent !== "general-purpose" ? ` · ${r.agent}` : "";
91
- return `${glyph} ${label}${agentSeg}${dur}${tok}${ctx}${cost}`;
106
+ // #23: liveness — only after LIVENESS_THRESHOLD_MS, to keep short runs concise (per acceptance).
107
+ // turn N/max + last-event class (no prompt content, no args/results — only the tool name)
108
+ // + a stale indicator if no event has arrived for STALE_THRESHOLD_MS ("events still arriving?").
109
+ const elapsed = typeof r.startedAt === "number" ? now - r.startedAt : 0;
110
+ let liveness = "";
111
+ if (elapsed > LIVENESS_THRESHOLD_MS) {
112
+ const turn = (r.turnCount != null && r.turnMax != null) ? ` turn ${r.turnCount}/${r.turnMax}` : (r.turnCount != null ? ` turn ${r.turnCount}` : "");
113
+ const ev = r.lastEventClass ? ` ●${r.lastEventClass}` : "";
114
+ const stale = (r.lastEventAt != null && now - r.lastEventAt > STALE_THRESHOLD_MS) ? " ⏰stale" : "";
115
+ liveness = `${turn}${ev}${stale}`;
116
+ }
117
+ return `${glyph} ${label}${agentSeg}${dur}${liveness}${tok}${ctx}${cost}`;
92
118
  }
93
119
 
94
- /** Above-editor widget: one line per active run, cap 5, overflow → "+N more in /fleet". */
120
+ /** Above-editor widget: one line per active run, cap 5, overflow → "+N more in /fleet".
121
+ * #23: when an active foreground run has been running longer than LIVENESS_THRESHOLD_MS, append an
122
+ * explicit abort-warning footer naming its runId (so the controller can distinguish active work
123
+ * from a hang without cancelling, and knows submitting a message will abort it). */
95
124
  export function renderWidgetLines(runs: WidgetRun[], now: number = Date.now()): string[] {
96
125
  const active = filterActive(runs);
97
126
  const cap = 5;
98
- if (active.length <= cap) return active.map((r) => widgetLine(r, now));
99
- const shown = active.slice(0, cap).map((r) => widgetLine(r, now));
100
- shown.push(`+${active.length - cap} more in /fleet`);
101
- return shown;
127
+ const lines = active.length <= cap
128
+ ? active.map((r) => widgetLine(r, now))
129
+ : [...active.slice(0, cap).map((r) => widgetLine(r, now)), `+${active.length - cap} more in /fleet`];
130
+ // #23: abort-warning footer — only when a RUNNING foreground run is active long enough that
131
+ // a controller might worry it's hung. Paused/queued fg runs aren't aborted by a new message.
132
+ const longFg = active.find((r) => r.kind === "fg" && r.status === "running" && typeof r.startedAt === "number" && now - r.startedAt > LIVENESS_THRESHOLD_MS);
133
+ if (longFg) {
134
+ lines.push(`⚠ submitting a message aborts the foreground run · ${longFg.runId} · /fleet to inspect`);
135
+ }
136
+ return lines;
102
137
  }
103
138
 
@@ -29,10 +29,21 @@ export const subagentParams = Type.Object({
29
29
  ], { description: "Edit isolation for background runs. 'worktree' = git worktree (requires a git repo; fails sync if not). 'none' = in-place in cwd (no isolation; parallel edits may conflict). 'auto' (default) = worktree when cwd is a git repo, in-place otherwise." })),
30
30
  schedule: Type.Optional(Type.String({ description: 'Schedule the run instead of firing now: a cron string ("0 9 * * 1-5"), an interval ("30m"/"2h"), or a one-shot ISO datetime ("2026-07-25T14:00"). Returns { scheduleId, nextFire }. Session-scoped (fires only while pi is open); no catch-up.' })),
31
31
  maxTurns: Type.Optional(Type.Number({ description: 'Per-run turn budget (default 20). Raise for complex multi-step tasks (e.g. 40) so the subagent doesn\'t hit the budget mid-task; lower for trivial lookups.' })),
32
+ readOnly: Type.Optional(Type.Boolean({ description: 'Default false. Pass true ONLY for dispatches that will NOT mutate the working directory (review/audit, or research that writes no scratch files). A readOnly dispatch bypasses the foreground single-slot lock so multiple readOnly dispatches — and/or a readOnly alongside a write dispatch — can run in parallel. The caller is responsible for the assertion: mislabeling a dispatch that edits as readOnly risks in-place edit conflicts. Has no effect on background/scheduled runs (they use their own locks).' })),
33
+ skills: Type.Optional(Type.Array(Type.String(), { description: 'Skills to load for this dispatch (opt-in). By default a dispatch loads NO skills (#32 — lean substrate; previously an agent with no skills field loaded ALL ~42 installed skills, ~570K tokens / ~59% of context). Pass skill names from the installed arsenal (e.g. ["executing-plans", "test-driven-development"]) to opt in. For a direct dispatch, this replaces the agent\'s frontmatter skills (pass [] to load zero). For a lifecycle dispatch, this is ADDITIVE — the phase\'s designed skill bundle always loads and these are merged on top (a caller cannot strip a phase\'s required skills).' })),
34
+ modelFallback: Type.Optional(Type.String({ description: 'Model to retry with if the primary dispatch fails with a retryable provider rate-limit / auth failure (stopReason "error"). The fleet retries ONCE on this model and relinks the same tracked todo. Surface the model that served the retry in the result details (retriedWithModel). Per the AGENTS.md "Ollama primary + OpenRouter fallback" pattern. No effect on non-retryable failures (turn budget, agent-not-found, abort). Direct foreground dispatches only — background/scheduled/lifecycle retries are a follow-up.' })),
32
35
  });
33
36
 
34
37
  export type SubagentInput = Static<typeof subagentParams>;
35
38
 
39
+ /** #32: merge a lifecycle phase's skill bundle with the caller's `skills` param.
40
+ * Additive + deduped — the phase's designed skills always load; the caller can add extras but
41
+ * cannot strip phase skills (avoids the footgun where a caller passing `skills: ["tdd"]` with
42
+ * `lifecycle: "default"` would silently drop `brainstorming` from the brainstorm phase). */
43
+ export function mergeLifecycleSkills(phaseSkills: string[] | undefined, callerSkills: string[] | undefined): string[] {
44
+ return [...new Set([...(phaseSkills ?? []), ...(callerSkills ?? [])])];
45
+ }
46
+
36
47
  export interface SubagentToolDeps {
37
48
  registry: Map<string, AgentDef>;
38
49
  runRegistry: RunRegistry;
@@ -76,6 +87,9 @@ export function createSubagentTool(deps: SubagentToolDeps) {
76
87
  "Use subagent to delegate an isolated, well-scoped task to a named agent; it runs in the foreground and returns the result + a runId.",
77
88
  "Pass todoId to link the run to an existing open todo you see in the Open TODOs block; otherwise fleet creates a tracked fleet task.",
78
89
  "Pass track:false only for trivial throwaway lookups that don't represent real work.",
90
+ "Pass readOnly:true for dispatches that will NOT edit the working directory (review/audit, or research that writes no scratch files). It bypasses the foreground single-slot lock so multiple readOnly dispatches can run in parallel. Only use it when you are certain the child won't mutate cwd — mislabeling risks edit conflicts.",
91
+ "By default a dispatch loads NO skills (lean substrate). If the task needs a skill (e.g. test-driven-development for a TDD task, executing-plans for a plan-execution task), pass its name in the `skills` array to opt in — loading all skills by default wastes ~59% of the context window.",
92
+ "Pass `modelFallback` so a transient provider rate-limit / auth failure (stopReason 'error') auto-retries once on the fallback model instead of failing the dispatch. Per the AGENTS.md 'Ollama primary + OpenRouter fallback' pattern — don't let infra limits break a dispatch chain.",
79
93
  ],
80
94
  parameters: subagentParams,
81
95
  async execute(_toolCallId: string, params: SubagentInput, signal: AbortSignal, _onUpdate: unknown, _ctx: any) {
@@ -101,11 +115,12 @@ export function createSubagentTool(deps: SubagentToolDeps) {
101
115
  ...deps.lifecycleDeps,
102
116
  spawn: async (o) => spawnSubagent({
103
117
  agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
104
- skillsOverride: o.skills, backendOverride: o.backend,
118
+ skillsOverride: mergeLifecycleSkills(o.skills, params.skills), backendOverride: o.backend,
105
119
  registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
106
120
  backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: deps.parentCwd, runLog: deps.runLog, signal,
107
121
  maxTurns: params.maxTurns,
108
122
  tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
123
+ readOnly: params.readOnly,
109
124
  }),
110
125
  };
111
126
  const res = await runLifecycle(params.task, params.lifecycle, {
@@ -127,6 +142,8 @@ export function createSubagentTool(deps: SubagentToolDeps) {
127
142
  todoId: params.todoId,
128
143
  track: params.track,
129
144
  model: params.model,
145
+ readOnly: params.readOnly,
146
+ skillsOverride: params.skills,
130
147
  registry: deps.registry,
131
148
  todoSync: deps.todoSync,
132
149
  runRegistry: deps.runRegistry,
@@ -139,15 +156,49 @@ export function createSubagentTool(deps: SubagentToolDeps) {
139
156
  maxTurns: params.maxTurns,
140
157
  tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
141
158
  });
142
- const isError = res.status === "failed" || res.status === "aborted";
159
+ // #39: auto-retry on a retryable provider rate-limit / auth failure (stopReason "error").
160
+ // The primary run reverted its linked todo to open (finishRun -> markRunTodoReverted), so the
161
+ // retry relinks the SAME todoId to continue the tracked task. Retry ONCE, only on the direct
162
+ // foreground path, only if a distinct fallback model was provided. The retry's runId differs
163
+ // from the primary's (each spawnSubagent call mints its own); details.retriedWithModel marks it.
164
+ let retriedWithModel: string | undefined;
165
+ let finalRes = res;
166
+ if (
167
+ res.status === "failed" && res.retryable && params.modelFallback &&
168
+ params.modelFallback !== res.model && !signal.aborted
169
+ ) {
170
+ finalRes = await spawnSubagent({
171
+ agent: params.agent,
172
+ task: params.task,
173
+ todoId: res.todoId ?? undefined,
174
+ track: params.track,
175
+ model: params.modelFallback,
176
+ readOnly: params.readOnly,
177
+ skillsOverride: params.skills,
178
+ registry: deps.registry,
179
+ todoSync: deps.todoSync,
180
+ runRegistry: deps.runRegistry,
181
+ lock: deps.lock,
182
+ backendRegistry: deps.backendRegistry,
183
+ parentModel: deps.parentModel,
184
+ parentCwd: deps.parentCwd,
185
+ runLog: deps.runLog,
186
+ signal,
187
+ maxTurns: params.maxTurns,
188
+ tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
189
+ });
190
+ retriedWithModel = params.modelFallback;
191
+ }
192
+ const isError = finalRes.status === "failed" || finalRes.status === "aborted";
143
193
  return {
144
- content: [{ type: "text" as const, text: isError ? (res.error ?? res.status) : res.finalText }],
194
+ content: [{ type: "text" as const, text: isError ? (finalRes.error ?? finalRes.status) : finalRes.finalText }],
145
195
  details: {
146
- runId: res.runId, todoId: res.todoId, agent: res.agent, model: res.model,
147
- status: res.status, durationMs: res.durationMs, tokenTotal: res.tokenTotal,
196
+ runId: finalRes.runId, todoId: finalRes.todoId, agent: finalRes.agent, model: finalRes.model,
197
+ status: finalRes.status, durationMs: finalRes.durationMs, tokenTotal: finalRes.tokenTotal,
198
+ retriedWithModel,
148
199
  },
149
200
  isError,
150
201
  };
151
202
  },
152
203
  };
153
- }
204
+ }