diff --git a/package.json b/package.json index 4f969a2..dad60e4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/index.ts b/src/index.ts index 6bf2fc6..644f8fb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -258,7 +258,9 @@ export default async function (pi: ExtensionAPI): Promise { // 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"); } diff --git a/src/runtime/reconcile.ts b/src/runtime/reconcile.ts index 9fd3073..1c46b07 100644 --- a/src/runtime/reconcile.ts +++ b/src/runtime/reconcile.ts @@ -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; @@ -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; diff --git a/test/reconcile.test.mts b/test/reconcile.test.mts index e30c6ce..ac33348 100644 --- a/test/reconcile.test.mts +++ b/test/reconcile.test.mts @@ -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 }); -}); \ No newline at end of file +}); +// 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 }); +});