@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 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`; the client tracks a monotonic per-stream byte offset so the
110
- * hub-side ring can resume-from-offset after a consumer reconnect (S5). Bytes
111
- * are UTF-8-encoded on the wire as the payload's `chunk` string.
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`; the client tracks a monotonic per-stream byte offset so the
211
- * hub-side ring can resume-from-offset after a consumer reconnect (S5). Bytes
212
- * are UTF-8-encoded on the wire as the payload's `chunk` string.
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 before touching the per-stream offset map (which
216
- // close() already released) so a post-close relay neither re-grows that map
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 offset = this.relayOffsets.get(stream) ?? 0;
222
- const payload = { stream, offset, chunk };
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 and per-stream relay offsets can never be
260
- // drained again, so release them here rather than pinning a large outage
261
- // backlog (buffered frames, many relay streams) in memory for the lifetime
262
- // of the now-dead client. Clear BEFORE anything can fire the close event
263
- // both the transport (an injectable seam that may legally fire onClose
264
- // synchronously from close(), as FakeTransport does when open) and our own
265
- // handleClose below emit onClose synchronously. A subscriber that reads
266
- // `buffered` (or the relay-offset state) must observe the released,
267
- // self-consistent terminal state that close() documents — not a stale
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";
@@ -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.0",
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.1.0"
48
+ "@nanobpm/agentic": "^0.4.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@types/node": "^22.20.1",
@@ -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
- // Per-stream byte offset advances by the UTF-8 length of each chunk.
87
- assert.deepEqual(relays[0]?.payload, { stream: "stdout", offset: 0, chunk: "hello" });
88
- assert.deepEqual(relays[1]?.payload, { stream: "stdout", offset: 5, chunk: "world!" });
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
- // (and the per-stream relay offsets) can never be drained again, so they are
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 offset tracks UTF-8 byte length per stream, independently", () => {
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. A dropped relay must consume no offset space, or the
206
- // hub-side resume-from-offset (S5) would see a phantom gap for bytes never sent.
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", offset: 0, chunk: "sent" }],
221
- "offset starts at 0 — the earlier dropped relay consumed no offset space",
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", offset: 1, chunk: "b" },
271
- { stream: "stdout", offset: 2, chunk: "c" },
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 consumes no offset space (advance-only-on-accept)", () => {
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. The offset for a real stream must be untouched by it, and a
440
- // rejected relay must never advance its own (empty-stream) offset bucket.
441
- client.relay("stdout", "ok"); // accepted → stdout offset advances to 2
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"); // must resume at offset 2, unaffected by the reject
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", offset: 0, chunk: "ok" },
451
- { stream: "stdout", offset: 2, chunk: "next" },
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 advanced nor corrupted any offset",
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: buffered relay frames + per-stream offsets.
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
- RelayPayload,
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`; the client tracks a monotonic per-stream byte offset so the
298
- * hub-side ring can resume-from-offset after a consumer reconnect (S5). Bytes
299
- * are UTF-8-encoded on the wire as the payload's `chunk` string.
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 before touching the per-stream offset map (which
303
- // close() already released) so a post-close relay neither re-grows that map
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 offset = this.relayOffsets.get(stream) ?? 0;
309
- const payload: RelayPayload = { stream, offset, chunk };
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 and per-stream relay offsets can never be
349
- // drained again, so release them here rather than pinning a large outage
350
- // backlog (buffered frames, many relay streams) in memory for the lifetime
351
- // of the now-dead client. Clear BEFORE anything can fire the close event
352
- // both the transport (an injectable seam that may legally fire onClose
353
- // synchronously from close(), as FakeTransport does when open) and our own
354
- // handleClose below emit onClose synchronously. A subscriber that reads
355
- // `buffered` (or the relay-offset state) must observe the released,
356
- // self-consistent terminal state that close() documents — not a stale
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
@@ -74,4 +74,5 @@ export type {
74
74
  DeregisterPayload,
75
75
  ServePayload,
76
76
  RelayPayload,
77
+ RelayProducePayload,
77
78
  } from "./protocol.ts";
package/src/protocol.ts CHANGED
@@ -41,6 +41,7 @@ export type {
41
41
  DeregisterPayload,
42
42
  ServePayload,
43
43
  RelayPayload,
44
+ RelayProducePayload,
44
45
  BlackboardPayload,
45
46
  DemandPayload,
46
47
  } from "@nanobpm/agentic/source/protocol";
@@ -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
+ });