akm-cli 0.9.0 → 0.9.1-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +724 -0
- package/README.md +28 -63
- package/STABILITY.md +4 -2
- package/dist/cli/parse-args.js +7 -1
- package/dist/commands/agent/contribute-cli.js +1 -1
- package/dist/commands/env/child-env.js +14 -0
- package/dist/commands/feedback-cli.js +7 -1
- package/dist/commands/health/llm-usage.js +2 -1
- package/dist/commands/health/surfaces.js +4 -77
- package/dist/commands/health.js +65 -11
- package/dist/commands/improve/distill/quality-gate.js +6 -1
- package/dist/commands/improve/eligibility.js +7 -1
- package/dist/commands/improve/eval-cases.js +2 -0
- package/dist/commands/improve/improve.js +126 -10
- package/dist/commands/improve/locks.js +7 -0
- package/dist/commands/improve/memory/memory-improve.js +9 -0
- package/dist/commands/improve/run-context.js +5 -0
- package/dist/commands/improve/session-asset.js +4 -0
- package/dist/commands/lint/base-linter.js +31 -7
- package/dist/commands/lint/index.js +205 -51
- package/dist/commands/lint/types.js +22 -1
- package/dist/commands/proposal/repository.js +17 -1
- package/dist/commands/sources/add-cli.js +8 -2
- package/dist/commands/sources/info.js +12 -2
- package/dist/commands/sources/installed-stashes.js +6 -1
- package/dist/commands/sources/migration-help.js +12 -3
- package/dist/commands/sources/self-update.js +9 -1
- package/dist/commands/tasks/tasks.js +8 -2
- package/dist/commands/workflow-cli.js +17 -11
- package/dist/core/abort-deadline.js +28 -0
- package/dist/core/adapter/adapters/agent-skills-adapter.js +83 -5
- package/dist/core/adapter/adapters/akm-adapter.js +13 -10
- package/dist/core/adapter/adapters/akm-lint.js +78 -22
- package/dist/core/adapter/adapters/akm-task-adapter.js +43 -20
- package/dist/core/adapter/adapters/dotenv-adapter.js +21 -0
- package/dist/core/adapter/adapters/tool-dir-shared.js +5 -3
- package/dist/core/asset/frontmatter.js +10 -1
- package/dist/core/common.js +147 -9
- package/dist/core/concurrent.js +32 -0
- package/dist/core/config/config-io.js +5 -45
- package/dist/core/config/schema/engines.js +14 -3
- package/dist/core/config/schema/workflow.js +11 -0
- package/dist/core/errors.js +25 -0
- package/dist/core/events.js +30 -24
- package/dist/core/extra-params.js +11 -0
- package/dist/core/file-lock.js +7 -1
- package/dist/core/fs-txn.js +15 -2
- package/dist/core/improve-result.js +5 -0
- package/dist/core/json-schema.js +344 -9
- package/dist/core/loopback.js +89 -0
- package/dist/core/migration-operation.js +17 -2
- package/dist/core/path-access.js +107 -0
- package/dist/core/paths.js +16 -2
- package/dist/core/redaction.js +86 -18
- package/dist/core/spawn-env.js +234 -0
- package/dist/core/state-db-scope.js +134 -0
- package/dist/core/state-db.js +1 -0
- package/dist/core/subprocess.js +181 -37
- package/dist/core/write-provenance.js +85 -0
- package/dist/core/write-source.js +33 -2
- package/dist/indexer/db/graph-db.js +17 -6
- package/dist/indexer/ensure-index.js +10 -3
- package/dist/indexer/index-written-assets.js +17 -2
- package/dist/indexer/indexer.js +86 -21
- package/dist/indexer/passes/memory-inference.js +4 -0
- package/dist/indexer/search/db-search.js +25 -17
- package/dist/indexer/walk/walker.js +6 -1
- package/dist/integrations/agent/detect.js +13 -1
- package/dist/integrations/agent/engine-resolution.js +24 -11
- package/dist/integrations/agent/model-aliases.js +1 -1
- package/dist/integrations/agent/profiles.js +9 -1
- package/dist/integrations/agent/spawn.js +15 -87
- package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +21 -0
- package/dist/integrations/lockfile.js +55 -2
- package/dist/llm/client.js +14 -19
- package/dist/llm/embedder.js +23 -3
- package/dist/llm/embedders/remote.js +27 -2
- package/dist/output/html-render.js +40 -1
- package/dist/output/text/lint-format.js +17 -4
- package/dist/runtime.js +23 -1
- package/dist/scripts/akm-migrate-node.js +1714 -836
- package/dist/scripts/akm-migrate.js +1682 -804
- package/dist/setup/setup.js +22 -7
- package/dist/sources/providers/git-install.js +25 -2
- package/dist/sources/providers/git-stash.js +19 -0
- package/dist/sources/providers/git.js +1 -1
- package/dist/sources/snapshot-fetchers/content-extract.js +63 -1
- package/dist/sources/snapshot-fetchers/website-ingest.js +126 -20
- package/dist/storage/database.js +71 -7
- package/dist/storage/engines/sqlite-migrations.js +61 -2
- package/dist/storage/managed-db.js +19 -0
- package/dist/storage/repositories/index-connection.js +39 -4
- package/dist/storage/repositories/index-entries-repository.js +6 -1
- package/dist/storage/repositories/index-meta-repository.js +11 -0
- package/dist/storage/repositories/index-schema.js +17 -2
- package/dist/storage/repositories/index-vec-repository.js +43 -5
- package/dist/storage/repositories/workflow-runs-repository.js +66 -13
- package/dist/storage/sqlite-pragmas.js +12 -1
- package/dist/tasks/log-redaction.js +156 -0
- package/dist/tasks/parser.js +82 -5
- package/dist/tasks/runner.js +222 -17
- package/dist/tasks/scheduler-invocation.js +19 -0
- package/dist/tasks/schema.js +86 -1
- package/dist/text-import-hook.mjs +1 -1
- package/dist/workflows/concurrency-policy.js +95 -1
- package/dist/workflows/exec/dispatch-redaction.js +114 -0
- package/dist/workflows/exec/exec-unit.js +542 -0
- package/dist/workflows/exec/frozen-judge.js +114 -42
- package/dist/workflows/exec/native-executor.js +465 -238
- package/dist/workflows/exec/param-secrets.js +4 -3
- package/dist/workflows/exec/run-workflow.js +424 -219
- package/dist/workflows/exec/step-work.js +506 -167
- package/dist/workflows/exec/unit-dispatch.js +31 -1
- package/dist/workflows/exec/unit-writer.js +53 -13
- package/dist/workflows/exec/worktree.js +454 -41
- package/dist/workflows/ir/compile.js +26 -2
- package/dist/workflows/ir/freeze.js +82 -15
- package/dist/workflows/ir/schema.js +105 -20
- package/dist/workflows/parser.js +242 -19
- package/dist/workflows/program/schema.js +24 -0
- package/dist/workflows/renderer.js +32 -4
- package/dist/workflows/resource-limits.js +182 -0
- package/dist/workflows/runtime/runs.js +146 -6
- package/dist/workflows/validate-summary.js +17 -2
- package/docs/README.md +74 -32
- package/docs/migration/release-notes/0.9.0.md +2 -1
- package/docs/migration/v0.7-to-v0.8.md +2 -1
- package/docs/migration/v0.8-to-v0.9.md +3 -1
- package/docs/reference/README.md +11 -4
- package/docs/reference/bundle-types.md +19 -0
- package/docs/reference/cli.md +105 -16
- package/docs/reference/configuration.md +15 -2
- package/docs/reference/data-and-telemetry.md +30 -10
- package/docs/reference/supported-formats.md +50 -0
- package/docs/reference/workflow-schema.md +1014 -0
- package/docs/reference/workflows.md +37 -633
- package/package.json +13 -6
- package/schemas/akm-config.json +18 -5
- package/schemas/akm-task.json +27 -5
- package/schemas/akm-workflow.json +92 -13
|
@@ -0,0 +1,1014 @@
|
|
|
1
|
+
# Workflow Schema
|
|
2
|
+
|
|
3
|
+
This is the authoritative reference for a workflow asset's exact frontmatter
|
|
4
|
+
and body syntax — every key, its shape, and the validation rules enforced by
|
|
5
|
+
the published JSON Schema (`schemas/akm-workflow.json`) and
|
|
6
|
+
`akm lint --type workflows`. Other pages link here instead of reproducing this
|
|
7
|
+
detail.
|
|
8
|
+
|
|
9
|
+
- For a task-oriented walkthrough of writing your first workflow, see the
|
|
10
|
+
[Author's Guide](https://github.com/itlackey/akm/blob/main/docs/guides/author-workflows.md).
|
|
11
|
+
- For how a frozen plan actually executes — persistence, dispatch, resume —
|
|
12
|
+
see [Architecture: The Workflow Engine](https://github.com/itlackey/akm/blob/main/docs/architecture/workflow-engine.md).
|
|
13
|
+
- For operating a run day to day (`run`, `status`, `resume`, `abandon`), see
|
|
14
|
+
[Running Workflows](https://github.com/itlackey/akm/blob/main/docs/guides/run-workflows.md).
|
|
15
|
+
|
|
16
|
+
## One format
|
|
17
|
+
|
|
18
|
+
A workflow is an ordinary AKM markdown asset — the same envelope as every
|
|
19
|
+
other type, OKF-conformant frontmatter plus a markdown body — whose
|
|
20
|
+
frontmatter carries the entire orchestration graph (params, and how each step
|
|
21
|
+
dispatches, fans out, routes, and gates) and whose body carries each step's
|
|
22
|
+
instructions and gate rubric under plain headings, joined to the frontmatter
|
|
23
|
+
by step id. There is **one** format: no separate YAML "program" surface, no
|
|
24
|
+
`.yaml`/`.yml` workflow files.
|
|
25
|
+
|
|
26
|
+
## Frontmatter keys
|
|
27
|
+
|
|
28
|
+
Frontmatter is the standard AKM asset envelope (`type`, `description`, `tags`,
|
|
29
|
+
`when_to_use`, `xrefs`, `updated`/`timestamp`, and the OKF v0.2 trust/lifecycle
|
|
30
|
+
families) plus the orchestration keys:
|
|
31
|
+
|
|
32
|
+
- `params` — name → `{ type, description }` (JSON-Schema-typed, unlike a bare
|
|
33
|
+
description string).
|
|
34
|
+
- `defaults` — run-level dispatch defaults (`engine`, `model`, `llm`,
|
|
35
|
+
`timeout`, `on_error`), overridable per unit. `defaults.llm` is the
|
|
36
|
+
exception: `llm:` tuning applies only to engines of kind `llm`, and a
|
|
37
|
+
document-level `llm:` reaches EVERY step, so a document that also has a step
|
|
38
|
+
on an agent engine fails to freeze — naming the step and the engine — rather
|
|
39
|
+
than dropping the settings for that step. There is no per-step opt-out (`llm:
|
|
40
|
+
{}` is a no-op and `llm: null` is a parse error), so in a mixed document put
|
|
41
|
+
`llm:` on the `unit:` of each LLM step instead of in `defaults:`.
|
|
42
|
+
- `budget` — run-lifetime ceilings (`max_units`, `max_tokens`; see
|
|
43
|
+
[Budget ceilings](#budget-ceilings) below).
|
|
44
|
+
- `steps` — an ordered list. Each step has an `id`
|
|
45
|
+
(`[A-Za-z_][A-Za-z0-9_-]*` — no dots) and **at most one** of `unit`, `map`,
|
|
46
|
+
or `route`. A step with neither is **still a unit step** — bare
|
|
47
|
+
`- id: validate` is the complete minimal declaration. `unit:` is the
|
|
48
|
+
optional dispatch-override bag (`exec`, `engine`, `model`, `llm`, `timeout`,
|
|
49
|
+
`retry`, `on_error`, `env`, `isolation`; see
|
|
50
|
+
[Exec (shell) units](#exec-shell-units),
|
|
51
|
+
[Failure policy](#failure-policy) and
|
|
52
|
+
[Worktree isolation](https://github.com/itlackey/akm/blob/main/docs/architecture/workflow-engine.md#worktree-isolation)).
|
|
53
|
+
- `inputs` — on a `unit`/`map` step, the prior-step artifacts this step
|
|
54
|
+
consumes, as bare reference strings (sub-paths legal:
|
|
55
|
+
`steps.x.output.issues`, not just `steps.x.output`). This is how a step's
|
|
56
|
+
attached context sees upstream data, and how replay hashing gets its exact
|
|
57
|
+
input set — a step re-dispatches only when the slice it actually consumes
|
|
58
|
+
changes.
|
|
59
|
+
- `output` — a JSON Schema for the step's promoted artifact.
|
|
60
|
+
- `gate` — optional validation-loop configuration: `max_loops` bounds
|
|
61
|
+
evaluator-optimizer retries (see
|
|
62
|
+
[Gates and verification](#gates-and-verification)). The rubric itself lives
|
|
63
|
+
in the body's `### gate` section. Without non-empty rubric text, the
|
|
64
|
+
configuration is inert.
|
|
65
|
+
|
|
66
|
+
No `version:`/`name:` keys — identity is the ref, and the frozen plan already
|
|
67
|
+
versions execution semantics — and no step titles anywhere: a step is its id,
|
|
68
|
+
and the asset's human name is its `description` and H1 like any other asset
|
|
69
|
+
type.
|
|
70
|
+
|
|
71
|
+
## Body structure and rules
|
|
72
|
+
|
|
73
|
+
Checked by `akm lint --type workflows`:
|
|
74
|
+
|
|
75
|
+
1. Every level-2 heading must be `## <step-id>` for a step declared in
|
|
76
|
+
frontmatter, exactly — no titles, no `Step:`/`Step ID:` lines, no
|
|
77
|
+
`# Workflow:` prefix on the H1. (Fenced code blocks are skipped when
|
|
78
|
+
scanning for headings.)
|
|
79
|
+
2. A `unit` or `map` step **must** have a body section — its instructions,
|
|
80
|
+
or its per-item template for a map step, byte-exact to the next H2 or
|
|
81
|
+
EOF. A `route` step **may** have one (documentation, plus a gate rubric
|
|
82
|
+
if it is gated). Everything before the first H2 is free preamble —
|
|
83
|
+
indexed for search, shown in `akm show`, never dispatched.
|
|
84
|
+
3. Inside a step's section, an optional `### gate` sub-heading starts that
|
|
85
|
+
step's gate rubric, running to the section end — the format's **single
|
|
86
|
+
reserved marker**. The judge that evaluates the step receives this whole
|
|
87
|
+
section byte-exact. An omitted or empty `### gate` section needs no
|
|
88
|
+
verification. A non-empty rubric enables mandatory fail-closed
|
|
89
|
+
verification; frontmatter `gate:` only tunes its retry bound.
|
|
90
|
+
|
|
91
|
+
Prose is never templated — see [The reference grammar](#the-reference-grammar)
|
|
92
|
+
for how a step's instructions refer to run params, upstream artifacts, and a
|
|
93
|
+
map unit's item.
|
|
94
|
+
|
|
95
|
+
## Minimal example
|
|
96
|
+
|
|
97
|
+
```markdown
|
|
98
|
+
---
|
|
99
|
+
type: workflow
|
|
100
|
+
description: Ship a tagged release to production
|
|
101
|
+
params:
|
|
102
|
+
version: { type: string, description: The semver version string to release }
|
|
103
|
+
steps:
|
|
104
|
+
- id: validate
|
|
105
|
+
- id: build
|
|
106
|
+
inputs: [steps.validate.output]
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
# Ship Release
|
|
110
|
+
|
|
111
|
+
## validate
|
|
112
|
+
|
|
113
|
+
Check that the `version` parameter follows semver and the tag does not
|
|
114
|
+
already exist.
|
|
115
|
+
|
|
116
|
+
### gate
|
|
117
|
+
|
|
118
|
+
- `git tag v<version>` does not already exist.
|
|
119
|
+
- The version string matches `^\d+\.\d+\.\d+$`.
|
|
120
|
+
|
|
121
|
+
## build
|
|
122
|
+
|
|
123
|
+
Run `npm run build && npm test`, using the validation from `validate`,
|
|
124
|
+
attached to this unit as input. Fix any failures before proceeding.
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Richer example
|
|
128
|
+
|
|
129
|
+
Fan-out, routing, retries, gates, and a run budget:
|
|
130
|
+
|
|
131
|
+
```markdown
|
|
132
|
+
---
|
|
133
|
+
type: workflow
|
|
134
|
+
description: Review changed files and route the outcome
|
|
135
|
+
params:
|
|
136
|
+
changed_files: { type: array, description: Files to review }
|
|
137
|
+
defaults: { engine: reviewer, model: balanced, timeout: 10m, on_error: fail }
|
|
138
|
+
budget: { max_units: 40, max_tokens: 200000 }
|
|
139
|
+
steps:
|
|
140
|
+
- id: discover
|
|
141
|
+
output: { type: object, properties: { files: { type: array } }, required: [files] }
|
|
142
|
+
- id: review
|
|
143
|
+
map:
|
|
144
|
+
over: steps.discover.output.files
|
|
145
|
+
concurrency: 8
|
|
146
|
+
unit:
|
|
147
|
+
engine: reviewer
|
|
148
|
+
model: deep
|
|
149
|
+
timeout: 5m
|
|
150
|
+
retry: { max: 1, on: [timeout, llm_rate_limit] }
|
|
151
|
+
on_error: continue
|
|
152
|
+
isolation: worktree
|
|
153
|
+
output: { type: object, properties: { file: { type: string }, verdict: { type: string } }, required: [file, verdict] }
|
|
154
|
+
# `output` here describes the REDUCER RESULT, not one unit's result: the
|
|
155
|
+
# default `collect` reducer folds per-item unit results into an array.
|
|
156
|
+
output: { type: array }
|
|
157
|
+
gate: { max_loops: 2 }
|
|
158
|
+
- id: aggregate
|
|
159
|
+
inputs: [steps.review.output]
|
|
160
|
+
output: { type: object, properties: { verdict: { type: string } }, required: [verdict] }
|
|
161
|
+
- id: triage
|
|
162
|
+
route:
|
|
163
|
+
input: steps.aggregate.output.verdict
|
|
164
|
+
when: [{ match: pass, step: ship }, { match: fail, step: rework }]
|
|
165
|
+
default: manual-triage
|
|
166
|
+
- id: ship
|
|
167
|
+
- id: rework
|
|
168
|
+
- id: manual-triage
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
# Review Changes
|
|
172
|
+
|
|
173
|
+
## discover
|
|
174
|
+
|
|
175
|
+
List the files that need review, drawn from the `changed_files` parameter.
|
|
176
|
+
|
|
177
|
+
### gate
|
|
178
|
+
|
|
179
|
+
Every file named by `changed_files` is listed in the reported result.
|
|
180
|
+
|
|
181
|
+
## review
|
|
182
|
+
|
|
183
|
+
This section is the **map unit template** — the engine attaches each unit's
|
|
184
|
+
item (the file to review) and its index as context; instructions refer to
|
|
185
|
+
"the file you were given," never a template expression.
|
|
186
|
+
|
|
187
|
+
Review the file you were given for correctness bugs.
|
|
188
|
+
|
|
189
|
+
### gate
|
|
190
|
+
|
|
191
|
+
Every changed file has a verdict of `pass` or `fail`.
|
|
192
|
+
|
|
193
|
+
## aggregate
|
|
194
|
+
|
|
195
|
+
Combine the per-file review verdicts — attached to this unit as input via
|
|
196
|
+
`inputs: [steps.review.output]` above — into one overall verdict, `pass` or
|
|
197
|
+
`fail`.
|
|
198
|
+
|
|
199
|
+
## triage
|
|
200
|
+
|
|
201
|
+
Routes on the verdict `aggregate` reported: `pass` proceeds to `ship`, `fail`
|
|
202
|
+
proceeds to `rework`, anything else goes to `manual-triage`.
|
|
203
|
+
|
|
204
|
+
## ship
|
|
205
|
+
|
|
206
|
+
Ship the change.
|
|
207
|
+
|
|
208
|
+
## rework
|
|
209
|
+
|
|
210
|
+
Address the review findings. Confirming the fix is a fresh `akm workflow run`
|
|
211
|
+
of this workflow, not a step this run routes back to.
|
|
212
|
+
|
|
213
|
+
## manual-triage
|
|
214
|
+
|
|
215
|
+
Summarize the ambiguous verdict for a human to triage.
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## The reference grammar
|
|
219
|
+
|
|
220
|
+
Workflow prose is **never templated** — there is no `${{ … }}`/`{{ … }}`
|
|
221
|
+
interpolation anywhere in a workflow's body, and no escape syntax to learn,
|
|
222
|
+
because there are no delimiters in prose to escape.
|
|
223
|
+
|
|
224
|
+
Bare reference strings appear in exactly three frontmatter positions, each an
|
|
225
|
+
unquoted-style YAML string:
|
|
226
|
+
|
|
227
|
+
| Position | What it names |
|
|
228
|
+
| --- | --- |
|
|
229
|
+
| `map.over` | The list a map step fans out over. |
|
|
230
|
+
| `route.input` | The value a route step matches on. |
|
|
231
|
+
| `inputs` (each entry) | A prior step's artifact this step consumes. |
|
|
232
|
+
|
|
233
|
+
Every reference resolves against exactly two roots:
|
|
234
|
+
|
|
235
|
+
| Reference | Meaning |
|
|
236
|
+
| --- | --- |
|
|
237
|
+
| `params.<name>` | A run parameter, by name. |
|
|
238
|
+
| `steps.<id>.output( .<ident> \| [<int>] )*` | A prior step's artifact, addressed by producer step id; the path walks properties (`.name`) and array indexes (`[0]`). |
|
|
239
|
+
|
|
240
|
+
Nothing else parses: no functions, no clock, no randomness, no ambient
|
|
241
|
+
lookup. `item` and `item_index` are **not** part of the language — a map
|
|
242
|
+
unit's item and its index are never referenced from anywhere in frontmatter
|
|
243
|
+
or body. They arrive as **attached context** instead, the same way as
|
|
244
|
+
everything else a unit needs.
|
|
245
|
+
|
|
246
|
+
**Context attachment, not string splicing.** Each dispatched unit receives,
|
|
247
|
+
alongside its byte-exact instructions, structured context:
|
|
248
|
+
|
|
249
|
+
- every run **param** (params are run-scoped — see
|
|
250
|
+
[Params are not secret](#params-are-not-secret) below);
|
|
251
|
+
- for a **map** unit, its **item** and **item index**;
|
|
252
|
+
- the artifacts named by its step's **`inputs:`**.
|
|
253
|
+
|
|
254
|
+
Instructions refer to this context in plain language — "clone the repository
|
|
255
|
+
named by the `repo` parameter," "review the file you were given," "using the
|
|
256
|
+
intake step's artifact attached to this unit" — never by splicing a value
|
|
257
|
+
into the instruction string. This closes the injection class at the root:
|
|
258
|
+
data never enters the instruction string, spliced or otherwise.
|
|
259
|
+
|
|
260
|
+
`akm lint --type workflows` still checks every bare reference statically —
|
|
261
|
+
unknown step, unknown param, bad path — at lint time.
|
|
262
|
+
|
|
263
|
+
### Params are not secret
|
|
264
|
+
|
|
265
|
+
Run params are copied verbatim into every unit's dispatched instructions and
|
|
266
|
+
are part of the unit's content-derived input hash — the same hash that makes
|
|
267
|
+
resume-without-replay possible (see
|
|
268
|
+
[Resume is journaled replay](https://github.com/itlackey/akm/blob/main/docs/architecture/workflow-engine.md#resume-is-journaled-replay)).
|
|
269
|
+
Redacting a param would change what gets hashed and make a resumed run
|
|
270
|
+
diverge from the original, so params are **declared non-secret and
|
|
271
|
+
un-redactable** by design: secrets belong in `env:` refs instead, which carry
|
|
272
|
+
by name only through the plan and are resolved from akm's env/secret store
|
|
273
|
+
at dispatch (see [Reference: Env & Secrets](https://github.com/itlackey/akm/blob/main/docs/reference/env-and-secrets.md)).
|
|
274
|
+
|
|
275
|
+
As a best-effort guardrail, `akm workflow run` scans a new run's params for
|
|
276
|
+
values that *look* like credentials — secret-suggesting key names (`token`,
|
|
277
|
+
`password`, `apikey`, `credential`, …) or long, high-entropy strings matching
|
|
278
|
+
known token prefixes — and surfaces a warning naming the param path and
|
|
279
|
+
recommending an `env:` ref instead. This is advisory only: it never blocks a
|
|
280
|
+
run and never mutates params, and false positives/negatives are expected.
|
|
281
|
+
|
|
282
|
+
## What a step's output is
|
|
283
|
+
|
|
284
|
+
`steps.<id>.output` resolves to the value the step's execution produced:
|
|
285
|
+
|
|
286
|
+
- a `unit` step → the unit's structured result (when the unit declares
|
|
287
|
+
`output`) or its text;
|
|
288
|
+
- a `map` step → the collected array of per-item results, in item order
|
|
289
|
+
(under `on_error: continue`, a failed item's slot is `null`), unless the
|
|
290
|
+
step's own `output` schema describes a reduced, single-value shape instead;
|
|
291
|
+
- an [exec unit](#exec-shell-units) → its stdout (trailing newlines stripped),
|
|
292
|
+
or the JSON value stdout parsed to when the unit declares an `output` schema.
|
|
293
|
+
|
|
294
|
+
**An empty successful free-text output is treated as no output.** When a
|
|
295
|
+
schemaless unit (one that declares no `output` schema) succeeds but returns
|
|
296
|
+
the empty string, akm normalizes it to *absent*: nothing is journaled for its
|
|
297
|
+
result, and its contribution to the step artifact is `null` — a `null` slot
|
|
298
|
+
in a collected array, or `output = null` for a solo step. This absence is
|
|
299
|
+
deliberate, so a live run and a resumed run promote the identical artifact.
|
|
300
|
+
The practical consequence: a downstream step that declares an empty upstream
|
|
301
|
+
result in its `inputs:` gets nothing meaningful attached for it — akm
|
|
302
|
+
surfaces this loudly rather than silently attaching an empty string. A unit
|
|
303
|
+
that declares an `output` schema is unaffected — an empty response is not
|
|
304
|
+
valid JSON, so it fails as a parse error and can never satisfy a schema as a
|
|
305
|
+
silent `null`.
|
|
306
|
+
|
|
307
|
+
## Typed step artifacts
|
|
308
|
+
|
|
309
|
+
When a step declares `output`, the promoted step artifact (the unit's
|
|
310
|
+
structured result, the collected array, or a reduced single value — see
|
|
311
|
+
[What a step's output is](#what-a-steps-output-is) above) is validated
|
|
312
|
+
against that schema **before** the step can complete. A mismatch fails the
|
|
313
|
+
step with the validation errors in its summary. This is fail-fast on purpose:
|
|
314
|
+
a bounded gate loop (see [Gates and verification](#gates-and-verification))
|
|
315
|
+
can re-run the step with those errors as corrective feedback.
|
|
316
|
+
|
|
317
|
+
### The enforced JSON Schema subset
|
|
318
|
+
|
|
319
|
+
`output` and `params` schemas are validated **as schemas** at parse time
|
|
320
|
+
(`akm lint --type workflows`, `akm workflow create`), because the runtime
|
|
321
|
+
enforces only a subset of JSON Schema:
|
|
322
|
+
|
|
323
|
+
`type`, `enum`, `properties`, `required`, `items`, `additionalProperties:
|
|
324
|
+
false`, `minItems`, `maxItems`, `minLength`, `maxLength`, `minimum`,
|
|
325
|
+
`maximum`, `allOf`, `anyOf`, `oneOf`, `not`.
|
|
326
|
+
|
|
327
|
+
Anything outside it is an authoring **error**, not a silent no-op. A typo'd
|
|
328
|
+
type name (`type: strig`) and a recognized-but-unenforced keyword (`$ref`,
|
|
329
|
+
`$defs`, `const`, `pattern`, `format`, `patternProperties`, `if`/`then`/`else`,
|
|
330
|
+
`uniqueItems`, `multipleOf`, `exclusiveMinimum`/`exclusiveMaximum`,
|
|
331
|
+
tuple-form `items`, schema-form `additionalProperties`, …) both fail with the
|
|
332
|
+
offending keyword named, a suggested replacement where one exists (`const` →
|
|
333
|
+
a single-value `enum`; `$ref` → inline the schema), and the location anchored
|
|
334
|
+
to the line. A gate that depends on a schema constraining nothing is worse
|
|
335
|
+
than a loud failure. Annotation keywords (`description`, `title`, `default`,
|
|
336
|
+
`examples`) constrain nothing in full JSON Schema either, so they pass through
|
|
337
|
+
untouched.
|
|
338
|
+
|
|
339
|
+
`pattern` is among the unsupported keywords. Matching an author-supplied regex
|
|
340
|
+
inside a synchronous gate decision would have to be bounded before the match
|
|
341
|
+
starts — a static safety analysis — and any such analysis also refuses regexes
|
|
342
|
+
authors legitimately write. Rather than carry machinery that fails authoring
|
|
343
|
+
for no benefit, the subset does not evaluate `pattern` at all and says so at
|
|
344
|
+
the point of authoring. Where a string's shape matters, list the allowed values
|
|
345
|
+
with `enum`, bound its size with `minLength`/`maxLength`, or check the shape in
|
|
346
|
+
the step's gate rubric, which can explain a mismatch in a way a regex cannot.
|
|
347
|
+
|
|
348
|
+
Evaluation is bounded: schema nesting is capped at 64 levels and one validation
|
|
349
|
+
may make at most 100 000 checks. Exhausting either is reported as an error — a
|
|
350
|
+
truncated evaluation never reports a value as valid.
|
|
351
|
+
|
|
352
|
+
### Bounds
|
|
353
|
+
|
|
354
|
+
These are enforced identically by the parser, the published JSON Schema, and
|
|
355
|
+
the frozen-plan decoder (they share one set of constants in
|
|
356
|
+
`src/workflows/resource-limits.ts`), so a document that lints clean cannot
|
|
357
|
+
fail later at `akm workflow run`:
|
|
358
|
+
|
|
359
|
+
| Field | Bound |
|
|
360
|
+
| --- | --- |
|
|
361
|
+
| `gate.max_loops` | 1 – 100 |
|
|
362
|
+
| `map.concurrency` | 1 – 64 |
|
|
363
|
+
| `retry.max` | 0 – 100 |
|
|
364
|
+
| `timeout` | ≤ 2147483647 ms (~24.8 days), or `none` |
|
|
365
|
+
| `engine` names | `^[a-z][a-z0-9]*(-[a-z0-9]+)*$`, ≤ 63 chars |
|
|
366
|
+
|
|
367
|
+
`timeout` is resolved **once, at freeze time**, and the frozen value is what
|
|
368
|
+
dispatch applies — there is no separate engine-side ceiling on top of it. The
|
|
369
|
+
first of these that is set wins: the unit's `timeout`, then the document's
|
|
370
|
+
`defaults.timeout`, then `engines.<name>.timeoutMs`, then the engine-kind
|
|
371
|
+
default — **10m for `kind: llm` engines, and none for agent engines**, which
|
|
372
|
+
manage their own process lifetime. Writing `timeout: none` is an explicit opt
|
|
373
|
+
out and leaves the unit genuinely unbounded; nothing later re-imposes a cap.
|
|
374
|
+
|
|
375
|
+
## Exec (shell) units
|
|
376
|
+
|
|
377
|
+
A step whose `unit:` block declares `exec:` runs a **shell command** instead of
|
|
378
|
+
dispatching to an engine. Deterministic work — running the test suite, building,
|
|
379
|
+
linting, copying a file, invoking a script — is a command, not a prompt, and
|
|
380
|
+
paying for an LLM or agent dispatch to get it done buys nondeterminism, latency,
|
|
381
|
+
and tokens in exchange for nothing.
|
|
382
|
+
|
|
383
|
+
```yaml
|
|
384
|
+
steps:
|
|
385
|
+
- id: test
|
|
386
|
+
unit:
|
|
387
|
+
exec:
|
|
388
|
+
command: ["bun", "run", "test:unit"]
|
|
389
|
+
cwd: packages/core # optional, relative
|
|
390
|
+
timeout: "10m"
|
|
391
|
+
retry: { max: 1, on: [timeout] }
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
An exec unit names **no engine**. It carries no `engine`, `model`, or `llm` (the
|
|
395
|
+
parser rejects those alongside `exec:`), it consumes no tokens, and a workflow
|
|
396
|
+
made only of exec steps freezes and runs on an install with no engine configured
|
|
397
|
+
at all. Everything else about a unit still applies unchanged: `timeout`, `retry`,
|
|
398
|
+
`on_error`, `output`, `env`, `isolation`, `map` fan-out and its concurrency, the
|
|
399
|
+
unit journal, budget accounting, and replay/reuse.
|
|
400
|
+
|
|
401
|
+
The step's body prose is still required (it is the step's section, like any
|
|
402
|
+
other unit step) and is **not** passed to the command — it documents what the
|
|
403
|
+
command does, for the human reading the workflow.
|
|
404
|
+
|
|
405
|
+
### `command` is an argv array, never a shell string
|
|
406
|
+
|
|
407
|
+
There is deliberately **no shell-string spelling**. The child is spawned
|
|
408
|
+
directly, so nothing ever parses the words: `;`, `|`, `&&`, `$(…)`, backticks,
|
|
409
|
+
`>`, and `*` inside an argument are inert literal bytes. The entire
|
|
410
|
+
quoting/injection class a `sh -c "<string>"` surface opens is *structurally
|
|
411
|
+
absent*, not defended against — a value that happens to contain `; rm -rf /`
|
|
412
|
+
is one argument containing those characters, and always was.
|
|
413
|
+
|
|
414
|
+
If you genuinely want a pipeline or a shell builtin, name the interpreter
|
|
415
|
+
yourself:
|
|
416
|
+
|
|
417
|
+
```yaml
|
|
418
|
+
command: ["bash", "-lc", "bun run build | tee build.log"]
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
That is allowed and sometimes right — but it is now a visible, reviewable line
|
|
422
|
+
in the frontmatter diff rather than something the format did for you silently.
|
|
423
|
+
|
|
424
|
+
Bounds: 1–64 argv entries, each a non-empty string of at most 4096 bytes.
|
|
425
|
+
|
|
426
|
+
### `cwd`
|
|
427
|
+
|
|
428
|
+
Optional and **relative**. It resolves inside the unit's working directory —
|
|
429
|
+
the engine invocation's working directory normally, or the unit's fresh
|
|
430
|
+
detached worktree under `isolation: worktree`. Absolute paths, Windows drive
|
|
431
|
+
letters, `~`, and `..` segments are rejected by the parser *and* by the
|
|
432
|
+
frozen-plan decoder, and containment is re-checked against the *resolved* base
|
|
433
|
+
(symlinks included) immediately before the command is spawned. An exec unit
|
|
434
|
+
cannot step outside the tree its isolation promised.
|
|
435
|
+
|
|
436
|
+
### The output rule
|
|
437
|
+
|
|
438
|
+
- **No `output:` schema** → the artifact is the command's **stdout**, with
|
|
439
|
+
trailing newlines stripped (exactly like shell `$(…)`). Empty stdout follows
|
|
440
|
+
the ordinary [empty-output rule](#what-a-steps-output-is): it is treated as
|
|
441
|
+
*no output*.
|
|
442
|
+
- **With an `output:` schema on the unit** → stdout must be **exactly one JSON
|
|
443
|
+
value** (surrounding whitespace tolerated, nothing else), which is parsed and
|
|
444
|
+
validated against the schema. This is a *strict* parse, unlike the forgiving
|
|
445
|
+
embedded-JSON scan used for model output: a command that claims a schema
|
|
446
|
+
prints JSON, and log noise that happens to contain a JSON object must never
|
|
447
|
+
be promoted as the artifact. Non-JSON stdout fails as `parse_error`; JSON that
|
|
448
|
+
misses the schema fails as `validation_error`.
|
|
449
|
+
- **stderr is never part of the artifact.** It is a diagnostic channel: the tail
|
|
450
|
+
of a failed command's stderr is included (clipped, redacted) in the unit's
|
|
451
|
+
failure diagnostic and its journal row. Because a failing command's stderr is
|
|
452
|
+
frequently the *only* explanation of the failure, that diagnostic is durable —
|
|
453
|
+
it survives to `akm workflow status --units` and to the step's summary in the
|
|
454
|
+
run output, not just to the in-memory result. It is clipped to 2000 characters
|
|
455
|
+
and goes through the same redaction contract as everything else journaled.
|
|
456
|
+
- **Retained output is bounded at 8 MiB per stream.** akm keeps at most that much
|
|
457
|
+
of stdout and of stderr; past the cap it keeps *reading* and discards, so the
|
|
458
|
+
command still runs to completion and its exit code still decides the unit. See
|
|
459
|
+
[Output limits](#output-limits) — the artifact is then explicitly marked
|
|
460
|
+
truncated, never silently shortened, and only a unit with a declared `output:`
|
|
461
|
+
schema fails for it.
|
|
462
|
+
- **An incomplete STDOUT capture is a failure, not a partial artifact.** Exiting
|
|
463
|
+
0 is not on its own proof that stdout was fully read: a pipe can error, and a
|
|
464
|
+
background descendant that keeps the stdout handle open after the command
|
|
465
|
+
leader exits will hold the pipe past the drain deadline. Either way the
|
|
466
|
+
captured text is a *prefix* of the real output, so the unit fails
|
|
467
|
+
`exec_capture_incomplete` rather than promoting the prefix. That reason is
|
|
468
|
+
deliberately outside `retry.on`: the command already RAN, and re-dispatching
|
|
469
|
+
identical argv to fix a capture problem would run its side effects again. If
|
|
470
|
+
you hit this, have the command wait for its children or redirect their
|
|
471
|
+
output.
|
|
472
|
+
|
|
473
|
+
Only **stdout** is fatal here. stderr never contributes to the artifact, so a
|
|
474
|
+
stderr drain that did not finish leaves the unit's actual result — a completed
|
|
475
|
+
command, a fully captured stdout — intact and the unit succeeds; failing it
|
|
476
|
+
would discard a valid artifact over a lost log tail. akm warns when that
|
|
477
|
+
happens, naming the unit, so stderr shown for it is never mistaken for the
|
|
478
|
+
whole of it.
|
|
479
|
+
|
|
480
|
+
The drain deadline itself starts when nothing living owns the pipe any more —
|
|
481
|
+
the command's exit, or for a unit with a declared `timeout` the earlier of
|
|
482
|
+
that and the budget expiring — plus a short grace. A unit does not sit until
|
|
483
|
+
its whole `timeout` because a descendant outlived the command.
|
|
484
|
+
|
|
485
|
+
Note that a schema failure is **not** retried by the corrective-feedback loop
|
|
486
|
+
that model units use. Re-prompting is meaningless to a fixed argv — the same
|
|
487
|
+
command cannot produce different output — but re-running it *can* deploy twice.
|
|
488
|
+
A declared `retry:` still applies, because that is a policy you opted into per
|
|
489
|
+
failure reason.
|
|
490
|
+
|
|
491
|
+
### Exit codes and failure reasons
|
|
492
|
+
|
|
493
|
+
| Outcome | `failure_reason` | In `retry.on`? |
|
|
494
|
+
| --- | --- | --- |
|
|
495
|
+
| exit 0 | — (unit succeeds) | — |
|
|
496
|
+
| non-zero exit | `non_zero_exit` | yes |
|
|
497
|
+
| exceeded `timeout` | `timeout` | yes |
|
|
498
|
+
| run cancelled (`Ctrl-C`, `--timeout`, budget) | `aborted` | yes |
|
|
499
|
+
| binary missing / working directory unusable | `spawn_failed` | yes |
|
|
500
|
+
| **stdout** capture never completed | `exec_capture_incomplete` | **no** (the command already ran) |
|
|
501
|
+
| **stderr** capture never completed | — (unit succeeds; a warning says the stderr tail may be missing) | — |
|
|
502
|
+
| stdout past the retention limit, **and** the unit declares `output:` | `exec_output_limit` | **no** (deterministic) |
|
|
503
|
+
| stdout past the retention limit, no `output:` schema | — (unit succeeds; artifact marked truncated) | — |
|
|
504
|
+
| `AKM_*` context too large for **this platform** to spawn | `exec_context_too_large` | **no** (an authoring/data problem) |
|
|
505
|
+
| `cwd` resolved outside its base | `exec_cwd_escape` | **no** (tampering, never transient) |
|
|
506
|
+
|
|
507
|
+
A non-zero exit is an ordinary unit failure, so it flows through the ordinary
|
|
508
|
+
policy: with the default `on_error: fail` it fails the step and the run — which
|
|
509
|
+
is exactly what makes a `test` step a **gate**. With `on_error: continue` the
|
|
510
|
+
failure is recorded in the step's evidence and the completion gate decides.
|
|
511
|
+
|
|
512
|
+
On timeout or cancellation the child's whole **process group** gets a
|
|
513
|
+
SIGTERM→SIGKILL ladder, so a command that spawned its own children does not
|
|
514
|
+
leave them orphaned.
|
|
515
|
+
|
|
516
|
+
### Output limits
|
|
517
|
+
|
|
518
|
+
akm captures the command's stdout and stderr into memory — stdout *is* the
|
|
519
|
+
artifact — so it **retains** at most **8 MiB** of each stream.
|
|
520
|
+
|
|
521
|
+
This is a cap on akm's memory, not on your command. Past the cap akm keeps
|
|
522
|
+
reading the pipe and throws the extra bytes away, so the command never blocks on
|
|
523
|
+
a full pipe: it runs to completion and **its exit code is what decides the
|
|
524
|
+
unit**. A chatty-but-passing test suite is not failed for its log volume.
|
|
525
|
+
|
|
526
|
+
What overflow does cost is honesty about the artifact, and that depends on what
|
|
527
|
+
the unit promised:
|
|
528
|
+
|
|
529
|
+
| The unit declares… | On overflow |
|
|
530
|
+
| --- | --- |
|
|
531
|
+
| no `output:` schema | the unit **succeeds** (given exit 0). Its artifact is the retained first 8 MiB with a `__akm_exec_output_truncated__` block appended, naming the bytes written and the bytes retained. |
|
|
532
|
+
| an `output:` schema | the unit **fails** `exec_output_limit` and **nothing is promoted**. |
|
|
533
|
+
|
|
534
|
+
Nothing is ever truncated *silently*. The marker block is the last thing in the
|
|
535
|
+
artifact, so a downstream `steps.<id>.output` reference, a completion gate's
|
|
536
|
+
judge, and a human reading `akm workflow status` all see plainly that the text is
|
|
537
|
+
incomplete. Truncated data can never be mistaken for complete data — that is the
|
|
538
|
+
rule, and the marker is how it is kept.
|
|
539
|
+
|
|
540
|
+
The schema case stays a failure because a truncated prefix is not a JSON value:
|
|
541
|
+
with `output:` declared, stdout must parse as exactly one JSON value, so there is
|
|
542
|
+
nothing to validate and nothing safe to promote. `exec_output_limit` is therefore
|
|
543
|
+
still outside the `retry.on` vocabulary — the command is deterministic, so
|
|
544
|
+
re-dispatching it can only spend the budget to produce the same oversized output
|
|
545
|
+
again.
|
|
546
|
+
|
|
547
|
+
stderr overflow never fails anything. stderr is a diagnostic channel; the journal
|
|
548
|
+
already clips and marks what it keeps.
|
|
549
|
+
|
|
550
|
+
The cap is generous (8× the 1 MiB evidence-persistence cap), so an ordinary build
|
|
551
|
+
or test log is nowhere near it. If a command legitimately produces more and you
|
|
552
|
+
want the whole thing, have it write to a file and print the **path**:
|
|
553
|
+
|
|
554
|
+
```yaml
|
|
555
|
+
command: ["bash", "-lc", "bun run build > build.log 2>&1; echo build.log"]
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
### Context reaching the command
|
|
559
|
+
|
|
560
|
+
A frozen argv is never interpolated (this format has no substitution language),
|
|
561
|
+
so data reaches an exec unit as **environment**, the argv analogue of the
|
|
562
|
+
context blocks a model unit gets in its prompt:
|
|
563
|
+
|
|
564
|
+
| Variable | Value |
|
|
565
|
+
| --- | --- |
|
|
566
|
+
| `AKM_RUN_ID`, `AKM_STEP_ID`, `AKM_UNIT_ID` | ids of this dispatch |
|
|
567
|
+
| `AKM_PARAMS` | the run params, canonical JSON |
|
|
568
|
+
| `AKM_ITEM`, `AKM_ITEM_INDEX` | a `map` unit's item (canonical JSON) and 0-based index |
|
|
569
|
+
| `AKM_INPUTS` | the step's declared `inputs:` artifacts, keyed by reference string |
|
|
570
|
+
|
|
571
|
+
These are applied *after* your `env:` bindings, so a binding can never shadow
|
|
572
|
+
them.
|
|
573
|
+
|
|
574
|
+
#### Context size limits
|
|
575
|
+
|
|
576
|
+
An environment variable is an operating-system object with a hard ceiling, and a
|
|
577
|
+
workflow artifact has no comparable bound — so a perfectly legitimate declared
|
|
578
|
+
input can grow past what **process creation itself** accepts. akm therefore
|
|
579
|
+
bounds what it puts in the child's environment, **against the ceiling of the
|
|
580
|
+
platform the run is actually on**:
|
|
581
|
+
|
|
582
|
+
| Bound | Linux / macOS / BSD | Windows |
|
|
583
|
+
| --- | --- | --- |
|
|
584
|
+
| One `AKM_*` context variable | 98 304 bytes (96 KiB) | 32 767 bytes |
|
|
585
|
+
| All `AKM_*` context variables combined | 131 072 bytes (128 KiB) | 64 000 bytes |
|
|
586
|
+
|
|
587
|
+
Where those numbers come from:
|
|
588
|
+
|
|
589
|
+
- **Linux** caps a single `argv`/`environ` string at `MAX_ARG_STRLEN`, defined as
|
|
590
|
+
`32 * PAGE_SIZE` — 131 072 bytes. The 96 KiB bound leaves 32 KiB of margin for
|
|
591
|
+
the variable's name, the `=`, the `NUL`, and the kernel's own accounting.
|
|
592
|
+
- **macOS** has no per-string cap; its binding constraint is `ARG_MAX`
|
|
593
|
+
(262 144 bytes) over `argv` + `environ` *combined*. The 128 KiB total keeps
|
|
594
|
+
akm's own contribution to half of that, leaving the rest for the argv, the
|
|
595
|
+
environment allowlist and your `env:` bindings.
|
|
596
|
+
- **Windows** caps a single user-defined environment variable at 32 767
|
|
597
|
+
characters (`SetEnvironmentVariable`), and the environment block has limits of
|
|
598
|
+
the same order.
|
|
599
|
+
|
|
600
|
+
Crossing either bound fails the unit `exec_context_too_large` **before anything
|
|
601
|
+
is spawned**, with an error naming the variable, its actual size, this platform's
|
|
602
|
+
limit and where that number comes from. That translation is the check's *only*
|
|
603
|
+
job: without it the same workflow dies inside the spawn syscall with a bare
|
|
604
|
+
`E2BIG` ("argument list too long") that names neither the variable nor the step
|
|
605
|
+
that produced the data.
|
|
606
|
+
|
|
607
|
+
Because that is its only job, the check uses **this** platform's ceiling rather
|
|
608
|
+
than the smallest supported one. A guard that applied Windows' 32 767-byte limit
|
|
609
|
+
on Linux would refuse spawns the kernel would happily have accepted — inventing a
|
|
610
|
+
failure instead of explaining an inevitable one.
|
|
611
|
+
|
|
612
|
+
> **Portability guidance (not enforcement).** If a workflow is meant to run on
|
|
613
|
+
> Windows as well, keep `AKM_*` context under **32 767 bytes per variable**. akm
|
|
614
|
+
> will not fail your Linux or macOS run for exceeding that — but a Windows runner
|
|
615
|
+
> will. The fix in both cases is the same: emit a reference instead of bulk data.
|
|
616
|
+
|
|
617
|
+
If you hit this, have the producing step emit a **reference** — a file path, an
|
|
618
|
+
id, a key — instead of inline bulk data:
|
|
619
|
+
|
|
620
|
+
```yaml
|
|
621
|
+
- id: extract
|
|
622
|
+
unit:
|
|
623
|
+
exec:
|
|
624
|
+
command: ["bash", "-lc", "./extract.sh > /tmp/rows.json; echo /tmp/rows.json"]
|
|
625
|
+
- id: load
|
|
626
|
+
inputs: [steps.extract.output] # a PATH, not the rows
|
|
627
|
+
unit:
|
|
628
|
+
exec:
|
|
629
|
+
command: ["./load.sh"]
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
akm deliberately does **not** transparently spill an oversized context to a file
|
|
633
|
+
and pass a path instead. That would make the `AKM_INPUTS` contract conditional
|
|
634
|
+
on the size of the data — sometimes JSON, sometimes a filename — so every
|
|
635
|
+
command would have to handle both shapes, and the spill file would have to be
|
|
636
|
+
placed, isolated and cleaned up inside a unit's worktree. A stable contract plus
|
|
637
|
+
an explicit error is the smaller, more predictable surface.
|
|
638
|
+
|
|
639
|
+
### The child's environment is an allowlist
|
|
640
|
+
|
|
641
|
+
An exec unit's command does **not** inherit akm's environment. The child starts
|
|
642
|
+
from an **empty** environment and is built in three layers, in this order:
|
|
643
|
+
|
|
644
|
+
1. the **default allowlist** below, copied through from akm's own environment
|
|
645
|
+
(plus any names the unit adds with `pass_env:`);
|
|
646
|
+
2. the unit's resolved **`env:` bindings**;
|
|
647
|
+
3. the engine-authored **`AKM_*` context** — last, so a binding can never
|
|
648
|
+
shadow it.
|
|
649
|
+
|
|
650
|
+
The default allowlist is exactly these names (a name absent from akm's own
|
|
651
|
+
environment is simply absent from the child):
|
|
652
|
+
|
|
653
|
+
| Group | Names | Why |
|
|
654
|
+
| --- | --- | --- |
|
|
655
|
+
| Command resolution | `PATH` | without it only an absolute `command[0]` can be spawned |
|
|
656
|
+
| Home | `HOME` | the config/cache root git, npm, bun, cargo and ssh all read |
|
|
657
|
+
| Identity | `USER`, `LOGNAME`, `SHELL` | read by git/ssh and by tools that re-exec a login shell |
|
|
658
|
+
| Locale | `LANG`, `LC_ALL`, `LC_CTYPE` | without a locale, non-ASCII stdout — *which is this unit's artifact* — gets mangled |
|
|
659
|
+
| Terminal / clock | `TERM`, `TZ` | some CLIs abort with no `TERM`; `TZ` keeps printed timestamps stable |
|
|
660
|
+
| Scratch space | `TMPDIR`, `TEMP`, `TMP` | POSIX and Windows temp roots |
|
|
661
|
+
| Windows essentials | `SystemRoot`, `SystemDrive`, `WINDIR`, `COMSPEC`, `PATHEXT` | Windows **process creation itself** fails with an empty environment; `PATHEXT` is what makes `bun.exe`/`bun.cmd` resolvable at all |
|
|
662
|
+
| Windows home/config | `USERPROFILE`, `HOMEDRIVE`, `HOMEPATH`, `APPDATA`, `LOCALAPPDATA`, `ProgramData`, `ProgramFiles` | the Windows analogues of `HOME` and the machine-wide install roots toolchain shims resolve against |
|
|
663
|
+
| akm provenance | `AKM_EVENT_SOURCE` | a command that calls `akm` records machine traffic, not user demand |
|
|
664
|
+
|
|
665
|
+
`PATH` is additionally supplemented with well-known user binary directories
|
|
666
|
+
when akm is running in a scheduler context (cron/launchd/Task Scheduler) that
|
|
667
|
+
stripped it — the same treatment an agent-harness child gets.
|
|
668
|
+
|
|
669
|
+
Deliberately **not** on the list: credentials of any kind, cloud/CI variables,
|
|
670
|
+
and the proxy family (`HTTP_PROXY` and friends — proxy URLs routinely embed
|
|
671
|
+
credentials). Reach them with `pass_env:`, an `env:` binding, or `inherit_env:`.
|
|
672
|
+
|
|
673
|
+
#### `pass_env:` — widen the allowlist by name
|
|
674
|
+
|
|
675
|
+
```yaml
|
|
676
|
+
unit:
|
|
677
|
+
exec:
|
|
678
|
+
command: ["cargo", "build", "--release"]
|
|
679
|
+
pass_env: [CARGO_HOME, SCCACHE_DIR]
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
Names only, 1–32 of them, matching `^[A-Za-z_][A-Za-z0-9_]*$`. Use it for a
|
|
683
|
+
**per-machine** variable an `env:` binding cannot express — an env asset stores
|
|
684
|
+
a committed *value*, so it cannot carry "whatever this build agent's
|
|
685
|
+
`CARGO_HOME` happens to be".
|
|
686
|
+
|
|
687
|
+
Values passed through this way are **not** redacted from the command's output
|
|
688
|
+
the way `env:` binding values are, so never list a credential here.
|
|
689
|
+
|
|
690
|
+
#### `inherit_env:` — opt back into full inheritance
|
|
691
|
+
|
|
692
|
+
```yaml
|
|
693
|
+
unit:
|
|
694
|
+
exec:
|
|
695
|
+
command: ["./scripts/deploy.sh"]
|
|
696
|
+
inherit_env: true
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
`inherit_env: true` gives the command akm's **entire** environment, verbatim —
|
|
700
|
+
what it would see if you had typed it yourself in the shell that ran
|
|
701
|
+
`akm workflow run`. Reach for it when a command genuinely needs the
|
|
702
|
+
caller's whole environment (a wrapper script, a toolchain with many ambient
|
|
703
|
+
variables) and enumerating names would be a losing game. Prefer `pass_env:` or
|
|
704
|
+
`env:` bindings when you can, because those keep what the command can see
|
|
705
|
+
visible in the frontmatter diff.
|
|
706
|
+
|
|
707
|
+
Both keys are **dispatch-significant**: they change what the command can see,
|
|
708
|
+
so both are part of the unit's input hash. Changing either re-dispatches the
|
|
709
|
+
unit rather than reusing a journaled row produced under the other scope.
|
|
710
|
+
|
|
711
|
+
### What `akm show` reports for an exec step
|
|
712
|
+
|
|
713
|
+
`akm show <workflow> --format json` summarizes each step under
|
|
714
|
+
`steps[].orchestration`. For an exec step that summary carries an `exec` object
|
|
715
|
+
and **no `engine`/`model`** — an exec unit names no engine, so reporting the
|
|
716
|
+
workflow's `defaults.engine` there would describe a dispatch that never
|
|
717
|
+
happens. Field presence is the discriminator, the same way `fanOut` marks a
|
|
718
|
+
`map` step and `route` marks a route step:
|
|
719
|
+
|
|
720
|
+
```json
|
|
721
|
+
{
|
|
722
|
+
"id": "test",
|
|
723
|
+
"title": "test",
|
|
724
|
+
"instructions": "Run the unit tests.",
|
|
725
|
+
"orchestration": {
|
|
726
|
+
"timeoutMs": 600000,
|
|
727
|
+
"exec": {
|
|
728
|
+
"command": ["bun", "run", "test:unit"],
|
|
729
|
+
"cwd": "packages/core",
|
|
730
|
+
"passEnv": ["CARGO_HOME"],
|
|
731
|
+
"inheritEnv": true
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
- `command` is the argv **in full, never clipped** — the point of the field is
|
|
738
|
+
that what `show` prints is what runs, and a truncated argv would be the same
|
|
739
|
+
misdescription in miniature. It is safe to print because it is authored
|
|
740
|
+
literally in the asset: this format has no substitution language, so no part
|
|
741
|
+
of it is resolved from your environment, from a secret ref, or from a prior
|
|
742
|
+
step's output. Every byte is already visible in the workflow file (and stored
|
|
743
|
+
verbatim in `plan_json`) — which is also why you never inline a secret there.
|
|
744
|
+
- `cwd`, `passEnv` and `inheritEnv` appear only when the unit declares them.
|
|
745
|
+
`passEnv` is a list of variable **names**; no value is ever projected.
|
|
746
|
+
- `timeoutMs` is still reported, because an exec unit really does inherit
|
|
747
|
+
`defaults.timeout` — that number is true for it.
|
|
748
|
+
|
|
749
|
+
Everything else in the summary is unchanged: a `map` of exec units carries both
|
|
750
|
+
`fanOut` and `exec`, and `hasSchema`/`env` mean what they mean for any unit.
|
|
751
|
+
|
|
752
|
+
### Security
|
|
753
|
+
|
|
754
|
+
Exec units sit inside the existing workflow trust model — see
|
|
755
|
+
[Security: workflow sources are executed code](https://github.com/itlackey/akm/blob/main/docs/guides/run-workflows.md#security-workflow-sources-are-executed-code).
|
|
756
|
+
They do not widen it, and they do not narrow it:
|
|
757
|
+
|
|
758
|
+
- **The child's environment is an allowlist, not an inheritance.** Be clear
|
|
759
|
+
about what that does and does not buy. It does *not* stop a determined
|
|
760
|
+
attacker: a command that runs at all can read the same credentials off disk
|
|
761
|
+
that the environment would have handed it, and a workflow source is executed
|
|
762
|
+
code either way. What it does buy is real but narrower — it bounds
|
|
763
|
+
**accidental** exposure (the shell or CI job that invoked `akm` routinely
|
|
764
|
+
exports tokens for unrelated services, and a third-party step that merely
|
|
765
|
+
prints its environment, or a tool that ships one in a crash report, should
|
|
766
|
+
not get them for free), it makes the environment surface **explicit and
|
|
767
|
+
reviewable** (this list plus lines in the frontmatter diff, rather than
|
|
768
|
+
"whatever the invoking shell happened to export"), and it **matches the
|
|
769
|
+
convention akm already applies to spawned children** — agent-harness children
|
|
770
|
+
have always been built from `envPassthrough` this way, and exec units now use
|
|
771
|
+
the same mechanism rather than a second one. Operators who need a harder
|
|
772
|
+
boundary still scope the *akm process* (dedicated account, ephemeral working
|
|
773
|
+
directory, external network/filesystem policy); that is the boundary that
|
|
774
|
+
actually holds.
|
|
775
|
+
- **Secrets come from `env:` bindings by name.** The frozen plan carries only
|
|
776
|
+
the ref names, the replay hash carries only the ref names, and the resolved
|
|
777
|
+
values are collected and scrubbed out of stdout, stderr, and the failure
|
|
778
|
+
diagnostic by the same redaction contract every other dispatch uses — before
|
|
779
|
+
anything is journaled. Never inline a secret into `command:`; argv is stored
|
|
780
|
+
verbatim in `plan_json`.
|
|
781
|
+
- **Read a workflow before you run it.** `exec:` makes what a workflow will run
|
|
782
|
+
explicit and auditable in one place, which is a real improvement over
|
|
783
|
+
instructing a model to "run the tests" — but a bundle you do not trust is
|
|
784
|
+
still a stranger's script.
|
|
785
|
+
|
|
786
|
+
## Fan-out and concurrency
|
|
787
|
+
|
|
788
|
+
A `map` step is a fan-out: it expands `over:` into one unit per item, runs
|
|
789
|
+
those units, and folds the results with its `reducer`. The units are
|
|
790
|
+
independent by construction — no unit can read another's result — so they run
|
|
791
|
+
**in parallel by default**.
|
|
792
|
+
|
|
793
|
+
### The default
|
|
794
|
+
|
|
795
|
+
**Since 0.9.1, a `map` step that declares no `concurrency:` freezes a width of
|
|
796
|
+
4.** (Before 0.9.1 it froze 1, so every fan-out ran one item at a time unless
|
|
797
|
+
the author opted in.) 4 rather than "as wide as the machine allows" is
|
|
798
|
+
deliberate: it is a predictable 4× on any fan-out longer than four items, it
|
|
799
|
+
stays under the host CPU cap on any machine with 6 or more cores, and it is a
|
|
800
|
+
number an author can reason about without knowing which box the run lands on.
|
|
801
|
+
|
|
802
|
+
Three ways to change it:
|
|
803
|
+
|
|
804
|
+
| You want | Write |
|
|
805
|
+
| --- | --- |
|
|
806
|
+
| A specific width for one step | `map.concurrency: <n>` in that step |
|
|
807
|
+
| **Serial execution for one step** | `map.concurrency: 1` |
|
|
808
|
+
| A different default for every workflow on this machine | `akm config set workflow.defaultMapConcurrency <n>` |
|
|
809
|
+
|
|
810
|
+
`concurrency: 1` is a real, honored opt-out, not the absence of a value: an
|
|
811
|
+
authored `1` is kept distinct from an unset field, and it always beats the
|
|
812
|
+
config default. Set `workflow.defaultMapConcurrency` to `1` to restore the
|
|
813
|
+
pre-0.9.1 serial-by-default behavior everywhere at once.
|
|
814
|
+
|
|
815
|
+
A step with no `map:` is one unit, not a fan-out. It is unaffected by any of
|
|
816
|
+
this.
|
|
817
|
+
|
|
818
|
+
### The four limits
|
|
819
|
+
|
|
820
|
+
The width a step really runs at is the **minimum** of four independent values.
|
|
821
|
+
Raising one never raises the others:
|
|
822
|
+
|
|
823
|
+
| Limit | Set by | Default when unset |
|
|
824
|
+
| --- | --- | --- |
|
|
825
|
+
| `map.concurrency` | the step | `workflow.defaultMapConcurrency`, else **4** |
|
|
826
|
+
| `execution.maxConcurrency` | `workflow.maxConcurrency` config | CPU-derived `min(16, max(1, cores − 2))` |
|
|
827
|
+
| the selected LLM engine's concurrency | `engines.<name>.concurrency` | **1** for a loopback endpoint, **4** for a remote one |
|
|
828
|
+
| the host CPU safety cap | nothing — reapplied at dispatch | `min(16, max(1, cores − 2))` |
|
|
829
|
+
|
|
830
|
+
The engine limit is per **endpoint kind** on purpose. A local model server (LM
|
|
831
|
+
Studio, Ollama) has one loaded model, and concurrent inference makes it reload
|
|
832
|
+
and return HTTP 500 — a hard failure, so loopback endpoints stay at 1 and a
|
|
833
|
+
`map` against a local model is still effectively serial unless you raise
|
|
834
|
+
`engines.<name>.concurrency` yourself. Remote providers fail softly (a
|
|
835
|
+
retryable 429), and four concurrent completions is well inside any hosted
|
|
836
|
+
provider's entry tier. Agent engines carry no concurrency limit of their own —
|
|
837
|
+
except an `opencode-sdk` engine with an `llmEngine` fallback, which inherits
|
|
838
|
+
that fallback engine's limit.
|
|
839
|
+
|
|
840
|
+
#### What counts as a loopback endpoint
|
|
841
|
+
|
|
842
|
+
The whole loopback space, not one address:
|
|
843
|
+
|
|
844
|
+
| Recognized | Examples |
|
|
845
|
+
| --- | --- |
|
|
846
|
+
| all of `127.0.0.0/8` | `http://127.0.0.1:1234`, `http://127.0.0.2:11434` |
|
|
847
|
+
| `localhost` and any `*.localhost` name | `http://localhost:1234`, `http://lmstudio.localhost` |
|
|
848
|
+
| IPv6 `::1`, in any spelling | `http://[::1]:1234`, `http://[0:0:0:0:0:0:0:1]:1234` |
|
|
849
|
+
| the unspecified addresses (a client connecting there reaches loopback) | `http://0.0.0.0:11434`, `http://[::]:11434` |
|
|
850
|
+
|
|
851
|
+
`127.0.0.2` matters in practice: running a second LM Studio or Ollama on
|
|
852
|
+
another address inside the `127.0.0.0/8` block is ordinary, and that server is
|
|
853
|
+
exactly as single-model as one on `127.0.0.1`.
|
|
854
|
+
|
|
855
|
+
**IPv4-mapped IPv6 is recognized for `127.0.0.0/8`** in both spellings —
|
|
856
|
+
`[::ffff:127.0.0.1]` and the hex `[::ffff:7f00:1]` — because the URL parser
|
|
857
|
+
re-serializes the dotted form to the hex one before the check ever sees it. A
|
|
858
|
+
mapped public address such as `[::ffff:8.8.8.8]` still reads as remote. The
|
|
859
|
+
deprecated IPv4-compatible form `[::127.0.0.1]` is not recognized; if you use
|
|
860
|
+
it, set `engines.<name>.concurrency: 1`.
|
|
861
|
+
|
|
862
|
+
The check is **purely syntactic — it never resolves a name.** A frozen plan has
|
|
863
|
+
to come out the same on your laptop, on CI, and on a machine with no network at
|
|
864
|
+
all, and a DNS lookup would make the frozen width depend on what a resolver
|
|
865
|
+
happened to answer. So a *name* that resolves to loopback (a hosts-file alias,
|
|
866
|
+
`host.docker.internal`, an internal DNS record) is treated as **remote**; point
|
|
867
|
+
the engine at the address itself, or set `engines.<name>.concurrency: 1`
|
|
868
|
+
explicitly. In the other direction the classification is deliberately
|
|
869
|
+
conservative: an endpoint akm cannot parse at all is treated as loopback, since
|
|
870
|
+
freezing 4 for a config it does not understand is the failure worth avoiding.
|
|
871
|
+
|
|
872
|
+
The host cap is re-derived from the CURRENT machine at every dispatch, not
|
|
873
|
+
frozen. A plan frozen on a 32-core CI box narrows itself when it resumes on a
|
|
874
|
+
4-core laptop.
|
|
875
|
+
|
|
876
|
+
### Frozen widths
|
|
877
|
+
|
|
878
|
+
Every one of these numbers except the host cap is resolved **once, when the run
|
|
879
|
+
starts**, and stored in the run's plan. Editing config, upgrading akm, or
|
|
880
|
+
changing the defaults above never alters a run that is already in flight or
|
|
881
|
+
being resumed — it keeps the widths it froze. The new defaults apply only to
|
|
882
|
+
runs started after the upgrade.
|
|
883
|
+
|
|
884
|
+
## Routing
|
|
885
|
+
|
|
886
|
+
A `route` step makes classify-and-dispatch first-class: the engine resolves
|
|
887
|
+
the explicit `input:` expression, selects the matching `when:` branch (or
|
|
888
|
+
`default:`), and auto-skips the unselected branch targets as the spine
|
|
889
|
+
reaches them. **Routes are forward-only**: every target (each `when.step`
|
|
890
|
+
and `default`) must be a step declared *later* in the workflow than the
|
|
891
|
+
routing step, and a step never routes to itself — this keeps the plan a DAG,
|
|
892
|
+
so termination is structural rather than a runtime budget's job. A
|
|
893
|
+
`default:` that names an earlier step is a lint error, not a loop. An
|
|
894
|
+
unroutable value with no `default` fails the step rather than letting every
|
|
895
|
+
branch run.
|
|
896
|
+
|
|
897
|
+
**"Go back and fix it" is a gate, not a backward route.** A failed gate
|
|
898
|
+
re-runs its *own* step with the judge's feedback, bounded by `gate.max_loops`
|
|
899
|
+
— and a declared `output:` schema the promoted artifact fails is specifically
|
|
900
|
+
the error a gate loop retries through. A workflow that used to describe "loop
|
|
901
|
+
back to an earlier step until this passes" expresses that as a bounded gate
|
|
902
|
+
on the step doing the work, not as routing.
|
|
903
|
+
|
|
904
|
+
Route decisions are journaled, so a resumed run replays the same choice.
|
|
905
|
+
Skips cascade: when a route step is itself skipped (it was the unselected
|
|
906
|
+
target of an earlier route), its own branch targets are skipped too — a
|
|
907
|
+
router that never decided selects nothing.
|
|
908
|
+
|
|
909
|
+
## Failure policy
|
|
910
|
+
|
|
911
|
+
Fail-fast is the default. Per unit (or via `defaults.on_error`):
|
|
912
|
+
|
|
913
|
+
- `on_error: fail` — the first failed unit fails the step, which fails the
|
|
914
|
+
run (`akm workflow resume` re-opens it; `akm workflow run` re-dispatches
|
|
915
|
+
only incomplete units).
|
|
916
|
+
- `on_error: continue` — failures are recorded in the step's results and the
|
|
917
|
+
completion gate decides whether the step passes.
|
|
918
|
+
- `retry: { max: <n>, on: [<failure_reason>…] }` — re-dispatches a failed
|
|
919
|
+
unit up to `max` extra times when its recorded `failure_reason` is listed
|
|
920
|
+
(e.g. `timeout`, `llm_rate_limit`, `spawn_failed`, `non_zero_exit`); every
|
|
921
|
+
attempt is journaled separately. For an
|
|
922
|
+
[exec unit](#exec-shell-units) a non-zero exit is `non_zero_exit`, a
|
|
923
|
+
wall-clock expiry is `timeout`, and a failure to start is `spawn_failed`.
|
|
924
|
+
|
|
925
|
+
A unit's `output` schema is validated on every runner; a validation miss
|
|
926
|
+
re-dispatches once with corrective feedback before the unit is recorded as
|
|
927
|
+
failed. Exec units are the one exception: a fixed argv cannot answer feedback,
|
|
928
|
+
so a schema miss fails immediately rather than re-running a side-effecting
|
|
929
|
+
command (see [The output rule](#the-output-rule)).
|
|
930
|
+
|
|
931
|
+
## Gates and verification
|
|
932
|
+
|
|
933
|
+
**Gates judge the artifact; `max_loops` bounds the retry.** Under
|
|
934
|
+
`akm workflow run`, a step with a body `### gate` rubric is gated on its
|
|
935
|
+
**artifact**, not on engine prose: the judge receives the step's artifact as
|
|
936
|
+
canonical JSON (clipped at 4000 characters) alongside the `### gate` section
|
|
937
|
+
byte-exact, so the gate evaluates real results rather than a machine summary
|
|
938
|
+
like "Executed 3 units". Each engine-driven gate evaluation is itself an LLM
|
|
939
|
+
call and is journaled as its own unit row.
|
|
940
|
+
|
|
941
|
+
`gate.max_loops: <n>` (frontmatter) turns the gate into a bounded
|
|
942
|
+
evaluator-optimizer loop: on a rejection (or a typed-artifact schema
|
|
943
|
+
mismatch) with loop budget left, the engine re-executes the step's units
|
|
944
|
+
with the gate feedback and the missing-criteria list appended as attached
|
|
945
|
+
context. The feedback changes each unit's inputs, so the re-run naturally
|
|
946
|
+
dispatches fresh units instead of replaying journaled results. When the loop
|
|
947
|
+
budget is spent, the rejection stands exactly as in the one-shot case.
|
|
948
|
+
|
|
949
|
+
**An [exec step](#exec-shell-units) is judged, but never looped.** A gate loop
|
|
950
|
+
earns its re-dispatch by handing the judge's feedback to a unit that can answer
|
|
951
|
+
it, and an exec unit cannot: its argv is frozen and never interpolated, and the
|
|
952
|
+
`AKM_*` context carries no feedback variable. A second loop would re-run the
|
|
953
|
+
byte-identical command — deploying, publishing, or migrating twice — for a
|
|
954
|
+
verdict that cannot change. So a step whose unit is `exec:` still has its
|
|
955
|
+
artifact judged and can still be failed by the verdict; a rejection simply lands
|
|
956
|
+
on the first evaluation, carrying the same missing criteria and feedback the
|
|
957
|
+
one-shot case does. `gate.max_loops` on such a step is capped at 1 rather than
|
|
958
|
+
rejected, and an engine step's declared `max_loops` is untouched. This is the
|
|
959
|
+
same reasoning that makes an exec unit's `output:` schema miss fail without a
|
|
960
|
+
corrective re-dispatch.
|
|
961
|
+
|
|
962
|
+
**Fail-closed verification.** With no non-empty `### gate` rubric, no
|
|
963
|
+
verification runs. When a rubric is present, the workflow requires
|
|
964
|
+
`workflow.judgeEngine` to name a configured LLM or agent engine before the
|
|
965
|
+
plan can be frozen — see
|
|
966
|
+
[Author's Guide: Troubleshooting](https://github.com/itlackey/akm/blob/main/docs/guides/author-workflows.md#troubleshooting)
|
|
967
|
+
if that engine isn't configured yet. That verifier invocation is frozen into
|
|
968
|
+
the run.
|
|
969
|
+
|
|
970
|
+
Only a well-formed `complete: true` verdict advances a criteria-bearing step.
|
|
971
|
+
A missing verifier, dispatch failure, or malformed result rejects the gate
|
|
972
|
+
instead of silently bypassing it. A well-formed `complete: false` verdict
|
|
973
|
+
returns its missing criteria and feedback and can trigger another bounded
|
|
974
|
+
`max_loops` attempt.
|
|
975
|
+
|
|
976
|
+
## Budget ceilings
|
|
977
|
+
|
|
978
|
+
The top-level `budget:` key declares run-lifetime ceilings: `max_units`
|
|
979
|
+
(total dispatched units) and `max_tokens` (total reported token usage). Both
|
|
980
|
+
counters are seeded from the unit journal, so they measure the **whole run
|
|
981
|
+
across resumes**, not just the current invocation. Hitting a ceiling aborts
|
|
982
|
+
the step's still-pending dispatches and fails the step with a
|
|
983
|
+
`budget exceeded (<which> ceiling)` summary — budget exhaustion is a hard
|
|
984
|
+
stop that ignores `on_error: continue`. Because the plan is frozen, raising a
|
|
985
|
+
budget means starting a new run.
|
|
986
|
+
|
|
987
|
+
## Model references
|
|
988
|
+
|
|
989
|
+
Reference semantic aliases in `model:` fields instead of exact model ids so a
|
|
990
|
+
workflow stays harness-agnostic. Recommended vocabulary (convention, not
|
|
991
|
+
hardcoded) via the config-root `modelAliases` key:
|
|
992
|
+
|
|
993
|
+
```jsonc
|
|
994
|
+
{
|
|
995
|
+
"modelAliases": {
|
|
996
|
+
"fast": { "llm": "claude-haiku-4-5", "*": "claude-haiku-4-5" },
|
|
997
|
+
"balanced": { "llm": "claude-sonnet-4-6", "*": "claude-sonnet-4-6" },
|
|
998
|
+
"deep": { "claude": "claude-fable-5", "opencode": "opencode/claude-fable-5", "*": "claude-fable-5" }
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
```
|
|
1002
|
+
|
|
1003
|
+
For an LLM engine, resolution checks its engine-name column, then `llm`, then
|
|
1004
|
+
`*`. Agent engines check their harness platform and then `*`. The built-in
|
|
1005
|
+
aliases `fable`, `opus`, `sonnet`, and `haiku` resolve per platform with no
|
|
1006
|
+
config. See the [Author's Guide](https://github.com/itlackey/akm/blob/main/docs/guides/author-workflows.md#choosing-engines-and-models)
|
|
1007
|
+
for guidance on which tier to pick per step.
|
|
1008
|
+
|
|
1009
|
+
## See also
|
|
1010
|
+
|
|
1011
|
+
- [Running Workflows](https://github.com/itlackey/akm/blob/main/docs/guides/run-workflows.md) — operating a run day to day
|
|
1012
|
+
- [Author's Guide: Writing Workflows](https://github.com/itlackey/akm/blob/main/docs/guides/author-workflows.md) — a task-oriented walkthrough
|
|
1013
|
+
- [Architecture: The Workflow Engine](https://github.com/itlackey/akm/blob/main/docs/architecture/workflow-engine.md) — persistence, dispatch, and resume internals
|
|
1014
|
+
- [CLI Reference](cli.md) — full flag documentation for `workflow` and `lint`
|