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 +33 -2
- package/package.json +9 -8
- package/work-buffer.mjs +331 -0
- package/work-channel.mjs +1 -0
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
|
-
|
|
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.
|
|
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.
|
|
60
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.
|
|
61
|
-
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.
|
|
62
|
-
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.
|
|
63
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.
|
|
64
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.
|
|
65
|
-
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.
|
|
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
|
}
|
package/work-buffer.mjs
ADDED
|
@@ -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
|