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 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 --job-timeout 600000
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, and a job that outlives `--job-timeout`
256
- is killed. Profiles are stored in the plugin's `config.json` (see `c8ctl nano
257
- config`).
258
-
259
- > **Activation lock vs. kill deadline.** `--job-timeout` is the harness *kill*
260
- > deadline. The broker's job-activation lock is derived as `--job-timeout +
261
- > --lock-grace` (grace defaults to `120000`ms) so it strictly outlasts the kill:
262
- > the worker always reports the outcome (complete/fail) before the lock lapses.
263
- > Without that gap a lock expiring exactly as the harness dies lets the broker
264
- > re-activate the still-retryable job (a second agent starts) and the stale
265
- > `fail` is rejected with a 409 "job cannot be failed in the current state".
266
- > Raising `--job-timeout` alone does **not** fix this it moves both coupled
267
- > deadlines together; widen `--lock-grace` (or keep the default) instead.
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` is force-removed. The envelope is piped on
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
- `--job-timeout`, `--lock-grace`, `--poll-timeout`, `--sandbox`/`--image`,
484
- `--job-type`, `--env`, `--arg`, …); they are forwarded verbatim to the spawned
485
- child, so a supervised worker is byte-identical to a hand-run `nano work`. In the
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
- const jobTimeoutMs = intFlag(flags?.['job-timeout'], 5 * 60_000);
2975
- // The broker's job-activation lock MUST outlast the harness kill deadline: the
2976
- // worker has to report the outcome (complete/fail) before the lock lapses,
2977
- // otherwise the broker re-activates the still-retryable job (a second agent
2978
- // starts) and the stale `fail` is rejected with a 409 "job cannot be failed in
2979
- // the current state". So lock = harness kill + grace. Raising --job-timeout
2980
- // alone does not help it moves both coupled deadlines together.
2981
- const lockGraceMs = intFlag(flags?.['lock-grace'], 2 * 60_000);
2982
- // deriveJobLockMs is the single source of truth for both deadlines: the harness
2983
- // MUST enforce the clamped `jobKillMs` (not the raw --job-timeout), or a very
2984
- // large --job-timeout would outlive the broker lock and re-break the invariant.
2985
- const { killMs: jobKillMs, lockMs: jobLockMs } = deriveJobLockMs(jobTimeoutMs, lockGraceMs);
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}; job timeout: ${jobKillMs}ms; activation lock: ${jobLockMs}ms; poll timeout: ${pollTimeoutMs}ms`);
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: jobLockMs,
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: jobKillMs,
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: max harness runtime per job in ms; the spawned process is killed past this (default 300000)' },
6179
- 'lock-grace': { type: 'string', description: 'work: extra ms added to --job-timeout to derive the broker activation lock, so the worker reports before the lock lapses (default 120000)' },
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>] [--job-timeout <ms>] [--lock-grace <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]');
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(' --job-timeout <ms> work: max harness runtime per job in ms (default 300000)');
6380
- console.log(' --lock-grace <ms> work: extra ms over --job-timeout for the broker activation lock (default 120000)');
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)');
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.0.12",
3
- "commit": "39f4a33",
4
- "updated": "2026-08-07T05:19:36Z"
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.23.0",
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.23.0",
51
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.23.0",
52
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.23.0",
53
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.23.0",
54
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.23.0",
55
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.23.0",
56
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.23.0"
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
  }