UNPKG

akm-cli

Version:

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 Cur

165 lines (164 loc) • 7.79 kB
// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. /** * The task-attempt reservation/finalization lifecycle: `reserveTaskAttempt` * (called before any dispatch), `finishAttempt` (the monotonic finish-time * clamp every arm's result uses), and `recordTaskAttemptFailure` (the * catch-all for a dispatch that threw after an attempt was reserved). * * Moved from src/tasks/runner.ts (spec docs/plans/specs/p1b-model-extraction.md * §5.1, §9, runner.ts:970-1069). `reserveTaskAttempt` and `finishAttempt` * were module-private at head; both are exported here because run-task.ts and * every dispatch arm (run-native-task.ts, run-workflow-task.ts, * run-command-task.ts), now separate files, call them. * * F-4 (P1a advisory, spec §5.5): `SAFE_TASK_ATTEMPT_ERROR_CODES` gains * `TASK_SOURCE_INVALID` and `COMPOSITION_INVALID` — see the comment on the * set for the verified reachability path. `SAFE_TASK_ATTEMPT_ERROR_CODES` and * `safeTaskAttemptErrorCode` stay unexported: their membership is observed * the same way production code observes it, through * `recordTaskAttemptFailure`'s one externally-visible effect (the stored * `detail.error` code). * * P4 (docs/plans/specs/p4-deletions-closeout.md §4.1, row B-55): the set * additionally gains `TARGET_REF_INVALID`, `WORKFLOW_SOURCE_INVALID`, * `INPUT_BINDING_INVALID`, and `TASK_TARGET_UNSUPPORTED` — every code P4's * `INVALID_FLAG_VALUE` re-coding sweep (spec §5.2) makes reachable from the * same post-reservation dispatch catch. `TARGET_REF_INVALID` * (`execution/target-ref.ts`) and `INPUT_BINDING_INVALID` * (`workflows/freeze/task-bindings.ts`) already reach it today through the * same WORKFLOW arm as `COMPOSITION_INVALID` above — freezing a composed * child (a `tasks/<ref>` or `workflows/<ref>` step) during dispatch can * classify a malformed `uses:` or an invalid `with:` binding after the * attempt is reserved. `WORKFLOW_SOURCE_INVALID` and `TASK_TARGET_UNSUPPORTED` * get their first live throw sites in this same phase (the freeze wrapper's * P4-N2 mapping, and `prepare/script-capture.ts`'s interpreter rejections, * respectively) — added here ahead of / alongside that wiring rather than * split across two commits, since an unreachable member of an allowlist Set * is inert, not a defect. */ import { AkmError, rethrowIfTestIsolationError } from "../../core/errors.js"; import { withStateDb } from "../../core/state-db.js"; import { reserveTaskHistoryAttempt } from "../../storage/repositories/task-history-repository.js"; import { validateTaskId } from "../task-id.js"; import { appendHistory } from "./task-history.js"; import { persistRunLog, resolveTaskLogPath } from "./task-log.js"; export const INVALID_TASK_ATTEMPT_ID = "_invalid-task-id"; /** Reserve a collision-free identity through state.db's existing unique index. */ export function reserveTaskAttempt(taskId, requestedStartedAt) { try { return withStateDb((db) => { for (let offsetMs = 0;; offsetMs++) { const startedAt = new Date(requestedStartedAt.getTime() + offsetMs); const reserved = reserveTaskHistoryAttempt(db, { task_id: taskId, status: "active", started_at: startedAt.toISOString(), completed_at: null, failed_at: null, log_path: null, target_kind: null, target_ref: null, metadata_json: JSON.stringify({ metadataVersion: 2, durationMs: 0, detail: null }), }); if (reserved) return { startedAt, historyReserved: true }; } }); } catch (error) { rethrowIfTestIsolationError(error); // Attempt recording cannot prevent or replace task execution. return { startedAt: requestedStartedAt, historyReserved: false }; } } /** Clamp a finish observation to never precede its own start (a mocked clock can otherwise report a negative duration). */ export function finishAttempt(startedAt, observedFinishedAt) { return observedFinishedAt.getTime() < startedAt.getTime() ? new Date(startedAt) : observedFinishedAt; } /** * Error codes safe to surface verbatim in a task-history `detail.error` * (rather than the generic `"INTERNAL"`) — user-actionable configuration and * usage failures, never an unclassified internal error that might leak * implementation detail. * * F-4 (P1a advisory, spec §5.5): `TASK_SOURCE_INVALID` and * `COMPOSITION_INVALID` join the allowlist. Verified reachability: the only * caller of `recordTaskAttemptFailure` inside the runner is the * post-reservation catch in run-task.ts; task-source parsing happens BEFORE * reservation (load-task.ts), so the direct parse path never reaches it. * Both codes reach it through the WORKFLOW arm instead: a workflow task whose * plan freezes a `tasks/<ref>` step raises `TASK_SOURCE_INVALID` (via * `taskDispatch`'s `parseTaskSource`/`parseTaskSourceV4Document` — * `src/workflows/freeze/targets/task.ts`; `parseTaskV3Yaml` no longer exists * in `src`, P4 deleted task v3 acceptance, spec * docs/plans/specs/p4-deletions-closeout.md §3.2) or `COMPOSITION_INVALID` * (the P1a with-rejection guard) DURING dispatch, after the attempt was * already reserved. Before this widening, both were recorded as * `"INTERNAL"`. */ const SAFE_TASK_ATTEMPT_ERROR_CODES = new Set([ "CONFIG_DIR_UNRESOLVABLE", "STASH_DIR_NOT_FOUND", "STASH_DIR_NOT_A_DIRECTORY", "STASH_DIR_UNREADABLE", "LLM_NOT_CONFIGURED", "INVALID_CONFIG_FILE", "TEST_ISOLATION_MISSING", "INVALID_FLAG_VALUE", "MISSING_REQUIRED_ARGUMENT", "PATH_ESCAPE_VIOLATION", "TASK_SCHEMA_VERSION_UNSUPPORTED", "ASSET_NOT_FOUND", "WORKFLOW_NOT_FOUND", "FILE_NOT_FOUND", "TASK_SOURCE_INVALID", "COMPOSITION_INVALID", "TARGET_REF_INVALID", "WORKFLOW_SOURCE_INVALID", "INPUT_BINDING_INVALID", "TASK_TARGET_UNSUPPORTED", ]); function safeTaskAttemptErrorCode(failure) { if (failure instanceof AkmError && SAFE_TASK_ATTEMPT_ERROR_CODES.has(failure.code)) return failure.code; return "INTERNAL"; } export function recordTaskAttemptFailure(input) { let taskId = input.taskId; try { validateTaskId(taskId); } catch { taskId = INVALID_TASK_ATTEMPT_ID; } const attempt = input.historyReserved === undefined ? reserveTaskAttempt(taskId, input.startedAt) : { startedAt: input.startedAt, historyReserved: input.historyReserved }; const finishedAt = finishAttempt(attempt.startedAt, input.finishedAt ?? new Date()); const startedAtIso = attempt.startedAt.toISOString(); const finishedAtIso = finishedAt.toISOString(); const errorCode = safeTaskAttemptErrorCode(input.failure); const logPath = resolveTaskLogPath(input.logDir, taskId, startedAtIso); const line = `[akm task] status=failed reason=${input.reason} code=${errorCode}`; const result = { id: taskId, status: "failed", startedAt: startedAtIso, finishedAt: finishedAtIso, durationMs: Math.max(0, finishedAt.getTime() - attempt.startedAt.getTime()), log: logPath, target: { kind: "unknown" }, detail: { reason: input.reason, error: errorCode }, }; persistRunLog({ taskId, startedAtIso, finishedAtIso, logPath, fileText: `${line}\n`, dbLines: [{ level: "error", line }], }); appendHistory(result, attempt.historyReserved); }