nomarmy 0.1.0-alpha.0
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/LICENSE +202 -0
- package/NOTICE +25 -0
- package/README.md +484 -0
- package/bin/nomarmy.mjs +2248 -0
- package/config/agents.yml.example +63 -0
- package/config/common.env +31 -0
- package/config/profiles/bedrock-cheap.env +26 -0
- package/config/profiles/bedrock.env +28 -0
- package/config/profiles/cpu-linux.env +8 -0
- package/config/profiles/dgx-spark.env +12 -0
- package/config/profiles/macbook-pro.env +9 -0
- package/config/profiles/nvidia-linux.env +9 -0
- package/docker/Dockerfile +15 -0
- package/docker/Dockerfile.go +29 -0
- package/docker/Dockerfile.rust +19 -0
- package/e2e.sh +153 -0
- package/install.sh +125 -0
- package/lib/agents.mjs +285 -0
- package/lib/army.mjs +400 -0
- package/lib/budget.mjs +368 -0
- package/lib/claude-transcript.mjs +150 -0
- package/lib/config.mjs +193 -0
- package/lib/connect.mjs +409 -0
- package/lib/coordinator-instructions.mjs +23 -0
- package/lib/decompose.mjs +389 -0
- package/lib/dispatch-config.mjs +164 -0
- package/lib/dispatch-schema.mjs +280 -0
- package/lib/doctor.mjs +443 -0
- package/lib/evidence.mjs +679 -0
- package/lib/gguf.mjs +589 -0
- package/lib/hardware.mjs +476 -0
- package/lib/health.mjs +278 -0
- package/lib/model-catalog.mjs +71 -0
- package/lib/notifier-app.mjs +95 -0
- package/lib/notify.mjs +66 -0
- package/lib/openclaw-config.mjs +65 -0
- package/lib/openclaw-errors.mjs +40 -0
- package/lib/propose.mjs +110 -0
- package/lib/prune.mjs +77 -0
- package/lib/repo-query.mjs +267 -0
- package/lib/runs.mjs +150 -0
- package/lib/sabotage.mjs +128 -0
- package/lib/sandbox-images.mjs +434 -0
- package/lib/scan.mjs +1538 -0
- package/lib/schema.mjs +288 -0
- package/lib/scout.mjs +544 -0
- package/lib/sizing.mjs +1322 -0
- package/lib/slots.mjs +112 -0
- package/lib/statusline.mjs +126 -0
- package/lib/subscription-config.mjs +68 -0
- package/lib/subscription-setup.mjs +217 -0
- package/lib/transcript.mjs +195 -0
- package/lib/verify.mjs +700 -0
- package/mcp/server.mjs +4206 -0
- package/notifier/icon.swift +34 -0
- package/notifier/main.swift +52 -0
- package/notifier/nomarmy-icon.png +0 -0
- package/package.json +67 -0
- package/playbooks/feature.md +43 -0
- package/policies/coder.md +49 -0
- package/policies/orchestrator.md +35 -0
- package/policies/reviewer.md +35 -0
- package/policies/scout.md +65 -0
- package/scripts/configure-openclaw.sh +96 -0
- package/scripts/configure-orchestrator.sh +84 -0
- package/scripts/install-llama-cpp.sh +16 -0
- package/scripts/lib.sh +198 -0
- package/scripts/select-model.mjs +96 -0
- package/scripts/select-model.sh +4 -0
- package/scripts/setup-sandbox.sh +38 -0
- package/scripts/start-inference.sh +46 -0
- package/scripts/stop-inference.sh +5 -0
- package/scripts/uninstall.sh +6 -0
- package/scripts/verify-install.sh +68 -0
package/lib/propose.mjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Turns scan evidence into a conservative `.nomarmy.yml` PROPOSAL for
|
|
2
|
+
// `nomarmy init` to show a human, never a finished answer.
|
|
3
|
+
//
|
|
4
|
+
// lib/evidence.mjs's own header already says the evidence shape is meant for
|
|
5
|
+
// an LLM's judgment, not a deterministic mapper -- picking an `environment`
|
|
6
|
+
// level and a service's `source` are judgment calls evidence alone can't
|
|
7
|
+
// answer. So this mapper deliberately stays narrow:
|
|
8
|
+
// * only ever proposes `source: "compose"` services, mapped 1:1 from
|
|
9
|
+
// evidence (mechanical, not a judgment call) -- never `shared`/`remote`,
|
|
10
|
+
// the schema's elevated sources that require explicit human policy
|
|
11
|
+
// approval (see lib/schema.mjs's ELEVATED_SERVICE_SOURCES);
|
|
12
|
+
// * verification always needs review: the schema requires at least one
|
|
13
|
+
// command, so a repo with no detected command gets an explicit,
|
|
14
|
+
// fail-loud placeholder rather than a fake one that would silently
|
|
15
|
+
// "pass" every job;
|
|
16
|
+
// * evidence flagged fixture-like by lib/scan.mjs's fixturePaths is never
|
|
17
|
+
// included.
|
|
18
|
+
import { validateConfig } from "./config.mjs";
|
|
19
|
+
|
|
20
|
+
const PLACEHOLDER_COMMAND = "echo 'REPLACE ME: no verification command configured yet' && exit 1";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} evidence scanRepository()'s return value
|
|
24
|
+
* @returns {{ proposal: object, valid: boolean, errors: string[], excludedFixturePaths: string[], notes: string[] }}
|
|
25
|
+
*/
|
|
26
|
+
export function buildConfigProposal(evidence) {
|
|
27
|
+
const fixturePaths = new Set(evidence?.fixturePaths ?? []);
|
|
28
|
+
const notes = [];
|
|
29
|
+
const excludedFixturePaths = new Set();
|
|
30
|
+
|
|
31
|
+
const allServices = evidence?.services?.items ?? [];
|
|
32
|
+
const services = allServices.filter((s) => {
|
|
33
|
+
if (fixturePaths.has(s.source)) { excludedFixturePaths.add(s.source); return false; }
|
|
34
|
+
return true;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const proposal = {};
|
|
38
|
+
|
|
39
|
+
// --- environment.services (compose only, mechanical) --------------------
|
|
40
|
+
const composeFiles = [...new Set(services.map((s) => s.source))];
|
|
41
|
+
if (composeFiles.length > 0) {
|
|
42
|
+
const file = composeFiles[0];
|
|
43
|
+
if (composeFiles.length > 1) {
|
|
44
|
+
notes.push(`found real (non-fixture) services across ${composeFiles.length} different compose files (${composeFiles.join(", ")}); only ${file} was used -- review the others by hand.`);
|
|
45
|
+
}
|
|
46
|
+
const fromFile = services.filter((s) => s.source === file);
|
|
47
|
+
const environmentServices = {};
|
|
48
|
+
for (const s of fromFile) environmentServices[s.name] = { source: "compose", service: s.name };
|
|
49
|
+
proposal.environment = { compose: { file }, services: environmentServices };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --- environment.python.requirements -------------------------------------
|
|
53
|
+
// lib/sandbox-images.mjs's ensurePythonImageBuilt only ever `pip install
|
|
54
|
+
// -r <file>`s whatever this proposes -- it cannot install from
|
|
55
|
+
// pyproject.toml/poetry/uv directly, so this stays scoped to the same
|
|
56
|
+
// requirements.txt-shaped evidence handleRequirements records (tooling
|
|
57
|
+
// name "pip"), never pyproject.toml evidence alone; proposing something
|
|
58
|
+
// the sandbox builder can't actually act on would be worse than proposing
|
|
59
|
+
// nothing. A repo whose real Python dependency source is poetry/uv with no
|
|
60
|
+
// requirements.txt anywhere gets no proposal here -- a distinct, real gap
|
|
61
|
+
// (the sandbox has no poetry/uv install path at all yet), not this one.
|
|
62
|
+
const pipTooling = (evidence?.tooling?.items ?? []).filter((t) => t.name === "pip" && t.category === "package-manager" && t.source);
|
|
63
|
+
for (const t of pipTooling) if (fixturePaths.has(t.source)) excludedFixturePaths.add(t.source);
|
|
64
|
+
const requirementsFiles = [...new Set(pipTooling.filter((t) => !fixturePaths.has(t.source)).map((t) => t.source))].sort();
|
|
65
|
+
if (requirementsFiles.length === 1) {
|
|
66
|
+
proposal.environment = { ...proposal.environment, python: { requirements: requirementsFiles } };
|
|
67
|
+
} else if (requirementsFiles.length > 1) {
|
|
68
|
+
// The exact ambiguity this file's own header already treats as a human
|
|
69
|
+
// judgment call for compose files: several requirements.txt-shaped files
|
|
70
|
+
// (app, dev, a sub-package's own) with no single conventional
|
|
71
|
+
// combination this could guess -- a wrong guess here is worse than no
|
|
72
|
+
// proposal, since a worker's correct diff would fail verification for
|
|
73
|
+
// an infrastructure reason indistinguishable from broken code (this is
|
|
74
|
+
// exactly the failure this whole field exists to prevent in the first
|
|
75
|
+
// place: silently omitting it entirely did the same thing).
|
|
76
|
+
notes.push(`found ${requirementsFiles.length} requirements files (${requirementsFiles.join(", ")}) -- none was proposed automatically since the right combination is your call; add environment.python.requirements by hand, or verification will silently run in a base image with no Python dependencies installed.`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// --- verification (always needs review) ----------------------------------
|
|
80
|
+
// Only ever seed from a command evidence itself already labels "test" --
|
|
81
|
+
// evidence.commands also carries start/build/lint/seed/migrate commands
|
|
82
|
+
// (e.g. a Dockerfile's CMD, "sleep infinity", keeping a container alive),
|
|
83
|
+
// and picking any command regardless of purpose picked exactly that once.
|
|
84
|
+
const testCommandItems = evidence?.commands?.items?.filter((c) => c.kind === "test") ?? [];
|
|
85
|
+
for (const c of testCommandItems) if (fixturePaths.has(c.source)) excludedFixturePaths.add(c.source);
|
|
86
|
+
const commandItems = testCommandItems.filter((c) => !fixturePaths.has(c.source));
|
|
87
|
+
const firstCommand = commandItems.find((c) => c.command)?.command;
|
|
88
|
+
if (firstCommand) {
|
|
89
|
+
notes.push(`verification.quick.commands was seeded from a command found in evidence (${firstCommand}) -- confirm this is actually the right check before trusting it.`);
|
|
90
|
+
proposal.verification = { quick: { environment: "none", commands: [firstCommand] } };
|
|
91
|
+
} else {
|
|
92
|
+
notes.push("no command was found anywhere in evidence; verification.quick.commands is a fail-loud placeholder -- replace it before this profile is usable.");
|
|
93
|
+
proposal.verification = { quick: { environment: "none", commands: [PLACEHOLDER_COMMAND] } };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A bundle or packaging step (Lambda assets, SAM, Serverless, CDK): unit
|
|
97
|
+
// tests pass while a module the bundle leaves out crashes the deployed
|
|
98
|
+
// function on import. A real Senti branch had exactly that, and pytest,
|
|
99
|
+
// nomArmy's verification and the revert check were all green. Only a
|
|
100
|
+
// repo-specific check catches it, so say so; the command is the repo's own.
|
|
101
|
+
const BUNDLE_RE = /\bbundle[\w-]*|\bpackage-[\w-]+|prepare-[\w-]*packages?\b|\bsam (?:build|package)\b|\b(?:serverless|sls) package\b|\bcdk synth\b/i;
|
|
102
|
+
const bundleCommands = [...new Set((evidence?.commands?.items ?? []).filter((c) => !fixturePaths.has(c.source) && BUNDLE_RE.test(c.command ?? "")).map((c) => c.command.trim()))];
|
|
103
|
+
if (bundleCommands.length) {
|
|
104
|
+
const shown = bundleCommands.slice(0, 3).join("; ");
|
|
105
|
+
notes.push(`found a bundle/packaging step (${shown}${bundleCommands.length > 3 ? "; ..." : ""}). Tests pass even when a module is left out of the bundle, so consider a verification profile that runs it and then imports each entry point from inside the built bundle (e.g. \`python -c "import handler"\` in each asset directory): that catches a deploy-time import crash before it ships.`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const { valid, errors } = validateConfig(proposal);
|
|
109
|
+
return { proposal, valid, errors, excludedFixturePaths: [...excludedFixturePaths].sort(), notes };
|
|
110
|
+
}
|
package/lib/prune.mjs
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Removing finished jobs' runtime/ directories: each job's npm cache and
|
|
2
|
+
// harness state (Codex's, OpenClaw's transcript), about 100 MB a job, and
|
|
3
|
+
// 2 GB after a busy day of real runs. Shared by `nomarmy jobs --prune` and
|
|
4
|
+
// the health check's automatic prune (lib/health.mjs).
|
|
5
|
+
//
|
|
6
|
+
// Only a job with a final record (metadata.json with finishedAt) and no
|
|
7
|
+
// live lease is touched. A running job's status.json heartbeat is always in
|
|
8
|
+
// the past, and treating it as a finish time deleted a live job's state
|
|
9
|
+
// with --older-than 0. The record, logs, report and any kept worktree stay.
|
|
10
|
+
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
function readJson(file) { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } }
|
|
15
|
+
|
|
16
|
+
function sizeOf(dir) {
|
|
17
|
+
let n = 0;
|
|
18
|
+
const stack = [dir];
|
|
19
|
+
while (stack.length) {
|
|
20
|
+
const d = stack.pop();
|
|
21
|
+
let entries = [];
|
|
22
|
+
try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { continue; }
|
|
23
|
+
for (const e of entries) {
|
|
24
|
+
const p = path.join(d, e.name);
|
|
25
|
+
if (e.isDirectory()) stack.push(p);
|
|
26
|
+
else { try { n += fs.lstatSync(p).size; } catch { /* gone */ } }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return n;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {{ stateRoot: string, olderThanMs: number, now?: number }} input
|
|
34
|
+
* @returns {{ pruned: number, scratchCleared: number, freedBytes: number }}
|
|
35
|
+
*/
|
|
36
|
+
export function pruneJobRuntime({ stateRoot, olderThanMs, now = Date.now() }) {
|
|
37
|
+
const jobsRoot = path.join(stateRoot, "jobs"), leasesRoot = path.join(stateRoot, "leases");
|
|
38
|
+
const cutoff = now - olderThanMs;
|
|
39
|
+
let pruned = 0, scratchCleared = 0, freedBytes = 0, names = [];
|
|
40
|
+
try { names = fs.readdirSync(jobsRoot); } catch { return { pruned, scratchCleared, freedBytes }; }
|
|
41
|
+
for (const name of names) {
|
|
42
|
+
const dir = path.join(jobsRoot, name), runtime = path.join(dir, "runtime");
|
|
43
|
+
if (!fs.existsSync(runtime)) continue;
|
|
44
|
+
const finishedAt = Date.parse(readJson(path.join(dir, "metadata.json"))?.finishedAt ?? "");
|
|
45
|
+
if (!Number.isFinite(finishedAt)) continue;
|
|
46
|
+
if (fs.existsSync(path.join(leasesRoot, `${name}.json`))) continue;
|
|
47
|
+
if (finishedAt <= cutoff) {
|
|
48
|
+
freedBytes += sizeOf(runtime);
|
|
49
|
+
fs.rmSync(runtime, { recursive: true, force: true });
|
|
50
|
+
pruned++;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
// OpenClaw's scratch space (plugin build copies, 1.2 GB for a Codex
|
|
54
|
+
// job) is of no use once the job is done, whatever its age; the
|
|
55
|
+
// transcript a recent job might still need isn't in it.
|
|
56
|
+
const scratch = path.join(runtime, "state", "tmp");
|
|
57
|
+
if (fs.existsSync(scratch)) {
|
|
58
|
+
freedBytes += sizeOf(scratch);
|
|
59
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
60
|
+
scratchCleared++;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { pruned, scratchCleared, freedBytes };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* How old a finished job must be before the health check prunes it on its
|
|
68
|
+
* own: NOMARMY_AUTO_PRUNE_HOURS (default 24, the window a report recovery
|
|
69
|
+
* could still want the transcript), or null when set to 0 or "off".
|
|
70
|
+
*/
|
|
71
|
+
export function autoPruneAgeMs(env = process.env) {
|
|
72
|
+
const raw = env.NOMARMY_AUTO_PRUNE_HOURS;
|
|
73
|
+
if (raw === undefined || raw === "") return 24 * 3600000;
|
|
74
|
+
if (/^(0|off|false|no)$/i.test(String(raw).trim())) return null;
|
|
75
|
+
const hours = Number(raw);
|
|
76
|
+
return Number.isFinite(hours) && hours > 0 ? hours * 3600000 : 24 * 3600000;
|
|
77
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Deterministic repository evidence. No model, no network, no dependencies.
|
|
3
|
+
//
|
|
4
|
+
// Most "scout-shaped" questions are not questions for a language model:
|
|
5
|
+
// where is X defined, who references it, what does this file declare, which
|
|
6
|
+
// files match this pattern. Answering them by reading files into a frontier
|
|
7
|
+
// model's context is the most expensive way to do it, and answering them with
|
|
8
|
+
// a small local model adds latency and invented citations on top. This module
|
|
9
|
+
// answers them from the files, in milliseconds, with an exact [path:line] on
|
|
10
|
+
// every hit.
|
|
11
|
+
//
|
|
12
|
+
// It runs in two places with the same code: on the host as the coordinator's
|
|
13
|
+
// `repo_evidence` MCP tool, and inside the worker sandbox as a CLI a scout can
|
|
14
|
+
// call. Inside the sandbox the output lines ARE citations in the scout
|
|
15
|
+
// contract's syntax, so the model copies real locations instead of a template.
|
|
16
|
+
//
|
|
17
|
+
// Definitions are found heuristically per language family. That is stated in
|
|
18
|
+
// every result; a heuristic hit is still a real line in a real file.
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_IGNORES = Object.freeze([
|
|
24
|
+
"node_modules", ".git", ".openclaw", ".npm", ".npm-cache", "dist", "build", "out", "coverage", ".next", ".nuxt",
|
|
25
|
+
"target", "vendor", "__pycache__", ".venv", "venv", ".tox", ".mypy_cache", ".pytest_cache", ".cache", ".idea", ".vscode",
|
|
26
|
+
]);
|
|
27
|
+
export const LIMITS = Object.freeze({ maxFiles: 20000, maxFileBytes: 2 * 1024 * 1024, maxResults: 200, maxLineChars: 200, binarySniffBytes: 8192 });
|
|
28
|
+
|
|
29
|
+
const TEXT_EXT_HINT = /\.(mjs|cjs|js|jsx|ts|tsx|py|go|rs|java|kt|kts|cs|rb|php|sh|bash|zsh|ps1|c|h|cpp|hpp|cc|swift|m|scala|sql|yml|yaml|json|toml|ini|cfg|env|md|txt|html|css|scss|vue|svelte|tf|hcl|dockerfile|make|gradle|xml|proto|graphql)$/i;
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// File walk
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
function globToRegex(glob) {
|
|
35
|
+
// Minimal glob: ** any path, * within a segment, ? one char, {a,b} alternation.
|
|
36
|
+
let re = "";
|
|
37
|
+
for (let i = 0; i < glob.length; i++) {
|
|
38
|
+
const ch = glob[i];
|
|
39
|
+
if (ch === "*") { if (glob[i + 1] === "*") { re += "(?:.*\\/)?"; i++; if (glob[i + 1] === "/") { i++; } } else re += "[^/]*"; }
|
|
40
|
+
else if (ch === "?") re += "[^/]";
|
|
41
|
+
else if (ch === "{") { const j = glob.indexOf("}", i); if (j > i) { re += "(" + glob.slice(i + 1, j).split(",").map(s => s.replace(/[.+^$()|[\]\\]/g, "\\$&")).join("|") + ")"; i = j; } else re += "\\{"; }
|
|
42
|
+
else if (/[.+^$()|[\]\\]/.test(ch)) re += "\\" + ch;
|
|
43
|
+
else re += ch;
|
|
44
|
+
}
|
|
45
|
+
return new RegExp("^(?:.*/)?" + re + "$");
|
|
46
|
+
}
|
|
47
|
+
export function matchesGlob(rel, glob) {
|
|
48
|
+
if (!glob) return true;
|
|
49
|
+
return globToRegex(glob.replace(/\\/g, "/")).test(rel);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function listFiles(root, { glob = null, ignores = DEFAULT_IGNORES, maxFiles = LIMITS.maxFiles } = {}) {
|
|
53
|
+
const files = [];
|
|
54
|
+
const ignore = new Set(ignores);
|
|
55
|
+
const stack = [""];
|
|
56
|
+
let truncated = false;
|
|
57
|
+
while (stack.length && !truncated) {
|
|
58
|
+
const rel = stack.pop();
|
|
59
|
+
let entries;
|
|
60
|
+
try { entries = fs.readdirSync(path.join(root, rel), { withFileTypes: true }); } catch { continue; }
|
|
61
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
62
|
+
for (const e of entries) {
|
|
63
|
+
if (ignore.has(e.name)) continue;
|
|
64
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
65
|
+
if (e.isDirectory()) stack.push(r);
|
|
66
|
+
else if (e.isFile()) {
|
|
67
|
+
if (glob && !matchesGlob(r, glob)) continue;
|
|
68
|
+
files.push(r);
|
|
69
|
+
if (files.length >= maxFiles) { truncated = true; break; }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { files: files.sort(), truncated };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readTextFile(root, rel) {
|
|
77
|
+
const abs = path.join(root, rel);
|
|
78
|
+
let stat;
|
|
79
|
+
try { stat = fs.statSync(abs); } catch { return null; }
|
|
80
|
+
if (stat.size > LIMITS.maxFileBytes) return null;
|
|
81
|
+
let buf;
|
|
82
|
+
try { buf = fs.readFileSync(abs); } catch { return null; }
|
|
83
|
+
const sniff = buf.subarray(0, LIMITS.binarySniffBytes);
|
|
84
|
+
if (sniff.includes(0) && !TEXT_EXT_HINT.test(rel)) return null;
|
|
85
|
+
return buf.toString("utf8");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function clip(text, max = LIMITS.maxLineChars) {
|
|
89
|
+
const t = text.replace(/\t/g, " ").trimEnd();
|
|
90
|
+
return t.length > max ? `${t.slice(0, max - 1)}…` : t;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// grep
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
export function grepRepo(root, { pattern, regex = true, ignoreCase = false, wholeWord = false, glob = null, maxResults = LIMITS.maxResults, ignores = DEFAULT_IGNORES } = {}) {
|
|
97
|
+
if (!pattern) throw new Error("grep needs a pattern");
|
|
98
|
+
let src = regex ? pattern : pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
99
|
+
if (wholeWord) src = `\\b(?:${src})\\b`;
|
|
100
|
+
let re;
|
|
101
|
+
try { re = new RegExp(src, ignoreCase ? "i" : ""); } catch (e) { throw new Error(`invalid pattern: ${e.message}`); }
|
|
102
|
+
const { files, truncated: filesTruncated } = listFiles(root, { glob, ignores });
|
|
103
|
+
const hits = [];
|
|
104
|
+
let filesScanned = 0, truncated = false;
|
|
105
|
+
for (const rel of files) {
|
|
106
|
+
const text = readTextFile(root, rel);
|
|
107
|
+
if (text === null) continue;
|
|
108
|
+
filesScanned++;
|
|
109
|
+
const lines = text.split(/\r?\n/);
|
|
110
|
+
for (let i = 0; i < lines.length; i++) {
|
|
111
|
+
if (!re.test(lines[i])) continue;
|
|
112
|
+
hits.push({ path: rel, line: i + 1, text: clip(lines[i]) });
|
|
113
|
+
if (hits.length >= maxResults) { truncated = true; break; }
|
|
114
|
+
}
|
|
115
|
+
if (truncated) break;
|
|
116
|
+
}
|
|
117
|
+
return { hits, truncated: truncated || filesTruncated, filesScanned };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// definitions / references / outline (heuristic, per language family)
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
124
|
+
/** Definition patterns. `S` is the escaped symbol, or a capture for outline. */
|
|
125
|
+
export function definitionPatterns(S) {
|
|
126
|
+
const n = S; // already escaped or a capture group
|
|
127
|
+
return [
|
|
128
|
+
{ kind: "function", lang: "js", re: `^\\s*(?:export\\s+)?(?:default\\s+)?(?:async\\s+)?function\\s*\\*?\\s*${n}\\s*[(<]` },
|
|
129
|
+
{ kind: "const", lang: "js", re: `^\\s*(?:export\\s+)?(?:const|let|var)\\s+${n}\\s*(?::[^=]+)?=` },
|
|
130
|
+
{ kind: "class", lang: "js", re: `^\\s*(?:export\\s+)?(?:default\\s+)?(?:abstract\\s+)?class\\s+${n}\\b` },
|
|
131
|
+
{ kind: "type", lang: "ts", re: `^\\s*(?:export\\s+)?(?:declare\\s+)?(?:interface|type|enum)\\s+${n}\\b` },
|
|
132
|
+
{ kind: "method", lang: "js", re: `^\\s+(?:public\\s+|private\\s+|protected\\s+|static\\s+|async\\s+|override\\s+)*${n}\\s*\\([^)]*\\)\\s*(?::\\s*[^{]+)?\\{` },
|
|
133
|
+
{ kind: "function", lang: "py", re: `^\\s*(?:async\\s+)?def\\s+${n}\\s*\\(` },
|
|
134
|
+
{ kind: "class", lang: "py", re: `^\\s*class\\s+${n}\\s*[(:]` },
|
|
135
|
+
{ kind: "function", lang: "go", re: `^func\\s+(?:\\([^)]*\\)\\s*)?${n}\\s*[(\\[]` },
|
|
136
|
+
{ kind: "type", lang: "go", re: `^type\\s+${n}\\s+` },
|
|
137
|
+
{ kind: "function", lang: "rust", re: `^\\s*(?:pub(?:\\([^)]*\\))?\\s+)?(?:async\\s+)?(?:unsafe\\s+)?fn\\s+${n}\\s*[(<]` },
|
|
138
|
+
{ kind: "type", lang: "rust", re: `^\\s*(?:pub(?:\\([^)]*\\))?\\s+)?(?:struct|enum|trait|type)\\s+${n}\\b` },
|
|
139
|
+
// A typed declaration `Type name(`; the lookahead stops `return foo(` and
|
|
140
|
+
// `await foo(` from reading as a definition named foo.
|
|
141
|
+
{ kind: "method", lang: "jvm", re: `^\\s*(?!(?:return|new|throw|await|yield|else|case|typeof|delete|void|if|while|for|switch|catch|const|let|var|import|export|from|not|and|or|is|print|assert|raise|in|of|do|goto|defer|go)\\s)(?:(?:public|private|protected|internal|static|final|abstract|override|virtual|async|open|suspend)\\s+)*(?:[\\w<>\\[\\],.?]+\\s+)${n}\\s*\\(` },
|
|
142
|
+
{ kind: "class", lang: "jvm", re: `^\\s*(?:(?:public|private|protected|internal|static|final|abstract|sealed|data|open|partial)\\s+)*(?:class|interface|enum|record|object|struct)\\s+${n}\\b` },
|
|
143
|
+
{ kind: "function", lang: "rb", re: `^\\s*def\\s+(?:self\\.)?${n}\\b` },
|
|
144
|
+
{ kind: "class", lang: "rb", re: `^\\s*(?:class|module)\\s+${n}\\b` },
|
|
145
|
+
{ kind: "function", lang: "sh", re: `^\\s*(?:function\\s+)?${n}\\s*\\(\\)\\s*\\{` },
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function findDefinitions(root, symbol, { glob = null, maxResults = LIMITS.maxResults, ignores = DEFAULT_IGNORES } = {}) {
|
|
150
|
+
if (!symbol) throw new Error("definitions needs a symbol");
|
|
151
|
+
const pats = definitionPatterns(esc(symbol)).map(p => ({ ...p, rx: new RegExp(p.re) }));
|
|
152
|
+
const { files, truncated: filesTruncated } = listFiles(root, { glob, ignores });
|
|
153
|
+
const hits = [];
|
|
154
|
+
let truncated = false;
|
|
155
|
+
for (const rel of files) {
|
|
156
|
+
const text = readTextFile(root, rel);
|
|
157
|
+
if (text === null) continue;
|
|
158
|
+
const lines = text.split(/\r?\n/);
|
|
159
|
+
for (let i = 0; i < lines.length && !truncated; i++) {
|
|
160
|
+
const p = pats.find(p => p.rx.test(lines[i]));
|
|
161
|
+
if (!p) continue;
|
|
162
|
+
hits.push({ path: rel, line: i + 1, text: clip(lines[i]), kind: p.kind, lang: p.lang });
|
|
163
|
+
if (hits.length >= maxResults) truncated = true;
|
|
164
|
+
}
|
|
165
|
+
if (truncated) break;
|
|
166
|
+
}
|
|
167
|
+
return { symbol, hits, truncated: truncated || filesTruncated, heuristic: true };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function findReferences(root, symbol, { glob = null, maxResults = LIMITS.maxResults, ignores = DEFAULT_IGNORES } = {}) {
|
|
171
|
+
if (!symbol) throw new Error("references needs a symbol");
|
|
172
|
+
const defs = findDefinitions(root, symbol, { glob, maxResults: LIMITS.maxResults, ignores });
|
|
173
|
+
const defKeys = new Set(defs.hits.map(h => `${h.path}:${h.line}`));
|
|
174
|
+
const all = grepRepo(root, { pattern: esc(symbol), wholeWord: true, glob, maxResults: maxResults + defKeys.size, ignores });
|
|
175
|
+
const hits = all.hits.filter(h => !defKeys.has(`${h.path}:${h.line}`)).slice(0, maxResults);
|
|
176
|
+
return { symbol, hits, definitions: defs.hits, truncated: all.truncated || hits.length >= maxResults, filesScanned: all.filesScanned };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function outlineFile(root, rel) {
|
|
180
|
+
if (!rel) throw new Error("outline needs a path");
|
|
181
|
+
const clean = String(rel).replace(/\\/g, "/").replace(/^\.\//, "");
|
|
182
|
+
if (clean.startsWith("/") || clean.split("/").includes("..")) throw new Error("outline path must be inside the repository");
|
|
183
|
+
const text = readTextFile(root, clean);
|
|
184
|
+
if (text === null) return { path: clean, exists: fs.existsSync(path.join(root, clean)), items: [], reason: "not a readable text file" };
|
|
185
|
+
const pats = definitionPatterns("([A-Za-z_$][\\w$]*)").map(p => ({ ...p, rx: new RegExp(p.re) }));
|
|
186
|
+
const lines = text.split(/\r?\n/);
|
|
187
|
+
const items = [];
|
|
188
|
+
for (let i = 0; i < lines.length; i++) {
|
|
189
|
+
const line = lines[i];
|
|
190
|
+
if (!line.trim() || /^\s*(\/\/|#|\*|\/\*)/.test(line)) continue;
|
|
191
|
+
for (const p of pats) {
|
|
192
|
+
const m = line.match(p.rx);
|
|
193
|
+
if (!m) continue;
|
|
194
|
+
// Methods and nested definitions are kept, but only shallowly indented
|
|
195
|
+
// ones, so an outline is an outline and not every callback.
|
|
196
|
+
const indent = line.match(/^\s*/)[0].length;
|
|
197
|
+
if (indent > 4 && p.kind !== "method") break;
|
|
198
|
+
const exported = /^\s*(export|pub|public)\b/.test(line);
|
|
199
|
+
items.push({ line: i + 1, kind: p.kind, name: m[1], exported, text: clip(line, 120) });
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return { path: clean, exists: true, lines: lines.length, items, heuristic: true };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Dispatcher and citation formatting
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
export const OPS = Object.freeze(["grep", "definitions", "references", "outline", "files"]);
|
|
210
|
+
|
|
211
|
+
export function runQuery(root, op, args = {}) {
|
|
212
|
+
const max = Math.max(1, Math.min(Number(args.max_results) || LIMITS.maxResults, 1000));
|
|
213
|
+
switch (op) {
|
|
214
|
+
case "grep": return { op, ...grepRepo(root, { pattern: args.query, regex: args.regex !== false, ignoreCase: Boolean(args.ignore_case), wholeWord: Boolean(args.whole_word), glob: args.glob ?? null, maxResults: max }) };
|
|
215
|
+
case "definitions": return { op, ...findDefinitions(root, args.query, { glob: args.glob ?? null, maxResults: max }) };
|
|
216
|
+
case "references": return { op, ...findReferences(root, args.query, { glob: args.glob ?? null, maxResults: max }) };
|
|
217
|
+
case "outline": return { op, ...outlineFile(root, args.path ?? args.query) };
|
|
218
|
+
case "files": { const r = listFiles(root, { glob: args.glob ?? args.query ?? null, maxFiles: max }); return { op, files: r.files, truncated: r.truncated }; }
|
|
219
|
+
default: throw new Error(`unknown op '${op}'; expected one of ${OPS.join(", ")}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Render results as lines the scout contract accepts verbatim: [path:line] text. */
|
|
224
|
+
export function formatCitations(result) {
|
|
225
|
+
const out = [];
|
|
226
|
+
if (result.op === "files") {
|
|
227
|
+
for (const f of result.files) out.push(`[${f}]`);
|
|
228
|
+
if (!result.files.length) out.push("(no files matched)");
|
|
229
|
+
} else if (result.op === "outline") {
|
|
230
|
+
if (!result.items?.length) out.push(result.exists ? `(no definitions recognized in ${result.path}; ${result.lines ?? "?"} lines)` : `(no such file: ${result.path})`);
|
|
231
|
+
for (const it of result.items ?? []) out.push(`[${result.path}:${it.line}] ${it.kind}${it.exported ? " (exported)" : ""} ${it.name}`);
|
|
232
|
+
} else {
|
|
233
|
+
if (result.op === "references" && result.definitions?.length) {
|
|
234
|
+
out.push(`definitions of ${result.symbol}:`);
|
|
235
|
+
for (const h of result.definitions) out.push(`[${h.path}:${h.line}] ${h.text}`);
|
|
236
|
+
out.push(`references:`);
|
|
237
|
+
}
|
|
238
|
+
for (const h of result.hits) out.push(`[${h.path}:${h.line}] ${h.text}`);
|
|
239
|
+
if (!result.hits.length) out.push(`(no matches${result.op === "grep" ? "" : ` for ${result.symbol}`})`);
|
|
240
|
+
}
|
|
241
|
+
if (result.truncated) out.push("(results truncated; narrow the query or add --glob)");
|
|
242
|
+
if (result.heuristic) out.push("(definitions are matched heuristically by language family)");
|
|
243
|
+
return out.join("\n");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// CLI: node repo-query.mjs <op> <query|path> [--glob <g>] [--max <n>] [--json] [--root <dir>]
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
const isMain = (() => { try { return Boolean(process.argv[1]) && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); } catch { return false; } })();
|
|
250
|
+
if (isMain) {
|
|
251
|
+
const argv = process.argv.slice(2);
|
|
252
|
+
const flag = n => argv.includes(`--${n}`);
|
|
253
|
+
const val = (n, d = null) => { const i = argv.indexOf(`--${n}`); return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : d; };
|
|
254
|
+
const op = argv[0], query = argv[1] && !argv[1].startsWith("--") ? argv[1] : null;
|
|
255
|
+
if (!op || !OPS.includes(op)) {
|
|
256
|
+
console.log(`usage: node repo-query.mjs <${OPS.join("|")}> <query|path> [--glob <pattern>] [--max <n>] [--ignore-case] [--json]\n\n` +
|
|
257
|
+
` grep <regex> lines matching a pattern\n definitions <symbol> where a symbol is defined (heuristic)\n` +
|
|
258
|
+
` references <symbol> where a symbol is used, definitions listed first\n outline <path> what one file declares\n files <glob> files matching a glob\n\n` +
|
|
259
|
+
`Every output line starts with a [path:line] citation you can copy verbatim.`);
|
|
260
|
+
process.exit(op ? 2 : 0);
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
const root = path.resolve(val("root", process.cwd()));
|
|
264
|
+
const result = runQuery(root, op, { query, path: query, glob: val("glob"), max_results: val("max"), ignore_case: flag("ignore-case"), whole_word: flag("word") });
|
|
265
|
+
console.log(flag("json") ? JSON.stringify(result, null, 2) : formatCitations(result));
|
|
266
|
+
} catch (err) { console.error(`repo-query ${op}: ${err.message}`); process.exit(1); }
|
|
267
|
+
}
|
package/lib/runs.mjs
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// nomArmy runs: one feature, end to end, with limits.
|
|
2
|
+
//
|
|
3
|
+
// A run groups every job the General dispatches for one feature (`run_id`
|
|
4
|
+
// on each job). It carries limits -- jobs, api spend, wall-clock hours --
|
|
5
|
+
// that admission enforces, warning at `warn_at` and refusing at the cap. It
|
|
6
|
+
// also watches for a vendor's usage-limit error: that agent is paused for
|
|
7
|
+
// the rest of the run and the General is told, rather than nomArmy quietly
|
|
8
|
+
// sending the role's work to some other vendor (which would change who did
|
|
9
|
+
// the work, and could be pooling).
|
|
10
|
+
//
|
|
11
|
+
// What a run can't see: the General's own seat. No tool reports a Claude
|
|
12
|
+
// subscription's remaining usage to the session using it, so the run log
|
|
13
|
+
// exists for the other half -- if the General's seat runs out mid-feature,
|
|
14
|
+
// a fresh session resumes from the log instead of starting over.
|
|
15
|
+
//
|
|
16
|
+
// Limits come from the army's `run_limits` (global or local layer only: a
|
|
17
|
+
// committed project file must never be able to raise what a run may spend
|
|
18
|
+
// of your money). The General may lower them for one run, never raise them.
|
|
19
|
+
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import crypto from "node:crypto";
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_RUN_LIMITS = Object.freeze({ max_jobs: 40, max_api_usd: 10, max_hours: 6, warn_at: 0.8 });
|
|
25
|
+
const LIMIT_KEYS = ["max_jobs", "max_api_usd", "max_hours"];
|
|
26
|
+
|
|
27
|
+
/** A vendor's "you've hit your limit" error, as opposed to any other failure. */
|
|
28
|
+
const USAGE_LIMIT_RE = /\b(rate[ _-]?limit(?:ed)?|usage[ _-]limit|quota(?: exceeded)?|insufficient[_ ]quota|too many requests|limit (?:reached|exceeded)|hit your (?:usage |rate )?limit|\b429\b)/i;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The usage-limit message in a failed job's error text, or null. Only
|
|
32
|
+
* error text is ever passed here -- never the worker's report or tool
|
|
33
|
+
* output, where "rate limit" could simply be the code under discussion.
|
|
34
|
+
*/
|
|
35
|
+
export function detectUsageLimit(errorText) {
|
|
36
|
+
const text = String(errorText ?? "");
|
|
37
|
+
const match = USAGE_LIMIT_RE.exec(text);
|
|
38
|
+
if (!match) return null;
|
|
39
|
+
const line = text.split(/\r?\n/).find((l) => USAGE_LIMIT_RE.test(l)) ?? match[0];
|
|
40
|
+
return line.replace(/\u001b\[[0-9;]*m/g, "").trim().slice(0, 300);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What the General asked for that it didn't get, as plain sentences: the
|
|
45
|
+
* configured ceiling (or the default, when none is configured) always
|
|
46
|
+
* wins. A $15 request silently becoming $10 read as a bug.
|
|
47
|
+
*/
|
|
48
|
+
export function describeLoweredLimits(configured = {}, requested = {}, resolved) {
|
|
49
|
+
const label = { max_jobs: "jobs", max_api_usd: "api spend ($)", max_hours: "hours" };
|
|
50
|
+
return LIMIT_KEYS.filter((k) => Number.isFinite(requested?.[k]) && requested[k] > resolved[k]).map((k) => {
|
|
51
|
+
const source = configured?.[k] !== undefined ? "your configured run_limits" : "the default (no run_limits configured)";
|
|
52
|
+
return `${label[k]} capped at ${resolved[k]}, not ${requested[k]}: that's the ceiling from ${source}. Raise it with army.run_limits.${k} in ~/.config/nomarmy/config.yml or .nomarmy.local.yml.`;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The configured limits, tightened (never loosened) by the General's per-run request. */
|
|
57
|
+
export function resolveRunLimits(configured = {}, requested = {}) {
|
|
58
|
+
const base = { ...DEFAULT_RUN_LIMITS, ...Object.fromEntries(Object.entries(configured ?? {}).filter(([, v]) => v !== undefined)) };
|
|
59
|
+
const out = { ...base };
|
|
60
|
+
for (const key of LIMIT_KEYS) {
|
|
61
|
+
const want = requested?.[key];
|
|
62
|
+
if (Number.isFinite(want) && want > 0) out[key] = Math.min(base[key], want);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function runPath(runsDir, id) {
|
|
68
|
+
if (!/^run-[a-z0-9-]{1,80}$/.test(String(id))) throw new Error(`"${id}" is not a run id`);
|
|
69
|
+
return path.join(runsDir, `${id}.json`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function writeRun(runsDir, run) {
|
|
73
|
+
fs.mkdirSync(runsDir, { recursive: true });
|
|
74
|
+
const file = runPath(runsDir, run.id);
|
|
75
|
+
fs.writeFileSync(`${file}.tmp`, JSON.stringify(run, null, 2));
|
|
76
|
+
fs.renameSync(`${file}.tmp`, file);
|
|
77
|
+
return run;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createRun(runsDir, { name, repo, limits, now = Date.now() }) {
|
|
81
|
+
const slug = String(name ?? "feature").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "feature";
|
|
82
|
+
const id = `run-${slug}-${crypto.randomBytes(3).toString("hex")}`;
|
|
83
|
+
return writeRun(runsDir, {
|
|
84
|
+
id, name: name ?? slug, repo, status: "running", createdAt: new Date(now).toISOString(),
|
|
85
|
+
limits, jobs: [], pausedAgents: {}, logPath: path.join(runsDir, `${id}.md`), finishedAt: null, summary: null,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function loadRun(runsDir, id) {
|
|
90
|
+
const file = runPath(runsDir, id);
|
|
91
|
+
if (!fs.existsSync(file)) throw new Error(`unknown run "${id}"`);
|
|
92
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Totals so far, each limit's share used, and the warnings to surface. */
|
|
96
|
+
export function runTotals(run, { now = Date.now() } = {}) {
|
|
97
|
+
const apiUsd = run.jobs.reduce((sum, j) => sum + (j.kind === "api" && Number.isFinite(j.costUsd) ? j.costUsd : 0), 0);
|
|
98
|
+
const hours = (now - Date.parse(run.createdAt)) / 3600000;
|
|
99
|
+
const used = { max_jobs: run.jobs.length, max_api_usd: +apiUsd.toFixed(4), max_hours: +hours.toFixed(2) };
|
|
100
|
+
const share = Object.fromEntries(LIMIT_KEYS.map((k) => [k, run.limits[k] > 0 ? used[k] / run.limits[k] : 0]));
|
|
101
|
+
const byAgent = {};
|
|
102
|
+
for (const j of run.jobs) {
|
|
103
|
+
const a = (byAgent[j.agent] ??= { jobs: 0, apiUsd: 0, tokens: 0 });
|
|
104
|
+
a.jobs += 1;
|
|
105
|
+
if (j.kind === "api" && Number.isFinite(j.costUsd)) a.apiUsd = +(a.apiUsd + j.costUsd).toFixed(4);
|
|
106
|
+
if (Number.isFinite(j.tokens)) a.tokens += j.tokens;
|
|
107
|
+
}
|
|
108
|
+
const label = { max_jobs: "jobs", max_api_usd: "api spend ($)", max_hours: "hours" };
|
|
109
|
+
// Past a limit is the loudest case, not a silent one: warnings used to
|
|
110
|
+
// stop at 100%, so a run at 8.46 of its 4 hours reported nothing.
|
|
111
|
+
const warnings = [
|
|
112
|
+
...LIMIT_KEYS.filter((k) => share[k] >= 1).map((k) => `run is OVER its ${label[k]} limit (${used[k]} of ${run.limits[k]}); no more jobs are admitted`),
|
|
113
|
+
...LIMIT_KEYS.filter((k) => share[k] >= run.limits.warn_at && share[k] < 1).map((k) => `run is at ${Math.round(share[k] * 100)}% of its ${label[k]} limit (${used[k]} of ${run.limits[k]})`),
|
|
114
|
+
];
|
|
115
|
+
for (const [agent, reason] of Object.entries(run.pausedAgents)) warnings.push(`agent "${agent}" is paused for this run: ${reason}`);
|
|
116
|
+
return { used, share, byAgent, warnings };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Why a job may not start in this run, or []. `agentName` is the job's
|
|
121
|
+
* resolved agent ("local" when it names none).
|
|
122
|
+
*/
|
|
123
|
+
export function runAdmissionProblems(run, { agentName = "local", now = Date.now(), running = 0 } = {}) {
|
|
124
|
+
if (run.status !== "running") return [`run "${run.id}" is ${run.status}; start a new run (or resume by reading its log) rather than adding jobs to it`];
|
|
125
|
+
const problems = [];
|
|
126
|
+
const { used } = runTotals(run, { now });
|
|
127
|
+
// Jobs still running count too: a job is recorded into its run only when
|
|
128
|
+
// it finishes, so jobs started together could otherwise overshoot.
|
|
129
|
+
if (used.max_jobs + running >= run.limits.max_jobs) problems.push(`run "${run.id}" has used all ${run.limits.max_jobs} of its jobs${running ? ` (${running} still running)` : ""}`);
|
|
130
|
+
if (used.max_api_usd >= run.limits.max_api_usd) problems.push(`run "${run.id}" has spent $${used.max_api_usd} of its $${run.limits.max_api_usd} api limit`);
|
|
131
|
+
if (used.max_hours >= run.limits.max_hours) problems.push(`run "${run.id}" has run ${used.max_hours} of its ${run.limits.max_hours} hours`);
|
|
132
|
+
if (run.pausedAgents[agentName]) problems.push(`agent "${agentName}" is paused for run "${run.id}" (${run.pausedAgents[agentName]}); stop and tell the operator rather than moving this role to a different vendor`);
|
|
133
|
+
return problems;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Add a finished job; pause its agent on a usage-limit error. Returns the updated run. */
|
|
137
|
+
export function recordRunJob(runsDir, id, job) {
|
|
138
|
+
const run = loadRun(runsDir, id);
|
|
139
|
+
run.jobs.push({ ...job, recordedAt: new Date().toISOString() });
|
|
140
|
+
if (job.usageLimit) run.pausedAgents[job.agent] = job.usageLimit;
|
|
141
|
+
return writeRun(runsDir, run);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function finishRun(runsDir, id, { status, summary }) {
|
|
145
|
+
const run = loadRun(runsDir, id);
|
|
146
|
+
run.status = status;
|
|
147
|
+
run.summary = summary ?? null;
|
|
148
|
+
run.finishedAt = new Date().toISOString();
|
|
149
|
+
return writeRun(runsDir, run);
|
|
150
|
+
}
|