c8ctl-plugin-nano 1.59.1 → 1.60.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/c8ctl-plugin.js CHANGED
@@ -3323,6 +3323,23 @@ async function createAgenticEndpoint(opts) {
3323
3323
  // @param {typeof fetch} [opts.fetchImpl] injected fetch (tests)
3324
3324
  // @param {NodeJS.ProcessEnv} [opts.env]
3325
3325
  // @returns {Promise<{ deps: object, registry: object, settle: { complete: Function, fail: Function }, makeSupervisor: Function, Effect: object, Fiber: object }>}
3326
+ // Bind a settle seam to a SINGLE activation's lease token, captured from `job`
3327
+ // in closure scope. A settlement is thereby fenced with the exact activation
3328
+ // that ran — NOT a token re-read from a shared activeJobs map at settle time: a
3329
+ // same-key reactivation can overwrite that entry while an interrupted runner is
3330
+ // still unwinding, so a map lookup could fence the completion with the WRONG
3331
+ // (newer) token and clobber the new activation — the very lease bypass this
3332
+ // fence exists to prevent. `job.leaseToken` in the returned closures cannot drift.
3333
+ // @param {{ complete: Function, fail: Function }} settle the raw settle seam
3334
+ // @param {{ jobKey: string, leaseToken?: string }} job the activation whose lease fences the settlement
3335
+ // @returns {{ complete: (variables:any)=>any, fail: (opts?:object)=>any }}
3336
+ function bindJobSettle(settle, job) {
3337
+ return {
3338
+ complete: (variables) => settle.complete(job.jobKey, variables, job.leaseToken),
3339
+ fail: (opts2) => settle.fail(job.jobKey, { ...(opts2 || {}), leaseToken: job.leaseToken }),
3340
+ };
3341
+ }
3342
+
3326
3343
  async function createSupervisorDeps(opts = {}) {
3327
3344
  const {
3328
3345
  runner,
@@ -3424,7 +3441,7 @@ async function createSupervisorDeps(opts = {}) {
3424
3441
  // the runtime uses (activate/extendLock), and any future port instrumentation
3425
3442
  // covers the settle path too.
3426
3443
  const settle = {
3427
- complete: (jobKey, variables) => rt.Effect.runPromise(engine.complete(jobKey, variables)),
3444
+ complete: (jobKey, variables, leaseToken) => rt.Effect.runPromise(engine.complete(jobKey, variables, leaseToken)),
3428
3445
  fail: (jobKey, opts2) => rt.Effect.runPromise(engine.fail(jobKey, opts2)),
3429
3446
  };
3430
3447
 
@@ -6373,16 +6390,25 @@ function normalizeProjectApps(projects) {
6373
6390
  }
6374
6391
 
6375
6392
  /**
6376
- * Probe whether an embedded app's `/agentic` endpoint answers a WebSocket
6377
- * upgrade. Connects to `ws://<host>:<port>/agentic?token=…` (host defaults to
6393
+ * Probe whether a `/agentic` endpoint answers a WebSocket upgrade. Connects to
6394
+ * `ws(s)://<host>:<port><pathPrefix>/agentic?token=…` (host defaults to
6378
6395
  * `127.0.0.1`; a bare IPv6 literal is bracketed for the URL authority) and
6379
6396
  * resolves `true` only if the socket opens within `timeoutMs`; a refused
6380
- * connection, the console proxy's deliberate `501`, a `404`, or a timeout all
6381
- * resolve `false`. Self-cleaning — the probe socket is closed as soon as the
6382
- * outcome is known. Never throws.
6397
+ * connection, a `404`/`501`, or a timeout all resolve `false`. Self-cleaning —
6398
+ * the probe socket is closed as soon as the outcome is known. Never throws.
6383
6399
  *
6384
- * @param {number} port the app's direct agentic port (`appUi.port`)
6385
- * @param {{ host?: string, token?: string, WebSocketImpl?: Function, timeoutMs?: number }} [opts]
6400
+ * `pathPrefix` targets either the app's own port (empty prefix → `/agentic`) or
6401
+ * the engine's console **app-view WebSocket tunnel** (engine #1054), where the
6402
+ * prefix is `/console/app-view/<project>` so the channel rides the single engine
6403
+ * port instead of the app's direct port (see {@link discoverAgenticHubs}).
6404
+ *
6405
+ * `secure` selects the WS scheme: `false` → `ws://` (plain), `true` → `wss://`.
6406
+ * The tunnel leg rides the engine's own port, so an `https://` engine base
6407
+ * requires a `wss://` upgrade — probing it with plain `ws://` would spuriously
6408
+ * fail the tunnel and force the direct-port fallback (see {@link discoverAgenticHubs}).
6409
+ *
6410
+ * @param {number|string} port the port the WS connects to (app port, or engine port for the tunnel)
6411
+ * @param {{ host?: string, token?: string, WebSocketImpl?: Function, timeoutMs?: number, pathPrefix?: string, secure?: boolean }} [opts]
6386
6412
  * @returns {Promise<boolean>}
6387
6413
  */
6388
6414
  function probeAgenticChannel(port, {
@@ -6390,9 +6416,12 @@ function probeAgenticChannel(port, {
6390
6416
  token = LOCAL_AGENTIC_TOKEN,
6391
6417
  WebSocketImpl = globalThis.WebSocket,
6392
6418
  timeoutMs = AGENTIC_DISCOVERY_TIMEOUT_MS,
6419
+ pathPrefix = '',
6420
+ secure = false,
6393
6421
  } = {}) {
6394
6422
  if (typeof WebSocketImpl !== 'function') return Promise.resolve(false);
6395
- const url = `ws://${wsHostPart(host)}:${port}/agentic?token=${encodeURIComponent(token)}`;
6423
+ const wsScheme = secure ? 'wss' : 'ws';
6424
+ const url = `${wsScheme}://${wsHostPart(host)}:${port}${pathPrefix}/agentic?token=${encodeURIComponent(token)}`;
6396
6425
  return new Promise((resolve) => {
6397
6426
  let done = false;
6398
6427
  let ws;
@@ -6499,8 +6528,8 @@ async function resolveProbeCandidates(host, { lookupImpl = dnsLookup } = {}) {
6499
6528
  * slow-to-open one can't stall the whole probe: a fast later candidate still
6500
6529
  * wins. Each per-host probe is bounded by `timeoutMs`. A single candidate skips
6501
6530
  * the racing machinery entirely (unchanged legacy path).
6502
- * @param {number} port
6503
- * @param {{ hosts?: string[], token?: string, timeoutMs?: number, wsProbe?: Function, staggerMs?: number }} [opts]
6531
+ * @param {number|string} port
6532
+ * @param {{ hosts?: string[], token?: string, timeoutMs?: number, wsProbe?: Function, staggerMs?: number, pathPrefix?: string, secure?: boolean }} [opts]
6504
6533
  * @returns {Promise<string|null>} the winning host, or null
6505
6534
  */
6506
6535
  async function raceProbeCandidates(port, {
@@ -6509,12 +6538,14 @@ async function raceProbeCandidates(port, {
6509
6538
  timeoutMs = AGENTIC_DISCOVERY_TIMEOUT_MS,
6510
6539
  wsProbe = probeAgenticChannel,
6511
6540
  staggerMs = 250,
6541
+ pathPrefix = '',
6542
+ secure = false,
6512
6543
  } = {}) {
6513
6544
  const list = Array.isArray(hosts) ? hosts.filter(Boolean) : [];
6514
6545
  if (list.length === 0) return null;
6515
6546
  if (list.length === 1) {
6516
6547
  try {
6517
- return (await wsProbe(port, { host: list[0], token, timeoutMs })) ? list[0] : null;
6548
+ return (await wsProbe(port, { host: list[0], token, timeoutMs, pathPrefix, secure })) ? list[0] : null;
6518
6549
  } catch {
6519
6550
  return null;
6520
6551
  }
@@ -6531,7 +6562,7 @@ async function raceProbeCandidates(port, {
6531
6562
  };
6532
6563
  const start = (host) => {
6533
6564
  Promise.resolve()
6534
- .then(() => wsProbe(port, { host, token, timeoutMs }))
6565
+ .then(() => wsProbe(port, { host, token, timeoutMs, pathPrefix, secure }))
6535
6566
  .catch(() => false)
6536
6567
  .then((ok) => {
6537
6568
  if (ok) done(host);
@@ -6547,23 +6578,32 @@ async function raceProbeCandidates(port, {
6547
6578
 
6548
6579
  /**
6549
6580
  * Auto-discover the embedded nwf agentic hub(s) reachable from an engine base
6550
- * URL (#75, #96). Reads `GET <engine>/console/api/projects`, keeps the apps that
6551
- * advertise an agentic UI port, and WS-probes each app's `/agentic` **on the
6552
- * engine's own host** to confirm the channel is actually served there (bypassing
6553
- * the WS-incapable console proxy). Works cross-machine on a trusted LAN: a
6554
- * loopback engine probes `127.0.0.1`, a remote engine (e.g. `merlin.local`)
6555
- * probes that same host the port is taken from the projects API but the host is
6556
- * always the engine's, so a rogue projects API can never steer a probe at the
6557
- * worker's own loopback (#76). Gives the projects fetch and each WS probe
6558
- * INDEPENDENT deadlines (#133) so a slow fetch can't starve the probe, prefers a
6559
- * routable address over a link-local `fe80::` one (Happy-Eyeballs), and is
6560
- * fail-open: any error not a nano engine (Camunda), network failure, malformed
6561
- * body, or a timeout degrades to `[]` so the worker's real job is never
6562
- * blocked.
6581
+ * URL (#75, #96, #97). Reads `GET <engine>/console/api/projects`, keeps the apps
6582
+ * that advertise an agentic UI port, and WS-probes each app's `/agentic` **on the
6583
+ * engine's own host** to confirm the channel is actually served there. Works
6584
+ * cross-machine on a trusted LAN: a loopback engine probes `127.0.0.1`, a remote
6585
+ * engine (e.g. `merlin.local`) probes that same host — the port is taken from the
6586
+ * projects API but the host is always the engine's, so a rogue projects API can
6587
+ * never steer a probe at the worker's own loopback (#76).
6588
+ *
6589
+ * **Single-port hardening (#97, engine #1054):** each app is probed **tunnel-first**
6590
+ * `ws://<engineHost>:<enginePort>/console/app-view/<project>/agentic`, the console
6591
+ * app-view WebSocket tunnel on the engine's *own* port (the same host:port the
6592
+ * worker already reached for the projects read). A surviving tunnel hub is marked
6593
+ * `via:'tunnel'` and needs only the engine port to be reachable — the app's direct
6594
+ * port need not be LAN-open. If the tunnel probe fails (an engine that predates
6595
+ * #1054 refuses the `/agentic` WS upgrade with `501`), it falls back to probing the
6596
+ * app's **direct** port and marks the hub `via:'direct'` (unchanged #96 behaviour).
6597
+ *
6598
+ * Gives the projects fetch and each WS probe INDEPENDENT deadlines (#133) so a slow
6599
+ * fetch can't starve the probe, prefers a routable address over a link-local `fe80::`
6600
+ * one (Happy-Eyeballs), and is fail-open: any error — not a nano engine (Camunda),
6601
+ * network failure, malformed body, or a timeout — degrades to `[]` so the worker's
6602
+ * real job is never blocked.
6563
6603
  *
6564
6604
  * @param {string} engineBaseUrl the engine base URL (e.g. `http://merlin.local:8080`)
6565
6605
  * @param {{ token?: string, fetchImpl?: Function, wsProbe?: Function, lookupImpl?: Function, timeoutMs?: number, fetchTimeoutMs?: number, probeTimeoutMs?: number }} [opts]
6566
- * @returns {Promise<Array<{ project: string, port: number, label?: string, host: string }>>}
6606
+ * @returns {Promise<Array<{ project: string, port: number, label?: string, host: string, via: 'tunnel'|'direct', enginePort?: string, scheme?: string }>>}
6567
6607
  */
6568
6608
  async function discoverAgenticHubs(engineBaseUrl, {
6569
6609
  token = LOCAL_AGENTIC_TOKEN,
@@ -6586,12 +6626,15 @@ async function discoverAgenticHubs(engineBaseUrl, {
6586
6626
  // loopback services — which was the actual #76 concern (a rogue projects API
6587
6627
  // making the worker probe its own localhost). So the port comes from the
6588
6628
  // engine's projects API, but the HOST is always the engine's, never guessed.
6589
- let host;
6629
+ let engineUrl;
6590
6630
  try {
6591
- host = new URL(base).hostname;
6631
+ engineUrl = new URL(base);
6592
6632
  } catch {
6593
6633
  return [];
6594
6634
  }
6635
+ const host = engineUrl.hostname;
6636
+ const engineScheme = engineUrl.protocol; // 'http:' | 'https:'
6637
+ const enginePort = engineUrl.port || (engineScheme === 'https:' ? '443' : '80');
6595
6638
  const probeHost = isLoopbackHost(host) ? '127.0.0.1' : host;
6596
6639
  // (C) Decoupled budgets (#133): the projects fetch and each WS probe get their
6597
6640
  // OWN independent deadline. Previously they shared one 2s budget, so a fetch
@@ -6623,13 +6666,32 @@ async function discoverAgenticHubs(engineBaseUrl, {
6623
6666
  const candidates = await resolveProbeCandidates(probeHost, { lookupImpl });
6624
6667
  const settled = await Promise.all(apps.map(async (app) => {
6625
6668
  try {
6626
- const winner = await raceProbeCandidates(app.port, {
6669
+ // Tunnel-first (#97): confirm the channel over the console app-view WS
6670
+ // tunnel on the ENGINE port — the same host:port the projects read just
6671
+ // used — so the app's direct port need not be reachable. A pre-#1054
6672
+ // engine 501s the upgrade and the probe fails; we then fall back to the
6673
+ // app's direct port (unchanged #96 path).
6674
+ const tunnelHost = await raceProbeCandidates(enginePort, {
6675
+ hosts: candidates,
6676
+ token,
6677
+ timeoutMs: probeTimeoutMs,
6678
+ wsProbe,
6679
+ pathPrefix: `/console/app-view/${encodeURIComponent(app.project)}`,
6680
+ // The tunnel rides the engine's own port, so match its TLS: an
6681
+ // `https://` engine base upgrades over `wss://`, not `ws://` (else the
6682
+ // tunnel probe spuriously fails and we drop to the direct port).
6683
+ secure: engineScheme === 'https:',
6684
+ });
6685
+ if (tunnelHost) {
6686
+ return { ...app, host: tunnelHost, via: 'tunnel', enginePort, scheme: engineScheme };
6687
+ }
6688
+ const directHost = await raceProbeCandidates(app.port, {
6627
6689
  hosts: candidates,
6628
6690
  token,
6629
6691
  timeoutMs: probeTimeoutMs,
6630
6692
  wsProbe,
6631
6693
  });
6632
- return winner ? { ...app, host: winner } : null;
6694
+ return directHost ? { ...app, host: directHost, via: 'direct' } : null;
6633
6695
  } catch {
6634
6696
  return null;
6635
6697
  }
@@ -6646,8 +6708,11 @@ async function discoverAgenticHubs(engineBaseUrl, {
6646
6708
  * half-configured. No discovery attempted.
6647
6709
  * - `{ status: 'connect', config }` — a target to connect to. Either the
6648
6710
  * explicit `NANO_AGENTIC_URL`/`agenticUrl` verbatim (no discovery), or the
6649
- * single discovered app's `ws://<engineHost>:<port>/agentic` (loopback for a
6650
- * local engine, the engine's LAN host for a remote one).
6711
+ * single discovered app: the console app-view WS tunnel on the engine port
6712
+ * (`<engineHost>:<enginePort>/console/app-view/<project>/agentic`, `via:'tunnel'`,
6713
+ * #97) when available, else the direct app port
6714
+ * (`ws://<engineHost>:<appPort>/agentic`, `via:'direct'`, #96) — loopback for a
6715
+ * local engine, the engine's LAN host for a remote one.
6651
6716
  * - `{ status: 'ambiguous', message, candidates }` — two+ apps expose a
6652
6717
  * channel. Hard stop for the worker: it must not silently pick one.
6653
6718
  * - `{ status: 'advisory', message }` — nothing discoverable (zero matches,
@@ -6681,10 +6746,18 @@ async function resolveAgenticTarget({ camunda, cache, ...opts } = {}) {
6681
6746
  } catch { /* keep the loopback default */ }
6682
6747
 
6683
6748
  if (hubs.length === 1) {
6684
- const { project, port, host } = hubs[0];
6749
+ const { project, port, host, via, enginePort, scheme } = hubs[0];
6750
+ // A tunnel hub rides the console app-view WS bridge on the engine's own port
6751
+ // (#97): `<scheme>//<engineHost>:<enginePort>/console/app-view/<project>`, to
6752
+ // which `buildAgenticUrl` appends `/agentic`. A direct hub keeps the #96
6753
+ // `http://<host>:<appPort>` form. `discovered.port` stays the app's advertised
6754
+ // port either way (the hub identity); the local `via` records the route taken.
6755
+ const url = via === 'tunnel'
6756
+ ? `${scheme}//${wsHostPart(host)}:${enginePort}/console/app-view/${encodeURIComponent(project)}`
6757
+ : `http://${wsHostPart(host)}:${port}`;
6685
6758
  const config = {
6686
6759
  ...base,
6687
- url: `http://${wsHostPart(host)}:${port}`,
6760
+ url,
6688
6761
  discovered: { project, port, host },
6689
6762
  };
6690
6763
  // Cache the known-good hub so a later blip self-heals from cache (#133-C).
@@ -7715,10 +7788,18 @@ async function workAgent(req, flags) {
7715
7788
  // dispatch claim/release lifecycle keyed by this worker's instance drives the
7716
7789
  // cockpit's jobKeys — so the recorders no longer poke a per-process channel.
7717
7790
  const recordJobStart = (job, jobType) => {
7718
- activeJobs.set(String(job.jobKey), { type: jobType, since: Date.now(), retries: Number(job.retries) });
7791
+ activeJobs.set(String(job.jobKey), { type: jobType, since: Date.now(), retries: Number(job.retries), leaseToken: job.leaseToken });
7719
7792
  writeActivity();
7720
7793
  };
7721
7794
  const recordJobEnd = (job) => {
7795
+ // Delete only if the current entry is THIS activation. A same-key
7796
+ // reactivation may have replaced the entry (new leaseToken) while this
7797
+ // (interrupted) run unwinds — deleting then would erase the NEWER activation
7798
+ // from status and can make force-stop miss its yield. The per-activation
7799
+ // leaseToken is the identity; an unleased job (no token both sides) deletes as
7800
+ // before.
7801
+ const cur = activeJobs.get(String(job.jobKey));
7802
+ if (cur && cur.leaseToken !== job.leaseToken) return;
7722
7803
  activeJobs.delete(String(job.jobKey));
7723
7804
  writeActivity();
7724
7805
  };
@@ -7910,6 +7991,14 @@ async function workAgent(req, flags) {
7910
7991
  run: async (job, abortSignal) => {
7911
7992
  const jobType = job.type;
7912
7993
  recordJobStart(job, jobType);
7994
+ // Bind the settler to THIS activation's lease token, captured from the job
7995
+ // in closure scope. A settlement is fenced with the exact activation that
7996
+ // ran — NOT a token re-read from the shared activeJobs map at settle time: a
7997
+ // same-key reactivation can overwrite that entry while an interrupted runner
7998
+ // is still unwinding, so a map lookup could fence the completion with the
7999
+ // WRONG (newer) token and clobber the new activation — the very lease bypass
8000
+ // this fence exists to prevent. `job.leaseToken` in the closure cannot drift.
8001
+ const settleJob = bindJobSettle(settle, job);
7913
8002
  try {
7914
8003
  logger.info(`[${jobType}] job ${job.jobKey} (instance ${job.processInstanceKey ?? '-'}) → ${buildAgentCommandLine(profile.command, effectiveArgs)}`);
7915
8004
 
@@ -7922,7 +8011,7 @@ async function workAgent(req, flags) {
7922
8011
  const freeMb = budget.free != null ? Math.round(budget.free / 1_048_576) : '?';
7923
8012
  const retries = Math.max(0, (Number(job.retries) || 1) - 1);
7924
8013
  logger.warn(`[${jobType}] job ${job.jobKey} shed — low disk (${freeMb}MB free); retries left ${retries}`);
7925
- return settle.fail(job.jobKey, { errorMessage: `disk budget exceeded (only ${freeMb}MB free)`, retries, retryBackOff: 30_000 });
8014
+ return settleJob.fail({ errorMessage: `disk budget exceeded (only ${freeMb}MB free)`, retries, retryBackOff: 30_000 });
7926
8015
  }
7927
8016
  }
7928
8017
 
@@ -7954,7 +8043,7 @@ async function workAgent(req, flags) {
7954
8043
  const retries = Math.max(0, (Number(job.retries) || 1) - 1);
7955
8044
  const msg = err instanceof ProvisionError ? err.message : `prompt resource fetch failed: ${err.message}`;
7956
8045
  logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
7957
- return settle.fail(job.jobKey, { errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
8046
+ return settleJob.fail({ errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
7958
8047
  }
7959
8048
 
7960
8049
  // Assemble + normalize the task envelope from headers (defaults) and
@@ -7965,7 +8054,7 @@ async function workAgent(req, flags) {
7965
8054
  const retries = Math.max(0, (Number(job.retries) || 1) - 1);
7966
8055
  const msg = `missing secret(s): ${missing.join(', ')} (resolver: ${secretResolver.kind})`;
7967
8056
  logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
7968
- return settle.fail(job.jobKey, { errorMessage: msg, retries });
8057
+ return settleJob.fail({ errorMessage: msg, retries });
7969
8058
  }
7970
8059
 
7971
8060
  // #130: per-task liveness overrides. Precedence: envelope override →
@@ -8031,7 +8120,7 @@ async function workAgent(req, flags) {
8031
8120
  : 'repository.url is missing';
8032
8121
  const msg = `incomplete repository envelope — ${why}; refusing to run in the launch/temp cwd (likely an orchestrator bug emitting a half-specified repository block)`;
8033
8122
  logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
8034
- return settle.fail(job.jobKey, { errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
8123
+ return settleJob.fail({ errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
8035
8124
  }
8036
8125
  }
8037
8126
 
@@ -8086,7 +8175,7 @@ async function workAgent(req, flags) {
8086
8175
  const retries = Math.max(0, (Number(job.retries) || 1) - 1);
8087
8176
  const msg = err instanceof ProvisionError ? err.message : `provisioning error: ${err.message}`;
8088
8177
  logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
8089
- return settle.fail(job.jobKey, { errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
8178
+ return settleJob.fail({ errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
8090
8179
  }
8091
8180
  } else if (!isContainer) {
8092
8181
  // Repo-less host job (issue #129, hardening 1): nothing is provisioned,
@@ -8112,7 +8201,7 @@ async function workAgent(req, flags) {
8112
8201
  const retries = Math.max(0, (Number(job.retries) || 1) - 1);
8113
8202
  const msg = `could not create a temp workspace under the worker namespace: ${err.message}`;
8114
8203
  logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
8115
- return settle.fail(job.jobKey, { errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
8204
+ return settleJob.fail({ errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
8116
8205
  }
8117
8206
  }
8118
8207
 
@@ -8310,7 +8399,7 @@ async function workAgent(req, flags) {
8310
8399
  const resultKeys = Object.keys(resultVars);
8311
8400
  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.`);
8312
8401
  else logger.info(`[${jobType}] job ${job.jobKey}: merged agent result vars [${resultKeys.join(', ')}]`);
8313
- return await settle.complete(job.jobKey, {
8402
+ return await settleJob.complete({
8314
8403
  ...resultVars,
8315
8404
  [AGENT_RESULT_KEY]: resultEnvelope,
8316
8405
  output: result.stdout,
@@ -8327,7 +8416,7 @@ async function workAgent(req, flags) {
8327
8416
  || (result.stderr || '').trim() + (result.stderrTruncated && (result.stderr || '').trim() ? ' [stderr truncated]' : '')
8328
8417
  || (result.signal ? `terminated by signal ${result.signal}` : `exit code ${result.exitCode}`);
8329
8418
  logger.warn(`[${jobType}] job ${job.jobKey} failed (${detail}); retries left ${retries}`);
8330
- return await settle.fail(job.jobKey, {
8419
+ return await settleJob.fail({
8331
8420
  errorMessage: `agent "${profile.name}" failed: ${detail}`.slice(0, 2000),
8332
8421
  retries,
8333
8422
  variables: { [AGENT_RESULT_KEY]: resultEnvelope },
@@ -8360,6 +8449,12 @@ async function workAgent(req, flags) {
8360
8449
  dispatch: { recoveryWindowMs, extendIntervalMs: lockExtendIntervalMs },
8361
8450
  },
8362
8451
  });
8452
+ // The settle seam is used directly with an EXPLICIT lease token per call: the
8453
+ // runner binds `settleJob` to its activation's `job.leaseToken` (race-free —
8454
+ // see the runner), and the force-stop yield passes the token captured in
8455
+ // `inflight`. We deliberately do NOT wrap `settle` to look the token up from the
8456
+ // shared `activeJobs` map by jobKey, because a same-key reactivation could make
8457
+ // that lookup fence a settlement with the wrong activation's token.
8363
8458
  settle = composed.settle;
8364
8459
  const {
8365
8460
  deps: supervisorDeps,
@@ -8555,7 +8650,7 @@ async function workAgent(req, flags) {
8555
8650
  logger.info(`Received ${signal} — aborting in-flight work and stopping worker...`);
8556
8651
  // Snapshot in-flight jobs (with their retry budget) BEFORE the interrupt
8557
8652
  // clears the ownership registry, so we can yield each one afterwards.
8558
- const inflight = [...activeJobs.entries()].map(([jobKey, info]) => ({ jobKey, retries: info?.retries }));
8653
+ const inflight = [...activeJobs.entries()].map(([jobKey, info]) => ({ jobKey, retries: info?.retries, leaseToken: info?.leaseToken }));
8559
8654
  // Interrupt the runtime: this aborts each running job's AbortSignal (the
8560
8655
  // makeJobRunner seam) so runAgentJob killTree's the harness process group,
8561
8656
  // and runs dispatch's bracketed teardown (release ownership + slot). The
@@ -8569,12 +8664,13 @@ async function workAgent(req, flags) {
8569
8664
  // Yield each in-flight job so the broker re-activates it at once (retries
8570
8665
  // preserved — a force-stop doesn't consume an attempt). Best-effort: a
8571
8666
  // failed yield just lets the lock lapse (the honest fallback).
8572
- for (const { jobKey, retries } of inflight) {
8667
+ for (const { jobKey, retries, leaseToken } of inflight) {
8573
8668
  try {
8574
8669
  await SupervisorEffect.runPromise(settle.fail(jobKey, {
8575
8670
  errorMessage: `worker force-stopped (${signal}); job yielded for retry`,
8576
8671
  retries: Number.isFinite(retries) && retries > 0 ? retries : 1,
8577
8672
  retryBackOff: 0,
8673
+ leaseToken,
8578
8674
  }));
8579
8675
  logger.info(` yielded job ${jobKey} for immediate retry.`);
8580
8676
  } catch (err) {
@@ -13805,6 +13901,7 @@ export {
13805
13901
  loadSupervisorRuntime,
13806
13902
  createAgenticEndpoint,
13807
13903
  createSupervisorDeps,
13904
+ bindJobSettle,
13808
13905
  enableEngineHappyEyeballs,
13809
13906
  preferIpv4Resolution,
13810
13907
  isLikelyLocalNetworkTccBlock,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.59.1",
3
+ "version": "1.60.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",
@@ -73,12 +73,12 @@
73
73
  },
74
74
  "optionalDependencies": {
75
75
  "node-pty": "^1.0.0",
76
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.59.1",
77
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.59.1",
78
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.59.1",
79
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.59.1",
80
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.59.1",
81
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.59.1",
82
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.59.1"
76
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.60.1",
77
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.60.1",
78
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.60.1",
79
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.60.1",
80
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.60.1",
81
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.60.1",
82
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.60.1"
83
83
  }
84
84
  }
@@ -36,6 +36,13 @@
36
36
  * else the SAME calls issued raw via `fetchImpl` — the settle surface the
37
37
  * supervisor's JobRunner uses once an agent finishes.
38
38
  *
39
+ * The three POST-ACTIVATION commands take an optional lease token that FENCES them
40
+ * against a superseded worker (the engine validates `updateJob` and requires it on
41
+ * `completeJob`/`failJob`): `extendLock(jobKey, ms, leaseToken?)`,
42
+ * `complete(jobKey, variables?, leaseToken?)`, and `fail(jobKey, { …, leaseToken? })`.
43
+ * On every path (SDK + raw) it is sent as the TOP-LEVEL camelCase `leaseToken` wire
44
+ * field and OMITTED when blank/absent (a non-leased job needs no fence).
45
+ *
39
46
  * Every method on this surface (`activate`→`activateJobs`,
40
47
  * `extendLock`→`updateJob`, `complete`→`completeJob`, `fail`→`failJob`) PREFERS
41
48
  * the injected `camunda` client and only hand-rolls the REST call as a
@@ -95,6 +102,18 @@ function isPlainObjectMap(v) {
95
102
  return proto === Object.prototype || proto === null;
96
103
  }
97
104
 
105
+ /**
106
+ * A non-blank lease token — the opaque per-activation `leaseToken` the engine
107
+ * stamps on an agent job. When present it fences a post-activation command
108
+ * (`updateJob`/complete/fail) so a SUPERSEDED worker's call is rejected 409
109
+ * (`JobLeaseMismatch`) instead of silently mutating a job it no longer owns. A
110
+ * blank/absent token is OMITTED so the unfenced operator/bulk path (the engine
111
+ * treats a missing token as "always applies") is preserved untouched.
112
+ */
113
+ function isNonBlankString(v) {
114
+ return typeof v === "string" && v.trim() !== "";
115
+ }
116
+
98
117
  /**
99
118
  * Map one raw v2 activated-job record to the port's {@link ActivatedJob}. Keys are
100
119
  * strings in v2. A record missing `jobKey`/`type` violates the `ActivatedJob`
@@ -179,8 +198,8 @@ async function readErrorBody(res) {
179
198
  * @param {Record<string,string>|(() => (Record<string,string>|Promise<Record<string,string>>))} [opts.authHeaders] Ready-made auth header map, OR a resolver invoked per request (so rotating SDK auth — e.g. an OAuth bearer that refreshes — is re-derived each call rather than frozen). Wins over `token`.
180
199
  * @param {typeof fetch} [opts.fetchImpl] Injected `fetch` (defaults to the global; overridden in tests).
181
200
  * @param {number} [opts.requestTimeoutSlackMs] Extra ms added to a call's abort budget over its server long-poll (default 5000).
182
- * @param {{ activateJobs?: (input: { type: string, worker?: string, maxJobsToActivate: number, timeout: number, requestTimeout?: number }) => (Promise<{ jobs?: object[] }> & { cancel?: () => void }), updateJob?: (req: { jobKey: string, changeset: { timeout: number } }) => Promise<unknown>, completeJob?: (req: { jobKey: string, variables?: object }) => Promise<unknown>, failJob?: (req: { jobKey: string, retries?: number, errorMessage?: string, retryBackOff?: number, variables?: object }) => Promise<unknown> }} [opts.camunda] Optional Camunda SDK client. When present, each engine method prefers its typed SDK method over the raw fetch fallback: `activate`→`activateJobs` (`POST /v2/jobs/activation`, cancelled via the returned `CancelablePromise.cancel()`), `extendLock`→`updateJob` (`PATCH /v2/jobs/{jobKey}` `{ changeset: { timeout } }`), `complete`→`completeJob` (`POST /v2/jobs/{jobKey}/completion`), `fail`→`failJob` (`POST /v2/jobs/{jobKey}/failure`).
183
- * @returns {{ activate(req: ActivateRequest, signal?: AbortSignal): Promise<ReadonlyArray<ActivatedJob>>, extendLock(jobKey: string, ms: number): Promise<void>, complete(jobKey: string, variables?: object): Promise<void>, fail(jobKey: string, opts?: { retries?: number, errorMessage?: string, retryBackOff?: number, variables?: object }): Promise<void> }}
201
+ * @param {{ activateJobs?: (input: { type: string, worker?: string, maxJobsToActivate: number, timeout: number, requestTimeout?: number }) => (Promise<{ jobs?: object[] }> & { cancel?: () => void }), updateJob?: (req: { jobKey: string, changeset: { timeout: number }, leaseToken?: string }) => (Promise<unknown> & { cancel?: () => void }), completeJob?: (req: { jobKey: string, variables?: object, leaseToken?: string }) => Promise<unknown>, failJob?: (req: { jobKey: string, retries?: number, errorMessage?: string, retryBackOff?: number, variables?: object, leaseToken?: string }) => Promise<unknown> }} [opts.camunda] Optional Camunda SDK client. When present, each engine method prefers its typed SDK method over the raw fetch fallback: `activate`→`activateJobs` (`POST /v2/jobs/activation`, cancelled via the returned `CancelablePromise.cancel()`), `extendLock`→`updateJob` (`PATCH /v2/jobs/{jobKey}` `{ changeset: { timeout }, leaseToken? }`, likewise cancelled via `cancel()` when the extend's `AbortSignal` fires), `complete`→`completeJob` (`POST /v2/jobs/{jobKey}/completion`), `fail`→`failJob` (`POST /v2/jobs/{jobKey}/failure`). Each post-activation request carries the activation's top-level `leaseToken` (camelCase wire field) so the engine's lease fence accepts it.
202
+ * @returns {{ activate(req: ActivateRequest, signal?: AbortSignal): Promise<ReadonlyArray<ActivatedJob>>, extendLock(jobKey: string, ms: number, leaseToken?: string, signal?: AbortSignal): Promise<void>, complete(jobKey: string, variables?: object, leaseToken?: string): Promise<void>, fail(jobKey: string, opts?: { retries?: number, errorMessage?: string, retryBackOff?: number, variables?: object, leaseToken?: string }): Promise<void> }} Every post-activation method takes the activation's `leaseToken` (Camunda-v10 lease fence): the engine validates it before mutating a leased job, so `extendLock`/`complete`/`fail` must each carry the token from the activation that owns the lock — omitting it is rejected once a job is leased. `extendLock` also accepts an optional `AbortSignal` so a hung extend can be cancelled (not merely abandoned) when the caller's deadline fires.
184
203
  */
185
204
  export function createRawEngineClient(opts = {}) {
186
205
  const {
@@ -304,22 +323,53 @@ export function createRawEngineClient(opts = {}) {
304
323
  return jobs.map(mapJob);
305
324
  },
306
325
 
307
- async extendLock(jobKey, ms) {
326
+ async extendLock(jobKey, ms, leaseToken, signal) {
308
327
  // Prefer the SDK's typed `updateJob` (operationId `updateJob` →
309
328
  // `PATCH /v2/jobs/{jobKey}` with `{ changeset: { timeout } }`) so the lock
310
329
  // extension tracks the engine contract instead of a hand-rolled URL. Fall
311
330
  // back to the SAME call issued raw when no SDK client is injected (keeps
312
331
  // this module wire-testable and usable standalone).
332
+ //
333
+ // `leaseToken` (when present) is a TOP-LEVEL sibling of `changeset`/`jobKey`
334
+ // — NOT nested in the changeset — matching the Camunda v10 `JobUpdateRequest`.
335
+ // The wire field is camelCase `leaseToken` (the engine's `JobUpdateRequest`
336
+ // deserializes it via `#[serde(rename = "leaseToken")]` into its internal
337
+ // `lease_token` field), so BOTH the SDK and this raw PATCH send the same
338
+ // camelCase key. It FENCES the extend: a superseded
339
+ // worker whose lease has been reassigned is rejected 409 (`JobLeaseMismatch`)
340
+ // rather than renewing a lock it no longer owns, which is what let a reclaimed
341
+ // agent job keep running and loop (empty transcript husks). Omitted when blank
342
+ // so the unfenced operator/bulk path is unchanged.
343
+ const fence = isNonBlankString(leaseToken) ? { leaseToken } : {};
313
344
  if (camunda && typeof camunda.updateJob === "function") {
345
+ // Mirror `activate`: wire the external abort `signal` (fired when the
346
+ // dispatch-side `Effect.timeout`/interruption cancels a hung beat) onto the
347
+ // SDK call's `CancelablePromise.cancel()` when it exposes one, so a wedged
348
+ // `updateJob` is actually cancelled rather than left in flight to (late)
349
+ // re-lock a job whose lease we've already treated as lost.
350
+ const p = camunda.updateJob({ changeset: { timeout: ms }, jobKey: String(jobKey), ...fence });
351
+ const cancel = typeof p?.cancel === "function" ? () => p.cancel() : () => {};
352
+ const onExtAbort = () => cancel();
353
+ if (signal) {
354
+ if (signal.aborted) cancel();
355
+ else signal.addEventListener("abort", onExtAbort, { once: true });
356
+ }
314
357
  try {
315
- await camunda.updateJob({ changeset: { timeout: ms }, jobKey: String(jobKey) });
358
+ await p;
316
359
  return;
317
360
  } catch (err) {
318
361
  throw new Error(`extendLock ${jobKey}: SDK updateJob failed: ${err?.message ?? err}`, { cause: err });
362
+ } finally {
363
+ if (signal) signal.removeEventListener("abort", onExtAbort);
319
364
  }
320
365
  }
321
366
  const url = `${base}/jobs/${encodeURIComponent(jobKey)}`;
322
- const res = await call(url, { method: "PATCH", body: JSON.stringify({ changeset: { timeout: ms } }) }, 15_000);
367
+ const res = await call(
368
+ url,
369
+ { method: "PATCH", body: JSON.stringify({ changeset: { timeout: ms }, ...fence }) },
370
+ 15_000,
371
+ signal,
372
+ );
323
373
  if (!res || !res.ok) {
324
374
  const status = res ? res.status : "?";
325
375
  throw new Error(`extendLock ${jobKey}: HTTP ${status} from ${url}${res ? await readErrorBody(res) : ""}`);
@@ -338,23 +388,31 @@ export function createRawEngineClient(opts = {}) {
338
388
  // SDK rejection (e.g. a 409 when the lock lapsed and the job was reclaimed)
339
389
  // surfaces as a rejected promise for the port to map.
340
390
 
341
- async complete(jobKey, variables) {
391
+ async complete(jobKey, variables, leaseToken) {
342
392
  // Prefer the SDK's typed `completeJob` (operationId `completeJob` →
343
393
  // `POST /v2/jobs/{jobKey}/completion` with `{ variables }`) when a `camunda`
344
394
  // SDK client is injected, else the SAME call issued raw via `fetchImpl`. The
345
395
  // result-variable map the model produced is merged onto the process
346
396
  // instance. C8 v2 answers 204 No Content on success.
397
+ //
398
+ // `leaseToken` (top-level, like the extend) FENCES the completion: the engine
399
+ // validates it with `required=true`, so for a LEASED job the matching token
400
+ // MUST be supplied — a superseded worker (whose token rotated when the job was
401
+ // re-activated) is rejected `JobLeaseMismatch`, never clobbering the newer
402
+ // activation's result. A non-leased job carries no `job.leaseToken`, so the
403
+ // field is omitted and no token is required.
347
404
  const vars = isPlainObjectMap(variables) ? { variables } : {};
405
+ const fence = isNonBlankString(leaseToken) ? { leaseToken } : {};
348
406
  if (camunda && typeof camunda.completeJob === "function") {
349
407
  try {
350
- await camunda.completeJob({ jobKey: String(jobKey), ...vars });
408
+ await camunda.completeJob({ jobKey: String(jobKey), ...vars, ...fence });
351
409
  return;
352
410
  } catch (err) {
353
411
  throw new Error(`complete ${jobKey}: SDK completeJob failed: ${err?.message ?? err}`, { cause: err });
354
412
  }
355
413
  }
356
414
  const url = `${base}/jobs/${encodeURIComponent(jobKey)}/completion`;
357
- const res = await call(url, { method: "POST", body: JSON.stringify(vars) }, 15_000);
415
+ const res = await call(url, { method: "POST", body: JSON.stringify({ ...vars, ...fence }) }, 15_000);
358
416
  if (!res || !res.ok) {
359
417
  const status = res ? res.status : "?";
360
418
  throw new Error(`complete ${jobKey}: HTTP ${status} from ${url}${res ? await readErrorBody(res) : ""}`);
@@ -365,7 +423,7 @@ export function createRawEngineClient(opts = {}) {
365
423
  // Tolerate a `null` opts the same as `undefined` (mirrors `complete`'s
366
424
  // null-safe `variables`), so a caller passing `null` never trips the
367
425
  // signature-destructure TypeError.
368
- const { retries = 0, errorMessage, retryBackOff, variables } = opts || {};
426
+ const { retries = 0, errorMessage, retryBackOff, variables, leaseToken } = opts || {};
369
427
  // Normalize retries to a non-negative integer (mirrors `mapJob`), so a
370
428
  // string/float/negative never reaches the engine (or SDK) as an invalid
371
429
  // count. `retries > 0` re-queues for another attempt, `retries === 0`
@@ -377,6 +435,10 @@ export function createRawEngineClient(opts = {}) {
377
435
  if (errorMessage !== undefined && errorMessage !== null) extra.errorMessage = String(errorMessage);
378
436
  if (Number.isFinite(retryBackOff) && retryBackOff > 0) extra.retryBackOff = retryBackOff;
379
437
  if (isPlainObjectMap(variables)) extra.variables = variables;
438
+ // Same lease fencing as `complete` (engine validates `failJob` with
439
+ // `required=true`): a leased job's failure must carry the matching token or a
440
+ // superseded worker's fail is rejected; omitted for a non-leased job.
441
+ if (isNonBlankString(leaseToken)) extra.leaseToken = leaseToken;
380
442
  // Prefer the SDK's typed `failJob` (operationId `failJob` →
381
443
  // `POST /v2/jobs/{jobKey}/failure`) when a `camunda` SDK client is injected,
382
444
  // else the SAME call issued raw via `fetchImpl`. C8 v2 answers 204.