@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 +12 -0
- package/SPEC.md +21 -0
- package/app/agentic/families/presence.family.test.ts +315 -11
- package/app/agentic/families/presence.family.ts +189 -8
- package/app/convergenceStaleRoundReset.test.ts +92 -0
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +6 -0
- package/workers/capture-head/worker.ts +10 -0
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
|
-
|
|
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 = () => {
|
|
@@ -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.
|
|
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";
|