Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lifecycles/default.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,20 @@ phases:
skills: [writing-plans]
agent: general-purpose
checkpoint: true
gates: [completenessCheck]
- name: implement
skills: [executing-plans, test-driven-development, verification-before-completion]
agent: general-purpose
checkpoint: false
gates: [verification-before-completion, completenessCheck, gate]
- name: review
skills: [requesting-code-review, receiving-code-review]
agent: general-purpose
checkpoint: true
- name: finish
skills: [finishing-a-development-branch]
agent: general-purpose
challengeStep: false
---

## brainstorm
Expand Down
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.10.3",
"version": "0.11.0",
"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
5 changes: 5 additions & 0 deletions src/backend/claude-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,9 @@ export class ClaudeChildSession implements ChildSession {
isDisposed(): boolean {
return this.disposed;
}

/** SPEC-6-2: cross-process liveness probe — is the claude child proc still running? */
isAlive(): boolean {
return !this.disposed && this.proc.killed === false && this.proc.exitCode === null && this.proc.signalCode === null;
}
}
7 changes: 7 additions & 0 deletions src/engine/run-registry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// src/engine/run-registry.ts
import type { FleetRunStatus } from "../todo-sync/port.ts";
import type { LiveSessionHandle } from "./spawnSubagent.ts";
import type { BackendId } from "../lifecycle/lifecycle-types.ts";

export interface RunRecord {
runId: string;
Expand Down Expand Up @@ -29,6 +30,12 @@ export interface RunRecord {
contextTokens?: number;
/** SPEC-6-1: the tier name this run used (for Tiers-view "used by" + per-tier spend). */
tier?: string;
/** SPEC-6-2: the cwd this run belongs to (widget cross-cwd filter + reconcile ownership). */
cwd: string;
/** SPEC-6-2: the backend (probe dispatch: pi→handle, claude→pid). */
backend: BackendId;
/** SPEC-6-2: claude-backend child PID (cross-process liveness probe). */
pid?: number;
/** SPEC-5b-4: live session handle while status === "running"; cleared by finishRun.
* Transient, in-memory only — never written to RunLog (the journal append constructs
* a plain object, not RunRecord). */
Expand Down
27 changes: 23 additions & 4 deletions src/engine/spawnSubagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export interface ChildSession {
steer?(text: string): Promise<void>;
/** SPEC-5b-4: optional live streaming flag. Pi backend forwards the native SDK getter; claude omits. */
readonly isStreaming?: boolean;
/** SPEC-6-2: is the underlying session still active (not disposed/ended/killed)? */
isAlive?(): boolean;
}

/** SPEC-5b-4: narrow live-session handle retained on RunRecord while status === "running".
Expand All @@ -65,6 +67,8 @@ export interface LiveSessionHandle {
subscribe(handler: (e: ChildSessionEvent) => void): () => void;
readonly isStreaming: boolean;
readonly supportsSteer: boolean;
/** SPEC-6-2: is the underlying session still active (not disposed/ended/killed)? */
isAlive(): boolean;
}

/** SPEC-5b-4: wrap a ChildSession into a narrow LiveSessionHandle for the panel.
Expand All @@ -76,6 +80,8 @@ export function toLiveHandle(session: ChildSession): LiveSessionHandle {
subscribe: (h) => session.subscribe(h),
get isStreaming() { return session.isStreaming ?? false; },
get supportsSteer() { return typeof session.steer === "function"; },
isAlive: () => typeof (session as { isAlive?: () => boolean }).isAlive === "function"
? (session as { isAlive: () => boolean }).isAlive() : true,
};
}

Expand Down Expand Up @@ -212,10 +218,8 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
runId, agent: agentDef.name, model, task: opts.task, track,
todoId: null, status: "running", startedAt,
tier: tier?.name, costTotal: 0, contextTokens: 0,
cwd: opts.parentCwd, backend: backendId,
});
try {
opts.runLog?.append(runId, { type: "run:meta", runId, agent: agentDef.name, model, task: opts.task, startedAt, track, todoId: null });
} catch { /* best-effort: journal is the index, not the product */ }

// todo-sync (before) — only when both caller tracks AND agent allows todoSync
let priorStatus: string | undefined;
Expand Down Expand Up @@ -280,7 +284,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
if (e.type === "session_init" && e.backendSessionId) {
opts.runRegistry.update(runId, { backendSessionId: e.backendSessionId, sessionKey: agentDef.sessionKey });
try {
opts.runLog?.append(runId, { type: "run:meta", runId, agent: agentDef.name, model, task: opts.task, startedAt, track, todoId, backendSessionId: e.backendSessionId, sessionKey: agentDef.sessionKey });
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 });
} catch { /* best-effort */ }
} else if (e.type === "turn_start") {
turnIdx++;
Expand Down Expand Up @@ -355,11 +359,26 @@ function fail(runId: string, startedAt: number, message: string, agent: string):
};
}

/** SPEC-6-2: guard against double-finishRun (abort-then-complete). */
const finalizedRunIds = new Set<string>();

async function finishRun(
opts: SpawnOptions, runId: string, startedAt: number,
status: FleetRunStatus, finalText: string, todoId: string | null, priorStatus: string | undefined,
error: string | undefined, agentName: string, model: string, tokenTotal = 0, costTotal = 0, contextTokens = 0,
): Promise<SpawnResult> {
if (finalizedRunIds.has(runId)) {
// Already finalized — return the existing registry record's result without re-appending.
const existing = opts.runRegistry.get(runId);
return {
status: existing?.status ?? status, finalText: existing?.resultSummary ?? finalText,
runId, todoId, agent: agentName, model,
durationMs: existing?.endedAt ? existing.endedAt - startedAt : Date.now() - startedAt,
tokenTotal, costTotal, contextTokens, error,
};
}
finalizedRunIds.add(runId);

const endedAt = Date.now();
opts.runRegistry.update(runId, {
status, endedAt, resultSummary: finalText.slice(0, 120),
Expand Down
30 changes: 30 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import { reconcileRuns } from "./runtime/reconcile.ts";
import { Scheduler } from "./scheduling/scheduler.ts";
import { createFleetResultsTool } from "./tools/fleet-results.ts";
import { BgRunsStore } from "./panel/bg-runs-store.ts";
import { GateRegistry } from "./lifecycle/gates/registry.ts";
import { registerBuiltinGates } from "./lifecycle/gates/builtin.ts";
import { FleetWidgetController } from "./panel/fleet-widget.ts";
import { TierRegistry, mergeTiers } from "./tiers/tier-registry.ts";
import { BUILTIN_TIERS } from "./tiers/builtin.ts";
Expand Down Expand Up @@ -177,6 +179,20 @@ export default async function (pi: ExtensionAPI): Promise<void> {
// Builtin-only placeholder tier registry so spawn works before session_start rebuilds with merged tiers.
deps.tierRegistry = new TierRegistry({ tiers: BUILTIN_TIERS, agents: deps.registry });

// SPEC-6-2: gate registry + builtin gate registration.
const gateRegistry = new GateRegistry();
registerBuiltinGates(gateRegistry);
// Wire gate deps into lifecycleDeps (both the async + foreground lifecycle sites spread from lifecycleDeps).
deps.lifecycleDeps.gateRegistry = gateRegistry;
deps.lifecycleDeps.getGateCtxState = (todoId: string, _agentName: string) => {
const runs = deps.runRegistry.list().filter((r) => r.todoId === todoId);
const lifecycleCost = runs.reduce((s, r) => s + (r.costTotal ?? 0), 0);
const contextTokens = runs.reduce((max, r) => Math.max(max, r.contextTokens ?? 0), 0);
const tierName = runs.find((r) => r.tier)?.tier;
const tier = tierName ? deps.tierRegistry?.get(tierName) : undefined;
return { lifecycleCost, contextTokens, tier };
};

// ── SPEC-5a: operational runtime (async/bg + scheduling + worktree isolation) ──
const fleetDir = (cwd: string) => join(cwd, ".pi", "fleet");
const bgRuns = new BgRunsStore();
Expand Down Expand Up @@ -274,6 +290,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
notify: (m, lvl) => ctx.ui.notify(m, lvl),
genRunId: () => "fl-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8),
onProgress: (runId, status) => { bgRuns.set(runId, status); },
runRegistry: deps.runRegistry,
};
deps.scheduler = new Scheduler({
storePath: join(dir, "schedules.json"),
Expand All @@ -295,12 +312,16 @@ export default async function (pi: ExtensionAPI): Promise<void> {
return sharedModelRegistry.find(provider, id)?.contextWindow;
};
deps.getModelContextWindow = getModelContextWindow;
// SPEC-6-2: thread getModelContextWindow into lifecycle deps for gate ctx.
deps.lifecycleDeps.getModelContextWindow = getModelContextWindow;
fleetWidget = new FleetWidgetController({
runRegistry: deps.runRegistry,
bgRuns,
ui: ctx.ui as never,
getTheme: () => ctx.ui.theme,
getModelContextWindow,
cwd: ctx.cwd,
runLog: deps.runLog,
});
fleetWidget.start();

Expand Down Expand Up @@ -390,4 +411,13 @@ export default async function (pi: ExtensionAPI): Promise<void> {
ctx.ui.notify(`lifecycle ${res.status}: ${res.runId}${res.error ? " — " + res.error : ""}`, res.status === "completed" ? "info" : "warning");
},
});

// SPEC-6-2: gate extensibility — pi doesn't expose registerGate, so we provide a command
// so other extensions can register custom gates at runtime.
pi.registerCommand("fleet-register-gate", {
description: "Register a custom gate on the fleet gate registry (extensibility path).",
handler: async (_args, ctx) => {
ctx.ui.notify("fleet-register-gate: custom gates must be registered via the GateRegistry module export (see src/lifecycle/gates/registry.ts).", "info");
},
});
}
3 changes: 3 additions & 0 deletions src/lifecycle/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,20 @@ phases:
skills: [writing-plans]
agent: general-purpose
checkpoint: true
gates: [completenessCheck]
- name: implement
skills: [executing-plans, test-driven-development, verification-before-completion]
agent: general-purpose
checkpoint: false
gates: [verification-before-completion, completenessCheck, gate]
- name: review
skills: [requesting-code-review, receiving-code-review]
agent: general-purpose
checkpoint: true
- name: finish
skills: [finishing-a-development-branch]
agent: general-purpose
challengeStep: false
---

## brainstorm
Expand Down
14 changes: 14 additions & 0 deletions src/lifecycle/gates/builtin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// src/lifecycle/gates/builtin.ts
import type { GateRegistry } from "./registry.ts";
import { verificationBeforeCompletionGate } from "./verification-before-completion.ts";
import { completenessCheckGate } from "./completeness-check.ts";
import { gateGate } from "./gate.ts";
import { verifyGate } from "./verify.ts";

/** Register the 4 builtin gates on a GateRegistry. */
export function registerBuiltinGates(reg: GateRegistry): void {
reg.register(verificationBeforeCompletionGate);
reg.register(completenessCheckGate);
reg.register(gateGate);
reg.register(verifyGate);
}
37 changes: 37 additions & 0 deletions src/lifecycle/gates/chain-runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { GateDef, GateCtx, GateResult } from "./registry.ts";

export interface GateChainOutcome {
results: GateResult[];
shortCircuit?: { action: "revise" | "abort"; feedback?: string; reason?: string };
}

/** Run the gate chain left-to-right. Advise-failures continue; revise/abort short-circuit. */
export async function runGateChain(opts: { gates: GateDef[]; ctx: GateCtx }): Promise<GateChainOutcome> {
const results: GateResult[] = [];
for (const gate of opts.gates) {
// Each gate sees its own params on ctx.gateParams (set per-gate by the caller/run-lifecycle).
const gateCtx: GateCtx = { ...opts.ctx, gateParams: gate.params };
const started = Date.now();
let result: GateResult;
let crashed = false;
try {
result = await gate.run(gateCtx);
} catch (e) {
// A throwing gate is treated as an advise-failure (never auto-revise on a crash).
crashed = true;
result = { gate: gate.name, kind: gate.kind, passed: false, evidence: `gate '${gate.name}' threw: ${(e as Error).message}`, onFail: "advise" };
}
result.durationMs = Date.now() - started;
results.push(result);
if (!crashed && !result.passed) {
if (gate.onFail === "revise") {
return { results, shortCircuit: { action: "revise", feedback: result.evidence } };
}
if (gate.onFail === "abort") {
return { results, shortCircuit: { action: "abort", reason: result.evidence } };
}
// advise → continue
}
}
return { results };
}
36 changes: 36 additions & 0 deletions src/lifecycle/gates/completeness-check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { existsSync, statSync } from "node:fs";
import { isAbsolute, resolve } from "node:path";
import type { GateDef, GateCtx, GateResult } from "./registry.ts";

export interface CompletenessResult { passed: boolean; evidence: string; }

/** Pure-ish: stat every claimed path. Relative paths resolve against baseDir. */
export function checkCompleteness(paths: string[], baseDir: string): CompletenessResult {
if (paths.length === 0) return { passed: true, evidence: "no claimed artifacts (terminal-phase exemption)" };
const missing: string[] = [];
let found = 0;
for (const p of paths) {
const abs = isAbsolute(p) ? p : resolve(baseDir, p);
try {
statSync(abs);
found++;
} catch {
missing.push(p);
}
}
if (missing.length > 0) {
return { passed: false, evidence: `missing: ${missing.join(", ")} (${found}/${paths.length} exist)` };
}
return { passed: true, evidence: `${paths.length}/${paths.length} artifacts exist` };
}

export const completenessCheckGate: GateDef = {
name: "completenessCheck",
kind: "predicate",
onFail: "revise",
run: async (ctx: GateCtx): Promise<GateResult> => {
const base = ctx.worktreePath ?? process.cwd();
const r = checkCompleteness(ctx.phaseRec.paths, base);
return { gate: "completenessCheck", kind: "predicate", passed: r.passed, evidence: r.evidence, onFail: "revise" };
},
};
41 changes: 41 additions & 0 deletions src/lifecycle/gates/gate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { GateDef, GateCtx, GateResult } from "./registry.ts";
import type { Tier } from "../../tiers/tier-registry.ts";

export interface BudgetInput {
lifecycleCost: number;
contextTokens: number;
tier?: Tier;
params?: Record<string, unknown>;
}
export interface BudgetResult { passed: boolean; evidence: string; }

/** Pure: assert cost < cap and context < floor. Missing tier/caps → skip (pass). */
export function assertBudget(input: BudgetInput): BudgetResult {
const { lifecycleCost, contextTokens, tier, params } = input;
if (!tier) return { passed: true, evidence: "no tier → no caps to assert (skip)" };
const costCap = typeof (params as { costCap?: number } | undefined)?.costCap === "number"
? (params as { costCap: number }).costCap : tier.costCap;
const contextFloor = typeof (params as { contextFloor?: number } | undefined)?.contextFloor === "number"
? (params as { contextFloor: number }).contextFloor : tier.contextFloor;
const parts: string[] = [];
if (typeof costCap === "number") {
if (lifecycleCost > costCap) return { passed: false, evidence: `cost $${lifecycleCost.toFixed(2)} > cap $${costCap.toFixed(2)}` };
parts.push(`cost $${lifecycleCost.toFixed(2)} < cap $${costCap.toFixed(2)}`);
}
if (typeof contextFloor === "number") {
if (contextTokens > contextFloor) return { passed: false, evidence: `context ${contextTokens} > floor ${contextFloor}` };
parts.push(`ctx ${contextTokens} < floor ${contextFloor}`);
}
if (parts.length === 0) return { passed: true, evidence: "tier has no costCap/contextFloor → nothing to assert" };
return { passed: true, evidence: parts.join("; ") };
}

export const gateGate: GateDef = {
name: "gate",
kind: "predicate",
onFail: "abort",
run: async (ctx: GateCtx): Promise<GateResult> => {
const r = assertBudget({ lifecycleCost: ctx.lifecycleCost, contextTokens: ctx.contextTokens, tier: ctx.tier, params: ctx.gateParams });
return { gate: "gate", kind: "predicate", passed: r.passed, evidence: r.evidence, onFail: "abort" };
},
};
Loading
Loading