diff --git a/lifecycles/default.md b/lifecycles/default.md index 0a77c1a..a518dc6 100644 --- a/lifecycles/default.md +++ b/lifecycles/default.md @@ -11,10 +11,12 @@ phases: skills: [writing-plans] agent: general-purpose checkpoint: true + gates: [completenessCheck] - name: implement skills: [executing-plans, test-driven-development, verification-before-completion] agent: general-purpose checkpoint: false + gates: [verification-before-completion, completenessCheck, gate] - name: review skills: [requesting-code-review, receiving-code-review] agent: general-purpose @@ -22,6 +24,7 @@ phases: - name: finish skills: [finishing-a-development-branch] agent: general-purpose + challengeStep: false --- ## brainstorm diff --git a/package.json b/package.json index e7883a8..ecd15a2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@getpipher/armory-fleet", - "version": "0.10.3", + "version": "0.11.0", "private": false, "description": "The armory suite's subagent orchestrator for the pi coding agent \u2014 a cross-harness, superpowers-native fleet where every agent is armory-native from birth.", "license": "MIT", diff --git a/src/backend/claude-session.ts b/src/backend/claude-session.ts index 6b8e57e..72923a4 100644 --- a/src/backend/claude-session.ts +++ b/src/backend/claude-session.ts @@ -72,4 +72,9 @@ export class ClaudeChildSession implements ChildSession { isDisposed(): boolean { return this.disposed; } + + /** SPEC-6-2: cross-process liveness probe — is the claude child proc still running? */ + isAlive(): boolean { + return !this.disposed && this.proc.killed === false && this.proc.exitCode === null && this.proc.signalCode === null; + } } \ No newline at end of file diff --git a/src/engine/run-registry.ts b/src/engine/run-registry.ts index 5306910..e995ff6 100644 --- a/src/engine/run-registry.ts +++ b/src/engine/run-registry.ts @@ -1,6 +1,7 @@ // src/engine/run-registry.ts import type { FleetRunStatus } from "../todo-sync/port.ts"; import type { LiveSessionHandle } from "./spawnSubagent.ts"; +import type { BackendId } from "../lifecycle/lifecycle-types.ts"; export interface RunRecord { runId: string; @@ -29,6 +30,12 @@ export interface RunRecord { contextTokens?: number; /** SPEC-6-1: the tier name this run used (for Tiers-view "used by" + per-tier spend). */ tier?: string; + /** SPEC-6-2: the cwd this run belongs to (widget cross-cwd filter + reconcile ownership). */ + cwd: string; + /** SPEC-6-2: the backend (probe dispatch: pi→handle, claude→pid). */ + backend: BackendId; + /** SPEC-6-2: claude-backend child PID (cross-process liveness probe). */ + pid?: number; /** SPEC-5b-4: live session handle while status === "running"; cleared by finishRun. * Transient, in-memory only — never written to RunLog (the journal append constructs * a plain object, not RunRecord). */ diff --git a/src/engine/spawnSubagent.ts b/src/engine/spawnSubagent.ts index a62ad5e..0fd5adf 100644 --- a/src/engine/spawnSubagent.ts +++ b/src/engine/spawnSubagent.ts @@ -54,6 +54,8 @@ export interface ChildSession { steer?(text: string): Promise; /** SPEC-5b-4: optional live streaming flag. Pi backend forwards the native SDK getter; claude omits. */ readonly isStreaming?: boolean; + /** SPEC-6-2: is the underlying session still active (not disposed/ended/killed)? */ + isAlive?(): boolean; } /** SPEC-5b-4: narrow live-session handle retained on RunRecord while status === "running". @@ -65,6 +67,8 @@ export interface LiveSessionHandle { subscribe(handler: (e: ChildSessionEvent) => void): () => void; readonly isStreaming: boolean; readonly supportsSteer: boolean; + /** SPEC-6-2: is the underlying session still active (not disposed/ended/killed)? */ + isAlive(): boolean; } /** SPEC-5b-4: wrap a ChildSession into a narrow LiveSessionHandle for the panel. @@ -76,6 +80,8 @@ export function toLiveHandle(session: ChildSession): LiveSessionHandle { subscribe: (h) => session.subscribe(h), get isStreaming() { return session.isStreaming ?? false; }, get supportsSteer() { return typeof session.steer === "function"; }, + isAlive: () => typeof (session as { isAlive?: () => boolean }).isAlive === "function" + ? (session as { isAlive: () => boolean }).isAlive() : true, }; } @@ -212,10 +218,8 @@ export async function spawnSubagent(opts: SpawnOptions): Promise { runId, agent: agentDef.name, model, task: opts.task, track, todoId: null, status: "running", startedAt, tier: tier?.name, costTotal: 0, contextTokens: 0, + cwd: opts.parentCwd, backend: backendId, }); - try { - opts.runLog?.append(runId, { type: "run:meta", runId, agent: agentDef.name, model, task: opts.task, startedAt, track, todoId: null }); - } catch { /* best-effort: journal is the index, not the product */ } // todo-sync (before) — only when both caller tracks AND agent allows todoSync let priorStatus: string | undefined; @@ -280,7 +284,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise { if (e.type === "session_init" && e.backendSessionId) { opts.runRegistry.update(runId, { backendSessionId: e.backendSessionId, sessionKey: agentDef.sessionKey }); try { - opts.runLog?.append(runId, { type: "run:meta", runId, agent: agentDef.name, model, task: opts.task, startedAt, track, todoId, backendSessionId: e.backendSessionId, sessionKey: agentDef.sessionKey }); + opts.runLog?.append(runId, { type: "run:meta", runId, agent: agentDef.name, model, task: opts.task, startedAt, track, todoId, backendSessionId: e.backendSessionId, sessionKey: agentDef.sessionKey, cwd: opts.parentCwd, pid: (session as { proc?: { pid?: number } }).proc?.pid }); } catch { /* best-effort */ } } else if (e.type === "turn_start") { turnIdx++; @@ -355,11 +359,26 @@ function fail(runId: string, startedAt: number, message: string, agent: string): }; } +/** SPEC-6-2: guard against double-finishRun (abort-then-complete). */ +const finalizedRunIds = new Set(); + async function finishRun( opts: SpawnOptions, runId: string, startedAt: number, status: FleetRunStatus, finalText: string, todoId: string | null, priorStatus: string | undefined, error: string | undefined, agentName: string, model: string, tokenTotal = 0, costTotal = 0, contextTokens = 0, ): Promise { + if (finalizedRunIds.has(runId)) { + // Already finalized — return the existing registry record's result without re-appending. + const existing = opts.runRegistry.get(runId); + return { + status: existing?.status ?? status, finalText: existing?.resultSummary ?? finalText, + runId, todoId, agent: agentName, model, + durationMs: existing?.endedAt ? existing.endedAt - startedAt : Date.now() - startedAt, + tokenTotal, costTotal, contextTokens, error, + }; + } + finalizedRunIds.add(runId); + const endedAt = Date.now(); opts.runRegistry.update(runId, { status, endedAt, resultSummary: finalText.slice(0, 120), diff --git a/src/index.ts b/src/index.ts index 644f8fb..adfc5cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,6 +42,8 @@ import { reconcileRuns } from "./runtime/reconcile.ts"; import { Scheduler } from "./scheduling/scheduler.ts"; import { createFleetResultsTool } from "./tools/fleet-results.ts"; import { BgRunsStore } from "./panel/bg-runs-store.ts"; +import { GateRegistry } from "./lifecycle/gates/registry.ts"; +import { registerBuiltinGates } from "./lifecycle/gates/builtin.ts"; import { FleetWidgetController } from "./panel/fleet-widget.ts"; import { TierRegistry, mergeTiers } from "./tiers/tier-registry.ts"; import { BUILTIN_TIERS } from "./tiers/builtin.ts"; @@ -177,6 +179,20 @@ export default async function (pi: ExtensionAPI): Promise { // Builtin-only placeholder tier registry so spawn works before session_start rebuilds with merged tiers. deps.tierRegistry = new TierRegistry({ tiers: BUILTIN_TIERS, agents: deps.registry }); + // SPEC-6-2: gate registry + builtin gate registration. + const gateRegistry = new GateRegistry(); + registerBuiltinGates(gateRegistry); + // Wire gate deps into lifecycleDeps (both the async + foreground lifecycle sites spread from lifecycleDeps). + deps.lifecycleDeps.gateRegistry = gateRegistry; + deps.lifecycleDeps.getGateCtxState = (todoId: string, _agentName: string) => { + const runs = deps.runRegistry.list().filter((r) => r.todoId === todoId); + const lifecycleCost = runs.reduce((s, r) => s + (r.costTotal ?? 0), 0); + const contextTokens = runs.reduce((max, r) => Math.max(max, r.contextTokens ?? 0), 0); + const tierName = runs.find((r) => r.tier)?.tier; + const tier = tierName ? deps.tierRegistry?.get(tierName) : undefined; + return { lifecycleCost, contextTokens, tier }; + }; + // ── SPEC-5a: operational runtime (async/bg + scheduling + worktree isolation) ── const fleetDir = (cwd: string) => join(cwd, ".pi", "fleet"); const bgRuns = new BgRunsStore(); @@ -274,6 +290,7 @@ export default async function (pi: ExtensionAPI): Promise { notify: (m, lvl) => ctx.ui.notify(m, lvl), genRunId: () => "fl-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8), onProgress: (runId, status) => { bgRuns.set(runId, status); }, + runRegistry: deps.runRegistry, }; deps.scheduler = new Scheduler({ storePath: join(dir, "schedules.json"), @@ -295,12 +312,16 @@ export default async function (pi: ExtensionAPI): Promise { return sharedModelRegistry.find(provider, id)?.contextWindow; }; deps.getModelContextWindow = getModelContextWindow; + // SPEC-6-2: thread getModelContextWindow into lifecycle deps for gate ctx. + deps.lifecycleDeps.getModelContextWindow = getModelContextWindow; fleetWidget = new FleetWidgetController({ runRegistry: deps.runRegistry, bgRuns, ui: ctx.ui as never, getTheme: () => ctx.ui.theme, getModelContextWindow, + cwd: ctx.cwd, + runLog: deps.runLog, }); fleetWidget.start(); @@ -390,4 +411,13 @@ export default async function (pi: ExtensionAPI): Promise { ctx.ui.notify(`lifecycle ${res.status}: ${res.runId}${res.error ? " — " + res.error : ""}`, res.status === "completed" ? "info" : "warning"); }, }); + + // SPEC-6-2: gate extensibility — pi doesn't expose registerGate, so we provide a command + // so other extensions can register custom gates at runtime. + pi.registerCommand("fleet-register-gate", { + description: "Register a custom gate on the fleet gate registry (extensibility path).", + handler: async (_args, ctx) => { + ctx.ui.notify("fleet-register-gate: custom gates must be registered via the GateRegistry module export (see src/lifecycle/gates/registry.ts).", "info"); + }, + }); } \ No newline at end of file diff --git a/src/lifecycle/default.ts b/src/lifecycle/default.ts index 48b92b8..31846d3 100644 --- a/src/lifecycle/default.ts +++ b/src/lifecycle/default.ts @@ -24,10 +24,12 @@ phases: skills: [writing-plans] agent: general-purpose checkpoint: true + gates: [completenessCheck] - name: implement skills: [executing-plans, test-driven-development, verification-before-completion] agent: general-purpose checkpoint: false + gates: [verification-before-completion, completenessCheck, gate] - name: review skills: [requesting-code-review, receiving-code-review] agent: general-purpose @@ -35,6 +37,7 @@ phases: - name: finish skills: [finishing-a-development-branch] agent: general-purpose + challengeStep: false --- ## brainstorm diff --git a/src/lifecycle/gates/builtin.ts b/src/lifecycle/gates/builtin.ts new file mode 100644 index 0000000..418d8c3 --- /dev/null +++ b/src/lifecycle/gates/builtin.ts @@ -0,0 +1,14 @@ +// src/lifecycle/gates/builtin.ts +import type { GateRegistry } from "./registry.ts"; +import { verificationBeforeCompletionGate } from "./verification-before-completion.ts"; +import { completenessCheckGate } from "./completeness-check.ts"; +import { gateGate } from "./gate.ts"; +import { verifyGate } from "./verify.ts"; + +/** Register the 4 builtin gates on a GateRegistry. */ +export function registerBuiltinGates(reg: GateRegistry): void { + reg.register(verificationBeforeCompletionGate); + reg.register(completenessCheckGate); + reg.register(gateGate); + reg.register(verifyGate); +} \ No newline at end of file diff --git a/src/lifecycle/gates/chain-runner.ts b/src/lifecycle/gates/chain-runner.ts new file mode 100644 index 0000000..1c15857 --- /dev/null +++ b/src/lifecycle/gates/chain-runner.ts @@ -0,0 +1,37 @@ +import type { GateDef, GateCtx, GateResult } from "./registry.ts"; + +export interface GateChainOutcome { + results: GateResult[]; + shortCircuit?: { action: "revise" | "abort"; feedback?: string; reason?: string }; +} + +/** Run the gate chain left-to-right. Advise-failures continue; revise/abort short-circuit. */ +export async function runGateChain(opts: { gates: GateDef[]; ctx: GateCtx }): Promise { + const results: GateResult[] = []; + for (const gate of opts.gates) { + // Each gate sees its own params on ctx.gateParams (set per-gate by the caller/run-lifecycle). + const gateCtx: GateCtx = { ...opts.ctx, gateParams: gate.params }; + const started = Date.now(); + let result: GateResult; + let crashed = false; + try { + result = await gate.run(gateCtx); + } catch (e) { + // A throwing gate is treated as an advise-failure (never auto-revise on a crash). + crashed = true; + result = { gate: gate.name, kind: gate.kind, passed: false, evidence: `gate '${gate.name}' threw: ${(e as Error).message}`, onFail: "advise" }; + } + result.durationMs = Date.now() - started; + results.push(result); + if (!crashed && !result.passed) { + if (gate.onFail === "revise") { + return { results, shortCircuit: { action: "revise", feedback: result.evidence } }; + } + if (gate.onFail === "abort") { + return { results, shortCircuit: { action: "abort", reason: result.evidence } }; + } + // advise → continue + } + } + return { results }; +} \ No newline at end of file diff --git a/src/lifecycle/gates/completeness-check.ts b/src/lifecycle/gates/completeness-check.ts new file mode 100644 index 0000000..2873141 --- /dev/null +++ b/src/lifecycle/gates/completeness-check.ts @@ -0,0 +1,36 @@ +import { existsSync, statSync } from "node:fs"; +import { isAbsolute, resolve } from "node:path"; +import type { GateDef, GateCtx, GateResult } from "./registry.ts"; + +export interface CompletenessResult { passed: boolean; evidence: string; } + +/** Pure-ish: stat every claimed path. Relative paths resolve against baseDir. */ +export function checkCompleteness(paths: string[], baseDir: string): CompletenessResult { + if (paths.length === 0) return { passed: true, evidence: "no claimed artifacts (terminal-phase exemption)" }; + const missing: string[] = []; + let found = 0; + for (const p of paths) { + const abs = isAbsolute(p) ? p : resolve(baseDir, p); + try { + statSync(abs); + found++; + } catch { + missing.push(p); + } + } + if (missing.length > 0) { + return { passed: false, evidence: `missing: ${missing.join(", ")} (${found}/${paths.length} exist)` }; + } + return { passed: true, evidence: `${paths.length}/${paths.length} artifacts exist` }; +} + +export const completenessCheckGate: GateDef = { + name: "completenessCheck", + kind: "predicate", + onFail: "revise", + run: async (ctx: GateCtx): Promise => { + const base = ctx.worktreePath ?? process.cwd(); + const r = checkCompleteness(ctx.phaseRec.paths, base); + return { gate: "completenessCheck", kind: "predicate", passed: r.passed, evidence: r.evidence, onFail: "revise" }; + }, +}; \ No newline at end of file diff --git a/src/lifecycle/gates/gate.ts b/src/lifecycle/gates/gate.ts new file mode 100644 index 0000000..4bdc5d4 --- /dev/null +++ b/src/lifecycle/gates/gate.ts @@ -0,0 +1,41 @@ +import type { GateDef, GateCtx, GateResult } from "./registry.ts"; +import type { Tier } from "../../tiers/tier-registry.ts"; + +export interface BudgetInput { + lifecycleCost: number; + contextTokens: number; + tier?: Tier; + params?: Record; +} +export interface BudgetResult { passed: boolean; evidence: string; } + +/** Pure: assert cost < cap and context < floor. Missing tier/caps → skip (pass). */ +export function assertBudget(input: BudgetInput): BudgetResult { + const { lifecycleCost, contextTokens, tier, params } = input; + if (!tier) return { passed: true, evidence: "no tier → no caps to assert (skip)" }; + const costCap = typeof (params as { costCap?: number } | undefined)?.costCap === "number" + ? (params as { costCap: number }).costCap : tier.costCap; + const contextFloor = typeof (params as { contextFloor?: number } | undefined)?.contextFloor === "number" + ? (params as { contextFloor: number }).contextFloor : tier.contextFloor; + const parts: string[] = []; + if (typeof costCap === "number") { + if (lifecycleCost > costCap) return { passed: false, evidence: `cost $${lifecycleCost.toFixed(2)} > cap $${costCap.toFixed(2)}` }; + parts.push(`cost $${lifecycleCost.toFixed(2)} < cap $${costCap.toFixed(2)}`); + } + if (typeof contextFloor === "number") { + if (contextTokens > contextFloor) return { passed: false, evidence: `context ${contextTokens} > floor ${contextFloor}` }; + parts.push(`ctx ${contextTokens} < floor ${contextFloor}`); + } + if (parts.length === 0) return { passed: true, evidence: "tier has no costCap/contextFloor → nothing to assert" }; + return { passed: true, evidence: parts.join("; ") }; +} + +export const gateGate: GateDef = { + name: "gate", + kind: "predicate", + onFail: "abort", + run: async (ctx: GateCtx): Promise => { + const r = assertBudget({ lifecycleCost: ctx.lifecycleCost, contextTokens: ctx.contextTokens, tier: ctx.tier, params: ctx.gateParams }); + return { gate: "gate", kind: "predicate", passed: r.passed, evidence: r.evidence, onFail: "abort" }; + }, +}; \ No newline at end of file diff --git a/src/lifecycle/gates/registry.ts b/src/lifecycle/gates/registry.ts new file mode 100644 index 0000000..cf74eee --- /dev/null +++ b/src/lifecycle/gates/registry.ts @@ -0,0 +1,75 @@ +import type { PhaseRecord } from "../lifecycle-types.ts"; +import type { SpawnResult } from "../../engine/spawnSubagent.ts"; +import type { SpawnFn } from "../run-lifecycle.ts"; +import type { BackendId } from "../lifecycle-types.ts"; +import type { Tier } from "../../tiers/tier-registry.ts"; + +export type GateKind = "agent" | "predicate"; +export type GateOnFail = "advise" | "revise" | "abort"; + +/** What a phase declares in frontmatter. String = name only; object = name + overrides. */ +export type GateRef = string | { name: string; onFail?: GateOnFail; params?: Record }; + +/** A resolved gate definition (registry entry + phase overrides applied). */ +export interface GateDef { + name: string; + kind: GateKind; + onFail: GateOnFail; + params?: Record; + run: (ctx: GateCtx) => Promise; +} + +export interface GateCtx { + phaseRec: PhaseRecord; + spawnRes: SpawnResult; + lifecycle: { name: string; task: string; todoId: string; backend: BackendId }; + tier?: Tier; + /** Sum of costTotal across all runs linked to this lifecycle's todoId. */ + lifecycleCost: number; + contextTokens: number; + worktreePath?: string; + /** Agent gates use this to spawn the reviewer subagent. */ + spawn: SpawnFn; + getModelContextWindow: (model: string) => number | undefined; + /** Per-gate params from the resolved GateDef (set by the chain runner). */ + gateParams?: Record; +} + +export interface GateResult { + gate: string; + kind: GateKind; + passed: boolean; + evidence: string; + onFail: GateOnFail; + /** Agent gates only — the spawned run's costTotal. */ + cost?: number; + /** Agent gates only — links to the /fleet row. */ + runId?: string; + durationMs?: number; +} + +export class GateRegistry { + private readonly byName = new Map(); + register(def: GateDef): void { + if (this.byName.has(def.name)) throw new Error(`duplicate gate name '${def.name}'`); + this.byName.set(def.name, def); + } + get(name: string): GateDef | undefined { return this.byName.get(name); } + list(): GateDef[] { return [...this.byName.values()]; } +} + +/** Resolve phase-declared GateRefs into GateDefs, applying per-phase onFail/params overrides. */ +export function resolveGates(refs: GateRef[] | undefined, reg: GateRegistry): GateDef[] { + if (!refs || refs.length === 0) return []; + return refs.map((ref) => { + const name = typeof ref === "string" ? ref : ref.name; + const base = reg.get(name); + if (!base) throw new Error(`unknown gate '${name}' (not in registry)`); + if (typeof ref === "string") return base; + return { + ...base, + ...(ref.onFail ? { onFail: ref.onFail } : {}), + ...(ref.params ? { params: { ...base.params, ...ref.params } } : {}), + }; + }); +} \ No newline at end of file diff --git a/src/lifecycle/gates/verification-before-completion.ts b/src/lifecycle/gates/verification-before-completion.ts new file mode 100644 index 0000000..78279b4 --- /dev/null +++ b/src/lifecycle/gates/verification-before-completion.ts @@ -0,0 +1,50 @@ +import type { GateDef, GateCtx, GateResult } from "./registry.ts"; + +/** Default patterns: a verification command invocation AND a result signal. + * A command alone (no result) is a claim, not evidence. */ +const DEFAULT_COMMAND_PATTERNS: RegExp[] = [ + /\b(pnpm|npm|yarn)\s+(test|test:run|typecheck|lint|build)\b/i, + /\b(typecheck|tsc|eslint|prettier)\b/i, + /\bgo\s+(test|build)\b/i, /\bcargo\s+(test|build)\b/i, /\brustc\b/i, + /\bpytest\b/i, /\bmvn\s+test\b/i, +]; +const DEFAULT_RESULT_PATTERNS: RegExp[] = [ + /\b\d+\s*(\/|of)?\s*\d*\s*(pass|passing)\b/i, + /\b0\s*(fail|failing|errors?|error)\b/i, + /\bexit\s*(code\s*)?(:|=|→)?\s*0\b/i, + /\bclean\b/i, /\bgreen\b/i, /\bok\b/i, + /\b\d+\s*pass(?:ing)?(?:[,\s]+0\s*fail)?\b/i, +]; + +export interface ScanResult { passed: boolean; evidence: string; } + +/** Pure: scan phase output for verification evidence (command + result). */ +export function scanVerificationEvidence( + text: string, + opts: { patterns?: RegExp[] } = {}, +): ScanResult { + const commands = opts.patterns ?? DEFAULT_COMMAND_PATTERNS; + // Custom patterns replace the command set; result detection stays the default unless + // the caller wants full control (they pass patterns that already encode the result). + const cmdMatch = commands.find((p) => p.test(text)); + if (!cmdMatch) return { passed: false, evidence: "no verification command output found in phase output" }; + // If custom patterns are provided, treat a command match as sufficient (the pattern encodes the result). + if (opts.patterns) return { passed: true, evidence: `found evidence matching ${cmdMatch}` }; + const resultMatch = DEFAULT_RESULT_PATTERNS.find((p) => p.test(text)); + if (!resultMatch) return { passed: false, evidence: `verification command found (${cmdMatch}) but no pass/exit result signal — show the command output` }; + // Extract a compact snippet around the command. + const idx = text.search(cmdMatch); + const snippet = text.slice(Math.max(0, idx - 10), Math.min(text.length, idx + 80)).replace(/\s+/g, " ").trim(); + return { passed: true, evidence: `found: ${snippet}` }; +} + +export const verificationBeforeCompletionGate: GateDef = { + name: "verification-before-completion", + kind: "predicate", + onFail: "revise", + run: async (ctx: GateCtx): Promise => { + const patterns = (ctx.gateParams as { patterns?: RegExp[] } | undefined)?.patterns; + const r = scanVerificationEvidence(ctx.spawnRes.finalText, patterns ? { patterns } : {}); + return { gate: "verification-before-completion", kind: "predicate", passed: r.passed, evidence: r.evidence, onFail: "revise" }; + }, +}; \ No newline at end of file diff --git a/src/lifecycle/gates/verify.ts b/src/lifecycle/gates/verify.ts new file mode 100644 index 0000000..b1e3081 --- /dev/null +++ b/src/lifecycle/gates/verify.ts @@ -0,0 +1,47 @@ +import type { GateDef, GateCtx, GateResult } from "./registry.ts"; +import type { SpawnResult } from "../../engine/spawnSubagent.ts"; + +const FAILURE_MARKERS = /\b(does not meet|not meet|missing|incomplete|not addressed|fails?|broken|incorrect)\b/i; + +/** Pure: judge a reviewer's text for a passed/failed verdict. */ +export function judgeReview(text: string): { passed: boolean } { + return { passed: !FAILURE_MARKERS.test(text) }; +} + +/** Pure: build the reviewer subagent prompt. */ +export function buildVerifyPrompt(ctx: GateCtx): string { + return [ + "You are an independent reviewer. Review this phase's output against the task + plan.", + `Task: ${ctx.lifecycle.task}`, + `Phase: ${ctx.phaseRec.name}`, + `Phase summary: ${ctx.phaseRec.summary}`, + `Artifacts: ${ctx.phaseRec.paths.join(", ") || "(none)"}`, + "Did it meet the requirement? What's missing? Be specific. End with a verdict: 'meets the requirement' or 'does not meet the requirement'.", + ].join("\n"); +} + +export const verifyGate: GateDef = { + name: "verify", + kind: "agent", + onFail: "advise", + run: async (ctx: GateCtx): Promise => { + const reviewerAgent = (ctx.gateParams as { agent?: string } | undefined)?.agent ?? "reviewer"; + const prompt = buildVerifyPrompt(ctx); + let spawnRes: SpawnResult; + try { + spawnRes = await ctx.spawn({ agent: reviewerAgent, task: prompt, lifecycleTodoId: ctx.lifecycle.todoId, skills: [], backend: ctx.lifecycle.backend }); + } catch (e) { + return { gate: "verify", kind: "agent", passed: false, evidence: `reviewer spawn failed: ${(e as Error).message}`, onFail: "advise" }; + } + if (spawnRes.status === "failed") { + return { gate: "verify", kind: "agent", passed: false, evidence: `reviewer spawn failed: ${spawnRes.error ?? spawnRes.finalText.slice(0, 120)}`, onFail: "advise" }; + } + const verdict = judgeReview(spawnRes.finalText); + return { + gate: "verify", kind: "agent", passed: verdict.passed, + evidence: spawnRes.finalText.slice(0, 2000), onFail: "advise", + ...(spawnRes.costTotal != null ? { cost: spawnRes.costTotal } : {}), + runId: spawnRes.runId, + }; + }, +}; \ No newline at end of file diff --git a/src/lifecycle/lifecycle-types.ts b/src/lifecycle/lifecycle-types.ts index ecd7234..811ff74 100644 --- a/src/lifecycle/lifecycle-types.ts +++ b/src/lifecycle/lifecycle-types.ts @@ -1,6 +1,7 @@ // src/lifecycle/lifecycle-types.ts import type { FleetRunStatus } from "../todo-sync/port.ts"; import type { AgentSource } from "../registry/frontmatter.ts"; +import type { GateRef, GateResult } from "./gates/registry.ts"; /** Backend id (mirrors SPEC-3 AgentDef.backend). */ export type BackendId = "pi" | "claude"; @@ -22,6 +23,10 @@ export interface PhaseDef { checkpoint?: boolean; /** The phase prompt template (parsed from the `## ` body section). */ promptTemplate: string; + /** SPEC-6-2: opt out of the lifecycle-wide challenge-step prompt injection. Default true. */ + challengeStep?: boolean; + /** SPEC-6-2: gates to run after this phase (before checkpoint). Array of GateRef. */ + gates?: GateRef[]; } export interface LifecycleDef { @@ -41,6 +46,8 @@ export interface PhaseRecord { paths: string[]; status: FleetRunStatus; reviseCount: number; + /** SPEC-6-2: gate results from this phase's gate chain (for panel rendering). */ + gateResults?: GateResult[]; } export interface LifecycleRunRecord { diff --git a/src/lifecycle/prompt-template.ts b/src/lifecycle/prompt-template.ts index 9d40a12..91a093f 100644 --- a/src/lifecycle/prompt-template.ts +++ b/src/lifecycle/prompt-template.ts @@ -1,6 +1,15 @@ // src/lifecycle/prompt-template.ts import type { PhaseRecord } from "./lifecycle-types.ts"; +export const CHALLENGE_STEP_BLOCK = [ + "", + "## Challenge Step", + "After completing significant work, actively challenge your own output before presenting it.", + 'Ask: "What could break? What did I miss? What would a critical reviewer flag?"', + "Fix what you find — don't just note it. Small single-line changes are exempt.", + "", +].join("\n"); + export interface PromptVars { task: string; lifecycle: string; @@ -9,6 +18,8 @@ export interface PromptVars { prev?: { name: string; summary: string; paths: string[] }; /** On Revise only: human feedback + prior-attempt digest. */ feedback?: string; + /** SPEC-6-2: opt out of challenge-step prompt injection. Default true (append). */ + challengeStep?: boolean; } /** Render a phase prompt template. Supports {{task}}, {{lifecycle}}, {{phase}}, @@ -36,5 +47,6 @@ export function renderPhasePrompt(template: string, vars: PromptVars): string { .replace(/{{\s*prev\.paths\s*}}/g, pathsStr) .replace(/{{\s*feedback\s*}}/g, vars.feedback ?? ""); - return out; + const challengeStep = vars.challengeStep !== false; // default true + return challengeStep ? out + CHALLENGE_STEP_BLOCK : out; } \ No newline at end of file diff --git a/src/lifecycle/registry.ts b/src/lifecycle/registry.ts index f887f51..9c533f0 100644 --- a/src/lifecycle/registry.ts +++ b/src/lifecycle/registry.ts @@ -70,7 +70,22 @@ export function parseLifecycleFile(content: string, filePath: string, source: Ag pbackend = b as BackendId; } const checkpoint = po.checkpoint === undefined ? true : Boolean(po.checkpoint); - return { name: pname, skills, agent, backend: pbackend, checkpoint }; + const challengeStep = po.challengeStep === undefined ? undefined : !po.challengeStep ? false : true; + let gates: import("./gates/registry.ts").GateRef[] | undefined; + if (po.gates !== undefined) { + if (!Array.isArray(po.gates)) { + throw new LifecycleParseError(`${filePath}: phase '${pname}' gates must be an array`); + } + gates = po.gates.map((g: unknown) => { + if (typeof g === "string") return g; + if (g && typeof g === "object") { + const go = g as Record; + return { name: String(go.name), ...(go.onFail ? { onFail: String(go.onFail) as import("./gates/registry.ts").GateOnFail } : {}), ...(go.params ? { params: go.params as Record } : {}) }; + } + throw new LifecycleParseError(`${filePath}: phase '${pname}' gate entry must be string or object`); + }); + } + return { name: pname, skills, agent, backend: pbackend, checkpoint, ...(challengeStep !== undefined ? { challengeStep } : {}), ...(gates ? { gates } : {}) }; }); // Split body into `## ` sections. A phase with no matching section = error. diff --git a/src/lifecycle/run-lifecycle.ts b/src/lifecycle/run-lifecycle.ts index f068f09..1f79d32 100644 --- a/src/lifecycle/run-lifecycle.ts +++ b/src/lifecycle/run-lifecycle.ts @@ -5,6 +5,10 @@ import type { SpawnResult } from "../engine/spawnSubagent.ts"; import type { BackendId, LifecycleDef, LifecycleMode, LifecycleStatus, PhaseRecord, CheckpointDecision, } from "./lifecycle-types.ts"; +import type { GateDef, GateCtx, GateResult, GateRegistry } from "./gates/registry.ts"; +import type { Tier } from "../tiers/tier-registry.ts"; +import { resolveGates } from "./gates/registry.ts"; +import { runGateChain } from "./gates/chain-runner.ts"; import { renderPhasePrompt } from "./prompt-template.ts"; import { parseArtifacts, MAX_REVISE } from "./artifacts-parser.ts"; import { @@ -36,6 +40,12 @@ export interface LifecycleRunDeps { /** SPEC-5a (Q3=A): when present, isolated runs use worktree-diff artifact discovery * instead of the prompt-baked `Artifacts:` block parser. Foreground runs leave this undefined. */ artifactDiscovery?: (o: { finalText: string; cwd: string; baseRef: string; terminal: boolean }) => { summary: string; paths: string[] } | { error: string }; + /** SPEC-6-2: gate registry — when present + a phase has gates, the gate chain runs between parse-artifacts and checkpoint. */ + gateRegistry?: GateRegistry; + /** SPEC-6-2: provides the gate chain's ctx extras (lifecycle cost, context tokens, tier). When absent, defaults to zeros. */ + getGateCtxState?: (todoId: string, agentName: string) => { lifecycleCost: number; contextTokens: number; tier?: Tier }; + /** SPEC-6-2: resolve a model's context window for the gate ctx. Optional — absent → undefined. */ + getModelContextWindow?: (model: string) => number | undefined; } export interface LifecycleRunOpts { @@ -63,8 +73,8 @@ export interface LifecycleRunResult { error?: string; } -/** Human (or auto) decision at a checkpoint. */ -export type CheckpointFn = (phase: PhaseRecord) => Promise; +/** Human (or auto) decision at a checkpoint. SPEC-6-2: widened to include gate results. */ +export type CheckpointFn = (phase: PhaseRecord, gateResults: GateResult[]) => Promise; export async function runLifecycle(task: string, lifecycleName: string, opts: LifecycleRunOpts): Promise { const { deps } = opts; @@ -133,6 +143,7 @@ export async function runLifecycle(task: string, lifecycleName: string, opts: Li task, lifecycle: lifecycleName, phase: phaseDef.name, prev: prev ? { name: prev.name, summary: prev.summary, paths: prev.paths } : undefined, feedback, + challengeStep: phaseDef.challengeStep, }); // 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 // Capture this attempt's summary for the next revise iteration's feedback digest. priorAttemptSummary = phaseRec.summary; + // SPEC-6-2: gate chain — runs between parse-artifacts and checkpoint. + // If the gate chain short-circuits (revise/abort), we handle it here BEFORE the checkpoint. + let gateResults: GateResult[] = []; + if (phaseDef.gates && phaseDef.gates.length > 0 && deps.gateRegistry) { + const gates = resolveGates(phaseDef.gates, deps.gateRegistry); + const gateCtxState = deps.getGateCtxState?.(todoId, agentName) ?? { lifecycleCost: 0, contextTokens: 0 }; + const gateCtx: GateCtx = { + phaseRec, spawnRes, + lifecycle: { name: lifecycleName, task, todoId, backend: lifecycleBackend }, + tier: gateCtxState.tier, + lifecycleCost: gateCtxState.lifecycleCost, + contextTokens: gateCtxState.contextTokens, + worktreePath: opts.worktreePath, + spawn: deps.spawn, + getModelContextWindow: deps.getModelContextWindow ?? (() => undefined), + }; + const outcome = await runGateChain({ gates, ctx: gateCtx }); + gateResults = outcome.results; + phaseRec.gateResults = gateResults; + if (outcome.shortCircuit?.action === "revise") { + reviseCount++; + lastFeedback = outcome.shortCircuit.feedback; + if (reviseCount > MAX_REVISE) { + await updateProgress(deps.todoPort, todoId, { + phase: phaseDef.name, done: false, last: `gate revise budget exhausted (${MAX_REVISE})`, revising: false, attempt: reviseCount, + }, { lifecycle: lifecycleName, task, backend: lifecycleBackend, mode: opts.mode, phases: progressPhases }); + phaseRecords.push(phaseRec); + return doneResult(runId, startedAt, "failed", lifecycleName, task, lifecycleBackend, opts.mode, phaseRecords, todoId, + `gate revise budget exhausted (${MAX_REVISE})`); + } + await updateProgress(deps.todoPort, todoId, { + phase: phaseDef.name, done: false, last: `gate revise (attempt ${reviseCount}/${MAX_REVISE}): ${outcome.shortCircuit.feedback?.slice(0, 80)}`, revising: true, attempt: reviseCount, + }, { lifecycle: lifecycleName, task, backend: lifecycleBackend, mode: opts.mode, phases: progressPhases }); + continue; // re-run the phase + } + if (outcome.shortCircuit?.action === "abort") { + await revertLifecycleTodo(deps.todoPort, todoId, `gate aborted: ${outcome.shortCircuit.reason}`); + phaseRecords.push(phaseRec); + return doneResult(runId, startedAt, "failed", lifecycleName, task, lifecycleBackend, opts.mode, phaseRecords, todoId, outcome.shortCircuit.reason); + } + } + // h: update the lifecycle todo progress block. await updateProgress(deps.todoPort, todoId, { phase: phaseDef.name, done: phaseRec.status === "completed", @@ -186,7 +239,7 @@ export async function runLifecycle(task: string, lifecycleName: string, opts: Li break; // advance to next phase } - const decision = await opts.onCheckpoint(phaseRec); + const decision = await opts.onCheckpoint(phaseRec, gateResults); if (decision.action === "continue") { if (forceCheckpoint) { // cannot continue past a failure — treat as abort (guard against a misbehaving checkpoint fn) diff --git a/src/panel/fleet-panel.ts b/src/panel/fleet-panel.ts index 7b3b770..1127a63 100644 --- a/src/panel/fleet-panel.ts +++ b/src/panel/fleet-panel.ts @@ -358,7 +358,7 @@ export class FleetPanel extends Container { this.fullMessageEvent ? " esc:Back" : this.infoAgent || this.selectedBackend || this.selectedLifecycle || this.selectedSchedule || this.selectedRun - ? (this.selectedRun ? " enter:Full-message esc:Back" : " esc:Back") + ? (this.selectedRun ? " enter:Full-message esc:Back" : this.selectedLifecycle ? " v:View-evidence g:Re-run-gate esc:Back" : " esc:Back") : this.pendingCheckpoint ? " c:Continue v:Revise a:Abort" : this.lcRevising @@ -474,7 +474,42 @@ export class FleetPanel extends Container { return; } if (this.selectedLifecycle) { - if (matchesKey(data, "escape")) { this.selectedLifecycle = null; this.renderShell(); } + if (matchesKey(data, "escape")) { this.selectedLifecycle = null; this.renderShell(); return; } + // SPEC-6-2: v:View-evidence — open the conversation viewer on the first agent gate's runId. + if (matchesKey(data, "v")) { + const agentGate = this.selectedLifecycle.phases + .flatMap((p) => p.gateResults ?? []) + .find((gr) => gr.runId); + if (agentGate?.runId && this.deps.runLog) { + this.selectedRun = buildRunsIndex(this.deps.runLog.dir).find((r) => r.runId === agentGate.runId) ?? null; + this.runTimeline = this.deps.runLog.replay(agentGate.runId); + this.selectedLifecycle = null; + this.view = "runs"; + this.renderShell(); + } else if (agentGate) { + this.onNotify(`Gate '${agentGate.gate}' evidence: ${agentGate.evidence.slice(0, 200)}`, "info"); + } else { + const predGate = this.selectedLifecycle.phases + .flatMap((p) => p.gateResults ?? []) + .find((gr) => !gr.passed && gr.evidence); + if (predGate) { + this.onNotify(`Gate '${predGate.gate}' evidence: ${predGate.evidence.slice(0, 200)}`, "info"); + } else { + this.onNotify("No gate evidence available for this lifecycle.", "info"); + } + } + return; + } + // SPEC-6-2: g:Re-run-gate — requires the GateCtx which the runtime holds, not the panel. + // Full re-run from the panel is a post-v0.11.0 enhancement (the panel doesn't have the GateCtx). + if (matchesKey(data, "g")) { + if (this.selectedLifecycle.status === "checkpoint") { + 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"); + } else { + this.onNotify("Gate re-run requires a checkpointed lifecycle (current status: " + this.selectedLifecycle.status + ").", "warning"); + } + return; + } return; } if (this.selectedSchedule) { diff --git a/src/panel/fleet-widget.ts b/src/panel/fleet-widget.ts index 9749f9d..d43424e 100644 --- a/src/panel/fleet-widget.ts +++ b/src/panel/fleet-widget.ts @@ -17,6 +17,7 @@ import type { Theme } from "@earendil-works/pi-coding-agent"; import type { RunRegistry } from "../engine/run-registry.ts"; import type { BgRunsStore } from "./bg-runs-store.ts"; +import { reconcileRuns } from "../runtime/reconcile.ts"; import { toWidgetRun, toWidgetRunFromBg, renderWidgetLines, } from "./widget-rows.ts"; @@ -41,6 +42,10 @@ export interface FleetWidgetDeps { clearInterval?: (id: unknown) => void; /** SPEC-6-1: resolve a model's context window for the ctx% widget segment. Optional — absent → no ctx%. */ getModelContextWindow?: (model: string) => number | undefined; + /** SPEC-6-2: the session's cwd — only runs from this cwd are shown in the widget (cross-cwd filter). */ + cwd?: string; + /** SPEC-6-2: RunLog for the periodic liveness probe (reconcileRuns). Optional — absent → no periodic probe. */ + runLog?: import("../runtime/run-log.ts").RunLog; } export class FleetWidgetController { @@ -50,6 +55,7 @@ export class FleetWidgetController { private readonly clearIntervalFn: (id: unknown) => void; private readonly unsubs: (() => void)[] = []; private timerId: unknown | null = null; + private livenessTimerId: unknown | null = null; private disposed = false; constructor(deps: FleetWidgetDeps) { @@ -62,11 +68,20 @@ export class FleetWidgetController { start(): void { this.unsubs.push(this.deps.runRegistry.subscribe(() => this.render())); if (this.deps.bgRuns) this.unsubs.push(this.deps.bgRuns.subscribe(() => this.render())); + // SPEC-6-2: periodic liveness probe — reconciles dead orphans every 60s. + if (this.deps.runLog) { + this.livenessTimerId = this.setIntervalFn(() => { + reconcileRuns(this.deps.runLog!, { runRegistry: this.deps.runRegistry }); + }, 60_000); + (this.livenessTimerId as { unref?: () => void }).unref?.(); + } this.render(); // initial — shows any runs already active on session_start (e.g. a survived bg run) } private activeRuns() { - const fg = this.deps.runRegistry.list().map((r) => { + const fg = this.deps.runRegistry.list() + .filter((r) => !this.deps.cwd || r.cwd === this.deps.cwd) + .map((r) => { const w = toWidgetRun(r); w.maxContext = this.deps.getModelContextWindow?.(r.model); return w; @@ -105,6 +120,10 @@ export class FleetWidgetController { this.clearIntervalFn(this.timerId); this.timerId = null; } + if (this.livenessTimerId !== null) { + this.clearIntervalFn(this.livenessTimerId); + this.livenessTimerId = null; + } } /** Unsubscribe + clear timer + clear the widget. Idempotent. */ diff --git a/src/panel/gate-line.ts b/src/panel/gate-line.ts new file mode 100644 index 0000000..5775446 --- /dev/null +++ b/src/panel/gate-line.ts @@ -0,0 +1,22 @@ +import type { GateResult } from "../lifecycle/gates/registry.ts"; + +export function gateGlyph(r: GateResult): string { + if (r.passed) return "✅"; + if (r.onFail === "abort") return "⛔"; + if (r.onFail === "revise") return "↻"; + return "⚠"; // advise +} + +/** Pure: build the compact gate line for a Lifecycle view phase row. */ +export function buildGateLine(results: GateResult[]): string { + if (results.length === 0) return ""; + const parts = results.map((r) => `${gateGlyph(r)}${r.gate}`); + // If the last failing gate short-circuited, append the action. + const lastFail = [...results].reverse().find((r) => !r.passed); + let suffix = ""; + if (lastFail) { + if (lastFail.onFail === "abort") suffix = " → aborted"; + else if (lastFail.onFail === "revise") suffix = " → revising"; + } + return `gates: ${parts.join(" ")}${suffix}`; +} \ No newline at end of file diff --git a/src/panel/rows.ts b/src/panel/rows.ts index b57e1c7..5ea07cc 100644 --- a/src/panel/rows.ts +++ b/src/panel/rows.ts @@ -92,6 +92,7 @@ export function backendInfo(b: Backend): string { } import type { LifecycleRunRecord, LifecycleStatus } from "../lifecycle/lifecycle-types.ts"; +import { buildGateLine } from "./gate-line.ts"; // 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 { const mark = p.reviseCount > 0 ? "[~]" : p.status === "completed" ? "[x]" : "[ ]"; const art = p.paths.length ? ` → ${p.paths.join(", ")}` : ""; lines.push(` ${mark} ${p.name} ${p.status}${art}${p.paths.length ? " [Open]" : ""}`); + const gateLine = buildGateLine(p.gateResults ?? []); + if (gateLine) lines.push(` ${gateLine}`); } if (r.status === "checkpoint") { lines.push("", "── Checkpoint ──", "[Continue] [Revise] [Abort]"); diff --git a/src/runtime/async-runner.ts b/src/runtime/async-runner.ts index 2ecf432..f0cd0f8 100644 --- a/src/runtime/async-runner.ts +++ b/src/runtime/async-runner.ts @@ -41,6 +41,8 @@ export interface AsyncRunnerDeps { genRunId: () => string; /** SPEC-5a: called at each run/phase transition so the host (index.ts) can update the live bgRuns map. */ onProgress?: (runId: string, status: import("../panel/rows.ts").BgRunStatus) => void; + /** SPEC-6-2: the RunRegistry so emitProgress can read the run's actual backend. */ + runRegistry?: import("../engine/run-registry.ts").RunRegistry; } export interface RunBackgroundOpts { @@ -60,7 +62,7 @@ function emitProgress(deps: AsyncRunnerDeps, runId: string, partial: Partial boolean }; pid?: number; startedAt: number }, now: number, grace: number): Liveness { + // 1. in-process handle + if (rec.session && typeof rec.session.isAlive === "function") { + return rec.session.isAlive() ? "alive" : "dead"; + } + // 2. pid (works cross-process — system-wide) + if (typeof rec.pid === "number") { + try { process.kill(rec.pid, 0); return "alive"; } catch { return "dead"; } + } + // 3. fallback — cross-process pi-backend orphan, no reachable probe + return (now - rec.startedAt > grace) ? "dead" : "alive"; +} export interface ReconcileOpts { - /** Orphans whose startedAt is older than (now - graceMs) are marked aborted. Default 60000. */ graceMs?: number; - /** Test injection. Default Date.now(). */ now?: number; - /** - * v0.10.2: the in-memory RunRegistry to sync alongside the durable log. When set, each orphan - * reconciled in the log is also transitioned to status:"aborted" in memory so the live widget - * clears its stale ▶ row. Optional — existing callers that pass only a RunLog are unaffected. - */ runRegistry?: RunRegistry; } -/** Returns the runIds it marked aborted. Idempotent: a run already ended is skipped. */ +/** Returns the runIds it marked aborted. Probe-driven (SPEC-6-2); idempotent. */ export function reconcileRuns(log: RunLog, opts: ReconcileOpts = {}): string[] { const grace = opts.graceMs ?? 60_000; const now = opts.now ?? Date.now(); @@ -31,14 +40,14 @@ export function reconcileRuns(log: RunLog, opts: ReconcileOpts = {}): string[] { const aborted: string[] = []; for (const meta of log.scanMeta()) { if (meta.status !== "running") continue; - if (now - meta.startedAt <= grace) continue; + // The in-memory record (if present) carries the live handle/pid; the log meta carries pid for cross-process. + const memRec = reg?.get(meta.runId); + const probeRec = memRec ?? { status: meta.status, pid: (meta as { pid?: number }).pid, startedAt: meta.startedAt }; + if (probeRun(probeRec, now, grace) !== "dead") continue; log.append(meta.runId, { type: "run:ended", runId: meta.runId, status: "aborted", - endedAt: now, resultSummary: "process-gone", tokenTotal: meta.tokenTotal, + endedAt: now, resultSummary: "process-gone (probe)", tokenTotal: meta.tokenTotal, }); - // v0.10.2: sync the in-memory registry so the live widget (which reads runRegistry.list(), - // not the RunLog) clears the orphan's stale ▶ row. No-op when the run isn't in the registry - // (e.g. a cross-cwd orphan from another session — out of scope for this patch). reg?.update(meta.runId, { status: "aborted", endedAt: now }); aborted.push(meta.runId); } diff --git a/src/runtime/run-log.ts b/src/runtime/run-log.ts index ac49096..ac1ea3a 100644 --- a/src/runtime/run-log.ts +++ b/src/runtime/run-log.ts @@ -11,6 +11,10 @@ export interface RunMetaEvent { type: "run:meta"; runId: string; agent: string; model: string; task: string; startedAt: number; track: boolean; todoId: string | null; backendSessionId?: string; sessionKey?: string; + /** SPEC-6-2: claude child PID (cross-process liveness probe). */ + pid?: number; + /** SPEC-6-2: the cwd this run belongs to. */ + cwd?: string; } export interface MessageEvent { type: "message"; role: string; text: string; @@ -41,6 +45,10 @@ export interface RunMeta { costTotal?: number; /** SPEC-6-1: latest context-token snapshot at run end. */ contextTokens?: number; + /** SPEC-6-2: claude child PID. */ + pid?: number; + /** SPEC-6-2: the cwd this run belongs to. */ + cwd?: string; } const ARGS_LIMIT = 200; @@ -96,7 +104,7 @@ export class RunLog { if (!meta) { meta = { runId: e.runId, agent: e.agent, model: e.model, task: e.task, startedAt: e.startedAt, track: e.track, todoId: e.todoId, backendSessionId: e.backendSessionId, sessionKey: e.sessionKey, - status: "running", tokenTotal: 0 }; + status: "running", tokenTotal: 0, pid: e.pid, cwd: e.cwd }; } else { // latest binding wins if (e.backendSessionId) meta.backendSessionId = e.backendSessionId; diff --git a/test/fleet-items.test.mts b/test/fleet-items.test.mts index cc4dc99..ac6493a 100644 --- a/test/fleet-items.test.mts +++ b/test/fleet-items.test.mts @@ -20,8 +20,8 @@ test("empty registries → empty list", () => { test("foreground-only: renders fleetRow for each RunRecord", () => { const rr = new RunRegistry(); - rr.add({ runId: "fl-fg1", agent: "coder", model: "m", task: "do thing", track: true, todoId: null, status: "running", startedAt: 2 }); - rr.add({ runId: "fl-fg2", agent: "coder", model: "m", task: "other", track: true, todoId: null, status: "completed", startedAt: 1, endedAt: 9 }); + rr.add({ runId: "fl-fg1", agent: "coder", model: "m", task: "do thing", track: true, todoId: null, status: "running", startedAt: 2 , cwd: "/", backend: "pi"}); + rr.add({ runId: "fl-fg2", agent: "coder", model: "m", task: "other", track: true, todoId: null, status: "completed", startedAt: 1, endedAt: 9 , cwd: "/", backend: "pi"}); const items = buildFleetItems({ runRegistry: rr }); strictEqual(items.length, 2); // newest-first (RunRegistry.list sorts by startedAt desc) @@ -44,7 +44,7 @@ test("bg-only: renders renderBgRow for each BgRunStatus", () => { test("merge: foreground + bg rows both appear", () => { const rr = new RunRegistry(); - rr.add({ runId: "fl-fg1", agent: "coder", model: "m", task: "fg", track: true, todoId: null, status: "running", startedAt: 1 }); + rr.add({ runId: "fl-fg1", agent: "coder", model: "m", task: "fg", track: true, todoId: null, status: "running", startedAt: 1 , cwd: "/", backend: "pi"}); const bg = new BgRunsStore(); bg.set("fl-bg1", bgRow({ runId: "fl-bg1" })); const items = buildFleetItems({ runRegistry: rr, bgRuns: bg }); @@ -55,7 +55,7 @@ test("merge: foreground + bg rows both appear", () => { test("dedup: a runId present in both stores appears once (foreground wins)", () => { const rr = new RunRegistry(); - rr.add({ runId: "fl-dup", agent: "coder", model: "m", task: "fg", track: true, todoId: null, status: "completed", startedAt: 1, endedAt: 5 }); + rr.add({ runId: "fl-dup", agent: "coder", model: "m", task: "fg", track: true, todoId: null, status: "completed", startedAt: 1, endedAt: 5 , cwd: "/", backend: "pi"}); const bg = new BgRunsStore(); bg.set("fl-dup", bgRow({ runId: "fl-dup", status: "running" })); const items = buildFleetItems({ runRegistry: rr, bgRuns: bg }); diff --git a/test/fleet-widget.test.mts b/test/fleet-widget.test.mts index 8ab8a44..c1db1c4 100644 --- a/test/fleet-widget.test.mts +++ b/test/fleet-widget.test.mts @@ -35,7 +35,7 @@ test("active fg run → widget set; completion → cleared", () => { }); c.start(); - rr.add({ runId: "fl-1", agent: "coder", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 1000 }); + rr.add({ runId: "fl-1", agent: "coder", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 1000 , cwd: "/", backend: "pi"}); const lastActive = calls.filter((c2) => c2.key === "fleet-active").at(-1)!; ok(lastActive.content!.length === 1 && lastActive.content![0]!.includes('"t"'), "above widget shows the run (task excerpt)"); @@ -71,7 +71,7 @@ test("timer tick re-renders with updated live duration", () => { setInterval: (fn) => { tickFn = fn; return 1 as any; }, clearInterval: () => {}, }); c.start(); - rr.add({ runId: "fl-1", agent: "coder", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 0 }); + rr.add({ runId: "fl-1", agent: "coder", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 0 , cwd: "/", backend: "pi"}); const before = calls.filter((c2) => c2.key === "fleet-active").at(-1)!; ok(before.content![0]!.includes("3s"), `duration at now=3000: ${before.content![0]}`); @@ -91,7 +91,7 @@ test("dispose clears timer + both widgets + is idempotent", () => { setInterval: () => 7 as any, clearInterval: () => { cleared++; }, }); c.start(); - rr.add({ runId: "fl-1", agent: "a", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 0 }); + rr.add({ runId: "fl-1", agent: "a", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 0 , cwd: "/", backend: "pi"}); c.dispose(); const lastActive = calls.filter((c2) => c2.key === "fleet-active").at(-1)!; strictEqual(lastActive.content, undefined, "cleared on dispose"); @@ -108,7 +108,7 @@ test("store emits after dispose are no-op (disposed guard)", () => { c.start(); c.dispose(); const callsBefore = calls.length; - rr.add({ runId: "fl-1", agent: "a", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 0 }); + rr.add({ runId: "fl-1", agent: "a", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 0 , cwd: "/", backend: "pi"}); strictEqual(calls.length, callsBefore, "no render after dispose"); }); @@ -121,7 +121,7 @@ test("maxContext threaded via getModelContextWindow (SPEC-6-1)", () => { getModelContextWindow: (model) => model === "big-model" ? 256000 : undefined, }); c.start(); - rr.add({ runId: "fl-ctx", agent: "coder", model: "big-model", task: "task", track: true, todoId: null, status: "running", startedAt: 0, contextTokens: 128000 }); + rr.add({ runId: "fl-ctx", agent: "coder", model: "big-model", task: "task", track: true, todoId: null, status: "running", startedAt: 0, contextTokens: 128000 , cwd: "/", backend: "pi"}); const last = calls.filter((c2) => c2.key === "fleet-active").at(-1)!; ok(last.content![0]!.includes("50%"), `ctx% shown via maxContext threading: ${last.content![0]}`); c.dispose(); diff --git a/test/gate-chain-runner.test.mts b/test/gate-chain-runner.test.mts new file mode 100644 index 0000000..3e1bb92 --- /dev/null +++ b/test/gate-chain-runner.test.mts @@ -0,0 +1,89 @@ +import { test } from "node:test"; +import { strictEqual, deepStrictEqual, ok } from "node:assert"; +import { runGateChain } from "../src/lifecycle/gates/chain-runner.ts"; +import type { GateDef, GateResult, GateCtx } from "../src/lifecycle/gates/registry.ts"; + +const passGate = (name: string): GateDef => ({ + name, kind: "predicate", onFail: "advise", + run: async () => ({ gate: name, kind: "predicate", passed: true, evidence: "ok", onFail: "advise" }), +}); +const failGate = (name: string, onFail: "advise" | "revise" | "abort"): GateDef => ({ + name, kind: "predicate", onFail, + run: async () => ({ gate: name, kind: "predicate", passed: false, evidence: `${name} failed`, onFail }), +}); + +const ctx = (): GateCtx => ({ + phaseRec: { name: "implement", summary: "", paths: [], status: "completed" as const, reviseCount: 0 }, + spawnRes: { status: "completed" as const, finalText: "", runId: "fl-x", todoId: null, agent: "a", model: "m", durationMs: 0, tokenTotal: 0 }, + lifecycle: { name: "default", task: "t", todoId: "todo1", backend: "pi" as const }, + lifecycleCost: 0, contextTokens: 0, gateParams: undefined, + spawn: async () => { throw new Error("not used"); }, + getModelContextWindow: () => undefined, +}); + +test("all gates pass → results returned, no short-circuit", async () => { + const r = await runGateChain({ gates: [passGate("a"), passGate("b")], ctx: ctx() }); + strictEqual(r.shortCircuit, undefined); + strictEqual(r.results.length, 2); + ok(r.results.every((x) => x.passed)); +}); + +test("advise failure → continues chain, collects evidence, no short-circuit", async () => { + const r = await runGateChain({ gates: [passGate("a"), failGate("v", "advise"), passGate("b")], ctx: ctx() }); + strictEqual(r.shortCircuit, undefined); + strictEqual(r.results.length, 3); + strictEqual(r.results[1]!.passed, false); +}); + +test("revise failure → short-circuits with revise + feedback", async () => { + const r = await runGateChain({ gates: [failGate("vbc", "revise"), passGate("b")], ctx: ctx() }); + strictEqual(r.shortCircuit?.action, "revise"); + ok(r.shortCircuit?.feedback?.includes("vbc failed")); + strictEqual(r.results.length, 1, "chain stopped at the revise gate"); +}); + +test("abort failure → short-circuits with abort + reason; later gates not run", async () => { + let ran = false; + const later: GateDef = { name: "later", kind: "predicate", onFail: "advise", run: async () => { ran = true; return { gate: "later", kind: "predicate", passed: true, evidence: "", onFail: "advise" }; } }; + const r = await runGateChain({ gates: [failGate("gate", "abort"), later], ctx: ctx() }); + strictEqual(r.shortCircuit?.action, "abort"); + ok(r.shortCircuit?.reason?.includes("gate failed")); + strictEqual(ran, false, "abort short-circuits before later gates"); +}); + +test("empty gates → empty results, no short-circuit", async () => { + const r = await runGateChain({ gates: [], ctx: ctx() }); + deepStrictEqual(r.results, []); + strictEqual(r.shortCircuit, undefined); +}); + +test("left-to-right: abort before a later advise gate (cost saved)", async () => { + const r = await runGateChain({ gates: [failGate("gate", "abort"), failGate("v", "advise")], ctx: ctx() }); + strictEqual(r.shortCircuit?.action, "abort"); + strictEqual(r.results.length, 1); +}); + +test("crash path: throwing revise gate → advise (never short-circuits)", async () => { + const throwingRevise: GateDef = { + name: "boom", kind: "predicate", onFail: "revise", + run: async () => { throw new Error("gate exploded"); }, + }; + const later: GateDef = { name: "later", kind: "predicate", onFail: "advise", + run: async () => ({ gate: "later", kind: "predicate", passed: true, evidence: "", onFail: "advise" }) }; + const r = await runGateChain({ gates: [throwingRevise, later], ctx: ctx() }); + strictEqual(r.shortCircuit, undefined, "crash never short-circuits, even for onFail:revise"); + strictEqual(r.results.length, 2, "chain continued past the crash"); + strictEqual(r.results[0]!.passed, false); + strictEqual(r.results[0]!.onFail, "advise", "crash result forced to advise"); + ok(r.results[0]!.evidence.includes("gate exploded")); +}); + +test("crash path: throwing abort gate → advise (never short-circuits)", async () => { + const throwingAbort: GateDef = { + name: "boom", kind: "predicate", onFail: "abort", + run: async () => { throw new Error("gate exploded"); }, + }; + const r = await runGateChain({ gates: [throwingAbort], ctx: ctx() }); + strictEqual(r.shortCircuit, undefined, "crash never short-circuits, even for onFail:abort"); + strictEqual(r.results[0]!.onFail, "advise"); +}); \ No newline at end of file diff --git a/test/gate-completeness-check.test.mts b/test/gate-completeness-check.test.mts new file mode 100644 index 0000000..7c3efd4 --- /dev/null +++ b/test/gate-completeness-check.test.mts @@ -0,0 +1,60 @@ +import { test } from "node:test"; +import { strictEqual, ok } from "node:assert"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { completenessCheckGate, checkCompleteness } from "../src/lifecycle/gates/completeness-check.ts"; + +const tmp = mkdtempSync(join(tmpdir(), "fleet-cc-")); +test("checkCompleteness: all paths exist → passed", () => { + writeFileSync(join(tmp, "a.ts"), "x"); + writeFileSync(join(tmp, "b.ts"), "y"); + const r = checkCompleteness([join(tmp, "a.ts"), join(tmp, "b.ts")], tmp); + ok(r.passed); + ok(r.evidence.includes("2/2")); +}); + +test("checkCompleteness: missing path → failed with the missing path", () => { + const r = checkCompleteness([join(tmp, "a.ts"), join(tmp, "nope.ts")], tmp); + strictEqual(r.passed, false); + ok(r.evidence.includes("nope.ts")); +}); + +test("checkCompleteness: empty paths → passed (terminal-phase exemption)", () => { + const r = checkCompleteness([], tmp); + ok(r.passed, "no claimed artifacts → nothing to check"); +}); + +test("checkCompleteness: relative paths resolved against baseDir", () => { + writeFileSync(join(tmp, "rel.ts"), "x"); + const r = checkCompleteness(["rel.ts"], tmp); + ok(r.passed); +}); + +const ctx = (paths: string[], worktreePath?: string) => ({ + phaseRec: { name: "implement", summary: "", paths, status: "completed" as const, reviseCount: 0 }, + spawnRes: { status: "completed" as const, finalText: "", runId: "fl-x", todoId: null, agent: "a", model: "m", durationMs: 0, tokenTotal: 0 }, + lifecycle: { name: "default", task: "t", todoId: "todo1", backend: "pi" as const }, + lifecycleCost: 0, contextTokens: 0, worktreePath, gateParams: undefined, + spawn: async () => { throw new Error("not used"); }, + getModelContextWindow: () => undefined, +}); + +test("gate: onFail revise, kind predicate", () => { + strictEqual(completenessCheckGate.onFail, "revise"); + strictEqual(completenessCheckGate.kind, "predicate"); +}); + +test("gate.run: uses worktreePath when set", async () => { + writeFileSync(join(tmp, "wt.ts"), "x"); + const r = await completenessCheckGate.run(ctx(["wt.ts"], tmp)); + ok(r.passed); +}); + +test("gate.run: missing → failed", async () => { + const r = await completenessCheckGate.run(ctx(["ghost.ts"], tmp)); + strictEqual(r.passed, false); +}); + +// cleanup after all +test("cleanup", () => { rmSync(tmp, { recursive: true, force: true }); ok(true); }); \ No newline at end of file diff --git a/test/gate-cost.test.mts b/test/gate-cost.test.mts new file mode 100644 index 0000000..553ebe4 --- /dev/null +++ b/test/gate-cost.test.mts @@ -0,0 +1,62 @@ +import { test } from "node:test"; +import { strictEqual, ok } from "node:assert"; +import { gateGate, assertBudget } from "../src/lifecycle/gates/gate.ts"; +import type { Tier } from "../src/tiers/tier-registry.ts"; + +const tier = (costCap?: number, contextFloor?: number): Tier => ({ + name: "std", models: ["Ollama/glm-5.2:cloud"], ...(costCap != null ? { costCap } : {}), ...(contextFloor != null ? { contextFloor } : {}), +}); + +test("assertBudget: under cap + under floor → passed", () => { + const r = assertBudget({ lifecycleCost: 0.42, contextTokens: 27000, tier: tier(1.0, 200000) }); + ok(r.passed); + ok(r.evidence.includes("0.42")); +}); + +test("assertBudget: cost over cap → failed", () => { + const r = assertBudget({ lifecycleCost: 1.12, contextTokens: 27000, tier: tier(1.0, 200000) }); + strictEqual(r.passed, false); + ok(r.evidence.includes("1.12")); + ok(r.evidence.includes("1.00")); +}); + +test("assertBudget: context over floor → failed", () => { + const r = assertBudget({ lifecycleCost: 0.1, contextTokens: 250000, tier: tier(1.0, 200000) }); + strictEqual(r.passed, false); + ok(r.evidence.includes("250000")); +}); + +test("assertBudget: no tier → passed (skip — nothing to assert)", () => { + const r = assertBudget({ lifecycleCost: 99, contextTokens: 999, tier: undefined }); + ok(r.passed, "no tier → no caps → skip (advise-pass)"); +}); + +test("assertBudget: tier without caps → passed", () => { + const r = assertBudget({ lifecycleCost: 99, contextTokens: 999, tier: tier() }); + ok(r.passed, "tier has no costCap/contextFloor → nothing to assert"); +}); + +test("assertBudget: params.costCap overrides tier.costCap", () => { + const r = assertBudget({ lifecycleCost: 1.5, contextTokens: 1000, tier: tier(1.0), params: { costCap: 2.0 } }); + ok(r.passed, "param cap 2.0 wins over tier cap 1.0"); +}); + +test("gate: onFail abort, kind predicate", () => { + strictEqual(gateGate.onFail, "abort"); + strictEqual(gateGate.kind, "predicate"); +}); + +const ctx = (lifecycleCost: number, contextTokens: number, t?: Tier) => ({ + phaseRec: { name: "implement", summary: "", paths: [], status: "completed" as const, reviseCount: 0 }, + spawnRes: { status: "completed" as const, finalText: "", runId: "fl-x", todoId: null, agent: "a", model: "m", durationMs: 0, tokenTotal: 0 }, + lifecycle: { name: "default", task: "t", todoId: "todo1", backend: "pi" as const }, + tier: t, lifecycleCost, contextTokens, gateParams: undefined, + spawn: async () => { throw new Error("not used"); }, + getModelContextWindow: () => undefined, +}); + +test("gate.run: over cap → failed + abort", async () => { + const r = await gateGate.run(ctx(1.12, 27000, tier(1.0))); + strictEqual(r.passed, false); + strictEqual(r.onFail, "abort"); +}); \ No newline at end of file diff --git a/test/gate-line.test.mts b/test/gate-line.test.mts new file mode 100644 index 0000000..b59a678 --- /dev/null +++ b/test/gate-line.test.mts @@ -0,0 +1,33 @@ +import { test } from "node:test"; +import { strictEqual, ok } from "node:assert"; +import { buildGateLine, gateGlyph } from "../src/panel/gate-line.ts"; +import type { GateResult } from "../src/lifecycle/gates/registry.ts"; + +const r = (gate: string, passed: boolean, onFail: "advise"|"revise"|"abort" = "advise"): GateResult => + ({ gate, kind: "predicate", passed, evidence: "", onFail }); + +test("gateGlyph: passed → ✅", () => { strictEqual(gateGlyph(r("v", true)), "✅"); }); +test("gateGlyph: failed + abort → ⛔", () => { strictEqual(gateGlyph(r("gate", false, "abort")), "⛔"); }); +test("gateGlyph: failed + revise → ↻", () => { strictEqual(gateGlyph(r("vbc", false, "revise")), "↻"); }); +test("gateGlyph: failed + advise → ⚠", () => { strictEqual(gateGlyph(r("verify", false, "advise")), "⚠"); }); + +test("buildGateLine: empty results → empty string", () => { + strictEqual(buildGateLine([]), ""); +}); + +test("buildGateLine: mixed results → compact glyph line", () => { + const line = buildGateLine([r("verification-before-completion", true), r("completenessCheck", true), r("gate", false, "abort")]); + ok(line.includes("✅verification-before-completion")); + ok(line.includes("✅completenessCheck")); + ok(line.includes("⛔gate")); +}); + +test("buildGateLine: abort short-circuit suffix", () => { + const line = buildGateLine([r("gate", false, "abort")]); + ok(line.includes("→ aborted")); +}); + +test("buildGateLine: revise short-circuit suffix", () => { + const line = buildGateLine([r("vbc", false, "revise")]); + ok(line.includes("→ revising")); +}); \ No newline at end of file diff --git a/test/gate-registry.test.mts b/test/gate-registry.test.mts new file mode 100644 index 0000000..5bfa981 --- /dev/null +++ b/test/gate-registry.test.mts @@ -0,0 +1,56 @@ +import { test } from "node:test"; +import { strictEqual, deepStrictEqual, throws } from "node:assert"; +import { GateRegistry, resolveGates, type GateDef, type GateRef, type GateResult } from "../src/lifecycle/gates/registry.ts"; + +const fakeGate = (name: string, kind: "agent" | "predicate" = "predicate", onFail: "advise" | "revise" | "abort" = "advise"): GateDef => ({ + name, kind, onFail, + run: async () => ({ gate: name, kind, passed: true, evidence: "", onFail }), +}); + +test("GateRegistry register/get/list", () => { + const reg = new GateRegistry(); + reg.register(fakeGate("verify", "agent", "advise")); + reg.register(fakeGate("completenessCheck")); + strictEqual(reg.get("verify")!.kind, "agent"); + strictEqual(reg.get("nope"), undefined); + deepStrictEqual(reg.list().map((g) => g.name), ["verify", "completenessCheck"]); +}); + +test("GateRegistry.register: duplicate name throws", () => { + const reg = new GateRegistry(); + reg.register(fakeGate("verify")); + throws(() => reg.register(fakeGate("verify")), /duplicate gate name 'verify'/); +}); + +test("resolveGates: string ref → registry defaults", () => { + const reg = new GateRegistry(); + reg.register(fakeGate("verify", "agent", "advise")); + const resolved = resolveGates(["verify"], reg); + strictEqual(resolved.length, 1); + strictEqual(resolved[0]!.name, "verify"); + strictEqual(resolved[0]!.onFail, "advise", "onFail from registry default"); +}); + +test("resolveGates: object ref overrides onFail + params", () => { + const reg = new GateRegistry(); + reg.register(fakeGate("gate", "predicate", "abort")); + const resolved = resolveGates([{ name: "gate", onFail: "revise", params: { costCap: 2 } }], reg); + strictEqual(resolved[0]!.onFail, "revise", "phase override wins"); + deepStrictEqual(resolved[0]!.params, { costCap: 2 }); +}); + +test("resolveGates: unknown gate name throws", () => { + const reg = new GateRegistry(); + throws(() => resolveGates(["nope"], reg), /unknown gate 'nope'/); +}); + +test("resolveGates: empty refs → empty array", () => { + const reg = new GateRegistry(); + deepStrictEqual(resolveGates([], reg), []); +}); + +test("GateResult shape: agent gate carries cost + runId", () => { + const r: GateResult = { gate: "verify", kind: "agent", passed: true, evidence: "ok", onFail: "advise", cost: 0.42, runId: "fl-x" }; + strictEqual(r.cost, 0.42); + strictEqual(r.runId, "fl-x"); +}); \ No newline at end of file diff --git a/test/gate-verification-before-completion.test.mts b/test/gate-verification-before-completion.test.mts new file mode 100644 index 0000000..a8e8959 --- /dev/null +++ b/test/gate-verification-before-completion.test.mts @@ -0,0 +1,51 @@ +import { test } from "node:test"; +import { strictEqual, ok } from "node:assert"; +import { verificationBeforeCompletionGate, scanVerificationEvidence } from "../src/lifecycle/gates/verification-before-completion.ts"; + +const ctx = (finalText: string) => ({ + phaseRec: { name: "implement", summary: "", paths: [], status: "completed" as const, reviseCount: 0 }, + spawnRes: { status: "completed" as const, finalText, runId: "fl-x", todoId: null, agent: "a", model: "m", durationMs: 0, tokenTotal: 0 }, + lifecycle: { name: "default", task: "t", todoId: "todo1", backend: "pi" as const }, + lifecycleCost: 0, contextTokens: 0, + spawn: async () => { throw new Error("not used"); }, + getModelContextWindow: () => undefined, + gateParams: undefined, +}); + +test("scanVerificationEvidence: detects test command + pass output", () => { + ok(scanVerificationEvidence("I ran `pnpm test:run` — 368 pass, 0 fail").passed); + ok(scanVerificationEvidence("typecheck: exit 0, clean").passed); + ok(scanVerificationEvidence("$ pnpm typecheck\n0 errors").passed); +}); + +test("scanVerificationEvidence: bare claim with no command → fails", () => { + strictEqual(scanVerificationEvidence("Done! I implemented the feature.").passed, false); + strictEqual(scanVerificationEvidence("The work is complete.").passed, false); +}); + +test("scanVerificationEvidence: command but no exit/pass signal → fails", () => { + strictEqual(scanVerificationEvidence("I ran pnpm test:run").passed, false, "command alone without result is not evidence"); +}); + +test("scanVerificationEvidence: custom patterns override", () => { + const r = scanVerificationEvidence("custom-check: green", { patterns: [/custom-check:\s*(green|ok)/i] }); + ok(r.passed, "custom pattern matches"); + strictEqual(scanVerificationEvidence("pnpm test:run — 368 pass", { patterns: [/custom-check/]}).passed, false, "custom patterns replace defaults"); +}); + +test("gate: onFail is revise", () => { + strictEqual(verificationBeforeCompletionGate.onFail, "revise"); + strictEqual(verificationBeforeCompletionGate.kind, "predicate"); +}); + +test("gate.run: evidence present → passed", async () => { + const r = await verificationBeforeCompletionGate.run(ctx("ran `pnpm test:run` → 368 pass")); + strictEqual(r.passed, true); + ok(r.evidence.includes("pnpm test:run")); +}); + +test("gate.run: no evidence → failed with actionable message", async () => { + const r = await verificationBeforeCompletionGate.run(ctx("done")); + strictEqual(r.passed, false); + ok(r.evidence.includes("no verification command output"), r.evidence); +}); \ No newline at end of file diff --git a/test/gate-verify.test.mts b/test/gate-verify.test.mts new file mode 100644 index 0000000..f64ed46 --- /dev/null +++ b/test/gate-verify.test.mts @@ -0,0 +1,74 @@ +import { test } from "node:test"; +import { strictEqual, ok } from "node:assert"; +import { verifyGate, judgeReview, buildVerifyPrompt } from "../src/lifecycle/gates/verify.ts"; +import type { SpawnResult } from "../src/engine/spawnSubagent.ts"; + +const okSpawn = async (): Promise => ({ + status: "completed", finalText: "The implementation meets the requirement. All plan items addressed.", + runId: "fl-rev", todoId: "todo1", agent: "reviewer", model: "m", durationMs: 100, tokenTotal: 50, costTotal: 0.01, +}); +const badSpawn = async (): Promise => ({ + status: "completed", finalText: "The implementation does not meet the requirement. Missing edge case for empty input.", + runId: "fl-rev2", todoId: "todo1", agent: "reviewer", model: "m", durationMs: 100, tokenTotal: 50, costTotal: 0.01, +}); +const crashSpawn = async (): Promise => ({ + status: "failed", finalText: "", runId: "fl-rev3", todoId: "todo1", agent: "reviewer", model: "m", durationMs: 0, tokenTotal: 0, error: "boom", +}); + +const ctx = (spawn: typeof okSpawn) => ({ + phaseRec: { name: "review", summary: "impl done", paths: ["src/a.ts"], status: "completed" as const, reviseCount: 0 }, + spawnRes: { status: "completed" as const, finalText: "impl", runId: "fl-x", todoId: "todo1", agent: "a", model: "m", durationMs: 0, tokenTotal: 0 }, + lifecycle: { name: "default", task: "build the foo feature", todoId: "todo1", backend: "pi" as const }, + lifecycleCost: 0.42, contextTokens: 27000, gateParams: undefined, spawn, + getModelContextWindow: () => undefined, +}); + +test("judgeReview: positive review → passed", () => { + ok(judgeReview("Meets the requirement. All items addressed.").passed); +}); + +test("judgeReview: failure markers → failed", () => { + strictEqual(judgeReview("Does not meet the requirement. Missing edge case.").passed, false); + strictEqual(judgeReview("The work is incomplete — X not addressed.").passed, false); +}); + +test("buildVerifyPrompt: includes task + phase summary + paths", () => { + const p = buildVerifyPrompt(ctx(okSpawn)); + ok(p.includes("build the foo feature")); + ok(p.includes("impl done")); + ok(p.includes("src/a.ts")); +}); + +test("gate: onFail advise, kind agent", () => { + strictEqual(verifyGate.onFail, "advise"); + strictEqual(verifyGate.kind, "agent"); +}); + +test("gate.run: positive review → passed + cost + runId", async () => { + const r = await verifyGate.run(ctx(okSpawn)); + strictEqual(r.passed, true); + strictEqual(r.cost, 0.01); + strictEqual(r.runId, "fl-rev"); +}); + +test("gate.run: negative review → failed + advise", async () => { + const r = await verifyGate.run(ctx(badSpawn)); + strictEqual(r.passed, false); + strictEqual(r.onFail, "advise"); + ok(r.evidence.includes("Missing edge case")); +}); + +test("gate.run: spawn crash → failed + advise (not revise)", async () => { + const r = await verifyGate.run(ctx(crashSpawn)); + strictEqual(r.passed, false); + strictEqual(r.onFail, "advise", "crash → advise, not revise (can't fix a crash by re-running the phase)"); + ok(r.evidence.includes("reviewer spawn failed")); +}); + +test("gate.run: params.agent pins the reviewer agent", async () => { + let captured: string | undefined; + const spySpawn = async (o: { agent: string }): Promise => { captured = o.agent; return await okSpawn(); }; + const c = { ...ctx(spySpawn as any), gateParams: { agent: "senior-reviewer" } }; + await verifyGate.run(c); + strictEqual(captured, "senior-reviewer"); +}); \ No newline at end of file diff --git a/test/prompt-template.test.mts b/test/prompt-template.test.mts index 9aa280c..e0b0604 100644 --- a/test/prompt-template.test.mts +++ b/test/prompt-template.test.mts @@ -1,37 +1,56 @@ import { test } from "node:test"; -import { strictEqual } from "node:assert"; -import { renderPhasePrompt, type PromptVars } from "../src/lifecycle/prompt-template.ts"; +import { strictEqual, ok } from "node:assert"; +import { renderPhasePrompt, CHALLENGE_STEP_BLOCK, type PromptVars } from "../src/lifecycle/prompt-template.ts"; test("renders {{task}} and {{lifecycle}}/{{phase}}", () => { const out = renderPhasePrompt("Task: {{task}} | lc={{lifecycle}} ph={{phase}}", { - task: "fix bug", lifecycle: "default", phase: "plan", + task: "fix bug", lifecycle: "default", phase: "plan", challengeStep: false, }); strictEqual(out, "Task: fix bug | lc=default ph=plan"); }); test("renders prev block when prev is present, omits when absent", () => { const t = "{% if prev %}prev: {{prev.name}} {{prev.summary}} paths={{prev.paths}}{% endif %}"; - strictEqual(renderPhasePrompt(t, { task: "x", lifecycle: "d", phase: "plan", prev: { name: "brainstorm", summary: "did it", paths: ["a.md", "b.md"] } }), + strictEqual(renderPhasePrompt(t, { task: "x", lifecycle: "d", phase: "plan", challengeStep: false, prev: { name: "brainstorm", summary: "did it", paths: ["a.md", "b.md"] } }), "prev: brainstorm did it paths=- a.md\n- b.md"); - strictEqual(renderPhasePrompt(t, { task: "x", lifecycle: "d", phase: "brainstorm" }), ""); + strictEqual(renderPhasePrompt(t, { task: "x", lifecycle: "d", phase: "brainstorm", challengeStep: false }), ""); }); test("renders feedback block only when feedback present", () => { const t = "{% if feedback %}FB: {{feedback}}{% endif %}end"; - strictEqual(renderPhasePrompt(t, { task: "x", lifecycle: "d", phase: "implement", feedback: "tighter" }), "FB: tighterend"); - strictEqual(renderPhasePrompt(t, { task: "x", lifecycle: "d", phase: "implement" }), "end"); + strictEqual(renderPhasePrompt(t, { task: "x", lifecycle: "d", phase: "implement", challengeStep: false, feedback: "tighter" }), "FB: tighterend"); + strictEqual(renderPhasePrompt(t, { task: "x", lifecycle: "d", phase: "implement", challengeStep: false }), "end"); }); test("prev.paths renders as a newline-separated list, empty string when no paths", () => { const t = "{% if prev %}{{prev.paths}}{% endif %}"; - strictEqual(renderPhasePrompt(t, { task: "x", lifecycle: "d", phase: "p", prev: { name: "a", summary: "s", paths: [] } }), ""); - strictEqual(renderPhasePrompt(t, { task: "x", lifecycle: "d", phase: "p", prev: { name: "a", summary: "s", paths: ["only.md"] } }), "- only.md"); + strictEqual(renderPhasePrompt(t, { task: "x", lifecycle: "d", phase: "p", challengeStep: false, prev: { name: "a", summary: "s", paths: [] } }), ""); + strictEqual(renderPhasePrompt(t, { task: "x", lifecycle: "d", phase: "p", challengeStep: false, prev: { name: "a", summary: "s", paths: ["only.md"] } }), "- only.md"); }); test("Revise feedback includes prior-attempt digest", () => { const t = "{% if feedback %}{{feedback}}{% endif %}"; - const out = renderPhasePrompt(t, { task: "x", lifecycle: "d", phase: "plan", + const out = renderPhasePrompt(t, { task: "x", lifecycle: "d", phase: "plan", challengeStep: false, feedback: "Prior attempt summary: first try\n\nHuman feedback: be more concrete" }); if (!out.includes("Human feedback: be more concrete")) throw new Error("missing human feedback"); if (!out.includes("first try")) throw new Error("missing prior attempt digest"); +}); + +test("challenge-step: injected by default", () => { + const out = renderPhasePrompt("## implement\nDo the work.", { + task: "t", lifecycle: "default", phase: "implement", + }); + ok(out.includes(CHALLENGE_STEP_BLOCK), "challenge-step block appended when challengeStep not set"); +}); + +test("challenge-step: omitted when challengeStep === false", () => { + const out = renderPhasePrompt("## finish\nMerge.", { + task: "t", lifecycle: "default", phase: "finish", challengeStep: false, + }); + ok(!out.includes(CHALLENGE_STEP_BLOCK), "no challenge-step when opted out"); +}); + +test("challenge-step: block contains the self-critique directive", () => { + ok(CHALLENGE_STEP_BLOCK.includes("What could break?")); + ok(CHALLENGE_STEP_BLOCK.includes("challenge")); }); \ No newline at end of file diff --git a/test/reconcile.test.mts b/test/reconcile.test.mts index ac33348..11148e4 100644 --- a/test/reconcile.test.mts +++ b/test/reconcile.test.mts @@ -5,7 +5,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { RunLog } from "../src/runtime/run-log.ts"; -import { reconcileRuns } from "../src/runtime/reconcile.ts"; +import { reconcileRuns, probeRun } from "../src/runtime/reconcile.ts"; function makeDir(): string { return mkdtempSync(join(tmpdir(), "reconcile-")); } const GRACE = 60_000; @@ -19,7 +19,7 @@ test("orphan run:meta with no run:ended, older than grace → marked aborted", ( assert.deepEqual(aborted, ["fl-old"]); const meta = log.scanMeta()[0]!; assert.equal(meta.status, "aborted"); - assert.equal(meta.resultSummary, "process-gone"); + assert.equal(meta.resultSummary, "process-gone (probe)"); rmSync(dir, { recursive: true, force: true }); }); @@ -63,7 +63,7 @@ test("reconcile also marks the orphan aborted in the in-memory RunRegistry (v0.1 const oldStarted = 1_000; // The orphan exists in BOTH stores: durable log (run:meta, no run:ended) + in-memory registry (running). log.append("fl-ghost", { type: "run:meta", runId: "fl-ghost", agent: "g", model: "m", task: "t", startedAt: oldStarted, track: true, todoId: null }); - reg.add({ runId: "fl-ghost", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: oldStarted }); + reg.add({ runId: "fl-ghost", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: oldStarted , cwd: "/", backend: "pi"}); const aborted = reconcileRuns(log, { runRegistry: reg, now: oldStarted + GRACE + 5_000 }); assert.deepEqual(aborted, ["fl-ghost"]); // Durable log updated (existing behavior). @@ -79,7 +79,7 @@ test("reconcile leaves a fresh orphan running in-memory (within grace)", () => { const reg = new RunRegistry(); const now = 50_000; log.append("fl-fresh", { type: "run:meta", runId: "fl-fresh", agent: "g", model: "m", task: "t", startedAt: now - 1_000, track: true, todoId: null }); - reg.add({ runId: "fl-fresh", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: now - 1_000 }); + reg.add({ runId: "fl-fresh", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: now - 1_000 , cwd: "/", backend: "pi"}); assert.deepEqual(reconcileRuns(log, { runRegistry: reg, now }), []); assert.equal(reg.get("fl-fresh")!.status, "running", "fresh run untouched in-memory"); rmSync(dir, { recursive: true, force: true }); @@ -94,3 +94,52 @@ test("reconcile RunRegistry arg is optional (back-compat: existing callers passi assert.equal(log.scanMeta()[0]!.status, "aborted"); rmSync(dir, { recursive: true, force: true }); }); + +// SPEC-6-2: probeRun + probe-driven reconcile tests +const deadHandle = { isAlive: () => false } as any; +const aliveHandle = { isAlive: () => true } as any; + +test("probeRun: in-process handle isAlive:false → dead", () => { + assert.strictEqual(probeRun({ status: "running", session: deadHandle } as any, 1000, 60_000), "dead"); +}); + +test("probeRun: in-process handle isAlive:true → alive", () => { + assert.strictEqual(probeRun({ status: "running", session: aliveHandle, startedAt: 900 } as any, 1000, 60_000), "alive"); +}); + +test("probeRun: pid dead → dead", () => { + // pid that definitely doesn't exist (use a huge number; signal 0 throws ESRCH) + assert.strictEqual(probeRun({ status: "running", pid: 4_000_000, startedAt: 0 } as any, 1000, 60_000), "dead"); +}); + +test("probeRun: no handle, no pid, age > grace → dead (fallback)", () => { + assert.strictEqual(probeRun({ status: "running", startedAt: 0 } as any, 100_000, 60_000), "dead"); +}); + +test("probeRun: no handle, no pid, age < grace → alive (fallback)", () => { + assert.strictEqual(probeRun({ status: "running", startedAt: 99_999 } as any, 100_000, 60_000), "alive"); +}); + +test("reconcileRuns: probe-driven — handle-dead orphan aborted in log + registry", () => { + const tmp = mkdtempSync(join(tmpdir(), "fleet-rec-")); + const log = new RunLog(tmp); + log.append("fl-1", { type: "run:meta", runId: "fl-1", agent: "a", model: "m", task: "t", startedAt: 0, track: true, todoId: null, cwd: "/repo" }); + const reg = new RunRegistry(); + reg.add({ runId: "fl-1", agent: "a", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 0, cwd: "/repo", backend: "pi", session: deadHandle }); + const aborted = reconcileRuns(log, { now: 100_000, graceMs: 60_000, runRegistry: reg }); + assert.deepEqual(aborted, ["fl-1"]); + assert.strictEqual(reg.get("fl-1")!.status, "aborted"); + rmSync(tmp, { recursive: true, force: true }); +}); + +test("reconcileRuns: alive handle → not aborted", () => { + const tmp = mkdtempSync(join(tmpdir(), "fleet-rec2-")); + const log = new RunLog(tmp); + log.append("fl-1", { type: "run:meta", runId: "fl-1", agent: "a", model: "m", task: "t", startedAt: 0, track: true, todoId: null, cwd: "/repo" }); + const reg = new RunRegistry(); + reg.add({ runId: "fl-1", agent: "a", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 0, cwd: "/repo", backend: "pi", session: aliveHandle }); + const aborted = reconcileRuns(log, { now: 100_000, graceMs: 60_000, runRegistry: reg }); + assert.deepEqual(aborted, []); + assert.strictEqual(reg.get("fl-1")!.status, "running"); + rmSync(tmp, { recursive: true, force: true }); +}); diff --git a/test/rows.test.mts b/test/rows.test.mts index afc0b6c..ae451e6 100644 --- a/test/rows.test.mts +++ b/test/rows.test.mts @@ -7,7 +7,7 @@ import type { AgentDef } from "../src/registry/frontmatter.ts"; const run = (over: Partial = {}): RunRecord => ({ runId: "fl-3kf9a2", agent: "general-purpose", model: "m", task: "review auth module", - track: true, todoId: "td-mrubw7", status: "running", startedAt: 1, ...over, + track: true, todoId: "td-mrubw7", status: "running", startedAt: 1, cwd: "/", backend: "pi", ...over, }); test("fmtDuration: seconds", () => { strictEqual(fmtDuration(18000), "18s"); }); diff --git a/test/run-lifecycle.test.mts b/test/run-lifecycle.test.mts index 6dfdd85..96d0ebb 100644 --- a/test/run-lifecycle.test.mts +++ b/test/run-lifecycle.test.mts @@ -292,3 +292,145 @@ test("SPEC-5a Q3=A: without worktreePath, artifactDiscovery is NOT used (foregro strictEqual(discoveryCalled, false); strictEqual(res.phases[0]!.paths[0], "a.md"); }); + +// SPEC-6-2: gate chain integration tests +import { GateRegistry } from "../src/lifecycle/gates/registry.ts"; +import { verificationBeforeCompletionGate } from "../src/lifecycle/gates/verification-before-completion.ts"; +import { completenessCheckGate } from "../src/lifecycle/gates/completeness-check.ts"; +import { gateGate } from "../src/lifecycle/gates/gate.ts"; + +const GATED_LC = `--- +name: gated-lc +description: t +backend: pi +phases: + - { name: a, skills: [], checkpoint: true, gates: [completenessCheck] } + - { name: b, skills: [], checkpoint: false } +--- +## a +phase a {{task}} +## b +phase b +`; + +function makeGatedDeps(spawns: Array<{ finalText: string; status: "completed" | "failed" }>, opts?: { gateCtxState?: { lifecycleCost: number; contextTokens: number; tier?: any } }): LifecycleRunDeps { + let i = 0; + const reg = new GateRegistry(); + reg.register(completenessCheckGate); + reg.register(verificationBeforeCompletionGate); + reg.register(gateGate); + return { + registry: new Map([["gated-lc", parseLifecycleFile(GATED_LC, "/x/gated.md", "builtin")]]), + agentRegistry: new Map([["general-purpose", agent]]), + spawn: async (o) => { + const s = spawns[Math.min(i, spawns.length - 1)]!; + i++; + return { status: s.status, finalText: s.finalText, runId: `fl-${i}`, todoId: o.lifecycleTodoId ?? "td", agent: "general-purpose", model: "m", durationMs: 1, tokenTotal: 0 }; + }, + todoPort: { async linkOrCreateRunTodo() { return { todoId: "td" }; }, async markRunTodoDone() {}, async markRunTodoReverted() {}, async updateLifecycleProgress() {} } as any, + resolveBackend: (_p, lb) => lb, + genRunId: () => "fl-g", + gateRegistry: reg, + getGateCtxState: () => opts?.gateCtxState ?? { lifecycleCost: 0, contextTokens: 0 }, + }; +} + +test("gate chain: all gates pass → onCheckpoint receives gateResults", async () => { + let capturedGateResults: any[] | undefined; + const deps = makeGatedDeps([ + { finalText: "done\n\nArtifacts:\n - path: /dev/null\n", status: "completed" }, + { finalText: "b done", status: "completed" }, + ]); + const onCheckpoint: CheckpointFn = async (_phase, gateResults) => { + capturedGateResults = gateResults; + return { action: "continue" }; + }; + const res = await runLifecycle("t", "gated-lc", { deps, mode: "checkpointed", onCheckpoint }); + strictEqual(res.status, "completed"); + ok(capturedGateResults !== undefined, "onCheckpoint was called with gateResults"); + strictEqual(capturedGateResults!.length, 1); + strictEqual(capturedGateResults![0]!.passed, true); + strictEqual(capturedGateResults![0]!.gate, "completenessCheck"); +}); + +test("gate chain: backward-compat — phase with no gates → onCheckpoint(phase, [])", async () => { + let capturedGateResults: any[] | undefined; + const deps = makeDeps([ + { finalText: "a done\n\nArtifacts:\n - path: a.md\n", status: "completed" }, + { finalText: "b done\n\nArtifacts:\n - path: b.md\n", status: "completed" }, + { finalText: "c done", status: "completed" }, + ]); + // No gateRegistry → no gate chain → gateResults should be [] + const onCheckpoint: CheckpointFn = async (_phase, gateResults) => { + capturedGateResults = gateResults; + return { action: "continue" }; + }; + const res = await runLifecycle("t", "test-lc", { deps, mode: "checkpointed", onCheckpoint }); + strictEqual(res.status, "completed"); + ok(capturedGateResults !== undefined); + strictEqual(capturedGateResults!.length, 0, "no gates → empty gateResults"); +}); + +const VBC_LC = `--- +name: vbc-lc +description: t +backend: pi +phases: + - { name: a, skills: [], checkpoint: true, gates: [verification-before-completion] } +--- +## a +phase a {{task}} +`; + +test("gate chain: verification-before-completion fails → revise loop fires", async () => { + let checkpointCalls = 0; + const deps: LifecycleRunDeps = { + registry: new Map([["vbc-lc", parseLifecycleFile(VBC_LC, "/x/vbc.md", "builtin")]]), + agentRegistry: new Map([["general-purpose", agent]]), + spawn: async (o) => { + // First spawn: "done" (no evidence) → gate revise. Second: has evidence → pass. + checkpointCalls++; + const text = checkpointCalls === 1 ? "done\n\nArtifacts:\n - path: a.md\n" : "ran pnpm test:run → 5 pass\n\nArtifacts:\n - path: a.md\n"; + return { status: "completed", finalText: text, runId: `fl-${checkpointCalls}`, todoId: o.lifecycleTodoId ?? "td", agent: "general-purpose", model: "m", durationMs: 1, tokenTotal: 0 }; + }, + todoPort: { async linkOrCreateRunTodo() { return { todoId: "td" }; }, async markRunTodoDone() {}, async markRunTodoReverted() {}, async updateLifecycleProgress() {} } as any, + resolveBackend: (_p, lb) => lb, + genRunId: () => "fl-vbc", + gateRegistry: (() => { const r = new GateRegistry(); r.register(verificationBeforeCompletionGate); return r; })(), + getGateCtxState: () => ({ lifecycleCost: 0, contextTokens: 0 }), + }; + const onCheckpoint: CheckpointFn = async () => ({ action: "continue" }); + const res = await runLifecycle("t", "vbc-lc", { deps, mode: "auto", onCheckpoint }); + strictEqual(res.status, "completed", "lifecycle completed after revise"); + strictEqual(res.phases[0]!.reviseCount, 1, "phase was revised once"); +}); + +const COST_LC = `--- +name: cost-lc +description: t +backend: pi +phases: + - { name: a, skills: [], checkpoint: true, gates: [gate] } +--- +## a +phase a {{task}} +`; + +test("gate chain: gate (cost) abort → lifecycle failed, no checkpoint", async () => { + let checkpointCalled = false; + const deps: LifecycleRunDeps = { + registry: new Map([["cost-lc", parseLifecycleFile(COST_LC, "/x/cost.md", "builtin")]]), + agentRegistry: new Map([["general-purpose", agent]]), + spawn: async (o) => ({ status: "completed", finalText: "done\n\nArtifacts:\n - path: a.md\n", runId: "fl-1", todoId: o.lifecycleTodoId ?? "td", agent: "general-purpose", model: "m", durationMs: 1, tokenTotal: 0 }), + todoPort: { async linkOrCreateRunTodo() { return { todoId: "td" }; }, async markRunTodoDone() {}, async markRunTodoReverted() {}, async updateLifecycleProgress() {} } as any, + resolveBackend: (_p, lb) => lb, + genRunId: () => "fl-cost", + gateRegistry: (() => { const r = new GateRegistry(); r.register(gateGate); return r; })(), + getGateCtxState: () => ({ lifecycleCost: 999, contextTokens: 1000, tier: { name: "std", models: ["m"], costCap: 1.0 } as any }), + }; + const onCheckpoint: CheckpointFn = async () => { checkpointCalled = true; return { action: "continue" }; }; + const res = await runLifecycle("t", "cost-lc", { deps, mode: "auto", onCheckpoint }); + strictEqual(res.status, "failed", "lifecycle failed on cost abort"); + strictEqual(checkpointCalled, false, "checkpoint NOT called on abort"); + ok(res.error?.includes("cost"), "error mentions cost"); +}); diff --git a/test/run-log.test.mts b/test/run-log.test.mts index 0840ca5..7ca55f9 100644 --- a/test/run-log.test.mts +++ b/test/run-log.test.mts @@ -1,6 +1,6 @@ // test/run-log.test.mts import { test } from "node:test"; -import assert from "node:assert/strict"; +import assert, { strictEqual } from "node:assert/strict"; import { mkdtempSync, rmSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -91,4 +91,14 @@ test("append never throws on I/O failure (best-effort); run proceeds", () => { // both macOS + Linux. Avoid /proc (procfs) — writes there can block on Linux, hanging the test. const log = new RunLog("/nonexistent-fleet-test-root-xyz/no-write-here"); assert.doesNotThrow(() => log.append("fl-x", { type: "run:meta", runId: "fl-x", agent: "g", model: "m", task: "t", startedAt: 1, track: true, todoId: null })); -}); \ No newline at end of file +}); +test("run:meta: pid + cwd round-trip through scanMeta", () => { + const tmp = mkdtempSync(join(tmpdir(), "fleet-rl-")); + const log = new RunLog(tmp); + log.append("fl-1", { type: "run:meta", runId: "fl-1", agent: "a", model: "m", task: "t", startedAt: 1, track: true, todoId: null, pid: 999, cwd: "/repo" }); + const metas = log.scanMeta(); + strictEqual(metas[0]!.runId, "fl-1"); + strictEqual((metas[0] as any).pid, 999); + strictEqual((metas[0] as any).cwd, "/repo"); + rmSync(tmp, { recursive: true, force: true }); +}); diff --git a/test/run-registry.test.mts b/test/run-registry.test.mts index 7919184..ac04bf9 100644 --- a/test/run-registry.test.mts +++ b/test/run-registry.test.mts @@ -11,16 +11,16 @@ test("genRunId is fl- prefixed and unique-ish", () => { test("add + get a run; list is newest-first", () => { const r = new RunRegistry(); - r.add({ runId: "fl-1", agent: "g", model: "m", task: "t", track: true, todoId: "td-1", status: "running", startedAt: 1 }); + r.add({ runId: "fl-1", agent: "g", model: "m", task: "t", track: true, todoId: "td-1", status: "running", startedAt: 1 , cwd: "/", backend: "pi"}); strictEqual(r.get("fl-1")!.agent, "g"); strictEqual(r.list().length, 1); - r.add({ runId: "fl-2", agent: "g", model: "m", task: "t2", track: true, todoId: null, status: "running", startedAt: 2 }); + r.add({ runId: "fl-2", agent: "g", model: "m", task: "t2", track: true, todoId: null, status: "running", startedAt: 2 , cwd: "/", backend: "pi"}); strictEqual(r.list()[0]!.runId, "fl-2"); }); test("update patches status + endedAt + resultSummary", () => { const r = new RunRegistry(); - r.add({ runId: "fl-3", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 1 }); + r.add({ runId: "fl-3", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 1 , cwd: "/", backend: "pi"}); r.update("fl-3", { status: "completed", endedAt: 99, resultSummary: "done" }); strictEqual(r.get("fl-3")!.status, "completed"); strictEqual(r.get("fl-3")!.endedAt, 99); @@ -30,18 +30,18 @@ test("subscribe fires on add + update; unsubscribe stops them", () => { const r = new RunRegistry(); const calls: string[] = []; const unsub = r.subscribe(() => calls.push("x")); - r.add({ runId: "fl-a", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 1 }); + r.add({ runId: "fl-a", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 1 , cwd: "/", backend: "pi"}); strictEqual(calls.length, 1, "add fires"); r.update("fl-a", { status: "completed", endedAt: 2 }); strictEqual(calls.length, 2, "update fires"); unsub(); - r.add({ runId: "fl-b", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 3 }); + r.add({ runId: "fl-b", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 3 , cwd: "/", backend: "pi"}); strictEqual(calls.length, 2, "no fire after unsubscribe"); }); test("list/get do not fire subscribers (read-only)", () => { const r = new RunRegistry(); - r.add({ runId: "fl-r", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 1 }); + r.add({ runId: "fl-r", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 1 , cwd: "/", backend: "pi"}); const calls: string[] = []; r.subscribe(() => calls.push("x")); r.list(); @@ -51,7 +51,7 @@ test("list/get do not fire subscribers (read-only)", () => { test("resumedFrom/forkedFrom survive add + update (additive optional fields)", () => { const r = new RunRegistry(); - r.add({ runId: "fl-r1", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 1, resumedFrom: "fl-prior" }); + r.add({ runId: "fl-r1", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 1, resumedFrom: "fl-prior" , cwd: "/", backend: "pi"}); strictEqual(r.get("fl-r1")!.resumedFrom, "fl-prior"); r.update("fl-r1", { forkedFrom: "fl-other" }); strictEqual(r.get("fl-r1")!.forkedFrom, "fl-other"); @@ -59,7 +59,7 @@ test("resumedFrom/forkedFrom survive add + update (additive optional fields)", ( }); test("tokenTotal survives add + update (additive optional field)", () => { const r = new RunRegistry(); - r.add({ runId: "fl-t1", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 1 }); + r.add({ runId: "fl-t1", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 1 , cwd: "/", backend: "pi"}); strictEqual(r.get("fl-t1")!.tokenTotal, undefined, "absent by default"); r.update("fl-t1", { tokenTotal: 142 }); strictEqual(r.get("fl-t1")!.tokenTotal, 142, "set by update"); @@ -71,9 +71,9 @@ test("session handle survives add + update (additive optional field)", () => { const r = new RunRegistry(); const handle: LiveSessionHandle = { steer: async () => {}, abort: async () => {}, subscribe: () => () => {}, - isStreaming: true, supportsSteer: true, + isStreaming: true, supportsSteer: true, isAlive: () => true, }; - r.add({ runId: "fl-s1", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 1, session: handle }); + r.add({ runId: "fl-s1", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 1, session: handle , cwd: "/", backend: "pi"}); strictEqual(r.get("fl-s1")!.session, handle, "handle set on add"); r.update("fl-s1", { tokenTotal: 42 }); strictEqual(r.get("fl-s1")!.session, handle, "handle survives unrelated update"); @@ -83,9 +83,9 @@ test("update clears session handle (finishRun sets session: undefined)", () => { const r = new RunRegistry(); const handle: LiveSessionHandle = { steer: async () => {}, abort: async () => {}, subscribe: () => () => {}, - isStreaming: true, supportsSteer: true, + isStreaming: true, supportsSteer: true, isAlive: () => true, }; - r.add({ runId: "fl-s2", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 1, session: handle }); + r.add({ runId: "fl-s2", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 1, session: handle , cwd: "/", backend: "pi"}); r.update("fl-s2", { status: "completed", endedAt: 9, session: undefined }); strictEqual(r.get("fl-s2")!.status, "completed"); strictEqual(r.get("fl-s2")!.session, undefined, "handle cleared by finishRun patch"); @@ -93,7 +93,7 @@ test("update clears session handle (finishRun sets session: undefined)", () => { test("RunRecord carries costTotal/contextTokens/tier (SPEC-6-1, additive)", () => { const r = new RunRegistry(); - r.add({ runId: "fl-c1", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 1, tier: "standard", costTotal: 0, contextTokens: 0 } as any); + r.add({ runId: "fl-c1", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 1, tier: "standard", costTotal: 0, contextTokens: 0 , cwd: "/", backend: "pi" } as any); strictEqual(r.get("fl-c1")!.tier, "standard"); strictEqual(r.get("fl-c1")!.costTotal, 0); strictEqual(r.get("fl-c1")!.contextTokens, 0); @@ -101,3 +101,14 @@ test("RunRecord carries costTotal/contextTokens/tier (SPEC-6-1, additive)", () = strictEqual(r.get("fl-c1")!.costTotal, 0.01); strictEqual(r.get("fl-c1")!.contextTokens, 50000); }); + +test("RunRecord: cwd/backend/pid fields round-trip", () => { + const reg = new RunRegistry(); + reg.add({ runId: "fl-1", agent: "a", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 1, cwd: "/repo", backend: "pi" }); + const r = reg.get("fl-1")!; + strictEqual(r.cwd, "/repo"); + strictEqual(r.backend, "pi"); + strictEqual(r.pid, undefined); + reg.update("fl-1", { pid: 12345 }); + strictEqual(reg.get("fl-1")!.pid, 12345); +}); diff --git a/test/spawn-subagent-runlog.test.mts b/test/spawn-subagent-runlog.test.mts index cbe9ea7..54063f9 100644 --- a/test/spawn-subagent-runlog.test.mts +++ b/test/spawn-subagent-runlog.test.mts @@ -77,10 +77,12 @@ test("spawnSubagent writes run:meta + message + tool + run:ended in order when r const events = log.replay(res.runId); const types = events.map((e) => e.type); ok(types[0] === "run:meta", "first event is run:meta"); - ok(types.filter((t) => t === "run:meta").length === 2, "two run:meta events (init + session_init)"); + // SPEC-6-2: the optimistic pre-session_init run:meta was removed (double-write dedup); + // now a single run:meta is written after session_init, carrying backendSessionId. + strictEqual(types.filter((t) => t === "run:meta").length, 1, "one run:meta event (post-session_init, dedup)"); ok(types.indexOf("message") < types.indexOf("tool"), "message before tool"); ok(types[types.length - 1] === "run:ended", "last event is run:ended"); - const meta = events.find((e) => e.type === "run:meta" && (e as any).backendSessionId) as any; + const meta = events.find((e) => e.type === "run:meta") as any; strictEqual(meta.backendSessionId, "sess-1", "session_init bound into run:meta"); const ended = events[events.length - 1] as any; strictEqual(ended.status, "completed"); diff --git a/test/tiers-items.test.mts b/test/tiers-items.test.mts index 6281f7f..27726dd 100644 --- a/test/tiers-items.test.mts +++ b/test/tiers-items.test.mts @@ -20,9 +20,9 @@ test("buildTiersItems: one item per tier", () => { test("buildTiersItems: spend = sum of run.costTotal for runs with matching tier", () => { const reg = tiers([t({ name: "standard" })]); const rr = new RunRegistry(); - rr.add({ runId: "r1", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "completed", startedAt: 1, tier: "standard", costTotal: 0.05 }); - rr.add({ runId: "r2", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "completed", startedAt: 2, tier: "standard", costTotal: 0.03 }); - rr.add({ runId: "r3", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "completed", startedAt: 3, tier: "frontier", costTotal: 0.5 }); + rr.add({ runId: "r1", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "completed", startedAt: 1, tier: "standard", costTotal: 0.05 , cwd: "/", backend: "pi"}); + rr.add({ runId: "r2", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "completed", startedAt: 2, tier: "standard", costTotal: 0.03 , cwd: "/", backend: "pi"}); + rr.add({ runId: "r3", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "completed", startedAt: 3, tier: "frontier", costTotal: 0.5 , cwd: "/", backend: "pi"}); const items = buildTiersItems({ tierRegistry: reg, runRegistry: rr }); ok(items[0]!.label.includes("$0.0800"), `spend summed: ${items[0]!.label}`); ok(items[0]!.label.includes("2 runs"), `run count: ${items[0]!.label}`); diff --git a/test/widget-rows.test.mts b/test/widget-rows.test.mts index 8258f2f..58d0653 100644 --- a/test/widget-rows.test.mts +++ b/test/widget-rows.test.mts @@ -11,7 +11,7 @@ import type { BgRunStatus } from "../src/panel/rows.ts"; const fg = (over: Partial = {}): RunRecord => ({ runId: "fl-fg1", agent: "coder", model: "m", task: "t", track: true, todoId: null, - status: "running", startedAt: 1000, ...over, + status: "running", startedAt: 1000, cwd: "/", backend: "pi", ...over, }); const bg = (over: Partial = {}): BgRunStatus => ({