akm-cli 0.9.0-rc.2 → 0.9.0-rc.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.
Files changed (115) hide show
  1. package/CHANGELOG.md +40 -12
  2. package/README.md +7 -8
  3. package/SECURITY.md +5 -3
  4. package/dist/akm +44 -33
  5. package/dist/akm-migrate-storage +44 -33
  6. package/dist/assets/backends/schtasks-template.xml +2 -1
  7. package/dist/assets/improve-strategies/catchup.json +3 -1
  8. package/dist/assets/improve-strategies/consolidate.json +3 -1
  9. package/dist/assets/improve-strategies/frequent.json +3 -1
  10. package/dist/assets/improve-strategies/graph-refresh.json +3 -1
  11. package/dist/assets/improve-strategies/memory-focus.json +4 -1
  12. package/dist/assets/improve-strategies/proactive-maintenance.json +1 -0
  13. package/dist/assets/improve-strategies/quick.json +3 -1
  14. package/dist/assets/improve-strategies/recombine-only.json +2 -0
  15. package/dist/assets/improve-strategies/reflect-distill.json +1 -0
  16. package/dist/assets/improve-strategies/synthesize.json +2 -0
  17. package/dist/assets/tasks/core/backup.yml +2 -2
  18. package/dist/cli/config-migrate.js +554 -26
  19. package/dist/cli.js +28 -11
  20. package/dist/commands/backup-cli.js +6 -4
  21. package/dist/commands/config-cli.js +10 -3
  22. package/dist/commands/feedback-cli.js +24 -7
  23. package/dist/commands/health/checks.js +94 -15
  24. package/dist/commands/health/html-report.js +23 -2
  25. package/dist/commands/health/improve-metrics.js +45 -6
  26. package/dist/commands/health/md-report.js +10 -1
  27. package/dist/commands/health/surfaces.js +2 -1
  28. package/dist/commands/health/windows.js +1 -1
  29. package/dist/commands/health.js +1 -1
  30. package/dist/commands/improve/anti-collapse.js +3 -3
  31. package/dist/commands/improve/collapse-detector.js +5 -5
  32. package/dist/commands/improve/consolidate.js +123 -66
  33. package/dist/commands/improve/distill/promote-memory.js +8 -6
  34. package/dist/commands/improve/distill/quality-gate.js +1 -1
  35. package/dist/commands/improve/distill-guards.js +1 -1
  36. package/dist/commands/improve/distill-promotion-policy.js +32 -26
  37. package/dist/commands/improve/distill.js +52 -36
  38. package/dist/commands/improve/eligibility.js +9 -8
  39. package/dist/commands/improve/extract-prompt.js +2 -2
  40. package/dist/commands/improve/extract.js +43 -11
  41. package/dist/commands/improve/improve-auto-accept.js +14 -28
  42. package/dist/commands/improve/improve-cli.js +4 -2
  43. package/dist/commands/improve/improve-strategies.js +2 -1
  44. package/dist/commands/improve/improve.js +49 -8
  45. package/dist/commands/improve/loop-stages.js +11 -6
  46. package/dist/commands/improve/preparation.js +51 -17
  47. package/dist/commands/improve/procedural.js +10 -6
  48. package/dist/commands/improve/recombine.js +31 -7
  49. package/dist/commands/improve/reflect.js +24 -14
  50. package/dist/commands/improve/salience.js +5 -1
  51. package/dist/commands/improve/source-identity.js +43 -0
  52. package/dist/commands/migrate-cli.js +36 -0
  53. package/dist/commands/mv-cli.js +647 -258
  54. package/dist/commands/proposal/drain.js +16 -4
  55. package/dist/commands/proposal/proposal-cli.js +3 -2
  56. package/dist/commands/proposal/proposal.js +16 -55
  57. package/dist/commands/proposal/repository.js +664 -58
  58. package/dist/commands/read/curate.js +4 -2
  59. package/dist/commands/read/knowledge.js +4 -1
  60. package/dist/commands/read/search.js +6 -2
  61. package/dist/commands/read/show.js +8 -7
  62. package/dist/commands/sources/self-update.js +156 -112
  63. package/dist/commands/sources/sources-cli.js +7 -2
  64. package/dist/commands/tasks/default-tasks.js +5 -5
  65. package/dist/commands/tasks/tasks-cli.js +7 -3
  66. package/dist/commands/tasks/tasks.js +261 -68
  67. package/dist/core/common.js +36 -3
  68. package/dist/core/config/config-io.js +2 -2
  69. package/dist/core/config/config-schema.js +15 -3
  70. package/dist/core/config/config.js +14 -13
  71. package/dist/core/file-lock.js +2 -2
  72. package/dist/core/improve-result.js +87 -7
  73. package/dist/core/migration-backup.js +816 -171
  74. package/dist/core/migration-operation.js +44 -0
  75. package/dist/core/state/migrations.js +4 -7
  76. package/dist/core/state-db.js +23 -7
  77. package/dist/core/write-source.js +86 -18
  78. package/dist/indexer/db/db.js +109 -53
  79. package/dist/indexer/index-writer-lock.js +38 -37
  80. package/dist/indexer/index-written-assets.js +73 -56
  81. package/dist/indexer/indexer.js +5 -1
  82. package/dist/indexer/search/search-source.js +2 -2
  83. package/dist/indexer/usage/usage-events.js +8 -2
  84. package/dist/integrations/agent/engine-resolution.js +14 -7
  85. package/dist/scripts/migrate-storage.js +887 -992
  86. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +494 -574
  87. package/dist/setup/detected-engines.js +7 -13
  88. package/dist/setup/engine-config.js +14 -4
  89. package/dist/setup/setup.js +1 -1
  90. package/dist/setup/steps/connection.js +1 -1
  91. package/dist/setup/steps/tasks.js +1 -1
  92. package/dist/sources/providers/git-stash.js +58 -21
  93. package/dist/sources/providers/git.js +1 -1
  94. package/dist/storage/engines/sqlite-migrations.js +138 -3
  95. package/dist/storage/repositories/events-repository.js +24 -0
  96. package/dist/storage/repositories/proposals-repository.js +14 -0
  97. package/dist/storage/repositories/task-history-repository.js +30 -3
  98. package/dist/tasks/backends/cron.js +71 -38
  99. package/dist/tasks/backends/exec-utils.js +55 -3
  100. package/dist/tasks/backends/launchd.js +241 -50
  101. package/dist/tasks/backends/schtasks.js +434 -59
  102. package/dist/tasks/command-executable.js +93 -0
  103. package/dist/tasks/embedded.js +2 -0
  104. package/dist/tasks/parser.js +142 -6
  105. package/dist/tasks/resolve-akm-bin.js +19 -4
  106. package/dist/tasks/runner.js +258 -93
  107. package/dist/tasks/schedule.js +108 -19
  108. package/dist/tasks/scheduler-invocation.js +86 -0
  109. package/dist/tasks/task-id.js +37 -0
  110. package/dist/workflows/db.js +24 -13
  111. package/dist/workflows/validator.js +24 -2
  112. package/docs/README.md +103 -0
  113. package/docs/migration/release-notes/0.9.0.md +28 -9
  114. package/docs/migration/v0.8-to-v0.9.md +186 -26
  115. package/package.json +2 -3
@@ -12,7 +12,8 @@
12
12
  * `tasks sync` time, not here — at run time we still want to attempt
13
13
  * execution and surface the actual failure rather than re-fail on a
14
14
  * validation error that the user already knows about.)
15
- * 3. Refuse to run when `enabled === false` (defense-in-depth).
15
+ * 3. Skip disabled tasks only when the invocation is scheduler-generated;
16
+ * explicit manual runs are allowed for catch-up and testing.
16
17
  * 4. Dispatch by target kind:
17
18
  * • workflow → `startWorkflowRun(ref, params)`
18
19
  * • prompt → `executeRunner(engine, prompt, { stdio: "captured" })`
@@ -25,97 +26,135 @@
25
26
  * and so tests can assert against it without scraping stdout.
26
27
  */
27
28
  import fs from "node:fs";
29
+ import os from "node:os";
28
30
  import path from "node:path";
29
31
  import { assertNever } from "../core/assert.js";
30
32
  import { parseAssetRef } from "../core/asset/asset-ref.js";
31
33
  import { resolveStashDir } from "../core/common.js";
32
34
  import { loadConfig } from "../core/config/config.js";
33
- import { NotFoundError, rethrowIfTestIsolationError } from "../core/errors.js";
35
+ import { AkmError, NotFoundError, rethrowIfTestIsolationError } from "../core/errors.js";
34
36
  import { buildTaskRunId, insertTaskLogLines, openLogsDatabase, } from "../core/logs-db.js";
35
37
  import { getTaskLogDir } from "../core/paths.js";
36
38
  import { withStateDb } from "../core/state-db.js";
37
- import { error } from "../core/warn.js";
38
39
  import { resolveEngine, resolveLlmEngineUse } from "../integrations/agent/engine-resolution.js";
39
40
  import { resolveModel } from "../integrations/agent/model-aliases.js";
40
41
  import { executeRunner } from "../integrations/agent/runner-dispatch.js";
41
42
  import { chatCompletion } from "../llm/client.js";
42
43
  import { spawn } from "../runtime.js";
43
44
  import { resolveAssetPath } from "../sources/resolve.js";
44
- import { decodeTaskHistoryMetadata, getTaskHistory, queryTaskHistory, upsertTaskHistory, } from "../storage/repositories/task-history-repository.js";
45
+ import { decodeTaskHistoryMetadata, finalizeTaskHistoryAttempt, getTaskHistory, queryTaskHistory, reserveTaskHistoryAttempt, upsertTaskHistory, } from "../storage/repositories/task-history-repository.js";
45
46
  import { startWorkflowRun } from "../workflows/runtime/runs.js";
47
+ import { findBareAkmExecutableIndex } from "./command-executable.js";
46
48
  import { parseTaskDocument } from "./parser.js";
49
+ import { resolveAkmInvocation } from "./resolve-akm-bin.js";
50
+ import { validateTaskId } from "./task-id.js";
51
+ export const INVALID_TASK_ATTEMPT_ID = "_invalid-task-id";
47
52
  export async function runTask(id, options = {}) {
48
- const stashDir = options.stashDir ?? resolveStashDir();
49
53
  const runAgentImpl = options.runAgentImpl;
50
54
  const startWorkflowRunImpl = options.startWorkflowRunImpl ?? startWorkflowRun;
51
55
  const now = options.now ?? (() => new Date());
52
- const logDir = options.logDir ?? getTaskLogDir();
53
- const filePath = await resolveAssetPath(stashDir, "task", id);
54
- const yaml = fs.readFileSync(filePath, "utf8");
55
- const task = parseTaskDocument({ yaml, filePath, id });
56
- const startedAt = now();
57
- const startedIso = startedAt.toISOString();
58
- const tsSlug = startedIso.replace(/[:.]/g, "-");
59
- const taskLogDir = path.join(logDir, id);
60
- fs.mkdirSync(taskLogDir, { recursive: true });
61
- const logPath = path.join(taskLogDir, `${tsSlug}.log`);
62
- if (!task.enabled) {
63
- const finishedAt = now();
64
- const disabledTarget = task.target.kind === "workflow"
65
- ? { kind: "workflow", ref: task.target.ref }
66
- : task.target.kind === "command"
67
- ? { kind: "command", cmd: task.target.cmd }
68
- : { kind: "prompt", engine: task.target.engine ?? null };
69
- const result = {
70
- id,
71
- status: "disabled",
72
- startedAt: startedIso,
73
- finishedAt: finishedAt.toISOString(),
74
- durationMs: finishedAt.getTime() - startedAt.getTime(),
75
- log: logPath,
76
- target: disabledTarget,
77
- };
78
- const disabledLine = `[akm tasks] task "${id}" is disabled — skipping run.`;
79
- persistRunLog({
80
- taskId: id,
81
- startedAtIso: startedIso,
82
- finishedAtIso: result.finishedAt,
83
- logPath,
84
- fileText: `${disabledLine}\n`,
85
- dbLines: [{ line: disabledLine }],
56
+ const requestedStartedAt = now();
57
+ try {
58
+ validateTaskId(id);
59
+ }
60
+ catch (failure) {
61
+ const attempt = reserveTaskAttempt(INVALID_TASK_ATTEMPT_ID, requestedStartedAt);
62
+ recordTaskAttemptFailure({
63
+ taskId: INVALID_TASK_ATTEMPT_ID,
64
+ reason: "invalid_task_id",
65
+ failure,
66
+ startedAt: attempt.startedAt,
67
+ finishedAt: now(),
68
+ logDir: options.logDir,
69
+ historyReserved: attempt.historyReserved,
86
70
  });
87
- appendHistory(result);
88
- return result;
71
+ throw failure;
89
72
  }
90
- if (task.target.kind === "workflow") {
91
- return await runWorkflowTask({
73
+ const attempt = reserveTaskAttempt(id, requestedStartedAt);
74
+ const startedAt = attempt.startedAt;
75
+ let failureReason = "task_load_failed";
76
+ try {
77
+ const stashDir = options.stashDir ?? resolveStashDir();
78
+ const filePath = await resolveAssetPath(stashDir, "task", id);
79
+ const yaml = fs.readFileSync(filePath, "utf8");
80
+ failureReason = "task_parse_failed";
81
+ const task = parseTaskDocument({ yaml, filePath, id });
82
+ failureReason = "task_dispatch_failed";
83
+ const startedIso = startedAt.toISOString();
84
+ const logPath = resolveTaskLogPath(options.logDir, id, startedIso);
85
+ if (!task.enabled && options.scheduled === true) {
86
+ const finishedAt = finishAttempt(startedAt, now());
87
+ const disabledTarget = task.target.kind === "workflow"
88
+ ? { kind: "workflow", ref: task.target.ref }
89
+ : task.target.kind === "command"
90
+ ? { kind: "command", cmd: task.target.cmd }
91
+ : { kind: "prompt", engine: task.target.engine ?? null };
92
+ const result = {
93
+ id,
94
+ status: "disabled",
95
+ startedAt: startedIso,
96
+ finishedAt: finishedAt.toISOString(),
97
+ durationMs: finishedAt.getTime() - startedAt.getTime(),
98
+ log: logPath,
99
+ target: disabledTarget,
100
+ };
101
+ const disabledLine = `[akm tasks] task "${id}" is disabled — skipping run.`;
102
+ persistRunLog({
103
+ taskId: id,
104
+ startedAtIso: startedIso,
105
+ finishedAtIso: result.finishedAt,
106
+ logPath,
107
+ fileText: `${disabledLine}\n`,
108
+ dbLines: [{ line: disabledLine }],
109
+ });
110
+ appendHistory(result, attempt.historyReserved);
111
+ return result;
112
+ }
113
+ if (task.target.kind === "workflow") {
114
+ return await runWorkflowTask({
115
+ task,
116
+ logPath,
117
+ startedAt,
118
+ now,
119
+ startWorkflowRunImpl,
120
+ historyReserved: attempt.historyReserved,
121
+ });
122
+ }
123
+ if (task.target.kind === "command") {
124
+ return await runCommandTask({ task, logPath, startedAt, now, historyReserved: attempt.historyReserved });
125
+ }
126
+ return await runPromptTask({
92
127
  task,
128
+ stashDir,
93
129
  logPath,
94
130
  startedAt,
95
131
  now,
96
- startWorkflowRunImpl,
132
+ runAgentImpl,
133
+ agentOptions: options.agentOptions,
134
+ chatCompletionImpl: options.chatCompletionImpl ?? chatCompletion,
135
+ historyReserved: attempt.historyReserved,
97
136
  });
98
137
  }
99
- if (task.target.kind === "command") {
100
- return await runCommandTask({ task, logPath, startedAt, now });
138
+ catch (failure) {
139
+ recordTaskAttemptFailure({
140
+ taskId: id,
141
+ reason: failureReason,
142
+ failure,
143
+ startedAt,
144
+ finishedAt: now(),
145
+ logDir: options.logDir,
146
+ historyReserved: attempt.historyReserved,
147
+ });
148
+ throw failure;
101
149
  }
102
- return await runPromptTask({
103
- task,
104
- stashDir,
105
- logPath,
106
- startedAt,
107
- now,
108
- runAgentImpl,
109
- agentOptions: options.agentOptions,
110
- chatCompletionImpl: options.chatCompletionImpl ?? chatCompletion,
111
- });
112
150
  }
113
151
  // ── command target ──────────────────────────────────────────────────────────
114
152
  async function runCommandTask(input) {
115
- const { task, logPath, startedAt, now } = input;
153
+ const { task, logPath, startedAt, now, historyReserved } = input;
116
154
  if (task.target.kind !== "command")
117
155
  throw new Error("invariant: command target");
118
156
  const { cmd } = task.target;
157
+ const spawnCmd = resolveNestedAkmCommand(cmd);
119
158
  const timeoutMs = task.timeoutMs !== undefined ? task.timeoutMs : null;
120
159
  const header = `[akm tasks] task=${task.id} kind=command cmd=${cmd.join(" ")}`;
121
160
  const logLines = [header];
@@ -124,11 +163,11 @@ async function runCommandTask(input) {
124
163
  let stderr = "";
125
164
  let exitCode = null;
126
165
  try {
127
- const proc = spawn(cmd, {
166
+ const proc = spawn(spawnCmd, {
128
167
  stdin: "ignore",
129
168
  stdout: "pipe",
130
169
  stderr: "pipe",
131
- cwd: process.env.HOME ?? "/tmp",
170
+ cwd: process.env.HOME ?? os.tmpdir(),
132
171
  // Stamp task-runner provenance so any akm invocation in the command tree
133
172
  // records usage events as machine traffic, not user demand (DRIFT-6).
134
173
  // A more specific stamp already in the environment (e.g. improve's
@@ -181,7 +220,7 @@ async function runCommandTask(input) {
181
220
  dbLines.push({ level: "error", line: `spawn_error=${msg}` });
182
221
  exitCode = 1;
183
222
  }
184
- const finishedAt = now();
223
+ const finishedAt = finishAttempt(startedAt, now());
185
224
  persistRunLog({
186
225
  taskId: task.id,
187
226
  startedAtIso: startedAt.toISOString(),
@@ -201,12 +240,19 @@ async function runCommandTask(input) {
201
240
  target: { kind: "command", cmd },
202
241
  detail: { exitCode },
203
242
  };
204
- appendHistory(result);
243
+ appendHistory(result, historyReserved);
205
244
  return result;
206
245
  }
246
+ /** Avoid a second PATH lookup when a task invokes the same AKM installation. */
247
+ function resolveNestedAkmCommand(cmd) {
248
+ const akmIndex = findBareAkmExecutableIndex(cmd);
249
+ if (akmIndex === undefined)
250
+ return cmd;
251
+ return [...cmd.slice(0, akmIndex), ...resolveAkmInvocation().argv, ...cmd.slice(akmIndex + 1)];
252
+ }
207
253
  // ── workflow target ─────────────────────────────────────────────────────────
208
254
  async function runWorkflowTask(input) {
209
- const { task, logPath, startedAt, now, startWorkflowRunImpl } = input;
255
+ const { task, logPath, startedAt, now, startWorkflowRunImpl, historyReserved } = input;
210
256
  if (task.target.kind !== "workflow")
211
257
  throw new Error("invariant: workflow target");
212
258
  const ref = parseAssetRef(task.target.ref);
@@ -219,9 +265,11 @@ async function runWorkflowTask(input) {
219
265
  detail = await startWorkflowRunImpl(task.target.ref, task.target.params);
220
266
  }
221
267
  catch (e) {
268
+ if (e instanceof AkmError && e.kind === "config")
269
+ throw e;
222
270
  error = e instanceof Error ? e : new Error(String(e));
223
271
  }
224
- const finishedAt = now();
272
+ const finishedAt = finishAttempt(startedAt, now());
225
273
  const status = error ? "failed" : mapWorkflowStatus(detail?.run.status);
226
274
  const log = renderWorkflowLog({ task, detail, error });
227
275
  persistRunLog({
@@ -245,7 +293,7 @@ async function runWorkflowTask(input) {
245
293
  ...(error ? { error: error.message } : {}),
246
294
  },
247
295
  };
248
- appendHistory(result);
296
+ appendHistory(result, historyReserved);
249
297
  // Don't re-throw on workflow failure: the OS scheduler reads exit codes,
250
298
  // not exceptions, and the CLI maps `status: "failed"` to a non-zero exit
251
299
  // via exitCodeForStatus(). Throwing here would route through the generic
@@ -359,7 +407,7 @@ async function runPromptTask(input) {
359
407
  return { ok: true, exitCode: 0, stdout, stderr: "", durationMs: Date.now() - started };
360
408
  },
361
409
  });
362
- const finishedAt = now();
410
+ const finishedAt = finishAttempt(startedAt, now());
363
411
  const log = renderPromptLog({ task, engineName, result });
364
412
  persistRunLog({
365
413
  taskId: task.id,
@@ -382,7 +430,7 @@ async function runPromptTask(input) {
382
430
  ? { exitCode: result.exitCode }
383
431
  : { reason: result.reason, error: result.error, exitCode: result.exitCode },
384
432
  };
385
- appendHistory(out);
433
+ appendHistory(out, input.historyReserved);
386
434
  return out;
387
435
  }
388
436
  async function resolvePromptText(task, stashDir) {
@@ -428,6 +476,19 @@ function renderPromptLog(input) {
428
476
  }
429
477
  return { fileText: `${lines.join("\n")}\n`, dbLines };
430
478
  }
479
+ function taskLogPath(logDir, taskId, startedAtIso) {
480
+ const tsSlug = startedAtIso.replace(/[:.]/g, "-");
481
+ return path.join(logDir, taskId, `${tsSlug}.log`);
482
+ }
483
+ function resolveTaskLogPath(logDir, taskId, startedAtIso) {
484
+ try {
485
+ return taskLogPath(logDir ?? getTaskLogDir(), taskId, startedAtIso);
486
+ }
487
+ catch (error) {
488
+ rethrowIfTestIsolationError(error);
489
+ return "";
490
+ }
491
+ }
431
492
  /** Split captured pipe output into per-line logs.db rows (blank lines dropped). */
432
493
  function streamLines(text, stream, level) {
433
494
  return text
@@ -444,7 +505,16 @@ function streamLines(text, stream, level) {
444
505
  * logs.db must never fail a task run.
445
506
  */
446
507
  function persistRunLog(input) {
447
- fs.writeFileSync(input.logPath, input.fileText);
508
+ if (input.logPath) {
509
+ try {
510
+ fs.mkdirSync(path.dirname(input.logPath), { recursive: true });
511
+ fs.writeFileSync(input.logPath, input.fileText);
512
+ }
513
+ catch (error) {
514
+ rethrowIfTestIsolationError(error);
515
+ // Transitional file logging is fully best-effort.
516
+ }
517
+ }
448
518
  try {
449
519
  const db = openLogsDatabase();
450
520
  try {
@@ -459,36 +529,129 @@ function persistRunLog(input) {
459
529
  db.close();
460
530
  }
461
531
  }
462
- catch (err) {
463
- rethrowIfTestIsolationError(err);
464
- error(`[akm] task log DB write failed: ${String(err)}`);
532
+ catch (error) {
533
+ rethrowIfTestIsolationError(error);
534
+ // Structured logging is fully best-effort and must not alter CLI output.
535
+ }
536
+ }
537
+ /** Reserve a collision-free identity through state.db's existing unique index. */
538
+ function reserveTaskAttempt(taskId, requestedStartedAt) {
539
+ try {
540
+ return withStateDb((db) => {
541
+ for (let offsetMs = 0;; offsetMs++) {
542
+ const startedAt = new Date(requestedStartedAt.getTime() + offsetMs);
543
+ const reserved = reserveTaskHistoryAttempt(db, {
544
+ task_id: taskId,
545
+ status: "active",
546
+ started_at: startedAt.toISOString(),
547
+ completed_at: null,
548
+ failed_at: null,
549
+ log_path: null,
550
+ target_kind: null,
551
+ target_ref: null,
552
+ metadata_json: JSON.stringify({ metadataVersion: 2, durationMs: 0, detail: null }),
553
+ });
554
+ if (reserved)
555
+ return { startedAt, historyReserved: true };
556
+ }
557
+ });
558
+ }
559
+ catch (error) {
560
+ rethrowIfTestIsolationError(error);
561
+ // Attempt recording cannot prevent or replace task execution.
562
+ return { startedAt: requestedStartedAt, historyReserved: false };
563
+ }
564
+ }
565
+ function finishAttempt(startedAt, observedFinishedAt) {
566
+ return observedFinishedAt.getTime() < startedAt.getTime() ? new Date(startedAt) : observedFinishedAt;
567
+ }
568
+ const SAFE_TASK_ATTEMPT_ERROR_CODES = new Set([
569
+ "CONFIG_DIR_UNRESOLVABLE",
570
+ "STASH_DIR_NOT_FOUND",
571
+ "STASH_DIR_NOT_A_DIRECTORY",
572
+ "STASH_DIR_UNREADABLE",
573
+ "LLM_NOT_CONFIGURED",
574
+ "INVALID_CONFIG_FILE",
575
+ "UNSUPPORTED_CONFIG_VERSION",
576
+ "TEST_ISOLATION_MISSING",
577
+ "INVALID_FLAG_VALUE",
578
+ "MISSING_REQUIRED_ARGUMENT",
579
+ "PATH_ESCAPE_VIOLATION",
580
+ "TASK_SCHEMA_VERSION_UNSUPPORTED",
581
+ "ASSET_NOT_FOUND",
582
+ "WORKFLOW_NOT_FOUND",
583
+ "FILE_NOT_FOUND",
584
+ ]);
585
+ function safeTaskAttemptErrorCode(failure) {
586
+ if (failure instanceof AkmError && SAFE_TASK_ATTEMPT_ERROR_CODES.has(failure.code))
587
+ return failure.code;
588
+ return "INTERNAL";
589
+ }
590
+ export function recordTaskAttemptFailure(input) {
591
+ let taskId = input.taskId;
592
+ try {
593
+ validateTaskId(taskId);
594
+ }
595
+ catch {
596
+ taskId = INVALID_TASK_ATTEMPT_ID;
465
597
  }
598
+ const attempt = input.historyReserved === undefined
599
+ ? reserveTaskAttempt(taskId, input.startedAt)
600
+ : { startedAt: input.startedAt, historyReserved: input.historyReserved };
601
+ const finishedAt = finishAttempt(attempt.startedAt, input.finishedAt ?? new Date());
602
+ const startedAtIso = attempt.startedAt.toISOString();
603
+ const finishedAtIso = finishedAt.toISOString();
604
+ const errorCode = safeTaskAttemptErrorCode(input.failure);
605
+ const logPath = resolveTaskLogPath(input.logDir, taskId, startedAtIso);
606
+ const line = `[akm tasks] status=failed reason=${input.reason} code=${errorCode}`;
607
+ const result = {
608
+ id: taskId,
609
+ status: "failed",
610
+ startedAt: startedAtIso,
611
+ finishedAt: finishedAtIso,
612
+ durationMs: Math.max(0, finishedAt.getTime() - attempt.startedAt.getTime()),
613
+ log: logPath,
614
+ target: { kind: "unknown" },
615
+ detail: { reason: input.reason, error: errorCode },
616
+ };
617
+ persistRunLog({
618
+ taskId,
619
+ startedAtIso,
620
+ finishedAtIso,
621
+ logPath,
622
+ fileText: `${line}\n`,
623
+ dbLines: [{ level: "error", line }],
624
+ });
625
+ appendHistory(result, attempt.historyReserved);
466
626
  }
467
627
  // ── history ─────────────────────────────────────────────────────────────────
468
- function appendHistory(result) {
628
+ function appendHistory(result, historyReserved = false) {
629
+ const row = {
630
+ task_id: result.id,
631
+ status: result.status,
632
+ started_at: result.startedAt,
633
+ completed_at: result.finishedAt,
634
+ failed_at: result.status === "failed" ? result.finishedAt : null,
635
+ log_path: result.log || null,
636
+ target_kind: result.target.kind === "unknown" ? null : result.target.kind,
637
+ target_ref: result.target.kind === "workflow" ? result.target.ref : null,
638
+ metadata_json: JSON.stringify({
639
+ metadataVersion: 2,
640
+ durationMs: result.durationMs,
641
+ detail: result.detail ?? null,
642
+ ...(result.target.kind === "prompt" ? { engine: result.target.engine } : {}),
643
+ }),
644
+ };
469
645
  try {
470
646
  withStateDb((db) => {
471
- upsertTaskHistory(db, {
472
- task_id: result.id,
473
- status: result.status,
474
- started_at: result.startedAt,
475
- completed_at: result.finishedAt,
476
- failed_at: result.status === "failed" ? result.finishedAt : null,
477
- log_path: result.log,
478
- target_kind: result.target.kind,
479
- target_ref: result.target.kind === "workflow" ? result.target.ref : null,
480
- metadata_json: JSON.stringify({
481
- metadataVersion: 2,
482
- durationMs: result.durationMs,
483
- detail: result.detail ?? null,
484
- ...(result.target.kind === "prompt" ? { engine: result.target.engine } : {}),
485
- }),
486
- });
647
+ if (historyReserved && finalizeTaskHistoryAttempt(db, row))
648
+ return;
649
+ upsertTaskHistory(db, row);
487
650
  });
488
651
  }
489
- catch (err) {
490
- rethrowIfTestIsolationError(err);
491
- error(`[akm] task history DB write failed: ${String(err)}`);
652
+ catch (error) {
653
+ rethrowIfTestIsolationError(error);
654
+ // History recording is fully best-effort and must not alter CLI output.
492
655
  }
493
656
  }
494
657
  export function readTaskHistory(options = {}) {
@@ -518,9 +681,11 @@ function taskHistoryRowToResult(row) {
518
681
  ? { kind: "workflow", ref: row.target_ref ?? "" }
519
682
  : row.target_kind === "command"
520
683
  ? { kind: "command" }
521
- : meta.metadataVersion === 2
522
- ? { kind: "prompt", engine: meta.engine ?? null }
523
- : { kind: "prompt", engine: null, ...(meta.legacyProfile ? { legacyProfile: meta.legacyProfile } : {}) };
684
+ : row.target_kind === "prompt"
685
+ ? meta.metadataVersion === 2
686
+ ? { kind: "prompt", engine: meta.engine ?? null }
687
+ : { kind: "prompt", engine: null, ...(meta.legacyProfile ? { legacyProfile: meta.legacyProfile } : {}) }
688
+ : { kind: "unknown" };
524
689
  return {
525
690
  id: row.task_id,
526
691
  status: row.status,