c8ctl-plugin-nano 1.23.0 → 1.24.1
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 +123 -65
- package/nanobpmn-binary.json +4 -4
- 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
|
@@ -1560,46 +1560,6 @@ function parseJobTypeFlags(input) {
|
|
|
1560
1560
|
return { jobTypes, errors };
|
|
1561
1561
|
}
|
|
1562
1562
|
|
|
1563
|
-
/**
|
|
1564
|
-
* The broker job-activation lock must strictly outlast the harness kill
|
|
1565
|
-
* deadline: the worker has to report the outcome (complete/fail) before the lock
|
|
1566
|
-
* lapses, or the broker re-activates the still-retryable job (a second agent
|
|
1567
|
-
* starts) and the stale `fail` is rejected 409 "job cannot be failed in the
|
|
1568
|
-
* current state". So lock = kill + grace. Non-finite or non-positive inputs
|
|
1569
|
-
* fall back to fixed defaults (5m kill / 2m grace). All inputs are coerced to
|
|
1570
|
-
* safe positive integers (positive fractional values floor to at least 1) and
|
|
1571
|
-
* grace is capped so a positive kill always fits and `kill + grace` stays within
|
|
1572
|
-
* the safe-integer range, so the invariant lock > kill holds strictly for every
|
|
1573
|
-
* accepted input — including values at or beyond 2^53 where float addition
|
|
1574
|
-
* would otherwise round `kill + grace` back down to `kill`.
|
|
1575
|
-
*
|
|
1576
|
-
* Returns BOTH derived deadlines from this single computation so the caller
|
|
1577
|
-
* never re-derives (and drifts): `killMs` is the *clamped* harness kill deadline
|
|
1578
|
-
* the caller must actually enforce, and `lockMs` is the broker activation lock.
|
|
1579
|
-
* The caller must use `killMs` — not the raw input — for the harness timeout, or
|
|
1580
|
-
* the lock > kill invariant breaks for large inputs (the raw input can exceed
|
|
1581
|
-
* the clamped `killMs`, and thus reach or exceed `lockMs`).
|
|
1582
|
-
*
|
|
1583
|
-
* @returns {{ killMs: number, lockMs: number }}
|
|
1584
|
-
*/
|
|
1585
|
-
function deriveJobLockMs(jobTimeoutMs, lockGraceMs) {
|
|
1586
|
-
const MAX = Number.MAX_SAFE_INTEGER;
|
|
1587
|
-
const toSafeMs = (value, fallback) => {
|
|
1588
|
-
// Floor positive fractional values to at least 1 so a sub-millisecond input
|
|
1589
|
-
// (e.g. 0.5) never collapses to a non-positive value.
|
|
1590
|
-
const n = Number.isFinite(value) && value > 0 ? Math.max(1, Math.floor(value)) : fallback;
|
|
1591
|
-
return Math.min(n, MAX);
|
|
1592
|
-
};
|
|
1593
|
-
// Cap grace to MAX - 1 so there is always room for a positive kill while
|
|
1594
|
-
// keeping kill + grace within the safe-integer range.
|
|
1595
|
-
const grace = Math.min(toSafeMs(lockGraceMs, 2 * 60_000), MAX - 1);
|
|
1596
|
-
// Cap kill so kill + grace stays a safe integer and floor it at 1 so the
|
|
1597
|
-
// internal kill is always positive; the sum is then exact and strictly
|
|
1598
|
-
// greater than kill (never equal to it via float rounding).
|
|
1599
|
-
const killMs = Math.max(1, Math.min(toSafeMs(jobTimeoutMs, 5 * 60_000), MAX - grace));
|
|
1600
|
-
return { killMs, lockMs: killMs + grace };
|
|
1601
|
-
}
|
|
1602
|
-
|
|
1603
1563
|
/**
|
|
1604
1564
|
* Resolve the broker long-poll window (ms) each `activateJobs` request is held
|
|
1605
1565
|
* open before returning empty. A longer window keeps an idle worker on ONE open
|
|
@@ -2626,7 +2586,7 @@ const MAX_CAPTURE_BYTES = 1_048_576; // 1 MiB per stream
|
|
|
2626
2586
|
// Spawn a child, pipe `stdinData`, capture byte-capped stdout/stderr, enforce a
|
|
2627
2587
|
// timeout (invoking `onTimeout(child)` to tear the child down), and resolve to a
|
|
2628
2588
|
// uniform result. Used by both the host and container executors.
|
|
2629
|
-
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 }) {
|
|
2630
2590
|
return new Promise((resolve) => {
|
|
2631
2591
|
let child;
|
|
2632
2592
|
const stdoutChunks = [];
|
|
@@ -2637,6 +2597,7 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
|
|
|
2637
2597
|
let stderrTruncated = false;
|
|
2638
2598
|
let settled = false;
|
|
2639
2599
|
let timer = null;
|
|
2600
|
+
let idleTimer = null;
|
|
2640
2601
|
|
|
2641
2602
|
// Live "spy" tee (--stream): mirror the child's output line-by-line to a
|
|
2642
2603
|
// caller-supplied emitter (the worker routes these through c8ctl's
|
|
@@ -2673,6 +2634,7 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
|
|
|
2673
2634
|
if (settled) return;
|
|
2674
2635
|
settled = true;
|
|
2675
2636
|
if (timer) clearTimeout(timer);
|
|
2637
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
2676
2638
|
if (teeOut) teeOut('', true);
|
|
2677
2639
|
if (teeErr) teeErr('', true);
|
|
2678
2640
|
resolve(result);
|
|
@@ -2692,7 +2654,25 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
|
|
|
2692
2654
|
}, timeoutMs)
|
|
2693
2655
|
: null;
|
|
2694
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
|
+
|
|
2695
2674
|
child.stdout.on('data', (d) => {
|
|
2675
|
+
armIdle();
|
|
2696
2676
|
const buf = Buffer.isBuffer(d) ? d : Buffer.from(d);
|
|
2697
2677
|
if (teeOut) teeOut(buf.toString('utf8'), false);
|
|
2698
2678
|
const remaining = MAX_CAPTURE_BYTES - stdoutBytes;
|
|
@@ -2701,6 +2681,7 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
|
|
|
2701
2681
|
else { stdoutChunks.push(buf); stdoutBytes += buf.length; }
|
|
2702
2682
|
});
|
|
2703
2683
|
child.stderr.on('data', (d) => {
|
|
2684
|
+
armIdle();
|
|
2704
2685
|
const buf = Buffer.isBuffer(d) ? d : Buffer.from(d);
|
|
2705
2686
|
if (teeErr) teeErr(buf.toString('utf8'), false);
|
|
2706
2687
|
const remaining = MAX_CAPTURE_BYTES - stderrBytes;
|
|
@@ -2756,6 +2737,43 @@ function baseAgentEnv(profile, job) {
|
|
|
2756
2737
|
};
|
|
2757
2738
|
}
|
|
2758
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
|
+
|
|
2759
2777
|
/**
|
|
2760
2778
|
* Run a single activated job through the profile's CLI command (one-shot),
|
|
2761
2779
|
* dispatching on the profile's sandbox:
|
|
@@ -2766,7 +2784,7 @@ function baseAgentEnv(profile, job) {
|
|
|
2766
2784
|
* Both paths resolve to the same result contract.
|
|
2767
2785
|
*/
|
|
2768
2786
|
function runAgentJob(profile, job, opts = {}) {
|
|
2769
|
-
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;
|
|
2770
2788
|
const payload = JSON.stringify(buildAgentPayload(profile, job, envelope));
|
|
2771
2789
|
const agentEnv = baseAgentEnv(profile, job);
|
|
2772
2790
|
// The harness command line: the profile command plus its structured switches
|
|
@@ -2801,6 +2819,7 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
2801
2819
|
env: { ...process.env, ...staticEnv, ...secretEnv, ...agentEnv, ...extraEnv, ...resultEnv },
|
|
2802
2820
|
stdinData: payload,
|
|
2803
2821
|
timeoutMs,
|
|
2822
|
+
idleTimeoutMs,
|
|
2804
2823
|
onTimeout: (child) => killTree(child),
|
|
2805
2824
|
stream,
|
|
2806
2825
|
streamPrefix,
|
|
@@ -2856,6 +2875,7 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
2856
2875
|
env: { ...process.env, ...staticEnv, ...secretEnv, ...agentEnv, ...extraEnv, ...resultEnv },
|
|
2857
2876
|
stdinData: payload,
|
|
2858
2877
|
timeoutMs,
|
|
2878
|
+
idleTimeoutMs,
|
|
2859
2879
|
stream,
|
|
2860
2880
|
streamPrefix,
|
|
2861
2881
|
onStreamOut,
|
|
@@ -2971,18 +2991,36 @@ async function workAgent(req, flags) {
|
|
|
2971
2991
|
return Number.isFinite(n) && n > 0 ? n : dflt;
|
|
2972
2992
|
};
|
|
2973
2993
|
const maxParallelJobs = intFlag(flags?.['max-parallel'], 1);
|
|
2974
|
-
|
|
2975
|
-
//
|
|
2976
|
-
//
|
|
2977
|
-
//
|
|
2978
|
-
//
|
|
2979
|
-
//
|
|
2980
|
-
//
|
|
2981
|
-
|
|
2982
|
-
//
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
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
|
+
);
|
|
2986
3024
|
// Broker long-poll window: how long each activateJobs request is held open
|
|
2987
3025
|
// waiting for work. 30s default so idle workers hold one connection open ~30s
|
|
2988
3026
|
// rather than reconnecting every few seconds — fewer reconnects, fewer chances
|
|
@@ -3096,7 +3134,7 @@ async function workAgent(req, flags) {
|
|
|
3096
3134
|
if (profileEnvKeys.length > 0) logger.info(` harness env: ${profileEnvKeys.join(', ')}`);
|
|
3097
3135
|
const extraNote = extraJobTypes.length > 0 ? ` (${extraJobTypes.length} via --job-type)` : '';
|
|
3098
3136
|
logger.info(` listening on ${jobTypes.length} job type(s)${extraNote}: ${jobTypes.join(' ')}`);
|
|
3099
|
-
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`);
|
|
3100
3138
|
logger.info('Polling for work — press Ctrl-C to stop.');
|
|
3101
3139
|
|
|
3102
3140
|
// When launched under the supervisor, report per-job activity — which job(s)
|
|
@@ -3141,10 +3179,16 @@ async function workAgent(req, flags) {
|
|
|
3141
3179
|
jobType,
|
|
3142
3180
|
workerName: `${workerName}:${jobType}`,
|
|
3143
3181
|
maxParallelJobs,
|
|
3144
|
-
jobTimeoutMs:
|
|
3182
|
+
jobTimeoutMs: recoveryWindowMs,
|
|
3145
3183
|
pollTimeoutMs,
|
|
3146
3184
|
jobHandler: async (job) => {
|
|
3147
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 = () => {};
|
|
3148
3192
|
try {
|
|
3149
3193
|
logger.info(`[${jobType}] job ${job.jobKey} (instance ${job.processInstanceKey ?? '-'}) → ${buildAgentCommandLine(profile.command, effectiveArgs)}`);
|
|
3150
3194
|
|
|
@@ -3181,6 +3225,12 @@ async function workAgent(req, flags) {
|
|
|
3181
3225
|
const hasRepo = !isContainer && !!envelope.repository?.url;
|
|
3182
3226
|
let runDir = null;
|
|
3183
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);
|
|
3184
3234
|
let cwd;
|
|
3185
3235
|
let extraEnv;
|
|
3186
3236
|
let repoToken = null;
|
|
@@ -3227,7 +3277,8 @@ async function workAgent(req, flags) {
|
|
|
3227
3277
|
} catch { resultDir = null; resultFile = null; }
|
|
3228
3278
|
|
|
3229
3279
|
result = await runAgentJob(profile, job, {
|
|
3230
|
-
timeoutMs:
|
|
3280
|
+
timeoutMs: hardCapMs,
|
|
3281
|
+
idleTimeoutMs,
|
|
3231
3282
|
envelope,
|
|
3232
3283
|
sandbox,
|
|
3233
3284
|
image,
|
|
@@ -3296,7 +3347,7 @@ async function workAgent(req, flags) {
|
|
|
3296
3347
|
const resultKeys = Object.keys(resultVars);
|
|
3297
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.`);
|
|
3298
3349
|
else logger.info(`[${jobType}] job ${job.jobKey}: merged agent result vars [${resultKeys.join(', ')}]`);
|
|
3299
|
-
return job.complete({
|
|
3350
|
+
return await job.complete({
|
|
3300
3351
|
...resultVars,
|
|
3301
3352
|
[AGENT_RESULT_KEY]: resultEnvelope,
|
|
3302
3353
|
output: result.stdout,
|
|
@@ -3313,12 +3364,13 @@ async function workAgent(req, flags) {
|
|
|
3313
3364
|
|| (result.stderr || '').trim() + (result.stderrTruncated && (result.stderr || '').trim() ? ' [stderr truncated]' : '')
|
|
3314
3365
|
|| (result.signal ? `terminated by signal ${result.signal}` : `exit code ${result.exitCode}`);
|
|
3315
3366
|
logger.warn(`[${jobType}] job ${job.jobKey} failed (${detail}); retries left ${retries}`);
|
|
3316
|
-
return job.fail({
|
|
3367
|
+
return await job.fail({
|
|
3317
3368
|
errorMessage: `agent "${profile.name}" failed: ${detail}`.slice(0, 2000),
|
|
3318
3369
|
retries,
|
|
3319
3370
|
variables: { [AGENT_RESULT_KEY]: resultEnvelope },
|
|
3320
3371
|
});
|
|
3321
3372
|
} finally {
|
|
3373
|
+
stopLockExtender();
|
|
3322
3374
|
recordJobEnd(job);
|
|
3323
3375
|
}
|
|
3324
3376
|
},
|
|
@@ -3534,6 +3586,8 @@ const SUPERVISOR_MAX_FRAME_BYTES = 1 << 20; // 1 MiB
|
|
|
3534
3586
|
const WORK_FORWARD_FLAGS = {
|
|
3535
3587
|
'max-parallel': 'value',
|
|
3536
3588
|
'job-timeout': 'value',
|
|
3589
|
+
'recovery-window': 'value',
|
|
3590
|
+
'idle-timeout': 'value',
|
|
3537
3591
|
'lock-grace': 'value',
|
|
3538
3592
|
'poll-timeout': 'value',
|
|
3539
3593
|
sandbox: 'value',
|
|
@@ -6023,6 +6077,7 @@ export {
|
|
|
6023
6077
|
diskBudgetOk,
|
|
6024
6078
|
containerEngineAvailable,
|
|
6025
6079
|
runAgentJob,
|
|
6080
|
+
startLockExtender,
|
|
6026
6081
|
provisionRepo,
|
|
6027
6082
|
finalizeGit,
|
|
6028
6083
|
reconcileAgentPr,
|
|
@@ -6037,7 +6092,6 @@ export {
|
|
|
6037
6092
|
jobTypeMatrix,
|
|
6038
6093
|
diffJobTypes,
|
|
6039
6094
|
parseJobTypeFlags,
|
|
6040
|
-
deriveJobLockMs,
|
|
6041
6095
|
derivePollTimeoutMs,
|
|
6042
6096
|
AGENT_TASK_NS,
|
|
6043
6097
|
AGENT_RESULT_KEY,
|
|
@@ -6175,8 +6229,10 @@ export const commands = {
|
|
|
6175
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)' },
|
|
6176
6230
|
list: { type: 'boolean', description: 'hire: list existing agent profiles instead of creating one' },
|
|
6177
6231
|
'max-parallel': { type: 'string', description: 'work: max concurrent jobs per worker (default 1)' },
|
|
6178
|
-
'job-timeout': { type: 'string', description: 'work:
|
|
6179
|
-
'
|
|
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.' },
|
|
6180
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' },
|
|
6181
6237
|
'job-type': { type: 'string', multiple: true, description: 'work: extra job type to service alongside the rank×capability matrix (repeatable)' },
|
|
6182
6238
|
worker: { type: 'string', multiple: true, description: 'supervisor start: profile to launch as a supervised worker (repeatable)' },
|
|
@@ -6331,7 +6387,7 @@ function printUsage() {
|
|
|
6331
6387
|
console.log(' c8ctl nano update [--check]');
|
|
6332
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]');
|
|
6333
6389
|
console.log(' c8ctl nano assign <profileName> [<capability> ...] [--name <n>] [--capabilities <a,b>]');
|
|
6334
|
-
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]');
|
|
6335
6391
|
console.log(' c8ctl nano supervisor [start|status|add|remove|restart|stop|logs|attach] ... (manage many workers from one terminal)');
|
|
6336
6392
|
console.log('');
|
|
6337
6393
|
console.log('Subcommands:');
|
|
@@ -6376,8 +6432,10 @@ function printUsage() {
|
|
|
6376
6432
|
console.log(' --list hire: list existing agent profiles instead of creating one');
|
|
6377
6433
|
console.log(' --max-parallel <n> work: max concurrent jobs per worker (default 1)');
|
|
6378
6434
|
console.log(' --job-type <token> work: extra job type to service alongside the rank×capability matrix (repeatable)');
|
|
6379
|
-
console.log(' --
|
|
6380
|
-
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');
|
|
6381
6439
|
console.log(' --poll-timeout <ms> work: broker long-poll window per activateJobs request (default 30000; 0 = broker default, negative = immediate)');
|
|
6382
6440
|
console.log(' --secret-resolver <r> work: secret resolver for task secretRefs (host; default host)');
|
|
6383
6441
|
console.log(' --reap-age <ms> work: age before a finished agent container/workspace is reaped (default 3600000)');
|
package/nanobpmn-binary.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.0.
|
|
3
|
-
"commit": "
|
|
4
|
-
"updated": "2026-08-
|
|
5
|
-
}
|
|
2
|
+
"version": "0.0.13",
|
|
3
|
+
"commit": "d58b3d6",
|
|
4
|
+
"updated": "2026-08-09T23:09:25Z"
|
|
5
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c8ctl-plugin-nano",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.24.1",
|
|
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.1",
|
|
51
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.24.1",
|
|
52
|
+
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.24.1",
|
|
53
|
+
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.24.1",
|
|
54
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.24.1",
|
|
55
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.24.1",
|
|
56
|
+
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.24.1"
|
|
57
57
|
}
|
|
58
58
|
}
|