Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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.1",
"version": "0.10.2",
"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
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
// SPEC-5b-1: per-session RunLog at .pi/fleet/conversations/ (separate from the
// SPEC-5a phase journal at .pi/fleet/runs/ — different granularity, no filename collision).
deps.runLog = new RunLog(join(dir, "conversations"));
const reconciled = reconcileRuns(deps.runLog);
// v0.10.2: pass the in-memory RunRegistry so reconcile syncs it too — otherwise orphaned
// (process-gone) runs keep status:"running" in memory and the live widget shows a stale ▶ forever.
const reconciled = reconcileRuns(deps.runLog, { runRegistry: deps.runRegistry });
if (reconciled.length > 0) {
ctx.ui.notify(`reconciled ${reconciled.length} interrupted fleet run${reconciled.length > 1 ? "s" : ""} (marked aborted)`, "info");
}
Expand Down
17 changes: 17 additions & 0 deletions src/runtime/reconcile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,32 @@
// SPEC-5b-1 — on pi boot, mark orphan RunLog runs (run:meta with no run:ended whose process
// is gone) as aborted so the Runs tab doesn't show stale "running" rows across restarts.
// Foreground orphans; bg/lifecycle orphans are already handled by scanResumeCandidates (SPEC-5a).
//
// v0.10.2 patch: reconcile now ALSO syncs the in-memory RunRegistry (opts.runRegistry). Before this,
// reconcile only wrote run:ended: aborted to the durable RunLog — the in-memory RunRegistry kept
// status:"running", so the live above-editor widget (filterActive keeps running|queued|paused)
// rendered a stale ▶ row that ticked forever for every orphaned (process-gone) run.
import type { RunLog } from "./run-log.ts";
import type { RunRegistry } from "../engine/run-registry.ts";

export interface ReconcileOpts {
/** Orphans whose startedAt is older than (now - graceMs) are marked aborted. Default 60000. */
graceMs?: number;
/** Test injection. Default Date.now(). */
now?: number;
/**
* v0.10.2: the in-memory RunRegistry to sync alongside the durable log. When set, each orphan
* reconciled in the log is also transitioned to status:"aborted" in memory so the live widget
* clears its stale ▶ row. Optional — existing callers that pass only a RunLog are unaffected.
*/
runRegistry?: RunRegistry;
}

/** Returns the runIds it marked aborted. Idempotent: a run already ended is skipped. */
export function reconcileRuns(log: RunLog, opts: ReconcileOpts = {}): string[] {
const grace = opts.graceMs ?? 60_000;
const now = opts.now ?? Date.now();
const reg = opts.runRegistry;
const aborted: string[] = [];
for (const meta of log.scanMeta()) {
if (meta.status !== "running") continue;
Expand All @@ -23,6 +36,10 @@ export function reconcileRuns(log: RunLog, opts: ReconcileOpts = {}): string[] {
type: "run:ended", runId: meta.runId, status: "aborted",
endedAt: now, resultSummary: "process-gone", tokenTotal: meta.tokenTotal,
});
// v0.10.2: sync the in-memory registry so the live widget (which reads runRegistry.list(),
// not the RunLog) clears the orphan's stale ▶ row. No-op when the run isn't in the registry
// (e.g. a cross-cwd orphan from another session — out of scope for this patch).
reg?.update(meta.runId, { status: "aborted", endedAt: now });
aborted.push(meta.runId);
}
return aborted;
Expand Down
45 changes: 44 additions & 1 deletion test/reconcile.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,47 @@ test("default now = Date.now(); graceMs default 60000", () => {
log.append("fl-ancient", { type: "run:meta", runId: "fl-ancient", agent: "g", model: "m", task: "t", startedAt: 1, track: true, todoId: null });
assert.deepEqual(reconcileRuns(log), ["fl-ancient"]);
rmSync(dir, { recursive: true, force: true });
});
});
// SPEC-6-1 patch (v0.10.2): reconcile must also sync the in-memory RunRegistry, not just the
// durable RunLog. Otherwise orphaned (process-gone) runs stay status:"running" in memory and the
// live widget (filterActive keeps running|queued|paused) shows a stale ▶ row forever.
import { RunRegistry } from "../src/engine/run-registry.ts";

test("reconcile also marks the orphan aborted in the in-memory RunRegistry (v0.10.2)", () => {
const dir = makeDir();
const log = new RunLog(dir);
const reg = new RunRegistry();
const oldStarted = 1_000;
// The orphan exists in BOTH stores: durable log (run:meta, no run:ended) + in-memory registry (running).
log.append("fl-ghost", { type: "run:meta", runId: "fl-ghost", agent: "g", model: "m", task: "t", startedAt: oldStarted, track: true, todoId: null });
reg.add({ runId: "fl-ghost", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: oldStarted });
const aborted = reconcileRuns(log, { runRegistry: reg, now: oldStarted + GRACE + 5_000 });
assert.deepEqual(aborted, ["fl-ghost"]);
// Durable log updated (existing behavior).
assert.equal(log.scanMeta()[0]!.status, "aborted");
// NEW: in-memory registry also updated — the ghost row must clear from the live widget.
assert.equal(reg.get("fl-ghost")!.status, "aborted", "in-memory RunRegistry must transition to aborted so the widget stops showing ▶");
rmSync(dir, { recursive: true, force: true });
});

test("reconcile leaves a fresh orphan running in-memory (within grace)", () => {
const dir = makeDir();
const log = new RunLog(dir);
const reg = new RunRegistry();
const now = 50_000;
log.append("fl-fresh", { type: "run:meta", runId: "fl-fresh", agent: "g", model: "m", task: "t", startedAt: now - 1_000, track: true, todoId: null });
reg.add({ runId: "fl-fresh", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: now - 1_000 });
assert.deepEqual(reconcileRuns(log, { runRegistry: reg, now }), []);
assert.equal(reg.get("fl-fresh")!.status, "running", "fresh run untouched in-memory");
rmSync(dir, { recursive: true, force: true });
});

test("reconcile RunRegistry arg is optional (back-compat: existing callers passing only log)", () => {
const dir = makeDir();
const log = new RunLog(dir);
log.append("fl-solo", { type: "run:meta", runId: "fl-solo", agent: "g", model: "m", task: "t", startedAt: 1, track: true, todoId: null });
// No RunRegistry passed — must not throw.
assert.deepEqual(reconcileRuns(log, { now: 999_999_999 }), ["fl-solo"]);
assert.equal(log.scanMeta()[0]!.status, "aborted");
rmSync(dir, { recursive: true, force: true });
});
Loading