c8ctl-plugin-nano 1.44.10 → 1.44.11

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 +22 -0
  2. package/c8ctl-plugin.js +199 -14
  3. package/package.json +8 -8
package/README.md CHANGED
@@ -377,6 +377,28 @@ export NANO_AGENTIC_STALE_MS=60000 # force re-discovery if a drop hasn't rec
377
377
  export NANO_AGENTIC_WATCHDOG_MS=15000 # how often the watchdog checks channel liveness (default)
378
378
  ```
379
379
 
380
+ **Lossy-link hardening (reconnect churn that never re-lands presence).** A
381
+ distinct failure mode shows up on a **lossy/roaming WiFi (or NAT) link**: the drop
382
+ *is* detected (an abnormal closure, code `1006`), the client *does* reconnect and
383
+ re-announce — yet presence never re-lands on the hub, so the worker stays absent
384
+ even though its log shows it re-announcing. The sustained-drop watchdog above does
385
+ not catch this, because each brief reconnect keeps resetting its drop clock. Two
386
+ extra safeguards close the gap:
387
+
388
+ - **Presence-keyed watchdog trigger.** A reconnect only counts as recovered once
389
+ the socket *holds* for a short grace window (so a `1006` blip that immediately
390
+ re-drops does not mask an unrecovered presence). When presence has not been
391
+ confirmed within a threshold — regardless of the reconnect flapping — the
392
+ watchdog forces the same full re-discovery + reopen.
393
+ - **Jittered reconnect backoff.** Reconnect attempts are spread with equal-jitter
394
+ backoff so a fleet dropped on the same link does not reconnect in lockstep and
395
+ re-congest it.
396
+
397
+ ```bash
398
+ export NANO_AGENTIC_PRESENCE_STALE_MS=60000 # force re-discovery if presence isn't re-confirmed within 60s (default)
399
+ export NANO_AGENTIC_PRESENCE_GRACE_MS=5000 # how long a reconnect must hold before presence counts as landed (default)
400
+ ```
401
+
380
402
  **Secure mode (opt-in).** For a deployment where you want the visibility channel
381
403
  authenticated (rather than open on the LAN), start the server **and** every worker
382
404
  box with the **same** `NANO_AGENTIC_SECRET` — same env-var name, same value on both
package/c8ctl-plugin.js CHANGED
@@ -6259,6 +6259,20 @@ async function rediscoverAgenticUntilConnected({
6259
6259
  // client lib alone. Overridable via NANO_AGENTIC_STALE_MS / NANO_AGENTIC_WATCHDOG_MS.
6260
6260
  const DEFAULT_AGENTIC_STALE_MS = 60_000;
6261
6261
  const DEFAULT_AGENTIC_WATCHDOG_INTERVAL_MS = 15_000;
6262
+ // #147 presence-keyed staleness. #144's stale trigger keys on a SUSTAINED socket
6263
+ // drop (`disconnectedSince` ages past the threshold). On a lossy WiFi/NAT link
6264
+ // that drops `1006` repeatedly, the client lib reconnects and re-announces, but
6265
+ // each brief reconnect clears the drop clock — so the sustained-drop trigger
6266
+ // never fires even though presence never actually re-lands on the hub (the
6267
+ // worker stays absent from the Workers view). The presence-keyed trigger closes
6268
+ // that gap: a connection only counts as "presence confirmed" once it HOLDS for
6269
+ // the grace window, so a reconnect that immediately re-drops cannot mask an
6270
+ // unrecovered presence. When presence has not been confirmed within the stale
6271
+ // threshold — regardless of transient reconnect flapping — the watchdog forces a
6272
+ // full re-discovery + reopen. Overridable via NANO_AGENTIC_PRESENCE_STALE_MS /
6273
+ // NANO_AGENTIC_PRESENCE_GRACE_MS.
6274
+ const DEFAULT_AGENTIC_PRESENCE_STALE_MS = DEFAULT_AGENTIC_STALE_MS;
6275
+ const DEFAULT_AGENTIC_PRESENCE_GRACE_MS = 5_000;
6262
6276
 
6263
6277
  /**
6264
6278
  * Decide whether a worker's agentic channel is *stale* — i.e. it once connected,
@@ -6293,6 +6307,78 @@ function agenticChannelIsStale({
6293
6307
  return now() - since >= staleAfterMs;
6294
6308
  }
6295
6309
 
6310
+ /**
6311
+ * Decide whether a worker's agentic channel is *presence-stale* (#147) — i.e. it
6312
+ * once connected but its presence has NOT been re-confirmed on the hub within
6313
+ * `staleAfterMs`, even though the socket may be intermittently reconnecting. This
6314
+ * is the churn counterpart to {@link agenticChannelIsStale}: on a lossy link the
6315
+ * client lib reconnects and re-announces after every `1006`, which keeps clearing
6316
+ * the sustained-drop clock, so the socket-keyed trigger never fires — yet
6317
+ * presence never actually lands. `presenceHealthySince` is the epoch-ms of the
6318
+ * last time the channel was observed *stably* connected long enough for presence
6319
+ * to be considered landed. The first open seeds it immediately (that open drains
6320
+ * the buffered REGISTER, so presence lands with it); thereafter it advances only
6321
+ * once a connection has *held* past a grace window (via `onPresenceHealthy`), so a
6322
+ * reconnect that immediately re-drops does not count. Pure so the trigger is
6323
+ * unit-testable
6324
+ * without timers or sockets. A channel that never opened is not presence-stale
6325
+ * (the initial connect owns it); a null `presenceHealthySince` (never confirmed,
6326
+ * or reset by a heal) also reads as not-stale so a fresh open is given its grace.
6327
+ *
6328
+ * @param {{
6329
+ * everConnected: () => boolean,
6330
+ * presenceHealthySince?: () => (number|null),
6331
+ * now?: () => number,
6332
+ * staleAfterMs?: number,
6333
+ * }} opts
6334
+ * @returns {boolean}
6335
+ */
6336
+ function agenticPresenceIsStale({
6337
+ everConnected,
6338
+ presenceHealthySince,
6339
+ now = () => Date.now(),
6340
+ staleAfterMs = DEFAULT_AGENTIC_PRESENCE_STALE_MS,
6341
+ }) {
6342
+ if (typeof everConnected !== 'function') return false;
6343
+ if (!everConnected()) return false; // never opened → the initial connect owns it
6344
+ const since = typeof presenceHealthySince === 'function' ? presenceHealthySince() : null;
6345
+ if (since == null) return false; // never confirmed / freshly reset → give the open its grace
6346
+ return now() - since >= staleAfterMs;
6347
+ }
6348
+
6349
+ /**
6350
+ * Equal-jitter backoff (#147). Given a base backoff `baseMs`, keep half of it and
6351
+ * randomise the other half: `baseMs/2 + rand()*baseMs/2`. A fleet of workers that
6352
+ * all dropped on the same lossy link would otherwise reconnect in lockstep (the
6353
+ * client lib's exponential backoff is deterministic), re-congesting the link and
6354
+ * re-triggering the `1006` drops that stranded them. Spreading the reconnect
6355
+ * attempts bounds that thundering-herd churn while preserving the exponential
6356
+ * growth of the underlying policy. Pure (`rand` injectable) so it is testable.
6357
+ *
6358
+ * @param {number} baseMs the deterministic backoff the client lib computed
6359
+ * @param {{ rand?: () => number }} [opts]
6360
+ * @returns {number} the jittered delay in ms (0 when baseMs is non-positive)
6361
+ */
6362
+ function jitteredDelay(baseMs, { rand = Math.random } = {}) {
6363
+ const b = Number.isFinite(baseMs) && baseMs > 0 ? baseMs : 0;
6364
+ if (b === 0) return 0;
6365
+ const half = b / 2;
6366
+ return Math.round(half + rand() * half);
6367
+ }
6368
+
6369
+ /**
6370
+ * Build a reconnect `schedule` function that applies {@link jitteredDelay} to the
6371
+ * backoff the client lib passes, so the worker's reconnect attempts on a lossy
6372
+ * link are de-synchronised (#147). Drops straight into `createWorkChannel`'s
6373
+ * injectable `schedule` seam; the timer is injectable for tests.
6374
+ *
6375
+ * @param {{ rand?: () => number, timer?: (fn: () => void, ms: number) => any }} [opts]
6376
+ * @returns {(fn: () => void, ms: number) => void}
6377
+ */
6378
+ function makeJitteredReconnectSchedule({ rand = Math.random, timer = setTimeout } = {}) {
6379
+ return (fn, ms) => { timer(fn, jitteredDelay(ms, { rand })); };
6380
+ }
6381
+
6296
6382
  /**
6297
6383
  * Start the worker-side agentic-channel liveness watchdog (#144). On a fixed
6298
6384
  * interval it asks {@link agenticChannelIsStale} whether the channel dropped and
@@ -6310,9 +6396,23 @@ function agenticChannelIsStale({
6310
6396
  * prevent a stale-channel resurrection mid-teardown), and `tick()` runs a single
6311
6397
  * check (tests drive it directly).
6312
6398
  *
6399
+ * #147 adds a second, presence-keyed trigger alongside the #144 sustained-drop
6400
+ * one: each tick, a channel observed *stably* connected (connected for at least
6401
+ * `presenceGraceMs` since `connectedSince()`) advances the presence-health clock
6402
+ * via `onPresenceHealthy()`; when presence has not been confirmed within
6403
+ * `presenceStaleAfterMs` — even while the socket flaps `1006` reconnects — the
6404
+ * watchdog heals just as it does for a sustained drop. The presence accessors are
6405
+ * optional: omitting them leaves the #147 trigger inert, so the #144 behaviour is
6406
+ * unchanged.
6407
+ *
6313
6408
  * @param {{
6314
6409
  * getChannel: () => (import('./work-channel.mjs').WorkChannel | null),
6315
6410
  * disconnectedSince: () => (number|null),
6411
+ * connectedSince?: () => (number|null),
6412
+ * presenceHealthySince?: () => (number|null),
6413
+ * onPresenceHealthy?: () => void,
6414
+ * presenceGraceMs?: number,
6415
+ * presenceStaleAfterMs?: number,
6316
6416
  * onStale: () => (void|Promise<void>),
6317
6417
  * staleAfterMs?: number,
6318
6418
  * intervalMs?: number,
@@ -6327,6 +6427,11 @@ function startAgenticChannelWatchdog({
6327
6427
  getChannel,
6328
6428
  disconnectedSince,
6329
6429
  onStale,
6430
+ connectedSince = null,
6431
+ presenceHealthySince = null,
6432
+ onPresenceHealthy = null,
6433
+ presenceGraceMs = DEFAULT_AGENTIC_PRESENCE_GRACE_MS,
6434
+ presenceStaleAfterMs = DEFAULT_AGENTIC_PRESENCE_STALE_MS,
6330
6435
  staleAfterMs = DEFAULT_AGENTIC_STALE_MS,
6331
6436
  intervalMs = DEFAULT_AGENTIC_WATCHDOG_INTERVAL_MS,
6332
6437
  now = () => Date.now(),
@@ -6347,17 +6452,39 @@ function startAgenticChannelWatchdog({
6347
6452
  const tick = async () => {
6348
6453
  if (stopped || healing) return; // shutting down, or a heal is in flight — don't stack a second re-discovery
6349
6454
  const ch = typeof getChannel === 'function' ? getChannel() : null;
6455
+ // #147: advance the presence-health clock when the channel is observed
6456
+ // STABLY connected (held for the grace window). A churny link that reconnects
6457
+ // but immediately re-drops never accrues the grace, so its health clock ages
6458
+ // out and the presence-keyed trigger below fires — unlike the sustained-drop
6459
+ // trigger, which the churn keeps resetting. Inert unless the accessors are wired.
6460
+ if (ch && typeof onPresenceHealthy === 'function' && typeof connectedSince === 'function') {
6461
+ try {
6462
+ const cs = connectedSince();
6463
+ if (ch.connected() && cs != null && now() - cs >= presenceGraceMs) onPresenceHealthy();
6464
+ } catch { /* best effort — never let a health probe break the tick */ }
6465
+ }
6466
+ // The channel is stale when EITHER trigger fires: #144's sustained socket
6467
+ // drop, or #147's presence-not-confirmed-despite-reconnect-churn.
6468
+ const stale = !!ch && (
6469
+ agenticChannelIsStale({
6470
+ connected: () => ch.connected(),
6471
+ everConnected: () => ch.everConnected(),
6472
+ disconnectedSince,
6473
+ now,
6474
+ staleAfterMs,
6475
+ })
6476
+ || agenticPresenceIsStale({
6477
+ everConnected: () => ch.everConnected(),
6478
+ presenceHealthySince,
6479
+ now,
6480
+ staleAfterMs: presenceStaleAfterMs,
6481
+ })
6482
+ );
6350
6483
  // No channel object → the initial open or the cold-start self-heal loop owns
6351
6484
  // recovery; the watchdog only guards a channel that HAS connected and stalled.
6352
6485
  // A missing or non-stale (healthy / recovered / still-connecting) channel also
6353
6486
  // ends any current stale episode, so re-arm the latch for the next one.
6354
- if (!ch || !agenticChannelIsStale({
6355
- connected: () => ch.connected(),
6356
- everConnected: () => ch.everConnected(),
6357
- disconnectedSince,
6358
- now,
6359
- staleAfterMs,
6360
- })) {
6487
+ if (!stale) {
6361
6488
  firedForEpisode = false;
6362
6489
  return;
6363
6490
  }
@@ -6366,9 +6493,16 @@ function startAgenticChannelWatchdog({
6366
6493
  firedForEpisode = true;
6367
6494
  try {
6368
6495
  if (stopped) return; // shutdown raced us between the checks — do not heal
6369
- const since = disconnectedSince();
6370
- const downFor = since != null ? Math.round((now() - since) / 1000) : '?';
6371
- 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).`);
6496
+ const since = typeof disconnectedSince === 'function' ? disconnectedSince() : null;
6497
+ // During reconnect churn `disconnectedSince` can be null (no sustained drop)
6498
+ // or reset by the latest blip, so it under-reports the real staleness. Fall
6499
+ // back to the presence-health clock — the signal we actually acted on — so
6500
+ // the logged age reflects how long presence has genuinely been unconfirmed.
6501
+ const staleSince = since != null
6502
+ ? since
6503
+ : (typeof presenceHealthySince === 'function' ? presenceHealthySince() : null);
6504
+ const downFor = staleSince != null ? Math.round((now() - staleSince) / 1000) : '?';
6505
+ logger?.warn?.(` agentic channel: presence not confirmed within threshold (down ${downFor}s / reconnect churn) — forcing re-discovery (the client lib did not self-heal; likely a lossy-link 1006 churn or half-open drop).`);
6372
6506
  await onStale?.();
6373
6507
  } catch (err) {
6374
6508
  firedForEpisode = false; // heal failed → re-arm so a later tick retries this episode
@@ -6888,6 +7022,16 @@ async function workAgent(req, flags) {
6888
7022
  // shutdown); `agenticSelfHealing` guards against two concurrent re-discovery
6889
7023
  // loops (the cold-start one and a watchdog-triggered one).
6890
7024
  let agenticDisconnectedSince = null;
7025
+ // #147 presence-keyed watchdog state. `agenticConnectedSince` is the epoch-ms
7026
+ // the channel last (re)connected (null while down); the watchdog uses it to
7027
+ // require a stable connection to have held for a grace window before counting
7028
+ // presence as confirmed. `agenticPresenceHealthyAt` is the epoch-ms presence
7029
+ // was last confirmed healthy — advanced on the first connect (buffered REGISTER
7030
+ // drains) and by the watchdog whenever a stable connection is observed, and
7031
+ // reset by a heal. When it ages past the presence-stale threshold — even while
7032
+ // the socket flaps `1006` reconnects — the watchdog forces a re-discovery.
7033
+ let agenticConnectedSince = null;
7034
+ let agenticPresenceHealthyAt = null;
6891
7035
  /** @type {{ stop: () => void } | null} */
6892
7036
  let agenticWatchdog = null;
6893
7037
  let agenticSelfHealing = false;
@@ -6985,6 +7129,11 @@ async function workAgent(req, flags) {
6985
7129
  token: cfg.token,
6986
7130
  credential: cfg.credential,
6987
7131
  bufferCapacity: cfg.bufferCapacity,
7132
+ // #147: de-synchronise reconnect attempts with equal-jitter backoff so a
7133
+ // fleet dropped on the same lossy link does not reconnect in lockstep and
7134
+ // re-congest it. Wraps the client lib's own exponential policy (which has
7135
+ // no jitter of its own); the base delays/factor stay the lib's defaults.
7136
+ schedule: makeJitteredReconnectSchedule(),
6988
7137
  logger,
6989
7138
  });
6990
7139
  const shown = redactAgenticUrl(buildAgenticUrl(cfg.url, {}));
@@ -7004,16 +7153,38 @@ async function workAgent(req, flags) {
7004
7153
  // a disconnect starts it (first drop wins, so the watchdog measures from the
7005
7154
  // ORIGINAL drop, not the latest of a reconnect storm). The watchdog reads
7006
7155
  // this to decide when the client lib has failed to self-heal.
7007
- workChannel.onConnect(() => { markAgentic('connected'); agenticDisconnectedSince = null; });
7008
- workChannel.onReconnect(() => { markAgentic('connected'); agenticDisconnectedSince = null; });
7156
+ workChannel.onConnect(() => {
7157
+ markAgentic('connected');
7158
+ agenticDisconnectedSince = null;
7159
+ agenticConnectedSince = Date.now();
7160
+ // First open drains the buffered REGISTER → presence lands; seed the
7161
+ // presence-health clock so the #147 trigger measures from here (#147).
7162
+ agenticPresenceHealthyAt = Date.now();
7163
+ });
7164
+ workChannel.onReconnect(() => {
7165
+ markAgentic('connected');
7166
+ agenticDisconnectedSince = null;
7167
+ agenticConnectedSince = Date.now();
7168
+ // Deliberately do NOT advance agenticPresenceHealthyAt here: a reconnect
7169
+ // only CLAIMS presence (re-announces). On a lossy link the socket may
7170
+ // re-drop `1006` before presence actually lands, so the watchdog confirms
7171
+ // it only once a connection HOLDS for the grace window — a reconnect that
7172
+ // immediately re-drops must not mask an unrecovered presence (#147).
7173
+ });
7009
7174
  workChannel.onDisconnect((info) => {
7010
7175
  markAgentic('disconnected', normalizeAgenticMessage(info));
7011
7176
  if (agenticDisconnectedSince == null) agenticDisconnectedSince = Date.now();
7177
+ agenticConnectedSince = null;
7012
7178
  });
7013
- if (workChannel.connected()) { markAgentic('connected'); agenticDisconnectedSince = null; }
7014
- else if (workChannel.everConnected()) {
7179
+ if (workChannel.connected()) {
7180
+ markAgentic('connected');
7181
+ agenticDisconnectedSince = null;
7182
+ agenticConnectedSince = Date.now();
7183
+ if (agenticPresenceHealthyAt == null) agenticPresenceHealthyAt = Date.now();
7184
+ } else if (workChannel.everConnected()) {
7015
7185
  markAgentic('disconnected');
7016
7186
  if (agenticDisconnectedSince == null) agenticDisconnectedSince = Date.now();
7187
+ agenticConnectedSince = null;
7017
7188
  }
7018
7189
  } catch (err) {
7019
7190
  // Never let a channel failure stop the worker from doing its actual job.
@@ -7093,6 +7264,8 @@ async function workAgent(req, flags) {
7093
7264
  if (!stale) return;
7094
7265
  workChannel = null; // re-arms armAgenticSelfHeal()'s shouldContinue gate
7095
7266
  agenticDisconnectedSince = null; // reset the clock; the fresh open restarts it
7267
+ agenticConnectedSince = null; // #147: the fresh open re-seeds it
7268
+ agenticPresenceHealthyAt = null; // #147: the fresh open re-confirms presence
7096
7269
  try { bufferMonitor?.stop(); } catch { /* best effort */ }
7097
7270
  bufferMonitor = null;
7098
7271
  markAgentic('disconnected', 'stale channel — re-discovering hub');
@@ -7106,9 +7279,18 @@ async function workAgent(req, flags) {
7106
7279
  if (agenticWatchdog) return;
7107
7280
  const staleAfterMs = Math.max(5_000, intFlag(process.env.NANO_AGENTIC_STALE_MS, DEFAULT_AGENTIC_STALE_MS));
7108
7281
  const intervalMs = Math.max(1_000, intFlag(process.env.NANO_AGENTIC_WATCHDOG_MS, DEFAULT_AGENTIC_WATCHDOG_INTERVAL_MS));
7282
+ const presenceStaleAfterMs = Math.max(5_000, intFlag(process.env.NANO_AGENTIC_PRESENCE_STALE_MS, DEFAULT_AGENTIC_PRESENCE_STALE_MS));
7283
+ const presenceGraceMs = Math.max(1_000, intFlag(process.env.NANO_AGENTIC_PRESENCE_GRACE_MS, DEFAULT_AGENTIC_PRESENCE_GRACE_MS));
7109
7284
  agenticWatchdog = startAgenticChannelWatchdog({
7110
7285
  getChannel: () => workChannel,
7111
7286
  disconnectedSince: () => agenticDisconnectedSince,
7287
+ // #147: presence-keyed trigger — heal reconnect-churn that never re-lands
7288
+ // presence, not just a sustained socket drop.
7289
+ connectedSince: () => agenticConnectedSince,
7290
+ presenceHealthySince: () => agenticPresenceHealthyAt,
7291
+ onPresenceHealthy: () => { agenticPresenceHealthyAt = Date.now(); },
7292
+ presenceStaleAfterMs,
7293
+ presenceGraceMs,
7112
7294
  onStale: healStaleAgenticChannel,
7113
7295
  staleAfterMs,
7114
7296
  intervalMs,
@@ -12154,6 +12336,9 @@ export {
12154
12336
  rediscoverAgenticUntilConnected,
12155
12337
  defaultAgenticRediscoveryDelays,
12156
12338
  agenticChannelIsStale,
12339
+ agenticPresenceIsStale,
12340
+ jitteredDelay,
12341
+ makeJitteredReconnectSchedule,
12157
12342
  startAgenticChannelWatchdog,
12158
12343
  };
12159
12344
  export { compareSemver, githubRepoSlug, filterReleasesSince, renderReleaseBody };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.44.10",
3
+ "version": "1.44.11",
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.10",
61
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.44.10",
62
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.44.10",
63
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.44.10",
64
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.44.10",
65
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.44.10",
66
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.44.10"
60
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.44.11",
61
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.44.11",
62
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.44.11",
63
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.44.11",
64
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.44.11",
65
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.44.11",
66
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.44.11"
67
67
  }
68
68
  }