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,975 @@
|
|
|
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
|
+
* Native executor — executes ONE step's IR v2 subgraph (`IrStepPlan.root`) on
|
|
6
|
+
* the local machine: fan-out through the scheduler, schema-validated
|
|
7
|
+
* structured output through `runStructured` (core/structured.ts), per-unit
|
|
8
|
+
* persistence through the serialized writer queue, and `workflow_unit_*`
|
|
9
|
+
* events for observability.
|
|
10
|
+
*
|
|
11
|
+
* Data flow (redesign addendum, R1): workflow-authored templates go through
|
|
12
|
+
* the deterministic `${{ … }}` expression language (`program/expressions.ts`)
|
|
13
|
+
* — but ONLY for nodes the frontend marked `templating: "expressions"` (YAML
|
|
14
|
+
* program units). Classic linear markdown instructions are `"verbatim"`:
|
|
15
|
+
* opaque data handed to the agent byte-exact (the stable CLI contract — a
|
|
16
|
+
* literal `${{` there is content, never grammar). Expression templates are
|
|
17
|
+
* parsed ONCE per step and resolved per unit against `{ params, stepOutputs,
|
|
18
|
+
* item, item_index }`; `map.over` resolves as a single whole-value reference.
|
|
19
|
+
* Substituted content is data, never re-scanned — the P1 `{{item}}` re-scan
|
|
20
|
+
* injection class is structurally impossible. There is NO ambient key search:
|
|
21
|
+
* a `steps.<id>.output.<path>` reference addresses INTO that step's recorded
|
|
22
|
+
* output explicitly.
|
|
23
|
+
*
|
|
24
|
+
* Step outputs (`${{ steps.<id>.output… }}`): every engine-executed step
|
|
25
|
+
* journals a promoted ARTIFACT under `evidence.output` — the solo unit's
|
|
26
|
+
* result/text, the collect reducer's per-item array, or the vote reducer's
|
|
27
|
+
* winner — and that artifact is what the expression scope exposes
|
|
28
|
+
* ({@link projectStepOutput}). The documented addressing
|
|
29
|
+
* (`steps.discover.output.files`) therefore resolves against real step
|
|
30
|
+
* results, never the raw evidence envelope (peer review R1). Steps completed
|
|
31
|
+
* manually (no `output` key in their evidence) expose their recorded evidence
|
|
32
|
+
* object as-is.
|
|
33
|
+
*
|
|
34
|
+
* Empty free-text outputs (peer review): a SUCCESSFUL schemaless unit that
|
|
35
|
+
* returns the empty string is normalized to "no output" — {@link dispatchUnit}
|
|
36
|
+
* drops the falsy `text`, `finishUnit` journals `result_json = NULL`, and both
|
|
37
|
+
* durable-row reuse and the R3 report surface rehydrate the same absence
|
|
38
|
+
* (`unitOutcomeFromRow`). This is the ONLY empty-output resolution: `''` never
|
|
39
|
+
* survives on any surface, so the live artifact cannot diverge from the
|
|
40
|
+
* resume/report artifact (the cross-surface parity cardinal rule; the
|
|
41
|
+
* `EMPTY_OUTPUT` driver-parity golden pins it). Consequences that follow from
|
|
42
|
+
* "empty == absent", not special-cased anywhere:
|
|
43
|
+
* - a SOLO empty step promotes `output = null` (the unit's absent text ??
|
|
44
|
+
* null); a `collect` fan-out promotes `null` in that item's slot.
|
|
45
|
+
* - A downstream `${{ steps.x.output }}` of an empty solo step therefore
|
|
46
|
+
* resolves against `null` and fails LOUDLY at expression resolution
|
|
47
|
+
* (`… resolved to null`) — a deterministic `expression_error` on BOTH
|
|
48
|
+
* surfaces, never a silent empty string.
|
|
49
|
+
* - A SCHEMA unit is unaffected by this normalization: an empty response is
|
|
50
|
+
* not parseable JSON, so `runStructured` fails it (`parse_error`) — an
|
|
51
|
+
* empty output can never satisfy a declared schema as a silent `null`.
|
|
52
|
+
*
|
|
53
|
+
* Typed artifacts (addendum, R2): when the step declares an `output` schema
|
|
54
|
+
* (`IrStepPlan.outputSchema`), the promoted artifact is validated with the
|
|
55
|
+
* JSON-schema-subset validator BEFORE the step can complete. A mismatch fails
|
|
56
|
+
* the step (fail-fast) with the validation errors in the summary — a
|
|
57
|
+
* downstream consumer must never receive an artifact the author's contract
|
|
58
|
+
* says cannot exist. The failure is flagged (`artifactSchemaFailure` on the
|
|
59
|
+
* result) so the engine's bounded gate loop can re-run the step with the
|
|
60
|
+
* validation errors as feedback ("gate loops can re-run it") — a step with
|
|
61
|
+
* loop budget left regenerates instead of killing the run.
|
|
62
|
+
*
|
|
63
|
+
* Unit identity (addendum, R2): CONTENT-DERIVED, never positional. A fan-out
|
|
64
|
+
* unit's id is `<node_id>:<sha256(canonicalJson(item))[:12]>`; a solo unit's
|
|
65
|
+
* is `<node_id>:solo`. Identity therefore survives item-list regeneration and
|
|
66
|
+
* reordering — resuming a run whose producer re-emitted the same items in a
|
|
67
|
+
* different order reuses every journaled result. Consequences:
|
|
68
|
+
* - DUPLICATE items in one fan-out list collide on identity. That is an
|
|
69
|
+
* authoring error (the same work dispatched twice under one id): the step
|
|
70
|
+
* fails deterministically after resolving the item list, naming the
|
|
71
|
+
* duplicate, before anything dispatches.
|
|
72
|
+
* - REPLAY DIVERGENCE: a journaled COMPLETED row whose unit_id matches but
|
|
73
|
+
* whose `input_hash` differs is a hard step failure ("replay divergence"),
|
|
74
|
+
* never a silent re-dispatch — under a frozen plan the same identity must
|
|
75
|
+
* reproduce the same inputs, so a mismatch means the journal (or params
|
|
76
|
+
* row) was tampered with. Failed/running/missing rows dispatch live.
|
|
77
|
+
* - Pre-release R1 journals used positional ids (`node.unit[3]`). There is
|
|
78
|
+
* no back-compat shim: those rows simply never match a content-derived id
|
|
79
|
+
* and are ignored (the step re-runs cleanly on top of them).
|
|
80
|
+
*
|
|
81
|
+
* Gate loops (addendum, R2 `gate.max_loops`): when the engine re-executes a
|
|
82
|
+
* step subgraph after a gate rejection, it threads the judge's feedback in as
|
|
83
|
+
* `ctx.gateFeedback` (appended to every unit prompt — the input hash changes,
|
|
84
|
+
* so re-dispatch is natural) and marks the attempt with `ctx.gateLoop` (>= 2).
|
|
85
|
+
* Loop attempts journal under `<unitId>~l<loop>` — like `~r<n>` retries, pure
|
|
86
|
+
* journal bookkeeping on top of the content-derived identity, so loop 1's
|
|
87
|
+
* rows are never clobbered. Because gate feedback is JUDGE-authored (a fresh
|
|
88
|
+
* LLM output per invocation, not a pure function of the frozen plan), a
|
|
89
|
+
* journaled loop row whose hash no longer matches re-dispatches live instead
|
|
90
|
+
* of raising replay divergence — the divergence guarantee applies to loop-1
|
|
91
|
+
* rows, whose inputs ARE pure functions of (plan, params, journaled results).
|
|
92
|
+
*
|
|
93
|
+
* Failure policy (addendum, "explicit surface, fail-fast default"):
|
|
94
|
+
* - `onError: "fail"` (default) fails the step on any unit failure;
|
|
95
|
+
* `"continue"` records failures in the evidence and lets the gate decide.
|
|
96
|
+
* - `retry: { max, on }` re-dispatches a failed unit up to `max` extra
|
|
97
|
+
* times when its `failureReason` is in `on`. Every retry journals its OWN
|
|
98
|
+
* row under `<unitId>~r<attempt>` so no attempt's record is clobbered.
|
|
99
|
+
*
|
|
100
|
+
* Worktree isolation (addendum, R2 `isolation: worktree`): each journaled
|
|
101
|
+
* attempt of an isolated agent/sdk unit runs in a FRESH detached git worktree
|
|
102
|
+
* of the engine's working directory (`ctx.workDir`, default `process.cwd()`),
|
|
103
|
+
* minted under a run-scoped tmp dir (`worktree.ts`) and passed to dispatch as
|
|
104
|
+
* the child's cwd. The path is journaled on the unit row (`worktree_path`);
|
|
105
|
+
* after the unit finishes, a clean worktree is removed and a dirty one is
|
|
106
|
+
* retained + logged (uncollected work is never destroyed). "Clean" is
|
|
107
|
+
* `git status --porcelain` WITHOUT `--ignored`, so a worktree whose only
|
|
108
|
+
* residue is `.gitignore`-matched files (build outputs, `node_modules`) counts
|
|
109
|
+
* as clean and IS removed — those files are disposable by the repo's own
|
|
110
|
+
* declaration, and retaining a worktree per build would blow up disk
|
|
111
|
+
* (`worktree.ts` contract). A non-git base directory fails the step cleanly
|
|
112
|
+
* before any dispatch, and llm units reject isolation loudly — there is no
|
|
113
|
+
* child process to isolate.
|
|
114
|
+
*
|
|
115
|
+
* Budget ceilings (addendum, R2): a frozen plan's `budget` block
|
|
116
|
+
* (`max_units` / `max_tokens`) is enforced per RUN. The engine seeds
|
|
117
|
+
* `ctx.unitsDispatched` (journal row count) and `ctx.tokensUsed` (journaled
|
|
118
|
+
* token sum) and threads the running totals across steps; this executor
|
|
119
|
+
* consumes both per ACTUAL dispatch. Hitting a ceiling aborts pending and
|
|
120
|
+
* in-flight dispatches through an AbortController chained onto `ctx.signal`
|
|
121
|
+
* and fails the step with a "budget exceeded (<which> ceiling)" summary —
|
|
122
|
+
* hard, regardless of `on_error`, exactly like the lifetime cap.
|
|
123
|
+
*
|
|
124
|
+
* Layering (see the plan's *Reconciliation* section):
|
|
125
|
+
* - Dispatch goes through ONE injected {@link UnitDispatcher} seam. The
|
|
126
|
+
* default dispatcher composes the EXISTING substrate — `executeRunner`
|
|
127
|
+
* (agent/sdk) and `chatCompletion` (llm, lazily imported so the engine
|
|
128
|
+
* stays offline-capable until a workflow actually declares an llm unit).
|
|
129
|
+
* - This module NEVER writes step rows: advancing the gated spine is the
|
|
130
|
+
* engine loop's job (`run-workflow.ts`) via `completeWorkflowStep`.
|
|
131
|
+
*/
|
|
132
|
+
import { ConfigError } from "../../core/errors.js";
|
|
133
|
+
import { appendEvent } from "../../core/events.js";
|
|
134
|
+
import { validateJsonSchemaSubset } from "../../core/json-schema.js";
|
|
135
|
+
import { runStructured } from "../../core/structured.js";
|
|
136
|
+
import { warn } from "../../core/warn.js";
|
|
137
|
+
import { withWorkflowRunsRepo } from "../../storage/repositories/workflow-runs-repository.js";
|
|
138
|
+
import { LIFETIME_UNIT_CAP, scheduleUnits, UnitCapExceededError } from "./scheduler.js";
|
|
139
|
+
// Shared step semantics — the ONE implementation consumed by both the engine
|
|
140
|
+
// (this module + run-workflow.ts) and, from R3, the brief/report driver
|
|
141
|
+
// protocol. This module dispatches; step-work.ts owns the pure decisions.
|
|
142
|
+
import { buildArtifactSummary, computeStepWorkList, DEFAULT_UNIT_TIMEOUT_MS, projectStepOutput, reduceEmptyStep, reduceStepOutcomes, stepOutputsFromEvidence, unitOutcomeFromRow, } from "./step-work.js";
|
|
143
|
+
import { enqueueUnitWrite } from "./unit-writer.js";
|
|
144
|
+
import { assertGitWorkTree, cleanupUnitWorktree, createUnitWorktree } from "./worktree.js";
|
|
145
|
+
// Re-exported for existing consumers (run-workflow.ts, tests) that import these
|
|
146
|
+
// from native-executor; they now live in the shared step-work module.
|
|
147
|
+
export { buildArtifactSummary, DEFAULT_UNIT_TIMEOUT_MS, projectStepOutput };
|
|
148
|
+
/**
|
|
149
|
+
* Mutable per-step dispatch budget: the lifetime unit cap PLUS the declared
|
|
150
|
+
* run-level budget ceilings (`budget.max_units` / `budget.max_tokens`,
|
|
151
|
+
* addendum R2). Consumed once per journaled dispatch attempt (including
|
|
152
|
+
* retries); durable-row reuses never touch it — the peer-review fix that
|
|
153
|
+
* keeps large partially-completed fan-outs resumable instead of tripping the
|
|
154
|
+
* cap on `journaled + items`. Token usage accumulates per actual dispatch on
|
|
155
|
+
* top of the journal-seeded run total (reused rows' tokens are already in the
|
|
156
|
+
* seed). Check-and-increment is synchronous, so concurrent units cannot race
|
|
157
|
+
* it; crossing a declared ceiling fires `onExceeded` ONCE (the executor's
|
|
158
|
+
* chained AbortController), aborting pending and in-flight dispatches.
|
|
159
|
+
*/
|
|
160
|
+
class DispatchBudget {
|
|
161
|
+
used;
|
|
162
|
+
/** Run-total tokens: journal-seeded input + this step's dispatch usage. */
|
|
163
|
+
tokens;
|
|
164
|
+
/** Set (once) when a dispatch was refused; the step fails with this message. */
|
|
165
|
+
capMessage;
|
|
166
|
+
/** Set (once) when a declared budget ceiling was hit; the step fails hard with it. */
|
|
167
|
+
budgetMessage;
|
|
168
|
+
maxUnits;
|
|
169
|
+
maxTokens;
|
|
170
|
+
onExceeded;
|
|
171
|
+
constructor(alreadyDispatched, opts) {
|
|
172
|
+
this.used = alreadyDispatched;
|
|
173
|
+
this.tokens = opts?.tokensUsed ?? 0;
|
|
174
|
+
this.maxUnits = opts?.budget?.maxUnits;
|
|
175
|
+
this.maxTokens = opts?.budget?.maxTokens;
|
|
176
|
+
this.onExceeded = opts?.onExceeded;
|
|
177
|
+
}
|
|
178
|
+
/** Consume one dispatch slot; false (and a sticky message) when a ceiling or the cap is hit. */
|
|
179
|
+
tryConsume() {
|
|
180
|
+
if (this.budgetMessage !== undefined)
|
|
181
|
+
return false;
|
|
182
|
+
if (this.maxUnits !== undefined && this.used >= this.maxUnits) {
|
|
183
|
+
this.exceed(`budget exceeded (max_units ceiling): ${this.used} unit(s) already dispatched for this run ` +
|
|
184
|
+
`against the workflow's declared budget.max_units of ${this.maxUnits} — refusing further dispatch.`);
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
if (this.maxTokens !== undefined && this.tokens >= this.maxTokens) {
|
|
188
|
+
this.exceed(`budget exceeded (max_tokens ceiling): ${this.tokens} token(s) already spent for this run ` +
|
|
189
|
+
`against the workflow's declared budget.max_tokens of ${this.maxTokens} — refusing further dispatch.`);
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
if (this.used >= LIFETIME_UNIT_CAP) {
|
|
193
|
+
this.capMessage ??= new UnitCapExceededError(LIFETIME_UNIT_CAP).message;
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
this.used++;
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
/** Record one dispatch's reported usage; crossing `maxTokens` trips the ceiling. */
|
|
200
|
+
addTokens(tokens) {
|
|
201
|
+
this.tokens += tokens;
|
|
202
|
+
if (this.budgetMessage === undefined && this.maxTokens !== undefined && this.tokens >= this.maxTokens) {
|
|
203
|
+
this.exceed(`budget exceeded (max_tokens ceiling): ${this.tokens} token(s) spent for this run, ` +
|
|
204
|
+
`reaching the workflow's declared budget.max_tokens of ${this.maxTokens} — aborting pending dispatches.`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
exceed(message) {
|
|
208
|
+
this.budgetMessage = message;
|
|
209
|
+
this.onExceeded?.();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function classifyUnitReuse(workUnit, existingUnits, gateLoop) {
|
|
213
|
+
if (!workUnit.resolved.ok)
|
|
214
|
+
return { kind: "dispatch" };
|
|
215
|
+
const inputHash = workUnit.resolved.inputHash;
|
|
216
|
+
const maxAttempts = 1 + Math.max(0, workUnit.retry?.max ?? 0);
|
|
217
|
+
const base = workUnit.journalBaseId;
|
|
218
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
219
|
+
const attemptId = attempt === 0 ? base : `${base}~r${attempt}`;
|
|
220
|
+
const prior = existingUnits?.get(attemptId);
|
|
221
|
+
if (!prior || prior.status !== "completed")
|
|
222
|
+
continue;
|
|
223
|
+
if (prior.input_hash === inputHash)
|
|
224
|
+
return { kind: "reuse", row: prior };
|
|
225
|
+
// Gate-loop rows are NOT replay-deterministic (the prompt embeds a fresh
|
|
226
|
+
// judge output): a stale loop-N row with a different hash re-dispatches
|
|
227
|
+
// live. Divergence only guards loop-1 rows, whose inputs ARE a pure
|
|
228
|
+
// function of (frozen plan, params, journaled results).
|
|
229
|
+
if (gateLoop > 1)
|
|
230
|
+
return { kind: "dispatch" };
|
|
231
|
+
return { kind: "diverge", attemptId };
|
|
232
|
+
}
|
|
233
|
+
return { kind: "dispatch" };
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Does the step have at least one unit that will ACTUALLY dispatch? Env
|
|
237
|
+
* resolution and worktree preflight are dispatch prerequisites, so a step whose
|
|
238
|
+
* units are all reused / unresolved / diverged must skip them (reviewer finding
|
|
239
|
+
* #2). Mirrors runUnit's reuse decision exactly (shared {@link classifyUnitReuse}).
|
|
240
|
+
*/
|
|
241
|
+
function stepWillDispatch(workUnits, existingUnits, gateLoop) {
|
|
242
|
+
return workUnits.some((u) => u.resolved.ok && classifyUnitReuse(u, existingUnits, gateLoop).kind === "dispatch");
|
|
243
|
+
}
|
|
244
|
+
/** Execute one step plan natively. Never throws for unit-level failures. */
|
|
245
|
+
export async function executeStepPlan(plan, ctx) {
|
|
246
|
+
const dispatched = ctx.unitsDispatched ?? 0;
|
|
247
|
+
// Work-list computation is the SHARED, PURE decision (step-work.ts): resolve
|
|
248
|
+
// the fan-out list, derive content-derived unit ids, assemble each unit's
|
|
249
|
+
// prompt, and hash its resolved input. `brief` (R3) computes the identical
|
|
250
|
+
// list — that shared implementation is the anti-drift guarantee. This module
|
|
251
|
+
// owns only the impure remainder: env/worktree preflight, durable-row reuse,
|
|
252
|
+
// dispatch, journaling, budget.
|
|
253
|
+
const workList = computeStepWorkList(plan, {
|
|
254
|
+
runId: ctx.runId,
|
|
255
|
+
params: ctx.params,
|
|
256
|
+
stepOutputs: stepOutputsFromEvidence(ctx.evidence),
|
|
257
|
+
...(ctx.gateLoop !== undefined ? { gateLoop: ctx.gateLoop } : {}),
|
|
258
|
+
...(ctx.gateFeedback ? { gateFeedback: ctx.gateFeedback } : {}),
|
|
259
|
+
});
|
|
260
|
+
if (!workList.ok) {
|
|
261
|
+
return failedStep(dispatched, workList.error);
|
|
262
|
+
}
|
|
263
|
+
const { template, reducer, isFanOut, items, units: workUnits } = workList.list;
|
|
264
|
+
if (items.length === 0) {
|
|
265
|
+
// Empty fan-out: the promoted artifact is the degenerate empty value, honored
|
|
266
|
+
// against the step's declared output schema. `reduceEmptyStep` is the SHARED
|
|
267
|
+
// decision (step-work.ts) the R3 report surface also uses to auto-complete an
|
|
268
|
+
// empty step the spine reaches, so both surfaces promote the identical
|
|
269
|
+
// artifact + schema verdict.
|
|
270
|
+
return { ...reduceEmptyStep(plan, reducer), unitsDispatched: dispatched };
|
|
271
|
+
}
|
|
272
|
+
const dispatcher = ctx.dispatcher ?? defaultUnitDispatcher;
|
|
273
|
+
// Durable-row resume: load the step's journaled unit rows FIRST — before
|
|
274
|
+
// resolving env or preflighting worktrees. A unit whose previous attempt
|
|
275
|
+
// completed with the SAME input hash (the canonical envelope in step-work.ts)
|
|
276
|
+
// is reused, not re-dispatched — a crash-resume must never double-issue
|
|
277
|
+
// side-effecting work. Loading the rows up front is what lets us skip the
|
|
278
|
+
// dispatch prerequisites below when nothing will actually dispatch.
|
|
279
|
+
const existingUnits = new Map();
|
|
280
|
+
for (const row of await withWorkflowRunsRepo((repo) => repo.getUnitsForStep(ctx.runId, plan.stepId))) {
|
|
281
|
+
existingUnits.set(row.unit_id, row);
|
|
282
|
+
}
|
|
283
|
+
// Reviewer finding #2: env resolution and worktree preflight are DISPATCH
|
|
284
|
+
// prerequisites, so they must run only when a unit will actually dispatch. A
|
|
285
|
+
// fully-journaled step whose units all reuse completed rows must resume to
|
|
286
|
+
// completion even if an env asset was deleted, a secret is unavailable, the
|
|
287
|
+
// cwd is no longer a git worktree, or git is missing — none of that is needed
|
|
288
|
+
// to hand back a cached result. The predicate mirrors runUnit's reuse
|
|
289
|
+
// decision exactly (shared classifyUnitReuse).
|
|
290
|
+
const gateLoop = ctx.gateLoop ?? 1;
|
|
291
|
+
const willDispatch = stepWillDispatch(workUnits, existingUnits, gateLoop);
|
|
292
|
+
// Env bindings resolve once per step, before any dispatch; a binding error
|
|
293
|
+
// fails the whole step cleanly rather than N units racing into it. Skipped
|
|
294
|
+
// entirely when nothing will dispatch.
|
|
295
|
+
let env;
|
|
296
|
+
if (willDispatch && template.env && template.env.length > 0) {
|
|
297
|
+
const resolveEnv = ctx.resolveEnv ?? resolveEnvBindings;
|
|
298
|
+
try {
|
|
299
|
+
env = await resolveEnv(template.env);
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
return failedStep(dispatched, `Step "${plan.stepId}" env binding failed: ${message(err)}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// Worktree isolation preflight (addendum R2), once per step, before any
|
|
306
|
+
// dispatch — and ONLY when a unit will dispatch: llm units have no working
|
|
307
|
+
// directory to isolate (fail loudly), and a non-git base directory (or a
|
|
308
|
+
// missing git binary) fails the step cleanly instead of N units racing into
|
|
309
|
+
// identical git errors. The actual worktrees are minted per journaled
|
|
310
|
+
// attempt in dispatchJournaledAttempt.
|
|
311
|
+
let worktreeBase;
|
|
312
|
+
if (willDispatch && template.isolation === "worktree") {
|
|
313
|
+
if (template.runner === "llm") {
|
|
314
|
+
return failedStep(dispatched, `Step "${plan.stepId}" declares isolation: worktree on an llm unit — the llm runner has no ` +
|
|
315
|
+
`working directory to isolate. Use the agent or sdk runner for worktree-isolated units.`);
|
|
316
|
+
}
|
|
317
|
+
const base = ctx.workDir ?? process.cwd();
|
|
318
|
+
const preflightWorktree = ctx.preflightWorktree ?? assertGitWorkTree;
|
|
319
|
+
const gitError = preflightWorktree(base);
|
|
320
|
+
if (gitError !== undefined) {
|
|
321
|
+
return failedStep(dispatched, `Step "${plan.stepId}" cannot use isolation: worktree: ${gitError}`);
|
|
322
|
+
}
|
|
323
|
+
worktreeBase = base;
|
|
324
|
+
}
|
|
325
|
+
// Budget ceilings (addendum R2): when the frozen plan declares a budget,
|
|
326
|
+
// dispatch runs under an AbortController CHAINED onto ctx.signal — hitting
|
|
327
|
+
// a ceiling aborts pending and in-flight dispatches, and the step fails
|
|
328
|
+
// hard below. Without a budget the context signal passes through untouched
|
|
329
|
+
// (the no-budget path is byte-identical to pre-R2 behavior).
|
|
330
|
+
const declaredBudget = ctx.budget && (ctx.budget.maxUnits !== undefined || ctx.budget.maxTokens !== undefined) ? ctx.budget : undefined;
|
|
331
|
+
let signal = ctx.signal;
|
|
332
|
+
let onExceeded;
|
|
333
|
+
let unchainSignal;
|
|
334
|
+
if (declaredBudget) {
|
|
335
|
+
const controller = new AbortController();
|
|
336
|
+
const upstream = ctx.signal;
|
|
337
|
+
if (upstream) {
|
|
338
|
+
if (upstream.aborted) {
|
|
339
|
+
controller.abort();
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
const onUpstreamAbort = () => controller.abort();
|
|
343
|
+
upstream.addEventListener("abort", onUpstreamAbort, { once: true });
|
|
344
|
+
unchainSignal = () => upstream.removeEventListener("abort", onUpstreamAbort);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
signal = controller.signal;
|
|
348
|
+
onExceeded = () => controller.abort();
|
|
349
|
+
}
|
|
350
|
+
// Lifetime-cap + declared-budget accounting: seeded with the run's
|
|
351
|
+
// journaled dispatch count and token total, consumed per ACTUAL dispatch
|
|
352
|
+
// inside runUnit — never for durable-row reuses, so resuming a large
|
|
353
|
+
// partially-completed fan-out works.
|
|
354
|
+
const budget = new DispatchBudget(dispatched, {
|
|
355
|
+
tokensUsed: ctx.tokensUsed ?? 0,
|
|
356
|
+
...(declaredBudget ? { budget: declaredBudget } : {}),
|
|
357
|
+
...(onExceeded ? { onExceeded } : {}),
|
|
358
|
+
});
|
|
359
|
+
let outcomes;
|
|
360
|
+
try {
|
|
361
|
+
outcomes = await scheduleUnits(workUnits, (workUnit) => runUnit({
|
|
362
|
+
plan,
|
|
363
|
+
workUnit,
|
|
364
|
+
env,
|
|
365
|
+
...(worktreeBase !== undefined ? { worktreeBase } : {}),
|
|
366
|
+
ctx,
|
|
367
|
+
signal,
|
|
368
|
+
dispatcher,
|
|
369
|
+
existingUnits,
|
|
370
|
+
budget,
|
|
371
|
+
}), {
|
|
372
|
+
concurrency: workList.list.concurrency,
|
|
373
|
+
signal,
|
|
374
|
+
maxConcurrency: ctx.maxConcurrency,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
finally {
|
|
378
|
+
unchainSignal?.();
|
|
379
|
+
}
|
|
380
|
+
// Declared budget ceilings and the lifetime cap are hard backstops: a step
|
|
381
|
+
// that hit one FAILS regardless of on_error policy (a capped run must never
|
|
382
|
+
// quietly pass its gate). The budget message names WHICH ceiling tripped.
|
|
383
|
+
if (budget.budgetMessage) {
|
|
384
|
+
return { ...failedStep(budget.used, budget.budgetMessage), tokensUsed: budget.tokens };
|
|
385
|
+
}
|
|
386
|
+
if (budget.capMessage) {
|
|
387
|
+
return { ...failedStep(budget.used, budget.capMessage), tokensUsed: budget.tokens };
|
|
388
|
+
}
|
|
389
|
+
const units = outcomes.map((outcome, index) => outcome ?? {
|
|
390
|
+
unitId: workUnits[index].unitId,
|
|
391
|
+
ok: false,
|
|
392
|
+
failureReason: "aborted",
|
|
393
|
+
error: "unit was not dispatched (aborted or scheduler failure)",
|
|
394
|
+
});
|
|
395
|
+
// Replay divergence is a HARD failure regardless of on_error: a journal
|
|
396
|
+
// whose completed row disagrees with the frozen plan's inputs must stop the
|
|
397
|
+
// run loudly (module doc), never be tolerated as "just a failed unit".
|
|
398
|
+
const diverged = units.filter((u) => u.failureReason === "replay_divergence");
|
|
399
|
+
if (diverged.length > 0) {
|
|
400
|
+
return failedStep(budget.used, diverged
|
|
401
|
+
.map((u) => u.error ?? `replay divergence: unit "${u.unitId}" was journaled with different inputs`)
|
|
402
|
+
.join(" "));
|
|
403
|
+
}
|
|
404
|
+
// Failure policy + reducer + typed-artifact validation are the SHARED
|
|
405
|
+
// post-dispatch decision (`reduceStepOutcomes`): `onError: "fail"` (default)
|
|
406
|
+
// fails the step on any unit failure, `"continue"` records failures and lets
|
|
407
|
+
// the gate decide, a vote reducer with no majority fails under either policy,
|
|
408
|
+
// and the promoted artifact is validated against the step's declared output
|
|
409
|
+
// schema (fail-fast; the `artifactSchemaFailure` marker lets the bounded gate
|
|
410
|
+
// loop retry that ONE failure class with the errors as feedback). The report
|
|
411
|
+
// path (R3) reduces journal-replayed outcomes through the same function, so a
|
|
412
|
+
// step promotes the SAME artifact whichever surface drove it.
|
|
413
|
+
const reduced = reduceStepOutcomes(plan, reducer, isFanOut, template.onError, units);
|
|
414
|
+
return {
|
|
415
|
+
...reduced,
|
|
416
|
+
unitsDispatched: budget.used,
|
|
417
|
+
tokensUsed: budget.tokens,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
async function runUnit(input) {
|
|
421
|
+
const { plan, workUnit, env, ctx, dispatcher } = input;
|
|
422
|
+
const unitId = workUnit.unitId;
|
|
423
|
+
// A per-unit expression resolution failure (missing param, bad `item.<path>`)
|
|
424
|
+
// is deterministic authoring/data breakage computed by the shared work-list:
|
|
425
|
+
// the unit fails WITHOUT dispatching — and without journaling a row, since no
|
|
426
|
+
// resolved input exists to hash.
|
|
427
|
+
if (!workUnit.resolved.ok) {
|
|
428
|
+
return { unitId, ok: false, failureReason: "expression_error", error: workUnit.resolved.error };
|
|
429
|
+
}
|
|
430
|
+
// The prompt (and therefore the input hash) was built once with the BASE
|
|
431
|
+
// unit id by computeStepWorkList: a retry re-dispatches the SAME input, the
|
|
432
|
+
// `~r<n>` suffix is journal bookkeeping only.
|
|
433
|
+
const { prompt, inputHash } = workUnit.resolved;
|
|
434
|
+
const request = {
|
|
435
|
+
runId: ctx.runId,
|
|
436
|
+
stepId: plan.stepId,
|
|
437
|
+
unitId,
|
|
438
|
+
nodeId: workUnit.nodeId,
|
|
439
|
+
prompt,
|
|
440
|
+
runner: workUnit.runner,
|
|
441
|
+
...(workUnit.profile ? { profile: workUnit.profile } : {}),
|
|
442
|
+
...(workUnit.model ? { model: workUnit.model } : {}),
|
|
443
|
+
timeoutMs: workUnit.timeoutMs,
|
|
444
|
+
...(workUnit.schema ? { schema: workUnit.schema } : {}),
|
|
445
|
+
...(env ? { env } : {}),
|
|
446
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
447
|
+
};
|
|
448
|
+
// Bounded retry (IR v2 failure policy): attempt 0 journals under the base
|
|
449
|
+
// journal id (`<unitId>`, or `<unitId>~l<loop>` in a gate loop — computed by
|
|
450
|
+
// the shared work-list), retry attempt N under `<baseId>~r<N>`. Every attempt
|
|
451
|
+
// keeps its own row. Retries only fire when the failure reason is in
|
|
452
|
+
// `retry.on`.
|
|
453
|
+
const retry = workUnit.retry;
|
|
454
|
+
const maxAttempts = 1 + Math.max(0, retry?.max ?? 0);
|
|
455
|
+
const gateLoop = ctx.gateLoop ?? 1;
|
|
456
|
+
const journalBaseId = workUnit.journalBaseId;
|
|
457
|
+
const attemptIdFor = (attempt) => (attempt === 0 ? journalBaseId : `${journalBaseId}~r${attempt}`);
|
|
458
|
+
// Durable-row reuse (shared classifyUnitReuse — the SAME decision
|
|
459
|
+
// executeStepPlan's preflight gate uses, so the gate can never disagree with
|
|
460
|
+
// what happens here). A completed row with the matching input hash IS the
|
|
461
|
+
// result: return it without touching rows, dispatching, or re-emitting events
|
|
462
|
+
// (a crash-resume must never double-issue work). A completed loop-1 row with
|
|
463
|
+
// a DIFFERENT hash is replay divergence (under a frozen plan the same
|
|
464
|
+
// content-derived identity must reproduce the same inputs — the journal was
|
|
465
|
+
// tampered with; executeStepPlan promotes this to a hard step failure
|
|
466
|
+
// regardless of on_error). Stale gate-loop rows, failed/running/missing rows,
|
|
467
|
+
// and pre-release R1 positional ids all fall through and dispatch live.
|
|
468
|
+
const reuse = classifyUnitReuse(workUnit, input.existingUnits, gateLoop);
|
|
469
|
+
if (reuse.kind === "reuse") {
|
|
470
|
+
// Identity in the durable step evidence is the CONTENT-derived base id, not
|
|
471
|
+
// the `~r<n>` attempt row it was reused from — the report surface reduces
|
|
472
|
+
// from the base id too, so both surfaces' evidence.units[].unitId agree.
|
|
473
|
+
return reuseCompletedUnit(unitId, reuse.row, workUnit.schema !== undefined);
|
|
474
|
+
}
|
|
475
|
+
if (reuse.kind === "diverge") {
|
|
476
|
+
return {
|
|
477
|
+
unitId,
|
|
478
|
+
ok: false,
|
|
479
|
+
failureReason: "replay_divergence",
|
|
480
|
+
error: `replay divergence: unit "${reuse.attemptId}" was journaled with different inputs ` +
|
|
481
|
+
`(journaled input_hash does not match this invocation's) — refusing to re-dispatch.`,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
let outcome;
|
|
485
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
486
|
+
const attemptId = attemptIdFor(attempt);
|
|
487
|
+
// Lifetime cap + declared budget ceilings, consumed per ACTUAL dispatch
|
|
488
|
+
// (reuses above returned before reaching here). Refusal fails this unit
|
|
489
|
+
// without journaling a row — nothing was dispatched — and the sticky
|
|
490
|
+
// capMessage/budgetMessage fails the step.
|
|
491
|
+
if (!input.budget.tryConsume()) {
|
|
492
|
+
const budgetHit = input.budget.budgetMessage !== undefined;
|
|
493
|
+
return (outcome ?? {
|
|
494
|
+
unitId,
|
|
495
|
+
ok: false,
|
|
496
|
+
failureReason: budgetHit ? "budget_exceeded" : "unit_cap_exceeded",
|
|
497
|
+
error: input.budget.budgetMessage ?? input.budget.capMessage ?? "lifetime unit cap exceeded",
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
outcome = await dispatchJournaledAttempt({
|
|
501
|
+
plan,
|
|
502
|
+
workUnit,
|
|
503
|
+
ctx,
|
|
504
|
+
dispatcher,
|
|
505
|
+
request: { ...request, unitId: attemptId },
|
|
506
|
+
attemptId,
|
|
507
|
+
inputHash,
|
|
508
|
+
...(input.worktreeBase !== undefined ? { worktreeBase: input.worktreeBase } : {}),
|
|
509
|
+
});
|
|
510
|
+
// The journal ROW keeps the `~r<n>`/`~l<loop>` attempt id (dispatchJournaledAttempt
|
|
511
|
+
// wrote it), but the returned outcome's identity in the DURABLE step evidence is
|
|
512
|
+
// the content-derived BASE id — the suffix is journal bookkeeping the report
|
|
513
|
+
// surface never sees, so leaking it into evidence.units would diverge the two
|
|
514
|
+
// surfaces (R4 parity, exposed once the conformance graph compares evidence.units).
|
|
515
|
+
outcome.unitId = unitId;
|
|
516
|
+
// Budget token accounting (addendum R2): every actual dispatch's reported
|
|
517
|
+
// usage counts against the run's max_tokens ceiling; crossing it aborts
|
|
518
|
+
// pending dispatches via the chained controller. Reuses never reach here
|
|
519
|
+
// (their tokens are already in the journal-seeded total).
|
|
520
|
+
if (outcome.tokens !== undefined)
|
|
521
|
+
input.budget.addTokens(outcome.tokens);
|
|
522
|
+
if (outcome.ok)
|
|
523
|
+
return outcome;
|
|
524
|
+
const reason = outcome.failureReason;
|
|
525
|
+
if (!retry || reason === undefined || !retry.on.includes(reason))
|
|
526
|
+
return outcome;
|
|
527
|
+
}
|
|
528
|
+
// maxAttempts >= 1, so outcome is always set by the loop above.
|
|
529
|
+
return outcome;
|
|
530
|
+
}
|
|
531
|
+
/** Journal one dispatch attempt: insert row, events, dispatch, finish row. */
|
|
532
|
+
async function dispatchJournaledAttempt(input) {
|
|
533
|
+
const { plan, workUnit, ctx, dispatcher, attemptId, inputHash } = input;
|
|
534
|
+
let request = input.request;
|
|
535
|
+
// Worktree isolation (addendum R2): a FRESH detached worktree per journaled
|
|
536
|
+
// attempt, minted before the row is inserted so worktree_path is journaled
|
|
537
|
+
// with the dispatch. A creation failure fails the unit WITHOUT journaling a
|
|
538
|
+
// row — nothing was dispatched (same contract as an expression failure).
|
|
539
|
+
let worktreePath;
|
|
540
|
+
if (input.worktreeBase !== undefined) {
|
|
541
|
+
const created = createUnitWorktree(input.worktreeBase, ctx.runId, attemptId);
|
|
542
|
+
if (!created.ok) {
|
|
543
|
+
return { unitId: request.unitId, ok: false, failureReason: "worktree_failed", error: created.error };
|
|
544
|
+
}
|
|
545
|
+
if (created.preservedLeftover !== undefined) {
|
|
546
|
+
// Never destroy a dirty (or unverifiable) leftover from a prior
|
|
547
|
+
// invocation of the same attempt — it was moved aside instead.
|
|
548
|
+
warn(`Workflow unit ${attemptId}: a previous attempt left uncollected work in its isolation worktree; ` +
|
|
549
|
+
`preserved at ${created.preservedLeftover}`);
|
|
550
|
+
}
|
|
551
|
+
worktreePath = created.path;
|
|
552
|
+
request = { ...request, cwd: worktreePath };
|
|
553
|
+
}
|
|
554
|
+
await enqueueUnitWrite(async () => {
|
|
555
|
+
await withWorkflowRunsRepo((repo) => repo.insertUnit({
|
|
556
|
+
runId: ctx.runId,
|
|
557
|
+
unitId: attemptId,
|
|
558
|
+
stepId: plan.stepId,
|
|
559
|
+
nodeId: workUnit.nodeId,
|
|
560
|
+
parentUnitId: workUnit.isFanOut ? `${plan.stepId}.map` : null,
|
|
561
|
+
phase: null,
|
|
562
|
+
runner: workUnit.runner,
|
|
563
|
+
model: workUnit.model ?? null,
|
|
564
|
+
inputHash,
|
|
565
|
+
worktreePath: worktreePath ?? null,
|
|
566
|
+
startedAt: new Date().toISOString(),
|
|
567
|
+
}));
|
|
568
|
+
});
|
|
569
|
+
// Ids/status only — instructions and results are workflow-authored content
|
|
570
|
+
// and stay out of the events stream (07 P1-B).
|
|
571
|
+
appendEvent({
|
|
572
|
+
eventType: "workflow_unit_started",
|
|
573
|
+
ref: ctx.workflowRef,
|
|
574
|
+
metadata: { runId: ctx.runId, stepId: plan.stepId, unitId: attemptId },
|
|
575
|
+
});
|
|
576
|
+
const outcome = await dispatchUnit(request, dispatcher);
|
|
577
|
+
await enqueueUnitWrite(async () => {
|
|
578
|
+
await withWorkflowRunsRepo((repo) => repo.finishUnit({
|
|
579
|
+
runId: ctx.runId,
|
|
580
|
+
unitId: attemptId,
|
|
581
|
+
status: outcome.ok ? "completed" : "failed",
|
|
582
|
+
resultJson: outcome.result !== undefined
|
|
583
|
+
? JSON.stringify(outcome.result)
|
|
584
|
+
: outcome.text
|
|
585
|
+
? JSON.stringify(outcome.text)
|
|
586
|
+
: null,
|
|
587
|
+
tokens: outcome.tokens ?? null,
|
|
588
|
+
failureReason: outcome.failureReason ?? null,
|
|
589
|
+
// Harness-native session id (P2): journaled so resume can replay the
|
|
590
|
+
// harness's own context cache (e.g. `codex exec resume <id>`).
|
|
591
|
+
sessionId: outcome.sessionId ?? null,
|
|
592
|
+
finishedAt: new Date().toISOString(),
|
|
593
|
+
}));
|
|
594
|
+
});
|
|
595
|
+
appendEvent({
|
|
596
|
+
eventType: "workflow_unit_finished",
|
|
597
|
+
ref: ctx.workflowRef,
|
|
598
|
+
metadata: {
|
|
599
|
+
runId: ctx.runId,
|
|
600
|
+
stepId: plan.stepId,
|
|
601
|
+
unitId: attemptId,
|
|
602
|
+
status: outcome.ok ? "completed" : "failed",
|
|
603
|
+
...(outcome.failureReason ? { failureReason: outcome.failureReason } : {}),
|
|
604
|
+
...(outcome.tokens !== undefined ? { tokens: outcome.tokens } : {}),
|
|
605
|
+
},
|
|
606
|
+
});
|
|
607
|
+
// Worktree lifecycle epilogue: a CLEAN worktree (`git status --porcelain`
|
|
608
|
+
// empty) is removed; a DIRTY one is retained and logged — the unit left
|
|
609
|
+
// uncollected work, and its journaled worktree_path says where. Cleanup is
|
|
610
|
+
// best-effort observability, never a unit failure.
|
|
611
|
+
if (worktreePath !== undefined && input.worktreeBase !== undefined) {
|
|
612
|
+
const cleanup = cleanupUnitWorktree(input.worktreeBase, worktreePath);
|
|
613
|
+
if (cleanup.dirty) {
|
|
614
|
+
warn(`Workflow unit ${attemptId} left uncommitted changes in its isolation worktree; retained at ${worktreePath}`);
|
|
615
|
+
}
|
|
616
|
+
else if (!cleanup.removed) {
|
|
617
|
+
warn(`Workflow unit ${attemptId}: could not clean up isolation worktree ${worktreePath}: ${cleanup.error}`);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return outcome;
|
|
621
|
+
}
|
|
622
|
+
/** Transport failures surface as this sentinel so runStructured doesn't retry them. */
|
|
623
|
+
class UnitTransportError extends Error {
|
|
624
|
+
result;
|
|
625
|
+
constructor(result) {
|
|
626
|
+
super(result.error ?? "unit dispatch failed");
|
|
627
|
+
this.result = result;
|
|
628
|
+
this.name = "UnitTransportError";
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
async function dispatchUnit(request, dispatcher) {
|
|
632
|
+
let tokens = 0;
|
|
633
|
+
let sawUsage = false;
|
|
634
|
+
// Harness-native session id revealed by dispatch (P2). Captured across
|
|
635
|
+
// structured-output retries (last one wins) so it survives into the
|
|
636
|
+
// UnitOutcome and gets journaled on the unit row by finishUnit — the seam's
|
|
637
|
+
// contract ("stored opportunistically on the unit row for resume").
|
|
638
|
+
let sessionId;
|
|
639
|
+
const dispatchOnce = async (feedback) => {
|
|
640
|
+
const result = await dispatcher(request, feedback);
|
|
641
|
+
if (result.usage) {
|
|
642
|
+
sawUsage = true;
|
|
643
|
+
tokens +=
|
|
644
|
+
(result.usage.inputTokens ?? 0) + (result.usage.outputTokens ?? 0) + (result.usage.reasoningTokens ?? 0);
|
|
645
|
+
}
|
|
646
|
+
// Capture before the ok-check: a failed attempt can still have configured
|
|
647
|
+
// a session (e.g. codex `session_configured` then a tool crash).
|
|
648
|
+
if (result.sessionId !== undefined)
|
|
649
|
+
sessionId = result.sessionId;
|
|
650
|
+
if (!result.ok)
|
|
651
|
+
throw new UnitTransportError(result);
|
|
652
|
+
return result.text;
|
|
653
|
+
};
|
|
654
|
+
const captured = () => ({
|
|
655
|
+
...(sawUsage ? { tokens } : {}),
|
|
656
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
657
|
+
});
|
|
658
|
+
try {
|
|
659
|
+
if (request.schema) {
|
|
660
|
+
const schema = request.schema;
|
|
661
|
+
const structured = await runStructured({
|
|
662
|
+
dispatch: dispatchOnce,
|
|
663
|
+
validate: (candidate) => {
|
|
664
|
+
const errors = validateJsonSchemaSubset(candidate, schema);
|
|
665
|
+
return errors.length === 0 ? { ok: true, value: candidate } : { ok: false, errors };
|
|
666
|
+
},
|
|
667
|
+
});
|
|
668
|
+
if (structured.ok) {
|
|
669
|
+
return { unitId: request.unitId, ok: true, result: structured.value, ...captured() };
|
|
670
|
+
}
|
|
671
|
+
return {
|
|
672
|
+
unitId: request.unitId,
|
|
673
|
+
ok: false,
|
|
674
|
+
failureReason: structured.reason,
|
|
675
|
+
error: structured.errors.join("; "),
|
|
676
|
+
text: structured.raw,
|
|
677
|
+
...captured(),
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
const text = await dispatchOnce();
|
|
681
|
+
// Normalize an EMPTY successful output to "no text". `finishUnit` journals
|
|
682
|
+
// result_json = NULL for a falsy text, so durable-reuse and the R3 report
|
|
683
|
+
// surface both rehydrate NO text from the row (unitOutcomeFromRow). Preserving
|
|
684
|
+
// `text: ""` only in this live outcome would make the LIVE step artifact ("")
|
|
685
|
+
// diverge from the resume/report artifact (null) — the exact byte-identical-
|
|
686
|
+
// graph violation the cardinal rule forbids. Treating empty as absent keeps
|
|
687
|
+
// the live engine, engine resume, and report surfaces identical.
|
|
688
|
+
return { unitId: request.unitId, ok: true, ...(text ? { text } : {}), ...captured() };
|
|
689
|
+
}
|
|
690
|
+
catch (err) {
|
|
691
|
+
if (err instanceof UnitTransportError) {
|
|
692
|
+
return {
|
|
693
|
+
unitId: request.unitId,
|
|
694
|
+
ok: false,
|
|
695
|
+
failureReason: err.result.failureReason ?? "dispatch_error",
|
|
696
|
+
error: err.result.error ?? "unit dispatch failed",
|
|
697
|
+
text: err.result.text,
|
|
698
|
+
...captured(),
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
return {
|
|
702
|
+
unitId: request.unitId,
|
|
703
|
+
ok: false,
|
|
704
|
+
failureReason: "dispatch_error",
|
|
705
|
+
error: message(err),
|
|
706
|
+
...captured(),
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
// ── Env bindings ─────────────────────────────────────────────────────────────
|
|
711
|
+
/**
|
|
712
|
+
* Resolve every unit `env` ref through the extracted `akm env run` core
|
|
713
|
+
* (loadEnv + secret tokens + dangerous-key policy + keys-only audit event).
|
|
714
|
+
* Lazily imported so the engine has no env/secret dependency until a
|
|
715
|
+
* workflow actually declares bindings.
|
|
716
|
+
*/
|
|
717
|
+
async function resolveEnvBindings(refs) {
|
|
718
|
+
const { resolveEnvBinding } = await import("../../commands/env/env-binding.js");
|
|
719
|
+
const merged = {};
|
|
720
|
+
for (const ref of refs) {
|
|
721
|
+
Object.assign(merged, resolveEnvBinding(ref).values);
|
|
722
|
+
}
|
|
723
|
+
return merged;
|
|
724
|
+
}
|
|
725
|
+
// ── Default dispatcher (production substrate) ───────────────────────────────
|
|
726
|
+
/**
|
|
727
|
+
* Dispatch through akm's existing execution substrate:
|
|
728
|
+
* llm → `chatCompletion` on the profile/default LLM connection
|
|
729
|
+
* agent → `executeRunner` → `runAgent` (per-harness AgentCommandBuilder)
|
|
730
|
+
* sdk → `executeRunner` → `runOpencodeSdk`
|
|
731
|
+
*
|
|
732
|
+
* `inherit` resolves against config: the node/step profile, else
|
|
733
|
+
* `defaults.agent` (sdk when the profile is opencode-sdk), else `defaults.llm`.
|
|
734
|
+
*/
|
|
735
|
+
/**
|
|
736
|
+
* Build the platform-agnostic {@link import("../../integrations/agent/builder-shared.js").AgentDispatchRequest}
|
|
737
|
+
* for an agent (CLI) unit from the resolved dispatch request and its final
|
|
738
|
+
* (feedback-augmented) prompt.
|
|
739
|
+
*
|
|
740
|
+
* Threading the unit's output `schema` here is what activates each harness's
|
|
741
|
+
* native structured-output path (plan §"Structured-output normalization"):
|
|
742
|
+
* - Codex (native-schema tier) writes it to a temp file and passes
|
|
743
|
+
* `--output-schema <file>`.
|
|
744
|
+
* - Copilot / Gemini switch stdout to their documented JSON envelope
|
|
745
|
+
* (`--output-format json`) and append their schema-aware prompt directive.
|
|
746
|
+
* - Pi switches to its JSONL event stream (`--mode json`) and appends its
|
|
747
|
+
* directive.
|
|
748
|
+
* Without the schema the argv is byte-identical to the pre-fix plain-prompt
|
|
749
|
+
* shape. The engine's post-hoc `runStructured` validation runs regardless — the
|
|
750
|
+
* harness path constrains/hints, the engine still verifies (constrained output
|
|
751
|
+
* is trusted but verified). The `model` is passed raw so the builder resolves
|
|
752
|
+
* aliases per-harness. Only `prompt` (with any gate feedback already folded in),
|
|
753
|
+
* `model`, and `schema` are engine-derived; `systemPrompt`/`tools`/`cwd` come
|
|
754
|
+
* from the profile/asset, not the workflow unit.
|
|
755
|
+
*/
|
|
756
|
+
export function buildAgentDispatchRequest(request, prompt) {
|
|
757
|
+
return {
|
|
758
|
+
prompt,
|
|
759
|
+
...(request.model ? { model: request.model } : {}),
|
|
760
|
+
...(request.schema ? { schema: request.schema } : {}),
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
export const defaultUnitDispatcher = async (request, feedback) => {
|
|
764
|
+
const { loadConfig } = await import("../../core/config/config.js");
|
|
765
|
+
const config = loadConfig();
|
|
766
|
+
const prompt = feedback ? `${request.prompt}\n\n${feedback}` : request.prompt;
|
|
767
|
+
const resolved = await resolveUnitRunner(request, config);
|
|
768
|
+
// `env` bindings can only reach a child process. The agent (CLI) runner
|
|
769
|
+
// spawns one per call, and the sdk runner now injects them for real via the
|
|
770
|
+
// env-keyed opencode server registry (sdk-runner.ts module doc, open seam
|
|
771
|
+
// decision 1 resolved in R2) — but the llm runner has no child at all, so
|
|
772
|
+
// it still fails loudly: an audit event claiming an injection that never
|
|
773
|
+
// reached the unit would be a lie.
|
|
774
|
+
if (request.env && Object.keys(request.env).length > 0 && resolved.kind === "llm") {
|
|
775
|
+
return {
|
|
776
|
+
ok: false,
|
|
777
|
+
text: "",
|
|
778
|
+
failureReason: "env_unsupported",
|
|
779
|
+
error: `unit "${request.unitId}" declares env bindings, which require a child process (agent or sdk runner) — ` +
|
|
780
|
+
`the "llm" runner cannot inject a per-unit child environment.`,
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
// Same shape for worktree isolation resolved onto llm through `inherit`:
|
|
784
|
+
// the executor already rejects an EXPLICIT llm+isolation pairing before
|
|
785
|
+
// dispatch, but an inherit unit only reveals its runner here.
|
|
786
|
+
if (request.cwd && resolved.kind === "llm") {
|
|
787
|
+
return {
|
|
788
|
+
ok: false,
|
|
789
|
+
text: "",
|
|
790
|
+
failureReason: "isolation_unsupported",
|
|
791
|
+
error: `unit "${request.unitId}" declares isolation: worktree but resolved to the "llm" runner, ` +
|
|
792
|
+
`which has no working directory to isolate. Use the agent or sdk runner for isolated units.`,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
if (resolved.kind === "llm") {
|
|
796
|
+
const { chatCompletion, LlmCallError } = await import("../../llm/client.js");
|
|
797
|
+
const { resolveModel } = await import("../../integrations/agent/model-aliases.js");
|
|
798
|
+
const connection = request.model
|
|
799
|
+
? { ...resolved.connection, model: resolveModel(request.model, "llm", undefined, config.modelAliases) }
|
|
800
|
+
: resolved.connection;
|
|
801
|
+
try {
|
|
802
|
+
const text = await chatCompletion(connection, [{ role: "user", content: prompt }], {
|
|
803
|
+
// null = author declared `timeout: none` — cap at the max signed
|
|
804
|
+
// 32-bit delay (setTimeout's ceiling, ~24.8 days ≈ unbounded here).
|
|
805
|
+
timeoutMs: request.timeoutMs === null ? 2 ** 31 - 1 : request.timeoutMs,
|
|
806
|
+
...(request.signal ? { signal: request.signal } : {}),
|
|
807
|
+
// Native structured output where the connection supports it; the
|
|
808
|
+
// executor's subset validator still runs downstream either way.
|
|
809
|
+
...(request.schema ? { responseSchema: request.schema } : {}),
|
|
810
|
+
});
|
|
811
|
+
return { ok: true, text };
|
|
812
|
+
}
|
|
813
|
+
catch (err) {
|
|
814
|
+
// Map typed LlmCallError codes into the persisted AgentFailureReason
|
|
815
|
+
// taxonomy — the vocabulary `retry.on` is validated against (program
|
|
816
|
+
// schema PROGRAM_RETRY_REASONS) and the journal's failure_reason column
|
|
817
|
+
// speaks. A collapsed out-of-taxonomy value ("llm_error") made the
|
|
818
|
+
// declared failure policy dead for the entire llm runner.
|
|
819
|
+
const failureReason = err instanceof LlmCallError ? llmFailureReasonFor(err.code) : "dispatch_error";
|
|
820
|
+
return { ok: false, text: "", failureReason, error: message(err) };
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
const { executeRunner } = await import("../../integrations/agent/runner-dispatch.js");
|
|
824
|
+
const profile = request.model ? { ...resolved.profile, model: request.model } : resolved.profile;
|
|
825
|
+
const result = await executeRunner({ kind: resolved.kind, profile }, prompt, {
|
|
826
|
+
stdio: "captured",
|
|
827
|
+
parseOutput: "text",
|
|
828
|
+
timeoutMs: request.timeoutMs,
|
|
829
|
+
...(request.env ? { env: request.env } : {}),
|
|
830
|
+
// Worktree isolation: the unit's fresh checkout is the child's cwd —
|
|
831
|
+
// runAgent spawns there; the sdk runner scopes the session to it.
|
|
832
|
+
...(request.cwd ? { cwd: request.cwd } : {}),
|
|
833
|
+
...(request.signal ? { signal: request.signal } : {}),
|
|
834
|
+
// Route CLI dispatch through the platform AgentCommandBuilder so model
|
|
835
|
+
// aliases resolve per-harness (P0.5 model routing) AND the unit's output
|
|
836
|
+
// schema reaches the harness's structured-output path (see
|
|
837
|
+
// buildAgentDispatchRequest).
|
|
838
|
+
...(resolved.kind === "agent" ? { dispatch: buildAgentDispatchRequest(request, prompt) } : {}),
|
|
839
|
+
});
|
|
840
|
+
// Harness result extraction (P2, plan §"The adapter contract" step 3):
|
|
841
|
+
// when the profile's harness declares a `resultExtractor`, normalize the
|
|
842
|
+
// raw stdout into the final answer (+ opportunistic session id) BEFORE the
|
|
843
|
+
// engine's schema validation / retry loop sees it. Only successful agent
|
|
844
|
+
// (CLI) runs are normalized — failures keep the raw stdout for diagnostics,
|
|
845
|
+
// and the default path is byte-identical when no extractor is registered.
|
|
846
|
+
let text = result.stdout;
|
|
847
|
+
let sessionId = result.sessionId;
|
|
848
|
+
if (resolved.kind === "agent" && result.ok) {
|
|
849
|
+
const extractor = await resolveHarnessExtractor(resolved.profile);
|
|
850
|
+
if (extractor) {
|
|
851
|
+
const extraction = extractor(result);
|
|
852
|
+
text = extraction.text;
|
|
853
|
+
if (extraction.sessionId !== undefined)
|
|
854
|
+
sessionId = extraction.sessionId;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
return {
|
|
858
|
+
ok: result.ok,
|
|
859
|
+
text,
|
|
860
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
861
|
+
...(result.reason ? { failureReason: result.reason } : {}),
|
|
862
|
+
...(result.error ? { error: result.error } : {}),
|
|
863
|
+
...(result.usage ? { usage: result.usage } : {}),
|
|
864
|
+
};
|
|
865
|
+
};
|
|
866
|
+
/**
|
|
867
|
+
* Map a typed {@link import("../../llm/client.js").LlmCallErrorCode} into the
|
|
868
|
+
* persisted `AgentFailureReason` taxonomy (agent/spawn.ts) — the ONLY
|
|
869
|
+
* vocabulary `retry.on` accepts and the journal's `failure_reason` column
|
|
870
|
+
* carries. Exhaustive over the code union (typecheck fails on drift):
|
|
871
|
+
*
|
|
872
|
+
* - `timeout` → `timeout` (wall-clock expiry; also covers
|
|
873
|
+
* signal aborts, which chatCompletion
|
|
874
|
+
* folds into its timeout code)
|
|
875
|
+
* - `rate_limited` → `llm_rate_limit` (HTTP 429 — the canonical transient)
|
|
876
|
+
* - `parse_error` / `provider_html_error`
|
|
877
|
+
* → `parse_error` (a response arrived but was not the
|
|
878
|
+
* promised JSON)
|
|
879
|
+
* - `network_error` / `provider_error`
|
|
880
|
+
* → `spawn_failed` (the backend could not be reached or
|
|
881
|
+
* could not do the work — the LLM
|
|
882
|
+
* analog of failing to start the
|
|
883
|
+
* child; retryable as a transient)
|
|
884
|
+
*/
|
|
885
|
+
export function llmFailureReasonFor(code) {
|
|
886
|
+
switch (code) {
|
|
887
|
+
case "timeout":
|
|
888
|
+
return "timeout";
|
|
889
|
+
case "rate_limited":
|
|
890
|
+
return "llm_rate_limit";
|
|
891
|
+
case "parse_error":
|
|
892
|
+
case "provider_html_error":
|
|
893
|
+
return "parse_error";
|
|
894
|
+
case "network_error":
|
|
895
|
+
case "provider_error":
|
|
896
|
+
return "spawn_failed";
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Resolve the harness `resultExtractor` for an agent profile, mirroring the
|
|
901
|
+
* platform routing of `getCommandBuilder`: the profile's explicit
|
|
902
|
+
* `commandBuilder` wins, else its name (with the `-headless` builtin-variant
|
|
903
|
+
* suffix stripped, same derivation as BUILTIN_BUILDERS). Unknown/custom
|
|
904
|
+
* platforms resolve to no extractor — raw stdout passes through unchanged.
|
|
905
|
+
*/
|
|
906
|
+
async function resolveHarnessExtractor(profile) {
|
|
907
|
+
const { getHarness } = await import("../../integrations/harnesses/index.js");
|
|
908
|
+
const platform = profile.commandBuilder ?? profile.name;
|
|
909
|
+
const harness = getHarness(platform) ?? getHarness(platform.replace(/-headless$/, ""));
|
|
910
|
+
return harness?.resultExtractor;
|
|
911
|
+
}
|
|
912
|
+
async function resolveUnitRunner(request, config) {
|
|
913
|
+
const requested = request.runner;
|
|
914
|
+
if (requested === "llm") {
|
|
915
|
+
const connection = request.profile
|
|
916
|
+
? config.profiles?.llm?.[request.profile]
|
|
917
|
+
: await requireDefaultLlm(config, request);
|
|
918
|
+
if (!connection) {
|
|
919
|
+
throw new ConfigError(`Workflow unit "${request.unitId}" wants llm profile "${request.profile}", which is not in profiles.llm.`, "LLM_NOT_CONFIGURED");
|
|
920
|
+
}
|
|
921
|
+
return { kind: "llm", connection };
|
|
922
|
+
}
|
|
923
|
+
const profileName = request.profile ?? config.defaults?.agent;
|
|
924
|
+
if (profileName) {
|
|
925
|
+
const { resolveProfileFromConfig } = await import("../../integrations/agent/config.js");
|
|
926
|
+
const profile = resolveProfileFromConfig(profileName, config);
|
|
927
|
+
if (!profile) {
|
|
928
|
+
throw new ConfigError(`Workflow unit "${request.unitId}" wants agent profile "${profileName}", which cannot be resolved. ` +
|
|
929
|
+
`Define profiles.agent."${profileName}" or set defaults.agent.`, "INVALID_CONFIG_FILE");
|
|
930
|
+
}
|
|
931
|
+
const inferred = profile.sdkMode ? "sdk" : "agent";
|
|
932
|
+
if (requested !== "inherit" && requested !== inferred) {
|
|
933
|
+
throw new ConfigError(`Workflow unit "${request.unitId}" declares runner "${requested}" but profile "${profileName}" is ${profile.sdkMode ? "an opencode-sdk (sdk) profile" : "a CLI (agent) profile"}.`, "INVALID_CONFIG_FILE");
|
|
934
|
+
}
|
|
935
|
+
return { kind: inferred, profile };
|
|
936
|
+
}
|
|
937
|
+
if (requested === "inherit") {
|
|
938
|
+
const connection = await requireDefaultLlm(config, request, /* soft */ true);
|
|
939
|
+
if (connection)
|
|
940
|
+
return { kind: "llm", connection };
|
|
941
|
+
}
|
|
942
|
+
throw new ConfigError(`Workflow unit "${request.unitId}" has no runnable backend: set defaults.agent or defaults.llm in config.json, ` +
|
|
943
|
+
`or declare a runner/profile on the unit.`, "INVALID_CONFIG_FILE");
|
|
944
|
+
}
|
|
945
|
+
async function requireDefaultLlm(config, request, soft = false) {
|
|
946
|
+
const { getDefaultLlmConfig } = await import("../../core/config/config.js");
|
|
947
|
+
const connection = getDefaultLlmConfig(config);
|
|
948
|
+
if (!connection && !soft) {
|
|
949
|
+
throw new ConfigError(`Workflow unit "${request.unitId}" declares runner "llm" but no default LLM is configured (defaults.llm).`, "LLM_NOT_CONFIGURED");
|
|
950
|
+
}
|
|
951
|
+
return connection ?? undefined;
|
|
952
|
+
}
|
|
953
|
+
// ── Small helpers ────────────────────────────────────────────────────────────
|
|
954
|
+
/**
|
|
955
|
+
* Rehydrate a journaled completed unit row into a UnitOutcome (durable-row
|
|
956
|
+
* reuse). Delegates to the shared {@link unitOutcomeFromRow} — the reuse path
|
|
957
|
+
* only reaches here for completed rows (the caller guards `status ===
|
|
958
|
+
* "completed"`), so the mapping is identical to what the R3 report path applies
|
|
959
|
+
* when it replays the same journal.
|
|
960
|
+
*/
|
|
961
|
+
function reuseCompletedUnit(unitId, row, hasSchema) {
|
|
962
|
+
return unitOutcomeFromRow(unitId, row, hasSchema);
|
|
963
|
+
}
|
|
964
|
+
function failedStep(dispatched, reason) {
|
|
965
|
+
return {
|
|
966
|
+
ok: false,
|
|
967
|
+
units: [],
|
|
968
|
+
evidence: { error: reason },
|
|
969
|
+
summary: reason,
|
|
970
|
+
unitsDispatched: dispatched,
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
function message(err) {
|
|
974
|
+
return err instanceof Error ? err.message : String(err);
|
|
975
|
+
}
|