@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 +1 -1
- package/src/backend/claude-session.ts +5 -0
- package/src/engine/run-registry.ts +7 -0
- package/src/engine/spawnSubagent.ts +23 -4
- package/src/index.ts +30 -0
- package/src/lifecycle/default.ts +3 -0
- package/src/lifecycle/gates/builtin.ts +14 -0
- package/src/lifecycle/gates/chain-runner.ts +37 -0
- package/src/lifecycle/gates/completeness-check.ts +36 -0
- package/src/lifecycle/gates/gate.ts +41 -0
- package/src/lifecycle/gates/registry.ts +75 -0
- package/src/lifecycle/gates/verification-before-completion.ts +50 -0
- package/src/lifecycle/gates/verify.ts +47 -0
- package/src/lifecycle/lifecycle-types.ts +7 -0
- package/src/lifecycle/prompt-template.ts +13 -1
- package/src/lifecycle/registry.ts +16 -1
- package/src/lifecycle/run-lifecycle.ts +56 -3
- package/src/panel/fleet-panel.ts +37 -2
- package/src/panel/fleet-widget.ts +20 -1
- package/src/panel/gate-line.ts +22 -0
- package/src/panel/rows.ts +3 -0
- package/src/panel/runs-rows.ts +3 -1
- package/src/panel/widget-rows.ts +4 -1
- package/src/runtime/async-runner.ts +3 -1
- package/src/runtime/reconcile.ts +27 -18
- package/src/runtime/run-log.ts +9 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getpipher/armory-fleet",
|
|
3
|
-
"version": "0.
|
|
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
|
}
|
package/src/lifecycle/default.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
package/src/panel/fleet-panel.ts
CHANGED
|
@@ -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()
|
|
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]");
|
package/src/panel/runs-rows.ts
CHANGED
|
@@ -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
|
-
|
|
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)}` : "";
|
package/src/panel/widget-rows.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|
package/src/runtime/reconcile.ts
CHANGED
|
@@ -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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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.
|
|
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
|
-
|
|
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
|
}
|
package/src/runtime/run-log.ts
CHANGED
|
@@ -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;
|