@getpipher/armory-fleet 0.10.2 → 0.11.0

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.10.2",
3
+ "version": "0.11.0",
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",
@@ -72,4 +72,9 @@ export class ClaudeChildSession implements ChildSession {
72
72
  isDisposed(): boolean {
73
73
  return this.disposed;
74
74
  }
75
+
76
+ /** SPEC-6-2: cross-process liveness probe — is the claude child proc still running? */
77
+ isAlive(): boolean {
78
+ return !this.disposed && this.proc.killed === false && this.proc.exitCode === null && this.proc.signalCode === null;
79
+ }
75
80
  }
@@ -1,6 +1,7 @@
1
1
  // src/engine/run-registry.ts
2
2
  import type { FleetRunStatus } from "../todo-sync/port.ts";
3
3
  import type { LiveSessionHandle } from "./spawnSubagent.ts";
4
+ import type { BackendId } from "../lifecycle/lifecycle-types.ts";
4
5
 
5
6
  export interface RunRecord {
6
7
  runId: string;
@@ -29,6 +30,12 @@ export interface RunRecord {
29
30
  contextTokens?: number;
30
31
  /** SPEC-6-1: the tier name this run used (for Tiers-view "used by" + per-tier spend). */
31
32
  tier?: string;
33
+ /** SPEC-6-2: the cwd this run belongs to (widget cross-cwd filter + reconcile ownership). */
34
+ cwd: string;
35
+ /** SPEC-6-2: the backend (probe dispatch: pi→handle, claude→pid). */
36
+ backend: BackendId;
37
+ /** SPEC-6-2: claude-backend child PID (cross-process liveness probe). */
38
+ pid?: number;
32
39
  /** SPEC-5b-4: live session handle while status === "running"; cleared by finishRun.
33
40
  * Transient, in-memory only — never written to RunLog (the journal append constructs
34
41
  * a plain object, not RunRecord). */
@@ -54,6 +54,8 @@ export interface ChildSession {
54
54
  steer?(text: string): Promise<void>;
55
55
  /** SPEC-5b-4: optional live streaming flag. Pi backend forwards the native SDK getter; claude omits. */
56
56
  readonly isStreaming?: boolean;
57
+ /** SPEC-6-2: is the underlying session still active (not disposed/ended/killed)? */
58
+ isAlive?(): boolean;
57
59
  }
58
60
 
59
61
  /** SPEC-5b-4: narrow live-session handle retained on RunRecord while status === "running".
@@ -65,6 +67,8 @@ export interface LiveSessionHandle {
65
67
  subscribe(handler: (e: ChildSessionEvent) => void): () => void;
66
68
  readonly isStreaming: boolean;
67
69
  readonly supportsSteer: boolean;
70
+ /** SPEC-6-2: is the underlying session still active (not disposed/ended/killed)? */
71
+ isAlive(): boolean;
68
72
  }
69
73
 
70
74
  /** SPEC-5b-4: wrap a ChildSession into a narrow LiveSessionHandle for the panel.
@@ -76,6 +80,8 @@ export function toLiveHandle(session: ChildSession): LiveSessionHandle {
76
80
  subscribe: (h) => session.subscribe(h),
77
81
  get isStreaming() { return session.isStreaming ?? false; },
78
82
  get supportsSteer() { return typeof session.steer === "function"; },
83
+ isAlive: () => typeof (session as { isAlive?: () => boolean }).isAlive === "function"
84
+ ? (session as { isAlive: () => boolean }).isAlive() : true,
79
85
  };
80
86
  }
81
87
 
@@ -212,10 +218,8 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
212
218
  runId, agent: agentDef.name, model, task: opts.task, track,
213
219
  todoId: null, status: "running", startedAt,
214
220
  tier: tier?.name, costTotal: 0, contextTokens: 0,
221
+ cwd: opts.parentCwd, backend: backendId,
215
222
  });
216
- try {
217
- opts.runLog?.append(runId, { type: "run:meta", runId, agent: agentDef.name, model, task: opts.task, startedAt, track, todoId: null });
218
- } catch { /* best-effort: journal is the index, not the product */ }
219
223
 
220
224
  // todo-sync (before) — only when both caller tracks AND agent allows todoSync
221
225
  let priorStatus: string | undefined;
@@ -280,7 +284,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
280
284
  if (e.type === "session_init" && e.backendSessionId) {
281
285
  opts.runRegistry.update(runId, { backendSessionId: e.backendSessionId, sessionKey: agentDef.sessionKey });
282
286
  try {
283
- opts.runLog?.append(runId, { type: "run:meta", runId, agent: agentDef.name, model, task: opts.task, startedAt, track, todoId, backendSessionId: e.backendSessionId, sessionKey: agentDef.sessionKey });
287
+ 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 });
284
288
  } catch { /* best-effort */ }
285
289
  } else if (e.type === "turn_start") {
286
290
  turnIdx++;
@@ -355,11 +359,26 @@ function fail(runId: string, startedAt: number, message: string, agent: string):
355
359
  };
356
360
  }
357
361
 
362
+ /** SPEC-6-2: guard against double-finishRun (abort-then-complete). */
363
+ const finalizedRunIds = new Set<string>();
364
+
358
365
  async function finishRun(
359
366
  opts: SpawnOptions, runId: string, startedAt: number,
360
367
  status: FleetRunStatus, finalText: string, todoId: string | null, priorStatus: string | undefined,
361
368
  error: string | undefined, agentName: string, model: string, tokenTotal = 0, costTotal = 0, contextTokens = 0,
362
369
  ): Promise<SpawnResult> {
370
+ if (finalizedRunIds.has(runId)) {
371
+ // Already finalized — return the existing registry record's result without re-appending.
372
+ const existing = opts.runRegistry.get(runId);
373
+ return {
374
+ status: existing?.status ?? status, finalText: existing?.resultSummary ?? finalText,
375
+ runId, todoId, agent: agentName, model,
376
+ durationMs: existing?.endedAt ? existing.endedAt - startedAt : Date.now() - startedAt,
377
+ tokenTotal, costTotal, contextTokens, error,
378
+ };
379
+ }
380
+ finalizedRunIds.add(runId);
381
+
363
382
  const endedAt = Date.now();
364
383
  opts.runRegistry.update(runId, {
365
384
  status, endedAt, resultSummary: finalText.slice(0, 120),
package/src/index.ts CHANGED
@@ -42,6 +42,8 @@ import { reconcileRuns } from "./runtime/reconcile.ts";
42
42
  import { Scheduler } from "./scheduling/scheduler.ts";
43
43
  import { createFleetResultsTool } from "./tools/fleet-results.ts";
44
44
  import { BgRunsStore } from "./panel/bg-runs-store.ts";
45
+ import { GateRegistry } from "./lifecycle/gates/registry.ts";
46
+ import { registerBuiltinGates } from "./lifecycle/gates/builtin.ts";
45
47
  import { FleetWidgetController } from "./panel/fleet-widget.ts";
46
48
  import { TierRegistry, mergeTiers } from "./tiers/tier-registry.ts";
47
49
  import { BUILTIN_TIERS } from "./tiers/builtin.ts";
@@ -177,6 +179,20 @@ export default async function (pi: ExtensionAPI): Promise<void> {
177
179
  // Builtin-only placeholder tier registry so spawn works before session_start rebuilds with merged tiers.
178
180
  deps.tierRegistry = new TierRegistry({ tiers: BUILTIN_TIERS, agents: deps.registry });
179
181
 
182
+ // SPEC-6-2: gate registry + builtin gate registration.
183
+ const gateRegistry = new GateRegistry();
184
+ registerBuiltinGates(gateRegistry);
185
+ // Wire gate deps into lifecycleDeps (both the async + foreground lifecycle sites spread from lifecycleDeps).
186
+ deps.lifecycleDeps.gateRegistry = gateRegistry;
187
+ deps.lifecycleDeps.getGateCtxState = (todoId: string, _agentName: string) => {
188
+ const runs = deps.runRegistry.list().filter((r) => r.todoId === todoId);
189
+ const lifecycleCost = runs.reduce((s, r) => s + (r.costTotal ?? 0), 0);
190
+ const contextTokens = runs.reduce((max, r) => Math.max(max, r.contextTokens ?? 0), 0);
191
+ const tierName = runs.find((r) => r.tier)?.tier;
192
+ const tier = tierName ? deps.tierRegistry?.get(tierName) : undefined;
193
+ return { lifecycleCost, contextTokens, tier };
194
+ };
195
+
180
196
  // ── SPEC-5a: operational runtime (async/bg + scheduling + worktree isolation) ──
181
197
  const fleetDir = (cwd: string) => join(cwd, ".pi", "fleet");
182
198
  const bgRuns = new BgRunsStore();
@@ -274,6 +290,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
274
290
  notify: (m, lvl) => ctx.ui.notify(m, lvl),
275
291
  genRunId: () => "fl-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8),
276
292
  onProgress: (runId, status) => { bgRuns.set(runId, status); },
293
+ runRegistry: deps.runRegistry,
277
294
  };
278
295
  deps.scheduler = new Scheduler({
279
296
  storePath: join(dir, "schedules.json"),
@@ -295,12 +312,16 @@ export default async function (pi: ExtensionAPI): Promise<void> {
295
312
  return sharedModelRegistry.find(provider, id)?.contextWindow;
296
313
  };
297
314
  deps.getModelContextWindow = getModelContextWindow;
315
+ // SPEC-6-2: thread getModelContextWindow into lifecycle deps for gate ctx.
316
+ deps.lifecycleDeps.getModelContextWindow = getModelContextWindow;
298
317
  fleetWidget = new FleetWidgetController({
299
318
  runRegistry: deps.runRegistry,
300
319
  bgRuns,
301
320
  ui: ctx.ui as never,
302
321
  getTheme: () => ctx.ui.theme,
303
322
  getModelContextWindow,
323
+ cwd: ctx.cwd,
324
+ runLog: deps.runLog,
304
325
  });
305
326
  fleetWidget.start();
306
327
 
@@ -390,4 +411,13 @@ export default async function (pi: ExtensionAPI): Promise<void> {
390
411
  ctx.ui.notify(`lifecycle ${res.status}: ${res.runId}${res.error ? " — " + res.error : ""}`, res.status === "completed" ? "info" : "warning");
391
412
  },
392
413
  });
414
+
415
+ // SPEC-6-2: gate extensibility — pi doesn't expose registerGate, so we provide a command
416
+ // so other extensions can register custom gates at runtime.
417
+ pi.registerCommand("fleet-register-gate", {
418
+ description: "Register a custom gate on the fleet gate registry (extensibility path).",
419
+ handler: async (_args, ctx) => {
420
+ ctx.ui.notify("fleet-register-gate: custom gates must be registered via the GateRegistry module export (see src/lifecycle/gates/registry.ts).", "info");
421
+ },
422
+ });
393
423
  }
@@ -24,10 +24,12 @@ phases:
24
24
  skills: [writing-plans]
25
25
  agent: general-purpose
26
26
  checkpoint: true
27
+ gates: [completenessCheck]
27
28
  - name: implement
28
29
  skills: [executing-plans, test-driven-development, verification-before-completion]
29
30
  agent: general-purpose
30
31
  checkpoint: false
32
+ gates: [verification-before-completion, completenessCheck, gate]
31
33
  - name: review
32
34
  skills: [requesting-code-review, receiving-code-review]
33
35
  agent: general-purpose
@@ -35,6 +37,7 @@ phases:
35
37
  - name: finish
36
38
  skills: [finishing-a-development-branch]
37
39
  agent: general-purpose
40
+ challengeStep: false
38
41
  ---
39
42
 
40
43
  ## brainstorm
@@ -0,0 +1,14 @@
1
+ // src/lifecycle/gates/builtin.ts
2
+ import type { GateRegistry } from "./registry.ts";
3
+ import { verificationBeforeCompletionGate } from "./verification-before-completion.ts";
4
+ import { completenessCheckGate } from "./completeness-check.ts";
5
+ import { gateGate } from "./gate.ts";
6
+ import { verifyGate } from "./verify.ts";
7
+
8
+ /** Register the 4 builtin gates on a GateRegistry. */
9
+ export function registerBuiltinGates(reg: GateRegistry): void {
10
+ reg.register(verificationBeforeCompletionGate);
11
+ reg.register(completenessCheckGate);
12
+ reg.register(gateGate);
13
+ reg.register(verifyGate);
14
+ }
@@ -0,0 +1,37 @@
1
+ import type { GateDef, GateCtx, GateResult } from "./registry.ts";
2
+
3
+ export interface GateChainOutcome {
4
+ results: GateResult[];
5
+ shortCircuit?: { action: "revise" | "abort"; feedback?: string; reason?: string };
6
+ }
7
+
8
+ /** Run the gate chain left-to-right. Advise-failures continue; revise/abort short-circuit. */
9
+ export async function runGateChain(opts: { gates: GateDef[]; ctx: GateCtx }): Promise<GateChainOutcome> {
10
+ const results: GateResult[] = [];
11
+ for (const gate of opts.gates) {
12
+ // Each gate sees its own params on ctx.gateParams (set per-gate by the caller/run-lifecycle).
13
+ const gateCtx: GateCtx = { ...opts.ctx, gateParams: gate.params };
14
+ const started = Date.now();
15
+ let result: GateResult;
16
+ let crashed = false;
17
+ try {
18
+ result = await gate.run(gateCtx);
19
+ } catch (e) {
20
+ // A throwing gate is treated as an advise-failure (never auto-revise on a crash).
21
+ crashed = true;
22
+ result = { gate: gate.name, kind: gate.kind, passed: false, evidence: `gate '${gate.name}' threw: ${(e as Error).message}`, onFail: "advise" };
23
+ }
24
+ result.durationMs = Date.now() - started;
25
+ results.push(result);
26
+ if (!crashed && !result.passed) {
27
+ if (gate.onFail === "revise") {
28
+ return { results, shortCircuit: { action: "revise", feedback: result.evidence } };
29
+ }
30
+ if (gate.onFail === "abort") {
31
+ return { results, shortCircuit: { action: "abort", reason: result.evidence } };
32
+ }
33
+ // advise → continue
34
+ }
35
+ }
36
+ return { results };
37
+ }
@@ -0,0 +1,36 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { isAbsolute, resolve } from "node:path";
3
+ import type { GateDef, GateCtx, GateResult } from "./registry.ts";
4
+
5
+ export interface CompletenessResult { passed: boolean; evidence: string; }
6
+
7
+ /** Pure-ish: stat every claimed path. Relative paths resolve against baseDir. */
8
+ export function checkCompleteness(paths: string[], baseDir: string): CompletenessResult {
9
+ if (paths.length === 0) return { passed: true, evidence: "no claimed artifacts (terminal-phase exemption)" };
10
+ const missing: string[] = [];
11
+ let found = 0;
12
+ for (const p of paths) {
13
+ const abs = isAbsolute(p) ? p : resolve(baseDir, p);
14
+ try {
15
+ statSync(abs);
16
+ found++;
17
+ } catch {
18
+ missing.push(p);
19
+ }
20
+ }
21
+ if (missing.length > 0) {
22
+ return { passed: false, evidence: `missing: ${missing.join(", ")} (${found}/${paths.length} exist)` };
23
+ }
24
+ return { passed: true, evidence: `${paths.length}/${paths.length} artifacts exist` };
25
+ }
26
+
27
+ export const completenessCheckGate: GateDef = {
28
+ name: "completenessCheck",
29
+ kind: "predicate",
30
+ onFail: "revise",
31
+ run: async (ctx: GateCtx): Promise<GateResult> => {
32
+ const base = ctx.worktreePath ?? process.cwd();
33
+ const r = checkCompleteness(ctx.phaseRec.paths, base);
34
+ return { gate: "completenessCheck", kind: "predicate", passed: r.passed, evidence: r.evidence, onFail: "revise" };
35
+ },
36
+ };
@@ -0,0 +1,41 @@
1
+ import type { GateDef, GateCtx, GateResult } from "./registry.ts";
2
+ import type { Tier } from "../../tiers/tier-registry.ts";
3
+
4
+ export interface BudgetInput {
5
+ lifecycleCost: number;
6
+ contextTokens: number;
7
+ tier?: Tier;
8
+ params?: Record<string, unknown>;
9
+ }
10
+ export interface BudgetResult { passed: boolean; evidence: string; }
11
+
12
+ /** Pure: assert cost < cap and context < floor. Missing tier/caps → skip (pass). */
13
+ export function assertBudget(input: BudgetInput): BudgetResult {
14
+ const { lifecycleCost, contextTokens, tier, params } = input;
15
+ if (!tier) return { passed: true, evidence: "no tier → no caps to assert (skip)" };
16
+ const costCap = typeof (params as { costCap?: number } | undefined)?.costCap === "number"
17
+ ? (params as { costCap: number }).costCap : tier.costCap;
18
+ const contextFloor = typeof (params as { contextFloor?: number } | undefined)?.contextFloor === "number"
19
+ ? (params as { contextFloor: number }).contextFloor : tier.contextFloor;
20
+ const parts: string[] = [];
21
+ if (typeof costCap === "number") {
22
+ if (lifecycleCost > costCap) return { passed: false, evidence: `cost $${lifecycleCost.toFixed(2)} > cap $${costCap.toFixed(2)}` };
23
+ parts.push(`cost $${lifecycleCost.toFixed(2)} < cap $${costCap.toFixed(2)}`);
24
+ }
25
+ if (typeof contextFloor === "number") {
26
+ if (contextTokens > contextFloor) return { passed: false, evidence: `context ${contextTokens} > floor ${contextFloor}` };
27
+ parts.push(`ctx ${contextTokens} < floor ${contextFloor}`);
28
+ }
29
+ if (parts.length === 0) return { passed: true, evidence: "tier has no costCap/contextFloor → nothing to assert" };
30
+ return { passed: true, evidence: parts.join("; ") };
31
+ }
32
+
33
+ export const gateGate: GateDef = {
34
+ name: "gate",
35
+ kind: "predicate",
36
+ onFail: "abort",
37
+ run: async (ctx: GateCtx): Promise<GateResult> => {
38
+ const r = assertBudget({ lifecycleCost: ctx.lifecycleCost, contextTokens: ctx.contextTokens, tier: ctx.tier, params: ctx.gateParams });
39
+ return { gate: "gate", kind: "predicate", passed: r.passed, evidence: r.evidence, onFail: "abort" };
40
+ },
41
+ };
@@ -0,0 +1,75 @@
1
+ import type { PhaseRecord } from "../lifecycle-types.ts";
2
+ import type { SpawnResult } from "../../engine/spawnSubagent.ts";
3
+ import type { SpawnFn } from "../run-lifecycle.ts";
4
+ import type { BackendId } from "../lifecycle-types.ts";
5
+ import type { Tier } from "../../tiers/tier-registry.ts";
6
+
7
+ export type GateKind = "agent" | "predicate";
8
+ export type GateOnFail = "advise" | "revise" | "abort";
9
+
10
+ /** What a phase declares in frontmatter. String = name only; object = name + overrides. */
11
+ export type GateRef = string | { name: string; onFail?: GateOnFail; params?: Record<string, unknown> };
12
+
13
+ /** A resolved gate definition (registry entry + phase overrides applied). */
14
+ export interface GateDef {
15
+ name: string;
16
+ kind: GateKind;
17
+ onFail: GateOnFail;
18
+ params?: Record<string, unknown>;
19
+ run: (ctx: GateCtx) => Promise<GateResult>;
20
+ }
21
+
22
+ export interface GateCtx {
23
+ phaseRec: PhaseRecord;
24
+ spawnRes: SpawnResult;
25
+ lifecycle: { name: string; task: string; todoId: string; backend: BackendId };
26
+ tier?: Tier;
27
+ /** Sum of costTotal across all runs linked to this lifecycle's todoId. */
28
+ lifecycleCost: number;
29
+ contextTokens: number;
30
+ worktreePath?: string;
31
+ /** Agent gates use this to spawn the reviewer subagent. */
32
+ spawn: SpawnFn;
33
+ getModelContextWindow: (model: string) => number | undefined;
34
+ /** Per-gate params from the resolved GateDef (set by the chain runner). */
35
+ gateParams?: Record<string, unknown>;
36
+ }
37
+
38
+ export interface GateResult {
39
+ gate: string;
40
+ kind: GateKind;
41
+ passed: boolean;
42
+ evidence: string;
43
+ onFail: GateOnFail;
44
+ /** Agent gates only — the spawned run's costTotal. */
45
+ cost?: number;
46
+ /** Agent gates only — links to the /fleet row. */
47
+ runId?: string;
48
+ durationMs?: number;
49
+ }
50
+
51
+ export class GateRegistry {
52
+ private readonly byName = new Map<string, GateDef>();
53
+ register(def: GateDef): void {
54
+ if (this.byName.has(def.name)) throw new Error(`duplicate gate name '${def.name}'`);
55
+ this.byName.set(def.name, def);
56
+ }
57
+ get(name: string): GateDef | undefined { return this.byName.get(name); }
58
+ list(): GateDef[] { return [...this.byName.values()]; }
59
+ }
60
+
61
+ /** Resolve phase-declared GateRefs into GateDefs, applying per-phase onFail/params overrides. */
62
+ export function resolveGates(refs: GateRef[] | undefined, reg: GateRegistry): GateDef[] {
63
+ if (!refs || refs.length === 0) return [];
64
+ return refs.map((ref) => {
65
+ const name = typeof ref === "string" ? ref : ref.name;
66
+ const base = reg.get(name);
67
+ if (!base) throw new Error(`unknown gate '${name}' (not in registry)`);
68
+ if (typeof ref === "string") return base;
69
+ return {
70
+ ...base,
71
+ ...(ref.onFail ? { onFail: ref.onFail } : {}),
72
+ ...(ref.params ? { params: { ...base.params, ...ref.params } } : {}),
73
+ };
74
+ });
75
+ }
@@ -0,0 +1,50 @@
1
+ import type { GateDef, GateCtx, GateResult } from "./registry.ts";
2
+
3
+ /** Default patterns: a verification command invocation AND a result signal.
4
+ * A command alone (no result) is a claim, not evidence. */
5
+ const DEFAULT_COMMAND_PATTERNS: RegExp[] = [
6
+ /\b(pnpm|npm|yarn)\s+(test|test:run|typecheck|lint|build)\b/i,
7
+ /\b(typecheck|tsc|eslint|prettier)\b/i,
8
+ /\bgo\s+(test|build)\b/i, /\bcargo\s+(test|build)\b/i, /\brustc\b/i,
9
+ /\bpytest\b/i, /\bmvn\s+test\b/i,
10
+ ];
11
+ const DEFAULT_RESULT_PATTERNS: RegExp[] = [
12
+ /\b\d+\s*(\/|of)?\s*\d*\s*(pass|passing)\b/i,
13
+ /\b0\s*(fail|failing|errors?|error)\b/i,
14
+ /\bexit\s*(code\s*)?(:|=|→)?\s*0\b/i,
15
+ /\bclean\b/i, /\bgreen\b/i, /\bok\b/i,
16
+ /\b\d+\s*pass(?:ing)?(?:[,\s]+0\s*fail)?\b/i,
17
+ ];
18
+
19
+ export interface ScanResult { passed: boolean; evidence: string; }
20
+
21
+ /** Pure: scan phase output for verification evidence (command + result). */
22
+ export function scanVerificationEvidence(
23
+ text: string,
24
+ opts: { patterns?: RegExp[] } = {},
25
+ ): ScanResult {
26
+ const commands = opts.patterns ?? DEFAULT_COMMAND_PATTERNS;
27
+ // Custom patterns replace the command set; result detection stays the default unless
28
+ // the caller wants full control (they pass patterns that already encode the result).
29
+ const cmdMatch = commands.find((p) => p.test(text));
30
+ if (!cmdMatch) return { passed: false, evidence: "no verification command output found in phase output" };
31
+ // If custom patterns are provided, treat a command match as sufficient (the pattern encodes the result).
32
+ if (opts.patterns) return { passed: true, evidence: `found evidence matching ${cmdMatch}` };
33
+ const resultMatch = DEFAULT_RESULT_PATTERNS.find((p) => p.test(text));
34
+ if (!resultMatch) return { passed: false, evidence: `verification command found (${cmdMatch}) but no pass/exit result signal — show the command output` };
35
+ // Extract a compact snippet around the command.
36
+ const idx = text.search(cmdMatch);
37
+ const snippet = text.slice(Math.max(0, idx - 10), Math.min(text.length, idx + 80)).replace(/\s+/g, " ").trim();
38
+ return { passed: true, evidence: `found: ${snippet}` };
39
+ }
40
+
41
+ export const verificationBeforeCompletionGate: GateDef = {
42
+ name: "verification-before-completion",
43
+ kind: "predicate",
44
+ onFail: "revise",
45
+ run: async (ctx: GateCtx): Promise<GateResult> => {
46
+ const patterns = (ctx.gateParams as { patterns?: RegExp[] } | undefined)?.patterns;
47
+ const r = scanVerificationEvidence(ctx.spawnRes.finalText, patterns ? { patterns } : {});
48
+ return { gate: "verification-before-completion", kind: "predicate", passed: r.passed, evidence: r.evidence, onFail: "revise" };
49
+ },
50
+ };
@@ -0,0 +1,47 @@
1
+ import type { GateDef, GateCtx, GateResult } from "./registry.ts";
2
+ import type { SpawnResult } from "../../engine/spawnSubagent.ts";
3
+
4
+ const FAILURE_MARKERS = /\b(does not meet|not meet|missing|incomplete|not addressed|fails?|broken|incorrect)\b/i;
5
+
6
+ /** Pure: judge a reviewer's text for a passed/failed verdict. */
7
+ export function judgeReview(text: string): { passed: boolean } {
8
+ return { passed: !FAILURE_MARKERS.test(text) };
9
+ }
10
+
11
+ /** Pure: build the reviewer subagent prompt. */
12
+ export function buildVerifyPrompt(ctx: GateCtx): string {
13
+ return [
14
+ "You are an independent reviewer. Review this phase's output against the task + plan.",
15
+ `Task: ${ctx.lifecycle.task}`,
16
+ `Phase: ${ctx.phaseRec.name}`,
17
+ `Phase summary: ${ctx.phaseRec.summary}`,
18
+ `Artifacts: ${ctx.phaseRec.paths.join(", ") || "(none)"}`,
19
+ "Did it meet the requirement? What's missing? Be specific. End with a verdict: 'meets the requirement' or 'does not meet the requirement'.",
20
+ ].join("\n");
21
+ }
22
+
23
+ export const verifyGate: GateDef = {
24
+ name: "verify",
25
+ kind: "agent",
26
+ onFail: "advise",
27
+ run: async (ctx: GateCtx): Promise<GateResult> => {
28
+ const reviewerAgent = (ctx.gateParams as { agent?: string } | undefined)?.agent ?? "reviewer";
29
+ const prompt = buildVerifyPrompt(ctx);
30
+ let spawnRes: SpawnResult;
31
+ try {
32
+ spawnRes = await ctx.spawn({ agent: reviewerAgent, task: prompt, lifecycleTodoId: ctx.lifecycle.todoId, skills: [], backend: ctx.lifecycle.backend });
33
+ } catch (e) {
34
+ return { gate: "verify", kind: "agent", passed: false, evidence: `reviewer spawn failed: ${(e as Error).message}`, onFail: "advise" };
35
+ }
36
+ if (spawnRes.status === "failed") {
37
+ return { gate: "verify", kind: "agent", passed: false, evidence: `reviewer spawn failed: ${spawnRes.error ?? spawnRes.finalText.slice(0, 120)}`, onFail: "advise" };
38
+ }
39
+ const verdict = judgeReview(spawnRes.finalText);
40
+ return {
41
+ gate: "verify", kind: "agent", passed: verdict.passed,
42
+ evidence: spawnRes.finalText.slice(0, 2000), onFail: "advise",
43
+ ...(spawnRes.costTotal != null ? { cost: spawnRes.costTotal } : {}),
44
+ runId: spawnRes.runId,
45
+ };
46
+ },
47
+ };
@@ -1,6 +1,7 @@
1
1
  // src/lifecycle/lifecycle-types.ts
2
2
  import type { FleetRunStatus } from "../todo-sync/port.ts";
3
3
  import type { AgentSource } from "../registry/frontmatter.ts";
4
+ import type { GateRef, GateResult } from "./gates/registry.ts";
4
5
 
5
6
  /** Backend id (mirrors SPEC-3 AgentDef.backend). */
6
7
  export type BackendId = "pi" | "claude";
@@ -22,6 +23,10 @@ export interface PhaseDef {
22
23
  checkpoint?: boolean;
23
24
  /** The phase prompt template (parsed from the `## <name>` body section). */
24
25
  promptTemplate: string;
26
+ /** SPEC-6-2: opt out of the lifecycle-wide challenge-step prompt injection. Default true. */
27
+ challengeStep?: boolean;
28
+ /** SPEC-6-2: gates to run after this phase (before checkpoint). Array of GateRef. */
29
+ gates?: GateRef[];
25
30
  }
26
31
 
27
32
  export interface LifecycleDef {
@@ -41,6 +46,8 @@ export interface PhaseRecord {
41
46
  paths: string[];
42
47
  status: FleetRunStatus;
43
48
  reviseCount: number;
49
+ /** SPEC-6-2: gate results from this phase's gate chain (for panel rendering). */
50
+ gateResults?: GateResult[];
44
51
  }
45
52
 
46
53
  export interface LifecycleRunRecord {
@@ -1,6 +1,15 @@
1
1
  // src/lifecycle/prompt-template.ts
2
2
  import type { PhaseRecord } from "./lifecycle-types.ts";
3
3
 
4
+ export const CHALLENGE_STEP_BLOCK = [
5
+ "",
6
+ "## Challenge Step",
7
+ "After completing significant work, actively challenge your own output before presenting it.",
8
+ 'Ask: "What could break? What did I miss? What would a critical reviewer flag?"',
9
+ "Fix what you find — don't just note it. Small single-line changes are exempt.",
10
+ "",
11
+ ].join("\n");
12
+
4
13
  export interface PromptVars {
5
14
  task: string;
6
15
  lifecycle: string;
@@ -9,6 +18,8 @@ export interface PromptVars {
9
18
  prev?: { name: string; summary: string; paths: string[] };
10
19
  /** On Revise only: human feedback + prior-attempt digest. */
11
20
  feedback?: string;
21
+ /** SPEC-6-2: opt out of challenge-step prompt injection. Default true (append). */
22
+ challengeStep?: boolean;
12
23
  }
13
24
 
14
25
  /** Render a phase prompt template. Supports {{task}}, {{lifecycle}}, {{phase}},
@@ -36,5 +47,6 @@ export function renderPhasePrompt(template: string, vars: PromptVars): string {
36
47
  .replace(/{{\s*prev\.paths\s*}}/g, pathsStr)
37
48
  .replace(/{{\s*feedback\s*}}/g, vars.feedback ?? "");
38
49
 
39
- return out;
50
+ const challengeStep = vars.challengeStep !== false; // default true
51
+ return challengeStep ? out + CHALLENGE_STEP_BLOCK : out;
40
52
  }
@@ -70,7 +70,22 @@ export function parseLifecycleFile(content: string, filePath: string, source: Ag
70
70
  pbackend = b as BackendId;
71
71
  }
72
72
  const checkpoint = po.checkpoint === undefined ? true : Boolean(po.checkpoint);
73
- return { name: pname, skills, agent, backend: pbackend, checkpoint };
73
+ const challengeStep = po.challengeStep === undefined ? undefined : !po.challengeStep ? false : true;
74
+ let gates: import("./gates/registry.ts").GateRef[] | undefined;
75
+ if (po.gates !== undefined) {
76
+ if (!Array.isArray(po.gates)) {
77
+ throw new LifecycleParseError(`${filePath}: phase '${pname}' gates must be an array`);
78
+ }
79
+ gates = po.gates.map((g: unknown) => {
80
+ if (typeof g === "string") return g;
81
+ if (g && typeof g === "object") {
82
+ const go = g as Record<string, unknown>;
83
+ return { name: String(go.name), ...(go.onFail ? { onFail: String(go.onFail) as import("./gates/registry.ts").GateOnFail } : {}), ...(go.params ? { params: go.params as Record<string, unknown> } : {}) };
84
+ }
85
+ throw new LifecycleParseError(`${filePath}: phase '${pname}' gate entry must be string or object`);
86
+ });
87
+ }
88
+ return { name: pname, skills, agent, backend: pbackend, checkpoint, ...(challengeStep !== undefined ? { challengeStep } : {}), ...(gates ? { gates } : {}) };
74
89
  });
75
90
 
76
91
  // Split body into `## <phase>` sections. A phase with no matching section = error.
@@ -5,6 +5,10 @@ import type { SpawnResult } from "../engine/spawnSubagent.ts";
5
5
  import type {
6
6
  BackendId, LifecycleDef, LifecycleMode, LifecycleStatus, PhaseRecord, CheckpointDecision,
7
7
  } from "./lifecycle-types.ts";
8
+ import type { GateDef, GateCtx, GateResult, GateRegistry } from "./gates/registry.ts";
9
+ import type { Tier } from "../tiers/tier-registry.ts";
10
+ import { resolveGates } from "./gates/registry.ts";
11
+ import { runGateChain } from "./gates/chain-runner.ts";
8
12
  import { renderPhasePrompt } from "./prompt-template.ts";
9
13
  import { parseArtifacts, MAX_REVISE } from "./artifacts-parser.ts";
10
14
  import {
@@ -36,6 +40,12 @@ export interface LifecycleRunDeps {
36
40
  /** SPEC-5a (Q3=A): when present, isolated runs use worktree-diff artifact discovery
37
41
  * instead of the prompt-baked `Artifacts:` block parser. Foreground runs leave this undefined. */
38
42
  artifactDiscovery?: (o: { finalText: string; cwd: string; baseRef: string; terminal: boolean }) => { summary: string; paths: string[] } | { error: string };
43
+ /** SPEC-6-2: gate registry — when present + a phase has gates, the gate chain runs between parse-artifacts and checkpoint. */
44
+ gateRegistry?: GateRegistry;
45
+ /** SPEC-6-2: provides the gate chain's ctx extras (lifecycle cost, context tokens, tier). When absent, defaults to zeros. */
46
+ getGateCtxState?: (todoId: string, agentName: string) => { lifecycleCost: number; contextTokens: number; tier?: Tier };
47
+ /** SPEC-6-2: resolve a model's context window for the gate ctx. Optional — absent → undefined. */
48
+ getModelContextWindow?: (model: string) => number | undefined;
39
49
  }
40
50
 
41
51
  export interface LifecycleRunOpts {
@@ -63,8 +73,8 @@ export interface LifecycleRunResult {
63
73
  error?: string;
64
74
  }
65
75
 
66
- /** Human (or auto) decision at a checkpoint. */
67
- export type CheckpointFn = (phase: PhaseRecord) => Promise<CheckpointDecision>;
76
+ /** Human (or auto) decision at a checkpoint. SPEC-6-2: widened to include gate results. */
77
+ export type CheckpointFn = (phase: PhaseRecord, gateResults: GateResult[]) => Promise<CheckpointDecision>;
68
78
 
69
79
  export async function runLifecycle(task: string, lifecycleName: string, opts: LifecycleRunOpts): Promise<LifecycleRunResult> {
70
80
  const { deps } = opts;
@@ -133,6 +143,7 @@ export async function runLifecycle(task: string, lifecycleName: string, opts: Li
133
143
  task, lifecycle: lifecycleName, phase: phaseDef.name,
134
144
  prev: prev ? { name: prev.name, summary: prev.summary, paths: prev.paths } : undefined,
135
145
  feedback,
146
+ challengeStep: phaseDef.challengeStep,
136
147
  });
137
148
 
138
149
  // e/f: spawn the phase child (links to the lifecycle todo; skips mark-done/revert — Task 8).
@@ -171,6 +182,48 @@ export async function runLifecycle(task: string, lifecycleName: string, opts: Li
171
182
  // Capture this attempt's summary for the next revise iteration's feedback digest.
172
183
  priorAttemptSummary = phaseRec.summary;
173
184
 
185
+ // SPEC-6-2: gate chain — runs between parse-artifacts and checkpoint.
186
+ // If the gate chain short-circuits (revise/abort), we handle it here BEFORE the checkpoint.
187
+ let gateResults: GateResult[] = [];
188
+ if (phaseDef.gates && phaseDef.gates.length > 0 && deps.gateRegistry) {
189
+ const gates = resolveGates(phaseDef.gates, deps.gateRegistry);
190
+ const gateCtxState = deps.getGateCtxState?.(todoId, agentName) ?? { lifecycleCost: 0, contextTokens: 0 };
191
+ const gateCtx: GateCtx = {
192
+ phaseRec, spawnRes,
193
+ lifecycle: { name: lifecycleName, task, todoId, backend: lifecycleBackend },
194
+ tier: gateCtxState.tier,
195
+ lifecycleCost: gateCtxState.lifecycleCost,
196
+ contextTokens: gateCtxState.contextTokens,
197
+ worktreePath: opts.worktreePath,
198
+ spawn: deps.spawn,
199
+ getModelContextWindow: deps.getModelContextWindow ?? (() => undefined),
200
+ };
201
+ const outcome = await runGateChain({ gates, ctx: gateCtx });
202
+ gateResults = outcome.results;
203
+ phaseRec.gateResults = gateResults;
204
+ if (outcome.shortCircuit?.action === "revise") {
205
+ reviseCount++;
206
+ lastFeedback = outcome.shortCircuit.feedback;
207
+ if (reviseCount > MAX_REVISE) {
208
+ await updateProgress(deps.todoPort, todoId, {
209
+ phase: phaseDef.name, done: false, last: `gate revise budget exhausted (${MAX_REVISE})`, revising: false, attempt: reviseCount,
210
+ }, { lifecycle: lifecycleName, task, backend: lifecycleBackend, mode: opts.mode, phases: progressPhases });
211
+ phaseRecords.push(phaseRec);
212
+ return doneResult(runId, startedAt, "failed", lifecycleName, task, lifecycleBackend, opts.mode, phaseRecords, todoId,
213
+ `gate revise budget exhausted (${MAX_REVISE})`);
214
+ }
215
+ await updateProgress(deps.todoPort, todoId, {
216
+ phase: phaseDef.name, done: false, last: `gate revise (attempt ${reviseCount}/${MAX_REVISE}): ${outcome.shortCircuit.feedback?.slice(0, 80)}`, revising: true, attempt: reviseCount,
217
+ }, { lifecycle: lifecycleName, task, backend: lifecycleBackend, mode: opts.mode, phases: progressPhases });
218
+ continue; // re-run the phase
219
+ }
220
+ if (outcome.shortCircuit?.action === "abort") {
221
+ await revertLifecycleTodo(deps.todoPort, todoId, `gate aborted: ${outcome.shortCircuit.reason}`);
222
+ phaseRecords.push(phaseRec);
223
+ return doneResult(runId, startedAt, "failed", lifecycleName, task, lifecycleBackend, opts.mode, phaseRecords, todoId, outcome.shortCircuit.reason);
224
+ }
225
+ }
226
+
174
227
  // h: update the lifecycle todo progress block.
175
228
  await updateProgress(deps.todoPort, todoId, {
176
229
  phase: phaseDef.name, done: phaseRec.status === "completed",
@@ -186,7 +239,7 @@ export async function runLifecycle(task: string, lifecycleName: string, opts: Li
186
239
  break; // advance to next phase
187
240
  }
188
241
 
189
- const decision = await opts.onCheckpoint(phaseRec);
242
+ const decision = await opts.onCheckpoint(phaseRec, gateResults);
190
243
  if (decision.action === "continue") {
191
244
  if (forceCheckpoint) {
192
245
  // cannot continue past a failure — treat as abort (guard against a misbehaving checkpoint fn)
@@ -358,7 +358,7 @@ export class FleetPanel extends Container {
358
358
  this.fullMessageEvent
359
359
  ? " esc:Back"
360
360
  : this.infoAgent || this.selectedBackend || this.selectedLifecycle || this.selectedSchedule || this.selectedRun
361
- ? (this.selectedRun ? " enter:Full-message esc:Back" : " esc:Back")
361
+ ? (this.selectedRun ? " enter:Full-message esc:Back" : this.selectedLifecycle ? " v:View-evidence g:Re-run-gate esc:Back" : " esc:Back")
362
362
  : this.pendingCheckpoint
363
363
  ? " c:Continue v:Revise a:Abort"
364
364
  : this.lcRevising
@@ -474,7 +474,42 @@ export class FleetPanel extends Container {
474
474
  return;
475
475
  }
476
476
  if (this.selectedLifecycle) {
477
- if (matchesKey(data, "escape")) { this.selectedLifecycle = null; this.renderShell(); }
477
+ if (matchesKey(data, "escape")) { this.selectedLifecycle = null; this.renderShell(); return; }
478
+ // SPEC-6-2: v:View-evidence — open the conversation viewer on the first agent gate's runId.
479
+ if (matchesKey(data, "v")) {
480
+ const agentGate = this.selectedLifecycle.phases
481
+ .flatMap((p) => p.gateResults ?? [])
482
+ .find((gr) => gr.runId);
483
+ if (agentGate?.runId && this.deps.runLog) {
484
+ this.selectedRun = buildRunsIndex(this.deps.runLog.dir).find((r) => r.runId === agentGate.runId) ?? null;
485
+ this.runTimeline = this.deps.runLog.replay(agentGate.runId);
486
+ this.selectedLifecycle = null;
487
+ this.view = "runs";
488
+ this.renderShell();
489
+ } else if (agentGate) {
490
+ this.onNotify(`Gate '${agentGate.gate}' evidence: ${agentGate.evidence.slice(0, 200)}`, "info");
491
+ } else {
492
+ const predGate = this.selectedLifecycle.phases
493
+ .flatMap((p) => p.gateResults ?? [])
494
+ .find((gr) => !gr.passed && gr.evidence);
495
+ if (predGate) {
496
+ this.onNotify(`Gate '${predGate.gate}' evidence: ${predGate.evidence.slice(0, 200)}`, "info");
497
+ } else {
498
+ this.onNotify("No gate evidence available for this lifecycle.", "info");
499
+ }
500
+ }
501
+ return;
502
+ }
503
+ // SPEC-6-2: g:Re-run-gate — requires the GateCtx which the runtime holds, not the panel.
504
+ // Full re-run from the panel is a post-v0.11.0 enhancement (the panel doesn't have the GateCtx).
505
+ if (matchesKey(data, "g")) {
506
+ if (this.selectedLifecycle.status === "checkpoint") {
507
+ this.onNotify("Gate re-run from the panel is not yet supported — use the fleet tool or revise at the checkpoint to re-trigger gates.", "info");
508
+ } else {
509
+ this.onNotify("Gate re-run requires a checkpointed lifecycle (current status: " + this.selectedLifecycle.status + ").", "warning");
510
+ }
511
+ return;
512
+ }
478
513
  return;
479
514
  }
480
515
  if (this.selectedSchedule) {
@@ -17,6 +17,7 @@
17
17
  import type { Theme } from "@earendil-works/pi-coding-agent";
18
18
  import type { RunRegistry } from "../engine/run-registry.ts";
19
19
  import type { BgRunsStore } from "./bg-runs-store.ts";
20
+ import { reconcileRuns } from "../runtime/reconcile.ts";
20
21
  import {
21
22
  toWidgetRun, toWidgetRunFromBg, renderWidgetLines,
22
23
  } from "./widget-rows.ts";
@@ -41,6 +42,10 @@ export interface FleetWidgetDeps {
41
42
  clearInterval?: (id: unknown) => void;
42
43
  /** SPEC-6-1: resolve a model's context window for the ctx% widget segment. Optional — absent → no ctx%. */
43
44
  getModelContextWindow?: (model: string) => number | undefined;
45
+ /** SPEC-6-2: the session's cwd — only runs from this cwd are shown in the widget (cross-cwd filter). */
46
+ cwd?: string;
47
+ /** SPEC-6-2: RunLog for the periodic liveness probe (reconcileRuns). Optional — absent → no periodic probe. */
48
+ runLog?: import("../runtime/run-log.ts").RunLog;
44
49
  }
45
50
 
46
51
  export class FleetWidgetController {
@@ -50,6 +55,7 @@ export class FleetWidgetController {
50
55
  private readonly clearIntervalFn: (id: unknown) => void;
51
56
  private readonly unsubs: (() => void)[] = [];
52
57
  private timerId: unknown | null = null;
58
+ private livenessTimerId: unknown | null = null;
53
59
  private disposed = false;
54
60
 
55
61
  constructor(deps: FleetWidgetDeps) {
@@ -62,11 +68,20 @@ export class FleetWidgetController {
62
68
  start(): void {
63
69
  this.unsubs.push(this.deps.runRegistry.subscribe(() => this.render()));
64
70
  if (this.deps.bgRuns) this.unsubs.push(this.deps.bgRuns.subscribe(() => this.render()));
71
+ // SPEC-6-2: periodic liveness probe — reconciles dead orphans every 60s.
72
+ if (this.deps.runLog) {
73
+ this.livenessTimerId = this.setIntervalFn(() => {
74
+ reconcileRuns(this.deps.runLog!, { runRegistry: this.deps.runRegistry });
75
+ }, 60_000);
76
+ (this.livenessTimerId as { unref?: () => void }).unref?.();
77
+ }
65
78
  this.render(); // initial — shows any runs already active on session_start (e.g. a survived bg run)
66
79
  }
67
80
 
68
81
  private activeRuns() {
69
- const fg = this.deps.runRegistry.list().map((r) => {
82
+ const fg = this.deps.runRegistry.list()
83
+ .filter((r) => !this.deps.cwd || r.cwd === this.deps.cwd)
84
+ .map((r) => {
70
85
  const w = toWidgetRun(r);
71
86
  w.maxContext = this.deps.getModelContextWindow?.(r.model);
72
87
  return w;
@@ -105,6 +120,10 @@ export class FleetWidgetController {
105
120
  this.clearIntervalFn(this.timerId);
106
121
  this.timerId = null;
107
122
  }
123
+ if (this.livenessTimerId !== null) {
124
+ this.clearIntervalFn(this.livenessTimerId);
125
+ this.livenessTimerId = null;
126
+ }
108
127
  }
109
128
 
110
129
  /** Unsubscribe + clear timer + clear the widget. Idempotent. */
@@ -0,0 +1,22 @@
1
+ import type { GateResult } from "../lifecycle/gates/registry.ts";
2
+
3
+ export function gateGlyph(r: GateResult): string {
4
+ if (r.passed) return "✅";
5
+ if (r.onFail === "abort") return "⛔";
6
+ if (r.onFail === "revise") return "↻";
7
+ return "⚠"; // advise
8
+ }
9
+
10
+ /** Pure: build the compact gate line for a Lifecycle view phase row. */
11
+ export function buildGateLine(results: GateResult[]): string {
12
+ if (results.length === 0) return "";
13
+ const parts = results.map((r) => `${gateGlyph(r)}${r.gate}`);
14
+ // If the last failing gate short-circuited, append the action.
15
+ const lastFail = [...results].reverse().find((r) => !r.passed);
16
+ let suffix = "";
17
+ if (lastFail) {
18
+ if (lastFail.onFail === "abort") suffix = " → aborted";
19
+ else if (lastFail.onFail === "revise") suffix = " → revising";
20
+ }
21
+ return `gates: ${parts.join(" ")}${suffix}`;
22
+ }
package/src/panel/rows.ts CHANGED
@@ -92,6 +92,7 @@ export function backendInfo(b: Backend): string {
92
92
  }
93
93
 
94
94
  import type { LifecycleRunRecord, LifecycleStatus } from "../lifecycle/lifecycle-types.ts";
95
+ import { buildGateLine } from "./gate-line.ts";
95
96
 
96
97
 
97
98
  // SPEC-5a §11 — bg run row status (Q8=A). The fleet tab gains live status icons + phase progress
@@ -174,6 +175,8 @@ export function lifecyclePhaseTimeline(r: LifecycleRunRecord): string {
174
175
  const mark = p.reviseCount > 0 ? "[~]" : p.status === "completed" ? "[x]" : "[ ]";
175
176
  const art = p.paths.length ? ` → ${p.paths.join(", ")}` : "";
176
177
  lines.push(` ${mark} ${p.name} ${p.status}${art}${p.paths.length ? " [Open]" : ""}`);
178
+ const gateLine = buildGateLine(p.gateResults ?? []);
179
+ if (gateLine) lines.push(` ${gateLine}`);
177
180
  }
178
181
  if (r.status === "checkpoint") {
179
182
  lines.push("", "── Checkpoint ──", "[Continue] [Revise] [Abort]");
@@ -10,7 +10,9 @@ const STATUS_GLYPH: Record<RunMeta["status"], string> = {
10
10
 
11
11
  export function runsRow(r: RunMeta, getModelContextWindow?: (model: string) => number | undefined): string {
12
12
  const dur = r.endedAt ? fmtDuration(r.endedAt - r.startedAt) : "—";
13
- const tok = r.tokenTotal > 0 ? ` ${fmtTokens(r.tokenTotal)} tok` : "";
13
+ // SPEC-6-1 fix: "tok" is the final context snapshot (contextTokens), NOT cumulative
14
+ // tokenTotal — it pairs with the ctx% segment (same metric).
15
+ const tok = r.contextTokens != null && r.contextTokens > 0 ? ` ${fmtTokens(r.contextTokens)} tok` : "";
14
16
  const maxCtx = getModelContextWindow?.(r.model);
15
17
  const ctx = (r.contextTokens != null && maxCtx != null && maxCtx > 0) ? ` ${Math.round(r.contextTokens / maxCtx * 100)}%` : "";
16
18
  const cost = r.costTotal ? ` $${r.costTotal.toFixed(4)}` : "";
@@ -72,7 +72,10 @@ const STATUS_GLYPH: Record<WidgetRun["status"], string> = {
72
72
  function widgetLine(r: WidgetRun, now: number): string {
73
73
  const glyph = STATUS_GLYPH[r.status];
74
74
  const dur = typeof r.startedAt === "number" ? ` ${fmtDuration(now - r.startedAt)}` : "";
75
- const tok = r.tokenTotal ? ` ${fmtTokens(r.tokenTotal)} tok` : "";
75
+ // SPEC-6-1 fix: "tok" is the live context snapshot (contextTokens), NOT cumulative
76
+ // tokenTotal — it pairs with the ctx% segment (same metric). Showing tokenTotal here
77
+ // ballooned to 6.7M on long runs (cumulative re-sends) next to a 35% ctx, looking broken.
78
+ const tok = r.contextTokens != null ? ` ${fmtTokens(r.contextTokens)} tok` : "";
76
79
  const ctx = (r.contextTokens != null && r.maxContext != null && r.maxContext > 0) ? ` ${Math.round(r.contextTokens / r.maxContext * 100)}%` : "";
77
80
  const cost = r.costTotal ? ` $${r.costTotal.toFixed(4)}` : "";
78
81
 
@@ -41,6 +41,8 @@ export interface AsyncRunnerDeps {
41
41
  genRunId: () => string;
42
42
  /** SPEC-5a: called at each run/phase transition so the host (index.ts) can update the live bgRuns map. */
43
43
  onProgress?: (runId: string, status: import("../panel/rows.ts").BgRunStatus) => void;
44
+ /** SPEC-6-2: the RunRegistry so emitProgress can read the run's actual backend. */
45
+ runRegistry?: import("../engine/run-registry.ts").RunRegistry;
44
46
  }
45
47
 
46
48
  export interface RunBackgroundOpts {
@@ -60,7 +62,7 @@ function emitProgress(deps: AsyncRunnerDeps, runId: string, partial: Partial<imp
60
62
  runId,
61
63
  lifecycle: "",
62
64
  mode: "auto",
63
- backend: "pi",
65
+ backend: deps.runRegistry?.get(runId)?.backend ?? "pi",
64
66
  task: "",
65
67
  ...partial,
66
68
  });
@@ -3,27 +3,36 @@
3
3
  // is gone) as aborted so the Runs tab doesn't show stale "running" rows across restarts.
4
4
  // Foreground orphans; bg/lifecycle orphans are already handled by scanResumeCandidates (SPEC-5a).
5
5
  //
6
- // v0.10.2 patch: reconcile now ALSO syncs the in-memory RunRegistry (opts.runRegistry). Before this,
7
- // reconcile only wrote run:ended: aborted to the durable RunLog the in-memory RunRegistry kept
8
- // status:"running", so the live above-editor widget (filterActive keeps running|queued|paused)
9
- // rendered a stale ▶ row that ticked forever for every orphaned (process-gone) run.
6
+ // SPEC-6-2: rewritten to be probe-driven uses `probeRun` (handle/pid/age-fallback) instead of
7
+ // age+grace alone. This catches orphans whose process died but whose startedAt is within grace
8
+ // (e.g. a crash seconds after start), and avoids aborting runs whose process is alive but old
9
+ // (e.g. a long-running build).
10
10
  import type { RunLog } from "./run-log.ts";
11
- import type { RunRegistry } from "../engine/run-registry.ts";
11
+ import type { RunRegistry, RunRecord } from "../engine/run-registry.ts";
12
+
13
+ export type Liveness = "alive" | "dead";
14
+
15
+ /** SPEC-6-2: real probe first (handle / pid), age+grace fallback for cross-process pi-backend orphans. */
16
+ export function probeRun(rec: { status: string; session?: { isAlive?: () => boolean }; pid?: number; startedAt: number }, now: number, grace: number): Liveness {
17
+ // 1. in-process handle
18
+ if (rec.session && typeof rec.session.isAlive === "function") {
19
+ return rec.session.isAlive() ? "alive" : "dead";
20
+ }
21
+ // 2. pid (works cross-process — system-wide)
22
+ if (typeof rec.pid === "number") {
23
+ try { process.kill(rec.pid, 0); return "alive"; } catch { return "dead"; }
24
+ }
25
+ // 3. fallback — cross-process pi-backend orphan, no reachable probe
26
+ return (now - rec.startedAt > grace) ? "dead" : "alive";
27
+ }
12
28
 
13
29
  export interface ReconcileOpts {
14
- /** Orphans whose startedAt is older than (now - graceMs) are marked aborted. Default 60000. */
15
30
  graceMs?: number;
16
- /** Test injection. Default Date.now(). */
17
31
  now?: number;
18
- /**
19
- * v0.10.2: the in-memory RunRegistry to sync alongside the durable log. When set, each orphan
20
- * reconciled in the log is also transitioned to status:"aborted" in memory so the live widget
21
- * clears its stale ▶ row. Optional — existing callers that pass only a RunLog are unaffected.
22
- */
23
32
  runRegistry?: RunRegistry;
24
33
  }
25
34
 
26
- /** Returns the runIds it marked aborted. Idempotent: a run already ended is skipped. */
35
+ /** Returns the runIds it marked aborted. Probe-driven (SPEC-6-2); idempotent. */
27
36
  export function reconcileRuns(log: RunLog, opts: ReconcileOpts = {}): string[] {
28
37
  const grace = opts.graceMs ?? 60_000;
29
38
  const now = opts.now ?? Date.now();
@@ -31,14 +40,14 @@ export function reconcileRuns(log: RunLog, opts: ReconcileOpts = {}): string[] {
31
40
  const aborted: string[] = [];
32
41
  for (const meta of log.scanMeta()) {
33
42
  if (meta.status !== "running") continue;
34
- if (now - meta.startedAt <= grace) continue;
43
+ // The in-memory record (if present) carries the live handle/pid; the log meta carries pid for cross-process.
44
+ const memRec = reg?.get(meta.runId);
45
+ const probeRec = memRec ?? { status: meta.status, pid: (meta as { pid?: number }).pid, startedAt: meta.startedAt };
46
+ if (probeRun(probeRec, now, grace) !== "dead") continue;
35
47
  log.append(meta.runId, {
36
48
  type: "run:ended", runId: meta.runId, status: "aborted",
37
- endedAt: now, resultSummary: "process-gone", tokenTotal: meta.tokenTotal,
49
+ endedAt: now, resultSummary: "process-gone (probe)", tokenTotal: meta.tokenTotal,
38
50
  });
39
- // v0.10.2: sync the in-memory registry so the live widget (which reads runRegistry.list(),
40
- // not the RunLog) clears the orphan's stale ▶ row. No-op when the run isn't in the registry
41
- // (e.g. a cross-cwd orphan from another session — out of scope for this patch).
42
51
  reg?.update(meta.runId, { status: "aborted", endedAt: now });
43
52
  aborted.push(meta.runId);
44
53
  }
@@ -11,6 +11,10 @@ export interface RunMetaEvent {
11
11
  type: "run:meta"; runId: string; agent: string; model: string; task: string;
12
12
  startedAt: number; track: boolean; todoId: string | null;
13
13
  backendSessionId?: string; sessionKey?: string;
14
+ /** SPEC-6-2: claude child PID (cross-process liveness probe). */
15
+ pid?: number;
16
+ /** SPEC-6-2: the cwd this run belongs to. */
17
+ cwd?: string;
14
18
  }
15
19
  export interface MessageEvent {
16
20
  type: "message"; role: string; text: string;
@@ -41,6 +45,10 @@ export interface RunMeta {
41
45
  costTotal?: number;
42
46
  /** SPEC-6-1: latest context-token snapshot at run end. */
43
47
  contextTokens?: number;
48
+ /** SPEC-6-2: claude child PID. */
49
+ pid?: number;
50
+ /** SPEC-6-2: the cwd this run belongs to. */
51
+ cwd?: string;
44
52
  }
45
53
 
46
54
  const ARGS_LIMIT = 200;
@@ -96,7 +104,7 @@ export class RunLog {
96
104
  if (!meta) {
97
105
  meta = { runId: e.runId, agent: e.agent, model: e.model, task: e.task, startedAt: e.startedAt,
98
106
  track: e.track, todoId: e.todoId, backendSessionId: e.backendSessionId, sessionKey: e.sessionKey,
99
- status: "running", tokenTotal: 0 };
107
+ status: "running", tokenTotal: 0, pid: e.pid, cwd: e.cwd };
100
108
  } else {
101
109
  // latest binding wins
102
110
  if (e.backendSessionId) meta.backendSessionId = e.backendSessionId;