c8ctl-plugin-nano 1.44.11 → 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/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,
@@ -6655,6 +6670,86 @@ function enableEngineHappyEyeballs(opts = {}) {
6655
6670
  return false;
6656
6671
  }
6657
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
+
6658
6753
  /**
6659
6754
  * work — turn a hire profile into live Nano job workers (one per job-type in
6660
6755
  * the rank×capability matrix) and poll for work in the foreground until Ctrl-C.
@@ -6902,6 +6997,14 @@ async function workAgent(req, flags) {
6902
6997
  // scaling the fleet to zero (jwulf/c8ctl-plugin-nano#139). Process-wide, so it
6903
6998
  // covers both the SDK's activateJobs and the raw-fetch --auto engine reads.
6904
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();
6905
7008
  const camunda = globalThis.c8ctl.createClient();
6906
7009
 
6907
7010
  // Broker REST endpoint for live linked-resource prompts (issue #63) and the
@@ -7688,9 +7791,12 @@ async function workAgent(req, flags) {
7688
7791
  if (isContainer) liveRunIds.delete(runId);
7689
7792
  if (runDir && !keepRuns) { try { rmSync(runDir, { recursive: true, force: true }); } catch { /* best effort */ } }
7690
7793
  if (runDir) liveRunDirs.delete(runDir);
7691
- // Detach the relay session's inbound-frame subscription so it never
7692
- // outlives the job or leaks a steer listener across jobs.
7693
- 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 */ } }
7694
7800
  }
7695
7801
 
7696
7802
  // Read the agent's structured result: the file it wrote, else a stdout
@@ -12427,6 +12533,10 @@ export {
12427
12533
  readDeployedAgentJobTypes,
12428
12534
  resolveAutoJobTypes,
12429
12535
  enableEngineHappyEyeballs,
12536
+ preferIpv4Resolution,
12537
+ ipv4FirstNodeOptions,
12538
+ withIpv4FirstNodeOptions,
12539
+ DNS_RESULT_ORDER_IPV4_FIRST,
12430
12540
  workAgent,
12431
12541
  derivePollTimeoutMs,
12432
12542
  AGENT_TASK_NS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.44.11",
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.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"
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