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.
- package/CHANGELOG.md +724 -0
- package/README.md +28 -63
- package/STABILITY.md +4 -2
- package/dist/cli/parse-args.js +7 -1
- package/dist/commands/agent/contribute-cli.js +1 -1
- package/dist/commands/env/child-env.js +14 -0
- package/dist/commands/feedback-cli.js +7 -1
- package/dist/commands/health/llm-usage.js +2 -1
- package/dist/commands/health/surfaces.js +4 -77
- package/dist/commands/health.js +65 -11
- package/dist/commands/improve/distill/quality-gate.js +6 -1
- package/dist/commands/improve/eligibility.js +7 -1
- package/dist/commands/improve/eval-cases.js +2 -0
- package/dist/commands/improve/improve.js +126 -10
- package/dist/commands/improve/locks.js +7 -0
- package/dist/commands/improve/memory/memory-improve.js +9 -0
- package/dist/commands/improve/run-context.js +5 -0
- package/dist/commands/improve/session-asset.js +4 -0
- package/dist/commands/lint/base-linter.js +31 -7
- package/dist/commands/lint/index.js +205 -51
- package/dist/commands/lint/types.js +22 -1
- package/dist/commands/proposal/repository.js +17 -1
- package/dist/commands/sources/add-cli.js +8 -2
- package/dist/commands/sources/info.js +12 -2
- package/dist/commands/sources/installed-stashes.js +6 -1
- package/dist/commands/sources/migration-help.js +12 -3
- package/dist/commands/sources/self-update.js +9 -1
- package/dist/commands/tasks/tasks.js +8 -2
- package/dist/commands/workflow-cli.js +17 -11
- package/dist/core/abort-deadline.js +28 -0
- package/dist/core/adapter/adapters/agent-skills-adapter.js +83 -5
- package/dist/core/adapter/adapters/akm-adapter.js +13 -10
- package/dist/core/adapter/adapters/akm-lint.js +78 -22
- package/dist/core/adapter/adapters/akm-task-adapter.js +43 -20
- package/dist/core/adapter/adapters/dotenv-adapter.js +21 -0
- package/dist/core/adapter/adapters/tool-dir-shared.js +5 -3
- package/dist/core/asset/frontmatter.js +10 -1
- package/dist/core/common.js +147 -9
- package/dist/core/concurrent.js +32 -0
- package/dist/core/config/config-io.js +5 -45
- package/dist/core/config/schema/engines.js +14 -3
- package/dist/core/config/schema/workflow.js +11 -0
- package/dist/core/errors.js +25 -0
- package/dist/core/events.js +30 -24
- package/dist/core/extra-params.js +11 -0
- package/dist/core/file-lock.js +7 -1
- package/dist/core/fs-txn.js +15 -2
- package/dist/core/improve-result.js +5 -0
- package/dist/core/json-schema.js +344 -9
- package/dist/core/loopback.js +89 -0
- package/dist/core/migration-operation.js +17 -2
- package/dist/core/path-access.js +107 -0
- package/dist/core/paths.js +16 -2
- package/dist/core/redaction.js +86 -18
- package/dist/core/spawn-env.js +234 -0
- package/dist/core/state-db-scope.js +134 -0
- package/dist/core/state-db.js +1 -0
- package/dist/core/subprocess.js +181 -37
- package/dist/core/write-provenance.js +85 -0
- package/dist/core/write-source.js +33 -2
- package/dist/indexer/db/graph-db.js +17 -6
- package/dist/indexer/ensure-index.js +10 -3
- package/dist/indexer/index-written-assets.js +17 -2
- package/dist/indexer/indexer.js +86 -21
- package/dist/indexer/passes/memory-inference.js +4 -0
- package/dist/indexer/search/db-search.js +25 -17
- package/dist/indexer/walk/walker.js +6 -1
- package/dist/integrations/agent/detect.js +13 -1
- package/dist/integrations/agent/engine-resolution.js +24 -11
- package/dist/integrations/agent/model-aliases.js +1 -1
- package/dist/integrations/agent/profiles.js +9 -1
- package/dist/integrations/agent/spawn.js +15 -87
- package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +21 -0
- package/dist/integrations/lockfile.js +55 -2
- package/dist/llm/client.js +14 -19
- package/dist/llm/embedder.js +23 -3
- package/dist/llm/embedders/remote.js +27 -2
- package/dist/output/html-render.js +40 -1
- package/dist/output/text/lint-format.js +17 -4
- package/dist/runtime.js +23 -1
- package/dist/scripts/akm-migrate-node.js +1714 -836
- package/dist/scripts/akm-migrate.js +1682 -804
- package/dist/setup/setup.js +22 -7
- package/dist/sources/providers/git-install.js +25 -2
- package/dist/sources/providers/git-stash.js +19 -0
- package/dist/sources/providers/git.js +1 -1
- package/dist/sources/snapshot-fetchers/content-extract.js +63 -1
- package/dist/sources/snapshot-fetchers/website-ingest.js +126 -20
- package/dist/storage/database.js +71 -7
- package/dist/storage/engines/sqlite-migrations.js +61 -2
- package/dist/storage/managed-db.js +19 -0
- package/dist/storage/repositories/index-connection.js +39 -4
- package/dist/storage/repositories/index-entries-repository.js +6 -1
- package/dist/storage/repositories/index-meta-repository.js +11 -0
- package/dist/storage/repositories/index-schema.js +17 -2
- package/dist/storage/repositories/index-vec-repository.js +43 -5
- package/dist/storage/repositories/workflow-runs-repository.js +66 -13
- package/dist/storage/sqlite-pragmas.js +12 -1
- package/dist/tasks/log-redaction.js +156 -0
- package/dist/tasks/parser.js +82 -5
- package/dist/tasks/runner.js +222 -17
- package/dist/tasks/scheduler-invocation.js +19 -0
- package/dist/tasks/schema.js +86 -1
- package/dist/text-import-hook.mjs +1 -1
- package/dist/workflows/concurrency-policy.js +95 -1
- package/dist/workflows/exec/dispatch-redaction.js +114 -0
- package/dist/workflows/exec/exec-unit.js +542 -0
- package/dist/workflows/exec/frozen-judge.js +114 -42
- package/dist/workflows/exec/native-executor.js +465 -238
- package/dist/workflows/exec/param-secrets.js +4 -3
- package/dist/workflows/exec/run-workflow.js +424 -219
- package/dist/workflows/exec/step-work.js +506 -167
- package/dist/workflows/exec/unit-dispatch.js +31 -1
- package/dist/workflows/exec/unit-writer.js +53 -13
- package/dist/workflows/exec/worktree.js +454 -41
- package/dist/workflows/ir/compile.js +26 -2
- package/dist/workflows/ir/freeze.js +82 -15
- package/dist/workflows/ir/schema.js +105 -20
- package/dist/workflows/parser.js +242 -19
- package/dist/workflows/program/schema.js +24 -0
- package/dist/workflows/renderer.js +32 -4
- package/dist/workflows/resource-limits.js +182 -0
- package/dist/workflows/runtime/runs.js +146 -6
- package/dist/workflows/validate-summary.js +17 -2
- package/docs/README.md +74 -32
- package/docs/migration/release-notes/0.9.0.md +2 -1
- package/docs/migration/v0.7-to-v0.8.md +2 -1
- package/docs/migration/v0.8-to-v0.9.md +3 -1
- package/docs/reference/README.md +11 -4
- package/docs/reference/bundle-types.md +19 -0
- package/docs/reference/cli.md +105 -16
- package/docs/reference/configuration.md +15 -2
- package/docs/reference/data-and-telemetry.md +30 -10
- package/docs/reference/supported-formats.md +50 -0
- package/docs/reference/workflow-schema.md +1014 -0
- package/docs/reference/workflows.md +37 -633
- package/package.json +13 -6
- package/schemas/akm-config.json +18 -5
- package/schemas/akm-task.json +27 -5
- 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 {
|
|
49
|
-
import {
|
|
50
|
-
import {
|
|
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 }`.
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
296
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
477
|
-
evidence.
|
|
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
|
|
548
|
-
*
|
|
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:
|
|
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: [] }
|
|
735
|
-
*
|
|
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 =
|
|
740
|
-
?
|
|
741
|
-
:
|
|
742
|
-
?
|
|
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
|
|
762
|
-
*
|
|
763
|
-
*
|
|
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 =
|
|
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
|
|
984
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
1332
|
+
raw = await innerJudge(prompt, identity);
|
|
1020
1333
|
}
|
|
1021
1334
|
catch (err) {
|
|
1022
|
-
|
|
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 — `
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
1078
|
-
|
|
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";
|