nomarmy 0.1.0-alpha.2 → 0.1.0-alpha.21
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/README.md +86 -480
- package/bin/nomarmy.mjs +1081 -185
- package/docker/Dockerfile +2 -2
- package/docker/Dockerfile.go +6 -4
- package/docker/Dockerfile.rust +17 -2
- package/harnesses/_template/README.md +27 -0
- package/harnesses/_template/harness.yml +26 -0
- package/harnesses/browser-playwright/README.md +35 -0
- package/harnesses/browser-playwright/fixture/package.json +1 -0
- package/harnesses/browser-playwright/fixture/page.html +1 -0
- package/harnesses/browser-playwright/fixture/page.spec.js +5 -0
- package/harnesses/browser-playwright/fixture/playwright.config.js +8 -0
- package/harnesses/browser-playwright/harness.yml +18 -0
- package/harnesses/go/README.md +45 -0
- package/harnesses/go/harness.yml +14 -0
- package/harnesses/mock-oidc/README.md +31 -0
- package/harnesses/mock-oidc/fixture/.nomarmy.yml +4 -0
- package/harnesses/mock-oidc/fixture/discovery.test.mjs +16 -0
- package/harnesses/mock-oidc/harness.yml +19 -0
- package/harnesses/node/README.md +53 -0
- package/harnesses/node/harness.yml +18 -0
- package/harnesses/python/README.md +46 -0
- package/harnesses/python/harness.yml +16 -0
- package/harnesses/rust/README.md +45 -0
- package/harnesses/rust/harness.yml +13 -0
- package/install.sh +29 -9
- package/lib/admission.mjs +178 -30
- package/lib/agents.mjs +8 -6
- package/lib/army.mjs +25 -10
- package/lib/codex-link.mjs +37 -0
- package/lib/config.mjs +15 -0
- package/lib/connect.mjs +232 -19
- package/lib/continue-from.mjs +103 -0
- package/lib/coordinator-instructions.mjs +5 -1
- package/lib/diff-checks.mjs +114 -0
- package/lib/dispatch-schema.mjs +14 -12
- package/lib/doctor.mjs +98 -9
- package/lib/egress-proxy.mjs +116 -0
- package/lib/execute.mjs +241 -33
- package/lib/git-record.mjs +27 -3
- package/lib/harness-schema.mjs +61 -0
- package/lib/harnesses.mjs +99 -0
- package/lib/health.mjs +162 -18
- package/lib/install-freshness.mjs +114 -0
- package/lib/jev-checks.mjs +110 -0
- package/lib/job-format.mjs +54 -0
- package/lib/judge.mjs +130 -0
- package/lib/limits.mjs +77 -0
- package/lib/model-probe.mjs +61 -0
- package/lib/mutation.mjs +159 -0
- package/lib/notify.mjs +30 -3
- package/lib/openclaw-install.mjs +122 -0
- package/lib/openclaw-path.mjs +28 -0
- package/lib/openclaw-run.mjs +74 -12
- package/lib/openclaw-runtime-health.mjs +56 -0
- package/lib/outcome.mjs +21 -2
- package/lib/outcomes.mjs +6 -0
- package/lib/path-utils.mjs +4 -0
- package/lib/podman-health.mjs +41 -0
- package/lib/process.mjs +4 -1
- package/lib/propose.mjs +10 -11
- package/lib/refusal-retry.mjs +16 -0
- package/lib/registry-python.mjs +98 -0
- package/lib/registry-secrets.mjs +140 -0
- package/lib/repo-query.mjs +13 -7
- package/lib/runs.mjs +7 -1
- package/lib/same-path.mjs +14 -0
- package/lib/sandbox-images.mjs +499 -83
- package/lib/sandbox-vm.mjs +32 -0
- package/lib/scan.mjs +5 -1
- package/lib/schema.mjs +20 -11
- package/lib/scout.mjs +21 -3
- package/lib/server-context.mjs +21 -1
- package/lib/setup-steps.mjs +55 -0
- package/lib/share.mjs +82 -0
- package/lib/stale-sessions.mjs +60 -0
- package/lib/stats.mjs +315 -0
- package/lib/statusline.mjs +32 -6
- package/lib/subscription-setup.mjs +13 -0
- package/lib/suggestions.mjs +153 -0
- package/lib/thinking.mjs +23 -0
- package/lib/transcript.mjs +30 -5
- package/lib/usage-limits.mjs +329 -0
- package/lib/user-config.mjs +106 -0
- package/lib/validators.mjs +220 -0
- package/lib/verification-artifacts.mjs +46 -0
- package/lib/verification-flow.mjs +52 -7
- package/lib/verification-network.mjs +66 -0
- package/lib/verify.mjs +338 -85
- package/lib/worker-prompt.mjs +5 -2
- package/lib/wsl-cli.mjs +152 -0
- package/lib/wsl.mjs +230 -0
- package/lib/zod-issues.mjs +15 -0
- package/mcp/server.mjs +165 -34
- package/package.json +7 -5
- package/playbooks/feature.md +8 -5
- package/scripts/configure-openclaw.sh +4 -2
- package/scripts/generate-harness-docs.mjs +42 -0
- package/scripts/install-openclaw.mjs +23 -0
- package/scripts/lib.sh +9 -2
- package/scripts/select-model.mjs +12 -5
- package/scripts/start-inference.sh +2 -2
package/lib/admission.mjs
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { executionMode } from "./execution.mjs";
|
|
4
|
-
import {
|
|
4
|
+
import { jobLabel, jobElapsedSeconds } from "./job-format.mjs";
|
|
5
|
+
import { continuationProblem } from "./continue-from.mjs";
|
|
6
|
+
import { loadConfig } from "./config.mjs";
|
|
7
|
+
import { maxJobs } from "./limits.mjs";
|
|
5
8
|
import { checkBrief, assessAdmission, describeBudgets } from "./budget.mjs";
|
|
6
9
|
import { parseStatusPorcelainZ, isRuntimeJunk } from "./git-record.mjs";
|
|
7
10
|
import { readJson } from "./openclaw-run.mjs";
|
|
8
11
|
import { readOpenClawTranscriptTail } from "./transcript.mjs";
|
|
9
12
|
import { readClaudeSessionTranscript } from "./claude-transcript.mjs";
|
|
10
13
|
import { notify } from "./notify.mjs";
|
|
11
|
-
import {
|
|
14
|
+
import { readCodexRateLimits, recordUsageSnapshot, readUsageSnapshots, usageStatus, usageDisplayText, refreshStaleOverLimitReadings, staleUsageRefreshedNote, USAGE_STALE_MINUTES } from "./usage-limits.mjs";
|
|
15
|
+
import { projectDirProblem } from "./server-context.mjs";
|
|
16
|
+
import { recentModelRefusal, refusedModelIn, recordModelRefusal, recordProbeSuccess, modelProven, modelRefusals, claimModelRefusalRetry } from "./health.mjs";
|
|
12
17
|
import { writeLease, removeLease, liveLeases, liveSlots, acquireSlot } from "./slots.mjs";
|
|
13
18
|
import { loadRun, runTotals, runAdmissionProblems, recordRunJob, detectUsageLimit } from "./runs.mjs";
|
|
14
19
|
import { agentProviderId, hostToolsImplementProblem } from "./agents.mjs";
|
|
@@ -21,7 +26,7 @@ import { policyAdmissionProblems } from "./outcome.mjs";
|
|
|
21
26
|
// Claude or Codex job took llama-server's only slot and blocked local work
|
|
22
27
|
// it never competed with -- reported from a real Senti run.
|
|
23
28
|
export function jobLane(job) {
|
|
24
|
-
return job.pool || job.subscription_worker ? "remote" : "local";
|
|
29
|
+
return job.mode === "verify" || job.pool || job.subscription_worker ? "remote" : "local";
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
// A static, operator-declared ceiling on how many remote jobs (api and
|
|
@@ -33,8 +38,9 @@ export function jobLane(job) {
|
|
|
33
38
|
// benefit of spreading load across providers with their own separate rate
|
|
34
39
|
// limits. Not rate-limit-aware (see config/providers.yml.example); read
|
|
35
40
|
// fresh each call, matching currentMaxWorkers()'s own env-read pattern.
|
|
41
|
+
/** Api and subscription jobs at once, machine-wide: limits.yml's max_jobs (see lib/limits.mjs). */
|
|
36
42
|
export function currentMaxPoolWorkers() {
|
|
37
|
-
return
|
|
43
|
+
return maxJobs().value;
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
// Pure partition of a batch's ORIGINAL indices by lane -- pulled out of
|
|
@@ -113,7 +119,7 @@ export function createJobRuntime(deps) {
|
|
|
113
119
|
* batch queue for a slot instead of failing.
|
|
114
120
|
*/
|
|
115
121
|
function withAgentSlot(args, jobId, fn, { waitMs = 0 } = {}) {
|
|
116
|
-
const max = args.agentName ? agentMaxConcurrent(args.agentName) : null;
|
|
122
|
+
const max = args.mode !== "verify" && args.agentName ? agentMaxConcurrent(args.agentName) : null;
|
|
117
123
|
if (!max) return fn();
|
|
118
124
|
return (async () => {
|
|
119
125
|
const slot = await acquireSlot(slotsRoot, args.agentName, max, { jobId, waitMs });
|
|
@@ -121,6 +127,19 @@ export function createJobRuntime(deps) {
|
|
|
121
127
|
try { return await fn(); } finally { slot.release(); }
|
|
122
128
|
})();
|
|
123
129
|
}
|
|
130
|
+
// Who ran the job and for whom, onto its finished record, so `nomarmy
|
|
131
|
+
// stats` can count by role and agent, and by repo for verify runs too
|
|
132
|
+
// (their records didn't carry the repo). Best-effort; never fails the job.
|
|
133
|
+
function stampJobRecord(entry, result) {
|
|
134
|
+
const file = path.join(result?.jobDir ?? path.join(jobsRoot, entry.jobId), "metadata.json");
|
|
135
|
+
try {
|
|
136
|
+
const record = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
137
|
+
record.labels = { role: entry.role ?? null, agent: entry.agent ?? null, label: entry.label ?? null, runId: entry.runId ?? null };
|
|
138
|
+
record.projectDir = record.projectDir ?? projectDir;
|
|
139
|
+
fs.writeFileSync(`${file}.tmp`, JSON.stringify(record, null, 2));
|
|
140
|
+
fs.renameSync(`${file}.tmp`, file);
|
|
141
|
+
} catch { /* no record (a refused or crashed job): nothing to label */ }
|
|
142
|
+
}
|
|
124
143
|
function track(jobId, meta, promise) {
|
|
125
144
|
const entry = { ...meta, jobId, startedAt: new Date().toISOString(), settled: false, result: null, error: null, promise: null };
|
|
126
145
|
// A machine-wide lease for as long as the job runs, so every session's
|
|
@@ -129,7 +148,7 @@ export function createJobRuntime(deps) {
|
|
|
129
148
|
if (meta.lane) writeLease(leasesRoot, jobId, { lane: meta.lane, agent: meta.agent ?? null, runId: meta.runId ?? null, role: meta.role ?? null, model: meta.model ?? null, repo: projectDir });
|
|
130
149
|
const release = () => removeLease(leasesRoot, jobId);
|
|
131
150
|
entry.promise = promise.then(
|
|
132
|
-
r => { entry.settled = true; entry.result = r; release(); notifyJobFinished(entry, r, null); return r; },
|
|
151
|
+
r => { entry.settled = true; entry.result = r; release(); stampJobRecord(entry, r); notifyJobFinished(entry, r, null); return r; },
|
|
133
152
|
e => { entry.settled = true; entry.error = e; release(); notifyJobFinished(entry, null, e); throw e; });
|
|
134
153
|
entry.promise.catch(() => {});
|
|
135
154
|
activeJobs.set(jobId, entry);
|
|
@@ -140,18 +159,29 @@ export function createJobRuntime(deps) {
|
|
|
140
159
|
* watching hears about it from any coordinator without polling.
|
|
141
160
|
*/
|
|
142
161
|
function notifyJobFinished(entry, result, error) {
|
|
162
|
+
try {
|
|
163
|
+
const provider = entry.agent ? agentProviderId(agentsConfig().agents[entry.agent]) : null;
|
|
164
|
+
const snapshot = provider ? readCodexRateLimits(path.join(jobsRoot, entry.jobId)) : null;
|
|
165
|
+
if (snapshot) recordUsageSnapshot(stateRoot, provider, snapshot);
|
|
166
|
+
} catch { /* Usage telemetry must never affect the job result. */ }
|
|
143
167
|
if (!entry.lane) return; // only tracked jobs, never internal helpers
|
|
144
168
|
const m = result?.manifest ?? {};
|
|
169
|
+
// Remember a model its vendor refused, until something on it works.
|
|
170
|
+
try {
|
|
171
|
+
const refused = refusedModelIn(m);
|
|
172
|
+
if (refused) recordModelRefusal(stateRoot, refused, String(m.workerError ?? m.worker?.error ?? "").split("\n")[0].slice(0, 300) || null);
|
|
173
|
+
else if (m.worker?.provider && m.worker?.model && !m.workerError && !error) recordProbeSuccess(stateRoot, `${m.worker.provider}/${m.worker.model}`);
|
|
174
|
+
} catch { /* best-effort, like the usage reading */ }
|
|
145
175
|
const outcome = error ? "failed" : String(m.outcome ?? (result?.ok ? "done" : "finished")).toLowerCase().replace(/_/g, " ");
|
|
146
|
-
const who = entry.agent ? `${entry.agent}${entry.model ? `/${entry.model}` : ""}` : "local model";
|
|
176
|
+
const who = entry.mode === "verify" ? "verification runner" : entry.agent ? `${entry.agent}${entry.model ? `/${entry.model}` : ""}` : "local model";
|
|
147
177
|
const took = Math.round((Date.now() - Date.parse(entry.startedAt)) / 60000);
|
|
148
178
|
const ok = !error && (result?.ok || m.coordinatorStatus === "complete");
|
|
149
|
-
notify(`nomArmy: ${entry.role ?? entry.mode ?? "job"} ${ok ? "done" : outcome}`, `${entry.workerId ?? entry.jobId} on ${who}: ${outcome} after ${took}m. ${ok ? "Ready for the General's review." : "Needs a look."}`);
|
|
179
|
+
notify(`nomArmy: ${entry.role ?? entry.mode ?? "job"} ${ok ? "done" : outcome}`, `${entry.label ? `${entry.label} (${entry.workerId ?? entry.jobId})` : entry.workerId ?? entry.jobId} on ${who}: ${outcome} after ${took}m. ${ok ? "Ready for the General's review." : "Needs a look."}`);
|
|
150
180
|
}
|
|
151
181
|
function admissionHardware() {
|
|
152
182
|
return executionMode(deps.env).managesModelServer ? deps.budgetState.hardwareSnapshot : null;
|
|
153
183
|
}
|
|
154
|
-
function capacitySnapshot() {
|
|
184
|
+
function capacitySnapshot(usageOverride = null) {
|
|
155
185
|
const admission = assessAdmission({ hardware: admissionHardware(), runningJobs: runningCount("local"), slots: deps.budgetState.contextInfo.slots, maxWorkers: currentMaxWorkers() });
|
|
156
186
|
return {
|
|
157
187
|
// The local model's budget. An api or subscription job's scales with
|
|
@@ -161,14 +191,55 @@ export function createJobRuntime(deps) {
|
|
|
161
191
|
admission,
|
|
162
192
|
memory: deps.budgetState.hardwareSnapshot?.memory ?? null,
|
|
163
193
|
running: [...activeJobs.values()].filter(j => !j.settled).map(j => ({ jobId: j.jobId, workerId: j.workerId, mode: j.mode, lane: j.lane, startedAt: j.startedAt, phase: readJson(path.join(jobsRoot, j.jobId, "status.json"))?.phase ?? "starting" })),
|
|
194
|
+
usageLimits: Object.fromEntries(Object.entries(usageOverride ?? readUsageSnapshots(stateRoot)).map(([provider, snapshot]) => {
|
|
195
|
+
const status = usageStatus(snapshot);
|
|
196
|
+
return [provider, { ...status, text: usageDisplayText(status) }];
|
|
197
|
+
})),
|
|
164
198
|
maxWorkers: currentMaxWorkers(),
|
|
165
|
-
remote: { running: runningCount("remote"), maxWorkers:
|
|
199
|
+
remote: (() => { const limit = maxJobs(); return { running: runningCount("remote"), maxWorkers: limit.value, setBy: limit.source === "file" ? limit.path : limit.source === "env" ? "NOMARMY_MAX_POOL_WORKERS" : "default", note: "api and subscription agents; each agent's own max_concurrent also applies. Change with `nomarmy config max-jobs <n>`." }; })()
|
|
166
200
|
};
|
|
167
201
|
}
|
|
168
202
|
async function admit(jobs) {
|
|
169
|
-
await deps.budgetState.refresh();
|
|
170
|
-
if (jobs.some((j) => jobLane(j) === "remote")) await modelCatalogReady();
|
|
203
|
+
if (jobs.some(j => j.mode !== "verify")) await deps.budgetState.refresh();
|
|
204
|
+
if (jobs.some((j) => j.mode !== "verify" && jobLane(j) === "remote")) await modelCatalogReady();
|
|
171
205
|
const problems = [];
|
|
206
|
+
const notARepo = (deps.projectDirProblem ?? projectDirProblem)(projectDir);
|
|
207
|
+
if (notARepo) problems.push(notARepo);
|
|
208
|
+
// Every job needs its sandbox; the server wires the Podman check (tests don't).
|
|
209
|
+
const noSandbox = deps.sandboxProblem ? deps.sandboxProblem() : null;
|
|
210
|
+
if (noSandbox) problems.push(noSandbox);
|
|
211
|
+
const usageNow = deps.now?.() ?? Date.now();
|
|
212
|
+
let snapshots = readUsageSnapshots(stateRoot);
|
|
213
|
+
const providerFor = (job) => {
|
|
214
|
+
if (job.mode === "verify" || !job.agentName || job.confirm_over_limit === true) return null;
|
|
215
|
+
try { return agentProviderId(agentsConfig().agents[job.agentName]) ?? null; } catch { return null; }
|
|
216
|
+
};
|
|
217
|
+
const held = [];
|
|
218
|
+
jobs.forEach((j, i) => {
|
|
219
|
+
const provider = providerFor(j);
|
|
220
|
+
if (!provider || !snapshots[provider]) return;
|
|
221
|
+
const status = usageStatus(snapshots[provider], usageNow);
|
|
222
|
+
if (status.level === "over") held.push({ i, j, provider, status });
|
|
223
|
+
});
|
|
224
|
+
const usageNotes = [];
|
|
225
|
+
let failedProviders = new Set();
|
|
226
|
+
let refreshError = null;
|
|
227
|
+
if (held.some((item) => item.status.ageMinutes >= USAGE_STALE_MINUTES)) {
|
|
228
|
+
const refreshed = await refreshStaleOverLimitReadings(stateRoot, { run: deps.usageRun, now: usageNow, snapshots, timeoutMs: deps.usageRefreshTimeoutMs });
|
|
229
|
+
snapshots = refreshed.snapshots;
|
|
230
|
+
refreshError = refreshed.error;
|
|
231
|
+
failedProviders = new Set(refreshed.failedProviders ?? []);
|
|
232
|
+
}
|
|
233
|
+
const cleared = new Set();
|
|
234
|
+
for (const item of held) {
|
|
235
|
+
const status = snapshots[item.provider] ? usageStatus(snapshots[item.provider], usageNow) : item.status;
|
|
236
|
+
if (status.level !== "over") {
|
|
237
|
+
if (!cleared.has(item.provider)) { cleared.add(item.provider); usageNotes.push(staleUsageRefreshedNote(item.provider)); }
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const extra = failedProviders.has(item.provider) ? ` ${refreshError ?? "OpenClaw usage refresh failed"}.` : "";
|
|
241
|
+
problems.push(`${jobs.length > 1 ? `job ${item.i + 1}: ` : ""}agent "${item.j.agentName}" is held at its usage limit: ${status.text} (reading ${status.ageMinutes} minutes old).${extra} Ask the operator before resubmitting with confirm_over_limit: true, or send the job to another agent.`);
|
|
242
|
+
}
|
|
172
243
|
// A pool-routed job is checked against that pool's OWN (model-dependent)
|
|
173
244
|
// budget, not the local-derived global one -- see budgetsForPool. Which
|
|
174
245
|
// specific entry pickProvider will land on isn't known yet at admission
|
|
@@ -178,12 +249,36 @@ export function createJobRuntime(deps) {
|
|
|
178
249
|
// (see budgetsForSubscriptionWorker) -- there's no "which entry" unknown
|
|
179
250
|
// the way a weighted pool has, since the name given IS the entry.
|
|
180
251
|
jobs.forEach((j, i) => {
|
|
252
|
+
if (j.mode === "verify") {
|
|
253
|
+
try {
|
|
254
|
+
const profiles = Object.keys(loadConfig(projectDir)?.config?.verification ?? {});
|
|
255
|
+
if (!j.verification || !profiles.includes(j.verification)) problems.push(`${jobs.length > 1 ? `job ${i + 1}: ` : ""}verify requires a verification profile from .nomarmy.yml; available profiles: ${profiles.join(", ") || "(none)"}`);
|
|
256
|
+
} catch (error) { problems.push(error.message); }
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
181
259
|
const jobBudgets = budgetsForJob(j);
|
|
182
260
|
for (const p of checkBrief(j, jobBudgets)) problems.push(jobs.length > 1 ? `job ${i + 1}: ${p}` : p);
|
|
183
261
|
});
|
|
184
262
|
// verify_regression re-runs `verification`; with no profile set there is
|
|
185
263
|
// nothing to re-run. Refuse before starting anything, matching every other
|
|
186
264
|
// admission check here, rather than silently no-op at runtime.
|
|
265
|
+
// stakes applies to what gets committed; reviews names the job a scout reviews.
|
|
266
|
+
jobs.forEach((j, i) => {
|
|
267
|
+
const at = jobs.length > 1 ? `job ${i + 1}: ` : "";
|
|
268
|
+
if (j.stakes === "high" && (j.mode ?? "implement") !== "implement") problems.push(`${at}stakes: high applies to implement jobs; for a review, send a scout with reviews: <job id>`);
|
|
269
|
+
if (j.reviews) {
|
|
270
|
+
if (j.mode !== "scout") problems.push(`${at}reviews: <job id> marks a scout as a review of that job; this job is ${j.mode ?? "implement"}`);
|
|
271
|
+
else if (!fs.existsSync(path.join(jobsRoot, j.reviews, "metadata.json"))) problems.push(`${at}reviews: no finished job ${j.reviews} to review (see local_worker_jobs)`);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
// continue_from: the retained job must exist, be unfinished and uncommitted,
|
|
275
|
+
// and belong to this repo; see lib/continue-from.mjs.
|
|
276
|
+
jobs.forEach((j, i) => {
|
|
277
|
+
if (!j.continue_from) return;
|
|
278
|
+
const { problem } = continuationProblem({ continueFrom: j.continue_from, mode: j.mode, baseRef: j.base_ref ?? null, jobsRoot, projectDir,
|
|
279
|
+
isRunning: (id) => activeJobs.get(id)?.settled === false });
|
|
280
|
+
if (problem) problems.push(`${jobs.length > 1 ? `job ${i + 1}: ` : ""}${problem}`);
|
|
281
|
+
});
|
|
187
282
|
jobs.forEach((j, i) => {
|
|
188
283
|
if (j.verify_regression && !j.verification) {
|
|
189
284
|
problems.push(`${jobs.length > 1 ? `job ${i + 1}: ` : ""}verify_regression requires a verification profile; there is nothing to run twice without one`);
|
|
@@ -196,6 +291,7 @@ export function createJobRuntime(deps) {
|
|
|
196
291
|
// clean, so a missing on_behalf_of is never reported twice in two
|
|
197
292
|
// different shapes.
|
|
198
293
|
jobs.forEach((j, i) => {
|
|
294
|
+
if (j.mode === "verify") return;
|
|
199
295
|
const fieldProblems = subscriptionJobFieldProblems(j);
|
|
200
296
|
for (const p of fieldProblems) problems.push(jobs.length > 1 ? `job ${i + 1}: ${p}` : p);
|
|
201
297
|
if (fieldProblems.length === 0 && j.on_behalf_of) {
|
|
@@ -211,26 +307,63 @@ export function createJobRuntime(deps) {
|
|
|
211
307
|
// Claude CLI) isn't bounded by the sandbox, so it's refused unless that
|
|
212
308
|
// agent says allow_host_tools (lib/agents.mjs). Scouts and reviews still run.
|
|
213
309
|
jobs.forEach((j, i) => {
|
|
214
|
-
if (!j.agentName || (j.mode ?? "implement") !== "implement") return;
|
|
310
|
+
if (j.mode === "verify" || !j.agentName || (j.mode ?? "implement") !== "implement") return;
|
|
215
311
|
let problem = null;
|
|
216
312
|
try { problem = hostToolsImplementProblem(j.agentName, agentsConfig().agents[j.agentName]); } catch { return; }
|
|
217
313
|
if (problem) problems.push(`${jobs.length > 1 ? `job ${i + 1}: ` : ""}${problem}`);
|
|
218
314
|
});
|
|
219
|
-
//
|
|
220
|
-
|
|
315
|
+
// Re-test stale refusals once per day before making an admission decision.
|
|
316
|
+
const now = deps.now?.() ?? Date.now();
|
|
317
|
+
const refusedJobs = new Map();
|
|
221
318
|
jobs.forEach((j, i) => {
|
|
222
|
-
if (!j.agentName || !j.model) return;
|
|
319
|
+
if (j.mode === "verify" || !j.agentName || !j.model) return;
|
|
223
320
|
let provider = null;
|
|
224
321
|
try { provider = agentProviderId(agentsConfig().agents[j.agentName]); } catch { return; }
|
|
225
322
|
if (!provider) return;
|
|
226
|
-
const
|
|
227
|
-
if (
|
|
323
|
+
const key = `${provider}/${j.model}`;
|
|
324
|
+
if (!recentModelRefusal(stateRoot, key, { now })) return;
|
|
325
|
+
if (!refusedJobs.has(key)) refusedJobs.set(key, { provider, model: j.model, jobs: [] });
|
|
326
|
+
refusedJobs.get(key).jobs.push(i);
|
|
228
327
|
});
|
|
328
|
+
await Promise.all([...refusedJobs.entries()].map(async ([key, entry]) => {
|
|
329
|
+
const hadRefusal = Boolean(modelRefusals(stateRoot)[key]);
|
|
330
|
+
if (hadRefusal && deps.probeModel && claimModelRefusalRetry(stateRoot, key, { now })) {
|
|
331
|
+
try {
|
|
332
|
+
const result = await deps.probeModel({ provider: entry.provider, model: entry.model, stateRoot });
|
|
333
|
+
if (result.ok) { recordProbeSuccess(stateRoot, key, { now }); return; }
|
|
334
|
+
if (result.refused) recordModelRefusal(stateRoot, key, result.reason, { now });
|
|
335
|
+
} catch { /* A failed probe is inconclusive; keep the original refusal. */ }
|
|
336
|
+
}
|
|
337
|
+
for (const i of entry.jobs) problems.push(`${jobs.length > 1 ? `job ${i + 1}: ` : ""}model_not_found: ${key} was refused on an earlier job and hasn't worked since, so this job wasn't sent. Use another model or re-test with nomarmy army assign.`);
|
|
338
|
+
}));
|
|
339
|
+
// A model that has never worked for its agent gets one test call first
|
|
340
|
+
// (lib/model-probe.mjs): a refusal then costs seconds, not a whole job.
|
|
341
|
+
// The server wires the probe; without one (tests), nothing is probed.
|
|
342
|
+
if (deps.probeModel && !problems.length) {
|
|
343
|
+
const pending = new Map();
|
|
344
|
+
jobs.forEach((j, i) => {
|
|
345
|
+
if (j.mode === "verify" || !j.agentName || !j.model) return;
|
|
346
|
+
let provider = null;
|
|
347
|
+
try { provider = agentProviderId(agentsConfig().agents[j.agentName]); } catch { return; }
|
|
348
|
+
const key = provider && `${provider}/${j.model}`;
|
|
349
|
+
if (!key || modelProven(stateRoot, key) || modelRefusals(stateRoot)[key]) return;
|
|
350
|
+
if (!pending.has(key)) pending.set(key, { provider, model: j.model, jobs: [] });
|
|
351
|
+
pending.get(key).jobs.push({ i, agentName: j.agentName });
|
|
352
|
+
});
|
|
353
|
+
await Promise.all([...pending.entries()].map(async ([key, p]) => {
|
|
354
|
+
let result;
|
|
355
|
+
try { result = await deps.probeModel({ provider: p.provider, model: p.model, stateRoot }); } catch { return; }
|
|
356
|
+
if (result.ok) { recordProbeSuccess(stateRoot, key); return; }
|
|
357
|
+
if (!result.refused) return; // inconclusive: let the job find out
|
|
358
|
+
recordModelRefusal(stateRoot, key, result.reason);
|
|
359
|
+
for (const { i, agentName } of p.jobs) problems.push(`${jobs.length > 1 ? `job ${i + 1}: ` : ""}model_not_found: ${key} refused a test call before this job was sent${result.reason ? ` (${result.reason})` : ""}. Pick another of ${agentName}'s models (the job's \`model\`; the \`army\` tool lists them).`);
|
|
360
|
+
}));
|
|
361
|
+
}
|
|
229
362
|
// An agent's max_concurrent, machine-wide. Batch jobs on the same agent
|
|
230
363
|
// queue for its slot at launch instead (withAgentSlot's waitMs).
|
|
231
364
|
if (jobs.length === 1) {
|
|
232
365
|
const [j] = jobs;
|
|
233
|
-
const max = j.agentName ? agentMaxConcurrent(j.agentName) : null;
|
|
366
|
+
const max = j.mode !== "verify" && j.agentName ? agentMaxConcurrent(j.agentName) : null;
|
|
234
367
|
const held = max ? liveSlots(slotsRoot, j.agentName) : 0;
|
|
235
368
|
if (max && held >= max) problems.push(`not admitted (capacity): agent "${j.agentName}" already has ${held} job(s) running across this machine's nomArmy sessions, at its max_concurrent of ${max}`);
|
|
236
369
|
}
|
|
@@ -241,7 +374,7 @@ export function createJobRuntime(deps) {
|
|
|
241
374
|
const run = loadRun(runsRoot, j.run_id);
|
|
242
375
|
if (run.repo !== projectDir) problems.push(`${jobs.length > 1 ? `job ${i + 1}: ` : ""}run "${run.id}" belongs to ${run.repo}, not this repository`);
|
|
243
376
|
const running = liveLeases(leasesRoot, { runId: run.id }).length + jobs.slice(0, i).filter((o) => o.run_id === run.id).length;
|
|
244
|
-
for (const p of runAdmissionProblems(run, { agentName: j.agentName ?? "local", running })) problems.push(jobs.length > 1 ? `job ${i + 1}: ${p}` : p);
|
|
377
|
+
for (const p of runAdmissionProblems(run, { agentName: j.mode === "verify" ? null : j.agentName ?? "local", running })) problems.push(jobs.length > 1 ? `job ${i + 1}: ${p}` : p);
|
|
245
378
|
} catch (error) { problems.push(jobs.length > 1 ? `job ${i + 1}: ${error.message}` : error.message); }
|
|
246
379
|
});
|
|
247
380
|
// Slot capacity only concerns local jobs: a remote job's inference runs
|
|
@@ -254,17 +387,31 @@ export function createJobRuntime(deps) {
|
|
|
254
387
|
const anyLocal = jobs.some((j) => jobLane(j) === "local");
|
|
255
388
|
const admission = anyLocal
|
|
256
389
|
? assessAdmission({ hardware: admissionHardware(), runningJobs: runningCount("local"), slots: deps.budgetState.contextInfo.slots, maxWorkers: currentMaxWorkers() })
|
|
257
|
-
: assessAdmission({ hardware: admissionHardware(), runningJobs: 0, slots: null, maxWorkers: Infinity });
|
|
390
|
+
: assessAdmission({ hardware: jobs.every(j => j.mode === "verify") ? null : admissionHardware(), runningJobs: 0, slots: null, maxWorkers: Infinity });
|
|
258
391
|
if (!admission.admit) problems.push(...admission.reasons.map(r => `not admitted (${admission.level}): ${r}`));
|
|
259
392
|
if (jobs.some((j) => jobLane(j) === "remote")) {
|
|
260
393
|
const remoteCeiling = currentMaxPoolWorkers(), runningRemote = runningCount("remote");
|
|
261
394
|
if (runningRemote >= remoteCeiling) {
|
|
262
|
-
problems.push(`not admitted (capacity): ${runningRemote} remote job(s) (api or subscription agents) already running,
|
|
395
|
+
problems.push(`not admitted (capacity): ${runningRemote} remote job(s) (api or subscription agents) already running, the limit of ${remoteCeiling} at once (raise it with \`nomarmy config max-jobs <n>\`)`);
|
|
263
396
|
}
|
|
264
397
|
}
|
|
398
|
+
for (const note of usageNotes) admission.reasons.push(note);
|
|
265
399
|
return { problems, admission };
|
|
266
400
|
}
|
|
267
401
|
|
|
402
|
+
async function displayedCapacity() {
|
|
403
|
+
const now = deps.now?.() ?? Date.now();
|
|
404
|
+
const refreshed = await refreshStaleOverLimitReadings(stateRoot, { run: deps.usageRun, now, timeoutMs: deps.usageRefreshTimeoutMs });
|
|
405
|
+
const view = capacitySnapshot(refreshed.snapshots);
|
|
406
|
+
if (refreshed.error) {
|
|
407
|
+
for (const provider of refreshed.failedProviders ?? []) {
|
|
408
|
+
if (!view.usageLimits[provider]) continue;
|
|
409
|
+
view.usageLimits[provider] = { ...view.usageLimits[provider], text: `${view.usageLimits[provider].text}. ${refreshed.error}` };
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return view;
|
|
413
|
+
}
|
|
414
|
+
|
|
268
415
|
function refusal(problems) {
|
|
269
416
|
return toolText(refusalText(problems, capacitySnapshot), true);
|
|
270
417
|
}
|
|
@@ -285,17 +432,17 @@ export function createJobRuntime(deps) {
|
|
|
285
432
|
*/
|
|
286
433
|
function recordJobInRun(args, jobId, result, error = null) {
|
|
287
434
|
if (!args.run_id) return;
|
|
288
|
-
const kind = args.pool ? "api" : args.subscription_worker ? "subscription" : "local";
|
|
435
|
+
const kind = args.mode === "verify" ? "verify" : args.pool ? "api" : args.subscription_worker ? "subscription" : "local";
|
|
289
436
|
const m = result?.manifest ?? {};
|
|
290
437
|
const errorLines = [m.worker?.error, error?.message,
|
|
291
438
|
...String(m.workerError ?? "").split(/\r?\n/).filter((l) => /error|limit|429/i.test(l))].filter(Boolean).join("\n");
|
|
292
|
-
const usageLimit = kind === "local" ? null : detectUsageLimit(errorLines);
|
|
439
|
+
const usageLimit = (kind === "local" || kind === "verify") ? null : detectUsageLimit(errorLines);
|
|
293
440
|
try {
|
|
294
441
|
const before = runTotals(loadRun(runsRoot, args.run_id)).warnings;
|
|
295
442
|
const updated = recordRunJob(runsRoot, args.run_id, {
|
|
296
|
-
jobId, agent: args.agentName ?? "local", kind, model: args.model ?? null, role: args.armyRole ?? null, mode: args.mode,
|
|
297
|
-
outcome: m.outcome ?? (error ? "ERROR" : null), costUsd: m.metrics?.worker_cost_usd ?? null,
|
|
298
|
-
tokens: m.metrics?.worker_tokens_total ?? null, usageLimit,
|
|
443
|
+
jobId, agent: kind === "verify" ? null : args.agentName ?? "local", kind, model: kind === "verify" ? null : args.model ?? null, role: args.armyRole ?? null, mode: args.mode,
|
|
444
|
+
outcome: m.outcome ?? (error ? "ERROR" : null), costUsd: kind === "verify" ? 0 : m.metrics?.worker_cost_usd ?? null,
|
|
445
|
+
tokens: kind === "verify" ? 0 : m.metrics?.worker_tokens_total ?? null, usageLimit,
|
|
299
446
|
});
|
|
300
447
|
// A limit crossed or an agent paused by this job is worth interrupting for.
|
|
301
448
|
const fresh = runTotals(updated).warnings.filter((w) => !before.includes(w) && /OVER|paused/.test(w));
|
|
@@ -311,7 +458,7 @@ export function createJobRuntime(deps) {
|
|
|
311
458
|
function launch(args) {
|
|
312
459
|
const workerId = args.worker_id || null;
|
|
313
460
|
const jobId = slug(workerId || (args.mode === "scout" ? "scout" : "worker"));
|
|
314
|
-
return trackInRun(args, track(jobId, { mode: args.mode, workerId: workerId || jobId, lane: jobLane(args), agent: args.agentName ?? null, runId: args.run_id ?? null, role: args.armyRole ?? null, model: args.model ?? null },
|
|
461
|
+
return trackInRun(args, track(jobId, { mode: args.mode, workerId: workerId || jobId, lane: jobLane(args), agent: args.agentName ?? null, runId: args.run_id ?? null, role: args.armyRole ?? null, model: args.model ?? null, label: jobLabel(args) },
|
|
315
462
|
withAgentSlot(args, jobId, () => executeJob({ ...jobArgs(args, workerId), jobId }))));
|
|
316
463
|
}
|
|
317
464
|
// Best-effort progress signal for a job still mid-run: a plain "phase: worker,
|
|
@@ -357,12 +504,13 @@ export function createJobRuntime(deps) {
|
|
|
357
504
|
|
|
358
505
|
async function summarize(entry, files, jobDir = null) {
|
|
359
506
|
const status = files.status, meta = files.meta ?? files.failure;
|
|
360
|
-
const elapsedSeconds =
|
|
507
|
+
const elapsedSeconds = jobElapsedSeconds({ status, meta, entry });
|
|
361
508
|
const out = { jobId: entry?.jobId ?? status?.jobId ?? meta?.jobId ?? null, workerId: entry?.workerId ?? status?.workerId ?? meta?.workerId ?? null,
|
|
362
509
|
mode: entry?.mode ?? status?.mode ?? meta?.mode ?? null, state: null, phase: status?.phase ?? "starting", elapsedSeconds,
|
|
363
510
|
timeoutSeconds: status?.timeoutSeconds ?? null, coordinatorStatus: meta?.coordinatorStatus ?? null, outcome: meta?.outcome ?? null,
|
|
364
511
|
reviewRequired: meta?.reviewRequired ?? null, issues: (meta?.issues ?? []).slice(0, 6), worktree: meta?.worktree ?? null, branch: meta?.branch ?? null,
|
|
365
512
|
commit: meta?.commit?.sha ?? null, scout: meta?.scout ? { supported: meta.scout.supported, unsupported: meta.scout.unsupported } : null };
|
|
513
|
+
if (meta?.mode === "verify") Object.assign(out, { verification: meta.verification, baseRef: meta.baseRef, baseSha: meta.baseSha });
|
|
366
514
|
if (entry && !entry.settled) out.state = "running";
|
|
367
515
|
else if (entry?.error) { out.state = "failed"; out.error = String(entry.error.message ?? entry.error).split("\n")[0]; }
|
|
368
516
|
else if (meta) out.state = "finished";
|
|
@@ -372,5 +520,5 @@ export function createJobRuntime(deps) {
|
|
|
372
520
|
return out;
|
|
373
521
|
}
|
|
374
522
|
|
|
375
|
-
return { WORKER_START_STAGGER_MS, activeJobs, runningCount, agentMaxConcurrent, withAgentSlot, track, notifyJobFinished, capacitySnapshot, admit, refusal, runBrief, recordJobInRun, trackInRun, launch, liveProgress, summarize };
|
|
523
|
+
return { WORKER_START_STAGGER_MS, activeJobs, runningCount, agentMaxConcurrent, withAgentSlot, track, notifyJobFinished, capacitySnapshot, displayedCapacity, admit, refusal, runBrief, recordJobInRun, trackInRun, launch, liveProgress, summarize };
|
|
376
524
|
}
|
package/lib/agents.mjs
CHANGED
|
@@ -30,12 +30,14 @@ import fs from "node:fs";
|
|
|
30
30
|
import path from "node:path";
|
|
31
31
|
import YAML from "yaml";
|
|
32
32
|
import { z } from "zod";
|
|
33
|
+
import { typeError, isMissingField, isUnknownDiscriminator } from "./zod-issues.mjs";
|
|
33
34
|
|
|
34
35
|
import { providerEntrySchema, formatDispatchIssues, openclawProviderId, PROVIDER_TYPES, ID_RE, HOST_TOOL_PROVIDERS, agentRunsToolsOnHost } from "./dispatch-schema.mjs";
|
|
35
36
|
|
|
36
37
|
export { HOST_TOOL_PROVIDERS, agentRunsToolsOnHost };
|
|
37
38
|
import { findProviderConflicts, describeProviderConflict } from "./subscription-config.mjs";
|
|
38
39
|
import { privateConfigProblem } from "./army.mjs";
|
|
40
|
+
import { THINKING_LEVELS } from "./thinking.mjs";
|
|
39
41
|
|
|
40
42
|
export const AGENTS_FILENAME = "agents.yml";
|
|
41
43
|
export const AGENT_KINDS = Object.freeze(["local", "api", "subscription"]);
|
|
@@ -45,10 +47,10 @@ export const RESERVED_AGENT_NAMES = Object.freeze(["__proto__", "constructor", "
|
|
|
45
47
|
export const BUILTIN_LOCAL_AGENT = Object.freeze({ kind: "local", slot: "coder" });
|
|
46
48
|
|
|
47
49
|
const requiredString = () =>
|
|
48
|
-
z.string({
|
|
50
|
+
z.string({ error: typeError("a string") })
|
|
49
51
|
.refine((value) => value.trim().length > 0, { message: "must not be empty" });
|
|
50
|
-
const thinkingSchema = z.union([z.boolean(), z.enum(
|
|
51
|
-
const positiveInt = () => z.number({
|
|
52
|
+
const thinkingSchema = z.union([z.boolean(), z.enum(THINKING_LEVELS)]).default(true);
|
|
53
|
+
const positiveInt = () => z.number({ error: typeError("a number") }).int("must be a whole number").positive("must be a positive number");
|
|
52
54
|
|
|
53
55
|
const localAgentSchema = z.object({
|
|
54
56
|
kind: z.literal("local"),
|
|
@@ -62,7 +64,7 @@ const localAgentSchema = z.object({
|
|
|
62
64
|
// providerEntrySchema in agentsFileSchema's superRefine, so they exist once.
|
|
63
65
|
const apiAgentSchema = z.object({
|
|
64
66
|
kind: z.literal("api"),
|
|
65
|
-
provider: z.enum(API_PROVIDER_TYPES, {
|
|
67
|
+
provider: z.enum(API_PROVIDER_TYPES, { error: `must be one of ${API_PROVIDER_TYPES.join(", ")}` }),
|
|
66
68
|
model: requiredString().optional(),
|
|
67
69
|
auth_env: requiredString(),
|
|
68
70
|
base_url: z.string().optional(),
|
|
@@ -95,7 +97,7 @@ export function hostToolsImplementProblem(name, agent) {
|
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
export const agentSchema = z.discriminatedUnion("kind", [localAgentSchema, apiAgentSchema, subscriptionAgentSchema], {
|
|
98
|
-
|
|
100
|
+
error: (issue) => (isUnknownDiscriminator(issue) ? `kind must be one of ${AGENT_KINDS.join(", ")}` : undefined),
|
|
99
101
|
});
|
|
100
102
|
|
|
101
103
|
/** An api agent as the pool entry the dispatch path understands. `model` may be absent (see resolveAgentModel). */
|
|
@@ -148,7 +150,7 @@ export function formatAgentIssues(error) {
|
|
|
148
150
|
const lines = error.issues.map((issue) => {
|
|
149
151
|
const where = issue.path.length ? issue.path.join(".") : "config";
|
|
150
152
|
if (issue.code === "unrecognized_keys") return `${where}: unexpected field(s) ${issue.keys.map((k) => `"${k}"`).join(", ")}`;
|
|
151
|
-
if (issue
|
|
153
|
+
if (isMissingField(issue)) return `${where}: is required`;
|
|
152
154
|
return `${where}: ${issue.message}`;
|
|
153
155
|
});
|
|
154
156
|
return [...new Set(lines)];
|
package/lib/army.mjs
CHANGED
|
@@ -29,7 +29,9 @@ import os from "node:os";
|
|
|
29
29
|
import path from "node:path";
|
|
30
30
|
import YAML from "yaml";
|
|
31
31
|
import { z } from "zod";
|
|
32
|
+
import { typeError } from "./zod-issues.mjs";
|
|
32
33
|
import { agentRunsToolsOnHost } from "./dispatch-schema.mjs";
|
|
34
|
+
import { usageStatus, usageDisplayText } from "./usage-limits.mjs";
|
|
33
35
|
|
|
34
36
|
export const GLOBAL_CONFIG_FILENAME = "config.yml";
|
|
35
37
|
export const PROJECT_CONFIG_FILENAMES = Object.freeze([".nomarmy.yml", ".nomarmy.yaml"]);
|
|
@@ -115,7 +117,7 @@ const roleSchema = z.object({
|
|
|
115
117
|
disabled: z.boolean().optional(),
|
|
116
118
|
}).strict();
|
|
117
119
|
|
|
118
|
-
const positiveNumber = () => z.number({
|
|
120
|
+
const positiveNumber = () => z.number({ error: typeError("a number") }).positive("must be positive");
|
|
119
121
|
// Limits on one /feature run (lib/runs.mjs). Personal, like the General:
|
|
120
122
|
// global or local only, never a committed project file.
|
|
121
123
|
const runLimitsSchema = z.object({
|
|
@@ -152,11 +154,13 @@ function formatIssues(error) {
|
|
|
152
154
|
});
|
|
153
155
|
}
|
|
154
156
|
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
// project file is .nomarmy.yml, whose other sections lib/schema.mjs owns.
|
|
157
|
+
// Global configuration is army-only; the local file also admits the separately
|
|
158
|
+
// validated verification network policy. Both stay strict against unknown keys.
|
|
159
|
+
// The project file is .nomarmy.yml, whose other sections lib/schema.mjs owns.
|
|
159
160
|
const armyOnlyFileSchema = z.object({ army: armySchema.optional() }).strict();
|
|
161
|
+
// Verification owns validation and use of this operator-local section. Army
|
|
162
|
+
// updates must preserve it without permitting it in global configuration.
|
|
163
|
+
const localArmyFileSchema = armyOnlyFileSchema.extend({ verification_network: z.unknown().optional() });
|
|
160
164
|
|
|
161
165
|
/** Parse one layer's file and validate its `army:` section. Missing file or no section -> null. */
|
|
162
166
|
export function readArmyFile(filePath, { armyOnly = false } = {}) {
|
|
@@ -168,7 +172,7 @@ export function readArmyFile(filePath, { armyOnly = false } = {}) {
|
|
|
168
172
|
throw new ArmyConfigError(`${filePath} is not valid YAML: ${error.message}`, { path: filePath, errors: [`config: is not valid YAML: ${error.message}`] });
|
|
169
173
|
}
|
|
170
174
|
const doc = parsed ?? {};
|
|
171
|
-
const result = armyOnly ? armyOnlyFileSchema.safeParse(doc) : armySchema.optional().safeParse(doc.army);
|
|
175
|
+
const result = armyOnly ? (path.basename(filePath) === LOCAL_CONFIG_FILENAME ? localArmyFileSchema : armyOnlyFileSchema).safeParse(doc) : armySchema.optional().safeParse(doc.army);
|
|
172
176
|
if (result.success) return (armyOnly ? result.data.army : result.data) ?? null;
|
|
173
177
|
const errors = formatIssues(result.error).map((line) => (armyOnly ? line : `army.${line}`));
|
|
174
178
|
throw new ArmyConfigError(`${filePath} has an invalid army section:\n${errors.map((l) => ` - ${l}`).join("\n")}`, { path: filePath, errors });
|
|
@@ -361,11 +365,22 @@ export function generalOverlap(army, agents = {}) {
|
|
|
361
365
|
* description, phase, suggested mode, agent, any problem or overlap with
|
|
362
366
|
* the General, and which layer set each field.
|
|
363
367
|
*/
|
|
364
|
-
export function describeArmy(loaded, { agents = {}, describeAgent = null } = {}) {
|
|
368
|
+
export function describeArmy(loaded, { agents = {}, describeAgent = null, usageSnapshots = null, agentProviderId = null, now = Date.now(), usageRefreshError = null, usageRefreshFailed = null } = {}) {
|
|
365
369
|
const army = loaded.army;
|
|
366
370
|
const problems = armyTargetProblems(army, agents);
|
|
367
371
|
const overlap = generalOverlap(army, agents);
|
|
368
372
|
const runsOn = (name) => (name && agents[name] && describeAgent ? describeAgent(agents[name]) : null);
|
|
373
|
+
const usage = (name) => {
|
|
374
|
+
if (!name || !agents[name] || !usageSnapshots || !agentProviderId) return null;
|
|
375
|
+
let provider;
|
|
376
|
+
try { provider = agentProviderId(agents[name]); } catch { return null; }
|
|
377
|
+
const snapshot = usageSnapshots[provider];
|
|
378
|
+
if (!snapshot) return null;
|
|
379
|
+
const status = usageStatus(snapshot, now);
|
|
380
|
+
const failed = new Set(usageRefreshFailed ?? []);
|
|
381
|
+
const text = failed.has(provider) && usageRefreshError ? `${usageDisplayText(status)}. ${usageRefreshError}` : usageDisplayText(status);
|
|
382
|
+
return { level: status.level, text };
|
|
383
|
+
};
|
|
369
384
|
// A subscription whose owner isn't the General's own is worth a word,
|
|
370
385
|
// not a refusal: it may be the same person's other account (a real Senti
|
|
371
386
|
// General skipped a role's agent for exactly this, unsure whose it was).
|
|
@@ -378,7 +393,7 @@ export function describeArmy(loaded, { agents = {}, describeAgent = null } = {})
|
|
|
378
393
|
const roles = Object.fromEntries(Object.entries(army.roles).map(([name, role]) => [name, {
|
|
379
394
|
description: role.description ?? null, phase: role.phase ?? null, mode: role.mode ?? null,
|
|
380
395
|
agent: role.agent ?? null, model: role.model ?? agents[role.agent]?.model ?? null, modelIsAuto: role.model === "auto",
|
|
381
|
-
agentRunsOn: runsOn(role.agent),
|
|
396
|
+
agentRunsOn: runsOn(role.agent), usage: usage(role.agent),
|
|
382
397
|
// Its agent's own tools run on this machine (dispatch-schema.mjs): an
|
|
383
398
|
// implement role there is refused per job unless the agent allows it.
|
|
384
399
|
hostTools: agentRunsToolsOnHost(agents[role.agent]) ? { allowed: agents[role.agent].allow_host_tools === true, implementRole: (role.mode ?? "implement") === "implement" } : null,
|
|
@@ -388,13 +403,13 @@ export function describeArmy(loaded, { agents = {}, describeAgent = null } = {})
|
|
|
388
403
|
? "not defined -- run `nomarmy army general <agent>` so nomArmy can flag roles that share the General's model or usage"
|
|
389
404
|
: !Object.prototype.hasOwnProperty.call(agents, army.general) ? `agent "${army.general}" is not defined in your agents.yml` : null;
|
|
390
405
|
return {
|
|
391
|
-
general: { ...GENERAL, agent: army.general, agentRunsOn: runsOn(army.general), problem: generalProblem, setBy: loaded.sources.general },
|
|
406
|
+
general: { ...GENERAL, agent: army.general, agentRunsOn: runsOn(army.general), usage: usage(army.general), problem: generalProblem, setBy: loaded.sources.general },
|
|
392
407
|
workflow: army.workflow,
|
|
393
408
|
runLimits: army.runLimits ?? {},
|
|
394
409
|
roles,
|
|
395
410
|
layers: loaded.layers,
|
|
396
411
|
howToDispatch: Object.keys(roles).length
|
|
397
|
-
? "Pass army_role: \"<role>\" on a job (plus on_behalf_of when its agent is a subscription). The role's description goes at the top of the brief; set `mode` on the job yourself, the role's is only a suggestion. A role with model \"auto\" needs model on the job, picked from that agent's models below; any job may pass model to override the role's. Use agent: \"<name>\" instead to pick an agent directly."
|
|
412
|
+
? "Pass army_role: \"<role>\" on a job (plus on_behalf_of when its agent is a subscription). The role's description goes at the top of the brief; set `mode` on the job yourself, the role's is only a suggestion. A role with model \"auto\" needs model on the job, picked from that agent's models below (never its refusedModels: its vendor turned those down on a job); any job may pass model to override the role's. Use agent: \"<name>\" instead to pick an agent directly."
|
|
398
413
|
: "No army is configured. Run `nomarmy army init` for the default roster, or dispatch with agent: \"<name>\".",
|
|
399
414
|
};
|
|
400
415
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseOpenclawAuthProfiles } from "./health.mjs";
|
|
4
|
+
import { CODEX_IMPORT_ARGS, CODEX_IMPORT_RECOVERY, emailOpenaiProfiles, usableCodexImport } from "./openclaw-runtime-health.mjs";
|
|
5
|
+
|
|
6
|
+
// Never relay migration output: --include-secrets may expose credential data.
|
|
7
|
+
export async function linkCodex({ run, command = "openclaw", isTTY = false, removeEmailProfiles = false, confirm = async () => false, print = () => {}, codexDir = path.join(os.homedir(), ".codex"), now = Date.now() }) {
|
|
8
|
+
const read = async () => {
|
|
9
|
+
const result = await run(command, ["models", "auth", "list", "--json"]);
|
|
10
|
+
return result.ok ? parseOpenclawAuthProfiles(result.stdout) : null;
|
|
11
|
+
};
|
|
12
|
+
const profiles = await read();
|
|
13
|
+
if (!profiles) { print("Cannot inspect OpenClaw auth profiles; nothing imported."); return false; }
|
|
14
|
+
const emails = emailOpenaiProfiles(profiles);
|
|
15
|
+
if (emails.length) {
|
|
16
|
+
print(`Email-keyed profiles will capture the Codex import: ${emails.map((p) => p.id).join(", ")}.`);
|
|
17
|
+
if (!removeEmailProfiles && !(isTTY && await confirm("Remove these email-keyed profiles before importing?", { defaultYes: true }))) {
|
|
18
|
+
print("Stopped; use --remove-email-profiles to allow removal non-interactively.");
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
for (const p of emails) {
|
|
22
|
+
if (!(await run(command, ["models", "auth", "logout", p.id])).ok) { print("OpenClaw profile logout failed; nothing imported."); return false; }
|
|
23
|
+
}
|
|
24
|
+
const remaining = await read();
|
|
25
|
+
if (!remaining || emailOpenaiProfiles(remaining).length) { print("Email-keyed profiles still exist; nothing imported."); return false; }
|
|
26
|
+
}
|
|
27
|
+
print(`Linking OpenClaw: ${CODEX_IMPORT_RECOVERY}`);
|
|
28
|
+
const args = CODEX_IMPORT_ARGS.map((arg) => arg === "~/.codex" ? codexDir : arg);
|
|
29
|
+
if (!(await run(command, args)).ok) { print("Codex import failed."); return false; }
|
|
30
|
+
const imported = await read();
|
|
31
|
+
if (!imported || emailOpenaiProfiles(imported).length || !usableCodexImport(imported, now)) {
|
|
32
|
+
print("Sign-in has no usable auth profile: expected an unexpired openai:account- profile labeled (Codex import).");
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
print("Confirmed an unexpired openai:account- (Codex import) profile.");
|
|
36
|
+
return true;
|
|
37
|
+
}
|
package/lib/config.mjs
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
import fs from "node:fs";
|
|
11
11
|
import path from "node:path";
|
|
12
12
|
import YAML from "yaml";
|
|
13
|
+
import { validateEnabledHarnesses } from "./harnesses.mjs";
|
|
14
|
+
import { readRegistrySecrets } from "./registry-secrets.mjs";
|
|
13
15
|
|
|
14
16
|
import {
|
|
15
17
|
collectElevated,
|
|
@@ -125,6 +127,13 @@ export function validateConfig(input) {
|
|
|
125
127
|
};
|
|
126
128
|
}
|
|
127
129
|
|
|
130
|
+
if (Object.hasOwn(candidate, "verification_network")) {
|
|
131
|
+
return { valid: false, config: null, errors: ["verification_network is operator-local only: put it in gitignored .nomarmy.local.yml, never .nomarmy.yml"], elevated: emptyElevated() };
|
|
132
|
+
}
|
|
133
|
+
if (Object.hasOwn(candidate, "registries")) {
|
|
134
|
+
return { valid: false, config: null, errors: ["registries: allowed only in .nomarmy.local.yml, never in committed repository configuration"], elevated: emptyElevated() };
|
|
135
|
+
}
|
|
136
|
+
|
|
128
137
|
const result = configSchema.safeParse(candidate);
|
|
129
138
|
if (!result.success) {
|
|
130
139
|
return {
|
|
@@ -135,6 +144,10 @@ export function validateConfig(input) {
|
|
|
135
144
|
};
|
|
136
145
|
}
|
|
137
146
|
|
|
147
|
+
try { validateEnabledHarnesses(result.data.harnesses); }
|
|
148
|
+
catch (error) {
|
|
149
|
+
return { valid: false, config: null, errors: [error.message], elevated: emptyElevated() };
|
|
150
|
+
}
|
|
138
151
|
return {
|
|
139
152
|
valid: true,
|
|
140
153
|
config: result.data,
|
|
@@ -154,6 +167,8 @@ export function validateConfig(input) {
|
|
|
154
167
|
* @returns {{ found: boolean, path: string|null, config: object|null, elevated: { shared: string[], remote: string[] } }}
|
|
155
168
|
*/
|
|
156
169
|
export function loadConfig(repoDir = process.cwd()) {
|
|
170
|
+
try { readRegistrySecrets(repoDir); }
|
|
171
|
+
catch (error) { throw new ConfigError(error.message, { errors: [error.message] }); }
|
|
157
172
|
const configPath = findConfigFile(repoDir);
|
|
158
173
|
if (!configPath) {
|
|
159
174
|
return { found: false, path: null, config: null, elevated: emptyElevated() };
|