akm-cli 0.8.2 → 0.8.4

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 CHANGED
@@ -4,6 +4,53 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
+ ## [0.8.4] - 2026-06-08
8
+
9
+ ### Fixed
10
+
11
+ - **`akm tasks sync` ignored schedule changes.** Sync classified any task already
12
+ present in the OS scheduler as "unchanged" without comparing its installed
13
+ entry, so editing a task's `schedule:` in the `.yml` never reached the crontab —
14
+ the only way to apply a new schedule was to `remove` and re-`add` the task. The
15
+ same gap affected `tasks enable`/`disable`, which merely toggled the existing
16
+ cron line's comment and so re-enabled a stale schedule. Sync now compares the
17
+ backend's installed signature against the signature the current definition would
18
+ produce and reinstalls on drift (reported in a new `updated[]` field);
19
+ `enable`/`disable` reinstall from the current `.yml` instead of toggling in
20
+ place. Backends that can't cheaply read their installed form fall back to an
21
+ idempotent reinstall, so the fix is correct on launchd/schtasks too. The cron
22
+ backend gains `expectedSignature()` and a signature on each `list()` entry.
23
+
24
+ ### Added
25
+
26
+ - **`akm improve --skip-if-locked`.** When another improve run already holds the
27
+ lock, the run logs and exits 0 with a no-op result (`skipped.reason:
28
+ "lock-held"`) instead of failing with the "already running" config error
29
+ (exit 78). Intended for high-frequency scheduled runs (e.g. an every-30-min
30
+ `quick` pass) that would otherwise pile up exit-78 failures whenever a longer
31
+ run overlaps them. Default off — the hard error is preserved for interactive
32
+ use. The result is still recorded so the skip is auditable.
33
+
34
+ ## [0.8.3] - 2026-06-08
35
+
36
+ ### Fixed
37
+
38
+ - **`improve.lock` leaked on signal death (cron timeout).** The improve
39
+ SIGTERM/SIGINT/SIGHUP handler calls `process.exit()`, which skips `finally`
40
+ blocks — so the `finally` that releases `improve.lock` never ran, and every
41
+ timed-out cron run leaked the lock sentinel. (It wasn't a permanent deadlock
42
+ only because the next run reclaims a dead-PID lock, a path that PID reuse can
43
+ defeat.) The lock is now released from a `process.on("exit", …)` handler
44
+ registered at acquire time (exit handlers DO run on `process.exit()`), via a
45
+ new ownership-checked `releaseLockIfOwned(path, pid)` so a backstop release can
46
+ never delete a different run's lock. This generalizes to the budget watchdog
47
+ and any future exit path.
48
+ - **`quick` profile was not quick.** It was documented "Reflect-only" but did
49
+ not disable the session-`extract` process (which is default-ON), so a `quick`
50
+ run processed the entire unindexed-session backlog (~40 min) — guaranteeing a
51
+ 5-minute cron timeout → SIGTERM → the lock leak above, every run. `quick` now
52
+ explicitly sets `processes.extract.enabled: false`.
53
+
7
54
  ## [0.8.2] - 2026-06-05
8
55
 
9
56
  ### Added
@@ -1,10 +1,11 @@
1
1
  {
2
- "description": "Reflect-only pass — no distill, consolidate, memoryInference, or graphExtraction.",
2
+ "description": "Reflect-only pass — no extract, distill, consolidate, memoryInference, or graphExtraction.",
3
3
  "processes": {
4
4
  "reflect": {
5
5
  "enabled": true,
6
6
  "allowedTypes": ["agent", "command", "knowledge", "lesson", "memory", "skill", "wiki", "workflow"]
7
7
  },
8
+ "extract": { "enabled": false },
8
9
  "distill": { "enabled": false },
9
10
  "consolidate": { "enabled": false },
10
11
  "memoryInference": { "enabled": false },
@@ -54,6 +54,11 @@ export const improveCommand = defineCommand({
54
54
  description: "Emit the full JSON result on stdout (legacy behaviour). (0.8.0+: full result is recorded in the improve_runs table of state.db and stdout is empty; use this flag for the prior behaviour, e.g. `akm improve --json-to-stdout | jq`.)",
55
55
  default: false,
56
56
  },
57
+ "skip-if-locked": {
58
+ type: "boolean",
59
+ description: "If another improve run already holds the lock, skip gracefully (exit 0) instead of failing with 'already running' (exit 78). Use for high-frequency scheduled runs so they don't pile up failures while a longer run is in progress.",
60
+ default: false,
61
+ },
57
62
  profile: {
58
63
  type: "string",
59
64
  description: "Named improve profile from profiles.improve or built-in profiles (default, quick, thorough, memory-focus, graph-refresh). Controls which sub-processes run and which asset types are processed.",
@@ -92,6 +97,7 @@ export const improveCommand = defineCommand({
92
97
  const minRetrievalCountRaw = getHyphenatedArg(args, "min-retrieval-count");
93
98
  const minRetrievalCount = parseNonNegativeIntFlag(minRetrievalCountRaw, "--min-retrieval-count");
94
99
  const requireFeedbackSignal = getHyphenatedBoolean(args, "require-feedback-signal");
100
+ const skipIfLocked = getHyphenatedBoolean(args, "skip-if-locked");
95
101
  const profileArg = getStringArg(args, "profile");
96
102
  // Only set the keys the user actually passed (citty leaves the flag
97
103
  // undefined unless `--sync`/`--no-sync` / `--push`/`--no-push` appears),
@@ -167,6 +173,7 @@ export const improveCommand = defineCommand({
167
173
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
168
174
  ...(minRetrievalCount !== undefined ? { minRetrievalCount } : {}),
169
175
  ...(requireFeedbackSignal ? { requireFeedbackSignal } : {}),
176
+ ...(skipIfLocked ? { skipIfLocked } : {}),
170
177
  ...(profileArg !== undefined ? { profile: profileArg } : {}),
171
178
  ...(Object.keys(syncOverride).length > 0 ? { sync: syncOverride } : {}),
172
179
  consolidateOptions: {
@@ -8,7 +8,7 @@ import { daysToMs, isAssetType } from "../core/common";
8
8
  import { getDefaultLlmConfig, loadConfig } from "../core/config";
9
9
  import { ConfigError, NotFoundError, rethrowIfTestIsolationError, UsageError } from "../core/errors";
10
10
  import { appendEvent, readEvents } from "../core/events";
11
- import { probeLock, releaseLock, tryAcquireLockSync } from "../core/file-lock";
11
+ import { probeLock, releaseLock, releaseLockIfOwned, tryAcquireLockSync } from "../core/file-lock";
12
12
  import { parseFrontmatter } from "../core/frontmatter";
13
13
  import { detectAndWriteContradictions } from "../core/memory-contradiction-detect";
14
14
  import { analyzeMemoryCleanup, applyMemoryCleanup, } from "../core/memory-improve";
@@ -435,7 +435,7 @@ export async function akmImprove(options = {}) {
435
435
  fs.mkdirSync(path.dirname(resolvedLockPath), { recursive: true });
436
436
  const lockPayload = () => JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() });
437
437
  if (tryAcquireLockSync(resolvedLockPath, lockPayload()))
438
- return;
438
+ return "acquired";
439
439
  // Lock file already exists — probe to determine whether it's still held
440
440
  // or whether the prior run died without cleaning up.
441
441
  const probe = probeLock(resolvedLockPath, { staleAfterMs: MAX_LOCK_AGE_MS });
@@ -470,9 +470,19 @@ export async function akmImprove(options = {}) {
470
470
  }
471
471
  releaseLock(resolvedLockPath);
472
472
  if (tryAcquireLockSync(resolvedLockPath, lockPayload()))
473
- return;
473
+ return "acquired";
474
+ // Lost the race to another run that grabbed the freed stale lock.
475
+ if (options.skipIfLocked) {
476
+ warn("[improve] another run acquired the lock during stale recovery; skipping (--skip-if-locked)");
477
+ return "skipped";
478
+ }
474
479
  throw new ConfigError(`akm improve is already running. Delete ${resolvedLockPath} to force.`, "INVALID_CONFIG_FILE");
475
480
  }
481
+ // Lock is held by a live run within the staleness window.
482
+ if (options.skipIfLocked) {
483
+ warn(`[improve] another improve run holds the lock (PID ${lock?.pid}, started ${lock?.startedAt}); skipping (--skip-if-locked)`);
484
+ return "skipped";
485
+ }
476
486
  throw new ConfigError(`akm improve is already running (PID ${lock?.pid}, started ${lock?.startedAt}). Delete ${resolvedLockPath} to force.`, "INVALID_CONFIG_FILE");
477
487
  };
478
488
  // Phase 4 lock-leak guard (§7 ordering hazard): hoisting `improve.lock` above
@@ -497,6 +507,17 @@ export async function akmImprove(options = {}) {
497
507
  }
498
508
  lockAcquired = false;
499
509
  };
510
+ // Signal-safe lock release (0.8.3 hotfix). The SIGTERM/SIGINT/SIGHUP handler
511
+ // in improve-cli.ts calls `process.exit()`, which does NOT run the `finally`
512
+ // below that owns lock release — so a cron-timeout SIGTERM leaked
513
+ // `improve.lock` every run. `process.exit()` DOES fire `'exit'` listeners,
514
+ // so we release the lock from one. `releaseLockIfOwned` only unlinks a lock
515
+ // still owned by this PID, so it is safe even if a later run re-acquired it.
516
+ // The listener is removed in the `finally` so the normal path stays single-release
517
+ // and repeated in-process `akmImprove` calls (tests) do not accumulate listeners.
518
+ const releaseLockOnExit = () => {
519
+ releaseLockIfOwned(resolvedLockPath, process.pid);
520
+ };
500
521
  const preEnsureCleanupWarnings = [];
501
522
  let plannedRefs;
502
523
  let memorySummary;
@@ -508,8 +529,25 @@ export async function akmImprove(options = {}) {
508
529
  // The dry-run branch below produces plannedRefs/memorySummary WITHOUT the lock
509
530
  // or triage (decision: dry-run never mutates the queue).
510
531
  if (!options.dryRun) {
511
- acquireLock();
532
+ if (acquireLock() === "skipped") {
533
+ // Another improve holds the lock and the caller asked to skip rather
534
+ // than fail. Return a clean no-op result (exit 0) before any index/DB
535
+ // work — never registered the exit listener, never set lockAcquired,
536
+ // so we release nothing belonging to the run that owns the lock.
537
+ return {
538
+ schemaVersion: 1,
539
+ ok: true,
540
+ scope,
541
+ dryRun: false,
542
+ skipped: { reason: "lock-held" },
543
+ memorySummary: { eligible: 0, derived: 0 },
544
+ plannedRefs: [],
545
+ };
546
+ }
512
547
  lockAcquired = true;
548
+ // Backstop release on process.exit() (signal handler / budget watchdog),
549
+ // which skips the finally below. Removed in that finally on the normal path.
550
+ process.on("exit", releaseLockOnExit);
513
551
  // Phase 4 triage pre-pass (§7, §13): drain the standing pending backlog
514
552
  // BEFORE ensureIndex so improve generates fresh proposals against a cleared
515
553
  // queue (no `duplicate_pending` collisions) and ensureIndex absorbs triage's
@@ -922,6 +960,9 @@ export async function akmImprove(options = {}) {
922
960
  catch {
923
961
  // ignore
924
962
  }
963
+ // The normal path released the lock above; drop the process.exit backstop so
964
+ // it does not fire later (or accumulate across repeated in-process calls).
965
+ process.removeListener("exit", releaseLockOnExit);
925
966
  // I1: close the long-lived state.db connection opened at the top of the run.
926
967
  try {
927
968
  eventsDb?.close();
@@ -218,7 +218,13 @@ export async function akmTasksSetEnabled(id, enabled) {
218
218
  fs.writeFileSync(filePath, updated, "utf8");
219
219
  const sched = selectBackend();
220
220
  try {
221
- await sched.setEnabled(normalised, enabled);
221
+ // Reinstall from the (just-updated) definition rather than only toggling
222
+ // the comment. A plain toggle leaves a stale schedule in place if the
223
+ // .yml's `schedule:` changed while the task was disabled — re-enabling
224
+ // would silently keep the old cron line. install() renders the block with
225
+ // both the current schedule and the new enabled state, and is idempotent.
226
+ const task = parseTaskDocument({ yaml: updated, filePath, id: normalised });
227
+ await sched.install(task);
222
228
  }
223
229
  catch (err) {
224
230
  // Roll the file back so the YAML source-of-truth and the OS
@@ -254,9 +260,12 @@ export async function akmTasksHistory(input) {
254
260
  * Reconcile the on-disk task files with the OS scheduler.
255
261
  * • install missing tasks (after validating them — invalid files are
256
262
  * skipped with a per-task reason rather than aborting the whole sync)
263
+ * • reinstall tasks whose schedule or enabled state changed in the .yml
264
+ * (drift detected by comparing the backend's installed signature against
265
+ * the signature the current definition would produce)
257
266
  * • remove orphan scheduler entries that no longer have a backing file
258
267
  */
259
- export async function akmTasksSync() {
268
+ export async function akmTasksSync(deps = {}) {
260
269
  const stashDir = resolveStashDir();
261
270
  const typeRoot = path.join(stashDir, "tasks");
262
271
  if (fs.existsSync(typeRoot))
@@ -267,10 +276,13 @@ export async function akmTasksSync() {
267
276
  .filter((f) => f.endsWith(".yml"))
268
277
  .map((f) => f.slice(0, -4))
269
278
  : [];
270
- const sched = selectBackend();
279
+ const sched = deps.backend ?? selectBackend();
271
280
  const backend = backendNameForPlatform();
272
- const present = new Set((await sched.list()).map((t) => t.id));
281
+ // Map id installed signature so sync can detect schedule/enabled drift on
282
+ // tasks that already exist in the scheduler, not just presence/absence.
283
+ const present = new Map((await sched.list()).map((t) => [t.id, t.signature]));
273
284
  const installed = [];
285
+ const updated = [];
274
286
  const unchanged = [];
275
287
  const skipped = [];
276
288
  for (const id of fileIds) {
@@ -290,22 +302,34 @@ export async function akmTasksSync() {
290
302
  skipped.push({ id, reason: err instanceof Error ? err.message : String(err) });
291
303
  continue;
292
304
  }
293
- if (present.has(id)) {
305
+ if (!present.has(id)) {
306
+ await sched.install(task);
307
+ installed.push(id);
308
+ continue;
309
+ }
310
+ // Already installed — reconcile against the current definition. Compare the
311
+ // installed signature to what this task would render to; reinstall on drift.
312
+ // When the backend can't produce a signature (no expectedSignature, or it
313
+ // didn't record one), reinstall unconditionally — install() is idempotent,
314
+ // so the cost is one crontab write and correctness is guaranteed.
315
+ const installedSig = present.get(id);
316
+ const expectedSig = sched.expectedSignature?.(task);
317
+ if (installedSig !== undefined && expectedSig !== undefined && installedSig === expectedSig) {
294
318
  unchanged.push(id);
295
319
  }
296
320
  else {
297
321
  await sched.install(task);
298
- installed.push(id);
322
+ updated.push(id);
299
323
  }
300
324
  }
301
325
  const removed = [];
302
- for (const installedId of present) {
326
+ for (const installedId of present.keys()) {
303
327
  if (!fileIds.includes(installedId)) {
304
328
  await sched.uninstall(installedId);
305
329
  removed.push(installedId);
306
330
  }
307
331
  }
308
- return { installed, removed, unchanged, skipped, backend: sched.name };
332
+ return { installed, updated, removed, unchanged, skipped, backend: sched.name };
309
333
  }
310
334
  export async function akmTasksDoctor() {
311
335
  const warnings = [];
@@ -79,6 +79,28 @@ export function releaseLock(lockPath) {
79
79
  // Sentinel already gone — fine.
80
80
  }
81
81
  }
82
+ /**
83
+ * Release a lock ONLY if it is still owned by `ownerPid`. Safe to call from a
84
+ * `process.exit()` / `'exit'` handler as a backstop: `process.exit()` skips
85
+ * `finally` blocks — so the normal lock-release never runs on signal death
86
+ * (SIGTERM/SIGINT) — but it DOES fire `'exit'` listeners synchronously. Checking
87
+ * ownership first means that if the lock was already released and re-acquired by
88
+ * a different process, this leaves that process's lock intact (no cross-run
89
+ * deletion / PID-reuse footgun). Synchronous so it is valid inside an exit handler.
90
+ */
91
+ export function releaseLockIfOwned(lockPath, ownerPid) {
92
+ let rawContent;
93
+ try {
94
+ rawContent = fs.readFileSync(lockPath, "utf8");
95
+ }
96
+ catch {
97
+ // Absent or unreadable — nothing of ours to release.
98
+ return;
99
+ }
100
+ if (extractHolderPid(rawContent) === ownerPid) {
101
+ releaseLock(lockPath);
102
+ }
103
+ }
82
104
  /**
83
105
  * Extract a PID from a sentinel body. Accepts the two shapes used across
84
106
  * the codebase: a bare numeric string (config-io, vault, lockfile) and
@@ -64,13 +64,11 @@ export function CRON_BACKEND(options = {}) {
64
64
  },
65
65
  list() {
66
66
  const existing = readCrontab(exec);
67
- const ids = [];
68
- for (const line of existing.split(/\r?\n/)) {
69
- const m = line.match(BLOCK_RE);
70
- if (m)
71
- ids.push(m[1]);
72
- }
73
- return ids.map((id) => ({ id }));
67
+ return listBlocks(existing).map(({ id, body }) => ({ id, signature: normalizeSignature(body) }));
68
+ },
69
+ expectedSignature(task) {
70
+ const cronLine = buildCronLine(task, akmArgv, logDir);
71
+ return normalizeSignature(cronBlockBody(cronLine, task.enabled));
74
72
  },
75
73
  };
76
74
  }
@@ -82,9 +80,48 @@ export function buildCronLine(task, akmArgv, logDir) {
82
80
  const cmd = [...akmArgv, "tasks", "run", task.id].map((part) => quoteForCron(part)).join(" ");
83
81
  return `${cronExpr} ${cmd} >> ${quoteForCron(logPath)} 2>&1`;
84
82
  }
83
+ /** The crontab line as it appears inside a block — commented when disabled. */
84
+ export function cronBlockBody(cronLine, enabled) {
85
+ return enabled ? cronLine : `${DISABLED_PREFIX}${cronLine}`;
86
+ }
85
87
  export function renderBlock(id, cronLine, enabled) {
86
- const body = enabled ? cronLine : `${DISABLED_PREFIX}${cronLine}`;
87
- return [BEGIN(id), body, END(id)].join("\n");
88
+ return [BEGIN(id), cronBlockBody(cronLine, enabled), END(id)].join("\n");
89
+ }
90
+ /**
91
+ * Parse the akm-owned blocks out of a crontab, returning each task id with the
92
+ * raw body line(s) between its BEGIN/END markers. Used by `list()` to build a
93
+ * drift signature, and exported for tests.
94
+ */
95
+ export function listBlocks(existing) {
96
+ const out = [];
97
+ const lines = existing.split(/\r?\n/);
98
+ let currentId = null;
99
+ let body = [];
100
+ for (const line of lines) {
101
+ const begin = line.match(BLOCK_RE);
102
+ if (begin) {
103
+ currentId = begin[1];
104
+ body = [];
105
+ continue;
106
+ }
107
+ if (currentId !== null && line === END(currentId)) {
108
+ out.push({ id: currentId, body: body.join("\n") });
109
+ currentId = null;
110
+ body = [];
111
+ continue;
112
+ }
113
+ if (currentId !== null)
114
+ body.push(line);
115
+ }
116
+ return out;
117
+ }
118
+ /** Collapse incidental whitespace so signature comparison ignores it. */
119
+ function normalizeSignature(body) {
120
+ return body
121
+ .split(/\r?\n/)
122
+ .map((l) => l.trim())
123
+ .filter((l) => l.length > 0)
124
+ .join("\n");
88
125
  }
89
126
  export function upsertBlock(existing, id, block) {
90
127
  const trimmed = existing.replace(/\s+$/g, "");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "type": "module",
5
5
  "description": "akm (Agent Knowledge Management) — A package manager for AI agent skills, commands, tools, and knowledge. Works with Claude Code, OpenCode, Cursor, and any AI coding assistant.",
6
6
  "keywords": [