@getpipher/armory-fleet 0.12.4 → 0.13.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 +8 -0
- package/package.json +1 -1
- package/src/backend/claude-factory.ts +1 -1
- package/src/engine/child-loader.ts +31 -5
- package/src/engine/concurrency-lock.ts +72 -20
- package/src/engine/retry-fallback.ts +26 -0
- package/src/engine/run-registry.ts +9 -0
- package/src/engine/spawnSubagent.ts +98 -17
- package/src/index.ts +30 -6
- 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 +5 -1
- package/src/tools/subagent.ts +51 -8
package/README.md
CHANGED
|
@@ -308,6 +308,14 @@ 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
|
+
|
|
311
319
|
## Roadmap
|
|
312
320
|
|
|
313
321
|
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.13.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",
|
|
@@ -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
|
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// noExtensions (deterministic child, no host-extension leakage), composed
|
|
4
4
|
// systemPromptOverride (rolePrompt + memoryBlock + base), scoped skills.
|
|
5
5
|
import { DefaultResourceLoader, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { dirname } from "node:path";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { existsSync as fsExistsSync } from "node:fs";
|
|
7
9
|
import type { AgentDef } from "../registry/frontmatter.ts";
|
|
8
10
|
import type { MemoryHydratePort } from "../memory-hydrate/port.ts";
|
|
9
11
|
|
|
@@ -39,9 +41,30 @@ export function composeChildPrompt(args: { rolePrompt: string; memoryBlock: stri
|
|
|
39
41
|
return [args.rolePrompt, args.memoryBlock, args.base].filter((s) => s && s.trim().length > 0).join("\n\n");
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
/** Build the
|
|
43
|
-
|
|
44
|
-
|
|
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 } : {}) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** #40: resolve extra skill dirs to scan for the child, beyond the default `~/.pi/agent/skills`.
|
|
52
|
+
*
|
|
53
|
+
* The parent's pi package-manager scans `~/.agents/skills` (user) + `<cwd>/.agents/skills`
|
|
54
|
+
* (project) and feeds them to the loader via `extendResources` — that's how cross-harness skills
|
|
55
|
+
* like firecrawl (which live in `~/.agents/skills`, NOT `~/.pi/agent/skills`) reach the parent.
|
|
56
|
+
* The child's bare `DefaultResourceLoader` skips that, so a `skills: ["firecrawl-scrape"]` opt-in
|
|
57
|
+
* can't resolve. We restore the parent's skill surface by passing these dirs as `additionalSkillPaths`.
|
|
58
|
+
*
|
|
59
|
+
* Returns absolute paths only for dirs that exist (no hard dependency on the `~/.agents` layout —
|
|
60
|
+
* machines without it get []). Inject `homedir`/`cwd`/`existsSync` for testability. */
|
|
61
|
+
export function resolveAdditionalSkillPaths(opts: { homedir?: string; cwd: string; existsSync?: (p: string) => boolean }): string[] {
|
|
62
|
+
const home = opts.homedir ?? homedir();
|
|
63
|
+
const exists = opts.existsSync ?? ((p: string) => { try { return fsExistsSync(p); } catch { return false; } });
|
|
64
|
+
const candidates = [join(home, ".agents", "skills"), join(opts.cwd, ".agents", "skills")];
|
|
65
|
+
const out: string[] = [];
|
|
66
|
+
for (const p of candidates) if (!out.includes(p) && exists(p)) out.push(p);
|
|
67
|
+
return out;
|
|
45
68
|
}
|
|
46
69
|
|
|
47
70
|
export interface ChildLoaderOpts {
|
|
@@ -52,12 +75,15 @@ export interface ChildLoaderOpts {
|
|
|
52
75
|
|
|
53
76
|
/** Build the fleet CustomResourceLoader for a child session. */
|
|
54
77
|
export function buildChildLoader(opts: ChildLoaderOpts): DefaultResourceLoader {
|
|
55
|
-
const scopes = memoryScopesFor(opts.cwd);
|
|
78
|
+
const scopes = memoryScopesFor(opts.cwd, { includeUser: opts.agent.userMemory ?? false });
|
|
56
79
|
const memoryBlock = opts.agent.memoryHydrate ? opts.memoryPort.renderScopes(scopes) : "";
|
|
57
80
|
return new DefaultResourceLoader({
|
|
58
81
|
cwd: opts.cwd,
|
|
59
82
|
agentDir: getAgentDir(),
|
|
60
83
|
noExtensions: true,
|
|
84
|
+
// #40: scan the shared `~/.agents/skills` + `<cwd>/.agents/skills` dirs so cross-harness skills
|
|
85
|
+
// (firecrawl, etc.) are discoverable by a caller's `skills` opt-in, matching the parent surface.
|
|
86
|
+
additionalSkillPaths: resolveAdditionalSkillPaths({ cwd: opts.cwd }),
|
|
61
87
|
systemPromptOverride: (base) =>
|
|
62
88
|
composeChildPrompt({ rolePrompt: opts.agent.rolePrompt, memoryBlock, base: base ?? "" }),
|
|
63
89
|
skillsOverride: (cur) => resolveChildSkills(opts.agent, cur),
|
|
@@ -1,29 +1,81 @@
|
|
|
1
1
|
// src/engine/concurrency-lock.ts
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
// #31 tail: the foreground concurrency lock. A cap-based semaphore shared across all
|
|
3
|
+
// foreground subagent dispatches (serializes writes to prevent in-place edit conflicts).
|
|
4
|
+
//
|
|
5
|
+
// - cap=1 (default, backward-compat): FAIL-FAST. A 2nd dispatch while one is running is
|
|
6
|
+
// rejected with `{ ok: false, busy }` so the orchestrator gets an actionable error (the
|
|
7
|
+
// held runId is named) instead of blocking. This preserves the pre-#31 behavior exactly.
|
|
8
|
+
// - cap>1 (opt-in via ARMORY_FLEET_FOREGROUND_CONCURRENCY): QUEUE. Up to `cap` write
|
|
9
|
+
// dispatches run in parallel; the (cap+1)th WAITS for a slot (not rejected).
|
|
10
|
+
//
|
|
11
|
+
// #31 design note: concurrency is SESSION-LEVEL (env/settings), NOT a per-dispatch param.
|
|
12
|
+
// A shared lock serializes across dispatches, so a per-dispatch `concurrency` param can't
|
|
13
|
+
// re-size it per-call (two dispatches requesting different caps have no consistent global
|
|
14
|
+
// cap). The cap is fixed at init. The subagent tool's promptGuidelines surface this to the
|
|
15
|
+
// orchestrator so it understands the behavior + how to change it.
|
|
16
|
+
//
|
|
17
|
+
// readOnly dispatches bypass the lock entirely (#41) — they assert no cwd mutation.
|
|
18
|
+
|
|
19
|
+
export type AcquireResult =
|
|
20
|
+
| { ok: true }
|
|
21
|
+
| { ok: false; busy: string[] };
|
|
22
|
+
|
|
23
|
+
export interface ForegroundLock {
|
|
24
|
+
/** The configured cap (1 = fail-fast; >1 = queue). */
|
|
25
|
+
readonly cap: number;
|
|
26
|
+
/** Acquire a slot for `runId`. At cap=1: fail-fast if busy (returns the held runIds).
|
|
27
|
+
* At cap>1: waits (queues) if at cap, then acquires. */
|
|
28
|
+
acquire(runId: string): Promise<AcquireResult>;
|
|
29
|
+
/** Release a held slot. No-op if `runId` isn't held (guard against double-release). */
|
|
30
|
+
release(runId: string): void;
|
|
31
|
+
/** RunIds currently holding a slot (for diagnostics + error messages). */
|
|
32
|
+
holders(): string[];
|
|
7
33
|
}
|
|
8
34
|
|
|
9
|
-
export class
|
|
10
|
-
private
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
35
|
+
export class ForegroundLockImpl implements ForegroundLock {
|
|
36
|
+
private active: string[] = [];
|
|
37
|
+
private waiters: Array<() => void> = [];
|
|
38
|
+
constructor(readonly cap = 1) {}
|
|
39
|
+
|
|
40
|
+
acquire(runId: string): Promise<AcquireResult> {
|
|
41
|
+
if (this.active.length >= this.cap) {
|
|
42
|
+
if (this.cap === 1) {
|
|
43
|
+
// Backward-compat: fail-fast at cap=1. The orchestrator gets the held runId + the cap
|
|
44
|
+
// + the env hint, so it can wait/abort or raise the cap.
|
|
45
|
+
return Promise.resolve({ ok: false, busy: [...this.active] });
|
|
46
|
+
}
|
|
47
|
+
// cap>1: queue — resolve when a slot frees (FIFO).
|
|
48
|
+
return new Promise<AcquireResult>((resolve) => {
|
|
49
|
+
this.waiters.push(() => { this.active.push(runId); resolve({ ok: true }); });
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
this.active.push(runId);
|
|
53
|
+
return Promise.resolve({ ok: true });
|
|
18
54
|
}
|
|
19
|
-
|
|
20
|
-
|
|
55
|
+
|
|
56
|
+
release(runId: string): void {
|
|
57
|
+
const i = this.active.indexOf(runId);
|
|
58
|
+
if (i < 0) return; // no-op if not held (don't wake a waiter without freeing a slot)
|
|
59
|
+
this.active.splice(i, 1);
|
|
60
|
+
const next = this.waiters.shift();
|
|
61
|
+
if (next) next();
|
|
21
62
|
}
|
|
63
|
+
|
|
64
|
+
holders(): string[] { return [...this.active]; }
|
|
22
65
|
}
|
|
23
66
|
|
|
24
|
-
/**
|
|
25
|
-
export
|
|
67
|
+
/** Configurable foreground lock. cap from `ARMORY_FLEET_FOREGROUND_CONCURRENCY` (default 1). */
|
|
68
|
+
export function createForegroundLock(cap = 1): ForegroundLock {
|
|
69
|
+
return new ForegroundLockImpl(cap);
|
|
70
|
+
}
|
|
26
71
|
|
|
27
|
-
|
|
28
|
-
|
|
72
|
+
/** Backward-compat factory: a cap=1 fail-fast lock. Existing call sites pass this to
|
|
73
|
+
* spawnSubagent; it now returns a ForegroundLock(1) with the same cap=1 semantics. */
|
|
74
|
+
export function createSingleSlotLock(): ForegroundLock {
|
|
75
|
+
return new ForegroundLockImpl(1);
|
|
29
76
|
}
|
|
77
|
+
|
|
78
|
+
/** @deprecated alias kept for call sites that construct `new SingleSlotLock()` or type
|
|
79
|
+
* `lock: SingleSlotLock` (workflow adapters, fleet-panel). Same as ForegroundLock(1). */
|
|
80
|
+
export type SingleSlotLock = ForegroundLock;
|
|
81
|
+
export const SingleSlotLock = ForegroundLockImpl;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// src/engine/retry-fallback.ts
|
|
2
|
+
// #39 tail: a reusable retry wrapper for the lifecycle + background spawn sites, where the
|
|
3
|
+
// direct-foreground path's inline retry (subagent.ts) doesn't reach. Mirrors that path's
|
|
4
|
+
// contract: retry ONCE, only on a retryable provider rate-limit / auth failure (stopReason
|
|
5
|
+
// "error" → SpawnResult.retryable), only with a DISTINCT fallback model, only when not aborted.
|
|
6
|
+
// Non-retryable failures (turn budget, agent-not-found, EMPTY_RESULT, abort, lock busy) pass
|
|
7
|
+
// through unchanged. Lifecycle/bg retries are transparent — the retry's SpawnResult (with
|
|
8
|
+
// model = fallback) wins; the direct-foreground path separately surfaces `retriedWithModel`
|
|
9
|
+
// in the tool details, which lifecycle/bg don't need.
|
|
10
|
+
import type { SpawnResult } from "./spawnSubagent.ts";
|
|
11
|
+
import type { SpawnFn } from "../lifecycle/run-lifecycle.ts";
|
|
12
|
+
|
|
13
|
+
/** Wrap a lifecycle/bg SpawnFn so a retryable failure retries once on `fallback`. `fallback`
|
|
14
|
+
* being undefined returns the spawn unchanged (no global default + no per-dispatch param → no
|
|
15
|
+
* retry). `signal` optional — background/panel sites have no AbortSignal; the direct path checks
|
|
16
|
+
* abort inline. */
|
|
17
|
+
export function withModelFallbackRetry(spawn: SpawnFn, fallback: string | undefined, signal?: AbortSignal): SpawnFn {
|
|
18
|
+
if (!fallback) return spawn;
|
|
19
|
+
return async (opts) => {
|
|
20
|
+
const first = await spawn(opts);
|
|
21
|
+
if (first.status === "failed" && first.retryable && fallback !== first.model && !signal?.aborted) {
|
|
22
|
+
return spawn({ ...opts, model: fallback });
|
|
23
|
+
}
|
|
24
|
+
return first;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -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,8 +5,9 @@ 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
|
-
import type {
|
|
10
|
+
import type { ForegroundLock } from "./concurrency-lock.ts";
|
|
10
11
|
import type { RunLog } from "../runtime/run-log.ts";
|
|
11
12
|
import { buildToolEvent } from "../runtime/run-log.ts";
|
|
12
13
|
import { resolveAgentModel, type ModelRegistryLike } from "../tiers/resolve.ts";
|
|
@@ -112,10 +113,14 @@ export interface SpawnOptions {
|
|
|
112
113
|
registry: Map<string, AgentDef>;
|
|
113
114
|
todoSync: TodoSyncPort;
|
|
114
115
|
runRegistry: RunRegistry;
|
|
115
|
-
lock:
|
|
116
|
+
lock: ForegroundLock;
|
|
116
117
|
backendRegistry: BackendRegistry; // SPEC-3: replaces childFactory — engine looks up by agentDef.backend
|
|
117
118
|
parentModel: { provider: string; id: string };
|
|
118
119
|
parentCwd: string;
|
|
120
|
+
/** SPEC-6-5: the dispatch target's working directory. Default = parentCwd (the session cwd, backward-compat).
|
|
121
|
+
* When set, all child-scoped sites use this cwd (factory.create, RunRecord.cwd, run:meta cwd);
|
|
122
|
+
* session-scoped audit (`sessionCwd`) keeps parentCwd. */
|
|
123
|
+
cwd?: string;
|
|
119
124
|
memoryPort?: MemoryHydratePort;
|
|
120
125
|
visionPort?: VisionPort;
|
|
121
126
|
signal?: AbortSignal;
|
|
@@ -168,12 +173,54 @@ export interface SpawnResult {
|
|
|
168
173
|
* when this is set. Non-retryable failures (turn budget, agent-not-found, EMPTY_RESULT,
|
|
169
174
|
* abort, lock busy) leave this unset. */
|
|
170
175
|
retryable?: boolean;
|
|
176
|
+
/** #49: file paths the child mutated before the run ended (edit/write `path`; bash
|
|
177
|
+
* redirections/tee best-effort), deduped + sorted. Lets the controller re-inspect only
|
|
178
|
+
* what changed after a turn-budget cut, instead of the whole repo. Populated on every
|
|
179
|
+
* run (completed runs too); most useful on the turn-budget branch. */
|
|
180
|
+
filesTouched?: string[];
|
|
181
|
+
/** #49: did the child emit a trailing assistant message after its last tool (a summary),
|
|
182
|
+
* or was it cut mid-tool-work? Surfaced on turn-budget exhaustion so the controller knows
|
|
183
|
+
* whether finalText is a partial summary or a mid-thought. Undefined for non-turn-budget paths. */
|
|
184
|
+
reachedSummary?: boolean;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** #49: extract file paths a tool event touched, for the structured partial-result report.
|
|
188
|
+
* edit/write carry a `path` arg reliably; bash is best-effort (redirections `>`/`>>` + `tee` to a
|
|
189
|
+
* path-like token). Reads are NOT mutations and are excluded. */
|
|
190
|
+
function extractTouchedFiles(toolName: string, args: unknown): string[] {
|
|
191
|
+
if (!args || typeof args !== "object") return [];
|
|
192
|
+
const a = args as Record<string, unknown>;
|
|
193
|
+
if (toolName === "edit" || toolName === "write") {
|
|
194
|
+
const p = typeof a.path === "string" ? a.path : typeof a.file_path === "string" ? a.file_path : undefined;
|
|
195
|
+
return p ? [p] : [];
|
|
196
|
+
}
|
|
197
|
+
if (toolName === "bash") {
|
|
198
|
+
const cmd = typeof a.command === "string" ? a.command : undefined;
|
|
199
|
+
if (!cmd) return [];
|
|
200
|
+
const out: string[] = [];
|
|
201
|
+
// `> path` / `>> path` / `tee path` — only tokens that look like a path (contain `/` or `.`)
|
|
202
|
+
// to avoid false positives on bare words ("echo done", "exit 0").
|
|
203
|
+
const redir = />>?\s+([^\s|;&<>]+)/g;
|
|
204
|
+
let m: RegExpExecArray | null;
|
|
205
|
+
while ((m = redir.exec(cmd)) !== null) {
|
|
206
|
+
const tok = m[1];
|
|
207
|
+
if (tok && /[/.]/.test(tok)) out.push(tok);
|
|
208
|
+
}
|
|
209
|
+
const tee = /\btee\s+(?:-a\s+)?([^\s|;&<>]+)/g;
|
|
210
|
+
while ((m = tee.exec(cmd)) !== null) {
|
|
211
|
+
const tok = m[1];
|
|
212
|
+
if (tok && /[/.]/.test(tok)) out.push(tok);
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
return [];
|
|
171
217
|
}
|
|
172
218
|
|
|
173
219
|
export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
174
220
|
const track = opts.track ?? true;
|
|
175
221
|
const maxTurns = opts.maxTurns ?? DEFAULT_MAX_TURNS;
|
|
176
222
|
const startedAt = Date.now();
|
|
223
|
+
const childCwd = opts.cwd ?? opts.parentCwd;
|
|
177
224
|
|
|
178
225
|
// #31: read-only dispatches (review/audit/research) bypass the foreground single-slot lock —
|
|
179
226
|
// the caller asserts no cwd mutation, so the in-place edit-conflict guard doesn't apply and
|
|
@@ -184,15 +231,17 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
184
231
|
if (readOnly) {
|
|
185
232
|
runId = genRunId();
|
|
186
233
|
} else {
|
|
187
|
-
// concurrency=1 (
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
return fail("", startedAt, `a subagent is already running (concurrency=1 in v0.1); wait for ${busyId} to finish or abort it first`, opts.agent);
|
|
192
|
-
}
|
|
234
|
+
// #31 tail: foreground concurrency lock. cap=1 (default) is FAIL-FAST (backward-compat — a
|
|
235
|
+
// 2nd dispatch is rejected with the held runId + the cap + the env hint); cap>1 (opt-in via
|
|
236
|
+
// ARMORY_FLEET_FOREGROUND_CONCURRENCY) QUEUES — up to `cap` writes run in parallel, the rest wait.
|
|
237
|
+
// The cap is session-level (a shared lock can't be re-sized per dispatch); see concurrency-lock.ts.
|
|
193
238
|
runId = genRunId();
|
|
194
|
-
|
|
195
|
-
|
|
239
|
+
const acq = await opts.lock.acquire(runId);
|
|
240
|
+
if (!acq.ok) {
|
|
241
|
+
const hint = opts.lock.cap === 1
|
|
242
|
+
? ` (foreground concurrency=1; ${acq.busy.join(", ")} is running — wait for it to finish or abort it, or raise ARMORY_FLEET_FOREGROUND_CONCURRENCY to allow parallel write dispatches)`
|
|
243
|
+
: ` (foreground concurrency=${opts.lock.cap}; all ${opts.lock.cap} slots held by ${acq.busy.join(", ")}); wait for one to finish or raise ARMORY_FLEET_FOREGROUND_CONCURRENCY)`;
|
|
244
|
+
return fail("", startedAt, `a subagent is already running${hint}`, opts.agent);
|
|
196
245
|
}
|
|
197
246
|
}
|
|
198
247
|
|
|
@@ -244,7 +293,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
244
293
|
runId, agent: agentDef.name, model, task: opts.task, track,
|
|
245
294
|
todoId: null, status: "running", startedAt,
|
|
246
295
|
tier: tier?.name, costTotal: 0, contextTokens: 0,
|
|
247
|
-
cwd: opts.parentCwd, backend: backendId,
|
|
296
|
+
cwd: childCwd, sessionCwd: opts.parentCwd, backend: backendId,
|
|
248
297
|
});
|
|
249
298
|
|
|
250
299
|
// todo-sync (before) — only when both caller tracks AND agent allows todoSync
|
|
@@ -268,7 +317,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
268
317
|
for (const cand of candidates) {
|
|
269
318
|
try {
|
|
270
319
|
const result = await backend.factory.create({
|
|
271
|
-
cwd:
|
|
320
|
+
cwd: childCwd,
|
|
272
321
|
model: cand,
|
|
273
322
|
thinkingLevel: childAgent.thinkingLevel,
|
|
274
323
|
tools,
|
|
@@ -302,10 +351,18 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
302
351
|
let costTotal = 0;
|
|
303
352
|
let contextTokens = 0;
|
|
304
353
|
let turnIdx = -1;
|
|
354
|
+
// #32: substrate baseline — the turn-1 context-token snapshot (armory substrate overhead).
|
|
355
|
+
// Captured once on the first assistant message_end (turnIdx === 0); threaded to the RunRecord
|
|
356
|
+
// so the widget can classify the tok/ctx% segment as "substrate" (flat) vs "work" (growing).
|
|
357
|
+
let substrateBaseline: number | undefined;
|
|
305
358
|
// #26/#22: declared before subscribe() because some child sessions emit events
|
|
306
359
|
// synchronously inside subscribe() (temporal-dead-zone guard).
|
|
307
360
|
let modelError: string | undefined; // model-call failure surfaced via stopReason "error"
|
|
308
361
|
let sawAssistantMessage = false; // #22: did the child emit any assistant message_end at all?
|
|
362
|
+
// #49: structured partial-result tracking — what the child mutated, and whether it emitted
|
|
363
|
+
// a trailing assistant message after its last tool (a summary) vs being cut mid-tool-work.
|
|
364
|
+
const filesTouched = new Set<string>();
|
|
365
|
+
let sawAssistantAfterLastTool = true; // no tools yet = trivially "reached a summary"
|
|
309
366
|
|
|
310
367
|
// #23: liveness — classify events into a short, content-free class string for the widget.
|
|
311
368
|
// Names the tool (safe — tool name is not args/result) so the operator sees "what's happening"
|
|
@@ -326,7 +383,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
326
383
|
if (e.type === "session_init" && e.backendSessionId) {
|
|
327
384
|
opts.runRegistry.update(runId, { backendSessionId: e.backendSessionId, sessionKey: agentDef.sessionKey });
|
|
328
385
|
try {
|
|
329
|
-
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 });
|
|
386
|
+
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 });
|
|
330
387
|
} catch { /* best-effort */ }
|
|
331
388
|
} else if (e.type === "turn_start") {
|
|
332
389
|
turnIdx++;
|
|
@@ -334,6 +391,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
334
391
|
if (budget.consume()) void session.abort();
|
|
335
392
|
} else if (e.type === "message_end" && e.message?.role === "assistant") {
|
|
336
393
|
sawAssistantMessage = true;
|
|
394
|
+
sawAssistantAfterLastTool = true; // #49: a trailing assistant message = (so far) reached a summary
|
|
337
395
|
const text = e.message.content?.map((c) => (c.type === "text" ? c.text ?? "" : "")).join("") ?? "";
|
|
338
396
|
// #26/#22: a model-call failure (401, provider down, rate limit) surfaces as
|
|
339
397
|
// stopReason "error". The SDK retries internally; if it still ends with an error
|
|
@@ -355,7 +413,15 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
355
413
|
const cost = u?.cost?.total ?? 0;
|
|
356
414
|
costTotal += cost;
|
|
357
415
|
contextTokens = calcContextTokens(u ?? {});
|
|
358
|
-
|
|
416
|
+
// #32: capture the substrate baseline at the end of turn 1 (the first assistant
|
|
417
|
+
// message_end). The turn-1 context is dominated by the armory substrate (system prompt +
|
|
418
|
+
// skills + memory); subsequent turns barely grow it unless real work adds tool results.
|
|
419
|
+
const patch: Partial<RunRecord> = { costTotal, contextTokens, tokenTotal };
|
|
420
|
+
if (substrateBaseline === undefined && turnIdx === 0 && contextTokens > 0) {
|
|
421
|
+
substrateBaseline = contextTokens;
|
|
422
|
+
patch.substrateBaseline = substrateBaseline;
|
|
423
|
+
}
|
|
424
|
+
opts.runRegistry.update(runId, patch);
|
|
359
425
|
try {
|
|
360
426
|
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 });
|
|
361
427
|
} catch { /* best-effort */ }
|
|
@@ -365,6 +431,9 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
365
431
|
void session.abort();
|
|
366
432
|
}
|
|
367
433
|
} else if (e.type === "tool_execution_end") {
|
|
434
|
+
// #49: track mutated files for the structured partial-result report.
|
|
435
|
+
sawAssistantAfterLastTool = false; // cut mid-tool-work unless a message follows
|
|
436
|
+
for (const f of extractTouchedFiles((e as { toolName?: string }).toolName ?? "", (e as { args?: unknown }).args)) filesTouched.add(f);
|
|
368
437
|
try {
|
|
369
438
|
opts.runLog?.append(runId, buildToolEvent((e as any).toolName, (e as any).args, (e as any).result, (e as any).isError ?? false, turnIdx));
|
|
370
439
|
} catch { /* best-effort */ }
|
|
@@ -394,6 +463,8 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
394
463
|
|
|
395
464
|
let status: FleetRunStatus;
|
|
396
465
|
let error: string | undefined;
|
|
466
|
+
const filesTouchedList = [...filesTouched].sort(); // #49: deduped + sorted
|
|
467
|
+
let reachedSummary: boolean | undefined; // #49: only set on the turn-budget branch
|
|
397
468
|
if (aborted) {
|
|
398
469
|
status = "aborted";
|
|
399
470
|
// #23: distinguish TODO-status reversion from filesystem rollback. A foreground run is in-place
|
|
@@ -415,7 +486,14 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
415
486
|
? finalText.slice(0, PARTIAL_WINDOW) + "\n…(partial truncated — see run log for full output)"
|
|
416
487
|
: finalText;
|
|
417
488
|
status = "failed";
|
|
418
|
-
|
|
489
|
+
// #49: surface WHAT was modified + whether the partial is a summary or a mid-thought, so the
|
|
490
|
+
// controller can re-inspect only the touched files instead of the whole repo, and knows
|
|
491
|
+
// whether to trust finalText as a partial summary.
|
|
492
|
+
reachedSummary = sawAssistantAfterLastTool;
|
|
493
|
+
const filesLine = filesTouchedList.length
|
|
494
|
+
? `\n\nFiles modified before the cut: ${filesTouchedList.join(", ")}`
|
|
495
|
+
: "\n\nFiles modified before the cut: (none detected)";
|
|
496
|
+
error = `hit turn budget (${maxTurns}) mid-task; partial result:\n${partial}${filesLine}\nReached summary: ${reachedSummary ? "yes" : "no (cut mid-tool-work)"}`;
|
|
419
497
|
} else if (runError) {
|
|
420
498
|
status = "failed";
|
|
421
499
|
error = runError;
|
|
@@ -446,11 +524,11 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
446
524
|
status = "completed";
|
|
447
525
|
}
|
|
448
526
|
|
|
449
|
-
return await finishRun(opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentDef.name, model, tokenTotal, costTotal, contextTokens, modelError ? true : undefined);
|
|
527
|
+
return await finishRun(opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentDef.name, model, tokenTotal, costTotal, contextTokens, modelError ? true : undefined, filesTouchedList, reachedSummary);
|
|
450
528
|
} finally {
|
|
451
529
|
// #31: a readOnly dispatch never acquired the lock — don't release what it didn't take
|
|
452
530
|
// (releasing a lock held by another concurrent write dispatch would corrupt serialization).
|
|
453
|
-
if (!readOnly) opts.lock.release();
|
|
531
|
+
if (!readOnly) opts.lock.release(runId);
|
|
454
532
|
}
|
|
455
533
|
}
|
|
456
534
|
|
|
@@ -469,6 +547,7 @@ async function finishRun(
|
|
|
469
547
|
status: FleetRunStatus, finalText: string, todoId: string | null, priorStatus: string | undefined,
|
|
470
548
|
error: string | undefined, agentName: string, model: string, tokenTotal = 0, costTotal = 0, contextTokens = 0,
|
|
471
549
|
retryable?: boolean,
|
|
550
|
+
filesTouched?: string[], reachedSummary?: boolean,
|
|
472
551
|
): Promise<SpawnResult> {
|
|
473
552
|
if (finalizedRunIds.has(runId)) {
|
|
474
553
|
// Already finalized — return the existing registry record's result without re-appending.
|
|
@@ -478,6 +557,7 @@ async function finishRun(
|
|
|
478
557
|
runId, todoId, agent: agentName, model,
|
|
479
558
|
durationMs: existing?.endedAt ? existing.endedAt - startedAt : Date.now() - startedAt,
|
|
480
559
|
tokenTotal, costTotal, contextTokens, error, retryable,
|
|
560
|
+
filesTouched, reachedSummary,
|
|
481
561
|
};
|
|
482
562
|
}
|
|
483
563
|
finalizedRunIds.add(runId);
|
|
@@ -512,5 +592,6 @@ async function finishRun(
|
|
|
512
592
|
return {
|
|
513
593
|
status, finalText, runId, todoId, agent: agentName, model,
|
|
514
594
|
durationMs: endedAt - startedAt, tokenTotal, costTotal, contextTokens, error, retryable,
|
|
595
|
+
filesTouched, reachedSummary,
|
|
515
596
|
};
|
|
516
597
|
}
|
package/src/index.ts
CHANGED
|
@@ -12,11 +12,12 @@ import { createSubagentTool, type SubagentToolDeps } from "./tools/subagent.ts";
|
|
|
12
12
|
// SPEC-6-3: /fleet uses openWorkflowPanelLoop (Task 12) instead of the raw openFleetPanel factory.
|
|
13
13
|
import { discoverAgents } from "./registry/discovery.ts";
|
|
14
14
|
import { RunRegistry } from "./engine/run-registry.ts";
|
|
15
|
-
import { createSingleSlotLock } from "./engine/concurrency-lock.ts";
|
|
15
|
+
import { createSingleSlotLock, createForegroundLock } from "./engine/concurrency-lock.ts";
|
|
16
16
|
import { ArmoryTodoAdapter } from "./todo-sync/adapter.ts";
|
|
17
17
|
import { ArmoryMemoryAdapter } from "./memory-hydrate/adapter.ts";
|
|
18
18
|
import { ArmoryVisionAdapter } from "./vision/adapter.ts";
|
|
19
19
|
import { buildChildLoader } from "./engine/child-loader.ts";
|
|
20
|
+
import { withModelFallbackRetry } from "./engine/retry-fallback.ts";
|
|
20
21
|
import { createDescribeImageTool } from "./vision/describe-image-tool.ts";
|
|
21
22
|
import type { MemoryHydratePort } from "./memory-hydrate/port.ts";
|
|
22
23
|
import type { VisionPort } from "./vision/port.ts";
|
|
@@ -152,12 +153,23 @@ export function createChildSessionFactory(modelRuntime: ModelRuntime, memoryPort
|
|
|
152
153
|
};
|
|
153
154
|
}
|
|
154
155
|
|
|
156
|
+
/** #31 tail: parse the foreground-concurrency cap from `ARMORY_FLEET_FOREGROUND_CONCURRENCY`.
|
|
157
|
+
* Default 1 (fail-fast, backward-compat). >1 enables a queueing pool (up to N parallel write
|
|
158
|
+
* dispatches). Clamped to [1, 64]; invalid/empty → 1. */
|
|
159
|
+
function parseForegroundConcurrency(): number {
|
|
160
|
+
const raw = process.env.ARMORY_FLEET_FOREGROUND_CONCURRENCY;
|
|
161
|
+
if (!raw) return 1;
|
|
162
|
+
const n = Number.parseInt(raw, 10);
|
|
163
|
+
if (!Number.isFinite(n) || n < 1) return 1;
|
|
164
|
+
return Math.min(n, 64);
|
|
165
|
+
}
|
|
166
|
+
|
|
155
167
|
export default async function (pi: ExtensionAPI): Promise<void> {
|
|
156
168
|
const modelRuntime = await ModelRuntime.create();
|
|
157
169
|
const deps: SubagentToolDeps = {
|
|
158
170
|
registry: new Map(),
|
|
159
171
|
runRegistry: new RunRegistry(),
|
|
160
|
-
lock:
|
|
172
|
+
lock: createForegroundLock(parseForegroundConcurrency()),
|
|
161
173
|
todoSync: new ArmoryTodoAdapter(),
|
|
162
174
|
backendRegistry: await buildDefaultBackendRegistry(modelRuntime),
|
|
163
175
|
parentModel: { provider: "", id: "" },
|
|
@@ -189,6 +201,17 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
189
201
|
// Builtin-only placeholder tier registry so spawn works before session_start rebuilds with merged tiers.
|
|
190
202
|
deps.tierRegistry = new TierRegistry({ tiers: BUILTIN_TIERS, agents: deps.registry });
|
|
191
203
|
|
|
204
|
+
// #39 tail: global default fallback model (env-driven for now; a settings.json field is a follow-up).
|
|
205
|
+
// A retryable provider failure (stopReason "error") retries once on this model even without a
|
|
206
|
+
// per-dispatch `modelFallback`. Per the AGENTS.md "Ollama primary + OpenRouter fallback" pattern.
|
|
207
|
+
deps.defaultModelFallback = process.env.ARMORY_FLEET_MODEL_FALLBACK || undefined;
|
|
208
|
+
// SPEC-6-5: cross-cwd dispatch notify hook (wired per-session in session_start below).
|
|
209
|
+
// Placeholder; the real wiring happens in session_start where ctx is in scope.
|
|
210
|
+
|
|
211
|
+
// #31 tail: foreground concurrency is SESSION-LEVEL (a shared lock can't be re-sized per
|
|
212
|
+
// dispatch). cap=1 (default) is fail-fast (backward-compat); cap>1 enables a queueing pool so
|
|
213
|
+
// up to `cap` write dispatches run in parallel. readOnly dispatches bypass the lock (#41).
|
|
214
|
+
|
|
192
215
|
// SPEC-6-2: gate registry + builtin gate registration.
|
|
193
216
|
const gateRegistry = new GateRegistry();
|
|
194
217
|
registerBuiltinGates(gateRegistry);
|
|
@@ -234,14 +257,14 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
234
257
|
...deps.lifecycleDeps,
|
|
235
258
|
genRunId: () => opts.runId, // override: use the async runner's runId
|
|
236
259
|
...(isolated ? { artifactDiscovery: ({ finalText, cwd, baseRef }: { finalText: string; cwd: string; baseRef: string }) => (deps.asyncRunner as AsyncRunnerDeps).diff.diffPhase(cwd, baseRef, finalText) } : {}),
|
|
237
|
-
spawn: async (o) => spawnSubagent({
|
|
260
|
+
spawn: withModelFallbackRetry(async (o) => spawnSubagent({
|
|
238
261
|
agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
|
|
239
262
|
skillsOverride: o.skills, backendOverride: o.backend,
|
|
240
263
|
registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: bgLock,
|
|
241
264
|
backendRegistry: deps.backendRegistry, parentModel: deps.parentModel,
|
|
242
265
|
parentCwd: isolated ? opts.worktreePath! : deps.parentCwd,
|
|
243
266
|
runLog: deps.runLog, tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
|
|
244
|
-
}),
|
|
267
|
+
}), deps.defaultModelFallback),
|
|
245
268
|
};
|
|
246
269
|
const res = await runLifecycle(task, lifecycleName, { deps: lifecycleFullDeps, mode: opts.mode, worktreePath: opts.worktreePath, baseRef: "HEAD", onCheckpoint: async (p) => p.status === "failed" ? { action: "abort" } : { action: "continue" } });
|
|
247
270
|
return res as unknown as import("./runtime/async-runner.ts").FakeLifecycleResult;
|
|
@@ -287,6 +310,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
287
310
|
const m = ctx.model;
|
|
288
311
|
deps.parentModel = m ? { provider: m.provider, id: m.id } : { provider: "", id: "" };
|
|
289
312
|
deps.parentCwd = ctx.cwd;
|
|
313
|
+
deps.onNotify = (m, k) => ctx.ui.notify(m, k ?? "info");
|
|
290
314
|
// SPEC-5a: build the per-session async runner + scheduler, start firing, scan for interrupted runs.
|
|
291
315
|
const dir = fleetDir(ctx.cwd);
|
|
292
316
|
// SPEC-5b-1: per-session RunLog at .pi/fleet/conversations/ (separate from the
|
|
@@ -508,13 +532,13 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
508
532
|
};
|
|
509
533
|
const lifecycleFullDeps: import("./lifecycle/run-lifecycle.ts").LifecycleRunDeps = {
|
|
510
534
|
...deps.lifecycleDeps,
|
|
511
|
-
spawn: async (o) => spawnSubagent({
|
|
535
|
+
spawn: withModelFallbackRetry(async (o) => spawnSubagent({
|
|
512
536
|
agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
|
|
513
537
|
skillsOverride: o.skills, backendOverride: o.backend,
|
|
514
538
|
registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
|
|
515
539
|
backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: deps.parentCwd,
|
|
516
540
|
tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry, // SPEC-6-1
|
|
517
|
-
}),
|
|
541
|
+
}), deps.defaultModelFallback),
|
|
518
542
|
};
|
|
519
543
|
const res = await runLifecycle(parsed.task, lcName, { deps: lifecycleFullDeps, mode: parsed.auto ? "auto" : "checkpointed", onCheckpoint });
|
|
520
544
|
deps.lifecycleRuns.set(res.runId, res);
|
|
@@ -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;
|
|
@@ -49,6 +51,8 @@ export interface RunMeta {
|
|
|
49
51
|
pid?: number;
|
|
50
52
|
/** SPEC-6-2: the cwd this run belongs to. */
|
|
51
53
|
cwd?: string;
|
|
54
|
+
/** SPEC-6-5: the session cwd the dispatch originated from (= parentCwd). */
|
|
55
|
+
sessionCwd?: string;
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
const ARGS_LIMIT = 200;
|
|
@@ -104,7 +108,7 @@ export class RunLog {
|
|
|
104
108
|
if (!meta) {
|
|
105
109
|
meta = { runId: e.runId, agent: e.agent, model: e.model, task: e.task, startedAt: e.startedAt,
|
|
106
110
|
track: e.track, todoId: e.todoId, backendSessionId: e.backendSessionId, sessionKey: e.sessionKey,
|
|
107
|
-
status: "running", tokenTotal: 0, pid: e.pid, cwd: e.cwd };
|
|
111
|
+
status: "running", tokenTotal: 0, pid: e.pid, cwd: e.cwd, sessionCwd: e.sessionCwd };
|
|
108
112
|
} else {
|
|
109
113
|
// latest binding wins
|
|
110
114
|
if (e.backendSessionId) meta.backendSessionId = e.backendSessionId;
|
package/src/tools/subagent.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
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";
|
|
5
7
|
import type { RunRegistry } from "../engine/run-registry.ts";
|
|
6
|
-
import type {
|
|
8
|
+
import type { ForegroundLock } from "../engine/concurrency-lock.ts";
|
|
7
9
|
import type { SpawnResult } from "../engine/spawnSubagent.ts";
|
|
8
10
|
import { spawnSubagent } from "../engine/spawnSubagent.ts";
|
|
9
11
|
import type { BackendRegistry } from "../backend/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,10 +47,23 @@ 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;
|
|
50
|
-
lock:
|
|
66
|
+
lock: ForegroundLock;
|
|
51
67
|
todoSync: TodoSyncPort;
|
|
52
68
|
backendRegistry: BackendRegistry; // SPEC-3: replaces childFactory
|
|
53
69
|
parentModel: { provider: string; id: string };
|
|
@@ -74,6 +90,14 @@ export interface SubagentToolDeps {
|
|
|
74
90
|
reloadTiers?: () => void;
|
|
75
91
|
/** SPEC-6-1: model contextWindow resolver for Runs-tab ctx% (Surface C). Optional — ctx% hidden when absent. */
|
|
76
92
|
getModelContextWindow?: (model: string) => number | undefined;
|
|
93
|
+
/** #39 tail: a global default fallback model so a retryable provider failure (stopReason "error")
|
|
94
|
+
* retries once even without a per-dispatch `modelFallback` param. Wired from the
|
|
95
|
+
* `ARMORY_FLEET_MODEL_FALLBACK` env var in index.ts; a settings.json field is a follow-up.
|
|
96
|
+
* Per-dispatch `modelFallback` (when passed) takes precedence. Applies to the direct foreground
|
|
97
|
+
* path, the foreground lifecycle spawn, and the background/scheduled spawn. */
|
|
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;
|
|
77
101
|
}
|
|
78
102
|
|
|
79
103
|
/** Build the pi.registerTool definition. Thin wrapper over spawnSubagent. */
|
|
@@ -90,9 +114,17 @@ export function createSubagentTool(deps: SubagentToolDeps) {
|
|
|
90
114
|
"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
115
|
"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
116
|
"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.",
|
|
117
|
+
"For web tasks, dispatch with the firecrawl skill the child needs (e.g. skills: [\"firecrawl-scrape\"] / [\"firecrawl-search\"]) — children now discover skills from `~/.agents/skills` + `~/.pi/agent/skills`, matching your surface. Curl stays the right call for raw HTTP probes, smoke tests, and plain JSON/text APIs where JS rendering isn't needed (per the AGENTS.md firecrawl-first/curl-fallback preference).",
|
|
118
|
+
"Foreground concurrency is SESSION-LEVEL, not per-dispatch: write dispatches serialize through one shared lock sized by ARMORY_FLEET_FOREGROUND_CONCURRENCY. At the default (1) a 2nd write dispatch is rejected fail-fast (the error names the held runId) — dispatch sequentially (await each) or use readOnly:true for parallel read-only work. Raise the env cap only if you accept parallel in-place edits (conflict risk).",
|
|
93
119
|
],
|
|
94
120
|
parameters: subagentParams,
|
|
95
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
|
+
}
|
|
96
128
|
// SPEC-5a: background + schedule routing (Q1/Q2/Q5).
|
|
97
129
|
if (params.background && params.schedule) {
|
|
98
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." }] };
|
|
@@ -111,9 +143,12 @@ export function createSubagentTool(deps: SubagentToolDeps) {
|
|
|
111
143
|
}
|
|
112
144
|
if (params.lifecycle) {
|
|
113
145
|
const { runLifecycle } = await import("../lifecycle/run-lifecycle.ts");
|
|
146
|
+
const { withModelFallbackRetry } = await import("../engine/retry-fallback.ts");
|
|
114
147
|
const lifecycleFullDeps: LifecycleRunDeps = {
|
|
115
148
|
...deps.lifecycleDeps,
|
|
116
|
-
|
|
149
|
+
// #39 tail: wrap the phase spawn so a retryable provider failure retries once on the
|
|
150
|
+
// fallback (per-dispatch `modelFallback` param OR the global `defaultModelFallback` dep).
|
|
151
|
+
spawn: withModelFallbackRetry(async (o) => spawnSubagent({
|
|
117
152
|
agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
|
|
118
153
|
skillsOverride: mergeLifecycleSkills(o.skills, params.skills), backendOverride: o.backend,
|
|
119
154
|
registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
|
|
@@ -121,10 +156,12 @@ export function createSubagentTool(deps: SubagentToolDeps) {
|
|
|
121
156
|
maxTurns: params.maxTurns,
|
|
122
157
|
tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
|
|
123
158
|
readOnly: params.readOnly,
|
|
124
|
-
|
|
159
|
+
cwd: o.cwd,
|
|
160
|
+
}), params.modelFallback ?? deps.defaultModelFallback, signal),
|
|
125
161
|
};
|
|
126
162
|
const res = await runLifecycle(params.task, params.lifecycle, {
|
|
127
163
|
deps: lifecycleFullDeps, mode: "auto",
|
|
164
|
+
entryCwd: resolvedCwd,
|
|
128
165
|
onCheckpoint: async (phase) => phase.status === "failed" ? { action: "abort" } : { action: "continue" },
|
|
129
166
|
});
|
|
130
167
|
const isError = res.status === "failed" || res.status === "aborted";
|
|
@@ -155,24 +192,28 @@ export function createSubagentTool(deps: SubagentToolDeps) {
|
|
|
155
192
|
signal,
|
|
156
193
|
maxTurns: params.maxTurns,
|
|
157
194
|
tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
|
|
195
|
+
cwd: resolvedCwd,
|
|
158
196
|
});
|
|
159
197
|
// #39: auto-retry on a retryable provider rate-limit / auth failure (stopReason "error").
|
|
160
198
|
// The primary run reverted its linked todo to open (finishRun -> markRunTodoReverted), so the
|
|
161
199
|
// retry relinks the SAME todoId to continue the tracked task. Retry ONCE, only on the direct
|
|
162
200
|
// foreground path, only if a distinct fallback model was provided. The retry's runId differs
|
|
163
201
|
// from the primary's (each spawnSubagent call mints its own); details.retriedWithModel marks it.
|
|
202
|
+
// #39 tail: the fallback comes from the per-dispatch `modelFallback` param OR the global
|
|
203
|
+
// `defaultModelFallback` dep (wired from ARMORY_FLEET_MODEL_FALLBACK) — per-dispatch wins.
|
|
204
|
+
const fallback = params.modelFallback ?? deps.defaultModelFallback;
|
|
164
205
|
let retriedWithModel: string | undefined;
|
|
165
206
|
let finalRes = res;
|
|
166
207
|
if (
|
|
167
|
-
res.status === "failed" && res.retryable &&
|
|
168
|
-
|
|
208
|
+
res.status === "failed" && res.retryable && fallback &&
|
|
209
|
+
fallback !== res.model && !signal.aborted
|
|
169
210
|
) {
|
|
170
211
|
finalRes = await spawnSubagent({
|
|
171
212
|
agent: params.agent,
|
|
172
213
|
task: params.task,
|
|
173
214
|
todoId: res.todoId ?? undefined,
|
|
174
215
|
track: params.track,
|
|
175
|
-
model:
|
|
216
|
+
model: fallback,
|
|
176
217
|
readOnly: params.readOnly,
|
|
177
218
|
skillsOverride: params.skills,
|
|
178
219
|
registry: deps.registry,
|
|
@@ -186,8 +227,9 @@ export function createSubagentTool(deps: SubagentToolDeps) {
|
|
|
186
227
|
signal,
|
|
187
228
|
maxTurns: params.maxTurns,
|
|
188
229
|
tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
|
|
230
|
+
cwd: resolvedCwd,
|
|
189
231
|
});
|
|
190
|
-
retriedWithModel =
|
|
232
|
+
retriedWithModel = fallback;
|
|
191
233
|
}
|
|
192
234
|
const isError = finalRes.status === "failed" || finalRes.status === "aborted";
|
|
193
235
|
return {
|
|
@@ -196,6 +238,7 @@ export function createSubagentTool(deps: SubagentToolDeps) {
|
|
|
196
238
|
runId: finalRes.runId, todoId: finalRes.todoId, agent: finalRes.agent, model: finalRes.model,
|
|
197
239
|
status: finalRes.status, durationMs: finalRes.durationMs, tokenTotal: finalRes.tokenTotal,
|
|
198
240
|
retriedWithModel,
|
|
241
|
+
filesTouched: finalRes.filesTouched, reachedSummary: finalRes.reachedSummary,
|
|
199
242
|
},
|
|
200
243
|
isError,
|
|
201
244
|
};
|