Skip to content
Merged
841 changes: 841 additions & 0 deletions docs/superpowers/plans/2026-07-28-bg-non-git-isolation-split.md

Large diffs are not rendered by default.

194 changes: 194 additions & 0 deletions docs/superpowers/specs/2026-07-28-bg-non-git-isolation-split-design.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@getpipher/armory-fleet",
"version": "0.11.0",
"version": "0.11.1",
"private": false,
"description": "The armory suite's subagent orchestrator for the pi coding agent \u2014 a cross-harness, superpowers-native fleet where every agent is armory-native from birth.",
"license": "MIT",
Expand Down
13 changes: 8 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,17 +212,20 @@ export default async function (pi: ExtensionAPI): Promise<void> {
// foreground subagent holding deps.lock made every bg run's first-phase spawn fail fast
// (tryAcquire → "concurrency lock unexpectedly unavailable" → 6ms run:aborted).
const bgLock = createSingleSlotLock();
// v0.11.1: isolated runs use worktree-diff artifact discovery + the worktree as spawn cwd;
// in-place runs (worktreePath undefined) use the prompt-baked parser + the session cwd.
const isolated = !!opts.worktreePath;
const lifecycleFullDeps: LifecycleRunDeps = {
...deps.lifecycleDeps,
genRunId: () => opts.runId, // override: use the async runner's runId
// SPEC-5a (Q3=A): isolated run — worktree-diff artifact discovery instead of the prompt-baked block.
artifactDiscovery: ({ finalText, cwd, baseRef }) => (deps.asyncRunner as AsyncRunnerDeps).diff.diffPhase(cwd, baseRef, finalText),
...(isolated ? { artifactDiscovery: ({ finalText, cwd, baseRef }: { finalText: string; cwd: string; baseRef: string }) => (deps.asyncRunner as AsyncRunnerDeps).diff.diffPhase(cwd, baseRef, finalText) } : {}),
spawn: async (o) => spawnSubagent({
agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
skillsOverride: o.skills, backendOverride: o.backend,
registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: bgLock,
backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: opts.worktreePath, runLog: deps.runLog,
tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry, // SPEC-6-1
backendRegistry: deps.backendRegistry, parentModel: deps.parentModel,
parentCwd: isolated ? opts.worktreePath! : deps.parentCwd,
runLog: deps.runLog, tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
}),
};
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" } });
Expand Down Expand Up @@ -297,7 +300,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
lockPath: join(dir, "schedules.lock"),
onFire: (spec) => {
if (!deps.asyncRunner) return;
runBackground(spec.task, { deps: deps.asyncRunner, lifecycle: spec.lifecycle ?? "default", mode: spec.auto ? "auto" : "checkpointed" });
runBackground(spec.task, { deps: deps.asyncRunner, lifecycle: spec.lifecycle ?? "default", mode: spec.auto ? "auto" : "checkpointed", isolation: spec.isolation });
},
});
deps.scheduler.start();
Expand Down
104 changes: 79 additions & 25 deletions src/runtime/async-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ export interface FakeLifecycleResult {

export interface RunLifecycleOpts {
runId: string;
worktreePath: string;
branch: string;
worktreePath?: string;
branch?: string;
mode: "auto" | "checkpointed";
}

Expand All @@ -49,12 +49,22 @@ export interface RunBackgroundOpts {
deps: AsyncRunnerDeps;
lifecycle: string;
mode: "auto" | "checkpointed";
/** v0.11.1: edit isolation for background runs. Default "auto" (worktree when cwd is a git repo, in-place otherwise). */
isolation?: Isolation;
}

export interface RunBackgroundHandle {
runId: string;
status: "background";
}
/** The success shape (back-compat: existing external refs to RunBackgroundHandle still typecheck). */
export interface RunBackgroundHandle { runId: string; status: "background"; }

/** v0.11.1: a background dispatch either starts (runId + background) or fails synchronously (error, no runId). */
export type RunBackgroundResult =
| { runId: string; status: "background" }
| { status: "failed"; error: string };

export type Isolation = "worktree" | "none" | "auto";

/** Per-session dedup flag for the auto-fallback in-place notify (resets on process restart = new session). */
let inPlaceFallbackWarned = false;

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 {
if (!deps.onProgress) return;
Expand All @@ -72,54 +82,98 @@ function sh(cmd: string, cwd: string): void {
execSync(cmd, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
}

export function runBackground(task: string, opts: RunBackgroundOpts): RunBackgroundHandle {
/** The git-agnostic core: pool slot → journal → runLifecycle in ctx.cwd (or a worktree, when `isolated` is set) → inbox + notify.
* Fire-and-forget. When `isolated` is present, journals the worktree field, commits on completion, and removes the worktree. */
function runBackgroundInPlace(runId: string, task: string, opts: RunBackgroundOpts, isolated?: { worktreePath: string; branch: string }): void {
const { deps } = opts;
const runId = deps.genRunId();
const baseRef = "HEAD";

// Fire-and-forget: the pool gates concurrency; the journal records the run.
void deps.pool.withSlot(async () => {
let wt: { path: string; branch: string } | null = null;
try {
wt = deps.worktree.create(runId, baseRef);
const ev0: JournalEvent = { type: "run:started", runId, task, lifecycle: opts.lifecycle, worktree: { path: wt.path, branch: wt.branch }, mode: opts.mode, ts: Date.now() };
const ev0: JournalEvent = isolated
? { type: "run:started", runId, task, lifecycle: opts.lifecycle, worktree: { path: isolated.worktreePath, branch: isolated.branch }, mode: opts.mode, ts: Date.now() }
: { type: "run:started", runId, task, lifecycle: opts.lifecycle, mode: opts.mode, ts: Date.now() };
deps.journal.append(runId, ev0);
emitProgress(deps, runId, { status: "running", phase: "", phaseIndex: 0, phaseTotal: 0, lifecycle: opts.lifecycle, mode: opts.mode, task });

const res = await deps.runLifecycle(task, opts.lifecycle, { runId, worktreePath: wt.path, branch: wt.branch, mode: opts.mode });
const res = await deps.runLifecycle(task, opts.lifecycle, { runId, worktreePath: isolated?.worktreePath, branch: isolated?.branch, mode: opts.mode });

if (res.status === "completed") {
// commit the worktree to the branch (lifecycle finish phase or single-delegate completion)
try { sh("git add -A && git commit -m 'fleet run complete'", wt.path); } catch { /* nothing to commit */ }
deps.journal.append(runId, { type: "run:completed", runId, branch: wt.branch, ts: Date.now() });
if (isolated) {
try { sh("git add -A && git commit -m 'fleet run complete'", isolated.worktreePath); } catch { /* nothing to commit */ }
}
deps.journal.append(runId, isolated
? { type: "run:completed", runId, branch: isolated.branch, ts: Date.now() }
: { type: "run:completed", runId, ts: Date.now() });
const total = res.phases.length;
const lastIdx = total; // completed = past the last phase
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 });
const lastIdx = total;
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 } : {}) });
const lastPhase = res.phases[res.phases.length - 1];
const result: RunResult = {
runId, task, status: "completed",
summary: lastPhase?.summary ?? "",
paths: res.phases.flatMap((p) => p.paths),
branch: wt.branch, completedAt: Date.now(),
completedAt: Date.now(),
...(isolated ? { branch: isolated.branch } : {}),
};
deps.inbox.push(result);
deps.notify(`fleet run ${runId} completed`, "info");
// SPEC-5a: the worktree dir is temporary scaffolding; remove it but keep the branch for merge/inspection.
deps.worktree.removeWorktree(runId);
if (isolated) deps.worktree.removeWorktree(runId);
} else {
deps.journal.append(runId, { type: "run:aborted", runId, reason: res.error ?? res.status, ts: Date.now() });
deps.worktree.remove(runId);
if (isolated) deps.worktree.remove(runId);
emitProgress(deps, runId, { status: "failed", phase: "", phaseIndex: 0, phaseTotal: res.phases.length, lifecycle: opts.lifecycle, mode: opts.mode, task });
deps.notify(`fleet run ${runId} ${res.status}: ${res.error ?? ""}`, "warning");
}
} catch (e) {
const msg = (e as Error).message;
deps.journal.append(runId, { type: "run:aborted", runId, reason: msg, ts: Date.now() });
if (wt) deps.worktree.remove(runId);
if (isolated) deps.worktree.remove(runId);
deps.notify(`fleet run ${runId} failed: ${msg}`, "error");
emitProgress(deps, runId, { status: "failed", phase: "", phaseIndex: 0, phaseTotal: 0, lifecycle: opts.lifecycle, mode: opts.mode, task });
}
});
}

/** The worktree wrapper: SYNCHRONOUS pre-flight + worktree create, then core-with-isolation. */
function runBackgroundIsolated(task: string, opts: RunBackgroundOpts): RunBackgroundResult {
const { deps } = opts;
if (!deps.worktree.isGitRepo()) {
return { status: "failed", error: "isolation: 'worktree' requires a git repo; cwd is not one — use isolation: 'none' or run in a git repo" };
}
const runId = deps.genRunId();
const baseRef = "HEAD";
let wt: { path: string; branch: string };
try {
wt = deps.worktree.create(runId, baseRef);
} catch (e) {
return { status: "failed", error: (e as Error).message };
}
runBackgroundInPlace(runId, task, opts, { worktreePath: wt.path, branch: wt.branch });
return { runId, status: "background" };
}

/** The auto router: isolated when cwd is a git repo, in-place + one per-session notify when not. */
function runBackgroundAuto(task: string, opts: RunBackgroundOpts): RunBackgroundResult {
const { deps } = opts;
if (deps.worktree.isGitRepo()) {
return runBackgroundIsolated(task, opts);
}
if (!inPlaceFallbackWarned) {
inPlaceFallbackWarned = true;
deps.notify("background run in-place (no worktree isolation — parallel edits may conflict)", "warning");
}
const runId = deps.genRunId();
runBackgroundInPlace(runId, task, opts);
return { runId, status: "background" };
}

/** Public dispatcher (keeps the `runBackground` name + RunBackgroundHandle success shape for back-compat). */
export function runBackground(task: string, opts: RunBackgroundOpts): RunBackgroundResult {
const isolation = opts.isolation ?? "auto";
if (isolation === "worktree") return runBackgroundIsolated(task, opts);
if (isolation === "none") {
const runId = opts.deps.genRunId();
runBackgroundInPlace(runId, task, opts);
return { runId, status: "background" };
}
return runBackgroundAuto(task, opts);
}
36 changes: 21 additions & 15 deletions src/runtime/resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ export interface ResumeCandidate {
runId: string;
task: string;
lifecycle: string;
worktreePath: string;
branch: string;
/** Present only for isolated (worktree) runs. */
worktreePath?: string;
/** Present only for isolated (worktree) runs. */
branch?: string;
lastPhase: string | null;
canResume: boolean;
}
Expand All @@ -26,23 +28,27 @@ export function scanResumeCandidates(_projectDir: string, opts: ScanResumeOpts):
for (const runId of ids) {
const events = journal.replay(runId);
const started = events.find((e) => e.type === "run:started") as
| (JournalEvent & { type: "run:started" }) | undefined;
| (JournalEvent & { type: "run:started"; worktree?: { path: string; branch: string } }) | undefined;
if (!started) continue;
const phaseEvents = events.filter((e) => e.type === "phase:completed" || e.type === "phase:started" || e.type === "phase:failed") as Array<{ phase: string }>;
const lastPhase = phaseEvents.length > 0 ? phaseEvents[phaseEvents.length - 1]!.phase : null;
const wtExists = opts.worktree.exists(runId);
if (!wtExists) {
journal.append(runId, { type: "run:aborted", runId, reason: "worktree-missing", ts: Date.now() });

if (started.worktree) {
// isolated run: resume iff the worktree still exists
const wtExists = opts.worktree.exists(runId);
if (!wtExists) {
journal.append(runId, { type: "run:aborted", runId, reason: "worktree-missing", ts: Date.now() });
}
cands.push({
runId, task: started.task, lifecycle: started.lifecycle,
worktreePath: started.worktree.path, branch: started.worktree.branch,
lastPhase, canResume: wtExists,
});
} else {
// v0.11.1: in-place interrupted run — no worktree to clean; abort (partial edits may remain in cwd).
journal.append(runId, { type: "run:aborted", runId, reason: "in-place interrupted (partial edits may remain in cwd)", ts: Date.now() });
cands.push({ runId, task: started.task, lifecycle: started.lifecycle, lastPhase, canResume: false });
}
cands.push({
runId,
task: started.task,
lifecycle: started.lifecycle,
worktreePath: started.worktree.path,
branch: started.worktree.branch,
lastPhase,
canResume: wtExists,
});
}
return cands;
}
4 changes: 2 additions & 2 deletions src/runtime/run-journal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";

export interface RunStartedEvent { type: "run:started"; runId: string; task: string; lifecycle: string; worktree: { path: string; branch: string }; mode: "auto" | "checkpointed"; ts: number; }
export interface RunStartedEvent { type: "run:started"; runId: string; task: string; lifecycle: string; worktree?: { path: string; branch: string }; mode: "auto" | "checkpointed"; ts: number; }
export interface PhaseStartedEvent { type: "phase:started"; phase: string; ts: number; }
export interface PhaseCompletedEvent { type: "phase:completed"; phase: string; summary: string; paths: string[]; ts: number; }
export interface PhaseFailedEvent { type: "phase:failed"; phase: string; error: string; ts: number; }
export interface CheckpointEvent { type: "checkpoint"; phase: string; decision: "continue" | "revise" | "abort"; ts: number; }
export interface RunCompletedEvent { type: "run:completed"; runId: string; branch: string; ts: number; }
export interface RunCompletedEvent { type: "run:completed"; runId: string; branch?: string; ts: number; }
export interface RunAbortedEvent { type: "run:aborted"; runId: string; reason: string; ts: number; }

export type JournalEvent =
Expand Down
4 changes: 4 additions & 0 deletions src/scheduling/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface ScheduleSpec {
expression: string;
lifecycle?: string; // default "default"
auto?: boolean;
/** v0.11.1: edit isolation for the background run on fire. Default "auto". */
isolation?: "worktree" | "none" | "auto";
}

export interface Schedule extends ScheduleSpec {
Expand Down Expand Up @@ -71,6 +73,7 @@ export class Scheduler {
expression: spec.expression,
lifecycle: spec.lifecycle ?? "default",
auto: spec.auto ?? true,
isolation: spec.isolation,
paused: false,
};
this.schedules.set(id, { spec: stored, expr, timer: null });
Expand All @@ -86,6 +89,7 @@ export class Scheduler {
expression: e.spec.expression,
lifecycle: e.spec.lifecycle,
auto: e.spec.auto,
isolation: e.spec.isolation,
paused: e.spec.paused,
nextFire: e.spec.paused ? null : e.expr.nextFire(new Date()),
}));
Expand Down
10 changes: 8 additions & 2 deletions src/tools/subagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export const subagentParams = Type.Object({
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." })),
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." })),
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." })),
isolation: Type.Optional(Type.Union([
Type.Literal("worktree"),
Type.Literal("none"),
Type.Literal("auto"),
], { 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." })),
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.' })),
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.' })),
});
Expand Down Expand Up @@ -80,13 +85,14 @@ export function createSubagentTool(deps: SubagentToolDeps) {
}
if (params.schedule) {
if (!deps.scheduler) return { isError: true, content: [{ type: "text" as const, text: "scheduling not configured (scheduler missing)" }] };
const id = deps.scheduler.register({ task: params.task, expression: params.schedule, lifecycle: params.lifecycle ?? "default", auto: params.auto ?? true });
const id = deps.scheduler.register({ task: params.task, expression: params.schedule, lifecycle: params.lifecycle ?? "default", auto: params.auto ?? true, isolation: params.isolation });
const entry = deps.scheduler.list().find((s) => s.id === id);
return { content: [{ type: "text" as const, text: `scheduled: ${id} · next fire: ${entry?.nextFire?.toISOString() ?? "(paused)"}` }], details: { scheduleId: id, nextFire: entry?.nextFire ?? null } };
}
if (params.background) {
if (!deps.asyncRunner) return { isError: true, content: [{ type: "text" as const, text: "background runs not configured (asyncRunner missing)" }] };
const handle = runBackground(params.task, { deps: deps.asyncRunner, lifecycle: params.lifecycle ?? "default", mode: "auto" });
const handle = runBackground(params.task, { deps: deps.asyncRunner, lifecycle: params.lifecycle ?? "default", mode: "auto", isolation: params.isolation });
if (handle.status === "failed") return { isError: true, content: [{ type: "text" as const, text: handle.error }] };
return { content: [{ type: "text" as const, text: `background run: ${handle.runId}` }], details: handle };
}
if (params.lifecycle) {
Expand Down
10 changes: 10 additions & 0 deletions src/worktree/worktree-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ export class WorktreeService {
return existsSync(this.pathFor(runId));
}

/** v0.11.1: is `rootDir` (or `dir`) inside a git repo? Cheap sync pre-flight for isolation routing. */
isGitRepo(dir: string = this.rootDir): boolean {
try {
sh("git rev-parse --show-toplevel", dir);
return true;
} catch {
return false;
}
}

create(runId: string, baseRef = "HEAD"): WorktreeRef {
if (this.exists(runId)) {
throw new Error(`worktree for run ${runId} already exists at ${this.pathFor(runId)}`);
Expand Down
Loading
Loading