akm-cli 0.9.0-beta.9 → 0.9.0-rc.1
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 +715 -0
- package/README.md +12 -4
- package/dist/akm +38 -0
- package/dist/akm-migrate-storage +38 -0
- package/dist/assets/help/help-improve.md +9 -6
- package/dist/assets/hints/cli-hints-full.md +6 -5
- package/dist/assets/profiles/default.json +9 -4
- package/dist/assets/profiles/frequent.json +1 -1
- package/dist/assets/profiles/memory-focus.json +1 -1
- package/dist/assets/profiles/proactive-maintenance.json +25 -0
- package/dist/assets/profiles/quick.json +1 -1
- package/dist/assets/profiles/recombine-only.json +21 -0
- package/dist/assets/profiles/reflect-distill.json +30 -0
- package/dist/assets/profiles/synthesize.json +15 -0
- package/dist/assets/profiles/thorough.json +1 -1
- package/dist/assets/prompts/consolidate-system.md +23 -0
- package/dist/assets/prompts/contradiction-judge.md +33 -0
- package/dist/assets/prompts/distill-knowledge-system.md +22 -0
- package/dist/assets/prompts/distill-lesson-system.md +36 -0
- package/dist/assets/prompts/extract-session.md +11 -3
- package/dist/assets/prompts/graph-extract-system.md +1 -0
- package/dist/assets/prompts/graph-extract-user-prompt.md +1 -1
- package/dist/assets/prompts/memory-infer-system.md +1 -0
- package/dist/assets/prompts/memory-infer-user.md +5 -0
- package/dist/assets/prompts/metadata-enhance-system.md +1 -0
- package/dist/assets/prompts/procedural-system.md +44 -0
- package/dist/assets/prompts/recombine-system.md +40 -0
- package/dist/assets/prompts/staleness-detect-system.md +6 -0
- package/dist/assets/prompts/validate-summary-judge.md +1 -0
- package/dist/assets/prompts/workflow-unit-preamble.md +26 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +38 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +38 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +39 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +40 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +43 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +38 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +43 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +40 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +43 -0
- package/dist/assets/templates/html/health.html +281 -111
- package/dist/assets/wiki/ingest-workflow-template.md +45 -16
- package/dist/assets/wiki/schema-template.md +4 -4
- package/dist/cli/clack.js +56 -0
- package/dist/cli/config-migrate.js +7 -1
- package/dist/cli/confirm.js +1 -1
- package/dist/cli/parse-args.js +46 -1
- package/dist/cli/shared.js +28 -0
- package/dist/cli.js +25 -21
- package/dist/commands/agent/agent-dispatch.js +3 -2
- package/dist/commands/agent/agent-support.js +0 -7
- package/dist/commands/agent/contribute-cli.js +26 -7
- package/dist/commands/config-cli.js +26 -13
- package/dist/commands/env/child-env.js +47 -0
- package/dist/commands/env/env-binding.js +95 -0
- package/dist/commands/env/env-cli.js +228 -292
- package/dist/commands/env/env.js +14 -67
- package/dist/commands/env/secret-cli.js +140 -138
- package/dist/commands/feedback-cli.js +156 -155
- package/dist/commands/graph/graph-cli.js +5 -13
- package/dist/commands/graph/graph.js +3 -3
- package/dist/commands/health/advisories.js +151 -0
- package/dist/commands/health/checks.js +103 -16
- package/dist/commands/health/html-report.js +447 -81
- package/dist/commands/health/improve-metrics.js +771 -0
- package/dist/commands/health/llm-usage.js +65 -0
- package/dist/commands/health/md-report.js +103 -0
- package/dist/commands/health/metrics.js +278 -0
- package/dist/commands/health/stash-exposure.js +46 -0
- package/dist/commands/health/surfaces.js +216 -0
- package/dist/commands/health/task-runs.js +135 -0
- package/dist/commands/health/types.js +26 -0
- package/dist/commands/health/windows.js +195 -0
- package/dist/commands/health.js +91 -1091
- package/dist/commands/improve/anti-collapse.js +170 -0
- package/dist/commands/improve/calibration.js +161 -0
- package/dist/commands/improve/collapse-detector.js +421 -0
- package/dist/commands/improve/consolidate/chunking.js +141 -0
- package/dist/commands/improve/consolidate/eligibility.js +64 -0
- package/dist/commands/improve/consolidate/merge.js +145 -0
- package/dist/commands/improve/consolidate/sanitize.js +231 -0
- package/dist/commands/{lint.js → improve/consolidate/types.js} +1 -1
- package/dist/commands/improve/consolidate.js +1295 -1277
- package/dist/commands/improve/dedup.js +482 -0
- package/dist/commands/improve/distill/content-repair.js +202 -0
- package/dist/commands/improve/distill/promote-memory.js +229 -0
- package/dist/commands/improve/distill/quality-gate.js +236 -0
- package/dist/commands/improve/distill-guards.js +127 -0
- package/dist/commands/improve/distill-promotion-policy.js +826 -167
- package/dist/commands/improve/distill.js +228 -605
- package/dist/commands/improve/eligibility.js +434 -0
- package/dist/commands/improve/encoding-salience.js +205 -0
- package/dist/commands/improve/extract-cli.js +179 -59
- package/dist/commands/improve/extract-prompt.js +54 -3
- package/dist/commands/improve/extract-watch.js +140 -0
- package/dist/commands/improve/extract.js +409 -43
- package/dist/commands/improve/feedback-valence.js +54 -0
- package/dist/commands/improve/hot-probation.js +45 -0
- package/dist/commands/improve/improve-auto-accept.js +157 -10
- package/dist/commands/improve/improve-cli.js +115 -73
- package/dist/commands/improve/improve-profiles.js +28 -8
- package/dist/commands/improve/improve-result-file.js +15 -25
- package/dist/commands/improve/improve-session.js +58 -0
- package/dist/commands/improve/improve.js +485 -2764
- package/dist/commands/improve/locks.js +154 -0
- package/dist/commands/improve/loop-stages.js +1100 -0
- package/dist/commands/improve/memory/memory-belief.js +14 -15
- package/dist/commands/improve/memory/memory-contradiction-detect.js +83 -60
- package/dist/commands/improve/memory/memory-improve.js +27 -27
- package/dist/commands/improve/outcome-loop.js +270 -0
- package/dist/commands/improve/preparation.js +2002 -0
- package/dist/commands/improve/proactive-maintenance.js +37 -35
- package/dist/commands/improve/procedural.js +398 -0
- package/dist/commands/improve/recombine.js +818 -0
- package/dist/commands/improve/reflect-noise.js +0 -0
- package/dist/commands/improve/reflect.js +206 -45
- package/dist/commands/improve/salience.js +455 -0
- package/dist/commands/improve/schema-similarity-gate.js +168 -0
- package/dist/commands/improve/shared.js +51 -0
- package/dist/commands/improve/triage.js +93 -0
- package/dist/commands/lint/agent-linter.js +19 -24
- package/dist/commands/lint/base-linter.js +173 -60
- package/dist/commands/lint/command-linter.js +19 -24
- package/dist/commands/lint/env-key-rules.js +38 -1
- package/dist/commands/lint/fact-linter.js +39 -0
- package/dist/commands/lint/index.js +31 -13
- package/dist/commands/lint/memory-linter.js +1 -1
- package/dist/commands/lint/registry.js +7 -2
- package/dist/commands/lint/task-linter.js +3 -3
- package/dist/commands/lint/workflow-linter.js +26 -1
- package/dist/commands/observability-cli.js +4 -4
- package/dist/commands/proposal/drain-policies.js +13 -4
- package/dist/commands/proposal/drain.js +45 -51
- package/dist/commands/proposal/legacy-import.js +115 -0
- package/dist/commands/proposal/proposal-cli.js +24 -34
- package/dist/commands/proposal/proposal.js +2 -1
- package/dist/commands/proposal/propose.js +8 -3
- package/dist/commands/proposal/repository.js +829 -0
- package/dist/commands/proposal/validators/proposal-quality-validators.js +9 -8
- package/dist/commands/proposal/validators/proposals.js +93 -895
- package/dist/commands/read/curate.js +410 -111
- package/dist/commands/read/knowledge.js +10 -3
- package/dist/commands/read/remember-cli.js +133 -138
- package/dist/commands/read/search-cli.js +15 -8
- package/dist/commands/read/search.js +22 -11
- package/dist/commands/read/show.js +106 -14
- package/dist/commands/registry-cli.js +76 -87
- package/dist/commands/remember.js +11 -12
- package/dist/commands/sources/add-cli.js +91 -95
- package/dist/commands/sources/history.js +1 -1
- package/dist/commands/sources/init.js +66 -18
- package/dist/commands/sources/installed-stashes.js +11 -3
- package/dist/commands/sources/migration-help.js +7 -4
- package/dist/commands/sources/schema-repair.js +44 -46
- package/dist/commands/sources/self-update.js +2 -2
- package/dist/commands/sources/source-add.js +7 -3
- package/dist/commands/sources/sources-cli.js +3 -3
- package/dist/commands/sources/stash-cli.js +19 -39
- package/dist/commands/sources/stash-skeleton.js +57 -8
- package/dist/commands/tasks/default-tasks.js +15 -2
- package/dist/commands/tasks/tasks-cli.js +20 -29
- package/dist/commands/tasks/tasks.js +39 -11
- package/dist/commands/wiki-cli.js +23 -38
- package/dist/commands/workflow-cli.js +291 -13
- package/dist/core/asset/asset-registry.js +3 -1
- package/dist/core/asset/asset-spec.js +79 -5
- package/dist/core/asset/frontmatter.js +188 -167
- package/dist/core/asset/markdown.js +8 -0
- package/dist/core/authoring-rules.js +92 -0
- package/dist/core/common.js +4 -23
- package/dist/core/concurrent.js +10 -1
- package/dist/core/config/config-io.js +10 -1
- package/dist/core/config/config-migration.js +18 -40
- package/dist/core/config/config-schema.js +403 -62
- package/dist/core/config/config-types.js +3 -3
- package/dist/core/config/config.js +67 -22
- package/dist/core/deep-merge.js +38 -0
- package/dist/core/errors.js +1 -0
- package/dist/core/eval/rank-metrics.js +113 -0
- package/dist/core/events.js +4 -7
- package/dist/core/improve-types.js +47 -8
- package/dist/core/json-schema.js +142 -0
- package/dist/core/logs-db.js +14 -75
- package/dist/core/parse.js +36 -16
- package/dist/core/paths.js +18 -18
- package/dist/core/standards/resolve-standards-context.js +87 -0
- package/dist/core/standards/resolve-stash-standards.js +99 -0
- package/dist/core/standards/resolve-type-conventions.js +66 -0
- package/dist/core/state/migrations.js +770 -0
- package/dist/core/state-db.js +132 -1126
- package/dist/core/structured.js +69 -0
- package/dist/core/time.js +53 -0
- package/dist/core/warn.js +21 -0
- package/dist/core/write-source.js +37 -0
- package/dist/indexer/db/db.js +261 -770
- package/dist/indexer/db/entry-mapper.js +41 -0
- package/dist/indexer/db/graph-db.js +129 -86
- package/dist/indexer/db/llm-cache.js +2 -2
- package/dist/indexer/db/schema.js +516 -0
- package/dist/indexer/ensure-index.js +36 -92
- package/dist/indexer/feedback/utility-policy.js +75 -0
- package/dist/indexer/graph/graph-boost.js +51 -41
- package/dist/indexer/graph/graph-extraction.js +207 -4
- package/dist/indexer/index-writer-lock.js +18 -11
- package/dist/indexer/index-written-assets.js +105 -0
- package/dist/indexer/indexer.js +182 -204
- package/dist/indexer/passes/dir-staleness.js +114 -0
- package/dist/indexer/passes/memory-inference.js +13 -5
- package/dist/indexer/passes/metadata.js +20 -0
- package/dist/indexer/read-preflight.js +23 -0
- package/dist/indexer/search/db-search.js +89 -13
- package/dist/indexer/search/fts-query.js +51 -0
- package/dist/indexer/search/ranking-contributors.js +95 -9
- package/dist/indexer/search/ranking.js +79 -3
- package/dist/indexer/search/search-fields.js +6 -0
- package/dist/indexer/search/search-source.js +32 -21
- package/dist/indexer/search/semantic-status.js +4 -0
- package/dist/indexer/walk/matchers.js +48 -0
- package/dist/indexer/walk/walker.js +21 -13
- package/dist/integrations/agent/builders.js +41 -13
- package/dist/integrations/agent/config.js +20 -59
- package/dist/integrations/agent/detect.js +9 -0
- package/dist/integrations/agent/index.js +3 -19
- package/dist/integrations/agent/model-aliases.js +16 -2
- package/dist/integrations/agent/profiles.js +79 -6
- package/dist/integrations/agent/prompts.js +75 -9
- package/dist/integrations/agent/runner-dispatch.js +83 -0
- package/dist/integrations/agent/runner.js +13 -9
- package/dist/integrations/agent/spawn.js +206 -81
- package/dist/integrations/harnesses/aider/agent-builder.js +113 -0
- package/dist/integrations/harnesses/aider/index.js +58 -0
- package/dist/integrations/harnesses/aider/result-extractor.js +53 -0
- package/dist/integrations/harnesses/amazonq/agent-builder.js +153 -0
- package/dist/integrations/harnesses/amazonq/index.js +59 -0
- package/dist/integrations/harnesses/amazonq/result-extractor.js +48 -0
- package/dist/integrations/harnesses/claude/agent-builder.js +46 -7
- package/dist/integrations/harnesses/claude/index.js +27 -23
- package/dist/integrations/harnesses/claude/result-extractor.js +52 -0
- package/dist/integrations/harnesses/claude/session-log.js +10 -0
- package/dist/integrations/harnesses/codex/agent-builder.js +137 -0
- package/dist/integrations/harnesses/codex/index.js +63 -0
- package/dist/integrations/harnesses/codex/result-extractor.js +73 -0
- package/dist/integrations/harnesses/copilot/agent-builder.js +122 -0
- package/dist/integrations/harnesses/copilot/index.js +60 -0
- package/dist/integrations/harnesses/copilot/result-extractor.js +151 -0
- package/dist/integrations/harnesses/gemini/agent-builder.js +121 -0
- package/dist/integrations/harnesses/gemini/index.js +60 -0
- package/dist/integrations/harnesses/gemini/result-extractor.js +121 -0
- package/dist/integrations/harnesses/index.js +28 -7
- package/dist/integrations/harnesses/opencode/agent-builder.js +1 -1
- package/dist/integrations/harnesses/opencode/index.js +17 -16
- package/dist/integrations/harnesses/opencode/session-log.js +173 -3
- package/dist/integrations/harnesses/opencode-sdk/harness.js +65 -0
- package/dist/integrations/harnesses/opencode-sdk/index.js +10 -34
- package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +642 -71
- package/dist/integrations/harnesses/openhands/agent-builder.js +126 -0
- package/dist/integrations/harnesses/openhands/index.js +58 -0
- package/dist/integrations/harnesses/openhands/result-extractor.js +103 -0
- package/dist/integrations/harnesses/pi/agent-builder.js +104 -0
- package/dist/integrations/harnesses/pi/index.js +58 -0
- package/dist/integrations/harnesses/pi/result-extractor.js +135 -0
- package/dist/integrations/harnesses/types.js +8 -0
- package/dist/integrations/session-logs/index.js +40 -11
- package/dist/llm/call-ai.js +2 -2
- package/dist/llm/client.js +34 -11
- package/dist/llm/embedder.js +67 -4
- package/dist/llm/embedders/cache.js +3 -1
- package/dist/llm/embedders/deterministic.js +66 -0
- package/dist/llm/embedders/local.js +73 -3
- package/dist/llm/feature-gate.js +16 -15
- package/dist/llm/graph-extract.js +67 -44
- package/dist/llm/memory-infer-impl.js +138 -0
- package/dist/llm/memory-infer.js +1 -127
- package/dist/llm/metadata-enhance.js +44 -31
- package/dist/llm/structured-call.js +49 -0
- package/dist/migrate-storage-node.mjs +8 -0
- package/dist/output/context.js +5 -5
- package/dist/output/renderers.js +87 -15
- package/dist/output/shapes/curate.js +14 -2
- package/dist/output/shapes/helpers.js +0 -3
- package/dist/output/shapes/passthrough.js +6 -1
- package/dist/output/text/helpers.js +241 -2
- package/dist/output/text/workflow.js +4 -1
- package/dist/registry/providers/skills-sh.js +21 -147
- package/dist/registry/providers/static-index.js +15 -157
- package/dist/registry/resolve.js +27 -9
- package/dist/runtime.js +25 -1
- package/dist/schemas/akm-config.json +14225 -0
- package/dist/schemas/akm-workflow.json +328 -0
- package/dist/scripts/migrate-storage.js +2743 -8390
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +1652 -607
- package/dist/setup/detect.js +9 -0
- package/dist/setup/legacy-config.js +106 -0
- package/dist/setup/prompt.js +57 -0
- package/dist/setup/providers.js +14 -0
- package/dist/setup/registry-stash-loader.js +12 -0
- package/dist/setup/semantic-assets.js +124 -0
- package/dist/setup/setup.js +52 -1614
- package/dist/setup/steps/connection.js +734 -0
- package/dist/setup/steps/output.js +31 -0
- package/dist/setup/steps/platforms.js +124 -0
- package/dist/setup/steps/semantic.js +27 -0
- package/dist/setup/steps/sources.js +222 -0
- package/dist/setup/steps/stashdir.js +42 -0
- package/dist/setup/steps/tasks.js +152 -0
- package/dist/sources/include.js +6 -2
- package/dist/sources/providers/filesystem.js +0 -1
- package/dist/sources/providers/git-install.js +210 -0
- package/dist/sources/providers/git-provider.js +234 -0
- package/dist/sources/providers/git-stash.js +248 -0
- package/dist/sources/providers/git.js +10 -661
- package/dist/sources/providers/npm.js +2 -6
- package/dist/sources/providers/provider-utils.js +13 -7
- package/dist/sources/providers/sync-from-ref.js +9 -1
- package/dist/sources/providers/website.js +9 -5
- package/dist/sources/website-ingest.js +187 -29
- package/dist/sources/wiki-fetchers/registry.js +53 -0
- package/dist/sources/wiki-fetchers/youtube.js +239 -0
- package/dist/storage/database.js +45 -10
- package/dist/storage/managed-db.js +82 -0
- package/dist/storage/repositories/canaries-repository.js +107 -0
- package/dist/storage/repositories/consolidation-repository.js +38 -0
- package/dist/storage/repositories/embeddings-repository.js +72 -0
- package/dist/storage/repositories/events-repository.js +187 -0
- package/dist/storage/repositories/extract-sessions-repository.js +96 -0
- package/dist/storage/repositories/improve-runs-repository.js +146 -0
- package/dist/storage/repositories/index-db.js +14 -8
- package/dist/storage/repositories/proposals-repository.js +220 -0
- package/dist/storage/repositories/recombine-repository.js +213 -0
- package/dist/storage/repositories/registry-cache.js +93 -0
- package/dist/storage/repositories/registry-index-cache-repository.js +46 -0
- package/dist/storage/repositories/task-history-repository.js +93 -0
- package/dist/storage/repositories/workflow-runs-repository.js +189 -1
- package/dist/storage/sqlite-pragmas.js +146 -0
- package/dist/tasks/backends/cron.js +1 -1
- package/dist/tasks/backends/index.js +9 -0
- package/dist/tasks/backends/launchd.js +1 -1
- package/dist/tasks/backends/schtasks.js +1 -1
- package/dist/tasks/{resolveAkmBin.js → resolve-akm-bin.js} +2 -2
- package/dist/tasks/runner.js +15 -13
- package/dist/text-import-hook.mjs +1 -1
- package/dist/wiki/wiki.js +52 -11
- package/dist/workflows/authoring/authoring.js +123 -10
- package/dist/workflows/authoring/workflow-program-template.yaml +31 -0
- package/dist/workflows/cli.js +5 -0
- package/dist/workflows/db.js +138 -4
- package/dist/workflows/exec/brief.js +484 -0
- package/dist/workflows/exec/native-executor.js +975 -0
- package/dist/workflows/exec/param-secrets.js +115 -0
- package/dist/workflows/exec/report.js +1295 -0
- package/dist/workflows/exec/run-workflow.js +596 -0
- package/dist/workflows/exec/scheduler.js +100 -0
- package/dist/workflows/exec/step-work.js +1156 -0
- package/dist/workflows/exec/unit-writer.js +23 -0
- package/dist/workflows/exec/watch.js +116 -0
- package/dist/workflows/exec/worktree.js +171 -0
- package/dist/workflows/ir/compile.js +388 -0
- package/dist/workflows/ir/params.js +54 -0
- package/dist/workflows/ir/plan-hash.js +33 -0
- package/dist/workflows/ir/schema.js +4 -0
- package/dist/workflows/parser.js +3 -1
- package/dist/workflows/program/expressions.js +369 -0
- package/dist/workflows/program/parser.js +760 -0
- package/dist/workflows/program/project.js +105 -0
- package/dist/workflows/program/schema.js +54 -0
- package/dist/workflows/renderer.js +82 -5
- package/dist/workflows/runtime/agent-identity.js +59 -14
- package/dist/workflows/runtime/runs.js +248 -153
- package/dist/workflows/runtime/unit-checkin.js +45 -0
- package/dist/workflows/runtime/workflow-asset-loader.js +188 -0
- package/dist/workflows/validate-summary.js +26 -10
- package/dist/workflows/validator.js +1 -1
- package/docs/README.md +69 -18
- package/docs/data-and-telemetry.md +7 -5
- package/docs/migration/release-notes/0.7.0.md +1 -1
- package/docs/migration/release-notes/0.9.0-beta.60.md +19 -0
- package/docs/migration/release-notes/0.9.0.md +39 -0
- package/package.json +10 -10
- package/dist/assets/tasks/core/update-stashes.yml +0 -4
- package/dist/commands/db-cli.js +0 -23
- package/dist/indexer/db/db-backup.js +0 -376
- package/dist/indexer/passes/staleness-detect.js +0 -488
|
@@ -0,0 +1,1295 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
/**
|
|
5
|
+
* `akm workflow report <run>` — the MUTATING half of the harness-neutral driver
|
|
6
|
+
* protocol (redesign addendum R3). A driver that ran a unit from `akm workflow
|
|
7
|
+
* brief` reports its result back through THIS path, which ingests the result
|
|
8
|
+
* through the SAME shared step semantics the native engine uses
|
|
9
|
+
* (`step-work.ts`), so an engine-driven run and a brief/report-driven run of the
|
|
10
|
+
* same frozen plan produce byte-identical unit graphs (the invariant R4
|
|
11
|
+
* asserts).
|
|
12
|
+
*
|
|
13
|
+
* ## No duplicated semantics (the cardinal rule)
|
|
14
|
+
*
|
|
15
|
+
* Every decision report makes is a shared function:
|
|
16
|
+
* - the expected work-list (item resolution + content-derived unit ids + input
|
|
17
|
+
* hashes + prompt assembly with recovered gate feedback) — the SAME
|
|
18
|
+
* {@link computeStepWorkList} / {@link activeGateLoop} /
|
|
19
|
+
* {@link recoverGateFeedback} `brief` and the executor call;
|
|
20
|
+
* - the input hash stored on the unit row is the engine's
|
|
21
|
+
* ({@link StepWorkUnit.resolved.inputHash});
|
|
22
|
+
* - reducer / artifact promotion / output-schema validation via
|
|
23
|
+
* {@link reduceStepOutcomes} (the executor's post-dispatch reduction);
|
|
24
|
+
* - route evaluation + artifact-judged gate completion + gate-row journaling +
|
|
25
|
+
* the bounded-loop rejection contract via {@link finalizeExecutedStep} (the
|
|
26
|
+
* engine loop's completion path).
|
|
27
|
+
*
|
|
28
|
+
* ## The ONE mutating verb, guarded
|
|
29
|
+
*
|
|
30
|
+
* A report is REFUSED unless the run is active AND no live engine lease is held
|
|
31
|
+
* (the engine owns the spine while driving). The reported unit must belong to
|
|
32
|
+
* the active step's recomputed work-list. A COMPLETED unit re-reported with the
|
|
33
|
+
* same input hash is an idempotent no-op; a different hash is replay divergence.
|
|
34
|
+
* Declared budget ceilings are enforced (journal-seeded, same rule as the
|
|
35
|
+
* engine). When a report makes the active step's work-list fully terminal it
|
|
36
|
+
* runs the identical completion path — reducer → artifact promotion → schema
|
|
37
|
+
* validation → artifact-judged gate → `completeWorkflowStep` — honoring
|
|
38
|
+
* `on_error` and `gate.max_loops`.
|
|
39
|
+
*/
|
|
40
|
+
import { randomUUID } from "node:crypto";
|
|
41
|
+
import { UsageError } from "../../core/errors.js";
|
|
42
|
+
import { appendEvent } from "../../core/events.js";
|
|
43
|
+
import { validateJsonSchemaSubset } from "../../core/json-schema.js";
|
|
44
|
+
import { withWorkflowRunsRepo, } from "../../storage/repositories/workflow-runs-repository.js";
|
|
45
|
+
import { assertRunParamsSatisfyPlan } from "../ir/params.js";
|
|
46
|
+
import { PROGRAM_RETRY_REASONS } from "../program/schema.js";
|
|
47
|
+
import { completeWorkflowStep, getNextWorkflowStep, snapshotRunForDriver, } from "../runtime/runs.js";
|
|
48
|
+
import { UNIT_STALE_MS } from "../runtime/unit-checkin.js";
|
|
49
|
+
import { buildLease, resolveRunId } from "./brief.js";
|
|
50
|
+
import { activeGateLoop, cascadeSkippedRouter, computeStepWorkList, finalizeExecutedStep, isRetryEligibleFailure, parseFrozenPlan, recoverGateFeedback, reduceEmptyStep, reduceStepOutcomes, seedJournaledRouteDecisions, selectUnitAttemptRow, stepOutputsFromEvidence, unitOutcomeFromRow, unitStillNeedsReport, } from "./step-work.js";
|
|
51
|
+
/**
|
|
52
|
+
* Claim time-to-live. Equal to the unit-checkin stale window
|
|
53
|
+
* ({@link UNIT_STALE_MS}) so a claim expires at exactly the moment
|
|
54
|
+
* `workflow brief` starts surfacing the unit as stale — an expired claim is,
|
|
55
|
+
* by construction, a stale-driver claim another driver may reclaim.
|
|
56
|
+
*/
|
|
57
|
+
const CLAIM_TTL_MS = UNIT_STALE_MS;
|
|
58
|
+
/**
|
|
59
|
+
* Finalization-lock TTL (review round 2, #5). The finalize path claims the run's
|
|
60
|
+
* engine lease as a short-lived lock so exactly ONE reporter runs a step's
|
|
61
|
+
* completion. Sized like the run lease; the loser never runs the judge (it
|
|
62
|
+
* returns idempotent success on a failed acquire), so the TTL only bounds crash
|
|
63
|
+
* recovery — and `completeWorkflowStep`'s own transactional CAS remains the true
|
|
64
|
+
* arbiter of the single spine advance regardless.
|
|
65
|
+
*/
|
|
66
|
+
const FINALIZE_LOCK_TTL_MS = 90_000;
|
|
67
|
+
// ── Entry point ──────────────────────────────────────────────────────────────
|
|
68
|
+
export async function reportWorkflowUnit(input) {
|
|
69
|
+
const nowFn = input.now ?? (() => new Date());
|
|
70
|
+
const nowIso = nowFn().toISOString();
|
|
71
|
+
const runId = await resolveRunId(input.target);
|
|
72
|
+
// #14: read the spine, run row, and unit journal in ONE snapshot so the guards
|
|
73
|
+
// below see a consistent point-in-time state (same fix as `brief`).
|
|
74
|
+
const { next, run: runRow, units } = await snapshotRunForDriver(runId);
|
|
75
|
+
const planJson = runRow.plan_json;
|
|
76
|
+
const planHash = runRow.plan_hash;
|
|
77
|
+
const leaseHolder = runRow.engine_lease_holder;
|
|
78
|
+
const leaseUntil = runRow.engine_lease_until;
|
|
79
|
+
// Refuse a non-active run: there is no work-list to report against.
|
|
80
|
+
if (next.run.status !== "active" || next.done) {
|
|
81
|
+
throw new UsageError(`Workflow run ${runId} is ${next.run.status} — \`akm workflow report\` only records results for an ACTIVE ` +
|
|
82
|
+
`run. ${next.run.status === "completed" ? "The run is already done." : `Reopen it first: \`akm workflow resume ${runId}\`.`}`);
|
|
83
|
+
}
|
|
84
|
+
// Refuse while a LIVE engine lease is held — the engine owns the spine while
|
|
85
|
+
// driving; a report would race its completion.
|
|
86
|
+
const lease = buildLease(leaseHolder, leaseUntil);
|
|
87
|
+
if (lease?.live) {
|
|
88
|
+
throw new UsageError(`Workflow run ${runId} is being driven by engine ${lease.holder} (run lease expires ${lease.until}). ` +
|
|
89
|
+
`\`akm workflow report\` is refused while the engine lease is live — wait for the engine to finish or for ` +
|
|
90
|
+
`the lease to expire before reporting units.`);
|
|
91
|
+
}
|
|
92
|
+
if (!next.step) {
|
|
93
|
+
throw new UsageError(`Workflow run ${runId} is active but has no current step to report against.`);
|
|
94
|
+
}
|
|
95
|
+
// #14: the optional spine guard. `brief` embeds `--expect-step <activeStep>` in
|
|
96
|
+
// every report command; if the active step has since changed, refuse rather
|
|
97
|
+
// than record against a step the driver did not plan against.
|
|
98
|
+
if (input.expectStep !== undefined && next.step.id !== input.expectStep) {
|
|
99
|
+
throw new UsageError(`Workflow run ${runId} is now on step "${next.step.id}", not "${input.expectStep}" (--expect-step). The spine ` +
|
|
100
|
+
`moved since you briefed it — a concurrent report/run/manual completion advanced the run. Re-run ` +
|
|
101
|
+
`\`akm workflow brief ${runId}\` and report against the current step.`, "INVALID_FLAG_VALUE");
|
|
102
|
+
}
|
|
103
|
+
const plan = loadFrozenPlan(runId, planJson, planHash);
|
|
104
|
+
// Reviewer #12: the journaled params row must still satisfy the frozen param
|
|
105
|
+
// schemas before report resolves any unit prompt from it — a violation is
|
|
106
|
+
// post-start corruption, refused loudly (mirrors the frozen-plan hash check
|
|
107
|
+
// and the tampered-params replay-divergence path).
|
|
108
|
+
assertRunParamsSatisfyPlan(runId, plan, next.run.params ?? {});
|
|
109
|
+
// Resolve the step the driver should actually report against. If the spine is
|
|
110
|
+
// parked on a NON-DISPATCHING step — a route-only step, an empty fan-out
|
|
111
|
+
// (`over: []`), a step whose every unit is an unresolvable expression, or a
|
|
112
|
+
// whole-list resolution failure — the engine auto-completes or fails it: there
|
|
113
|
+
// is no `report --unit` that could ever advance it. So `report` first settles
|
|
114
|
+
// the spine past such steps (mutating exactly as the engine would, through the
|
|
115
|
+
// SAME shared completion path), then resolves the reported unit against the
|
|
116
|
+
// resting step. This is a no-op when the active step already has real
|
|
117
|
+
// reportable work (the common case) — no settle runs, nothing mutates.
|
|
118
|
+
let ctx = buildStepContext(runId, plan, next, units);
|
|
119
|
+
if (!ctx.dispatching) {
|
|
120
|
+
const settled = await settleSpine({ plan, runId, summaryJudge: input.summaryJudge });
|
|
121
|
+
if (settled.done || settled.run.status !== "active" || !settled.step) {
|
|
122
|
+
return settledTerminalResult(input, settled);
|
|
123
|
+
}
|
|
124
|
+
const freshUnits = await withWorkflowRunsRepo((repo) => repo.getUnitsForRun(runId));
|
|
125
|
+
ctx = buildStepContext(runId, plan, settled, freshUnits);
|
|
126
|
+
}
|
|
127
|
+
const { next: state, stepState, stepPlan, workList, gateLoop, priorEvidence, units: unitRows } = ctx;
|
|
128
|
+
if (!workList) {
|
|
129
|
+
throw new UsageError(`Active step "${stepState.id}" of run ${runId} dispatches no reportable units` +
|
|
130
|
+
`${ctx.computeError ? ` (${ctx.computeError})` : " (route-only or empty)"}. There is nothing to ` +
|
|
131
|
+
`\`report --unit\` for it; run \`akm workflow brief ${runId}\` to see the current state.`);
|
|
132
|
+
}
|
|
133
|
+
const workUnit = workList.units.find((u) => u.unitId === input.unitId);
|
|
134
|
+
if (!workUnit) {
|
|
135
|
+
const valid = workList.units.map((u) => u.unitId).join(", ") || "(none)";
|
|
136
|
+
throw new UsageError(`Unit "${input.unitId}" does not belong to the active step "${stepState.id}" of run ${runId}. ` +
|
|
137
|
+
`Valid unit ids for this step: ${valid}. Run \`akm workflow brief ${runId}\` for the current work-list.`, "INVALID_FLAG_VALUE");
|
|
138
|
+
}
|
|
139
|
+
if (!workUnit.resolved.ok) {
|
|
140
|
+
throw new UsageError(`Unit "${input.unitId}" cannot be resolved (${workUnit.resolved.error}) — it has no dispatchable input to ` +
|
|
141
|
+
`report a result against. This is an authoring/data error in the workflow; fix it and start a new run.`);
|
|
142
|
+
}
|
|
143
|
+
const inputHash = workUnit.resolved.inputHash;
|
|
144
|
+
const journalId = workUnit.journalBaseId;
|
|
145
|
+
// ── running: claim / heartbeat, never advances the spine ───────────────────
|
|
146
|
+
if (input.status === "running") {
|
|
147
|
+
// The claim holder: the driver's --session-id, else a token we mint and
|
|
148
|
+
// return so the driver can reuse it (as --session-id) to heartbeat and
|
|
149
|
+
// finish the SAME claim. First unexpired claim wins.
|
|
150
|
+
const holder = input.sessionId ?? `claim:${randomUUID()}`;
|
|
151
|
+
const claimExpiresAt = new Date(nowFn().getTime() + CLAIM_TTL_MS).toISOString();
|
|
152
|
+
const claimed = await withWorkflowRunsRepo((repo) => repo.transaction(() => {
|
|
153
|
+
const existing = repo.getUnit(runId, journalId);
|
|
154
|
+
if (existing && (existing.status === "completed" || existing.status === "failed")) {
|
|
155
|
+
throw new UsageError(`Unit "${journalId}" of run ${runId} is already ${existing.status} — cannot claim a terminal unit as ` +
|
|
156
|
+
`running. Report a fresh result with --status completed|failed, or start a new run to redo it.`);
|
|
157
|
+
}
|
|
158
|
+
if (existing) {
|
|
159
|
+
// Stale-hash guard (#3): a running row whose recorded input_hash no
|
|
160
|
+
// longer matches the recomputed one is a replay divergence (a
|
|
161
|
+
// tampered/stale claim under a frozen plan) — refuse to heartbeat it.
|
|
162
|
+
assertNoHashDivergence(existing, inputHash, runId, journalId);
|
|
163
|
+
// Compare-and-set the claim owner: a LIVE claim held by a DIFFERENT
|
|
164
|
+
// holder blocks reclaim; an expired one (or a claim already ours) is
|
|
165
|
+
// (re)claimable. First unexpired claim wins (crash recovery on expiry).
|
|
166
|
+
assertClaimHeldByOrFree(existing, holder, nowIso, runId, journalId, "heartbeat");
|
|
167
|
+
repo.updateUnitClaim(runId, journalId, holder, claimExpiresAt, nowIso);
|
|
168
|
+
return "heartbeat";
|
|
169
|
+
}
|
|
170
|
+
repo.insertUnit({
|
|
171
|
+
runId,
|
|
172
|
+
unitId: journalId,
|
|
173
|
+
stepId: stepState.id,
|
|
174
|
+
nodeId: workUnit.nodeId,
|
|
175
|
+
parentUnitId: workUnit.isFanOut ? `${stepState.id}.map` : null,
|
|
176
|
+
phase: null,
|
|
177
|
+
runner: workUnit.runner,
|
|
178
|
+
model: workUnit.model ?? null,
|
|
179
|
+
inputHash,
|
|
180
|
+
startedAt: nowIso,
|
|
181
|
+
claimHolder: holder,
|
|
182
|
+
claimExpiresAt,
|
|
183
|
+
});
|
|
184
|
+
repo.updateUnitClaim(runId, journalId, holder, claimExpiresAt, nowIso);
|
|
185
|
+
return "claim";
|
|
186
|
+
}));
|
|
187
|
+
appendEvent({
|
|
188
|
+
eventType: "workflow_unit_started",
|
|
189
|
+
ref: state.run.workflowRef,
|
|
190
|
+
metadata: { runId, stepId: stepState.id, unitId: journalId, status: "running" },
|
|
191
|
+
});
|
|
192
|
+
const remaining = countRemaining(workList.units, unitRows, journalId, "running");
|
|
193
|
+
return {
|
|
194
|
+
ok: true,
|
|
195
|
+
runId,
|
|
196
|
+
stepId: stepState.id,
|
|
197
|
+
unitId: journalId,
|
|
198
|
+
status: "running",
|
|
199
|
+
gateLoop,
|
|
200
|
+
recorded: "heartbeat",
|
|
201
|
+
remainingUnits: remaining,
|
|
202
|
+
runStatus: state.run.status,
|
|
203
|
+
claim: { holder, expiresAt: claimExpiresAt },
|
|
204
|
+
message: claimed === "claim"
|
|
205
|
+
? `Claimed unit "${journalId}" of step "${stepState.id}" as ${holder} (until ${claimExpiresAt}). ` +
|
|
206
|
+
`Reuse --session-id ${holder} to heartbeat, then report the result.`
|
|
207
|
+
: `Heartbeat recorded for unit "${journalId}" of step "${stepState.id}" (claim ${holder} extended to ${claimExpiresAt}).`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
// ── completed / failed: validate, guard, write, maybe finalize ─────────────
|
|
211
|
+
const { resultJson, failureReason } = prepareResult(input, workUnit);
|
|
212
|
+
const thisTokens = input.tokens ?? 0;
|
|
213
|
+
// Guarded write: the idempotent re-report / replay-divergence check and the
|
|
214
|
+
// budget ceiling are evaluated INSIDE the same SQLite transaction as the
|
|
215
|
+
// insert+finish, so two concurrent reports (same unit, or different units of
|
|
216
|
+
// one budgeted step) serialize on the write lock — each sees the other's row
|
|
217
|
+
// and the row is always internally consistent.
|
|
218
|
+
const status = input.status;
|
|
219
|
+
const holder = input.sessionId ?? null;
|
|
220
|
+
const writeResult = await withWorkflowRunsRepo((repo) => repo.transaction(() => {
|
|
221
|
+
const existing = repo.getUnit(runId, journalId);
|
|
222
|
+
if (existing?.status === "completed") {
|
|
223
|
+
if (existing.input_hash === inputHash)
|
|
224
|
+
return { kind: "idempotent" };
|
|
225
|
+
throw new UsageError(`Replay divergence: unit "${journalId}" of run ${runId} is already recorded COMPLETED with a different ` +
|
|
226
|
+
`input hash than this report's. Under a frozen plan the same unit identity must reproduce the same ` +
|
|
227
|
+
`inputs — refusing to overwrite. Start a new run to re-execute this work.`);
|
|
228
|
+
}
|
|
229
|
+
// A FAILED terminal row is idempotence-protected (#25): the same content
|
|
230
|
+
// re-reported is a no-op; a DIFFERING re-report is refused unless --rerun
|
|
231
|
+
// is set, which records a NEW attempt (insertUnit REPLACE bumps attempts,
|
|
232
|
+
// budget admission re-applies below). A frozen-plan hash mismatch is a hard
|
|
233
|
+
// replay divergence regardless of --rerun.
|
|
234
|
+
if (existing?.status === "failed") {
|
|
235
|
+
assertNoHashDivergence(existing, inputHash, runId, journalId);
|
|
236
|
+
if (!input.rerun) {
|
|
237
|
+
const sameOutcome = status === "failed" && existing.result_json === resultJson && existing.failure_reason === failureReason;
|
|
238
|
+
if (sameOutcome)
|
|
239
|
+
return { kind: "idempotent" };
|
|
240
|
+
throw new UsageError(`Unit "${journalId}" of run ${runId} is already recorded FAILED. Re-reporting it with a different result ` +
|
|
241
|
+
`requires an explicit --rerun (which records a NEW attempt and re-applies the declared budget). Without ` +
|
|
242
|
+
`--rerun a differing re-report is refused; a same-content re-report is an idempotent no-op.`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// A `running` row being finalized: the stale-hash guard (#3) refuses a
|
|
246
|
+
// tampered/stale claim whose input_hash diverges, and the claim
|
|
247
|
+
// compare-and-set requires the matching holder while the claim is live (a
|
|
248
|
+
// claimless row, or an expired claim, finishes freely — simple drivers).
|
|
249
|
+
if (existing?.status === "running") {
|
|
250
|
+
assertNoHashDivergence(existing, inputHash, runId, journalId);
|
|
251
|
+
assertClaimHeldByOrFree(existing, holder, nowIso, runId, journalId, "finish");
|
|
252
|
+
}
|
|
253
|
+
// Budget ceilings, journal-seeded exactly as the engine seeds
|
|
254
|
+
// `DispatchBudget` (dispatch rows only — gate rows excluded). The existing
|
|
255
|
+
// row's already-spent attempts are counted before admission (#4), then this
|
|
256
|
+
// write's own increment is added, so overwriting a prior attempt never
|
|
257
|
+
// erases it from the ceiling.
|
|
258
|
+
const verdict = assessBudget(plan, repo.getUnitsForRun(runId), journalId, thisTokens, existing);
|
|
259
|
+
// A `refuse` verdict crosses a ceiling on ADMISSION: the engine would fail
|
|
260
|
+
// this dispatch WITHOUT journaling it. Write nothing; the caller fails the
|
|
261
|
+
// step (matching the engine's terminal state, not a stuck run).
|
|
262
|
+
if (verdict.kind === "refuse")
|
|
263
|
+
return { kind: "budget-refused", message: verdict.message };
|
|
264
|
+
// Journal the dispatch row, then finalize it. When this unit was already
|
|
265
|
+
// claimed with `--status running`, its row exists with the SAME dispatch
|
|
266
|
+
// metadata this insert would write; re-inserting would bump `attempts`
|
|
267
|
+
// (migration 008) and count the claim+report of ONE execution as two
|
|
268
|
+
// dispatches — inflating budget accounting relative to the engine, which
|
|
269
|
+
// does a single `insertUnit` per dispatch. So skip the insert for a live
|
|
270
|
+
// `running` claim (finishUnit below finalizes it, `attempts` unchanged);
|
|
271
|
+
// a fresh report with no claim, or a re-dispatch over a prior FAILED row,
|
|
272
|
+
// still (re)inserts and is charged an attempt.
|
|
273
|
+
if (existing?.status !== "running") {
|
|
274
|
+
repo.insertUnit({
|
|
275
|
+
runId,
|
|
276
|
+
unitId: journalId,
|
|
277
|
+
stepId: stepState.id,
|
|
278
|
+
nodeId: workUnit.nodeId,
|
|
279
|
+
parentUnitId: workUnit.isFanOut ? `${stepState.id}.map` : null,
|
|
280
|
+
phase: null,
|
|
281
|
+
runner: workUnit.runner,
|
|
282
|
+
model: workUnit.model ?? null,
|
|
283
|
+
inputHash,
|
|
284
|
+
startedAt: existing?.started_at ?? nowIso,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
// Run-lifetime token accounting (Codex round-3 finding A). `finishUnit`
|
|
288
|
+
// OVERWRITES the row's `tokens` column, so a `--rerun` over a prior FAILED
|
|
289
|
+
// attempt would otherwise ERASE that attempt's already-spent tokens from
|
|
290
|
+
// the run total — a budgeted run could spend 80 tokens on a failed report,
|
|
291
|
+
// rerun for 30 more under `max_tokens: 100`, and pass because only 30 were
|
|
292
|
+
// retained. The engine never hits this (its retries journal SEPARATE `~r`
|
|
293
|
+
// rows, each keeping its own tokens). To match, the `tokens` column on a
|
|
294
|
+
// re-dispatched row carries the CUMULATIVE spend across the row's attempts,
|
|
295
|
+
// mirroring how `attempts` accumulates: budget seeds that sum this column
|
|
296
|
+
// then see ALL tokens ever spent on the unit. A fresh write or a live
|
|
297
|
+
// `running`-claim finish (no prior tokens on the row) keeps the plain
|
|
298
|
+
// per-attempt value — and NULL when no tokens were reported — so the engine
|
|
299
|
+
// parity graph (which journals NULL for a usage-less unit) is unchanged.
|
|
300
|
+
const priorTokens = existing?.tokens ?? 0;
|
|
301
|
+
const carriedTokens = priorTokens > 0 ? priorTokens + thisTokens : (input.tokens ?? null);
|
|
302
|
+
repo.finishUnit({
|
|
303
|
+
runId,
|
|
304
|
+
unitId: journalId,
|
|
305
|
+
status,
|
|
306
|
+
resultJson,
|
|
307
|
+
tokens: carriedTokens,
|
|
308
|
+
failureReason,
|
|
309
|
+
sessionId: input.sessionId ?? null,
|
|
310
|
+
finishedAt: nowIso,
|
|
311
|
+
});
|
|
312
|
+
// A `tokens-cross` verdict: this unit's OWN tokens push the run total over
|
|
313
|
+
// `max_tokens`. The engine journals the unit (it dispatched), then aborts
|
|
314
|
+
// and fails the step. The row is written above; the caller fails the step.
|
|
315
|
+
if (verdict.kind === "tokens-cross")
|
|
316
|
+
return { kind: "budget-tokens", message: verdict.message };
|
|
317
|
+
return { kind: "written" };
|
|
318
|
+
}));
|
|
319
|
+
// Budget REFUSAL: no row written — fail the step hard, naming the ceiling
|
|
320
|
+
// (budget ceilings ignore on_error), so the run reaches the engine's terminal
|
|
321
|
+
// FAILED state rather than getting permanently stuck (peer review R3).
|
|
322
|
+
if (writeResult.kind === "budget-refused") {
|
|
323
|
+
return failStepOnBudget(runId, stepState.id, journalId, status, gateLoop, writeResult.message, false);
|
|
324
|
+
}
|
|
325
|
+
// Events carry ids/status only — never workflow-authored content. Emitted only
|
|
326
|
+
// when a row was actually written (idempotent/normal/token-crossing).
|
|
327
|
+
appendEvent({
|
|
328
|
+
eventType: "workflow_unit_started",
|
|
329
|
+
ref: state.run.workflowRef,
|
|
330
|
+
metadata: { runId, stepId: stepState.id, unitId: journalId, status: "running" },
|
|
331
|
+
});
|
|
332
|
+
appendEvent({
|
|
333
|
+
eventType: "workflow_unit_finished",
|
|
334
|
+
ref: state.run.workflowRef,
|
|
335
|
+
metadata: {
|
|
336
|
+
runId,
|
|
337
|
+
stepId: stepState.id,
|
|
338
|
+
unitId: journalId,
|
|
339
|
+
status,
|
|
340
|
+
...(failureReason ? { failureReason } : {}),
|
|
341
|
+
...(input.tokens !== undefined ? { tokens: input.tokens } : {}),
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
// Budget TOKEN crossing: the row is written; fail the step hard naming the
|
|
345
|
+
// ceiling (same terminal state as the engine's addTokens abort).
|
|
346
|
+
if (writeResult.kind === "budget-tokens") {
|
|
347
|
+
return failStepOnBudget(runId, stepState.id, journalId, status, gateLoop, writeResult.message, true);
|
|
348
|
+
}
|
|
349
|
+
const idempotent = writeResult.kind === "idempotent";
|
|
350
|
+
// Is the step's work-list now fully terminal? Re-read the journal so a
|
|
351
|
+
// concurrent report of a sibling unit is observed. Unresolvable units are
|
|
352
|
+
// never reportable and count as terminally failed (the engine's
|
|
353
|
+
// expression_error), so they never keep a step outstanding.
|
|
354
|
+
const rowsAfter = await withWorkflowRunsRepo((repo) => repo.getUnitsForRun(runId));
|
|
355
|
+
const byUnit = indexDispatchRows(rowsAfter);
|
|
356
|
+
const remaining = remainingReportableUnits(workList, byUnit);
|
|
357
|
+
if (remaining > 0) {
|
|
358
|
+
// Fail-fast (Codex round-3 finding B). Under the default `on_error: fail` a
|
|
359
|
+
// single TERMINAL unit failure already fixes the step's verdict: the shared
|
|
360
|
+
// reducer maps ANY unit failure to a failed step, so the outstanding siblings
|
|
361
|
+
// cannot change the outcome. Waiting for them (the pre-fix behavior) only
|
|
362
|
+
// strands the run forever when a driver stops after its first failure. So a
|
|
363
|
+
// failed report finalizes the step NOW — through the SAME completion path +
|
|
364
|
+
// finalize CAS as a fully-terminal work-list — instead of returning as
|
|
365
|
+
// outstanding. Exceptions kept identical to the engine: `on_error: continue`
|
|
366
|
+
// always waits for the full work-list; a RETRY-ELIGIBLE failure is not yet
|
|
367
|
+
// terminal (the unit may still be re-run under its retry budget — the
|
|
368
|
+
// `--rerun` form brief advertises, mirroring the engine's `~r<n>` retry), so
|
|
369
|
+
// it too keeps waiting; and an idempotent re-report changed nothing to act on.
|
|
370
|
+
if (status === "failed" &&
|
|
371
|
+
!idempotent &&
|
|
372
|
+
workList.template.onError === "fail" &&
|
|
373
|
+
!isRetryEligibleFailure(workUnit, byUnit.get(journalId), failureReason)) {
|
|
374
|
+
return finalizeStep({
|
|
375
|
+
runId,
|
|
376
|
+
next: state,
|
|
377
|
+
plan,
|
|
378
|
+
stepPlan,
|
|
379
|
+
stepState,
|
|
380
|
+
workList,
|
|
381
|
+
byUnit,
|
|
382
|
+
gateLoop,
|
|
383
|
+
priorEvidence,
|
|
384
|
+
summaryJudge: input.summaryJudge,
|
|
385
|
+
now: nowFn,
|
|
386
|
+
written: { unitId: journalId, status },
|
|
387
|
+
recorded: "written",
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
// Not fully terminal → nothing to finalize. A written report advanced the
|
|
391
|
+
// work-list by one; an idempotent re-report changed nothing.
|
|
392
|
+
return {
|
|
393
|
+
ok: true,
|
|
394
|
+
runId,
|
|
395
|
+
stepId: stepState.id,
|
|
396
|
+
unitId: journalId,
|
|
397
|
+
status,
|
|
398
|
+
gateLoop,
|
|
399
|
+
recorded: idempotent ? "idempotent" : "written",
|
|
400
|
+
remainingUnits: remaining,
|
|
401
|
+
runStatus: state.run.status,
|
|
402
|
+
message: idempotent
|
|
403
|
+
? `Unit "${journalId}" was already recorded — no change (idempotent re-report). ${remaining} unit(s) still outstanding for step "${stepState.id}".`
|
|
404
|
+
: `Recorded unit "${journalId}" (${status}). ${remaining} unit(s) still outstanding for step "${stepState.id}".`,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
// The work-list is fully terminal. For an idempotent re-report this is EITHER
|
|
408
|
+
// the common case (the step's completion already ran on the first report and
|
|
409
|
+
// the spine has since moved off this step) OR crash recovery: the process died
|
|
410
|
+
// between the last unit write and `completeWorkflowStep`, so every unit is
|
|
411
|
+
// journaled-terminal but the step never advanced. Re-read the spine: only when
|
|
412
|
+
// this step is STILL the active, pending step is there completion left to run —
|
|
413
|
+
// and then ANY driver (not just the engine) must be able to finalize it, or the
|
|
414
|
+
// step is permanently un-advanceable through brief/report (peer review R3,
|
|
415
|
+
// engine/driver crash-recovery symmetry). If the spine already moved past this
|
|
416
|
+
// step, a concurrent report finalized it first and this is a true no-op.
|
|
417
|
+
if (idempotent) {
|
|
418
|
+
const fresh = await getNextWorkflowStep(runId);
|
|
419
|
+
if (fresh.run.status !== "active" || fresh.step?.id !== stepState.id) {
|
|
420
|
+
return {
|
|
421
|
+
ok: true,
|
|
422
|
+
runId,
|
|
423
|
+
stepId: stepState.id,
|
|
424
|
+
unitId: journalId,
|
|
425
|
+
status,
|
|
426
|
+
gateLoop,
|
|
427
|
+
recorded: "idempotent",
|
|
428
|
+
remainingUnits: 0,
|
|
429
|
+
runStatus: fresh.run.status,
|
|
430
|
+
message: `Unit "${journalId}" was already recorded — no change (idempotent re-report).`,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
// Fully terminal AND this step still needs completion → run the SHARED
|
|
435
|
+
// completion path (identical for a first report and a crash-recovering
|
|
436
|
+
// idempotent re-report).
|
|
437
|
+
return finalizeStep({
|
|
438
|
+
runId,
|
|
439
|
+
next: state,
|
|
440
|
+
plan,
|
|
441
|
+
stepPlan,
|
|
442
|
+
stepState,
|
|
443
|
+
workList,
|
|
444
|
+
byUnit,
|
|
445
|
+
gateLoop,
|
|
446
|
+
priorEvidence,
|
|
447
|
+
summaryJudge: input.summaryJudge,
|
|
448
|
+
now: nowFn,
|
|
449
|
+
written: { unitId: journalId, status },
|
|
450
|
+
recorded: idempotent ? "idempotent" : "written",
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
// ── The settle verb (finding D) ──────────────────────────────────────────────
|
|
454
|
+
/**
|
|
455
|
+
* `akm workflow report <run> --settle` — the mutating verb that advances a run
|
|
456
|
+
* parked on a NON-DISPATCHING step (Codex round-3 finding D).
|
|
457
|
+
*
|
|
458
|
+
* A driver following the documented `brief → execute → report` loop has no
|
|
459
|
+
* `report --unit` it can run when the active step dispatches nothing: a
|
|
460
|
+
* params-based route step, an empty fan-out (`over: []`), or a step whose every
|
|
461
|
+
* unit is unresolvable. The engine auto-advances such steps, but a pure
|
|
462
|
+
* brief/report driver — one whose FIRST step is a params-routed route, with no
|
|
463
|
+
* preceding unit report to trigger the internal `settleSpine` — would get stuck.
|
|
464
|
+
*
|
|
465
|
+
* `--settle` runs the EXISTING deterministic settle path ({@link settleSpine} —
|
|
466
|
+
* route decision journaled, cascaded skips, spine advance) under the SAME guards
|
|
467
|
+
* as `report --unit`: it refuses a non-active run, refuses while a LIVE engine
|
|
468
|
+
* lease is held, honors `--expect-step`, and REFUSES when the active step has
|
|
469
|
+
* reportable units (a driver must `report --unit` those, not settle them). The
|
|
470
|
+
* settle runs under a short-lived engine-lease finalize lock (the same CAS
|
|
471
|
+
* `finalizeStep` uses) so two concurrent settles/reports cannot double-journal.
|
|
472
|
+
*/
|
|
473
|
+
export async function settleWorkflowSpine(input) {
|
|
474
|
+
const nowFn = input.now ?? (() => new Date());
|
|
475
|
+
const runId = await resolveRunId(input.target);
|
|
476
|
+
const { next, run: runRow, units } = await snapshotRunForDriver(runId);
|
|
477
|
+
if (next.run.status !== "active" || next.done) {
|
|
478
|
+
throw new UsageError(`Workflow run ${runId} is ${next.run.status} — \`akm workflow report --settle\` only advances an ACTIVE run. ` +
|
|
479
|
+
`${next.run.status === "completed" ? "The run is already done." : `Reopen it first: \`akm workflow resume ${runId}\`.`}`);
|
|
480
|
+
}
|
|
481
|
+
const lease = buildLease(runRow.engine_lease_holder, runRow.engine_lease_until);
|
|
482
|
+
if (lease?.live) {
|
|
483
|
+
throw new UsageError(`Workflow run ${runId} is being driven by engine ${lease.holder} (run lease expires ${lease.until}). ` +
|
|
484
|
+
`\`akm workflow report --settle\` is refused while the engine lease is live — the engine settles its own ` +
|
|
485
|
+
`non-dispatching steps.`);
|
|
486
|
+
}
|
|
487
|
+
if (!next.step) {
|
|
488
|
+
throw new UsageError(`Workflow run ${runId} is active but has no current step to settle.`);
|
|
489
|
+
}
|
|
490
|
+
if (input.expectStep !== undefined && next.step.id !== input.expectStep) {
|
|
491
|
+
throw new UsageError(`Workflow run ${runId} is now on step "${next.step.id}", not "${input.expectStep}" (--expect-step). The spine ` +
|
|
492
|
+
`moved since you briefed it — re-run \`akm workflow brief ${runId}\` and settle against the current step.`, "INVALID_FLAG_VALUE");
|
|
493
|
+
}
|
|
494
|
+
const plan = loadFrozenPlan(runId, runRow.plan_json, runRow.plan_hash);
|
|
495
|
+
assertRunParamsSatisfyPlan(runId, plan, next.run.params ?? {});
|
|
496
|
+
// A step with resolvable units is settled ONLY when its work-list is FULLY
|
|
497
|
+
// TERMINAL — every resolvable unit run to a terminal state, nothing left to
|
|
498
|
+
// `report --unit` — yet still un-finalized (a required-gate block that was
|
|
499
|
+
// resumed, or a crash between the last unit write and completion; owner
|
|
500
|
+
// manual-validation finding 3). Such a list has no pending unit to report, so
|
|
501
|
+
// `--settle` runs the SAME shared completion path a report would
|
|
502
|
+
// (reducer → gate → advance / re-block). A step with GENUINELY PENDING units
|
|
503
|
+
// (pending, in-flight, or retry-eligible failed) is still refused — those
|
|
504
|
+
// advance via `report --unit`.
|
|
505
|
+
const ctx = buildStepContext(runId, plan, next, units);
|
|
506
|
+
if (ctx.dispatching) {
|
|
507
|
+
const workList = ctx.workList;
|
|
508
|
+
if (!workList) {
|
|
509
|
+
throw new UsageError(`Active step "${ctx.stepState.id}" of run ${runId} has no work-list to settle.`);
|
|
510
|
+
}
|
|
511
|
+
const byUnit = indexDispatchRows(units);
|
|
512
|
+
const outstanding = workList.units.filter((u) => unitStillNeedsReport(u, byUnit));
|
|
513
|
+
if (outstanding.length > 0) {
|
|
514
|
+
const valid = outstanding.filter((u) => u.resolved.ok).map((u) => u.unitId);
|
|
515
|
+
throw new UsageError(`Active step "${ctx.stepState.id}" of run ${runId} has reportable units — advance it with ` +
|
|
516
|
+
`\`akm workflow report ${runId} --unit <id> ...\`, not --settle. Unit ids: ${valid.join(", ") || "(none)"}.`, "INVALID_FLAG_VALUE");
|
|
517
|
+
}
|
|
518
|
+
// Fully terminal: finalize through the shared completion path. `finalizeStep`
|
|
519
|
+
// runs its OWN finalize CAS (claims the engine lease as a lock), so we must
|
|
520
|
+
// NOT also hold the settle lock here — call it directly.
|
|
521
|
+
return finalizeStep({
|
|
522
|
+
runId,
|
|
523
|
+
next,
|
|
524
|
+
plan,
|
|
525
|
+
stepPlan: ctx.stepPlan,
|
|
526
|
+
stepState: ctx.stepState,
|
|
527
|
+
workList,
|
|
528
|
+
byUnit,
|
|
529
|
+
gateLoop: ctx.gateLoop,
|
|
530
|
+
priorEvidence: ctx.priorEvidence,
|
|
531
|
+
summaryJudge: input.summaryJudge,
|
|
532
|
+
now: nowFn,
|
|
533
|
+
written: { unitId: "(settle)", status: "skipped" },
|
|
534
|
+
recorded: "not-recorded",
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
// Finalize CAS: claim the engine lease as a short-lived settle lock, thread the
|
|
538
|
+
// holder through the settle so its `completeWorkflowStep` calls pass the
|
|
539
|
+
// single-driver guard, and release it in `finally`.
|
|
540
|
+
const holder = `report-settle:${randomUUID()}`;
|
|
541
|
+
const nowIso = nowFn().toISOString();
|
|
542
|
+
const lockExpiry = new Date(nowFn().getTime() + FINALIZE_LOCK_TTL_MS).toISOString();
|
|
543
|
+
const acquired = await withWorkflowRunsRepo((repo) => repo.acquireEngineLease(runId, holder, lockExpiry, nowIso));
|
|
544
|
+
if (!acquired) {
|
|
545
|
+
// A concurrent finalizer/settler holds the lock; report the fresh spine state
|
|
546
|
+
// as idempotent success rather than racing it.
|
|
547
|
+
const fresh = await getNextWorkflowStep(runId);
|
|
548
|
+
return settleVerbResult(runId, fresh, `A concurrent driver is settling run ${runId}`);
|
|
549
|
+
}
|
|
550
|
+
let settled;
|
|
551
|
+
try {
|
|
552
|
+
settled = await settleSpine({ plan, runId, summaryJudge: input.summaryJudge, leaseHolder: holder });
|
|
553
|
+
}
|
|
554
|
+
finally {
|
|
555
|
+
await withWorkflowRunsRepo((repo) => repo.releaseEngineLease(runId, holder));
|
|
556
|
+
}
|
|
557
|
+
return settleVerbResult(runId, settled);
|
|
558
|
+
}
|
|
559
|
+
/** Shape a `--settle` outcome as a (unit-less) report result. */
|
|
560
|
+
function settleVerbResult(runId, state, contendedNote) {
|
|
561
|
+
const runStatus = state.run.status;
|
|
562
|
+
const message = runStatus === "completed"
|
|
563
|
+
? `Settled non-dispatching steps — the workflow run is now DONE.`
|
|
564
|
+
: state.step
|
|
565
|
+
? `Settled non-dispatching steps. Next: run \`akm workflow brief ${runId}\` for step "${state.step.id}".`
|
|
566
|
+
: `Run ${runId} is ${runStatus} after settling.`;
|
|
567
|
+
return {
|
|
568
|
+
ok: true,
|
|
569
|
+
runId,
|
|
570
|
+
stepId: state.run.currentStepId ?? "(none)",
|
|
571
|
+
// No unit was reported — the settle verb advances the deterministic spine.
|
|
572
|
+
unitId: "(settle)",
|
|
573
|
+
status: "skipped",
|
|
574
|
+
gateLoop: 1,
|
|
575
|
+
recorded: "not-recorded",
|
|
576
|
+
remainingUnits: 0,
|
|
577
|
+
...(runStatus === "completed" ? { stepOutcome: { kind: "advanced" } } : {}),
|
|
578
|
+
runStatus,
|
|
579
|
+
message: contendedNote ? `${contendedNote} (idempotent success). ${message}` : message,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Compute the report context for a run's active step: find the frozen step plan,
|
|
584
|
+
* project prior evidence, recover the gate loop + feedback, and compute the
|
|
585
|
+
* SHARED work-list (same ids/hashes/prompts the engine and `brief` compute).
|
|
586
|
+
* `dispatching` is false for the non-dispatching steps the engine auto-advances
|
|
587
|
+
* (route-only, empty fan-out, all-unresolvable, whole-list failure) — the report
|
|
588
|
+
* path settles past those rather than getting stuck at a step no `report --unit`
|
|
589
|
+
* can complete.
|
|
590
|
+
*/
|
|
591
|
+
function buildStepContext(runId, plan, next, units) {
|
|
592
|
+
const stepState = next.step;
|
|
593
|
+
if (!stepState) {
|
|
594
|
+
throw new UsageError(`Workflow run ${runId} is active but has no current step to report against.`);
|
|
595
|
+
}
|
|
596
|
+
const stepPlan = plan.steps.find((s) => s.stepId === stepState.id);
|
|
597
|
+
if (!stepPlan) {
|
|
598
|
+
throw new UsageError(`Step "${stepState.id}" of run ${runId} is not present in the run's frozen plan — cannot report against it.`);
|
|
599
|
+
}
|
|
600
|
+
const priorEvidence = {};
|
|
601
|
+
for (const s of next.workflow.steps)
|
|
602
|
+
priorEvidence[s.id] = s.evidence;
|
|
603
|
+
const stepOutputs = stepOutputsFromEvidence(priorEvidence);
|
|
604
|
+
const gateLoop = activeGateLoop(units, stepState.id);
|
|
605
|
+
const gateFeedback = recoverGateFeedback(units, stepState.id, gateLoop);
|
|
606
|
+
// Route-only steps carry no execution subgraph — nothing to report.
|
|
607
|
+
if (!stepPlan.root) {
|
|
608
|
+
return { next, stepState, stepPlan, workList: null, gateLoop, priorEvidence, units, dispatching: false };
|
|
609
|
+
}
|
|
610
|
+
const computed = computeStepWorkList(stepPlan, {
|
|
611
|
+
runId,
|
|
612
|
+
params: next.run.params ?? {},
|
|
613
|
+
stepOutputs,
|
|
614
|
+
gateLoop,
|
|
615
|
+
...(gateFeedback ? { gateFeedback } : {}),
|
|
616
|
+
});
|
|
617
|
+
if (!computed.ok) {
|
|
618
|
+
return {
|
|
619
|
+
next,
|
|
620
|
+
stepState,
|
|
621
|
+
stepPlan,
|
|
622
|
+
workList: null,
|
|
623
|
+
computeError: computed.error,
|
|
624
|
+
gateLoop,
|
|
625
|
+
priorEvidence,
|
|
626
|
+
units,
|
|
627
|
+
dispatching: false,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
const workList = computed.list;
|
|
631
|
+
// A step is dispatching only if a driver can actually report ≥1 unit: an empty
|
|
632
|
+
// fan-out or an all-unresolvable work-list has nothing to report.
|
|
633
|
+
const dispatching = workList.units.some((u) => u.resolved.ok);
|
|
634
|
+
return { next, stepState, stepPlan, workList, gateLoop, priorEvidence, units, dispatching };
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Rebuild a step's unit outcomes from the journal and reduce them through the
|
|
638
|
+
* SAME functions the executor uses. An EMPTY work-list promotes the degenerate
|
|
639
|
+
* empty artifact ({@link reduceEmptyStep}); otherwise each unit is rehydrated
|
|
640
|
+
* from its journal row, and an UNRESOLVABLE unit (a bad `item.<path>` reference)
|
|
641
|
+
* is treated as the engine's immediate `expression_error` failure — never
|
|
642
|
+
* journaled, always reduced as a failed outcome — so a partially- or fully-
|
|
643
|
+
* unresolvable step reduces identically on both surfaces.
|
|
644
|
+
*/
|
|
645
|
+
function reduceWorkListOutcomes(stepPlan, workList, byUnit) {
|
|
646
|
+
if (workList.units.length === 0) {
|
|
647
|
+
return reduceEmptyStep(stepPlan, workList.reducer);
|
|
648
|
+
}
|
|
649
|
+
const outcomes = [];
|
|
650
|
+
for (const u of workList.units) {
|
|
651
|
+
if (!u.resolved.ok) {
|
|
652
|
+
outcomes.push({ unitId: u.unitId, ok: false, failureReason: "expression_error", error: u.resolved.error });
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
// Reduce each unit by its BEST terminal attempt (base + `~r<n>` retries), the
|
|
656
|
+
// SAME reuse the engine applies (shared {@link selectUnitAttemptRow}). A unit
|
|
657
|
+
// whose base attempt failed but whose retry completed reduces as COMPLETED,
|
|
658
|
+
// exactly like engine resume (finding C). A still-outstanding sibling with no
|
|
659
|
+
// terminal row is excluded rather than reduced against a missing row — this
|
|
660
|
+
// only arises on the fail-fast path (a single failed unit under
|
|
661
|
+
// `on_error: fail` already fixes the step's verdict; unreported siblings play
|
|
662
|
+
// no part in it). On the normal finalize path every resolvable unit is
|
|
663
|
+
// terminal, so nothing is excluded and the reduction is unchanged.
|
|
664
|
+
const row = selectUnitAttemptRow(u, byUnit);
|
|
665
|
+
if (!row || (row.status !== "completed" && row.status !== "failed"))
|
|
666
|
+
continue;
|
|
667
|
+
outcomes.push(unitOutcomeFromRow(u.unitId, row, u.schema !== undefined));
|
|
668
|
+
}
|
|
669
|
+
return reduceStepOutcomes(stepPlan, workList.reducer, workList.isFanOut, workList.template.onError, outcomes);
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Run ONE completion attempt for a reduced step outcome through the SHARED
|
|
673
|
+
* {@link finalizeExecutedStep} (route eval → artifact-judged gate → gate-row
|
|
674
|
+
* journaling → `completeWorkflowStep`), normalizing its result. A typed-artifact
|
|
675
|
+
* schema mismatch is a HARD failure on the report surface (documented call:
|
|
676
|
+
* the ENGINE recovers it from in-invocation memory, but no gate row is
|
|
677
|
+
* journaled, so the stateless report path cannot recover the feedback across
|
|
678
|
+
* invocations — and synthesizing a gate row would break engine/report unit-graph
|
|
679
|
+
* parity). A GATE rejection journals its `<stepId>.gate:l<loop>` row, so its
|
|
680
|
+
* feedback IS recoverable and stays a real bounded loop.
|
|
681
|
+
*/
|
|
682
|
+
async function runStepCompletion(args) {
|
|
683
|
+
const maxLoops = Math.max(1, args.stepPlan.gate.maxLoops ?? 1);
|
|
684
|
+
const lease = args.leaseHolder !== undefined ? { leaseHolder: args.leaseHolder } : {};
|
|
685
|
+
const finalize = await finalizeExecutedStep({
|
|
686
|
+
runId: args.runId,
|
|
687
|
+
workflowRef: args.workflowRef,
|
|
688
|
+
stepId: args.stepId,
|
|
689
|
+
stepPlan: args.stepPlan,
|
|
690
|
+
completionCriteria: args.completionCriteria,
|
|
691
|
+
gateLoop: args.gateLoop,
|
|
692
|
+
loopsRemaining: args.gateLoop < maxLoops,
|
|
693
|
+
result: args.reduced,
|
|
694
|
+
priorEvidence: args.priorEvidence,
|
|
695
|
+
params: args.params,
|
|
696
|
+
routeSelected: args.routeSelected,
|
|
697
|
+
routeUnselected: args.routeUnselected,
|
|
698
|
+
summaryJudge: args.summaryJudge,
|
|
699
|
+
...lease,
|
|
700
|
+
});
|
|
701
|
+
if (finalize.kind === "retry") {
|
|
702
|
+
if (!args.reduced.ok) {
|
|
703
|
+
// Typed-artifact schema mismatch → hard failure on the report surface.
|
|
704
|
+
await completeWorkflowStep({
|
|
705
|
+
runId: args.runId,
|
|
706
|
+
stepId: args.stepId,
|
|
707
|
+
status: "failed",
|
|
708
|
+
notes: args.reduced.summary,
|
|
709
|
+
evidence: args.reduced.evidence,
|
|
710
|
+
...lease,
|
|
711
|
+
});
|
|
712
|
+
return { kind: "failed", summary: args.reduced.summary };
|
|
713
|
+
}
|
|
714
|
+
return {
|
|
715
|
+
kind: "gate-rejected",
|
|
716
|
+
loopsRemaining: true,
|
|
717
|
+
missing: finalize.gateFeedback.missing,
|
|
718
|
+
feedback: finalize.gateFeedback.feedback,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
if (finalize.kind === "gate-exhausted") {
|
|
722
|
+
return {
|
|
723
|
+
kind: "gate-rejected",
|
|
724
|
+
loopsRemaining: false,
|
|
725
|
+
missing: finalize.gateRejection.missing,
|
|
726
|
+
feedback: finalize.gateRejection.feedback,
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
if (finalize.kind === "failed") {
|
|
730
|
+
return { kind: "failed", summary: finalize.summary };
|
|
731
|
+
}
|
|
732
|
+
if (finalize.kind === "blocked") {
|
|
733
|
+
// Reviewer #18: a required gate with no judge available. The frozen plan's
|
|
734
|
+
// `gate.required` rides both surfaces, so the report path blocks identically.
|
|
735
|
+
return { kind: "blocked", summary: finalize.summary };
|
|
736
|
+
}
|
|
737
|
+
return { kind: "advanced" };
|
|
738
|
+
}
|
|
739
|
+
async function finalizeStep(args) {
|
|
740
|
+
const { runId, next, plan, stepPlan, stepState, workList, byUnit, gateLoop, recorded } = args;
|
|
741
|
+
// ── Finalization CAS (#5) ──────────────────────────────────────────────────
|
|
742
|
+
// Two reporters that both observe the work-list fully terminal would otherwise
|
|
743
|
+
// BOTH run the completion path — double-judging the gate, journaling duplicate
|
|
744
|
+
// gate rows, racing `completeWorkflowStep`. Claim the run's engine lease as a
|
|
745
|
+
// short-lived finalize lock (the same atomic single-UPDATE primitive the engine
|
|
746
|
+
// uses): exactly one reporter wins. The loser (failed acquire) never runs the
|
|
747
|
+
// judge; it re-reads the spine and returns the winner's outcome as idempotent
|
|
748
|
+
// success — never a raw throw after useful work. `completeWorkflowStep`'s own
|
|
749
|
+
// transactional CAS remains the ultimate arbiter of the single spine advance.
|
|
750
|
+
const finalizeHolder = `report-finalize:${randomUUID()}`;
|
|
751
|
+
const nowIso = args.now().toISOString();
|
|
752
|
+
const lockExpiry = new Date(args.now().getTime() + FINALIZE_LOCK_TTL_MS).toISOString();
|
|
753
|
+
const acquired = await withWorkflowRunsRepo((repo) => repo.acquireEngineLease(runId, finalizeHolder, lockExpiry, nowIso));
|
|
754
|
+
if (!acquired) {
|
|
755
|
+
// A concurrent finalizer holds the lock; it will advance the step exactly
|
|
756
|
+
// once. Return idempotent success reflecting the freshest spine state.
|
|
757
|
+
return contendedFinalizeResult(runId, stepState, args.written, gateLoop, recorded);
|
|
758
|
+
}
|
|
759
|
+
let completion;
|
|
760
|
+
const maxLoops = Math.max(1, stepPlan.gate.maxLoops ?? 1);
|
|
761
|
+
try {
|
|
762
|
+
// Under the lock, re-read the spine: a PRIOR finalizer may have already
|
|
763
|
+
// advanced this step (a sequential race — both reporters saw the work-list
|
|
764
|
+
// terminal, the first finished before this one acquired). If so, skip
|
|
765
|
+
// re-completion and report idempotent success — the step is done.
|
|
766
|
+
const fresh = await getNextWorkflowStep(runId);
|
|
767
|
+
if (fresh.run.status !== "active" || fresh.step?.id !== stepState.id) {
|
|
768
|
+
return contendedFinalizeResult(runId, stepState, args.written, gateLoop, recorded, fresh);
|
|
769
|
+
}
|
|
770
|
+
const reduced = reduceWorkListOutcomes(stepPlan, workList, byUnit);
|
|
771
|
+
// Route/skip bookkeeping seeded from the journal so cascaded skips survive
|
|
772
|
+
// (identical to the engine's resume seeding).
|
|
773
|
+
const routeSelected = new Set();
|
|
774
|
+
const routeUnselected = new Map();
|
|
775
|
+
seedJournaledRouteDecisions(plan, next, routeSelected, routeUnselected);
|
|
776
|
+
completion = await runStepCompletion({
|
|
777
|
+
runId,
|
|
778
|
+
workflowRef: next.run.workflowRef,
|
|
779
|
+
stepPlan,
|
|
780
|
+
stepId: stepState.id,
|
|
781
|
+
completionCriteria: stepState.completionCriteria ?? [],
|
|
782
|
+
gateLoop,
|
|
783
|
+
reduced,
|
|
784
|
+
priorEvidence: args.priorEvidence,
|
|
785
|
+
params: next.run.params ?? {},
|
|
786
|
+
routeSelected,
|
|
787
|
+
routeUnselected,
|
|
788
|
+
summaryJudge: args.summaryJudge,
|
|
789
|
+
leaseHolder: finalizeHolder,
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
finally {
|
|
793
|
+
// Release the finalize lock before the trailing settle/messaging below runs
|
|
794
|
+
// its own (unlocked) `completeWorkflowStep` calls for downstream
|
|
795
|
+
// non-dispatching steps.
|
|
796
|
+
await withWorkflowRunsRepo((repo) => repo.releaseEngineLease(runId, finalizeHolder));
|
|
797
|
+
}
|
|
798
|
+
if (completion.kind === "failed") {
|
|
799
|
+
const state = await getNextWorkflowStep(runId);
|
|
800
|
+
return reportResult(runId, stepState.id, args.written, gateLoop, state.run.status, { kind: "failed", summary: completion.summary }, `Step "${stepState.id}" failed: ${completion.summary}`, recorded);
|
|
801
|
+
}
|
|
802
|
+
if (completion.kind === "blocked") {
|
|
803
|
+
// Reviewer #18: a required gate with no judge available blocked the step.
|
|
804
|
+
// The run is now `blocked`; a human resolves it via `akm workflow resume`.
|
|
805
|
+
const state = await getNextWorkflowStep(runId);
|
|
806
|
+
return reportResult(runId, stepState.id, args.written, gateLoop, state.run.status, { kind: "blocked", summary: completion.summary }, `Step "${stepState.id}" is BLOCKED: ${completion.summary}`, recorded);
|
|
807
|
+
}
|
|
808
|
+
if (completion.kind === "gate-rejected") {
|
|
809
|
+
if (completion.loopsRemaining) {
|
|
810
|
+
const nextLoop = gateLoop + 1;
|
|
811
|
+
return reportResult(runId, stepState.id, args.written, gateLoop, "active", {
|
|
812
|
+
kind: "gate-rejected",
|
|
813
|
+
loopsRemaining: true,
|
|
814
|
+
missing: completion.missing,
|
|
815
|
+
feedback: completion.feedback,
|
|
816
|
+
summary: completion.feedback,
|
|
817
|
+
}, `Step "${stepState.id}" was rejected — run \`akm workflow brief ${runId}\` for loop ${nextLoop}'s work-list (feedback threaded in).`, recorded);
|
|
818
|
+
}
|
|
819
|
+
return reportResult(runId, stepState.id, args.written, gateLoop, "active", {
|
|
820
|
+
kind: "gate-rejected",
|
|
821
|
+
loopsRemaining: false,
|
|
822
|
+
missing: completion.missing,
|
|
823
|
+
feedback: completion.feedback,
|
|
824
|
+
summary: completion.feedback,
|
|
825
|
+
}, `Step "${stepState.id}" was rejected and its ${maxLoops}-loop gate budget is exhausted. Resolve it manually (\`akm workflow complete\`/\`resume\`/\`abandon\`).`, recorded);
|
|
826
|
+
}
|
|
827
|
+
// advanced — the spine moved. Settle forward over any non-dispatching steps
|
|
828
|
+
// (route-only / skipped / empty fan-out / all-unresolvable) so the run never
|
|
829
|
+
// gets stuck at a step no driver could report, then surface the resting state.
|
|
830
|
+
const state = await settleSpine({ plan, runId, summaryJudge: args.summaryJudge });
|
|
831
|
+
const message = state.run.status === "completed"
|
|
832
|
+
? `Step "${stepState.id}" completed — the workflow run is now DONE.`
|
|
833
|
+
: state.step
|
|
834
|
+
? `Step "${stepState.id}" completed. Next: run \`akm workflow brief ${runId}\` for step "${state.step.id}".`
|
|
835
|
+
: `Step "${stepState.id}" completed; run is ${state.run.status}.`;
|
|
836
|
+
return reportResult(runId, stepState.id, args.written, gateLoop, state.run.status, { kind: "advanced" }, message, recorded);
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Settle the spine forward over every NON-DISPATCHING step the engine would
|
|
840
|
+
* auto-advance but no `report --unit` could ever complete: a route-skipped
|
|
841
|
+
* target, a route-only step, an empty fan-out (`over: []`), a step whose every
|
|
842
|
+
* unit is unresolvable, and a whole-list resolution failure. Each is completed
|
|
843
|
+
* (or failed) through the SAME shared helpers the engine uses, so the run does
|
|
844
|
+
* not get stuck — the exact gap peer review R3 flagged. Stops at the first step
|
|
845
|
+
* with real reportable work, or when the run leaves `active`.
|
|
846
|
+
*/
|
|
847
|
+
async function settleSpine(args) {
|
|
848
|
+
const { plan, runId, summaryJudge, leaseHolder } = args;
|
|
849
|
+
const leaseArg = leaseHolder !== undefined ? { leaseHolder } : {};
|
|
850
|
+
let state = await getNextWorkflowStep(runId);
|
|
851
|
+
const routeSelected = new Set();
|
|
852
|
+
const routeUnselected = new Map();
|
|
853
|
+
seedJournaledRouteDecisions(plan, state, routeSelected, routeUnselected);
|
|
854
|
+
// Bounded: each iteration advances the spine by one step OR advances one gate
|
|
855
|
+
// loop of a stuck gated step (which journals a gate row and is capped by that
|
|
856
|
+
// step's max_loops). The sum-of-loops bound cannot be exceeded.
|
|
857
|
+
const cap = plan.steps.reduce((n, s) => n + Math.max(1, s.gate.maxLoops ?? 1) + 2, 1);
|
|
858
|
+
for (let guard = 0; guard < cap && state.run.status === "active" && state.step; guard++) {
|
|
859
|
+
const step = state.step;
|
|
860
|
+
const sp = plan.steps.find((s) => s.stepId === step.id);
|
|
861
|
+
if (!sp)
|
|
862
|
+
break;
|
|
863
|
+
// A route-skipped target: complete it as skipped, cascading if it is itself
|
|
864
|
+
// a router (identical to the engine loop's skip handling).
|
|
865
|
+
const skipInfo = routeUnselected.get(step.id);
|
|
866
|
+
if (skipInfo && !routeSelected.has(step.id)) {
|
|
867
|
+
if (sp.route)
|
|
868
|
+
cascadeSkippedRouter(sp.route, step.id, routeUnselected);
|
|
869
|
+
const notes = skipInfo.selected === null
|
|
870
|
+
? `Skipped by route: step "${skipInfo.router}" was itself skipped, so none of its branch targets run.`
|
|
871
|
+
: `Skipped by route: step "${skipInfo.router}" selected "${skipInfo.selected}".`;
|
|
872
|
+
await completeWorkflowStep({ runId, stepId: step.id, status: "skipped", notes, ...leaseArg });
|
|
873
|
+
state = await getNextWorkflowStep(runId);
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
// A route-only step (no execution subgraph): evaluate + complete it here.
|
|
877
|
+
if (!sp.root && sp.route) {
|
|
878
|
+
const priorEvidence = {};
|
|
879
|
+
for (const s of state.workflow.steps)
|
|
880
|
+
priorEvidence[s.id] = s.evidence;
|
|
881
|
+
const fin = await finalizeExecutedStep({
|
|
882
|
+
runId,
|
|
883
|
+
workflowRef: state.run.workflowRef,
|
|
884
|
+
stepId: step.id,
|
|
885
|
+
stepPlan: sp,
|
|
886
|
+
completionCriteria: step.completionCriteria ?? [],
|
|
887
|
+
gateLoop: 1,
|
|
888
|
+
loopsRemaining: false,
|
|
889
|
+
result: {
|
|
890
|
+
ok: true,
|
|
891
|
+
units: [],
|
|
892
|
+
evidence: {},
|
|
893
|
+
summary: `Step "${step.id}" is a route step — no units dispatched.`,
|
|
894
|
+
},
|
|
895
|
+
priorEvidence,
|
|
896
|
+
params: state.run.params ?? {},
|
|
897
|
+
routeSelected,
|
|
898
|
+
routeUnselected,
|
|
899
|
+
summaryJudge,
|
|
900
|
+
...leaseArg,
|
|
901
|
+
});
|
|
902
|
+
if (fin.kind !== "advanced")
|
|
903
|
+
break; // a route failure stops the walk
|
|
904
|
+
state = await getNextWorkflowStep(runId);
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
// An executing step. Settle it ONLY when it dispatches no reportable units
|
|
908
|
+
// (empty fan-out, all-unresolvable, or a whole-list failure); otherwise the
|
|
909
|
+
// driver briefs and reports it, so stop here.
|
|
910
|
+
if (sp.root) {
|
|
911
|
+
const freshUnits = await withWorkflowRunsRepo((repo) => repo.getUnitsForRun(runId));
|
|
912
|
+
const priorEvidence = {};
|
|
913
|
+
for (const s of state.workflow.steps)
|
|
914
|
+
priorEvidence[s.id] = s.evidence;
|
|
915
|
+
const stepOutputs = stepOutputsFromEvidence(priorEvidence);
|
|
916
|
+
const gateLoop = activeGateLoop(freshUnits, step.id);
|
|
917
|
+
const gateFeedback = recoverGateFeedback(freshUnits, step.id, gateLoop);
|
|
918
|
+
const computed = computeStepWorkList(sp, {
|
|
919
|
+
runId,
|
|
920
|
+
params: state.run.params ?? {},
|
|
921
|
+
stepOutputs,
|
|
922
|
+
gateLoop,
|
|
923
|
+
...(gateFeedback ? { gateFeedback } : {}),
|
|
924
|
+
});
|
|
925
|
+
if (!computed.ok) {
|
|
926
|
+
// Whole-list resolution failure → the engine fails the step (failedStep).
|
|
927
|
+
await completeWorkflowStep({
|
|
928
|
+
runId,
|
|
929
|
+
stepId: step.id,
|
|
930
|
+
status: "failed",
|
|
931
|
+
notes: computed.error,
|
|
932
|
+
evidence: { error: computed.error },
|
|
933
|
+
...leaseArg,
|
|
934
|
+
});
|
|
935
|
+
break; // run failed
|
|
936
|
+
}
|
|
937
|
+
const list = computed.list;
|
|
938
|
+
if (list.units.some((u) => u.resolved.ok))
|
|
939
|
+
break; // real reportable work — stop.
|
|
940
|
+
// No reportable units (empty fan-out OR all-unresolvable). Auto-complete
|
|
941
|
+
// it exactly as the engine would, through the SAME completion path.
|
|
942
|
+
const byUnit = indexDispatchRows(freshUnits);
|
|
943
|
+
const reduced = reduceWorkListOutcomes(sp, list, byUnit);
|
|
944
|
+
const completion = await runStepCompletion({
|
|
945
|
+
runId,
|
|
946
|
+
workflowRef: state.run.workflowRef,
|
|
947
|
+
stepPlan: sp,
|
|
948
|
+
stepId: step.id,
|
|
949
|
+
completionCriteria: step.completionCriteria ?? [],
|
|
950
|
+
gateLoop,
|
|
951
|
+
reduced,
|
|
952
|
+
priorEvidence,
|
|
953
|
+
params: state.run.params ?? {},
|
|
954
|
+
routeSelected,
|
|
955
|
+
routeUnselected,
|
|
956
|
+
summaryJudge,
|
|
957
|
+
...leaseArg,
|
|
958
|
+
});
|
|
959
|
+
if (completion.kind === "advanced") {
|
|
960
|
+
state = await getNextWorkflowStep(runId);
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
// A gate rejection on a zero-unit step re-runs the (unchanged) empty
|
|
964
|
+
// artifact, but the journaled gate row advances the loop, so the next
|
|
965
|
+
// iteration re-evaluates at gateLoop+1, bounded by max_loops. Loop the
|
|
966
|
+
// SAME step WITHOUT advancing the spine.
|
|
967
|
+
if (completion.kind === "gate-rejected" && completion.loopsRemaining)
|
|
968
|
+
continue;
|
|
969
|
+
// failed / gate-exhausted → nothing more to auto-advance.
|
|
970
|
+
break;
|
|
971
|
+
}
|
|
972
|
+
break;
|
|
973
|
+
}
|
|
974
|
+
// Re-read the freshest run state: a terminal-break path (a failed step, an
|
|
975
|
+
// exhausted gate) left `state` reflecting the pre-completion snapshot from the
|
|
976
|
+
// top of the loop iteration, but the DB now holds the true resting state.
|
|
977
|
+
return getNextWorkflowStep(runId);
|
|
978
|
+
}
|
|
979
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
980
|
+
function loadFrozenPlan(runId, planJson, planHash) {
|
|
981
|
+
if (!planJson) {
|
|
982
|
+
throw new UsageError(`Workflow run ${runId} predates frozen plans (no plan_json on the run row) and cannot be driven by ` +
|
|
983
|
+
`\`akm workflow report\`. Use engine-driven mode: \`akm workflow run ${runId}\`.`);
|
|
984
|
+
}
|
|
985
|
+
return parseFrozenPlan(runId, planJson, planHash);
|
|
986
|
+
}
|
|
987
|
+
/**
|
|
988
|
+
* Normalize a driver-supplied `--failure-reason` to the persisted taxonomy (PR
|
|
989
|
+
* #714 review round 2, #16). An external driver can type ANY string; storing it
|
|
990
|
+
* verbatim would let an arbitrary token masquerade as a first-class failure
|
|
991
|
+
* vocabulary and (worse) accidentally match a workflow's `retry.on`. So:
|
|
992
|
+
*
|
|
993
|
+
* - an EMPTY/absent reason → the neutral default `reported_failure`;
|
|
994
|
+
* - a reason IN the canonical taxonomy ({@link PROGRAM_RETRY_REASONS}, the
|
|
995
|
+
* exact `AgentFailureReason` set `retry.on` accepts) → stored VERBATIM, so a
|
|
996
|
+
* driver reporting e.g. `timeout` participates in retry semantics identically
|
|
997
|
+
* to an engine-dispatched unit;
|
|
998
|
+
* - anything ELSE → namespaced under `external:<slug>` (lowercase, `[a-z0-9_-]`,
|
|
999
|
+
* clipped). An `external:*` value is BY CONSTRUCTION outside the taxonomy, so
|
|
1000
|
+
* `retry.on` (which only lists taxonomy reasons) can never fire on it — an
|
|
1001
|
+
* unknown external reason is recorded for observability without ever
|
|
1002
|
+
* triggering retry.
|
|
1003
|
+
*/
|
|
1004
|
+
export function normalizeFailureReason(raw) {
|
|
1005
|
+
const trimmed = raw?.trim();
|
|
1006
|
+
if (!trimmed)
|
|
1007
|
+
return "reported_failure";
|
|
1008
|
+
if (PROGRAM_RETRY_REASONS.includes(trimmed))
|
|
1009
|
+
return trimmed;
|
|
1010
|
+
const slug = trimmed
|
|
1011
|
+
.toLowerCase()
|
|
1012
|
+
.replace(/[^a-z0-9_-]+/g, "-")
|
|
1013
|
+
.replace(/^-+|-+$/g, "")
|
|
1014
|
+
.slice(0, 48);
|
|
1015
|
+
return `external:${slug || "unknown"}`;
|
|
1016
|
+
}
|
|
1017
|
+
/** Validate + shape the reported result into what `finishUnit` persists. */
|
|
1018
|
+
function prepareResult(input, workUnit) {
|
|
1019
|
+
if (input.status === "failed") {
|
|
1020
|
+
return {
|
|
1021
|
+
resultJson: input.resultRaw !== undefined && input.resultRaw !== "" ? JSON.stringify(input.resultRaw) : null,
|
|
1022
|
+
failureReason: normalizeFailureReason(input.failureReason),
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
// completed
|
|
1026
|
+
if (workUnit.schema) {
|
|
1027
|
+
const raw = input.resultRaw;
|
|
1028
|
+
if (raw === undefined || raw.trim() === "") {
|
|
1029
|
+
throw new UsageError(`Unit "${input.unitId}" declares an output schema — its --result must be a JSON value matching that schema, ` +
|
|
1030
|
+
`but no result was provided.`, "MISSING_REQUIRED_ARGUMENT");
|
|
1031
|
+
}
|
|
1032
|
+
let parsed;
|
|
1033
|
+
try {
|
|
1034
|
+
parsed = JSON.parse(raw);
|
|
1035
|
+
}
|
|
1036
|
+
catch (err) {
|
|
1037
|
+
throw new UsageError(`Unit "${input.unitId}" result is not valid JSON (its output schema requires a JSON value): ${err instanceof Error ? err.message : String(err)}`, "INVALID_FLAG_VALUE");
|
|
1038
|
+
}
|
|
1039
|
+
const errors = validateJsonSchemaSubset(parsed, workUnit.schema);
|
|
1040
|
+
if (errors.length > 0) {
|
|
1041
|
+
throw new UsageError(`Unit "${input.unitId}" result failed validation against its declared output schema: ${errors.join("; ")}.`, "INVALID_FLAG_VALUE");
|
|
1042
|
+
}
|
|
1043
|
+
return { resultJson: JSON.stringify(parsed), failureReason: null };
|
|
1044
|
+
}
|
|
1045
|
+
// Free-text unit: journal the text as a JSON string EXACTLY as the executor
|
|
1046
|
+
// does — `native-executor.ts` finishUnit uses `outcome.text ? JSON.stringify… :
|
|
1047
|
+
// null`, so an empty (or absent) output journals result_json = NULL, not '""'.
|
|
1048
|
+
// Matching that keeps the promoted artifact and the dispatch row byte-identical
|
|
1049
|
+
// across the engine and report surfaces (the cardinal graph-parity rule), and
|
|
1050
|
+
// stays consistent with the FAILED branch above which also maps ""→null.
|
|
1051
|
+
return { resultJson: input.resultRaw ? JSON.stringify(input.resultRaw) : null, failureReason: null };
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Assess the frozen plan's declared budget ceilings for ONE report, seeded from
|
|
1055
|
+
* the journal exactly as the engine seeds `DispatchBudget`: dispatch rows
|
|
1056
|
+
* (phase = null) OTHER than the one being written count against `max_units`, and
|
|
1057
|
+
* their token sum against `max_tokens`. Unlike a simple admission check, this
|
|
1058
|
+
* mirrors the engine's TWO enforcement points so the report path reaches the
|
|
1059
|
+
* engine's terminal state (a HARD step failure naming the ceiling) instead of
|
|
1060
|
+
* throwing and leaving the run stuck (peer review R3, finding 1):
|
|
1061
|
+
*
|
|
1062
|
+
* - `refuse` — the engine's `tryConsume` refuses the (maxUnits+1)-th dispatch,
|
|
1063
|
+
* or a dispatch whose run token total is already at/over `max_tokens`,
|
|
1064
|
+
* WITHOUT journaling it. The report writes no row and fails the step.
|
|
1065
|
+
* - `tokens-cross` — the engine's `addTokens` crosses `max_tokens` AFTER a
|
|
1066
|
+
* dispatch: the unit IS journaled, then pending dispatches abort and the
|
|
1067
|
+
* step fails. The report writes the row, then fails the step.
|
|
1068
|
+
*
|
|
1069
|
+
* Budget ceilings fail the step regardless of `on_error` — a capped run must
|
|
1070
|
+
* never quietly pass its gate.
|
|
1071
|
+
*/
|
|
1072
|
+
function assessBudget(plan, rows, journalId, thisTokens, existing) {
|
|
1073
|
+
const budget = plan.budget;
|
|
1074
|
+
if (!budget || (budget.maxUnits === undefined && budget.maxTokens === undefined))
|
|
1075
|
+
return { kind: "ok" };
|
|
1076
|
+
// The row being (re)written carries CUMULATIVE tokens across its attempts
|
|
1077
|
+
// (finding A): `finishUnit` will preserve them (prior spend + this write), so
|
|
1078
|
+
// they are part of the run's committed token total and count against
|
|
1079
|
+
// `max_tokens` here just like every other row's tokens. NULL ⇒ 0.
|
|
1080
|
+
const existingTokens = existing?.tokens ?? 0;
|
|
1081
|
+
let othersDispatched = 0;
|
|
1082
|
+
let othersTokens = 0;
|
|
1083
|
+
let existingAttempts = 0;
|
|
1084
|
+
for (const row of rows) {
|
|
1085
|
+
if (row.phase !== null)
|
|
1086
|
+
continue; // gate rows excluded
|
|
1087
|
+
if (row.unit_id === journalId) {
|
|
1088
|
+
// The row being (re)written. Its ALREADY-SPENT attempts must be counted
|
|
1089
|
+
// before admission (#4): excluding them let a re-report of a non-terminal
|
|
1090
|
+
// or failed unit erase the prior attempt from the ceiling. Its tokens are
|
|
1091
|
+
// accounted via `existingTokens` (the finish carries them forward, not
|
|
1092
|
+
// overwrites), so a rerun never drops a failed attempt's spend (finding A).
|
|
1093
|
+
existingAttempts = row.attempts;
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
// Sum `attempts` (migration 008), not a per-row +1: a crash-retried unit
|
|
1097
|
+
// occupies ONE row whose `attempts` records every re-dispatch, so this
|
|
1098
|
+
// mirrors the engine seed (`run-workflow.ts`) and charges each dispatch.
|
|
1099
|
+
othersDispatched += row.attempts;
|
|
1100
|
+
othersTokens += row.tokens ?? 0;
|
|
1101
|
+
}
|
|
1102
|
+
// The attempts this write adds: a live `running` claim being finalized reuses
|
|
1103
|
+
// the claim's already-charged attempt (finishUnit, no insert ⇒ +0); every
|
|
1104
|
+
// other write (fresh, or a FAILED --rerun re-dispatch) inserts and bumps
|
|
1105
|
+
// `attempts` by one, mirroring the engine's charge-before-dispatch. The
|
|
1106
|
+
// projected total after this write must not exceed `max_units`.
|
|
1107
|
+
const increment = existing?.status === "running" ? 0 : 1;
|
|
1108
|
+
const projectedUnits = othersDispatched + existingAttempts + increment;
|
|
1109
|
+
if (budget.maxUnits !== undefined && projectedUnits > budget.maxUnits) {
|
|
1110
|
+
return {
|
|
1111
|
+
kind: "refuse",
|
|
1112
|
+
message: `budget exceeded (max_units ceiling): ${projectedUnits} unit dispatch(es) would be charged for this run ` +
|
|
1113
|
+
`against the workflow's declared budget.max_units of ${budget.maxUnits} — the step fails hard (budget ` +
|
|
1114
|
+
`ceilings ignore on_error).`,
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
const committedTokens = othersTokens + existingTokens;
|
|
1118
|
+
if (budget.maxTokens !== undefined && committedTokens >= budget.maxTokens) {
|
|
1119
|
+
return {
|
|
1120
|
+
kind: "refuse",
|
|
1121
|
+
message: `budget exceeded (max_tokens ceiling): ${committedTokens} token(s) already spent for this run against the ` +
|
|
1122
|
+
`workflow's declared budget.max_tokens of ${budget.maxTokens} — the step fails hard (budget ceilings ignore on_error).`,
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
if (budget.maxTokens !== undefined && committedTokens + thisTokens >= budget.maxTokens) {
|
|
1126
|
+
return {
|
|
1127
|
+
kind: "tokens-cross",
|
|
1128
|
+
message: `budget exceeded (max_tokens ceiling): ${committedTokens + thisTokens} token(s) spent for this run, reaching the ` +
|
|
1129
|
+
`workflow's declared budget.max_tokens of ${budget.maxTokens} — the step fails hard (budget ceilings ignore on_error).`,
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
return { kind: "ok" };
|
|
1133
|
+
}
|
|
1134
|
+
// ── Claim + hash guards (shared by the running + finish transactions) ─────────
|
|
1135
|
+
/**
|
|
1136
|
+
* Stale-hash guard (review round 2, #3): under a frozen plan the same unit
|
|
1137
|
+
* identity must reproduce the same input hash, so an existing row whose recorded
|
|
1138
|
+
* `input_hash` differs from the recomputed one is a hard replay divergence — a
|
|
1139
|
+
* stale or tampered row. A NULL recorded hash (never seen in practice for a
|
|
1140
|
+
* report-claimed row) is treated as non-divergent.
|
|
1141
|
+
*/
|
|
1142
|
+
function assertNoHashDivergence(existing, inputHash, runId, journalId) {
|
|
1143
|
+
if (existing.input_hash !== null && existing.input_hash !== inputHash) {
|
|
1144
|
+
throw new UsageError(`Replay divergence: unit "${journalId}" of run ${runId} has a journaled ${existing.status} row whose input ` +
|
|
1145
|
+
`hash differs from this report's. Under a frozen plan the same unit identity must reproduce the same inputs ` +
|
|
1146
|
+
`— refusing to heartbeat or finalize a stale/tampered row. Start a new run to re-execute this work.`);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Claim compare-and-set (review round 2, #3): a LIVE claim (holder set, expiry
|
|
1151
|
+
* in the future) held by a DIFFERENT holder blocks a reclaim/heartbeat/finish;
|
|
1152
|
+
* a free row, an EXPIRED claim (crash recovery), or a claim already ours passes.
|
|
1153
|
+
* A claimless row always passes — simple drivers skip claims entirely.
|
|
1154
|
+
*/
|
|
1155
|
+
function assertClaimHeldByOrFree(existing, holder, nowIso, runId, journalId, action) {
|
|
1156
|
+
const claimLive = existing.claim_expires_at !== null && existing.claim_expires_at >= nowIso;
|
|
1157
|
+
if (existing.claim_holder !== null && claimLive && existing.claim_holder !== holder) {
|
|
1158
|
+
throw new UsageError(`Unit "${journalId}" of run ${runId} is claimed by ${existing.claim_holder} until ${existing.claim_expires_at}; ` +
|
|
1159
|
+
`only that holder can ${action} it while the claim is live. Pass --session-id ${existing.claim_holder} if you ` +
|
|
1160
|
+
`own the claim, or wait for it to expire (then it is reclaimable — crash recovery).`);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* Fail the active step because a declared budget ceiling was crossed — the same
|
|
1165
|
+
* terminal state the engine reaches (`failedStep` → a FAILED step and run). The
|
|
1166
|
+
* failure goes through `completeWorkflowStep` (the gate spine is never bypassed)
|
|
1167
|
+
* with the ceiling-naming message as notes/evidence. `wroteRow` reflects whether
|
|
1168
|
+
* the crossing unit's row was journaled (a `tokens-cross`) or not (a `refuse`).
|
|
1169
|
+
*/
|
|
1170
|
+
async function failStepOnBudget(runId, stepId, journalId, status, gateLoop, message, wroteRow) {
|
|
1171
|
+
await completeWorkflowStep({ runId, stepId, status: "failed", notes: message, evidence: { error: message } });
|
|
1172
|
+
const state = await getNextWorkflowStep(runId);
|
|
1173
|
+
return {
|
|
1174
|
+
ok: true,
|
|
1175
|
+
runId,
|
|
1176
|
+
stepId,
|
|
1177
|
+
unitId: journalId,
|
|
1178
|
+
status,
|
|
1179
|
+
gateLoop,
|
|
1180
|
+
recorded: wroteRow ? "written" : "not-recorded",
|
|
1181
|
+
remainingUnits: 0,
|
|
1182
|
+
stepOutcome: { kind: "failed", summary: message },
|
|
1183
|
+
runStatus: state.run.status,
|
|
1184
|
+
message: `Step "${stepId}" failed: ${message}`,
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* The result surfaced to the LOSER of the finalization CAS (#5): a concurrent
|
|
1189
|
+
* reporter holds (or already finished) the step's finalization. This report's
|
|
1190
|
+
* unit row IS durably journaled; the step advance is (being) done exactly once
|
|
1191
|
+
* by the winner, so this is idempotent success — never a raw throw. Re-reads the
|
|
1192
|
+
* freshest spine so the surfaced run status / advance is accurate.
|
|
1193
|
+
*/
|
|
1194
|
+
async function contendedFinalizeResult(runId, stepState, written, gateLoop, recorded, fresh) {
|
|
1195
|
+
const state = fresh ?? (await getNextWorkflowStep(runId));
|
|
1196
|
+
const advancedPast = state.run.status !== "active" || state.step?.id !== stepState.id;
|
|
1197
|
+
return {
|
|
1198
|
+
ok: true,
|
|
1199
|
+
runId,
|
|
1200
|
+
stepId: stepState.id,
|
|
1201
|
+
unitId: written.unitId,
|
|
1202
|
+
status: written.status,
|
|
1203
|
+
gateLoop,
|
|
1204
|
+
// The UNIT write resolved as `recorded` (a real write, or an idempotent
|
|
1205
|
+
// re-report); only the STEP finalization was contended (a concurrent
|
|
1206
|
+
// reporter owns it), which the message conveys.
|
|
1207
|
+
recorded,
|
|
1208
|
+
remainingUnits: 0,
|
|
1209
|
+
...(advancedPast ? { stepOutcome: { kind: "advanced" } } : {}),
|
|
1210
|
+
runStatus: state.run.status,
|
|
1211
|
+
message: advancedPast
|
|
1212
|
+
? `Unit "${written.unitId}" recorded; step "${stepState.id}" was finalized by a concurrent reporter (idempotent success).`
|
|
1213
|
+
: `Unit "${written.unitId}" recorded; step "${stepState.id}" is being finalized by a concurrent reporter (idempotent success).`,
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
/**
|
|
1217
|
+
* The result surfaced when settling non-dispatching steps drove the run to a
|
|
1218
|
+
* terminal (or step-less) state before the reported unit could be recorded —
|
|
1219
|
+
* the run advanced/failed on its own, so there is nothing left to journal.
|
|
1220
|
+
*/
|
|
1221
|
+
function settledTerminalResult(input, settled) {
|
|
1222
|
+
const runStatus = settled.run.status;
|
|
1223
|
+
const message = runStatus === "completed"
|
|
1224
|
+
? `The run advanced to completion while settling steps with no reportable work; unit "${input.unitId}" needed no report.`
|
|
1225
|
+
: `The run is ${runStatus} after settling steps with no reportable work; unit "${input.unitId}" could not be recorded.`;
|
|
1226
|
+
return {
|
|
1227
|
+
ok: true,
|
|
1228
|
+
runId: settled.run.id,
|
|
1229
|
+
stepId: settled.run.currentStepId ?? "(none)",
|
|
1230
|
+
unitId: input.unitId,
|
|
1231
|
+
status: input.status,
|
|
1232
|
+
gateLoop: 1,
|
|
1233
|
+
recorded: "not-recorded",
|
|
1234
|
+
remainingUnits: 0,
|
|
1235
|
+
stepOutcome: runStatus === "completed" ? { kind: "advanced" } : { kind: "failed", summary: message },
|
|
1236
|
+
runStatus,
|
|
1237
|
+
message,
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* Count the step's units that are still OUTSTANDING: resolvable units without a
|
|
1242
|
+
* terminal journal row. Unresolvable units are never reportable (the engine's
|
|
1243
|
+
* immediate `expression_error`), so they never keep a step outstanding — the
|
|
1244
|
+
* caller's reduction treats them as failed outcomes.
|
|
1245
|
+
*/
|
|
1246
|
+
function remainingReportableUnits(workList, byUnit) {
|
|
1247
|
+
return workList.units.filter((u) => {
|
|
1248
|
+
if (!u.resolved.ok)
|
|
1249
|
+
return false;
|
|
1250
|
+
// A unit is terminal when its best attempt (base OR a completed `~r<n>`
|
|
1251
|
+
// retry) is terminal — the SAME reuse the reducer applies (finding C), so a
|
|
1252
|
+
// base-failed unit rescued by a completed retry is NOT counted outstanding.
|
|
1253
|
+
const row = selectUnitAttemptRow(u, byUnit);
|
|
1254
|
+
return !(row && (row.status === "completed" || row.status === "failed"));
|
|
1255
|
+
}).length;
|
|
1256
|
+
}
|
|
1257
|
+
/** Index the run's DISPATCH unit rows (phase != gate) by unit id. */
|
|
1258
|
+
function indexDispatchRows(rows) {
|
|
1259
|
+
const map = new Map();
|
|
1260
|
+
for (const row of rows) {
|
|
1261
|
+
if (row.phase === null)
|
|
1262
|
+
map.set(row.unit_id, row);
|
|
1263
|
+
}
|
|
1264
|
+
return map;
|
|
1265
|
+
}
|
|
1266
|
+
/** Count units whose journal row is not yet terminal (for progress messaging). */
|
|
1267
|
+
function countRemaining(workUnits, rows, justClaimed, claimedStatus) {
|
|
1268
|
+
const byUnit = indexDispatchRows(rows);
|
|
1269
|
+
return workUnits.filter((u) => {
|
|
1270
|
+
if (u.journalBaseId === justClaimed)
|
|
1271
|
+
return claimedStatus !== "completed" && claimedStatus !== "failed";
|
|
1272
|
+
// Unresolvable units are never reportable (the engine's expression_error), so
|
|
1273
|
+
// they never count as outstanding.
|
|
1274
|
+
if (!u.resolved.ok)
|
|
1275
|
+
return false;
|
|
1276
|
+
// Best terminal attempt (base + `~r<n>` retries), the shared reuse (finding C).
|
|
1277
|
+
const row = selectUnitAttemptRow(u, byUnit);
|
|
1278
|
+
return !(row && (row.status === "completed" || row.status === "failed"));
|
|
1279
|
+
}).length;
|
|
1280
|
+
}
|
|
1281
|
+
function reportResult(runId, stepId, written, gateLoop, runStatus, stepOutcome, message, recorded = "written") {
|
|
1282
|
+
return {
|
|
1283
|
+
ok: true,
|
|
1284
|
+
runId,
|
|
1285
|
+
stepId,
|
|
1286
|
+
unitId: written.unitId,
|
|
1287
|
+
status: written.status,
|
|
1288
|
+
gateLoop,
|
|
1289
|
+
recorded,
|
|
1290
|
+
remainingUnits: 0,
|
|
1291
|
+
stepOutcome,
|
|
1292
|
+
runStatus,
|
|
1293
|
+
message,
|
|
1294
|
+
};
|
|
1295
|
+
}
|