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
1,260 lines • 60.9 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/.
/**
* Shared step semantics: the one implementation of a step's orchestration
* decisions, used by the engine on both a fresh run and a resume, so the same
* frozen plan produces byte-identical unit graphs. Pure except the
* gate-evaluation journaling; never dispatches and never writes step rows.
* See docs/architecture/decisions/0002-unit-reuse-and-input-hash-scope.md.
*/
import { createHash } from "node:crypto";
import unitPreambleTemplate from "../../assets/prompts/workflow-unit-preamble.md" with { type: "text" };
import { UsageError } from "../../core/errors.js";
import { validateJsonSchemaSubset } from "../../core/json-schema.js";
import { parseEmbeddedJsonResponse } from "../../core/parse.js";
import { withSchemaInstruction } from "../../core/structured.js";
import { canonicalInputJson, validateInputs } from "../../execution/input-contract.js";
import { withWorkflowRunsRepo, } from "../../storage/repositories/workflow-runs-repository.js";
import { canonicalJson } from "../ir/plan-hash.js";
import { parseReference, resolveReferenceString, } from "../program/expressions.js";
import { clip, WORKFLOW_UNIT_DIAGNOSTIC_CLIP } from "../resource-limits.js";
import { completeWorkflowStep } from "../runtime/runs.js";
import { parseJudgeVerdict } from "../validate-summary.js";
import { gateNodeId } from "./frozen-judge.js";
import { enqueueUnitWrite } from "./unit-writer.js";
/** How much raw unit output is retained in step evidence (full text lives on the unit row). */
const EVIDENCE_TEXT_CLIP = 2_000;
/** How much artifact JSON the completion-criteria judge receives. */
const GATE_ARTIFACT_CLIP = 4_000;
/**
* Validate a fan-out item list before any identity/dispatch work: no
* null/undefined items (there would be nothing to hand the unit) and no
* canonical duplicates (content-derived unit ids would collide). Returns the
* failure message, or undefined when the list is dispatchable.
*/
function validateFanOutItems(stepId, items) {
const nullIndex = items.findIndex((item) => item === null || item === undefined);
if (nullIndex !== -1) {
return (`Step "${stepId}" fan-out list contains a null item (index ${nullIndex}). ` +
`Every item must be a concrete value — fix the producing step's output.`);
}
return undefined;
}
/**
* Unit ids for a fan-out list: the first occurrence of a canonical value keeps
* {@link unitIdFor}'s id; later occurrences gain `#2`, `#3`, … by position, so
* a fresh run and a resume derive the same ids.
*/
function occurrenceSuffixedUnitIds(nodeId, items) {
const occurrenceByCanonical = new Map();
return items.map((item) => {
const base = unitIdFor(nodeId, item, true, true);
const canonical = canonicalJson(item) ?? "null";
const occurrence = (occurrenceByCanonical.get(canonical) ?? 0) + 1;
occurrenceByCanonical.set(canonical, occurrence);
return occurrence === 1 ? base : `${base}#${occurrence}`;
});
}
/** Resolve one whole-value reference (`inputs[]`, `map.over`, `route.input`, a binding's `from`). */
function resolveStepReference(reference, scope) {
return resolveReferenceString(reference, scope);
}
/**
* Resolve a composing step's frozen `inputBindings` before any attempt: a
* literal passes through (checked at freeze); a reference resolves like every
* other whole-value position and is validated against its frozen schema. A
* failure fails the whole step. No bindings resolve to `{}`.
*/
function resolveTaskInputBindings(bindings, stepId, scope) {
if (!bindings || bindings.length === 0)
return { ok: true, values: {} };
const values = {};
for (const binding of bindings) {
if (binding.kind === "literal") {
values[binding.name] = binding.value;
continue;
}
const resolved = resolveStepReference(binding.from, scope);
if (!resolved.ok) {
return {
ok: false,
error: `Step "${stepId}" input "${binding.name}" reference ${binding.from} failed to resolve: ` +
resolved.error.message,
};
}
const errors = validateInputs({ [binding.name]: { schema: binding.schema, required: false } }, { [binding.name]: resolved.value },
// Fixed neutral namespace (matches `checkScheduleEntryRunnable`'s
// `pathRoot: "inputs"` and the `contractViolation` diagnostics' own
// `$`-strip) — NOT `binding.name`, which would double the input name
// (`count.count: ...`) since the outer message below already names it.
{ pathRoot: "inputs" });
if (errors.length > 0) {
return {
ok: false,
error: `Step "${stepId}" input "${binding.name}" reference ${binding.from} resolved to a value violating its ` +
`declared schema: ${errors.join("; ")}`,
};
}
values[binding.name] = resolved.value;
}
return { ok: true, values };
}
/**
* Compute a step's work list purely from the frozen plan and its inputs:
* resolve the fan-out list, derive content-derived unit ids, assemble each
* unit's prompt, and hash its input. Same inputs give byte-identical
* ids/hashes/prompts — what resume relies on to recognize journaled units.
* Every reference resolves once per step, so failures fail the whole list.
*/
export function computeStepWorkList(plan, input) {
const root = plan.root;
// Route-only steps (YAML `route:`) carry no execution subgraph.
if (!root) {
return {
ok: false,
error: `Step "${plan.stepId}" has no execution subgraph (a route-only step); the native executor cannot dispatch it.`,
};
}
const template = root.kind === "map" ? root.template : root;
const reducer = root.kind === "map" ? root.reducer : "collect";
const scope = { params: input.params, stepOutputs: input.stepOutputs };
// Instructions are ALWAYS the step's body prose, byte-exact — never
// templated, never scanned for reference syntax (workflow-format-
// unification, spec §2.3). Only `map.over` / `route.input` / `inputs[]`
// carry the closed reference grammar.
// Resolve the step's declared `inputs:` ONCE (shared by every unit in this
// step — map items differ, declared inputs do not): prior-step artifacts
// attached to every dispatched unit as structured context.
const resolvedInputs = [];
for (const reference of template.inputs ?? []) {
const resolved = resolveStepReference(reference, scope);
if (!resolved.ok) {
return {
ok: false,
error: `Step "${plan.stepId}" declared input "${reference}" failed to resolve: ${resolved.error.message}`,
};
}
resolvedInputs.push({ reference, value: resolved.value });
}
// A composing step's frozen `inputBindings`, resolved against this scope for every target kind.
const taskInputsResolution = resolveTaskInputBindings(template.frozenTarget.inputBindings, plan.stepId, scope);
if (!taskInputsResolution.ok)
return taskInputsResolution;
const hasTaskInputs = Object.keys(taskInputsResolution.values).length > 0;
// Resolve fan-out items: `over` is a single whole-value reference naming
// its producer explicitly — no ambient key search.
let items;
if (root.kind === "map") {
const source = resolveStepReference(root.over, scope);
if (!source.ok) {
return {
ok: false,
error: `Step "${plan.stepId}" fan-out "over" (${root.over}) failed to resolve: ${source.error.message}`,
};
}
if (!Array.isArray(source.value)) {
return {
ok: false,
error: `Step "${plan.stepId}" fan-out "over" (${root.over}) resolved to ${typeof source.value}, not an array.`,
};
}
items = source.value;
}
else {
items = [undefined];
}
const isFanOut = root.kind === "map";
const fanOutProblem = isFanOut ? validateFanOutItems(plan.stepId, items) : undefined;
if (fanOutProblem)
return { ok: false, error: fanOutProblem };
// Content-derived unit identity: compute every id up front. A fan-out's
// canonical duplicates are disambiguated by occurrence ordinal rather than
// rejected (issue 6); a solo (non-fan-out) step has exactly one item, so
// there is nothing to disambiguate.
const unitIds = isFanOut
? occurrenceSuffixedUnitIds(template.id, items)
: [unitIdFor(template.id, undefined, false, true)];
const gateLoop = input.gateLoop ?? 1;
const target = template.frozenTarget;
const frozenExec = target.kind === "shell" || target.kind === "script" ? target.exec : undefined;
const runner = target.kind === "command" ? target.runner.kind : "exec";
// Taken verbatim from the frozen plan, resolved once at freeze (an exec
// unit's on its exec spec). A frozen `null` means genuinely unbounded
// (`timeout: none`, or an agent harness that owns its own lifetime).
const timeoutMs = target.kind === "command"
? (target.runner.timeoutMs ?? null)
: target.kind === "child-workflow"
? // A child-workflow target carries no exec spec of its own.
// A child-workflow unit is driven by child-workflow.ts, never by this value.
null
: target.exec.timeoutMs;
// Step-constant exec context: `AKM_PARAMS` / `AKM_INPUTS` depend only on
// step-level values, so they are serialized ONCE here and shared by every
// unit. Building them inside the per-unit loop deep-cloned and re-stringified
// identical data per unit, and retained one distinct copy per unit until the
// step reduced.
const execParamsJson = frozenExec ? (canonicalJson(input.params) ?? "{}") : undefined;
const execInputsJson = frozenExec && resolvedInputs.length > 0
? (canonicalJson(Object.fromEntries(resolvedInputs.map((entry) => [entry.reference, entry.value]))) ?? "{}")
: undefined;
// P2b Lane A2: the resolved effective task-composition inputs,
// serialized ONCE here (mirrors execParamsJson/execInputsJson above) —
// Lane B's delivery (buildUnitPrompt's "## Task inputs" block,
// buildExecContextEnv's AKM_TASK_INPUTS) reads both back per unit.
const taskInputsJson = hasTaskInputs ? (canonicalJson(taskInputsResolution.values) ?? "{}") : undefined;
const ctx = {
plan,
input,
template,
isFanOut,
gateLoop,
resolvedInputs,
runner,
timeoutMs,
target,
...(frozenExec ? { frozenExec } : {}),
...(execParamsJson !== undefined ? { execParamsJson } : {}),
...(execInputsJson !== undefined ? { execInputsJson } : {}),
...(hasTaskInputs ? { taskInputs: taskInputsResolution.values, taskInputsJson } : {}),
};
const units = items.map((item, index) => buildStepWorkUnit(ctx, unitIds[index], item, index));
const concurrency = root.kind === "map" ? root.concurrency : 1;
return {
ok: true,
list: { template, reducer, isFanOut, ...(concurrency !== undefined ? { concurrency } : {}), items, units },
};
}
/** Build one unit of the step's work list: journal id, prompt, exec context env, and input hash. */
function buildStepWorkUnit(ctx, unitId, item, index) {
const { plan, input, template, isFanOut, resolvedInputs, target, frozenExec, taskInputs } = ctx;
// Gate loops (>= 2) journal under `<unitId>~l<loop>` so loop 1's rows are
// never clobbered; the content-derived identity (and the prompt's
// {{UNIT_ID}}) stays the base id.
const journalBaseId = ctx.gateLoop > 1 ? `${unitId}~l${ctx.gateLoop}` : unitId;
// Every unit receives the run params, its item + index if it is a map unit,
// and its step's `inputs:` artifacts as attached context; instructions are
// never interpolated. An exec unit gets no prompt: its context reaches the
// child as environment ({@link buildExecContextEnv}), never spliced into argv.
const prompt = frozenExec
? ""
: buildUnitPrompt({
runId: input.runId,
stepId: plan.stepId,
unitId,
params: input.params,
...(isFanOut ? { item, itemIndex: index } : {}),
...(resolvedInputs.length > 0 ? { inputs: resolvedInputs } : {}),
// P2b Lane B: the composed task's resolved
// `inputBindings`, when non-empty — see StepWorkUnitContext.taskInputs.
...(taskInputs && Object.keys(taskInputs).length > 0 ? { taskInputs } : {}),
...(input.gateFeedback ? { gateFeedback: input.gateFeedback } : {}),
// An agent or SDK transport's request lowering appends the schema
// instruction itself; a direct-LLM unit carries it in its prompt.
...(template.schema && ctx.runner === "llm" ? { schema: template.schema } : {}),
instructions: template.instructions,
});
const inputHash = computeUnitInputHash(ctx, item);
return {
unitId,
nodeId: template.id,
index,
item,
isFanOut,
journalBaseId,
runner: ctx.runner,
frozenTarget: target,
environment: template.environment,
...(frozenExec ? { execContext: buildExecContextEnv({ ctx, unitId, item, index }) } : {}),
...(target.kind === "command" && target.request.model?.resolved ? { model: target.request.model.resolved } : {}),
timeoutMs: ctx.timeoutMs,
...(template.schema ? { schema: template.schema } : {}),
...(template.retry ? { retry: template.retry } : {}),
onError: template.onError,
...(template.isolation ? { isolation: template.isolation } : {}),
// the SAME resolved `with:` bindings `taskInputs` already
// carries, exposed under the name `child-workflow.ts`'s drive contract
// reads. Absent (never `{}`) when the step binds nothing.
...(taskInputs && Object.keys(taskInputs).length > 0 ? { childParams: taskInputs } : {}),
prompt,
inputHash,
};
}
/**
* The `AKM_*` context environment an exec unit's child receives: how a fan-out
* item, the run params, and the step's `inputs:` artifacts reach a frozen argv
* (as canonical JSON). Applied over the resolved `env:` bindings so a binding
* cannot shadow it. Size is checked at the spawn boundary
* (`checkExecContextSize`, exec-unit.ts), where an E2BIG can be reported by name.
*/
function buildExecContextEnv(args) {
const { ctx, unitId, item, index } = args;
// The step-constant payloads were serialized once by `computeStepWorkList`;
// only the item and the ids vary per unit.
const env = {
AKM_RUN_ID: ctx.input.runId,
AKM_STEP_ID: ctx.plan.stepId,
AKM_UNIT_ID: unitId,
AKM_PARAMS: ctx.execParamsJson ?? "{}",
};
if (ctx.isFanOut) {
env.AKM_ITEM = canonicalJson(item) ?? "null";
env.AKM_ITEM_INDEX = String(index);
}
if (ctx.execInputsJson !== undefined)
env.AKM_INPUTS = ctx.execInputsJson;
// One variable carrying the resolved `inputBindings` as canonical JSON; absent when nothing is bound.
if (ctx.taskInputsJson !== undefined)
env.AKM_TASK_INPUTS = ctx.taskInputsJson;
return env;
}
/**
* The unit's `input_hash`: every input that changes what the backend is asked
* to do (names, never secret values). Informational — resume reuses a
* completed row by unit id. `gateFeedback`/`taskInputs` are included only when
* present so the loop-1, binding-free preimage keeps its `hashVersion` 7 shape.
* See docs/architecture/decisions/0002-unit-reuse-and-input-hash-scope.md.
*/
function computeUnitInputHash(ctx, item) {
return createHash("sha256")
.update("akm.workflow.unit\0v7\0")
.update(canonicalJson({
hashVersion: 7,
role: "unit",
stepId: ctx.plan.stepId,
nodeId: ctx.template.id,
template: ctx.template.instructions,
item: ctx.isFanOut ? (item ?? null) : null,
inputs: ctx.resolvedInputs,
params: ctx.input.params,
frozenTarget: ctx.target,
environment: ctx.template.environment,
schema: ctx.template.schema ?? null,
isolation: ctx.template.isolation ?? "none",
...(ctx.taskInputs !== undefined ? { taskInputs: ctx.taskInputs } : {}),
...(ctx.input.gateFeedback ? { gateFeedback: ctx.input.gateFeedback } : {}),
}))
.digest("hex");
}
/**
* Assemble the final prompt: the engine preamble (params, item/index, input
* artifacts as JSON context) + the step's byte-exact instructions (+ gate
* feedback on a loop, + schema directive). Only the preamble's own
* placeholders are substituted.
*/
export function buildUnitPrompt(input) {
const { runId, stepId, unitId, params, itemIndex, item, inputs, taskInputs, gateFeedback, schema, instructions } = input;
// Function replacements throughout: a string replacement would interpret
// GetSubstitution patterns ($&, $$, $', $`) inside VALUES and silently
// corrupt the prompt (e.g. a param value containing "$&").
const preamble = unitPreambleTemplate
.replaceAll("{{RUN_ID}}", () => runId)
.replaceAll("{{STEP_ID}}", () => stepId)
.replaceAll("{{UNIT_ID}}", () => unitId)
.replaceAll("{{PARAMS_JSON}}", () => safeJson(params));
// Map-unit context: the item this unit was given, plus its index. Attached
// as structured JSON — the engine never splices it into the instructions.
const itemBlock = itemIndex !== undefined
? `\n\n## Item (index ${itemIndex})\nYou were given this item from the fan-out list:\n${safeJson(item)}`
: "";
// Declared `inputs:` context: the prior-step artifacts this step named.
const inputsBlock = inputs && inputs.length > 0
? `\n\n## Declared inputs\n${inputs.map((i) => `### ${i.reference}\n${safeJson(i.value)}`).join("\n\n")}`
: "";
// The resolved `inputBindings` as a fenced JSON block, serialized exactly like AKM_TASK_INPUTS.
const taskInputsBlock = taskInputs && Object.keys(taskInputs).length > 0
? `\n\n## Task inputs\nThe composed task's declared inputs resolved to:\n\`\`\`json\n${canonicalInputJson(taskInputs)}\n\`\`\``
: "";
// Gate-loop feedback (R2 max_loops): the judge's rejection is appended so
// the re-executed unit can address it — and so the input hash changes,
// making the loop's re-dispatch natural instead of a durable-row reuse.
const gateBlock = gateFeedback
? `\n\n## Completion-gate feedback (previous attempt rejected)\n` +
`A completion-criteria judge rejected this step's previous results. Address this feedback:\n` +
gateFeedback.feedback +
(gateFeedback.missing.length > 0
? `\nUnmet criteria:\n${gateFeedback.missing.map((m) => `- ${m}`).join("\n")}`
: "")
: "";
const prompt = `${preamble}\n${instructions}${itemBlock}${inputsBlock}${taskInputsBlock}${gateBlock}`;
return schema ? withSchemaInstruction(prompt, schema) : prompt;
}
/**
* Content-derived unit identity (module doc): `<node_id>:<sha256>` for a
* fan-out item, `<node_id>:solo` otherwise. The hash is over the item's
* canonical JSON (sorted keys — same canonicalization the vote reducer
* counts with), so identity survives list reordering/regeneration and is
* independent of item position. Retry attempts stack `~r<n>` on top.
*/
export function unitIdFor(nodeId, item, isFanOut, collisionSafe = false) {
if (!isFanOut)
return `${nodeId}:solo`;
const canonical = canonicalJson(item) ?? "null";
const digest = createHash("sha256").update(canonical).digest("hex");
return `${nodeId}:${collisionSafe ? digest : digest.slice(0, 12)}`;
}
// ── Step outputs + reducers + typed artifacts ────────────────────────────────
/**
* The value a `steps.<id>.output` reference resolves to for ONE step, given that
* step's journaled evidence: an engine-executed step carries a promoted
* ARTIFACT under `evidence.output` (solo unit result/text, collect array, or
* vote winner); evidence without an `output` key (manually-completed steps) is
* exposed as-is.
*/
export function projectStepOutput(evidence) {
return Object.hasOwn(evidence, "output") ? evidence.output : evidence;
}
/** Project the engine's evidence map into the expression scope's `stepOutputs`. */
export function stepOutputsFromEvidence(evidence) {
const outputs = {};
for (const [stepId, stepEvidence] of Object.entries(evidence)) {
if (stepEvidence !== undefined)
outputs[stepId] = projectStepOutput(stepEvidence);
}
return outputs;
}
/** The step's dispatch template — the map template for a fan-out, else the root unit. */
function stepTemplate(stepPlan) {
const root = stepPlan.root;
if (!root)
return undefined;
return root.kind === "map" ? root.template : root;
}
/**
* The step ids another step can still read (named by `inputs[]`, `map.over`,
* or `route.input` — the whole reference surface). A step outside this set
* need not keep its artifact in memory once journaled. Derived from the plan alone.
*/
export function referencedStepIds(plan) {
const referenced = new Set();
const note = (reference) => {
const parsed = parseReference(reference);
if (parsed.ok && parsed.expr.kind === "stepOutput")
referenced.add(parsed.expr.stepId);
};
for (const step of plan.steps) {
if (step.root?.kind === "map")
note(step.root.over);
for (const reference of stepTemplate(step)?.inputs ?? [])
note(reference);
if (step.route)
note(step.route.input);
}
return referenced;
}
/**
* Typed artifacts: validate the promoted step artifact against
* `WorkflowPlanStep.outputSchema`. Returns the step-failure summary (validation
* errors included) on mismatch, undefined when valid or when no schema is
* declared.
*/
export function validateStepArtifact(plan, evidence) {
if (!plan.outputSchema)
return undefined;
const errors = validateJsonSchemaSubset(projectStepOutput(evidence), plan.outputSchema);
if (errors.length === 0)
return undefined;
return (`Step "${plan.stepId}" artifact failed validation against the step's declared output schema: ` +
`${errors.join("; ")}.`);
}
/**
* Warn-only check of each successful unit's value against its declared
* `unit.output` schema — the field a harness without structured output drops
* during lowering. Never fails the step (unlike {@link validateStepArtifact}).
*/
export function unitSchemaWarning(plan, units) {
const schema = stepTemplate(plan)?.schema;
if (!schema)
return undefined;
const mismatches = [];
for (const unit of units) {
if (!unit.ok)
continue;
const candidate = unit.result !== undefined
? unit.result
: unit.text !== undefined
? parseEmbeddedJsonResponse(unit.text)
: undefined;
if (candidate === undefined) {
mismatches.push(`unit "${unit.unitId}" produced no structured output to check`);
continue;
}
const errors = validateJsonSchemaSubset(candidate, schema);
if (errors.length > 0)
mismatches.push(`unit "${unit.unitId}": ${errors.join("; ")}`);
}
if (mismatches.length === 0)
return undefined;
return `Output does not match the unit's declared schema (advisory; the run continued): ${mismatches.join("; ")}.`;
}
/**
* Build the summary the completion-criteria gate judges for a step (addendum
* R2, "typed artifacts, honest gates"): a one-line unit count followed by the
* promoted step artifact as canonical JSON, clipped at {@link GATE_ARTIFACT_CLIP}
* chars. This replaces machine-prose so the gate evaluates real results.
*/
export function buildArtifactSummary(stepId, units, evidence) {
const failedCount = units.filter((u) => !u.ok).length;
const json = canonicalJson(projectStepOutput(evidence)) ?? "null";
return (`Step "${stepId}" executed ${units.length} unit(s) (${units.length - failedCount} succeeded, ${failedCount} failed). ` +
`Step artifact (canonical JSON${json.length > GATE_ARTIFACT_CLIP ? `, clipped at ${GATE_ARTIFACT_CLIP} chars` : ""}):\n` +
clip(json, GATE_ARTIFACT_CLIP));
}
/** A unit's contribution to the step artifact: structured result, else text, else null (failures). */
function unitOutputValue(unit) {
if (!unit.ok)
return null;
if (unit.result !== undefined)
return unit.result;
return unit.text ?? null;
}
export function buildEvidence(units, reducer, isFanOut) {
// Per-unit evidence carries only what the journal can reproduce, so a fresh
// run and a resume agree byte-for-byte: a success keeps its `result`/clipped
// `text`, a failure only its `failureReason` (diagnostics stay on the unit row).
const collected = units.map((u) => u.ok
? {
unitId: u.unitId,
ok: true,
...(u.result !== undefined ? { result: u.result } : {}),
...(u.text !== undefined ? { text: clip(u.text, EVIDENCE_TEXT_CLIP) } : {}),
}
: {
unitId: u.unitId,
ok: false,
...(u.failureReason ? { failureReason: u.failureReason } : {}),
});
const evidence = { units: collected, itemCount: units.length };
// Promoted step artifact (`evidence.output`) — what a `steps.<id>.output`
// reference resolves to (see projectStepOutput). Values are UNCLIPPED.
if (reducer === "vote") {
evidence.output = null;
}
else {
evidence.output = isFanOut ? units.map(unitOutputValue) : unitOutputValue(units[0]);
}
if (reducer === "vote") {
const counts = new Map();
for (const unit of units) {
if (!unit.ok)
continue;
const value = unit.result !== undefined ? unit.result : unit.text;
const key = canonicalJson(value);
const entry = counts.get(key);
if (entry)
entry.count++;
else
counts.set(key, { value, count: 1 });
}
const ranked = [...counts.values()].sort((a, b) => b.count - a.count);
if (ranked.length === 0) {
evidence.voteError = "Vote reducer had no successful unit results to count.";
}
else if (ranked.length > 1 && ranked[0].count === ranked[1].count) {
evidence.voteError = `Vote reducer tied at ${ranked[0].count} vote(s) — no majority.`;
}
else {
const winner = ranked[0].value;
evidence.vote = { winner, votes: ranked[0].count, total: units.length };
// An empty free-text winner is `undefined`; normalize to "" so a live run
// and a resume promote the same `output` key.
evidence.output = winner === undefined ? "" : winner;
}
}
return evidence;
}
/**
* The first failed unit's diagnostic (e.g. an exec unit's stderr), clipped to
* {@link WORKFLOW_UNIT_DIAGNOSTIC_CLIP}, for the step summary. A live outcome
* carries it as `error`, a rehydrated one as `text`; both give the same summary.
*/
function firstFailureDiagnostic(failed) {
const first = failed.find((u) => (u.error ?? u.text)?.trim());
if (!first)
return "";
const diagnostic = (first.error ?? first.text ?? "").trim();
return ` First failure diagnostic (${first.unitId}): ${clip(diagnostic, WORKFLOW_UNIT_DIAGNOSTIC_CLIP)}`;
}
/**
* Reduce a step's terminal unit outcomes into the promoted artifact and step
* verdict: the `on_error` policy, the reducer, the vote-tie failure, and the
* typed-artifact schema check (`artifactSchemaFailure` marks a retryable one).
*/
export function reduceStepOutcomes(plan, reducer, isFanOut, onError, units) {
const failed = units.filter((u) => !u.ok);
const evidence = buildEvidence(units, reducer, isFanOut);
const reducerNote = typeof evidence.voteError === "string" ? ` ${evidence.voteError}` : "";
const tolerateFailures = onError === "continue";
let ok = (tolerateFailures || failed.length === 0) && !evidence.voteError;
let summary = `Executed ${units.length} unit(s) for step "${plan.stepId}" via workflow orchestration: ` +
`${units.length - failed.length} succeeded, ${failed.length} failed.` +
(failed.length > 0
? ` Failures${tolerateFailures ? " (recorded, on_error: continue)" : ""}: ${failed
.map((u) => `${u.unitId} (${u.failureReason ?? "error"})`)
.join(", ")}.`
: "") +
firstFailureDiagnostic(failed) +
reducerNote;
let artifactSchemaFailure = false;
if (ok) {
const schemaFailure = validateStepArtifact(plan, evidence);
if (schemaFailure !== undefined) {
ok = false;
summary = schemaFailure;
artifactSchemaFailure = true;
}
}
if (!artifactSchemaFailure) {
const schemaWarning = unitSchemaWarning(plan, units);
if (schemaWarning !== undefined)
summary += ` ${schemaWarning}`;
}
// A blocked child workflow (the failed unit's live-only `childRun`) is
// surfaced so `finalizeExecutedStep` blocks the step instead of retrying.
const blockedChildUnit = failed.find((u) => u.failureReason === "child_workflow_blocked" && u.childRun !== undefined);
const childBlocked = blockedChildUnit?.childRun
? {
childRunId: blockedChildUnit.childRun.runId,
childRef: blockedChildUnit.childRun.ref,
childStepId: blockedChildUnit.childRun.currentStepId,
}
: undefined;
return {
ok,
units,
evidence,
summary,
...(artifactSchemaFailure ? { artifactSchemaFailure: true } : {}),
...(childBlocked ? { childBlocked } : {}),
};
}
/**
* The outcome of a step whose fan-out list is empty: no units dispatch and the
* artifact is `[]` (collect) or `null` (vote), still checked against the step's
* `outputSchema`. The reducer/vote-tie logic does not run.
*/
export function reduceEmptyStep(plan, reducer) {
const evidence = { units: [], itemCount: 0, output: reducer === "collect" ? [] : null };
const schemaFailure = validateStepArtifact(plan, evidence);
return {
ok: schemaFailure === undefined,
units: [],
evidence,
summary: schemaFailure ?? `Step "${plan.stepId}" fan-out list was empty — no units dispatched.`,
...(schemaFailure !== undefined ? { artifactSchemaFailure: true } : {}),
};
}
/**
* Rehydrate a journaled unit row into the {@link UnitOutcome} its live dispatch
* produced: a completed row's JSON text or structured result, or a failed row's
* `failure_reason` with its journaled diagnostic as `text`.
*/
export function unitOutcomeFromRow(unitId, row, hasSchema) {
let parsed;
try {
parsed = row.result_json === null ? undefined : JSON.parse(row.result_json);
}
catch {
parsed = undefined;
}
if (row.status === "completed") {
return {
unitId,
ok: true,
...(hasSchema
? { result: parsed }
: typeof parsed === "string"
? { text: parsed }
: parsed !== undefined
? { result: parsed }
: {}),
...(row.tokens !== null ? { tokens: row.tokens } : {}),
...(row.session_id !== null && row.session_id !== undefined ? { sessionId: row.session_id } : {}),
};
}
return {
unitId,
ok: false,
failureReason: row.failure_reason ?? "reported_failure",
...(typeof parsed === "string" ? { text: parsed } : {}),
...(row.tokens !== null ? { tokens: row.tokens } : {}),
};
}
/** Re-exported so existing importers (`tests/workflows/fuzz/*`) keep resolving; canonical impl lives in `../ir/plan-hash`. */
export { canonicalJson };
// ── Gate-feedback recovery (PURE) ────────────────────────────────────────────
//
// A gate rejection journals `{ complete: false, missing, feedback }` under
// `<stepId>.gate:l<loop>`, byte-identical to what the next loop's prompts
// carry, so a resume rebuilds the same loop-N work list.
/** `phase` marker on gate-evaluation unit rows (dispatch rows journal `phase: null`). */
const GATE_EVALUATION_PHASE = "gate";
/** The unit id of a step's gate-evaluation row for a given 1-based loop. */
export function gateUnitId(stepId, loop) {
return `${stepId}.gate:l${loop}`;
}
/**
* How many times a step's subgraph may run under its gate. An exec step never
* loops: its frozen argv cannot read the judge's feedback, so a second loop
* would only repeat the command's side effects. Its gate still evaluates.
*/
export function effectiveGateMaxLoops(stepPlan) {
const declared = Math.max(1, stepPlan.gate.maxLoops ?? 1);
const target = stepTemplate(stepPlan)?.frozenTarget;
return target && target.kind !== "command" ? 1 : declared;
}
/**
* The gate loop the engine is about to run for an active step: one past the
* highest journaled rejected loop (loop 1 when none). An unparseable gate row
* throws ({@link parseGateVerdict}) rather than silently restarting at loop 1.
*/
export function activeGateLoop(rows, stepId) {
let maxRejectedLoop = 0;
for (const row of rows) {
if (row.phase !== GATE_EVALUATION_PHASE || row.step_id !== stepId)
continue;
const loop = gateLoopOf(row.unit_id, stepId);
if (loop === undefined)
continue;
// Throws loudly on a corrupt/malformed gate row — never treated as absent.
if (parseGateVerdict(row).kind === "rejected" && loop > maxRejectedLoop)
maxRejectedLoop = loop;
}
return maxRejectedLoop + 1;
}
/**
* The `{ feedback, missing }` the previous loop's rejection journaled, which
* `loop`'s prompts carry; none for loop 1. An unparseable previous row throws.
*/
export function recoverGateFeedback(rows, stepId, loop) {
if (loop <= 1)
return undefined;
const prevId = gateUnitId(stepId, loop - 1);
const prev = rows.find((r) => r.unit_id === prevId && r.phase === GATE_EVALUATION_PHASE);
if (!prev)
return undefined;
const verdict = parseGateVerdict(prev);
return verdict.kind === "rejected" ? { feedback: verdict.feedback, missing: verdict.missing } : undefined;
}
/** The 1-based loop encoded in a `<stepId>.gate:l<n>` unit id, if well-formed. */
function gateLoopOf(unitId, stepId) {
const prefix = `${stepId}.gate:l`;
if (!unitId.startsWith(prefix))
return undefined;
const n = Number.parseInt(unitId.slice(prefix.length), 10);
return Number.isInteger(n) && n >= 1 ? n : undefined;
}
/**
* Classify a gate row's journaled verdict. A NULL `result_json` (in flight, or
* a completion error) is `empty`; a present value that is not `{ complete:
* boolean }` throws rather than resetting the gate loop to 1.
*/
function parseGateVerdict(row) {
if (row.result_json === null)
return { kind: "empty" };
let verdict;
try {
verdict = JSON.parse(row.result_json);
}
catch {
throw new UsageError(gateCorruptionMessage(row, "its result_json is not valid JSON"));
}
if (typeof verdict !== "object" || verdict === null || Array.isArray(verdict)) {
throw new UsageError(gateCorruptionMessage(row, "its result_json is not a JSON object"));
}
const v = verdict;
if (typeof v.complete !== "boolean") {
throw new UsageError(gateCorruptionMessage(row, 'its verdict has no boolean "complete" field'));
}
if (v.complete === false) {
const feedback = typeof v.feedback === "string" ? v.feedback : "";
const missing = Array.isArray(v.missing) ? v.missing.filter((m) => typeof m === "string") : [];
return { kind: "rejected", missing, feedback };
}
return { kind: "passed" };
}
function gateCorruptionMessage(row, why) {
return (`Workflow run ${row.run_id} has a corrupt gate-evaluation row "${row.unit_id}" for step "${row.step_id}" — ${why}. ` +
`A gate verdict must be {"complete": true|false, …}; refusing to treat a malformed gate row as absent, which would ` +
`silently restart the step's gate loop and re-dispatch work whose completion outcome is unknown. Fix or remove the ` +
`journaled row, then resume the run.`);
}
/** Insert the gate-evaluation unit row (running) just before the judge runs. */
export async function journalGateEvaluationStart(gate) {
const unitId = gateUnitId(gate.stepId, gate.loop);
const reserved = await enqueueUnitWrite(() => withWorkflowRunsRepo((repo) => repo.reserveUnitAttempt({
runId: gate.runId,
unitId,
stepId: gate.stepId,
nodeId: gateNodeId(gate.stepId),
phase: GATE_EVALUATION_PHASE,
runner: gate.runner,
engine: gate.engine,
model: gate.model,
inputHash: gate.inputHash,
now: new Date().toISOString(),
})));
return { ...gate, durableAttempt: reserved.attempt };
}
/**
* Finish the gate-evaluation row: a rejection journals `{ complete: false,
* missing, feedback }`, a pass `{ complete: true, missing: [] }`. An errored
* evaluation journals a failed row with no verdict, so a judge outage never
* burns a gate loop on resume.
*/
export async function journalGateEvaluationFinish(gate, errored, rejection) {
const unitId = gateUnitId(gate.stepId, gate.loop);
const verdict = errored
? null
: rejection
? { complete: false, missing: rejection.missing, feedback: rejection.feedback }
: { complete: true, missing: [] };
const status = errored ? "failed" : "completed";
if (!gate.durableAttempt)
throw new UsageError(`Gate ${unitId} has no durable attempt to finish.`);
const durableAttempt = gate.durableAttempt;
const finished = await enqueueUnitWrite(() => withWorkflowRunsRepo((repo) => {
return repo.finishUnitAttempt({
runId: gate.runId,
unitId,
attempt: durableAttempt.attempt,
dispatchId: durableAttempt.dispatch_id,
status,
resultJson: verdict ? JSON.stringify(verdict) : null,
tokens: gate.tokens ?? null,
failureReason: errored ? "dispatch_error" : null,
finishedAt: new Date().toISOString(),
});
}));
if (!finished) {
throw new UsageError(`Gate ${unitId} was already finished; refusing a duplicate terminal write.`);
}
}
/**
* Resolve a route's input (a single whole-value reference string — `params.x` or
* `steps.<id>.output…`, with no `${{ }}` delimiters) and pick the branch. No
* ambient key search. Only primitive values route; the comparison is exact
* string equality against the declared `when:` matches.
*/
export function evaluateRoute(route, scope) {
const resolved = resolveStepReference(route.input, scope);
if (!resolved.ok) {
return { ok: false, error: `route input ${route.input} failed to resolve: ${resolved.error.message}` };
}
const value = resolved.value;
if (typeof value === "object" && value !== null) {
return {
ok: false,
error: `route input ${route.input} resolved to a non-primitive value; branches match on strings/numbers/booleans.`,
};
}
const valueString = typeof value === "string" ? value : String(value);
// Own-property check: `when` is author-controlled, and a value such as
// "constructor" must not resolve through Object.prototype.
const selected = Object.hasOwn(route.when, valueString) ? route.when[valueString] : route.defaultStepId;
if (!selected) {
return {
ok: false,
error: `value "${valueString}" matched no "when:" branch and the route declares no default.`,
};
}
return { ok: true, value: valueString, selected };
}
/**
* Cascade a SKIPPED router: it never evaluated its route, so every declared
* target (branches + default) is marked skip-on-reach unless an earlier router
* already claimed it. Shared by the live skip path and the journal replay.
*/
export function cascadeSkippedRouter(route, routerId, routeUnselected) {
const targets = [...Object.values(route.when), ...(route.defaultStepId ? [route.defaultStepId] : [])];
for (const target of targets) {
if (!routeUnselected.has(target)) {
routeUnselected.set(target, { router: routerId, selected: null });
}
}
}
/**
* Record one router's decision in the skip bookkeeping: the selected target is
* protected, every other declared target (branches + default) is marked
* skip-on-reach unless an earlier router already claimed it. Shared by the live
* evaluation path and the journal replay.
*/
export function applyRouteDecision(route, routerId, selected, routeSelected, routeUnselected) {
routeSelected.add(selected);
const targets = [...Object.values(route.when), ...(route.defaultStepId ? [route.defaultStepId] : [])];
for (const target of targets) {
if (target !== selected && !routeUnselected.has(target)) {
routeUnselected.set(target, { router: routerId, selected });
}
}
}
/**
* The `stepOutputs` scope a route resolves against: every prior step's recorded
* evidence plus the just-finished step's fresh evidence — each projected
* through {@link projectStepOutput}. Same projection as unit templates, so the
* two scopes cannot drift.
*/
export function routeStepOutputs(evidence, currentStepId, currentEvidence) {
const outputs = {};
for (const [stepId, stepEvidence] of Object.entries(evidence)) {
if (stepEvidence !== undefined)
outputs[stepId] = projectStepOutput(stepEvidence);
}
outputs[currentStepId] = projectStepOutput(currentEvidence);
return outputs;
}
/** The `selected` target journaled on a route step's evidence, if well-formed. */
function journaledRouteSelection(evidence) {
const route = evidence?.route;
if (typeof route !== "object" || route === null || Array.isArray(route))
return undefined;
const selected = route.selected;
return typeof selected === "string" && selected !== "" ? selected : undefined;
}
/** The set of steps a route may legally select: its `when` branches + default. */
function routeTargets(route) {
return new Set([...Object.values(route.when), ...(route.defaultStepId ? [route.defaultStepId] : [])]);
}
/** A journaled route decision must name a target the route declares; anything else fails loudly. */
function assertRouteTargetDeclared(route, stepId, selected, runId) {
const targets = routeTargets(route);
if (!targets.has(selected)) {
throw new UsageError(`Workflow run ${runId} has a completed route step "${stepId}" whose journaled route decision selected ` +
`"${selected}", which is not a declared branch or default target of the route (valid targets: ` +
`${[...targets].join(", ") || "(none)"}). The route evidence was corrupted or manually edited — refusing to ` +
`apply a bogus route decision that would skip the real branch targets. Start a new run.`);
}
}
/**
* Replay journaled route decisions into the skip bookkeeping (resume path).
* For every COMPLETED route step of the frozen plan, in spine order: the
* journaled decision wins; else a re-derivation from the frozen plan +
* journaled evidence; else fail loudly. A SKIPPED route step cascades its
* targets into the skip set exactly as on the live path.
*/
export function seedJournaledRouteDecisions(plan, state, routeSelected, routeUnselected) {
const evidence = {};
for (const s of state.workflow.steps)
evidence[s.id] = s.evidence;
for (const stepPlan of plan.steps) {
if (!stepPlan.route)
continue;
const stepState = state.workflow.steps.find((s) => s.id === stepPlan.stepId);
if (!stepState)
continue;
if (stepState.status === "skipped") {
cascadeSkippedRouter(stepPlan.route, stepPlan.stepId, routeUnselected);
continue;
}
if (stepState.status !== "completed")
continue;
let selected = journaledRouteSelection(stepState.evidence);
if (selected !== undefined) {
// a stored decision must name a declared target — a bogus one
// (tampered/hand-edited evidence) fails loudly rather than seeding a skip
// set that buries the real branches.
assertRouteTargetDeclared(stepPlan.route, stepPlan.stepId, selected, state.run.id);
}
if (selected === undefined) {
const scope = {
params: state.run.params ?? {},
stepOutputs: routeStepOutputs(evidence, stepPlan.stepId, stepState.evidence ?? {}),
};
const decision = evaluateRoute(stepPlan.route, scope);
if (decision.ok)
selected = decision.selected;
}
if (selected === undefined) {
throw new UsageError(`Workflow run ${state.run.id} has a completed route step "${stepPlan.stepId}" with no journaled route ` +
`decision, and the decision cannot be re-derived from the journaled evidence. Refusing to guess which ` +
`branch was selected. The run journal is inconsistent; abandon this run and start a new one.`);
}
applyRouteDecision(stepPlan.route, stepPlan.stepId, selected, routeSelected, routeUnselected);
}
}
/**
* The blocked-step notes for a verifier-infrastructure failure (bug: judge
* outage must not burn the gate budget). Shared by every judge-failure path —
* missing judge, unresolvable frozen judge, thrown judge call, malformed
* verdict — so the resume instruction is worded once.
*/
function judgeFailureNotes(runId, stepId, cause) {
return (`Step "${stepId}" could not be verified: ${cause}. ` +
`This is a verification-judge failure, not a verdict — no gate loop was consumed and the step's ` +
`journaled units are preserved. Fix the verifier configuration or service, then run ` +
`\`akm workflow resume ${runId}\` to re-evaluate the gate against the existing results ` +
`without re-dispatching units.`);
}
/**
* Complete a step `blocked` for a verifier-INFRASTRUCTURE failure and return
* the notes written. The ONE implementation for both judge-failure paths — the
* engine's pre-dispatch judge resolution (`run-workflow.ts`) and this module's
* post-execution gate — so the wording, the blocked status, and the evidence
* decision cannot drift between them.
*/
export async function blockStepForJudgeFailure(input) {
const notes = judgeFailureNotes(input.runId, input.stepId, input.cause);
await completeWorkflowStep({
runId: input.runId,
stepId: input.stepId,
status: "blocked",
notes,
...(input.evidence !== undefined ? { evidence: input.evidence } : {}),
});
return notes;
}
/** The finalize path's blocked write: the executed units' evidence is preserved. */
async function blockFinalizedStep(input, cause) {
const summary = await blockStepForJudgeFailure({
runId: input.runId,
stepId: input.stepId,
cause,
evidence: input.result.evidence,
});
return { kind: "judge-failed", summary };
}
/** The blocked-child resume notes: resume the child first, then re-drive the parent. */
function childWorkflowBlockedNotes(runId, stepId, childRunId, childRef, childStepId) {
return (`Step "${stepId}" composes child workflow run ${childRunId} (${childRef}), ` +
`which is blocked at its own step "${childStepId ?? "(unknown)"}". Nothing in this run advances ` +
`until the child does — a gate is a gate for a child workflow too, so \`akm\` will ` +
`not resume it for you. Clear it with \`akm workflow resume ${childRunId}\`, then ` +
`\`akm workflow resume ${runId}\` and \`akm workflow run ${runId}\` to ` +
`continue: re-driving the parent drives the resumed child.`);
}
/** Complete a step `blocked` because its child workflow is blocked; `akm workflow resume` clears it. */
export async function blockStepForChildWorkflow(input) {
const notes = childWorkflowBlockedNotes(input.runId, input.stepId, input.childRunId, input.childRef, input.childStepId);
await completeWorkflowStep({
runId: input.runId,
stepId: input.stepId,
status: "blocked",
notes,
...(input.evidence !== undefined ? { evidence: input.evidence } : {}),
});
return notes;
}
/** The finalize path's child-blocked write: the executed units' evidence (the composing unit) is preserved. */
async function blockFinalizedStepForChildWorkflow(input, childBlocked) {
const summary = await blockStepForChildWorkflow({
runId: input.runId,
stepId: input.stepId,
childRunId: childBlocked.childRunId,
childRef: childBlocked.childRef,
childStepId: childBlocked.childStepId,
evidence: input.result.evidence,
});
return { kind: "child-blocked", summary };
}
/**
* Perform one completion attempt for an executed step:
* - a hard unit failure fails the step (a retryable artifact-schema mismatch
* with loops left returns `retry` without a gate row);
* - a route decision is evaluated, journaled on the evidence, and applied to
* the skip bookkeeping; an unroutable value fails the step;
* - the gate judges a summary built from the promoted artifact: a rejection
* returns `retry` (loops left) or `gate-exhausted`, a pass `advanced`;
* - a judge infrastructure failure is not a verdict: it blocks the step for
* `akm workflow resume` (`judge-failed`) without consuming a loop.
* Every advance goes through {@link completeWorkflowStep}.
*/
export async function finalizeExecutedStep(input) {
const { runId, workflowRef, stepId, stepPlan, completionCriteria, gateLoop, loopsRemaining, result } = input;
if (!result.ok) {
// a composed child workflow that blocked is never fed into the
// bounded gate loop — a gate is a gate for a child workflow too. Checked
// FIRST, before the artifactSchemaFailure retry branch below.
if (result.childBlocked) {
return blockFinalizedStepForChildWorkflow(input, result.childBlocked);
}
// Typed-artifact mismatch with loop budget left: regenerate-with-errors
// (the validation errors become the next loop's feedback). No judge ran, so
// no gate row is journaled for this attempt.
if (result.artifactSchemaFailure && loopsRemaining) {
return { kind: "retry", gateFeedback: { feedback: result.summary, missing: [] } };
}
await completeWorkflowStep({
runId,
stepId,
status: "failed",
notes: result.summary,
evidence: result.evidence,
});
return { kind: "failed", summary: result.summary };
}
// Resolve the completion-criteria judge ONCE (reused by the gate below). A
// A frozen plan either supplies its judge at the dispatch boundary or has no
// judge. Re-selecting defaults here would let config drift change a run.
const innerJudge = input.summaryJudge ?? null;
// A criteria-bearing step with NO judge cannot be verified at all — that is
// verifier infrastructure failure, never a silent bypass and never an honest
// rejection: block for resume without invoking the gate (no loop consumed).
if (completionCriteria.some((c) => c.trim().length > 0) && !innerJudge) {
return blockFinalizedStep(input, "this step declares completion criteria but no verification judge is available " +
"(the frozen plan resolves no judge — set workflow.judgeEngine, or restore the judge configuration)");
}
// Route evaluation BEFORE completion: an unroutable value is an
// authoring/config failure that must fail the step deterministically.
let summaryOverride;
if (stepPlan.route) {
const scope = {
params: input.params,
stepOutputs: routeStepOutputs(input.priorEvidence, stepId, result.evidence),
};
const decision = evaluateRoute(stepPlan.route, scope);
if (!decision.ok) {
const notes = `Step "${stepId}" route failed: ${decision.error}`;
await completeWorkflowStep({ runId, stepId, status: "failed", notes, evidence: result.evidence });
return { kind: "failed", summary: notes, routeFailure: true };
}
applyRouteDecision(stepPlan.route, stepId, decision.selected, input.routeSelected, input.routeUnselected);
// Journal the decision on the evidence: resume replays it via
// seedJournaledRouteDecisions, so the skip set survives re-invocation.
result.evidence.route = { input: stepPlan.route.input, value: decision.value, selected: decision.selected };
if (!stepPlan.root) {
summaryOverride = `Step "${stepId}" routed on ${stepPlan.route.input}: value "${decision.value}" selected step "${decision.selected}".`;
}
}
// Artifact-judging gate: a criteria-bearing executing step is judged on a
// summary BUILT FROM the promoted artifact; everything else keeps the machine
// summary (a route-only step's summary IS its decision).
const summary = stepPlan.root && completionCriteria.length > 0
? buildArtifactSummary(stepId, result.units, result.evidence)
: (summaryOverride ?? result.summary);
// Journal engine-driven judge calls as unit rows. With no criteria there is
// no judge invocation or row; a criteria-bearing plan without a judge is a
// configuration error rather than a silent bypass.
const gateTarget = innerJudge ? stepPlan.gate.frozenJudge : null;
let gateUnit;
// `judgeFailure` records a verifier INFRASTRUCTURE failure observed during
// the judge call — a throw (transport/service error) or a response that is
// not a well-formed verdict (same parser as validateStepSummary, so the
// fail-closed rejection it synthesizes is recognizably NOT an honest verdict
// here).
let judgeFailure;
const summaryJudge = innerJudge
? async (prompt) => {
if (gateTarget) {
const engineName = gateTarget.request.engine.name;
if (!engineName)
throw new Error(`Gate ${stepId} has no frozen engine identity.`);
gateUnit = {
runId,
workflowRef,
stepId,
loop: gateLoop,
engine: engineName,
model: gateTarget.request.model?.resolved ?? null,
runner: gateTarget.runner.kind,
// The gate prefix rides the unit prefix's version — unit and gate
// hashVersion are one vocabulary (p3a §0.1; the R-R15 fix moved
// both 6 → 7 in lockstep even though the gate preimage's own
// fields are unchanged).
inputHash: createHash("sha256")
.update("akm.workflow.gate\0v7\0")
.update(canonicalJson({
hashVersion: 7,
dispatch: gateTarget,
invocation: null,
prompt,
}))
.digest("hex"),
};
gateUnit = await journalGateEvaluationStart(gateUnit);
}
// The judge dispatch must describe the SAME thing the gate row does, so
// the row identity is threaded down to the dispatcher from right here —
// the one place that computes it — instead of being re-derived (or, as
// before, synthesized as a constant "gate"). Both ids come from the same
// helpers `journalGateEvaluationStart/Finish` use.
const identity = {
runId,
stepId,
unitId: gateUnitId(stepId, gateLoop),
...(gateUnit?.durableAttempt
? {
attempt: gateUnit.durableAttempt.attempt,
dispatchId: gateUnit.durableAttempt.dispatch_id,
recordTokens: (tokens) => {
if (gateUnit)
gateUnit.tokens = tokens;
},
}
: {}),
};
let raw;
try {
raw = await innerJudge(prompt, identity);
}
catch (err) {
const detail = err instanceof Error && err.message ? ` (${err.message})` : "";
judgeFailure = `the verification judge failed${detail}`;
throw err;
}
if (parseJudgeVerdict(raw) === undefined) {
judgeFailure =
"the verification judge responded with a malformed verdict instead of the required JSON result";
}
return raw;
}
: null;
// Once the judge runs, its `running` gate row must be finished on every
// exit: if `completeWorkflowStep` throws afterwards, finish it as errored,
// then re-propagate.
let completion;
try {
completion = await completeWorkflowStep({
runId,
stepId,
status: "completed",
summary,
evidence: result.evidence,
summaryJudge,
...(input.signal ? { signal: input.signal } : {}),
});
}
catch (err) {
if (gateUnit)
await journalGateEvaluationFinish(gateUnit, true, undefined);
throw err;
}
const rejection = "ok" in completion && completion.ok === false ? completion : undefined;
const judgeFailed = judgeFailure !== undefined;
if (gateUnit) {
// An infrastructure failure journals an ERRORED gate row (no verdict) —
// never the synthesized fail-closed rejection, which would read as an
// honest rejection to activeGateLoop/recoverGateFeedback on resume.
await journalGateEvaluationFinish(gateUnit, judgeFailed, rejection);
}
// Judge infrastructure failure: the fail-closed rejection is synthetic, not a
// verdict. Consume NO gate loop; block the step (and therefore the run) so
// `akm workflow resume` retries the gate over the journaled units.
if (rejection && judgeFailed) {
return blockFinalizedStep(input, judgeFailure ?? "the verification judge failed");
}
if (!rejection) {
return { kind: "advanced", ...(summaryOverride !== undefined ? { summaryOverride } : {}) };
}
if (loopsRemaining) {
return { kind: "retry", gateFeedback: { feedback: rejection.feedback, missing: rejection.missing } };
}
return {
kind: "gate-exhausted",
gateRejection: { stepId, missing: rejection.missing, feedback: rejection.feedback },
};
}
// ── Small helpers ────────────────────────────────────────────────────────────
function safeJson(value) {
try {
return JSON.stringify(value) ?? "null";
}
catch {
return "null";
}
}
// `clip` lives with the bounds it applies (`workflows/resource-limits.ts`) so
// the write side here and the read side in `runtime/runs.ts` — which cannot
// import this module — truncate through one implementation. Re-exported
// because this module is where the dispatch path already reaches for it.
export { clip } from "../resource-limits.js";