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
|
@@ -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 (
|
|
12
|
-
*
|
|
13
|
-
* —
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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 (
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
45
|
-
* resolves against `null` and fails LOUDLY at
|
|
46
|
-
* (`… resolved to null`) — a deterministic
|
|
47
|
-
*
|
|
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,
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
227
|
-
|
|
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
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
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
|
|
239
|
-
return
|
|
270
|
+
export function executeStepPlan(plan, ctx) {
|
|
271
|
+
return withWorkflowRunsConnection(() => executeStepPlanInConnection(plan, ctx));
|
|
240
272
|
}
|
|
241
|
-
/**
|
|
242
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
|
|
434
|
-
|
|
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
|
|
448
|
-
const sensitiveValues =
|
|
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
|
|
475
|
-
//
|
|
476
|
-
// what happens here
|
|
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 =
|
|
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
|
-
|
|
569
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
|
795
|
-
...(request.invocation
|
|
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(
|
|
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
|