akm docs

Architecture: The Workflow Engine

A workflow compiles to a frozen plan, persists run and unit state, dispatches its work, verifies declared gates, and resumes without replaying completed units. This page is the architecture-level reference for that engine — how a run's plan is frozen and stored, how dispatch and resume actually work, how one engine owns a run, and how isolated file-mutating units execute. For the exact frontmatter/body syntax that produces the plan, see Workflow Schema. For the day-to-day commands that drive a run, see Running Workflows.

akm workflow run is Stable, ungated, and the only execution surface. It is the canonical start/resume/execute command; there is no separate external-driver protocol.

Frozen plans

The first akm workflow run <ref> compiles either peer source format (Markdown or GitHub-Actions-style YAML) straight to the one plan type and stores it as durable plan irVersion 6 on the run row (plan_json + plan_hash); a new start never emits an older version. plan_ir_version and plan_hash are recorded provenance, not gates: a stored plan is decoded as it is — irVersion 4 and 5 plans included — and one frozen by an older or newer akm that still decodes simply runs (with one warning). A newer akm's plan that does not decode is refused with a message naming "Upgrade akm".

The durable plan records sourceHash, the sha256 of the workflow source file at freeze. It is used only for the resume warning below, never to refuse a run.

Dispatch-significant material is immutable. The resolved request is frozen, the resolved target is frozen, and runner selection is frozen. Working directory (cwd) identity is frozen. Git identity and its commit OID are frozen. The executable a unit runs is resolved at dispatch, so upgrading a CLI (say, claude) never strands a run. Exact models, inference, tools and authorization, execution limits, parameter snapshots, command/script bytes, and verifier selection therefore cannot drift under an in-flight run.

Workflow environment asset values are the narrow live-value exception within authored workflow inputs. The environment owner key set and secret-token topology are frozen. Literal values remain frozen. Pass-through bindings materialize at dispatch from the current process, and env/secret references materialize their current values only after owner/topology checks. Durable plans never store secret values or enable whole-process inheritEnv.

A run executes the plan compiled at creation; edits to source need a new run. Resuming a run re-reads its workflow source only to warn, once, when the source changed since the freeze — the run continues on the frozen plan. Orchestration decisions are pure functions of the frozen plan, run params, and journaled results.

Child workflows

A step that composes another workflow — directly (uses: workflows/<ref>) or through a task whose own target is a workflow (uses: tasks/<ref>) — freezes to a child-workflow frozen target. Unlike the command/shell/ script targets, a child-workflow target carries the child's complete frozen plan, embedded: compiling the parent recursively compiles, validates, and freezes each child in full — depth-first, all of it — before the parent run is ever published. Nothing about a child is re-read at dispatch time; the embedded plan is authoritative. See Workflow Schema: Child workflows for the authoring-side view, including the three composition bounds (depth, cycle, aggregate embedded bytes) enforced at freeze.

Decoding. An embedded child plan is decoded by the same structural decoder as its parent, recursively. Its planHash and the target's contentHash are recorded provenance, carried as data and never re-verified against the bytes.

The dispatch seam. Dispatching a step whose target is kind: "child-workflow" is the one branch point in dispatchJournaledAttempt (src/workflows/exec/native-executor.ts) that does not call the ordinary unit dispatcher: it calls driveChildWorkflowUnit instead, with everything the composing unit already resolved — the frozen target, the parent's dispatch context, the child's resolved params, and the unit input hash. The branch sits inside the existing retry loop, after the parent unit's attempt row is already claimed running (so a crash between claiming that row and publishing the child leaves a recoverable running parent unit and no orphaned child) and before the ordinary post-dispatch journaling (redaction, attempt finishing, the worktree epilogue), which runs unchanged either way — a child-workflow unit is journaled exactly like any other.

The drive contract. driveChildWorkflowUnit validates and resolves the child's params through the same input-binding resolution every frozen target uses, computes the child's identity key from {parentRunId, parentUnitId, unitInputHash}, and publishes the child run — idempotently: the same three inputs always resolve to the same child row, so a retried or resumed composing step re-drives the run it already started rather than starting a new one, and only a change to the composing step's own inputs (params, an upstream step output it reads, or gate feedback from a rejected verification loop) produces a different child. A child found already blocked or failed is not re-driven — driving happens once per call, and a terminal-but-unhealthy child surfaces through the status mapping below instead. The child's final status maps onto the composing unit, and from there onto the parent step and run, exactly as described in Workflow Schema: Child execution.

Why runWorkflowSteps is reused, not a second executor. The child drive calls the exact same exported entry point akm workflow run calls — runWorkflowSteps(options), pointed at the child's run id as target — with no special-casing for "this target is a child": resolving a run id, decoding the stored plan, taking that run's own lock, and walking its spine through completeWorkflowStep are identical to the top-level path. That reuse is what makes child run locks arbitrate two-parent contention with no new mechanism, and what makes a resumed parent skip a completed child step with zero dispatcher calls. The child drive passes a no-op disposeDispatchResources (the parent's finally remains the sole owner of the process-lifecycle drain for the whole process) and no maxSteps or maxRetries (the parent's own budgets count only its own spine steps). The lower-level driveRun that runWorkflowSteps wraps stays module-private — reaching for it directly, instead of the public entry point, would be exactly the second executor this design avoids.

Why hashVersion is 7, and why child composition did not move it. The version prefix mixed into a unit's input hash is 7 (src/workflows/exec/step-work.ts); 0.9.2 bumped it — a single, durable 5 → 7 step, since every released tag on the line carries 5 — when taskInputs, the resolved values of a composed task's reference-kind inputBindings, joined the preimage (see ADR 0002). Child composition itself contributed nothing to that bump: the parent unit's input-hash preimage already covers everything the child drive depends on — the frozen target carries the child's ref, plan hash, content hash, binding mode, and its entire embedded plan, so any change anywhere in the child changes the parent unit's own input hash transitively. The child run id (minted at dispatch) and its exported result are unit outputs, not inputs, so neither belongs in the preimage — adding either would be a hashing-boundary violation, not a fix.

Run outputs

A workflow may declare outputs: — a run-level export projected from its steps' own artifacts once every step has finished (see Workflow Schema: Workflow outputs for the authoring grammar). Resolution happens inside the same write transaction that completes the run's final step, immediately after the run's terminal status is derived: each declared output is resolved from the persisted step evidence and, if declared, validated against its schema. If resolution or validation fails for any entry, the whole completion rolls back — the run stays active, its final step stays pending, and no completion event is emitted — rather than leaving a run completed with missing or invalid exports. A run with no outputs: declaration exports {runId, status} instead, synthesized on read and never persisted. This exported result is exactly what a parent composing this workflow as a child promotes as the composing unit's own result on completed — the same value, read through the same function, whether the caller is akm workflow status or a parent unit's dispatch.

Resume skips completed units

Resume never re-reads config or the asset index, and reads the workflow source only for the drift warning above. It consumes the frozen plan plus the journaled attempts and results. A stored plan that this akm cannot decode is not an error to fix in place: akm workflow run marks such a run abandoned (status failed) and says to start a new run with akm workflow run <ref>; akm workflow status, list, and abandon never read the plan at all. A plan frozen by a newer akm that this one cannot decode is left untouched, and the message names upgrading akm.

Every dispatched unit is journaled under a content-derived identity — the node id plus a hash of its item for a map unit (<node>:solo otherwise) — together with an input hash over everything the unit was asked to do (its frozen instructions and target, declared inputs: artifacts, the params snapshot, gate feedback). On re-run, a unit whose journal row is completed is reused, never re-dispatched; a failed or missing unit is dispatched live. The recorded input hash is informational: a completed unit stays completed even when the params row or an upstream artifact changed since — a fresh run is what recomputes it.

Durable attempts and at-least-once dispatch

Workflow dispatch is at-least-once. Every unit has a stable content-derived unit id and an append-only sequence of attempts. A crash reclaim reuses the same stable dispatchId for the interrupted attempt. An explicit retry gets a new dispatchId under one stable unit id and increments the attempt number.

One driver per run (below) keeps a second engine off a run, and a finished attempt accepts no second terminal write, but neither can prove whether an external process completed immediately before a crash. An ambiguous crash outcome may re-run and can produce a duplicate side effect. Workflow actions should be idempotent or use the stable dispatch identity as their own deduplication key.

One engine drives a run (the run lock)

akm workflow run takes the run's lock file before reading the plan or dispatching anything: one O_EXCL file per run id under the data directory (workflow-run-locks/<run id>.lock, next to state.db), recording the holder's pid, released when the invocation exits. A second workflow run against a run another live process holds refuses up front with RUN_LEASE_HELD (exit 75), naming the holder pid. A lock whose holder pid is dead is reclaimed at once, so a crashed engine never wedges a run; nothing expires by age and nothing renews. workflow status and list never take the lock.

Worktree isolation

A file-mutating unit can declare isolation: worktree in its unit: bag (agent and sdk runners) — see Workflow Schema: Frontmatter keys. Each unit attempt gets a fresh detached git worktree of the run's base repository under a run-scoped temp directory; the worktree path is journaled on the unit row and passed to the harness as its working directory, so parallel fan-out units can never trample each other's working tree. After the unit finishes, a clean worktree (git status --porcelain empty) is removed automatically; a dirty one is retained and its path logged, so uncollected work is never destroyed. Declaring worktree isolation in a non-git directory fails the step cleanly before anything dispatches.

Warning — outputs matched by .gitignore are treated as disposable. A worktree-isolated unit's output survives only if it lands on a collectible path: a tracked file, or an untracked file your repository does not .gitignore. Anything a unit writes to a .gitignored path — build outputs, caches, logs, dependency directories like node_modules/dist, or a scratch file under an ignored directory — is discarded when its clean worktree is auto-removed. If a unit produces an artifact that must survive, write it to a non-ignored path, or report it as a result (a structured output / free-text result), before the unit returns.

The clean probe deliberately does not pass --ignored, so "uncollected work" means tracked or untracked-unignored changes only. A worktree whose only residue is files your repository's own .gitignore matches is treated as clean and removed: those files are disposable by the repo's own declaration, and retaining a worktree after every package install or build would blow up disk under the temp root.

Concurrency limits

Native fan-out (akm workflow run) uses the minimum of four limits: the map's declared concurrency, the run's frozen workflow.maxConcurrency, the selected frozen LLM engine's concurrency (including an SDK engine's fallback LLM), and the current host's CPU-derived safety limit. Reapplying host safety keeps a run safe when it resumes on a smaller machine.

$ akm config set workflow.maxConcurrency 8   # raise the frozen workflow limit
$ akm config get workflow.maxConcurrency
8

A workflow that fans out is authorizing N parallel agents, not one — see Running Workflows: workflow sources are executed code for what that means for trust.

Run scope and persistence

Run state (plan_json, plan_hash, step statuses, and the unit journal) persists in the project's state.db; the per-run lock file sits beside it. Run state is scoped to the current project directory — the nearest .akm/config.json, git root, bundle root, or current directory — so the same workflow can run independently in separate projects, and akm workflow list/status without an explicit run id only ever see runs in that scope.

See also