c8ctl-plugin-nano 1.18.0 → 1.18.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
@@ -207,6 +207,16 @@ the job with a decremented retry count, and a job that outlives `--job-timeout`
207
207
  is killed. Profiles are stored in the plugin's `config.json` (see `c8ctl nano
208
208
  config`).
209
209
 
210
+ > **Activation lock vs. kill deadline.** `--job-timeout` is the harness *kill*
211
+ > deadline. The broker's job-activation lock is derived as `--job-timeout +
212
+ > --lock-grace` (grace defaults to `120000`ms) so it strictly outlasts the kill:
213
+ > the worker always reports the outcome (complete/fail) before the lock lapses.
214
+ > Without that gap a lock expiring exactly as the harness dies lets the broker
215
+ > re-activate the still-retryable job (a second agent starts) and the stale
216
+ > `fail` is rejected with a 409 "job cannot be failed in the current state".
217
+ > Raising `--job-timeout` alone does **not** fix this — it moves both coupled
218
+ > deadlines together; widen `--lock-grace` (or keep the default) instead.
219
+
210
220
  > **Trust boundary.** The profile `command` is run through a shell so you can
211
221
  > write a full invocation (args, pipes, multi-word commands). It is
212
222
  > **operator-authored** — only what you put in your own `config.json` is
package/c8ctl-plugin.js CHANGED
@@ -1478,6 +1478,46 @@ function parseJobTypeFlags(input) {
1478
1478
  return { jobTypes, errors };
1479
1479
  }
1480
1480
 
1481
+ /**
1482
+ * The broker job-activation lock must strictly outlast the harness kill
1483
+ * deadline: the worker has to report the outcome (complete/fail) before the lock
1484
+ * lapses, or the broker re-activates the still-retryable job (a second agent
1485
+ * starts) and the stale `fail` is rejected 409 "job cannot be failed in the
1486
+ * current state". So lock = kill + grace. Non-finite or non-positive inputs
1487
+ * fall back to fixed defaults (5m kill / 2m grace). All inputs are coerced to
1488
+ * safe positive integers (positive fractional values floor to at least 1) and
1489
+ * grace is capped so a positive kill always fits and `kill + grace` stays within
1490
+ * the safe-integer range, so the invariant lock > kill holds strictly for every
1491
+ * accepted input — including values at or beyond 2^53 where float addition
1492
+ * would otherwise round `kill + grace` back down to `kill`.
1493
+ *
1494
+ * Returns BOTH derived deadlines from this single computation so the caller
1495
+ * never re-derives (and drifts): `killMs` is the *clamped* harness kill deadline
1496
+ * the caller must actually enforce, and `lockMs` is the broker activation lock.
1497
+ * The caller must use `killMs` — not the raw input — for the harness timeout, or
1498
+ * the lock > kill invariant breaks for large inputs (the raw input can exceed
1499
+ * the clamped `killMs`, and thus reach or exceed `lockMs`).
1500
+ *
1501
+ * @returns {{ killMs: number, lockMs: number }}
1502
+ */
1503
+ function deriveJobLockMs(jobTimeoutMs, lockGraceMs) {
1504
+ const MAX = Number.MAX_SAFE_INTEGER;
1505
+ const toSafeMs = (value, fallback) => {
1506
+ // Floor positive fractional values to at least 1 so a sub-millisecond input
1507
+ // (e.g. 0.5) never collapses to a non-positive value.
1508
+ const n = Number.isFinite(value) && value > 0 ? Math.max(1, Math.floor(value)) : fallback;
1509
+ return Math.min(n, MAX);
1510
+ };
1511
+ // Cap grace to MAX - 1 so there is always room for a positive kill while
1512
+ // keeping kill + grace within the safe-integer range.
1513
+ const grace = Math.min(toSafeMs(lockGraceMs, 2 * 60_000), MAX - 1);
1514
+ // Cap kill so kill + grace stays a safe integer and floor it at 1 so the
1515
+ // internal kill is always positive; the sum is then exact and strictly
1516
+ // greater than kill (never equal to it via float rounding).
1517
+ const killMs = Math.max(1, Math.min(toSafeMs(jobTimeoutMs, 5 * 60_000), MAX - grace));
1518
+ return { killMs, lockMs: killMs + grace };
1519
+ }
1520
+
1481
1521
  /** A profile name must be a safe, filesystem/token-friendly slug. */
1482
1522
  function isValidProfileName(name) {
1483
1523
  return typeof name === 'string' && /^[a-z0-9][a-z0-9._-]*$/i.test(name);
@@ -2679,6 +2719,17 @@ async function workAgent(req, flags) {
2679
2719
  };
2680
2720
  const maxParallelJobs = intFlag(flags?.['max-parallel'], 1);
2681
2721
  const jobTimeoutMs = intFlag(flags?.['job-timeout'], 5 * 60_000);
2722
+ // The broker's job-activation lock MUST outlast the harness kill deadline: the
2723
+ // worker has to report the outcome (complete/fail) before the lock lapses,
2724
+ // otherwise the broker re-activates the still-retryable job (a second agent
2725
+ // starts) and the stale `fail` is rejected with a 409 "job cannot be failed in
2726
+ // the current state". So lock = harness kill + grace. Raising --job-timeout
2727
+ // alone does not help — it moves both coupled deadlines together.
2728
+ const lockGraceMs = intFlag(flags?.['lock-grace'], 2 * 60_000);
2729
+ // deriveJobLockMs is the single source of truth for both deadlines: the harness
2730
+ // MUST enforce the clamped `jobKillMs` (not the raw --job-timeout), or a very
2731
+ // large --job-timeout would outlive the broker lock and re-break the invariant.
2732
+ const { killMs: jobKillMs, lockMs: jobLockMs } = deriveJobLockMs(jobTimeoutMs, lockGraceMs);
2682
2733
 
2683
2734
  // Sandbox: flag overrides the stored profile default. `none` runs on the host
2684
2735
  // (legacy); `docker`/`podman` run each job in a throwaway labelled container.
@@ -2786,7 +2837,7 @@ async function workAgent(req, flags) {
2786
2837
  if (profileEnvKeys.length > 0) logger.info(` harness env: ${profileEnvKeys.join(', ')}`);
2787
2838
  const extraNote = extraJobTypes.length > 0 ? ` (${extraJobTypes.length} via --job-type)` : '';
2788
2839
  logger.info(` listening on ${jobTypes.length} job type(s)${extraNote}: ${jobTypes.join(' ')}`);
2789
- logger.info(` max parallel: ${maxParallelJobs}; job timeout: ${jobTimeoutMs}ms`);
2840
+ logger.info(` max parallel: ${maxParallelJobs}; job timeout: ${jobKillMs}ms; activation lock: ${jobLockMs}ms`);
2790
2841
  logger.info('Polling for work — press Ctrl-C to stop.');
2791
2842
 
2792
2843
  const workers = jobTypes.map((jobType) =>
@@ -2794,7 +2845,7 @@ async function workAgent(req, flags) {
2794
2845
  jobType,
2795
2846
  workerName: `${name}:${jobType}`,
2796
2847
  maxParallelJobs,
2797
- jobTimeoutMs,
2848
+ jobTimeoutMs: jobLockMs,
2798
2849
  jobHandler: async (job) => {
2799
2850
  logger.info(`[${jobType}] job ${job.jobKey} (instance ${job.processInstanceKey ?? '-'}) → ${buildAgentCommandLine(profile.command, effectiveArgs)}`);
2800
2851
 
@@ -2877,7 +2928,7 @@ async function workAgent(req, flags) {
2877
2928
  } catch { resultDir = null; resultFile = null; }
2878
2929
 
2879
2930
  result = await runAgentJob(profile, job, {
2880
- timeoutMs: jobTimeoutMs,
2931
+ timeoutMs: jobKillMs,
2881
2932
  envelope,
2882
2933
  sandbox,
2883
2934
  image,
@@ -4358,6 +4409,7 @@ export {
4358
4409
  normalizeStoredProfile,
4359
4410
  jobTypeMatrix,
4360
4411
  parseJobTypeFlags,
4412
+ deriveJobLockMs,
4361
4413
  AGENT_TASK_NS,
4362
4414
  AGENT_RESULT_KEY,
4363
4415
  RESULT_SENTINEL,
@@ -4462,6 +4514,7 @@ export const commands = {
4462
4514
  list: { type: 'boolean', description: 'hire: list existing agent profiles instead of creating one' },
4463
4515
  'max-parallel': { type: 'string', description: 'work: max concurrent jobs per worker (default 1)' },
4464
4516
  'job-timeout': { type: 'string', description: 'work: max harness runtime per job in ms; the spawned process is killed past this (default 300000)' },
4517
+ '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)' },
4465
4518
  'job-type': { type: 'string', multiple: true, description: 'work: extra job type to service alongside the rank×capability matrix (repeatable)' },
4466
4519
  },
4467
4520
  handler: async (args, flags) => {
@@ -4606,7 +4659,7 @@ function printUsage() {
4606
4659
  console.log(' c8ctl nano config');
4607
4660
  console.log(' c8ctl nano update [--check]');
4608
4661
  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]');
4609
- console.log(' c8ctl nano work <profileName> [--arg <switch> ...] [--max-parallel <n>] [--job-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]');
4662
+ console.log(' c8ctl nano work <profileName> [--arg <switch> ...] [--max-parallel <n>] [--job-timeout <ms>] [--lock-grace <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]');
4610
4663
  console.log('');
4611
4664
  console.log('Subcommands:');
4612
4665
  console.log(' start Spawn an N-node local cluster wired to talk to each other on localhost');
@@ -4649,6 +4702,7 @@ function printUsage() {
4649
4702
  console.log(' --max-parallel <n> work: max concurrent jobs per worker (default 1)');
4650
4703
  console.log(' --job-type <token> work: extra job type to service alongside the rank×capability matrix (repeatable)');
4651
4704
  console.log(' --job-timeout <ms> work: max harness runtime per job in ms (default 300000)');
4705
+ console.log(' --lock-grace <ms> work: extra ms over --job-timeout for the broker activation lock (default 120000)');
4652
4706
  console.log(' --secret-resolver <r> work: secret resolver for task secretRefs (host; default host)');
4653
4707
  console.log(' --reap-age <ms> work: age before a finished agent container/workspace is reaped (default 3600000)');
4654
4708
  console.log(' --reap-interval <ms> work: how often to sweep finished agent containers/workspaces (default 300000)');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.18.0",
3
+ "version": "1.18.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",
@@ -49,12 +49,12 @@
49
49
  "semantic-release": "^25.0.3"
50
50
  },
51
51
  "optionalDependencies": {
52
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.18.0",
53
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.18.0",
54
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.18.0",
55
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.18.0",
56
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.18.0",
57
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.18.0",
58
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.18.0"
52
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.18.1",
53
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.18.1",
54
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.18.1",
55
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.18.1",
56
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.18.1",
57
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.18.1",
58
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.18.1"
59
59
  }
60
60
  }