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
@@ -18,6 +18,12 @@
18
18
  * `git status --porcelain` CLEAN → the worktree is removed;
19
19
  * DIRTY → it is RETAINED (the caller logs the path) so uncollected work
20
20
  * is never destroyed.
21
+ * 4. {@link sweepStaleWorktrees} — opportunistic, at most once per process:
22
+ * an age-based GC of run roots and retained trees that outlived their run.
23
+ * Age alone cannot see a unit that is still running in ANOTHER process, so
24
+ * every live worktree carries a liveness lease (pid + host + path) in
25
+ * git's own administrative directory for it, and the sweep skips a tree
26
+ * whose lease holder is still running.
21
27
  *
22
28
  * What "uncollected work" means (the honest contract): the clean probe is
23
29
  * `git status --porcelain` WITHOUT `--ignored`, so it counts tracked-file
@@ -32,28 +38,97 @@
32
38
  * must therefore be tracked or untracked-unignored; anything the workflow
33
39
  * repo has chosen to `.gitignore` is treated as throwaway.
34
40
  *
35
- * All git invocations are `spawnSync` (the repo-wide pattern for git
36
- * shell-outs) with explicit timeouts; this module never throws — every
37
- * operation returns a result object so the executor maps failures onto its
38
- * own step/unit failure vocabulary.
41
+ * Concurrency (bug 6). `git worktree add|prune|remove` mutate the base repo's
42
+ * administrative state (`.git/worktrees/*`) under repo-level locks, so a map
43
+ * step running N isolated units at once used to have N of them racing on the
44
+ * same repository. Two invariants close that:
45
+ *
46
+ * • every repo-mutating operation runs inside {@link withRepoWorktreeLock},
47
+ * a promise chain keyed by the resolved base repo path (`serializeByKey`
48
+ * in `core/concurrent.ts`, shared with `unit-writer.ts` — Bun is
49
+ * single-threaded, so an in-process chain is sufficient), so at most one
50
+ * add/prune/remove per repository is ever in flight;
51
+ * • those git calls are ASYNC ({@link runManagedSubprocess}) rather than
52
+ * `spawnSync`, so a unit waiting on a git lock parks a promise instead of
53
+ * wedging the whole event loop (and with it every other in-flight unit,
54
+ * the lease heartbeat, and abort handling).
55
+ *
56
+ * The two sync git shell-outs that remain — {@link isGitAvailable} and
57
+ * {@link assertGitWorkTree} — are read-only, take no repo lock, and run
58
+ * BEFORE any unit dispatches (preflight / test gate), so they can never block
59
+ * work that is already in flight.
60
+ *
61
+ * This module never throws — every operation returns a result object so the
62
+ * executor maps failures onto its own step/unit failure vocabulary. The GC
63
+ * sweep is the sole exception to "no logging here": it is fire-and-forget and
64
+ * has no caller to report to, so it reports through `warn`.
39
65
  */
40
66
  import { spawnSync } from "node:child_process";
41
- import fs from "node:fs";
67
+ import fsp from "node:fs/promises";
42
68
  import os from "node:os";
43
69
  import path from "node:path";
70
+ import { isWithinAsync, safeRealpathAsync } from "../../core/common.js";
71
+ import { serializeByKey } from "../../core/concurrent.js";
72
+ import { runManagedSubprocess } from "../../core/subprocess.js";
73
+ import { warn } from "../../core/warn.js";
44
74
  const GIT_TIMEOUT_MS = 30_000;
45
- /** Run one git command; `ok` = exit 0. Never throws (spawn errors → ok: false). */
46
- function git(cwd, args) {
75
+ /** Directory under `os.tmpdir()` that owns every run's worktree roots. */
76
+ export const WORKTREES_DIR_NAME = "akm-worktrees";
77
+ /**
78
+ * Age after which an orphaned entry under the worktrees root is swept.
79
+ *
80
+ * Retained dirty worktrees are forensic state — deleting them is only
81
+ * acceptable once they are far past any plausible investigation window. Seven
82
+ * days is one full on-call rotation: long enough that a retained tree from a
83
+ * failed run has been triaged (or abandoned), short enough that a tmpdir does
84
+ * not accumulate whole repository checkouts indefinitely.
85
+ */
86
+ export const STALE_WORKTREE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
87
+ function gitExitError(args, code, stderr, stdout) {
88
+ const detail = (stderr || stdout || "").trim();
89
+ return `git ${args.join(" ")} exited ${code}${detail ? `: ${detail}` : ""}`;
90
+ }
91
+ /**
92
+ * Run one git command asynchronously; `ok` = exit 0. Never throws (spawn
93
+ * errors and the 30 s timeout → ok: false). Async so a git lock wait parks a
94
+ * promise instead of blocking the event loop; repo-mutating callers must hold
95
+ * {@link withRepoWorktreeLock}.
96
+ */
97
+ async function git(cwd, args) {
98
+ const result = await runManagedSubprocess(["git", "-C", cwd, ...args], {
99
+ capture: true,
100
+ timeoutMs: GIT_TIMEOUT_MS,
101
+ });
102
+ if (result.spawnError) {
103
+ return { ok: false, stdout: "", error: `git ${args[0]} failed to spawn: ${result.spawnError.message}` };
104
+ }
105
+ if (result.timedOut) {
106
+ return { ok: false, stdout: result.stdout, error: `git ${args.join(" ")} timed out after ${GIT_TIMEOUT_MS}ms` };
107
+ }
108
+ if (result.exitCode !== 0) {
109
+ return {
110
+ ok: false,
111
+ stdout: result.stdout,
112
+ error: gitExitError(args, result.exitCode, result.stderr, result.stdout),
113
+ };
114
+ }
115
+ return { ok: true, stdout: result.stdout };
116
+ }
117
+ /**
118
+ * Synchronous git for the two read-only probes that run before any unit is in
119
+ * flight ({@link isGitAvailable}, {@link assertGitWorkTree}). They take no
120
+ * repository lock, so blocking here cannot stall another unit's git call.
121
+ */
122
+ function gitSync(cwd, args) {
47
123
  const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8", timeout: GIT_TIMEOUT_MS });
48
124
  if (result.error) {
49
125
  return { ok: false, stdout: "", error: `git ${args[0]} failed to spawn: ${result.error.message}` };
50
126
  }
51
127
  if (result.status !== 0) {
52
- const detail = (result.stderr || result.stdout || "").trim();
53
128
  return {
54
129
  ok: false,
55
130
  stdout: result.stdout ?? "",
56
- error: `git ${args.join(" ")} exited ${result.status}${detail ? `: ${detail}` : ""}`,
131
+ error: gitExitError(args, result.status, result.stderr ?? "", result.stdout ?? ""),
57
132
  };
58
133
  }
59
134
  return { ok: true, stdout: result.stdout ?? "" };
@@ -70,7 +145,7 @@ export function isGitAvailable() {
70
145
  * declares isolation cannot run without git.
71
146
  */
72
147
  export function assertGitWorkTree(dir) {
73
- const result = git(dir, ["rev-parse", "--is-inside-work-tree"]);
148
+ const result = gitSync(dir, ["rev-parse", "--is-inside-work-tree"]);
74
149
  if (!result.ok) {
75
150
  return `"${dir}" is not a git repository (isolation: worktree requires one): ${result.error}`;
76
151
  }
@@ -79,27 +154,54 @@ export function assertGitWorkTree(dir) {
79
154
  }
80
155
  return undefined;
81
156
  }
157
+ // ── Per-repository serialization ────────────────────────────────────────────
158
+ /** In-flight tail of each base repository's serialized git-worktree chain. */
159
+ const repoOperationTails = new Map();
160
+ /**
161
+ * Base repos already pruned in this process, per run id. Granularity is
162
+ * per-(repo, run), not per-repo: a run resuming against a repo another run
163
+ * already pruned must still reap ITS own orphaned registrations. A run's whole
164
+ * entry is dropped when its drained worktree root is removed
165
+ * ({@link removeRunRootIfEmpty}), so the map never outgrows the live runs.
166
+ */
167
+ const prunedRuns = new Map();
168
+ /**
169
+ * Serialize `fn` against every other repo-mutating worktree operation on the
170
+ * same base repository ({@link serializeByKey}). Keyed by the RESOLVED repo
171
+ * path so two spellings of one repo (symlinked tmpdir, relative cwd) share a
172
+ * chain. A failure rejects its own caller but never wedges the chain.
173
+ */
174
+ function withRepoWorktreeLock(repoKey, fn) {
175
+ return serializeByKey(repoOperationTails, repoKey, fn);
176
+ }
82
177
  /** Journal-safe directory name for a unit attempt id (ids carry `:` / `~`). */
83
178
  function sanitizeAttemptId(attemptId) {
84
179
  return attemptId.replace(/[^A-Za-z0-9._-]/g, "-");
85
180
  }
181
+ /** Parent directory of every run's worktree root (`<tmp>/akm-worktrees`). */
182
+ export function worktreesRoot() {
183
+ return path.join(os.tmpdir(), WORKTREES_DIR_NAME);
184
+ }
86
185
  /** Run-scoped parent directory for all of one run's unit worktrees. */
87
186
  export function runWorktreeRoot(runId) {
88
- return path.join(os.tmpdir(), "akm-worktrees", runId);
187
+ return path.join(worktreesRoot(), runId);
89
188
  }
90
189
  /**
91
190
  * Move a leftover attempt directory aside to `<dest>.retained-<ts>[-n]`
92
191
  * (never overwriting an earlier retained copy). Throws on fs errors — the
93
192
  * caller maps them onto its result object.
94
193
  */
95
- function moveLeftoverAside(dest) {
194
+ async function moveLeftoverAside(dest) {
96
195
  const base = `${dest}.retained-${Date.now()}`;
97
196
  let aside = base;
98
- for (let n = 1; fs.existsSync(aside); n++)
197
+ for (let n = 1; await pathExists(aside); n++)
99
198
  aside = `${base}-${n}`;
100
- fs.renameSync(dest, aside);
199
+ await fsp.rename(dest, aside);
101
200
  return aside;
102
201
  }
202
+ async function pathExists(p) {
203
+ return fsp.access(p).then(() => true, () => false);
204
+ }
103
205
  /**
104
206
  * Create a fresh DETACHED worktree of `baseDir`'s repository at
105
207
  * `<tmp>/akm-worktrees/<runId>/<attemptId>` (detached HEAD — no branch is
@@ -113,31 +215,75 @@ function moveLeftoverAside(dest) {
113
215
  * → moved aside to `<dest>.retained-<ts>` and reported via
114
216
  * `preservedLeftover` so the caller can log where the work went. Either way
115
217
  * `git worktree prune` clears the stale registration before re-creating.
218
+ *
219
+ * The whole body runs under {@link withRepoWorktreeLock}: the leftover probe,
220
+ * the prune and the add form ONE critical section against the base repo's
221
+ * administrative state, so a concurrent unit's prune can never land between
222
+ * another unit's prune and its add.
223
+ *
224
+ * A successful add takes a liveness lease ({@link acquireWorktreeLease}) so the
225
+ * GC sweep — in this process or another one — never collects the tree while the
226
+ * unit is still running in it.
116
227
  */
117
- export function createUnitWorktree(baseDir, runId, attemptId) {
228
+ export async function createUnitWorktree(baseDir, runId, attemptId) {
229
+ // Opportunistic, at most once per process, never awaited — GC must never sit
230
+ // on the dispatch path.
231
+ sweepStaleWorktreesOnce();
232
+ const repoKey = await safeRealpathAsync(baseDir);
118
233
  const dest = path.join(runWorktreeRoot(runId), sanitizeAttemptId(attemptId));
119
- let preservedLeftover;
120
- try {
121
- if (fs.existsSync(dest)) {
122
- const status = git(dest, ["status", "--porcelain"]);
123
- if (status.ok && status.stdout.trim() === "") {
124
- fs.rmSync(dest, { recursive: true, force: true });
234
+ return withRepoWorktreeLock(repoKey, async () => {
235
+ let preservedLeftover;
236
+ let leftoverHandled = false;
237
+ try {
238
+ if (await pathExists(dest)) {
239
+ const status = await git(dest, ["status", "--porcelain"]);
240
+ if (status.ok && status.stdout.trim() === "") {
241
+ // Async on purpose: a recursive delete of a whole leftover checkout
242
+ // inside this critical section would otherwise block the event loop
243
+ // (every other in-flight unit, the lease heartbeat, abort handling).
244
+ await fsp.rm(dest, { recursive: true, force: true });
245
+ }
246
+ else {
247
+ preservedLeftover = await moveLeftoverAside(dest);
248
+ }
249
+ leftoverHandled = true;
125
250
  }
126
- else {
127
- preservedLeftover = moveLeftoverAside(dest);
128
- }
129
- git(baseDir, ["worktree", "prune"]);
251
+ await fsp.mkdir(path.dirname(dest), { recursive: true });
130
252
  }
131
- fs.mkdirSync(path.dirname(dest), { recursive: true });
132
- }
133
- catch (err) {
134
- return { ok: false, error: `could not prepare worktree directory ${dest}: ${message(err)}` };
135
- }
136
- const added = git(baseDir, ["worktree", "add", "--detach", dest]);
137
- if (!added.ok) {
138
- return { ok: false, error: `could not create isolation worktree at ${dest}: ${added.error}` };
139
- }
140
- return { ok: true, path: dest, ...(preservedLeftover !== undefined ? { preservedLeftover } : {}) };
253
+ catch (err) {
254
+ return {
255
+ ok: false,
256
+ error: `could not prepare worktree directory ${dest}: ${message(err)}`,
257
+ ...(preservedLeftover !== undefined ? { preservedLeftover } : {}),
258
+ };
259
+ }
260
+ // Prune only drops administrative entries whose worktree directory is
261
+ // already gone; it never touches a live worktree. Two triggers, both
262
+ // necessary, and never per-unit-attempt (which multiplied lock contention
263
+ // without buying safety):
264
+ // • a leftover was just removed/moved — its stale registration MUST go
265
+ // before re-adding at the same path;
266
+ // • first worktree of this (repo, run) — reaps registrations orphaned by
267
+ // earlier runs whose roots were GC'd or deleted out from under git.
268
+ const prunedRepos = prunedRuns.get(runId);
269
+ if (leftoverHandled || !prunedRepos?.has(repoKey)) {
270
+ if (prunedRepos)
271
+ prunedRepos.add(repoKey);
272
+ else
273
+ prunedRuns.set(runId, new Set([repoKey]));
274
+ await git(baseDir, ["worktree", "prune"]);
275
+ }
276
+ const added = await git(baseDir, ["worktree", "add", "--detach", dest]);
277
+ if (!added.ok) {
278
+ return {
279
+ ok: false,
280
+ error: `could not create isolation worktree at ${dest}: ${added.error}`,
281
+ ...(preservedLeftover !== undefined ? { preservedLeftover } : {}),
282
+ };
283
+ }
284
+ await acquireWorktreeLease(dest);
285
+ return { ok: true, path: dest, ...(preservedLeftover !== undefined ? { preservedLeftover } : {}) };
286
+ });
141
287
  }
142
288
  /**
143
289
  * Post-unit cleanup: remove the worktree when `git status --porcelain` shows
@@ -151,20 +297,287 @@ export function createUnitWorktree(baseDir, runId, attemptId) {
151
297
  * by the repo's own declaration; retaining a worktree per build/install would
152
298
  * blow up disk. "Uncollected work" the caller preserves is therefore
153
299
  * tracked-or-untracked-unignored changes only (module doc).
300
+ *
301
+ * Only `git worktree remove` takes the base repo's lock; the status probe stays
302
+ * OFF {@link withRepoWorktreeLock}. Since the probe now runs only when a
303
+ * removal was refused, a dirty worktree costs one failed removal inside the
304
+ * lock that it used to avoid — the trade that makes every CLEAN cleanup a
305
+ * single git process.
154
306
  */
155
- export function cleanupUnitWorktree(baseDir, worktreePath) {
156
- const status = git(worktreePath, ["status", "--porcelain"]);
307
+ export async function cleanupUnitWorktree(baseDir, worktreePath) {
308
+ // Try the removal FIRST and let it be the cleanliness check: `git worktree
309
+ // remove` without `--force` already refuses a worktree carrying changes, on
310
+ // the same terms as the probe (ignored files excluded either way). The clean
311
+ // case — the overwhelmingly common one — is then ONE git process per unit
312
+ // instead of two, which a wide fan-out pays per unit.
313
+ const removed = await withRepoWorktreeLock(await safeRealpathAsync(baseDir), () => git(baseDir, ["worktree", "remove", worktreePath]));
314
+ if (removed.ok) {
315
+ await removeRunRootIfEmpty(worktreePath);
316
+ return { removed: true, dirty: false };
317
+ }
318
+ // It refused, so the tree stays on disk — drop its lease, since no unit is
319
+ // using it any more and the sweep must be free to collect it once it is
320
+ // stale. (A successful removal took the whole admin directory, lease with it.)
321
+ await releaseWorktreeLease(worktreePath);
322
+ // Ask the probe WHY it refused rather than parsing git's message, whose
323
+ // wording varies with version and locale — and which the caller's warn text
324
+ // has never been written against.
325
+ const status = await git(worktreePath, ["status", "--porcelain"]);
157
326
  if (!status.ok) {
158
327
  return { removed: false, dirty: false, error: status.error };
159
328
  }
160
329
  if (status.stdout.trim() !== "") {
161
330
  return { removed: false, dirty: true };
162
331
  }
163
- const removed = git(baseDir, ["worktree", "remove", worktreePath]);
164
- if (!removed.ok) {
165
- return { removed: false, dirty: false, error: removed.error };
332
+ return { removed: false, dirty: false, error: removed.error };
333
+ }
334
+ // ── Liveness leases ─────────────────────────────────────────────────────────
335
+ /** Marker file, inside a worktree's git admin dir, naming the process using it. */
336
+ const LEASE_FILE_NAME = "akm-lease";
337
+ /**
338
+ * Path of `p`'s git administrative directory (`<repo>/.git/worktrees/<name>`),
339
+ * read from the `.git` FILE every linked worktree carries. Undefined when `p`
340
+ * is not a readable linked worktree.
341
+ */
342
+ async function worktreeAdminDir(p) {
343
+ let contents;
344
+ try {
345
+ contents = await fsp.readFile(path.join(p, ".git"), "utf8");
346
+ }
347
+ catch {
348
+ return undefined;
349
+ }
350
+ const gitdir = /^gitdir:[ \t]*(\S.*)$/m.exec(contents)?.[1];
351
+ return gitdir?.trim();
352
+ }
353
+ /**
354
+ * Record this process as the user of `worktreePath`, so {@link
355
+ * sweepStaleWorktrees} can tell a live worktree from an abandoned one.
356
+ *
357
+ * The marker lives in git's administrative directory for the worktree, never in
358
+ * the checkout: an untracked file inside the tree would make it probe DIRTY (and
359
+ * be retained forever), while git's own `worktree remove`/`prune` delete the
360
+ * admin dir — lease included — with no extra bookkeeping here. Best effort: a
361
+ * lease that cannot be written only leaves the tree collectible once stale,
362
+ * which is the pre-lease behaviour.
363
+ */
364
+ async function acquireWorktreeLease(worktreePath) {
365
+ const adminDir = await worktreeAdminDir(worktreePath);
366
+ if (adminDir === undefined)
367
+ return;
368
+ const lease = {
369
+ pid: process.pid,
370
+ host: os.hostname(),
371
+ path: await safeRealpathAsync(worktreePath),
372
+ };
373
+ try {
374
+ await fsp.writeFile(path.join(adminDir, LEASE_FILE_NAME), JSON.stringify(lease));
166
375
  }
167
- return { removed: true, dirty: false };
376
+ catch {
377
+ /* best effort — see above */
378
+ }
379
+ }
380
+ /** Drop the lease of a worktree this process is done with but is not removing. */
381
+ async function releaseWorktreeLease(worktreePath) {
382
+ const adminDir = await worktreeAdminDir(worktreePath);
383
+ if (adminDir === undefined)
384
+ return;
385
+ try {
386
+ await fsp.rm(path.join(adminDir, LEASE_FILE_NAME), { force: true });
387
+ }
388
+ catch {
389
+ /* best effort — a stale lease only delays the sweep by one run of it */
390
+ }
391
+ }
392
+ /**
393
+ * True when a still-running process holds `candidate`'s lease — the guard age
394
+ * cannot provide. A unit that runs longer than the sweep threshold while
395
+ * writing only inside subdirectories leaves the worktree ROOT's mtime at
396
+ * creation time, so another akm process minting a worktree would otherwise
397
+ * delete a tree that is still in use.
398
+ *
399
+ * A lease from a dead pid, from another host (where the pid means nothing), or
400
+ * for a different path is NOT liveness: crashed runs and retained dirty trees
401
+ * stay collectible, which is the whole point of the sweep.
402
+ */
403
+ async function isWorktreeLeaseLive(candidate) {
404
+ const adminDir = await worktreeAdminDir(candidate);
405
+ if (adminDir === undefined)
406
+ return false;
407
+ let lease;
408
+ try {
409
+ lease = JSON.parse(await fsp.readFile(path.join(adminDir, LEASE_FILE_NAME), "utf8"));
410
+ }
411
+ catch {
412
+ return false;
413
+ }
414
+ if (lease.host !== os.hostname())
415
+ return false;
416
+ if (lease.path !== (await safeRealpathAsync(candidate)))
417
+ return false;
418
+ return isProcessAlive(lease.pid);
419
+ }
420
+ /** `kill(pid, 0)` liveness probe. EPERM proves the process exists but is not ours. */
421
+ function isProcessAlive(pid) {
422
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
423
+ return false;
424
+ try {
425
+ process.kill(pid, 0);
426
+ return true;
427
+ }
428
+ catch (err) {
429
+ return err.code === "EPERM";
430
+ }
431
+ }
432
+ // ── Garbage collection ──────────────────────────────────────────────────────
433
+ /**
434
+ * Drop the run-scoped root once its last unit worktree is gone. `rmdir`
435
+ * refuses a non-empty directory, so a run that retained a dirty worktree (or
436
+ * a `.retained-<ts>` copy) keeps its root and its forensic contents; only a
437
+ * fully drained root disappears. Never touches anything that is not a DIRECT
438
+ * child of the worktrees root.
439
+ */
440
+ async function removeRunRootIfEmpty(worktreePath) {
441
+ const root = worktreesRoot();
442
+ const runRoot = path.dirname(path.resolve(worktreePath));
443
+ if (!(await isWithinAsync(runRoot, root)))
444
+ return;
445
+ if ((await safeRealpathAsync(path.dirname(runRoot))) !== (await safeRealpathAsync(root)))
446
+ return;
447
+ try {
448
+ await fsp.rmdir(runRoot);
449
+ }
450
+ catch {
451
+ // ENOTEMPTY (retained work) / ENOENT (already gone) — both fine.
452
+ return;
453
+ }
454
+ // The run's worktrees are fully drained — drop its prune bookkeeping so
455
+ // `prunedRuns` never outgrows the live runs. (A later worktree of the same
456
+ // run simply prunes once more; the guard is an optimization, not a
457
+ // correctness gate.)
458
+ prunedRuns.delete(path.basename(runRoot));
459
+ }
460
+ /**
461
+ * Age-based GC of the worktrees root. Removes `<root>/<runId>/<entry>`
462
+ * directories whose last activity is older than `maxAgeMs` — orphaned
463
+ * worktrees from crashed runs AND deliberately retained dirty trees, because
464
+ * the age threshold is exactly what makes discarding forensic state
465
+ * acceptable. A run root is dropped once it is empty and itself stale (or
466
+ * this sweep just emptied it), so a live run whose first worktree is mid-`add`
467
+ * is never pulled out from under git.
468
+ *
469
+ * Safety invariants: it only ever descends two levels from `root`; entries
470
+ * that are not real directories (symlinks included — `Dirent.isDirectory()`
471
+ * reflects `lstat`) are skipped, never followed; a stale-looking candidate
472
+ * whose {@link isWorktreeLeaseLive} lease holder is still running is skipped
473
+ * (age alone cannot see a unit in flight in another process); and every
474
+ * candidate is re-verified with {@link isWithin} against the resolved root
475
+ * before removal.
476
+ * Deleting a directory leaves its registration in whatever base repo minted
477
+ * it; the next run's `git worktree prune` on that repo reaps it.
478
+ *
479
+ * Returns the paths removed. Never throws.
480
+ */
481
+ export async function sweepStaleWorktrees(opts = {}) {
482
+ const root = path.resolve(opts.root ?? worktreesRoot());
483
+ const removed = [];
484
+ if (path.basename(root) !== WORKTREES_DIR_NAME)
485
+ return removed;
486
+ const maxAgeMs = opts.maxAgeMs ?? STALE_WORKTREE_MAX_AGE_MS;
487
+ const now = opts.now ?? Date.now();
488
+ let runRoots;
489
+ try {
490
+ runRoots = await fsp.readdir(root, { withFileTypes: true, encoding: "utf8" });
491
+ }
492
+ catch {
493
+ return removed; // No root yet (or unreadable) — nothing to sweep.
494
+ }
495
+ for (const runRootEntry of runRoots) {
496
+ if (!runRootEntry.isDirectory())
497
+ continue;
498
+ const runRoot = path.join(root, runRootEntry.name);
499
+ if (!(await isWithinAsync(runRoot, root)))
500
+ continue;
501
+ let entries;
502
+ try {
503
+ entries = await fsp.readdir(runRoot, { withFileTypes: true, encoding: "utf8" });
504
+ }
505
+ catch {
506
+ continue;
507
+ }
508
+ let emptiedHere = false;
509
+ for (const entry of entries) {
510
+ if (!entry.isDirectory())
511
+ continue;
512
+ const candidate = path.join(runRoot, entry.name);
513
+ if (!(await isWithinAsync(candidate, root)))
514
+ continue;
515
+ if (now - (await lastActivityMs(candidate, entry.name)) < maxAgeMs)
516
+ continue;
517
+ if (await isWorktreeLeaseLive(candidate))
518
+ continue;
519
+ try {
520
+ await fsp.rm(candidate, { recursive: true, force: true });
521
+ removed.push(candidate);
522
+ emptiedHere = true;
523
+ }
524
+ catch {
525
+ /* leave it for the next sweep */
526
+ }
527
+ }
528
+ try {
529
+ if ((await fsp.readdir(runRoot)).length > 0)
530
+ continue;
531
+ if (!emptiedHere && now - (await lastActivityMs(runRoot, runRootEntry.name)) < maxAgeMs)
532
+ continue;
533
+ await fsp.rmdir(runRoot);
534
+ removed.push(runRoot);
535
+ }
536
+ catch {
537
+ /* leave it for the next sweep */
538
+ }
539
+ }
540
+ return removed;
541
+ }
542
+ /**
543
+ * Newest evidence of activity for `p`: its mtime, or the timestamp embedded in
544
+ * a `.retained-<ts>[-n]` name when that is newer. Taking the max is the
545
+ * conservative direction — a sweep never deletes something that looks recent
546
+ * by either measure. An unstattable entry reports as "now" so it survives.
547
+ */
548
+ async function lastActivityMs(p, name) {
549
+ let mtimeMs;
550
+ try {
551
+ mtimeMs = (await fsp.stat(p)).mtimeMs;
552
+ }
553
+ catch {
554
+ return Date.now();
555
+ }
556
+ const stamped = /\.retained-(\d{10,})(?:-\d+)?$/.exec(name);
557
+ return stamped ? Math.max(mtimeMs, Number(stamped[1])) : mtimeMs;
558
+ }
559
+ let sweepStarted = false;
560
+ /**
561
+ * Kick off the GC sweep at most once per process, fire-and-forget. Called from
562
+ * {@link createUnitWorktree} so the cost is paid by a run that is already
563
+ * doing worktree work, and never awaited so dispatch does not wait on it.
564
+ */
565
+ function sweepStaleWorktreesOnce() {
566
+ if (sweepStarted)
567
+ return;
568
+ sweepStarted = true;
569
+ void sweepStaleWorktrees()
570
+ .then((removed) => {
571
+ if (removed.length === 0)
572
+ return;
573
+ const shown = removed.slice(0, 10).join(", ");
574
+ const rest = removed.length > 10 ? ` (+${removed.length - 10} more)` : "";
575
+ warn(`Workflow worktree GC: removed ${removed.length} stale entr${removed.length === 1 ? "y" : "ies"} ` +
576
+ `older than ${STALE_WORKTREE_MAX_AGE_MS / (24 * 60 * 60 * 1000)}d under ${worktreesRoot()}: ${shown}${rest}`);
577
+ })
578
+ .catch(() => {
579
+ // GC is best-effort observability; a failed sweep never affects a run.
580
+ });
168
581
  }
169
582
  function message(err) {
170
583
  return err instanceof Error ? err.message : String(err);
@@ -27,6 +27,7 @@
27
27
  * deterministic: the same document always compiles to the same plan.
28
28
  */
29
29
  import { formatReference, parseReference } from "../program/expressions.js";
30
+ import { projectExecCore } from "../program/schema.js";
30
31
  /**
31
32
  * Compile a parsed unified workflow document into a frozen-plan-ready graph.
32
33
  * `title` is the run-level display title (the asset's canonical name — the
@@ -145,6 +146,11 @@ function compileUnit(unit, id, instructions, defaults, inputs, source) {
145
146
  instructions,
146
147
  templating: "verbatim",
147
148
  ...(inputs && inputs.length > 0 ? { inputs: [...inputs] } : {}),
149
+ // Shared projection: both env-scope keys are carried CONDITIONALLY (and
150
+ // `inheritEnv` only when true), so an exec unit that says nothing about its
151
+ // environment freezes — and therefore hashes — byte-identically to one
152
+ // authored before these keys existed.
153
+ ...(unit?.exec ? { exec: projectExecCore(unit.exec) } : {}),
148
154
  ...(unit?.output !== undefined ? { schema: unit.output } : {}),
149
155
  ...(unit?.retry ? { retry: { max: unit.retry.max, on: [...unit.retry.on] } } : {}),
150
156
  onError: unit?.onError ?? defaults?.onError ?? "fail",
@@ -199,8 +205,10 @@ function checkInputReference(text, index, check) {
199
205
  // ── Non-fatal warnings ───────────────────────────────────────────────────────
200
206
  /**
201
207
  * Collect the document's non-fatal WARNINGS — advisories that never fail
202
- * compilation, never change the frozen plan or its hash, and are surfaced by
203
- * lint output (human + JSON) and as `warn()` lines at `workflow run`.
208
+ * compilation, never change the frozen plan or its hash, and are surfaced as
209
+ * `workflow-warning` entries in `akm lint`'s separate `warnings` channel
210
+ * (human + JSON output, via `core/adapter/adapters/akm-lint.ts#
211
+ * workflowCompileWarnings`) and as `warn()` lines at `workflow run`.
204
212
  *
205
213
  * A. A unit/map step with NO step-level `output:` schema carries its units'
206
214
  * raw results as an untyped artifact — permitted, but worth flagging.
@@ -209,11 +217,27 @@ function checkInputReference(text, index, check) {
209
217
  * block — a likely typo. Prose can no longer carry param references at
210
218
  * all (it is never scanned), so this warning's surface shrinks to the
211
219
  * two whole-value fields that can legally contain one.
220
+ * C. `gate.max_loops` above 1 on an `exec` step. The engine judges such a
221
+ * step but never loops it (`exec/step-work.ts#effectiveGateMaxLoops`):
222
+ * a frozen argv cannot read the judge's feedback, so a second loop would
223
+ * only re-run the identical command — and its side effects. The declared
224
+ * budget is not silently different from what runs; say so.
212
225
  */
213
226
  export function collectWorkflowWarnings(document) {
214
227
  const warnings = [];
215
228
  const declaredParams = document.params ? new Set(Object.keys(document.params)) : undefined;
216
229
  for (const step of document.steps) {
230
+ const maxLoops = step.gate?.maxLoops ?? 1;
231
+ const execUnit = step.map ? step.map.unit?.exec : step.unit?.exec;
232
+ if (maxLoops > 1 && execUnit && step.gateRubric?.text.trim()) {
233
+ warnings.push({
234
+ line: step.source.start,
235
+ message: `Step "${step.id}" declares \`gate.max_loops: ${maxLoops}\` on an \`exec\` step — it runs its command ` +
236
+ `ONCE. A gate loop re-executes the step so it can address the judge's feedback, and a frozen argv cannot ` +
237
+ `read that feedback; looping would only repeat the command's side effects. The gate still evaluates and ` +
238
+ `can still fail the step.`,
239
+ });
240
+ }
217
241
  if ((step.map || step.route === undefined) && step.output === undefined) {
218
242
  warnings.push({
219
243
  line: step.source.start,