@getpipher/armory-fleet 0.11.0 → 0.11.1

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.11.0",
3
+ "version": "0.11.1",
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",
package/src/index.ts CHANGED
@@ -212,17 +212,20 @@ export default async function (pi: ExtensionAPI): Promise<void> {
212
212
  // foreground subagent holding deps.lock made every bg run's first-phase spawn fail fast
213
213
  // (tryAcquire → "concurrency lock unexpectedly unavailable" → 6ms run:aborted).
214
214
  const bgLock = createSingleSlotLock();
215
+ // v0.11.1: isolated runs use worktree-diff artifact discovery + the worktree as spawn cwd;
216
+ // in-place runs (worktreePath undefined) use the prompt-baked parser + the session cwd.
217
+ const isolated = !!opts.worktreePath;
215
218
  const lifecycleFullDeps: LifecycleRunDeps = {
216
219
  ...deps.lifecycleDeps,
217
220
  genRunId: () => opts.runId, // override: use the async runner's runId
218
- // SPEC-5a (Q3=A): isolated run worktree-diff artifact discovery instead of the prompt-baked block.
219
- artifactDiscovery: ({ finalText, cwd, baseRef }) => (deps.asyncRunner as AsyncRunnerDeps).diff.diffPhase(cwd, baseRef, finalText),
221
+ ...(isolated ? { artifactDiscovery: ({ finalText, cwd, baseRef }: { finalText: string; cwd: string; baseRef: string }) => (deps.asyncRunner as AsyncRunnerDeps).diff.diffPhase(cwd, baseRef, finalText) } : {}),
220
222
  spawn: async (o) => spawnSubagent({
221
223
  agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
222
224
  skillsOverride: o.skills, backendOverride: o.backend,
223
225
  registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: bgLock,
224
- backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: opts.worktreePath, runLog: deps.runLog,
225
- tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry, // SPEC-6-1
226
+ backendRegistry: deps.backendRegistry, parentModel: deps.parentModel,
227
+ parentCwd: isolated ? opts.worktreePath! : deps.parentCwd,
228
+ runLog: deps.runLog, tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
226
229
  }),
227
230
  };
228
231
  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" } });
@@ -297,7 +300,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
297
300
  lockPath: join(dir, "schedules.lock"),
298
301
  onFire: (spec) => {
299
302
  if (!deps.asyncRunner) return;
300
- runBackground(spec.task, { deps: deps.asyncRunner, lifecycle: spec.lifecycle ?? "default", mode: spec.auto ? "auto" : "checkpointed" });
303
+ runBackground(spec.task, { deps: deps.asyncRunner, lifecycle: spec.lifecycle ?? "default", mode: spec.auto ? "auto" : "checkpointed", isolation: spec.isolation });
301
304
  },
302
305
  });
303
306
  deps.scheduler.start();
@@ -23,8 +23,8 @@ export interface FakeLifecycleResult {
23
23
 
24
24
  export interface RunLifecycleOpts {
25
25
  runId: string;
26
- worktreePath: string;
27
- branch: string;
26
+ worktreePath?: string;
27
+ branch?: string;
28
28
  mode: "auto" | "checkpointed";
29
29
  }
30
30
 
@@ -49,12 +49,22 @@ export interface RunBackgroundOpts {
49
49
  deps: AsyncRunnerDeps;
50
50
  lifecycle: string;
51
51
  mode: "auto" | "checkpointed";
52
+ /** v0.11.1: edit isolation for background runs. Default "auto" (worktree when cwd is a git repo, in-place otherwise). */
53
+ isolation?: Isolation;
52
54
  }
53
55
 
54
- export interface RunBackgroundHandle {
55
- runId: string;
56
- status: "background";
57
- }
56
+ /** The success shape (back-compat: existing external refs to RunBackgroundHandle still typecheck). */
57
+ export interface RunBackgroundHandle { runId: string; status: "background"; }
58
+
59
+ /** v0.11.1: a background dispatch either starts (runId + background) or fails synchronously (error, no runId). */
60
+ export type RunBackgroundResult =
61
+ | { runId: string; status: "background" }
62
+ | { status: "failed"; error: string };
63
+
64
+ export type Isolation = "worktree" | "none" | "auto";
65
+
66
+ /** Per-session dedup flag for the auto-fallback in-place notify (resets on process restart = new session). */
67
+ let inPlaceFallbackWarned = false;
58
68
 
59
69
  function emitProgress(deps: AsyncRunnerDeps, runId: string, partial: Partial<import("../panel/rows.ts").BgRunStatus> & { status: import("../panel/rows.ts").BgStatus; phase: string; phaseIndex: number; phaseTotal: number }): void {
60
70
  if (!deps.onProgress) return;
@@ -72,54 +82,98 @@ function sh(cmd: string, cwd: string): void {
72
82
  execSync(cmd, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
73
83
  }
74
84
 
75
- export function runBackground(task: string, opts: RunBackgroundOpts): RunBackgroundHandle {
85
+ /** The git-agnostic core: pool slot → journal → runLifecycle in ctx.cwd (or a worktree, when `isolated` is set) inbox + notify.
86
+ * Fire-and-forget. When `isolated` is present, journals the worktree field, commits on completion, and removes the worktree. */
87
+ function runBackgroundInPlace(runId: string, task: string, opts: RunBackgroundOpts, isolated?: { worktreePath: string; branch: string }): void {
76
88
  const { deps } = opts;
77
- const runId = deps.genRunId();
78
- const baseRef = "HEAD";
79
-
80
- // Fire-and-forget: the pool gates concurrency; the journal records the run.
81
89
  void deps.pool.withSlot(async () => {
82
- let wt: { path: string; branch: string } | null = null;
83
90
  try {
84
- wt = deps.worktree.create(runId, baseRef);
85
- const ev0: JournalEvent = { type: "run:started", runId, task, lifecycle: opts.lifecycle, worktree: { path: wt.path, branch: wt.branch }, mode: opts.mode, ts: Date.now() };
91
+ const ev0: JournalEvent = isolated
92
+ ? { type: "run:started", runId, task, lifecycle: opts.lifecycle, worktree: { path: isolated.worktreePath, branch: isolated.branch }, mode: opts.mode, ts: Date.now() }
93
+ : { type: "run:started", runId, task, lifecycle: opts.lifecycle, mode: opts.mode, ts: Date.now() };
86
94
  deps.journal.append(runId, ev0);
87
95
  emitProgress(deps, runId, { status: "running", phase: "", phaseIndex: 0, phaseTotal: 0, lifecycle: opts.lifecycle, mode: opts.mode, task });
88
96
 
89
- const res = await deps.runLifecycle(task, opts.lifecycle, { runId, worktreePath: wt.path, branch: wt.branch, mode: opts.mode });
97
+ const res = await deps.runLifecycle(task, opts.lifecycle, { runId, worktreePath: isolated?.worktreePath, branch: isolated?.branch, mode: opts.mode });
90
98
 
91
99
  if (res.status === "completed") {
92
- // commit the worktree to the branch (lifecycle finish phase or single-delegate completion)
93
- try { sh("git add -A && git commit -m 'fleet run complete'", wt.path); } catch { /* nothing to commit */ }
94
- deps.journal.append(runId, { type: "run:completed", runId, branch: wt.branch, ts: Date.now() });
100
+ if (isolated) {
101
+ try { sh("git add -A && git commit -m 'fleet run complete'", isolated.worktreePath); } catch { /* nothing to commit */ }
102
+ }
103
+ deps.journal.append(runId, isolated
104
+ ? { type: "run:completed", runId, branch: isolated.branch, ts: Date.now() }
105
+ : { type: "run:completed", runId, ts: Date.now() });
95
106
  const total = res.phases.length;
96
- const lastIdx = total; // completed = past the last phase
97
- emitProgress(deps, runId, { status: "completed", phase: res.phases[total - 1]?.name ?? "finish", phaseIndex: lastIdx, phaseTotal: total, lifecycle: opts.lifecycle, mode: opts.mode, task, branch: wt.branch });
107
+ const lastIdx = total;
108
+ emitProgress(deps, runId, { status: "completed", phase: res.phases[total - 1]?.name ?? "finish", phaseIndex: lastIdx, phaseTotal: total, lifecycle: opts.lifecycle, mode: opts.mode, task, ...(isolated ? { branch: isolated.branch } : {}) });
98
109
  const lastPhase = res.phases[res.phases.length - 1];
99
110
  const result: RunResult = {
100
111
  runId, task, status: "completed",
101
112
  summary: lastPhase?.summary ?? "",
102
113
  paths: res.phases.flatMap((p) => p.paths),
103
- branch: wt.branch, completedAt: Date.now(),
114
+ completedAt: Date.now(),
115
+ ...(isolated ? { branch: isolated.branch } : {}),
104
116
  };
105
117
  deps.inbox.push(result);
106
118
  deps.notify(`fleet run ${runId} completed`, "info");
107
- // SPEC-5a: the worktree dir is temporary scaffolding; remove it but keep the branch for merge/inspection.
108
- deps.worktree.removeWorktree(runId);
119
+ if (isolated) deps.worktree.removeWorktree(runId);
109
120
  } else {
110
121
  deps.journal.append(runId, { type: "run:aborted", runId, reason: res.error ?? res.status, ts: Date.now() });
111
- deps.worktree.remove(runId);
122
+ if (isolated) deps.worktree.remove(runId);
112
123
  emitProgress(deps, runId, { status: "failed", phase: "", phaseIndex: 0, phaseTotal: res.phases.length, lifecycle: opts.lifecycle, mode: opts.mode, task });
113
124
  deps.notify(`fleet run ${runId} ${res.status}: ${res.error ?? ""}`, "warning");
114
125
  }
115
126
  } catch (e) {
116
127
  const msg = (e as Error).message;
117
128
  deps.journal.append(runId, { type: "run:aborted", runId, reason: msg, ts: Date.now() });
118
- if (wt) deps.worktree.remove(runId);
129
+ if (isolated) deps.worktree.remove(runId);
119
130
  deps.notify(`fleet run ${runId} failed: ${msg}`, "error");
120
131
  emitProgress(deps, runId, { status: "failed", phase: "", phaseIndex: 0, phaseTotal: 0, lifecycle: opts.lifecycle, mode: opts.mode, task });
121
132
  }
122
133
  });
134
+ }
123
135
 
136
+ /** The worktree wrapper: SYNCHRONOUS pre-flight + worktree create, then core-with-isolation. */
137
+ function runBackgroundIsolated(task: string, opts: RunBackgroundOpts): RunBackgroundResult {
138
+ const { deps } = opts;
139
+ if (!deps.worktree.isGitRepo()) {
140
+ return { status: "failed", error: "isolation: 'worktree' requires a git repo; cwd is not one — use isolation: 'none' or run in a git repo" };
141
+ }
142
+ const runId = deps.genRunId();
143
+ const baseRef = "HEAD";
144
+ let wt: { path: string; branch: string };
145
+ try {
146
+ wt = deps.worktree.create(runId, baseRef);
147
+ } catch (e) {
148
+ return { status: "failed", error: (e as Error).message };
149
+ }
150
+ runBackgroundInPlace(runId, task, opts, { worktreePath: wt.path, branch: wt.branch });
124
151
  return { runId, status: "background" };
152
+ }
153
+
154
+ /** The auto router: isolated when cwd is a git repo, in-place + one per-session notify when not. */
155
+ function runBackgroundAuto(task: string, opts: RunBackgroundOpts): RunBackgroundResult {
156
+ const { deps } = opts;
157
+ if (deps.worktree.isGitRepo()) {
158
+ return runBackgroundIsolated(task, opts);
159
+ }
160
+ if (!inPlaceFallbackWarned) {
161
+ inPlaceFallbackWarned = true;
162
+ deps.notify("background run in-place (no worktree isolation — parallel edits may conflict)", "warning");
163
+ }
164
+ const runId = deps.genRunId();
165
+ runBackgroundInPlace(runId, task, opts);
166
+ return { runId, status: "background" };
167
+ }
168
+
169
+ /** Public dispatcher (keeps the `runBackground` name + RunBackgroundHandle success shape for back-compat). */
170
+ export function runBackground(task: string, opts: RunBackgroundOpts): RunBackgroundResult {
171
+ const isolation = opts.isolation ?? "auto";
172
+ if (isolation === "worktree") return runBackgroundIsolated(task, opts);
173
+ if (isolation === "none") {
174
+ const runId = opts.deps.genRunId();
175
+ runBackgroundInPlace(runId, task, opts);
176
+ return { runId, status: "background" };
177
+ }
178
+ return runBackgroundAuto(task, opts);
125
179
  }
@@ -8,8 +8,10 @@ export interface ResumeCandidate {
8
8
  runId: string;
9
9
  task: string;
10
10
  lifecycle: string;
11
- worktreePath: string;
12
- branch: string;
11
+ /** Present only for isolated (worktree) runs. */
12
+ worktreePath?: string;
13
+ /** Present only for isolated (worktree) runs. */
14
+ branch?: string;
13
15
  lastPhase: string | null;
14
16
  canResume: boolean;
15
17
  }
@@ -26,23 +28,27 @@ export function scanResumeCandidates(_projectDir: string, opts: ScanResumeOpts):
26
28
  for (const runId of ids) {
27
29
  const events = journal.replay(runId);
28
30
  const started = events.find((e) => e.type === "run:started") as
29
- | (JournalEvent & { type: "run:started" }) | undefined;
31
+ | (JournalEvent & { type: "run:started"; worktree?: { path: string; branch: string } }) | undefined;
30
32
  if (!started) continue;
31
33
  const phaseEvents = events.filter((e) => e.type === "phase:completed" || e.type === "phase:started" || e.type === "phase:failed") as Array<{ phase: string }>;
32
34
  const lastPhase = phaseEvents.length > 0 ? phaseEvents[phaseEvents.length - 1]!.phase : null;
33
- const wtExists = opts.worktree.exists(runId);
34
- if (!wtExists) {
35
- journal.append(runId, { type: "run:aborted", runId, reason: "worktree-missing", ts: Date.now() });
35
+
36
+ if (started.worktree) {
37
+ // isolated run: resume iff the worktree still exists
38
+ const wtExists = opts.worktree.exists(runId);
39
+ if (!wtExists) {
40
+ journal.append(runId, { type: "run:aborted", runId, reason: "worktree-missing", ts: Date.now() });
41
+ }
42
+ cands.push({
43
+ runId, task: started.task, lifecycle: started.lifecycle,
44
+ worktreePath: started.worktree.path, branch: started.worktree.branch,
45
+ lastPhase, canResume: wtExists,
46
+ });
47
+ } else {
48
+ // v0.11.1: in-place interrupted run — no worktree to clean; abort (partial edits may remain in cwd).
49
+ journal.append(runId, { type: "run:aborted", runId, reason: "in-place interrupted (partial edits may remain in cwd)", ts: Date.now() });
50
+ cands.push({ runId, task: started.task, lifecycle: started.lifecycle, lastPhase, canResume: false });
36
51
  }
37
- cands.push({
38
- runId,
39
- task: started.task,
40
- lifecycle: started.lifecycle,
41
- worktreePath: started.worktree.path,
42
- branch: started.worktree.branch,
43
- lastPhase,
44
- canResume: wtExists,
45
- });
46
52
  }
47
53
  return cands;
48
54
  }
@@ -4,12 +4,12 @@
4
4
  import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
 
7
- export interface RunStartedEvent { type: "run:started"; runId: string; task: string; lifecycle: string; worktree: { path: string; branch: string }; mode: "auto" | "checkpointed"; ts: number; }
7
+ export interface RunStartedEvent { type: "run:started"; runId: string; task: string; lifecycle: string; worktree?: { path: string; branch: string }; mode: "auto" | "checkpointed"; ts: number; }
8
8
  export interface PhaseStartedEvent { type: "phase:started"; phase: string; ts: number; }
9
9
  export interface PhaseCompletedEvent { type: "phase:completed"; phase: string; summary: string; paths: string[]; ts: number; }
10
10
  export interface PhaseFailedEvent { type: "phase:failed"; phase: string; error: string; ts: number; }
11
11
  export interface CheckpointEvent { type: "checkpoint"; phase: string; decision: "continue" | "revise" | "abort"; ts: number; }
12
- export interface RunCompletedEvent { type: "run:completed"; runId: string; branch: string; ts: number; }
12
+ export interface RunCompletedEvent { type: "run:completed"; runId: string; branch?: string; ts: number; }
13
13
  export interface RunAbortedEvent { type: "run:aborted"; runId: string; reason: string; ts: number; }
14
14
 
15
15
  export type JournalEvent =
@@ -11,6 +11,8 @@ export interface ScheduleSpec {
11
11
  expression: string;
12
12
  lifecycle?: string; // default "default"
13
13
  auto?: boolean;
14
+ /** v0.11.1: edit isolation for the background run on fire. Default "auto". */
15
+ isolation?: "worktree" | "none" | "auto";
14
16
  }
15
17
 
16
18
  export interface Schedule extends ScheduleSpec {
@@ -71,6 +73,7 @@ export class Scheduler {
71
73
  expression: spec.expression,
72
74
  lifecycle: spec.lifecycle ?? "default",
73
75
  auto: spec.auto ?? true,
76
+ isolation: spec.isolation,
74
77
  paused: false,
75
78
  };
76
79
  this.schedules.set(id, { spec: stored, expr, timer: null });
@@ -86,6 +89,7 @@ export class Scheduler {
86
89
  expression: e.spec.expression,
87
90
  lifecycle: e.spec.lifecycle,
88
91
  auto: e.spec.auto,
92
+ isolation: e.spec.isolation,
89
93
  paused: e.spec.paused,
90
94
  nextFire: e.spec.paused ? null : e.expr.nextFire(new Date()),
91
95
  }));
@@ -22,6 +22,11 @@ export const subagentParams = Type.Object({
22
22
  lifecycle: Type.Optional(Type.String({ description: "Run a multi-phase superpowers lifecycle by name (e.g. 'default') instead of a single delegate. Tool-driven lifecycles run end-to-end (auto) — checkpoints are a /fleet panel feature." })),
23
23
  auto: Type.Optional(Type.Boolean({ description: "Only relevant with `lifecycle`. Tool-driven is always auto; this flag is forward-compat. Panel-driven uses --auto on /fleet-implement." })),
24
24
  background: Type.Optional(Type.Boolean({ description: "Fire without awaiting. The run goes to the async/bg pool on an isolated git worktree; this returns { runId, status: 'background' } immediately. Foreground (default) awaits the result." })),
25
+ isolation: Type.Optional(Type.Union([
26
+ Type.Literal("worktree"),
27
+ Type.Literal("none"),
28
+ Type.Literal("auto"),
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." })),
25
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.' })),
26
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.' })),
27
32
  });
@@ -80,13 +85,14 @@ export function createSubagentTool(deps: SubagentToolDeps) {
80
85
  }
81
86
  if (params.schedule) {
82
87
  if (!deps.scheduler) return { isError: true, content: [{ type: "text" as const, text: "scheduling not configured (scheduler missing)" }] };
83
- const id = deps.scheduler.register({ task: params.task, expression: params.schedule, lifecycle: params.lifecycle ?? "default", auto: params.auto ?? true });
88
+ const id = deps.scheduler.register({ task: params.task, expression: params.schedule, lifecycle: params.lifecycle ?? "default", auto: params.auto ?? true, isolation: params.isolation });
84
89
  const entry = deps.scheduler.list().find((s) => s.id === id);
85
90
  return { content: [{ type: "text" as const, text: `scheduled: ${id} · next fire: ${entry?.nextFire?.toISOString() ?? "(paused)"}` }], details: { scheduleId: id, nextFire: entry?.nextFire ?? null } };
86
91
  }
87
92
  if (params.background) {
88
93
  if (!deps.asyncRunner) return { isError: true, content: [{ type: "text" as const, text: "background runs not configured (asyncRunner missing)" }] };
89
- const handle = runBackground(params.task, { deps: deps.asyncRunner, lifecycle: params.lifecycle ?? "default", mode: "auto" });
94
+ const handle = runBackground(params.task, { deps: deps.asyncRunner, lifecycle: params.lifecycle ?? "default", mode: "auto", isolation: params.isolation });
95
+ if (handle.status === "failed") return { isError: true, content: [{ type: "text" as const, text: handle.error }] };
90
96
  return { content: [{ type: "text" as const, text: `background run: ${handle.runId}` }], details: handle };
91
97
  }
92
98
  if (params.lifecycle) {
@@ -40,6 +40,16 @@ export class WorktreeService {
40
40
  return existsSync(this.pathFor(runId));
41
41
  }
42
42
 
43
+ /** v0.11.1: is `rootDir` (or `dir`) inside a git repo? Cheap sync pre-flight for isolation routing. */
44
+ isGitRepo(dir: string = this.rootDir): boolean {
45
+ try {
46
+ sh("git rev-parse --show-toplevel", dir);
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
43
53
  create(runId: string, baseRef = "HEAD"): WorktreeRef {
44
54
  if (this.exists(runId)) {
45
55
  throw new Error(`worktree for run ${runId} already exists at ${this.pathFor(runId)}`);