akm-cli
Version:
akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur
629 lines (628 loc) • 31.5 kB
JavaScript
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
/**
* Engine-driven workflow execution — the `akm workflow run` start/resume/
* execute path. akm walks the frozen plan (read from `plan_json`, never live
* source) and dispatches every unit itself; every step advances through
* `completeWorkflowStep`; one O_EXCL lock file per run keeps a second driver
* off (a dead holder's lock is reclaimed); gate loops are bounded; and the
* SDK dispatch registry is drained on every exit so the process can exit.
* See docs/architecture/decisions/0011-engine-run-loop-invariants.md.
*/
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { TransientError, UsageError } from "../../core/errors.js";
import { releaseLock } from "../../core/file-lock.js";
import { formatLockHolderPid, tryAcquireRunLock } from "../../core/run-lock.js";
import { disposeDispatchResources } from "../../integrations/agent/runner-dispatch.js";
import { resolveStorageLocations } from "../../storage/locations.js";
import { withWorkflowRunsConnection, withWorkflowRunsRepo } from "../../storage/repositories/workflow-runs-repository.js";
import { computePlanHash } from "../ir/plan-hash.js";
import { decodeWorkflowPlan, readRunPlan } from "../runtime/run-plan.js";
import { abandonWorkflowRun, completeWorkflowStep, getNextWorkflowStep, resumeWorkflowRun, } from "../runtime/runs.js";
import { loadWorkflowAsset } from "../runtime/workflow-asset-loader.js";
import { frozenSummaryJudge } from "./frozen-judge.js";
import { mergeLoweringNotices } from "./lowering-notices.js";
import { defaultUnitDispatcher, executeStepPlan, } from "./native-executor.js";
// Shared step semantics — route evaluation + cascaded-skip bookkeeping,
// gate-evaluation journaling, and the whole step-completion path
// (`finalizeExecutedStep`) live in step-work.ts as ONE implementation, so the
// fresh-execution and resume paths cannot drift from each other.
import { activeGateLoop, blockStepForJudgeFailure, cascadeSkippedRouter, effectiveGateMaxLoops, finalizeExecutedStep, recoverGateFeedback, referencedStepIds, seedJournaledRouteDecisions, } from "./step-work.js";
export async function runWorkflowSteps(options) {
let target = options.target;
let params = options.params;
let parameterFlags = options.parameterFlags;
// `--new` only applies to the FIRST resolution of `target` (a ref); every
// retry re-targets the run id `startWorkflowRun` already created, so it is
// cleared alongside `params`/`parameterFlags` below (#919).
let newRun = options.newRun;
let remainingRetries = options.maxRetries ?? 0;
let remainingSteps = options.maxSteps;
const executed = [];
let stepsProcessed = 0;
// Spans the retry loop: a retry re-opens only the ONE failed step, so every
// step this call already completed keeps handing its complete artifact
// downstream instead of falling back to the (possibly clipped) row. See the
// {@link driveRun} declaration.
const liveEvidence = new Map();
for (;;) {
const result = await runWorkflowAttempt({
...options,
target,
...(params !== undefined ? { params } : { params: undefined }),
...(parameterFlags !== undefined ? { parameterFlags } : { parameterFlags: undefined }),
newRun,
...(remainingSteps !== undefined ? { maxSteps: remainingSteps } : { maxSteps: undefined }),
}, liveEvidence);
executed.push(...result.executed);
stepsProcessed += result.stepsProcessed;
const notices = mergeLoweringNotices(...executed.map((step) => step.notices));
const aggregate = { ...result, executed, stepsProcessed, ...(notices ? { notices } : {}) };
// An attempt that executed nothing (an abandoned, undecodable plan) has no
// failed step for a retry to re-open.
if (result.run.status !== "failed" ||
result.aborted ||
result.gateRejection ||
result.executed.length === 0 ||
remainingRetries <= 0) {
return aggregate;
}
if (remainingSteps !== undefined) {
// The step budget is DISTINCT PROCESSED STEPS, not `executed` entries:
// gate loops of one step and route-skips must not shrink a retry's
// remaining budget (they never counted against maxSteps either).
remainingSteps -= result.stepsProcessed;
if (remainingSteps <= 0)
return aggregate;
}
await resumeWorkflowRun(result.run.id);
target = result.run.id;
params = undefined;
parameterFlags = undefined;
newRun = undefined;
remainingRetries -= 1;
}
}
async function runWorkflowAttempt(options, liveEvidence) {
const next = await getNextWorkflowStep(options.target, options.params, {
parameterFlags: options.parameterFlags,
newRun: options.newRun,
});
// Refuse non-active runs BEFORE any dispatch — completeWorkflowStep would
// reject the completion anyway, but only after the units already ran (and
// cost money). Mirror its preflight up front.
if (!next.done && next.run.status !== "active") {
throw new UsageError(`Workflow run ${next.run.id} is ${next.run.status} and cannot be executed. ` +
`Use \`akm workflow resume ${next.run.id}\` to reopen it first.`);
}
// One driver per run: the per-run lock file is taken BEFORE the plan is
// read or anything dispatches, so a second `akm workflow run` on a run
// another process is driving refuses up front (exit 75) instead of racing
// its spine. A done run takes no lock: nothing will dispatch.
const runId = next.run.id;
const lock = next.done ? undefined : acquireWorkflowRunLock(runId);
try {
let plan;
const warnings = [...(next.startWarnings ?? [])];
if (!next.done) {
const row = await withWorkflowRunsRepo((repo) => repo.getRunById(runId));
if (!row)
throw new UsageError(`Workflow run ${runId} was not found.`);
const read = readRunPlan(row);
if (!read.ok) {
// A newer akm's run is left untouched for that akm.
if (read.newer)
throw new UsageError(read.problem);
// Otherwise a frozen plan this akm cannot decode is a status change,
// not an exception: the run is abandoned and the message names how to
// start afresh.
const abandoned = await abandonWorkflowRun(runId);
const message = `${read.problem} The run was abandoned; start a new run with 'akm workflow run ${row.workflow_ref}'.`;
return {
run: abandoned.run,
executed: [],
stepsProcessed: 0,
warnings: [...warnings, message],
...(next.resumed ? { resumed: true } : {}),
};
}
plan = read.plan;
if (!next.autoStarted) {
const drift = await workflowSourceDriftWarning(runId, row.workflow_ref, plan);
if (drift)
warnings.push(drift);
}
}
// One state.db connection scope for the whole drive loop (the per-step
// scopes nest into it); each `openStateDatabase` is not free.
const result = await withWorkflowRunsConnection(() => driveRun(options, next, plan, liveEvidence));
// Creation/resume-time notices reach the caller only here: the run row
// has no warnings column, and a later invocation of the same run must
// stay silent about a decision it did not make. `driveRun` never sets
// `warnings` or `resumed` — both are properties of THIS resolution of
// `target`, not of the run row (#919).
return {
...result,
...(next.resumed ? { resumed: true } : {}),
...(warnings.length > 0 ? { warnings } : {}),
};
}
finally {
try {
if (lock)
releaseLock(lock);
}
finally {
// Process-lifecycle drain (owner finding 4): release any cached SDK server
// child processes so a one-shot CLI invocation exits cleanly instead of
// hanging on the leaked handle. Runs even if the lock release itself
// fails; a teardown-time error must not skip dispatch cleanup.
try {
await (options.disposeDispatchResources ?? disposeDispatchResources)();
}
catch {
/* disposal is best-effort; never let cleanup mask the run outcome */
}
}
}
}
/** The per-run lock file: `<data dir>/workflow-run-locks/<run id>.lock`, next to `state.db`. */
export function workflowRunLockPath(runId) {
return path.join(path.dirname(resolveStorageLocations().stateDb), "workflow-run-locks", `${runId}.lock`);
}
/**
* Take the run's O_EXCL lock file, or refuse with `RUN_LEASE_HELD` (exit 75)
* naming the live holder. A lock whose holder pid is dead is reclaimed by
* `tryAcquireRunLock` before this refuses, so a crashed engine never wedges
* a run; nothing here expires by age.
*/
function acquireWorkflowRunLock(runId) {
const result = tryAcquireRunLock(workflowRunLockPath(runId), { label: `workflow run ${runId}` });
if (result.state === "acquired")
return result.ownership;
const since = result.holder.startedAt ? `, since ${result.holder.startedAt}` : "";
throw new TransientError(`Workflow run ${runId} is already being driven by another akm process ` +
`(pid ${formatLockHolderPid(result.holder)}${since}). A second \`akm workflow run\` would race it — ` +
"wait for that invocation to finish.", "RUN_LEASE_HELD");
}
/**
* Resume re-reads the authored workflow source and says so, once, when it no
* longer matches the bytes the plan was frozen from. The run keeps executing
* the frozen plan either way — that is the whole safety the freeze buys — and
* the warning tells the operator a fresh run is what picks up the edit.
*/
async function workflowSourceDriftWarning(runId, workflowRef, plan) {
if (!plan.sourceHash)
return undefined;
let sourcePath;
try {
sourcePath = (await loadWorkflowAsset(workflowRef)).path;
}
catch (error) {
const detail = error instanceof Error ? error.message : String(error);
return (`Workflow run ${runId}: the authored source ${workflowRef} could not be re-read (${detail}); ` +
"continuing with the frozen plan.");
}
let current;
try {
current = createHash("sha256").update(fs.readFileSync(sourcePath)).digest("hex");
}
catch {
return `Workflow run ${runId}: ${sourcePath} is no longer readable; continuing with the frozen plan.`;
}
if (current === plan.sourceHash)
return undefined;
return (`Workflow run ${runId}: ${workflowRef} (${sourcePath}) has changed since this run was frozen; ` +
`continuing with the frozen plan. Start a new run with 'akm workflow run ${workflowRef} --new' to pick up the edit.`);
}
/**
* A terminal run is a pure no-op: do not load or integrity-check its frozen
* plan, because post-completion plan corruption cannot change finished work.
*/
async function completedRunResult(runId) {
const doneState = await getNextWorkflowStep(runId);
return {
run: doneState.run,
executed: [],
stepsProcessed: 0,
...(doneState.run.status === "completed" ? { done: true } : {}),
};
}
function workflowSummaryJudge(options, stepPlan, signal, owner) {
if (options.summaryJudge !== undefined)
return options.summaryJudge;
// The judge dispatches under the real run/step identity and the same event source as units.
return frozenSummaryJudge(stepPlan.gate.frozenJudge, signal, options.dispatcher ?? defaultUnitDispatcher, owner, options.eventSource);
}
/**
* Seed the run's budget from the append-only attempt journal so a resumed run
* does not restart it at zero: dispatch attempts count against `max_units`,
* their tokens against `max_tokens`. Gate-evaluation rows are excluded, as the
* live path never charges a judge call.
*/
async function seedRunAccountingFromJournal(runId) {
const accounting = await withWorkflowRunsRepo((repo) => repo.getAttemptAccounting(runId));
return {
unitsDispatched: accounting.dispatchAttempts,
tokensUsed: accounting.dispatchTokens,
};
}
/**
* The row plan is the sole execution authority. The loader seam may assert an
* expected plan in tests, but can never replace it.
*/
async function loadAuthoritativeRunPlan(options, next, stored) {
if (options.loadPlan) {
const expected = decodeWorkflowPlan(await options.loadPlan(next.run.workflowRef));
if (computePlanHash(expected) !== computePlanHash(stored))
throw new UsageError(`Injected workflow plan for run ${next.run.id} differs from its frozen plan.`);
}
return stored;
}
/**
* Complete a branch target no completed router selected as `skipped` — no
* dispatch, no gate loop, and (per the `maxSteps` contract) no step consumed.
* Returns the re-read spine state so the caller can continue its walk.
*/
async function skipUnselectedRouteTarget(input) {
const { runId, stepId, stepPlan, skipInfo, routeUnselected, executed } = input;
// Cascade: a skipped step that is ITSELF a router
// never evaluates its route, so none of its declared targets were
// selected — mark them all skip-on-reach too (a target another
// completed router selects stays protected via routeSelected). Without
// this, every branch of the skipped router would run unconditionally.
if (stepPlan.route) {
cascadeSkippedRouter(stepPlan.route, stepId, routeUnselected);
}
const notes = skipInfo.selected === null
? `Skipped by route: step "${skipInfo.router}" was itself skipped, so none of its branch targets run.`
: `Skipped by route: step "${skipInfo.router}" selected "${skipInfo.selected}".`;
executed.push({ stepId, ok: true, unitCount: 0, failedUnits: 0, summary: notes });
await completeWorkflowStep({ runId, stepId, status: "skipped", notes });
return getNextWorkflowStep(runId);
}
/**
* Seed a step's starting gate loop and feedback from its journaled gate rows,
* so a run interrupted after a rejection resumes at the next loop with the
* stored feedback instead of re-judging loop 1. Reads only this step's rows.
*/
async function recoverGateLoopState(runId, stepPlan) {
// A step with no effective completion criteria never reaches a judge
// (`validateStepSummary` short-circuits before the gate-journaling wrapper),
// so it can have no gate rows and needs no query at all.
if (!stepPlan.gate.criteria.some((criterion) => criterion.trim().length > 0)) {
return { startLoop: 1, seededFeedback: undefined };
}
const stepId = stepPlan.stepId;
const stepJournal = await withWorkflowRunsRepo((repo) => repo.getUnitsForStep(runId, stepId));
const startLoop = activeGateLoop(stepJournal, stepId);
return { startLoop, seededFeedback: recoverGateFeedback(stepJournal, stepId, startLoop) };
}
/**
* The kinds that finished the step — its one `maxSteps` consumption. An abort,
* a judge outage, and a blocked child consume nothing: the work is still owed.
*/
const STEP_FINISHED_KINDS = new Set(["advanced", "failed", "gate-exhausted"]);
/**
* One attempt at a step's work. Route-only steps (YAML `route:` — no execution
* subgraph) dispatch no units; they only decide the spine's path in
* `finalizeExecutedStep`. Everything else executes its subgraph through the
* native executor.
*/
async function executeStepSubgraph(ctx, loop) {
const { options, next, plan, stepPlan, step, evidence } = ctx;
const { gateLoop, gateFeedback, unitsDispatched, tokensUsed } = loop;
return !stepPlan.root && stepPlan.route
? {
ok: true,
units: [],
evidence: {},
summary: `Step "${step.id}" is a route step — no units dispatched.`,
unitsDispatched,
}
: await executeStepPlan(stepPlan, {
runId: next.run.id,
workflowRef: next.run.workflowRef,
params: next.run.params ?? {},
evidence,
unitsDispatched,
tokensUsed,
// Budget ceilings ride the FROZEN plan: a mid-run
// asset edit can never loosen or tighten a run's budget.
...(plan.budget ? { budget: plan.budget } : {}),
gateLoop,
...(gateFeedback ? { gateFeedback } : {}),
// F-1: threaded to an exec unit's child env;
// undefined for every non-task caller (byte-identical, RunWorkflowOptions doc).
...(options.eventSource !== undefined ? { eventSource: options.eventSource } : {}),
...(options.signal ? { signal: options.signal } : {}),
...(options.dispatcher ? { dispatcher: options.dispatcher } : {}),
maxConcurrency: Math.min(options.maxConcurrency ?? Number.POSITIVE_INFINITY, plan.execution?.maxConcurrency ?? 1),
});
}
/**
* Drive one step's bounded gate loop: a rejection with loops left re-executes
* the subgraph with the judge's feedback in the unit prompts. Returns the loop
* decision and running budget totals; `ctx.executed` is appended in place.
*/
async function runStepGateLoop(ctx, gate, totals) {
const { options, next, stepPlan, step, evidence, executed, routeSelected, routeUnselected } = ctx;
const { summaryJudge } = ctx;
const { startLoop, maxLoops } = gate;
let { unitsDispatched, tokensUsed } = totals;
let gateFeedback = gate.seededFeedback;
// Every exit carries the running totals back to the engine loop; naming the
// kind is the whole decision an exit point has to make.
const outcome = (rest) => ({
...rest,
unitsDispatched,
tokensUsed,
});
for (let gateLoop = startLoop; gateLoop <= maxLoops; gateLoop++) {
const result = await executeStepSubgraph(ctx, { gateLoop, gateFeedback, unitsDispatched, tokensUsed });
unitsDispatched = result.unitsDispatched;
if (result.tokensUsed !== undefined)
tokensUsed = result.tokensUsed;
if (options.signal?.aborted)
return outcome({ kind: "aborted" });
executed.push({
stepId: step.id,
ok: result.ok,
unitCount: result.units.length,
failedUnits: result.units.filter((u) => !u.ok).length,
summary: result.summary,
...(result.notices ? { notices: result.notices } : {}),
});
// Route, gate, and advance: the shared completion path, live or resumed.
let finalize;
try {
finalize = await finalizeExecutedStep({
runId: next.run.id,
workflowRef: next.run.workflowRef,
stepId: step.id,
stepPlan,
completionCriteria: stepPlan.gate.criteria,
gateLoop,
loopsRemaining: gateLoop < maxLoops,
result,
priorEvidence: evidence,
params: next.run.params ?? {},
routeSelected,
routeUnselected,
summaryJudge,
signal: options.signal,
});
}
catch (error) {
if (options.signal?.aborted)
return outcome({ kind: "aborted" });
throw error;
}
if (finalize.kind === "retry") {
// Re-execute the subgraph with the judge/validation feedback threaded
// into unit prompts — the changed prompt changes each unit's input
// hash, so the re-run dispatches fresh work instead of reusing rows.
gateFeedback = finalize.gateFeedback;
continue;
}
if (finalize.kind === "advanced") {
// Keep the complete artifact for later steps of this invocation, but only
// when some later reference can read it (`referencedStepIds`).
if (ctx.liveEvidenceConsumers.has(step.id))
ctx.liveEvidence.set(step.id, result.evidence);
// A route-only step's summary IS its decision (finalize surfaces it).
if (finalize.summaryOverride !== undefined) {
executed[executed.length - 1] = { ...executed[executed.length - 1], summary: finalize.summaryOverride };
}
return outcome({ kind: "advanced" });
}
if (finalize.kind === "judge-failed") {
// Verifier infrastructure failure (thrown judge / malformed verdict /
// missing judge): the step is blocked for resume, NO gate loop was
// consumed, and the step does not count against maxSteps. Surface the
// resume instruction in the step report so every output mode shows it.
executed[executed.length - 1] = { ...executed[executed.length - 1], summary: finalize.summary };
return outcome({ kind: "judge-failed", judgeFailure: { stepId: step.id, message: finalize.summary } });
}
if (finalize.kind === "child-blocked") {
// a composed child workflow is blocked. Like judge-failed,
// NO gate loop was consumed and the step does not count against
// maxSteps. `result.childBlocked` (set by reduceStepOutcomes off the
// failed unit's live-only childRun field) carries the identity
// finalizeExecutedStep's own return value deliberately omits.
executed[executed.length - 1] = { ...executed[executed.length - 1], summary: finalize.summary };
const child = result.childBlocked;
return outcome({
kind: "child-blocked",
...(child
? {
childBlocked: {
stepId: step.id,
childRunId: child.childRunId,
childRef: child.childRef,
resume: `akm workflow resume ${child.childRunId}`,
resumeParentCommand: `akm workflow resume ${next.run.id} && akm workflow run ${next.run.id}`,
},
}
: {}),
});
}
if (finalize.kind === "failed") {
// A route-failure was pushed as ok:true (the units succeeded); reflect
// the deterministic route failure in the executed report.
if (finalize.routeFailure) {
executed[executed.length - 1] = { ...executed[executed.length - 1], ok: false, summary: finalize.summary };
}
return outcome({ kind: "failed" });
}
// gate-exhausted: rejected with no loop budget left — stop with feedback.
return outcome({ kind: "gate-exhausted", gateRejection: finalize.gateRejection });
}
// Unreachable: `retry` is the ONLY path that continues the loop, and
// `finalizeExecutedStep` returns it exclusively while `gateLoop < maxLoops`,
// so the final iteration always exits through a terminal kind. Falling out
// here would mean those two bounds disagree — a bug, not a run outcome.
throw new Error(`Workflow run ${next.run.id} step "${step.id}" left its gate loop with no terminal outcome (loop bounds disagree).`);
}
/** The engine loop proper — runs under the per-run lock `runWorkflowAttempt` holds. */
async function driveRun(options, initial,
/** The run row's decoded frozen plan; undefined only for a done run, which drives nothing. */
storedPlan,
/**
* In-memory evidence of steps this call completed that a later step reads,
* preferred over re-parsing their rows (the values agree).
*/
liveEvidence) {
let next = initial;
if (initial.done || !storedPlan)
return completedRunResult(initial.run.id);
const executed = [];
let gateRejection;
let judgeFailure;
let childBlocked;
let aborted = false;
const maxSteps = options.maxSteps ?? Number.POSITIVE_INFINITY;
// The `maxSteps` budget counts DISTINCT spine steps that finished processing
// — never `executed.length`, which grows once per gate-loop iteration and
// once per route-skip. A step's whole bounded gate loop consumes ONE step;
// a route-skipped step consumes NOTHING (no work was dispatched for it).
let stepsProcessed = 0;
let { unitsDispatched, tokensUsed } = await seedRunAccountingFromJournal(next.run.id);
const plan = await loadAuthoritativeRunPlan(options, next, storedPlan);
// Live-evidence retention is decided at SET time, from the frozen plan alone:
// a completed step's complete artifact is held only while some other step's
// references can still read it. An exec unit's promoted stdout can be 8 MiB,
// and holding every step's for the whole invocation is pure ballast when
// nothing downstream names it.
const liveEvidenceConsumers = referencedStepIds(plan);
// Route bookkeeping: targets a completed router did NOT select are skipped
// when the spine reaches them; a target ANY router selected is protected
// (two routers may share a target).
const routeSelected = new Set();
const routeUnselected = new Map();
// Replay journaled route decisions before the spine advances, so a
// re-invoked run skips the unselected branches. A done run skips this.
if (!next.done) {
seedJournaledRouteDecisions(plan, next, routeSelected, routeUnselected);
}
while (!next.done && next.step && next.run.status === "active" && stepsProcessed < maxSteps) {
// A caller abort (options.signal) is a graceful break.
if (options.signal?.aborted) {
aborted = true;
break;
}
const step = next.step;
const stepPlan = plan.steps.find((s) => s.stepId === step.id);
if (!stepPlan) {
throw new UsageError(`Step "${step.id}" of run ${next.run.id} is not present in its frozen workflow plan (${next.run.workflowRef}). ` +
"The run journal is inconsistent; abandon this run and start a new one.");
}
// A branch target no completed router selected → auto-skip, no dispatch.
const skipInfo = routeUnselected.get(step.id);
if (skipInfo && !routeSelected.has(step.id)) {
next = await skipUnselectedRouteTarget({
runId: next.run.id,
stepId: step.id,
stepPlan,
skipInfo,
routeUnselected,
executed,
});
continue;
}
const evidence = {};
for (const s of next.workflow.steps)
evidence[s.id] = liveEvidence.get(s.id) ?? s.evidence;
// Bounded gate loop: loop 1 is the normal
// execution; a gate rejection with attempts left re-executes the subgraph
// with the judge's feedback threaded into unit prompts. The bound comes
// from the shared derivation, which holds an exec step to a single
// execution — its argv cannot answer feedback (see effectiveGateMaxLoops).
const maxLoops = effectiveGateMaxLoops(stepPlan);
const { startLoop, seededFeedback } = await recoverGateLoopState(next.run.id, stepPlan);
// Resumed after the final rejection: reproduce the gate-exhausted outcome
// from the stored feedback instead of running an extra loop.
if (startLoop > maxLoops) {
gateRejection = {
stepId: step.id,
missing: seededFeedback?.missing ?? [],
feedback: seededFeedback?.feedback ?? "",
};
break;
}
// Judge-outage contract: resolve the step's frozen completion judge BEFORE
// any dispatch. An unresolvable judge (missing frozen engine, no dispatcher
// for an agent judge) is verifier INFRASTRUCTURE failure — block the step
// for `akm workflow resume` instead of spending on units the gate can never
// verify. No gate loop is consumed and nothing is dispatched.
let summaryJudge;
try {
summaryJudge = workflowSummaryJudge(options, stepPlan, options.signal, {
runId: next.run.id,
stepId: step.id,
});
}
catch (error) {
const detail = error instanceof Error && error.message ? ` (${error.message})` : "";
// Nothing was dispatched, so there is no evidence to preserve — the same
// blocked write the post-execution path uses, minus the results it does
// not have.
const notes = await blockStepForJudgeFailure({
runId: next.run.id,
stepId: step.id,
cause: `the verification judge could not be resolved from the frozen plan${detail}`,
});
executed.push({ stepId: step.id, ok: false, unitCount: 0, failedUnits: 0, summary: notes });
judgeFailure = { stepId: step.id, message: notes };
break;
}
const outcome = await runStepGateLoop({
options,
next,
plan,
stepPlan,
step,
evidence,
liveEvidence,
liveEvidenceConsumers,
executed,
routeSelected,
routeUnselected,
summaryJudge,
}, { startLoop, maxLoops, seededFeedback }, { unitsDispatched, tokensUsed });
unitsDispatched = outcome.unitsDispatched;
tokensUsed = outcome.tokensUsed;
if (outcome.kind === "aborted")
aborted = true;
if (outcome.gateRejection)
gateRejection = outcome.gateRejection;
if (outcome.judgeFailure)
judgeFailure = outcome.judgeFailure;
if (outcome.childBlocked)
childBlocked = outcome.childBlocked;
if (STEP_FINISHED_KINDS.has(outcome.kind))
stepsProcessed += 1;
// Only an advance leaves the spine walkable; every other kind ends this
// invocation (failure, exhausted gate, judge outage, blocked child, abort).
if (outcome.kind !== "advanced")
break;
next = await getNextWorkflowStep(next.run.id);
}
// Re-read for the freshest run state (the loop may have exited on maxSteps).
const finalState = await getNextWorkflowStep(next.run.id);
const notices = mergeLoweringNotices(...executed.map((step) => step.notices));
return {
run: finalState.run,
executed,
stepsProcessed,
...(notices ? { notices } : {}),
...(finalState.run.status === "completed" ? { done: true } : {}),
...(gateRejection ? { gateRejection } : {}),
...(judgeFailure ? { judgeFailure } : {}),
...(childBlocked ? { childBlocked } : {}),
...(aborted ? { aborted: true } : {}),
};
}