@getpipher/armory-fleet 0.5.1 → 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.1",
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++;
@@ -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("?? "))