akm-cli 0.9.0 → 0.9.1-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +724 -0
  2. package/README.md +28 -63
  3. package/STABILITY.md +4 -2
  4. package/dist/cli/parse-args.js +7 -1
  5. package/dist/commands/agent/contribute-cli.js +1 -1
  6. package/dist/commands/env/child-env.js +14 -0
  7. package/dist/commands/feedback-cli.js +7 -1
  8. package/dist/commands/health/llm-usage.js +2 -1
  9. package/dist/commands/health/surfaces.js +4 -77
  10. package/dist/commands/health.js +65 -11
  11. package/dist/commands/improve/distill/quality-gate.js +6 -1
  12. package/dist/commands/improve/eligibility.js +7 -1
  13. package/dist/commands/improve/eval-cases.js +2 -0
  14. package/dist/commands/improve/improve.js +126 -10
  15. package/dist/commands/improve/locks.js +7 -0
  16. package/dist/commands/improve/memory/memory-improve.js +9 -0
  17. package/dist/commands/improve/run-context.js +5 -0
  18. package/dist/commands/improve/session-asset.js +4 -0
  19. package/dist/commands/lint/base-linter.js +31 -7
  20. package/dist/commands/lint/index.js +205 -51
  21. package/dist/commands/lint/types.js +22 -1
  22. package/dist/commands/proposal/repository.js +17 -1
  23. package/dist/commands/sources/add-cli.js +8 -2
  24. package/dist/commands/sources/info.js +12 -2
  25. package/dist/commands/sources/installed-stashes.js +6 -1
  26. package/dist/commands/sources/migration-help.js +12 -3
  27. package/dist/commands/sources/self-update.js +9 -1
  28. package/dist/commands/tasks/tasks.js +8 -2
  29. package/dist/commands/workflow-cli.js +17 -11
  30. package/dist/core/abort-deadline.js +28 -0
  31. package/dist/core/adapter/adapters/agent-skills-adapter.js +83 -5
  32. package/dist/core/adapter/adapters/akm-adapter.js +13 -10
  33. package/dist/core/adapter/adapters/akm-lint.js +78 -22
  34. package/dist/core/adapter/adapters/akm-task-adapter.js +43 -20
  35. package/dist/core/adapter/adapters/dotenv-adapter.js +21 -0
  36. package/dist/core/adapter/adapters/tool-dir-shared.js +5 -3
  37. package/dist/core/asset/frontmatter.js +10 -1
  38. package/dist/core/common.js +147 -9
  39. package/dist/core/concurrent.js +32 -0
  40. package/dist/core/config/config-io.js +5 -45
  41. package/dist/core/config/schema/engines.js +14 -3
  42. package/dist/core/config/schema/workflow.js +11 -0
  43. package/dist/core/errors.js +25 -0
  44. package/dist/core/events.js +30 -24
  45. package/dist/core/extra-params.js +11 -0
  46. package/dist/core/file-lock.js +7 -1
  47. package/dist/core/fs-txn.js +15 -2
  48. package/dist/core/improve-result.js +5 -0
  49. package/dist/core/json-schema.js +344 -9
  50. package/dist/core/loopback.js +89 -0
  51. package/dist/core/migration-operation.js +17 -2
  52. package/dist/core/path-access.js +107 -0
  53. package/dist/core/paths.js +16 -2
  54. package/dist/core/redaction.js +86 -18
  55. package/dist/core/spawn-env.js +234 -0
  56. package/dist/core/state-db-scope.js +134 -0
  57. package/dist/core/state-db.js +1 -0
  58. package/dist/core/subprocess.js +181 -37
  59. package/dist/core/write-provenance.js +85 -0
  60. package/dist/core/write-source.js +33 -2
  61. package/dist/indexer/db/graph-db.js +17 -6
  62. package/dist/indexer/ensure-index.js +10 -3
  63. package/dist/indexer/index-written-assets.js +17 -2
  64. package/dist/indexer/indexer.js +86 -21
  65. package/dist/indexer/passes/memory-inference.js +4 -0
  66. package/dist/indexer/search/db-search.js +25 -17
  67. package/dist/indexer/walk/walker.js +6 -1
  68. package/dist/integrations/agent/detect.js +13 -1
  69. package/dist/integrations/agent/engine-resolution.js +24 -11
  70. package/dist/integrations/agent/model-aliases.js +1 -1
  71. package/dist/integrations/agent/profiles.js +9 -1
  72. package/dist/integrations/agent/spawn.js +15 -87
  73. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +21 -0
  74. package/dist/integrations/lockfile.js +55 -2
  75. package/dist/llm/client.js +14 -19
  76. package/dist/llm/embedder.js +23 -3
  77. package/dist/llm/embedders/remote.js +27 -2
  78. package/dist/output/html-render.js +40 -1
  79. package/dist/output/text/lint-format.js +17 -4
  80. package/dist/runtime.js +23 -1
  81. package/dist/scripts/akm-migrate-node.js +1714 -836
  82. package/dist/scripts/akm-migrate.js +1682 -804
  83. package/dist/setup/setup.js +22 -7
  84. package/dist/sources/providers/git-install.js +25 -2
  85. package/dist/sources/providers/git-stash.js +19 -0
  86. package/dist/sources/providers/git.js +1 -1
  87. package/dist/sources/snapshot-fetchers/content-extract.js +63 -1
  88. package/dist/sources/snapshot-fetchers/website-ingest.js +126 -20
  89. package/dist/storage/database.js +71 -7
  90. package/dist/storage/engines/sqlite-migrations.js +61 -2
  91. package/dist/storage/managed-db.js +19 -0
  92. package/dist/storage/repositories/index-connection.js +39 -4
  93. package/dist/storage/repositories/index-entries-repository.js +6 -1
  94. package/dist/storage/repositories/index-meta-repository.js +11 -0
  95. package/dist/storage/repositories/index-schema.js +17 -2
  96. package/dist/storage/repositories/index-vec-repository.js +43 -5
  97. package/dist/storage/repositories/workflow-runs-repository.js +66 -13
  98. package/dist/storage/sqlite-pragmas.js +12 -1
  99. package/dist/tasks/log-redaction.js +156 -0
  100. package/dist/tasks/parser.js +82 -5
  101. package/dist/tasks/runner.js +222 -17
  102. package/dist/tasks/scheduler-invocation.js +19 -0
  103. package/dist/tasks/schema.js +86 -1
  104. package/dist/text-import-hook.mjs +1 -1
  105. package/dist/workflows/concurrency-policy.js +95 -1
  106. package/dist/workflows/exec/dispatch-redaction.js +114 -0
  107. package/dist/workflows/exec/exec-unit.js +542 -0
  108. package/dist/workflows/exec/frozen-judge.js +114 -42
  109. package/dist/workflows/exec/native-executor.js +465 -238
  110. package/dist/workflows/exec/param-secrets.js +4 -3
  111. package/dist/workflows/exec/run-workflow.js +424 -219
  112. package/dist/workflows/exec/step-work.js +506 -167
  113. package/dist/workflows/exec/unit-dispatch.js +31 -1
  114. package/dist/workflows/exec/unit-writer.js +53 -13
  115. package/dist/workflows/exec/worktree.js +454 -41
  116. package/dist/workflows/ir/compile.js +26 -2
  117. package/dist/workflows/ir/freeze.js +82 -15
  118. package/dist/workflows/ir/schema.js +105 -20
  119. package/dist/workflows/parser.js +242 -19
  120. package/dist/workflows/program/schema.js +24 -0
  121. package/dist/workflows/renderer.js +32 -4
  122. package/dist/workflows/resource-limits.js +182 -0
  123. package/dist/workflows/runtime/runs.js +146 -6
  124. package/dist/workflows/validate-summary.js +17 -2
  125. package/docs/README.md +74 -32
  126. package/docs/migration/release-notes/0.9.0.md +2 -1
  127. package/docs/migration/v0.7-to-v0.8.md +2 -1
  128. package/docs/migration/v0.8-to-v0.9.md +3 -1
  129. package/docs/reference/README.md +11 -4
  130. package/docs/reference/bundle-types.md +19 -0
  131. package/docs/reference/cli.md +105 -16
  132. package/docs/reference/configuration.md +15 -2
  133. package/docs/reference/data-and-telemetry.md +30 -10
  134. package/docs/reference/supported-formats.md +50 -0
  135. package/docs/reference/workflow-schema.md +1014 -0
  136. package/docs/reference/workflows.md +37 -633
  137. package/package.json +13 -6
  138. package/schemas/akm-config.json +18 -5
  139. package/schemas/akm-task.json +27 -5
  140. package/schemas/akm-workflow.json +92 -13
@@ -45,18 +45,14 @@ import { appendEvent } from "../../core/events.js";
45
45
  import { validateJsonSchemaSubset } from "../../core/json-schema.js";
46
46
  import { withWorkflowRunsRepo } from "../../storage/repositories/workflow-runs-repository.js";
47
47
  import { canonicalJson as canonicalJsonString } from "../ir/plan-hash.js";
48
- import { resolveReferenceString } from "../program/expressions.js";
49
- import { WORKFLOW_MAX_MAP_EXPANSION } from "../resource-limits.js";
50
- import { requireExecutableWorkflowPlan } from "../runtime/plan-classifier.js";
51
- import { completeWorkflowStep } from "../runtime/runs.js";
48
+ import { engineRuntimeKind } from "../ir/schema.js";
49
+ import { parseReference, resolveReferenceString, } from "../program/expressions.js";
50
+ import { clip, WORKFLOW_MAX_MAP_EXPANSION, WORKFLOW_UNIT_DIAGNOSTIC_CLIP } from "../resource-limits.js";
51
+ import { completeWorkflowStep, isTruncatedEvidence, } from "../runtime/runs.js";
52
52
  import { GATE_EVALUATION_PHASE } from "../runtime/unit-phases.js";
53
+ import { parseJudgeVerdict } from "../validate-summary.js";
54
+ import { gateNodeId } from "./frozen-judge.js";
53
55
  import { enqueueUnitWrite } from "./unit-writer.js";
54
- /**
55
- * Default per-unit timeout for workflow units. A unit's `timeout` declaration
56
- * overrides this; `none` disables it. Direct agent dispatch has no timeout by
57
- * default, while workflow units retain an independent safety ceiling.
58
- */
59
- export const DEFAULT_UNIT_TIMEOUT_MS = 600_000;
60
56
  /** How much raw unit output is retained in step evidence (full text lives on the unit row). */
61
57
  const EVIDENCE_TEXT_CLIP = 2_000;
62
58
  /** How much artifact JSON the completion-criteria judge receives (addendum R2, artifact-judging gates). */
@@ -70,14 +66,10 @@ const GATE_ARTIFACT_CLIP = 4_000;
70
66
  * units an earlier run already journaled.
71
67
  *
72
68
  * Whole-list failures (missing subgraph, unresolvable / non-array `over`,
73
- * null or duplicate fan-out items) return `{ ok: false }`. The per-unit
74
- * `resolved: { ok: false }` branch is STRUCTURALLY UNREACHABLE in the unified
75
- * format prose is never scanned for references, and everything that CAN
76
- * fail (map.over / route.input / inputs:) resolves once per step, failing the
77
- * whole list above. The branch is retained because every consumer of the work
78
- * list shares the shape and defensively handles it; if a future unit kind
79
- * reintroduces per-unit resolution (e.g. an exec/shell unit with real
80
- * substitution), the failure plumbing is already in place.
69
+ * null or duplicate fan-out items) return `{ ok: false }`. Per-unit resolution
70
+ * cannot fail in the unified format — prose is never scanned for references,
71
+ * and everything that CAN fail (map.over / route.input / inputs:) resolves
72
+ * once per step, failing the whole list above.
81
73
  */
82
74
  /**
83
75
  * Validate a fan-out item list BEFORE any identity/dispatch work: expansion
@@ -114,6 +106,54 @@ function validateFanOutItems(stepId, items) {
114
106
  }
115
107
  return undefined;
116
108
  }
109
+ /**
110
+ * Resolve one whole-value reference, refusing a value a persisted TRUNCATION
111
+ * ENVELOPE stands in for (`clipStepEvidenceForPersistence`, runtime/runs.ts).
112
+ *
113
+ * The engine threads each step's complete in-memory evidence to the rest of its
114
+ * own invocation, so only a RESUMED run can meet an envelope here. Left to the
115
+ * raw resolver, a path reference into one reports a generic missing property
116
+ * and a whole-value reference at one succeeds — handing the envelope to a unit
117
+ * as if it were the artifact. Both are silent corruption; name the cause
118
+ * instead. Every whole-value position (`inputs[]`, `map.over`, `route.input`)
119
+ * goes through here.
120
+ */
121
+ function resolveStepReference(reference, scope) {
122
+ const resolved = resolveReferenceString(reference, scope);
123
+ const truncated = truncatedReferenceTarget(reference, scope, resolved);
124
+ if (!truncated)
125
+ return resolved;
126
+ return {
127
+ ok: false,
128
+ error: {
129
+ reference,
130
+ message: `${reference} reads a step artifact that was NOT persisted (${truncated.originalBytes} bytes exceeded the ` +
131
+ `${truncated.limitBytes}-byte evidence_json cap, so the row stores a truncation marker). This run was ` +
132
+ `resumed from rows that no longer hold the value — it cannot be recovered. Start a new run, or have the ` +
133
+ `producing step emit a reference (path, id) instead of inline bulk data.`,
134
+ },
135
+ };
136
+ }
137
+ /** The envelope a reference lands on or walks through, if any. */
138
+ function truncatedReferenceTarget(reference, scope, resolved) {
139
+ if (resolved.ok)
140
+ return isTruncatedEvidence(resolved.value) ? resolved.value : undefined;
141
+ // A FAILED resolution is re-walked: the envelope is an object with none of
142
+ // the original's keys, so the raw failure is whatever property went missing
143
+ // along the way, several segments past the truncation.
144
+ const parsed = parseReference(reference);
145
+ if (!parsed.ok || parsed.expr.kind !== "stepOutput")
146
+ return undefined;
147
+ let current = scope.stepOutputs[parsed.expr.stepId];
148
+ for (const segment of parsed.expr.path) {
149
+ if (isTruncatedEvidence(current))
150
+ return current;
151
+ if (typeof current !== "object" || current === null)
152
+ return undefined;
153
+ current = current[segment];
154
+ }
155
+ return isTruncatedEvidence(current) ? current : undefined;
156
+ }
117
157
  export function computeStepWorkList(plan, input) {
118
158
  const root = plan.root;
119
159
  // Route-only steps (YAML `route:`) carry no execution subgraph.
@@ -135,7 +175,7 @@ export function computeStepWorkList(plan, input) {
135
175
  // attached to every dispatched unit as structured context.
136
176
  const resolvedInputs = [];
137
177
  for (const reference of template.inputs ?? []) {
138
- const resolved = resolveReferenceString(reference, scope);
178
+ const resolved = resolveStepReference(reference, scope);
139
179
  if (!resolved.ok) {
140
180
  return {
141
181
  ok: false,
@@ -148,7 +188,7 @@ export function computeStepWorkList(plan, input) {
148
188
  // its producer explicitly — no ambient key search.
149
189
  let items;
150
190
  if (root.kind === "map") {
151
- const source = resolveReferenceString(root.over, scope);
191
+ const source = resolveStepReference(root.over, scope);
152
192
  if (!source.ok) {
153
193
  return {
154
194
  ok: false,
@@ -174,26 +214,97 @@ export function computeStepWorkList(plan, input) {
174
214
  // were rejected above — identity requires distinct items).
175
215
  const unitIds = items.map((item) => unitIdFor(template.id, item, isFanOut));
176
216
  const gateLoop = input.gateLoop ?? 1;
217
+ // An `exec` unit dispatches a child process instead of an engine call, so it
218
+ // carries a frozen exec spec and NO invocation (the frozen-plan decoder
219
+ // enforces that exclusive-or). Everything downstream — identity, hashing,
220
+ // journaling, retry, budget — is shared; only the dispatch mechanism differs.
221
+ const frozenExec = template.exec;
177
222
  const frozenInvocation = template.invocation;
178
- if (!frozenInvocation)
223
+ if (!frozenExec && !frozenInvocation)
179
224
  return { ok: false, error: `Step "${plan.stepId}" has no frozen invocation.` };
180
- const frozenEngine = input.engines?.[frozenInvocation.engine];
181
- if (!frozenEngine) {
225
+ const frozenEngine = frozenInvocation ? input.engines?.[frozenInvocation.engine] : undefined;
226
+ if (frozenInvocation && !frozenEngine) {
182
227
  return { ok: false, error: `Step "${plan.stepId}" references missing frozen engine "${frozenInvocation.engine}".` };
183
228
  }
184
- const runner = frozenEngine.kind === "llm" ? "llm" : frozenEngine.runnerKind;
185
- const timeoutMs = frozenInvocation.timeoutMs;
186
- const units = items.map((item, index) => {
187
- const unitId = unitIds[index];
188
- // Gate loops (>= 2) journal under `<unitId>~l<loop>` so loop 1's rows are
189
- // never clobbered; the content-derived identity (and the prompt's
190
- // {{UNIT_ID}}) stays the base id.
191
- const journalBaseId = gateLoop > 1 ? `${unitId}~l${gateLoop}` : unitId;
192
- // Context attachment (workflow-format-unification, spec §4): every unit
193
- // receives the run params (already in the preamble), its item + index if
194
- // it is a map unit, and the artifacts named by its step's `inputs:`.
195
- // Instructions reach the unit byte-exact never interpolated.
196
- const prompt = buildUnitPrompt({
229
+ // No frozen engine at all means an exec unit: it carries argv, not an invocation.
230
+ const runner = frozenEngine ? engineRuntimeKind(frozenEngine) : "exec";
231
+ // Taken VERBATIM from the frozen plan — there is no engine-side backstop, by
232
+ // design. The whole timeout decision happens once at freeze time
233
+ // (`ir/freeze.ts` `effectiveTimeout`: unit `timeout:` document
234
+ // `defaults.timeout` `engines.<name>.timeoutMs` → the engine-kind default,
235
+ // `DEFAULT_LLM_TIMEOUT_MS` / `DEFAULT_AGENT_TIMEOUT_MS`). A frozen `null`
236
+ // means genuinely unbounded and is honored as such: it is reached either by an
237
+ // author writing `timeout: none` — an explicit, documented opt-out that a
238
+ // silent cap here would break or by `DEFAULT_AGENT_TIMEOUT_MS`, which is
239
+ // itself `null` because agent harnesses own their own lifetime. The frozen IR
240
+ // collapses both to `timeoutMs: null`, so this layer could not tell them apart
241
+ // even if it wanted to; anything that should bound a unit belongs in
242
+ // `effectiveTimeout`, not here.
243
+ // An exec unit's budget is frozen on its exec spec (there is no engine to
244
+ // inherit one from); `ir/freeze.ts` resolved it once from unit `timeout:` →
245
+ // `defaults.timeout` → DEFAULT_EXEC_TIMEOUT_MS.
246
+ const timeoutMs = frozenExec ? frozenExec.timeoutMs : (frozenInvocation?.timeoutMs ?? null);
247
+ // Step-constant exec context: `AKM_PARAMS` / `AKM_INPUTS` depend only on
248
+ // step-level values, so they are serialized ONCE here and shared by every
249
+ // unit. Building them inside the per-unit loop deep-cloned and re-stringified
250
+ // identical data per unit, and retained one distinct copy per unit until the
251
+ // step reduced.
252
+ const execParamsJson = frozenExec ? (canonicalJson(input.params) ?? "{}") : undefined;
253
+ const execInputsJson = frozenExec && resolvedInputs.length > 0
254
+ ? (canonicalJson(Object.fromEntries(resolvedInputs.map((entry) => [entry.reference, entry.value]))) ?? "{}")
255
+ : undefined;
256
+ const ctx = {
257
+ plan,
258
+ input,
259
+ template,
260
+ isFanOut,
261
+ gateLoop,
262
+ resolvedInputs,
263
+ runner,
264
+ timeoutMs,
265
+ ...(frozenEngine ? { frozenEngine } : {}),
266
+ ...(frozenInvocation ? { frozenInvocation } : {}),
267
+ ...(frozenExec ? { frozenExec } : {}),
268
+ ...(execParamsJson !== undefined ? { execParamsJson } : {}),
269
+ ...(execInputsJson !== undefined ? { execInputsJson } : {}),
270
+ };
271
+ const units = items.map((item, index) => buildStepWorkUnit(ctx, unitIds[index], item, index));
272
+ const concurrency = root.kind === "map" ? root.concurrency : 1;
273
+ return {
274
+ ok: true,
275
+ list: { template, reducer, isFanOut, ...(concurrency !== undefined ? { concurrency } : {}), items, units },
276
+ };
277
+ }
278
+ /**
279
+ * Build ONE unit of the step's work list: its journal id, its assembled prompt,
280
+ * its exec context env (exec units only), and its canonical input hash.
281
+ *
282
+ * Extracted from {@link computeStepWorkList} verbatim — same inputs, same
283
+ * bytes. It is a separate named pass only because the step-level resolution
284
+ * (inputs, fan-out items, runner, timeout) and the per-unit instantiation are
285
+ * two different jobs, and keeping them in one function had grown it past the
286
+ * repo's 220-line function bar.
287
+ */
288
+ function buildStepWorkUnit(ctx, unitId, item, index) {
289
+ const { plan, input, template, isFanOut, resolvedInputs, frozenEngine, frozenInvocation, frozenExec } = ctx;
290
+ // Gate loops (>= 2) journal under `<unitId>~l<loop>` so loop 1's rows are
291
+ // never clobbered; the content-derived identity (and the prompt's
292
+ // {{UNIT_ID}}) stays the base id.
293
+ const journalBaseId = ctx.gateLoop > 1 ? `${unitId}~l${ctx.gateLoop}` : unitId;
294
+ // Context attachment (workflow-format-unification, spec §4): every unit
295
+ // receives the run params (already in the preamble), its item + index if
296
+ // it is a map unit, and the artifacts named by its step's `inputs:`.
297
+ // Instructions reach the unit byte-exact — never interpolated.
298
+ //
299
+ // An EXEC unit gets NO prompt: there is no model to read one, the exec
300
+ // dispatch branch returns before ever touching `request.prompt`, and the input
301
+ // hash is built from `template.instructions`, not from the assembled string.
302
+ // Its context reaches the child through {@link buildExecContextEnv} instead —
303
+ // attached as environment, never spliced into argv, which is the argv-array
304
+ // analogue of "data is attached context, not string splices".
305
+ const prompt = frozenExec
306
+ ? ""
307
+ : buildUnitPrompt({
197
308
  runId: input.runId,
198
309
  stepId: plan.stepId,
199
310
  unitId,
@@ -204,97 +315,143 @@ export function computeStepWorkList(plan, input) {
204
315
  ...(template.schema ? { schema: template.schema } : {}),
205
316
  instructions: template.instructions,
206
317
  });
207
- // Canonical dispatch-input envelope (reviewer finding #1). Every field
208
- // here is a PLAN-FROZEN input that changes what the backend is actually
209
- // asked to do, so a completed unit is reused ONLY when all of them match;
210
- // a change to any of them re-dispatches. Key order is FIXED — it is the
211
- // hash preimage (JSON.stringify preserves insertion order) — and this is
212
- // the ONE place a unit's inputHash is computed (every caller goes through
213
- // computeStepWorkList), so a hash that is byte-identical across a fresh
214
- // run and a resume is structural, not coincidental.
215
- //
216
- // Unit identity (workflow-format-unification, spec §2.3/§4) hashes the
217
- // FROZEN TEMPLATE BYTES (`template.instructions`, byte-exact, never an
218
- // instantiated/interpolated string) + the canonical item JSON + the
219
- // declared-input artifact hashes + the params snapshot — instead of a
220
- // resolved/spliced prompt string, since there is no more splicing. The
221
- // assembled `prompt` above is what the harness SEES; the hash is over the
222
- // plan-frozen INPUTS that determine it, which is the same replay contract
223
- // the old resolved-prompt hash gave (same inputs ⇒ same hash) with the
224
- // interpolation step removed.
225
- //
226
- // Included beyond the R4 baseline (template/runner/model/schema): resolved
227
- // timeoutMs, the env asset ref NAMES, and isolation — each reaches
228
- // dispatch (native-executor's UnitDispatchRequest) and a changed one
229
- // yields a materially different call. `env` carries NAMES ONLY, never
230
- // resolved values: hashing a resolved secret would leak it into a durable
231
- // hash oracle and would spuriously re-dispatch on every secret rotation.
232
- // `retry`/`onError` are DELIBERATELY excluded — they govern failed-unit
233
- // re-dispatch and step-level failure reduction, not a COMPLETED unit's
234
- // inputs/output, so a completed row stays valid across policy changes.
235
- //
236
- // `gateFeedback` IS included (conditionally, so a no-feedback unit's
237
- // preimage is byte-identical to before): it is appended to the prompt by
238
- // `buildUnitPrompt`, so a gate loop's retry is materially a different ask
239
- // than the rejected attempt — omitting it made loop 1 and loop 2 journal
240
- // identical hashes for different prompts, breaking the "changed inputs ⇒
241
- // changed hash" audit contract. Replay-safe: feedback is re-derived from
242
- // the journaled gate decision, so a resumed retry re-hashes identically.
243
- //
244
- // Ambient config is DELIBERATELY excluded — the model-alias table, the
245
- // resolved backend/connection, and the working directory (`ctx.workDir` /
246
- // process.cwd()) are NOT plan-frozen. The frozen plan is the identity
247
- // boundary (redesign addendum determinism bar #2): config drift under an
248
- // in-flight run is out of scope by design.
249
- const dispatch = transitiveDispatchSnapshot(frozenEngine, input.engines ?? {});
250
- const inputHash = createHash("sha256")
251
- .update(canonicalJsonString({
252
- hashVersion: 4,
253
- template: template.instructions,
254
- item: isFanOut ? (item ?? null) : null,
255
- inputs: resolvedInputs,
256
- params: input.params,
257
- dispatch,
258
- invocation: frozenInvocation,
259
- schema: template.schema ?? null,
260
- env: template.env ?? null,
261
- isolation: template.isolation ?? "none",
262
- ...(input.gateFeedback ? { gateFeedback: input.gateFeedback } : {}),
263
- }))
264
- .digest("hex");
265
- const resolved = { ok: true, prompt, inputHash };
266
- return {
267
- unitId,
268
- nodeId: template.id,
269
- index,
270
- item,
271
- isFanOut,
272
- journalBaseId,
273
- runner,
274
- engine: frozenEngine,
275
- ...(frozenEngine?.kind === "agent" &&
276
- frozenEngine.fallbackLlmEngine &&
277
- input.engines?.[frozenEngine.fallbackLlmEngine]?.kind === "llm"
278
- ? {
279
- fallbackEngine: input.engines[frozenEngine.fallbackLlmEngine],
280
- }
281
- : {}),
282
- invocation: frozenInvocation,
283
- ...(frozenInvocation.model ? { model: frozenInvocation.model } : {}),
284
- timeoutMs,
285
- ...(template.schema ? { schema: template.schema } : {}),
286
- ...(template.env ? { env: template.env } : {}),
287
- ...(template.retry ? { retry: template.retry } : {}),
288
- onError: template.onError,
289
- ...(template.isolation ? { isolation: template.isolation } : {}),
290
- resolved,
291
- };
292
- });
293
- const concurrency = root.kind === "map" ? root.concurrency : 1;
318
+ const inputHash = computeUnitInputHash(ctx, item);
294
319
  return {
295
- ok: true,
296
- list: { template, reducer, isFanOut, ...(concurrency !== undefined ? { concurrency } : {}), items, units },
320
+ unitId,
321
+ nodeId: template.id,
322
+ index,
323
+ item,
324
+ isFanOut,
325
+ journalBaseId,
326
+ runner: ctx.runner,
327
+ ...(frozenEngine ? { engine: frozenEngine } : {}),
328
+ ...(frozenEngine?.kind === "agent" &&
329
+ frozenEngine.fallbackLlmEngine &&
330
+ input.engines?.[frozenEngine.fallbackLlmEngine]?.kind === "llm"
331
+ ? {
332
+ fallbackEngine: input.engines[frozenEngine.fallbackLlmEngine],
333
+ }
334
+ : {}),
335
+ ...(frozenInvocation ? { invocation: frozenInvocation } : {}),
336
+ ...(frozenExec ? { exec: frozenExec } : {}),
337
+ ...(frozenExec ? { execContext: buildExecContextEnv({ ctx, unitId, item, index }) } : {}),
338
+ ...(frozenInvocation?.model ? { model: frozenInvocation.model } : {}),
339
+ timeoutMs: ctx.timeoutMs,
340
+ ...(template.schema ? { schema: template.schema } : {}),
341
+ ...(template.env ? { env: template.env } : {}),
342
+ ...(template.retry ? { retry: template.retry } : {}),
343
+ onError: template.onError,
344
+ ...(template.isolation ? { isolation: template.isolation } : {}),
345
+ prompt,
346
+ inputHash,
347
+ };
348
+ }
349
+ /**
350
+ * The `AKM_*` context environment an exec unit's child receives.
351
+ *
352
+ * An exec unit's argv is FROZEN and never interpolated (the unified format has
353
+ * no substitution language at all), so this is how a fan-out item, the run
354
+ * params, and the step's declared `inputs:` artifacts actually reach a command
355
+ * — as attached environment, exactly as they reach an engine unit as attached
356
+ * prompt context. Values are canonical JSON so a command can parse them.
357
+ *
358
+ * These are applied on top of the resolved `env:` bindings in the child, so an
359
+ * engine-authored context variable can never be shadowed by a binding. Params
360
+ * are DECLARED NON-SECRET (`exec/param-secrets.ts` explains why: they are in
361
+ * every unit prompt and in the input hash, so they cannot be redacted);
362
+ * secrets belong in `env:` bindings, which reach the child by name.
363
+ *
364
+ * SIZE is not bounded here, on purpose. A workflow artifact has no bound
365
+ * comparable to an OS environment entry, so `AKM_INPUTS` (and `AKM_PARAMS` /
366
+ * `AKM_ITEM`) can serialize past what `execve` accepts and make PROCESS CREATION
367
+ * fail with a bare `E2BIG`. The check belongs at the spawn boundary, where the
368
+ * failure can be journaled as a unit outcome with an actionable message naming
369
+ * the variable: `checkExecContextSize` in `exec/exec-unit.ts`, against
370
+ * `execContextLimits()` for the platform the run is actually on (a Linux run is
371
+ * checked against Linux's ceiling, not against the smallest supported one).
372
+ * This function stays PURE and total.
373
+ */
374
+ function buildExecContextEnv(args) {
375
+ const { ctx, unitId, item, index } = args;
376
+ // The step-constant payloads were serialized once by `computeStepWorkList`;
377
+ // only the item and the ids vary per unit.
378
+ const env = {
379
+ AKM_RUN_ID: ctx.input.runId,
380
+ AKM_STEP_ID: ctx.plan.stepId,
381
+ AKM_UNIT_ID: unitId,
382
+ AKM_PARAMS: ctx.execParamsJson ?? "{}",
297
383
  };
384
+ if (ctx.isFanOut) {
385
+ env.AKM_ITEM = canonicalJson(item) ?? "null";
386
+ env.AKM_ITEM_INDEX = String(index);
387
+ }
388
+ if (ctx.execInputsJson !== undefined)
389
+ env.AKM_INPUTS = ctx.execInputsJson;
390
+ return env;
391
+ }
392
+ /**
393
+ * The canonical dispatch-input envelope (reviewer finding #1). Every field here
394
+ * is a PLAN-FROZEN input that changes what the backend is actually asked to do,
395
+ * so a completed unit is reused ONLY when all of them match; a change to any of
396
+ * them re-dispatches. `canonicalJsonString` sorts keys recursively, so the
397
+ * preimage is order-independent, and this is the ONE place a unit's inputHash
398
+ * is computed (every caller goes through {@link computeStepWorkList}) — a hash
399
+ * that is byte-identical across a fresh run and a resume is structural, not
400
+ * coincidental.
401
+ *
402
+ * Unit identity (workflow-format-unification, spec §2.3/§4) hashes the FROZEN
403
+ * TEMPLATE BYTES (`template.instructions`, byte-exact, never an
404
+ * instantiated/interpolated string) + the canonical item JSON + the
405
+ * declared-input artifacts + the params snapshot — instead of a
406
+ * resolved/spliced prompt string, since there is no more splicing.
407
+ *
408
+ * Included beyond the R4 baseline (template/runner/model/schema): resolved
409
+ * timeoutMs (via `invocation`/`exec`), the env asset ref NAMES, and isolation —
410
+ * each reaches dispatch and a changed one yields a materially different call.
411
+ * `env` carries NAMES ONLY, never resolved values: hashing a resolved secret
412
+ * would leak it into a durable hash oracle and would spuriously re-dispatch on
413
+ * every secret rotation. `retry`/`onError` are DELIBERATELY excluded — they
414
+ * govern failed-unit re-dispatch and step-level failure reduction, not a
415
+ * COMPLETED unit's inputs/output, so a completed row stays valid across policy
416
+ * changes.
417
+ *
418
+ * `gateFeedback` IS included (conditionally, so a no-feedback unit's preimage
419
+ * is byte-identical to before): it is appended to the prompt by
420
+ * `buildUnitPrompt`, so a gate loop's retry is materially a different ask than
421
+ * the rejected attempt. Replay-safe: feedback is re-derived from the journaled
422
+ * gate decision, so a resumed retry re-hashes identically.
423
+ *
424
+ * `exec` is likewise conditional, which is what made the exec unit ADDITIVE:
425
+ * an exec unit has no engine and no invocation, and its frozen spec (argv, cwd,
426
+ * resolved timeout) is exactly what makes its dispatch different — so it gets
427
+ * its own key, present only on exec units, and `hashVersion` stays 4. Every
428
+ * previously-frozen llm/agent/sdk unit therefore hashes byte-identically to
429
+ * before, and no in-flight run re-dispatches work it already completed.
430
+ *
431
+ * Ambient config is DELIBERATELY excluded — the model-alias table, the resolved
432
+ * backend/connection, and the working directory (`ctx.workDir` /
433
+ * `process.cwd()`) are NOT plan-frozen. The frozen plan is the identity
434
+ * boundary (redesign addendum determinism bar #2): config drift under an
435
+ * in-flight run is out of scope by design.
436
+ */
437
+ function computeUnitInputHash(ctx, item) {
438
+ const dispatch = ctx.frozenEngine ? transitiveDispatchSnapshot(ctx.frozenEngine, ctx.input.engines ?? {}) : null;
439
+ return createHash("sha256")
440
+ .update(canonicalJsonString({
441
+ hashVersion: 4,
442
+ template: ctx.template.instructions,
443
+ item: ctx.isFanOut ? (item ?? null) : null,
444
+ inputs: ctx.resolvedInputs,
445
+ params: ctx.input.params,
446
+ dispatch,
447
+ invocation: ctx.frozenInvocation ?? null,
448
+ ...(ctx.frozenExec ? { exec: ctx.frozenExec } : {}),
449
+ schema: ctx.template.schema ?? null,
450
+ env: ctx.template.env ?? null,
451
+ isolation: ctx.template.isolation ?? "none",
452
+ ...(ctx.input.gateFeedback ? { gateFeedback: ctx.input.gateFeedback } : {}),
453
+ }))
454
+ .digest("hex");
298
455
  }
299
456
  /**
300
457
  * Assemble the final prompt: engine preamble (run params + item/index +
@@ -365,7 +522,7 @@ function transitiveDispatchSnapshot(engine, engines) {
365
522
  }
366
523
  // ── Step outputs + reducers + typed artifacts ────────────────────────────────
367
524
  /**
368
- * The value `${{ steps.<id>.output }}` resolves to for ONE step, given that
525
+ * The value a `steps.<id>.output` reference resolves to for ONE step, given that
369
526
  * step's journaled evidence: an engine-executed step carries a promoted
370
527
  * ARTIFACT under `evidence.output` (solo unit result/text, collect array, or
371
528
  * vote winner); evidence without an `output` key (manually-completed steps) is
@@ -383,6 +540,43 @@ export function stepOutputsFromEvidence(evidence) {
383
540
  }
384
541
  return outputs;
385
542
  }
543
+ /** The step's dispatch template — the map template for a fan-out, else the root unit. */
544
+ function stepTemplate(stepPlan) {
545
+ const root = stepPlan.root;
546
+ if (!root)
547
+ return undefined;
548
+ return root.kind === "map" ? root.template : root;
549
+ }
550
+ /**
551
+ * The step ids that ANOTHER step of the frozen plan can still read: the
552
+ * producers named by an `inputs[]` entry, a `map.over`, or a `route.input`.
553
+ * Those three fields are the WHOLE reference surface — instructions are never
554
+ * scanned (workflow-format-unification, spec §2.3) — so a step outside this set
555
+ * has no in-plan consumer and nothing needs to hold its artifact in memory once
556
+ * it is journaled.
557
+ *
558
+ * Derived from the plan alone: O(plan), independent of run state, and stable
559
+ * across the retry and gate loops (a retry re-opens one failed step, and a
560
+ * looping step has not advanced, so neither can turn an unreferenced producer
561
+ * into a referenced one mid-invocation).
562
+ */
563
+ export function referencedStepIds(plan) {
564
+ const referenced = new Set();
565
+ const note = (reference) => {
566
+ const parsed = parseReference(reference);
567
+ if (parsed.ok && parsed.expr.kind === "stepOutput")
568
+ referenced.add(parsed.expr.stepId);
569
+ };
570
+ for (const step of plan.steps) {
571
+ if (step.root?.kind === "map")
572
+ note(step.root.over);
573
+ for (const reference of stepTemplate(step)?.inputs ?? [])
574
+ note(reference);
575
+ if (step.route)
576
+ note(step.route.input);
577
+ }
578
+ return referenced;
579
+ }
386
580
  /**
387
581
  * Typed artifacts (addendum, R2): validate the promoted step artifact against
388
582
  * `IrStepPlan.outputSchema`. Returns the step-failure summary (validation
@@ -444,8 +638,8 @@ export function buildEvidence(units, reducer, isFanOut) {
444
638
  ...(u.failureReason ? { failureReason: u.failureReason } : {}),
445
639
  });
446
640
  const evidence = { units: collected, itemCount: units.length };
447
- // Promoted step artifact (`evidence.output`) — what `${{ steps.<id>.output }}`
448
- // resolves to (see projectStepOutput). Values are UNCLIPPED.
641
+ // Promoted step artifact (`evidence.output`) — what a `steps.<id>.output`
642
+ // reference resolves to (see projectStepOutput). Values are UNCLIPPED.
449
643
  if (reducer === "vote") {
450
644
  evidence.output = null;
451
645
  }
@@ -473,12 +667,42 @@ export function buildEvidence(units, reducer, isFanOut) {
473
667
  evidence.voteError = `Vote reducer tied at ${ranked[0].count} vote(s) — no majority.`;
474
668
  }
475
669
  else {
476
- evidence.vote = { winner: ranked[0].value, votes: ranked[0].count, total: units.length };
477
- evidence.output = ranked[0].value;
670
+ const winner = ranked[0].value;
671
+ evidence.vote = { winner, votes: ranked[0].count, total: units.length };
672
+ // An empty free-text unit normalizes to absent text, so its vote value is
673
+ // `undefined`. Assigning that to `evidence.output` made the key vanish
674
+ // under JSON serialization: a LIVE run then saw `output` absent (and fell
675
+ // back to the whole evidence envelope), while a RESUMED run rehydrated the
676
+ // same step from the journal and produced a different artifact — with the
677
+ // raw envelope exposed as `steps.<id>.output`. Normalize to an explicit
678
+ // empty string so both paths promote the same value.
679
+ evidence.output = winner === undefined ? "" : winner;
478
680
  }
479
681
  }
480
682
  return evidence;
481
683
  }
684
+ /**
685
+ * The FIRST failed unit's diagnostic, appended to the step summary.
686
+ *
687
+ * A failure reason alone is not a diagnosis. `non_zero_exit` says a command
688
+ * failed; for an exec unit the reason it failed is on stderr, and the summary is
689
+ * what `akm workflow run` prints and what the failed step row keeps as its
690
+ * notes. Bounded on both axes: ONE unit (a 10 000-wide fan-out must not turn its
691
+ * summary into a log) clipped to {@link WORKFLOW_UNIT_DIAGNOSTIC_CLIP} — the same
692
+ * bound the journal and `status --units` use.
693
+ *
694
+ * Reproducible on both surfaces: a live dispatch carries the diagnostic as
695
+ * `error`; a unit rehydrated from the journal carries it as `text` (the column
696
+ * `journaledUnitResultJson` wrote it to), so the fallback below composes the
697
+ * SAME summary from either.
698
+ */
699
+ function firstFailureDiagnostic(failed) {
700
+ const first = failed.find((u) => (u.error ?? u.text)?.trim());
701
+ if (!first)
702
+ return "";
703
+ const diagnostic = (first.error ?? first.text ?? "").trim();
704
+ return ` First failure diagnostic (${first.unitId}): ${clip(diagnostic, WORKFLOW_UNIT_DIAGNOSTIC_CLIP)}`;
705
+ }
482
706
  /**
483
707
  * Reduce a step's terminal unit outcomes into the promoted artifact + step
484
708
  * verdict — the shared semantics between native dispatch and the report path.
@@ -501,6 +725,7 @@ export function reduceStepOutcomes(plan, reducer, isFanOut, onError, units) {
501
725
  .map((u) => `${u.unitId} (${u.failureReason ?? "error"})`)
502
726
  .join(", ")}.`
503
727
  : "") +
728
+ firstFailureDiagnostic(failed) +
504
729
  reducerNote;
505
730
  let artifactSchemaFailure = false;
506
731
  if (ok) {
@@ -544,8 +769,11 @@ export function reduceEmptyStep(plan, reducer) {
544
769
  * failed-row branch keeps the mapping TOTAL, so any reduction driven off the
545
770
  * journal yields the same outcome the live dispatch produced. A completed row's
546
771
  * text unit journals its output as a JSON string; a schema unit journals the
547
- * validated structure. A failed row carries its `failure_reason`; any journaled
548
- * text is surfaced too.
772
+ * validated structure. A failed row carries its `failure_reason` plus whatever
773
+ * `journaledUnitResultJson` (native-executor.ts) wrote to `result_json` —
774
+ * surfaced as `text`, its historical meaning. {@link firstFailureDiagnostic} is
775
+ * the one consumer that wants it as a diagnostic and falls back to `text`, so
776
+ * the step summary stays the same on both surfaces.
549
777
  */
550
778
  export function unitOutcomeFromRow(unitId, row, hasSchema) {
551
779
  let parsed;
@@ -607,6 +835,29 @@ function sortKeys(value) {
607
835
  export function gateUnitId(stepId, loop) {
608
836
  return `${stepId}.gate:l${loop}`;
609
837
  }
838
+ /**
839
+ * How many times a step's subgraph may run under its completion gate — the
840
+ * bound the engine loop walks and the one `loopsRemaining` is derived from.
841
+ *
842
+ * A gate loop only earns its re-dispatch when the subgraph can ANSWER the
843
+ * judge: an engine unit reads the rejection feedback in its prompt and produces
844
+ * different work. An `exec` unit cannot. Its argv is frozen and never
845
+ * interpolated, {@link buildExecContextEnv} exposes no feedback variable, and
846
+ * the default dispatcher drops feedback for exec — so a second loop re-runs the
847
+ * BYTE-IDENTICAL command for a verdict that cannot change, which for a deploy /
848
+ * publish / migrate command means performing the side effect twice. The same
849
+ * reasoning already pins exec structured output to a single attempt and makes
850
+ * `exec_capture_incomplete` non-retryable (`native-executor.ts`).
851
+ *
852
+ * So an exec step's gate still EVALUATES — the verdict can still fail the step
853
+ * — but it never loops: a rejection lands on the gate-exhausted terminal
854
+ * instead of re-dispatching. An authored `gate.max_loops` on an engine step is
855
+ * untouched.
856
+ */
857
+ export function effectiveGateMaxLoops(stepPlan) {
858
+ const declared = Math.max(1, stepPlan.gate.maxLoops ?? 1);
859
+ return stepTemplate(stepPlan)?.exec ? 1 : declared;
860
+ }
610
861
  /**
611
862
  * The gate loop the engine is about to (re-)run for an ACTIVE step, derived
612
863
  * purely from the journal: one past the highest journaled loop that REJECTED
@@ -711,7 +962,7 @@ export async function journalGateEvaluationStart(gate) {
711
962
  runId: gate.runId,
712
963
  unitId,
713
964
  stepId: gate.stepId,
714
- nodeId: `${gate.stepId}.gate`,
965
+ nodeId: gateNodeId(gate.stepId),
715
966
  parentUnitId: null,
716
967
  // Marks the row as a judge call, NOT a dispatch: the budget/lifetime
717
968
  // seed in `driveRun` skips these so resume accounting matches live.
@@ -731,15 +982,19 @@ export async function journalGateEvaluationStart(gate) {
731
982
  /**
732
983
  * Finish the gate-evaluation unit row with the verdict as observed from the
733
984
  * completion outcome: a rejection journals `{ complete: false, missing,
734
- * feedback }`; a pass journals `{ complete: true, missing: [] }`; a judge that
735
- * threw journals a failed row with the synthesized fail-closed rejection.
985
+ * feedback }`; a pass journals `{ complete: true, missing: [] }`. An ERRORED
986
+ * evaluation (thrown judge, malformed verdict, completion failure after the
987
+ * judge ran) journals a failed row with NO verdict (`result_json` NULL) —
988
+ * `errored` takes precedence over any synthesized fail-closed rejection, so
989
+ * `activeGateLoop`/`recoverGateFeedback` never mistake a judge outage for an
990
+ * honest rejection and burn a gate loop on resume.
736
991
  */
737
992
  export async function journalGateEvaluationFinish(gate, errored, rejection) {
738
993
  const unitId = gateUnitId(gate.stepId, gate.loop);
739
- const verdict = rejection
740
- ? { complete: false, missing: rejection.missing, feedback: rejection.feedback }
741
- : errored
742
- ? null
994
+ const verdict = errored
995
+ ? null
996
+ : rejection
997
+ ? { complete: false, missing: rejection.missing, feedback: rejection.feedback }
743
998
  : { complete: true, missing: [] };
744
999
  const status = errored ? "failed" : "completed";
745
1000
  await enqueueUnitWrite(() => withWorkflowRunsRepo((repo) => repo.finishUnit({
@@ -758,12 +1013,13 @@ export async function journalGateEvaluationFinish(gate, errored, rejection) {
758
1013
  });
759
1014
  }
760
1015
  /**
761
- * Resolve a route's input (a single whole-value `${{ }}` reference) and pick
762
- * the branch. No ambient key search. Only primitive values route; the
763
- * comparison is exact string equality against the declared `when:` matches.
1016
+ * Resolve a route's input (a single whole-value reference string `params.x` or
1017
+ * `steps.<id>.output…`, with no `${{ }}` delimiters) and pick the branch. No
1018
+ * ambient key search. Only primitive values route; the comparison is exact
1019
+ * string equality against the declared `when:` matches.
764
1020
  */
765
1021
  export function evaluateRoute(route, scope) {
766
- const resolved = resolveReferenceString(route.input, scope);
1022
+ const resolved = resolveStepReference(route.input, scope);
767
1023
  if (!resolved.ok) {
768
1024
  return { ok: false, error: `route input ${route.input} failed to resolve: ${resolved.error.message}` };
769
1025
  }
@@ -906,6 +1162,49 @@ export function seedJournaledRouteDecisions(plan, state, routeSelected, routeUns
906
1162
  applyRouteDecision(stepPlan.route, stepPlan.stepId, selected, routeSelected, routeUnselected);
907
1163
  }
908
1164
  }
1165
+ /**
1166
+ * The blocked-step notes for a verifier-infrastructure failure (bug: judge
1167
+ * outage must not burn the gate budget). Shared by every judge-failure path —
1168
+ * missing judge, unresolvable frozen judge, thrown judge call, malformed
1169
+ * verdict — so the resume instruction is worded once.
1170
+ */
1171
+ function judgeFailureNotes(runId, stepId, cause) {
1172
+ return (`Step "${stepId}" could not be verified: ${cause}. ` +
1173
+ `This is a verification-judge failure, not a verdict — no gate loop was consumed and the step's ` +
1174
+ `journaled units are preserved. Fix the verifier configuration or service, then run ` +
1175
+ `\`akm workflow resume ${runId}\` to re-evaluate the gate against the existing results ` +
1176
+ `without re-dispatching units.`);
1177
+ }
1178
+ /**
1179
+ * Complete a step `blocked` for a verifier-INFRASTRUCTURE failure and return
1180
+ * the notes written. The ONE implementation for both judge-failure paths — the
1181
+ * engine's pre-dispatch judge resolution (`run-workflow.ts`) and this module's
1182
+ * post-execution gate — so the wording, the blocked status, and the evidence
1183
+ * decision cannot drift between them.
1184
+ */
1185
+ export async function blockStepForJudgeFailure(input) {
1186
+ const notes = judgeFailureNotes(input.runId, input.stepId, input.cause);
1187
+ await completeWorkflowStep({
1188
+ runId: input.runId,
1189
+ stepId: input.stepId,
1190
+ status: "blocked",
1191
+ notes,
1192
+ ...(input.evidence !== undefined ? { evidence: input.evidence } : {}),
1193
+ ...(input.leaseHolder !== undefined ? { leaseHolder: input.leaseHolder } : {}),
1194
+ });
1195
+ return notes;
1196
+ }
1197
+ /** The finalize path's blocked write: the executed units' evidence is preserved. */
1198
+ async function blockFinalizedStep(input, cause) {
1199
+ const summary = await blockStepForJudgeFailure({
1200
+ runId: input.runId,
1201
+ stepId: input.stepId,
1202
+ cause,
1203
+ evidence: input.result.evidence,
1204
+ ...(input.leaseHolder !== undefined ? { leaseHolder: input.leaseHolder } : {}),
1205
+ });
1206
+ return { kind: "judge-failed", summary };
1207
+ }
909
1208
  /**
910
1209
  * Perform ONE completion attempt for an executed step:
911
1210
  *
@@ -919,7 +1218,11 @@ export function seedJournaledRouteDecisions(plan, state, routeSelected, routeUns
919
1218
  * the step declares criteria), journaled as a `<stepId>.gate:l<loop>` unit
920
1219
  * row; a rejection with loops remaining returns `retry` (feedback threaded
921
1220
  * into the next loop), a rejection with none returns `gate-exhausted`, a pass
922
- * returns `advanced`.
1221
+ * returns `advanced`;
1222
+ * - a judge INFRASTRUCTURE failure (missing judge, thrown judge call, or a
1223
+ * malformed verdict) is NOT a verdict: it consumes no gate loop and blocks
1224
+ * the step for `akm workflow resume` (`judge-failed`) instead of feeding
1225
+ * the bounded loop's re-dispatch.
923
1226
  *
924
1227
  * Every DB advance goes through {@link completeWorkflowStep} — the gate spine is
925
1228
  * never bypassed. Behavior is byte-identical to the engine's former inline loop
@@ -949,6 +1252,13 @@ export async function finalizeExecutedStep(input) {
949
1252
  // A frozen plan either supplies its judge at the dispatch boundary or has no
950
1253
  // judge. Re-selecting defaults here would let config drift change a run.
951
1254
  const innerJudge = input.summaryJudge ?? null;
1255
+ // A criteria-bearing step with NO judge cannot be verified at all — that is
1256
+ // verifier infrastructure failure, never a silent bypass and never an honest
1257
+ // rejection: block for resume without invoking the gate (no loop consumed).
1258
+ if (completionCriteria.some((c) => c.trim().length > 0) && !innerJudge) {
1259
+ return blockFinalizedStep(input, "this step declares completion criteria but no verification judge is available " +
1260
+ "(the frozen plan resolves no judge — set workflow.judgeEngine, or restore the judge configuration)");
1261
+ }
952
1262
  // Route evaluation BEFORE completion: an unroutable value is an
953
1263
  // authoring/config failure that must fail the step deterministically.
954
1264
  let summaryOverride;
@@ -980,22 +1290,17 @@ export async function finalizeExecutedStep(input) {
980
1290
  // Journal engine-driven judge calls as unit rows. With no criteria there is
981
1291
  // no judge invocation or row; a criteria-bearing plan without a judge is a
982
1292
  // configuration error rather than a silent bypass.
983
- const frozenGate = innerJudge
984
- ? await withWorkflowRunsRepo((repo) => {
985
- const row = repo.getRunById(runId);
986
- if (!row)
987
- throw new UsageError(`Workflow run ${runId} was not found.`);
988
- const plan = requireExecutableWorkflowPlan(row);
989
- const invocation = plan.steps.find((step) => step.stepId === stepId)?.gate.judge ?? null;
990
- return invocation ? { invocation, engine: plan.execution?.engines[invocation.engine] ?? null } : null;
991
- })
992
- : null;
993
- const gateInvocation = frozenGate?.invocation ?? null;
1293
+ const gateInvocation = innerJudge ? (stepPlan.gate.judge ?? null) : null;
1294
+ const gateEngine = gateInvocation ? (input.engines?.[gateInvocation.engine] ?? null) : null;
994
1295
  let gateUnit;
995
- const judgeState = { invoked: false, errored: false };
1296
+ // `judgeFailure` records a verifier INFRASTRUCTURE failure observed during
1297
+ // the judge call — a throw (transport/service error) or a response that is
1298
+ // not a well-formed verdict (same parser as validateStepSummary, so the
1299
+ // fail-closed rejection it synthesizes is recognizably NOT an honest verdict
1300
+ // here).
1301
+ let judgeFailure;
996
1302
  const summaryJudge = innerJudge
997
1303
  ? async (prompt) => {
998
- judgeState.invoked = true;
999
1304
  if (gateInvocation) {
1000
1305
  gateUnit = {
1001
1306
  runId,
@@ -1003,11 +1308,12 @@ export async function finalizeExecutedStep(input) {
1003
1308
  stepId,
1004
1309
  loop: gateLoop,
1005
1310
  invocation: gateInvocation,
1006
- runner: frozenGate?.engine?.kind === "agent" ? frozenGate.engine.runnerKind : "llm",
1311
+ // No engine snapshot means the built-in llm judge.
1312
+ runner: gateEngine ? engineRuntimeKind(gateEngine) : "llm",
1007
1313
  inputHash: createHash("sha256")
1008
1314
  .update(canonicalJsonString({
1009
1315
  hashVersion: 3,
1010
- dispatch: frozenGate?.engine ?? null,
1316
+ dispatch: gateEngine,
1011
1317
  invocation: gateInvocation,
1012
1318
  prompt,
1013
1319
  }))
@@ -1015,24 +1321,45 @@ export async function finalizeExecutedStep(input) {
1015
1321
  };
1016
1322
  await journalGateEvaluationStart(gateUnit);
1017
1323
  }
1324
+ // The judge dispatch must describe the SAME thing the gate row does, so
1325
+ // the row identity is threaded down to the dispatcher from right here —
1326
+ // the one place that computes it — instead of being re-derived (or, as
1327
+ // before, synthesized as a constant "gate"). Both ids come from the same
1328
+ // helpers `journalGateEvaluationStart/Finish` use.
1329
+ const identity = { runId, stepId, unitId: gateUnitId(stepId, gateLoop) };
1330
+ let raw;
1018
1331
  try {
1019
- return await innerJudge(prompt);
1332
+ raw = await innerJudge(prompt, identity);
1020
1333
  }
1021
1334
  catch (err) {
1022
- judgeState.errored = true;
1335
+ const detail = err instanceof Error && err.message ? ` (${err.message})` : "";
1336
+ judgeFailure = `the verification judge failed${detail}`;
1023
1337
  throw err;
1024
1338
  }
1339
+ if (parseJudgeVerdict(raw) === undefined) {
1340
+ judgeFailure =
1341
+ "the verification judge responded with a malformed verdict instead of the required JSON result";
1342
+ }
1343
+ return raw;
1025
1344
  }
1026
1345
  : null;
1027
1346
  // Reviewer #6: once the judge is invoked, its gate row is journaled `running`
1028
1347
  // (journalGateEvaluationStart) and MUST be finished on every exit. The
1029
1348
  // already-fixed window is the judge itself throwing (caught inside
1030
- // validateStepSummary — `judgeState.errored` records it). The remaining
1349
+ // validateStepSummary — `judgeFailure` records it). The remaining
1031
1350
  // window is `completeWorkflowStep` throwing AFTER the judge ran — a stolen
1032
1351
  // lease, a concurrent state change, a DB error — which would otherwise skip the
1033
1352
  // finish and strand the gate row in `running`. Finish it as an errored row (the
1034
1353
  // observed outcome: the completion did not succeed), then re-propagate.
1354
+ //
1355
+ // The signal handed down is the DISPATCH signal (the judge call runs under
1356
+ // it), not just the caller's: the interruption guard inside the completion
1357
+ // path rethrows an abort instead of classifying it as a judge outage, and a
1358
+ // lost lease aborting mid-judge is an interruption — recording it as a
1359
+ // verifier failure would blame infrastructure and durably block a step whose
1360
+ // gate simply never finished evaluating.
1035
1361
  let completion;
1362
+ const completionSignal = input.dispatchSignal ?? input.signal;
1036
1363
  try {
1037
1364
  completion = await completeWorkflowStep({
1038
1365
  runId,
@@ -1041,7 +1368,7 @@ export async function finalizeExecutedStep(input) {
1041
1368
  summary,
1042
1369
  evidence: result.evidence,
1043
1370
  summaryJudge,
1044
- ...(input.signal ? { signal: input.signal } : {}),
1371
+ ...(completionSignal ? { signal: completionSignal } : {}),
1045
1372
  ...lease,
1046
1373
  });
1047
1374
  }
@@ -1051,8 +1378,18 @@ export async function finalizeExecutedStep(input) {
1051
1378
  throw err;
1052
1379
  }
1053
1380
  const rejection = "ok" in completion && completion.ok === false ? completion : undefined;
1381
+ const judgeFailed = judgeFailure !== undefined;
1054
1382
  if (gateUnit) {
1055
- await journalGateEvaluationFinish(gateUnit, judgeState.errored, rejection);
1383
+ // An infrastructure failure journals an ERRORED gate row (no verdict)
1384
+ // never the synthesized fail-closed rejection, which would read as an
1385
+ // honest rejection to activeGateLoop/recoverGateFeedback on resume.
1386
+ await journalGateEvaluationFinish(gateUnit, judgeFailed, rejection);
1387
+ }
1388
+ // Judge infrastructure failure: the fail-closed rejection is synthetic, not a
1389
+ // verdict. Consume NO gate loop; block the step (and therefore the run) so
1390
+ // `akm workflow resume` retries the gate over the journaled units.
1391
+ if (rejection && judgeFailed) {
1392
+ return blockFinalizedStep(input, judgeFailure ?? "the verification judge failed");
1056
1393
  }
1057
1394
  if (!rejection) {
1058
1395
  return { kind: "advanced", ...(summaryOverride !== undefined ? { summaryOverride } : {}) };
@@ -1074,6 +1411,8 @@ function safeJson(value) {
1074
1411
  return "null";
1075
1412
  }
1076
1413
  }
1077
- function clip(text, max) {
1078
- return text.length > max ? `${text.slice(0, max)}…` : text;
1079
- }
1414
+ // `clip` lives with the bounds it applies (`workflows/resource-limits.ts`) so
1415
+ // the write side here and the read side in `runtime/runs.ts` which cannot
1416
+ // import this module — truncate through one implementation. Re-exported
1417
+ // because this module is where the dispatch path already reaches for it.
1418
+ export { clip } from "../resource-limits.js";