UNPKG

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

143 lines (129 loc) • 7.93 kB
# 0002 — Unit reuse and the input-hash scope ## Context A resumed workflow run must recompute the same units the original run dispatched, byte-for-byte, so that journaled rows can stand in for a live dispatch instead of re-executing. That guarantee depends on two things staying strictly separated: which computations are PURE functions of `(frozen plan, params, journaled results)`, and exactly which fields feed the hash that decides whether a journaled unit is still valid to reuse. `src/workflows/exec/step-work.ts` is the one module both the fresh-execution path (`native-executor.ts`) and the resume/replay path share for these decisions, specifically so the two paths cannot drift apart. ## Decision ### The module's purity contract Moved verbatim from `step-work.ts`'s module header: > Shared step semantics — the ONE implementation of a step's orchestration > decisions, consumed by the engine loop (`run-workflow.ts` + > `native-executor.ts`) on both the fresh-execution and the resume/replay > path. The cardinal rule here is *no duplicated semantics*: work-list > computation, prompt assembly, reducer/artifact promotion, output-schema > validation, artifact-judged gate summaries, gate-feedback recovery, and > route evaluation live here so a first run and a resumed run of the same > frozen plan produce byte-identical unit graphs. > > **What is PURE here.** `computeStepWorkList` — given the frozen step plan > and a `WorkListInput` (params, prior step outputs, gate-loop number + its > recovered feedback) — is a pure function: same inputs ⇒ same unit ids, > input hashes, and fully-resolved prompts. It takes NO clock, NO IO, and NO > journal (journal-derived state, i.e. the recovered gate feedback, is > passed in). This is the load-bearing guarantee that a resumed run > recomputes exactly the units the original run dispatched, so journaled > rows can be reused instead of re-executed. So are the reducer/artifact > helpers (`buildEvidence`, `projectStepOutput`, `validateStepArtifact`, > `buildArtifactSummary`), the gate-feedback recovery (`recoverGateFeedback` > / `activeGateLoop`), and route evaluation (`evaluateRoute` and its > bookkeeping). > > **What does IO here.** The gate-evaluation journaling > (`journalGateEvaluationStart` / `journalGateEvaluationFinish`) writes > `workflow_run_units` rows through the serialized writer queue — an > engine-driven judge call is an LLM call and is journaled like a unit. It > lives here (not in the engine loop) so every caller journals gate > evaluations through the identical writer. > > This module NEVER dispatches a unit and NEVER writes step rows: dispatch > is the executor's job (`native-executor.ts`), advancing the gated spine is > the engine loop's job (`run-workflow.ts` via `completeWorkflowStep`). ### The input-hash preimage Moved verbatim from `computeUnitInputHash`'s doc comment: > The canonical dispatch-input envelope (reviewer finding #1). Every field > here is a PLAN-FROZEN input that changes what the backend is actually > asked to do, so a completed unit is reused ONLY when all of them match; a > change to any of them re-dispatches. `canonicalJsonString` sorts keys > recursively, so the preimage is order-independent, and this is the ONE > place a unit's inputHash is computed (every caller goes through > `computeStepWorkList`) — a hash that is byte-identical across a fresh run > and a resume is structural, not coincidental. > > Unit identity (workflow-format-unification, spec §2.3/§4) hashes the > FROZEN TEMPLATE BYTES (`template.instructions`, byte-exact, never an > instantiated/interpolated string) + the canonical item JSON + the > declared-input artifacts + the params snapshot — instead of a > resolved/spliced prompt string, since there is no more splicing. > > Included beyond the template/runner/model/schema baseline: resolved > timeoutMs, named environment bindings, and isolation — each reaches > dispatch and a changed one yields a materially different call. `env` > carries NAMES ONLY, never resolved values: hashing a resolved secret would > leak it into a durable hash oracle and would spuriously re-dispatch on > every secret rotation. `retry`/`onError` are DELIBERATELY excluded — they > govern failed-unit re-dispatch and step-level failure reduction, not a > COMPLETED unit's inputs/output, so a completed row stays valid across > policy changes. > > `gateFeedback` IS included (conditionally, so a no-feedback unit's > preimage is byte-identical to before): it is appended to the prompt by > `buildUnitPrompt`, so a gate loop's retry is materially a different ask > than the rejected attempt. Replay-safe: feedback is re-derived from the > journaled gate decision, so a resumed retry re-hashes identically. > > `taskInputs` IS included, on the same conditional terms (R-R15, > `hashVersion` 7): a reference binding's RESOLVED value reaches the unit's > prompt / `AKM_TASK_INPUTS` / `childParams`, so a changed upstream value is > a materially different ask even though the binding's authored shape inside > `frozenTarget` is unchanged. Hashing it makes a resume whose journaled > upstream output was altered fail loudly as replay divergence instead of > silently reusing the stale row. The key is absent for a unit whose target > carries no `inputBindings`, so a binding-free unit's preimage keeps the > shape it had — only the version fields moved. > > Command targets carry their frozen argv/script/cwd/timeout identity in the > same target slot used by agent, SDK, and direct-LLM work. The complete > current target is hashed once, so a completed unit is reused only for the > exact durable request that originally produced it. > > Ambient config is deliberately excluded because it is not consulted > during execution. The frozen target and named environment bindings are > the runtime identity boundary; only the values behind those names remain > live. ## Consequences - Any new field added to a unit's dispatch plan must be triaged against this scope explicitly: does it change what the backend is asked to do (include it), is it a policy about failure handling rather than a completed unit's inputs/output (exclude it, like `retry`/`onError`), or is it a resolved secret value (exclude the value, include only the name)? Getting this wrong either lets a materially different request reuse a stale journaled row, or spuriously invalidates every journaled row on an unrelated change. - `hashVersion` (currently **7**) is the version prefix mixed into this hash; changing what this preimage covers requires bumping it. P4's R-R15 carried advisory — a reference binding's RESOLVED value sitting outside this hash — is exactly such a scope change, and it is now **resolved**: `taskInputs` (the resolved values of a composed task's reference-kind `inputBindings`) is a conditional preimage field, present only for a unit whose frozen target carries `inputBindings`, and closing it came with the bump this rule mandates. The 0.9.2 line's released tags (`v0.9.2-alpha.1`…`alpha.4`) all carry 5, so the durable, user-visible step is 5 → 7. The "left unfixed rather than folded in as a quiet scope change" note in `docs/plans/specs/p4-deletions-closeout.md` §8 R-R15 (and p3b §3.6's "why `hashVersion` stays 6") records the state BEFORE that bump and is superseded by this entry. - Because the whole module is import-cycle-free with respect to dispatch (it never dispatches, never writes step rows), the fresh-execution and resume/replay paths cannot silently diverge by one of them bypassing this module's decisions. ## Provenance - Source: `src/workflows/exec/step-work.ts`, module header (the Purity Contract) and `computeUnitInputHash`'s doc comment (reviewer finding #1). - Spec: workflow-format-unification, spec §2.3/§4. - Extracted: P4 (`docs/plans/specs/p4-deletions-closeout.md` §4.2), 2026-08-27.