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.
Files changed (140) hide show
  1. package/CHANGELOG.md +724 -0
  2. package/README.md +28 -63
  3. package/STABILITY.md +4 -2
  4. package/dist/cli/parse-args.js +7 -1
  5. package/dist/commands/agent/contribute-cli.js +1 -1
  6. package/dist/commands/env/child-env.js +14 -0
  7. package/dist/commands/feedback-cli.js +7 -1
  8. package/dist/commands/health/llm-usage.js +2 -1
  9. package/dist/commands/health/surfaces.js +4 -77
  10. package/dist/commands/health.js +65 -11
  11. package/dist/commands/improve/distill/quality-gate.js +6 -1
  12. package/dist/commands/improve/eligibility.js +7 -1
  13. package/dist/commands/improve/eval-cases.js +2 -0
  14. package/dist/commands/improve/improve.js +126 -10
  15. package/dist/commands/improve/locks.js +7 -0
  16. package/dist/commands/improve/memory/memory-improve.js +9 -0
  17. package/dist/commands/improve/run-context.js +5 -0
  18. package/dist/commands/improve/session-asset.js +4 -0
  19. package/dist/commands/lint/base-linter.js +31 -7
  20. package/dist/commands/lint/index.js +205 -51
  21. package/dist/commands/lint/types.js +22 -1
  22. package/dist/commands/proposal/repository.js +17 -1
  23. package/dist/commands/sources/add-cli.js +8 -2
  24. package/dist/commands/sources/info.js +12 -2
  25. package/dist/commands/sources/installed-stashes.js +6 -1
  26. package/dist/commands/sources/migration-help.js +12 -3
  27. package/dist/commands/sources/self-update.js +9 -1
  28. package/dist/commands/tasks/tasks.js +8 -2
  29. package/dist/commands/workflow-cli.js +17 -11
  30. package/dist/core/abort-deadline.js +28 -0
  31. package/dist/core/adapter/adapters/agent-skills-adapter.js +83 -5
  32. package/dist/core/adapter/adapters/akm-adapter.js +13 -10
  33. package/dist/core/adapter/adapters/akm-lint.js +78 -22
  34. package/dist/core/adapter/adapters/akm-task-adapter.js +43 -20
  35. package/dist/core/adapter/adapters/dotenv-adapter.js +21 -0
  36. package/dist/core/adapter/adapters/tool-dir-shared.js +5 -3
  37. package/dist/core/asset/frontmatter.js +10 -1
  38. package/dist/core/common.js +147 -9
  39. package/dist/core/concurrent.js +32 -0
  40. package/dist/core/config/config-io.js +5 -45
  41. package/dist/core/config/schema/engines.js +14 -3
  42. package/dist/core/config/schema/workflow.js +11 -0
  43. package/dist/core/errors.js +25 -0
  44. package/dist/core/events.js +30 -24
  45. package/dist/core/extra-params.js +11 -0
  46. package/dist/core/file-lock.js +7 -1
  47. package/dist/core/fs-txn.js +15 -2
  48. package/dist/core/improve-result.js +5 -0
  49. package/dist/core/json-schema.js +344 -9
  50. package/dist/core/loopback.js +89 -0
  51. package/dist/core/migration-operation.js +17 -2
  52. package/dist/core/path-access.js +107 -0
  53. package/dist/core/paths.js +16 -2
  54. package/dist/core/redaction.js +86 -18
  55. package/dist/core/spawn-env.js +234 -0
  56. package/dist/core/state-db-scope.js +134 -0
  57. package/dist/core/state-db.js +1 -0
  58. package/dist/core/subprocess.js +181 -37
  59. package/dist/core/write-provenance.js +85 -0
  60. package/dist/core/write-source.js +33 -2
  61. package/dist/indexer/db/graph-db.js +17 -6
  62. package/dist/indexer/ensure-index.js +10 -3
  63. package/dist/indexer/index-written-assets.js +17 -2
  64. package/dist/indexer/indexer.js +86 -21
  65. package/dist/indexer/passes/memory-inference.js +4 -0
  66. package/dist/indexer/search/db-search.js +25 -17
  67. package/dist/indexer/walk/walker.js +6 -1
  68. package/dist/integrations/agent/detect.js +13 -1
  69. package/dist/integrations/agent/engine-resolution.js +24 -11
  70. package/dist/integrations/agent/model-aliases.js +1 -1
  71. package/dist/integrations/agent/profiles.js +9 -1
  72. package/dist/integrations/agent/spawn.js +15 -87
  73. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +21 -0
  74. package/dist/integrations/lockfile.js +55 -2
  75. package/dist/llm/client.js +14 -19
  76. package/dist/llm/embedder.js +23 -3
  77. package/dist/llm/embedders/remote.js +27 -2
  78. package/dist/output/html-render.js +40 -1
  79. package/dist/output/text/lint-format.js +17 -4
  80. package/dist/runtime.js +23 -1
  81. package/dist/scripts/akm-migrate-node.js +1714 -836
  82. package/dist/scripts/akm-migrate.js +1682 -804
  83. package/dist/setup/setup.js +22 -7
  84. package/dist/sources/providers/git-install.js +25 -2
  85. package/dist/sources/providers/git-stash.js +19 -0
  86. package/dist/sources/providers/git.js +1 -1
  87. package/dist/sources/snapshot-fetchers/content-extract.js +63 -1
  88. package/dist/sources/snapshot-fetchers/website-ingest.js +126 -20
  89. package/dist/storage/database.js +71 -7
  90. package/dist/storage/engines/sqlite-migrations.js +61 -2
  91. package/dist/storage/managed-db.js +19 -0
  92. package/dist/storage/repositories/index-connection.js +39 -4
  93. package/dist/storage/repositories/index-entries-repository.js +6 -1
  94. package/dist/storage/repositories/index-meta-repository.js +11 -0
  95. package/dist/storage/repositories/index-schema.js +17 -2
  96. package/dist/storage/repositories/index-vec-repository.js +43 -5
  97. package/dist/storage/repositories/workflow-runs-repository.js +66 -13
  98. package/dist/storage/sqlite-pragmas.js +12 -1
  99. package/dist/tasks/log-redaction.js +156 -0
  100. package/dist/tasks/parser.js +82 -5
  101. package/dist/tasks/runner.js +222 -17
  102. package/dist/tasks/scheduler-invocation.js +19 -0
  103. package/dist/tasks/schema.js +86 -1
  104. package/dist/text-import-hook.mjs +1 -1
  105. package/dist/workflows/concurrency-policy.js +95 -1
  106. package/dist/workflows/exec/dispatch-redaction.js +114 -0
  107. package/dist/workflows/exec/exec-unit.js +542 -0
  108. package/dist/workflows/exec/frozen-judge.js +114 -42
  109. package/dist/workflows/exec/native-executor.js +465 -238
  110. package/dist/workflows/exec/param-secrets.js +4 -3
  111. package/dist/workflows/exec/run-workflow.js +424 -219
  112. package/dist/workflows/exec/step-work.js +506 -167
  113. package/dist/workflows/exec/unit-dispatch.js +31 -1
  114. package/dist/workflows/exec/unit-writer.js +53 -13
  115. package/dist/workflows/exec/worktree.js +454 -41
  116. package/dist/workflows/ir/compile.js +26 -2
  117. package/dist/workflows/ir/freeze.js +82 -15
  118. package/dist/workflows/ir/schema.js +105 -20
  119. package/dist/workflows/parser.js +242 -19
  120. package/dist/workflows/program/schema.js +24 -0
  121. package/dist/workflows/renderer.js +32 -4
  122. package/dist/workflows/resource-limits.js +182 -0
  123. package/dist/workflows/runtime/runs.js +146 -6
  124. package/dist/workflows/validate-summary.js +17 -2
  125. package/docs/README.md +74 -32
  126. package/docs/migration/release-notes/0.9.0.md +2 -1
  127. package/docs/migration/v0.7-to-v0.8.md +2 -1
  128. package/docs/migration/v0.8-to-v0.9.md +3 -1
  129. package/docs/reference/README.md +11 -4
  130. package/docs/reference/bundle-types.md +19 -0
  131. package/docs/reference/cli.md +105 -16
  132. package/docs/reference/configuration.md +15 -2
  133. package/docs/reference/data-and-telemetry.md +30 -10
  134. package/docs/reference/supported-formats.md +50 -0
  135. package/docs/reference/workflow-schema.md +1014 -0
  136. package/docs/reference/workflows.md +37 -633
  137. package/package.json +13 -6
  138. package/schemas/akm-config.json +18 -5
  139. package/schemas/akm-task.json +27 -5
  140. package/schemas/akm-workflow.json +92 -13
@@ -8,28 +8,31 @@
8
8
  * persistence through the serialized writer queue, and `workflow_unit_*`
9
9
  * events for observability.
10
10
  *
11
- * Data flow (redesign addendum, R1): workflow-authored templates go through
12
- * the deterministic `${{ }}` expression language (`program/expressions.ts`)
13
- * — but ONLY for nodes the frontend marked `templating: "expressions"` (YAML
14
- * program units). Classic linear markdown instructions are `"verbatim"`:
15
- * opaque data handed to the agent byte-exact (the stable CLI contract — a
16
- * literal `${{` there is content, never grammar). Expression templates are
17
- * parsed ONCE per step and resolved per unit against `{ params, stepOutputs,
18
- * item, item_index }`; `map.over` resolves as a single whole-value reference.
19
- * Substituted content is data, never re-scanned — the P1 `{{item}}` re-scan
20
- * injection class is structurally impossible. There is NO ambient key search:
21
- * a `steps.<id>.output.<path>` reference addresses INTO that step's recorded
11
+ * Data flow (workflow-format-unification, spec §2.3): there is NO
12
+ * interpolation language. A unit's instructions are the step's body prose
13
+ * BYTE-EXACT prose is never scanned for reference syntax, so a literal `${{`
14
+ * in a body is content, not grammar. Data reaches the unit as ATTACHED
15
+ * STRUCTURED CONTEXT rather than string splices: `buildUnitPrompt`
16
+ * (`exec/step-work.ts`) wraps the verbatim instructions with JSON blocks for
17
+ * the run params, a map unit's item + index, and the artifacts named by the
18
+ * step's `inputs:`. Because nothing is ever substituted INTO the prose, the P1
19
+ * `{{item}}` re-scan injection class is structurally impossible.
20
+ *
21
+ * References survive only in the three whole-value FRONTMATTER positions the
22
+ * closed two-root grammar occupies (`program/expressions.ts`): `map.over`,
23
+ * `route.input`, and each `inputs[]` entry. They resolve ONCE per step against
24
+ * `{ params, stepOutputs }`. There is NO ambient key search: a
25
+ * `steps.<id>.output.<path>` reference addresses INTO that step's recorded
22
26
  * output explicitly.
23
27
  *
24
- * Step outputs (`${{ steps.<id>.output… }}`): every engine-executed step
25
- * journals a promoted ARTIFACT under `evidence.output` — the solo unit's
26
- * result/text, the collect reducer's per-item array, or the vote reducer's
27
- * winner — and that artifact is what the expression scope exposes
28
- * ({@link projectStepOutput}). The documented addressing
29
- * (`steps.discover.output.files`) therefore resolves against real step
30
- * results, never the raw evidence envelope (peer review R1). Steps completed
31
- * manually (no `output` key in their evidence) expose their recorded evidence
32
- * object as-is.
28
+ * Step outputs (`steps.<id>.output…`): every engine-executed step journals a
29
+ * promoted ARTIFACT under `evidence.output` — the solo unit's result/text, the
30
+ * collect reducer's per-item array, or the vote reducer's winner — and that
31
+ * artifact is what the reference scope exposes ({@link projectStepOutput}).
32
+ * The documented addressing (`steps.discover.output.files`) therefore resolves
33
+ * against real step results, never the raw evidence envelope (peer review R1).
34
+ * Steps completed manually (no `output` key in their evidence) expose their
35
+ * recorded evidence object as-is.
33
36
  *
34
37
  * Empty free-text outputs (peer review): a SUCCESSFUL schemaless unit that
35
38
  * returns the empty string is normalized to "no output" — {@link dispatchUnit}
@@ -41,10 +44,10 @@
41
44
  * "empty == absent", not special-cased anywhere:
42
45
  * - a SOLO empty step promotes `output = null` (the unit's absent text ??
43
46
  * null); a `collect` fan-out promotes `null` in that item's slot.
44
- * - A downstream `${{ steps.x.output }}` of an empty solo step therefore
45
- * resolves against `null` and fails LOUDLY at expression resolution
46
- * (`… resolved to null`) — a deterministic `expression_error` on BOTH
47
- * surfaces, never a silent empty string.
47
+ * - A downstream `steps.x.output` reference to an empty solo step therefore
48
+ * resolves against `null` and fails LOUDLY at reference resolution
49
+ * (`… resolved to null`) — a deterministic whole-step failure, never a
50
+ * silent empty string.
48
51
  * - A SCHEMA unit is unaffected by this normalization: an empty response is
49
52
  * not parseable JSON, so `runStructured` fails it (`parse_error`) — an
50
53
  * empty output can never satisfy a declared schema as a silent `null`.
@@ -126,20 +129,26 @@
126
129
  * - This module NEVER writes step rows: advancing the gated spine is the
127
130
  * engine loop's job (`run-workflow.ts`) via `completeWorkflowStep`.
128
131
  */
129
- import { deepMergeConfig } from "../../core/config/deep-merge.js";
130
- import { ConfigError } from "../../core/errors.js";
131
132
  import { appendEvent } from "../../core/events.js";
132
133
  import { validateJsonSchemaSubset } from "../../core/json-schema.js";
133
- import { collectSensitiveValues, isEnvPassthroughValueSafeToExpose, redactSensitiveValue } from "../../core/redaction.js";
134
134
  import { runStructured } from "../../core/structured.js";
135
135
  import { warn } from "../../core/warn.js";
136
136
  import { insertEventStrict } from "../../storage/repositories/events-repository.js";
137
- import { withWorkflowRunsRepo } from "../../storage/repositories/workflow-runs-repository.js";
137
+ import { withWorkflowRunsConnection, withWorkflowRunsRepo, } from "../../storage/repositories/workflow-runs-repository.js";
138
+ import { WORKFLOW_UNIT_DIAGNOSTIC_CLIP } from "../resource-limits.js";
139
+ // The ONE dispatch redaction contract, shared with the gate-judge path
140
+ // (exec/frozen-judge.ts). Consumers import the leaf directly — this module is
141
+ // not a second front door onto the seam.
142
+ import { collectWorkflowDispatchSensitiveValues, redactUnitOutcome } from "./dispatch-redaction.js";
143
+ // The exec (shell) unit runner — a leaf that owns argv spawning, containment,
144
+ // and the process-outcome → failure-reason mapping.
145
+ import { runExecUnit } from "./exec-unit.js";
138
146
  import { LIFETIME_UNIT_CAP, scheduleUnits, UnitCapExceededError } from "./scheduler.js";
139
147
  // Shared step semantics — the ONE implementation consumed by the engine
140
148
  // (this module + run-workflow.ts) on both the fresh-execution and the resume
141
149
  // path. This module dispatches; step-work.ts owns the pure decisions.
142
- import { computeStepWorkList, reduceEmptyStep, reduceStepOutcomes, stepOutputsFromEvidence, unitOutcomeFromRow, } from "./step-work.js";
150
+ import { clip, computeStepWorkList, reduceEmptyStep, reduceStepOutcomes, stepOutputsFromEvidence, unitOutcomeFromRow, } from "./step-work.js";
151
+ import { materializeFrozenLlm, } from "./unit-dispatch.js";
143
152
  import { enqueueUnitWrite } from "./unit-writer.js";
144
153
  import { assertGitWorkTree, cleanupUnitWorktree, createUnitWorktree } from "./worktree.js";
145
154
  /**
@@ -206,40 +215,107 @@ class DispatchBudget {
206
215
  this.onExceeded?.();
207
216
  }
208
217
  }
209
- function classifyUnitReuse(workUnit, existingUnits, gateLoop) {
210
- if (!workUnit.resolved.ok)
211
- return { kind: "dispatch" };
212
- const inputHash = workUnit.resolved.inputHash;
213
- const maxAttempts = 1 + Math.max(0, workUnit.retry?.max ?? 0);
214
- const base = workUnit.journalBaseId;
215
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
216
- const attemptId = attempt === 0 ? base : `${base}~r${attempt}`;
217
- const prior = existingUnits?.get(attemptId);
218
- if (!prior || prior.status !== "completed")
219
- continue;
218
+ function classifyUnitReuse(workUnit, completedRows, gateLoop) {
219
+ const inputHash = workUnit.inputHash;
220
+ // Scan EVERY journaled attempt row of this unit (`<base>` / `<base>~r<N>`
221
+ // for ANY N), not just the attempts the CURRENT retry policy allows:
222
+ // retry/onError are deliberately excluded from the input hash (step-work.ts)
223
+ // precisely so completed rows stay valid across policy changes — a run
224
+ // re-invoked with a lowered retry.max must still find the `~rN` row a prior
225
+ // invocation completed beyond the new max, never re-dispatch finished work.
226
+ // A completed hash-matching row anywhere wins (reuse); a completed loop-1
227
+ // row with a DIFFERENT hash — and no matching sibling — is replay divergence.
228
+ let divergedAttemptId;
229
+ for (const prior of completedRows?.get(workUnit.journalBaseId) ?? []) {
220
230
  if (prior.input_hash === inputHash)
221
231
  return { kind: "reuse", row: prior };
222
232
  // Gate-loop rows are NOT replay-deterministic (the prompt embeds a fresh
223
233
  // judge output): a stale loop-N row with a different hash re-dispatches
224
234
  // live. Divergence only guards loop-1 rows, whose inputs ARE a pure
225
235
  // function of (frozen plan, params, journaled results).
226
- if (gateLoop > 1)
227
- return { kind: "dispatch" };
228
- return { kind: "diverge", attemptId };
236
+ if (gateLoop <= 1)
237
+ divergedAttemptId ??= prior.unit_id;
229
238
  }
239
+ if (divergedAttemptId !== undefined)
240
+ return { kind: "diverge", attemptId: divergedAttemptId };
230
241
  return { kind: "dispatch" };
231
242
  }
243
+ function indexCompletedRows(rows) {
244
+ const index = new Map();
245
+ for (const row of rows) {
246
+ if (row.status !== "completed")
247
+ continue;
248
+ const base = row.unit_id.replace(/~r\d+$/, "");
249
+ const forBase = index.get(base);
250
+ if (forBase)
251
+ forBase.push(row);
252
+ else
253
+ index.set(base, [row]);
254
+ }
255
+ return index;
256
+ }
232
257
  /**
233
- * Does the step have at least one unit that will ACTUALLY dispatch? Env
234
- * resolution and worktree preflight are dispatch prerequisites, so a step whose
235
- * units are all reused / unresolved / diverged must skip them (reviewer finding
236
- * #2). Mirrors runUnit's reuse decision exactly (shared {@link classifyUnitReuse}).
258
+ * Execute one step plan natively. Never throws for unit-level failures.
259
+ *
260
+ * The whole step runs inside ONE state.db connection scope
261
+ * ({@link withWorkflowRunsConnection}): the journal read, every unit's
262
+ * insert/finish transaction, and every `workflow_unit_*` event share a single
263
+ * handle for the step's lifetime instead of opening and closing state.db twice
264
+ * per unit plus twice per unit's events. The scope closes the handle when the
265
+ * step settles (success, failure, or throw), so there is no handle to leak and
266
+ * no lifetime that outlives the step. Everything inside keeps its existing
267
+ * transaction boundaries — see `core/state-db-scope.ts` for why sharing a
268
+ * handle across concurrently-scheduled units is safe here.
237
269
  */
238
- function stepWillDispatch(workUnits, existingUnits, gateLoop) {
239
- return workUnits.some((u) => u.resolved.ok && classifyUnitReuse(u, existingUnits, gateLoop).kind === "dispatch");
270
+ export function executeStepPlan(plan, ctx) {
271
+ return withWorkflowRunsConnection(() => executeStepPlanInConnection(plan, ctx));
240
272
  }
241
- /** Execute one step plan natively. Never throws for unit-level failures. */
242
- export async function executeStepPlan(plan, ctx) {
273
+ /**
274
+ * Open the step's dispatch budget and the abort signal it trips.
275
+ *
276
+ * Budget ceilings (addendum R2): when the frozen plan declares a budget,
277
+ * dispatch runs under an AbortController CHAINED onto `ctx.signal` — hitting a
278
+ * ceiling aborts pending and in-flight dispatches, and the step fails hard.
279
+ * Without a budget the context signal passes through untouched (the no-budget
280
+ * path is byte-identical to pre-R2 behavior).
281
+ *
282
+ * The returned {@link DispatchBudget} is seeded with the run's journaled
283
+ * dispatch count and token total and consumed per ACTUAL dispatch inside
284
+ * `runUnit` — never for durable-row reuses, so resuming a large
285
+ * partially-completed fan-out works.
286
+ *
287
+ * `unchainSignal` MUST be called when dispatch finishes (the caller's
288
+ * `finally`) so the upstream abort listener is removed.
289
+ */
290
+ function openDispatchBudget(ctx, dispatched) {
291
+ const declaredBudget = ctx.budget && (ctx.budget.maxUnits !== undefined || ctx.budget.maxTokens !== undefined) ? ctx.budget : undefined;
292
+ let signal = ctx.signal;
293
+ let onExceeded;
294
+ let unchainSignal;
295
+ if (declaredBudget) {
296
+ const controller = new AbortController();
297
+ const upstream = ctx.signal;
298
+ if (upstream) {
299
+ if (upstream.aborted) {
300
+ controller.abort();
301
+ }
302
+ else {
303
+ const onUpstreamAbort = () => controller.abort();
304
+ upstream.addEventListener("abort", onUpstreamAbort, { once: true });
305
+ unchainSignal = () => upstream.removeEventListener("abort", onUpstreamAbort);
306
+ }
307
+ }
308
+ signal = controller.signal;
309
+ onExceeded = () => controller.abort();
310
+ }
311
+ const budget = new DispatchBudget(dispatched, {
312
+ tokensUsed: ctx.tokensUsed ?? 0,
313
+ ...(declaredBudget ? { budget: declaredBudget } : {}),
314
+ ...(onExceeded ? { onExceeded } : {}),
315
+ });
316
+ return { signal, budget, unchainSignal };
317
+ }
318
+ async function executeStepPlanInConnection(plan, ctx) {
243
319
  const dispatched = ctx.unitsDispatched ?? 0;
244
320
  // Work-list computation is the SHARED, PURE decision (step-work.ts): resolve
245
321
  // the fan-out list, derive content-derived unit ids, assemble each unit's
@@ -274,10 +350,7 @@ export async function executeStepPlan(plan, ctx) {
274
350
  // is reused, not re-dispatched — a crash-resume must never double-issue
275
351
  // side-effecting work. Loading the rows up front is what lets us skip the
276
352
  // dispatch prerequisites below when nothing will actually dispatch.
277
- const existingUnits = new Map();
278
- for (const row of await withWorkflowRunsRepo((repo) => repo.getUnitsForStep(ctx.runId, plan.stepId))) {
279
- existingUnits.set(row.unit_id, row);
280
- }
353
+ const completedRows = indexCompletedRows(await withWorkflowRunsRepo((repo) => repo.getUnitsForStep(ctx.runId, plan.stepId)));
281
354
  // Reviewer finding #2: env resolution and worktree preflight are DISPATCH
282
355
  // prerequisites, so they must run only when a unit will actually dispatch. A
283
356
  // fully-journaled step whose units all reuse completed rows must resume to
@@ -286,7 +359,12 @@ export async function executeStepPlan(plan, ctx) {
286
359
  // to hand back a cached result. The predicate mirrors runUnit's reuse
287
360
  // decision exactly (shared classifyUnitReuse).
288
361
  const gateLoop = ctx.gateLoop ?? 1;
289
- const willDispatch = stepWillDispatch(workUnits, existingUnits, gateLoop);
362
+ // Classify every unit ONCE. The gate below and each unit's own dispatch then
363
+ // read the SAME decision rather than recomputing it from inputs that must be
364
+ // identical — the agreement the gate depends on is structural, not a property
365
+ // two call sites have to keep re-establishing.
366
+ const reuseDecisions = workUnits.map((unit) => classifyUnitReuse(unit, completedRows, gateLoop));
367
+ const willDispatch = reuseDecisions.some((decision) => decision.kind === "dispatch");
290
368
  // Env bindings resolve once per step, before any dispatch; a binding error
291
369
  // fails the whole step cleanly rather than N units racing into it. Skipped
292
370
  // entirely when nothing will dispatch.
@@ -321,41 +399,23 @@ export async function executeStepPlan(plan, ctx) {
321
399
  }
322
400
  worktreeBase = base;
323
401
  }
324
- // Budget ceilings (addendum R2): when the frozen plan declares a budget,
325
- // dispatch runs under an AbortController CHAINED onto ctx.signal hitting
326
- // a ceiling aborts pending and in-flight dispatches, and the step fails
327
- // hard below. Without a budget the context signal passes through untouched
328
- // (the no-budget path is byte-identical to pre-R2 behavior).
329
- const declaredBudget = ctx.budget && (ctx.budget.maxUnits !== undefined || ctx.budget.maxTokens !== undefined) ? ctx.budget : undefined;
330
- let signal = ctx.signal;
331
- let onExceeded;
332
- let unchainSignal;
333
- if (declaredBudget) {
334
- const controller = new AbortController();
335
- const upstream = ctx.signal;
336
- if (upstream) {
337
- if (upstream.aborted) {
338
- controller.abort();
339
- }
340
- else {
341
- const onUpstreamAbort = () => controller.abort();
342
- upstream.addEventListener("abort", onUpstreamAbort, { once: true });
343
- unchainSignal = () => upstream.removeEventListener("abort", onUpstreamAbort);
344
- }
345
- }
346
- signal = controller.signal;
347
- onExceeded = () => controller.abort();
348
- }
349
- // Lifetime-cap + declared-budget accounting: seeded with the run's
350
- // journaled dispatch count and token total, consumed per ACTUAL dispatch
351
- // inside runUnit — never for durable-row reuses, so resuming a large
352
- // partially-completed fan-out works.
353
- const budget = new DispatchBudget(dispatched, {
354
- tokensUsed: ctx.tokensUsed ?? 0,
355
- ...(declaredBudget ? { budget: declaredBudget } : {}),
356
- ...(onExceeded ? { onExceeded } : {}),
357
- });
402
+ // The values that must never survive into the journal. Collected once per
403
+ // step, not once per unit: the frozen engine and its SDK fallback are
404
+ // resolved from the step template and handed unchanged to every unit, and
405
+ // `env` is step-wide, so a fan-out was re-scanning process.env and
406
+ // re-inspecting every passthrough value once per unit, including for units
407
+ // that go on to reuse a journaled row and dispatch nothing at all.
408
+ const sensitiveValues = willDispatch && workUnits[0] ? collectWorkflowDispatchSensitiveValues(workUnits[0], env) : [];
409
+ // Budget ceilings + lifetime-cap accounting, and the budget-chained abort
410
+ // signal they trip. Extracted verbatim (behavior-identical) — see
411
+ // {@link openDispatchBudget}.
412
+ const { signal, budget, unchainSignal } = openDispatchBudget(ctx, dispatched);
358
413
  let outcomes;
414
+ // Worktree removals started by finished units and awaited below, before this
415
+ // step reports anything. Cleanup is best-effort, but "the step resolved" must
416
+ // still mean "its clean worktrees are gone" — only the WAIT moves off the
417
+ // unit's scheduler slot, not the guarantee.
418
+ const pendingWorktreeCleanups = [];
359
419
  const selectedEngine = template.invocation ? ctx.engines?.[template.invocation.engine] : undefined;
360
420
  const selectedLlmEngine = selectedEngine?.kind === "llm"
361
421
  ? selectedEngine
@@ -363,7 +423,7 @@ export async function executeStepPlan(plan, ctx) {
363
423
  ? ctx.engines?.[selectedEngine.fallbackLlmEngine]
364
424
  : undefined;
365
425
  try {
366
- outcomes = await scheduleUnits(workUnits, (workUnit) => runUnit({
426
+ outcomes = await scheduleUnits(workUnits, (workUnit, index) => runUnit({
367
427
  plan,
368
428
  workUnit,
369
429
  env,
@@ -371,7 +431,9 @@ export async function executeStepPlan(plan, ctx) {
371
431
  ctx,
372
432
  signal,
373
433
  dispatcher,
374
- existingUnits,
434
+ reuse: reuseDecisions[index],
435
+ sensitiveValues,
436
+ pendingWorktreeCleanups,
375
437
  budget,
376
438
  }), {
377
439
  concurrency: workList.list.concurrency,
@@ -382,6 +444,9 @@ export async function executeStepPlan(plan, ctx) {
382
444
  }
383
445
  finally {
384
446
  unchainSignal?.();
447
+ // The barrier. `allSettled` because each task already swallowed its own
448
+ // failure into a warn — nothing here can fail the step.
449
+ await Promise.allSettled(pendingWorktreeCleanups);
385
450
  }
386
451
  // Declared budget ceilings and the lifetime cap are hard backstops: a step
387
452
  // that hit one FAILS regardless of on_error policy (a capped run must never
@@ -407,6 +472,19 @@ export async function executeStepPlan(plan, ctx) {
407
472
  .map((u) => u.error ?? `replay divergence: unit "${u.unitId}" was journaled with different inputs`)
408
473
  .join(" "));
409
474
  }
475
+ // A journal-write failure is likewise HARD regardless of on_error: the
476
+ // unit dispatched (spent tokens, ran side effects) but its result could not
477
+ // be persisted, so completing the step would promote an artifact the
478
+ // journal cannot rebuild on resume — and the stuck-`running` row would
479
+ // wedge or double-dispatch a later invocation. The summary carries the
480
+ // per-unit cause verbatim.
481
+ const unjournaled = units.filter((u) => u.failureReason === "journal_write_failed");
482
+ if (unjournaled.length > 0) {
483
+ return {
484
+ ...failedStep(budget.used, unjournaled.map((u) => u.error ?? `unit "${u.unitId}" result could not be journaled`).join(" ")),
485
+ tokensUsed: budget.tokens,
486
+ };
487
+ }
410
488
  // Failure policy + reducer + typed-artifact validation are the SHARED
411
489
  // post-dispatch decision (`reduceStepOutcomes`): `onError: "fail"` (default)
412
490
  // fails the step on any unit failure, `"continue"` records failures and lets
@@ -426,35 +504,35 @@ export async function executeStepPlan(plan, ctx) {
426
504
  async function runUnit(input) {
427
505
  const { plan, workUnit, env, ctx, dispatcher } = input;
428
506
  const unitId = workUnit.unitId;
429
- // A per-unit expression resolution failure (missing param, bad `item.<path>`)
430
- // is deterministic authoring/data breakage computed by the shared work-list:
431
- // the unit fails WITHOUT dispatching and without journaling a row, since no
432
- // resolved input exists to hash.
433
- if (!workUnit.resolved.ok) {
434
- return { unitId, ok: false, failureReason: "expression_error", error: workUnit.resolved.error };
435
- }
436
- if (!workUnit.engine || !workUnit.invocation) {
437
- return {
438
- unitId,
439
- ok: false,
440
- failureReason: "dispatch_error",
441
- error: `unit "${unitId}" has no frozen engine snapshot and cannot be dispatched`,
442
- };
443
- }
507
+ // Engine/exec presence is a WHOLE-LIST invariant, never a per-unit condition:
508
+ // computeStepWorkList fails the entire step before it builds any unit when a
509
+ // template carries neither an `exec` spec nor an `invocation`, and when an
510
+ // `invocation` names an engine absent from the frozen catalog — and
511
+ // executeStepPlanInConnection returns on `!workList.ok` without reaching here.
512
+ // So every unit below carries either `exec` (a child process, naming no
513
+ // engine) or `engine` + `invocation`.
444
514
  // The prompt (and therefore the input hash) was built once with the BASE
445
515
  // unit id by computeStepWorkList: a retry re-dispatches the SAME input, the
446
516
  // `~r<n>` suffix is journal bookkeeping only.
447
- const { prompt, inputHash } = workUnit.resolved;
448
- const sensitiveValues = collectWorkflowDispatchSensitiveValues(workUnit, env);
517
+ const { prompt, inputHash } = workUnit;
518
+ const sensitiveValues = input.sensitiveValues;
449
519
  const request = {
450
520
  runId: ctx.runId,
451
521
  stepId: plan.stepId,
452
522
  unitId,
453
523
  nodeId: workUnit.nodeId,
454
524
  prompt,
455
- engine: workUnit.engine,
525
+ ...(workUnit.engine ? { engine: workUnit.engine } : {}),
456
526
  ...(workUnit.fallbackEngine ? { fallbackEngine: workUnit.fallbackEngine } : {}),
457
- invocation: workUnit.invocation,
527
+ ...(workUnit.invocation ? { invocation: workUnit.invocation } : {}),
528
+ ...(workUnit.exec ? { exec: workUnit.exec } : {}),
529
+ ...(workUnit.execContext ? { execContext: workUnit.execContext } : {}),
530
+ // A NON-isolated exec unit spawns in the engine invocation's working
531
+ // directory. `dispatchJournaledAttempt` overwrites this with the unit's
532
+ // fresh worktree when `isolation: worktree` is in play. Only exec units get
533
+ // it: handing an agent unit a cwd it never had would change harness
534
+ // behavior, and the agent path already takes its cwd from its profile.
535
+ ...(workUnit.exec && ctx.workDir !== undefined ? { cwd: ctx.workDir } : {}),
458
536
  timeoutMs: workUnit.timeoutMs,
459
537
  ...(workUnit.schema ? { schema: workUnit.schema } : {}),
460
538
  ...(env ? { env } : {}),
@@ -468,12 +546,11 @@ async function runUnit(input) {
468
546
  // `retry.on`.
469
547
  const retry = workUnit.retry;
470
548
  const maxAttempts = 1 + Math.max(0, retry?.max ?? 0);
471
- const gateLoop = ctx.gateLoop ?? 1;
472
549
  const journalBaseId = workUnit.journalBaseId;
473
550
  const attemptIdFor = (attempt) => (attempt === 0 ? journalBaseId : `${journalBaseId}~r${attempt}`);
474
- // Durable-row reuse (shared classifyUnitReuse the SAME decision
475
- // executeStepPlan's preflight gate uses, so the gate can never disagree with
476
- // what happens here). A completed row with the matching input hash IS the
551
+ // Durable-row reuse literally the decision executeStepPlan's preflight gate
552
+ // counted, handed down rather than recomputed, so the gate cannot disagree
553
+ // with what happens here. A completed row with the matching input hash IS the
477
554
  // result: return it without touching rows, dispatching, or re-emitting events
478
555
  // (a crash-resume must never double-issue work). A completed loop-1 row with
479
556
  // a DIFFERENT hash is replay divergence (under a frozen plan the same
@@ -481,7 +558,7 @@ async function runUnit(input) {
481
558
  // tampered with; executeStepPlan promotes this to a hard step failure
482
559
  // regardless of on_error). Stale gate-loop rows, failed/running/missing rows,
483
560
  // and pre-release R1 positional ids all fall through and dispatch live.
484
- const reuse = classifyUnitReuse(workUnit, input.existingUnits, gateLoop);
561
+ const reuse = input.reuse;
485
562
  if (reuse.kind === "reuse") {
486
563
  // Identity in the durable step evidence is the CONTENT-derived base id, not
487
564
  // the `~r<n>` attempt row it was reused from — the work list only ever knows
@@ -530,6 +607,7 @@ async function runUnit(input) {
530
607
  attemptId,
531
608
  inputHash,
532
609
  ...(input.worktreeBase !== undefined ? { worktreeBase: input.worktreeBase } : {}),
610
+ pendingWorktreeCleanups: input.pendingWorktreeCleanups,
533
611
  });
534
612
  // The journal ROW keeps the `~r<n>`/`~l<loop>` attempt id (dispatchJournaledAttempt
535
613
  // wrote it), but the returned outcome's identity in the DURABLE step evidence is
@@ -554,6 +632,52 @@ async function runUnit(input) {
554
632
  // maxAttempts >= 1, so outcome is always set by the loop above.
555
633
  return outcome;
556
634
  }
635
+ /**
636
+ * What a finished attempt writes to `workflow_run_steps`' unit row
637
+ * `result_json` — the ONE durable, human-facing surface for a dispatch outcome
638
+ * (`akm workflow status --units` reads exactly this, and the step summary is
639
+ * built from the same text).
640
+ *
641
+ * A SUCCESS journals its promoted value, unchanged.
642
+ *
643
+ * A FAILURE journals its DIAGNOSTIC. Before this, only `result`/`text` were
644
+ * written: `outcome.error` — the one field that says WHY — reached nothing
645
+ * durable, because `buildEvidence` deliberately drops it from the deterministic
646
+ * evidence graph and nothing else persisted it. For an engine unit that mostly
647
+ * cost detail; for an `exec` unit it lost the diagnostic entirely, since a
648
+ * command that fails and explains itself on stderr with empty stdout left
649
+ * `status --units` showing a bare `non_zero_exit`.
650
+ *
651
+ * Three constraints hold:
652
+ *
653
+ * - REDACTION — the caller journals only `redactUnitOutcome(...)` output, so
654
+ * `error` has already been through the shared dispatch redaction contract
655
+ * (`exec/dispatch-redaction.ts`) with this dispatch's resolved `env:`
656
+ * values. It is scrubbed by construction, exactly like `text`.
657
+ * - BOUNDS — clipped to {@link WORKFLOW_UNIT_DIAGNOSTIC_CLIP}, the same bound
658
+ * `status --units` renders with, so a runaway command cannot use the journal
659
+ * as its log file.
660
+ * - HASHES — `result_json` is an OUTPUT. The unit input hash
661
+ * (`computeUnitInputHash`) is computed from plan-frozen INPUTS only
662
+ * (template bytes, item, declared inputs, params, dispatch/invocation/exec
663
+ * snapshots, env ref names, isolation), and reuse compares the stored
664
+ * `input_hash` against that. Nothing here is a hash preimage input, so no
665
+ * completed unit re-dispatches because of it.
666
+ *
667
+ * Partial output on a failed unit is kept ALONGSIDE the diagnostic rather than
668
+ * replacing it: a tool that fails after printing its real complaint on stdout
669
+ * is common, and the reason lives on whichever stream that tool chose.
670
+ */
671
+ function journaledUnitResultJson(outcome) {
672
+ if (outcome.result !== undefined)
673
+ return JSON.stringify(outcome.result);
674
+ if (outcome.ok)
675
+ return outcome.text ? JSON.stringify(outcome.text) : null;
676
+ const parts = [outcome.error, outcome.text].filter((part) => Boolean(part && part.trim()));
677
+ if (parts.length === 0)
678
+ return null;
679
+ return JSON.stringify(clip(parts.join("\n--- unit output ---\n"), WORKFLOW_UNIT_DIAGNOSTIC_CLIP));
680
+ }
557
681
  /** Journal one dispatch attempt: insert row, events, dispatch, finish row. */
558
682
  async function dispatchJournaledAttempt(input) {
559
683
  const { plan, workUnit, ctx, dispatcher, attemptId, inputHash } = input;
@@ -564,35 +688,57 @@ async function dispatchJournaledAttempt(input) {
564
688
  // row — nothing was dispatched (same contract as an expression failure).
565
689
  let worktreePath;
566
690
  if (input.worktreeBase !== undefined) {
567
- const created = createUnitWorktree(input.worktreeBase, ctx.runId, attemptId);
568
- if (!created.ok) {
569
- return { unitId: request.unitId, ok: false, failureReason: "worktree_failed", error: created.error };
570
- }
691
+ const created = await createUnitWorktree(input.worktreeBase, ctx.runId, attemptId);
692
+ // Reported BEFORE the failure check: when the worktree could not be minted,
693
+ // the leftover moved aside is the only copy of the prior attempt's
694
+ // uncollected work, so that is exactly when its path must not be swallowed.
571
695
  if (created.preservedLeftover !== undefined) {
572
696
  // Never destroy a dirty (or unverifiable) leftover from a prior
573
697
  // invocation of the same attempt — it was moved aside instead.
574
698
  warn(`Workflow unit ${attemptId}: a previous attempt left uncollected work in its isolation worktree; ` +
575
699
  `preserved at ${created.preservedLeftover}`);
576
700
  }
701
+ if (!created.ok) {
702
+ return { unitId: request.unitId, ok: false, failureReason: "worktree_failed", error: created.error };
703
+ }
577
704
  worktreePath = created.path;
578
705
  request = { ...request, cwd: worktreePath };
579
706
  }
580
- await enqueueUnitWrite(async () => {
581
- await withWorkflowRunsRepo((repo) => repo.insertUnit({
582
- runId: ctx.runId,
583
- unitId: attemptId,
584
- stepId: plan.stepId,
585
- nodeId: workUnit.nodeId,
586
- parentUnitId: workUnit.isFanOut ? `${plan.stepId}.map` : null,
587
- phase: null,
588
- runner: workUnit.runner,
589
- engine: request.engine.name,
590
- model: request.invocation.model,
591
- inputHash,
592
- worktreePath: worktreePath ?? null,
593
- startedAt: new Date().toISOString(),
594
- }));
595
- });
707
+ const startedAt = new Date().toISOString();
708
+ try {
709
+ await enqueueUnitWrite(async () => {
710
+ await withWorkflowRunsRepo((repo) => repo.insertUnit({
711
+ runId: ctx.runId,
712
+ unitId: attemptId,
713
+ stepId: plan.stepId,
714
+ nodeId: workUnit.nodeId,
715
+ parentUnitId: workUnit.isFanOut ? `${plan.stepId}.map` : null,
716
+ phase: null,
717
+ runner: workUnit.runner,
718
+ engine: request.engine?.name ?? null,
719
+ model: request.invocation?.model ?? null,
720
+ inputHash,
721
+ worktreePath: worktreePath ?? null,
722
+ startedAt,
723
+ }));
724
+ });
725
+ }
726
+ catch (err) {
727
+ // A failed dispatch-row insert means NOTHING dispatched (the row is the
728
+ // dispatch's precondition) — fail the unit with the real cause instead of
729
+ // letting the throw escape into the scheduler, where a swallowed worker
730
+ // error is indistinguishable from "never claimed" and used to be
731
+ // misreported as an aborted, never-dispatched unit.
732
+ if (worktreePath !== undefined && input.worktreeBase !== undefined) {
733
+ await cleanupUnitWorktree(input.worktreeBase, worktreePath);
734
+ }
735
+ return {
736
+ unitId: request.unitId,
737
+ ok: false,
738
+ failureReason: "dispatch_error",
739
+ error: `unit "${attemptId}" could not journal its dispatch row (nothing was dispatched): ${message(err)}`,
740
+ };
741
+ }
596
742
  // Ids/status only — instructions and results are workflow-authored content
597
743
  // and stay out of the events stream (07 P1-B).
598
744
  appendEvent({
@@ -602,57 +748,142 @@ async function dispatchJournaledAttempt(input) {
602
748
  });
603
749
  const outcome = redactUnitOutcome(await dispatchUnit(request, dispatcher), request.sensitiveValues ?? []);
604
750
  const finishedAt = new Date().toISOString();
605
- await enqueueUnitWrite(() => withWorkflowRunsRepo((repo) => repo.immediateTransaction((db) => {
606
- const run = repo.getRunById(ctx.runId);
607
- if (run?.status !== "active")
608
- return;
609
- if (ctx.leaseHolder !== undefined && run.engine_lease_holder !== ctx.leaseHolder)
610
- return;
611
- repo.finishUnit({
612
- runId: ctx.runId,
613
- unitId: attemptId,
614
- status: outcome.ok ? "completed" : "failed",
615
- resultJson: outcome.result !== undefined
616
- ? JSON.stringify(outcome.result)
617
- : outcome.text
618
- ? JSON.stringify(outcome.text)
619
- : null,
620
- tokens: outcome.tokens ?? null,
621
- failureReason: outcome.failureReason ?? null,
622
- // Harness-native session id (P2): journaled so resume can replay the
623
- // harness's own context cache (e.g. `codex exec resume <id>`).
624
- sessionId: outcome.sessionId ?? null,
625
- finishedAt,
626
- });
627
- insertEventStrict(db, {
628
- eventType: "workflow_unit_finished",
629
- ts: finishedAt,
630
- ref: ctx.workflowRef,
631
- metadata: {
751
+ // A dispatched unit's outcome is NEVER silently discarded. The single-driver
752
+ // guard lives at the ROW level (`finishUnitFromDispatch`: still `running`,
753
+ // still this dispatch's `started_at`): a stolen run's new driver re-dispatches
754
+ // through insertUnit, which REPLACES the row with a fresh started_at, so the
755
+ // stale driver's finish matches nothing and can never clobber the new
756
+ // driver's live dispatch. A row that IS still ours is finished with the real
757
+ // result even when the run went non-active or the lease moved mid-flight —
758
+ // dropping it would leave the row `running` and make a later resume
759
+ // re-dispatch side-effecting work that already ran and already spent tokens.
760
+ // Persisting a unit result never advances the run; spine advancement stays
761
+ // lease-guarded in completeWorkflowStep.
762
+ let journalError;
763
+ try {
764
+ await enqueueUnitWrite(() => withWorkflowRunsRepo((repo) => repo.immediateTransaction((db) => {
765
+ const finished = repo.finishUnitFromDispatch({
632
766
  runId: ctx.runId,
633
- stepId: plan.stepId,
634
767
  unitId: attemptId,
635
768
  status: outcome.ok ? "completed" : "failed",
636
- ...(outcome.failureReason ? { failureReason: outcome.failureReason } : {}),
637
- ...(outcome.tokens !== undefined ? { tokens: outcome.tokens } : {}),
638
- },
639
- });
640
- })));
641
- // Worktree lifecycle epilogue: a CLEAN worktree (`git status --porcelain`
642
- // empty) is removed; a DIRTY one is retained and logged — the unit left
643
- // uncollected work, and its journaled worktree_path says where. Cleanup is
644
- // best-effort observability, never a unit failure.
769
+ resultJson: journaledUnitResultJson(outcome),
770
+ tokens: outcome.tokens ?? null,
771
+ failureReason: outcome.failureReason ?? null,
772
+ // Harness-native session id (P2): journaled so resume can replay the
773
+ // harness's own context cache (e.g. `codex exec resume <id>`).
774
+ sessionId: outcome.sessionId ?? null,
775
+ finishedAt,
776
+ dispatchStartedAt: startedAt,
777
+ });
778
+ if (!finished) {
779
+ if (!repo.getUnit(ctx.runId, attemptId)) {
780
+ // The dispatch row vanished (run deleted mid-flight, journal
781
+ // tampered): the same journaling-bug contract finishUnit throws
782
+ // for — surfaced below as a journal-write failure, never a no-op.
783
+ throw new Error(`finishUnit updated no row: no unit "${attemptId}" exists for run "${ctx.runId}". ` +
784
+ `The dispatch row this invocation inserted is gone, so the unit's terminal state cannot be journaled.`);
785
+ }
786
+ // The row exists but is no longer this dispatch's `running` row:
787
+ // another engine invocation re-dispatched (or finished) the unit
788
+ // after taking the run. Its journal owns the unit now — writing
789
+ // would clobber a live dispatch — so the outcome is surfaced
790
+ // loudly instead of silently dropped.
791
+ warn(`Workflow unit ${attemptId} (run ${ctx.runId}) ${outcome.ok ? "completed" : `failed (${outcome.failureReason ?? "error"})`}, ` +
792
+ `but its journal row was re-dispatched by another engine invocation mid-flight — refusing to overwrite ` +
793
+ `the new driver's row. This dispatch's result is not journaled.`);
794
+ return;
795
+ }
796
+ const run = repo.getRunById(ctx.runId);
797
+ if (run?.status !== "active" ||
798
+ (ctx.leaseHolder !== undefined && run.engine_lease_holder !== ctx.leaseHolder)) {
799
+ warn(`Workflow unit ${attemptId} (run ${ctx.runId}): the run ` +
800
+ `${run?.status !== "active" ? `is now ${run?.status ?? "gone"}` : `lease moved to ${run?.engine_lease_holder ?? "(nobody)"}`} ` +
801
+ `while the unit was in flight; its result was journaled so a resume reuses it instead of re-dispatching.`);
802
+ }
803
+ insertEventStrict(db, {
804
+ eventType: "workflow_unit_finished",
805
+ ts: finishedAt,
806
+ ref: ctx.workflowRef,
807
+ metadata: {
808
+ runId: ctx.runId,
809
+ stepId: plan.stepId,
810
+ unitId: attemptId,
811
+ status: outcome.ok ? "completed" : "failed",
812
+ ...(outcome.failureReason ? { failureReason: outcome.failureReason } : {}),
813
+ ...(outcome.tokens !== undefined ? { tokens: outcome.tokens } : {}),
814
+ },
815
+ });
816
+ })));
817
+ }
818
+ catch (err) {
819
+ journalError = err;
820
+ }
821
+ // Worktree lifecycle epilogue: a CLEAN worktree is removed; a DIRTY one is
822
+ // retained and logged — the unit left uncollected work, and its journaled
823
+ // worktree_path says where. Cleanup is best-effort observability, never a
824
+ // unit failure, so it is STARTED here and awaited at the step barrier: the
825
+ // removal serializes on the same per-repo chain as every sibling's
826
+ // `git worktree add`, and awaiting it in this unit's scheduler slot made a
827
+ // finished unit wait out other units' full checkouts before its worker could
828
+ // claim the next item.
645
829
  if (worktreePath !== undefined && input.worktreeBase !== undefined) {
646
- const cleanup = cleanupUnitWorktree(input.worktreeBase, worktreePath);
647
- if (cleanup.dirty) {
648
- warn(`Workflow unit ${attemptId} left uncommitted changes in its isolation worktree; retained at ${worktreePath}`);
649
- }
650
- else if (!cleanup.removed) {
651
- warn(`Workflow unit ${attemptId}: could not clean up isolation worktree ${worktreePath}: ${cleanup.error}`);
652
- }
830
+ const worktreeBase = input.worktreeBase;
831
+ input.pendingWorktreeCleanups.push((async () => {
832
+ try {
833
+ const cleanup = await cleanupUnitWorktree(worktreeBase, worktreePath);
834
+ if (cleanup.dirty) {
835
+ warn(`Workflow unit ${attemptId} left uncommitted changes in its isolation worktree; retained at ${worktreePath}`);
836
+ }
837
+ else if (!cleanup.removed) {
838
+ warn(`Workflow unit ${attemptId}: could not clean up isolation worktree ${worktreePath}: ${cleanup.error}`);
839
+ }
840
+ }
841
+ catch (err) {
842
+ warn(`Workflow unit ${attemptId}: could not clean up isolation worktree ${worktreePath}: ${message(err)}`);
843
+ }
844
+ })());
845
+ }
846
+ // A journal-write failure AFTER a successful dispatch is its own loud
847
+ // failure class: the unit's work ran (and may have succeeded), but its
848
+ // terminal state could not be recorded, so the row may be stuck `running`.
849
+ // It must never masquerade as "not dispatched" — the outcome names the unit
850
+ // and the real cause, and executeStepPlan fails the step hard on it
851
+ // (out-of-taxonomy reason, so retry.on can never re-dispatch the work).
852
+ if (journalError !== undefined) {
853
+ return {
854
+ unitId: request.unitId,
855
+ ok: false,
856
+ failureReason: "journal_write_failed",
857
+ error: `unit "${attemptId}" dispatched and ${outcome.ok ? "completed" : `failed (${outcome.failureReason ?? "error"})`}, ` +
858
+ `but its result could not be journaled: ${message(journalError)}`,
859
+ ...(outcome.tokens !== undefined ? { tokens: outcome.tokens } : {}),
860
+ ...(outcome.sessionId !== undefined ? { sessionId: outcome.sessionId } : {}),
861
+ };
653
862
  }
654
863
  return outcome;
655
864
  }
865
+ /**
866
+ * Strict JSON parse for an `exec` unit's declared-schema output: stdout must be
867
+ * EXACTLY one JSON value (leading/trailing whitespace tolerated, nothing else).
868
+ *
869
+ * Deliberately NOT `parseEmbeddedJsonResponse` — that scan strips code fences
870
+ * and hunts for a JSON island inside prose, which is the right forgiving
871
+ * behavior for an LLM and the wrong one for a command, where it would silently
872
+ * promote a JSON fragment found in unrelated log noise as the typed artifact.
873
+ * Returning `undefined` makes `runStructured` report `parse_error` (a real
874
+ * member of the retry taxonomy), so `retry.on: [parse_error]` still works.
875
+ */
876
+ function parseExecJson(raw) {
877
+ const trimmed = raw.trim();
878
+ if (!trimmed)
879
+ return undefined;
880
+ try {
881
+ return JSON.parse(trimmed);
882
+ }
883
+ catch {
884
+ return undefined;
885
+ }
886
+ }
656
887
  /** Transport failures surface as this sentinel so runStructured doesn't retry them. */
657
888
  class UnitTransportError extends Error {
658
889
  result;
@@ -694,6 +925,18 @@ async function dispatchUnit(request, dispatcher) {
694
925
  const schema = request.schema;
695
926
  const structured = await runStructured({
696
927
  dispatch: dispatchOnce,
928
+ // A command is not re-promptable. Its stdout must be EXACTLY one JSON
929
+ // value: the default embedded-JSON scan (fences, think-blocks, "find the
930
+ // JSON inside the prose") is right for an LLM and wrong for a command,
931
+ // where it would pluck a JSON fragment out of unrelated log noise and
932
+ // promote it as the typed artifact. And it gets exactly ONE attempt —
933
+ // `runStructured`'s corrective retry re-dispatches with feedback, which
934
+ // for a command means running a SIDE-EFFECTING process a second time
935
+ // with byte-identical argv: it cannot produce different output, and it
936
+ // can produce a second deployment. Declared `retry:` still applies (the
937
+ // executor's own loop), because that is a policy the author opted into
938
+ // per failure reason.
939
+ ...(request.exec ? { parse: parseExecJson, maxAttempts: 1 } : {}),
697
940
  validate: (candidate) => {
698
941
  const errors = validateJsonSchemaSubset(candidate, schema);
699
942
  return errors.length === 0 ? { ok: true, value: candidate } : { ok: false, errors };
@@ -705,6 +948,14 @@ async function dispatchUnit(request, dispatcher) {
705
948
  return {
706
949
  unitId: request.unitId,
707
950
  ok: false,
951
+ // NOTE: `validation_error` is deliberately outside PROGRAM_RETRY_REASONS,
952
+ // so no `retry.on:` can name it and a schema-violating unit is not
953
+ // re-run — see "fails with `validation_error` and is NOT re-run" in
954
+ // tests/integration/workflows/exec-unit.test.ts. A sweep finding
955
+ // proposed mapping it onto `llm_invalid_json` (which the parser accepts
956
+ // but this path never emits) to make such failures retryable; that is a
957
+ // behaviour change against an intentional design, not a bug fix, so it
958
+ // is left alone. Reconciling the vocabulary is a 0.9.2 decision.
708
959
  failureReason: structured.reason,
709
960
  error: structured.errors.join("; "),
710
961
  text: structured.raw,
@@ -791,14 +1042,45 @@ export function buildAgentDispatchRequest(request, prompt) {
791
1042
  return {
792
1043
  prompt,
793
1044
  ...(request.systemPrompt ? { systemPrompt: request.systemPrompt } : {}),
794
- ...(request.invocation.model ? { model: request.invocation.model } : {}),
795
- ...(request.invocation.model ? { modelIsExact: true } : {}),
1045
+ ...(request.invocation?.model ? { model: request.invocation.model } : {}),
1046
+ ...(request.invocation?.model ? { modelIsExact: true } : {}),
796
1047
  ...(request.schema ? { schema: request.schema } : {}),
797
1048
  };
798
1049
  }
799
1050
  export const defaultUnitDispatcher = async (request, feedback) => {
1051
+ // `exec` units are dispatched FIRST and separately: they name no engine, so
1052
+ // none of the engine-resolution below applies. `feedback` is deliberately
1053
+ // ignored — a corrective re-prompt is meaningless to a fixed argv, and
1054
+ // `dispatchUnit` already pins exec structured output to a single attempt.
1055
+ if (request.exec) {
1056
+ return runExecUnit({
1057
+ unitId: request.unitId,
1058
+ exec: request.exec,
1059
+ // Worktree isolation supplies `cwd`; otherwise the unit runs in the
1060
+ // engine invocation's own working directory.
1061
+ baseDir: request.cwd ?? process.cwd(),
1062
+ ...(request.env ? { env: request.env } : {}),
1063
+ ...(request.execContext ? { context: request.execContext } : {}),
1064
+ // A declared `output:` schema is what makes an output-cap overflow fatal:
1065
+ // stdout must then parse as exactly one JSON value, which a truncated
1066
+ // prefix cannot. Without one, overflow is marked in the artifact and the
1067
+ // command's own exit code decides the unit. See `exec-unit.ts`.
1068
+ ...(request.schema ? { hasOutputSchema: true } : {}),
1069
+ timeoutMs: request.timeoutMs,
1070
+ ...(request.signal ? { signal: request.signal } : {}),
1071
+ });
1072
+ }
1073
+ if (!request.engine || !request.invocation) {
1074
+ return {
1075
+ ok: false,
1076
+ text: "",
1077
+ failureReason: "dispatch_error",
1078
+ error: `unit "${request.unitId}" has neither a frozen engine snapshot nor an exec command to dispatch.`,
1079
+ };
1080
+ }
1081
+ const engineRequest = request;
800
1082
  const prompt = feedback ? `${request.prompt}\n\n${feedback}` : request.prompt;
801
- const resolved = frozenUnitRunner(request);
1083
+ const resolved = frozenUnitRunner(engineRequest);
802
1084
  // `env` bindings can only reach a child process. The agent (CLI) runner
803
1085
  // spawns one per call, and the sdk runner now injects them for real via the
804
1086
  // env-keyed opencode server registry (sdk-runner.ts module doc, open seam
@@ -906,36 +1188,6 @@ export const defaultUnitDispatcher = async (request, feedback) => {
906
1188
  ...(result.usage ? { usage: result.usage } : {}),
907
1189
  };
908
1190
  };
909
- function collectWorkflowDispatchSensitiveValues(workUnit, env) {
910
- const values = new Set(Object.values(env ?? {}));
911
- const addCredential = (engine) => {
912
- if (!engine)
913
- return;
914
- if (engine.kind === "llm") {
915
- for (const name of engine.credential?.names ?? []) {
916
- const value = process.env[name]?.trim();
917
- if (value)
918
- values.add(value);
919
- }
920
- return;
921
- }
922
- for (const name of engine.envPassthrough) {
923
- const value = process.env[name];
924
- if (!isEnvPassthroughValueSafeToExpose(name, value) && value)
925
- values.add(value);
926
- }
927
- };
928
- addCredential(workUnit.engine);
929
- addCredential(workUnit.fallbackEngine);
930
- return collectSensitiveValues(values);
931
- }
932
- function redactUnitOutcome(outcome, sensitiveValues) {
933
- const redacted = redactSensitiveValue(outcome, sensitiveValues);
934
- if (outcome.failureReason !== undefined && redacted.failureReason !== outcome.failureReason) {
935
- redacted.failureReason = "reported_failure";
936
- }
937
- return redacted;
938
- }
939
1191
  /**
940
1192
  * Map a typed {@link import("../../llm/client.js").LlmCallErrorCode} into the
941
1193
  * persisted `AgentFailureReason` taxonomy (agent/spawn.ts) — the ONLY
@@ -1004,31 +1256,6 @@ function frozenUnitRunner(request) {
1004
1256
  const fallback = request.fallbackEngine ? materializeFrozenLlm(request.fallbackEngine, undefined) : undefined;
1005
1257
  return { kind: "sdk", profile, ...(fallback ? { fallbackConnection: fallback } : {}) };
1006
1258
  }
1007
- function materializeFrozenLlm(snapshot, invocation) {
1008
- let apiKey;
1009
- for (const name of snapshot.credential?.names ?? []) {
1010
- const candidate = process.env[name]?.trim();
1011
- if (candidate) {
1012
- apiKey = candidate;
1013
- break;
1014
- }
1015
- }
1016
- if (snapshot.credential?.required && !apiKey)
1017
- throw new ConfigError(`Required engine credential ${snapshot.credential.names[0]} is not set.`, "INVALID_CONFIG_FILE");
1018
- const base = {
1019
- provider: snapshot.provider,
1020
- endpoint: snapshot.endpoint,
1021
- model: invocation?.model ?? snapshot.model,
1022
- ...(snapshot.temperature !== undefined ? { temperature: snapshot.temperature } : {}),
1023
- ...(snapshot.maxTokens !== undefined ? { maxTokens: snapshot.maxTokens } : {}),
1024
- ...(snapshot.supportsJsonSchema !== undefined ? { supportsJsonSchema: snapshot.supportsJsonSchema } : {}),
1025
- ...(snapshot.extraParams ? { extraParams: snapshot.extraParams } : {}),
1026
- ...(snapshot.contextLength !== undefined ? { contextLength: snapshot.contextLength } : {}),
1027
- ...(snapshot.enableThinking !== undefined ? { enableThinking: snapshot.enableThinking } : {}),
1028
- ...(apiKey ? { apiKey } : {}),
1029
- };
1030
- return invocation?.llm ? deepMergeConfig(base, invocation.llm) : base;
1031
- }
1032
1259
  // ── Small helpers ────────────────────────────────────────────────────────────
1033
1260
  /**
1034
1261
  * Rehydrate a journaled completed unit row into a UnitOutcome (durable-row