akm-cli 0.9.12 → 0.9.13

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.
@@ -5,6 +5,7 @@ import { randomUUID } from "node:crypto";
5
5
  import { NotFoundError, UsageError } from "../../core/errors.js";
6
6
  import { openStateDatabase, withImmediateTransaction } from "../../core/state-db.js";
7
7
  import { borrowScopedStateDb, withStateDbScope } from "../../core/state-db-scope.js";
8
+ import { sleepSync } from "../../runtime.js";
8
9
  import { escapeLikePattern } from "../like-pattern.js";
9
10
  import { resolveStorageLocations } from "../locations.js";
10
11
  import { insertEventOnce, insertEventStrict } from "./events-repository.js";
@@ -21,6 +22,51 @@ function assertAttemptReservationLease(input, run) {
21
22
  throw new UsageError(`Workflow run ${input.runId} engine lease expired before durable dispatch reservation.`, "RESOURCE_ALREADY_EXISTS");
22
23
  }
23
24
  }
25
+ /**
26
+ * Whether `error` is one of the specific SQLite conditions a run-lease
27
+ * statement can throw under real cross-process contention on the same row:
28
+ * `SQLITE_BUSY`/`SQLITE_LOCKED` (both drivers), or the message text a
29
+ * transient contention blip has been observed producing, "database is
30
+ * locked", "disk I/O error", or "database disk image is malformed".
31
+ * Matching on this set alone is never sufficient to call something lease
32
+ * contention — see {@link WorkflowRunsRepository.acquireEngineLease}, which
33
+ * additionally requires a fresh read confirming a live lease before
34
+ * substituting the lease-held message for the original error.
35
+ */
36
+ function isLeaseContentionSqliteError(error) {
37
+ const code = error?.code;
38
+ if (code === "SQLITE_BUSY" || code === "SQLITE_LOCKED")
39
+ return true;
40
+ const message = error instanceof Error ? error.message : String(error);
41
+ return (message.includes("database is locked") ||
42
+ message.includes("disk I/O error") ||
43
+ message.includes("database disk image is malformed"));
44
+ }
45
+ const LEASE_RETRY_ATTEMPTS = 4;
46
+ const LEASE_RETRY_BASE_DELAY_MS = 15;
47
+ /**
48
+ * Retry a single lease statement across a short, bounded set of attempts when
49
+ * it throws one of {@link isLeaseContentionSqliteError}'s conditions —
50
+ * absorbing a blip that a fresh attempt on the same connection clears on its
51
+ * own. Any other error, or the same error surviving every attempt, propagates
52
+ * unchanged; this never converts a persistent failure into a false success.
53
+ */
54
+ function runLeaseStatementWithRetry(fn) {
55
+ let lastError;
56
+ for (let attempt = 0; attempt < LEASE_RETRY_ATTEMPTS; attempt += 1) {
57
+ try {
58
+ return fn();
59
+ }
60
+ catch (error) {
61
+ if (!isLeaseContentionSqliteError(error))
62
+ throw error;
63
+ lastError = error;
64
+ if (attempt < LEASE_RETRY_ATTEMPTS - 1)
65
+ sleepSync(LEASE_RETRY_BASE_DELAY_MS * 2 ** attempt);
66
+ }
67
+ }
68
+ throw lastError;
69
+ }
24
70
  /**
25
71
  * Repository owning every raw SQL statement against `workflow_runs` and
26
72
  * `workflow_run_steps`. It is DB-location-agnostic: the lifecycle helper
@@ -365,25 +411,57 @@ export class WorkflowRunsRepository {
365
411
  * A live lease held by anyone (including a stale copy of the same holder)
366
412
  * is NOT reclaimable through this method; the single UPDATE is the whole
367
413
  * claim, so two racing invocations cannot both win.
414
+ *
415
+ * The UPDATE can throw instead of cleanly returning `changes: 0` under real
416
+ * cross-process contention on this row: a `SQLITE_BUSY`/`SQLITE_LOCKED`
417
+ * from two engines racing the same statement, occasionally surfacing as
418
+ * "database is locked" or even "database disk image is malformed" text that
419
+ * reads as corruption but is not. `runLeaseStatementWithRetry` absorbs a
420
+ * blip that a fresh attempt clears on its own. If it is still failing after
421
+ * every retry, the row is read fresh (a plain SELECT, far less likely to
422
+ * trip whatever the write hit) to get independent evidence of what is
423
+ * actually going on: a live lease there means this really was contention,
424
+ * so the caller gets the same lease-held message `akm workflow run` already
425
+ * shows for the clean (non-throwing) case, now with `RUN_LEASE_HELD`. No
426
+ * live lease — or the verifying read itself fails — means the error was
427
+ * never actually about the lease, so it is rethrown exactly as raised.
428
+ * Nothing here invents a diagnosis from error text alone or suppresses a
429
+ * genuine SQLite failure.
368
430
  */
369
431
  acquireEngineLease(runId, holder, until, now) {
370
- const result = this.db
371
- .prepare(`UPDATE workflow_runs
372
- SET engine_lease_holder = ?, engine_lease_until = ?
373
- WHERE id = ? AND status = 'active'
374
- AND (engine_lease_holder IS NULL OR engine_lease_until IS NULL OR engine_lease_until < ?)`)
375
- .run(holder, until, runId, now);
376
- return Number(result.changes) > 0;
432
+ try {
433
+ const result = runLeaseStatementWithRetry(() => this.db
434
+ .prepare(`UPDATE workflow_runs
435
+ SET engine_lease_holder = ?, engine_lease_until = ?
436
+ WHERE id = ? AND status = 'active'
437
+ AND (engine_lease_holder IS NULL OR engine_lease_until IS NULL OR engine_lease_until < ?)`)
438
+ .run(holder, until, runId, now));
439
+ return Number(result.changes) > 0;
440
+ }
441
+ catch (error) {
442
+ if (!isLeaseContentionSqliteError(error))
443
+ throw error;
444
+ const row = this.tryReadLeaseColumns(runId);
445
+ if (row?.engine_lease_holder && row.engine_lease_until && row.engine_lease_until >= now) {
446
+ throw new UsageError(`Workflow run ${runId} is already being driven by engine ${row.engine_lease_holder} ` +
447
+ `(run lease expires ${row.engine_lease_until}). A second \`akm workflow run\` would race it — ` +
448
+ `wait for that invocation to finish or for the lease to expire.`, "RUN_LEASE_HELD");
449
+ }
450
+ throw error;
451
+ }
377
452
  }
378
453
  /**
379
454
  * Extend the lease expiry — only while `holder` still owns it. Returns
380
455
  * false when the lease was lost (expired and claimed by another engine),
381
- * so the caller can stop driving instead of racing the new owner.
456
+ * so the caller can stop driving instead of racing the new owner. Wrapped
457
+ * in the same transient-error retry as {@link acquireEngineLease}; a
458
+ * renewal that still fails after retries is rethrown as-is (no confirmed
459
+ * "lost lease" diagnosis to substitute, unlike the acquire case above).
382
460
  */
383
461
  renewEngineLease(runId, holder, until) {
384
- const result = this.db
462
+ const result = runLeaseStatementWithRetry(() => this.db
385
463
  .prepare("UPDATE workflow_runs SET engine_lease_until = ? WHERE id = ? AND engine_lease_holder = ? AND status = 'active'")
386
- .run(until, runId, holder);
464
+ .run(until, runId, holder));
387
465
  return Number(result.changes) > 0;
388
466
  }
389
467
  /**
@@ -396,6 +474,36 @@ export class WorkflowRunsRepository {
396
474
  .prepare("UPDATE workflow_runs SET engine_lease_holder = NULL, engine_lease_until = NULL WHERE id = ? AND engine_lease_holder = ? AND status <> 'failed'")
397
475
  .run(runId, holder);
398
476
  }
477
+ /**
478
+ * Self-heal an engine lease its holder crashed without releasing: once
479
+ * `engine_lease_until` has passed, clear it so a read (`workflow status`,
480
+ * `workflow list`) stops reporting a run as engine-driven when the engine is
481
+ * long gone — mirroring the maintenance barrier's self-reclaim of a wedged
482
+ * sentinel (`tryAcquireMaintenanceBarrier`) rather than a bespoke mechanism.
483
+ * The WHERE clause repeats the exact (holder, until) snapshot the caller
484
+ * read, so a lease renewed or re-acquired in between never gets clobbered —
485
+ * same compare-and-swap shape as the claim above. Never touches a lease
486
+ * that is still live.
487
+ */
488
+ reclaimExpiredEngineLease(runId, holder, until, now) {
489
+ if (until >= now)
490
+ return false;
491
+ const result = this.db
492
+ .prepare(`UPDATE workflow_runs
493
+ SET engine_lease_holder = NULL, engine_lease_until = NULL
494
+ WHERE id = ? AND engine_lease_holder = ? AND engine_lease_until = ? AND engine_lease_until < ?`)
495
+ .run(runId, holder, until, now);
496
+ return Number(result.changes) > 0;
497
+ }
498
+ /** Best-effort lease-column read used only to confirm genuine contention after {@link acquireEngineLease} exhausts its retries. `undefined` on any failure — never a diagnosis, just "couldn't confirm". */
499
+ tryReadLeaseColumns(runId) {
500
+ try {
501
+ return (this.db.prepare("SELECT engine_lease_holder, engine_lease_until FROM workflow_runs WHERE id = ?").get(runId) ?? undefined);
502
+ }
503
+ catch {
504
+ return undefined;
505
+ }
506
+ }
399
507
  // ── durable v4 append-only dispatch attempts (migration 022) ─────────────
400
508
  getUnitAttempts(runId, unitId) {
401
509
  return this.db
@@ -194,7 +194,7 @@ async function acquireRunLease(runId, holder) {
194
194
  const row = repo.getRunById(runId);
195
195
  throw new UsageError(`Workflow run ${runId} is already being driven by engine ${row?.engine_lease_holder ?? "(unknown)"} ` +
196
196
  `(run lease expires ${row?.engine_lease_until ?? "(unknown)"}). A second \`akm workflow run\` would race it — ` +
197
- `wait for that invocation to finish or for the lease to expire.`);
197
+ `wait for that invocation to finish or for the lease to expire.`, "RUN_LEASE_HELD");
198
198
  }));
199
199
  }
200
200
  /**
@@ -18,6 +18,7 @@ import { createHash, randomUUID } from "node:crypto";
18
18
  import unitPreambleTemplate from "../../assets/prompts/workflow-unit-preamble.md" with { type: "text" };
19
19
  import { UsageError } from "../../core/errors.js";
20
20
  import { validateJsonSchemaSubset } from "../../core/json-schema.js";
21
+ import { parseEmbeddedJsonResponse } from "../../core/parse.js";
21
22
  import { canonicalInputJson, validateInputs } from "../../execution/input-contract.js";
22
23
  import { withWorkflowRunsRepo, } from "../../storage/repositories/workflow-runs-repository.js";
23
24
  import { canonicalJson } from "../ir/plan-hash.js";
@@ -599,6 +600,41 @@ export function validateStepArtifact(plan, evidence) {
599
600
  return (`Step "${plan.stepId}" artifact failed validation against the step's declared output schema: ` +
600
601
  `${errors.join("; ")}.`);
601
602
  }
603
+ /**
604
+ * Warn-only check of each successful unit's own promoted value against its
605
+ * template's declared `schema` (`unit.output`) — the one field a harness that
606
+ * cannot request structured output drops during lowering (`untranslated-field`,
607
+ * field `outputSchema`), after which nothing else ever compares the returned
608
+ * text to it. Unlike {@link validateStepArtifact} this never fails the step:
609
+ * a harness that DID honor the schema already returned a compliant `result`
610
+ * (this re-check then finds nothing), and one that could not is exactly the
611
+ * case this exists to surface — the run continues either way.
612
+ */
613
+ export function unitSchemaWarning(plan, units) {
614
+ const schema = stepTemplate(plan)?.schema;
615
+ if (!schema)
616
+ return undefined;
617
+ const mismatches = [];
618
+ for (const unit of units) {
619
+ if (!unit.ok)
620
+ continue;
621
+ const candidate = unit.result !== undefined
622
+ ? unit.result
623
+ : unit.text !== undefined
624
+ ? parseEmbeddedJsonResponse(unit.text)
625
+ : undefined;
626
+ if (candidate === undefined) {
627
+ mismatches.push(`unit "${unit.unitId}" produced no structured output to check`);
628
+ continue;
629
+ }
630
+ const errors = validateJsonSchemaSubset(candidate, schema);
631
+ if (errors.length > 0)
632
+ mismatches.push(`unit "${unit.unitId}": ${errors.join("; ")}`);
633
+ }
634
+ if (mismatches.length === 0)
635
+ return undefined;
636
+ return `Output does not match the unit's declared schema (advisory; the run continued): ${mismatches.join("; ")}.`;
637
+ }
602
638
  /**
603
639
  * Build the summary the completion-criteria gate judges for a step (addendum
604
640
  * R2, "typed artifacts, honest gates"): a one-line unit count followed by the
@@ -743,6 +779,11 @@ export function reduceStepOutcomes(plan, reducer, isFanOut, onError, units) {
743
779
  artifactSchemaFailure = true;
744
780
  }
745
781
  }
782
+ if (!artifactSchemaFailure) {
783
+ const schemaWarning = unitSchemaWarning(plan, units);
784
+ if (schemaWarning !== undefined)
785
+ summary += ` ${schemaWarning}`;
786
+ }
746
787
  // P3b §3.4: a composed child workflow that blocked is carried on the
747
788
  // failed unit's LIVE-ONLY `childRun` field (child-workflow.ts's
748
789
  // driveChildWorkflowUnit). Surfaced here, unconditionally on the unit
@@ -249,7 +249,7 @@ function bindStepSections(headings, lines, bodyStartLine, totalLines, path, decl
249
249
  if (!declaredIds.has(h.text)) {
250
250
  errors.push({
251
251
  line: h.line,
252
- message: `Unexpected level-2 heading "## ${h.text}" on line ${h.line} — no step "${h.text}" is declared in frontmatter "steps:". Level-2 headings must exactly match a declared step id.`,
252
+ message: `Unexpected level-2 heading "## ${h.text}" on line ${h.line} — no step "${h.text}" is declared in frontmatter "steps:". Level-2 headings must exactly match a declared step id. To document something that is not a step, use a level-3 heading.`,
253
253
  });
254
254
  continue;
255
255
  }
@@ -637,14 +637,33 @@ function readWorkflowRunOrPrefix(repo, specifier) {
637
637
  const run = findRunByIdOrPrefix(repo, specifier);
638
638
  if (!run)
639
639
  throw new NotFoundError(`Workflow run "${specifier}" not found.`, "WORKFLOW_NOT_FOUND");
640
- return run;
640
+ return reclaimOrphanedEngineLease(repo, run);
641
641
  }
642
642
  function readWorkflowRun(repo, runId) {
643
643
  const run = repo.getRunById(runId);
644
644
  if (!run) {
645
645
  throw new NotFoundError(`Workflow run "${runId}" not found.`, "WORKFLOW_NOT_FOUND");
646
646
  }
647
- return run;
647
+ return reclaimOrphanedEngineLease(repo, run);
648
+ }
649
+ /**
650
+ * Self-heal a run's engine lease once it has expired — the orphaned-lease
651
+ * case where an engine crashed without releasing it — mirroring the
652
+ * maintenance barrier's self-reclaim of a wedged sentinel, applied at the
653
+ * points a caller actually asks "what is this run's state". Never touches a
654
+ * live lease: {@link WorkflowRunsRepository.reclaimExpiredEngineLease} is a
655
+ * compare-and-swap on the exact (holder, until) this call observed, so a
656
+ * lease renewed or re-acquired between the read and this write is left alone.
657
+ */
658
+ function reclaimOrphanedEngineLease(repo, run) {
659
+ const { engine_lease_holder: holder, engine_lease_until: until } = run;
660
+ if (!holder || !until)
661
+ return run;
662
+ const now = new Date().toISOString();
663
+ if (until >= now)
664
+ return run;
665
+ repo.reclaimExpiredEngineLease(run.id, holder, until, now);
666
+ return { ...run, engine_lease_holder: null, engine_lease_until: null };
648
667
  }
649
668
  function readWorkflowRunSteps(repo, runId) {
650
669
  return repo.getStepsForRun(runId);
@@ -758,8 +777,13 @@ function toWorkflowRunSummary(run) {
758
777
  executionSupport: plan.support,
759
778
  // Surface the engine lease (holder id + expiry — never workflow-authored
760
779
  // content) so `workflow run`/`status` show which native execution
761
- // invocation currently holds the run lease.
762
- ...(run.engine_lease_holder && run.engine_lease_until
780
+ // invocation currently holds the run lease. Gated on `until` still being
781
+ // in the future: a crashed engine's lease self-expires, and a run
782
+ // whose holder is provably gone must stop reading as engine-driven the
783
+ // instant that happens, not just once something next attempts to acquire
784
+ // it (`readWorkflowRun`/`readWorkflowRunOrPrefix` also reclaim the DB
785
+ // columns outright on the same condition).
786
+ ...(run.engine_lease_holder && run.engine_lease_until && run.engine_lease_until >= new Date().toISOString()
763
787
  ? { engineLease: { holder: run.engine_lease_holder, until: run.engine_lease_until } }
764
788
  : {}),
765
789
  // P3b (spec §4.5): all three optional and conditionally spread, so every
@@ -786,7 +810,7 @@ function assertLeaseAllowsSpineAdvance(run, leaseHolder) {
786
810
  return; // expired ⇒ claimable, not live
787
811
  throw new UsageError(`Workflow run ${run.id} is being driven by engine ${run.engine_lease_holder} ` +
788
812
  `(run lease expires ${run.engine_lease_until}). The engine owns the step spine while it runs — ` +
789
- `wait for it to finish or for the lease to expire before advancing steps manually.`);
813
+ `wait for it to finish or for the lease to expire before advancing steps manually.`, "RUN_LEASE_HELD");
790
814
  }
791
815
  function toWorkflowRunStepState(step) {
792
816
  return {
@@ -79,6 +79,24 @@ For local materialized assets, `editHint` is added only when `editable` is
79
79
  or use `action` (or curate `followUp`). Registry-only results have no local
80
80
  `path`, `editable`, or `editHint`.
81
81
 
82
+ ### The `results` collection alias
83
+
84
+ Every list-returning command names its collection field differently —
85
+ `search` returns `hits`, `curate` returns `items`, `proposal list` returns
86
+ `proposals`, `bundle list` returns `sources`, `env list` returns `envs`,
87
+ `secret list` returns `secrets`, `registry list` returns `registries`,
88
+ `registry search` returns `hits`, `workflow list` returns `runs`,
89
+ `task history` returns `rows`, `log list` returns `events`. A caller that does
90
+ not already know each command's key cannot write one accessor across all of
91
+ them.
92
+
93
+ Every one of these commands also carries a `results` field — the identical
94
+ array, not a copy — alongside its semantic key, in every `--format`/`--detail`
95
+ combination and both `--shape human` (the default) and `--shape agent`. Code
96
+ written against a single command should keep using its semantic key for
97
+ clarity; code that needs to handle several list commands uniformly can read
98
+ `results` and never maintain a per-command lookup table.
99
+
82
100
  ### `--shape summary`
83
101
 
84
102
  Valid **only on `akm show`**. Every other command rejects `--shape summary`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.9.12",
3
+ "version": "0.9.13",
4
4
  "type": "module",
5
5
  "description": "akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cursor.",
6
6
  "keywords": [