@getpipher/armory-fleet 0.5.0 → 0.5.2

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.5.0",
3
+ "version": "0.5.2",
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
@@ -170,6 +170,13 @@ export default async function (pi: ExtensionAPI): Promise<void> {
170
170
  const asyncRunLifecycle: AsyncRunnerDeps["runLifecycle"] = async (task, lifecycleName, opts) => {
171
171
  const { runLifecycle } = await import("./lifecycle/run-lifecycle.ts");
172
172
  const { spawnSubagent } = await import("./engine/spawnSubagent.ts");
173
+ // SPEC-5a §8.1 (Q4=A): bg runs must NOT compete with the foreground single-slot lock.
174
+ // The ConcurrencyPool gates bg-RUN concurrency (N-slot); within a run the lifecycle loop
175
+ // serializes phases, so a fresh per-run lock just satisfies spawnSubagent's tryAcquire API
176
+ // without ever contending (foreground holds deps.lock; bg holds its own). Without this, a
177
+ // foreground subagent holding deps.lock made every bg run's first-phase spawn fail fast
178
+ // (tryAcquire → "concurrency lock unexpectedly unavailable" → 6ms run:aborted).
179
+ const bgLock = createSingleSlotLock();
173
180
  const lifecycleFullDeps: LifecycleRunDeps = {
174
181
  ...deps.lifecycleDeps,
175
182
  genRunId: () => opts.runId, // override: use the async runner's runId
@@ -178,7 +185,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
178
185
  spawn: async (o) => spawnSubagent({
179
186
  agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
180
187
  skillsOverride: o.skills, backendOverride: o.backend,
181
- registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
188
+ registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: bgLock,
182
189
  backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: opts.worktreePath, // child runs in the worktree
183
190
  }),
184
191
  };
@@ -203,7 +203,13 @@ export async function runLifecycle(task: string, lifecycleName: string, opts: Li
203
203
  // A failed phase that's aborted = lifecycle failed (the work failed); a healthy phase
204
204
  // aborted at a checkpoint = user-aborted (§12).
205
205
  const status: LifecycleStatus = phaseRec.status === "failed" ? "failed" : "aborted";
206
- return doneResult(runId, startedAt, status, lifecycleName, task, lifecycleBackend, opts.mode, phaseRecords, todoId);
206
+ // SPEC-5a fix: surface the failed phase's summary as the lifecycle error so the async
207
+ // runner's run:aborted journal event + notify show the real cause (spawnRes.error etc.),
208
+ // not just the bare status. Guard on non-empty summary so an empty summary still falls
209
+ // back to the status in the async runner (res.error ?? res.status). A healthy phase
210
+ // aborted at a checkpoint has no error.
211
+ const error = phaseRec.status === "failed" && phaseRec.summary ? phaseRec.summary : undefined;
212
+ return doneResult(runId, startedAt, status, lifecycleName, task, lifecycleBackend, opts.mode, phaseRecords, todoId, error);
207
213
  }
208
214
  // decision.action === "revise"
209
215
  reviseCount++;
@@ -117,6 +117,9 @@ export class Scheduler {
117
117
 
118
118
  start(): boolean {
119
119
  if (this.running) return true;
120
+ // Ensure the lock file's parent dir exists (fresh projects have no `.pi/fleet/` yet;
121
+ // persist() only runs on register(), so start() must self-sufficient the dir first).
122
+ mkdirSync(dirname(this.opts.lockPath), { recursive: true });
120
123
  if (!this.pidLock.acquire(this.opts.lockPath)) return false;
121
124
  this.running = true;
122
125
  for (const id of this.schedules.keys()) this.arm(id);
@@ -5,8 +5,8 @@
5
5
  - **Version:** 1.1.1 (latest 1.x — dependency-free; later versions pull in `luxon`)
6
6
  - **License:** MIT (see upstream LICENSE)
7
7
  - **Vendored on:** 2026-07-24
8
- - **Vendored surface:** `lib/` (4 files: `parser.js`, `expression.js`, `date.js`, `number.js` — CommonJS, all-relative `require()`s, zero runtime deps)
9
- - **Frozen:** do NOT edit files under `lib/`. To upgrade, replace `lib/` + update this NOTICE (version + date). Note: v2+ adds `luxon` as a runtime dep — vendoring those would require also vendoring luxon; v1.1.1 is intentionally dep-free.
8
+ - **Vendored surface:** `lib/` (4 files: `parser.js`, `expression.js`, `date.js`, `number.js` — CommonJS, all-relative `require()`s, zero runtime deps) + `package.json` (`{"type":"commonjs"}`) scoping the dir as CJS so Node's loader treats the `.js` files as CommonJS despite the package root's `"type": "module"`.
9
+ - **Frozen:** do NOT edit files under `lib/`. To upgrade, replace `lib/` + update this NOTICE (version + date). Note: v2+ adds `luxon` as a runtime dep — vendoring those would require also vendoring luxon; v1.1.1 is intentionally dep-free. The `package.json` scope file is NOT part of the upstream lib — it's our CJS-in-ESM interop shim; keep it across upgrades.
10
10
 
11
11
  ## Why vendored (per SPEC-5a §9, Q9=A)
12
12
  cron expression parsing is commodity plumbing (DST, month-length, DOW/DOM OR-semantics, Feb 29).
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }
@@ -8,6 +8,13 @@ export interface PhaseArtifacts {
8
8
  summary: string;
9
9
  }
10
10
 
11
+ /** SPEC-5a robustness: the error variant when the worktree git state is corrupted (e.g. child
12
+ * ran rm -rf .git). runLifecycle treats this as a failed phase (clean abort) instead of an
13
+ * unhandled throw. Matches the artifactDiscovery union in run-lifecycle.ts. */
14
+ export interface PhaseArtifactsError {
15
+ error: string;
16
+ }
17
+
11
18
  function sh(cmd: string, cwd: string): string {
12
19
  return execSync(cmd, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).toString();
13
20
  }
@@ -22,11 +29,19 @@ export class DiffService {
22
29
  *
23
30
  * @param childFinalText the child's final text, truncated to MAX_SUMMARY chars as the prose summary.
24
31
  */
25
- diffPhase(worktreePath: string, baseRef: string, childFinalText = ""): PhaseArtifacts {
26
- const tracked = sh(`git diff --name-only ${baseRef} --`, worktreePath)
27
- .split("\n")
28
- .filter(Boolean);
29
- const status = sh("git status --porcelain", worktreePath);
32
+ diffPhase(worktreePath: string, baseRef: string, childFinalText = ""): PhaseArtifacts | PhaseArtifactsError {
33
+ // SPEC-5a robustness: a child can corrupt its worktree's git state (e.g. rm -rf .git,
34
+ // git init over the worktree link). Catch git failures + return {error} so runLifecycle
35
+ // treats it as a failed phase (clean abort with surfaced cause) instead of an unhandled
36
+ // throw crashing the async runner. Empty diff (no changes) is NOT an error — returns paths: [].
37
+ let tracked: string[] = [];
38
+ let status = "";
39
+ try {
40
+ tracked = sh(`git diff --name-only ${baseRef} --`, worktreePath).split("\n").filter(Boolean);
41
+ status = sh("git status --porcelain", worktreePath);
42
+ } catch (e) {
43
+ return { error: `worktree diff failed: ${(e as Error).message.split("\n").filter(Boolean).pop() ?? (e as Error).message}` };
44
+ }
30
45
  const untracked = status
31
46
  .split("\n")
32
47
  .filter((l) => l.startsWith("?? "))