c8ctl-plugin-nano 1.22.1 → 1.24.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 +33 -18
- package/c8ctl-plugin.js +237 -69
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -204,7 +204,7 @@ c8ctl nano work reviewer --job-type senior:pr-review --job-type senior:triage
|
|
|
204
204
|
|
|
205
205
|
```bash
|
|
206
206
|
c8ctl nano work reviewer # poll for work until Ctrl-C
|
|
207
|
-
c8ctl nano work reviewer --max-parallel 2 --
|
|
207
|
+
c8ctl nano work reviewer --max-parallel 2 --recovery-window 300000
|
|
208
208
|
c8ctl nano work reviewer --name reviewer-eu # name this worker (else auto ‹host›-‹profile›-‹random›)
|
|
209
209
|
```
|
|
210
210
|
|
|
@@ -252,19 +252,32 @@ and the profile/model are also exported as `AGENT_PROFILE`, `AGENT_RANK`,
|
|
|
252
252
|
`AGENT_MODEL`, `AGENT_CAPABILITIES`, `AGENT_JOB_TYPE` env vars. On exit `0` the
|
|
253
253
|
job is **completed** with `{ output: <stdout>, exitCode: 0 }` (captured output is
|
|
254
254
|
capped at 1 MiB, with a `truncated` flag when exceeded); any other exit **fails**
|
|
255
|
-
the job with a decremented retry count
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
>
|
|
260
|
-
>
|
|
261
|
-
>
|
|
262
|
-
>
|
|
263
|
-
>
|
|
264
|
-
>
|
|
265
|
-
>
|
|
266
|
-
>
|
|
267
|
-
>
|
|
255
|
+
the job with a decremented retry count. Profiles are stored in the plugin's
|
|
256
|
+
`config.json` (see `c8ctl nano config`).
|
|
257
|
+
|
|
258
|
+
> **Self-managing activation lock (no hardcoded job timeout).** An agent job's
|
|
259
|
+
> duration is unpredictable, so the worker does **not** ask you to pick a fixed
|
|
260
|
+
> timeout up front. It keeps the broker's job-activation lock a bounded
|
|
261
|
+
> `--recovery-window` (default `300000`ms) ahead of *now*, refreshing it every
|
|
262
|
+
> ~1/3 of that window for as long as the harness is alive **and** producing
|
|
263
|
+
> output. Consequences:
|
|
264
|
+
> - **Long jobs never lose their lock.** A run that takes hours keeps going — the
|
|
265
|
+
> lock is continuously extended, so the broker never re-activates the job while
|
|
266
|
+
> you're still working on it (which would start a second agent and get the stale
|
|
267
|
+
> `complete`/`fail` rejected with a 409 "job cannot be failed in the current
|
|
268
|
+
> state").
|
|
269
|
+
> - **Fast recovery on death.** Because each refresh *sets* the deadline to
|
|
270
|
+
> now+window (the `UpdateJobTimeout` contract is a duration-from-now, not a
|
|
271
|
+
> cumulative delta), the moment the worker stops refreshing — the process dies,
|
|
272
|
+
> the node is lost, the harness is idle-killed, or it hits the hard cap — the
|
|
273
|
+
> lock lapses within one `--recovery-window` and the broker reclaims the job.
|
|
274
|
+
> This is deliberately optimised for quick reclaim, not for holding a stale lock.
|
|
275
|
+
> - **`--idle-timeout`** (default `300000`ms) is the liveness gate: if the agent
|
|
276
|
+
> produces no stdout/stderr for this long it is killed as wedged, extension
|
|
277
|
+
> stops, and the job is reclaimed — so a hung agent can't hold a job forever.
|
|
278
|
+
> - **`--job-timeout`** is now an *optional* absolute hard cap on total harness
|
|
279
|
+
> runtime (default `0` = unlimited), for when you want a ceiling regardless of
|
|
280
|
+
> output. `--lock-grace` is **deprecated and ignored** — the lock is auto-managed.
|
|
268
281
|
|
|
269
282
|
> **Long-poll window.** `--poll-timeout` (default `30000`ms) is how long the
|
|
270
283
|
> broker holds each `activateJobs` request open waiting for work before returning
|
|
@@ -372,7 +385,8 @@ c8ctl nano work coder --sandbox docker --image ghcr.io/acme/agent:1 # or overr
|
|
|
372
385
|
|
|
373
386
|
Containers are labelled (`nano.managed=1`, `nano.worker`, `nano.jobKey`,
|
|
374
387
|
`nano.run=<uuid>`), log-capped (`max-size=10m max-file=3`), run with `--rm`, and
|
|
375
|
-
a run that outlives `--job-timeout`
|
|
388
|
+
a run that is idle-killed (or that outlives an optional `--job-timeout` hard cap)
|
|
389
|
+
is force-removed. The envelope is piped on
|
|
376
390
|
the container's stdin exactly as on the host. (Container-side git provisioning —
|
|
377
391
|
strong isolation — is a later increment; container jobs don't clone yet.)
|
|
378
392
|
|
|
@@ -480,9 +494,10 @@ a worker id **or** a profile name — targeting a profile affects *every*
|
|
|
480
494
|
instance of it.
|
|
481
495
|
|
|
482
496
|
Each worker takes the **same flags as `nano work`** (`--max-parallel`,
|
|
483
|
-
`--
|
|
484
|
-
`--job-type`, `--env`, `--arg`, …); they are forwarded
|
|
485
|
-
child, so a supervised worker is byte-identical to a
|
|
497
|
+
`--recovery-window`, `--idle-timeout`, `--job-timeout`, `--poll-timeout`,
|
|
498
|
+
`--sandbox`/`--image`, `--job-type`, `--env`, `--arg`, …); they are forwarded
|
|
499
|
+
verbatim to the spawned child, so a supervised worker is byte-identical to a
|
|
500
|
+
hand-run `nano work`. In the
|
|
486
501
|
interactive console, type the flags after the profile: `add reviewer --max-parallel 2`.
|
|
487
502
|
|
|
488
503
|
How it works and where things live:
|
package/c8ctl-plugin.js
CHANGED
|
@@ -212,6 +212,34 @@ function getSupervisorLogDir() {
|
|
|
212
212
|
return join(getLogDir(), 'supervisor');
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Per-worker activity directory + file. A supervised `nano work` child writes a
|
|
217
|
+
* small JSON marker here reporting which job(s) it is currently servicing (or
|
|
218
|
+
* that it is idle); the daemon reads it for `supervisor status`. Worker ids are
|
|
219
|
+
* validated (`isValidWorkerName`: letters, digits, . _ -) so they are safe as a
|
|
220
|
+
* filename with no traversal risk.
|
|
221
|
+
*/
|
|
222
|
+
function getSupervisorActivityDir() {
|
|
223
|
+
return join(getStateHome(), 'supervisor-activity');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function supervisorWorkerActivityFile(id) {
|
|
227
|
+
return join(getSupervisorActivityDir(), `${id}.json`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Read a worker's activity marker. Returns the parsed object, or `null` when the
|
|
232
|
+
* file is absent (worker not reporting yet, or a standalone/older worker) or
|
|
233
|
+
* unreadable. Pure enough for status rendering (best-effort IO).
|
|
234
|
+
*/
|
|
235
|
+
function readWorkerActivity(id) {
|
|
236
|
+
try {
|
|
237
|
+
return JSON.parse(readFileSync(supervisorWorkerActivityFile(id), 'utf-8'));
|
|
238
|
+
} catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
215
243
|
/**
|
|
216
244
|
* Deterministic control-socket path shared by the daemon and every client.
|
|
217
245
|
* Derived from a hash of the (possibly overridden) state home so distinct
|
|
@@ -1532,46 +1560,6 @@ function parseJobTypeFlags(input) {
|
|
|
1532
1560
|
return { jobTypes, errors };
|
|
1533
1561
|
}
|
|
1534
1562
|
|
|
1535
|
-
/**
|
|
1536
|
-
* The broker job-activation lock must strictly outlast the harness kill
|
|
1537
|
-
* deadline: the worker has to report the outcome (complete/fail) before the lock
|
|
1538
|
-
* lapses, or the broker re-activates the still-retryable job (a second agent
|
|
1539
|
-
* starts) and the stale `fail` is rejected 409 "job cannot be failed in the
|
|
1540
|
-
* current state". So lock = kill + grace. Non-finite or non-positive inputs
|
|
1541
|
-
* fall back to fixed defaults (5m kill / 2m grace). All inputs are coerced to
|
|
1542
|
-
* safe positive integers (positive fractional values floor to at least 1) and
|
|
1543
|
-
* grace is capped so a positive kill always fits and `kill + grace` stays within
|
|
1544
|
-
* the safe-integer range, so the invariant lock > kill holds strictly for every
|
|
1545
|
-
* accepted input — including values at or beyond 2^53 where float addition
|
|
1546
|
-
* would otherwise round `kill + grace` back down to `kill`.
|
|
1547
|
-
*
|
|
1548
|
-
* Returns BOTH derived deadlines from this single computation so the caller
|
|
1549
|
-
* never re-derives (and drifts): `killMs` is the *clamped* harness kill deadline
|
|
1550
|
-
* the caller must actually enforce, and `lockMs` is the broker activation lock.
|
|
1551
|
-
* The caller must use `killMs` — not the raw input — for the harness timeout, or
|
|
1552
|
-
* the lock > kill invariant breaks for large inputs (the raw input can exceed
|
|
1553
|
-
* the clamped `killMs`, and thus reach or exceed `lockMs`).
|
|
1554
|
-
*
|
|
1555
|
-
* @returns {{ killMs: number, lockMs: number }}
|
|
1556
|
-
*/
|
|
1557
|
-
function deriveJobLockMs(jobTimeoutMs, lockGraceMs) {
|
|
1558
|
-
const MAX = Number.MAX_SAFE_INTEGER;
|
|
1559
|
-
const toSafeMs = (value, fallback) => {
|
|
1560
|
-
// Floor positive fractional values to at least 1 so a sub-millisecond input
|
|
1561
|
-
// (e.g. 0.5) never collapses to a non-positive value.
|
|
1562
|
-
const n = Number.isFinite(value) && value > 0 ? Math.max(1, Math.floor(value)) : fallback;
|
|
1563
|
-
return Math.min(n, MAX);
|
|
1564
|
-
};
|
|
1565
|
-
// Cap grace to MAX - 1 so there is always room for a positive kill while
|
|
1566
|
-
// keeping kill + grace within the safe-integer range.
|
|
1567
|
-
const grace = Math.min(toSafeMs(lockGraceMs, 2 * 60_000), MAX - 1);
|
|
1568
|
-
// Cap kill so kill + grace stays a safe integer and floor it at 1 so the
|
|
1569
|
-
// internal kill is always positive; the sum is then exact and strictly
|
|
1570
|
-
// greater than kill (never equal to it via float rounding).
|
|
1571
|
-
const killMs = Math.max(1, Math.min(toSafeMs(jobTimeoutMs, 5 * 60_000), MAX - grace));
|
|
1572
|
-
return { killMs, lockMs: killMs + grace };
|
|
1573
|
-
}
|
|
1574
|
-
|
|
1575
1563
|
/**
|
|
1576
1564
|
* Resolve the broker long-poll window (ms) each `activateJobs` request is held
|
|
1577
1565
|
* open before returning empty. A longer window keeps an idle worker on ONE open
|
|
@@ -2598,7 +2586,7 @@ const MAX_CAPTURE_BYTES = 1_048_576; // 1 MiB per stream
|
|
|
2598
2586
|
// Spawn a child, pipe `stdinData`, capture byte-capped stdout/stderr, enforce a
|
|
2599
2587
|
// timeout (invoking `onTimeout(child)` to tear the child down), and resolve to a
|
|
2600
2588
|
// uniform result. Used by both the host and container executors.
|
|
2601
|
-
function spawnCaptureOneShot({ command, args = [], shell = false, detached = false, cwd, env, stdinData, timeoutMs, onTimeout, stream = false, streamPrefix = '', onStreamOut, onStreamErr }) {
|
|
2589
|
+
function spawnCaptureOneShot({ command, args = [], shell = false, detached = false, cwd, env, stdinData, timeoutMs, idleTimeoutMs, onTimeout, stream = false, streamPrefix = '', onStreamOut, onStreamErr }) {
|
|
2602
2590
|
return new Promise((resolve) => {
|
|
2603
2591
|
let child;
|
|
2604
2592
|
const stdoutChunks = [];
|
|
@@ -2609,6 +2597,7 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
|
|
|
2609
2597
|
let stderrTruncated = false;
|
|
2610
2598
|
let settled = false;
|
|
2611
2599
|
let timer = null;
|
|
2600
|
+
let idleTimer = null;
|
|
2612
2601
|
|
|
2613
2602
|
// Live "spy" tee (--stream): mirror the child's output line-by-line to a
|
|
2614
2603
|
// caller-supplied emitter (the worker routes these through c8ctl's
|
|
@@ -2645,6 +2634,7 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
|
|
|
2645
2634
|
if (settled) return;
|
|
2646
2635
|
settled = true;
|
|
2647
2636
|
if (timer) clearTimeout(timer);
|
|
2637
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
2648
2638
|
if (teeOut) teeOut('', true);
|
|
2649
2639
|
if (teeErr) teeErr('', true);
|
|
2650
2640
|
resolve(result);
|
|
@@ -2664,7 +2654,25 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
|
|
|
2664
2654
|
}, timeoutMs)
|
|
2665
2655
|
: null;
|
|
2666
2656
|
|
|
2657
|
+
// Idle-liveness kill: if the child emits no stdout/stderr for `idleTimeoutMs`,
|
|
2658
|
+
// treat it as wedged and kill the tree. This is the liveness signal the
|
|
2659
|
+
// worker's lock-extender relies on — a silent hang stops producing output, we
|
|
2660
|
+
// kill it here, `runAgentJob` resolves, and the worker fails the job
|
|
2661
|
+
// (retryable) so the broker reclaims it. Distinct from the absolute `timeoutMs`
|
|
2662
|
+
// hard cap: this fires on *silence*, not total runtime. Re-armed on every chunk.
|
|
2663
|
+
const armIdle = () => {
|
|
2664
|
+
if (settled) return;
|
|
2665
|
+
if (!(idleTimeoutMs && idleTimeoutMs > 0)) return;
|
|
2666
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
2667
|
+
idleTimer = setTimeout(() => {
|
|
2668
|
+
try { if (onTimeout) onTimeout(child); } catch { /* best effort */ }
|
|
2669
|
+
finish({ ok: false, exitCode: null, stdout: joinCapped(stdoutChunks), stderr: joinCapped(stderrChunks), error: `no output for ${idleTimeoutMs}ms (idle)`, timedOut: true, idle: true, truncated: stdoutTruncated, stderrTruncated });
|
|
2670
|
+
}, idleTimeoutMs);
|
|
2671
|
+
};
|
|
2672
|
+
armIdle();
|
|
2673
|
+
|
|
2667
2674
|
child.stdout.on('data', (d) => {
|
|
2675
|
+
armIdle();
|
|
2668
2676
|
const buf = Buffer.isBuffer(d) ? d : Buffer.from(d);
|
|
2669
2677
|
if (teeOut) teeOut(buf.toString('utf8'), false);
|
|
2670
2678
|
const remaining = MAX_CAPTURE_BYTES - stdoutBytes;
|
|
@@ -2673,6 +2681,7 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
|
|
|
2673
2681
|
else { stdoutChunks.push(buf); stdoutBytes += buf.length; }
|
|
2674
2682
|
});
|
|
2675
2683
|
child.stderr.on('data', (d) => {
|
|
2684
|
+
armIdle();
|
|
2676
2685
|
const buf = Buffer.isBuffer(d) ? d : Buffer.from(d);
|
|
2677
2686
|
if (teeErr) teeErr(buf.toString('utf8'), false);
|
|
2678
2687
|
const remaining = MAX_CAPTURE_BYTES - stderrBytes;
|
|
@@ -2728,6 +2737,43 @@ function baseAgentEnv(profile, job) {
|
|
|
2728
2737
|
};
|
|
2729
2738
|
}
|
|
2730
2739
|
|
|
2740
|
+
/**
|
|
2741
|
+
* Keep a leased job's broker activation lock ahead of *now* while the harness is
|
|
2742
|
+
* running, so a long agent run never has its lock lapse and get re-activated (a
|
|
2743
|
+
* second worker starting → the classic stale complete/fail 409). The lock is NOT
|
|
2744
|
+
* hardcoded up front: we refresh it to `windowMs` — a duration-from-now, per the
|
|
2745
|
+
* UpdateJobTimeout contract ("the duration of the new timeout in ms, starting
|
|
2746
|
+
* from the current moment"), so calls SET rather than accumulate — every
|
|
2747
|
+
* `intervalMs`. The deadline therefore stays a bounded `windowMs` ahead of now.
|
|
2748
|
+
* The instant we stop refreshing (harness exit / idle-kill / hard cap) the lock
|
|
2749
|
+
* lapses within `windowMs` and the broker reclaims the job — fast node-loss
|
|
2750
|
+
* recovery. Because the harness is always killed locally before we stop, the lock
|
|
2751
|
+
* strictly outlives our local run, so a reclaim never races a still-running agent.
|
|
2752
|
+
*
|
|
2753
|
+
* Returns a stop() to call once the run settles. Extension failures are logged
|
|
2754
|
+
* and swallowed — a transient network blip must not crash the job handler. Older
|
|
2755
|
+
* SDKs without `modifyJobTimeout` degrade to the fixed initial lock (a no-op stop).
|
|
2756
|
+
*/
|
|
2757
|
+
function startLockExtender(job, windowMs, intervalMs, tag, logger) {
|
|
2758
|
+
if (!(windowMs > 0) || !(intervalMs > 0)) {
|
|
2759
|
+
return () => {};
|
|
2760
|
+
}
|
|
2761
|
+
if (typeof job?.modifyJobTimeout !== 'function') {
|
|
2762
|
+
logger?.warn?.(`${tag}: job.modifyJobTimeout unavailable — activation lock will NOT be auto-extended; a run longer than ${windowMs}ms risks being reclaimed and executed twice`);
|
|
2763
|
+
return () => {};
|
|
2764
|
+
}
|
|
2765
|
+
const extend = () => Promise.resolve()
|
|
2766
|
+
.then(() => job.modifyJobTimeout({ newTimeoutMs: windowMs }))
|
|
2767
|
+
.catch((err) => logger?.warn?.(`${tag}: lock extend failed — ${err?.message ?? err}`));
|
|
2768
|
+
// Renew immediately so the harness starts with a full, fresh window no matter
|
|
2769
|
+
// how much of the initial activation lease provisioning (clone/checkout) ate.
|
|
2770
|
+
extend();
|
|
2771
|
+
const timer = setInterval(extend, intervalMs);
|
|
2772
|
+
// Never let the heartbeat keep the process alive on shutdown.
|
|
2773
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
2774
|
+
return () => clearInterval(timer);
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2731
2777
|
/**
|
|
2732
2778
|
* Run a single activated job through the profile's CLI command (one-shot),
|
|
2733
2779
|
* dispatching on the profile's sandbox:
|
|
@@ -2738,7 +2784,7 @@ function baseAgentEnv(profile, job) {
|
|
|
2738
2784
|
* Both paths resolve to the same result contract.
|
|
2739
2785
|
*/
|
|
2740
2786
|
function runAgentJob(profile, job, opts = {}) {
|
|
2741
|
-
const { timeoutMs, envelope, sandbox = 'none', image, runId, secretEnv = {}, passThroughSecretNames = [], cwd, extraEnv = {}, profileEnv = {}, resultFile, stream = false, streamPrefix = '', onStreamOut, onStreamErr, args: commandArgs } = opts;
|
|
2787
|
+
const { timeoutMs, idleTimeoutMs, envelope, sandbox = 'none', image, runId, secretEnv = {}, passThroughSecretNames = [], cwd, extraEnv = {}, profileEnv = {}, resultFile, stream = false, streamPrefix = '', onStreamOut, onStreamErr, args: commandArgs } = opts;
|
|
2742
2788
|
const payload = JSON.stringify(buildAgentPayload(profile, job, envelope));
|
|
2743
2789
|
const agentEnv = baseAgentEnv(profile, job);
|
|
2744
2790
|
// The harness command line: the profile command plus its structured switches
|
|
@@ -2773,6 +2819,7 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
2773
2819
|
env: { ...process.env, ...staticEnv, ...secretEnv, ...agentEnv, ...extraEnv, ...resultEnv },
|
|
2774
2820
|
stdinData: payload,
|
|
2775
2821
|
timeoutMs,
|
|
2822
|
+
idleTimeoutMs,
|
|
2776
2823
|
onTimeout: (child) => killTree(child),
|
|
2777
2824
|
stream,
|
|
2778
2825
|
streamPrefix,
|
|
@@ -2828,6 +2875,7 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
2828
2875
|
env: { ...process.env, ...staticEnv, ...secretEnv, ...agentEnv, ...extraEnv, ...resultEnv },
|
|
2829
2876
|
stdinData: payload,
|
|
2830
2877
|
timeoutMs,
|
|
2878
|
+
idleTimeoutMs,
|
|
2831
2879
|
stream,
|
|
2832
2880
|
streamPrefix,
|
|
2833
2881
|
onStreamOut,
|
|
@@ -2943,18 +2991,36 @@ async function workAgent(req, flags) {
|
|
|
2943
2991
|
return Number.isFinite(n) && n > 0 ? n : dflt;
|
|
2944
2992
|
};
|
|
2945
2993
|
const maxParallelJobs = intFlag(flags?.['max-parallel'], 1);
|
|
2946
|
-
|
|
2947
|
-
//
|
|
2948
|
-
//
|
|
2949
|
-
//
|
|
2950
|
-
//
|
|
2951
|
-
//
|
|
2952
|
-
//
|
|
2953
|
-
|
|
2954
|
-
//
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2994
|
+
// The broker job-activation lock is NOT hardcoded up front. A fixed timeout is
|
|
2995
|
+
// impossible to size for an agent: too short reclaims a still-working job (a
|
|
2996
|
+
// second agent starts + the stale complete/fail is rejected 409), too long
|
|
2997
|
+
// strands a dead worker's job. Instead the worker keeps the lock a bounded
|
|
2998
|
+
// `recovery-window` ahead of *now* while the harness runs (see
|
|
2999
|
+
// startLockExtender), so long runs never lose their lock, and a dead/killed
|
|
3000
|
+
// worker's job is reclaimed within one window. Liveness is enforced by
|
|
3001
|
+
// `idle-timeout` (max silence before the harness is killed as wedged), so the
|
|
3002
|
+
// lock is held only while the agent is alive AND producing output.
|
|
3003
|
+
const recoveryWindowMs = intFlag(flags?.['recovery-window'], 5 * 60_000);
|
|
3004
|
+
const idleTimeoutMs = intFlag(flags?.['idle-timeout'], 5 * 60_000);
|
|
3005
|
+
// `--job-timeout` is now an OPTIONAL absolute hard cap on total harness runtime
|
|
3006
|
+
// (0/absent = unlimited), for operators who still want a ceiling regardless of
|
|
3007
|
+
// output. It no longer governs the broker lock. intFlag floors non-positive to
|
|
3008
|
+
// the default, so 0/absent both mean "no cap".
|
|
3009
|
+
const hardCapMs = intFlag(flags?.['job-timeout'], 0);
|
|
3010
|
+
// Refresh the lock well before it lapses: to `recovery-window` every ~1/3 of it,
|
|
3011
|
+
// so a couple of missed beats (a slow extend RPC) don't drop the job, while a
|
|
3012
|
+
// true stop (exit / idle-kill / hard cap) still reclaims within one window.
|
|
3013
|
+
// Floored at 5s so a tiny window can't spin the extender.
|
|
3014
|
+
// Refresh interval: ~1/3 of the window so we always renew comfortably before it
|
|
3015
|
+
// lapses, floored at 5s (avoid hammering the gateway) and capped strictly below
|
|
3016
|
+
// the window so even a tiny recovery window still renews before it expires.
|
|
3017
|
+
const lockExtendIntervalMs = Math.min(
|
|
3018
|
+
Math.max(5_000, Math.floor(recoveryWindowMs / 3)),
|
|
3019
|
+
// Strict upper bound: always renew before the window lapses. `* 0.75` (never
|
|
3020
|
+
// floored back up above the window) keeps the interval < recoveryWindowMs even
|
|
3021
|
+
// for a tiny window, so the lock can't lapse between beats.
|
|
3022
|
+
Math.max(1, Math.floor(recoveryWindowMs * 0.75)),
|
|
3023
|
+
);
|
|
2958
3024
|
// Broker long-poll window: how long each activateJobs request is held open
|
|
2959
3025
|
// waiting for work. 30s default so idle workers hold one connection open ~30s
|
|
2960
3026
|
// rather than reconnecting every few seconds — fewer reconnects, fewer chances
|
|
@@ -3068,9 +3134,43 @@ async function workAgent(req, flags) {
|
|
|
3068
3134
|
if (profileEnvKeys.length > 0) logger.info(` harness env: ${profileEnvKeys.join(', ')}`);
|
|
3069
3135
|
const extraNote = extraJobTypes.length > 0 ? ` (${extraJobTypes.length} via --job-type)` : '';
|
|
3070
3136
|
logger.info(` listening on ${jobTypes.length} job type(s)${extraNote}: ${jobTypes.join(' ')}`);
|
|
3071
|
-
logger.info(` max parallel: ${maxParallelJobs};
|
|
3137
|
+
logger.info(` max parallel: ${maxParallelJobs}; recovery window: ${recoveryWindowMs}ms; idle timeout: ${idleTimeoutMs}ms; hard cap: ${hardCapMs > 0 ? `${hardCapMs}ms` : 'off'}; poll timeout: ${pollTimeoutMs}ms`);
|
|
3072
3138
|
logger.info('Polling for work — press Ctrl-C to stop.');
|
|
3073
3139
|
|
|
3140
|
+
// When launched under the supervisor, report per-job activity — which job(s)
|
|
3141
|
+
// this worker is currently servicing, or that it is idle — to a small marker
|
|
3142
|
+
// file the supervisor reads for `supervisor status`. The daemon passes the
|
|
3143
|
+
// path via NANO_SUPERVISOR_ACTIVITY_FILE; a standalone `nano work` has no such
|
|
3144
|
+
// env var and writes nothing (this is entirely advisory).
|
|
3145
|
+
const activityFile = process.env.NANO_SUPERVISOR_ACTIVITY_FILE || null;
|
|
3146
|
+
const activeJobs = new Map(); // jobKey -> { type, since (ms epoch) }
|
|
3147
|
+
const writeActivity = () => {
|
|
3148
|
+
if (!activityFile) return;
|
|
3149
|
+
const jobs = [...activeJobs.entries()].map(([key, v]) => ({ key, type: v.type, since: v.since }));
|
|
3150
|
+
const payload = { pid: process.pid, updatedAt: Date.now(), busy: jobs.length > 0, jobs };
|
|
3151
|
+
const tmp = `${activityFile}.${process.pid}.tmp`;
|
|
3152
|
+
try {
|
|
3153
|
+
mkdirSync(dirname(activityFile), { recursive: true });
|
|
3154
|
+
writeFileSync(tmp, JSON.stringify(payload), { mode: 0o600 });
|
|
3155
|
+
renameSync(tmp, activityFile); // atomic swap so a reader never sees a half-write
|
|
3156
|
+
} catch {
|
|
3157
|
+
try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
|
|
3158
|
+
/* best effort — activity is advisory, never fail a job over it */
|
|
3159
|
+
}
|
|
3160
|
+
};
|
|
3161
|
+
const recordJobStart = (job, jobType) => {
|
|
3162
|
+
if (!activityFile) return;
|
|
3163
|
+
activeJobs.set(String(job.jobKey), { type: jobType, since: Date.now() });
|
|
3164
|
+
writeActivity();
|
|
3165
|
+
};
|
|
3166
|
+
const recordJobEnd = (job) => {
|
|
3167
|
+
if (!activityFile) return;
|
|
3168
|
+
activeJobs.delete(String(job.jobKey));
|
|
3169
|
+
writeActivity();
|
|
3170
|
+
};
|
|
3171
|
+
// Seed an initial idle marker so status reports 'idle' immediately after spawn.
|
|
3172
|
+
writeActivity();
|
|
3173
|
+
|
|
3074
3174
|
// A per-job-type worker factory. Captures all the CLI-local + profile context
|
|
3075
3175
|
// in closure scope so the profile watcher below can (re)spawn a poller for any
|
|
3076
3176
|
// job type on demand without re-reading the flags.
|
|
@@ -3079,9 +3179,17 @@ async function workAgent(req, flags) {
|
|
|
3079
3179
|
jobType,
|
|
3080
3180
|
workerName: `${workerName}:${jobType}`,
|
|
3081
3181
|
maxParallelJobs,
|
|
3082
|
-
jobTimeoutMs:
|
|
3182
|
+
jobTimeoutMs: recoveryWindowMs,
|
|
3083
3183
|
pollTimeoutMs,
|
|
3084
3184
|
jobHandler: async (job) => {
|
|
3185
|
+
recordJobStart(job, jobType);
|
|
3186
|
+
// Auto-extend the broker lock for the whole life of this job (harness run
|
|
3187
|
+
// + git finalize + complete/fail), stopped in the outer finally. The lock
|
|
3188
|
+
// is held only while the harness stays alive and productive — a silent
|
|
3189
|
+
// hang is killed by the idle-timeout, which resolves runAgentJob and stops
|
|
3190
|
+
// the extension, so the broker can reclaim the job.
|
|
3191
|
+
let stopLockExtender = () => {};
|
|
3192
|
+
try {
|
|
3085
3193
|
logger.info(`[${jobType}] job ${job.jobKey} (instance ${job.processInstanceKey ?? '-'}) → ${buildAgentCommandLine(profile.command, effectiveArgs)}`);
|
|
3086
3194
|
|
|
3087
3195
|
// Disk-budget admission shed: if the engine data root is below the free
|
|
@@ -3117,6 +3225,12 @@ async function workAgent(req, flags) {
|
|
|
3117
3225
|
const hasRepo = !isContainer && !!envelope.repository?.url;
|
|
3118
3226
|
let runDir = null;
|
|
3119
3227
|
let provisioned = null;
|
|
3228
|
+
// Start refreshing the broker activation lock BEFORE any potentially-long
|
|
3229
|
+
// work (host git clone/checkout can outlast the initial window). Starting
|
|
3230
|
+
// here — ahead of provisionRepo — guarantees the first renewal is queued
|
|
3231
|
+
// before the clone, so the lock can't lapse mid-provision and trigger the
|
|
3232
|
+
// duplicate-activation / stale-409 race. The `finally` below stops it.
|
|
3233
|
+
stopLockExtender = startLockExtender(job, recoveryWindowMs, lockExtendIntervalMs, `[${jobType}] job ${job.jobKey}`, logger);
|
|
3120
3234
|
let cwd;
|
|
3121
3235
|
let extraEnv;
|
|
3122
3236
|
let repoToken = null;
|
|
@@ -3163,7 +3277,8 @@ async function workAgent(req, flags) {
|
|
|
3163
3277
|
} catch { resultDir = null; resultFile = null; }
|
|
3164
3278
|
|
|
3165
3279
|
result = await runAgentJob(profile, job, {
|
|
3166
|
-
timeoutMs:
|
|
3280
|
+
timeoutMs: hardCapMs,
|
|
3281
|
+
idleTimeoutMs,
|
|
3167
3282
|
envelope,
|
|
3168
3283
|
sandbox,
|
|
3169
3284
|
image,
|
|
@@ -3232,7 +3347,7 @@ async function workAgent(req, flags) {
|
|
|
3232
3347
|
const resultKeys = Object.keys(resultVars);
|
|
3233
3348
|
if (resultKeys.length === 0) logger.warn(`[${jobType}] job ${job.jobKey}: agent returned no usable result vars — write a JSON object of result variables to $AGENT_RESULT_FILE (or print a "${RESULT_SENTINEL} {…}" line) so downstream gateways see status/summary/etc.`);
|
|
3234
3349
|
else logger.info(`[${jobType}] job ${job.jobKey}: merged agent result vars [${resultKeys.join(', ')}]`);
|
|
3235
|
-
return job.complete({
|
|
3350
|
+
return await job.complete({
|
|
3236
3351
|
...resultVars,
|
|
3237
3352
|
[AGENT_RESULT_KEY]: resultEnvelope,
|
|
3238
3353
|
output: result.stdout,
|
|
@@ -3249,11 +3364,15 @@ async function workAgent(req, flags) {
|
|
|
3249
3364
|
|| (result.stderr || '').trim() + (result.stderrTruncated && (result.stderr || '').trim() ? ' [stderr truncated]' : '')
|
|
3250
3365
|
|| (result.signal ? `terminated by signal ${result.signal}` : `exit code ${result.exitCode}`);
|
|
3251
3366
|
logger.warn(`[${jobType}] job ${job.jobKey} failed (${detail}); retries left ${retries}`);
|
|
3252
|
-
return job.fail({
|
|
3367
|
+
return await job.fail({
|
|
3253
3368
|
errorMessage: `agent "${profile.name}" failed: ${detail}`.slice(0, 2000),
|
|
3254
3369
|
retries,
|
|
3255
3370
|
variables: { [AGENT_RESULT_KEY]: resultEnvelope },
|
|
3256
3371
|
});
|
|
3372
|
+
} finally {
|
|
3373
|
+
stopLockExtender();
|
|
3374
|
+
recordJobEnd(job);
|
|
3375
|
+
}
|
|
3257
3376
|
},
|
|
3258
3377
|
});
|
|
3259
3378
|
|
|
@@ -3467,6 +3586,8 @@ const SUPERVISOR_MAX_FRAME_BYTES = 1 << 20; // 1 MiB
|
|
|
3467
3586
|
const WORK_FORWARD_FLAGS = {
|
|
3468
3587
|
'max-parallel': 'value',
|
|
3469
3588
|
'job-timeout': 'value',
|
|
3589
|
+
'recovery-window': 'value',
|
|
3590
|
+
'idle-timeout': 'value',
|
|
3470
3591
|
'lock-grace': 'value',
|
|
3471
3592
|
'poll-timeout': 'value',
|
|
3472
3593
|
sandbox: 'value',
|
|
@@ -3659,6 +3780,23 @@ function formatDuration(ms) {
|
|
|
3659
3780
|
function summarizeSupervisorWorker(w, now = Date.now()) {
|
|
3660
3781
|
const alive = isPidAlive(w.pid);
|
|
3661
3782
|
const uptimeMs = alive && w.startedAt ? Math.max(0, now - new Date(w.startedAt).getTime()) : 0;
|
|
3783
|
+
// Per-job activity (supervised workers only). Guard on pid so a stale marker
|
|
3784
|
+
// left by a previous incarnation can't show a dead job as in-flight.
|
|
3785
|
+
let activity = null; // { state: 'busy'|'idle', jobs: [{ key, type, sinceMs }] }
|
|
3786
|
+
if (alive) {
|
|
3787
|
+
const act = readWorkerActivity(w.id);
|
|
3788
|
+
if (act && act.pid === w.pid) {
|
|
3789
|
+
const jobs = Array.isArray(act.jobs)
|
|
3790
|
+
? act.jobs.map((j) => ({
|
|
3791
|
+
key: String(j.key),
|
|
3792
|
+
type: j.type ?? null,
|
|
3793
|
+
sinceMs: Number.isFinite(j.since) ? Math.max(0, now - j.since) : null,
|
|
3794
|
+
}))
|
|
3795
|
+
: [];
|
|
3796
|
+
activity = { state: jobs.length > 0 ? 'busy' : 'idle', jobs };
|
|
3797
|
+
}
|
|
3798
|
+
// No marker (or a stale-pid one): leave activity null → rendered as unknown.
|
|
3799
|
+
}
|
|
3662
3800
|
return {
|
|
3663
3801
|
id: w.id,
|
|
3664
3802
|
profile: w.profile,
|
|
@@ -3668,9 +3806,22 @@ function summarizeSupervisorWorker(w, now = Date.now()) {
|
|
|
3668
3806
|
uptimeMs,
|
|
3669
3807
|
lastExit: w.lastExit ?? null,
|
|
3670
3808
|
args: Array.isArray(w.args) ? w.args : [],
|
|
3809
|
+
activity,
|
|
3671
3810
|
};
|
|
3672
3811
|
}
|
|
3673
3812
|
|
|
3813
|
+
/** One-line JOB cell for a status row: the serviced job key, `idle`, or `-`. */
|
|
3814
|
+
function supervisorJobCell(w) {
|
|
3815
|
+
if (w.state !== 'running') return '-';
|
|
3816
|
+
const a = w.activity;
|
|
3817
|
+
if (!a) return '?'; // alive but not reporting (older worker / marker not yet written)
|
|
3818
|
+
if (a.state !== 'busy' || a.jobs.length === 0) return 'idle';
|
|
3819
|
+
const [first, ...rest] = a.jobs;
|
|
3820
|
+
const dur = first.sinceMs != null ? ` (${formatDuration(first.sinceMs)})` : '';
|
|
3821
|
+
const more = rest.length > 0 ? ` +${rest.length}` : '';
|
|
3822
|
+
return `${first.key}${more}${dur}`;
|
|
3823
|
+
}
|
|
3824
|
+
|
|
3674
3825
|
/** Render a supervisor status object as an aligned text table. */
|
|
3675
3826
|
function formatSupervisorStatus(status) {
|
|
3676
3827
|
const lines = [];
|
|
@@ -3690,13 +3841,14 @@ function formatSupervisorStatus(status) {
|
|
|
3690
3841
|
id: String(w.id),
|
|
3691
3842
|
profile: String(w.profile),
|
|
3692
3843
|
state: String(w.state),
|
|
3844
|
+
job: supervisorJobCell(w),
|
|
3693
3845
|
pid: w.pid ? String(w.pid) : '-',
|
|
3694
3846
|
restarts: String(w.restarts),
|
|
3695
3847
|
uptime: w.state === 'running' ? formatDuration(w.uptimeMs) : '-',
|
|
3696
3848
|
last: w.lastExit ? String(w.lastExit) : '-',
|
|
3697
3849
|
}));
|
|
3698
|
-
const head = { id: 'ID', profile: 'PROFILE', state: 'STATE', pid: 'PID', restarts: 'RESTARTS', uptime: 'UPTIME', last: 'LAST EXIT' };
|
|
3699
|
-
const cols = ['id', 'profile', 'state', 'pid', 'restarts', 'uptime', 'last'];
|
|
3850
|
+
const head = { id: 'ID', profile: 'PROFILE', state: 'STATE', job: 'JOB', pid: 'PID', restarts: 'RESTARTS', uptime: 'UPTIME', last: 'LAST EXIT' };
|
|
3851
|
+
const cols = ['id', 'profile', 'state', 'job', 'pid', 'restarts', 'uptime', 'last'];
|
|
3700
3852
|
const width = {};
|
|
3701
3853
|
for (const c of cols) width[c] = Math.max(head[c].length, ...rows.map((r) => r[c].length));
|
|
3702
3854
|
const fmt = (r) => ' ' + cols.map((c) => r[c].padEnd(width[c])).join(' ');
|
|
@@ -3855,10 +4007,17 @@ async function runSupervisorDaemon() {
|
|
|
3855
4007
|
const startWorker = (w) => {
|
|
3856
4008
|
let fd;
|
|
3857
4009
|
try { fd = openSync(w.logFile, 'a'); } catch { fd = 'ignore'; }
|
|
4010
|
+
// Clear any stale activity marker from a previous incarnation so a freshly
|
|
4011
|
+
// (re)started worker never briefly shows a dead job as in-flight.
|
|
4012
|
+
const activityFile = supervisorWorkerActivityFile(w.id);
|
|
4013
|
+
w.activityFile = activityFile;
|
|
4014
|
+
try { rmSync(activityFile, { force: true }); } catch { /* best effort */ }
|
|
3858
4015
|
// `--name w.id` makes the child's broker workerName match this worker's
|
|
3859
4016
|
// supervisor id, so the same profile launched twice is distinct end-to-end.
|
|
4017
|
+
// NANO_SUPERVISOR_ACTIVITY_FILE tells the child where to report per-job
|
|
4018
|
+
// activity for `supervisor status` (idle vs the job key it is servicing).
|
|
3860
4019
|
const child = spawn(exec, [entry, 'nano', 'work', w.profile, '--name', w.id, ...w.args], {
|
|
3861
|
-
env: process.env,
|
|
4020
|
+
env: { ...process.env, NANO_SUPERVISOR_ACTIVITY_FILE: activityFile },
|
|
3862
4021
|
stdio: ['ignore', fd, fd],
|
|
3863
4022
|
});
|
|
3864
4023
|
if (typeof fd === 'number') { try { closeSync(fd); } catch { /* dup'd into child */ } }
|
|
@@ -3880,6 +4039,8 @@ async function runSupervisorDaemon() {
|
|
|
3880
4039
|
settled = true;
|
|
3881
4040
|
w.pid = null;
|
|
3882
4041
|
w.lastExit = reason;
|
|
4042
|
+
// Drop the activity marker — a dead worker services no job.
|
|
4043
|
+
try { rmSync(w.activityFile || supervisorWorkerActivityFile(w.id), { force: true }); } catch { /* best effort */ }
|
|
3883
4044
|
const ranMs = Date.now() - (w.spawnedAt || Date.now());
|
|
3884
4045
|
if (ranMs >= SUPERVISOR_HEALTHY_UPTIME_MS) w.restarts = 0;
|
|
3885
4046
|
if (w.stopping || shuttingDown || !workers.has(w.id)) { persist(); return; }
|
|
@@ -3939,6 +4100,7 @@ async function runSupervisorDaemon() {
|
|
|
3939
4100
|
if (!workers.has(id)) return false;
|
|
3940
4101
|
await stopWorker(id);
|
|
3941
4102
|
workers.delete(id);
|
|
4103
|
+
try { rmSync(supervisorWorkerActivityFile(id), { force: true }); } catch { /* best effort */ }
|
|
3942
4104
|
dlog(`worker '${id}' removed`);
|
|
3943
4105
|
broadcast({ type: 'event', event: 'worker-remove', id });
|
|
3944
4106
|
persist();
|
|
@@ -5915,6 +6077,7 @@ export {
|
|
|
5915
6077
|
diskBudgetOk,
|
|
5916
6078
|
containerEngineAvailable,
|
|
5917
6079
|
runAgentJob,
|
|
6080
|
+
startLockExtender,
|
|
5918
6081
|
provisionRepo,
|
|
5919
6082
|
finalizeGit,
|
|
5920
6083
|
reconcileAgentPr,
|
|
@@ -5929,7 +6092,6 @@ export {
|
|
|
5929
6092
|
jobTypeMatrix,
|
|
5930
6093
|
diffJobTypes,
|
|
5931
6094
|
parseJobTypeFlags,
|
|
5932
|
-
deriveJobLockMs,
|
|
5933
6095
|
derivePollTimeoutMs,
|
|
5934
6096
|
AGENT_TASK_NS,
|
|
5935
6097
|
AGENT_RESULT_KEY,
|
|
@@ -5952,6 +6114,8 @@ export {
|
|
|
5952
6114
|
formatDuration,
|
|
5953
6115
|
summarizeSupervisorWorker,
|
|
5954
6116
|
formatSupervisorStatus,
|
|
6117
|
+
supervisorJobCell,
|
|
6118
|
+
supervisorWorkerActivityFile,
|
|
5955
6119
|
WORK_FORWARD_FLAGS,
|
|
5956
6120
|
runSupervisorDaemon,
|
|
5957
6121
|
startSupervisorDaemon,
|
|
@@ -6003,7 +6167,7 @@ export const metadata = {
|
|
|
6003
6167
|
{ command: 'c8ctl nano work coder --sandbox docker --image ghcr.io/acme/agent:1', description: 'Run jobs in isolated containers with disk-hygiene reaping' },
|
|
6004
6168
|
{ command: 'c8ctl nano supervisor start --worker reviewer --worker coder', description: 'Start a detached supervisor managing several workers from one terminal' },
|
|
6005
6169
|
{ command: 'c8ctl nano supervisor', description: 'Attach an interactive console to the supervisor (detach with Ctrl-D, leaving it running)' },
|
|
6006
|
-
{ command: 'c8ctl nano supervisor status', description: 'List supervised workers (pid, state, restarts, uptime) without the console' },
|
|
6170
|
+
{ command: 'c8ctl nano supervisor status', description: 'List supervised workers (pid, state, serviced job / idle, restarts, uptime) without the console' },
|
|
6007
6171
|
{ command: 'c8ctl nano supervisor add decider --max-parallel 2', description: 'Add a supervised worker (forwarding work flags) to the running supervisor' },
|
|
6008
6172
|
{ command: 'c8ctl nano supervisor restart reviewer', description: 'Restart a supervised worker by id or profile' },
|
|
6009
6173
|
{ command: 'c8ctl nano supervisor stop', description: 'Stop the supervisor daemon and all its workers' },
|
|
@@ -6065,8 +6229,10 @@ export const commands = {
|
|
|
6065
6229
|
stream: { type: 'boolean', description: 'work: tee each agent job\'s live stdout/stderr to this console, prefixed with the job type + key (spy/debug)' },
|
|
6066
6230
|
list: { type: 'boolean', description: 'hire: list existing agent profiles instead of creating one' },
|
|
6067
6231
|
'max-parallel': { type: 'string', description: 'work: max concurrent jobs per worker (default 1)' },
|
|
6068
|
-
'job-timeout': { type: 'string', description: 'work:
|
|
6069
|
-
'
|
|
6232
|
+
'job-timeout': { type: 'string', description: 'work: OPTIONAL absolute hard cap on total harness runtime per job in ms; the process is killed past this. Default 0 = unlimited (the broker lock is auto-managed — see --recovery-window / --idle-timeout).' },
|
|
6233
|
+
'recovery-window': { type: 'string', description: 'work: broker activation-lock window in ms, auto-refreshed while the agent runs; also the node-loss reclaim time (a dead/killed worker\'s job is re-activated within this). Default 300000.' },
|
|
6234
|
+
'idle-timeout': { type: 'string', description: 'work: max ms an agent may produce no stdout/stderr before it is killed as wedged (stops lock extension → job reclaimed). Default 300000.' },
|
|
6235
|
+
'lock-grace': { type: 'string', description: 'work: DEPRECATED and ignored — the broker lock is now auto-managed via --recovery-window.' },
|
|
6070
6236
|
'poll-timeout': { type: 'string', description: 'work: broker long-poll window in ms each activateJobs request is held open (fewer reconnects → fewer transient connect errors); default 30000, 0 = broker default, negative = return immediately' },
|
|
6071
6237
|
'job-type': { type: 'string', multiple: true, description: 'work: extra job type to service alongside the rank×capability matrix (repeatable)' },
|
|
6072
6238
|
worker: { type: 'string', multiple: true, description: 'supervisor start: profile to launch as a supervised worker (repeatable)' },
|
|
@@ -6221,7 +6387,7 @@ function printUsage() {
|
|
|
6221
6387
|
console.log(' c8ctl nano update [--check]');
|
|
6222
6388
|
console.log(' c8ctl nano hire [--name <n>] [--rank <r>] [--command <c>] [--arg <switch> ...] [--model <m>] [--capabilities <a,b>] [--sandbox none|docker|podman] [--image <ref>] [--env NAME=VALUE ...] [--list]');
|
|
6223
6389
|
console.log(' c8ctl nano assign <profileName> [<capability> ...] [--name <n>] [--capabilities <a,b>]');
|
|
6224
|
-
console.log(' c8ctl nano work <profileName> [--arg <switch> ...] [--max-parallel <n>] [--
|
|
6390
|
+
console.log(' c8ctl nano work <profileName> [--arg <switch> ...] [--max-parallel <n>] [--recovery-window <ms>] [--idle-timeout <ms>] [--job-timeout <ms>] [--poll-timeout <ms>] [--job-type <token> ...] [--sandbox none|docker|podman] [--image <ref>] [--env NAME=VALUE ...] [--secret-resolver host] [--min-free-mb <n>] [--clone-timeout <ms>] [--keep-runs] [--stream]');
|
|
6225
6391
|
console.log(' c8ctl nano supervisor [start|status|add|remove|restart|stop|logs|attach] ... (manage many workers from one terminal)');
|
|
6226
6392
|
console.log('');
|
|
6227
6393
|
console.log('Subcommands:');
|
|
@@ -6266,8 +6432,10 @@ function printUsage() {
|
|
|
6266
6432
|
console.log(' --list hire: list existing agent profiles instead of creating one');
|
|
6267
6433
|
console.log(' --max-parallel <n> work: max concurrent jobs per worker (default 1)');
|
|
6268
6434
|
console.log(' --job-type <token> work: extra job type to service alongside the rank×capability matrix (repeatable)');
|
|
6269
|
-
console.log(' --
|
|
6270
|
-
console.log(' --
|
|
6435
|
+
console.log(' --recovery-window <ms> work: broker activation-lock window, auto-refreshed while the agent runs; also the node-loss reclaim time (default 300000)');
|
|
6436
|
+
console.log(' --idle-timeout <ms> work: max silence (no agent stdout/stderr) before the harness is killed as wedged and the job reclaimed (default 300000)');
|
|
6437
|
+
console.log(' --job-timeout <ms> work: OPTIONAL absolute hard cap on total harness runtime; killed past this (default 0 = unlimited)');
|
|
6438
|
+
console.log(' --lock-grace <ms> work: DEPRECATED, ignored — the broker lock is now auto-managed via --recovery-window');
|
|
6271
6439
|
console.log(' --poll-timeout <ms> work: broker long-poll window per activateJobs request (default 30000; 0 = broker default, negative = immediate)');
|
|
6272
6440
|
console.log(' --secret-resolver <r> work: secret resolver for task secretRefs (host; default host)');
|
|
6273
6441
|
console.log(' --reap-age <ms> work: age before a finished agent container/workspace is reaped (default 3600000)');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c8ctl-plugin-nano",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.24.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",
|
|
@@ -47,12 +47,12 @@
|
|
|
47
47
|
"semantic-release": "^25.0.3"
|
|
48
48
|
},
|
|
49
49
|
"optionalDependencies": {
|
|
50
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.
|
|
51
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.
|
|
52
|
-
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.
|
|
53
|
-
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.
|
|
54
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.
|
|
55
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.
|
|
56
|
-
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.
|
|
50
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.24.0",
|
|
51
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.24.0",
|
|
52
|
+
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.24.0",
|
|
53
|
+
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.24.0",
|
|
54
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.24.0",
|
|
55
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.24.0",
|
|
56
|
+
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.24.0"
|
|
57
57
|
}
|
|
58
58
|
}
|