@nanobpm/urban-agent-client 0.1.0 → 0.1.4
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/dist/client.d.ts +21 -4
- package/dist/client.js +24 -41
- package/dist/index.d.ts +1 -1
- package/dist/protocol.d.ts +1 -1
- package/package.json +2 -2
- package/src/client.test.ts +24 -48
- package/src/client.ts +39 -44
- package/src/index.ts +1 -0
- package/src/protocol.ts +1 -0
- package/src/relay-roundtrip.test.ts +119 -0
package/dist/client.d.ts
CHANGED
|
@@ -31,6 +31,20 @@ export interface AgenticClientOptions {
|
|
|
31
31
|
readonly serveTimeoutMs?: number;
|
|
32
32
|
/** Reconnect/backoff policy. Enabled by default. */
|
|
33
33
|
readonly reconnect?: ReconnectOptions;
|
|
34
|
+
/**
|
|
35
|
+
* The producer generation stamped on every `produce` relay frame. The hub's
|
|
36
|
+
* incarnation fence admits a frame only when its incarnation is `>=` the
|
|
37
|
+
* highest seen for that stream, so a successor producer (a retried job on a
|
|
38
|
+
* fresh runner taking over the same stream) MUST present a strictly higher
|
|
39
|
+
* value to fence its stale predecessor. Defaults to `Date.now()`, which a
|
|
40
|
+
* later-started process naturally exceeds — but that default is best-effort:
|
|
41
|
+
* clock skew, a backwards clock adjustment, or two restarts inside the same
|
|
42
|
+
* millisecond can break strict monotonicity. A caller that needs a hard
|
|
43
|
+
* fencing guarantee MUST supply an explicit monotonic `incarnation` (e.g. a
|
|
44
|
+
* persisted per-takeover counter) rather than rely on the clock. Must be a
|
|
45
|
+
* non-negative integer.
|
|
46
|
+
*/
|
|
47
|
+
readonly incarnation?: number;
|
|
34
48
|
/** Injectable scheduler for reconnect backoff (tests). Defaults to setTimeout. */
|
|
35
49
|
readonly schedule?: (fn: () => void, ms: number) => void;
|
|
36
50
|
}
|
|
@@ -62,6 +76,7 @@ export declare class AgenticClient {
|
|
|
62
76
|
private readonly serveTimeoutMs;
|
|
63
77
|
private readonly reconnectPolicy;
|
|
64
78
|
private readonly schedule;
|
|
79
|
+
private readonly incarnation;
|
|
65
80
|
private capability;
|
|
66
81
|
private transport;
|
|
67
82
|
private state;
|
|
@@ -73,7 +88,6 @@ export declare class AgenticClient {
|
|
|
73
88
|
private heartbeatTimer;
|
|
74
89
|
private pendingServe;
|
|
75
90
|
private lastServe;
|
|
76
|
-
private readonly relayOffsets;
|
|
77
91
|
private readonly serveListeners;
|
|
78
92
|
private readonly frameListeners;
|
|
79
93
|
private readonly openListeners;
|
|
@@ -106,9 +120,12 @@ export declare class AgenticClient {
|
|
|
106
120
|
heartbeat(): void;
|
|
107
121
|
/**
|
|
108
122
|
* Produce relay bytes on the bulk lane. `chunk` is the terminal/command output
|
|
109
|
-
* for `stream
|
|
110
|
-
*
|
|
111
|
-
*
|
|
123
|
+
* for `stream`, sent as an op-tagged `produce` frame — `produce` is the payload
|
|
124
|
+
* op, carried on the bulk lane (not the QoS control lane) — stamped with this
|
|
125
|
+
* client's {@link incarnation} so the hub can fence a stale predecessor. The
|
|
126
|
+
* hub assigns the authoritative chunk offset from its ring — a per-chunk,
|
|
127
|
+
* monotonic, gap-free counter (not a byte offset); the producer never carries
|
|
128
|
+
* one. Bytes are UTF-8-encoded on the wire as the payload's `chunk`.
|
|
112
129
|
*/
|
|
113
130
|
relay(stream: string, chunk: string): void;
|
|
114
131
|
/**
|
package/dist/client.js
CHANGED
|
@@ -53,6 +53,7 @@ export class AgenticClient {
|
|
|
53
53
|
serveTimeoutMs;
|
|
54
54
|
reconnectPolicy;
|
|
55
55
|
schedule;
|
|
56
|
+
incarnation;
|
|
56
57
|
capability;
|
|
57
58
|
transport;
|
|
58
59
|
state = "idle";
|
|
@@ -64,7 +65,6 @@ export class AgenticClient {
|
|
|
64
65
|
heartbeatTimer;
|
|
65
66
|
pendingServe;
|
|
66
67
|
lastServe = [];
|
|
67
|
-
relayOffsets = new Map();
|
|
68
68
|
serveListeners = new Set();
|
|
69
69
|
frameListeners = new Set();
|
|
70
70
|
openListeners = new Set();
|
|
@@ -86,6 +86,10 @@ export class AgenticClient {
|
|
|
86
86
|
factor: options.reconnect?.factor ?? DEFAULT_RECONNECT.factor,
|
|
87
87
|
};
|
|
88
88
|
this.reconnectDelay = this.reconnectPolicy.initialDelayMs;
|
|
89
|
+
this.incarnation = options.incarnation ?? Date.now();
|
|
90
|
+
if (!Number.isInteger(this.incarnation) || this.incarnation < 0) {
|
|
91
|
+
throw new RangeError(`incarnation must be a non-negative integer, got ${this.incarnation}`);
|
|
92
|
+
}
|
|
89
93
|
// Reject timing/backoff options that Node would coerce into a 0ms hot loop.
|
|
90
94
|
// heartbeat/serveTimeout accept 0 as a "disabled" sentinel (guarded with > 0
|
|
91
95
|
// at use), but reconnect delays have no such sentinel — enabled:false disables
|
|
@@ -207,28 +211,21 @@ export class AgenticClient {
|
|
|
207
211
|
}
|
|
208
212
|
/**
|
|
209
213
|
* Produce relay bytes on the bulk lane. `chunk` is the terminal/command output
|
|
210
|
-
* for `stream
|
|
211
|
-
*
|
|
212
|
-
*
|
|
214
|
+
* for `stream`, sent as an op-tagged `produce` frame — `produce` is the payload
|
|
215
|
+
* op, carried on the bulk lane (not the QoS control lane) — stamped with this
|
|
216
|
+
* client's {@link incarnation} so the hub can fence a stale predecessor. The
|
|
217
|
+
* hub assigns the authoritative chunk offset from its ring — a per-chunk,
|
|
218
|
+
* monotonic, gap-free counter (not a byte offset); the producer never carries
|
|
219
|
+
* one. Bytes are UTF-8-encoded on the wire as the payload's `chunk`.
|
|
213
220
|
*/
|
|
214
221
|
relay(stream, chunk) {
|
|
215
|
-
// Terminal client: refuse
|
|
216
|
-
//
|
|
217
|
-
// nor buffers a frame that can never drain.
|
|
222
|
+
// Terminal client: refuse post-close so a relay neither buffers a frame that
|
|
223
|
+
// can never drain nor is emitted after deregister.
|
|
218
224
|
if (this.refuseWhenClosed("relay")) {
|
|
219
225
|
return;
|
|
220
226
|
}
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
// Advance the per-stream offset only if the frame was actually accepted
|
|
224
|
-
// for sending. A relay rejected for invalid payload, refused because the
|
|
225
|
-
// client is closed, OR dropped by the QoS overflow policy (ring full of
|
|
226
|
-
// higher-priority traffic) must not consume offset space, or every
|
|
227
|
-
// subsequent relay's offset would be inconsistent with the bytes the hub
|
|
228
|
-
// actually received.
|
|
229
|
-
if (this.enqueue("relay", OUTBOUND_LANE.relay, payload)) {
|
|
230
|
-
this.relayOffsets.set(stream, offset + byteLength(chunk));
|
|
231
|
-
}
|
|
227
|
+
const payload = { op: "produce", stream, incarnation: this.incarnation, chunk };
|
|
228
|
+
this.enqueue("relay", OUTBOUND_LANE.relay, payload);
|
|
232
229
|
}
|
|
233
230
|
/**
|
|
234
231
|
* Deregister and close. Sends a deregister frame best-effort — only when the
|
|
@@ -256,18 +253,16 @@ export class AgenticClient {
|
|
|
256
253
|
// we drive handleClose directly to guarantee onClose fires. handleClose is
|
|
257
254
|
// idempotent per connection attempt, so it de-duplicates against any onClose
|
|
258
255
|
// the transport also fires.
|
|
259
|
-
// Terminal: the outbound ring
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
// non-zero backlog — regardless of which path surfaces the close first.
|
|
256
|
+
// Terminal: the outbound ring can never be drained again, so release it here
|
|
257
|
+
// rather than pinning a large outage backlog (buffered frames) in memory for
|
|
258
|
+
// the lifetime of the now-dead client. Clear BEFORE anything can fire the
|
|
259
|
+
// close event — both the transport (an injectable seam that may legally fire
|
|
260
|
+
// onClose synchronously from close(), as FakeTransport does when open) and our
|
|
261
|
+
// own handleClose below emit onClose synchronously. A subscriber that reads
|
|
262
|
+
// `buffered` must observe the released, self-consistent terminal state that
|
|
263
|
+
// close() documents — not a stale non-zero backlog — regardless of which path
|
|
264
|
+
// surfaces the close first.
|
|
269
265
|
this.ring.clear();
|
|
270
|
-
this.relayOffsets.clear();
|
|
271
266
|
const transport = this.transport;
|
|
272
267
|
this.transport = undefined;
|
|
273
268
|
try {
|
|
@@ -672,18 +667,6 @@ export function connectAgenticChannel(options) {
|
|
|
672
667
|
client.connect();
|
|
673
668
|
return client;
|
|
674
669
|
}
|
|
675
|
-
const utf8Encoder = new TextEncoder();
|
|
676
|
-
// UTF-8 byte length of `text`. Prefer Node's `Buffer.byteLength`, which computes
|
|
677
|
-
// the length without allocating, on the `relay()` hot path where the extra
|
|
678
|
-
// per-call `Uint8Array` allocation from `TextEncoder.encode()` would add
|
|
679
|
-
// measurable GC/CPU overhead during bulk output storms. Fall back to
|
|
680
|
-
// `TextEncoder` where `Buffer` is unavailable (non-Node hosts).
|
|
681
|
-
function byteLength(text) {
|
|
682
|
-
if (typeof Buffer !== "undefined") {
|
|
683
|
-
return Buffer.byteLength(text, "utf8");
|
|
684
|
-
}
|
|
685
|
-
return utf8Encoder.encode(text).length;
|
|
686
|
-
}
|
|
687
670
|
function isServePayload(payload) {
|
|
688
671
|
if (typeof payload !== "object" || payload === null) {
|
|
689
672
|
return false;
|
package/dist/index.d.ts
CHANGED
|
@@ -35,4 +35,4 @@ export type { EnqueueResult, OutboundRingOptions } from "./ring.ts";
|
|
|
35
35
|
export { websocketTransport, normaliseIncoming } from "./transport.ts";
|
|
36
36
|
export type { Transport, TransportCloseInfo, TransportFactory, TransportHooks, } from "./transport.ts";
|
|
37
37
|
export { MESSAGE_FAMILIES, QOS_LANES, encodeFrame, decodeFrame, parseToken, isValidToken, validatePayload, } from "./protocol.ts";
|
|
38
|
-
export type { Capability, Frame, MessageFamily, QosLane, RegisterPayload, HeartbeatPayload, DeregisterPayload, ServePayload, RelayPayload, } from "./protocol.ts";
|
|
38
|
+
export type { Capability, Frame, MessageFamily, QosLane, RegisterPayload, HeartbeatPayload, DeregisterPayload, ServePayload, RelayPayload, RelayProducePayload, } from "./protocol.ts";
|
package/dist/protocol.d.ts
CHANGED
|
@@ -13,4 +13,4 @@
|
|
|
13
13
|
* client imports it, it never redefines it.
|
|
14
14
|
*/
|
|
15
15
|
export { MESSAGE_FAMILIES, isMessageFamily, QOS_LANES, isQosLane, compareFrameOrder, encodeFrame, decodeFrame, FrameDecodeError, FrameEncodeError, MAX_SEQ, parseToken, isValidToken, validatePayload, bytesToHex, hexToBytes, } from "@nanobpm/agentic/source/protocol";
|
|
16
|
-
export type { MessageFamily, QosLane, Frame, FrameDecodeErrorCode, Capability, RegisterPayload, HeartbeatPayload, DeregisterPayload, ServePayload, RelayPayload, BlackboardPayload, DemandPayload, } from "@nanobpm/agentic/source/protocol";
|
|
16
|
+
export type { MessageFamily, QosLane, Frame, FrameDecodeErrorCode, Capability, RegisterPayload, HeartbeatPayload, DeregisterPayload, ServePayload, RelayPayload, RelayProducePayload, BlackboardPayload, DemandPayload, } from "@nanobpm/agentic/source/protocol";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban-agent-client",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Worker-side client for the Nano agentic channel (ADR 0056, S9): REGISTER→SERVE, heartbeat/deregister, produce relay bytes, and a local buffer / flush-on-reconnect ring that tolerates a hub outage. Speaks the @nanobpm/agentic wire contract (via its @nanobpm/agentic/protocol subpath) and is held to its shared conformance corpus.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"test:conformance": "node --test --experimental-strip-types \"src/conformance.test.ts\""
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@nanobpm/agentic": "^0.
|
|
48
|
+
"@nanobpm/agentic": "^0.4.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@types/node": "^22.20.1",
|
package/src/client.test.ts
CHANGED
|
@@ -66,7 +66,7 @@ test("a SERVE addressed to a different instance is ignored", async () => {
|
|
|
66
66
|
});
|
|
67
67
|
|
|
68
68
|
test("heartbeat, relay and deregister emit correctly-shaped frames", () => {
|
|
69
|
-
const { client, t } = newClient();
|
|
69
|
+
const { client, t } = newClient({ incarnation: 7 });
|
|
70
70
|
client.connect();
|
|
71
71
|
t.last().fireOpen();
|
|
72
72
|
|
|
@@ -83,9 +83,10 @@ test("heartbeat, relay and deregister emit correctly-shaped frames", () => {
|
|
|
83
83
|
const relays = frames.filter((f) => f.family === "relay");
|
|
84
84
|
assert.equal(relays.length, 2);
|
|
85
85
|
assert.equal(relays[0]?.lane, "bulk");
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
assert.deepEqual(relays[
|
|
86
|
+
// Producer emits op-tagged `produce` control frames stamped with the client's
|
|
87
|
+
// incarnation; the hub assigns authoritative offsets, so the producer carries none.
|
|
88
|
+
assert.deepEqual(relays[0]?.payload, { op: "produce", stream: "stdout", incarnation: 7, chunk: "hello" });
|
|
89
|
+
assert.deepEqual(relays[1]?.payload, { op: "produce", stream: "stdout", incarnation: 7, chunk: "world!" });
|
|
89
90
|
|
|
90
91
|
const dereg = frames.find((f) => f.family === "deregister");
|
|
91
92
|
assert.deepEqual(dereg?.payload, { instance: "worker-1", reason: "done" });
|
|
@@ -146,8 +147,7 @@ test("close() releases the outbound buffer so a terminal client pins no backlog"
|
|
|
146
147
|
client.close();
|
|
147
148
|
|
|
148
149
|
// Terminal close must not pin the outage backlog in memory forever — the ring
|
|
149
|
-
//
|
|
150
|
-
// released.
|
|
150
|
+
// can never be drained again, so it is released.
|
|
151
151
|
assert.equal(client.buffered, 0, "close() cleared the outbound ring");
|
|
152
152
|
});
|
|
153
153
|
|
|
@@ -175,36 +175,12 @@ test("a closed client refuses every outbound-producing call instead of buffering
|
|
|
175
175
|
);
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
-
test("relay
|
|
179
|
-
const { client, t } = newClient();
|
|
180
|
-
client.connect();
|
|
181
|
-
t.last().fireOpen();
|
|
182
|
-
client.relay("a", "é"); // 2 bytes UTF-8
|
|
183
|
-
client.relay("b", "x"); // separate stream, offset 0
|
|
184
|
-
client.relay("a", "z"); // offset now 2
|
|
185
|
-
// Astral char (U+1F600) is 4 UTF-8 bytes but 2 UTF-16 code units, so the
|
|
186
|
-
// offset must advance by the UTF-8 byte count (4), never the JS string length
|
|
187
|
-
// (2) — guards the byte-length helper against surrogate-pair miscounting.
|
|
188
|
-
client.relay("a", "😀"); // offset now 3
|
|
189
|
-
client.relay("a", "!"); // offset now 3 + 4 = 7
|
|
190
|
-
const relays = t.last().sentFrames.filter((f) => f.family === "relay");
|
|
191
|
-
assert.deepEqual(relays.map((f) => f.payload), [
|
|
192
|
-
{ stream: "a", offset: 0, chunk: "é" },
|
|
193
|
-
{ stream: "b", offset: 0, chunk: "x" },
|
|
194
|
-
{ stream: "a", offset: 2, chunk: "z" },
|
|
195
|
-
{ stream: "a", offset: 3, chunk: "😀" },
|
|
196
|
-
{ stream: "a", offset: 7, chunk: "!" },
|
|
197
|
-
]);
|
|
198
|
-
client.close();
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
test("a relay dropped by the QoS overflow policy does not advance the stream offset", () => {
|
|
178
|
+
test("a relay dropped by the QoS overflow policy is not emitted", () => {
|
|
202
179
|
// With a single ring slot, the register buffered while the hub is down (control
|
|
203
180
|
// lane) fills the ring. A relay (bulk) enqueued now is the least-important frame
|
|
204
181
|
// in play, so the overflow policy DROPS the incoming relay rather than evict the
|
|
205
|
-
// higher-priority register.
|
|
206
|
-
|
|
207
|
-
const { client, t } = newClient({ bufferCapacity: 1 });
|
|
182
|
+
// higher-priority register. The dropped relay must never reach the wire.
|
|
183
|
+
const { client, t } = newClient({ incarnation: 7, bufferCapacity: 1 });
|
|
208
184
|
client.connect(); // transport built, hub still down
|
|
209
185
|
client.register({ capability: { cognition: "high" } }).catch(() => {}); // control frame fills the slot; rejected on close()
|
|
210
186
|
assert.equal(client.buffered, 1, "the buffered register occupies the single ring slot");
|
|
@@ -217,8 +193,8 @@ test("a relay dropped by the QoS overflow policy does not advance the stream off
|
|
|
217
193
|
const relays = t.last().sentFrames.filter((f) => f.family === "relay");
|
|
218
194
|
assert.deepEqual(
|
|
219
195
|
relays.map((f) => f.payload),
|
|
220
|
-
[{ stream: "s",
|
|
221
|
-
"
|
|
196
|
+
[{ op: "produce", stream: "s", incarnation: 7, chunk: "sent" }],
|
|
197
|
+
"only the accepted produce frame reached the wire",
|
|
222
198
|
);
|
|
223
199
|
client.close();
|
|
224
200
|
});
|
|
@@ -248,7 +224,7 @@ test("buffers while the hub is down and drains in QoS order on reconnect", () =>
|
|
|
248
224
|
});
|
|
249
225
|
|
|
250
226
|
test("survives a mid-stream drop: unsent frames stay buffered and drain on the next open", () => {
|
|
251
|
-
const { client, t } = newClient({ reconnect: { enabled: false } });
|
|
227
|
+
const { client, t } = newClient({ incarnation: 7, reconnect: { enabled: false } });
|
|
252
228
|
client.connect();
|
|
253
229
|
t.last().fireOpen();
|
|
254
230
|
client.relay("stdout", "a");
|
|
@@ -267,8 +243,8 @@ test("survives a mid-stream drop: unsent frames stay buffered and drain on the n
|
|
|
267
243
|
assert.equal(client.buffered, 0);
|
|
268
244
|
const chunks = t.last().sentFrames.filter((f) => f.family === "relay").map((f) => f.payload);
|
|
269
245
|
assert.deepEqual(chunks, [
|
|
270
|
-
{ stream: "stdout",
|
|
271
|
-
{ stream: "stdout",
|
|
246
|
+
{ op: "produce", stream: "stdout", incarnation: 7, chunk: "b" },
|
|
247
|
+
{ op: "produce", stream: "stdout", incarnation: 7, chunk: "c" },
|
|
272
248
|
]);
|
|
273
249
|
});
|
|
274
250
|
|
|
@@ -427,8 +403,8 @@ test("an invalid outbound relay payload is dropped with onError, not buffered",
|
|
|
427
403
|
client.close();
|
|
428
404
|
});
|
|
429
405
|
|
|
430
|
-
test("a rejected relay
|
|
431
|
-
const { client, t } = newClient({ reconnect: { enabled: false } });
|
|
406
|
+
test("a rejected relay does not disrupt subsequent produce frames", () => {
|
|
407
|
+
const { client, t } = newClient({ incarnation: 7, reconnect: { enabled: false } });
|
|
432
408
|
client.connect();
|
|
433
409
|
t.last().fireOpen();
|
|
434
410
|
|
|
@@ -436,21 +412,21 @@ test("a rejected relay consumes no offset space (advance-only-on-accept)", () =>
|
|
|
436
412
|
client.onError((e) => errors.push(e));
|
|
437
413
|
|
|
438
414
|
// An empty stream fails the S0 relay contract, so the frame is rejected at
|
|
439
|
-
// enqueue time.
|
|
440
|
-
//
|
|
441
|
-
client.relay("stdout", "ok"); // accepted
|
|
415
|
+
// enqueue time. Subsequent valid produce frames must be emitted unaffected —
|
|
416
|
+
// the hub, not the producer, assigns offsets from the produce stream.
|
|
417
|
+
client.relay("stdout", "ok"); // accepted
|
|
442
418
|
client.relay("", "dropped"); // rejected: empty stream
|
|
443
|
-
client.relay("stdout", "next"); //
|
|
419
|
+
client.relay("stdout", "next"); // accepted, unaffected by the reject
|
|
444
420
|
|
|
445
421
|
assert.ok(errors.some((e) => /relay payload failed validation/.test(e.message)));
|
|
446
422
|
const relays = t.last().sentFrames.filter((f) => f.family === "relay").map((f) => f.payload);
|
|
447
423
|
assert.deepEqual(
|
|
448
424
|
relays,
|
|
449
425
|
[
|
|
450
|
-
{ stream: "stdout",
|
|
451
|
-
{ stream: "stdout",
|
|
426
|
+
{ op: "produce", stream: "stdout", incarnation: 7, chunk: "ok" },
|
|
427
|
+
{ op: "produce", stream: "stdout", incarnation: 7, chunk: "next" },
|
|
452
428
|
],
|
|
453
|
-
"the rejected relay neither
|
|
429
|
+
"the rejected relay neither dropped nor corrupted a subsequent produce frame",
|
|
454
430
|
);
|
|
455
431
|
client.close();
|
|
456
432
|
});
|
|
@@ -649,7 +625,7 @@ test("close() releases the outbound buffer BEFORE it emits onClose, so subscribe
|
|
|
649
625
|
const { client } = newClient({ capability: { cognition: "high" } });
|
|
650
626
|
client.connect(); // transport built but never opened
|
|
651
627
|
|
|
652
|
-
// Accumulate an outage backlog
|
|
628
|
+
// Accumulate an outage backlog of buffered relay frames.
|
|
653
629
|
for (let i = 0; i < 4; i++) {
|
|
654
630
|
client.relay("stdout", `chunk-${i}`);
|
|
655
631
|
}
|
package/src/client.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type {
|
|
|
14
14
|
MessageFamily,
|
|
15
15
|
QosLane,
|
|
16
16
|
RegisterPayload,
|
|
17
|
-
|
|
17
|
+
RelayProducePayload,
|
|
18
18
|
ServePayload,
|
|
19
19
|
} from "./protocol.ts";
|
|
20
20
|
import { websocketTransport } from "./transport.ts";
|
|
@@ -86,6 +86,20 @@ export interface AgenticClientOptions {
|
|
|
86
86
|
readonly serveTimeoutMs?: number;
|
|
87
87
|
/** Reconnect/backoff policy. Enabled by default. */
|
|
88
88
|
readonly reconnect?: ReconnectOptions;
|
|
89
|
+
/**
|
|
90
|
+
* The producer generation stamped on every `produce` relay frame. The hub's
|
|
91
|
+
* incarnation fence admits a frame only when its incarnation is `>=` the
|
|
92
|
+
* highest seen for that stream, so a successor producer (a retried job on a
|
|
93
|
+
* fresh runner taking over the same stream) MUST present a strictly higher
|
|
94
|
+
* value to fence its stale predecessor. Defaults to `Date.now()`, which a
|
|
95
|
+
* later-started process naturally exceeds — but that default is best-effort:
|
|
96
|
+
* clock skew, a backwards clock adjustment, or two restarts inside the same
|
|
97
|
+
* millisecond can break strict monotonicity. A caller that needs a hard
|
|
98
|
+
* fencing guarantee MUST supply an explicit monotonic `incarnation` (e.g. a
|
|
99
|
+
* persisted per-takeover counter) rather than rely on the clock. Must be a
|
|
100
|
+
* non-negative integer.
|
|
101
|
+
*/
|
|
102
|
+
readonly incarnation?: number;
|
|
89
103
|
/** Injectable scheduler for reconnect backoff (tests). Defaults to setTimeout. */
|
|
90
104
|
readonly schedule?: (fn: () => void, ms: number) => void;
|
|
91
105
|
}
|
|
@@ -126,6 +140,7 @@ export class AgenticClient {
|
|
|
126
140
|
private readonly serveTimeoutMs: number;
|
|
127
141
|
private readonly reconnectPolicy: Required<ReconnectOptions>;
|
|
128
142
|
private readonly schedule: (fn: () => void, ms: number) => void;
|
|
143
|
+
private readonly incarnation: number;
|
|
129
144
|
|
|
130
145
|
private capability: Capability | undefined;
|
|
131
146
|
private transport: Transport | undefined;
|
|
@@ -138,7 +153,6 @@ export class AgenticClient {
|
|
|
138
153
|
private heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
|
139
154
|
private pendingServe: PendingServe | undefined;
|
|
140
155
|
private lastServe: readonly string[] = [];
|
|
141
|
-
private readonly relayOffsets = new Map<string, number>();
|
|
142
156
|
|
|
143
157
|
private readonly serveListeners = new Set<Listener<ServePayload>>();
|
|
144
158
|
private readonly frameListeners = new Set<Listener<Frame>>();
|
|
@@ -162,6 +176,10 @@ export class AgenticClient {
|
|
|
162
176
|
factor: options.reconnect?.factor ?? DEFAULT_RECONNECT.factor,
|
|
163
177
|
};
|
|
164
178
|
this.reconnectDelay = this.reconnectPolicy.initialDelayMs;
|
|
179
|
+
this.incarnation = options.incarnation ?? Date.now();
|
|
180
|
+
if (!Number.isInteger(this.incarnation) || this.incarnation < 0) {
|
|
181
|
+
throw new RangeError(`incarnation must be a non-negative integer, got ${this.incarnation}`);
|
|
182
|
+
}
|
|
165
183
|
// Reject timing/backoff options that Node would coerce into a 0ms hot loop.
|
|
166
184
|
// heartbeat/serveTimeout accept 0 as a "disabled" sentinel (guarded with > 0
|
|
167
185
|
// at use), but reconnect delays have no such sentinel — enabled:false disables
|
|
@@ -294,28 +312,21 @@ export class AgenticClient {
|
|
|
294
312
|
|
|
295
313
|
/**
|
|
296
314
|
* Produce relay bytes on the bulk lane. `chunk` is the terminal/command output
|
|
297
|
-
* for `stream
|
|
298
|
-
*
|
|
299
|
-
*
|
|
315
|
+
* for `stream`, sent as an op-tagged `produce` frame — `produce` is the payload
|
|
316
|
+
* op, carried on the bulk lane (not the QoS control lane) — stamped with this
|
|
317
|
+
* client's {@link incarnation} so the hub can fence a stale predecessor. The
|
|
318
|
+
* hub assigns the authoritative chunk offset from its ring — a per-chunk,
|
|
319
|
+
* monotonic, gap-free counter (not a byte offset); the producer never carries
|
|
320
|
+
* one. Bytes are UTF-8-encoded on the wire as the payload's `chunk`.
|
|
300
321
|
*/
|
|
301
322
|
relay(stream: string, chunk: string): void {
|
|
302
|
-
// Terminal client: refuse
|
|
303
|
-
//
|
|
304
|
-
// nor buffers a frame that can never drain.
|
|
323
|
+
// Terminal client: refuse post-close so a relay neither buffers a frame that
|
|
324
|
+
// can never drain nor is emitted after deregister.
|
|
305
325
|
if (this.refuseWhenClosed("relay")) {
|
|
306
326
|
return;
|
|
307
327
|
}
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
// Advance the per-stream offset only if the frame was actually accepted
|
|
311
|
-
// for sending. A relay rejected for invalid payload, refused because the
|
|
312
|
-
// client is closed, OR dropped by the QoS overflow policy (ring full of
|
|
313
|
-
// higher-priority traffic) must not consume offset space, or every
|
|
314
|
-
// subsequent relay's offset would be inconsistent with the bytes the hub
|
|
315
|
-
// actually received.
|
|
316
|
-
if (this.enqueue("relay", OUTBOUND_LANE.relay, payload)) {
|
|
317
|
-
this.relayOffsets.set(stream, offset + byteLength(chunk));
|
|
318
|
-
}
|
|
328
|
+
const payload: RelayProducePayload = { op: "produce", stream, incarnation: this.incarnation, chunk };
|
|
329
|
+
this.enqueue("relay", OUTBOUND_LANE.relay, payload);
|
|
319
330
|
}
|
|
320
331
|
|
|
321
332
|
/**
|
|
@@ -345,18 +356,16 @@ export class AgenticClient {
|
|
|
345
356
|
// we drive handleClose directly to guarantee onClose fires. handleClose is
|
|
346
357
|
// idempotent per connection attempt, so it de-duplicates against any onClose
|
|
347
358
|
// the transport also fires.
|
|
348
|
-
// Terminal: the outbound ring
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
// non-zero backlog — regardless of which path surfaces the close first.
|
|
359
|
+
// Terminal: the outbound ring can never be drained again, so release it here
|
|
360
|
+
// rather than pinning a large outage backlog (buffered frames) in memory for
|
|
361
|
+
// the lifetime of the now-dead client. Clear BEFORE anything can fire the
|
|
362
|
+
// close event — both the transport (an injectable seam that may legally fire
|
|
363
|
+
// onClose synchronously from close(), as FakeTransport does when open) and our
|
|
364
|
+
// own handleClose below emit onClose synchronously. A subscriber that reads
|
|
365
|
+
// `buffered` must observe the released, self-consistent terminal state that
|
|
366
|
+
// close() documents — not a stale non-zero backlog — regardless of which path
|
|
367
|
+
// surfaces the close first.
|
|
358
368
|
this.ring.clear();
|
|
359
|
-
this.relayOffsets.clear();
|
|
360
369
|
const transport = this.transport;
|
|
361
370
|
this.transport = undefined;
|
|
362
371
|
try {
|
|
@@ -789,20 +798,6 @@ export function connectAgenticChannel(options: AgenticClientOptions): AgenticCli
|
|
|
789
798
|
return client;
|
|
790
799
|
}
|
|
791
800
|
|
|
792
|
-
const utf8Encoder = new TextEncoder();
|
|
793
|
-
|
|
794
|
-
// UTF-8 byte length of `text`. Prefer Node's `Buffer.byteLength`, which computes
|
|
795
|
-
// the length without allocating, on the `relay()` hot path where the extra
|
|
796
|
-
// per-call `Uint8Array` allocation from `TextEncoder.encode()` would add
|
|
797
|
-
// measurable GC/CPU overhead during bulk output storms. Fall back to
|
|
798
|
-
// `TextEncoder` where `Buffer` is unavailable (non-Node hosts).
|
|
799
|
-
function byteLength(text: string): number {
|
|
800
|
-
if (typeof Buffer !== "undefined") {
|
|
801
|
-
return Buffer.byteLength(text, "utf8");
|
|
802
|
-
}
|
|
803
|
-
return utf8Encoder.encode(text).length;
|
|
804
|
-
}
|
|
805
|
-
|
|
806
801
|
function isServePayload(payload: unknown): payload is ServePayload {
|
|
807
802
|
if (typeof payload !== "object" || payload === null) {
|
|
808
803
|
return false;
|
package/src/index.ts
CHANGED
package/src/protocol.ts
CHANGED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-package contract test: the REAL worker client's produced relay frames,
|
|
3
|
+
* as they go on the wire, must be accepted by the REAL hub relay state machine.
|
|
4
|
+
*
|
|
5
|
+
* This is the test that was missing when the agentic-protocol epic (S0–S10)
|
|
6
|
+
* landed: the hub relay-family adopted an op-tagged `produce`/`incarnation`
|
|
7
|
+
* sub-protocol while this client kept emitting the legacy `{ stream, offset,
|
|
8
|
+
* chunk }` delivery shape, so every worker chunk was rejected at the hub as
|
|
9
|
+
* "malformed relay message payload". Each side's own unit tests were green
|
|
10
|
+
* because each mocked the other; nothing fed a real producer frame into the
|
|
11
|
+
* real hub. This test closes that gap and fails if the produce frame drifts.
|
|
12
|
+
*/
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { test } from "node:test";
|
|
15
|
+
import { RelayHub } from "@nanobpm/agentic/source/relay";
|
|
16
|
+
import type { RelayConnection } from "@nanobpm/agentic/source/relay";
|
|
17
|
+
import type { Frame } from "./protocol.ts";
|
|
18
|
+
import { AgenticClient } from "./client.ts";
|
|
19
|
+
import { fakeTransportFactory } from "./testkit.ts";
|
|
20
|
+
|
|
21
|
+
class FakeRegistry {
|
|
22
|
+
readonly live = new Set<string>();
|
|
23
|
+
has(id: string): boolean {
|
|
24
|
+
return this.live.has(id);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class FakeConn implements RelayConnection {
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly registry: FakeRegistry;
|
|
31
|
+
readonly sent: Frame[] = [];
|
|
32
|
+
constructor(id: string, registry: FakeRegistry) {
|
|
33
|
+
this.id = id;
|
|
34
|
+
this.registry = registry;
|
|
35
|
+
registry.live.add(id);
|
|
36
|
+
}
|
|
37
|
+
send(frame: Frame): void {
|
|
38
|
+
this.sent.push(frame);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function field(payload: unknown, key: string): unknown {
|
|
43
|
+
return typeof payload === "object" && payload !== null ? Reflect.get(payload, key) : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Data chunks the hub delivered to a consumer, in order. */
|
|
47
|
+
function dataChunks(conn: FakeConn): string[] {
|
|
48
|
+
return conn.sent
|
|
49
|
+
.filter((f) => f.lane === "bulk" && field(f.payload, "op") === undefined)
|
|
50
|
+
.map((f) => String(field(f.payload, "chunk")));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function subscribeFrame(stream: string, from: number, credit: number): Frame {
|
|
54
|
+
return { lane: "control", family: "relay", seq: 0, payload: { op: "subscribe", stream, from, credit } };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function producedRelayFrames(chunks: string[], incarnation: number): Frame[] {
|
|
58
|
+
const t = fakeTransportFactory();
|
|
59
|
+
const client = new AgenticClient({
|
|
60
|
+
url: "ws://test/agentic",
|
|
61
|
+
instance: "worker-1",
|
|
62
|
+
transport: t.factory,
|
|
63
|
+
reconnect: { enabled: false },
|
|
64
|
+
serveTimeoutMs: 0,
|
|
65
|
+
incarnation,
|
|
66
|
+
});
|
|
67
|
+
client.connect();
|
|
68
|
+
t.last().fireOpen();
|
|
69
|
+
for (const c of chunks) client.relay("job-1", c);
|
|
70
|
+
// sentFrames decodes the actual wire bytes the client emitted — a true
|
|
71
|
+
// producer-wire round-trip, not a peek at the pre-encode payload object.
|
|
72
|
+
const relays = t.last().sentFrames.filter((f) => f.family === "relay");
|
|
73
|
+
client.close();
|
|
74
|
+
return relays;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
test("the hub admits the client's produced relay frames and delivers the bytes", () => {
|
|
78
|
+
const produced = producedRelayFrames(["hello ", "world"], 42);
|
|
79
|
+
assert.equal(produced.length, 2);
|
|
80
|
+
// Every produced frame is an op-tagged `produce` frame (the `produce` payload
|
|
81
|
+
// op — carried on the bulk lane, not the QoS control lane).
|
|
82
|
+
for (const f of produced) {
|
|
83
|
+
assert.equal(field(f.payload, "op"), "produce");
|
|
84
|
+
assert.equal(field(f.payload, "incarnation"), 42);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const errors: unknown[] = [];
|
|
88
|
+
const hub = new RelayHub({ onError: (e) => errors.push(e) });
|
|
89
|
+
const reg = new FakeRegistry();
|
|
90
|
+
const prod = new FakeConn("producer", reg);
|
|
91
|
+
const cons = new FakeConn("consumer", reg);
|
|
92
|
+
|
|
93
|
+
hub.handle(subscribeFrame("job-1", 0, 1024), cons);
|
|
94
|
+
for (const f of produced) hub.handle(f, prod);
|
|
95
|
+
|
|
96
|
+
// The regression: before the fix, each produce frame tripped
|
|
97
|
+
// RelayMessageError("malformed relay message payload") here.
|
|
98
|
+
assert.deepEqual(errors, [], "hub rejected a produced relay frame");
|
|
99
|
+
assert.deepEqual(dataChunks(cons), ["hello ", "world"]);
|
|
100
|
+
assert.equal(hub.ring("job-1")?.nextOffset, 2);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("a later producer incarnation fences a stale predecessor on the hub", () => {
|
|
104
|
+
const stale = producedRelayFrames(["from-old"], 1);
|
|
105
|
+
const fresh = producedRelayFrames(["from-new"], 2);
|
|
106
|
+
|
|
107
|
+
const fenced: Array<{ stream: string; incarnation: number; current: number }> = [];
|
|
108
|
+
const hub = new RelayHub({ onFenced: (stream, incarnation, current) => fenced.push({ stream, incarnation, current }) });
|
|
109
|
+
const reg = new FakeRegistry();
|
|
110
|
+
const prod = new FakeConn("producer", reg);
|
|
111
|
+
|
|
112
|
+
// The fresh (higher) incarnation takes over the stream; the stale one is fenced.
|
|
113
|
+
for (const f of fresh) hub.handle(f, prod);
|
|
114
|
+
for (const f of stale) hub.handle(f, prod);
|
|
115
|
+
|
|
116
|
+
assert.equal(hub.ring("job-1")?.nextOffset, 1, "only the fresh incarnation's byte was admitted");
|
|
117
|
+
assert.equal(fenced.length, 1);
|
|
118
|
+
assert.deepEqual(fenced[0], { stream: "job-1", incarnation: 1, current: 2 });
|
|
119
|
+
});
|