akm-cli
Version:
akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur
126 lines (116 loc) • 7.44 kB
Markdown
0011 — The engine run loop's invariants: gate spine, frozen plan, run lease, process lifecycle
# Context
`src/workflows/exec/run-workflow.ts` is `akm workflow run`'s
start/resume/execute path — the single execution surface that walks a
frozen plan and dispatches every unit. It sits one layer above
`native-executor.ts` (unit dispatch, ADR 0001) and `step-work.ts` (pure
step semantics, ADR 0002): this module is where the ENGINE LOOP's own
invariants live — never bypassing the gate spine, reading the plan from
its frozen row rather than live source, holding a run lease, running
bounded gate loops, and not leaking OS handles on exit.
# Decision
Moved verbatim from `run-workflow.ts`'s module header:
Engine-driven workflow execution — the `akm workflow run`
start/resume/execute path, and the single execution surface for a run:
akm walks the frozen plan and dispatches every unit itself.
> **Invariant (plan §*Never bypass the gate spine*):** every step advances
through `completeWorkflowStep`, never by writing step rows directly, so
the summary-validation gate and run-state derivation stay authoritative.
A gate rejection (`SummaryValidationFailure`) STOPS the engine and
surfaces the corrective feedback — a gate is a gate, even for the engine.
> **Artifact-judging gates (redesign addendum, R2):** when a step declares
completion criteria, the engine hands the gate a summary BUILT FROM the
step's promoted artifact (canonical JSON, clipped, prefixed with a
one-line unit count — `buildArtifactSummary`) instead of the machine-prose
execution summary, so the judge evaluates real results. Each
engine-driven judge call is journaled as a unit row (`node_id
"<stepId>.gate"`, `unit_id "<stepId>.gate:l<loop>"`, runner "llm",
`result_json` = the verdict) through the writer queue — it is an LLM call
like any other. Human approvals are never cached: a blocked gate stays
blocked.
> **Bounded gate loops (`gate.max_loops`, addendum R2):** a rejection on a
step with `maxLoops > 1` re-executes the step subgraph with the judge's
feedback + `missing[]` threaded into every unit prompt (`gateFeedback` on
`StepExecutionContext`) — the feedback changes each unit's input hash, so
the loop re-dispatches naturally instead of reusing the rejected rows.
After `maxLoops` rejections the engine stops with the gate feedback,
exactly like the one-shot case. A typed-artifact schema mismatch feeds
the same loop (the validation errors are the feedback; no judge ran, so
no gate unit is journaled for that attempt) — only the FINAL loop's
mismatch fails the run. `effectiveGateMaxLoops` caps a step to a SINGLE
loop whenever its subgraph's frozen target is anything other than a
`command` (agent/SDK/LLM-runner) target — which covers both an `exec`
unit (a `shell`/`script` target) and a composed `child-workflow` step,
for two DIFFERENT reasons that both land on the same cap. An exec unit's
argv is frozen and never interpolated, so it cannot read the feedback: a
second loop would only re-run the identical side effect. A child-workflow
unit has no prompt to thread feedback into either — its only frozen input
is the composing step's resolved `with:` bindings, published once to the
child run — so a second loop would re-publish the same child with
byte-identical bindings rather than asking it anything new. Only a
`command`-targeted (engine) step, whose feedback is threaded into its
prompt, can loop past one; an authored `gate.max_loops` on an engine step
is untouched.
> **Frozen plan (redesign addendum, R1):** the plan graph is read from the
run row (`plan_json`, persisted by `startWorkflowRun` under migration
006) with a `plan_hash` integrity check — the workflow asset file is
NEVER re-read for an in-flight run, so a mid-run asset edit cannot change
behavior. Durable-row resume: re-invoking a partially-executed run
re-dispatches only work that never completed.
> **Run lease (redesign addendum, R2):** exactly one engine invocation
drives a run at a time. The lease (random holder id + 90s expiry on the
run row) is acquired before any dispatch, renewed between steps, and
released in a `finally` unless a failed run retains it as forensic
state; a second `workflow run` on a live-leased run refuses up front, and
an expired lease is claimable (crash recovery). While the lease is live,
any competing spine advance is refused — the engine owns the run while
driving (enforced inside `completeWorkflowStep`).
> **Process-lifecycle contract (owner finding 4 — no leaked handles):** the
SDK dispatch path caches `opencode serve` CHILD PROCESSES in a per-env
registry for reuse across units. Each live child is an OS handle that
keeps Bun's event loop open; the registry's own teardown is wired only to
`process.once('exit')`, which never fires while a child holds the loop
open. That deadlock hangs a one-shot CLI (`akm workflow run` has no
`process.exit` on success — it relies on the loop draining). The engine
therefore DRAINS the dispatch registry (`disposeDispatchResources`) in its
run `finally`, on EVERY exit path, so the process exits cleanly the
moment the run resolves. The drain is synchronous, idempotent, and a
no-op when no SDK server started.
# Consequences
- Any future code path that advances a step must go through
`completeWorkflowStep` — a direct write to a step row bypasses both the
summary-validation gate and run-state derivation, silently breaking the
gate-is-a-gate guarantee even if the write itself succeeds.
- Because the frozen plan is read from `plan_json` (never the live asset),
editing a workflow's Markdown/YAML source after a run has started has
**no effect** on that run — this is the same "child source edits cannot
alter an in-flight parent run" property child workflows rely on (P3a),
applied to the root run itself.
- A new dispatch backend that spawns a long-lived child process (the way
the SDK harness does) must register its teardown with the SAME drain the
engine already calls in its run `finally` — relying on `process.exit` or
`process.once('exit')` alone reintroduces the exact hang class "owner
finding 4" fixed.
- The run lease is the ONLY exclusivity mechanism for driving a run;
anything that touches a run's step spine outside `completeWorkflowStep`
while a lease might be live needs to reason about this contract
explicitly rather than assuming single-writer access.
# Provenance
- Source: `src/workflows/exec/run-workflow.ts`, module header.
- Review markers: "redesign addendum" R1/R2, "owner finding 4".
- Extracted: P4 (`docs/plans/specs/p4-deletions-closeout.md` §4.2), 2026-08-27.
Added as an 11th record, past the ten-item minimum set the spec named —
`run-workflow.ts` carries "(comment only)" on Lane B's file list (§6) but
was not cited by name in §4.2's minimum-ADR-set table; this essay does not
fit cleanly inside any of the other ten without either duplicating engine-
loop-specific reasoning into a step- or unit-scoped record or dropping it,
both of which the spec's no-reasoning-deleted rule forecloses. §4.2
explicitly permits the implementer to merge related essays and record the
final set in the commit body — this is the converse case, an essay that
does not merge cleanly, so it gets its own number instead.