c8ctl-plugin-nano 1.44.10 → 1.44.12

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
@@ -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
@@ -53,6 +53,7 @@ import {
53
53
  import { createConnection, createServer } from 'node:net';
54
54
  import * as nodeNet from 'node:net';
55
55
  import { lookup as dnsLookup } from 'node:dns/promises';
56
+ import * as nodeDns from 'node:dns';
56
57
  import { randomUUID, createHash, randomBytes } from 'node:crypto';
57
58
  import { homedir, platform as osPlatform, devNull, tmpdir, hostname } from 'node:os';
58
59
  import { join, isAbsolute, resolve as resolvePath, dirname, basename, sep } from 'node:path';
@@ -5463,7 +5464,11 @@ function runAgentJob(profile, job, opts = {}) {
5463
5464
  return Promise.resolve({ ok: false, exitCode: null, stdout: '', stderr: '', error: 'command-line args (--arg) are not supported for host execution on Windows; use a container sandbox or bake switches into the command', truncated: false, stderrTruncated: false });
5464
5465
  }
5465
5466
  const resultEnv = resultFile ? { [AGENT_RESULT_FILE_ENV]: resultFile } : {};
5466
- const harnessEnv = { ...process.env, ...staticEnv, ...secretEnv, ...agentEnv, ...extraEnv, ...resultEnv };
5467
+ // Propagate IPv4-first DNS ordering into the forked agent via NODE_OPTIONS
5468
+ // (jwulf/c8ctl-plugin-nano#151): the parent's process-wide setDefaultResultOrder
5469
+ // can't reach the child, so its own Node runtime must read the flag at startup.
5470
+ // Merges (never clobbers) any inherited/operator NODE_OPTIONS.
5471
+ const harnessEnv = withIpv4FirstNodeOptions({ ...process.env, ...staticEnv, ...secretEnv, ...agentEnv, ...extraEnv, ...resultEnv });
5467
5472
 
5468
5473
  // A role opted into ACP (`protocol: acp`) drives its harness over the Agent
5469
5474
  // Client Protocol (JSON-RPC 2.0 over stdio) instead of the stdin/scrape pipe
@@ -5566,6 +5571,16 @@ function runAgentJob(profile, job, opts = {}) {
5566
5571
  for (const n of passThroughSecretNames) envArgs.push('-e', n);
5567
5572
  for (const k of Object.keys(staticEnv)) envArgs.push('-e', k);
5568
5573
 
5574
+ // Propagate IPv4-first DNS ordering into the containerised agent's Node runtime
5575
+ // via NODE_OPTIONS (jwulf/c8ctl-plugin-nano#151), merged (never clobbered) with
5576
+ // any inherited/operator value. Forwarded by NAME like every other env so the
5577
+ // value stays out of argv/`docker inspect`; `-e NODE_OPTIONS` is added only when
5578
+ // it wasn't already forwarded via the static env above (avoid a duplicate flag).
5579
+ const containerEnv = withIpv4FirstNodeOptions({ ...process.env, ...staticEnv, ...secretEnv, ...agentEnv, ...extraEnv, ...resultEnv });
5580
+ if (!Object.prototype.hasOwnProperty.call(staticEnv, 'NODE_OPTIONS')) {
5581
+ envArgs.push('-e', 'NODE_OPTIONS');
5582
+ }
5583
+
5569
5584
  const args = [
5570
5585
  'run', '--rm', '-i',
5571
5586
  '--name', containerName,
@@ -5588,7 +5603,7 @@ function runAgentJob(profile, job, opts = {}) {
5588
5603
  // Reserved harness env (AGENT_* + the result-file path) is layered AFTER
5589
5604
  // resolved secrets so a task-supplied secret NAME can never shadow it. In
5590
5605
  // container mode docker reads these values from our child env by NAME.
5591
- env: { ...process.env, ...staticEnv, ...secretEnv, ...agentEnv, ...extraEnv, ...resultEnv },
5606
+ env: containerEnv,
5592
5607
  stdinData: payload,
5593
5608
  timeoutMs,
5594
5609
  idleTimeoutMs,
@@ -6259,6 +6274,20 @@ async function rediscoverAgenticUntilConnected({
6259
6274
  // client lib alone. Overridable via NANO_AGENTIC_STALE_MS / NANO_AGENTIC_WATCHDOG_MS.
6260
6275
  const DEFAULT_AGENTIC_STALE_MS = 60_000;
6261
6276
  const DEFAULT_AGENTIC_WATCHDOG_INTERVAL_MS = 15_000;
6277
+ // #147 presence-keyed staleness. #144's stale trigger keys on a SUSTAINED socket
6278
+ // drop (`disconnectedSince` ages past the threshold). On a lossy WiFi/NAT link
6279
+ // that drops `1006` repeatedly, the client lib reconnects and re-announces, but
6280
+ // each brief reconnect clears the drop clock — so the sustained-drop trigger
6281
+ // never fires even though presence never actually re-lands on the hub (the
6282
+ // worker stays absent from the Workers view). The presence-keyed trigger closes
6283
+ // that gap: a connection only counts as "presence confirmed" once it HOLDS for
6284
+ // the grace window, so a reconnect that immediately re-drops cannot mask an
6285
+ // unrecovered presence. When presence has not been confirmed within the stale
6286
+ // threshold — regardless of transient reconnect flapping — the watchdog forces a
6287
+ // full re-discovery + reopen. Overridable via NANO_AGENTIC_PRESENCE_STALE_MS /
6288
+ // NANO_AGENTIC_PRESENCE_GRACE_MS.
6289
+ const DEFAULT_AGENTIC_PRESENCE_STALE_MS = DEFAULT_AGENTIC_STALE_MS;
6290
+ const DEFAULT_AGENTIC_PRESENCE_GRACE_MS = 5_000;
6262
6291
 
6263
6292
  /**
6264
6293
  * Decide whether a worker's agentic channel is *stale* — i.e. it once connected,
@@ -6293,6 +6322,78 @@ function agenticChannelIsStale({
6293
6322
  return now() - since >= staleAfterMs;
6294
6323
  }
6295
6324
 
6325
+ /**
6326
+ * Decide whether a worker's agentic channel is *presence-stale* (#147) — i.e. it
6327
+ * once connected but its presence has NOT been re-confirmed on the hub within
6328
+ * `staleAfterMs`, even though the socket may be intermittently reconnecting. This
6329
+ * is the churn counterpart to {@link agenticChannelIsStale}: on a lossy link the
6330
+ * client lib reconnects and re-announces after every `1006`, which keeps clearing
6331
+ * the sustained-drop clock, so the socket-keyed trigger never fires — yet
6332
+ * presence never actually lands. `presenceHealthySince` is the epoch-ms of the
6333
+ * last time the channel was observed *stably* connected long enough for presence
6334
+ * to be considered landed. The first open seeds it immediately (that open drains
6335
+ * the buffered REGISTER, so presence lands with it); thereafter it advances only
6336
+ * once a connection has *held* past a grace window (via `onPresenceHealthy`), so a
6337
+ * reconnect that immediately re-drops does not count. Pure so the trigger is
6338
+ * unit-testable
6339
+ * without timers or sockets. A channel that never opened is not presence-stale
6340
+ * (the initial connect owns it); a null `presenceHealthySince` (never confirmed,
6341
+ * or reset by a heal) also reads as not-stale so a fresh open is given its grace.
6342
+ *
6343
+ * @param {{
6344
+ * everConnected: () => boolean,
6345
+ * presenceHealthySince?: () => (number|null),
6346
+ * now?: () => number,
6347
+ * staleAfterMs?: number,
6348
+ * }} opts
6349
+ * @returns {boolean}
6350
+ */
6351
+ function agenticPresenceIsStale({
6352
+ everConnected,
6353
+ presenceHealthySince,
6354
+ now = () => Date.now(),
6355
+ staleAfterMs = DEFAULT_AGENTIC_PRESENCE_STALE_MS,
6356
+ }) {
6357
+ if (typeof everConnected !== 'function') return false;
6358
+ if (!everConnected()) return false; // never opened → the initial connect owns it
6359
+ const since = typeof presenceHealthySince === 'function' ? presenceHealthySince() : null;
6360
+ if (since == null) return false; // never confirmed / freshly reset → give the open its grace
6361
+ return now() - since >= staleAfterMs;
6362
+ }
6363
+
6364
+ /**
6365
+ * Equal-jitter backoff (#147). Given a base backoff `baseMs`, keep half of it and
6366
+ * randomise the other half: `baseMs/2 + rand()*baseMs/2`. A fleet of workers that
6367
+ * all dropped on the same lossy link would otherwise reconnect in lockstep (the
6368
+ * client lib's exponential backoff is deterministic), re-congesting the link and
6369
+ * re-triggering the `1006` drops that stranded them. Spreading the reconnect
6370
+ * attempts bounds that thundering-herd churn while preserving the exponential
6371
+ * growth of the underlying policy. Pure (`rand` injectable) so it is testable.
6372
+ *
6373
+ * @param {number} baseMs the deterministic backoff the client lib computed
6374
+ * @param {{ rand?: () => number }} [opts]
6375
+ * @returns {number} the jittered delay in ms (0 when baseMs is non-positive)
6376
+ */
6377
+ function jitteredDelay(baseMs, { rand = Math.random } = {}) {
6378
+ const b = Number.isFinite(baseMs) && baseMs > 0 ? baseMs : 0;
6379
+ if (b === 0) return 0;
6380
+ const half = b / 2;
6381
+ return Math.round(half + rand() * half);
6382
+ }
6383
+
6384
+ /**
6385
+ * Build a reconnect `schedule` function that applies {@link jitteredDelay} to the
6386
+ * backoff the client lib passes, so the worker's reconnect attempts on a lossy
6387
+ * link are de-synchronised (#147). Drops straight into `createWorkChannel`'s
6388
+ * injectable `schedule` seam; the timer is injectable for tests.
6389
+ *
6390
+ * @param {{ rand?: () => number, timer?: (fn: () => void, ms: number) => any }} [opts]
6391
+ * @returns {(fn: () => void, ms: number) => void}
6392
+ */
6393
+ function makeJitteredReconnectSchedule({ rand = Math.random, timer = setTimeout } = {}) {
6394
+ return (fn, ms) => { timer(fn, jitteredDelay(ms, { rand })); };
6395
+ }
6396
+
6296
6397
  /**
6297
6398
  * Start the worker-side agentic-channel liveness watchdog (#144). On a fixed
6298
6399
  * interval it asks {@link agenticChannelIsStale} whether the channel dropped and
@@ -6310,9 +6411,23 @@ function agenticChannelIsStale({
6310
6411
  * prevent a stale-channel resurrection mid-teardown), and `tick()` runs a single
6311
6412
  * check (tests drive it directly).
6312
6413
  *
6414
+ * #147 adds a second, presence-keyed trigger alongside the #144 sustained-drop
6415
+ * one: each tick, a channel observed *stably* connected (connected for at least
6416
+ * `presenceGraceMs` since `connectedSince()`) advances the presence-health clock
6417
+ * via `onPresenceHealthy()`; when presence has not been confirmed within
6418
+ * `presenceStaleAfterMs` — even while the socket flaps `1006` reconnects — the
6419
+ * watchdog heals just as it does for a sustained drop. The presence accessors are
6420
+ * optional: omitting them leaves the #147 trigger inert, so the #144 behaviour is
6421
+ * unchanged.
6422
+ *
6313
6423
  * @param {{
6314
6424
  * getChannel: () => (import('./work-channel.mjs').WorkChannel | null),
6315
6425
  * disconnectedSince: () => (number|null),
6426
+ * connectedSince?: () => (number|null),
6427
+ * presenceHealthySince?: () => (number|null),
6428
+ * onPresenceHealthy?: () => void,
6429
+ * presenceGraceMs?: number,
6430
+ * presenceStaleAfterMs?: number,
6316
6431
  * onStale: () => (void|Promise<void>),
6317
6432
  * staleAfterMs?: number,
6318
6433
  * intervalMs?: number,
@@ -6327,6 +6442,11 @@ function startAgenticChannelWatchdog({
6327
6442
  getChannel,
6328
6443
  disconnectedSince,
6329
6444
  onStale,
6445
+ connectedSince = null,
6446
+ presenceHealthySince = null,
6447
+ onPresenceHealthy = null,
6448
+ presenceGraceMs = DEFAULT_AGENTIC_PRESENCE_GRACE_MS,
6449
+ presenceStaleAfterMs = DEFAULT_AGENTIC_PRESENCE_STALE_MS,
6330
6450
  staleAfterMs = DEFAULT_AGENTIC_STALE_MS,
6331
6451
  intervalMs = DEFAULT_AGENTIC_WATCHDOG_INTERVAL_MS,
6332
6452
  now = () => Date.now(),
@@ -6347,17 +6467,39 @@ function startAgenticChannelWatchdog({
6347
6467
  const tick = async () => {
6348
6468
  if (stopped || healing) return; // shutting down, or a heal is in flight — don't stack a second re-discovery
6349
6469
  const ch = typeof getChannel === 'function' ? getChannel() : null;
6470
+ // #147: advance the presence-health clock when the channel is observed
6471
+ // STABLY connected (held for the grace window). A churny link that reconnects
6472
+ // but immediately re-drops never accrues the grace, so its health clock ages
6473
+ // out and the presence-keyed trigger below fires — unlike the sustained-drop
6474
+ // trigger, which the churn keeps resetting. Inert unless the accessors are wired.
6475
+ if (ch && typeof onPresenceHealthy === 'function' && typeof connectedSince === 'function') {
6476
+ try {
6477
+ const cs = connectedSince();
6478
+ if (ch.connected() && cs != null && now() - cs >= presenceGraceMs) onPresenceHealthy();
6479
+ } catch { /* best effort — never let a health probe break the tick */ }
6480
+ }
6481
+ // The channel is stale when EITHER trigger fires: #144's sustained socket
6482
+ // drop, or #147's presence-not-confirmed-despite-reconnect-churn.
6483
+ const stale = !!ch && (
6484
+ agenticChannelIsStale({
6485
+ connected: () => ch.connected(),
6486
+ everConnected: () => ch.everConnected(),
6487
+ disconnectedSince,
6488
+ now,
6489
+ staleAfterMs,
6490
+ })
6491
+ || agenticPresenceIsStale({
6492
+ everConnected: () => ch.everConnected(),
6493
+ presenceHealthySince,
6494
+ now,
6495
+ staleAfterMs: presenceStaleAfterMs,
6496
+ })
6497
+ );
6350
6498
  // No channel object → the initial open or the cold-start self-heal loop owns
6351
6499
  // recovery; the watchdog only guards a channel that HAS connected and stalled.
6352
6500
  // A missing or non-stale (healthy / recovered / still-connecting) channel also
6353
6501
  // 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
- })) {
6502
+ if (!stale) {
6361
6503
  firedForEpisode = false;
6362
6504
  return;
6363
6505
  }
@@ -6366,9 +6508,16 @@ function startAgenticChannelWatchdog({
6366
6508
  firedForEpisode = true;
6367
6509
  try {
6368
6510
  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).`);
6511
+ const since = typeof disconnectedSince === 'function' ? disconnectedSince() : null;
6512
+ // During reconnect churn `disconnectedSince` can be null (no sustained drop)
6513
+ // or reset by the latest blip, so it under-reports the real staleness. Fall
6514
+ // back to the presence-health clock — the signal we actually acted on — so
6515
+ // the logged age reflects how long presence has genuinely been unconfirmed.
6516
+ const staleSince = since != null
6517
+ ? since
6518
+ : (typeof presenceHealthySince === 'function' ? presenceHealthySince() : null);
6519
+ const downFor = staleSince != null ? Math.round((now() - staleSince) / 1000) : '?';
6520
+ 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
6521
  await onStale?.();
6373
6522
  } catch (err) {
6374
6523
  firedForEpisode = false; // heal failed → re-arm so a later tick retries this episode
@@ -6521,6 +6670,86 @@ function enableEngineHappyEyeballs(opts = {}) {
6521
6670
  return false;
6522
6671
  }
6523
6672
 
6673
+ // The Node DNS result-order token that ranks any A (IPv4) record ahead of an
6674
+ // AAAA (IPv6) one, so a host advertised over mDNS (`*.local`) that answers with
6675
+ // an unreachable IPv6 link-local `fe80::…` FIRST is still connected over its
6676
+ // reachable IPv4 A record. Used both process-wide (setDefaultResultOrder) and as
6677
+ // the `--dns-result-order` value propagated into the forked agent's NODE_OPTIONS.
6678
+ const DNS_RESULT_ORDER_IPV4_FIRST = 'ipv4first';
6679
+
6680
+ /**
6681
+ * Prefer IPv4 whenever an A record exists, PROCESS-WIDE, by flipping Node's
6682
+ * default DNS result order to `ipv4first` (jwulf/c8ctl-plugin-nano#151). This is
6683
+ * the harness-wide *class* fix that complements the engine-client Happy-Eyeballs
6684
+ * enabler (#139): Happy-Eyeballs races families but can't help when the *only*
6685
+ * answer picked is a single dead address, and it doesn't govern ordering; forcing
6686
+ * `ipv4first` makes every `dns.lookup` (and therefore every outbound the worker
6687
+ * and any in-process client make — the SDK's `activateJobs`, the raw-`fetch`
6688
+ * `--auto` engine reads, agentic discovery) rank the reachable A record ahead of
6689
+ * a dead AAAA. Because `dns.lookup` re-resolves per connection (Node keeps no
6690
+ * process-wide lookup cache), a *running* worker that started against a bad
6691
+ * resolution also heals the moment DNS is corrected — no supervisor restart — as
6692
+ * the next long-poll / reconcile re-resolves under the new ordering.
6693
+ *
6694
+ * Fail-open and idempotent: a runtime without `setDefaultResultOrder` (or any
6695
+ * throw) is swallowed so an ordering hint — an optimisation — can never block a
6696
+ * worker from starting.
6697
+ *
6698
+ * @param {{ dns?: object, order?: string }} [opts] injection seam for tests
6699
+ * @returns {boolean} true if the default DNS result order was (re)asserted
6700
+ */
6701
+ function preferIpv4Resolution(opts = {}) {
6702
+ try {
6703
+ // Destructure INSIDE the try so a non-object arg (e.g. `null`) fails open
6704
+ // like any other throw rather than blowing up before the guard.
6705
+ const { dns = nodeDns, order = DNS_RESULT_ORDER_IPV4_FIRST } = opts || {};
6706
+ if (dns && typeof dns.setDefaultResultOrder === 'function') {
6707
+ dns.setDefaultResultOrder(order);
6708
+ return true;
6709
+ }
6710
+ } catch {
6711
+ // Fail-open: DNS ordering is a connectivity optimisation, never a start gate.
6712
+ }
6713
+ return false;
6714
+ }
6715
+
6716
+ /**
6717
+ * Merge `--dns-result-order=ipv4first` into an existing `NODE_OPTIONS` string so
6718
+ * a forked agent child (and any node-based tool it spawns) inherits the same
6719
+ * IPv4-first ordering as its parent worker (jwulf/c8ctl-plugin-nano#151) — a
6720
+ * `dns.setDefaultResultOrder` call in THIS process can't reach the child, but the
6721
+ * child's own Node runtime reads the flag from `NODE_OPTIONS` at startup.
6722
+ *
6723
+ * Preserves any other options already in `NODE_OPTIONS` (we append, never
6724
+ * clobber) and, crucially, RESPECTS an operator who has *explicitly* pinned a
6725
+ * `--dns-result-order` (e.g. `verbatim`): we only add ours when none is present,
6726
+ * so we harden the default without overriding a deliberate choice.
6727
+ *
6728
+ * @param {string} [existing] the incoming NODE_OPTIONS value (may be empty)
6729
+ * @returns {string} the NODE_OPTIONS value with an ordering flag guaranteed
6730
+ */
6731
+ function ipv4FirstNodeOptions(existing = '') {
6732
+ const flag = `--dns-result-order=${DNS_RESULT_ORDER_IPV4_FIRST}`;
6733
+ const cur = typeof existing === 'string' ? existing.trim() : '';
6734
+ // An explicit operator ordering (any `--dns-result-order=…`) wins untouched.
6735
+ if (/--dns-result-order[=\s]/.test(cur)) return cur;
6736
+ return cur ? `${cur} ${flag}` : flag;
6737
+ }
6738
+
6739
+ /**
6740
+ * Return a shallow copy of an env map whose `NODE_OPTIONS` carries the
6741
+ * IPv4-first ordering flag, so the forked harness inherits it (#151). Applied at
6742
+ * every harness spawn site (host pipe/PTY/ACP and container) so the ordering
6743
+ * follows the agent regardless of transport. Never mutates the input.
6744
+ *
6745
+ * @param {Record<string,string>} [env] the env map about to be handed to a child
6746
+ * @returns {Record<string,string>} a new env map with NODE_OPTIONS hardened
6747
+ */
6748
+ function withIpv4FirstNodeOptions(env) {
6749
+ const base = env && typeof env === 'object' ? env : {};
6750
+ return { ...base, NODE_OPTIONS: ipv4FirstNodeOptions(base.NODE_OPTIONS) };
6751
+ }
6752
+
6524
6753
  /**
6525
6754
  * work — turn a hire profile into live Nano job workers (one per job-type in
6526
6755
  * the rank×capability matrix) and poll for work in the foreground until Ctrl-C.
@@ -6768,6 +6997,14 @@ async function workAgent(req, flags) {
6768
6997
  // scaling the fleet to zero (jwulf/c8ctl-plugin-nano#139). Process-wide, so it
6769
6998
  // covers both the SDK's activateJobs and the raw-fetch --auto engine reads.
6770
6999
  enableEngineHappyEyeballs();
7000
+ // Prefer IPv4 whenever an A record exists (jwulf/c8ctl-plugin-nano#151): the
7001
+ // process-wide, class-level complement to Happy-Eyeballs. `ipv4first` ranks the
7002
+ // reachable A record ahead of a dead AAAA (mDNS `fe80::` link-local), so BOTH
7003
+ // the SDK activateJobs and the raw-fetch --auto reads resolve to it — and,
7004
+ // since Node re-resolves per connection, a running worker heals the moment a
7005
+ // bad DNS answer is corrected, without a supervisor restart. Set before the
7006
+ // client is created so every outbound inherits it.
7007
+ preferIpv4Resolution();
6771
7008
  const camunda = globalThis.c8ctl.createClient();
6772
7009
 
6773
7010
  // Broker REST endpoint for live linked-resource prompts (issue #63) and the
@@ -6888,6 +7125,16 @@ async function workAgent(req, flags) {
6888
7125
  // shutdown); `agenticSelfHealing` guards against two concurrent re-discovery
6889
7126
  // loops (the cold-start one and a watchdog-triggered one).
6890
7127
  let agenticDisconnectedSince = null;
7128
+ // #147 presence-keyed watchdog state. `agenticConnectedSince` is the epoch-ms
7129
+ // the channel last (re)connected (null while down); the watchdog uses it to
7130
+ // require a stable connection to have held for a grace window before counting
7131
+ // presence as confirmed. `agenticPresenceHealthyAt` is the epoch-ms presence
7132
+ // was last confirmed healthy — advanced on the first connect (buffered REGISTER
7133
+ // drains) and by the watchdog whenever a stable connection is observed, and
7134
+ // reset by a heal. When it ages past the presence-stale threshold — even while
7135
+ // the socket flaps `1006` reconnects — the watchdog forces a re-discovery.
7136
+ let agenticConnectedSince = null;
7137
+ let agenticPresenceHealthyAt = null;
6891
7138
  /** @type {{ stop: () => void } | null} */
6892
7139
  let agenticWatchdog = null;
6893
7140
  let agenticSelfHealing = false;
@@ -6985,6 +7232,11 @@ async function workAgent(req, flags) {
6985
7232
  token: cfg.token,
6986
7233
  credential: cfg.credential,
6987
7234
  bufferCapacity: cfg.bufferCapacity,
7235
+ // #147: de-synchronise reconnect attempts with equal-jitter backoff so a
7236
+ // fleet dropped on the same lossy link does not reconnect in lockstep and
7237
+ // re-congest it. Wraps the client lib's own exponential policy (which has
7238
+ // no jitter of its own); the base delays/factor stay the lib's defaults.
7239
+ schedule: makeJitteredReconnectSchedule(),
6988
7240
  logger,
6989
7241
  });
6990
7242
  const shown = redactAgenticUrl(buildAgenticUrl(cfg.url, {}));
@@ -7004,16 +7256,38 @@ async function workAgent(req, flags) {
7004
7256
  // a disconnect starts it (first drop wins, so the watchdog measures from the
7005
7257
  // ORIGINAL drop, not the latest of a reconnect storm). The watchdog reads
7006
7258
  // 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; });
7259
+ workChannel.onConnect(() => {
7260
+ markAgentic('connected');
7261
+ agenticDisconnectedSince = null;
7262
+ agenticConnectedSince = Date.now();
7263
+ // First open drains the buffered REGISTER → presence lands; seed the
7264
+ // presence-health clock so the #147 trigger measures from here (#147).
7265
+ agenticPresenceHealthyAt = Date.now();
7266
+ });
7267
+ workChannel.onReconnect(() => {
7268
+ markAgentic('connected');
7269
+ agenticDisconnectedSince = null;
7270
+ agenticConnectedSince = Date.now();
7271
+ // Deliberately do NOT advance agenticPresenceHealthyAt here: a reconnect
7272
+ // only CLAIMS presence (re-announces). On a lossy link the socket may
7273
+ // re-drop `1006` before presence actually lands, so the watchdog confirms
7274
+ // it only once a connection HOLDS for the grace window — a reconnect that
7275
+ // immediately re-drops must not mask an unrecovered presence (#147).
7276
+ });
7009
7277
  workChannel.onDisconnect((info) => {
7010
7278
  markAgentic('disconnected', normalizeAgenticMessage(info));
7011
7279
  if (agenticDisconnectedSince == null) agenticDisconnectedSince = Date.now();
7280
+ agenticConnectedSince = null;
7012
7281
  });
7013
- if (workChannel.connected()) { markAgentic('connected'); agenticDisconnectedSince = null; }
7014
- else if (workChannel.everConnected()) {
7282
+ if (workChannel.connected()) {
7283
+ markAgentic('connected');
7284
+ agenticDisconnectedSince = null;
7285
+ agenticConnectedSince = Date.now();
7286
+ if (agenticPresenceHealthyAt == null) agenticPresenceHealthyAt = Date.now();
7287
+ } else if (workChannel.everConnected()) {
7015
7288
  markAgentic('disconnected');
7016
7289
  if (agenticDisconnectedSince == null) agenticDisconnectedSince = Date.now();
7290
+ agenticConnectedSince = null;
7017
7291
  }
7018
7292
  } catch (err) {
7019
7293
  // Never let a channel failure stop the worker from doing its actual job.
@@ -7093,6 +7367,8 @@ async function workAgent(req, flags) {
7093
7367
  if (!stale) return;
7094
7368
  workChannel = null; // re-arms armAgenticSelfHeal()'s shouldContinue gate
7095
7369
  agenticDisconnectedSince = null; // reset the clock; the fresh open restarts it
7370
+ agenticConnectedSince = null; // #147: the fresh open re-seeds it
7371
+ agenticPresenceHealthyAt = null; // #147: the fresh open re-confirms presence
7096
7372
  try { bufferMonitor?.stop(); } catch { /* best effort */ }
7097
7373
  bufferMonitor = null;
7098
7374
  markAgentic('disconnected', 'stale channel — re-discovering hub');
@@ -7106,9 +7382,18 @@ async function workAgent(req, flags) {
7106
7382
  if (agenticWatchdog) return;
7107
7383
  const staleAfterMs = Math.max(5_000, intFlag(process.env.NANO_AGENTIC_STALE_MS, DEFAULT_AGENTIC_STALE_MS));
7108
7384
  const intervalMs = Math.max(1_000, intFlag(process.env.NANO_AGENTIC_WATCHDOG_MS, DEFAULT_AGENTIC_WATCHDOG_INTERVAL_MS));
7385
+ const presenceStaleAfterMs = Math.max(5_000, intFlag(process.env.NANO_AGENTIC_PRESENCE_STALE_MS, DEFAULT_AGENTIC_PRESENCE_STALE_MS));
7386
+ const presenceGraceMs = Math.max(1_000, intFlag(process.env.NANO_AGENTIC_PRESENCE_GRACE_MS, DEFAULT_AGENTIC_PRESENCE_GRACE_MS));
7109
7387
  agenticWatchdog = startAgenticChannelWatchdog({
7110
7388
  getChannel: () => workChannel,
7111
7389
  disconnectedSince: () => agenticDisconnectedSince,
7390
+ // #147: presence-keyed trigger — heal reconnect-churn that never re-lands
7391
+ // presence, not just a sustained socket drop.
7392
+ connectedSince: () => agenticConnectedSince,
7393
+ presenceHealthySince: () => agenticPresenceHealthyAt,
7394
+ onPresenceHealthy: () => { agenticPresenceHealthyAt = Date.now(); },
7395
+ presenceStaleAfterMs,
7396
+ presenceGraceMs,
7112
7397
  onStale: healStaleAgenticChannel,
7113
7398
  staleAfterMs,
7114
7399
  intervalMs,
@@ -7506,9 +7791,12 @@ async function workAgent(req, flags) {
7506
7791
  if (isContainer) liveRunIds.delete(runId);
7507
7792
  if (runDir && !keepRuns) { try { rmSync(runDir, { recursive: true, force: true }); } catch { /* best effort */ } }
7508
7793
  if (runDir) liveRunDirs.delete(runDir);
7509
- // Detach the relay session's inbound-frame subscription so it never
7510
- // outlives the job or leaks a steer listener across jobs.
7511
- if (relaySession) { try { relaySession.close(); } catch { /* best effort */ } }
7794
+ // Emit the relay session's `phase:close` lifecycle event and drain its
7795
+ // outbound buffer before the job settles (so the live-terminal tail is
7796
+ // flushed, nanobpm/nano-workforce#710), then detach its inbound-frame
7797
+ // subscription so it never outlives the job or leaks a steer listener
7798
+ // across jobs. Bounded internally — a hub outage never wedges completion.
7799
+ if (relaySession) { try { await relaySession.close(); } catch { /* best effort */ } }
7512
7800
  }
7513
7801
 
7514
7802
  // Read the agent's structured result: the file it wrote, else a stdout
@@ -12154,6 +12442,9 @@ export {
12154
12442
  rediscoverAgenticUntilConnected,
12155
12443
  defaultAgenticRediscoveryDelays,
12156
12444
  agenticChannelIsStale,
12445
+ agenticPresenceIsStale,
12446
+ jitteredDelay,
12447
+ makeJitteredReconnectSchedule,
12157
12448
  startAgenticChannelWatchdog,
12158
12449
  };
12159
12450
  export { compareSemver, githubRepoSlug, filterReleasesSince, renderReleaseBody };
@@ -12242,6 +12533,10 @@ export {
12242
12533
  readDeployedAgentJobTypes,
12243
12534
  resolveAutoJobTypes,
12244
12535
  enableEngineHappyEyeballs,
12536
+ preferIpv4Resolution,
12537
+ ipv4FirstNodeOptions,
12538
+ withIpv4FirstNodeOptions,
12539
+ DNS_RESULT_ORDER_IPV4_FIRST,
12245
12540
  workAgent,
12246
12541
  derivePollTimeoutMs,
12247
12542
  AGENT_TASK_NS,
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.12",
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.12",
61
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.44.12",
62
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.44.12",
63
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.44.12",
64
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.44.12",
65
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.44.12",
66
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.44.12"
67
67
  }
68
68
  }
package/work-relay.mjs CHANGED
@@ -62,6 +62,36 @@ export const RELAY_OPEN_CHUNK = `${agenticTranscript.encodeTranscriptEvent({
62
62
  phase: 'open',
63
63
  })}\n`;
64
64
 
65
+ /**
66
+ * The final produce frame emitted on a relay stream the instant a session is
67
+ * closed: the closing twin of {@link RELAY_OPEN_CHUNK} — a canonical
68
+ * `@nanobpm/agentic` lifecycle "close" transcript event.
69
+ *
70
+ * It carries no agent output — its sole job is to CLOSE the `job:<jobKey>`
71
+ * stream so the app can flush the durable transcript deterministically at job
72
+ * completion (nanobpm/nano-workforce#710), instead of relying only on the
73
+ * supersede/disconnect fallback (which can abandon the tail frames). Without it,
74
+ * a completed job's live-terminal transcript is truncated at the tail. Emitted
75
+ * from {@link RelaySession.close} before the outbound buffer is drained, so the
76
+ * close marker itself rides the same buffered, QoS-ordered relay lane as the
77
+ * agent's output (and survives a brief hub outage via C4's ring where possible).
78
+ * Newline-framed to match `RELAY_OPEN_CHUNK`, and derived through
79
+ * `encodeTranscriptEvent` (never hand-rolled) so the wire marker stays
80
+ * single-sourced in `@nanobpm/agentic`.
81
+ */
82
+ export const RELAY_CLOSE_CHUNK = `${agenticTranscript.encodeTranscriptEvent({
83
+ kind: 'lifecycle',
84
+ phase: 'close',
85
+ })}\n`;
86
+
87
+ /** Default bound on the outbound-buffer drain at session close (ms). A hub
88
+ * outage must never wedge job completion, so the drain is always bounded: on
89
+ * timeout the caller completes anyway and the app's supersede/disconnect
90
+ * fallback still eventually flushes whatever arrived. */
91
+ export const DEFAULT_DRAIN_TIMEOUT_MS = 2_000;
92
+ /** Default poll cadence while awaiting `channel.buffered() → 0` (ms). */
93
+ export const DEFAULT_DRAIN_POLL_MS = 25;
94
+
65
95
  /**
66
96
  * Resolve a role's terminal mode — whether the agent harness for this role gets
67
97
  * a full PTY or a plain pipe. Honors the vocab's per-role opt-in: a role may set
@@ -113,7 +143,7 @@ export function parseInboundRelayChunk(frame, stream) {
113
143
  * @property {string} stream the relay stream name (derived from the jobKey)
114
144
  * @property {(chunk: string|Uint8Array) => void} relay publish one framed, jobKey-tagged output chunk on the relay lane
115
145
  * @property {(write: (chunk: string) => void) => (() => void)} attachSteer wire inbound steer bytes for this stream to `write`; returns a detach fn
116
- * @property {() => void} close detach any steer subscription
146
+ * @property {() => Promise<{ closeEmitted: boolean, drained: boolean, timedOut: boolean }>} close emit the `phase:close` lifecycle event, detach any steer subscription, then drain the outbound buffer (bounded)
117
147
  */
118
148
 
119
149
  /**
@@ -132,9 +162,21 @@ export function parseInboundRelayChunk(frame, stream) {
132
162
  * @param {import('./work-channel.mjs').WorkChannel} opts.channel the C2 channel holder (NOT re-instantiated)
133
163
  * @param {string|number} opts.jobKey the activated job's key; tags every frame and names the stream
134
164
  * @param {{ warn?: Function, debug?: Function }} [opts.logger]
165
+ * @param {number} [opts.drainTimeoutMs] bound on the close-time outbound-buffer drain (ms); a hub outage must never wedge completion
166
+ * @param {number} [opts.drainPollMs] poll cadence while awaiting `channel.buffered() → 0` (ms)
167
+ * @param {(ms: number) => Promise<void>} [opts.sleep] injectable delay (tests); defaults to a `setTimeout` promise
168
+ * @param {() => number} [opts.now] injectable clock (tests); defaults to `Date.now`
135
169
  * @returns {RelaySession}
136
170
  */
137
- export function createRelaySession({ channel, jobKey, logger } = {}) {
171
+ export function createRelaySession({
172
+ channel,
173
+ jobKey,
174
+ logger,
175
+ drainTimeoutMs = DEFAULT_DRAIN_TIMEOUT_MS,
176
+ drainPollMs = DEFAULT_DRAIN_POLL_MS,
177
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
178
+ now = () => Date.now(),
179
+ } = {}) {
138
180
  if (!channel || typeof channel.relayLane !== 'function') {
139
181
  throw new Error('createRelaySession requires a WorkChannel with a relayLane() accessor');
140
182
  }
@@ -207,8 +249,56 @@ export function createRelaySession({ channel, jobKey, logger } = {}) {
207
249
  return detach;
208
250
  };
209
251
 
252
+ // The outbound-buffer drain: poll `channel.buffered()` down to zero, bounded
253
+ // by drainTimeoutMs. A channel without a buffered() accessor (or one that
254
+ // throws) is treated as already drained — the drain must never block or crash
255
+ // completion. Returns whether the buffer emptied and whether we hit the bound.
256
+ const drain = async () => {
257
+ if (typeof channel.buffered !== 'function') return { drained: true, timedOut: false };
258
+ const deadline = now() + Math.max(0, Number(drainTimeoutMs) || 0);
259
+ for (;;) {
260
+ let pending;
261
+ try {
262
+ pending = channel.buffered();
263
+ } catch {
264
+ return { drained: true, timedOut: false };
265
+ }
266
+ if (!(Number(pending) > 0)) return { drained: true, timedOut: false };
267
+ if (now() >= deadline) {
268
+ try {
269
+ log.warn?.(`relay drain timed out for ${stream}: ${pending} frame(s) still buffered; completing anyway`);
270
+ } catch {
271
+ /* never let a logging failure escape the drain path */
272
+ }
273
+ return { drained: false, timedOut: true };
274
+ }
275
+ await sleep(Math.max(1, Number(drainPollMs) || 1));
276
+ }
277
+ };
278
+
279
+ // close() is idempotent: a second call returns the same settled promise
280
+ // without re-emitting the close marker or re-draining.
281
+ let closed = false;
282
+ let closedPromise = Promise.resolve({ closeEmitted: false, drained: true, timedOut: false });
210
283
  const close = () => {
284
+ if (closed) return closedPromise;
285
+ closed = true;
286
+ // Emit the closing lifecycle twin of RELAY_OPEN_CHUNK FIRST, so the app can
287
+ // flush the durable transcript deterministically at completion
288
+ // (nanobpm/nano-workforce#710). Routed through the internal relay(), which
289
+ // swallows sink errors, so emitting the close marker never fails the job. It
290
+ // rides the same buffered relay lane as the agent's output, and is included
291
+ // in the drain below.
292
+ relay(RELAY_CLOSE_CHUNK);
293
+ // Detach every steer subscription so none outlives the job.
211
294
  for (const detach of [...activeDetaches]) detach();
295
+ // Then drain the outbound buffer — a bounded await until the agent's tail
296
+ // bytes (and the close marker) are actually transmitted before the job
297
+ // settles. The bound is essential: a hub outage must not wedge completion,
298
+ // so on timeout we resolve anyway (the app's supersede/disconnect fallback
299
+ // still eventually flushes what arrived).
300
+ closedPromise = drain().then((res) => ({ closeEmitted: true, ...res }));
301
+ return closedPromise;
212
302
  };
213
303
 
214
304
  // Open the stream the instant the session exists, so the app correlates