@nanobpm/nano-workforce 0.192.0 → 0.193.1

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 CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.193.1](https://github.com/nanobpm/nano-workforce/compare/v0.193.0...v0.193.1) (2026-09-28)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **convergence:** reset round output vars on entry so a no-result round can't re-escalate ([#823](https://github.com/nanobpm/nano-workforce/issues/823)) ([3154918](https://github.com/nanobpm/nano-workforce/commit/3154918f3aeea477733347277ec067c1e0359e84)), closes [nano-coder#35](https://github.com/nanobpm/nano-coder/issues/35) [#806](https://github.com/nanobpm/nano-workforce/issues/806) [#822](https://github.com/nanobpm/nano-workforce/issues/822) [#822](https://github.com/nanobpm/nano-workforce/issues/822)
6
+
7
+ ## [0.193.0](https://github.com/nanobpm/nano-workforce/compare/v0.192.0...v0.193.0) (2026-09-28)
8
+
9
+ ### Features
10
+
11
+ * **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)
12
+
1
13
  ## [0.192.0](https://github.com/nanobpm/nano-workforce/compare/v0.191.1...v0.192.0) (2026-09-28)
2
14
 
3
15
  ### Features
package/SPEC.md CHANGED
@@ -300,6 +300,27 @@ Notes:
300
300
  poller does not solicit a spurious review against the still-running round. The
301
301
  round cap and the review-wait timeout remain the outer safety nets.
302
302
 
303
+ - **Stale-output reset on round entry (issue #822).** `pr.capture-head` — the
304
+ single task every entry into `review-round` routes through first (from `Start`,
305
+ the review-loop re-enter, the human-answer resume, the husk auto-retry, and the
306
+ ack-only retry) — additionally CLEARS the round's output variables on entry:
307
+ `status`, `summary`, and `question` (→ `null`) and `escalated` (→ `false`),
308
+ mirroring the merge loop's `arm-merge` (which already clears `status` on entry
309
+ before its agent runs). A `review-round` job taken by a misconfigured worker can
310
+ exit 0 with no result variables, empty output, and no push; because nothing
311
+ overwrote them, the PREVIOUS round's `status`/`summary`/`question` would
312
+ otherwise persist and `gw-status` would re-route on that stale decision —
313
+ re-opening a word-for-word duplicate of an escalation a human had just answered
314
+ (the answer never reaching an agent). Resetting on entry makes a no-result round
315
+ enter on a blank `status`/`question`, so it can no longer inherit the prior
316
+ round's decision: it falls through `gw-status`'s `f_addressed` default into
317
+ `persist-round` → `check-progress`, where the unchanged head is caught by the
318
+ no-progress guard above and classified (husk vs. no-advance) on its own merits.
319
+ These vars are reset on ENTRY here rather than on `review-round`'s own output
320
+ because a same-task output-clear would clobber the agent's real verdict;
321
+ `answer`/`scopePending` (inputs the agent consumes) stay cleared on the
322
+ review-round output.
323
+
303
324
 
304
325
  ## 5. Agent job contract (`senior:pr-review`)
305
326
 
@@ -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
- async function mountFamily(db: SqliteDb | undefined): Promise<{ hub: AgenticHub; transport: ReturnType<typeof memTransport> }> {
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: db ? memData(db) : undefined,
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(memSqlite());
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(memSqlite());
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(memSqlite());
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(memSqlite());
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 db = openPresenceDb(ctx.data);
275
- if (!db) {
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
- const handle = attachPresenceFamily(ctx.hub, store, {
289
- sweepIntervalMs: 0,
290
- onError: (err, connectionId) =>
291
- ctx.log.warn("agentic presence fault", { connectionId, err: String(err) }),
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 = () => {
@@ -0,0 +1,92 @@
1
+ // Regression guard for issue #822 — the convergence loop must not REUSE a previous round's
2
+ // status/question/summary/escalated when an agent round returns NO result.
3
+ //
4
+ // The defect (proc 86936 / nano-coder#35): a `review-round` job taken by a misconfigured worker
5
+ // exited 0 in ~11s with NO result vars, no output, no commits, no push. Because nothing overwrote
6
+ // them, the PREVIOUS round's `status = "needs_input"`, `summary` and `question` stayed in scope.
7
+ // `gw-status` then routed on that stale `status`/`question` to `persist-escalation`, opening a
8
+ // word-for-word DUPLICATE of the escalation a human had just answered — the human's answer never
9
+ // reached an agent.
10
+ //
11
+ // The fix (categorical, not a special-case): clear the round's OUTPUT variables ON ENTRY to the
12
+ // round, on `capture-head` — the single task every entry into `review-round` routes through first
13
+ // (from Start, the review-loop re-enter, the human-answer resume, the husk auto-retry, and the
14
+ // ack-only retry). This mirrors the merge loop's `arm-merge`, which clears `status` on entry before
15
+ // its agent runs. With the round entering on a blank `status`/`question`, a no-result round can no
16
+ // longer inherit the prior round's decision: it falls through `gw-status`'s `f_addressed` default
17
+ // into `persist-round` → `check-progress`, where the unchanged PR head is caught as a no-progress
18
+ // round. `decideProgress` then classifies it on positive evidence (`app/roundProgress.ts`): only a
19
+ // corroborated non-terminal completing `review-round` instance is a husk (auto-retried within the
20
+ // budget); a terminal instance OR an absent/unknown read fails safe to `no-advance` and is escalated
21
+ // immediately as "produced nothing" (never re-running, to avoid duplicating agent work that may have
22
+ // run). Either way the round is routed as no-progress and NOT re-asked as an already-answered
23
+ // question.
24
+ //
25
+ // `answer`/`scopePending` (INPUTS the agent CONSUMES) are cleared on the review-round OUTPUT so the
26
+ // NEXT round can't re-consume them; `status`/`summary`/`question`/`escalated` (OUTPUTS the agent
27
+ // PRODUCES) must instead be cleared on ENTRY, because a same-task output-clear would clobber the
28
+ // agent's real verdict. Hence they live on `capture-head`, not on `review-round`.
29
+ //
30
+ // Pure text assertions over the committed BPMN (no engine), matching the repo's lightweight
31
+ // model-guard style (see convergenceEscalationGuard.test.ts, roundResultDefault.test.ts).
32
+
33
+ import { test } from "node:test";
34
+ import { assert, assertStringIncludes } from "#test-assert";
35
+ import { readFileSync } from "node:fs";
36
+
37
+ const bpmn = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
38
+ // Collapse whitespace so attribute-order / line-wrapping churn doesn't make the assertions brittle.
39
+ const flat = bpmn.replace(/\s+/g, " ");
40
+
41
+ /** The <serviceTask> element for the given id (whole element, up to its close), or null. */
42
+ function serviceTask(id: string): string | null {
43
+ const m = flat.match(new RegExp(`<bpmn:serviceTask\\b[^>]*\\bid="${id}"[\\s\\S]*?</bpmn:serviceTask>`));
44
+ return m ? m[0] : null;
45
+ }
46
+
47
+ // The round-output variables that must be reset on entry so a no-result round can't inherit them.
48
+ // `escalated` is reset to `false` (a boolean flag); the rest to `null` (absent).
49
+ const RESET_ON_ENTRY: ReadonlyArray<{ target: string; source: string }> = [
50
+ { target: "status", source: "=null" },
51
+ { target: "summary", source: "=null" },
52
+ { target: "question", source: "=null" },
53
+ { target: "escalated", source: "=false" },
54
+ ];
55
+
56
+ test("capture-head clears the round's output vars on entry to every review round", () => {
57
+ const el = serviceTask("capture-head");
58
+ assert(el, "capture-head service task must exist");
59
+ for (const { target, source } of RESET_ON_ENTRY) {
60
+ const re = new RegExp(`<zeebe:output\\b[^>]*\\bsource="${source}"[^>]*\\btarget="${target}"[^>]*/>`);
61
+ const alt = new RegExp(`<zeebe:output\\b[^>]*\\btarget="${target}"[^>]*\\bsource="${source}"[^>]*/>`);
62
+ assert(
63
+ re.test(el!) || alt.test(el!),
64
+ `capture-head must clear "${target}" (source ${source}) on entry so a no-result round can't reuse the prior round's ${target} (issue #822)`,
65
+ );
66
+ }
67
+ });
68
+
69
+ test("capture-head still precedes review-round on every loop entry (the reset is truly on entry)", () => {
70
+ // The reset is only "on entry to the round" if capture-head runs immediately before review-round
71
+ // for EVERY way the loop re-enters. capture-head's single outgoing must target review-round, and
72
+ // it must be the join for all the re-entry flows.
73
+ const el = serviceTask("capture-head");
74
+ assert(el, "capture-head service task must exist");
75
+ const incoming = [...el!.matchAll(/<bpmn:incoming>([^<]+)<\/bpmn:incoming>/g)].map((m) => m[1]);
76
+ for (const flow of ["f_start", "f_reviewLoop", "f_answerLoop", "f_huskRetry", "f_ackRetry"]) {
77
+ assert(incoming.includes(flow), `capture-head must be the entry join for ${flow} so the reset covers that re-entry (issue #822)`);
78
+ }
79
+ const capture = flat.match(/<bpmn:sequenceFlow[^>]*\bid="f_capture"[^>]*\/>/);
80
+ assert(capture, "f_capture flow missing");
81
+ assertStringIncludes(capture![0], 'sourceRef="capture-head"');
82
+ assertStringIncludes(capture![0], 'targetRef="review-round"');
83
+ });
84
+
85
+ test("gw-status still defaults a blank status to the addressed (re-enter) arm, not escalation", () => {
86
+ // The reset only helps if a blank status routes to the no-progress/husk path rather than
87
+ // escalation. This pins the interaction: with status/question cleared, gw-status falls to
88
+ // f_addressed (persist-round → check-progress), where an unchanged head is caught as a husk.
89
+ const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-status"[^>]*>/);
90
+ assert(gw, "gw-status gateway missing");
91
+ assertStringIncludes(gw![0], 'default="f_addressed"');
92
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.192.0",
3
+ "version": "0.193.1",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -145,6 +145,12 @@
145
145
  <zeebe:property name="io.nanobpm.dataEnvelope.in" value="PrCaptureHeadIn" />
146
146
  <zeebe:property name="io.nanobpm.dataEnvelope.out" value="PrCaptureHeadOut" />
147
147
  </zeebe:properties>
148
+ <zeebe:ioMapping>
149
+ <zeebe:output source="=null" target="status" />
150
+ <zeebe:output source="=null" target="summary" />
151
+ <zeebe:output source="=null" target="question" />
152
+ <zeebe:output source="=false" target="escalated" />
153
+ </zeebe:ioMapping>
148
154
  </bpmn:extensionElements>
149
155
  <bpmn:incoming>f_start</bpmn:incoming>
150
156
  <bpmn:incoming>f_reviewLoop</bpmn:incoming>
@@ -23,6 +23,16 @@
23
23
  // fresh on every round entry (including husk retries), so it never carries a stale prior-round value.
24
24
  // The read fails OPEN to `null` (a transient GitHub hiccup must never fabricate a no-progress verdict);
25
25
  // progress-check then falls back to the persisted `last_round_head`.
26
+ //
27
+ // Because this task is the single on-entry join before `review-round`, the model also uses it to RESET
28
+ // the round's OUTPUT variables on entry (issue #822): its `ioMapping` clears `status`, `summary`,
29
+ // `question` (→ null) and `escalated` (→ false) in convergence-loop.bpmn. A `review-round` that returns
30
+ // NO result (a misconfigured worker that exits 0 with empty output, no commits, no push) then can't
31
+ // inherit the PREVIOUS round's `status`/`question` and re-open a word-for-word duplicate of an
32
+ // already-answered escalation; instead it falls through `gw-status`'s addressed default into the
33
+ // no-progress (husk) path. These OUTPUT vars are reset on ENTRY (here) rather than on the review-round's
34
+ // own output — a same-task output-clear would clobber the agent's real verdict — mirroring the merge
35
+ // loop's `arm-merge`, which clears `status` on entry before its agent runs.
26
36
  import type { AppJobHandler } from "@nanobpm/urban";
27
37
  import { fetchBranchHead, fetchPrHead } from "../../app/github.ts";
28
38
  import { parsePr } from "../../app/service.ts";