@nanobpm/nano-workforce 0.188.1 → 0.189.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/SPEC.md +16 -0
  3. package/app/adjudications.test.ts +735 -0
  4. package/app/adjudications.ts +378 -0
  5. package/app/agentCompletion.test.ts +282 -10
  6. package/app/agentCompletion.ts +163 -23
  7. package/app/agentic/cockpit/mount.test.ts +50 -0
  8. package/app/agentic/cockpit/supply-render.test.ts +20 -0
  9. package/app/agentic/cockpit/supply-render.ts +15 -0
  10. package/app/agentic/cockpit/supply-view.ts +19 -2
  11. package/app/agentic/permission-bridge.test.ts +2 -2
  12. package/app/agentic/vocab/demand-report.test.ts +66 -1
  13. package/app/agentic/vocab/demand-report.ts +54 -6
  14. package/app/answer-escalation.test.ts +415 -2
  15. package/app/answerContextMapping.test.ts +83 -0
  16. package/app/contracts.ts +24 -0
  17. package/app/convergenceAdjudicationResume.test.ts +274 -0
  18. package/app/github.ts +10 -0
  19. package/app/harnessProtocol.test.ts +170 -0
  20. package/app/harnessProtocol.ts +312 -0
  21. package/app/mcpToolSurface.ts +7 -1
  22. package/app/service.test.ts +178 -1
  23. package/app/service.ts +104 -4
  24. package/app/terminalReaderBehaviour.test.ts +21 -0
  25. package/db/migrations/107_worker_harness_protocol.sql +30 -0
  26. package/db/migrations/109_pr_adjudications.sql +61 -0
  27. package/db/migrations/110_task_completions_auto_applied.sql +34 -0
  28. package/openapi.yaml +71 -1
  29. package/operations/completeUserTask.test.ts +5 -5
  30. package/operations/enrolAgenticWorker.test.ts +84 -0
  31. package/operations/enrolAgenticWorker.ts +67 -7
  32. package/operations/getAgenticRegistry.ts +1 -1
  33. package/operations/getAgenticSupply.test.ts +80 -0
  34. package/operations/getAgenticSupply.ts +15 -3
  35. package/operations/listEscalations.test.ts +1 -1
  36. package/package.json +1 -1
  37. package/pages/cockpit/mount.js +18 -0
  38. package/resources/processes/convergence-loop.bpmn +9 -0
  39. package/resources/processes/merge-loop.bpmn +1 -0
  40. package/test/worldDb.ts +6 -0
  41. package/workers/answer-escalation/worker.ts +191 -11
@@ -0,0 +1,274 @@
1
+ // Red/green regression for issue #806 — the convergence loop must not re-escalate an already-answered
2
+ // `wait-answer` question. When a durable adjudication exists for (this PR, this question fingerprint),
3
+ // the poller (`pollUserTasks`) auto-resumes the parked `wait-answer` with the recorded answer through
4
+ // the SAME `completeEscalationAsHuman` door a human uses — attributed to the prior adjudicator —
5
+ // instead of re-parking a human (PR #800 / proc 46310: the same design question escalated at round 2
6
+ // and again at round 13, both answered identically).
7
+ //
8
+ // A materially different question (no matching adjudication) still escalates normally.
9
+ import { test } from "node:test";
10
+ import { assertEquals } from "#test-assert";
11
+ import type { DataLayer, EngineClient } from "@nanobpm/urban";
12
+ import { questionFingerprint } from "./github.ts";
13
+ import { pollUserTasks } from "./service.ts";
14
+
15
+ // biome-ignore lint/suspicious/noExplicitAny: in-memory table double, mirrors pollUserTasks.test.ts
16
+ function memData(seed: Record<string, any[]> = {}): { data: DataLayer; stores: Record<string, any[]> } {
17
+ // biome-ignore lint/suspicious/noExplicitAny: see above
18
+ const stores: Record<string, any[]> = {};
19
+ for (const [k, v] of Object.entries(seed)) stores[k] = v.map((r) => ({ ...r }));
20
+ function tbl(name: string, pk = "id") {
21
+ // biome-ignore lint/suspicious/noExplicitAny: see above
22
+ const rows = (stores[name] ??= [] as any[]);
23
+ // biome-ignore lint/suspicious/noExplicitAny: see above
24
+ const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
25
+ return {
26
+ async all() {
27
+ return rows.slice();
28
+ },
29
+ // biome-ignore lint/suspicious/noExplicitAny: see above
30
+ async get(id: any) {
31
+ return rows.find((r) => r[pk] === id);
32
+ },
33
+ // biome-ignore lint/suspicious/noExplicitAny: see above
34
+ async find(where: any = {}) {
35
+ return rows.filter((r) => match(r, where));
36
+ },
37
+ // biome-ignore lint/suspicious/noExplicitAny: see above
38
+ async insert(r: any) {
39
+ rows.push({ ...r });
40
+ return r[pk];
41
+ },
42
+ // biome-ignore lint/suspicious/noExplicitAny: see above
43
+ async update(id: any, patch: any) {
44
+ const r = rows.find((row) => row[pk] === id);
45
+ if (r) Object.assign(r, patch);
46
+ },
47
+ // biome-ignore lint/suspicious/noExplicitAny: see above
48
+ async delete(id: any) {
49
+ const i = rows.findIndex((r) => r[pk] === id);
50
+ if (i >= 0) rows.splice(i, 1);
51
+ },
52
+ };
53
+ }
54
+ const data = { table: (n: string, pk?: string) => tbl(n, pk) } as unknown as DataLayer;
55
+ return { data, stores };
56
+ }
57
+
58
+ type FakeTask = { userTaskKey: string; elementId: string; processInstanceKey: string };
59
+
60
+ /** A fake engine backing BOTH the poller's per-instance `openUserTasks({processInstanceKey})` scan AND
61
+ * the `completeEscalationAsHuman` door's unfiltered `openUserTasks()` resolve. `completeUserTask`
62
+ * removes the task (a resumed task is no longer open) and records the completion for assertions. */
63
+ function fakeEngine(tasks: FakeTask[]) {
64
+ const open = tasks.slice();
65
+ const completions: { userTaskKey: string; variables: Record<string, unknown> }[] = [];
66
+ // Every `openUserTasks` filter seen this run, so a test can assert the auto-apply resolve scans a
67
+ // single instance (`{processInstanceKey}`) rather than an engine-wide unfiltered scan (issue #806).
68
+ const scans: (undefined | { processInstanceKey?: string; rootProcessInstanceKey?: string })[] = [];
69
+ const engine = {
70
+ openUserTasks: (filter?: { processInstanceKey?: string; rootProcessInstanceKey?: string }) => {
71
+ scans.push(filter);
72
+ return Promise.resolve(
73
+ open.filter((t) => {
74
+ if (filter?.processInstanceKey) return t.processInstanceKey === filter.processInstanceKey;
75
+ if (filter?.rootProcessInstanceKey) return t.processInstanceKey === filter.rootProcessInstanceKey;
76
+ return true;
77
+ }),
78
+ );
79
+ },
80
+ completeUserTask: (userTaskKey: string, variables: Record<string, unknown>) => {
81
+ const i = open.findIndex((t) => t.userTaskKey === userTaskKey);
82
+ if (i < 0) return Promise.reject(new Error("no such open task"));
83
+ open.splice(i, 1);
84
+ completions.push({ userTaskKey, variables });
85
+ return Promise.resolve();
86
+ },
87
+ } as unknown as EngineClient;
88
+ return { engine, completions, scans };
89
+ }
90
+
91
+ test("pollUserTasks: auto-resumes an already-answered wait-answer instead of re-parking a human (#806)", async () => {
92
+ const question = "Should the timeout be a boundary event or a poller sentinel?";
93
+ const { data, stores } = memData({
94
+ pull_requests: [{ pr_key: "o/r#800", status: "escalated", process_key: "rp-800", url: "https://github.com/o/r/pull/800", title: "Converge" }],
95
+ escalations: [{ id: 1, pr_key: "o/r#800", status: "open", question }],
96
+ pr_adjudications: [
97
+ {
98
+ id: 1,
99
+ pr_key: "o/r#800",
100
+ // Whitespace/case variant — the canonical fingerprint normalises it to the same key.
101
+ question_fingerprint: questionFingerprint(` ${question.toUpperCase()} `),
102
+ answer: "Option A: a bounded boundary event.",
103
+ adjudicated_by: "alice",
104
+ adjudicated_at: "2025-01-01T00:00:00.000Z",
105
+ },
106
+ ],
107
+ });
108
+ const { engine, completions } = fakeEngine([{ userTaskKey: "ut-800", elementId: "wait-answer", processInstanceKey: "rp-800" }]);
109
+
110
+ await pollUserTasks(data, engine);
111
+
112
+ assertEquals(completions.length, 1, "the parked wait-answer is auto-resumed");
113
+ assertEquals(completions[0].userTaskKey, "ut-800");
114
+ assertEquals(completions[0].variables.answer, "Option A: a bounded boundary event.", "resumed with the recorded answer");
115
+ assertEquals((stores.user_tasks ?? []).length, 0, "no wait-answer row is projected — the human is NOT re-parked");
116
+ const ledger = stores.task_completions ?? [];
117
+ assertEquals(ledger.length, 1, "the auto-resume is recorded in the completion ledger");
118
+ assertEquals(ledger[0].actor_id, "alice", "attributed to the prior adjudicator");
119
+ assertEquals(ledger[0].actor_kind, "human", "the prior adjudicator's kind is preserved (a human-settled decision)");
120
+ assertEquals(ledger[0].auto_applied, 1, "the replay is marked auto_applied — distinguishable from a first-hand submission");
121
+ assertEquals(ledger[0].reversible, 1, "an auto-applied replay is human-overridable");
122
+ });
123
+
124
+ test("pollUserTasks: auto-resume PRESERVES an agent adjudicator's kind and stays reversible (#806 review)", async () => {
125
+ // A prior AGENT-settled adjudication (ADR 0046) must replay as an agent completion, never laundered
126
+ // into an irreversible human authority — the whole point of recording `adjudicated_kind`.
127
+ const question = "Which retry cap should the husk loop use?";
128
+ const { data, stores } = memData({
129
+ pull_requests: [{ pr_key: "o/r#801", status: "escalated", process_key: "rp-801", url: "https://github.com/o/r/pull/801", title: "Converge" }],
130
+ escalations: [{ id: 1, pr_key: "o/r#801", status: "open", question }],
131
+ pr_adjudications: [
132
+ {
133
+ id: 1,
134
+ pr_key: "o/r#801",
135
+ question_fingerprint: questionFingerprint(question),
136
+ answer: "Cap at 3.",
137
+ adjudicated_by: "senior-agent",
138
+ adjudicated_kind: "agent",
139
+ adjudicated_at: "2025-01-01T00:00:00.000Z",
140
+ },
141
+ ],
142
+ });
143
+ const { engine, completions } = fakeEngine([{ userTaskKey: "ut-801", elementId: "wait-answer", processInstanceKey: "rp-801" }]);
144
+
145
+ await pollUserTasks(data, engine);
146
+
147
+ assertEquals(completions.length, 1, "the parked wait-answer is auto-resumed");
148
+ const ledger = stores.task_completions ?? [];
149
+ assertEquals(ledger[0].actor_kind, "agent", "the agent adjudicator's kind is preserved — not laundered into a human");
150
+ assertEquals(ledger[0].actor_id, "senior-agent");
151
+ assertEquals(ledger[0].auto_applied, 1, "still marked auto_applied");
152
+ assertEquals(ledger[0].reversible, 1, "still reversible — a human may override the replayed agent answer");
153
+ });
154
+
155
+ test("pollUserTasks: a DIFFERENT question with no adjudication still escalates to a human (#806)", async () => {
156
+ const { data, stores } = memData({
157
+ pull_requests: [{ pr_key: "o/r#800", status: "escalated", process_key: "rp-800", url: "https://github.com/o/r/pull/800", title: "Converge" }],
158
+ escalations: [{ id: 1, pr_key: "o/r#800", status: "open", question: "A brand-new question nobody has answered." }],
159
+ pr_adjudications: [
160
+ {
161
+ id: 1,
162
+ pr_key: "o/r#800",
163
+ question_fingerprint: questionFingerprint("Some other, already-settled question."),
164
+ answer: "Prior answer.",
165
+ adjudicated_by: "alice",
166
+ adjudicated_at: "2025-01-01T00:00:00.000Z",
167
+ },
168
+ ],
169
+ });
170
+ const { engine, completions } = fakeEngine([{ userTaskKey: "ut-800", elementId: "wait-answer", processInstanceKey: "rp-800" }]);
171
+
172
+ await pollUserTasks(data, engine);
173
+
174
+ assertEquals(completions.length, 0, "no auto-resume — the question is materially different");
175
+ const rows = stores.user_tasks ?? [];
176
+ assertEquals(rows.length, 1, "the new question is projected for a human to answer");
177
+ assertEquals(rows[0].user_task_key, "ut-800");
178
+ assertEquals(rows[0].question, "A brand-new question nobody has answered.");
179
+ });
180
+
181
+ test("pollUserTasks: an adjudication with UNKNOWN provenance fails open to a human, not a synthetic actor (#806 review)", async () => {
182
+ // A settled row whose `adjudicated_by` is blank (completed out of band, so `latestAdjudicator`
183
+ // returned no actor) must NOT be auto-replayed as a manufactured `human` actor — that would audit an
184
+ // unknown-provenance replay as a first-hand human decision. It fails open to a fresh human task.
185
+ const question = "Should the cache be write-through or write-back?";
186
+ const { data, stores } = memData({
187
+ pull_requests: [{ pr_key: "o/r#802", status: "escalated", process_key: "rp-802", url: "https://github.com/o/r/pull/802", title: "Converge" }],
188
+ escalations: [{ id: 1, pr_key: "o/r#802", status: "open", question }],
189
+ pr_adjudications: [
190
+ {
191
+ id: 1,
192
+ pr_key: "o/r#802",
193
+ question_fingerprint: questionFingerprint(question),
194
+ answer: "Write-through.",
195
+ adjudicated_by: null,
196
+ adjudicated_kind: null,
197
+ adjudicated_at: "2025-01-01T00:00:00.000Z",
198
+ },
199
+ ],
200
+ });
201
+ const { engine, completions } = fakeEngine([{ userTaskKey: "ut-802", elementId: "wait-answer", processInstanceKey: "rp-802" }]);
202
+
203
+ await pollUserTasks(data, engine);
204
+
205
+ assertEquals(completions.length, 0, "no auto-resume — a synthetic human actor is never manufactured");
206
+ const rows = stores.user_tasks ?? [];
207
+ assertEquals(rows.length, 1, "the question projects for a human to answer (fail-open)");
208
+ assertEquals(rows[0].user_task_key, "ut-802");
209
+ });
210
+
211
+ test("pollUserTasks: a transient adjudication-lookup error fails open and never aborts the pass (#806 review)", async () => {
212
+ // The adjudication LOOKUP is inside the fail-open try, so a transient `pr_adjudications.find` error
213
+ // must NOT reject `project`/abort `pollUserTasks` — the task still projects and reaches a human.
214
+ const question = "Should retries be capped?";
215
+ const { data, stores } = memData({
216
+ pull_requests: [{ pr_key: "o/r#803", status: "escalated", process_key: "rp-803", url: "https://github.com/o/r/pull/803", title: "Converge" }],
217
+ escalations: [{ id: 1, pr_key: "o/r#803", status: "open", question }],
218
+ });
219
+ const base = data.table.bind(data);
220
+ const failing = {
221
+ table(name: string, pk?: string) {
222
+ const t = base(name, pk);
223
+ if (name === "pr_adjudications") {
224
+ return { ...t, find: () => Promise.reject(new Error("transient db error")) };
225
+ }
226
+ return t;
227
+ },
228
+ } as unknown as DataLayer;
229
+ const { engine, completions } = fakeEngine([{ userTaskKey: "ut-803", elementId: "wait-answer", processInstanceKey: "rp-803" }]);
230
+
231
+ await pollUserTasks(failing, engine);
232
+
233
+ assertEquals(completions.length, 0, "no auto-resume on a lookup error");
234
+ const rows = stores.user_tasks ?? [];
235
+ assertEquals(rows.length, 1, "the task still projects — the poller did not abort (fail-open)");
236
+ assertEquals(rows[0].user_task_key, "ut-803");
237
+ });
238
+
239
+ test("pollUserTasks: auto-resume resolves the task per-instance, never an engine-wide scan (#806 review)", async () => {
240
+ // Copilot review of #806: `completeEscalationAutoApplied` -> `resolveEscalationTask` used an
241
+ // UNFILTERED `openUserTasks()` scan, run once per already-adjudicated PR in a single poll pass — so
242
+ // N parked-and-answered PRs cost N full engine scans (O(N²) work/REST). The poller already knows each
243
+ // task's owning instance, so the resolve must scan THAT instance (`{processInstanceKey}`) only. Seed
244
+ // several answered PRs and assert the pass issues ZERO unfiltered scans.
245
+ const q = "Boundary event or poller sentinel?";
246
+ const prKeys = ["o/r#810", "o/r#811", "o/r#812"];
247
+ const { data } = memData({
248
+ pull_requests: prKeys.map((pr_key, i) => ({
249
+ pr_key,
250
+ status: "escalated",
251
+ process_key: `rp-81${i}`,
252
+ url: `https://github.com/o/r/pull/81${i}`,
253
+ title: "Converge",
254
+ })),
255
+ escalations: prKeys.map((pr_key, i) => ({ id: i + 1, pr_key, status: "open", question: q })),
256
+ pr_adjudications: prKeys.map((pr_key, i) => ({
257
+ id: i + 1,
258
+ pr_key,
259
+ question_fingerprint: questionFingerprint(q),
260
+ answer: "Option A: a bounded boundary event.",
261
+ adjudicated_by: "alice",
262
+ adjudicated_at: "2025-01-01T00:00:00.000Z",
263
+ })),
264
+ });
265
+ const { engine, completions, scans } = fakeEngine(
266
+ prKeys.map((_, i) => ({ userTaskKey: `ut-81${i}`, elementId: "wait-answer", processInstanceKey: `rp-81${i}` })),
267
+ );
268
+
269
+ await pollUserTasks(data, engine);
270
+
271
+ assertEquals(completions.length, 3, "all three answered wait-answers auto-resume");
272
+ const unfiltered = scans.filter((f) => !f?.processInstanceKey && !f?.rootProcessInstanceKey);
273
+ assertEquals(unfiltered.length, 0, "no engine-wide (unfiltered) openUserTasks scan — the resolve is per-instance");
274
+ });
package/app/github.ts CHANGED
@@ -240,6 +240,16 @@ export function advisoryStableKey(path: string, text: string): string {
240
240
  return `${path.trim()}#${fingerprint(normalizeAdvisoryText(text))}`;
241
241
  }
242
242
 
243
+ /** The line-stable fingerprint of a convergence escalation QUESTION (issue #806): the SAME canonical
244
+ * `normalizeAdvisoryText` + `fingerprint` digest advisory acks key on, applied to the escalation's
245
+ * question text. Reuses the ONE normaliser/fingerprint pair (no second implementation) so a durable
246
+ * wait-answer adjudication keyed by `(prKey, questionFingerprint)` is byte/semantic-stable the exact
247
+ * disciplined way an advisory ack is — only a semantically-identical, already-answered question is
248
+ * suppressed; a materially different question keys differently and still escalates. */
249
+ export function questionFingerprint(text: string): string {
250
+ return fingerprint(normalizeAdvisoryText(text));
251
+ }
252
+
243
253
  /** Parse Copilot's suppressed / low-confidence advisories out of a review body. Copilot renders them
244
254
  * under a `<summary>Suppressed comments (N)</summary>` block, each as a bold `**path:line**` header
245
255
  * followed by the advisory prose. Returns de-duplicated advisories (empty when there is no block). */
@@ -0,0 +1,170 @@
1
+ // Tests for the harness-protocol enrolment gate (issue #802) — the env knobs, the staleness
2
+ // derivation, and the durable registry over `worker_harness_protocol` (migration 107).
3
+ import { test } from "node:test";
4
+ import { assert, assertEquals } from "#test-assert";
5
+ import {
6
+ assessWorkers,
7
+ assessWorkersWithAvailability,
8
+ HarnessProtocolRegistry,
9
+ isStaleProtocol,
10
+ minHarnessProtocol,
11
+ staleHarnessPolicy,
12
+ } from "./harnessProtocol.ts";
13
+ import { memDataFor } from "../test/worldDb.ts";
14
+
15
+ const MIGRATIONS = ["107_worker_harness_protocol.sql"];
16
+
17
+ test("minHarnessProtocol defaults to 1 and reads the declared env knob", () => {
18
+ assertEquals(minHarnessProtocol({}), 1);
19
+ assertEquals(minHarnessProtocol({ NANO_AGENTIC_MIN_HARNESS_PROTOCOL: "3" }), 3);
20
+ // A malformed/blank value degrades to the default rather than NaN-poisoning the gate.
21
+ assertEquals(minHarnessProtocol({ NANO_AGENTIC_MIN_HARNESS_PROTOCOL: "nonsense" }), 1);
22
+ });
23
+
24
+ test("minHarnessProtocol rejects parseInt-lenient values (Copilot #802): '3junk'/'1.9'/blank → default", () => {
25
+ // `Number.parseInt` would accept "3junk" (→ 3) and truncate "1.9" (→ 1); a strict integer parse
26
+ // degrades all of these to the registered default so a malformed knob never silently shifts the gate.
27
+ assertEquals(minHarnessProtocol({ NANO_AGENTIC_MIN_HARNESS_PROTOCOL: "3junk" }), 1);
28
+ assertEquals(minHarnessProtocol({ NANO_AGENTIC_MIN_HARNESS_PROTOCOL: "1.9" }), 1);
29
+ assertEquals(minHarnessProtocol({ NANO_AGENTIC_MIN_HARNESS_PROTOCOL: "" }), 1);
30
+ assertEquals(minHarnessProtocol({ NANO_AGENTIC_MIN_HARNESS_PROTOCOL: " " }), 1);
31
+ assertEquals(minHarnessProtocol({ NANO_AGENTIC_MIN_HARNESS_PROTOCOL: "-2" }), 1);
32
+ // A clean integer (with surrounding whitespace) still parses.
33
+ assertEquals(minHarnessProtocol({ NANO_AGENTIC_MIN_HARNESS_PROTOCOL: " 4 " }), 4);
34
+ assertEquals(minHarnessProtocol({ NANO_AGENTIC_MIN_HARNESS_PROTOCOL: "0" }), 0);
35
+ });
36
+
37
+ test("staleHarnessPolicy defaults to flag; only the exact 'refuse' token opts into refusal", () => {
38
+ assertEquals(staleHarnessPolicy({}), "flag");
39
+ assertEquals(staleHarnessPolicy({ NANO_AGENTIC_STALE_HARNESS_POLICY: "refuse" }), "refuse");
40
+ assertEquals(staleHarnessPolicy({ NANO_AGENTIC_STALE_HARNESS_POLICY: " REFUSE " }), "refuse");
41
+ assertEquals(staleHarnessPolicy({ NANO_AGENTIC_STALE_HARNESS_POLICY: "flagg" }), "flag");
42
+ });
43
+
44
+ test("isStaleProtocol: absent version is stale; below-minimum is stale; at-or-above is healthy", () => {
45
+ assertEquals(isStaleProtocol(undefined, 1), true, "no version advertised = stale");
46
+ assertEquals(isStaleProtocol(null, 1), true, "null version = stale");
47
+ assertEquals(isStaleProtocol(0, 1), true, "below minimum = stale");
48
+ assertEquals(isStaleProtocol(1, 1), false, "at minimum = healthy");
49
+ assertEquals(isStaleProtocol(5, 1), false, "above minimum = healthy");
50
+ });
51
+
52
+ test("registry records the advertised protocol and reads it back (idempotent upsert)", async () => {
53
+ const { data } = memDataFor(MIGRATIONS);
54
+ const reg = new HarnessProtocolRegistry(data);
55
+ await reg.recordEnrolment("wk-1", 2);
56
+ assertEquals(await reg.protocolFor("wk-1"), 2);
57
+ // Re-enrol overwrites (upsert keyed by instance).
58
+ await reg.recordEnrolment("wk-1", 4);
59
+ assertEquals(await reg.protocolFor("wk-1"), 4);
60
+ });
61
+
62
+ test("a downgrade re-enrol WITHOUT a version clears a stale-healthy value to absent (stale)", async () => {
63
+ const { data } = memDataFor(MIGRATIONS);
64
+ const reg = new HarnessProtocolRegistry(data);
65
+ await reg.recordEnrolment("wk-1", 3);
66
+ assertEquals(await reg.protocolFor("wk-1"), 3);
67
+ await reg.recordEnrolment("wk-1", undefined);
68
+ assertEquals(await reg.protocolFor("wk-1"), undefined, "stale-healthy value cleared to NULL");
69
+ });
70
+
71
+ test("a blank/whitespace instance is a no-op (no unreachable/colliding row)", async () => {
72
+ const { data } = memDataFor(MIGRATIONS);
73
+ const reg = new HarnessProtocolRegistry(data);
74
+ await reg.recordEnrolment(" ", 2);
75
+ assertEquals((await reg.all()).size, 0);
76
+ });
77
+
78
+ test("assessWorkers: flags absent/below-min as stale, at-or-above as healthy (canonical derivation)", async () => {
79
+ const { data } = memDataFor(MIGRATIONS);
80
+ const reg = new HarnessProtocolRegistry(data);
81
+ await reg.recordEnrolment("healthy", 2);
82
+ await reg.recordEnrolment("old", 0);
83
+ await reg.recordEnrolment("versionless", undefined);
84
+ // "never-enrolled" has no row at all.
85
+ const out = await assessWorkers(data, ["healthy", "old", "versionless", "never-enrolled"], {
86
+ NANO_AGENTIC_MIN_HARNESS_PROTOCOL: "1",
87
+ });
88
+ assertEquals(out.get("healthy"), { instance: "healthy", harnessProtocol: 2, stale: false });
89
+ assertEquals(out.get("old"), { instance: "old", harnessProtocol: 0, stale: true });
90
+ assertEquals(out.get("versionless"), { instance: "versionless", stale: true });
91
+ assertEquals(out.get("never-enrolled"), { instance: "never-enrolled", stale: true });
92
+ });
93
+
94
+ test("protocolsFor: one bounded IN query maps each ORIGINAL key back, skips blanks, short-circuits empty", async () => {
95
+ // The hot-path read is a single bounded `WHERE instance IN (…)` over the live set (not a per-worker
96
+ // findOne / O(history) scan): assert it maps each key back correctly and handles the edge sets.
97
+ const { data } = memDataFor(MIGRATIONS);
98
+ const reg = new HarnessProtocolRegistry(data);
99
+ await reg.recordEnrolment("a", 3);
100
+ await reg.recordEnrolment("b", undefined); // NULL row → undefined
101
+ // "c" never enrolled.
102
+ const got = await reg.protocolsFor(["a", "b", "c", " "]);
103
+ assertEquals(got.get("a"), 3);
104
+ assertEquals(got.get("b"), undefined);
105
+ assertEquals(got.get("c"), undefined);
106
+ assertEquals(got.has(" "), false, "a blank instance keys no row and is skipped");
107
+ // An empty (or all-blank) set short-circuits without issuing an `IN ()` query.
108
+ assertEquals((await reg.protocolsFor([])).size, 0);
109
+ assertEquals((await reg.protocolsFor([" "])).size, 0);
110
+ });
111
+
112
+ test("protocolsFor chunks past SQLite's host-parameter cap (a large fleet does not overflow one IN query)", async () => {
113
+ // A fleet larger than a single IN(…) batch must still resolve every key: the read is chunked under
114
+ // SQLite's ~999 host-parameter floor, so scale never throws (which the caller would mislabel as a
115
+ // fleet-wide outage marking everyone stale). Exercise > 900 (two batches) plus a boundary key.
116
+ const { data } = memDataFor(MIGRATIONS);
117
+ const reg = new HarnessProtocolRegistry(data);
118
+ const instances: string[] = [];
119
+ for (let i = 0; i < 1500; i++) {
120
+ const id = `wk-${i}`;
121
+ instances.push(id);
122
+ if (i % 2 === 0) await reg.recordEnrolment(id, 2); // even = enrolled@2, odd = never enrolled
123
+ }
124
+ const got = await reg.protocolsFor(instances);
125
+ assertEquals(got.size, 1500, "every requested key is mapped back across batches");
126
+ assertEquals(got.get("wk-0"), 2);
127
+ assertEquals(got.get("wk-900"), 2, "a key in the second batch still resolves");
128
+ assertEquals(got.get("wk-1"), undefined, "a never-enrolled key reads back undefined");
129
+ assertEquals(got.get("wk-1499"), undefined);
130
+ });
131
+
132
+ test("assessWorkers with no data layer treats every worker as stale (fail loud)", async () => {
133
+ const out = await assessWorkers(undefined, ["a", "b"]);
134
+ assertEquals(out.get("a")?.stale, true);
135
+ assertEquals(out.get("b")?.stale, true);
136
+ assert(!("harnessProtocol" in (out.get("a") ?? {})), "no protocol known without a registry");
137
+ });
138
+
139
+ test("assessWorkersWithAvailability: registryAvailable is true on a successful read, false without a data layer", async () => {
140
+ const { data } = memDataFor(MIGRATIONS);
141
+ await new HarnessProtocolRegistry(data).recordEnrolment("healthy", 2);
142
+ const ok = await assessWorkersWithAvailability(data, ["healthy"], { NANO_AGENTIC_MIN_HARNESS_PROTOCOL: "1" });
143
+ assertEquals(ok.registryAvailable, true);
144
+ assertEquals(ok.assessments.get("healthy")?.stale, false);
145
+
146
+ const noData = await assessWorkersWithAvailability(undefined, ["healthy"]);
147
+ assertEquals(noData.registryAvailable, false, "no data layer = registry could not be consulted");
148
+ assertEquals(noData.assessments.get("healthy")?.stale, true, "still fails loud per-worker");
149
+ });
150
+
151
+ test("assessWorkersWithAvailability: a registry read outage reports registryAvailable=false (not silent all-stale)", async () => {
152
+ // A legacy DB predating migration 107: the table is absent, so the bounded read throws and is caught.
153
+ const { data } = memDataFor([]);
154
+ const res = await assessWorkersWithAvailability(data, ["a", "b"]);
155
+ assertEquals(res.registryAvailable, false, "read failure surfaces as unavailable, distinct from all-healthy");
156
+ assertEquals(res.assessments.get("a")?.stale, true, "assessments still fail loud");
157
+ });
158
+
159
+ test("protocolsFor reads only the requested instances (bounded), not the whole history", async () => {
160
+ const { data } = memDataFor(MIGRATIONS);
161
+ const reg = new HarnessProtocolRegistry(data);
162
+ await reg.recordEnrolment("live-1", 2);
163
+ await reg.recordEnrolment("live-2", undefined);
164
+ await reg.recordEnrolment("disconnected-history", 3);
165
+ const scoped = await reg.protocolsFor(["live-1", "live-2", "never-enrolled"]);
166
+ assertEquals(scoped.get("live-1"), 2);
167
+ assertEquals(scoped.get("live-2"), undefined, "an enrolled-but-versionless row reads as undefined");
168
+ assertEquals(scoped.get("never-enrolled"), undefined, "an absent row reads as undefined");
169
+ assertEquals(scoped.has("disconnected-history"), false, "a historical instance outside the live set is never read");
170
+ });