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
package/dist/tasks/runner.js
CHANGED
|
@@ -15,7 +15,10 @@
|
|
|
15
15
|
* 3. Skip disabled tasks only when the invocation is scheduler-generated;
|
|
16
16
|
* explicit manual runs are allowed for catch-up and testing.
|
|
17
17
|
* 4. Dispatch by target kind:
|
|
18
|
-
* • workflow → `runWorkflowSteps({ target: ref, params })`
|
|
18
|
+
* • workflow → `runWorkflowSteps({ target: ref, params, signal, … })`
|
|
19
|
+
* under a whole-run timeout (issue 11): an unattended run
|
|
20
|
+
* gets the same abort path `akm workflow run --timeout`
|
|
21
|
+
* gives an interactive one.
|
|
19
22
|
* • prompt → `executeRunner(engine, prompt, { stdio: "captured" })`
|
|
20
23
|
* 5. Capture stdout / stderr as structured rows in logs.db (task_logs) and,
|
|
21
24
|
* transitionally, as a flat text tail at `<cacheDir>/tasks/logs/<id>/<ts>.log`
|
|
@@ -28,6 +31,7 @@
|
|
|
28
31
|
import fs from "node:fs";
|
|
29
32
|
import os from "node:os";
|
|
30
33
|
import path from "node:path";
|
|
34
|
+
import { armAbortDeadline } from "../core/abort-deadline.js";
|
|
31
35
|
import { shouldSkipUnactivatedTask } from "../core/activation-policy.js";
|
|
32
36
|
import { assertNever } from "../core/assert.js";
|
|
33
37
|
import { placementSpecFor } from "../core/asset/asset-placement.js";
|
|
@@ -36,7 +40,7 @@ import { loadConfig } from "../core/config/config.js";
|
|
|
36
40
|
import { AkmError, NotFoundError, rethrowIfTestIsolationError } from "../core/errors.js";
|
|
37
41
|
import { buildTaskRunId, insertTaskLogLines, openLogsDatabase, } from "../core/logs-db.js";
|
|
38
42
|
import { getTaskLogDir } from "../core/paths.js";
|
|
39
|
-
import { redactCredentialPatterns } from "../core/redaction.js";
|
|
43
|
+
import { redactCredentialPatterns, redactSensitiveText } from "../core/redaction.js";
|
|
40
44
|
import { withStateDb } from "../core/state-db.js";
|
|
41
45
|
import { runManagedSubprocess } from "../core/subprocess.js";
|
|
42
46
|
import { fallbackAnnouncement, NO_ENGINE_MESSAGE_SUFFIX, NO_ENGINE_REMEDY, withEngineFallback, } from "../integrations/agent/engine-fallback.js";
|
|
@@ -45,11 +49,13 @@ import { resolveModel } from "../integrations/agent/model-aliases.js";
|
|
|
45
49
|
import { executeRunner } from "../integrations/agent/runner-dispatch.js";
|
|
46
50
|
import { chatCompletion } from "../llm/client.js";
|
|
47
51
|
import { resolveAssetPath } from "../sources/resolve.js";
|
|
48
|
-
import { decodeTaskHistoryMetadata, finalizeTaskHistoryAttempt, getTaskHistory, queryTaskHistory, reserveTaskHistoryAttempt, upsertTaskHistory, } from "../storage/repositories/task-history-repository.js";
|
|
52
|
+
import { decodeTaskHistoryMetadata, finalizeTaskHistoryAttempt, getTaskHistory, getTaskHistoryRuns, queryTaskHistory, reserveTaskHistoryAttempt, upsertTaskHistory, } from "../storage/repositories/task-history-repository.js";
|
|
49
53
|
import { runWorkflowSteps } from "../workflows/exec/run-workflow.js";
|
|
50
54
|
import { findBareAkmExecutableIndex } from "./command-executable.js";
|
|
55
|
+
import { collectTaskLogSensitiveValues } from "./log-redaction.js";
|
|
51
56
|
import { parseTaskDocument } from "./parser.js";
|
|
52
57
|
import { resolveAkmInvocation } from "./resolve-akm-bin.js";
|
|
58
|
+
import { scheduledTaskContextEnv } from "./scheduler-invocation.js";
|
|
53
59
|
import { validateTaskId } from "./task-id.js";
|
|
54
60
|
export const INVALID_TASK_ATTEMPT_ID = "_invalid-task-id";
|
|
55
61
|
export async function runTask(id, options) {
|
|
@@ -109,6 +115,7 @@ export async function runTask(id, options) {
|
|
|
109
115
|
logPath,
|
|
110
116
|
fileText: `${disabledLine}\n`,
|
|
111
117
|
dbLines: [{ line: disabledLine }],
|
|
118
|
+
redactNames: task.redact,
|
|
112
119
|
});
|
|
113
120
|
appendHistory(result, attempt.historyReserved);
|
|
114
121
|
return result;
|
|
@@ -121,6 +128,8 @@ export async function runTask(id, options) {
|
|
|
121
128
|
now,
|
|
122
129
|
runWorkflowStepsImpl,
|
|
123
130
|
historyReserved: attempt.historyReserved,
|
|
131
|
+
...(options.setTimeoutFn ? { setTimeoutFn: options.setTimeoutFn } : {}),
|
|
132
|
+
...(options.clearTimeoutFn ? { clearTimeoutFn: options.clearTimeoutFn } : {}),
|
|
124
133
|
});
|
|
125
134
|
}
|
|
126
135
|
if (task.target.kind === "command") {
|
|
@@ -167,7 +176,8 @@ async function runCommandTask(input) {
|
|
|
167
176
|
throw new Error("invariant: command target");
|
|
168
177
|
const { cmd } = task.target;
|
|
169
178
|
const spawnCmd = resolveNestedAkmCommand(cmd);
|
|
170
|
-
|
|
179
|
+
// Unset → the unattended default; `null` → the explicit no-timeout opt-out.
|
|
180
|
+
const timeoutMs = task.timeoutMs !== undefined ? task.timeoutMs : DEFAULT_SCHEDULED_TASK_TIMEOUT_MS;
|
|
171
181
|
const header = `[akm task] task=${task.id} kind=command cmd=${cmd.join(" ")}`;
|
|
172
182
|
const logLines = [header];
|
|
173
183
|
const dbLines = [{ line: header }];
|
|
@@ -225,6 +235,7 @@ async function runCommandTask(input) {
|
|
|
225
235
|
logPath,
|
|
226
236
|
fileText: `${logLines.join("\n")}\n`,
|
|
227
237
|
dbLines,
|
|
238
|
+
redactNames: task.redact,
|
|
228
239
|
});
|
|
229
240
|
const status = exitCode === 0 ? "completed" : "failed";
|
|
230
241
|
const result = {
|
|
@@ -248,22 +259,86 @@ function resolveNestedAkmCommand(cmd) {
|
|
|
248
259
|
return [...cmd.slice(0, akmIndex), ...resolveAkmInvocation().argv, ...cmd.slice(akmIndex + 1)];
|
|
249
260
|
}
|
|
250
261
|
// ── workflow target ─────────────────────────────────────────────────────────
|
|
262
|
+
/**
|
|
263
|
+
* Whole-run timeout applied to a workflow-bound task that does not declare its
|
|
264
|
+
* own `timeoutMs` — six hours.
|
|
265
|
+
*
|
|
266
|
+
* `akm workflow run` deliberately has NO default `--timeout`: a human is
|
|
267
|
+
* watching, and Ctrl-C aborts the very same signal the flag's timer would.
|
|
268
|
+
* A scheduled task has nobody watching. Without a default, its only bound is
|
|
269
|
+
* the per-unit timeout — and a frozen plan may set `timeout: null` (unbounded),
|
|
270
|
+
* so one wedged agent unit hangs the run until the machine reboots, holding the
|
|
271
|
+
* run lease and silently skipping every later firing (issue 11).
|
|
272
|
+
*
|
|
273
|
+
* Six hours is deliberately generous rather than tight: the abort is graceful
|
|
274
|
+
* (the engine breaks at the next step boundary and the run stays resumable), so
|
|
275
|
+
* the cost of over-waiting is bounded while the cost of cutting a legitimate
|
|
276
|
+
* long run short is a lost step. It matches the 6h idle window `akm health`
|
|
277
|
+
* already uses to call a run stale (`commands/health/report-view-model.ts`),
|
|
278
|
+
* and it lands well inside a `@daily` cadence, so a wedged run can never still
|
|
279
|
+
* be holding the lease when the next day's firing arrives.
|
|
280
|
+
*
|
|
281
|
+
* An explicit `timeoutMs:` in the task file always wins; `timeoutMs: null` is
|
|
282
|
+
* the explicit opt-out back to unbounded.
|
|
283
|
+
*/
|
|
284
|
+
export const DEFAULT_WORKFLOW_TASK_TIMEOUT_MS = 6 * 60 * 60 * 1000;
|
|
285
|
+
/**
|
|
286
|
+
* The same unattended default for command and prompt tasks.
|
|
287
|
+
*
|
|
288
|
+
* The reasoning above is about SCHEDULED runs, not about workflows: nobody is
|
|
289
|
+
* watching, and one wedged run silently stops the schedule. Command tasks
|
|
290
|
+
* defaulted to `null` (no kill timer) and prompt tasks inherited
|
|
291
|
+
* DEFAULT_AGENT_TIMEOUT_MS, also null — so a hung `curl`, a prompting agent
|
|
292
|
+
* waiting on stdin, or a stuck engine wedged the task forever while the
|
|
293
|
+
* workflow arm was protected. Same value, same opt-out: an explicit
|
|
294
|
+
* `timeoutMs:` wins, and `timeoutMs: null` restores unbounded.
|
|
295
|
+
*/
|
|
296
|
+
export const DEFAULT_SCHEDULED_TASK_TIMEOUT_MS = DEFAULT_WORKFLOW_TASK_TIMEOUT_MS;
|
|
251
297
|
async function runWorkflowTask(input) {
|
|
252
298
|
const { task, logPath, startedAt, now, runWorkflowStepsImpl, historyReserved } = input;
|
|
253
299
|
if (task.target.kind !== "workflow")
|
|
254
300
|
throw new Error("invariant: workflow target");
|
|
255
|
-
const
|
|
301
|
+
const workflowTarget = task.target;
|
|
302
|
+
const ref = parseRefInput(workflowTarget.ref);
|
|
256
303
|
if (ref.type !== "workflow") {
|
|
257
|
-
throw new NotFoundError(`Task "${task.id}" workflow target must be a workflow ref (got "${
|
|
258
|
-
}
|
|
304
|
+
throw new NotFoundError(`Task "${task.id}" workflow target must be a workflow ref (got "${workflowTarget.ref}").`, "WORKFLOW_NOT_FOUND");
|
|
305
|
+
}
|
|
306
|
+
// Unset → the unattended default; `null` → the explicit no-timeout opt-out.
|
|
307
|
+
const timeoutMs = workflowTarget.timeoutMs === undefined ? DEFAULT_WORKFLOW_TASK_TIMEOUT_MS : workflowTarget.timeoutMs;
|
|
308
|
+
// The shared deadline `akm workflow run --timeout` also arms
|
|
309
|
+
// ({@link armAbortDeadline}): one AbortController for the run's lifetime,
|
|
310
|
+
// aborted by a timer. The engine reads `options.signal` at every step
|
|
311
|
+
// boundary and breaks GRACEFULLY — in-flight units are cancelled, the journal
|
|
312
|
+
// and the run lease are retained, and the run is left `active`, i.e.
|
|
313
|
+
// resumable with `akm workflow resume`.
|
|
314
|
+
const controller = new AbortController();
|
|
315
|
+
const deadline = armAbortDeadline(controller, {
|
|
316
|
+
timeoutMs,
|
|
317
|
+
reason: `Workflow task "${task.id}" timed out after ${timeoutMs}ms.`,
|
|
318
|
+
...(input.setTimeoutFn ? { setTimeoutFn: input.setTimeoutFn } : {}),
|
|
319
|
+
...(input.clearTimeoutFn ? { clearTimeoutFn: input.clearTimeoutFn } : {}),
|
|
320
|
+
});
|
|
259
321
|
let detail;
|
|
260
322
|
let gateError;
|
|
261
323
|
let error;
|
|
262
324
|
// The prompt path logs the engine-fallback announcement; a workflow-backed
|
|
263
325
|
// task must leave the same trace rather than silently using a chosen engine.
|
|
264
326
|
let runWarnings = [];
|
|
327
|
+
// Stamp task-runner provenance for the duration of the run (DRIFT-6), as the
|
|
328
|
+
// command and prompt arms do. This arm executes IN-PROCESS, so the stamp goes
|
|
329
|
+
// on process.env — child akm invocations made by workflow steps inherit it.
|
|
330
|
+
// Without it, workflow-task traffic was recorded as user demand. A more
|
|
331
|
+
// specific stamp already present wins, matching the command arm.
|
|
332
|
+
const priorEventSource = process.env.AKM_EVENT_SOURCE;
|
|
333
|
+
process.env.AKM_EVENT_SOURCE = priorEventSource ?? "task";
|
|
265
334
|
try {
|
|
266
|
-
const execution = await runWorkflowStepsImpl({
|
|
335
|
+
const execution = await runWorkflowStepsImpl({
|
|
336
|
+
target: workflowTarget.ref,
|
|
337
|
+
params: workflowTarget.params,
|
|
338
|
+
signal: controller.signal,
|
|
339
|
+
...(workflowTarget.maxSteps !== undefined ? { maxSteps: workflowTarget.maxSteps } : {}),
|
|
340
|
+
...(workflowTarget.maxRetries !== undefined ? { maxRetries: workflowTarget.maxRetries } : {}),
|
|
341
|
+
});
|
|
267
342
|
detail = execution.run;
|
|
268
343
|
runWarnings = execution.warnings ?? [];
|
|
269
344
|
if (execution.gateRejection) {
|
|
@@ -275,13 +350,41 @@ async function runWorkflowTask(input) {
|
|
|
275
350
|
throw e;
|
|
276
351
|
error = e instanceof Error ? e : new Error(String(e));
|
|
277
352
|
}
|
|
353
|
+
finally {
|
|
354
|
+
deadline.disarm();
|
|
355
|
+
if (priorEventSource === undefined)
|
|
356
|
+
delete process.env.AKM_EVENT_SOURCE;
|
|
357
|
+
else
|
|
358
|
+
process.env.AKM_EVENT_SOURCE = priorEventSource;
|
|
359
|
+
}
|
|
360
|
+
// A timeout is a failed ATTEMPT even though the engine stopped cleanly: the
|
|
361
|
+
// aborted run comes back `active` (resumable), which on its own would map to
|
|
362
|
+
// task status "active" and a 0 exit code, telling the OS scheduler nothing
|
|
363
|
+
// went wrong. Surface it like the command target's `timed_out=true` instead.
|
|
364
|
+
//
|
|
365
|
+
// Unless the run COMPLETED anyway. The abort is observed between steps, so a
|
|
366
|
+
// deadline landing in the run's final bookkeeping can set the flag on a run
|
|
367
|
+
// that then finishes — and reporting that as a failure would tell an operator
|
|
368
|
+
// to resume a run with nothing left to resume.
|
|
369
|
+
const ranToCompletion = detail?.status === "completed";
|
|
370
|
+
const timedOutAfterMs = deadline.timedOut() && timeoutMs !== null && !ranToCompletion ? timeoutMs : undefined;
|
|
371
|
+
const timeoutError = timedOutAfterMs === undefined
|
|
372
|
+
? undefined
|
|
373
|
+
: new Error(`Workflow run timed out after ${timedOutAfterMs}ms and was aborted at a step boundary` +
|
|
374
|
+
(detail?.id ? ` — resume it with \`akm workflow resume ${detail.id}\`.` : "."));
|
|
375
|
+
// One failure value for the three sinks below (status, log line, history
|
|
376
|
+
// detail): a thrown error outranks a gate rejection, which outranks the
|
|
377
|
+
// deadline. Re-laddering per sink is how a log line ends up naming a
|
|
378
|
+
// different cause than the history row it was written beside.
|
|
379
|
+
const failure = error ?? (gateError ? new Error(gateError) : timeoutError);
|
|
278
380
|
const finishedAt = finishAttempt(startedAt, now());
|
|
279
|
-
const status =
|
|
381
|
+
const status = failure ? "failed" : mapWorkflowStatus(detail?.status);
|
|
280
382
|
const log = renderWorkflowLog({
|
|
281
383
|
task,
|
|
282
384
|
detail,
|
|
283
|
-
error:
|
|
385
|
+
error: failure,
|
|
284
386
|
warnings: runWarnings,
|
|
387
|
+
...(timedOutAfterMs !== undefined ? { timedOutAfterMs } : {}),
|
|
285
388
|
});
|
|
286
389
|
persistRunLog({
|
|
287
390
|
taskId: task.id,
|
|
@@ -290,6 +393,7 @@ async function runWorkflowTask(input) {
|
|
|
290
393
|
logPath,
|
|
291
394
|
fileText: log.fileText,
|
|
292
395
|
dbLines: log.dbLines,
|
|
396
|
+
redactNames: task.redact,
|
|
293
397
|
});
|
|
294
398
|
const result = {
|
|
295
399
|
id: task.id,
|
|
@@ -301,7 +405,7 @@ async function runWorkflowTask(input) {
|
|
|
301
405
|
target: { kind: "workflow", ref: task.target.ref },
|
|
302
406
|
detail: {
|
|
303
407
|
runId: detail?.id,
|
|
304
|
-
...(
|
|
408
|
+
...(failure ? { error: failure.message } : {}),
|
|
305
409
|
},
|
|
306
410
|
};
|
|
307
411
|
appendHistory(result, historyReserved);
|
|
@@ -345,6 +449,9 @@ function renderWorkflowLog(input) {
|
|
|
345
449
|
];
|
|
346
450
|
for (const warning of input.warnings ?? [])
|
|
347
451
|
dbLines.push({ level: "warn", line: warning });
|
|
452
|
+
if (input.timedOutAfterMs !== undefined) {
|
|
453
|
+
dbLines.push({ level: "error", line: `timed_out=true timeout_ms=${input.timedOutAfterMs}` });
|
|
454
|
+
}
|
|
348
455
|
if (input.detail) {
|
|
349
456
|
dbLines.push({ line: `run_id=${input.detail.id} status=${input.detail.status}` });
|
|
350
457
|
dbLines.push({ line: `workflow_title=${input.detail.workflowTitle}` });
|
|
@@ -401,7 +508,10 @@ async function runPromptTask(input) {
|
|
|
401
508
|
runner = {
|
|
402
509
|
...runner,
|
|
403
510
|
profile: { ...runner.profile, ...(model ? { model, modelIsExact: true } : {}) },
|
|
404
|
-
|
|
511
|
+
// Unset → the unattended default (DEFAULT_AGENT_TIMEOUT_MS is null, which
|
|
512
|
+
// let a prompting or wedged agent CLI hang the schedule); `null` → the
|
|
513
|
+
// explicit no-timeout opt-out.
|
|
514
|
+
timeoutMs: promptTarget.timeoutMs !== undefined ? promptTarget.timeoutMs : DEFAULT_SCHEDULED_TASK_TIMEOUT_MS,
|
|
405
515
|
};
|
|
406
516
|
}
|
|
407
517
|
const promptText = await resolvePromptText(task, stashDir);
|
|
@@ -412,7 +522,13 @@ async function runPromptTask(input) {
|
|
|
412
522
|
// Stamp task-runner provenance for any akm invocation the agent makes
|
|
413
523
|
// (DRIFT-6: agent-task traffic must not be recorded as user demand).
|
|
414
524
|
// Caller-supplied env still wins on conflicts.
|
|
415
|
-
|
|
525
|
+
//
|
|
526
|
+
// The agent child env is built from an allowlist, not inherited, so the
|
|
527
|
+
// scheduler's AKM_* directory context was dropped here — an agent's `akm`
|
|
528
|
+
// sub-commands then targeted the DEFAULT stash and DB rather than the
|
|
529
|
+
// ones the scheduled run was configured for. The command arm keeps this
|
|
530
|
+
// context because it inherits process.env; forward it explicitly.
|
|
531
|
+
env: { AKM_EVENT_SOURCE: "task", ...scheduledTaskContextEnv(), ...agentOptions?.env },
|
|
416
532
|
}, {
|
|
417
533
|
...(input.runAgentImpl ? { runAgent: input.runAgentImpl } : {}),
|
|
418
534
|
llm: async (spec, prompt, options) => {
|
|
@@ -432,6 +548,7 @@ async function runPromptTask(input) {
|
|
|
432
548
|
logPath,
|
|
433
549
|
fileText: log.fileText,
|
|
434
550
|
dbLines: log.dbLines,
|
|
551
|
+
redactNames: task.redact,
|
|
435
552
|
});
|
|
436
553
|
const status = result.ok ? "completed" : "failed";
|
|
437
554
|
const out = {
|
|
@@ -515,12 +632,49 @@ function resolveTaskLogPath(logDir, taskId, startedAtIso) {
|
|
|
515
632
|
return "";
|
|
516
633
|
}
|
|
517
634
|
}
|
|
635
|
+
/**
|
|
636
|
+
* Redact logs.db rows against the SAME contiguous text the file sink sees.
|
|
637
|
+
*
|
|
638
|
+
* The rows arrive already split on "\n" (see {@link streamLines}), but the
|
|
639
|
+
* redaction needles are whole env values — and a needle containing a newline
|
|
640
|
+
* can never match inside a single line. Scrubbing row-by-row therefore left
|
|
641
|
+
* multi-line secrets (PEM keys, multi-line service-account credentials) intact
|
|
642
|
+
* in logs.db while the flat .log was correctly scrubbed, defeating all three
|
|
643
|
+
* tiers including the explicit `redact:` opt-in.
|
|
644
|
+
*
|
|
645
|
+
* Consecutive rows sharing a stream and level are rejoined, scrubbed as one
|
|
646
|
+
* string, and re-split, so a needle spanning lines matches. Collapsing a
|
|
647
|
+
* multi-line secret into a single [REDACTED] row is the intended outcome.
|
|
648
|
+
*/
|
|
649
|
+
export function scrubDbLines(dbLines, scrub) {
|
|
650
|
+
const out = [];
|
|
651
|
+
for (let i = 0; i < dbLines.length;) {
|
|
652
|
+
const { stream, level } = dbLines[i];
|
|
653
|
+
let end = i;
|
|
654
|
+
while (end < dbLines.length && dbLines[end].stream === stream && dbLines[end].level === level)
|
|
655
|
+
end++;
|
|
656
|
+
const joined = dbLines
|
|
657
|
+
.slice(i, end)
|
|
658
|
+
.map((entry) => entry.line)
|
|
659
|
+
.join("\n");
|
|
660
|
+
for (const line of scrub(joined).split("\n")) {
|
|
661
|
+
if (line.length > 0)
|
|
662
|
+
out.push({ stream, level, line });
|
|
663
|
+
}
|
|
664
|
+
i = end;
|
|
665
|
+
}
|
|
666
|
+
return out;
|
|
667
|
+
}
|
|
518
668
|
/** Split captured pipe output into per-line logs.db rows (blank lines dropped). */
|
|
519
669
|
function streamLines(text, stream, level) {
|
|
520
|
-
return text
|
|
670
|
+
return (text
|
|
521
671
|
.split("\n")
|
|
672
|
+
// Windows child output is CRLF-terminated. Splitting on "\n" alone left a
|
|
673
|
+
// trailing "\r" on every row and turned blank CRLF lines into phantom
|
|
674
|
+
// rows containing just "\r" (length 1 passes the filter below).
|
|
675
|
+
.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line))
|
|
522
676
|
.filter((line) => line.length > 0)
|
|
523
|
-
.map((line) => ({ stream, level, line }));
|
|
677
|
+
.map((line) => ({ stream, level, line })));
|
|
524
678
|
}
|
|
525
679
|
/**
|
|
526
680
|
* Persist a finished run's log: the flat text file (so `log_path` in
|
|
@@ -535,11 +689,56 @@ function streamLines(text, stream, level) {
|
|
|
535
689
|
* The DB write is best-effort, mirroring {@link appendHistory}: an unwritable
|
|
536
690
|
* logs.db must never fail a task run.
|
|
537
691
|
*/
|
|
692
|
+
/**
|
|
693
|
+
* Exact secret values to scrub from this run's persisted output (#755).
|
|
694
|
+
*
|
|
695
|
+
* Best-effort by construction: this runs on the persistence path of a run that
|
|
696
|
+
* has already finished, so a config that will not load must degrade to
|
|
697
|
+
* "pattern-based redaction only" rather than fail the run. It does NOT degrade
|
|
698
|
+
* to "log it anyway with no redaction at all" — `redactCredentialPatterns`
|
|
699
|
+
* still runs unconditionally in the caller.
|
|
700
|
+
*/
|
|
701
|
+
function taskLogSensitiveValues(redactNames) {
|
|
702
|
+
try {
|
|
703
|
+
return collectTaskLogSensitiveValues({
|
|
704
|
+
env: process.env,
|
|
705
|
+
config: loadConfig(),
|
|
706
|
+
declaredNames: redactNames,
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
catch (error) {
|
|
710
|
+
rethrowIfTestIsolationError(error);
|
|
711
|
+
// No config — the name heuristic and the task's own `redact:` list still apply.
|
|
712
|
+
try {
|
|
713
|
+
return collectTaskLogSensitiveValues({ env: process.env, declaredNames: redactNames });
|
|
714
|
+
}
|
|
715
|
+
catch (fallbackError) {
|
|
716
|
+
rethrowIfTestIsolationError(fallbackError);
|
|
717
|
+
return [];
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
538
721
|
function persistRunLog(input) {
|
|
539
|
-
|
|
540
|
-
|
|
722
|
+
// Two arms, and both are needed. `redactCredentialPatterns` catches
|
|
723
|
+
// credential SHAPES nobody listed; the exact-value pass catches configured
|
|
724
|
+
// secrets whose value is shaped like nothing in particular (#755). The
|
|
725
|
+
// command target had only the first, so a scheduled command that echoed an
|
|
726
|
+
// ordinary-looking secret persisted it verbatim to both sinks. Applying the
|
|
727
|
+
// exact pass here — the one sink all three target kinds funnel through —
|
|
728
|
+
// covers every arm once rather than per-arm; prompt/workflow runs already
|
|
729
|
+
// scrub upstream, and redaction is idempotent, so the overlap is free.
|
|
730
|
+
const sensitive = taskLogSensitiveValues(input.redactNames);
|
|
731
|
+
const scrub = (text) => sensitive.length > 0
|
|
732
|
+
? redactSensitiveText(redactCredentialPatterns(text), sensitive)
|
|
733
|
+
: redactCredentialPatterns(text);
|
|
734
|
+
const fileText = scrub(input.fileText);
|
|
735
|
+
const dbLines = scrubDbLines(input.dbLines, scrub);
|
|
541
736
|
if (input.logPath) {
|
|
542
737
|
try {
|
|
738
|
+
// Written at the process umask. #756 pinned 0600/0700 here; that went out
|
|
739
|
+
// with the rest of akm's permission enforcement (#791) — the operator owns
|
|
740
|
+
// the mode of their own data directory, and akm neither sets nor reports
|
|
741
|
+
// on it.
|
|
543
742
|
fs.mkdirSync(path.dirname(input.logPath), { recursive: true });
|
|
544
743
|
fs.writeFileSync(input.logPath, fileText);
|
|
545
744
|
}
|
|
@@ -692,6 +891,12 @@ export function readTaskHistory(options = {}) {
|
|
|
692
891
|
if (options.limit === 0)
|
|
693
892
|
return [];
|
|
694
893
|
if (options.id) {
|
|
894
|
+
// An id-scoped query used the single-row helper, so `--limit` was silently
|
|
895
|
+
// discarded and `akm task history --id X --limit 20` always returned one
|
|
896
|
+
// run. The CLI documents --limit as "Maximum rows to return"; honour it.
|
|
897
|
+
if (options.limit !== undefined && options.limit > 0) {
|
|
898
|
+
return getTaskHistoryRuns(db, options.id, options.limit).map(taskHistoryRowToResult);
|
|
899
|
+
}
|
|
695
900
|
const row = getTaskHistory(db, options.id);
|
|
696
901
|
return row ? [taskHistoryRowToResult(row)] : [];
|
|
697
902
|
}
|
|
@@ -14,6 +14,25 @@ export const SCHEDULED_TASK_CONTEXT_KEYS = [
|
|
|
14
14
|
"AKM_CACHE_DIR",
|
|
15
15
|
"AKM_STATE_DIR",
|
|
16
16
|
];
|
|
17
|
+
/**
|
|
18
|
+
* The AKM_* directory context currently in effect, as a plain env fragment.
|
|
19
|
+
*
|
|
20
|
+
* A scheduled run restores these into `process.env` from its
|
|
21
|
+
* `--scheduler-context` descriptor precisely because such installs have
|
|
22
|
+
* non-default directories. Paths that build a child environment from an
|
|
23
|
+
* allowlist rather than inheriting (the agent spawn) must forward this
|
|
24
|
+
* explicitly, or the child's `akm` sub-commands silently target the default
|
|
25
|
+
* stash and DB.
|
|
26
|
+
*/
|
|
27
|
+
export function scheduledTaskContextEnv(env = process.env) {
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const key of SCHEDULED_TASK_CONTEXT_KEYS) {
|
|
30
|
+
const value = env[key];
|
|
31
|
+
if (value)
|
|
32
|
+
out[key] = value;
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
17
36
|
export const SCHEDULER_CONTEXT_ARG = "--scheduler-context";
|
|
18
37
|
/** Resolve the complete non-secret AKM directory context captured by schedulers. */
|
|
19
38
|
export function resolveScheduledTaskContext(env = process.env, platform = process.platform) {
|
package/dist/tasks/schema.js
CHANGED
|
@@ -12,7 +12,35 @@
|
|
|
12
12
|
* Tasks are stored as pure YAML files at `<stash>/tasks/<id>.yml`. Multi-line
|
|
13
13
|
* inline prompts use a YAML block scalar (`prompt: |`).
|
|
14
14
|
*/
|
|
15
|
+
import { parse as parseYaml } from "yaml";
|
|
16
|
+
import { WORKFLOW_MAX_EXEC_PASS_ENV, WORKFLOW_MAX_TIMEOUT_MS } from "../workflows/resource-limits.js";
|
|
15
17
|
export const TASK_SCHEMA_VERSION = 2;
|
|
18
|
+
/** The ONE recognized on-disk task extension (spec §6 task row). */
|
|
19
|
+
export const TASK_EXTENSION = ".yml";
|
|
20
|
+
/**
|
|
21
|
+
* The near-miss spelling. `.yaml` is NOT a task extension: the indexer's
|
|
22
|
+
* `tasks` matcher (`indexer/walk/matchers.ts`) gates on `.yml`, so a
|
|
23
|
+
* `tasks/<id>.yaml` file is never indexed, never scheduled, and never runs.
|
|
24
|
+
* It is recognized HERE only so lint can say so out loud instead of walking
|
|
25
|
+
* past it (issue #760).
|
|
26
|
+
*/
|
|
27
|
+
export const TASK_NEAR_MISS_EXTENSION = ".yaml";
|
|
28
|
+
/**
|
|
29
|
+
* Largest expressible `timeoutMs` — `setTimeout`'s 32-bit signed ceiling
|
|
30
|
+
* (2^31-1, ~24.8 days). A larger delay overflows and fires almost immediately,
|
|
31
|
+
* which would silently abort a run seconds after it started instead of hours
|
|
32
|
+
* later. One definition with the workflow bound (`WORKFLOW_MAX_TIMEOUT_MS`) —
|
|
33
|
+
* it is a platform fact, not a per-surface policy. Mirrored as `maximum` on
|
|
34
|
+
* `timeoutMs` in `schemas/akm-task.json`.
|
|
35
|
+
*/
|
|
36
|
+
export const TASK_MAX_TIMEOUT_MS = WORKFLOW_MAX_TIMEOUT_MS;
|
|
37
|
+
/**
|
|
38
|
+
* Most names a task's `redact:` list may carry. Shares its bound with exec
|
|
39
|
+
* units' `pass_env:` — both are "name the one or two the defaults miss", not a
|
|
40
|
+
* way to declare the whole environment secret. Mirrored as `maxItems` on
|
|
41
|
+
* `redact` in `schemas/akm-task.json`.
|
|
42
|
+
*/
|
|
43
|
+
export const TASK_MAX_REDACT_NAMES = WORKFLOW_MAX_EXEC_PASS_ENV;
|
|
16
44
|
/**
|
|
17
45
|
* Lint-level shape problems for a parsed task YAML mapping: the field rules
|
|
18
46
|
* `src/tasks/parser.ts` enforces at load time, phrased as diagnostics. The ONE
|
|
@@ -24,8 +52,28 @@ export const TASK_SCHEMA_VERSION = 2;
|
|
|
24
52
|
* at runtime with TASK_SCHEMA_VERSION_UNSUPPORTED). `schemas/akm-task.json`
|
|
25
53
|
* agrees with the parser: `required: [version, schedule]`, `version:
|
|
26
54
|
* {const: 2}`, `enabled` optional but boolean. Target-arity rules stay with
|
|
27
|
-
* each caller (they legitimately differ: at-least-one vs exactly-one)
|
|
55
|
+
* each caller (they legitimately differ: at-least-one vs exactly-one), but
|
|
56
|
+
* what COUNTS as a target is shared — see {@link isPresentTarget}.
|
|
57
|
+
*/
|
|
58
|
+
/**
|
|
59
|
+
* Whether a task-target field counts as declared.
|
|
60
|
+
*
|
|
61
|
+
* The arity rules differ between the two linters, but the presence test must
|
|
62
|
+
* not: the runtime parser treats `""` as absent, so a key-existence check let
|
|
63
|
+
* `workflow: ""` lint clean and then die with MISSING_REQUIRED_ARGUMENT. An
|
|
64
|
+
* array target (`command`) counts only when it has entries, for the same
|
|
65
|
+
* reason. Kept beside {@link taskFieldProblems} so all three definitions of
|
|
66
|
+
* "valid task" stay in one file.
|
|
28
67
|
*/
|
|
68
|
+
export function isPresentTarget(value) {
|
|
69
|
+
if (value === undefined || value === null)
|
|
70
|
+
return false;
|
|
71
|
+
if (typeof value === "string")
|
|
72
|
+
return value.trim() !== "";
|
|
73
|
+
if (Array.isArray(value))
|
|
74
|
+
return value.length > 0;
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
29
77
|
export function taskFieldProblems(data) {
|
|
30
78
|
const problems = [];
|
|
31
79
|
if (data.version !== TASK_SCHEMA_VERSION)
|
|
@@ -36,3 +84,40 @@ export function taskFieldProblems(data) {
|
|
|
36
84
|
problems.push("enabled (must be a boolean when present)");
|
|
37
85
|
return problems;
|
|
38
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Parse a task YAML document into a plain mapping. Non-mapping documents (a
|
|
89
|
+
* scalar, a sequence, an empty file) are NOT a parse failure — they parse fine
|
|
90
|
+
* and simply carry no fields, which the field rules above already describe.
|
|
91
|
+
*
|
|
92
|
+
* The ONE parse used by all three task-lint surfaces (`commands/lint/index.ts`'s
|
|
93
|
+
* akm sweep, the `akm` adapter's `validate`, and the `akm-task` adapter) so a
|
|
94
|
+
* malformed file cannot be a finding on one surface and silence on another.
|
|
95
|
+
*/
|
|
96
|
+
export function parseTaskYaml(raw) {
|
|
97
|
+
try {
|
|
98
|
+
const doc = parseYaml(raw);
|
|
99
|
+
if (doc && typeof doc === "object" && !Array.isArray(doc))
|
|
100
|
+
return { ok: true, data: doc };
|
|
101
|
+
return { ok: true, data: {} };
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
// yaml's errors carry a multi-line source excerpt; keep the first line so
|
|
105
|
+
// the diagnostic stays one readable finding.
|
|
106
|
+
const message = (e instanceof Error ? e.message : String(e)).split("\n")[0]?.trim() ?? "unknown parse error";
|
|
107
|
+
return { ok: false, data: {}, error: message };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** The `invalid-task-yaml` detail for a file whose YAML could not be parsed. */
|
|
111
|
+
export function taskYamlParseDetail(error) {
|
|
112
|
+
return `task YAML does not parse: ${error}`;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The `invalid-task-yaml` detail for a task file using the `.yaml` near-miss
|
|
116
|
+
* spelling. See {@link TASK_NEAR_MISS_EXTENSION} for why this is an error and
|
|
117
|
+
* not a style nit.
|
|
118
|
+
*/
|
|
119
|
+
export function taskExtensionDetail(relPath) {
|
|
120
|
+
const base = relPath.replace(/\.yaml$/i, "");
|
|
121
|
+
return (`task file uses the ${TASK_NEAR_MISS_EXTENSION} extension; akm recognizes tasks only as ` +
|
|
122
|
+
`${TASK_EXTENSION}, so this file is never indexed or scheduled — rename it to ${base}${TASK_EXTENSION}.`);
|
|
123
|
+
}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import { readFile } from "node:fs/promises";
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
|
|
19
|
-
const TEXT_EXTENSIONS = new Set([".md", ".xml", ".txt", ".sql", ".yaml", ".yml"]);
|
|
19
|
+
const TEXT_EXTENSIONS = new Set([".md", ".xml", ".txt", ".sql", ".yaml", ".yml", ".html"]);
|
|
20
20
|
|
|
21
21
|
function isTextImport(url, importAttributes) {
|
|
22
22
|
if (importAttributes && importAttributes.type === "text") return true;
|
|
@@ -2,7 +2,16 @@
|
|
|
2
2
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
3
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
4
|
import os from "node:os";
|
|
5
|
-
|
|
5
|
+
import { isLoopbackEndpoint } from "../core/loopback.js";
|
|
6
|
+
import { WORKFLOW_MAX_CONCURRENCY } from "./resource-limits.js";
|
|
7
|
+
/**
|
|
8
|
+
* Run-level ceiling on `workflow.maxConcurrency`. It is deliberately the SAME
|
|
9
|
+
* value the frozen-plan decoder enforces on `execution.maxConcurrency` and on
|
|
10
|
+
* per-step `map.concurrency` — a clamp above the decoder's bound would freeze
|
|
11
|
+
* plans the decoder then rejects — so it reads the single shared constant
|
|
12
|
+
* (`./resource-limits`) rather than repeating the literal.
|
|
13
|
+
*/
|
|
14
|
+
export const WORKFLOW_MAX_CONCURRENCY_CEILING = WORKFLOW_MAX_CONCURRENCY;
|
|
6
15
|
export function cpuDerivedUnitConcurrency(cpuCount = os.cpus()?.length ?? 4) {
|
|
7
16
|
return Math.min(16, Math.max(1, cpuCount - 2));
|
|
8
17
|
}
|
|
@@ -13,3 +22,88 @@ export function clampMaxConcurrency(value) {
|
|
|
13
22
|
export function workflowMaxConcurrency(configured, cpuCount = os.cpus()?.length ?? 4) {
|
|
14
23
|
return configured === undefined ? cpuDerivedUnitConcurrency(cpuCount) : clampMaxConcurrency(configured);
|
|
15
24
|
}
|
|
25
|
+
// ── Fan-out defaults ─────────────────────────────────────────────────────────
|
|
26
|
+
//
|
|
27
|
+
// Four independent limits clamp a `map` step's real width, and the effective
|
|
28
|
+
// value is their minimum:
|
|
29
|
+
//
|
|
30
|
+
// 1. the step's own `map.concurrency` (this file's default below)
|
|
31
|
+
// 2. the run's frozen `execution.maxConcurrency` ({@link workflowMaxConcurrency})
|
|
32
|
+
// 3. the selected LLM engine's frozen concurrency ({@link defaultLlmEngineConcurrency})
|
|
33
|
+
// 4. the CURRENT host's CPU safety cap ({@link cpuDerivedUnitConcurrency})
|
|
34
|
+
//
|
|
35
|
+
// (1) and (3) both defaulted to 1 before 0.9.1, which made every fan-out serial
|
|
36
|
+
// unless the author opted in at BOTH layers — so (2) and (4), the limits that
|
|
37
|
+
// actually encode machine capacity, never bound anything. The defaults below
|
|
38
|
+
// replace those two 1s. They are deliberately modest rather than "as wide as
|
|
39
|
+
// the host allows": a `map` is independent by construction, but its units call
|
|
40
|
+
// out to rate-limited providers and RAM-hungry agent processes, so the value
|
|
41
|
+
// that a plan freezes should be one a laptop and a CI box can both survive.
|
|
42
|
+
/**
|
|
43
|
+
* Default width of a `map` step that declares no `concurrency:` (0.9.1+).
|
|
44
|
+
*
|
|
45
|
+
* 4 is chosen over the host cap on purpose. It is a real, predictable speedup
|
|
46
|
+
* (4× on any fan-out longer than four items) while staying below
|
|
47
|
+
* {@link cpuDerivedUnitConcurrency} on every machine with ≥6 cores, so the
|
|
48
|
+
* frozen number — not the host — is what an author reasons about, and a plan
|
|
49
|
+
* frozen on a 32-core CI box behaves the same when it resumes on a laptop.
|
|
50
|
+
*
|
|
51
|
+
* Overridable in both directions:
|
|
52
|
+
* - per step: `map.concurrency: <n>` (an explicit `1` still means serial),
|
|
53
|
+
* - per install: `workflow.defaultMapConcurrency` — set it to `1` to restore
|
|
54
|
+
* the pre-0.9.1 serial default for every workflow at once.
|
|
55
|
+
*/
|
|
56
|
+
export const DEFAULT_MAP_CONCURRENCY = 4;
|
|
57
|
+
/**
|
|
58
|
+
* Default `engines.<name>.concurrency` for an LLM engine on a LOOPBACK
|
|
59
|
+
* endpoint. Stays at 1, matching `getDefaultLlmConcurrency`
|
|
60
|
+
* (`src/indexer/indexer.ts`) and AGENTS.md's "lowest common denominator — a
|
|
61
|
+
* slow local model on a single-threaded server" rule. A local model server
|
|
62
|
+
* (LM Studio, Ollama) holds ONE loaded model; parallel inference triggers
|
|
63
|
+
* reload thrash and HTTP 500s, which is a hard failure, not a slow one.
|
|
64
|
+
*/
|
|
65
|
+
export const DEFAULT_LOCAL_LLM_ENGINE_CONCURRENCY = 1;
|
|
66
|
+
/**
|
|
67
|
+
* Default `engines.<name>.concurrency` for an LLM engine on a REMOTE endpoint.
|
|
68
|
+
*
|
|
69
|
+
* Deliberately equal to {@link DEFAULT_MAP_CONCURRENCY} so this limit does not
|
|
70
|
+
* silently re-serialize a fan-out the author already asked for: the step's own
|
|
71
|
+
* `concurrency:` stays the number that decides. Indexing's remote default is a
|
|
72
|
+
* lower 2 because indexing fans out implicitly over the whole stash; a
|
|
73
|
+
* workflow `map` is an explicit, bounded, author-declared fan-out, and four
|
|
74
|
+
* concurrent completions sit far inside any hosted provider's entry tier.
|
|
75
|
+
* Rate-limited installs set `engines.<name>.concurrency` to pin their own.
|
|
76
|
+
*/
|
|
77
|
+
export const DEFAULT_REMOTE_LLM_ENGINE_CONCURRENCY = 4;
|
|
78
|
+
// ── Loopback classification ──────────────────────────────────────────────────
|
|
79
|
+
//
|
|
80
|
+
// Everything above turns on ONE question: does this endpoint name a model
|
|
81
|
+
// server running on THIS machine? The classifier lives in `core/loopback.ts`
|
|
82
|
+
// (shared with the indexer's LLM pool default); the re-export keeps this
|
|
83
|
+
// module the policy surface workflow callers and the boundary-case table in
|
|
84
|
+
// `tests/workflows/concurrency-defaults.test.ts` import from. The check is
|
|
85
|
+
// purely syntactic — no DNS, no interface list — so freeze produces the same
|
|
86
|
+
// plan on a laptop, on CI, and on a machine with no network.
|
|
87
|
+
export { isLoopbackEndpoint, isLoopbackHost } from "../core/loopback.js";
|
|
88
|
+
/**
|
|
89
|
+
* Concurrency to freeze for an LLM engine. An explicit
|
|
90
|
+
* `engines.<name>.concurrency` always wins (clamped into the decoder's
|
|
91
|
+
* `[1, 64]` range so a fat-fingered config cannot freeze an unloadable plan);
|
|
92
|
+
* otherwise the endpoint decides.
|
|
93
|
+
*/
|
|
94
|
+
export function defaultLlmEngineConcurrency(endpoint, configured) {
|
|
95
|
+
if (typeof configured === "number" && Number.isFinite(configured))
|
|
96
|
+
return clampMaxConcurrency(configured);
|
|
97
|
+
return isLoopbackEndpoint(endpoint) ? DEFAULT_LOCAL_LLM_ENGINE_CONCURRENCY : DEFAULT_REMOTE_LLM_ENGINE_CONCURRENCY;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Width to freeze for a `map` step that declared no `concurrency:`. `configured`
|
|
101
|
+
* is `workflow.defaultMapConcurrency`; unset means {@link DEFAULT_MAP_CONCURRENCY}.
|
|
102
|
+
* An explicit `map.concurrency` never reaches this function — the caller keeps
|
|
103
|
+
* "author wrote 1" distinguishable from "author wrote nothing".
|
|
104
|
+
*/
|
|
105
|
+
export function defaultMapConcurrency(configured) {
|
|
106
|
+
return configured === undefined || !Number.isFinite(configured)
|
|
107
|
+
? DEFAULT_MAP_CONCURRENCY
|
|
108
|
+
: clampMaxConcurrency(configured);
|
|
109
|
+
}
|