c8ctl-plugin-nano 1.9.0 → 1.10.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/README.md +62 -0
- package/c8ctl-plugin.js +553 -61
- package/package.json +9 -9
package/README.md
CHANGED
|
@@ -197,6 +197,68 @@ config`).
|
|
|
197
197
|
> and `AGENT_*` env vars, never interpolated into the command line, so process
|
|
198
198
|
> variables cannot inject shell commands.
|
|
199
199
|
|
|
200
|
+
### Task envelope, sandboxes & disk hygiene
|
|
201
|
+
|
|
202
|
+
For **agentic** jobs (an agent that clones a repo, works a task, pushes a
|
|
203
|
+
branch) the job carries a structured **task envelope** under the reserved
|
|
204
|
+
`io.nanobpm.agentTask` namespace. It is assembled from the job's static
|
|
205
|
+
`customHeaders` (model-authored defaults) deep-merged with per-instance
|
|
206
|
+
`variables` (**overrides win**), then normalized to schema v1 and included in the
|
|
207
|
+
stdin payload as `task`:
|
|
208
|
+
|
|
209
|
+
```jsonc
|
|
210
|
+
{
|
|
211
|
+
"io.nanobpm.agentTask.repository.url": "https://github.com/o/r.git", // header
|
|
212
|
+
"io.nanobpm.agentTask.repository.ref": "main",
|
|
213
|
+
"io.nanobpm.agentTask.branch.push": "true",
|
|
214
|
+
"io.nanobpm.agentTask.task.allowPr": "false"
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Element templates emit flat dotpath header keys (strings); the plugin expands
|
|
219
|
+
them into a nested object and coerces `"true"/"false"` → bool and numeric
|
|
220
|
+
strings → int. The normalized shape is
|
|
221
|
+
`{ schemaVersion, repository{provider,url,ref,depth,submodules,authRef}, branch{base,create,push}, setup{commands,env,secretRefs}, task{prompt,promptFile,maxIterations,timeoutMs,allowPr,prBase} }`.
|
|
222
|
+
On completion the plugin writes an **output envelope** back under
|
|
223
|
+
`io.nanobpm.agentResult` (`{schemaVersion, status, sandbox, image, output, truncated, stderrTruncated, exitCode, signal, error}`).
|
|
224
|
+
|
|
225
|
+
**Sandbox.** By default the command runs on the host (`--sandbox none`). Pass
|
|
226
|
+
`--sandbox docker` (or `podman`) with an `--image` to run **each job in a
|
|
227
|
+
throwaway container** instead:
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
c8ctl nano hire --name coder --rank senior --command "agent-harness" \
|
|
231
|
+
--sandbox docker --image ghcr.io/acme/agent:1
|
|
232
|
+
c8ctl nano work coder # uses the profile's sandbox/image
|
|
233
|
+
c8ctl nano work coder --sandbox docker --image ghcr.io/acme/agent:1 # or override
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Containers are labelled (`nano.managed=1`, `nano.worker`, `nano.jobKey`,
|
|
237
|
+
`nano.run=<uuid>`), log-capped (`max-size=10m max-file=3`), run with `--rm`, and
|
|
238
|
+
a run that outlives `--job-timeout` is force-removed. The envelope is piped on
|
|
239
|
+
the container's stdin exactly as on the host.
|
|
240
|
+
|
|
241
|
+
**Secrets.** Secrets are referenced by **name**, never value. `setup.secretRefs`
|
|
242
|
+
(and the repo/PR credential when `task.allowPr` is set — defaulting to
|
|
243
|
+
`GITHUB_TOKEN` for GitHub) are resolved via a pluggable `--secret-resolver`
|
|
244
|
+
(only `host`, reading `process.env`, is implemented) and forwarded into the
|
|
245
|
+
container by name (`-e NAME`) so values never appear in argv or `docker inspect`.
|
|
246
|
+
A missing required secret fails the job with a clear provisioning message.
|
|
247
|
+
|
|
248
|
+
**Disk hygiene.** Container sandboxes get automatic cleanup so leaked
|
|
249
|
+
containers can't fill the disk: a **label-scoped** reaper runs at worker startup
|
|
250
|
+
and on an interval (`--reap-interval`, **milliseconds**, default `300000` = 5m),
|
|
251
|
+
removing finished/`exited` containers older than `--reap-age` (**milliseconds**,
|
|
252
|
+
default `3600000` = 1h) while **skipping any run still in flight** — it never
|
|
253
|
+
touches containers it didn't create and never `system prune`s. A **disk-budget
|
|
254
|
+
admission shed** fails (retryable) new jobs when the engine data root has less
|
|
255
|
+
than `--min-free-mb` MB free (default `1024`).
|
|
256
|
+
|
|
257
|
+
> Git provisioning (clone/branch/push), agent-opened PRs, and the
|
|
258
|
+
> Vercel/Sandcastle provider are **increment 2** — the envelope names above are
|
|
259
|
+
> frozen so the [nano-ide element-template pack](https://github.com/jwulf/nano-ide/issues/37)
|
|
260
|
+
> can be built against this contract.
|
|
261
|
+
|
|
200
262
|
## Cleaning up disk
|
|
201
263
|
|
|
202
264
|
```bash
|
package/c8ctl-plugin.js
CHANGED
|
@@ -40,7 +40,9 @@ import {
|
|
|
40
40
|
chmodSync,
|
|
41
41
|
renameSync,
|
|
42
42
|
realpathSync,
|
|
43
|
+
statfsSync,
|
|
43
44
|
} from 'node:fs';
|
|
45
|
+
import { randomUUID } from 'node:crypto';
|
|
44
46
|
import { homedir, platform as osPlatform } from 'node:os';
|
|
45
47
|
import { join, isAbsolute, resolve as resolvePath, dirname, sep } from 'node:path';
|
|
46
48
|
import { createRequire } from 'node:module';
|
|
@@ -1369,6 +1371,14 @@ function normalizeStoredProfile(name, profile) {
|
|
|
1369
1371
|
if (!command) {
|
|
1370
1372
|
return { error: `profile "${name}" has no command to run` };
|
|
1371
1373
|
}
|
|
1374
|
+
const sandbox = String(profile.sandbox || 'none').trim().toLowerCase();
|
|
1375
|
+
if (!SANDBOXES.includes(sandbox)) {
|
|
1376
|
+
return { error: `profile "${name}" has an invalid sandbox "${profile.sandbox}" (expected one of: ${SANDBOXES.join(', ')})` };
|
|
1377
|
+
}
|
|
1378
|
+
const image = typeof profile.image === 'string' ? profile.image.trim() : '';
|
|
1379
|
+
if (CONTAINER_SANDBOXES.has(sandbox) && !image) {
|
|
1380
|
+
return { error: `profile "${name}" uses sandbox "${sandbox}" but has no image` };
|
|
1381
|
+
}
|
|
1372
1382
|
return {
|
|
1373
1383
|
profile: {
|
|
1374
1384
|
name,
|
|
@@ -1376,6 +1386,8 @@ function normalizeStoredProfile(name, profile) {
|
|
|
1376
1386
|
command,
|
|
1377
1387
|
model: typeof profile.model === 'string' ? profile.model.trim() : '',
|
|
1378
1388
|
capabilities: normalizeCapabilities(profile.capabilities),
|
|
1389
|
+
sandbox,
|
|
1390
|
+
image,
|
|
1379
1391
|
},
|
|
1380
1392
|
};
|
|
1381
1393
|
}
|
|
@@ -1413,6 +1425,8 @@ async function hireWorker(req, flags) {
|
|
|
1413
1425
|
let command = flags?.command !== undefined ? String(flags.command).trim() : undefined;
|
|
1414
1426
|
let model = flags?.model !== undefined ? String(flags.model).trim() : undefined;
|
|
1415
1427
|
let capabilities = flags?.capabilities !== undefined ? flags.capabilities : undefined;
|
|
1428
|
+
let sandbox = flags?.sandbox !== undefined ? String(flags.sandbox).trim().toLowerCase() : undefined;
|
|
1429
|
+
let image = flags?.image !== undefined ? String(flags.image).trim() : undefined;
|
|
1416
1430
|
|
|
1417
1431
|
const missingRequired = !name || !rank || !command;
|
|
1418
1432
|
const missingOptional = model === undefined || capabilities === undefined;
|
|
@@ -1461,6 +1475,17 @@ async function hireWorker(req, flags) {
|
|
|
1461
1475
|
// Optional fields default to empty when omitted (e.g. scripted invocations).
|
|
1462
1476
|
if (model === undefined) model = '';
|
|
1463
1477
|
if (capabilities === undefined) capabilities = '';
|
|
1478
|
+
if (sandbox === undefined || sandbox === '') sandbox = 'none';
|
|
1479
|
+
if (image === undefined) image = '';
|
|
1480
|
+
|
|
1481
|
+
if (!SANDBOXES.includes(sandbox)) {
|
|
1482
|
+
logger.error(`Invalid --sandbox "${sandbox}". Use one of: ${SANDBOXES.join(', ')}`);
|
|
1483
|
+
process.exit(1);
|
|
1484
|
+
}
|
|
1485
|
+
if (CONTAINER_SANDBOXES.has(sandbox) && !image) {
|
|
1486
|
+
logger.error(`--sandbox ${sandbox} requires --image <ref> (the container image the agent runs in).`);
|
|
1487
|
+
process.exit(1);
|
|
1488
|
+
}
|
|
1464
1489
|
|
|
1465
1490
|
if (!isValidProfileName(name)) {
|
|
1466
1491
|
logger.error(`Invalid profile name "${name}". Use letters, digits, dot, dash or underscore.`);
|
|
@@ -1482,6 +1507,8 @@ async function hireWorker(req, flags) {
|
|
|
1482
1507
|
command,
|
|
1483
1508
|
model: model || '',
|
|
1484
1509
|
capabilities: normalizeCapabilities(capabilities),
|
|
1510
|
+
sandbox,
|
|
1511
|
+
image: image || '',
|
|
1485
1512
|
createdAt: new Date().toISOString(),
|
|
1486
1513
|
};
|
|
1487
1514
|
writeHire(profile);
|
|
@@ -1490,6 +1517,7 @@ async function hireWorker(req, flags) {
|
|
|
1490
1517
|
logger.info(`${existed ? 'Updated' : 'Hired'} "${name}" [${profile.rank}] → ${profile.command}`);
|
|
1491
1518
|
logger.info(` model: ${profile.model || '(none)'}`);
|
|
1492
1519
|
logger.info(` capabilities: ${profile.capabilities.join(', ') || '(none)'}`);
|
|
1520
|
+
logger.info(` sandbox: ${profile.sandbox}${CONTAINER_SANDBOXES.has(profile.sandbox) ? ` (image ${profile.image})` : ''}`);
|
|
1493
1521
|
logger.info(` job types (${matrix.length}): ${matrix.join(' ')}`);
|
|
1494
1522
|
logger.info(`Put it to work with: c8ctl nano work ${name}`);
|
|
1495
1523
|
}
|
|
@@ -1532,51 +1560,255 @@ function killTree(child) {
|
|
|
1532
1560
|
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
|
1533
1561
|
}
|
|
1534
1562
|
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1563
|
+
// ===========================================================================
|
|
1564
|
+
// Agent task envelope + sandboxed execution (issue #8, increment 1)
|
|
1565
|
+
// ===========================================================================
|
|
1566
|
+
|
|
1567
|
+
// Reserved namespaces. The INPUT envelope is assembled from the job's static
|
|
1568
|
+
// customHeaders (model-authored defaults) deep-merged with per-instance
|
|
1569
|
+
// variables (overrides win), then normalized/coerced to schema v1. The OUTPUT
|
|
1570
|
+
// envelope is written back on the job's completion variables.
|
|
1571
|
+
const AGENT_TASK_NS = 'io.nanobpm.agentTask';
|
|
1572
|
+
const AGENT_RESULT_KEY = 'io.nanobpm.agentResult';
|
|
1573
|
+
const TASK_ENVELOPE_SCHEMA_VERSION = 1;
|
|
1574
|
+
// The result-envelope version is intentionally independent of the task-envelope
|
|
1575
|
+
// version so the two contracts can evolve separately without silently coupling.
|
|
1576
|
+
const RESULT_ENVELOPE_SCHEMA_VERSION = 1;
|
|
1577
|
+
const SANDBOXES = ['none', 'docker', 'podman'];
|
|
1578
|
+
// Only container-based sandboxes need an image / disk hygiene / a runtime bin.
|
|
1579
|
+
const CONTAINER_SANDBOXES = new Set(['docker', 'podman']);
|
|
1580
|
+
|
|
1581
|
+
function coerceBool(v, dflt = false) {
|
|
1582
|
+
if (typeof v === 'boolean') return v;
|
|
1583
|
+
if (v == null) return dflt;
|
|
1584
|
+
const s = String(v).trim().toLowerCase();
|
|
1585
|
+
if (['true', '1', 'yes', 'on'].includes(s)) return true;
|
|
1586
|
+
if (['false', '0', 'no', 'off', ''].includes(s)) return false;
|
|
1587
|
+
return dflt;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
function coerceInt(v, dflt) {
|
|
1591
|
+
if (v == null || v === '') return dflt;
|
|
1592
|
+
const n = Number.parseInt(String(v), 10);
|
|
1593
|
+
return Number.isFinite(n) ? n : dflt;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
function isPlainObject(v) {
|
|
1597
|
+
return v != null && typeof v === 'object' && !Array.isArray(v);
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
function deepMerge(base, over) {
|
|
1601
|
+
if (!isPlainObject(base)) return isPlainObject(over) ? deepMerge({}, over) : over;
|
|
1602
|
+
const out = { ...base };
|
|
1603
|
+
if (!isPlainObject(over)) return out;
|
|
1604
|
+
for (const [k, v] of Object.entries(over)) {
|
|
1605
|
+
if (v === undefined) continue;
|
|
1606
|
+
out[k] = isPlainObject(v) && isPlainObject(out[k]) ? deepMerge(out[k], v) : v;
|
|
1607
|
+
}
|
|
1608
|
+
return out;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
function setPath(obj, path, value) {
|
|
1612
|
+
let cur = obj;
|
|
1613
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
1614
|
+
const k = path[i];
|
|
1615
|
+
if (!isPlainObject(cur[k])) cur[k] = {};
|
|
1616
|
+
cur = cur[k];
|
|
1617
|
+
}
|
|
1618
|
+
cur[path[path.length - 1]] = value;
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// Collect the reserved namespace out of a flat key→value map (customHeaders or
|
|
1622
|
+
// variables). Supports both a single `io.nanobpm.agentTask` key whose value is
|
|
1623
|
+
// a JSON string/object, AND flattened dotpath keys like
|
|
1624
|
+
// `io.nanobpm.agentTask.repository.ref` (element templates emit the latter).
|
|
1625
|
+
function collectEnvelopeFrom(source) {
|
|
1626
|
+
const out = {};
|
|
1627
|
+
if (!isPlainObject(source)) return out;
|
|
1628
|
+
const whole = source[AGENT_TASK_NS];
|
|
1629
|
+
if (whole != null) {
|
|
1630
|
+
let val = whole;
|
|
1631
|
+
if (typeof whole === 'string') {
|
|
1632
|
+
try { val = JSON.parse(whole); } catch { val = undefined; }
|
|
1633
|
+
}
|
|
1634
|
+
if (isPlainObject(val)) Object.assign(out, deepMerge(out, val));
|
|
1635
|
+
}
|
|
1636
|
+
const prefix = `${AGENT_TASK_NS}.`;
|
|
1637
|
+
for (const [key, value] of Object.entries(source)) {
|
|
1638
|
+
if (!key.startsWith(prefix)) continue;
|
|
1639
|
+
const rest = key.slice(prefix.length);
|
|
1640
|
+
if (!rest) continue;
|
|
1641
|
+
setPath(out, rest.split('.'), value);
|
|
1642
|
+
}
|
|
1643
|
+
return out;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
// Normalize the assembled envelope to schema v1, coercing header string values
|
|
1647
|
+
// (element templates write everything as strings) into bool/int as needed.
|
|
1648
|
+
function normalizeTaskEnvelope(customHeaders, variables) {
|
|
1649
|
+
const raw = deepMerge(collectEnvelopeFrom(customHeaders), collectEnvelopeFrom(variables));
|
|
1650
|
+
const str = (v) => (v == null ? undefined : String(v));
|
|
1651
|
+
const env = {
|
|
1652
|
+
// Normalization always emits the v1 shape, so the version is forced to v1
|
|
1653
|
+
// (the raw input version is only a hint about how the author authored it).
|
|
1654
|
+
schemaVersion: TASK_ENVELOPE_SCHEMA_VERSION,
|
|
1655
|
+
};
|
|
1656
|
+
|
|
1657
|
+
const repo = raw.repository;
|
|
1658
|
+
if (isPlainObject(repo) && str(repo.url)) {
|
|
1659
|
+
env.repository = {
|
|
1660
|
+
provider: str(repo.provider) || 'github',
|
|
1661
|
+
url: str(repo.url),
|
|
1662
|
+
ref: str(repo.ref),
|
|
1663
|
+
depth: coerceInt(repo.depth, undefined),
|
|
1664
|
+
submodules: coerceBool(repo.submodules, false),
|
|
1665
|
+
authRef: str(repo.authRef),
|
|
1562
1666
|
};
|
|
1667
|
+
}
|
|
1563
1668
|
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1669
|
+
const branch = isPlainObject(raw.branch) ? raw.branch : {};
|
|
1670
|
+
env.branch = {
|
|
1671
|
+
base: str(branch.base),
|
|
1672
|
+
create: str(branch.create),
|
|
1673
|
+
push: coerceBool(branch.push, true),
|
|
1674
|
+
};
|
|
1675
|
+
|
|
1676
|
+
const setup = isPlainObject(raw.setup) ? raw.setup : {};
|
|
1677
|
+
env.setup = {
|
|
1678
|
+
commands: Array.isArray(setup.commands) ? setup.commands.map(String) : [],
|
|
1679
|
+
env: isPlainObject(setup.env) ? setup.env : {},
|
|
1680
|
+
secretRefs: Array.isArray(setup.secretRefs) ? setup.secretRefs.map(String) : [],
|
|
1681
|
+
};
|
|
1682
|
+
|
|
1683
|
+
const task = isPlainObject(raw.task) ? raw.task : {};
|
|
1684
|
+
env.task = {
|
|
1685
|
+
prompt: str(task.prompt) ?? str(variables?.prompt) ?? str(variables?.task),
|
|
1686
|
+
promptFile: str(task.promptFile),
|
|
1687
|
+
maxIterations: coerceInt(task.maxIterations, undefined),
|
|
1688
|
+
timeoutMs: coerceInt(task.timeoutMs, undefined),
|
|
1689
|
+
allowPr: coerceBool(task.allowPr, false),
|
|
1690
|
+
prBase: str(task.prBase),
|
|
1691
|
+
};
|
|
1692
|
+
|
|
1693
|
+
return env;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// ---- Secret resolution (pluggable; only host-env implemented for now) ------
|
|
1697
|
+
// Secrets are referenced by NAME, never by value, in the model. A resolver maps
|
|
1698
|
+
// a name → value at run time. The wrapper injects them into the child ENV, so
|
|
1699
|
+
// values never appear in argv or `docker inspect`.
|
|
1700
|
+
const hostEnvSecretResolver = {
|
|
1701
|
+
kind: 'host',
|
|
1702
|
+
resolve(name) {
|
|
1703
|
+
const v = process.env[name];
|
|
1704
|
+
return v == null || v === '' ? undefined : v;
|
|
1705
|
+
},
|
|
1706
|
+
};
|
|
1707
|
+
|
|
1708
|
+
function makeSecretResolver(kind) {
|
|
1709
|
+
const k = (kind || 'host').trim().toLowerCase();
|
|
1710
|
+
if (k === 'host' || k === '') return hostEnvSecretResolver;
|
|
1711
|
+
return null; // unknown → caller reports a clear error
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
// Resolve the names a job needs (setup.secretRefs, plus the repo/PR credential
|
|
1715
|
+
// when allowPr). Returns resolved values + a list of names that were missing so
|
|
1716
|
+
// the caller can fail the job with a clear provisioning error.
|
|
1717
|
+
function resolveJobSecrets(resolver, envelope) {
|
|
1718
|
+
const names = new Set();
|
|
1719
|
+
for (const n of envelope.setup?.secretRefs || []) if (n) names.add(n);
|
|
1720
|
+
if (envelope.task?.allowPr) {
|
|
1721
|
+
const provider = envelope.repository?.provider || 'github';
|
|
1722
|
+
const authRef = envelope.repository?.authRef || (provider === 'github' ? 'GITHUB_TOKEN' : undefined);
|
|
1723
|
+
if (authRef) names.add(authRef);
|
|
1724
|
+
}
|
|
1725
|
+
const resolved = {};
|
|
1726
|
+
const missing = [];
|
|
1727
|
+
for (const name of names) {
|
|
1728
|
+
const v = resolver.resolve(name);
|
|
1729
|
+
if (v === undefined) missing.push(name);
|
|
1730
|
+
else resolved[name] = v;
|
|
1731
|
+
}
|
|
1732
|
+
return { resolved, missing, names: [...names] };
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
// ---- Disk hygiene (container sandboxes only) -------------------------------
|
|
1736
|
+
const CONTAINER_LABEL = 'nano.managed=1';
|
|
1737
|
+
|
|
1738
|
+
function containerEngineAvailable(engine) {
|
|
1739
|
+
try {
|
|
1740
|
+
const r = spawnSync(engine, ['version', '--format', '{{.Server.Version}}'], { encoding: 'utf8', timeout: 10_000 });
|
|
1741
|
+
return r.status === 0;
|
|
1742
|
+
} catch {
|
|
1743
|
+
return false;
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
// Resolve the container engine's data root. Returns null when it can't be
|
|
1748
|
+
// determined so the caller can fail OPEN (never fall back to an unrelated path
|
|
1749
|
+
// like the OS temp dir, which would shed on the wrong filesystem's free space).
|
|
1750
|
+
function dockerRootDir(engine) {
|
|
1751
|
+
try {
|
|
1752
|
+
const r = spawnSync(engine, ['info', '-f', '{{.DockerRootDir}}'], { encoding: 'utf8', timeout: 10_000 });
|
|
1753
|
+
const dir = (r.stdout || '').trim();
|
|
1754
|
+
if (r.status === 0 && dir) return dir;
|
|
1755
|
+
} catch { /* fall through */ }
|
|
1756
|
+
return null;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// Fail-open disk-budget check: shed work when free space on the engine's data
|
|
1760
|
+
// root drops below the configured floor (mirrors nano's admission-shed pattern).
|
|
1761
|
+
function diskBudgetOk(engine, minFreeBytes) {
|
|
1762
|
+
if (!minFreeBytes || minFreeBytes <= 0) return { ok: true, free: null };
|
|
1763
|
+
try {
|
|
1764
|
+
if (typeof statfsSync !== 'function') return { ok: true, free: null };
|
|
1765
|
+
const root = dockerRootDir(engine);
|
|
1766
|
+
if (!root) return { ok: true, free: null }; // can't resolve the real root → fail open
|
|
1767
|
+
const st = statfsSync(root);
|
|
1768
|
+
const free = st.bavail * st.bsize;
|
|
1769
|
+
return { ok: free >= minFreeBytes, free };
|
|
1770
|
+
} catch {
|
|
1771
|
+
return { ok: true, free: null }; // never block work on a stat failure
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
// Reap our own leaked/finished containers. Label-scoped (never touches anything
|
|
1776
|
+
// we didn't create — safe on shared hosts, NEVER `system prune -a`), age-gated,
|
|
1777
|
+
// and skips any run id still in flight.
|
|
1778
|
+
function reapAgentContainers(engine, { maxAgeMs = 0, liveRunIds = new Set() } = {}) {
|
|
1779
|
+
let reaped = 0;
|
|
1780
|
+
try {
|
|
1781
|
+
const fmt = '{{.ID}}\t{{.Label "nano.run"}}\t{{.State}}\t{{.CreatedAt}}';
|
|
1782
|
+
const r = spawnSync(engine, ['ps', '-a', '--filter', `label=${CONTAINER_LABEL}`, '--format', fmt], { encoding: 'utf8', timeout: 15_000 });
|
|
1783
|
+
if (r.status !== 0) return { reaped, error: (r.stderr || '').trim() || 'ps failed' };
|
|
1784
|
+
const now = Date.now();
|
|
1785
|
+
for (const line of (r.stdout || '').split('\n')) {
|
|
1786
|
+
if (!line.trim()) continue;
|
|
1787
|
+
const [id, run, state, created] = line.split('\t');
|
|
1788
|
+
if (run && liveRunIds.has(run)) continue; // in-flight; leave it
|
|
1789
|
+
if (!/exited|dead|created/i.test(state || '')) continue; // only finished/stuck
|
|
1790
|
+
if (maxAgeMs > 0) {
|
|
1791
|
+
const createdMs = Date.parse(created || '');
|
|
1792
|
+
if (Number.isFinite(createdMs) && now - createdMs < maxAgeMs) continue;
|
|
1793
|
+
}
|
|
1794
|
+
const rm = spawnSync(engine, ['rm', '-f', id], { timeout: 15_000 });
|
|
1795
|
+
if (rm.status === 0) reaped++;
|
|
1796
|
+
}
|
|
1797
|
+
} catch (err) {
|
|
1798
|
+
return { reaped, error: err.message };
|
|
1799
|
+
}
|
|
1800
|
+
return { reaped };
|
|
1801
|
+
}
|
|
1579
1802
|
|
|
1803
|
+
// ---- One-shot capture (shared by host + container executors) ---------------
|
|
1804
|
+
const MAX_CAPTURE_BYTES = 1_048_576; // 1 MiB per stream
|
|
1805
|
+
|
|
1806
|
+
// Spawn a child, pipe `stdinData`, capture byte-capped stdout/stderr, enforce a
|
|
1807
|
+
// timeout (invoking `onTimeout(child)` to tear the child down), and resolve to a
|
|
1808
|
+
// uniform result. Used by both the host and container executors.
|
|
1809
|
+
function spawnCaptureOneShot({ command, args = [], shell = false, detached = false, env, stdinData, timeoutMs, onTimeout }) {
|
|
1810
|
+
return new Promise((resolve) => {
|
|
1811
|
+
let child;
|
|
1580
1812
|
const stdoutChunks = [];
|
|
1581
1813
|
const stderrChunks = [];
|
|
1582
1814
|
let stdoutBytes = 0;
|
|
@@ -1584,9 +1816,8 @@ function runAgentJob(profile, job, timeoutMs) {
|
|
|
1584
1816
|
let stdoutTruncated = false;
|
|
1585
1817
|
let stderrTruncated = false;
|
|
1586
1818
|
let settled = false;
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
const MAX_CAPTURE_BYTES = 1_048_576; // 1 MiB per stream
|
|
1819
|
+
let timer = null;
|
|
1820
|
+
|
|
1590
1821
|
const finish = (result) => {
|
|
1591
1822
|
if (settled) return;
|
|
1592
1823
|
settled = true;
|
|
@@ -1594,12 +1825,17 @@ function runAgentJob(profile, job, timeoutMs) {
|
|
|
1594
1825
|
resolve(result);
|
|
1595
1826
|
};
|
|
1596
1827
|
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1828
|
+
try {
|
|
1829
|
+
child = spawn(command, args, { shell, detached, stdio: ['pipe', 'pipe', 'pipe'], env });
|
|
1830
|
+
} catch (err) {
|
|
1831
|
+
finish({ ok: false, exitCode: null, stdout: '', stderr: '', error: err.message, truncated: false, stderrTruncated: false });
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
timer = timeoutMs && timeoutMs > 0
|
|
1600
1836
|
? setTimeout(() => {
|
|
1601
|
-
|
|
1602
|
-
finish({ ok: false, exitCode: null, stdout: joinCapped(stdoutChunks), stderr: joinCapped(stderrChunks), error: `timed out after ${timeoutMs}ms`, truncated: stdoutTruncated, stderrTruncated });
|
|
1837
|
+
try { if (onTimeout) onTimeout(child); } catch { /* best effort */ }
|
|
1838
|
+
finish({ ok: false, exitCode: null, stdout: joinCapped(stdoutChunks), stderr: joinCapped(stderrChunks), error: `timed out after ${timeoutMs}ms`, timedOut: true, truncated: stdoutTruncated, stderrTruncated });
|
|
1603
1839
|
}, timeoutMs)
|
|
1604
1840
|
: null;
|
|
1605
1841
|
|
|
@@ -1625,19 +1861,130 @@ function runAgentJob(profile, job, timeoutMs) {
|
|
|
1625
1861
|
finish({ ok: code === 0, exitCode: code, signal: signal ?? null, stdout: joinCapped(stdoutChunks), stderr: joinCapped(stderrChunks), truncated: stdoutTruncated, stderrTruncated });
|
|
1626
1862
|
});
|
|
1627
1863
|
|
|
1628
|
-
// The child may exit before reading stdin; swallow the async EPIPE so it
|
|
1629
|
-
// doesn't crash the whole worker process (only the `child` close/error
|
|
1630
|
-
// handlers above decide the job outcome).
|
|
1631
1864
|
child.stdin.on('error', () => {});
|
|
1632
1865
|
try {
|
|
1633
|
-
child.stdin.write(
|
|
1866
|
+
if (stdinData != null) child.stdin.write(stdinData);
|
|
1634
1867
|
child.stdin.end();
|
|
1635
|
-
} catch {
|
|
1636
|
-
|
|
1637
|
-
|
|
1868
|
+
} catch { /* 'error' handler resolves on failure */ }
|
|
1869
|
+
});
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
function buildAgentPayload(profile, job, envelope) {
|
|
1873
|
+
const variables = job.variables && typeof job.variables === 'object' ? job.variables : {};
|
|
1874
|
+
return {
|
|
1875
|
+
jobKey: job.jobKey,
|
|
1876
|
+
jobType: job.type,
|
|
1877
|
+
processInstanceKey: job.processInstanceKey ?? null,
|
|
1878
|
+
elementInstanceKey: job.elementInstanceKey ?? null,
|
|
1879
|
+
elementId: job.elementId ?? null,
|
|
1880
|
+
bpmnProcessId: job.bpmnProcessId ?? job.processDefinitionId ?? null,
|
|
1881
|
+
prompt: envelope?.task?.prompt ?? variables.prompt ?? variables.task ?? null,
|
|
1882
|
+
task: envelope || null,
|
|
1883
|
+
variables,
|
|
1884
|
+
customHeaders: job.customHeaders ?? {},
|
|
1885
|
+
profile: {
|
|
1886
|
+
name: profile.name,
|
|
1887
|
+
rank: profile.rank,
|
|
1888
|
+
model: profile.model,
|
|
1889
|
+
capabilities: profile.capabilities,
|
|
1890
|
+
},
|
|
1891
|
+
};
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
function baseAgentEnv(profile, job) {
|
|
1895
|
+
return {
|
|
1896
|
+
AGENT_PROFILE: profile.name,
|
|
1897
|
+
AGENT_RANK: profile.rank,
|
|
1898
|
+
AGENT_MODEL: profile.model || '',
|
|
1899
|
+
AGENT_CAPABILITIES: (profile.capabilities || []).join(','),
|
|
1900
|
+
AGENT_JOB_TYPE: String(job.type ?? ''),
|
|
1901
|
+
};
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
/**
|
|
1905
|
+
* Run a single activated job through the profile's CLI command (one-shot),
|
|
1906
|
+
* dispatching on the profile's sandbox:
|
|
1907
|
+
* - none → spawn the command on the host (legacy behaviour).
|
|
1908
|
+
* - docker | podman → `run --rm` a labelled, log-capped container, piping the
|
|
1909
|
+
* task envelope on stdin; a run that outlives the timeout
|
|
1910
|
+
* is force-removed so it never leaks a slot or disk.
|
|
1911
|
+
* Both paths resolve to the same result contract.
|
|
1912
|
+
*/
|
|
1913
|
+
function runAgentJob(profile, job, opts = {}) {
|
|
1914
|
+
const { timeoutMs, envelope, sandbox = 'none', image, runId, secretEnv = {}, passThroughSecretNames = [] } = opts;
|
|
1915
|
+
const payload = JSON.stringify(buildAgentPayload(profile, job, envelope));
|
|
1916
|
+
const agentEnv = baseAgentEnv(profile, job);
|
|
1917
|
+
|
|
1918
|
+
if (!CONTAINER_SANDBOXES.has(sandbox)) {
|
|
1919
|
+
return spawnCaptureOneShot({
|
|
1920
|
+
command: profile.command,
|
|
1921
|
+
shell: true,
|
|
1922
|
+
// Own process group so the timeout handler can kill the whole tree.
|
|
1923
|
+
detached: process.platform !== 'win32',
|
|
1924
|
+
env: { ...process.env, ...agentEnv, ...secretEnv },
|
|
1925
|
+
stdinData: payload,
|
|
1926
|
+
timeoutMs,
|
|
1927
|
+
onTimeout: (child) => killTree(child),
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
const engine = sandbox;
|
|
1932
|
+
const containerName = `nano-${runId}`;
|
|
1933
|
+
// Forward env by NAME only (`-e NAME`) so secret VALUES stay out of argv and
|
|
1934
|
+
// `docker inspect`; docker reads the value from our child's environment.
|
|
1935
|
+
const envArgs = [];
|
|
1936
|
+
for (const k of Object.keys(agentEnv)) envArgs.push('-e', k);
|
|
1937
|
+
for (const n of passThroughSecretNames) envArgs.push('-e', n);
|
|
1938
|
+
const setupEnv = isPlainObject(envelope?.setup?.env) ? envelope.setup.env : {};
|
|
1939
|
+
const setupEnvValues = {};
|
|
1940
|
+
for (const [k, v] of Object.entries(setupEnv)) { envArgs.push('-e', k); setupEnvValues[k] = String(v); }
|
|
1941
|
+
|
|
1942
|
+
const args = [
|
|
1943
|
+
'run', '--rm', '-i',
|
|
1944
|
+
'--name', containerName,
|
|
1945
|
+
'--label', CONTAINER_LABEL,
|
|
1946
|
+
'--label', `nano.worker=${profile.name}`,
|
|
1947
|
+
'--label', `nano.jobKey=${job.jobKey}`,
|
|
1948
|
+
'--label', `nano.run=${runId}`,
|
|
1949
|
+
'--log-opt', 'max-size=10m',
|
|
1950
|
+
'--log-opt', 'max-file=3',
|
|
1951
|
+
...envArgs,
|
|
1952
|
+
image,
|
|
1953
|
+
'sh', '-c', profile.command,
|
|
1954
|
+
];
|
|
1955
|
+
|
|
1956
|
+
return spawnCaptureOneShot({
|
|
1957
|
+
command: engine,
|
|
1958
|
+
args,
|
|
1959
|
+
shell: false,
|
|
1960
|
+
env: { ...process.env, ...agentEnv, ...secretEnv, ...setupEnvValues },
|
|
1961
|
+
stdinData: payload,
|
|
1962
|
+
timeoutMs,
|
|
1963
|
+
onTimeout: (child) => {
|
|
1964
|
+
try { spawnSync(engine, ['rm', '-f', containerName], { timeout: 15_000 }); } catch { /* best effort */ }
|
|
1965
|
+
try { killTree(child); } catch { /* best effort */ }
|
|
1966
|
+
},
|
|
1638
1967
|
});
|
|
1639
1968
|
}
|
|
1640
1969
|
|
|
1970
|
+
// Shape the io.nanobpm.agentResult output envelope. branch/commits/pr are
|
|
1971
|
+
// reserved for increment 2 (git provisioning); increment 1 reports execution.
|
|
1972
|
+
function buildResultEnvelope(result, { sandbox, image }) {
|
|
1973
|
+
const status = result.ok ? 'completed' : (result.timedOut ? 'timedOut' : 'failed');
|
|
1974
|
+
return {
|
|
1975
|
+
schemaVersion: RESULT_ENVELOPE_SCHEMA_VERSION,
|
|
1976
|
+
status,
|
|
1977
|
+
sandbox,
|
|
1978
|
+
image: image || null,
|
|
1979
|
+
output: result.stdout ?? '',
|
|
1980
|
+
truncated: !!result.truncated,
|
|
1981
|
+
stderrTruncated: !!result.stderrTruncated,
|
|
1982
|
+
exitCode: result.exitCode ?? null,
|
|
1983
|
+
signal: result.signal ?? null,
|
|
1984
|
+
error: result.error ?? null,
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1641
1988
|
/**
|
|
1642
1989
|
* work — turn a hire profile into live Nano job workers (one per job-type in
|
|
1643
1990
|
* the rank×capability matrix) and poll for work in the foreground until Ctrl-C.
|
|
@@ -1680,11 +2027,63 @@ async function workAgent(req, flags) {
|
|
|
1680
2027
|
const maxParallelJobs = intFlag(flags?.['max-parallel'], 1);
|
|
1681
2028
|
const jobTimeoutMs = intFlag(flags?.['job-timeout'], 5 * 60_000);
|
|
1682
2029
|
|
|
2030
|
+
// Sandbox: flag overrides the stored profile default. `none` runs on the host
|
|
2031
|
+
// (legacy); `docker`/`podman` run each job in a throwaway labelled container.
|
|
2032
|
+
const sandbox = String(flags?.sandbox ?? profile.sandbox ?? 'none').trim().toLowerCase();
|
|
2033
|
+
if (!SANDBOXES.includes(sandbox)) {
|
|
2034
|
+
logger.error(`Invalid --sandbox "${sandbox}". Use one of: ${SANDBOXES.join(', ')}`);
|
|
2035
|
+
process.exit(1);
|
|
2036
|
+
}
|
|
2037
|
+
const image = flags?.image ? String(flags.image).trim() : (profile.image || '');
|
|
2038
|
+
const isContainer = CONTAINER_SANDBOXES.has(sandbox);
|
|
2039
|
+
if (isContainer && !image) {
|
|
2040
|
+
logger.error(`--sandbox ${sandbox} requires an --image (or hire the profile with --image).`);
|
|
2041
|
+
process.exit(1);
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
const secretResolver = makeSecretResolver(flags?.['secret-resolver']);
|
|
2045
|
+
if (!secretResolver) {
|
|
2046
|
+
logger.error(`Unknown --secret-resolver "${flags?.['secret-resolver']}". Only "host" is supported.`);
|
|
2047
|
+
process.exit(1);
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
// Disk-hygiene knobs (container sandboxes only). Reaper age + interval and the
|
|
2051
|
+
// free-space admission floor mirror nano's own disk-budget/reaper patterns.
|
|
2052
|
+
const reapAgeMs = intFlag(flags?.['reap-age'], 60 * 60_000); // 1h
|
|
2053
|
+
const reapIntervalMs = intFlag(flags?.['reap-interval'], 5 * 60_000); // 5m
|
|
2054
|
+
const minFreeBytes = flags?.['min-free-mb'] != null
|
|
2055
|
+
? Math.max(0, intFlag(flags['min-free-mb'], 0)) * 1_048_576
|
|
2056
|
+
: 1_073_741_824; // 1 GiB default floor
|
|
2057
|
+
|
|
2058
|
+
// Tracks run ids currently executing so the reaper never removes a live
|
|
2059
|
+
// container out from under an in-flight job.
|
|
2060
|
+
const liveRunIds = new Set();
|
|
2061
|
+
let reaperTimer = null;
|
|
2062
|
+
|
|
2063
|
+
if (isContainer) {
|
|
2064
|
+
if (!containerEngineAvailable(sandbox)) {
|
|
2065
|
+
logger.error(`--sandbox ${sandbox} selected but "${sandbox}" is not available/running on this host.`);
|
|
2066
|
+
process.exit(1);
|
|
2067
|
+
}
|
|
2068
|
+
// Age-gate the startup sweep too (not maxAgeMs:0): on a shared host other
|
|
2069
|
+
// worker processes may have just-created containers not yet in liveRunIds,
|
|
2070
|
+
// so only reap ones older than --reap-age, matching the interval reaper.
|
|
2071
|
+
const initial = reapAgentContainers(sandbox, { maxAgeMs: reapAgeMs, liveRunIds });
|
|
2072
|
+
if (initial.reaped > 0) logger.info(`Reaped ${initial.reaped} leftover agent container(s) at startup.`);
|
|
2073
|
+
if (initial.error) logger.warn(`Startup reap warning: ${initial.error}`);
|
|
2074
|
+
reaperTimer = setInterval(() => {
|
|
2075
|
+
const r = reapAgentContainers(sandbox, { maxAgeMs: reapAgeMs, liveRunIds });
|
|
2076
|
+
if (r.reaped > 0) logger.info(`Reaper removed ${r.reaped} finished agent container(s).`);
|
|
2077
|
+
}, reapIntervalMs);
|
|
2078
|
+
if (typeof reaperTimer.unref === 'function') reaperTimer.unref();
|
|
2079
|
+
}
|
|
2080
|
+
|
|
1683
2081
|
const matrix = jobTypeMatrix(profile.rank, profile.capabilities);
|
|
1684
2082
|
const camunda = globalThis.c8ctl.createClient();
|
|
1685
2083
|
|
|
1686
2084
|
logger.info(`Putting "${name}" [${profile.rank}] to work → ${profile.command}`);
|
|
1687
2085
|
logger.info(` model: ${profile.model || '(none)'}; capabilities: ${profile.capabilities.join(', ') || '(none)'}`);
|
|
2086
|
+
logger.info(` sandbox: ${sandbox}${isContainer ? ` (image ${image})` : ''}`);
|
|
1688
2087
|
logger.info(` listening on ${matrix.length} job type(s): ${matrix.join(' ')}`);
|
|
1689
2088
|
logger.info(` max parallel: ${maxParallelJobs}; job timeout: ${jobTimeoutMs}ms`);
|
|
1690
2089
|
logger.info('Polling for work — press Ctrl-C to stop.');
|
|
@@ -1697,10 +2096,58 @@ async function workAgent(req, flags) {
|
|
|
1697
2096
|
jobTimeoutMs,
|
|
1698
2097
|
jobHandler: async (job) => {
|
|
1699
2098
|
logger.info(`[${jobType}] job ${job.jobKey} (instance ${job.processInstanceKey ?? '-'}) → ${profile.command}`);
|
|
1700
|
-
|
|
2099
|
+
|
|
2100
|
+
// Disk-budget admission shed: if the engine data root is below the free
|
|
2101
|
+
// floor, don't start a container — fail (retryable) so work sheds until
|
|
2102
|
+
// the reaper/host frees space.
|
|
2103
|
+
if (isContainer) {
|
|
2104
|
+
const budget = diskBudgetOk(sandbox, minFreeBytes);
|
|
2105
|
+
if (!budget.ok) {
|
|
2106
|
+
const freeMb = budget.free != null ? Math.round(budget.free / 1_048_576) : '?';
|
|
2107
|
+
const retries = Math.max(0, (Number(job.retries) || 1) - 1);
|
|
2108
|
+
logger.warn(`[${jobType}] job ${job.jobKey} shed — low disk (${freeMb}MB free); retries left ${retries}`);
|
|
2109
|
+
return job.fail({ errorMessage: `disk budget exceeded (only ${freeMb}MB free)`, retries, retryBackOff: 30_000 });
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
// Assemble + normalize the task envelope from headers (defaults) and
|
|
2114
|
+
// variables (overrides), then resolve any secrets it references.
|
|
2115
|
+
const envelope = normalizeTaskEnvelope(job.customHeaders ?? {}, job.variables ?? {});
|
|
2116
|
+
const { resolved, missing, names } = resolveJobSecrets(secretResolver, envelope);
|
|
2117
|
+
if (missing.length > 0) {
|
|
2118
|
+
const retries = Math.max(0, (Number(job.retries) || 1) - 1);
|
|
2119
|
+
const msg = `missing secret(s): ${missing.join(', ')} (resolver: ${secretResolver.kind})`;
|
|
2120
|
+
logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
|
|
2121
|
+
return job.fail({ errorMessage: msg, retries });
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
const runId = randomUUID();
|
|
2125
|
+
if (isContainer) liveRunIds.add(runId);
|
|
2126
|
+
let result;
|
|
2127
|
+
try {
|
|
2128
|
+
result = await runAgentJob(profile, job, {
|
|
2129
|
+
timeoutMs: jobTimeoutMs,
|
|
2130
|
+
envelope,
|
|
2131
|
+
sandbox,
|
|
2132
|
+
image,
|
|
2133
|
+
runId,
|
|
2134
|
+
secretEnv: resolved,
|
|
2135
|
+
passThroughSecretNames: names,
|
|
2136
|
+
});
|
|
2137
|
+
} finally {
|
|
2138
|
+
if (isContainer) liveRunIds.delete(runId);
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
const resultEnvelope = buildResultEnvelope(result, { sandbox, image });
|
|
1701
2142
|
if (result.ok) {
|
|
1702
2143
|
logger.info(`[${jobType}] job ${job.jobKey} complete (exit 0)${result.truncated ? ' [output truncated]' : ''}`);
|
|
1703
|
-
return job.complete({
|
|
2144
|
+
return job.complete({
|
|
2145
|
+
[AGENT_RESULT_KEY]: resultEnvelope,
|
|
2146
|
+
output: result.stdout,
|
|
2147
|
+
exitCode: 0,
|
|
2148
|
+
agent: profile.name,
|
|
2149
|
+
truncated: Boolean(result.truncated),
|
|
2150
|
+
});
|
|
1704
2151
|
}
|
|
1705
2152
|
const retries = Math.max(0, (Number(job.retries) || 1) - 1);
|
|
1706
2153
|
const detail = result.error
|
|
@@ -1710,6 +2157,7 @@ async function workAgent(req, flags) {
|
|
|
1710
2157
|
return job.fail({
|
|
1711
2158
|
errorMessage: `agent "${profile.name}" failed: ${detail}`.slice(0, 2000),
|
|
1712
2159
|
retries,
|
|
2160
|
+
variables: { [AGENT_RESULT_KEY]: resultEnvelope },
|
|
1713
2161
|
});
|
|
1714
2162
|
},
|
|
1715
2163
|
}),
|
|
@@ -1722,6 +2170,7 @@ async function workAgent(req, flags) {
|
|
|
1722
2170
|
if (stopping) return;
|
|
1723
2171
|
stopping = true;
|
|
1724
2172
|
logger.info(`Received ${signal} — stopping ${workers.length} worker(s)...`);
|
|
2173
|
+
if (reaperTimer) clearInterval(reaperTimer);
|
|
1725
2174
|
let stopFailures = 0;
|
|
1726
2175
|
await Promise.all(
|
|
1727
2176
|
workers.map(async (w) => {
|
|
@@ -2973,6 +3422,27 @@ function parseProcessosRequest(args, flags) {
|
|
|
2973
3422
|
// Internal helpers exported for tests/tooling only. c8ctl consumes just
|
|
2974
3423
|
// `metadata` and `commands`; these named exports are inert to it.
|
|
2975
3424
|
export { resolveBinary, findBinary, launcherEnvMarkers };
|
|
3425
|
+
export {
|
|
3426
|
+
normalizeTaskEnvelope,
|
|
3427
|
+
collectEnvelopeFrom,
|
|
3428
|
+
coerceBool,
|
|
3429
|
+
coerceInt,
|
|
3430
|
+
deepMerge,
|
|
3431
|
+
resolveJobSecrets,
|
|
3432
|
+
makeSecretResolver,
|
|
3433
|
+
hostEnvSecretResolver,
|
|
3434
|
+
buildAgentPayload,
|
|
3435
|
+
buildResultEnvelope,
|
|
3436
|
+
reapAgentContainers,
|
|
3437
|
+
diskBudgetOk,
|
|
3438
|
+
containerEngineAvailable,
|
|
3439
|
+
runAgentJob,
|
|
3440
|
+
normalizeStoredProfile,
|
|
3441
|
+
jobTypeMatrix,
|
|
3442
|
+
AGENT_TASK_NS,
|
|
3443
|
+
AGENT_RESULT_KEY,
|
|
3444
|
+
SANDBOXES,
|
|
3445
|
+
};
|
|
2976
3446
|
|
|
2977
3447
|
export const metadata = {
|
|
2978
3448
|
name: 'c8ctl-plugin-nano',
|
|
@@ -3007,7 +3477,9 @@ export const metadata = {
|
|
|
3007
3477
|
{ command: 'c8ctl nano hire', description: 'Interactively create a CLI agent worker profile (name, rank, command, model, capabilities)' },
|
|
3008
3478
|
{ command: 'c8ctl nano hire --name reviewer --rank senior --command copilot --model gpt-5 --capabilities code-review,testing', description: 'Create a profile non-interactively' },
|
|
3009
3479
|
{ command: 'c8ctl nano hire --list', description: 'List hired agent profiles' },
|
|
3480
|
+
{ command: 'c8ctl nano hire --name coder --rank senior --command "agent-harness" --sandbox docker --image ghcr.io/acme/agent:1', description: 'Create a profile that runs each job in a throwaway Docker container' },
|
|
3010
3481
|
{ command: 'c8ctl nano work reviewer', description: 'Spawn Nano job workers for the "reviewer" profile and poll for work' },
|
|
3482
|
+
{ command: 'c8ctl nano work coder --sandbox docker --image ghcr.io/acme/agent:1', description: 'Run jobs in isolated containers with disk-hygiene reaping' },
|
|
3011
3483
|
],
|
|
3012
3484
|
},
|
|
3013
3485
|
processos: {
|
|
@@ -3054,6 +3526,12 @@ export const commands = {
|
|
|
3054
3526
|
command: { type: 'string', description: 'hire: CLI command that runs the agent harness (e.g. copilot, claude, pi)' },
|
|
3055
3527
|
model: { type: 'string', description: 'hire: model name passed to the harness (AGENT_MODEL)' },
|
|
3056
3528
|
capabilities: { type: 'string', description: 'hire: comma-separated capability list' },
|
|
3529
|
+
sandbox: { type: 'string', description: 'hire/work: execution sandbox none|docker|podman (default none). Containers isolate each job.' },
|
|
3530
|
+
image: { type: 'string', description: 'hire/work: container image the agent runs in (required for --sandbox docker|podman)' },
|
|
3531
|
+
'secret-resolver': { type: 'string', description: 'work: secret resolver for task secretRefs (host = process env; default host)' },
|
|
3532
|
+
'reap-age': { type: 'string', description: 'work: age in ms before a finished agent container is reaped (default 3600000)' },
|
|
3533
|
+
'reap-interval': { type: 'string', description: 'work: how often to sweep finished agent containers in ms (default 300000)' },
|
|
3534
|
+
'min-free-mb': { type: 'string', description: 'work: shed jobs when the engine data root has less than this many MB free (default 1024)' },
|
|
3057
3535
|
list: { type: 'boolean', description: 'hire: list existing agent profiles instead of creating one' },
|
|
3058
3536
|
'max-parallel': { type: 'string', description: 'work: max concurrent jobs per worker (default 1)' },
|
|
3059
3537
|
'job-timeout': { type: 'string', description: 'work: max harness runtime per job in ms; the spawned process is killed past this (default 300000)' },
|
|
@@ -3199,8 +3677,8 @@ function printUsage() {
|
|
|
3199
3677
|
console.log(' c8ctl nano set <bin|model-dir> <path>');
|
|
3200
3678
|
console.log(' c8ctl nano config');
|
|
3201
3679
|
console.log(' c8ctl nano update [--check]');
|
|
3202
|
-
console.log(' c8ctl nano hire [--name <n>] [--rank <r>] [--command <c>] [--model <m>] [--capabilities <a,b>] [--list]');
|
|
3203
|
-
console.log(' c8ctl nano work <profileName> [--max-parallel <n>] [--job-timeout <ms>]');
|
|
3680
|
+
console.log(' c8ctl nano hire [--name <n>] [--rank <r>] [--command <c>] [--model <m>] [--capabilities <a,b>] [--sandbox none|docker|podman] [--image <ref>] [--list]');
|
|
3681
|
+
console.log(' c8ctl nano work <profileName> [--max-parallel <n>] [--job-timeout <ms>] [--sandbox none|docker|podman] [--image <ref>] [--secret-resolver host] [--min-free-mb <n>]');
|
|
3204
3682
|
console.log('');
|
|
3205
3683
|
console.log('Subcommands:');
|
|
3206
3684
|
console.log(' start Spawn an N-node local cluster wired to talk to each other on localhost');
|
|
@@ -3231,6 +3709,20 @@ function printUsage() {
|
|
|
3231
3709
|
console.log(' --purge stop: also delete per-node engine data');
|
|
3232
3710
|
console.log(' --force start: stop any existing cluster first');
|
|
3233
3711
|
console.log(' --workspace clean: also delete the workspace (models + workers)');
|
|
3712
|
+
console.log(' --name <n> hire/work: agent profile name (alt to positional arg)');
|
|
3713
|
+
console.log(' --rank <r> hire: agent rank (principal|senior|junior|decider)');
|
|
3714
|
+
console.log(' --command <c> hire: CLI command that runs the agent harness');
|
|
3715
|
+
console.log(' --model <m> hire: model name passed to the harness (AGENT_MODEL)');
|
|
3716
|
+
console.log(' --capabilities <a,b> hire: comma-separated capability list');
|
|
3717
|
+
console.log(' --sandbox <s> hire/work: execution sandbox none|docker|podman (default none)');
|
|
3718
|
+
console.log(' --image <ref> hire/work: container image the agent runs in (required for docker|podman)');
|
|
3719
|
+
console.log(' --list hire: list existing agent profiles instead of creating one');
|
|
3720
|
+
console.log(' --max-parallel <n> work: max concurrent jobs per worker (default 1)');
|
|
3721
|
+
console.log(' --job-timeout <ms> work: max harness runtime per job in ms (default 300000)');
|
|
3722
|
+
console.log(' --secret-resolver <r> work: secret resolver for task secretRefs (host; default host)');
|
|
3723
|
+
console.log(' --reap-age <ms> work: age before a finished agent container is reaped (default 3600000)');
|
|
3724
|
+
console.log(' --reap-interval <ms> work: how often to sweep finished agent containers (default 300000)');
|
|
3725
|
+
console.log(' --min-free-mb <n> work: shed jobs when the engine data root has < this many MB free (default 1024)');
|
|
3234
3726
|
console.log('');
|
|
3235
3727
|
console.log('Persistent assets:');
|
|
3236
3728
|
console.log(' Models and workers live in the workspace dir (NANOBPMN_WORKSPACE_DIR),');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c8ctl-plugin-nano",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "c8ctl plugin to start, inspect, and stop a local Nano BPM (nanobpmn) cluster",
|
|
6
6
|
"main": "c8ctl-plugin.js",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
],
|
|
28
28
|
"scripts": {
|
|
29
29
|
"lint": "node --check c8ctl-plugin.js",
|
|
30
|
-
"test": "node --check c8ctl-plugin.js"
|
|
30
|
+
"test": "node --check c8ctl-plugin.js && node --test"
|
|
31
31
|
},
|
|
32
32
|
"license": "MIT",
|
|
33
33
|
"engines": {
|
|
@@ -49,12 +49,12 @@
|
|
|
49
49
|
"semantic-release": "^25.0.3"
|
|
50
50
|
},
|
|
51
51
|
"optionalDependencies": {
|
|
52
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.
|
|
53
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.
|
|
54
|
-
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.
|
|
55
|
-
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.
|
|
56
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.
|
|
57
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.
|
|
58
|
-
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.
|
|
52
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.10.0",
|
|
53
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.10.0",
|
|
54
|
+
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.10.0",
|
|
55
|
+
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.10.0",
|
|
56
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.10.0",
|
|
57
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.10.0",
|
|
58
|
+
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.10.0"
|
|
59
59
|
}
|
|
60
60
|
}
|