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
|
@@ -31,6 +31,9 @@
|
|
|
31
31
|
* the one-shot case. A typed-artifact schema mismatch feeds the same loop
|
|
32
32
|
* (the validation errors are the feedback; no judge ran, so no gate unit is
|
|
33
33
|
* journaled for that attempt) — only the FINAL loop's mismatch fails the run.
|
|
34
|
+
* A step whose subgraph is an `exec` unit is judged but NEVER looped
|
|
35
|
+
* (`effectiveGateMaxLoops`): its argv cannot read the feedback, so a second
|
|
36
|
+
* loop would only re-run the identical side effect.
|
|
34
37
|
*
|
|
35
38
|
* Frozen plan (redesign addendum, R1): the plan graph is read from the run
|
|
36
39
|
* row (`plan_json`, persisted by `startWorkflowRun` under migration 006) with
|
|
@@ -63,7 +66,7 @@ import { randomUUID } from "node:crypto";
|
|
|
63
66
|
import { UsageError } from "../../core/errors.js";
|
|
64
67
|
import { withMaintenanceStartBarrierAsync } from "../../core/maintenance-barrier.js";
|
|
65
68
|
import { disposeDispatchResources } from "../../integrations/agent/runner-dispatch.js";
|
|
66
|
-
import { withWorkflowRunsRepo } from "../../storage/repositories/workflow-runs-repository.js";
|
|
69
|
+
import { withWorkflowRunsConnection, withWorkflowRunsRepo } from "../../storage/repositories/workflow-runs-repository.js";
|
|
67
70
|
import { assertRunParamsSatisfyPlan } from "../ir/params.js";
|
|
68
71
|
import { computePlanHash } from "../ir/plan-hash.js";
|
|
69
72
|
import { decodeWorkflowPlanV3 } from "../ir/schema.js";
|
|
@@ -76,7 +79,7 @@ import { defaultUnitDispatcher, executeStepPlan, } from "./native-executor.js";
|
|
|
76
79
|
// gate-evaluation journaling, and the whole step-completion path
|
|
77
80
|
// (`finalizeExecutedStep`) live in step-work.ts as ONE implementation, so the
|
|
78
81
|
// fresh-execution and resume paths cannot drift from each other.
|
|
79
|
-
import { activeGateLoop, cascadeSkippedRouter, finalizeExecutedStep, recoverGateFeedback, seedJournaledRouteDecisions, } from "./step-work.js";
|
|
82
|
+
import { activeGateLoop, blockStepForJudgeFailure, cascadeSkippedRouter, effectiveGateMaxLoops, finalizeExecutedStep, recoverGateFeedback, referencedStepIds, seedJournaledRouteDecisions, } from "./step-work.js";
|
|
80
83
|
export async function runWorkflowSteps(options) {
|
|
81
84
|
let target = options.target;
|
|
82
85
|
let params = options.params;
|
|
@@ -84,6 +87,12 @@ export async function runWorkflowSteps(options) {
|
|
|
84
87
|
let remainingRetries = options.maxRetries ?? 0;
|
|
85
88
|
let remainingSteps = options.maxSteps;
|
|
86
89
|
const executed = [];
|
|
90
|
+
let stepsProcessed = 0;
|
|
91
|
+
// Spans the retry loop: a retry re-opens only the ONE failed step, so every
|
|
92
|
+
// step this call already completed keeps handing its complete artifact
|
|
93
|
+
// downstream instead of falling back to the (possibly clipped) row. See the
|
|
94
|
+
// {@link driveRun} declaration.
|
|
95
|
+
const liveEvidence = new Map();
|
|
87
96
|
for (;;) {
|
|
88
97
|
const result = await runWorkflowAttempt({
|
|
89
98
|
...options,
|
|
@@ -91,14 +100,18 @@ export async function runWorkflowSteps(options) {
|
|
|
91
100
|
...(params !== undefined ? { params } : { params: undefined }),
|
|
92
101
|
...(parameterFlags !== undefined ? { parameterFlags } : { parameterFlags: undefined }),
|
|
93
102
|
...(remainingSteps !== undefined ? { maxSteps: remainingSteps } : { maxSteps: undefined }),
|
|
94
|
-
});
|
|
103
|
+
}, liveEvidence);
|
|
95
104
|
executed.push(...result.executed);
|
|
96
|
-
|
|
105
|
+
stepsProcessed += result.stepsProcessed;
|
|
106
|
+
const aggregate = { ...result, executed, stepsProcessed };
|
|
97
107
|
if (result.run.status !== "failed" || result.aborted || result.gateRejection || remainingRetries <= 0) {
|
|
98
108
|
return aggregate;
|
|
99
109
|
}
|
|
100
110
|
if (remainingSteps !== undefined) {
|
|
101
|
-
|
|
111
|
+
// The step budget is DISTINCT PROCESSED STEPS, not `executed` entries:
|
|
112
|
+
// gate loops of one step and route-skips must not shrink a retry's
|
|
113
|
+
// remaining budget (they never counted against maxSteps either).
|
|
114
|
+
remainingSteps -= result.stepsProcessed;
|
|
102
115
|
if (remainingSteps <= 0)
|
|
103
116
|
return aggregate;
|
|
104
117
|
}
|
|
@@ -109,7 +122,7 @@ export async function runWorkflowSteps(options) {
|
|
|
109
122
|
remainingRetries -= 1;
|
|
110
123
|
}
|
|
111
124
|
}
|
|
112
|
-
async function runWorkflowAttempt(options) {
|
|
125
|
+
async function runWorkflowAttempt(options, liveEvidence) {
|
|
113
126
|
const next = await getNextWorkflowStep(options.target, options.params, {
|
|
114
127
|
parameterFlags: options.parameterFlags,
|
|
115
128
|
});
|
|
@@ -158,7 +171,17 @@ async function runWorkflowAttempt(options) {
|
|
|
158
171
|
: undefined;
|
|
159
172
|
heartbeat?.start();
|
|
160
173
|
try {
|
|
161
|
-
|
|
174
|
+
// Run-wide state.db connection scope: `executeStepPlan` already opens one
|
|
175
|
+
// per STEP, so widening it to the whole drive loop additionally folds the
|
|
176
|
+
// spine writes (`completeWorkflowStep`), the per-step lease renewals, the
|
|
177
|
+
// journal reads, and `finalizeExecutedStep`'s gate-row journaling onto that
|
|
178
|
+
// one handle — `openStateDatabase` costs a maintenance-activity lockfile
|
|
179
|
+
// plus a read-only ledger preflight on EVERY call. Nesting is an idempotent
|
|
180
|
+
// join (`core/state-db-scope.ts`): the inner per-step scope reuses this
|
|
181
|
+
// handle and does not close it, and this scope's own `finally` closes on
|
|
182
|
+
// every exit path (return, throw, abort), after which escaped async work
|
|
183
|
+
// transparently falls back to opening its own connection.
|
|
184
|
+
const result = await withWorkflowRunsConnection(() => driveRun(options, next, leaseHolder, heartbeat, liveEvidence));
|
|
162
185
|
// Creation-time notices reach the caller only here: the run row has no
|
|
163
186
|
// warnings column, and a later invocation of the same run must stay silent
|
|
164
187
|
// about a decision it did not make. `driveRun` never sets `warnings`.
|
|
@@ -340,16 +363,321 @@ async function completedRunResult(runId) {
|
|
|
340
363
|
return {
|
|
341
364
|
run: doneState.run,
|
|
342
365
|
executed: [],
|
|
366
|
+
stepsProcessed: 0,
|
|
343
367
|
...(doneState.run.status === "completed" ? { done: true } : {}),
|
|
344
368
|
};
|
|
345
369
|
}
|
|
346
|
-
function workflowSummaryJudge(options, plan, stepPlan, signal) {
|
|
370
|
+
function workflowSummaryJudge(options, plan, stepPlan, signal, owner) {
|
|
347
371
|
if (options.summaryJudge !== undefined)
|
|
348
372
|
return options.summaryJudge;
|
|
349
|
-
|
|
373
|
+
// The judge dispatches under the REAL run/step identity; the per-loop gate row
|
|
374
|
+
// identity is threaded in per call by the completion path that journals it.
|
|
375
|
+
return frozenSummaryJudge(plan, stepPlan.gate.judge, signal, options.dispatcher ?? defaultUnitDispatcher, owner);
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Seed the lifetime unit cap AND the budget ceilings from the journal so
|
|
379
|
+
* both are truly per-RUN: a resumed or re-invoked run must not restart the
|
|
380
|
+
* runaway backstop — or a declared `budget` — at zero. Journal rows = past
|
|
381
|
+
* dispatch ATTEMPTS (counted against `budget.max_units`); their summed
|
|
382
|
+
* `tokens` column is the run's spend so far (counted against
|
|
383
|
+
* `budget.max_tokens`). The executor consumes both only on new dispatches
|
|
384
|
+
* (durable-row reuses are free), so a large partially-completed fan-out
|
|
385
|
+
* stays resumable.
|
|
386
|
+
*
|
|
387
|
+
* Gate-evaluation rows (`phase = "gate"`, journaled by the completion-gate
|
|
388
|
+
* judge) are EXCLUDED from the seed: the live path never consumes
|
|
389
|
+
* DispatchBudget for a judge call, so counting its journal row on resume
|
|
390
|
+
* would make an interrupted run hit `max_units` (and the lifetime cap)
|
|
391
|
+
* earlier than the identical uninterrupted run — a spurious hard failure
|
|
392
|
+
* that `on_error` cannot soften. The seed must reproduce exactly what live
|
|
393
|
+
* accounting would have accumulated.
|
|
394
|
+
*
|
|
395
|
+
* The seed sums each dispatch row's `attempts` (migration 008), NOT the row
|
|
396
|
+
* COUNT: a crash between a unit's dispatch and its finish leaves a `running`
|
|
397
|
+
* row that resume re-dispatches under the SAME content-derived unit_id, and
|
|
398
|
+
* `insertUnit` REPLACES that one row while bumping `attempts`. Counting rows
|
|
399
|
+
* would erase every prior crash-retried dispatch from budget/lifetime
|
|
400
|
+
* accounting, letting the run spend past its declared ceiling; summing
|
|
401
|
+
* `attempts` charges each dispatch exactly once.
|
|
402
|
+
*/
|
|
403
|
+
async function seedRunAccountingFromJournal(runId) {
|
|
404
|
+
const journaledUnits = await withWorkflowRunsRepo((repo) => repo.getUnitsForRun(runId));
|
|
405
|
+
const journaledDispatches = journaledUnits.filter((row) => row.phase !== GATE_EVALUATION_PHASE);
|
|
406
|
+
return {
|
|
407
|
+
unitsDispatched: journaledDispatches.reduce((sum, row) => sum + row.attempts, 0),
|
|
408
|
+
tokensUsed: journaledDispatches.reduce((sum, row) => sum + (row.tokens ?? 0), 0),
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* The decoded/hash-verified row plan is the sole execution authority. The
|
|
413
|
+
* loader seam may assert an expected plan in tests, but can never replace it.
|
|
414
|
+
*
|
|
415
|
+
* Reviewer #12: the journaled params row must still satisfy the frozen param
|
|
416
|
+
* schemas before the engine resolves any unit prompt from it, so
|
|
417
|
+
* schema-violating params — post-start corruption — fail loudly BEFORE any
|
|
418
|
+
* unit is dispatched (start already validated the params it stored).
|
|
419
|
+
*/
|
|
420
|
+
async function loadAuthoritativeRunPlan(options, next) {
|
|
421
|
+
const plan = await loadFrozenPlan(next.run.id);
|
|
422
|
+
if (options.loadPlan) {
|
|
423
|
+
const expected = decodeWorkflowPlanV3(await options.loadPlan(next.run.workflowRef));
|
|
424
|
+
if (computePlanHash(expected) !== computePlanHash(plan))
|
|
425
|
+
throw new UsageError(`Injected workflow plan for run ${next.run.id} differs from its frozen plan.`);
|
|
426
|
+
}
|
|
427
|
+
assertRunParamsSatisfyPlan(next.run.id, plan, next.run.params ?? {});
|
|
428
|
+
return plan;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Complete a branch target no completed router selected as `skipped` — no
|
|
432
|
+
* dispatch, no gate loop, and (per the `maxSteps` contract) no step consumed.
|
|
433
|
+
* Returns the re-read spine state so the caller can continue its walk.
|
|
434
|
+
*/
|
|
435
|
+
async function skipUnselectedRouteTarget(input) {
|
|
436
|
+
const { runId, stepId, stepPlan, skipInfo, routeUnselected, executed, leaseHolder } = input;
|
|
437
|
+
// Cascade (peer review R1): a skipped step that is ITSELF a router
|
|
438
|
+
// never evaluates its route, so none of its declared targets were
|
|
439
|
+
// selected — mark them all skip-on-reach too (a target another
|
|
440
|
+
// completed router selects stays protected via routeSelected). Without
|
|
441
|
+
// this, every branch of the skipped router would run unconditionally.
|
|
442
|
+
if (stepPlan.route) {
|
|
443
|
+
cascadeSkippedRouter(stepPlan.route, stepId, routeUnselected);
|
|
444
|
+
}
|
|
445
|
+
const notes = skipInfo.selected === null
|
|
446
|
+
? `Skipped by route: step "${skipInfo.router}" was itself skipped, so none of its branch targets run.`
|
|
447
|
+
: `Skipped by route: step "${skipInfo.router}" selected "${skipInfo.selected}".`;
|
|
448
|
+
executed.push({ stepId, ok: true, unitCount: 0, failedUnits: 0, summary: notes });
|
|
449
|
+
await completeWorkflowStep({ runId, stepId, status: "skipped", notes, leaseHolder });
|
|
450
|
+
return getNextWorkflowStep(runId);
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Crash-resume gate state (Codex P1): SEED the starting gate loop from the
|
|
454
|
+
* journal through the SAME shared helpers the first pass used — no fork.
|
|
455
|
+
* A run interrupted after a rejected gate was journaled
|
|
456
|
+
* (`<step>.gate:l<n>`, complete:false) must resume at loop n+1 with the
|
|
457
|
+
* stored corrective feedback threaded into the unit prompts; without this
|
|
458
|
+
* the engine restarts at loop 1, reuses the rejected loop-1 rows, overwrites
|
|
459
|
+
* `<step>.gate:l1`, and re-judges the stale artifact — breaking journaled
|
|
460
|
+
* replay and making the resumed run diverge from the interrupted one. The rows
|
|
461
|
+
* are re-read per step (NOT the once-at-start budget seed) so a step reached
|
|
462
|
+
* later within THIS same invocation still starts fresh at loop 1.
|
|
463
|
+
*
|
|
464
|
+
* Only the STEP's rows are read (index-backed on `(run_id, step_id)`): both
|
|
465
|
+
* helpers already discard every row carrying a different `step_id`, and gate
|
|
466
|
+
* rows are journaled under the step's own id, so the narrow query returns a
|
|
467
|
+
* superset of what they read. Re-reading the whole run journal here would
|
|
468
|
+
* re-materialize every earlier step's `result_json` — synchronously, blocking
|
|
469
|
+
* the event loop the lease heartbeat and abort handling share — once per step.
|
|
470
|
+
*/
|
|
471
|
+
async function recoverGateLoopState(runId, stepPlan) {
|
|
472
|
+
// A step with no effective completion criteria never reaches a judge
|
|
473
|
+
// (`validateStepSummary` short-circuits before the gate-journaling wrapper),
|
|
474
|
+
// so it can have no gate rows and needs no query at all.
|
|
475
|
+
if (!stepPlan.gate.criteria.some((criterion) => criterion.trim().length > 0)) {
|
|
476
|
+
return { startLoop: 1, seededFeedback: undefined };
|
|
477
|
+
}
|
|
478
|
+
const stepId = stepPlan.stepId;
|
|
479
|
+
const stepJournal = await withWorkflowRunsRepo((repo) => repo.getUnitsForStep(runId, stepId));
|
|
480
|
+
const startLoop = activeGateLoop(stepJournal, stepId);
|
|
481
|
+
return { startLoop, seededFeedback: recoverGateFeedback(stepJournal, stepId, startLoop) };
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* The kinds that FINISHED the step (completed / failed / gate-exhausted) — the
|
|
485
|
+
* ONE `maxSteps` consumption for its whole gate loop. An abort and a judge
|
|
486
|
+
* outage leave the step unfinished and consume nothing: the next invocation
|
|
487
|
+
* still owes the work.
|
|
488
|
+
*/
|
|
489
|
+
const STEP_FINISHED_KINDS = new Set(["advanced", "failed", "gate-exhausted"]);
|
|
490
|
+
/**
|
|
491
|
+
* One attempt at a step's work. Route-only steps (YAML `route:` — no execution
|
|
492
|
+
* subgraph) dispatch no units; they only decide the spine's path in
|
|
493
|
+
* `finalizeExecutedStep`. Everything else executes its subgraph through the
|
|
494
|
+
* native executor.
|
|
495
|
+
*/
|
|
496
|
+
async function executeStepSubgraph(ctx, loop) {
|
|
497
|
+
const { options, next, plan, stepPlan, step, evidence, leaseHolder, dispatchSignal } = ctx;
|
|
498
|
+
const { gateLoop, gateFeedback, unitsDispatched, tokensUsed } = loop;
|
|
499
|
+
return !stepPlan.root && stepPlan.route
|
|
500
|
+
? {
|
|
501
|
+
ok: true,
|
|
502
|
+
units: [],
|
|
503
|
+
evidence: {},
|
|
504
|
+
summary: `Step "${step.id}" is a route step — no units dispatched.`,
|
|
505
|
+
unitsDispatched,
|
|
506
|
+
}
|
|
507
|
+
: await executeStepPlan(stepPlan, {
|
|
508
|
+
runId: next.run.id,
|
|
509
|
+
leaseHolder,
|
|
510
|
+
workflowRef: next.run.workflowRef,
|
|
511
|
+
params: next.run.params ?? {},
|
|
512
|
+
evidence,
|
|
513
|
+
unitsDispatched,
|
|
514
|
+
tokensUsed,
|
|
515
|
+
// Budget ceilings ride the FROZEN plan (addendum R2): a mid-run
|
|
516
|
+
// asset edit can never loosen or tighten a run's budget.
|
|
517
|
+
...(plan.budget ? { budget: plan.budget } : {}),
|
|
518
|
+
...(plan.execution ? { engines: plan.execution.engines } : {}),
|
|
519
|
+
gateLoop,
|
|
520
|
+
...(gateFeedback ? { gateFeedback } : {}),
|
|
521
|
+
// The heartbeat's signal is the effective dispatch signal: a lost
|
|
522
|
+
// lease (or a caller abort) aborts in-flight units promptly.
|
|
523
|
+
...(dispatchSignal ? { signal: dispatchSignal } : {}),
|
|
524
|
+
...(options.dispatcher ? { dispatcher: options.dispatcher } : {}),
|
|
525
|
+
maxConcurrency: Math.min(options.maxConcurrency ?? Number.POSITIVE_INFINITY, plan.execution?.maxConcurrency ?? 1),
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Drive ONE step's bounded gate loop (addendum R2, `gate.max_loops`): loop 1 is
|
|
530
|
+
* the normal execution; a gate rejection with attempts left re-executes the
|
|
531
|
+
* subgraph with the judge's feedback threaded into unit prompts.
|
|
532
|
+
*
|
|
533
|
+
* The engine owns only the loop control the shared completion path
|
|
534
|
+
* (`finalizeExecutedStep`) maps onto — retry re-executes; advanced moves on;
|
|
535
|
+
* failure/judge-failure/exhaustion stops this invocation — and returns that
|
|
536
|
+
* decision plus the running budget totals to {@link driveRun}. `ctx.executed`
|
|
537
|
+
* is appended in place (one report per iteration); everything else the caller
|
|
538
|
+
* must observe travels back through {@link StepGateLoopOutcome}.
|
|
539
|
+
*/
|
|
540
|
+
async function runStepGateLoop(ctx, gate, totals) {
|
|
541
|
+
const { options, next, stepPlan, step, evidence, executed, routeSelected, routeUnselected } = ctx;
|
|
542
|
+
const { summaryJudge, leaseHolder, heartbeat } = ctx;
|
|
543
|
+
const { startLoop, maxLoops } = gate;
|
|
544
|
+
let { unitsDispatched, tokensUsed } = totals;
|
|
545
|
+
let gateFeedback = gate.seededFeedback;
|
|
546
|
+
// Every exit carries the running totals back to the engine loop; naming the
|
|
547
|
+
// kind is the whole decision an exit point has to make.
|
|
548
|
+
const outcome = (rest) => ({
|
|
549
|
+
...rest,
|
|
550
|
+
unitsDispatched,
|
|
551
|
+
tokensUsed,
|
|
552
|
+
});
|
|
553
|
+
for (let gateLoop = startLoop; gateLoop <= maxLoops; gateLoop++) {
|
|
554
|
+
// A loop re-execution dispatches a fresh round of units — renew the
|
|
555
|
+
// lease so a long evaluator-optimizer cycle cannot outlive the TTL.
|
|
556
|
+
if (gateLoop > 1)
|
|
557
|
+
await renewRunLease(next.run.id, leaseHolder);
|
|
558
|
+
const result = await executeStepSubgraph(ctx, { gateLoop, gateFeedback, unitsDispatched, tokensUsed });
|
|
559
|
+
// If the heartbeat lost the lease WHILE this step dispatched, another
|
|
560
|
+
// engine now owns the run — stop loudly BEFORE finalizing the step
|
|
561
|
+
// (completeWorkflowStep would race the new owner's spine).
|
|
562
|
+
heartbeat?.assertAlive();
|
|
563
|
+
unitsDispatched = result.unitsDispatched;
|
|
564
|
+
if (result.tokensUsed !== undefined)
|
|
565
|
+
tokensUsed = result.tokensUsed;
|
|
566
|
+
if (options.signal?.aborted)
|
|
567
|
+
return outcome({ kind: "aborted" });
|
|
568
|
+
executed.push({
|
|
569
|
+
stepId: step.id,
|
|
570
|
+
ok: result.ok,
|
|
571
|
+
unitCount: result.units.length,
|
|
572
|
+
failedUnits: result.units.filter((u) => !u.ok).length,
|
|
573
|
+
summary: result.summary,
|
|
574
|
+
});
|
|
575
|
+
// Route evaluation + artifact-judged completion gate + gate-row
|
|
576
|
+
// journaling + the bounded-loop rejection contract are the SHARED
|
|
577
|
+
// completion path (`finalizeExecutedStep`): every step advances through
|
|
578
|
+
// that one sequence, whether its units were just dispatched or rehydrated
|
|
579
|
+
// from the journal on resume, so the same frozen plan always promotes the
|
|
580
|
+
// same artifact and advances (or rejects) the spine identically.
|
|
581
|
+
let finalize;
|
|
582
|
+
try {
|
|
583
|
+
finalize = await finalizeExecutedStep({
|
|
584
|
+
runId: next.run.id,
|
|
585
|
+
workflowRef: next.run.workflowRef,
|
|
586
|
+
stepId: step.id,
|
|
587
|
+
stepPlan,
|
|
588
|
+
completionCriteria: stepPlan.gate.criteria,
|
|
589
|
+
gateLoop,
|
|
590
|
+
loopsRemaining: gateLoop < maxLoops,
|
|
591
|
+
result,
|
|
592
|
+
priorEvidence: evidence,
|
|
593
|
+
params: next.run.params ?? {},
|
|
594
|
+
routeSelected,
|
|
595
|
+
routeUnselected,
|
|
596
|
+
summaryJudge,
|
|
597
|
+
...(ctx.plan.execution ? { engines: ctx.plan.execution.engines } : {}),
|
|
598
|
+
signal: options.signal,
|
|
599
|
+
// The judge runs under the DISPATCH signal, so the completion path must
|
|
600
|
+
// see it too: an abort delivered there (a lost lease, a caller Ctrl-C)
|
|
601
|
+
// is an interruption, not a verifier outage.
|
|
602
|
+
...(ctx.dispatchSignal ? { dispatchSignal: ctx.dispatchSignal } : {}),
|
|
603
|
+
leaseHolder,
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
catch (error) {
|
|
607
|
+
heartbeat?.assertAlive();
|
|
608
|
+
if (options.signal?.aborted)
|
|
609
|
+
return outcome({ kind: "aborted" });
|
|
610
|
+
throw error;
|
|
611
|
+
}
|
|
612
|
+
heartbeat?.assertAlive();
|
|
613
|
+
if (finalize.kind === "retry") {
|
|
614
|
+
// Re-execute the subgraph with the judge/validation feedback threaded
|
|
615
|
+
// into unit prompts — the changed prompt changes each unit's input
|
|
616
|
+
// hash, so the re-run dispatches fresh work instead of reusing rows.
|
|
617
|
+
gateFeedback = finalize.gateFeedback;
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
if (finalize.kind === "advanced") {
|
|
621
|
+
// Hand the rest of this invocation the COMPLETE artifact — but only when
|
|
622
|
+
// some LATER step's frozen references can actually read it (set-time
|
|
623
|
+
// retention, see `referencedStepIds`). `finalize` has already journaled
|
|
624
|
+
// the step (and stamped any route decision onto `result.evidence`), and
|
|
625
|
+
// the persisted row may carry a truncation envelope in place of an
|
|
626
|
+
// over-cap value — the row bound must not change what the very next step
|
|
627
|
+
// reads.
|
|
628
|
+
if (ctx.liveEvidenceConsumers.has(step.id))
|
|
629
|
+
ctx.liveEvidence.set(step.id, result.evidence);
|
|
630
|
+
// A route-only step's summary IS its decision (finalize surfaces it).
|
|
631
|
+
if (finalize.summaryOverride !== undefined) {
|
|
632
|
+
executed[executed.length - 1] = { ...executed[executed.length - 1], summary: finalize.summaryOverride };
|
|
633
|
+
}
|
|
634
|
+
return outcome({ kind: "advanced" });
|
|
635
|
+
}
|
|
636
|
+
if (finalize.kind === "judge-failed") {
|
|
637
|
+
// Verifier infrastructure failure (thrown judge / malformed verdict /
|
|
638
|
+
// missing judge): the step is blocked for resume, NO gate loop was
|
|
639
|
+
// consumed, and the step does not count against maxSteps. Surface the
|
|
640
|
+
// resume instruction in the step report so every output mode shows it.
|
|
641
|
+
executed[executed.length - 1] = { ...executed[executed.length - 1], summary: finalize.summary };
|
|
642
|
+
return outcome({ kind: "judge-failed", judgeFailure: { stepId: step.id, message: finalize.summary } });
|
|
643
|
+
}
|
|
644
|
+
if (finalize.kind === "failed") {
|
|
645
|
+
// A route-failure was pushed as ok:true (the units succeeded); reflect
|
|
646
|
+
// the deterministic route failure in the executed report.
|
|
647
|
+
if (finalize.routeFailure) {
|
|
648
|
+
executed[executed.length - 1] = { ...executed[executed.length - 1], ok: false, summary: finalize.summary };
|
|
649
|
+
}
|
|
650
|
+
return outcome({ kind: "failed" });
|
|
651
|
+
}
|
|
652
|
+
// gate-exhausted: rejected with no loop budget left — stop with feedback.
|
|
653
|
+
return outcome({ kind: "gate-exhausted", gateRejection: finalize.gateRejection });
|
|
654
|
+
}
|
|
655
|
+
// Unreachable: `retry` is the ONLY path that continues the loop, and
|
|
656
|
+
// `finalizeExecutedStep` returns it exclusively while `gateLoop < maxLoops`,
|
|
657
|
+
// so the final iteration always exits through a terminal kind. Falling out
|
|
658
|
+
// here would mean those two bounds disagree — a bug, not a run outcome.
|
|
659
|
+
throw new Error(`Workflow run ${next.run.id} step "${step.id}" left its gate loop with no terminal outcome (loop bounds disagree).`);
|
|
350
660
|
}
|
|
351
661
|
/** The engine loop proper — runs under the lease held by `runWorkflowSteps`. */
|
|
352
|
-
async function driveRun(options, initial, leaseHolder, heartbeat
|
|
662
|
+
async function driveRun(options, initial, leaseHolder, heartbeat,
|
|
663
|
+
/**
|
|
664
|
+
* The COMPLETE in-memory evidence of every step THIS call has completed,
|
|
665
|
+
* keyed by step id, preferred over the re-read row when the downstream scope
|
|
666
|
+
* is rebuilt below. The spine rows are re-read between steps, and
|
|
667
|
+
* `clipStepEvidenceForPersistence` (runtime/runs.ts) may have replaced an
|
|
668
|
+
* over-cap artifact with a truncation envelope on the way in — a bound on ONE
|
|
669
|
+
* SQLite row, not on what a run may promote (the exec per-pipe cap alone
|
|
670
|
+
* retains 8 MiB). Preferring the live value keeps the persistence bound
|
|
671
|
+
* invisible to the run that produced it. A LATER `akm workflow run` starts
|
|
672
|
+
* with an empty map and reads the rows, where a reference into a truncated
|
|
673
|
+
* artifact fails loudly by name (`isTruncatedEvidence`).
|
|
674
|
+
*
|
|
675
|
+
* Only steps some OTHER step's references NAME are stored (`referencedStepIds`
|
|
676
|
+
* — the set-time filter): a step nothing downstream reads has no consumer to
|
|
677
|
+
* keep it complete for, so retaining it would buy nothing and cost its bytes
|
|
678
|
+
* for the rest of the invocation.
|
|
679
|
+
*/
|
|
680
|
+
liveEvidence) {
|
|
353
681
|
let next = initial;
|
|
354
682
|
if (initial.done)
|
|
355
683
|
return completedRunResult(initial.run.id);
|
|
@@ -358,49 +686,22 @@ async function driveRun(options, initial, leaseHolder, heartbeat) {
|
|
|
358
686
|
const dispatchSignal = heartbeat?.signal ?? options.signal;
|
|
359
687
|
const executed = [];
|
|
360
688
|
let gateRejection;
|
|
689
|
+
let judgeFailure;
|
|
361
690
|
let aborted = false;
|
|
362
691
|
const maxSteps = options.maxSteps ?? Number.POSITIVE_INFINITY;
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
|
|
376
|
-
// earlier than the identical uninterrupted run — a spurious hard failure
|
|
377
|
-
// that `on_error` cannot soften. The seed must reproduce exactly what live
|
|
378
|
-
// accounting would have accumulated.
|
|
379
|
-
//
|
|
380
|
-
// The seed sums each dispatch row's `attempts` (migration 008), NOT the row
|
|
381
|
-
// COUNT: a crash between a unit's dispatch and its finish leaves a `running`
|
|
382
|
-
// row that resume re-dispatches under the SAME content-derived unit_id, and
|
|
383
|
-
// `insertUnit` REPLACES that one row while bumping `attempts`. Counting rows
|
|
384
|
-
// would erase every prior crash-retried dispatch from budget/lifetime
|
|
385
|
-
// accounting, letting the run spend past its declared ceiling; summing
|
|
386
|
-
// `attempts` charges each dispatch exactly once.
|
|
387
|
-
const journaledUnits = await withWorkflowRunsRepo((repo) => repo.getUnitsForRun(next.run.id));
|
|
388
|
-
const journaledDispatches = journaledUnits.filter((row) => row.phase !== GATE_EVALUATION_PHASE);
|
|
389
|
-
let unitsDispatched = journaledDispatches.reduce((sum, row) => sum + row.attempts, 0);
|
|
390
|
-
let tokensUsed = journaledDispatches.reduce((sum, row) => sum + (row.tokens ?? 0), 0);
|
|
391
|
-
// The decoded/hash-verified row plan is the sole execution authority. The
|
|
392
|
-
// loader seam may assert an expected plan in tests, but can never replace it.
|
|
393
|
-
const plan = await loadFrozenPlan(next.run.id);
|
|
394
|
-
if (options.loadPlan) {
|
|
395
|
-
const expected = decodeWorkflowPlanV3(await options.loadPlan(next.run.workflowRef));
|
|
396
|
-
if (computePlanHash(expected) !== computePlanHash(plan))
|
|
397
|
-
throw new UsageError(`Injected workflow plan for run ${next.run.id} differs from its frozen plan.`);
|
|
398
|
-
}
|
|
399
|
-
// Reviewer #12: the journaled params row must still satisfy the frozen param
|
|
400
|
-
// schemas before the engine resolves any unit prompt from it, so
|
|
401
|
-
// schema-violating params — post-start corruption — fail loudly BEFORE any
|
|
402
|
-
// unit is dispatched (start already validated the params it stored).
|
|
403
|
-
assertRunParamsSatisfyPlan(next.run.id, plan, next.run.params ?? {});
|
|
692
|
+
// The `maxSteps` budget counts DISTINCT spine steps that finished processing
|
|
693
|
+
// — never `executed.length`, which grows once per gate-loop iteration and
|
|
694
|
+
// once per route-skip. A step's whole bounded gate loop consumes ONE step;
|
|
695
|
+
// a route-skipped step consumes NOTHING (no work was dispatched for it).
|
|
696
|
+
let stepsProcessed = 0;
|
|
697
|
+
let { unitsDispatched, tokensUsed } = await seedRunAccountingFromJournal(next.run.id);
|
|
698
|
+
const plan = await loadAuthoritativeRunPlan(options, next);
|
|
699
|
+
// Live-evidence retention is decided at SET time, from the frozen plan alone:
|
|
700
|
+
// a completed step's complete artifact is held only while some other step's
|
|
701
|
+
// references can still read it. An exec unit's promoted stdout can be 8 MiB,
|
|
702
|
+
// and holding every step's for the whole invocation is pure ballast when
|
|
703
|
+
// nothing downstream names it.
|
|
704
|
+
const liveEvidenceConsumers = referencedStepIds(plan);
|
|
404
705
|
// Route bookkeeping: targets a completed router did NOT select are skipped
|
|
405
706
|
// when the spine reaches them; a target ANY router selected is protected
|
|
406
707
|
// (two routers may share a target).
|
|
@@ -418,7 +719,7 @@ async function driveRun(options, initial, leaseHolder, heartbeat) {
|
|
|
418
719
|
if (!next.done) {
|
|
419
720
|
seedJournaledRouteDecisions(plan, next, routeSelected, routeUnselected);
|
|
420
721
|
}
|
|
421
|
-
while (!next.done && next.step && next.run.status === "active" &&
|
|
722
|
+
while (!next.done && next.step && next.run.status === "active" && stepsProcessed < maxSteps) {
|
|
422
723
|
// A LOST lease (the heartbeat's renewal failed mid-step) is a loud stop —
|
|
423
724
|
// another engine owns the spine now. A caller abort (options.signal) is a
|
|
424
725
|
// graceful break, distinct from a lost lease.
|
|
@@ -440,57 +741,27 @@ async function driveRun(options, initial, leaseHolder, heartbeat) {
|
|
|
440
741
|
// A branch target no completed router selected → auto-skip, no dispatch.
|
|
441
742
|
const skipInfo = routeUnselected.get(step.id);
|
|
442
743
|
if (skipInfo && !routeSelected.has(step.id)) {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
? `Skipped by route: step "${skipInfo.router}" was itself skipped, so none of its branch targets run.`
|
|
453
|
-
: `Skipped by route: step "${skipInfo.router}" selected "${skipInfo.selected}".`;
|
|
454
|
-
executed.push({ stepId: step.id, ok: true, unitCount: 0, failedUnits: 0, summary: notes });
|
|
455
|
-
await completeWorkflowStep({ runId: next.run.id, stepId: step.id, status: "skipped", notes, leaseHolder });
|
|
456
|
-
next = await getNextWorkflowStep(next.run.id);
|
|
744
|
+
next = await skipUnselectedRouteTarget({
|
|
745
|
+
runId: next.run.id,
|
|
746
|
+
stepId: step.id,
|
|
747
|
+
stepPlan,
|
|
748
|
+
skipInfo,
|
|
749
|
+
routeUnselected,
|
|
750
|
+
executed,
|
|
751
|
+
leaseHolder,
|
|
752
|
+
});
|
|
457
753
|
continue;
|
|
458
754
|
}
|
|
459
|
-
// `dependsOn` edges (no frontend emits them today,
|
|
460
|
-
// but a frozen plan may carry them) are a declared ordering contract:
|
|
461
|
-
// every dependency must already be resolved before this step dispatches.
|
|
462
|
-
// Execution is sequential (spine order), so a violation means the plan
|
|
463
|
-
// ordered steps inconsistently with its declared edges — fail fast,
|
|
464
|
-
// before spending.
|
|
465
|
-
for (const dep of stepPlan.dependsOn ?? []) {
|
|
466
|
-
const depState = next.workflow.steps.find((s) => s.id === dep);
|
|
467
|
-
if (!depState || (depState.status !== "completed" && depState.status !== "skipped")) {
|
|
468
|
-
throw new UsageError(`Step "${step.id}" depends on step "${dep}", which is ${depState?.status ?? "missing"}. ` +
|
|
469
|
-
`Reorder the workflow so dependencies come first (execution is sequential in step order).`);
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
755
|
const evidence = {};
|
|
473
756
|
for (const s of next.workflow.steps)
|
|
474
|
-
evidence[s.id] = s.evidence;
|
|
757
|
+
evidence[s.id] = liveEvidence.get(s.id) ?? s.evidence;
|
|
475
758
|
// Bounded gate loop (addendum R2, `gate.max_loops`): loop 1 is the normal
|
|
476
759
|
// execution; a gate rejection with attempts left re-executes the subgraph
|
|
477
|
-
// with the judge's feedback threaded into unit prompts.
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
const maxLoops =
|
|
481
|
-
|
|
482
|
-
// journal through the SAME shared helpers the first pass used — no fork.
|
|
483
|
-
// A run interrupted after a rejected gate was journaled
|
|
484
|
-
// (`<step>.gate:l<n>`, complete:false) must resume at loop n+1 with the
|
|
485
|
-
// stored corrective feedback threaded into the unit prompts; without this
|
|
486
|
-
// the engine restarts at loop 1, reuses the rejected loop-1 rows, overwrites
|
|
487
|
-
// `<step>.gate:l1`, and re-judges the stale artifact — breaking journaled
|
|
488
|
-
// replay and making the resumed run diverge from the interrupted one. The rows
|
|
489
|
-
// are re-read here (NOT the once-at-start `journaledUnits` budget seed) so a
|
|
490
|
-
// step reached later within THIS same invocation still starts fresh at loop 1.
|
|
491
|
-
const stepJournal = await withWorkflowRunsRepo((repo) => repo.getUnitsForRun(next.run.id));
|
|
492
|
-
const startLoop = activeGateLoop(stepJournal, step.id);
|
|
493
|
-
const seededFeedback = recoverGateFeedback(stepJournal, step.id, startLoop);
|
|
760
|
+
// with the judge's feedback threaded into unit prompts. The bound comes
|
|
761
|
+
// from the shared derivation, which holds an exec step to a single
|
|
762
|
+
// execution — its argv cannot answer feedback (see effectiveGateMaxLoops).
|
|
763
|
+
const maxLoops = effectiveGateMaxLoops(stepPlan);
|
|
764
|
+
const { startLoop, seededFeedback } = await recoverGateLoopState(next.run.id, stepPlan);
|
|
494
765
|
// Resume AFTER the FINAL rejection (`startLoop` past the loop bound): the
|
|
495
766
|
// gate was already exhausted before the crash, so there is NO fresh loop to
|
|
496
767
|
// run — reproduce the documented gateRejection outcome from the stored
|
|
@@ -505,131 +776,63 @@ async function driveRun(options, initial, leaseHolder, heartbeat) {
|
|
|
505
776
|
};
|
|
506
777
|
break;
|
|
507
778
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
for
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
// units; they only decide the spine's path below. Everything else
|
|
518
|
-
// executes its subgraph through the native executor.
|
|
519
|
-
const result = !stepPlan.root && stepPlan.route
|
|
520
|
-
? {
|
|
521
|
-
ok: true,
|
|
522
|
-
units: [],
|
|
523
|
-
evidence: {},
|
|
524
|
-
summary: `Step "${step.id}" is a route step — no units dispatched.`,
|
|
525
|
-
unitsDispatched,
|
|
526
|
-
}
|
|
527
|
-
: await executeStepPlan(stepPlan, {
|
|
528
|
-
runId: next.run.id,
|
|
529
|
-
leaseHolder,
|
|
530
|
-
workflowRef: next.run.workflowRef,
|
|
531
|
-
params: next.run.params ?? {},
|
|
532
|
-
evidence,
|
|
533
|
-
unitsDispatched,
|
|
534
|
-
tokensUsed,
|
|
535
|
-
// Budget ceilings ride the FROZEN plan (addendum R2): a mid-run
|
|
536
|
-
// asset edit can never loosen or tighten a run's budget.
|
|
537
|
-
...(plan.budget ? { budget: plan.budget } : {}),
|
|
538
|
-
...(plan.execution ? { engines: plan.execution.engines } : {}),
|
|
539
|
-
gateLoop,
|
|
540
|
-
...(gateFeedback ? { gateFeedback } : {}),
|
|
541
|
-
// The heartbeat's signal is the effective dispatch signal: a lost
|
|
542
|
-
// lease (or a caller abort) aborts in-flight units promptly.
|
|
543
|
-
...(dispatchSignal ? { signal: dispatchSignal } : {}),
|
|
544
|
-
...(options.dispatcher ? { dispatcher: options.dispatcher } : {}),
|
|
545
|
-
maxConcurrency: Math.min(options.maxConcurrency ?? Number.POSITIVE_INFINITY, plan.execution?.maxConcurrency ?? 1),
|
|
546
|
-
});
|
|
547
|
-
// If the heartbeat lost the lease WHILE this step dispatched, another
|
|
548
|
-
// engine now owns the run — stop loudly BEFORE finalizing the step
|
|
549
|
-
// (completeWorkflowStep would race the new owner's spine).
|
|
550
|
-
heartbeat?.assertAlive();
|
|
551
|
-
unitsDispatched = result.unitsDispatched;
|
|
552
|
-
if (result.tokensUsed !== undefined)
|
|
553
|
-
tokensUsed = result.tokensUsed;
|
|
554
|
-
if (options.signal?.aborted) {
|
|
555
|
-
aborted = true;
|
|
556
|
-
stopEngine = true;
|
|
557
|
-
break;
|
|
558
|
-
}
|
|
559
|
-
executed.push({
|
|
779
|
+
// Judge-outage contract: resolve the step's frozen completion judge BEFORE
|
|
780
|
+
// any dispatch. An unresolvable judge (missing frozen engine, no dispatcher
|
|
781
|
+
// for an agent judge) is verifier INFRASTRUCTURE failure — block the step
|
|
782
|
+
// for `akm workflow resume` instead of spending on units the gate can never
|
|
783
|
+
// verify. No gate loop is consumed and nothing is dispatched.
|
|
784
|
+
let summaryJudge;
|
|
785
|
+
try {
|
|
786
|
+
summaryJudge = workflowSummaryJudge(options, plan, stepPlan, dispatchSignal, {
|
|
787
|
+
runId: next.run.id,
|
|
560
788
|
stepId: step.id,
|
|
561
|
-
ok: result.ok,
|
|
562
|
-
unitCount: result.units.length,
|
|
563
|
-
failedUnits: result.units.filter((u) => !u.ok).length,
|
|
564
|
-
summary: result.summary,
|
|
565
789
|
});
|
|
566
|
-
// Route evaluation + artifact-judged completion gate + gate-row
|
|
567
|
-
// journaling + the bounded-loop rejection contract are the SHARED
|
|
568
|
-
// completion path (`finalizeExecutedStep`): every step advances through
|
|
569
|
-
// that one sequence, whether its units were just dispatched or rehydrated
|
|
570
|
-
// from the journal on resume, so the same frozen plan always promotes the
|
|
571
|
-
// same artifact and advances (or rejects) the spine identically. The
|
|
572
|
-
// engine owns only the loop control the result maps onto (retry
|
|
573
|
-
// re-executes; advanced moves on; failure/exhaustion stops this invocation).
|
|
574
|
-
let finalize;
|
|
575
|
-
try {
|
|
576
|
-
finalize = await finalizeExecutedStep({
|
|
577
|
-
runId: next.run.id,
|
|
578
|
-
workflowRef: next.run.workflowRef,
|
|
579
|
-
stepId: step.id,
|
|
580
|
-
stepPlan,
|
|
581
|
-
completionCriteria: stepPlan.gate.criteria,
|
|
582
|
-
gateLoop,
|
|
583
|
-
loopsRemaining: gateLoop < maxLoops,
|
|
584
|
-
result,
|
|
585
|
-
priorEvidence: evidence,
|
|
586
|
-
params: next.run.params ?? {},
|
|
587
|
-
routeSelected,
|
|
588
|
-
routeUnselected,
|
|
589
|
-
summaryJudge: workflowSummaryJudge(options, plan, stepPlan, dispatchSignal),
|
|
590
|
-
signal: options.signal,
|
|
591
|
-
leaseHolder,
|
|
592
|
-
});
|
|
593
|
-
}
|
|
594
|
-
catch (error) {
|
|
595
|
-
heartbeat?.assertAlive();
|
|
596
|
-
if (options.signal?.aborted) {
|
|
597
|
-
aborted = true;
|
|
598
|
-
stopEngine = true;
|
|
599
|
-
break;
|
|
600
|
-
}
|
|
601
|
-
throw error;
|
|
602
|
-
}
|
|
603
|
-
heartbeat?.assertAlive();
|
|
604
|
-
if (finalize.kind === "retry") {
|
|
605
|
-
// Re-execute the subgraph with the judge/validation feedback threaded
|
|
606
|
-
// into unit prompts — the changed prompt changes each unit's input
|
|
607
|
-
// hash, so the re-run dispatches fresh work instead of reusing rows.
|
|
608
|
-
gateFeedback = finalize.gateFeedback;
|
|
609
|
-
continue;
|
|
610
|
-
}
|
|
611
|
-
if (finalize.kind === "advanced") {
|
|
612
|
-
// A route-only step's summary IS its decision (finalize surfaces it).
|
|
613
|
-
if (finalize.summaryOverride !== undefined) {
|
|
614
|
-
executed[executed.length - 1] = { ...executed[executed.length - 1], summary: finalize.summaryOverride };
|
|
615
|
-
}
|
|
616
|
-
advanced = true;
|
|
617
|
-
break;
|
|
618
|
-
}
|
|
619
|
-
if (finalize.kind === "failed") {
|
|
620
|
-
// A route-failure was pushed as ok:true (the units succeeded); reflect
|
|
621
|
-
// the deterministic route failure in the executed report.
|
|
622
|
-
if (finalize.routeFailure) {
|
|
623
|
-
executed[executed.length - 1] = { ...executed[executed.length - 1], ok: false, summary: finalize.summary };
|
|
624
|
-
}
|
|
625
|
-
stopEngine = true;
|
|
626
|
-
break;
|
|
627
|
-
}
|
|
628
|
-
// gate-exhausted: rejected with no loop budget left — stop with feedback.
|
|
629
|
-
gateRejection = finalize.gateRejection;
|
|
630
|
-
stopEngine = true;
|
|
631
790
|
}
|
|
632
|
-
|
|
791
|
+
catch (error) {
|
|
792
|
+
const detail = error instanceof Error && error.message ? ` (${error.message})` : "";
|
|
793
|
+
// Nothing was dispatched, so there is no evidence to preserve — the same
|
|
794
|
+
// blocked write the post-execution path uses, minus the results it does
|
|
795
|
+
// not have.
|
|
796
|
+
const notes = await blockStepForJudgeFailure({
|
|
797
|
+
runId: next.run.id,
|
|
798
|
+
stepId: step.id,
|
|
799
|
+
cause: `the verification judge could not be resolved from the frozen plan${detail}`,
|
|
800
|
+
leaseHolder,
|
|
801
|
+
});
|
|
802
|
+
executed.push({ stepId: step.id, ok: false, unitCount: 0, failedUnits: 0, summary: notes });
|
|
803
|
+
judgeFailure = { stepId: step.id, message: notes };
|
|
804
|
+
break;
|
|
805
|
+
}
|
|
806
|
+
const outcome = await runStepGateLoop({
|
|
807
|
+
options,
|
|
808
|
+
next,
|
|
809
|
+
plan,
|
|
810
|
+
stepPlan,
|
|
811
|
+
step,
|
|
812
|
+
evidence,
|
|
813
|
+
liveEvidence,
|
|
814
|
+
liveEvidenceConsumers,
|
|
815
|
+
executed,
|
|
816
|
+
routeSelected,
|
|
817
|
+
routeUnselected,
|
|
818
|
+
summaryJudge,
|
|
819
|
+
leaseHolder,
|
|
820
|
+
heartbeat,
|
|
821
|
+
dispatchSignal,
|
|
822
|
+
}, { startLoop, maxLoops, seededFeedback }, { unitsDispatched, tokensUsed });
|
|
823
|
+
unitsDispatched = outcome.unitsDispatched;
|
|
824
|
+
tokensUsed = outcome.tokensUsed;
|
|
825
|
+
if (outcome.kind === "aborted")
|
|
826
|
+
aborted = true;
|
|
827
|
+
if (outcome.gateRejection)
|
|
828
|
+
gateRejection = outcome.gateRejection;
|
|
829
|
+
if (outcome.judgeFailure)
|
|
830
|
+
judgeFailure = outcome.judgeFailure;
|
|
831
|
+
if (STEP_FINISHED_KINDS.has(outcome.kind))
|
|
832
|
+
stepsProcessed += 1;
|
|
833
|
+
// Only an advance leaves the spine walkable; every other kind ends this
|
|
834
|
+
// invocation (failure, exhausted gate, judge outage, abort).
|
|
835
|
+
if (outcome.kind !== "advanced")
|
|
633
836
|
break;
|
|
634
837
|
next = await getNextWorkflowStep(next.run.id);
|
|
635
838
|
}
|
|
@@ -638,8 +841,10 @@ async function driveRun(options, initial, leaseHolder, heartbeat) {
|
|
|
638
841
|
return {
|
|
639
842
|
run: finalState.run,
|
|
640
843
|
executed,
|
|
844
|
+
stepsProcessed,
|
|
641
845
|
...(finalState.run.status === "completed" ? { done: true } : {}),
|
|
642
846
|
...(gateRejection ? { gateRejection } : {}),
|
|
847
|
+
...(judgeFailure ? { judgeFailure } : {}),
|
|
643
848
|
...(aborted ? { aborted: true } : {}),
|
|
644
849
|
};
|
|
645
850
|
}
|