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,1156 @@
|
|
|
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
|
+
* Shared step semantics — the ONE implementation of a step's orchestration
|
|
6
|
+
* decisions, consumed by BOTH the engine loop (`run-workflow.ts` +
|
|
7
|
+
* `native-executor.ts`) and, from R3 on, the harness-neutral driver protocol
|
|
8
|
+
* (`workflow brief` / `workflow report`). The cardinal rule of the driver
|
|
9
|
+
* protocol (redesign addendum R3) is *no duplicated semantics*: work-list
|
|
10
|
+
* computation, prompt assembly, reducer/artifact promotion, output-schema
|
|
11
|
+
* validation, artifact-judged gate summaries, gate-feedback recovery, and
|
|
12
|
+
* route evaluation live here so an engine-driven run and a brief/report-driven
|
|
13
|
+
* run of the same frozen plan produce byte-identical unit graphs.
|
|
14
|
+
*
|
|
15
|
+
* ## What is PURE here
|
|
16
|
+
*
|
|
17
|
+
* {@link computeStepWorkList} — given the frozen step plan and a
|
|
18
|
+
* {@link WorkListInput} (params, prior step outputs, gate-loop number + its
|
|
19
|
+
* recovered feedback) — is a pure function: same inputs ⇒ same unit ids, input
|
|
20
|
+
* hashes, and fully-resolved prompts. It takes NO clock, NO IO, and NO journal
|
|
21
|
+
* (journal-derived state, i.e. the recovered gate feedback, is passed in). This
|
|
22
|
+
* is the load-bearing guarantee that `brief` can predict exactly the units the
|
|
23
|
+
* engine would dispatch. So are the reducer/artifact helpers
|
|
24
|
+
* ({@link buildEvidence}, {@link projectStepOutput}, {@link validateStepArtifact},
|
|
25
|
+
* {@link buildArtifactSummary}), the gate-feedback recovery
|
|
26
|
+
* ({@link recoverGateFeedback} / {@link activeGateLoop}), and route evaluation
|
|
27
|
+
* ({@link evaluateRoute} and its bookkeeping).
|
|
28
|
+
*
|
|
29
|
+
* ## What does IO here
|
|
30
|
+
*
|
|
31
|
+
* The gate-evaluation journaling ({@link journalGateEvaluationStart} /
|
|
32
|
+
* {@link journalGateEvaluationFinish}) writes `workflow_run_units` rows through
|
|
33
|
+
* the serialized writer queue — an engine-driven judge call is an LLM call and
|
|
34
|
+
* is journaled like a unit. It lives here (not in the engine loop) so the
|
|
35
|
+
* report path journals gate evaluations through the identical writer.
|
|
36
|
+
*
|
|
37
|
+
* This module NEVER dispatches a unit and NEVER writes step rows: dispatch is
|
|
38
|
+
* the executor's job (`native-executor.ts`), advancing the gated spine is the
|
|
39
|
+
* engine loop's job (`run-workflow.ts` via `completeWorkflowStep`).
|
|
40
|
+
*/
|
|
41
|
+
import { createHash } from "node:crypto";
|
|
42
|
+
import unitPreambleTemplate from "../../assets/prompts/workflow-unit-preamble.md" with { type: "text" };
|
|
43
|
+
import { UsageError } from "../../core/errors.js";
|
|
44
|
+
import { appendEvent } from "../../core/events.js";
|
|
45
|
+
import { validateJsonSchemaSubset } from "../../core/json-schema.js";
|
|
46
|
+
import { withWorkflowRunsRepo } from "../../storage/repositories/workflow-runs-repository.js";
|
|
47
|
+
import { computePlanHash } from "../ir/plan-hash.js";
|
|
48
|
+
import { parseTemplate, resolveTemplate, resolveWholeValue, } from "../program/expressions.js";
|
|
49
|
+
import { buildDefaultSummaryJudge, completeWorkflowStep, } from "../runtime/runs.js";
|
|
50
|
+
import { enqueueUnitWrite } from "./unit-writer.js";
|
|
51
|
+
/**
|
|
52
|
+
* Default per-unit timeout. Deliberately NOT the 60 s agent default
|
|
53
|
+
* (`DEFAULT_AGENT_TIMEOUT_MS`) — workflow units routinely run real coding
|
|
54
|
+
* tasks on slow local models; 10 minutes matches the LLM-path default
|
|
55
|
+
* (`tryLlmFeature`). A unit's `timeout` declaration overrides this; `none`
|
|
56
|
+
* disables.
|
|
57
|
+
*/
|
|
58
|
+
export const DEFAULT_UNIT_TIMEOUT_MS = 600_000;
|
|
59
|
+
/** How much raw unit output is retained in step evidence (full text lives on the unit row). */
|
|
60
|
+
const EVIDENCE_TEXT_CLIP = 2_000;
|
|
61
|
+
/** How much artifact JSON the completion-criteria judge receives (addendum R2, artifact-judging gates). */
|
|
62
|
+
const GATE_ARTIFACT_CLIP = 4_000;
|
|
63
|
+
/**
|
|
64
|
+
* Compute a step's expected work-list PURELY from the frozen plan and its
|
|
65
|
+
* inputs: resolve the fan-out list, derive content-derived unit ids, assemble
|
|
66
|
+
* each unit's prompt (preamble + interpolated instructions + gate feedback +
|
|
67
|
+
* schema directive), and hash the resolved input. Same inputs ⇒ byte-identical
|
|
68
|
+
* ids/hashes/prompts — the invariant `brief` relies on to predict the engine.
|
|
69
|
+
*
|
|
70
|
+
* Whole-list failures (missing subgraph, template parse error, unresolvable /
|
|
71
|
+
* non-array `over`, duplicate fan-out items) return `{ ok: false }`; a per-unit
|
|
72
|
+
* expression-resolution failure is carried on that unit's `resolved` field so
|
|
73
|
+
* the caller fails just that unit (mirroring the engine's `expression_error`
|
|
74
|
+
* outcome), never the whole step.
|
|
75
|
+
*/
|
|
76
|
+
export function computeStepWorkList(plan, input) {
|
|
77
|
+
const root = plan.root;
|
|
78
|
+
// Route-only steps (YAML `route:`) carry no execution subgraph.
|
|
79
|
+
if (!root) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
error: `Step "${plan.stepId}" has no execution subgraph (a route-only step); the native executor cannot dispatch it.`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const template = root.kind === "map" ? root.template : root;
|
|
86
|
+
const reducer = root.kind === "map" ? root.reducer : "collect";
|
|
87
|
+
const scope = { params: input.params, stepOutputs: input.stepOutputs };
|
|
88
|
+
// Parse the instruction template ONCE (deterministic; resolution is a single
|
|
89
|
+
// pass per unit — substituted content is never re-scanned). Only nodes the
|
|
90
|
+
// frontend marked `templating: "expressions"` carry the `${{ … }}` grammar;
|
|
91
|
+
// classic linear markdown is opaque verbatim text.
|
|
92
|
+
let instructionSegments;
|
|
93
|
+
if (template.templating === "expressions") {
|
|
94
|
+
const parsedInstructions = parseTemplate(template.instructions);
|
|
95
|
+
if (!parsedInstructions.ok) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
error: `Step "${plan.stepId}" instructions template failed to parse: ` +
|
|
99
|
+
parsedInstructions.errors.map((e) => e.message).join(" "),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
instructionSegments = parsedInstructions.segments;
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
instructionSegments = [{ kind: "literal", text: template.instructions }];
|
|
106
|
+
}
|
|
107
|
+
// Resolve fan-out items: `over` is a single whole-value `${{ … }}` reference
|
|
108
|
+
// naming its producer explicitly — no ambient key search.
|
|
109
|
+
let items;
|
|
110
|
+
if (root.kind === "map") {
|
|
111
|
+
const source = resolveWholeValue(root.over, scope);
|
|
112
|
+
if (!source.ok) {
|
|
113
|
+
return {
|
|
114
|
+
ok: false,
|
|
115
|
+
error: `Step "${plan.stepId}" fan-out "over" (${root.over}) failed to resolve: ${source.error.message}`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (!Array.isArray(source.value)) {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
error: `Step "${plan.stepId}" fan-out "over" (${root.over}) resolved to ${typeof source.value}, not an array.`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
items = source.value;
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
items = [undefined];
|
|
128
|
+
}
|
|
129
|
+
// Content-derived unit identity: compute every id up front. Duplicate items
|
|
130
|
+
// collide on identity — an authoring error caught HERE, deterministically.
|
|
131
|
+
const isFanOut = root.kind === "map";
|
|
132
|
+
const unitIds = items.map((item) => unitIdFor(template.id, item, isFanOut));
|
|
133
|
+
if (isFanOut) {
|
|
134
|
+
const firstIndexByCanonical = new Map();
|
|
135
|
+
for (let i = 0; i < items.length; i++) {
|
|
136
|
+
const canonical = canonicalJson(items[i]) ?? "null";
|
|
137
|
+
const firstIndex = firstIndexByCanonical.get(canonical);
|
|
138
|
+
if (firstIndex !== undefined) {
|
|
139
|
+
return {
|
|
140
|
+
ok: false,
|
|
141
|
+
error: `Step "${plan.stepId}" fan-out list contains duplicate items (indices ${firstIndex} and ${i}: ` +
|
|
142
|
+
`${clip(canonical, 200)}). Content-derived unit identity requires distinct items — ` +
|
|
143
|
+
`deduplicate the list this workflow fans out over.`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
firstIndexByCanonical.set(canonical, i);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const gateLoop = input.gateLoop ?? 1;
|
|
150
|
+
const timeoutMs = template.timeoutMs === undefined ? DEFAULT_UNIT_TIMEOUT_MS : template.timeoutMs;
|
|
151
|
+
const units = items.map((item, index) => {
|
|
152
|
+
const unitId = unitIds[index];
|
|
153
|
+
// Gate loops (>= 2) journal under `<unitId>~l<loop>` so loop 1's rows are
|
|
154
|
+
// never clobbered; the content-derived identity (and the prompt's
|
|
155
|
+
// {{UNIT_ID}}) stays the base id.
|
|
156
|
+
const journalBaseId = gateLoop > 1 ? `${unitId}~l${gateLoop}` : unitId;
|
|
157
|
+
// Single-pass resolution of the pre-parsed template against this unit's
|
|
158
|
+
// scope. A resolution failure is deterministic authoring/data breakage.
|
|
159
|
+
const unitScope = isFanOut ? { ...scope, item, itemIndex: index } : scope;
|
|
160
|
+
const resolvedInstr = resolveTemplate(instructionSegments, unitScope);
|
|
161
|
+
let resolved;
|
|
162
|
+
if (!resolvedInstr.ok) {
|
|
163
|
+
resolved = {
|
|
164
|
+
ok: false,
|
|
165
|
+
error: `instructions failed to resolve: ${resolvedInstr.errors.map((e) => e.message).join(" ")}`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
const prompt = buildUnitPrompt({
|
|
170
|
+
runId: input.runId,
|
|
171
|
+
stepId: plan.stepId,
|
|
172
|
+
unitId,
|
|
173
|
+
params: input.params,
|
|
174
|
+
...(input.gateFeedback ? { gateFeedback: input.gateFeedback } : {}),
|
|
175
|
+
...(template.schema ? { schema: template.schema } : {}),
|
|
176
|
+
instructions: resolvedInstr.text,
|
|
177
|
+
});
|
|
178
|
+
// Canonical dispatch-input envelope (reviewer finding #1). Every field
|
|
179
|
+
// here is a PLAN-FROZEN input that changes what the backend is actually
|
|
180
|
+
// asked to do, so a completed unit is reused ONLY when all of them match;
|
|
181
|
+
// a change to any of them re-dispatches. Key order is FIXED — it is the
|
|
182
|
+
// hash preimage (JSON.stringify preserves insertion order) — and shared
|
|
183
|
+
// by ALL surfaces, since this is the ONE place a unit's inputHash is
|
|
184
|
+
// computed (engine, brief, and report all call computeStepWorkList), so
|
|
185
|
+
// the byte-identical hash across surfaces is structural, not coincidental.
|
|
186
|
+
//
|
|
187
|
+
// Included beyond the R4 baseline (prompt/runner/model/schema): profile,
|
|
188
|
+
// resolved timeoutMs, the env asset ref NAMES, and isolation — each
|
|
189
|
+
// reaches dispatch (native-executor's UnitDispatchRequest) and a changed
|
|
190
|
+
// one yields a materially different call. `env` carries NAMES ONLY, never
|
|
191
|
+
// resolved values: hashing a resolved secret would leak it into a
|
|
192
|
+
// durable hash oracle and would spuriously re-dispatch on every secret
|
|
193
|
+
// rotation. `retry`/`onError` are DELIBERATELY excluded — they govern
|
|
194
|
+
// failed-unit re-dispatch and step-level failure reduction, not a
|
|
195
|
+
// COMPLETED unit's inputs/output, so a completed row stays valid across
|
|
196
|
+
// policy changes.
|
|
197
|
+
//
|
|
198
|
+
// Ambient config is DELIBERATELY excluded — the model-alias table, the
|
|
199
|
+
// resolved backend/connection, and the working directory (`ctx.workDir` /
|
|
200
|
+
// process.cwd()) are NOT plan-frozen. The frozen plan is the identity
|
|
201
|
+
// boundary (redesign addendum determinism bar #2): config drift under an
|
|
202
|
+
// in-flight run is out of scope by design.
|
|
203
|
+
const inputHash = createHash("sha256")
|
|
204
|
+
.update(JSON.stringify({
|
|
205
|
+
prompt,
|
|
206
|
+
runner: template.runner,
|
|
207
|
+
profile: template.profile ?? null,
|
|
208
|
+
model: template.model ?? null,
|
|
209
|
+
schema: template.schema ?? null,
|
|
210
|
+
timeoutMs,
|
|
211
|
+
env: template.env ?? null,
|
|
212
|
+
isolation: template.isolation ?? null,
|
|
213
|
+
}))
|
|
214
|
+
.digest("hex");
|
|
215
|
+
resolved = { ok: true, prompt, inputHash };
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
unitId,
|
|
219
|
+
nodeId: template.id,
|
|
220
|
+
index,
|
|
221
|
+
item,
|
|
222
|
+
isFanOut,
|
|
223
|
+
journalBaseId,
|
|
224
|
+
runner: template.runner,
|
|
225
|
+
...(template.profile ? { profile: template.profile } : {}),
|
|
226
|
+
...(template.model ? { model: template.model } : {}),
|
|
227
|
+
timeoutMs,
|
|
228
|
+
...(template.schema ? { schema: template.schema } : {}),
|
|
229
|
+
...(template.env ? { env: template.env } : {}),
|
|
230
|
+
...(template.retry ? { retry: template.retry } : {}),
|
|
231
|
+
onError: template.onError,
|
|
232
|
+
...(template.isolation ? { isolation: template.isolation } : {}),
|
|
233
|
+
resolved,
|
|
234
|
+
};
|
|
235
|
+
});
|
|
236
|
+
const concurrency = root.kind === "map" ? root.concurrency : 1;
|
|
237
|
+
return {
|
|
238
|
+
ok: true,
|
|
239
|
+
list: { template, reducer, isFanOut, ...(concurrency !== undefined ? { concurrency } : {}), items, units },
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Assemble the final prompt: engine preamble + resolved instructions
|
|
244
|
+
* (+ gate feedback on loop re-executions, + schema directive). Workflow-
|
|
245
|
+
* authored interpolation happened upstream via the expression module; only
|
|
246
|
+
* the ENGINE's own preamble placeholders are substituted here.
|
|
247
|
+
*/
|
|
248
|
+
export function buildUnitPrompt(input) {
|
|
249
|
+
const { runId, stepId, unitId, params, gateFeedback, schema, instructions } = input;
|
|
250
|
+
// Function replacements throughout: a string replacement would interpret
|
|
251
|
+
// GetSubstitution patterns ($&, $$, $', $`) inside VALUES and silently
|
|
252
|
+
// corrupt the prompt (e.g. a param value containing "$&").
|
|
253
|
+
const preamble = unitPreambleTemplate
|
|
254
|
+
.replaceAll("{{RUN_ID}}", () => runId)
|
|
255
|
+
.replaceAll("{{STEP_ID}}", () => stepId)
|
|
256
|
+
.replaceAll("{{UNIT_ID}}", () => unitId)
|
|
257
|
+
.replaceAll("{{PARAMS_JSON}}", () => safeJson(params));
|
|
258
|
+
// Gate-loop feedback (R2 max_loops): the judge's rejection is appended so
|
|
259
|
+
// the re-executed unit can address it — and so the input hash changes,
|
|
260
|
+
// making the loop's re-dispatch natural instead of a durable-row reuse.
|
|
261
|
+
const gateBlock = gateFeedback
|
|
262
|
+
? `\n\n## Completion-gate feedback (previous attempt rejected)\n` +
|
|
263
|
+
`A completion-criteria judge rejected this step's previous results. Address this feedback:\n` +
|
|
264
|
+
gateFeedback.feedback +
|
|
265
|
+
(gateFeedback.missing.length > 0
|
|
266
|
+
? `\nUnmet criteria:\n${gateFeedback.missing.map((m) => `- ${m}`).join("\n")}`
|
|
267
|
+
: "")
|
|
268
|
+
: "";
|
|
269
|
+
const schemaDirective = schema
|
|
270
|
+
? `\n\nRespond with ONLY a JSON value matching this JSON Schema (no prose, no code fences):\n${safeJson(schema)}`
|
|
271
|
+
: "";
|
|
272
|
+
return `${preamble}\n${instructions}${gateBlock}${schemaDirective}`;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Content-derived unit identity (module doc): `<node_id>:<hash12>` for a
|
|
276
|
+
* fan-out item, `<node_id>:solo` otherwise. The hash is over the item's
|
|
277
|
+
* canonical JSON (sorted keys — same canonicalization the vote reducer
|
|
278
|
+
* counts with), so identity survives list reordering/regeneration and is
|
|
279
|
+
* independent of item position. Retry attempts stack `~r<n>` on top.
|
|
280
|
+
*/
|
|
281
|
+
export function unitIdFor(nodeId, item, isFanOut) {
|
|
282
|
+
if (!isFanOut)
|
|
283
|
+
return `${nodeId}:solo`;
|
|
284
|
+
const canonical = canonicalJson(item) ?? "null";
|
|
285
|
+
return `${nodeId}:${createHash("sha256").update(canonical).digest("hex").slice(0, 12)}`;
|
|
286
|
+
}
|
|
287
|
+
// ── Step outputs + reducers + typed artifacts ────────────────────────────────
|
|
288
|
+
/**
|
|
289
|
+
* The value `${{ steps.<id>.output }}` resolves to for ONE step, given that
|
|
290
|
+
* step's journaled evidence: an engine-executed step carries a promoted
|
|
291
|
+
* ARTIFACT under `evidence.output` (solo unit result/text, collect array, or
|
|
292
|
+
* vote winner); evidence without an `output` key (manually-completed steps) is
|
|
293
|
+
* exposed as-is.
|
|
294
|
+
*/
|
|
295
|
+
export function projectStepOutput(evidence) {
|
|
296
|
+
return Object.hasOwn(evidence, "output") ? evidence.output : evidence;
|
|
297
|
+
}
|
|
298
|
+
/** Project the engine's evidence map into the expression scope's `stepOutputs`. */
|
|
299
|
+
export function stepOutputsFromEvidence(evidence) {
|
|
300
|
+
const outputs = {};
|
|
301
|
+
for (const [stepId, stepEvidence] of Object.entries(evidence)) {
|
|
302
|
+
if (stepEvidence !== undefined)
|
|
303
|
+
outputs[stepId] = projectStepOutput(stepEvidence);
|
|
304
|
+
}
|
|
305
|
+
return outputs;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Typed artifacts (addendum, R2): validate the promoted step artifact against
|
|
309
|
+
* `IrStepPlan.outputSchema`. Returns the step-failure summary (validation
|
|
310
|
+
* errors included) on mismatch, undefined when valid or when no schema is
|
|
311
|
+
* declared.
|
|
312
|
+
*/
|
|
313
|
+
export function validateStepArtifact(plan, evidence) {
|
|
314
|
+
if (!plan.outputSchema)
|
|
315
|
+
return undefined;
|
|
316
|
+
const errors = validateJsonSchemaSubset(projectStepOutput(evidence), plan.outputSchema);
|
|
317
|
+
if (errors.length === 0)
|
|
318
|
+
return undefined;
|
|
319
|
+
return (`Step "${plan.stepId}" artifact failed validation against the step's declared output schema: ` +
|
|
320
|
+
`${errors.join("; ")}.`);
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Build the summary the completion-criteria gate judges for a step (addendum
|
|
324
|
+
* R2, "typed artifacts, honest gates"): a one-line unit count followed by the
|
|
325
|
+
* promoted step artifact as canonical JSON, clipped at {@link GATE_ARTIFACT_CLIP}
|
|
326
|
+
* chars. This replaces machine-prose so the gate evaluates real results.
|
|
327
|
+
*/
|
|
328
|
+
export function buildArtifactSummary(stepId, units, evidence) {
|
|
329
|
+
const failedCount = units.filter((u) => !u.ok).length;
|
|
330
|
+
const json = canonicalJson(projectStepOutput(evidence)) ?? "null";
|
|
331
|
+
return (`Step "${stepId}" executed ${units.length} unit(s) (${units.length - failedCount} succeeded, ${failedCount} failed). ` +
|
|
332
|
+
`Step artifact (canonical JSON${json.length > GATE_ARTIFACT_CLIP ? `, clipped at ${GATE_ARTIFACT_CLIP} chars` : ""}):\n` +
|
|
333
|
+
clip(json, GATE_ARTIFACT_CLIP));
|
|
334
|
+
}
|
|
335
|
+
/** A unit's contribution to the step artifact: structured result, else text, else null (failures). */
|
|
336
|
+
function unitOutputValue(unit) {
|
|
337
|
+
if (!unit.ok)
|
|
338
|
+
return null;
|
|
339
|
+
if (unit.result !== undefined)
|
|
340
|
+
return unit.result;
|
|
341
|
+
return unit.text ?? null;
|
|
342
|
+
}
|
|
343
|
+
export function buildEvidence(units, reducer, isFanOut) {
|
|
344
|
+
// Per-unit evidence is the DURABLE, surface-independent projection the two
|
|
345
|
+
// driver surfaces (engine + brief/report) must agree on byte-for-byte (R4
|
|
346
|
+
// conformance, "identical unit graph"). It therefore carries ONLY fields both
|
|
347
|
+
// surfaces can reproduce from the journal:
|
|
348
|
+
// - a SUCCESS keeps its promoted contribution (structured `result` or clipped
|
|
349
|
+
// `text`) — the report path rehydrates exactly these from the unit row;
|
|
350
|
+
// - a FAILURE keeps only its `failureReason` (the durable, journaled failure
|
|
351
|
+
// vocabulary). The engine's in-memory dispatch diagnostic (`error`) and any
|
|
352
|
+
// residual `text` on a failed unit are NOT persisted here: a driver-reported
|
|
353
|
+
// failure carries neither, so persisting them on the engine surface alone
|
|
354
|
+
// would diverge the durable graph. The full raw text/reason still lives on
|
|
355
|
+
// the unit row for engine-side diagnostics; this is the shared graph.
|
|
356
|
+
const collected = units.map((u) => u.ok
|
|
357
|
+
? {
|
|
358
|
+
unitId: u.unitId,
|
|
359
|
+
ok: true,
|
|
360
|
+
...(u.result !== undefined ? { result: u.result } : {}),
|
|
361
|
+
...(u.text !== undefined ? { text: clip(u.text, EVIDENCE_TEXT_CLIP) } : {}),
|
|
362
|
+
}
|
|
363
|
+
: {
|
|
364
|
+
unitId: u.unitId,
|
|
365
|
+
ok: false,
|
|
366
|
+
...(u.failureReason ? { failureReason: u.failureReason } : {}),
|
|
367
|
+
});
|
|
368
|
+
const evidence = { units: collected, itemCount: units.length };
|
|
369
|
+
// Promoted step artifact (`evidence.output`) — what `${{ steps.<id>.output }}`
|
|
370
|
+
// resolves to (see projectStepOutput). Values are UNCLIPPED.
|
|
371
|
+
if (reducer === "vote") {
|
|
372
|
+
evidence.output = null;
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
evidence.output = isFanOut ? units.map(unitOutputValue) : unitOutputValue(units[0]);
|
|
376
|
+
}
|
|
377
|
+
if (reducer === "vote") {
|
|
378
|
+
const counts = new Map();
|
|
379
|
+
for (const unit of units) {
|
|
380
|
+
if (!unit.ok)
|
|
381
|
+
continue;
|
|
382
|
+
const value = unit.result !== undefined ? unit.result : unit.text;
|
|
383
|
+
const key = canonicalJson(value);
|
|
384
|
+
const entry = counts.get(key);
|
|
385
|
+
if (entry)
|
|
386
|
+
entry.count++;
|
|
387
|
+
else
|
|
388
|
+
counts.set(key, { value, count: 1 });
|
|
389
|
+
}
|
|
390
|
+
const ranked = [...counts.values()].sort((a, b) => b.count - a.count);
|
|
391
|
+
if (ranked.length === 0) {
|
|
392
|
+
evidence.voteError = "Vote reducer had no successful unit results to count.";
|
|
393
|
+
}
|
|
394
|
+
else if (ranked.length > 1 && ranked[0].count === ranked[1].count) {
|
|
395
|
+
evidence.voteError = `Vote reducer tied at ${ranked[0].count} vote(s) — no majority.`;
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
evidence.vote = { winner: ranked[0].value, votes: ranked[0].count, total: units.length };
|
|
399
|
+
evidence.output = ranked[0].value;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return evidence;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Reduce a step's terminal unit outcomes into the promoted artifact + step
|
|
406
|
+
* verdict — the shared semantics between native dispatch and the report path.
|
|
407
|
+
* Applies the `on_error` policy (`fail` vs `continue`), the reducer (via
|
|
408
|
+
* {@link buildEvidence}), the vote-tie failure, and the typed-artifact schema
|
|
409
|
+
* validation (fail-fast, errors in the summary, `artifactSchemaFailure` marker).
|
|
410
|
+
* Callers own dispatch-specific concerns (replay-divergence, budget) BEFORE
|
|
411
|
+
* calling this; those never occur on the report path (units are journaled).
|
|
412
|
+
*/
|
|
413
|
+
export function reduceStepOutcomes(plan, reducer, isFanOut, onError, units) {
|
|
414
|
+
const failed = units.filter((u) => !u.ok);
|
|
415
|
+
const evidence = buildEvidence(units, reducer, isFanOut);
|
|
416
|
+
const reducerNote = typeof evidence.voteError === "string" ? ` ${evidence.voteError}` : "";
|
|
417
|
+
const tolerateFailures = onError === "continue";
|
|
418
|
+
let ok = (tolerateFailures || failed.length === 0) && !evidence.voteError;
|
|
419
|
+
let summary = `Executed ${units.length} unit(s) for step "${plan.stepId}" via workflow orchestration: ` +
|
|
420
|
+
`${units.length - failed.length} succeeded, ${failed.length} failed.` +
|
|
421
|
+
(failed.length > 0
|
|
422
|
+
? ` Failures${tolerateFailures ? " (recorded, on_error: continue)" : ""}: ${failed
|
|
423
|
+
.map((u) => `${u.unitId} (${u.failureReason ?? "error"})`)
|
|
424
|
+
.join(", ")}.`
|
|
425
|
+
: "") +
|
|
426
|
+
reducerNote;
|
|
427
|
+
let artifactSchemaFailure = false;
|
|
428
|
+
if (ok) {
|
|
429
|
+
const schemaFailure = validateStepArtifact(plan, evidence);
|
|
430
|
+
if (schemaFailure !== undefined) {
|
|
431
|
+
ok = false;
|
|
432
|
+
summary = schemaFailure;
|
|
433
|
+
artifactSchemaFailure = true;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return { ok, units, evidence, summary, ...(artifactSchemaFailure ? { artifactSchemaFailure: true } : {}) };
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* The reduced outcome of a step whose fan-out list resolved to EMPTY (`over: []`
|
|
440
|
+
* or a producer that yielded `[]`): no units are dispatched, so the promoted
|
|
441
|
+
* artifact is the degenerate empty value — the empty array for a `collect`
|
|
442
|
+
* reducer, `null` for `vote` (references into a missing winner fail loudly at
|
|
443
|
+
* resolution rather than silently reading the envelope). Even the degenerate
|
|
444
|
+
* artifact must honor the step's declared `outputSchema` before it can complete.
|
|
445
|
+
*
|
|
446
|
+
* Shared by native dispatch (`executeStepPlan`'s `items.length === 0` branch)
|
|
447
|
+
* and the R3 driver protocol (`report` auto-completes an empty step the spine
|
|
448
|
+
* reaches, since no `report --unit` can ever advance a zero-unit step) so both
|
|
449
|
+
* surfaces promote the SAME artifact and apply the SAME schema verdict — the
|
|
450
|
+
* anti-drift guarantee. Deliberately does NOT run the reducer/vote-tie logic:
|
|
451
|
+
* an empty step has no successful results to count, and a vote-tie "failure"
|
|
452
|
+
* would diverge from the engine's long-standing empty-list semantics.
|
|
453
|
+
*/
|
|
454
|
+
export function reduceEmptyStep(plan, reducer) {
|
|
455
|
+
const evidence = { units: [], itemCount: 0, output: reducer === "collect" ? [] : null };
|
|
456
|
+
const schemaFailure = validateStepArtifact(plan, evidence);
|
|
457
|
+
return {
|
|
458
|
+
ok: schemaFailure === undefined,
|
|
459
|
+
units: [],
|
|
460
|
+
evidence,
|
|
461
|
+
summary: schemaFailure ?? `Step "${plan.stepId}" fan-out list was empty — no units dispatched.`,
|
|
462
|
+
...(schemaFailure !== undefined ? { artifactSchemaFailure: true } : {}),
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Rehydrate a journaled unit row into a {@link UnitOutcome}. Shared by the
|
|
467
|
+
* executor's durable-row reuse (`native-executor.ts`, completed rows only) and
|
|
468
|
+
* the R3 report path (which reduces completed AND failed rows replayed from the
|
|
469
|
+
* journal). A completed row's text unit journals its output as a JSON string; a
|
|
470
|
+
* schema unit journals the validated structure. A failed row carries its
|
|
471
|
+
* `failure_reason`; any journaled text is surfaced too.
|
|
472
|
+
*/
|
|
473
|
+
export function unitOutcomeFromRow(unitId, row, hasSchema) {
|
|
474
|
+
let parsed;
|
|
475
|
+
try {
|
|
476
|
+
parsed = row.result_json === null ? undefined : JSON.parse(row.result_json);
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
parsed = undefined;
|
|
480
|
+
}
|
|
481
|
+
if (row.status === "completed") {
|
|
482
|
+
return {
|
|
483
|
+
unitId,
|
|
484
|
+
ok: true,
|
|
485
|
+
...(hasSchema
|
|
486
|
+
? { result: parsed }
|
|
487
|
+
: typeof parsed === "string"
|
|
488
|
+
? { text: parsed }
|
|
489
|
+
: parsed !== undefined
|
|
490
|
+
? { result: parsed }
|
|
491
|
+
: {}),
|
|
492
|
+
...(row.tokens !== null ? { tokens: row.tokens } : {}),
|
|
493
|
+
...(row.session_id !== null && row.session_id !== undefined ? { sessionId: row.session_id } : {}),
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
return {
|
|
497
|
+
unitId,
|
|
498
|
+
ok: false,
|
|
499
|
+
failureReason: row.failure_reason ?? "reported_failure",
|
|
500
|
+
...(typeof parsed === "string" ? { text: parsed } : {}),
|
|
501
|
+
...(row.tokens !== null ? { tokens: row.tokens } : {}),
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Select the journaled attempt row that determines a unit's TERMINAL outcome on
|
|
506
|
+
* a REPLAY surface — the engine's durable-row reuse AND the harness-neutral
|
|
507
|
+
* brief/report driver protocol — given the run's dispatch rows indexed by
|
|
508
|
+
* unit_id. This is the ONE place all surfaces resolve "which journaled row IS
|
|
509
|
+
* this unit's outcome," so they cannot drift from each other or from the engine.
|
|
510
|
+
*
|
|
511
|
+
* It mirrors the executor's {@link classifyUnitReuse} attempt scan
|
|
512
|
+
* (native-executor.ts): among the base attempt and its `~r<n>` retries — all
|
|
513
|
+
* stacked on `journalBaseId`, which already carries the active `~l<loop>` gate
|
|
514
|
+
* suffix — the FIRST completed attempt is the effective result. So a unit whose
|
|
515
|
+
* base attempt FAILED but whose later retry COMPLETED reduces as COMPLETED,
|
|
516
|
+
* exactly like an engine resume reusing the `~r1` row (Codex round-3 finding C);
|
|
517
|
+
* reading only the base row would reduce it as failed and diverge the two
|
|
518
|
+
* surfaces. With no completed attempt the HIGHEST journaled attempt stands (a
|
|
519
|
+
* terminal failure, or a still-running row); no attempt row at all ⇒ `undefined`
|
|
520
|
+
* (the unit is still outstanding).
|
|
521
|
+
*/
|
|
522
|
+
export function selectUnitAttemptRow(workUnit, dispatchRows) {
|
|
523
|
+
const base = workUnit.journalBaseId;
|
|
524
|
+
const maxAttempts = 1 + Math.max(0, workUnit.retry?.max ?? 0);
|
|
525
|
+
let fallback;
|
|
526
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
527
|
+
const row = dispatchRows.get(attempt === 0 ? base : `${base}~r${attempt}`);
|
|
528
|
+
if (!row)
|
|
529
|
+
continue;
|
|
530
|
+
if (row.status === "completed")
|
|
531
|
+
return row;
|
|
532
|
+
fallback = row; // remember the highest journaled (non-completed) attempt
|
|
533
|
+
}
|
|
534
|
+
return fallback;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Is a FAILED unit still RETRY-ELIGIBLE — i.e. NOT terminal, because a driver
|
|
538
|
+
* could still re-run it via the `--rerun` form (the engine's automatic
|
|
539
|
+
* `<baseId>~r<n>` retry)? A unit whose declared `retry.on` matches the recorded
|
|
540
|
+
* failure reason AND whose attempt budget (`1 + retry.max`) is not yet spent can
|
|
541
|
+
* still be re-run. No `retry`, an off-list reason, or an exhausted attempt budget
|
|
542
|
+
* ⇒ the failure IS terminal. Shared by the report fail-fast decision, the
|
|
543
|
+
* `--settle` refusal, and `brief`'s fully-terminal detection so all three agree
|
|
544
|
+
* on when a failed unit is genuinely done vs. still re-runnable. The normalized
|
|
545
|
+
* failure reason is compared against `retry.on` directly (a canonical taxonomy
|
|
546
|
+
* reason is stored verbatim; an `external:*` reason is by construction outside
|
|
547
|
+
* the taxonomy `retry.on` lists).
|
|
548
|
+
*/
|
|
549
|
+
export function isRetryEligibleFailure(workUnit, row, failureReason) {
|
|
550
|
+
const retry = workUnit.retry;
|
|
551
|
+
if (!retry || failureReason === null || !retry.on.includes(failureReason))
|
|
552
|
+
return false;
|
|
553
|
+
const attempts = row?.attempts ?? 1;
|
|
554
|
+
return attempts < 1 + Math.max(0, retry.max);
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Does a resolvable unit still need a driver to execute + report it (or re-run
|
|
558
|
+
* it)? True for a unit with no terminal row (pending), a still-`running` row (a
|
|
559
|
+
* live/stale claim another driver holds), or a FAILED row that is still
|
|
560
|
+
* retry-eligible. False for a COMPLETED row, a terminal non-retry-eligible
|
|
561
|
+
* FAILURE, or an UNRESOLVABLE unit (the engine's immediate `expression_error` —
|
|
562
|
+
* never reportable). The best terminal attempt (base + `~r<n>` retries) is the
|
|
563
|
+
* one consulted, the SAME reuse the engine and reducer apply.
|
|
564
|
+
*/
|
|
565
|
+
export function unitStillNeedsReport(workUnit, dispatchRows) {
|
|
566
|
+
if (!workUnit.resolved.ok)
|
|
567
|
+
return false;
|
|
568
|
+
const row = selectUnitAttemptRow(workUnit, dispatchRows);
|
|
569
|
+
if (!row)
|
|
570
|
+
return true; // no journal row → pending
|
|
571
|
+
if (row.status === "running")
|
|
572
|
+
return true; // a live/stale claim is still in flight
|
|
573
|
+
if (row.status === "failed")
|
|
574
|
+
return isRetryEligibleFailure(workUnit, row, row.failure_reason);
|
|
575
|
+
return false; // completed (or a non-retry-eligible failure) → terminal
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Is the active step's work-list FULLY TERMINAL — every resolvable unit run to a
|
|
579
|
+
* terminal (done, or non-retry-eligible failed) state with nothing left to
|
|
580
|
+
* execute or per-unit report — yet still needing finalization? This is the
|
|
581
|
+
* driver-recovery state after a required-gate block is resumed, or a crash
|
|
582
|
+
* between the last unit write and the step's completion (owner manual-validation
|
|
583
|
+
* finding 3): the work-list is done but the step never advanced. `brief`
|
|
584
|
+
* surfaces it with a single `report --settle` command and `--settle` runs the
|
|
585
|
+
* shared completion path for it. A list with ANY outstanding unit (pending,
|
|
586
|
+
* in-flight, or retry-eligible failed) is NOT fully terminal — the driver
|
|
587
|
+
* `report --unit`s those. A route-only / empty / all-unresolvable list (no
|
|
588
|
+
* resolvable units) is a DIFFERENT non-dispatching state, handled separately.
|
|
589
|
+
*/
|
|
590
|
+
export function isWorkListFullyTerminal(workList, dispatchRows) {
|
|
591
|
+
if (!workList.units.some((u) => u.resolved.ok))
|
|
592
|
+
return false;
|
|
593
|
+
return workList.units.every((u) => !unitStillNeedsReport(u, dispatchRows));
|
|
594
|
+
}
|
|
595
|
+
/** Stable stringify (sorted object keys, recursively) so equal values vote together. */
|
|
596
|
+
export function canonicalJson(value) {
|
|
597
|
+
return JSON.stringify(sortKeys(value));
|
|
598
|
+
}
|
|
599
|
+
function sortKeys(value) {
|
|
600
|
+
if (Array.isArray(value))
|
|
601
|
+
return value.map(sortKeys);
|
|
602
|
+
if (value && typeof value === "object") {
|
|
603
|
+
return Object.fromEntries(Object.entries(value)
|
|
604
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
605
|
+
.map(([k, v]) => [k, sortKeys(v)]));
|
|
606
|
+
}
|
|
607
|
+
return value;
|
|
608
|
+
}
|
|
609
|
+
// ── Gate-feedback recovery (PURE) ────────────────────────────────────────────
|
|
610
|
+
//
|
|
611
|
+
// A gate rejection is journaled as `<stepId>.gate:l<loop>` with result_json
|
|
612
|
+
// `{ complete: false, missing, feedback }` (see journalGateEvaluationFinish).
|
|
613
|
+
// The feedback stored there is BYTE-IDENTICAL to what the engine threads into
|
|
614
|
+
// the next loop's prompts — both are the same `rejection.feedback`/`.missing`.
|
|
615
|
+
// `brief` recovers it from the journal so its loop-N work-list matches the
|
|
616
|
+
// engine's (redesign addendum R3, task item 2). `native-executor.test.ts`
|
|
617
|
+
// asserts the round-trip identity.
|
|
618
|
+
/**
|
|
619
|
+
* `phase` marker stamped on gate-evaluation unit rows. Step ids cannot contain
|
|
620
|
+
* dots (`PROGRAM_STEP_ID_PATTERN`), so a step can never be NAMED `x.gate` and
|
|
621
|
+
* the synthetic `<stepId>.gate` node id is collision-free against user step
|
|
622
|
+
* ids. The phase column is nonetheless the discriminator we key on — an
|
|
623
|
+
* explicit marker, not a `node_id` suffix match, so recovery stays robust even
|
|
624
|
+
* if the id scheme evolves. Dispatch rows always journal `phase: null`.
|
|
625
|
+
*/
|
|
626
|
+
export const GATE_EVALUATION_PHASE = "gate";
|
|
627
|
+
/** The unit id of a step's gate-evaluation row for a given 1-based loop. */
|
|
628
|
+
export function gateUnitId(stepId, loop) {
|
|
629
|
+
return `${stepId}.gate:l${loop}`;
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* The gate loop the engine is about to (re-)run for an ACTIVE step, derived
|
|
633
|
+
* purely from the journal: one past the highest journaled loop that REJECTED
|
|
634
|
+
* (`complete: false`). No rejected gate rows ⇒ loop 1 (the first execution).
|
|
635
|
+
* A passed gate would have advanced the spine, so an active step never has a
|
|
636
|
+
* `complete: true` row as its latest gate evaluation.
|
|
637
|
+
*
|
|
638
|
+
* Reviewer #17: a gate row that EXISTS but cannot be parsed (or carries an
|
|
639
|
+
* invalid verdict shape) is CORRUPTION — {@link parseGateVerdict} throws loudly
|
|
640
|
+
* rather than letting `gateRowRejected` swallow the parse error, which would
|
|
641
|
+
* silently drop the loop back to 1 and re-dispatch work whose gate outcome is
|
|
642
|
+
* unknown.
|
|
643
|
+
*/
|
|
644
|
+
export function activeGateLoop(rows, stepId) {
|
|
645
|
+
let maxRejectedLoop = 0;
|
|
646
|
+
for (const row of rows) {
|
|
647
|
+
if (row.phase !== GATE_EVALUATION_PHASE || row.step_id !== stepId)
|
|
648
|
+
continue;
|
|
649
|
+
const loop = gateLoopOf(row.unit_id, stepId);
|
|
650
|
+
if (loop === undefined)
|
|
651
|
+
continue;
|
|
652
|
+
// Throws loudly on a corrupt/malformed gate row — never treated as absent.
|
|
653
|
+
if (parseGateVerdict(row).kind === "rejected" && loop > maxRejectedLoop)
|
|
654
|
+
maxRejectedLoop = loop;
|
|
655
|
+
}
|
|
656
|
+
return maxRejectedLoop + 1;
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Recover the gate feedback the engine threads into `loop`'s unit prompts: the
|
|
660
|
+
* `{ feedback, missing }` journaled by the previous loop's rejection
|
|
661
|
+
* (`<stepId>.gate:l<loop-1>`). Loop 1 (or a missing/passed/errored previous row)
|
|
662
|
+
* has no feedback. Pure — the journal rows are passed in.
|
|
663
|
+
*
|
|
664
|
+
* Reviewer #17: a PRESENT previous gate row that cannot be parsed fails LOUDLY
|
|
665
|
+
* (via {@link parseGateVerdict}) instead of returning undefined — a corrupt row
|
|
666
|
+
* must not make an in-loop step look like loop 1 with no recovered feedback.
|
|
667
|
+
*/
|
|
668
|
+
export function recoverGateFeedback(rows, stepId, loop) {
|
|
669
|
+
if (loop <= 1)
|
|
670
|
+
return undefined;
|
|
671
|
+
const prevId = gateUnitId(stepId, loop - 1);
|
|
672
|
+
const prev = rows.find((r) => r.unit_id === prevId && r.phase === GATE_EVALUATION_PHASE);
|
|
673
|
+
if (!prev)
|
|
674
|
+
return undefined;
|
|
675
|
+
const verdict = parseGateVerdict(prev);
|
|
676
|
+
return verdict.kind === "rejected" ? { feedback: verdict.feedback, missing: verdict.missing } : undefined;
|
|
677
|
+
}
|
|
678
|
+
/** The 1-based loop encoded in a `<stepId>.gate:l<n>` unit id, if well-formed. */
|
|
679
|
+
function gateLoopOf(unitId, stepId) {
|
|
680
|
+
const prefix = `${stepId}.gate:l`;
|
|
681
|
+
if (!unitId.startsWith(prefix))
|
|
682
|
+
return undefined;
|
|
683
|
+
const n = Number.parseInt(unitId.slice(prefix.length), 10);
|
|
684
|
+
return Number.isInteger(n) && n >= 1 ? n : undefined;
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Classify a gate-evaluation row's journaled verdict, failing LOUDLY on a
|
|
688
|
+
* corrupt one (reviewer #17). A NULL `result_json` is the LEGITIMATE
|
|
689
|
+
* errored-judge / in-flight shape (`journalGateEvaluationFinish` writes null for
|
|
690
|
+
* an errored judge, and a `running` row has no verdict yet) and classifies as
|
|
691
|
+
* `empty`. But a PRESENT `result_json` that does not parse as JSON, or parses to
|
|
692
|
+
* anything other than an object with a boolean `complete` field, is corruption —
|
|
693
|
+
* a truncated or hand-edited row — and MUST NOT be silently treated as absent
|
|
694
|
+
* (which would reset an active step's gate loop to 1 and re-dispatch work whose
|
|
695
|
+
* completion outcome is unknown). We refuse to guess.
|
|
696
|
+
*/
|
|
697
|
+
function parseGateVerdict(row) {
|
|
698
|
+
if (row.result_json === null)
|
|
699
|
+
return { kind: "empty" };
|
|
700
|
+
let verdict;
|
|
701
|
+
try {
|
|
702
|
+
verdict = JSON.parse(row.result_json);
|
|
703
|
+
}
|
|
704
|
+
catch {
|
|
705
|
+
throw new UsageError(gateCorruptionMessage(row, "its result_json is not valid JSON"));
|
|
706
|
+
}
|
|
707
|
+
if (typeof verdict !== "object" || verdict === null || Array.isArray(verdict)) {
|
|
708
|
+
throw new UsageError(gateCorruptionMessage(row, "its result_json is not a JSON object"));
|
|
709
|
+
}
|
|
710
|
+
const v = verdict;
|
|
711
|
+
if (typeof v.complete !== "boolean") {
|
|
712
|
+
throw new UsageError(gateCorruptionMessage(row, 'its verdict has no boolean "complete" field'));
|
|
713
|
+
}
|
|
714
|
+
if (v.complete === false) {
|
|
715
|
+
const feedback = typeof v.feedback === "string" ? v.feedback : "";
|
|
716
|
+
const missing = Array.isArray(v.missing) ? v.missing.filter((m) => typeof m === "string") : [];
|
|
717
|
+
return { kind: "rejected", missing, feedback };
|
|
718
|
+
}
|
|
719
|
+
return { kind: "passed" };
|
|
720
|
+
}
|
|
721
|
+
function gateCorruptionMessage(row, why) {
|
|
722
|
+
return (`Workflow run ${row.run_id} has a corrupt gate-evaluation row "${row.unit_id}" for step "${row.step_id}" — ${why}. ` +
|
|
723
|
+
`A gate verdict must be {"complete": true|false, …}; refusing to treat a malformed gate row as absent, which would ` +
|
|
724
|
+
`silently restart the step's gate loop and re-dispatch work whose completion outcome is unknown. Fix or remove the ` +
|
|
725
|
+
`journaled row, then resume the run.`);
|
|
726
|
+
}
|
|
727
|
+
/** Insert the gate-evaluation unit row (running) just before the judge runs. */
|
|
728
|
+
export async function journalGateEvaluationStart(gate) {
|
|
729
|
+
const unitId = gateUnitId(gate.stepId, gate.loop);
|
|
730
|
+
await enqueueUnitWrite(() => withWorkflowRunsRepo((repo) => repo.insertUnit({
|
|
731
|
+
runId: gate.runId,
|
|
732
|
+
unitId,
|
|
733
|
+
stepId: gate.stepId,
|
|
734
|
+
nodeId: `${gate.stepId}.gate`,
|
|
735
|
+
parentUnitId: null,
|
|
736
|
+
// Marks the row as a judge call, NOT a dispatch: the budget/lifetime
|
|
737
|
+
// seed in `driveRun` skips these so resume accounting matches live.
|
|
738
|
+
phase: GATE_EVALUATION_PHASE,
|
|
739
|
+
runner: "llm",
|
|
740
|
+
model: null,
|
|
741
|
+
inputHash: null,
|
|
742
|
+
startedAt: new Date().toISOString(),
|
|
743
|
+
})));
|
|
744
|
+
appendEvent({
|
|
745
|
+
eventType: "workflow_unit_started",
|
|
746
|
+
ref: gate.workflowRef,
|
|
747
|
+
metadata: { runId: gate.runId, stepId: gate.stepId, unitId },
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Finish the gate-evaluation unit row with the verdict as observed from the
|
|
752
|
+
* completion outcome: a rejection journals `{ complete: false, missing,
|
|
753
|
+
* feedback }`; a pass journals `{ complete: true, missing: [] }`; a judge that
|
|
754
|
+
* threw (or, on a required gate, returned an unparseable verdict) journals a
|
|
755
|
+
* failed row with a NULL verdict. A NON-required errored gate then fails OPEN
|
|
756
|
+
* inside `validateStepSummary`; a REQUIRED errored gate BLOCKS the step
|
|
757
|
+
* (`finalizeExecutedStep`, Codex round-3 finding A).
|
|
758
|
+
*/
|
|
759
|
+
export async function journalGateEvaluationFinish(gate, errored, rejection) {
|
|
760
|
+
const unitId = gateUnitId(gate.stepId, gate.loop);
|
|
761
|
+
const verdict = errored
|
|
762
|
+
? null
|
|
763
|
+
: rejection
|
|
764
|
+
? { complete: false, missing: rejection.missing, feedback: rejection.feedback }
|
|
765
|
+
: { complete: true, missing: [] };
|
|
766
|
+
const status = errored ? "failed" : "completed";
|
|
767
|
+
await enqueueUnitWrite(() => withWorkflowRunsRepo((repo) => repo.finishUnit({
|
|
768
|
+
runId: gate.runId,
|
|
769
|
+
unitId,
|
|
770
|
+
status,
|
|
771
|
+
resultJson: verdict ? JSON.stringify(verdict) : null,
|
|
772
|
+
tokens: null,
|
|
773
|
+
failureReason: errored ? "dispatch_error" : null,
|
|
774
|
+
finishedAt: new Date().toISOString(),
|
|
775
|
+
})));
|
|
776
|
+
appendEvent({
|
|
777
|
+
eventType: "workflow_unit_finished",
|
|
778
|
+
ref: gate.workflowRef,
|
|
779
|
+
metadata: { runId: gate.runId, stepId: gate.stepId, unitId, status },
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Resolve a route's input (a single whole-value `${{ … }}` reference) and pick
|
|
784
|
+
* the branch. No ambient key search. Only primitive values route; the
|
|
785
|
+
* comparison is exact string equality against the declared `when:` matches.
|
|
786
|
+
*/
|
|
787
|
+
export function evaluateRoute(route, scope) {
|
|
788
|
+
const resolved = resolveWholeValue(route.input, scope);
|
|
789
|
+
if (!resolved.ok) {
|
|
790
|
+
return { ok: false, error: `route input ${route.input} failed to resolve: ${resolved.error.message}` };
|
|
791
|
+
}
|
|
792
|
+
const value = resolved.value;
|
|
793
|
+
if (typeof value === "object" && value !== null) {
|
|
794
|
+
return {
|
|
795
|
+
ok: false,
|
|
796
|
+
error: `route input ${route.input} resolved to a non-primitive value; branches match on strings/numbers/booleans.`,
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
const valueString = typeof value === "string" ? value : String(value);
|
|
800
|
+
// Own-property check: `when` is author-controlled, and a value such as
|
|
801
|
+
// "constructor" must not resolve through Object.prototype.
|
|
802
|
+
const selected = Object.hasOwn(route.when, valueString) ? route.when[valueString] : route.defaultStepId;
|
|
803
|
+
if (!selected) {
|
|
804
|
+
return {
|
|
805
|
+
ok: false,
|
|
806
|
+
error: `value "${valueString}" matched no "when:" branch and the route declares no default.`,
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
return { ok: true, value: valueString, selected };
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Cascade a SKIPPED router: it never evaluated its route, so every declared
|
|
813
|
+
* target (branches + default) is marked skip-on-reach unless an earlier router
|
|
814
|
+
* already claimed it. Shared by the live skip path and the journal replay.
|
|
815
|
+
*/
|
|
816
|
+
export function cascadeSkippedRouter(route, routerId, routeUnselected) {
|
|
817
|
+
const targets = [...Object.values(route.when), ...(route.defaultStepId ? [route.defaultStepId] : [])];
|
|
818
|
+
for (const target of targets) {
|
|
819
|
+
if (!routeUnselected.has(target)) {
|
|
820
|
+
routeUnselected.set(target, { router: routerId, selected: null });
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Record one router's decision in the skip bookkeeping: the selected target is
|
|
826
|
+
* protected, every other declared target (branches + default) is marked
|
|
827
|
+
* skip-on-reach unless an earlier router already claimed it. Shared by the live
|
|
828
|
+
* evaluation path and the journal replay.
|
|
829
|
+
*/
|
|
830
|
+
export function applyRouteDecision(route, routerId, selected, routeSelected, routeUnselected) {
|
|
831
|
+
routeSelected.add(selected);
|
|
832
|
+
const targets = [...Object.values(route.when), ...(route.defaultStepId ? [route.defaultStepId] : [])];
|
|
833
|
+
for (const target of targets) {
|
|
834
|
+
if (target !== selected && !routeUnselected.has(target)) {
|
|
835
|
+
routeUnselected.set(target, { router: routerId, selected });
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* The `stepOutputs` scope a route resolves against: every prior step's recorded
|
|
841
|
+
* evidence plus the just-finished step's fresh evidence — each projected
|
|
842
|
+
* through {@link projectStepOutput}. Same projection as unit templates, so the
|
|
843
|
+
* two scopes cannot drift.
|
|
844
|
+
*/
|
|
845
|
+
export function routeStepOutputs(evidence, currentStepId, currentEvidence) {
|
|
846
|
+
const outputs = {};
|
|
847
|
+
for (const [stepId, stepEvidence] of Object.entries(evidence)) {
|
|
848
|
+
if (stepEvidence !== undefined)
|
|
849
|
+
outputs[stepId] = projectStepOutput(stepEvidence);
|
|
850
|
+
}
|
|
851
|
+
outputs[currentStepId] = projectStepOutput(currentEvidence);
|
|
852
|
+
return outputs;
|
|
853
|
+
}
|
|
854
|
+
/** The `selected` target journaled on a route step's evidence, if well-formed. */
|
|
855
|
+
function journaledRouteSelection(evidence) {
|
|
856
|
+
const route = evidence?.route;
|
|
857
|
+
if (typeof route !== "object" || route === null || Array.isArray(route))
|
|
858
|
+
return undefined;
|
|
859
|
+
const selected = route.selected;
|
|
860
|
+
return typeof selected === "string" && selected !== "" ? selected : undefined;
|
|
861
|
+
}
|
|
862
|
+
/** The set of steps a route may legally select: its `when` branches + default. */
|
|
863
|
+
function routeTargets(route) {
|
|
864
|
+
return new Set([...Object.values(route.when), ...(route.defaultStepId ? [route.defaultStepId] : [])]);
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Reviewer #7: a journaled route decision must name a target the route actually
|
|
868
|
+
* DECLARES (`when` branch or `default`). Corrupted or hand-edited evidence can
|
|
869
|
+
* otherwise mark a non-existent step as `selected` — which unselects and skips
|
|
870
|
+
* every REAL branch target, silently steering the run down a phantom branch.
|
|
871
|
+
* `evaluateRoute` can only ever produce a declared target, so a stored value
|
|
872
|
+
* outside that set is provably tampered evidence: fail loudly rather than seed a
|
|
873
|
+
* bogus skip set.
|
|
874
|
+
*/
|
|
875
|
+
function assertRouteTargetDeclared(route, stepId, selected, runId) {
|
|
876
|
+
const targets = routeTargets(route);
|
|
877
|
+
if (!targets.has(selected)) {
|
|
878
|
+
throw new UsageError(`Workflow run ${runId} has a completed route step "${stepId}" whose journaled route decision selected ` +
|
|
879
|
+
`"${selected}", which is not a declared branch or default target of the route (valid targets: ` +
|
|
880
|
+
`${[...targets].join(", ") || "(none)"}). The route evidence was corrupted or manually edited — refusing to ` +
|
|
881
|
+
`apply a bogus route decision that would skip the real branch targets. Start a new run.`);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Validate every COMPLETED route step's journaled selection against its declared
|
|
886
|
+
* targets (reviewer #7). Read-only: it throws on a PRESENT-but-invalid selection
|
|
887
|
+
* and is silent on an absent one, so it never false-positives on a healthy run —
|
|
888
|
+
* making it safe to call from the read-only `brief` surface as well as the
|
|
889
|
+
* resume/report surfaces that already re-apply the decisions.
|
|
890
|
+
*/
|
|
891
|
+
export function assertJournaledRouteSelectionsValid(plan, state) {
|
|
892
|
+
for (const stepPlan of plan.steps) {
|
|
893
|
+
if (!stepPlan.route)
|
|
894
|
+
continue;
|
|
895
|
+
const stepState = state.workflow.steps.find((s) => s.id === stepPlan.stepId);
|
|
896
|
+
if (!stepState || stepState.status !== "completed")
|
|
897
|
+
continue;
|
|
898
|
+
const selected = journaledRouteSelection(stepState.evidence);
|
|
899
|
+
if (selected !== undefined) {
|
|
900
|
+
assertRouteTargetDeclared(stepPlan.route, stepPlan.stepId, selected, state.run.id);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* Replay journaled route decisions into the skip bookkeeping (resume path).
|
|
906
|
+
* For every COMPLETED route step of the frozen plan, in spine order: the
|
|
907
|
+
* journaled decision wins; else a re-derivation from the frozen plan +
|
|
908
|
+
* journaled evidence; else fail loudly. A SKIPPED route step cascades its
|
|
909
|
+
* targets into the skip set exactly as on the live path.
|
|
910
|
+
*/
|
|
911
|
+
export function seedJournaledRouteDecisions(plan, state, routeSelected, routeUnselected) {
|
|
912
|
+
const evidence = {};
|
|
913
|
+
for (const s of state.workflow.steps)
|
|
914
|
+
evidence[s.id] = s.evidence;
|
|
915
|
+
for (const stepPlan of plan.steps) {
|
|
916
|
+
if (!stepPlan.route)
|
|
917
|
+
continue;
|
|
918
|
+
const stepState = state.workflow.steps.find((s) => s.id === stepPlan.stepId);
|
|
919
|
+
if (!stepState)
|
|
920
|
+
continue;
|
|
921
|
+
if (stepState.status === "skipped") {
|
|
922
|
+
cascadeSkippedRouter(stepPlan.route, stepPlan.stepId, routeUnselected);
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
if (stepState.status !== "completed")
|
|
926
|
+
continue;
|
|
927
|
+
let selected = journaledRouteSelection(stepState.evidence);
|
|
928
|
+
if (selected !== undefined) {
|
|
929
|
+
// Reviewer #7: a stored decision must name a declared target — a bogus one
|
|
930
|
+
// (tampered/hand-edited evidence) fails loudly rather than seeding a skip
|
|
931
|
+
// set that buries the real branches.
|
|
932
|
+
assertRouteTargetDeclared(stepPlan.route, stepPlan.stepId, selected, state.run.id);
|
|
933
|
+
}
|
|
934
|
+
if (selected === undefined) {
|
|
935
|
+
const scope = {
|
|
936
|
+
params: state.run.params ?? {},
|
|
937
|
+
stepOutputs: routeStepOutputs(evidence, stepPlan.stepId, stepState.evidence ?? {}),
|
|
938
|
+
};
|
|
939
|
+
const decision = evaluateRoute(stepPlan.route, scope);
|
|
940
|
+
if (decision.ok)
|
|
941
|
+
selected = decision.selected;
|
|
942
|
+
}
|
|
943
|
+
if (selected === undefined) {
|
|
944
|
+
throw new UsageError(`Workflow run ${state.run.id} has a completed route step "${stepPlan.stepId}" with no journaled route ` +
|
|
945
|
+
`decision, and the decision cannot be re-derived from the journaled evidence. Refusing to guess which ` +
|
|
946
|
+
`branch was selected — advance the remaining steps manually with \`akm workflow complete\`.`);
|
|
947
|
+
}
|
|
948
|
+
applyRouteDecision(stepPlan.route, stepPlan.stepId, selected, routeSelected, routeUnselected);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* Perform ONE completion attempt for an executed step:
|
|
953
|
+
*
|
|
954
|
+
* - a hard unit failure completes the step `failed` (a retryable typed-artifact
|
|
955
|
+
* mismatch with loops remaining returns `retry` WITHOUT journaling a gate row
|
|
956
|
+
* — no judge ran, exactly like the engine);
|
|
957
|
+
* - a route decision is evaluated against params + prior/fresh step outputs; an
|
|
958
|
+
* unroutable value fails the step; a valid decision is journaled on the
|
|
959
|
+
* step evidence and applied to the skip bookkeeping;
|
|
960
|
+
* - the completion gate judges a summary BUILT FROM the promoted artifact (when
|
|
961
|
+
* the step declares criteria), journaled as a `<stepId>.gate:l<loop>` unit
|
|
962
|
+
* row; a rejection with loops remaining returns `retry` (feedback threaded
|
|
963
|
+
* into the next loop), a rejection with none returns `gate-exhausted`, a pass
|
|
964
|
+
* returns `advanced`.
|
|
965
|
+
*
|
|
966
|
+
* Every DB advance goes through {@link completeWorkflowStep} — the gate spine is
|
|
967
|
+
* never bypassed. Behavior is byte-identical to the engine's former inline loop
|
|
968
|
+
* body (its tests prove it).
|
|
969
|
+
*/
|
|
970
|
+
export async function finalizeExecutedStep(input) {
|
|
971
|
+
const { runId, workflowRef, stepId, stepPlan, completionCriteria, gateLoop, loopsRemaining, result } = input;
|
|
972
|
+
const lease = input.leaseHolder !== undefined ? { leaseHolder: input.leaseHolder } : {};
|
|
973
|
+
if (!result.ok) {
|
|
974
|
+
// Typed-artifact mismatch with loop budget left: regenerate-with-errors
|
|
975
|
+
// (the validation errors become the next loop's feedback). No judge ran, so
|
|
976
|
+
// no gate row is journaled for this attempt.
|
|
977
|
+
if (result.artifactSchemaFailure && loopsRemaining) {
|
|
978
|
+
return { kind: "retry", gateFeedback: { feedback: result.summary, missing: [] } };
|
|
979
|
+
}
|
|
980
|
+
await completeWorkflowStep({
|
|
981
|
+
runId,
|
|
982
|
+
stepId,
|
|
983
|
+
status: "failed",
|
|
984
|
+
notes: result.summary,
|
|
985
|
+
evidence: result.evidence,
|
|
986
|
+
...lease,
|
|
987
|
+
});
|
|
988
|
+
return { kind: "failed", summary: result.summary };
|
|
989
|
+
}
|
|
990
|
+
// Resolve the completion-criteria judge ONCE (reused by the gate below). A
|
|
991
|
+
// `null` result means NO judge is available: `undefined` builds the default
|
|
992
|
+
// from config (null when offline / misconfigured), and an explicit `null`
|
|
993
|
+
// caller override is offline by construction.
|
|
994
|
+
const innerJudge = input.summaryJudge === undefined ? buildDefaultSummaryJudge() : input.summaryJudge;
|
|
995
|
+
// Reviewer #18: a REQUIRED completion gate must actually be judged. When the
|
|
996
|
+
// gate carries criteria but no judge is available, `validateStepSummary` would
|
|
997
|
+
// fail OPEN and silently pass the gate — exactly the offline/misconfigured
|
|
998
|
+
// bypass a required gate exists to prevent. BLOCK the step instead (a human
|
|
999
|
+
// resolves it via the documented manual path), rather than advance the spine
|
|
1000
|
+
// on an unjudged gate. `gate.required` rides the frozen plan (both surfaces);
|
|
1001
|
+
// `requireGates` is the engine's run-wide `--require-gates` override. Checked
|
|
1002
|
+
// BEFORE route evaluation so a blocked step journals no route decision.
|
|
1003
|
+
const gateRequired = stepPlan.gate.required === true || input.requireGates === true;
|
|
1004
|
+
if (gateRequired && completionCriteria.length > 0 && innerJudge === null) {
|
|
1005
|
+
const notes = `Step "${stepId}" has a REQUIRED completion gate but no summary-validation judge is available ` +
|
|
1006
|
+
`(no LLM is configured, or default LLM resolution failed). A required gate must be judged — refusing to fail ` +
|
|
1007
|
+
`open and silently pass it. The step is BLOCKED: configure an LLM, then \`akm workflow resume ${runId}\` to ` +
|
|
1008
|
+
`re-evaluate the gate, or advance the step manually with \`akm workflow complete\`.`;
|
|
1009
|
+
await completeWorkflowStep({ runId, stepId, status: "blocked", notes, evidence: result.evidence, ...lease });
|
|
1010
|
+
return { kind: "blocked", summary: notes };
|
|
1011
|
+
}
|
|
1012
|
+
// Route evaluation BEFORE completion: an unroutable value is an
|
|
1013
|
+
// authoring/config failure that must fail the step deterministically.
|
|
1014
|
+
let summaryOverride;
|
|
1015
|
+
if (stepPlan.route) {
|
|
1016
|
+
const scope = {
|
|
1017
|
+
params: input.params,
|
|
1018
|
+
stepOutputs: routeStepOutputs(input.priorEvidence, stepId, result.evidence),
|
|
1019
|
+
};
|
|
1020
|
+
const decision = evaluateRoute(stepPlan.route, scope);
|
|
1021
|
+
if (!decision.ok) {
|
|
1022
|
+
const notes = `Step "${stepId}" route failed: ${decision.error}`;
|
|
1023
|
+
await completeWorkflowStep({ runId, stepId, status: "failed", notes, evidence: result.evidence, ...lease });
|
|
1024
|
+
return { kind: "failed", summary: notes, routeFailure: true };
|
|
1025
|
+
}
|
|
1026
|
+
applyRouteDecision(stepPlan.route, stepId, decision.selected, input.routeSelected, input.routeUnselected);
|
|
1027
|
+
// Journal the decision on the evidence: resume replays it via
|
|
1028
|
+
// seedJournaledRouteDecisions, so the skip set survives re-invocation.
|
|
1029
|
+
result.evidence.route = { input: stepPlan.route.input, value: decision.value, selected: decision.selected };
|
|
1030
|
+
if (!stepPlan.root) {
|
|
1031
|
+
summaryOverride = `Step "${stepId}" routed on ${stepPlan.route.input}: value "${decision.value}" selected step "${decision.selected}".`;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
// Artifact-judging gate: a criteria-bearing executing step is judged on a
|
|
1035
|
+
// summary BUILT FROM the promoted artifact; everything else keeps the machine
|
|
1036
|
+
// summary (a route-only step's summary IS its decision).
|
|
1037
|
+
const summary = stepPlan.root && completionCriteria.length > 0
|
|
1038
|
+
? buildArtifactSummary(stepId, result.units, result.evidence)
|
|
1039
|
+
: (summaryOverride ?? result.summary);
|
|
1040
|
+
// Journal engine-driven judge calls as unit rows (they are LLM calls). The
|
|
1041
|
+
// wrapper's `invoked` stays false when the gate is fail-open (no criteria / no
|
|
1042
|
+
// judge) — nothing is journaled, and human approvals are never cached.
|
|
1043
|
+
const gateUnit = { runId, workflowRef, stepId, loop: gateLoop };
|
|
1044
|
+
const judgeState = { invoked: false, errored: false };
|
|
1045
|
+
const summaryJudge = innerJudge
|
|
1046
|
+
? async (prompt) => {
|
|
1047
|
+
judgeState.invoked = true;
|
|
1048
|
+
await journalGateEvaluationStart(gateUnit);
|
|
1049
|
+
try {
|
|
1050
|
+
return await innerJudge(prompt);
|
|
1051
|
+
}
|
|
1052
|
+
catch (err) {
|
|
1053
|
+
judgeState.errored = true;
|
|
1054
|
+
throw err;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
: null;
|
|
1058
|
+
// Reviewer #6: once the judge is invoked, its gate row is journaled `running`
|
|
1059
|
+
// (journalGateEvaluationStart) and MUST be finished on every exit. The
|
|
1060
|
+
// already-fixed window is the judge itself throwing (caught inside
|
|
1061
|
+
// validateStepSummary — `judgeState.errored` records it; a non-required gate
|
|
1062
|
+
// fails open, a required gate blocks below). The remaining
|
|
1063
|
+
// window is `completeWorkflowStep` throwing AFTER the judge ran — a stolen
|
|
1064
|
+
// lease, a concurrent state change, a DB error — which would otherwise skip the
|
|
1065
|
+
// finish and strand the gate row in `running`. Finish it as an errored row (the
|
|
1066
|
+
// observed outcome: the completion did not succeed), then re-propagate.
|
|
1067
|
+
let completion;
|
|
1068
|
+
try {
|
|
1069
|
+
completion = await completeWorkflowStep({
|
|
1070
|
+
runId,
|
|
1071
|
+
stepId,
|
|
1072
|
+
status: "completed",
|
|
1073
|
+
summary,
|
|
1074
|
+
evidence: result.evidence,
|
|
1075
|
+
summaryJudge,
|
|
1076
|
+
// Codex round-3 finding A: mark this completion's gate REQUIRED so
|
|
1077
|
+
// `validateStepSummary` does NOT fail open when the judge throws / is
|
|
1078
|
+
// unreachable / returns garbage — it flags `errored` and we block below.
|
|
1079
|
+
...(gateRequired ? { requireGate: true } : {}),
|
|
1080
|
+
...lease,
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
catch (err) {
|
|
1084
|
+
if (judgeState.invoked)
|
|
1085
|
+
await journalGateEvaluationFinish(gateUnit, true, undefined);
|
|
1086
|
+
throw err;
|
|
1087
|
+
}
|
|
1088
|
+
const rejection = "ok" in completion && completion.ok === false ? completion : undefined;
|
|
1089
|
+
// A required gate whose judge could not be evaluated is an errored gate, not a
|
|
1090
|
+
// real rejection: journal the gate row as errored (verdict null) so the
|
|
1091
|
+
// observed outcome is honest, driven by EITHER the wrapper catching a throw OR
|
|
1092
|
+
// validateStepSummary flagging an unparseable verdict.
|
|
1093
|
+
const gateErrored = judgeState.errored || rejection?.errored === true;
|
|
1094
|
+
if (judgeState.invoked) {
|
|
1095
|
+
await journalGateEvaluationFinish(gateUnit, gateErrored, rejection);
|
|
1096
|
+
}
|
|
1097
|
+
// Codex round-3 finding A: a REQUIRED gate that could not be judged (the judge
|
|
1098
|
+
// threw, was unreachable, or returned an unparseable verdict) must NOT fail
|
|
1099
|
+
// open and advance. The gate row is journaled errored above; BLOCK the step (a
|
|
1100
|
+
// human resolves it) instead of silently passing an unjudged required gate.
|
|
1101
|
+
if (rejection?.errored) {
|
|
1102
|
+
const notes = `Step "${stepId}" has a REQUIRED completion gate but its summary-validation judge failed to return a verdict ` +
|
|
1103
|
+
`(the LLM threw, was unreachable, or returned an unparseable response). A required gate must be judged — refusing ` +
|
|
1104
|
+
`to fail open and silently pass it. The step is BLOCKED: fix the LLM/connection, then \`akm workflow resume ${runId}\` ` +
|
|
1105
|
+
`to re-evaluate the gate, or advance the step manually with \`akm workflow complete\`.`;
|
|
1106
|
+
await completeWorkflowStep({ runId, stepId, status: "blocked", notes, evidence: result.evidence, ...lease });
|
|
1107
|
+
return { kind: "blocked", summary: notes };
|
|
1108
|
+
}
|
|
1109
|
+
if (!rejection) {
|
|
1110
|
+
return { kind: "advanced", ...(summaryOverride !== undefined ? { summaryOverride } : {}) };
|
|
1111
|
+
}
|
|
1112
|
+
if (loopsRemaining) {
|
|
1113
|
+
return { kind: "retry", gateFeedback: { feedback: rejection.feedback, missing: rejection.missing } };
|
|
1114
|
+
}
|
|
1115
|
+
return {
|
|
1116
|
+
kind: "gate-exhausted",
|
|
1117
|
+
gateRejection: { stepId, missing: rejection.missing, feedback: rejection.feedback },
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
// ── Frozen plan parse + integrity check (shared) ─────────────────────────────
|
|
1121
|
+
/**
|
|
1122
|
+
* Parse and integrity-check a run's frozen plan JSON (migration 006). Shared by
|
|
1123
|
+
* the engine loop's plan loader (`run-workflow.ts`) and the R3 brief/report
|
|
1124
|
+
* surfaces so all three apply the SAME corruption + hash checks — the frozen
|
|
1125
|
+
* plan the engine executes is the exact plan brief describes and report
|
|
1126
|
+
* validates against. A NULL `plan_json` is the CALLER's decision (the engine
|
|
1127
|
+
* warns and compiles from the asset; brief/report error), so this helper only
|
|
1128
|
+
* handles a PRESENT plan string.
|
|
1129
|
+
*/
|
|
1130
|
+
export function parseFrozenPlan(runId, planJson, planHash) {
|
|
1131
|
+
let plan;
|
|
1132
|
+
try {
|
|
1133
|
+
plan = JSON.parse(planJson);
|
|
1134
|
+
}
|
|
1135
|
+
catch {
|
|
1136
|
+
throw new UsageError(`Workflow run ${runId} has a corrupt frozen plan (plan_json is not valid JSON). ` +
|
|
1137
|
+
`The journaled plan cannot be executed — start a new run.`);
|
|
1138
|
+
}
|
|
1139
|
+
if (computePlanHash(plan) !== planHash) {
|
|
1140
|
+
throw new UsageError(`Workflow run ${runId} failed the frozen-plan integrity check: plan_json does not match plan_hash. ` +
|
|
1141
|
+
`The journaled plan was modified after the run started — refusing to execute it. Start a new run.`);
|
|
1142
|
+
}
|
|
1143
|
+
return plan;
|
|
1144
|
+
}
|
|
1145
|
+
// ── Small helpers ────────────────────────────────────────────────────────────
|
|
1146
|
+
function safeJson(value) {
|
|
1147
|
+
try {
|
|
1148
|
+
return JSON.stringify(value) ?? "null";
|
|
1149
|
+
}
|
|
1150
|
+
catch {
|
|
1151
|
+
return "null";
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
function clip(text, max) {
|
|
1155
|
+
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
1156
|
+
}
|