c8ctl-plugin-nano 1.44.8 → 1.44.9

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
@@ -352,7 +352,7 @@ one of:
352
352
  | `starting` | transient: the worker just spawned and hasn't resolved its channel target yet (pre-`connecting`) |
353
353
  | `connected` | presence is live on the hub — you should see this worker in the Cockpit |
354
354
  | `connecting` | resolved a hub, socket not open yet (or the hub is unreachable) |
355
- | `disconnected` | an established channel dropped (hub restart/outage) — it auto-reconnects; also set if the channel failed to start (bad URL/refused socket), in which case it stays disconnected until the worker restarts |
355
+ | `disconnected` | an established channel dropped (hub restart/outage) — it auto-reconnects, and a worker-side **liveness watchdog** force-re-discovers the hub if it stays down (see below); also set if the channel failed to start (bad URL/refused socket), which is **not** auto-recovered by the watchdog (it only guards a channel that has connected) — fix the target and restart the worker |
356
356
  | `advisory` | nothing discoverable at the engine — **not** in the Cockpit; set `NANO_AGENTIC_URL` |
357
357
  | `off` | visibility disabled (`NANO_AGENTIC=off`) |
358
358
  | `?` | a live worker not yet reporting, or an older build predating these fields |
@@ -361,6 +361,22 @@ If workers show `advisory` (or stay `connecting`) while jobs still run, that's t
361
361
  "connected to the engine but empty Cockpit" case: point them at the app with
362
362
  `export NANO_AGENTIC_URL=http://<engine-host>:<appUi.port>` (e.g. `:3000`).
363
363
 
364
+ **Liveness watchdog (auto-recovery from a wedged channel).** If the nano server
365
+ restarts, crashes, or a network partition drops the connection *without* a clean
366
+ close (a **half-open** socket), a worker's channel client can sit `disconnected`
367
+ forever — the worker vanishes from the Nano Workers view / Cockpit and, before
368
+ this, only a supervisor restart brought it back. Each worker now runs a
369
+ belt-and-suspenders watchdog: once a channel that had connected stays down past a
370
+ threshold (the client library's own reconnect never recovered it), the worker
371
+ tears the wedged channel down and re-runs full hub discovery + reopen — no restart
372
+ needed. The thresholds are tunable via env (sensible defaults; you rarely need
373
+ these):
374
+
375
+ ```bash
376
+ export NANO_AGENTIC_STALE_MS=60000 # force re-discovery if a drop hasn't recovered within 60s (default)
377
+ export NANO_AGENTIC_WATCHDOG_MS=15000 # how often the watchdog checks channel liveness (default)
378
+ ```
379
+
364
380
  **Secure mode (opt-in).** For a deployment where you want the visibility channel
365
381
  authenticated (rather than open on the LAN), start the server **and** every worker
366
382
  box with the **same** `NANO_AGENTIC_SECRET` — same env-var name, same value on both
package/c8ctl-plugin.js CHANGED
@@ -6089,6 +6089,138 @@ async function rediscoverAgenticUntilConnected({
6089
6089
  return null;
6090
6090
  }
6091
6091
 
6092
+ // Worker-side liveness watchdog defaults (jwulf/c8ctl-plugin-nano#144). A
6093
+ // previously-connected agentic channel that has been `disconnected` for longer
6094
+ // than the stale threshold — because the client lib's own reconnect never
6095
+ // brought it back (e.g. a half-open drop after a server restart/crash/partition,
6096
+ // or a reconnect that keeps failing) — is force-healed: the wedged channel is
6097
+ // torn down and full discovery + reopen is re-armed, instead of trusting the
6098
+ // client lib alone. Overridable via NANO_AGENTIC_STALE_MS / NANO_AGENTIC_WATCHDOG_MS.
6099
+ const DEFAULT_AGENTIC_STALE_MS = 60_000;
6100
+ const DEFAULT_AGENTIC_WATCHDOG_INTERVAL_MS = 15_000;
6101
+
6102
+ /**
6103
+ * Decide whether a worker's agentic channel is *stale* — i.e. it once connected,
6104
+ * is no longer connected, and has stayed down past `staleAfterMs`. Pure so the
6105
+ * watchdog's trigger condition is unit-testable without timers or sockets. A
6106
+ * channel that never opened (`everConnected() === false`) is NOT stale — it is
6107
+ * still doing its first connect, which the initial open / cold-start self-heal
6108
+ * owns. `disconnectedSince` is null whenever the channel is up (or never went
6109
+ * down), which also reads as not-stale.
6110
+ *
6111
+ * @param {{
6112
+ * connected: () => boolean,
6113
+ * everConnected: () => boolean,
6114
+ * disconnectedSince: () => (number|null),
6115
+ * now?: () => number,
6116
+ * staleAfterMs?: number,
6117
+ * }} opts
6118
+ * @returns {boolean}
6119
+ */
6120
+ function agenticChannelIsStale({
6121
+ connected,
6122
+ everConnected,
6123
+ disconnectedSince,
6124
+ now = () => Date.now(),
6125
+ staleAfterMs = DEFAULT_AGENTIC_STALE_MS,
6126
+ }) {
6127
+ if (typeof connected !== 'function' || typeof everConnected !== 'function') return false;
6128
+ if (!everConnected()) return false; // never opened → the initial connect owns it
6129
+ if (connected()) return false; // healthy
6130
+ const since = typeof disconnectedSince === 'function' ? disconnectedSince() : null;
6131
+ if (since == null) return false; // no recorded drop → nothing to heal
6132
+ return now() - since >= staleAfterMs;
6133
+ }
6134
+
6135
+ /**
6136
+ * Start the worker-side agentic-channel liveness watchdog (#144). On a fixed
6137
+ * interval it asks {@link agenticChannelIsStale} whether the channel dropped and
6138
+ * never recovered within the threshold; when it has, it fires `onStale()` (which
6139
+ * tears the wedged channel down and re-runs discovery + reopen). It fires
6140
+ * `onStale` **exactly once per stale episode** — a per-episode latch is re-armed
6141
+ * only when the channel is next observed healthy (or gone), or when a heal
6142
+ * throws (a failed recovery retries on the next tick), so a persistent stale
6143
+ * condition with a successful heal does not retrigger the heal on every tick. Re-entrancy is
6144
+ * guarded so a slow heal never overlaps a later tick. Timers, clock, and the
6145
+ * channel accessors are injectable so this is unit-testable without real waits.
6146
+ * Returns a `{ stop, tick }` handle — `stop()` clears the timer AND latches the
6147
+ * watchdog stopped so any tick already scheduled or in flight around shutdown
6148
+ * becomes a no-op before it can reach `onStale` (shutdown relies on this to
6149
+ * prevent a stale-channel resurrection mid-teardown), and `tick()` runs a single
6150
+ * check (tests drive it directly).
6151
+ *
6152
+ * @param {{
6153
+ * getChannel: () => (import('./work-channel.mjs').WorkChannel | null),
6154
+ * disconnectedSince: () => (number|null),
6155
+ * onStale: () => (void|Promise<void>),
6156
+ * staleAfterMs?: number,
6157
+ * intervalMs?: number,
6158
+ * now?: () => number,
6159
+ * setIntervalFn?: typeof setInterval,
6160
+ * clearIntervalFn?: typeof clearInterval,
6161
+ * logger?: object|null,
6162
+ * }} opts
6163
+ * @returns {{ stop: () => void, tick: () => Promise<void> }}
6164
+ */
6165
+ function startAgenticChannelWatchdog({
6166
+ getChannel,
6167
+ disconnectedSince,
6168
+ onStale,
6169
+ staleAfterMs = DEFAULT_AGENTIC_STALE_MS,
6170
+ intervalMs = DEFAULT_AGENTIC_WATCHDOG_INTERVAL_MS,
6171
+ now = () => Date.now(),
6172
+ setIntervalFn = setInterval,
6173
+ clearIntervalFn = clearInterval,
6174
+ logger = null,
6175
+ } = {}) {
6176
+ let healing = false;
6177
+ // Set once `stop()` runs so any tick already scheduled/in flight around
6178
+ // shutdown becomes a no-op and can never re-open the channel mid-teardown
6179
+ // (shutdown relies on `stop()` to prevent a stale-channel resurrection).
6180
+ let stopped = false;
6181
+ // Per-episode latch: fire `onStale` exactly once when a connected channel goes
6182
+ // stale, and don't fire again until it recovers (a fresh episode). Without this
6183
+ // the helper would re-heal on every tick whenever `onStale` does not itself
6184
+ // clear the staleness signal, causing repeated teardown/re-discovery attempts.
6185
+ let firedForEpisode = false;
6186
+ const tick = async () => {
6187
+ if (stopped || healing) return; // shutting down, or a heal is in flight — don't stack a second re-discovery
6188
+ const ch = typeof getChannel === 'function' ? getChannel() : null;
6189
+ // No channel object → the initial open or the cold-start self-heal loop owns
6190
+ // recovery; the watchdog only guards a channel that HAS connected and stalled.
6191
+ // A missing or non-stale (healthy / recovered / still-connecting) channel also
6192
+ // ends any current stale episode, so re-arm the latch for the next one.
6193
+ if (!ch || !agenticChannelIsStale({
6194
+ connected: () => ch.connected(),
6195
+ everConnected: () => ch.everConnected(),
6196
+ disconnectedSince,
6197
+ now,
6198
+ staleAfterMs,
6199
+ })) {
6200
+ firedForEpisode = false;
6201
+ return;
6202
+ }
6203
+ if (firedForEpisode) return; // already fired once for this stale episode
6204
+ healing = true;
6205
+ firedForEpisode = true;
6206
+ try {
6207
+ if (stopped) return; // shutdown raced us between the checks — do not heal
6208
+ const since = disconnectedSince();
6209
+ const downFor = since != null ? Math.round((now() - since) / 1000) : '?';
6210
+ logger?.warn?.(` agentic channel: no reconnect ${downFor}s after drop — forcing re-discovery (the client lib did not self-heal; likely a half-open drop).`);
6211
+ await onStale?.();
6212
+ } catch (err) {
6213
+ firedForEpisode = false; // heal failed → re-arm so a later tick retries this episode
6214
+ logger?.debug?.(`agentic watchdog heal failed: ${err?.message || err}`);
6215
+ } finally {
6216
+ healing = false;
6217
+ }
6218
+ };
6219
+ const timer = setIntervalFn(() => { tick().catch(() => {}); }, intervalMs);
6220
+ if (timer && typeof timer.unref === 'function') timer.unref();
6221
+ return { stop: () => { stopped = true; try { clearIntervalFn(timer); } catch { /* best effort */ } }, tick };
6222
+ }
6223
+
6092
6224
  /**
6093
6225
  * Collapse an agentic disconnect/failure detail into the single short string the
6094
6226
  * marker's `agentic.message` field carries (#99 contract). Accepts the close
@@ -6587,6 +6719,17 @@ async function workAgent(req, flags) {
6587
6719
  let workChannel = null;
6588
6720
  /** @type {import('./work-buffer.mjs').BufferMonitor | null} */
6589
6721
  let bufferMonitor = null;
6722
+ // #144 liveness watchdog state. `agenticDisconnectedSince` is the epoch-ms the
6723
+ // channel last dropped (null whenever it is up or has never opened); the
6724
+ // watchdog uses it to force a full re-discovery + reopen when the client lib's
6725
+ // own reconnect fails to bring a previously-connected channel back within the
6726
+ // stale threshold. `agenticWatchdog` is the running timer handle (stopped on
6727
+ // shutdown); `agenticSelfHealing` guards against two concurrent re-discovery
6728
+ // loops (the cold-start one and a watchdog-triggered one).
6729
+ let agenticDisconnectedSince = null;
6730
+ /** @type {{ stop: () => void } | null} */
6731
+ let agenticWatchdog = null;
6732
+ let agenticSelfHealing = false;
6590
6733
  // Maintain `activeJobs` unconditionally: it feeds both the supervisor activity
6591
6734
  // file (gated inside writeActivity) AND the agentic presence frame's live
6592
6735
  // jobKey set, so a standalone worker (no NANO_SUPERVISOR_ACTIVITY_FILE) still
@@ -6696,11 +6839,21 @@ async function workAgent(req, flags) {
6696
6839
  // (before these listeners existed), connected() is false but everConnected()
6697
6840
  // is true — record that as `disconnected` rather than leaving it stuck at
6698
6841
  // `connecting`.
6699
- workChannel.onConnect(() => markAgentic('connected'));
6700
- workChannel.onReconnect(() => markAgentic('connected'));
6701
- workChannel.onDisconnect((info) => markAgentic('disconnected', normalizeAgenticMessage(info)));
6702
- if (workChannel.connected()) markAgentic('connected');
6703
- else if (workChannel.everConnected()) markAgentic('disconnected');
6842
+ // #144: track the drop clock alongside presence — a (re)connect clears it,
6843
+ // a disconnect starts it (first drop wins, so the watchdog measures from the
6844
+ // ORIGINAL drop, not the latest of a reconnect storm). The watchdog reads
6845
+ // this to decide when the client lib has failed to self-heal.
6846
+ workChannel.onConnect(() => { markAgentic('connected'); agenticDisconnectedSince = null; });
6847
+ workChannel.onReconnect(() => { markAgentic('connected'); agenticDisconnectedSince = null; });
6848
+ workChannel.onDisconnect((info) => {
6849
+ markAgentic('disconnected', normalizeAgenticMessage(info));
6850
+ if (agenticDisconnectedSince == null) agenticDisconnectedSince = Date.now();
6851
+ });
6852
+ if (workChannel.connected()) { markAgentic('connected'); agenticDisconnectedSince = null; }
6853
+ else if (workChannel.everConnected()) {
6854
+ markAgentic('disconnected');
6855
+ if (agenticDisconnectedSince == null) agenticDisconnectedSince = Date.now();
6856
+ }
6704
6857
  } catch (err) {
6705
6858
  // Never let a channel failure stop the worker from doing its actual job.
6706
6859
  workChannel = null;
@@ -6732,16 +6885,16 @@ async function workAgent(req, flags) {
6732
6885
  }
6733
6886
  };
6734
6887
 
6735
- if (agenticCfg) {
6736
- await openAgenticChannel(agenticCfg);
6737
- } else if (agenticTarget.status === 'advisory') {
6738
- // (A) Self-heal a cold-start discovery miss (#133): discovery is one-shot at
6739
- // enrolment, so a worker that merely lost the cold-start race (e.g. a slow
6740
- // link-local candidate blew the budget) would otherwise run `advisory` for
6741
- // its whole lifetime the only recovery being a restart. Keep re-discovering
6742
- // in the background on a jittered backoff and, on a later success, upgrade
6743
- // advisory→connected WITHOUT a restart, flipping the AGENTIC status surface.
6744
- // A shared cache lets a brief blip reuse the last known-good hub (#133-C).
6888
+ // (A) Background self-heal loop, shared by the cold-start advisory path (#133)
6889
+ // AND the #144 liveness watchdog. Re-run discovery on a jittered backoff and,
6890
+ // on the first `connect` target, (re)open the channel WITHOUT a restart. The
6891
+ // `agenticSelfHealing` guard makes it idempotent: the watchdog can call it
6892
+ // after tearing a stale channel down without racing a still-running cold-start
6893
+ // loop. A shared cache lets a brief blip reuse the last known-good hub (#133-C).
6894
+ const armAgenticSelfHeal = () => {
6895
+ if (agenticSelfHealing) return; // a re-discovery loop is already running
6896
+ if (workChannel !== null) return; // a channel already exists nothing to heal
6897
+ agenticSelfHealing = true;
6745
6898
  const hubCache = new Map();
6746
6899
  rediscoverAgenticUntilConnected({
6747
6900
  resolveTarget: () => resolveAgenticTarget({ camunda, logger, cache: hubCache }),
@@ -6749,7 +6902,7 @@ async function workAgent(req, flags) {
6749
6902
  agenticCfg = target.config;
6750
6903
  agenticState = agenticStateForTarget(target, safeAgenticDisplayUrl);
6751
6904
  writeActivity();
6752
- logger.info(' agentic channel: background re-discovery succeeded — upgrading advisory → connecting.');
6905
+ logger.info(' agentic channel: background re-discovery succeeded — (re)opening channel.');
6753
6906
  await openAgenticChannel(agenticCfg);
6754
6907
  // openAgenticChannel swallows its own open failures (it nulls
6755
6908
  // workChannel and returns rather than throwing), so a failed open must
@@ -6762,7 +6915,57 @@ async function workAgent(req, flags) {
6762
6915
  // Stop as soon as a channel exists (loop won this or a prior attempt did).
6763
6916
  shouldContinue: () => workChannel === null,
6764
6917
  logger,
6765
- }).catch(() => { /* best-effort self-heal — never surfaces an error */ });
6918
+ })
6919
+ .catch(() => { /* best-effort self-heal — never surfaces an error */ })
6920
+ .finally(() => { agenticSelfHealing = false; });
6921
+ };
6922
+
6923
+ // (B) #144 liveness watchdog: force-heal a wedged channel. When a channel that
6924
+ // HAS connected drops and the client lib's own reconnect never brings it back
6925
+ // within the stale threshold (a half-open drop after a server restart/crash/
6926
+ // partition, or a reconnect that keeps failing), the client sits `disconnected`
6927
+ // forever and the worker vanishes from the Workers view until a supervisor
6928
+ // restart. This tears the wedged channel down (so `shouldContinue` re-arms) and
6929
+ // re-runs full discovery + reopen instead of trusting the client lib alone.
6930
+ const healStaleAgenticChannel = async () => {
6931
+ const stale = workChannel;
6932
+ if (!stale) return;
6933
+ workChannel = null; // re-arms armAgenticSelfHeal()'s shouldContinue gate
6934
+ agenticDisconnectedSince = null; // reset the clock; the fresh open restarts it
6935
+ try { bufferMonitor?.stop(); } catch { /* best effort */ }
6936
+ bufferMonitor = null;
6937
+ markAgentic('disconnected', 'stale channel — re-discovering hub');
6938
+ // Deregister + close the wedged client so it stops its own doomed reconnect
6939
+ // attempts and we don't leak two clients once the fresh one connects.
6940
+ try { await stale.stop('stale channel — re-discovering'); } catch { /* best effort */ }
6941
+ armAgenticSelfHeal();
6942
+ };
6943
+
6944
+ const startAgenticWatchdog = () => {
6945
+ if (agenticWatchdog) return;
6946
+ const staleAfterMs = Math.max(5_000, intFlag(process.env.NANO_AGENTIC_STALE_MS, DEFAULT_AGENTIC_STALE_MS));
6947
+ const intervalMs = Math.max(1_000, intFlag(process.env.NANO_AGENTIC_WATCHDOG_MS, DEFAULT_AGENTIC_WATCHDOG_INTERVAL_MS));
6948
+ agenticWatchdog = startAgenticChannelWatchdog({
6949
+ getChannel: () => workChannel,
6950
+ disconnectedSince: () => agenticDisconnectedSince,
6951
+ onStale: healStaleAgenticChannel,
6952
+ staleAfterMs,
6953
+ intervalMs,
6954
+ logger,
6955
+ });
6956
+ };
6957
+
6958
+ if (agenticCfg) {
6959
+ await openAgenticChannel(agenticCfg);
6960
+ // Guard the connected channel: if it later drops and the client lib can't
6961
+ // recover it, the watchdog forces a full re-discovery + reopen (#144).
6962
+ startAgenticWatchdog();
6963
+ } else if (agenticTarget.status === 'advisory') {
6964
+ // A cold-start discovery miss leaves the worker `advisory`; the self-heal
6965
+ // loop upgrades it to `connected` without a restart (#133), and once a
6966
+ // channel exists the watchdog keeps it alive across later drops (#144).
6967
+ armAgenticSelfHeal();
6968
+ startAgenticWatchdog();
6766
6969
  }
6767
6970
 
6768
6971
  // C3 (#42): the role's live-terminal mode — a full PTY (streamed on the relay
@@ -7382,6 +7585,9 @@ async function workAgent(req, flags) {
7382
7585
  logger.info(`Received ${signal} — stopping ${list.length} worker(s)...`);
7383
7586
  if (reaperTimer) clearInterval(reaperTimer);
7384
7587
  if (runDirTimer) clearInterval(runDirTimer);
7588
+ // Stop the #144 liveness watchdog so it can't kick off a re-discovery
7589
+ // mid-teardown (which would resurrect the channel we're about to close).
7590
+ if (agenticWatchdog) { try { agenticWatchdog.stop(); } catch { /* best effort */ } agenticWatchdog = null; }
7385
7591
  const results = await Promise.all(list.map(drainWorker));
7386
7592
  const stopFailures = results.filter((ok) => !ok).length;
7387
7593
  if (stopFailures > 0) {
@@ -11751,6 +11957,8 @@ export {
11751
11957
  isLinkLocalAddress,
11752
11958
  rediscoverAgenticUntilConnected,
11753
11959
  defaultAgenticRediscoveryDelays,
11960
+ agenticChannelIsStale,
11961
+ startAgenticChannelWatchdog,
11754
11962
  };
11755
11963
  export { compareSemver, githubRepoSlug, filterReleasesSince, renderReleaseBody };
11756
11964
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.44.8",
3
+ "version": "1.44.9",
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",
@@ -57,12 +57,12 @@
57
57
  },
58
58
  "optionalDependencies": {
59
59
  "node-pty": "^1.0.0",
60
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.44.8",
61
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.44.8",
62
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.44.8",
63
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.44.8",
64
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.44.8",
65
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.44.8",
66
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.44.8"
60
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.44.9",
61
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.44.9",
62
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.44.9",
63
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.44.9",
64
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.44.9",
65
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.44.9",
66
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.44.9"
67
67
  }
68
68
  }
package/work-channel.mjs CHANGED
@@ -281,15 +281,25 @@ export async function createWorkChannel(opts) {
281
281
  everConnected: () => hasConnected,
282
282
  buffered: () => client.buffered,
283
283
  async stop(reason = 'worker stopped') {
284
+ // Deregister to drop presence cleanly, then ALWAYS close the socket so the
285
+ // client stops its own reconnect loop. Closing only on a deregister error
286
+ // (the old behaviour) left a successfully-deregistered client half-open and
287
+ // still reconnecting — which is exactly the wedged/duplicate-client case the
288
+ // #144 stale-channel heal path relies on stop() to end.
289
+ // deregister() is fire-and-forget but may be thenable (register() is
290
+ // treated as one above), so guard BOTH a synchronous throw and an async
291
+ // rejection — an unhandled rejection during shutdown must never escape.
284
292
  try {
285
- client.deregister(reason);
286
- } catch (err) {
287
- try {
293
+ Promise.resolve(client.deregister(reason)).catch((err) => {
288
294
  log.warn?.(`agentic deregister failed: ${err?.message || err}`);
289
- client.close();
290
- } catch {
291
- /* best effort never let shutdown hang on the channel */
292
- }
295
+ });
296
+ } catch (err) {
297
+ log.warn?.(`agentic deregister failed: ${err?.message || err}`);
298
+ }
299
+ try {
300
+ client.close();
301
+ } catch {
302
+ /* best effort — never let shutdown hang on the channel */
293
303
  }
294
304
  },
295
305
  };