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.
Files changed (3) hide show
  1. package/README.md +33 -18
  2. package/c8ctl-plugin.js +237 -69
  3. 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 --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
@@ -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
- const jobTimeoutMs = intFlag(flags?.['job-timeout'], 5 * 60_000);
2947
- // The broker's job-activation lock MUST outlast the harness kill deadline: the
2948
- // worker has to report the outcome (complete/fail) before the lock lapses,
2949
- // otherwise the broker re-activates the still-retryable job (a second agent
2950
- // starts) and the stale `fail` is rejected with a 409 "job cannot be failed in
2951
- // the current state". So lock = harness kill + grace. Raising --job-timeout
2952
- // alone does not help it moves both coupled deadlines together.
2953
- const lockGraceMs = intFlag(flags?.['lock-grace'], 2 * 60_000);
2954
- // deriveJobLockMs is the single source of truth for both deadlines: the harness
2955
- // MUST enforce the clamped `jobKillMs` (not the raw --job-timeout), or a very
2956
- // large --job-timeout would outlive the broker lock and re-break the invariant.
2957
- 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
+ );
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}; 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`);
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: jobLockMs,
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: jobKillMs,
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: max harness runtime per job in ms; the spawned process is killed past this (default 300000)' },
6069
- '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.' },
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>] [--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]');
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(' --job-timeout <ms> work: max harness runtime per job in ms (default 300000)');
6270
- 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');
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.22.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.22.1",
51
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.22.1",
52
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.22.1",
53
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.22.1",
54
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.22.1",
55
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.22.1",
56
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.22.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
  }