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
|
@@ -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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
46
|
-
|
|
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:
|
|
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 =
|
|
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(
|
|
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;
|
|
197
|
+
for (let n = 1; await pathExists(aside); n++)
|
|
99
198
|
aside = `${base}-${n}`;
|
|
100
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
preservedLeftover = moveLeftoverAside(dest);
|
|
128
|
-
}
|
|
129
|
-
git(baseDir, ["worktree", "prune"]);
|
|
251
|
+
await fsp.mkdir(path.dirname(dest), { recursive: true });
|
|
130
252
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
|
203
|
-
*
|
|
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,
|