@nanobpm/nano-workforce 0.191.1 → 0.193.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/CHANGELOG.md +12 -0
- package/app/agentic/families/presence.family.test.ts +315 -11
- package/app/agentic/families/presence.family.ts +189 -8
- package/app/github.test.ts +223 -1
- package/app/github.ts +122 -0
- package/app/roundProgress.test.ts +108 -1
- package/app/service.test.ts +132 -3
- package/app/service.ts +122 -52
- package/app/world/checkpoint.ts +5 -0
- package/app/world/selfHeal.test.ts +296 -0
- package/app/world/selfHeal.ts +194 -0
- package/app/world/store.test.ts +49 -0
- package/app/world/store.ts +37 -1
- package/db/migrations/115_world_checkpoint_process_key.sql +22 -0
- package/package.json +1 -1
- package/test/worldDb.ts +1 -1
- package/workers/persist-round/worker.ts +6 -0
- package/workers/progress-check/worker.ts +117 -18
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.193.0](https://github.com/nanobpm/nano-workforce/compare/v0.192.0...v0.193.0) (2026-09-28)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **agentic:** record harnessProtocol from the presence channel ([#821](https://github.com/nanobpm/nano-workforce/issues/821)) ([304b241](https://github.com/nanobpm/nano-workforce/commit/304b241f8b875bd9eca18da102090b0256b7ead7)), closes [#802](https://github.com/nanobpm/nano-workforce/issues/802) [jwulf/c8ctl-plugin-nano#272](https://github.com/jwulf/c8ctl-plugin-nano/issues/272) [#820](https://github.com/nanobpm/nano-workforce/issues/820)
|
|
6
|
+
|
|
7
|
+
## [0.192.0](https://github.com/nanobpm/nano-workforce/compare/v0.191.1...v0.192.0) (2026-09-28)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **world:** self-heal a no-advance round onto a reachable push-checkpoint ([#819](https://github.com/nanobpm/nano-workforce/issues/819)) ([891add2](https://github.com/nanobpm/nano-workforce/commit/891add2a217042dd2abaa37c8f12b1f1877c89fc)), closes [26/#28](https://github.com/26/nano-workforce/issues/28) [#818](https://github.com/nanobpm/nano-workforce/issues/818) [#818](https://github.com/nanobpm/nano-workforce/issues/818) [#818](https://github.com/nanobpm/nano-workforce/issues/818) [704/#497](https://github.com/704/nano-workforce/issues/497)
|
|
12
|
+
|
|
1
13
|
## [0.191.1](https://github.com/nanobpm/nano-workforce/compare/v0.191.0...v0.191.1) (2026-09-26)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
|
@@ -11,6 +11,7 @@ import { readFileSync } from "node:fs";
|
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { DatabaseSync } from "node:sqlite";
|
|
13
13
|
import { test } from "node:test";
|
|
14
|
+
import { mock } from "node:test";
|
|
14
15
|
import { fileURLToPath } from "node:url";
|
|
15
16
|
import { AgenticHub } from "@nanobpm/agentic/channel";
|
|
16
17
|
import type {
|
|
@@ -22,6 +23,8 @@ import { encodeFrame, type Frame, type MessageFamily } from "@nanobpm/agentic/pr
|
|
|
22
23
|
import type { SqliteDb } from "@nanobpm/agentic/presence";
|
|
23
24
|
import type { DataLayer } from "@nanobpm/urban";
|
|
24
25
|
import { assert, assertEquals } from "#test-assert";
|
|
26
|
+
import { assessWorkers } from "../../harnessProtocol.ts";
|
|
27
|
+
import { memDataFor } from "../../../test/worldDb.ts";
|
|
25
28
|
import { noopLog } from "../../../test/log.ts";
|
|
26
29
|
import type { AgenticContext } from "../registry.ts";
|
|
27
30
|
import {
|
|
@@ -47,11 +50,6 @@ function memSqlite(): SqliteDb {
|
|
|
47
50
|
};
|
|
48
51
|
}
|
|
49
52
|
|
|
50
|
-
/** A DataLayer whose default source exposes the given synchronous SqliteDb (nothing else is used). */
|
|
51
|
-
function memData(db: SqliteDb): DataLayer {
|
|
52
|
-
return { source: () => ({ db }) } as unknown as DataLayer;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
53
|
/** A mutable fake clock so TTL sweeps are deterministic. */
|
|
56
54
|
function fakeClock(start = 1_000): { now(): number; advance(ms: number): void } {
|
|
57
55
|
let t = start;
|
|
@@ -275,7 +273,72 @@ function familyFrame(fam: MessageFamily, instance: string): Frame {
|
|
|
275
273
|
return { lane: "control", family: fam, seq: 1, payload: { instance } };
|
|
276
274
|
}
|
|
277
275
|
|
|
278
|
-
|
|
276
|
+
/** A full in-memory DataLayer for the presence family: the synchronous `source().db` handle the
|
|
277
|
+
* presence store uses PLUS the `table()`/`open()` surface the harness-protocol registry writes
|
|
278
|
+
* through (#820), all over ONE real SQLite db with the `worker_harness_protocol` migration applied. */
|
|
279
|
+
function memMountData(): DataLayer {
|
|
280
|
+
const { data, db } = memDataFor(["107_worker_harness_protocol.sql"]);
|
|
281
|
+
const sqlite: SqliteDb = {
|
|
282
|
+
exec: (sql) => db.exec(sql),
|
|
283
|
+
run: (sql, params = []) => {
|
|
284
|
+
const r = db.prepare(sql).run(...(params as never[]));
|
|
285
|
+
return { changes: Number(r.changes), lastInsertRowid: Number(r.lastInsertRowid) };
|
|
286
|
+
},
|
|
287
|
+
all: <T = Record<string, unknown>>(sql: string, params: unknown[] = []) =>
|
|
288
|
+
db.prepare(sql).all(...(params as never[])) as T[],
|
|
289
|
+
};
|
|
290
|
+
return { ...(data as unknown as Record<string, unknown>), source: () => ({ db: sqlite }) } as unknown as DataLayer;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Wrap a DataLayer so every `harness_protocol` value written through the registry's `table` seam is
|
|
294
|
+
* appended to `writes`, in order. Lets a test observe WHICH register frames actually recorded — a
|
|
295
|
+
* concurrent rejected frame that (wrongly) records surfaces as an extra `null` write, independent of
|
|
296
|
+
* which write happens to land last. */
|
|
297
|
+
function withProtocolWriteSpy(data: DataLayer): { data: DataLayer; writes: Array<number | null> } {
|
|
298
|
+
const writes: Array<number | null> = [];
|
|
299
|
+
const realTable = (data as unknown as { table: (name: string, key: string) => unknown }).table.bind(data);
|
|
300
|
+
const wrapped = {
|
|
301
|
+
...(data as unknown as Record<string, unknown>),
|
|
302
|
+
table: (name: string, key: string) => {
|
|
303
|
+
const t = realTable(name, key) as Record<string, (...a: unknown[]) => unknown>;
|
|
304
|
+
if (name !== "worker_harness_protocol") return t;
|
|
305
|
+
const note = (v: unknown) => writes.push(typeof v === "number" ? v : null);
|
|
306
|
+
return {
|
|
307
|
+
...t,
|
|
308
|
+
insert: (row: Record<string, unknown>) => { note(row.harness_protocol); return t.insert(row); },
|
|
309
|
+
update: (k: unknown, patch: Record<string, unknown>) => { note(patch.harness_protocol); return t.update(k, patch); },
|
|
310
|
+
};
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
return { data: wrapped as unknown as DataLayer, writes };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Wrap a DataLayer so the registry's `update` of `worker_harness_protocol` to a specific protocol
|
|
317
|
+
* value is deferred by one MACROTASK (`setTimeout(0)`). The in-memory gateway resolves its reads/writes
|
|
318
|
+
* on microtasks, so a macrotask deterministically lands AFTER a concurrent frame's whole write —
|
|
319
|
+
* letting a test force an OLDER accepted re-register's write to complete last, the exact out-of-order
|
|
320
|
+
* hazard the per-instance serialization guards against. */
|
|
321
|
+
function withDelayedProtocolUpdate(data: DataLayer, delayValue: number): DataLayer {
|
|
322
|
+
const realTable = (data as unknown as { table: (name: string, key: string) => unknown }).table.bind(data);
|
|
323
|
+
return {
|
|
324
|
+
...(data as unknown as Record<string, unknown>),
|
|
325
|
+
table: (name: string, key: string) => {
|
|
326
|
+
const t = realTable(name, key) as Record<string, (...a: unknown[]) => unknown>;
|
|
327
|
+
if (name !== "worker_harness_protocol") return t;
|
|
328
|
+
return {
|
|
329
|
+
...t,
|
|
330
|
+
update: async (k: unknown, patch: Record<string, unknown>) => {
|
|
331
|
+
if (Number(patch.harness_protocol) === delayValue) {
|
|
332
|
+
await new Promise<void>((r) => setImmediate(r));
|
|
333
|
+
}
|
|
334
|
+
return t.update(k, patch);
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
},
|
|
338
|
+
} as unknown as DataLayer;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function mountFamily(data: DataLayer | undefined): Promise<{ hub: AgenticHub; transport: ReturnType<typeof memTransport> }> {
|
|
279
342
|
const transport = memTransport();
|
|
280
343
|
const hub = new AgenticHub({ transport: transport.transport, authenticator, sweepIntervalMs: 0 });
|
|
281
344
|
const ctx: AgenticContext = {
|
|
@@ -283,7 +346,7 @@ async function mountFamily(db: SqliteDb | undefined): Promise<{ hub: AgenticHub;
|
|
|
283
346
|
registry: hub.registry,
|
|
284
347
|
// The transport handle is not exercised by the presence family; the in-memory one stands in.
|
|
285
348
|
transport: transport.transport as never,
|
|
286
|
-
data
|
|
349
|
+
data,
|
|
287
350
|
log: noopLog(),
|
|
288
351
|
};
|
|
289
352
|
await family.mount(ctx);
|
|
@@ -291,7 +354,7 @@ async function mountFamily(db: SqliteDb | undefined): Promise<{ hub: AgenticHub;
|
|
|
291
354
|
}
|
|
292
355
|
|
|
293
356
|
test("family: mount attaches the three handlers and a REGISTER creates a durable presence row", async () => {
|
|
294
|
-
const { hub, transport } = await mountFamily(
|
|
357
|
+
const { hub, transport } = await mountFamily(memMountData());
|
|
295
358
|
try {
|
|
296
359
|
assertEquals(hub.router.families().sort(), ["deregister", "heartbeat", "register"]);
|
|
297
360
|
|
|
@@ -315,7 +378,7 @@ test("family: mount attaches the three handlers and a REGISTER creates a durable
|
|
|
315
378
|
});
|
|
316
379
|
|
|
317
380
|
test("family: HEARTBEAT keeps a worker and DEREGISTER removes it", async () => {
|
|
318
|
-
const { hub, transport } = await mountFamily(
|
|
381
|
+
const { hub, transport } = await mountFamily(memMountData());
|
|
319
382
|
try {
|
|
320
383
|
const peer = fakeConn("c1", "leaf");
|
|
321
384
|
transport.connect(peer.conn);
|
|
@@ -338,7 +401,7 @@ test("family: HEARTBEAT keeps a worker and DEREGISTER removes it", async () => {
|
|
|
338
401
|
});
|
|
339
402
|
|
|
340
403
|
test("family: a disconnect removes the worker via reconcile", async () => {
|
|
341
|
-
const { hub, transport } = await mountFamily(
|
|
404
|
+
const { hub, transport } = await mountFamily(memMountData());
|
|
342
405
|
try {
|
|
343
406
|
const peer = fakeConn("c1", "leaf");
|
|
344
407
|
transport.connect(peer.conn);
|
|
@@ -361,7 +424,7 @@ test("family: a disconnect removes the worker via reconcile", async () => {
|
|
|
361
424
|
});
|
|
362
425
|
|
|
363
426
|
test("family: teardown stops the family and clears the current registry", async () => {
|
|
364
|
-
const { hub } = await mountFamily(
|
|
427
|
+
const { hub } = await mountFamily(memMountData());
|
|
365
428
|
assert(currentPresenceRegistry(), "mounted");
|
|
366
429
|
family.teardown?.();
|
|
367
430
|
assertEquals(currentPresenceRegistry(), undefined, "cleared on teardown");
|
|
@@ -379,3 +442,244 @@ test("family: mounting without a DataLayer is a safe no-op", async () => {
|
|
|
379
442
|
await hub.close();
|
|
380
443
|
}
|
|
381
444
|
});
|
|
445
|
+
|
|
446
|
+
// ── harness-protocol recording over the presence channel (issue #820) ──────────────────────────
|
|
447
|
+
//
|
|
448
|
+
// The #802 staleness gate only ever read the HTTP-enrol recorder, so a presence-only c8ctl-nano
|
|
449
|
+
// fleet reported `harnessStale: true` permanently. These tests prove the presence REGISTER path now
|
|
450
|
+
// feeds the ONE canonical `worker_harness_protocol` recorder the supply assessment joins.
|
|
451
|
+
|
|
452
|
+
test("family: a REGISTER advertising harnessProtocol records it → the worker is assessed healthy", async () => {
|
|
453
|
+
const data = memMountData();
|
|
454
|
+
const { hub, transport } = await mountFamily(data);
|
|
455
|
+
try {
|
|
456
|
+
const peer = fakeConn("c1", "leafA");
|
|
457
|
+
transport.connect(peer.conn);
|
|
458
|
+
await flush();
|
|
459
|
+
// A presence-channel worker advertising the protocol on its enrolment capability (#820).
|
|
460
|
+
peer.feed(registerFrame("w1", { family: "opus", host: "boxA", harnessProtocol: 1 }));
|
|
461
|
+
await flush();
|
|
462
|
+
|
|
463
|
+
// Recorded into the ONE registry, keyed by instance, and joined by the shared supply assessment
|
|
464
|
+
// (default min protocol 1) → not stale.
|
|
465
|
+
const assessment = await assessWorkers(data, ["w1"], {});
|
|
466
|
+
assertEquals(assessment.get("w1")?.harnessProtocol, 1, "advertised protocol recorded");
|
|
467
|
+
assertEquals(assessment.get("w1")?.stale, false, "advertising min protocol clears the #802 stale flag");
|
|
468
|
+
} finally {
|
|
469
|
+
family.teardown?.();
|
|
470
|
+
await hub.close();
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("family: a REGISTER advertising NO harnessProtocol stays stale (no #802 regression)", async () => {
|
|
475
|
+
const data = memMountData();
|
|
476
|
+
const { hub, transport } = await mountFamily(data);
|
|
477
|
+
try {
|
|
478
|
+
const peer = fakeConn("c1", "leafA");
|
|
479
|
+
transport.connect(peer.conn);
|
|
480
|
+
await flush();
|
|
481
|
+
peer.feed(registerFrame("w1", { family: "opus", host: "boxA" }));
|
|
482
|
+
await flush();
|
|
483
|
+
|
|
484
|
+
const assessment = await assessWorkers(data, ["w1"], {});
|
|
485
|
+
assertEquals(assessment.get("w1")?.harnessProtocol, undefined, "no version advertised");
|
|
486
|
+
assertEquals(assessment.get("w1")?.stale, true, "absent version is stale — the #802 signal is preserved");
|
|
487
|
+
} finally {
|
|
488
|
+
family.teardown?.();
|
|
489
|
+
await hub.close();
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test("family: a below-minimum harnessProtocol is assessed stale against a raised minimum", async () => {
|
|
494
|
+
const data = memMountData();
|
|
495
|
+
const { hub, transport } = await mountFamily(data);
|
|
496
|
+
try {
|
|
497
|
+
const peer = fakeConn("c1", "leafA");
|
|
498
|
+
transport.connect(peer.conn);
|
|
499
|
+
await flush();
|
|
500
|
+
peer.feed(registerFrame("w1", { harnessProtocol: 1 }));
|
|
501
|
+
await flush();
|
|
502
|
+
|
|
503
|
+
const assessment = await assessWorkers(data, ["w1"], { NANO_AGENTIC_MIN_HARNESS_PROTOCOL: "2" });
|
|
504
|
+
assertEquals(assessment.get("w1")?.harnessProtocol, 1, "advertised protocol recorded");
|
|
505
|
+
assertEquals(assessment.get("w1")?.stale, true, "below the raised minimum is stale");
|
|
506
|
+
} finally {
|
|
507
|
+
family.teardown?.();
|
|
508
|
+
await hub.close();
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test("family: a rejected takeover REGISTER cannot overwrite another peer's recorded protocol", async () => {
|
|
513
|
+
const data = memMountData();
|
|
514
|
+
const { hub, transport } = await mountFamily(data);
|
|
515
|
+
try {
|
|
516
|
+
// Owner registers instance w1 with a healthy protocol.
|
|
517
|
+
const owner = fakeConn("c1", "owner");
|
|
518
|
+
transport.connect(owner.conn);
|
|
519
|
+
await flush();
|
|
520
|
+
owner.feed(registerFrame("w1", { harnessProtocol: 1 }));
|
|
521
|
+
await flush();
|
|
522
|
+
assertEquals((await assessWorkers(data, ["w1"], {})).get("w1")?.stale, false, "owner recorded healthy");
|
|
523
|
+
|
|
524
|
+
// A different identity tries to take over w1 advertising no version — the presence store rejects
|
|
525
|
+
// the ownership takeover, so the recording must NOT clear the owner's healthy protocol.
|
|
526
|
+
const attacker = fakeConn("c2", "attacker");
|
|
527
|
+
transport.connect(attacker.conn);
|
|
528
|
+
await flush();
|
|
529
|
+
attacker.feed(registerFrame("w1", {}));
|
|
530
|
+
await flush();
|
|
531
|
+
|
|
532
|
+
const assessment = await assessWorkers(data, ["w1"], {});
|
|
533
|
+
assertEquals(assessment.get("w1")?.harnessProtocol, 1, "owner's recorded protocol survives a rejected takeover");
|
|
534
|
+
assertEquals(assessment.get("w1")?.stale, false, "no foreign peer can flip a worker stale");
|
|
535
|
+
} finally {
|
|
536
|
+
family.teardown?.();
|
|
537
|
+
await hub.close();
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test("family: a rejected re-register from the SAME connection cannot clear its own recorded protocol", async () => {
|
|
542
|
+
const data = memMountData();
|
|
543
|
+
const { hub, transport } = await mountFamily(data);
|
|
544
|
+
try {
|
|
545
|
+
// The worker registers healthily on its connection.
|
|
546
|
+
const peer = fakeConn("c1", "leafA");
|
|
547
|
+
transport.connect(peer.conn);
|
|
548
|
+
await flush();
|
|
549
|
+
peer.feed(registerFrame("w1", { harnessProtocol: 1 }));
|
|
550
|
+
await flush();
|
|
551
|
+
assertEquals((await assessWorkers(data, ["w1"], {})).get("w1")?.stale, false, "recorded healthy");
|
|
552
|
+
|
|
553
|
+
// The SAME connection now sends a MALFORMED re-register (a non-object capability). The package
|
|
554
|
+
// handler rejects it and leaves the prior row in place — so a bare "this connection owns the row"
|
|
555
|
+
// check would still pass and clobber the healthy protocol with the rejected frame's absent value.
|
|
556
|
+
// The before/after acceptance check must skip a register that never took effect.
|
|
557
|
+
const malformed: Frame = { lane: "control", family: "register", seq: 1, payload: { instance: "w1", capability: "garbage" } };
|
|
558
|
+
peer.feed(malformed);
|
|
559
|
+
await flush();
|
|
560
|
+
|
|
561
|
+
const assessment = await assessWorkers(data, ["w1"], {});
|
|
562
|
+
assertEquals(assessment.get("w1")?.harnessProtocol, 1, "rejected re-register does not clear the recorded protocol");
|
|
563
|
+
assertEquals(assessment.get("w1")?.stale, false, "a rejected frame cannot flip a live worker stale");
|
|
564
|
+
} finally {
|
|
565
|
+
family.teardown?.();
|
|
566
|
+
await hub.close();
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test("family: two ACCEPTED same-connection re-registers in one clock tick both record their protocol", async () => {
|
|
571
|
+
// `lastSeen` is a millisecond timestamp, so two accepted REGISTERs on the same connection that
|
|
572
|
+
// arrive within one `Date.now()` tick share an identical `lastSeen`. An acceptance check that
|
|
573
|
+
// inferred "took effect" from a before/after `lastSeen` change would read the second (valid)
|
|
574
|
+
// re-register as a no-op and never record its protocol — a same-connection protocol upgrade would
|
|
575
|
+
// silently keep the old value. Freeze the clock so both registers land on the SAME tick and prove
|
|
576
|
+
// the upgrade is still recorded. (Reproduces the round-3 review finding; red under the old
|
|
577
|
+
// lastSeen-comparison code, green with the explicit accepted-register signal.)
|
|
578
|
+
mock.timers.enable({ apis: ["Date"], now: 1_700_000_000_000 });
|
|
579
|
+
const data = memMountData();
|
|
580
|
+
const { hub, transport } = await mountFamily(data);
|
|
581
|
+
try {
|
|
582
|
+
const peer = fakeConn("c1", "leafA");
|
|
583
|
+
transport.connect(peer.conn);
|
|
584
|
+
await flush();
|
|
585
|
+
|
|
586
|
+
// First accepted register: protocol 1.
|
|
587
|
+
peer.feed(registerFrame("w1", { harnessProtocol: 1 }));
|
|
588
|
+
await flush();
|
|
589
|
+
assertEquals((await assessWorkers(data, ["w1"], {})).get("w1")?.harnessProtocol, 1, "first protocol recorded");
|
|
590
|
+
|
|
591
|
+
// Second accepted re-register from the SAME connection, upgrading to protocol 2 — WITHOUT
|
|
592
|
+
// advancing the frozen clock, so its `lastSeen` is byte-identical to the first register's.
|
|
593
|
+
peer.feed(registerFrame("w1", { harnessProtocol: 2 }));
|
|
594
|
+
await flush();
|
|
595
|
+
|
|
596
|
+
const assessment = await assessWorkers(data, ["w1"], {});
|
|
597
|
+
assertEquals(assessment.get("w1")?.harnessProtocol, 2, "same-tick accepted re-register records its upgraded protocol");
|
|
598
|
+
} finally {
|
|
599
|
+
family.teardown?.();
|
|
600
|
+
await hub.close();
|
|
601
|
+
mock.timers.reset();
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
test("family: a rejected REGISTER concurrent with an accepted one for the same instance records only the accepted protocol", async () => {
|
|
606
|
+
// The recording wrapper `await`s the package handler, so two REGISTER frames for the SAME instance
|
|
607
|
+
// can be in flight at once (e.g. a foreign takeover on one connection racing a genuine re-register
|
|
608
|
+
// on the owner's). A shared per-instance "accepted generation" counter mis-attributes here: the
|
|
609
|
+
// rejected frame snapshots the counter, parks on its await while the concurrent ACCEPTED frame
|
|
610
|
+
// advances it, then resumes to see it changed and records its OWN absent protocol — a spurious
|
|
611
|
+
// `null` write that can clear a healthy value. The acceptance signal must be bound per-invocation
|
|
612
|
+
// (AsyncLocalStorage), so a concurrent frame can never flip THIS frame's flag. Assert it directly:
|
|
613
|
+
// during the overlapping pair, the ONLY value recorded is the accepted frame's — no clearing `null`
|
|
614
|
+
// write from the rejected frame. (Red under the shared-counter code, which writes `[null, 2]`;
|
|
615
|
+
// green with the per-invocation binding, which writes `[2]`.)
|
|
616
|
+
const { data, writes } = withProtocolWriteSpy(memMountData());
|
|
617
|
+
const { hub, transport } = await mountFamily(data);
|
|
618
|
+
try {
|
|
619
|
+
const owner = fakeConn("c1", "owner");
|
|
620
|
+
const attacker = fakeConn("c2", "attacker");
|
|
621
|
+
transport.connect(owner.conn);
|
|
622
|
+
transport.connect(attacker.conn);
|
|
623
|
+
await flush();
|
|
624
|
+
|
|
625
|
+
// Owner registers w1 healthily (protocol 1).
|
|
626
|
+
owner.feed(registerFrame("w1", { harnessProtocol: 1 }));
|
|
627
|
+
await flush();
|
|
628
|
+
assertEquals(writes, [1], "initial register records protocol 1");
|
|
629
|
+
writes.length = 0;
|
|
630
|
+
|
|
631
|
+
// Overlapping pair, fed WITHOUT a flush between so both are in flight across the await: a foreign
|
|
632
|
+
// takeover (rejected by ownership, no protocol) and the owner's accepted re-register to protocol 2.
|
|
633
|
+
attacker.feed(registerFrame("w1", {}));
|
|
634
|
+
owner.feed(registerFrame("w1", { harnessProtocol: 2 }));
|
|
635
|
+
await flush();
|
|
636
|
+
await flush();
|
|
637
|
+
|
|
638
|
+
// Only the accepted frame recorded; the rejected frame contributed no clearing `null` write.
|
|
639
|
+
assertEquals(writes, [2], "only the accepted re-register records; the rejected frame records nothing");
|
|
640
|
+
assertEquals((await assessWorkers(data, ["w1"], {})).get("w1")?.harnessProtocol, 2, "recorded protocol is the accepted upgrade");
|
|
641
|
+
} finally {
|
|
642
|
+
family.teardown?.();
|
|
643
|
+
await hub.close();
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
test("family: two overlapping ACCEPTED re-registers record the NEWEST protocol even if the older write lands last", async () => {
|
|
648
|
+
// Two ACCEPTED re-registers for the same instance can be in flight at once (a reconnect race on the
|
|
649
|
+
// owner). `recordEnrolment` is async (findOne → update), so if the OLDER frame's write resumes AFTER
|
|
650
|
+
// the newer frame's, the older protocol overwrites the newer one — leaving the supply assessment
|
|
651
|
+
// stale until the next register. The recording must serialize per instance and honour acceptance
|
|
652
|
+
// order so the NEWEST accepted protocol always wins. Force the hazard deterministically: delay the
|
|
653
|
+
// older frame's (protocol 2) update by one macrotask so, unguarded, it lands after the newer
|
|
654
|
+
// (protocol 3) write. (Red under the un-serialized code, which persists the stale 2; green with the
|
|
655
|
+
// per-instance serialization + acceptance-sequence guard, which persists 3.)
|
|
656
|
+
const data = withDelayedProtocolUpdate(memMountData(), 2);
|
|
657
|
+
const { hub, transport } = await mountFamily(data);
|
|
658
|
+
try {
|
|
659
|
+
const owner = fakeConn("c1", "owner");
|
|
660
|
+
transport.connect(owner.conn);
|
|
661
|
+
await flush();
|
|
662
|
+
|
|
663
|
+
// Initial accepted register: protocol 1 (an insert, so the later updates are the ones that race).
|
|
664
|
+
owner.feed(registerFrame("w1", { harnessProtocol: 1 }));
|
|
665
|
+
await flush();
|
|
666
|
+
assertEquals((await assessWorkers(data, ["w1"], {})).get("w1")?.harnessProtocol, 1, "initial protocol recorded");
|
|
667
|
+
|
|
668
|
+
// Overlapping accepted re-registers, fed WITHOUT a flush between so both are in flight across the
|
|
669
|
+
// await: protocol 2 (older) then protocol 3 (newer). The newest accepted protocol must win.
|
|
670
|
+
owner.feed(registerFrame("w1", { harnessProtocol: 2 }));
|
|
671
|
+
owner.feed(registerFrame("w1", { harnessProtocol: 3 }));
|
|
672
|
+
await flush();
|
|
673
|
+
await flush();
|
|
674
|
+
await flush();
|
|
675
|
+
|
|
676
|
+
assertEquals(
|
|
677
|
+
(await assessWorkers(data, ["w1"], {})).get("w1")?.harnessProtocol,
|
|
678
|
+
3,
|
|
679
|
+
"the newest accepted re-register wins even when the older frame's write completes last",
|
|
680
|
+
);
|
|
681
|
+
} finally {
|
|
682
|
+
family.teardown?.();
|
|
683
|
+
await hub.close();
|
|
684
|
+
}
|
|
685
|
+
});
|
|
@@ -19,19 +19,29 @@
|
|
|
19
19
|
// is untouched — presence rides the agentic channel only; ADVISORY — the registry is a read-only
|
|
20
20
|
// mirror and NEVER hard-locks or gates a BPMN sequence flow. Capability (cognition/weight/family/host)
|
|
21
21
|
// is an ENROLMENT attribute, never a routing token.
|
|
22
|
+
|
|
23
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
24
|
+
import type { AgenticHub, FamilyHandler, HubConnection } from "@nanobpm/agentic/channel";
|
|
22
25
|
import {
|
|
23
26
|
attachPresenceFamily,
|
|
24
27
|
type PresenceFamilyHandle,
|
|
28
|
+
type PresenceRow,
|
|
25
29
|
PresenceStore,
|
|
26
30
|
type PresenceStoreOptions,
|
|
31
|
+
type RegisterInput,
|
|
27
32
|
type SqliteDb,
|
|
28
33
|
} from "@nanobpm/agentic/presence";
|
|
34
|
+
import type { Frame, MessageFamily } from "@nanobpm/agentic/protocol";
|
|
29
35
|
import type { DataLayer } from "@nanobpm/urban";
|
|
36
|
+
import { HarnessProtocolRegistry } from "../../harnessProtocol.ts";
|
|
30
37
|
import type { AgenticContext, AgenticFamily } from "../registry.ts";
|
|
31
38
|
|
|
32
39
|
/** The message-family name this module owns (its three handlers are register/heartbeat/deregister). */
|
|
33
40
|
export const PRESENCE_FAMILY = "presence";
|
|
34
41
|
|
|
42
|
+
/** The presence REGISTER message family — the frame that carries the enrolment capability. */
|
|
43
|
+
const REGISTER_FAMILY: MessageFamily = "register";
|
|
44
|
+
|
|
35
45
|
/** The maintenance tick runs at a third of the presence TTL — matching the hub/store sweep cadence. */
|
|
36
46
|
const SWEEP_DIVISOR = 3;
|
|
37
47
|
|
|
@@ -262,6 +272,170 @@ export function createPresenceStore(db: SqliteDb, options?: PresenceStoreOptions
|
|
|
262
272
|
return new PresenceStore(db, options);
|
|
263
273
|
}
|
|
264
274
|
|
|
275
|
+
/** Read a property off an unknown value without an unsafe `as` cast (mirrors the relay/loader helper). */
|
|
276
|
+
function readProp(value: unknown, key: string): unknown {
|
|
277
|
+
if (!value || typeof value !== "object") return undefined;
|
|
278
|
+
return Object.hasOwn(value, key) ? Object.getOwnPropertyDescriptor(value, key)?.value : undefined;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Read an optional advertised harness protocol off a presence REGISTER capability (issue #820).
|
|
282
|
+
*
|
|
283
|
+
* The wire capability the package validates is `{cognition, weight, family, host}` and its handler
|
|
284
|
+
* DROPS any other field, so the harness-protocol version a c8ctl-nano worker advertises over the
|
|
285
|
+
* presence channel (jwulf/c8ctl-plugin-nano#272) would never reach the ONE `worker_harness_protocol`
|
|
286
|
+
* recorder that the #802 staleness gate reads — the gate is inert for the presence-only fleet. This
|
|
287
|
+
* lifts the advertised version out of the register capability so it can be recorded. It is an
|
|
288
|
+
* ADR 0056 §7 enrolment attribute (never a routing token): a non-numeric / absent value reads back as
|
|
289
|
+
* `undefined` → the recorder stores NULL → the worker is assessed STALE (absent-version-is-stale, the
|
|
290
|
+
* #802 signature, preserved). */
|
|
291
|
+
function readHarnessProtocol(payload: unknown): number | undefined {
|
|
292
|
+
const value = readProp(readProp(payload, "capability"), "harnessProtocol");
|
|
293
|
+
return typeof value === "number" ? value : undefined;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Wrap `hub` so that the presence REGISTER handler the package installs ALSO records the advertised
|
|
298
|
+
* harness protocol into the ONE canonical {@link HarnessProtocolRegistry} (issue #820) — the same
|
|
299
|
+
* table the HTTP `enrolAgenticWorker` door writes and `assessWorkers` reads (derivation over
|
|
300
|
+
* duplication, not a second store). Every other family passes through untouched.
|
|
301
|
+
*
|
|
302
|
+
* The record is gated on the register having actually TAKEN EFFECT — NOT merely on who owns the row
|
|
303
|
+
* afterwards. A bare "is the row now owned by this connection?" check is insufficient: a rejected or
|
|
304
|
+
* malformed RE-register from the connection that ALREADY owns the instance leaves the prior row in
|
|
305
|
+
* place, so that check still passes and would record the rejected frame's protocol (often `undefined`)
|
|
306
|
+
* — silently clearing a healthy protocol without any successful enrolment. The package accepts a
|
|
307
|
+
* register by calling {@link PresenceStore.register} exactly once and rejects it (a malformed payload,
|
|
308
|
+
* or an ownership takeover of an instance bound to a different identity) by dropping the frame WITHOUT
|
|
309
|
+
* touching the store. So we wrap `store.register` to flip an acceptance flag bound — via
|
|
310
|
+
* {@link AsyncLocalStorage} — to the exact recording invocation whose handler triggered it, and record
|
|
311
|
+
* only when THIS frame's flag is set: an explicit, per-invocation acceptance signal, not an inferred
|
|
312
|
+
* one.
|
|
313
|
+
*
|
|
314
|
+
* The binding must be per-invocation, NOT a shared per-instance marker: the recording wrapper `await`s
|
|
315
|
+
* the package handler, so two REGISTER frames for the same instance can be in flight at once. A shared
|
|
316
|
+
* "accepted generation" counter compared before/after the await would MIS-ATTRIBUTE — a rejected frame
|
|
317
|
+
* that snapshots the counter, parks on its await while a concurrent ACCEPTED frame for the same
|
|
318
|
+
* instance advances the counter, then resumes to see it changed and records its own absent/invalid
|
|
319
|
+
* protocol, clearing a healthy value. AsyncLocalStorage carries each frame's flag through its own await
|
|
320
|
+
* chain, so a concurrent frame runs in a DISTINCT context and can never flip this frame's flag. It also
|
|
321
|
+
* holds NO per-instance state, so nothing accumulates for deregistered / TTL-swept / ephemeral
|
|
322
|
+
* instances (an earlier per-instance `Map` leaked an entry per distinct instance forever). (An even
|
|
323
|
+
* earlier before/after `lastSeen` comparison could also FALSE-NEGATIVE two accepted same-connection
|
|
324
|
+
* re-registers within one `Date.now()` tick — identical millisecond timestamps read as "no change".)
|
|
325
|
+
* That skips every register the package dropped, so neither a foreign peer nor a self-inflicted bad
|
|
326
|
+
* frame can rewrite a worker's recorded protocol. Two ACCEPTED re-registers for one instance can also
|
|
327
|
+
* be in flight at once (a reconnect race), and the record write is async; the writes are SERIALIZED
|
|
328
|
+
* per instance and stamped with the monotonic acceptance sequence, so a stale (older) frame resuming
|
|
329
|
+
* last is skipped rather than overwriting the newer protocol — the newest accepted protocol always
|
|
330
|
+
* wins. Recording is best-effort: a registry write hiccup is
|
|
331
|
+
* logged and swallowed, exactly like the presence sweep, and never fails the register.
|
|
332
|
+
*/
|
|
333
|
+
function withHarnessProtocolRecording(
|
|
334
|
+
hub: AgenticHub,
|
|
335
|
+
store: PresenceStore,
|
|
336
|
+
data: DataLayer,
|
|
337
|
+
log: AgenticContext["log"],
|
|
338
|
+
): AgenticHub {
|
|
339
|
+
const registry = new HarnessProtocolRegistry(data);
|
|
340
|
+
// An acceptance flag bound to the CURRENT recording invocation (AsyncLocalStorage, not a shared
|
|
341
|
+
// per-instance counter): the package handler calls store.register exactly on an accepted frame, so
|
|
342
|
+
// flipping THIS invocation's flag inside the wrapped register makes acceptance observable without a
|
|
343
|
+
// before/after comparison that could either collide within one Date.now() tick or be cross-attributed
|
|
344
|
+
// to a concurrent frame for the same instance across the await. It also keeps no per-instance state,
|
|
345
|
+
// so nothing leaks for deregistered / TTL-swept instances.
|
|
346
|
+
const acceptance = new AsyncLocalStorage<{ accepted: boolean; seq: number }>();
|
|
347
|
+
let acceptSeq = 0;
|
|
348
|
+
const registerInner = store.register.bind(store);
|
|
349
|
+
store.register = (input: RegisterInput): PresenceRow => {
|
|
350
|
+
const row = registerInner(input);
|
|
351
|
+
const active = acceptance.getStore();
|
|
352
|
+
// Stamp a monotonic acceptance SEQUENCE at the synchronous register call: it reflects the true
|
|
353
|
+
// order in which the store accepted overlapping re-registers, which the async record write below
|
|
354
|
+
// must preserve.
|
|
355
|
+
if (active) {
|
|
356
|
+
active.accepted = true;
|
|
357
|
+
active.seq = ++acceptSeq;
|
|
358
|
+
}
|
|
359
|
+
return row;
|
|
360
|
+
};
|
|
361
|
+
// Per-instance serialized write chains. Two ACCEPTED re-registers for one instance can be in flight
|
|
362
|
+
// at once (a reconnect race on the owner), and `recordEnrolment` is async (findOne → update), so
|
|
363
|
+
// their writes could otherwise interleave and land out of order — the OLDER frame resuming last and
|
|
364
|
+
// overwriting the NEWER protocol, leaving the supply assessment stale until the next register.
|
|
365
|
+
// Serialize the writes per instance AND skip any write whose acceptance sequence is stale (a newer
|
|
366
|
+
// accepted register already applied), so the newest accepted protocol always wins regardless of
|
|
367
|
+
// completion timing. The entry is dropped once its chain drains, so — like the per-invocation marker
|
|
368
|
+
// — nothing accumulates for deregistered / TTL-swept / ephemeral instances.
|
|
369
|
+
const writeChains = new Map<string, { tail: Promise<void>; lastSeq: number; pending: number }>();
|
|
370
|
+
const recordInOrder = (instance: string, seq: number, protocol: number | undefined): Promise<void> => {
|
|
371
|
+
let chain = writeChains.get(instance);
|
|
372
|
+
if (chain === undefined) {
|
|
373
|
+
chain = { tail: Promise.resolve(), lastSeq: 0, pending: 0 };
|
|
374
|
+
writeChains.set(instance, chain);
|
|
375
|
+
}
|
|
376
|
+
const c = chain;
|
|
377
|
+
c.pending++;
|
|
378
|
+
const run = c.tail.then(async () => {
|
|
379
|
+
// A newer accepted register (higher seq) already applied — this frame's write is stale, so skip
|
|
380
|
+
// it rather than clobber the fresher protocol with an out-of-order value.
|
|
381
|
+
if (seq <= c.lastSeq) return;
|
|
382
|
+
c.lastSeq = seq;
|
|
383
|
+
await registry.recordEnrolment(instance, protocol);
|
|
384
|
+
});
|
|
385
|
+
// Keep the chain live even if one write rejects (a rejection must not wedge later writes), and drop
|
|
386
|
+
// the per-instance entry once the last queued write drains.
|
|
387
|
+
c.tail = run
|
|
388
|
+
.then(
|
|
389
|
+
() => {},
|
|
390
|
+
() => {},
|
|
391
|
+
)
|
|
392
|
+
.finally(() => {
|
|
393
|
+
if (--c.pending === 0 && writeChains.get(instance) === c) writeChains.delete(instance);
|
|
394
|
+
});
|
|
395
|
+
return run;
|
|
396
|
+
};
|
|
397
|
+
return new Proxy(hub, {
|
|
398
|
+
get(target, prop, receiver) {
|
|
399
|
+
if (prop !== "registerFamilyHandler") {
|
|
400
|
+
const value = Reflect.get(target, prop, receiver);
|
|
401
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
402
|
+
}
|
|
403
|
+
return (family: MessageFamily, handler: FamilyHandler<HubConnection>) => {
|
|
404
|
+
if (family !== REGISTER_FAMILY) {
|
|
405
|
+
target.registerFamilyHandler(family, handler);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const recording: FamilyHandler<HubConnection> = async (frame: Frame, conn: HubConnection) => {
|
|
409
|
+
const instance = readInstance(frame.payload);
|
|
410
|
+
// Run the package handler inside a fresh acceptance context. store.register — called by the
|
|
411
|
+
// handler exactly when it ACCEPTS this frame — flips THIS marker; a REJECTED frame (malformed
|
|
412
|
+
// or an ownership takeover the handler drops without touching the store) leaves it false. The
|
|
413
|
+
// marker travels with this frame's own async chain, so a concurrent frame for the same
|
|
414
|
+
// instance runs in a distinct context and can never flip it across the await.
|
|
415
|
+
const marker = { accepted: false, seq: 0 };
|
|
416
|
+
await acceptance.run(marker, () => handler(frame, conn));
|
|
417
|
+
if (instance === undefined) return;
|
|
418
|
+
// Record only when the package accepted THIS frame's register. A rejected re-register leaves
|
|
419
|
+
// the marker false, so it records nothing and cannot clear a healthy protocol.
|
|
420
|
+
if (!marker.accepted) return;
|
|
421
|
+
try {
|
|
422
|
+
await recordInOrder(instance, marker.seq, readHarnessProtocol(frame.payload));
|
|
423
|
+
} catch (err) {
|
|
424
|
+
log.warn("agentic presence: harness-protocol record failed", { instance, err: String(err) });
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
target.registerFamilyHandler(family, recording);
|
|
428
|
+
};
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** The `register.instance` off a presence payload, or undefined when absent/blank. */
|
|
434
|
+
function readInstance(payload: unknown): string | undefined {
|
|
435
|
+
const value = readProp(payload, "instance");
|
|
436
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
437
|
+
}
|
|
438
|
+
|
|
265
439
|
/**
|
|
266
440
|
* The presence family module. `mount` attaches register/heartbeat/deregister to the hub, applies the
|
|
267
441
|
* schema, and starts ONE canonical maintenance tick that both ages out on the presence TTL and drops
|
|
@@ -271,8 +445,9 @@ export const family: AgenticFamily = {
|
|
|
271
445
|
name: PRESENCE_FAMILY,
|
|
272
446
|
|
|
273
447
|
mount(ctx: AgenticContext): void {
|
|
274
|
-
const
|
|
275
|
-
|
|
448
|
+
const data = ctx.data;
|
|
449
|
+
const db = openPresenceDb(data);
|
|
450
|
+
if (!db || !data) {
|
|
276
451
|
ctx.log.warn("agentic presence: no data layer mounted — presence registry disabled");
|
|
277
452
|
return;
|
|
278
453
|
}
|
|
@@ -284,12 +459,18 @@ export const family: AgenticFamily = {
|
|
|
284
459
|
|
|
285
460
|
// Attach the three presence handlers via the S1 seam. Disable the package's own TTL timer
|
|
286
461
|
// (`sweepIntervalMs: 0`) so this module runs a SINGLE maintenance loop rather than two — the
|
|
287
|
-
// canonical presence-maintenance pass, not a second poller (derivation over duplication).
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
462
|
+
// canonical presence-maintenance pass, not a second poller (derivation over duplication). The hub
|
|
463
|
+
// is wrapped so the REGISTER handler ALSO records the advertised harness protocol into the ONE
|
|
464
|
+
// canonical registry (issue #820) — the presence-channel path to the #802 staleness gate.
|
|
465
|
+
const handle = attachPresenceFamily(
|
|
466
|
+
withHarnessProtocolRecording(ctx.hub, store, data, ctx.log),
|
|
467
|
+
store,
|
|
468
|
+
{
|
|
469
|
+
sweepIntervalMs: 0,
|
|
470
|
+
onError: (err, connectionId) =>
|
|
471
|
+
ctx.log.warn("agentic presence fault", { connectionId, err: String(err) }),
|
|
472
|
+
},
|
|
473
|
+
);
|
|
293
474
|
|
|
294
475
|
const interval = Math.max(1, Math.floor(store.ttlMs / SWEEP_DIVISOR));
|
|
295
476
|
const tick = () => {
|