c8ctl-plugin-nano 1.29.0 → 1.30.0

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
@@ -59,6 +59,7 @@ import { createInterface as createReadline } from 'node:readline';
59
59
  import { platformForHost } from './platforms.mjs';
60
60
  import { createWorkChannel, redactAgenticUrl, buildAgenticUrl } from './work-channel.mjs';
61
61
  import { createRelaySession, roleTerminalMode } from './work-relay.mjs';
62
+ import { createBufferMonitor, resolveBufferCapacity } from './work-buffer.mjs';
62
63
 
63
64
  const requireFromHere = createRequire(import.meta.url);
64
65
  const pluginDir = dirname(fileURLToPath(import.meta.url));
@@ -3457,7 +3458,7 @@ function buildResultEnvelope(result, { sandbox, image, git, result: agentResult
3457
3458
  * and a capability credential are present (enrolment) — absent either, it runs
3458
3459
  * exactly as before, off the visibility page. Returns `null` when not enrolled.
3459
3460
  *
3460
- * @returns {{ url: string, token: string, credential: string } | null}
3461
+ * @returns {{ url: string, token: string, credential: string, bufferCapacity: number } | null}
3461
3462
  */
3462
3463
  function resolveAgenticConfig() {
3463
3464
  const cfg = readConfig();
@@ -3469,7 +3470,13 @@ function resolveAgenticConfig() {
3469
3470
  const token = process.env.NANO_AGENTIC_TOKEN || cfg.agenticToken || '';
3470
3471
  const credential = process.env.NANO_AGENTIC_CREDENTIAL || cfg.agenticCredential || '';
3471
3472
  if (!url || !token || !credential) return null;
3472
- return { url, token, credential };
3473
+ // Outbound hub-down buffer bound (frames). Operator-tunable (C4, #43) so a
3474
+ // long expected outage can be given more headroom; resolveBufferCapacity
3475
+ // validates it to a positive integer and falls back to the client default.
3476
+ const bufferCapacity = resolveBufferCapacity(
3477
+ process.env.NANO_AGENTIC_BUFFER_CAPACITY ?? cfg.agenticBufferCapacity,
3478
+ );
3479
+ return { url, token, credential, bufferCapacity };
3473
3480
  }
3474
3481
 
3475
3482
  /**
@@ -3720,6 +3727,8 @@ async function workAgent(req, flags) {
3720
3727
  // recorders can refresh presence with the live job set as jobs start/end.
3721
3728
  /** @type {import('./work-channel.mjs').WorkChannel | null} */
3722
3729
  let workChannel = null;
3730
+ /** @type {import('./work-buffer.mjs').BufferMonitor | null} */
3731
+ let bufferMonitor = null;
3723
3732
  // Maintain `activeJobs` unconditionally: it feeds both the supervisor activity
3724
3733
  // file (gated inside writeActivity) AND the agentic presence frame's live
3725
3734
  // jobKey set, so a standalone worker (no NANO_SUPERVISOR_ACTIVITY_FILE) still
@@ -3763,6 +3772,7 @@ async function workAgent(req, flags) {
3763
3772
  url: agenticCfg.url,
3764
3773
  token: agenticCfg.token,
3765
3774
  credential: agenticCfg.credential,
3775
+ bufferCapacity: agenticCfg.bufferCapacity,
3766
3776
  logger,
3767
3777
  });
3768
3778
  const shown = redactAgenticUrl(buildAgenticUrl(agenticCfg.url, {}));
@@ -3772,6 +3782,23 @@ async function workAgent(req, flags) {
3772
3782
  workChannel = null;
3773
3783
  logger.warn(` agentic channel unavailable (${err?.message || err}); continuing without visibility.`);
3774
3784
  }
3785
+ // C4 (#43): observe the client's built-in outbound buffer across the
3786
+ // channel lifecycle — surface a high-water mark and warn when the bound
3787
+ // is hit so a hub outage that starts shedding frames is never silent. The
3788
+ // monitor is observability-only, so keep it OUTSIDE the channel try/catch:
3789
+ // a monitor failure must never null out a healthy channel and take down
3790
+ // presence/visibility.
3791
+ if (workChannel) {
3792
+ try {
3793
+ bufferMonitor = createBufferMonitor(workChannel, {
3794
+ capacity: agenticCfg.bufferCapacity,
3795
+ logger,
3796
+ });
3797
+ } catch (err) {
3798
+ bufferMonitor = null;
3799
+ logger.warn(` agentic buffer monitor unavailable (${err?.message || err}); channel presence still active.`);
3800
+ }
3801
+ }
3775
3802
  } else {
3776
3803
  logger.info(' agentic channel: not enrolled (set NANO_AGENTIC_URL + NANO_AGENTIC_TOKEN + NANO_AGENTIC_CREDENTIAL to appear on the visibility page).');
3777
3804
  }
@@ -4189,6 +4216,10 @@ async function workAgent(req, flags) {
4189
4216
  // from the page only once its jobs have drained. Best-effort — a channel
4190
4217
  // teardown must never hang shutdown.
4191
4218
  if (workChannel) {
4219
+ // Stop the buffer monitor first so its sampler can't fire mid-teardown.
4220
+ try {
4221
+ bufferMonitor?.stop();
4222
+ } catch { /* best effort */ }
4192
4223
  try {
4193
4224
  await workChannel.stop(`worker stopped (${signal})`);
4194
4225
  logger.info('Deregistered from the agentic visibility channel.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.29.0",
3
+ "version": "1.30.0",
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",
@@ -26,6 +26,7 @@
26
26
  "agentic-loader-hook.mjs",
27
27
  "work-channel.mjs",
28
28
  "work-relay.mjs",
29
+ "work-buffer.mjs",
29
30
  "nanobpmn-binary.json",
30
31
  "README.md"
31
32
  ],
@@ -56,12 +57,12 @@
56
57
  },
57
58
  "optionalDependencies": {
58
59
  "node-pty": "^1.0.0",
59
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.29.0",
60
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.29.0",
61
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.29.0",
62
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.29.0",
63
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.29.0",
64
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.29.0",
65
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.29.0"
60
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.30.0",
61
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.30.0",
62
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.30.0",
63
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.30.0",
64
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.30.0",
65
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.30.0",
66
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.30.0"
66
67
  }
67
68
  }
@@ -0,0 +1,331 @@
1
+ // The `work` command's hub-down buffer observability + policy layer
2
+ // (ADR 0056 — slice C4, jwulf/c8ctl-plugin-nano#43).
3
+ //
4
+ // C4 makes a running worker survive hub disconnects: while the app hub is
5
+ // unreachable (the worker started before the app, or the hub restarted) the
6
+ // worker keeps producing frames, and they drain — bounded and in order — when
7
+ // the channel comes back.
8
+ //
9
+ // DERIVATION OVER DUPLICATION. The bounded local buffer this slice is about
10
+ // ALREADY exists as the connected client's built-in `OutboundRing` (in
11
+ // `@nanobpm/urban-agent-client`): a QoS-aware, capacity-bounded ring that holds
12
+ // every outbound frame while the socket is down and drains in strict lane
13
+ // priority (control → interactive → bulk, FIFO within a lane) on reconnect,
14
+ // shedding the single least-important frame on overflow. It sits at the
15
+ // TRANSPORT seam — below the lanes — so it captures any lane's frames and is
16
+ // therefore independent of C3's relay producer. We do NOT re-declare a second
17
+ // ring here (that would be a parallel, drift-prone buffer over the same
18
+ // frames); we CONSUME the canonical one through C2's `WorkChannel` seam.
19
+ //
20
+ // What this slice actually adds over C2's client is the two things the built-in
21
+ // ring leaves implicit:
22
+ //
23
+ // 1. The bound is OPERATOR-CONFIGURABLE, not a buried literal — see
24
+ // {@link resolveBufferCapacity} (wired to `NANO_AGENTIC_BUFFER_CAPACITY`
25
+ // in `resolveAgenticConfig`), so a long expected outage can be given more
26
+ // headroom without a code change.
27
+ // 2. The drop/backpressure policy is OBSERVABLE. The client sheds overflow
28
+ // frames silently (`relay()` returns void; the evicted frame is dropped
29
+ // inside the ring). {@link createBufferMonitor} turns that silent bound
30
+ // into a visible signal: it watches the buffer depth across C2's
31
+ // connect / disconnect / reconnect lifecycle, records a high-water mark
32
+ // and each outage→flush, and warns when the bound is hit so a hit bound is
33
+ // never silent data loss.
34
+ //
35
+ // The monitor is driven ENTIRELY by C2's lifecycle events + the client's
36
+ // buffer-drained event; it never opens, authenticates, or re-instantiates the
37
+ // channel, and it never produces frames of its own. It is pure observation over
38
+ // the one connected client.
39
+
40
+ // The outbound-ring bound (frames) the client buffers while the hub is
41
+ // unreachable. Single-sourced from the transport seam (`work-channel.mjs`),
42
+ // which applies it to the client, so the "falls back to the client default"
43
+ // contract stays accurate from one edit — no drift-prone second literal here.
44
+ import { DEFAULT_BUFFER_CAPACITY } from './work-channel.mjs';
45
+
46
+ const DEFAULT_SAMPLE_INTERVAL_MS = 1_000;
47
+
48
+ export { DEFAULT_BUFFER_CAPACITY };
49
+
50
+ /**
51
+ * Resolve the outbound-buffer bound (in frames) from an operator-supplied value
52
+ * with a sane fallback. The bound MUST be a positive integer — the client's
53
+ * `OutboundRing` throws on a non-positive capacity, so we validate here and fall
54
+ * back rather than let a typo wedge enrolment. Accepts a number or a numeric
55
+ * string (env vars arrive as strings).
56
+ *
57
+ * @param {unknown} raw the operator value (e.g. `process.env.NANO_AGENTIC_BUFFER_CAPACITY`)
58
+ * @param {number} [fallback] the default when `raw` is absent/invalid
59
+ * @returns {number} a positive-integer frame bound
60
+ */
61
+ export function resolveBufferCapacity(raw, fallback = DEFAULT_BUFFER_CAPACITY) {
62
+ const base = Number.isInteger(fallback) && fallback > 0 ? fallback : DEFAULT_BUFFER_CAPACITY;
63
+ if (raw === undefined || raw === null || raw === '') return base;
64
+ const n = typeof raw === 'number' ? raw : Number(String(raw).trim());
65
+ if (!Number.isInteger(n) || n < 1) return base;
66
+ return n;
67
+ }
68
+
69
+ /**
70
+ * @typedef {object} BufferHealth
71
+ * @property {number} capacity the configured frame bound
72
+ * @property {number} buffered frames currently held awaiting a live channel
73
+ * @property {boolean} connected whether the channel is currently open
74
+ * @property {number} highWaterMark the deepest buffer depth observed
75
+ * @property {number} outages number of times the channel went from up→down (buffering began)
76
+ * @property {number} reconnects number of times the channel recovered (up again after a drop)
77
+ * @property {number} flushes number of outage backlogs that fully drained on (re)connect
78
+ * @property {number} lastFlushFrames backlog size captured at the (re)connect that drove the last flush
79
+ * @property {number|null} lastFlushAt timestamp (ms) the last flush completed, or null
80
+ * @property {number} atCapacityEvents times a sample found the buffer at/over its bound (overflow shedding)
81
+ * @property {boolean} atCapacity whether the last sample was at/over the bound
82
+ */
83
+
84
+ /**
85
+ * @typedef {object} BufferMonitor
86
+ * @property {() => BufferHealth} health snapshot of the buffer's current health/metrics
87
+ * @property {() => number} sample take a depth sample now (updates high-water / at-capacity); returns the depth
88
+ * @property {() => void} stop detach all listeners and stop sampling (idempotent)
89
+ */
90
+
91
+ /**
92
+ * Observe the connected client's built-in outbound buffer across C2's channel
93
+ * lifecycle and surface its health + the (otherwise silent) drop policy.
94
+ *
95
+ * The monitor:
96
+ * - reads live depth via `channel.buffered()` (the client's `OutboundRing`
97
+ * size) — it does not hold its own buffer;
98
+ * - on the FIRST connect and every RECONNECT, captures the backlog about to
99
+ * flush (the client fires `onOpen`/our lifecycle listeners BEFORE it pumps
100
+ * the ring, so the depth read here is the pre-drain backlog) and, when the
101
+ * client's `onDrain` then fires (ring emptied after sending), records the
102
+ * completed flush;
103
+ * - on DISCONNECT (or immediately, if the worker starts before the app and is
104
+ * not connected yet), enters an "outage" and samples depth periodically so a
105
+ * growing backlog that hits the bound is noticed and warned about;
106
+ * - keeps a high-water mark and an at-capacity counter, warning (once per
107
+ * transition into the at-capacity state, to avoid log spam) so operators see
108
+ * that the bound is shedding frames.
109
+ *
110
+ * @param {import('./work-channel.mjs').WorkChannel} channel the C2 seam holder
111
+ * @param {object} [opts]
112
+ * @param {number} [opts.capacity] the configured bound (for health/at-capacity); defaults to DEFAULT_BUFFER_CAPACITY
113
+ * @param {number} [opts.sampleIntervalMs] periodic depth-sample cadence while in an outage; <=0 disables the timer
114
+ * @param {{ warn?: Function, info?: Function, debug?: Function }} [opts.logger] optional logger
115
+ * @param {() => number} [opts.now] injectable clock (tests); defaults to Date.now
116
+ * @param {{ setInterval: Function, clearInterval: Function }} [opts.timers] injectable timers (tests)
117
+ * @returns {BufferMonitor}
118
+ */
119
+ export function createBufferMonitor(channel, opts = {}) {
120
+ if (!channel || typeof channel.buffered !== 'function') {
121
+ throw new Error('createBufferMonitor requires a WorkChannel with a buffered() accessor');
122
+ }
123
+ const capacity = resolveBufferCapacity(opts.capacity, DEFAULT_BUFFER_CAPACITY);
124
+ const sampleIntervalMs = Number.isFinite(opts.sampleIntervalMs)
125
+ ? opts.sampleIntervalMs
126
+ : DEFAULT_SAMPLE_INTERVAL_MS;
127
+ const log = opts.logger || {};
128
+ const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
129
+ const timers = opts.timers || { setInterval, clearInterval };
130
+
131
+ const state = {
132
+ highWaterMark: 0,
133
+ outages: 0,
134
+ reconnects: 0,
135
+ flushes: 0,
136
+ lastFlushFrames: 0,
137
+ lastFlushAt: /** @type {number|null} */ (null),
138
+ atCapacityEvents: 0,
139
+ atCapacity: false,
140
+ };
141
+
142
+ // `buffering` is true while we believe frames are queued for a hub that is
143
+ // down — set at disconnect (and at creation if we start disconnected), and
144
+ // cleared when the outage's backlog finishes flushing (the client's next
145
+ // onDrain) or when a (re)connect finds nothing was buffered. The client fires
146
+ // our lifecycle connect/reconnect listeners BEFORE it pumps the ring (and thus
147
+ // before its buffer-drained event), so settleOnOpen captures the pre-drain
148
+ // backlog into `outageBacklogPeak` and the subsequent onDrain records it.
149
+ // `outageBacklogPeak` is the deepest the buffer got during the current
150
+ // outage — what we report as the flushed frame count.
151
+ let buffering = false;
152
+ let outageBacklogPeak = 0;
153
+ let sampleTimer = null;
154
+ let stopped = false;
155
+
156
+ const depth = () => {
157
+ try {
158
+ return Number(channel.buffered()) || 0;
159
+ } catch {
160
+ return 0;
161
+ }
162
+ };
163
+
164
+ /** Take a depth sample: update the high-water mark, the per-outage peak, and
165
+ * the at-capacity signal. */
166
+ const sample = () => {
167
+ const d = depth();
168
+ if (d > state.highWaterMark) state.highWaterMark = d;
169
+ if (buffering && d > outageBacklogPeak) outageBacklogPeak = d;
170
+ const atCap = d >= capacity;
171
+ if (atCap) {
172
+ state.atCapacityEvents += 1;
173
+ if (!state.atCapacity) {
174
+ // Transition into the at-capacity state — warn ONCE so the operator sees
175
+ // the bound is full and further low-priority frames may be dropped
176
+ // (bulk relay before interactive before control), but we don't spam
177
+ // every sample. We only observe depth, so we don't assert a drop has
178
+ // already happened: depth can reach capacity before any overflow.
179
+ try {
180
+ log.warn?.(
181
+ `agentic outbound buffer full (${d}/${capacity} frames): the hub is unreachable and further low-priority frames may be dropped until it reconnects. Raise NANO_AGENTIC_BUFFER_CAPACITY for a longer expected outage.`,
182
+ );
183
+ } catch {
184
+ /* a logger failure must never break sampling */
185
+ }
186
+ }
187
+ }
188
+ state.atCapacity = atCap;
189
+ return d;
190
+ };
191
+
192
+ const startSampler = () => {
193
+ if (stopped || sampleTimer !== null || sampleIntervalMs <= 0) return;
194
+ sampleTimer = timers.setInterval(() => sample(), sampleIntervalMs);
195
+ // Don't keep the event loop alive just to sample a buffer.
196
+ if (sampleTimer && typeof sampleTimer.unref === 'function') sampleTimer.unref();
197
+ };
198
+ const stopSampler = () => {
199
+ if (sampleTimer !== null) {
200
+ timers.clearInterval(sampleTimer);
201
+ sampleTimer = null;
202
+ }
203
+ };
204
+
205
+ // Enter an outage: begin (or continue) buffering and start watching depth.
206
+ const beginOutage = () => {
207
+ buffering = true;
208
+ outageBacklogPeak = 0;
209
+ state.atCapacity = false;
210
+ startSampler();
211
+ sample();
212
+ };
213
+
214
+ // A (re)connect happened. The client fires this listener BEFORE it pumps the
215
+ // ring, so depth() here is the pre-drain backlog (captured below); the
216
+ // client's onDrain then fires and records the completed flush. If we are still
217
+ // buffering and the buffer is already empty, the outage carried nothing to
218
+ // flush — just clear it.
219
+ const settleOnOpen = () => {
220
+ stopSampler();
221
+ // Capture the pre-drain backlog. The client fires our connect/reconnect
222
+ // listeners BEFORE it pumps the ring (see the module header), so depth()
223
+ // here is the backlog about to flush. Recording it into the outage peak
224
+ // makes the flush count (onDrain) reflect the real drained depth even when
225
+ // no periodic sample happened to catch the peak — under production
226
+ // timer-based sampling a short outage would otherwise leave the peak at 0
227
+ // and fall back to 1.
228
+ const d = depth();
229
+ if (d > state.highWaterMark) state.highWaterMark = d;
230
+ if (buffering && d > outageBacklogPeak) outageBacklogPeak = d;
231
+ if (buffering && d === 0) {
232
+ buffering = false;
233
+ outageBacklogPeak = 0;
234
+ }
235
+ state.atCapacity = false;
236
+ };
237
+
238
+ const unsub = [];
239
+
240
+ // First connect (worker-before-app: the pre-app backlog flushes here too).
241
+ unsub.push(
242
+ channel.onConnect(() => {
243
+ settleOnOpen();
244
+ }),
245
+ );
246
+ // Every recovery after a drop (hub restart / transient outage).
247
+ unsub.push(
248
+ channel.onReconnect(() => {
249
+ state.reconnects += 1;
250
+ settleOnOpen();
251
+ }),
252
+ );
253
+ // The channel went down: begin (or continue) buffering; sample the backlog as
254
+ // it grows so a bound hit is noticed even during a long outage.
255
+ unsub.push(
256
+ channel.onDisconnect(() => {
257
+ state.outages += 1;
258
+ beginOutage();
259
+ }),
260
+ );
261
+ // The client's outbound ring emptied after sending: if we were flushing an
262
+ // outage backlog, the drain is now complete.
263
+ if (channel.client && typeof channel.client.onDrain === 'function') {
264
+ unsub.push(
265
+ channel.client.onDrain(() => {
266
+ if (buffering) {
267
+ state.flushes += 1;
268
+ state.lastFlushFrames = Math.max(outageBacklogPeak, 1);
269
+ state.lastFlushAt = now();
270
+ buffering = false;
271
+ outageBacklogPeak = 0;
272
+ stopSampler();
273
+ }
274
+ }),
275
+ );
276
+ }
277
+
278
+ // Worker-before-app: if we're created while the channel is still down, we are
279
+ // already buffering — start sampling immediately so a pre-connect bound hit is
280
+ // observed and the first-connect drain is recorded as a flush.
281
+ let connectedNow = false;
282
+ try {
283
+ connectedNow = typeof channel.connected === 'function' ? Boolean(channel.connected()) : false;
284
+ } catch {
285
+ connectedNow = false;
286
+ }
287
+ if (!connectedNow) {
288
+ buffering = true;
289
+ outageBacklogPeak = 0;
290
+ startSampler();
291
+ sample();
292
+ }
293
+
294
+ return {
295
+ health() {
296
+ return {
297
+ capacity,
298
+ buffered: depth(),
299
+ connected: (() => {
300
+ try {
301
+ return typeof channel.connected === 'function' ? Boolean(channel.connected()) : false;
302
+ } catch {
303
+ return false;
304
+ }
305
+ })(),
306
+ highWaterMark: state.highWaterMark,
307
+ outages: state.outages,
308
+ reconnects: state.reconnects,
309
+ flushes: state.flushes,
310
+ lastFlushFrames: state.lastFlushFrames,
311
+ lastFlushAt: state.lastFlushAt,
312
+ atCapacityEvents: state.atCapacityEvents,
313
+ atCapacity: state.atCapacity,
314
+ };
315
+ },
316
+ sample,
317
+ stop() {
318
+ if (stopped) return;
319
+ stopped = true;
320
+ stopSampler();
321
+ for (const off of unsub) {
322
+ try {
323
+ off?.();
324
+ } catch {
325
+ /* best effort */
326
+ }
327
+ }
328
+ unsub.length = 0;
329
+ },
330
+ };
331
+ }
package/work-channel.mjs CHANGED
@@ -36,6 +36,7 @@ const DEFAULT_HEARTBEAT_MS = 10_000;
36
36
  // before the app from losing its early presence/relay frames.
37
37
  const DEFAULT_BUFFER_CAPACITY = 1024;
38
38
 
39
+ export { DEFAULT_BUFFER_CAPACITY };
39
40
  /**
40
41
  * Build the worker's agentic-channel WebSocket URL from the app's HTTP base URL
41
42
  * plus the ADR 0028 identity token and capability credential, carried as query