@nanobpm/nano-workforce 0.188.0 → 0.189.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -67,6 +67,7 @@ interface PrRow {
67
67
  describe("single-issue feature run (#172 — feature.bpmn)", () => {
68
68
  const savedEnv = new Map<string, string | undefined>();
69
69
  let restoreGithub: (() => void) | undefined;
70
+ const githubState = admitGithubState("owner/repo", "main");
70
71
 
71
72
  before(() => {
72
73
  for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
@@ -75,7 +76,7 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
75
76
  }
76
77
  // ADR 0003: `startFeature` + the `pr.ensure-base-branch` head task pass through base admission,
77
78
  // which reads/creates the base ref. Pin the hermetic `token` transport + fetch stub.
78
- restoreGithub = installAdmitGithub(admitGithubState("owner/repo", "main"));
79
+ restoreGithub = installAdmitGithub(githubState);
79
80
  });
80
81
 
81
82
  after(() => {
@@ -331,6 +332,49 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
331
332
  }
332
333
  });
333
334
 
335
+ test("reconcile before escalate: a no-status result with an open PR on the branch adopts & converges (issue #801)", async () => {
336
+ // The #796/#801 defect: a harness returns NO machine-readable status but has pushed the branch and
337
+ // opened a green PR. Instead of dead-ending at a human, the cell's reconcile step observes the open
338
+ // PR on `feat/<task.id>` (= `feat/issue-7`), adopts it (status=opened, prKey), and converges.
339
+ githubState.openPrs.set("feat/issue-7", { number: 801, base: "epic/e2e" });
340
+ try {
341
+ await withApp(
342
+ { "senior:feature": () => ({ summary: "opened a PR but reported no status" }) },
343
+ { baseBranch: "epic/e2e", converge: true },
344
+ async ({ app, featureKey }) => {
345
+ const flows = takenFlows(app);
346
+ assert.ok(
347
+ flows.includes("ic_reconcile_gw->ic_end"),
348
+ `the adopted PR routed straight to the cell's done end (flows: ${flows.join(", ")})`,
349
+ );
350
+ assert.ok(
351
+ !flows.includes("ic_reconcile_gw->record-escalation"),
352
+ "the run did NOT escalate to a human",
353
+ );
354
+ assert.ok(
355
+ flows.includes("gw-converge->converge"),
356
+ `the adopted PR was handed to the convergence loop (flows: ${flows.join(", ")})`,
357
+ );
358
+ const run = await featureRow(app, featureKey);
359
+ assert.equal(run.status, "converging", "the reconciled run settled at converging");
360
+ assert.equal(run.pr_key, "owner/repo#801", "the adopted PR key is recorded on the run");
361
+ const prs = await app.db.table<PrRow>("pull_requests", "pr_key").find({ pr_key: "owner/repo#801" });
362
+ assert.equal(prs.length, 1, "the adopted PR was enrolled into the convergence loop (submitPr)");
363
+
364
+ // A native user-task escalation was never parked — the machine-recoverable outcome was
365
+ // reconciled without pulling in a person.
366
+ const tasks = await app.engine.searchUserTasks({ rootProcessInstanceKey: run.process_key! });
367
+ assert.ok(
368
+ !tasks.some((t) => t.elementId === "escalation"),
369
+ "no human-escalation task was created for the adopted run",
370
+ );
371
+ },
372
+ );
373
+ } finally {
374
+ githubState.openPrs.delete("feat/issue-7");
375
+ }
376
+ });
377
+
334
378
  test("escalate + abandon: abandoning routes to record-feature (default flow)", async () => {
335
379
  await withApp(
336
380
  {
@@ -16,6 +16,10 @@ export interface AdmitGithubState {
16
16
  branches: Map<string, string>; // branch → head sha
17
17
  creates: { ref: string; sha: string }[];
18
18
  resets: string[]; // any PATCH/force-update on an existing ref (must stay empty)
19
+ /** Open PRs keyed by head branch (issue #801): the implement-cell reconcile step lists PRs for a
20
+ * head via `listPrsForHead`. Empty by default → the pulls listing returns `[]` (no adoptable PR),
21
+ * so suites that don't opt in keep exactly today's escalate behaviour. */
22
+ openPrs: Map<string, { number: number; base?: string }>;
19
23
  }
20
24
 
21
25
  /** Build a fresh admit-github state with the default branch pre-seeded with a HEAD sha so an
@@ -30,6 +34,7 @@ export function admitGithubState(
30
34
  branches: new Map([[defaultBranch, "0".repeat(40)]]),
31
35
  creates: [],
32
36
  resets: [],
37
+ openPrs: new Map(),
33
38
  };
34
39
  }
35
40
 
@@ -72,6 +77,24 @@ function admitFetch(state: AdmitGithubState) {
72
77
  state.resets.push(decodeURIComponent(path.split("/git/refs/heads/")[1] ?? ""));
73
78
  return Promise.resolve(json({ ok: true }));
74
79
  }
80
+ // GET /repos/{repo}/pulls?state=…&head=owner:branch → the open PRs for a head branch, as read by
81
+ // `listPrsForHead` (the implement-cell reconcile step, issue #801). Default empty state → `[]`.
82
+ if (method === "GET" && path === `/repos/${state.repo}/pulls`) {
83
+ const head = u.searchParams.get("head") ?? "";
84
+ const branch = head.includes(":") ? head.slice(head.indexOf(":") + 1) : head;
85
+ const hit = state.openPrs.get(branch);
86
+ if (!hit) return Promise.resolve(json([]));
87
+ return Promise.resolve(
88
+ json([
89
+ {
90
+ number: hit.number,
91
+ html_url: `https://github.com/${state.repo}/pull/${hit.number}`,
92
+ state: "open",
93
+ base: { ref: hit.base ?? state.defaultBranch },
94
+ },
95
+ ]),
96
+ );
97
+ }
75
98
  // Any other endpoint is a best-effort read the sealed transport used to skip → 404 (null).
76
99
  return Promise.resolve(new Response("Not Found", { status: 404 }));
77
100
  };
package/nano.app.json CHANGED
@@ -239,6 +239,10 @@
239
239
  {
240
240
  "taskType": "pr.record-feature-implementing",
241
241
  "handler": "workers/record-feature-implementing/worker.ts"
242
+ },
243
+ {
244
+ "taskType": "pr.reconcile-implement",
245
+ "handler": "workers/reconcile-implement/worker.ts"
242
246
  }
243
247
  ],
244
248
  "externalTaskTypes": [
package/openapi.yaml CHANGED
@@ -429,6 +429,7 @@ components:
429
429
  - jobKeys
430
430
  - live
431
431
  - staleMs
432
+ - harnessStale
432
433
  properties:
433
434
  instance:
434
435
  type: string
@@ -456,6 +457,17 @@ components:
456
457
  staleMs:
457
458
  type: integer
458
459
  description: Milliseconds since the last liveness refresh (0 when fresh).
460
+ harnessProtocol:
461
+ type: integer
462
+ minimum: 0
463
+ description: The worker-harness protocol version this worker advertised at enrolment (issue #802), when a numeric one is known.
464
+ harnessStale:
465
+ type: boolean
466
+ description: >-
467
+ Whether this worker's harness is STALE (issue #802) — below the configured minimum protocol
468
+ or advertising no version at all — so it may silently swallow AgentInstance / transcript /
469
+ result-envelope artifacts. Surfaced so the operator can drain it. Distinct from the
470
+ liveness `staleMs` heartbeat grade.
459
471
  AgenticSupplyLeaf:
460
472
  type: object
461
473
  description: The supply for one leaf token — the workers registered under it.
@@ -622,6 +634,16 @@ components:
622
634
  sets false) redrives a re-leased round from scratch. Recorded only when `instance` is
623
635
  a non-blank string — a missing, empty, or whitespace-only `instance` is echoed back for
624
636
  provenance but the flag is not persisted.
637
+ harnessProtocol:
638
+ type: integer
639
+ minimum: 0
640
+ description: >-
641
+ The worker-harness protocol version (issue #802) — a non-negative integer declaring which
642
+ machine-readable artifacts the harness emits (AgentInstance, transcript flush, result
643
+ envelope). An ENROLMENT attribute, never a routing token. Recorded per instance so the app
644
+ can flag a stale harness in getAgenticSupply / the registry and — under
645
+ NANO_AGENTIC_STALE_HARNESS_POLICY=refuse — refuse it agent-job routing. A missing version is
646
+ treated as stale.
625
647
  EnrolledRole:
626
648
  type: object
627
649
  description: One matched role in an enrolment resolution — provenance for the resolved SERVE set.
@@ -643,6 +665,7 @@ components:
643
665
  - roles
644
666
  - demandVersion
645
667
  - leaseTtl
668
+ - harnessStale
646
669
  properties:
647
670
  instance:
648
671
  type: string
@@ -655,6 +678,20 @@ components:
655
678
  guarantee of durable persistence — recording into the durable-resume registry is
656
679
  best-effort (skipped when `instance` is absent/blank, and a registry write hiccup is
657
680
  logged without failing enrolment).
681
+ harnessProtocol:
682
+ type: integer
683
+ minimum: 0
684
+ description: >-
685
+ Echo of the request's advertised harness protocol version (issue #802). Present only when
686
+ the request supplied it.
687
+ harnessStale:
688
+ type: boolean
689
+ description: >-
690
+ Whether this worker's harness is STALE (issue #802) — below the configured minimum protocol
691
+ (NANO_AGENTIC_MIN_HARNESS_PROTOCOL) or advertising no version at all. Always present. Under
692
+ NANO_AGENTIC_STALE_HARNESS_POLICY=refuse a stale harness is handed an EMPTY `serve` set so
693
+ it wins no job leases; under the default `flag` policy `serve` is unchanged and the worker
694
+ is only flagged for observability/drain.
658
695
  serve:
659
696
  type: array
660
697
  description: The SERVE token set — sorted, de-duplicated leaf tokens the worker may serve.
@@ -812,7 +849,36 @@ components:
812
849
  status:
813
850
  type: string
814
851
  enum: [green, amber, red]
815
- description: The overall SLO — worst of the missing-agent signal and the diversity SLO.
852
+ description: >-
853
+ The overall SLO — worst of the missing-agent signal and the diversity SLO, and folded to
854
+ `red` when any enrolled harness is stale (`staleWorkers` non-empty, issue #802), since the
855
+ board renders only this pill as its overall signal.
856
+ staleWorkers:
857
+ type: array
858
+ description: >-
859
+ The enrolled workers whose harness is STALE (issue #802) — below the configured minimum
860
+ protocol or advertising no version at all — so they may silently swallow AgentInstance /
861
+ transcript / result-envelope artifacts and should be drained. Present (possibly empty) when
862
+ the app's harness-protocol registry is available.
863
+ items:
864
+ $ref: "#/components/schemas/StaleWorker"
865
+ StaleWorker:
866
+ type: object
867
+ description: One enrolled worker flagged as running a stale harness (issue #802).
868
+ required:
869
+ - instance
870
+ - stale
871
+ properties:
872
+ instance:
873
+ type: string
874
+ description: The worker instance id.
875
+ harnessProtocol:
876
+ type: integer
877
+ minimum: 0
878
+ description: The advertised harness protocol version, when a numeric one is known (omitted when none was advertised).
879
+ stale:
880
+ type: boolean
881
+ description: Whether the worker's harness is stale (always true for entries in this list).
816
882
  AgenticTranscript:
817
883
  type: object
818
884
  description: One captured agent session's transcript metadata (H3/#146 transcript store). A durable
@@ -3993,6 +4059,10 @@ paths:
3993
4059
  durableResume:
3994
4060
  type: boolean
3995
4061
  description: "Whether this worker's harness advertises durable-resume (issue #325, ADR 0062 Slice 5/5) — an ENROLMENT attribute, never a routing token. Recorded per instance so the app emits the world-restore marker only to a fleet with a participant; a harness that omits it (or sets false) redrives a re-leased round from scratch. Recorded only when `instance` is a non-blank string — a missing, empty, or whitespace-only `instance` is echoed back for provenance but the flag is not persisted."
4062
+ harnessProtocol:
4063
+ type: integer
4064
+ minimum: 0
4065
+ description: 'The worker-harness protocol version (issue #802) — a non-negative integer declaring which machine-readable artifacts the harness emits (AgentInstance, transcript flush, result envelope). An ENROLMENT attribute, never a routing token. Recorded per instance so the app can flag a stale harness in getAgenticSupply / the registry and — under NANO_AGENTIC_STALE_HARNESS_POLICY=refuse — refuse it agent-job routing. A missing version is treated as stale.'
3996
4066
  # END generated:mcp-body
3997
4067
  responses:
3998
4068
  "200":
@@ -4,6 +4,7 @@ import { assert, assertEquals } from "#test-assert";
4
4
  import type { AppApi } from "@nanobpm/urban";
5
5
  import { memDataFor } from "../test/worldDb.ts";
6
6
  import { DurableResumeRegistry } from "../app/durableResume.ts";
7
+ import { HarnessProtocolRegistry } from "../app/harnessProtocol.ts";
7
8
  import { noopLog } from "../test/log.ts";
8
9
  import handler from "./enrolAgenticWorker.ts";
9
10
 
@@ -151,3 +152,86 @@ test("enforces the shared secret when NANO_PR_WEBHOOK_SECRET is set", async () =
151
152
  else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
152
153
  }
153
154
  });
155
+
156
+ // Harness-protocol enrolment gate (issue #802).
157
+ const HARNESS_MIGRATIONS = ["052_worker_durable_resume.sql", "107_worker_harness_protocol.sql"];
158
+
159
+ test("echoes harnessProtocol and reports harnessStale=false for a healthy protocol (>= minimum)", async () => {
160
+ const res = (await handler(input({ capability: { cognition: "decide" }, instance: "w1", harnessProtocol: 2 }), app)) as any;
161
+ assertEquals(res.status, 200);
162
+ assertEquals(res.body.harnessProtocol, 2);
163
+ assertEquals(res.body.harnessStale, false);
164
+ // No routing regression under the default `flag` policy: SERVE is unchanged.
165
+ assert(res.body.serve.includes("decide"));
166
+ });
167
+
168
+ test("flags harnessStale=true when the harness advertises no version at all (absent = stale)", async () => {
169
+ const res = (await handler(input({ capability: { cognition: "decide" }, instance: "w1" }), app)) as any;
170
+ assertEquals(res.status, 200);
171
+ assertEquals("harnessProtocol" in res.body, false, "no protocol echoed when none advertised");
172
+ assertEquals(res.body.harnessStale, true);
173
+ // Default `flag` policy: a stale harness is still routed (only flagged), so no fleet regression.
174
+ assert(res.body.serve.includes("decide"), "flag policy leaves SERVE intact");
175
+ });
176
+
177
+ test("flags harnessStale=true for a below-minimum protocol", async () => {
178
+ const prev = process.env["NANO_AGENTIC_MIN_HARNESS_PROTOCOL"];
179
+ process.env["NANO_AGENTIC_MIN_HARNESS_PROTOCOL"] = "3";
180
+ try {
181
+ const res = (await handler(input({ capability: { cognition: "decide" }, instance: "w1", harnessProtocol: 1 }), app)) as any;
182
+ assertEquals(res.status, 200);
183
+ assertEquals(res.body.harnessStale, true);
184
+ } finally {
185
+ if (prev === undefined) delete process.env["NANO_AGENTIC_MIN_HARNESS_PROTOCOL"];
186
+ else process.env["NANO_AGENTIC_MIN_HARNESS_PROTOCOL"] = prev;
187
+ }
188
+ });
189
+
190
+ test("rejects a non-integer/negative harnessProtocol as 400", async () => {
191
+ const nonInt = (await handler(input({ capability: { cognition: "decide" }, harnessProtocol: 1.5 }), app)) as any;
192
+ assertEquals(nonInt.status, 400);
193
+ const negative = (await handler(input({ capability: { cognition: "decide" }, harnessProtocol: -1 }), app)) as any;
194
+ assertEquals(negative.status, 400);
195
+ const str = (await handler(input({ capability: { cognition: "decide" }, harnessProtocol: "2" }), app)) as any;
196
+ assertEquals(str.status, 400);
197
+ });
198
+
199
+ test("records the advertised harness protocol in the registry when a data layer + instance are present", async () => {
200
+ const { data } = memDataFor(HARNESS_MIGRATIONS);
201
+ const withData = { log: noopLog(), data } as unknown as AppApi;
202
+ const res = (await handler(input({ capability: { cognition: "decide" }, instance: "w1", harnessProtocol: 2 }), withData)) as any;
203
+ assertEquals(res.status, 200);
204
+ assertEquals(await new HarnessProtocolRegistry(data).protocolFor("w1"), 2);
205
+ });
206
+
207
+ test("a re-enrol WITHOUT a protocol clears a stale-healthy recorded value (degrade to stale)", async () => {
208
+ const { data } = memDataFor(HARNESS_MIGRATIONS);
209
+ const withData = { log: noopLog(), data } as unknown as AppApi;
210
+ await handler(input({ capability: { cognition: "decide" }, instance: "w1", harnessProtocol: 3 }), withData);
211
+ assertEquals(await new HarnessProtocolRegistry(data).protocolFor("w1"), 3);
212
+ const res = (await handler(input({ capability: { cognition: "decide" }, instance: "w1" }), withData)) as any;
213
+ assertEquals(res.status, 200);
214
+ assertEquals(await new HarnessProtocolRegistry(data).protocolFor("w1"), undefined, "stale-healthy value cleared");
215
+ });
216
+
217
+ test("under the `refuse` policy a stale harness is handed an EMPTY SERVE set (no job leases)", async () => {
218
+ const prev = process.env["NANO_AGENTIC_STALE_HARNESS_POLICY"];
219
+ process.env["NANO_AGENTIC_STALE_HARNESS_POLICY"] = "refuse";
220
+ try {
221
+ const mod = await import(`./enrolAgenticWorker.ts?refuse=${Date.now()}`);
222
+ const guarded = mod.default as typeof handler;
223
+ // A stale (version-less) worker: SERVE withheld.
224
+ const stale = (await guarded(input({ capability: { cognition: "decide" }, instance: "w1" }), app)) as any;
225
+ assertEquals(stale.status, 200);
226
+ assertEquals(stale.body.harnessStale, true);
227
+ assertEquals(stale.body.serve, [], "refuse policy withholds SERVE for a stale harness");
228
+ assertEquals(stale.body.roles, []);
229
+ // A healthy worker is routed exactly as today.
230
+ const healthy = (await guarded(input({ capability: { cognition: "decide" }, instance: "w2", harnessProtocol: 5 }), app)) as any;
231
+ assertEquals(healthy.body.harnessStale, false);
232
+ assert(healthy.body.serve.includes("decide"), "healthy harness routed under refuse policy");
233
+ } finally {
234
+ if (prev === undefined) delete process.env["NANO_AGENTIC_STALE_HARNESS_POLICY"];
235
+ else process.env["NANO_AGENTIC_STALE_HARNESS_POLICY"] = prev;
236
+ }
237
+ });
@@ -13,6 +13,12 @@
13
13
  import type { Capability } from "@nanobpm/agentic/protocol";
14
14
  import { resolveEnrolment } from "../app/agentic/vocab/enrol.ts";
15
15
  import { DurableResumeRegistry } from "../app/durableResume.ts";
16
+ import {
17
+ HarnessProtocolRegistry,
18
+ isStaleProtocol,
19
+ minHarnessProtocol,
20
+ staleHarnessPolicy,
21
+ } from "../app/harnessProtocol.ts";
16
22
  import { envVar } from "../app/version.ts";
17
23
  import type { EnrolResult } from "../nano-generated/api-io.d.ts";
18
24
  import { defineOperation } from "../nano-generated/operations.ts";
@@ -73,6 +79,22 @@ export default defineOperation("enrolAgenticWorker", async ({ req, body }, app)
73
79
  app.log.warn("enrolAgenticWorker rejected: non-boolean durableResume");
74
80
  return { status: 400, body: { error: "`durableResume` must be a boolean when provided" } };
75
81
  }
82
+ // The harness-protocol enrolment attribute (issue #802) — a non-negative integer the harness
83
+ // advertises declaring which machine-readable artifacts it emits (AgentInstance, transcript flush,
84
+ // result envelope). A directly-invoked delegate bypasses the OpenAPI runtime validation, so guard the
85
+ // type here: a non-integer / negative value would corrupt the persisted staleness gate.
86
+ if (
87
+ body.harnessProtocol !== undefined &&
88
+ (typeof body.harnessProtocol !== "number" ||
89
+ !Number.isInteger(body.harnessProtocol) ||
90
+ body.harnessProtocol < 0)
91
+ ) {
92
+ app.log.warn("enrolAgenticWorker rejected: non-integer harnessProtocol");
93
+ return {
94
+ status: 400,
95
+ body: { error: "`harnessProtocol` must be a non-negative integer when provided" },
96
+ };
97
+ }
76
98
 
77
99
  // Fold a top-level `host` into the capability when the capability didn't carry its own — a worker
78
100
  // may declare its host either on the capability or beside it (ADR 0059 `{ capability, host }`).
@@ -102,19 +124,57 @@ export default defineOperation("enrolAgenticWorker", async ({ req, body }, app)
102
124
  }
103
125
  }
104
126
 
127
+ // Harness-protocol enrolment gate (issue #802): record the advertised protocol per instance so the
128
+ // app can expose it in getAgenticSupply / the registry and gate on it. Recorded even on a downgrade
129
+ // (a re-enrol WITHOUT a version clears a stale-healthy value to absent → stale). Like durable-resume
130
+ // it needs a non-blank `instance` and is best-effort — a registry write hiccup must not fail enrol.
131
+ if (app.data && instanceKey) {
132
+ try {
133
+ await new HarnessProtocolRegistry(app.data).recordEnrolment(instanceKey, body.harnessProtocol);
134
+ } catch (err) {
135
+ app.log.warn("enrolAgenticWorker: harness-protocol record failed", { instance: instanceKey, err: String(err) });
136
+ }
137
+ }
138
+
139
+ // Derive staleness from the just-advertised protocol against the configured minimum (absent version =
140
+ // stale, the #802 signature). Under the `refuse` policy a stale harness is handed an EMPTY SERVE set
141
+ // so it wins no job leases — a REGISTER→SERVE gate, never an engine/job-protocol change. Under the
142
+ // default `flag` policy the SERVE set is unchanged (no routing regression); the worker is only
143
+ // flagged so the cockpit can surface it for drain.
144
+ const harnessStale = isStaleProtocol(body.harnessProtocol, minHarnessProtocol());
145
+ const refuseRouting = harnessStale && staleHarnessPolicy() === "refuse";
146
+ if (refuseRouting) {
147
+ app.log.warn("enrolAgenticWorker: refusing routing for a stale harness", {
148
+ instance: body.instance,
149
+ harnessProtocol: body.harnessProtocol,
150
+ minHarnessProtocol: minHarnessProtocol(),
151
+ });
152
+ }
153
+
105
154
  const result: EnrolResult = {
106
- serve: [...resolved.serve],
107
- roles: resolved.roles.map((role) => {
108
- const out: EnrolResult["roles"][number] = { token: role.token, seatsDistinctFamily: role.seatsDistinctFamily };
109
- if (role.weight !== undefined) out.weight = role.weight;
110
- return out;
111
- }),
155
+ serve: refuseRouting ? [] : [...resolved.serve],
156
+ roles: refuseRouting
157
+ ? []
158
+ : resolved.roles.map((role) => {
159
+ const out: EnrolResult["roles"][number] = { token: role.token, seatsDistinctFamily: role.seatsDistinctFamily };
160
+ if (role.weight !== undefined) out.weight = role.weight;
161
+ return out;
162
+ }),
112
163
  demandVersion: resolved.demandVersion,
113
164
  leaseTtl: resolved.leaseTtl,
165
+ // Always surface the staleness verdict so the caller (and the cockpit) can see a stale harness even
166
+ // when it advertised no version at all.
167
+ harnessStale,
114
168
  };
115
169
  if (body.instance !== undefined) result.instance = body.instance;
116
170
  if (body.durableResume !== undefined) result.durableResume = body.durableResume;
171
+ if (body.harnessProtocol !== undefined) result.harnessProtocol = body.harnessProtocol;
117
172
 
118
- app.log.info("agentic enrol resolved", { instance: body.instance, serve: result.serve, family: capability.family });
173
+ app.log.info("agentic enrol resolved", {
174
+ instance: body.instance,
175
+ serve: result.serve,
176
+ family: capability.family,
177
+ harnessStale,
178
+ });
119
179
  return { status: 200, body: result };
120
180
  });
@@ -20,6 +20,6 @@ export default defineOperation("getAgenticRegistry", async ({ req }, app) => {
20
20
  app.log.warn("getAgenticRegistry rejected: missing/invalid shared secret");
21
21
  return { status: 401, body: { error: "unauthorized" } };
22
22
  }
23
- const report = await computeRegistryReport(app.log);
23
+ const report = await computeRegistryReport(app.log, app.data);
24
24
  return { status: 200, body: toWireReport(report) };
25
25
  });
@@ -5,6 +5,7 @@
5
5
  // by instance, family/host/jobKeys/liveness) — driven through a REAL AgenticHub + in-memory transport
6
6
  // exactly as the presence family is exercised, so the singleton the operation reads is the live one.
7
7
  import { DatabaseSync } from "node:sqlite";
8
+ import { readFileSync } from "node:fs";
8
9
  import { test } from "node:test";
9
10
  import { AgenticHub } from "@nanobpm/agentic/channel";
10
11
  import type { Authenticator, ChannelConnection, ChannelTransport } from "@nanobpm/agentic/channel";
@@ -39,6 +40,19 @@ function memData(db: SqliteDb): DataLayer {
39
40
  return { source: () => ({ db }) } as unknown as DataLayer;
40
41
  }
41
42
 
43
+ /** Wrap an existing DatabaseSync as a SqliteDb (so the presence store and a raw insert share one db). */
44
+ function memSqliteOver(db: DatabaseSync): SqliteDb {
45
+ return {
46
+ exec: (sql) => db.exec(sql),
47
+ run: (sql, params = []) => {
48
+ const r = db.prepare(sql).run(...(params as never[]));
49
+ return { changes: Number(r.changes), lastInsertRowid: Number(r.lastInsertRowid) };
50
+ },
51
+ all: <T = Record<string, unknown>>(sql: string, params: unknown[] = []) =>
52
+ db.prepare(sql).all(...(params as never[])) as T[],
53
+ };
54
+ }
55
+
42
56
  function memTransport(): { transport: ChannelTransport; connect(conn: ChannelConnection): void } {
43
57
  let onConnection: ((conn: ChannelConnection) => void) | undefined;
44
58
  const transport: ChannelTransport = {
@@ -259,3 +273,69 @@ test("#738 drift: the supply advertises the producer's instance-scoped stream id
259
273
  await hub.close();
260
274
  }
261
275
  });
276
+
277
+ // Harness staleness (issue #802): the supply report flags a worker whose harness protocol is below the
278
+ // minimum / absent, joined by instance from the app's harness-protocol registry.
279
+ test("#802: flags harnessStale per worker, joining the harness-protocol registry by instance", async () => {
280
+ const raw = new DatabaseSync(":memory:");
281
+ raw.exec("PRAGMA foreign_keys = ON;");
282
+ raw.exec(readFileSync(new URL("../db/migrations/107_worker_harness_protocol.sql", import.meta.url), "utf8"));
283
+ const now = new Date().toISOString();
284
+ // wk-a advertised a healthy protocol (>= min 1); wk-a's presence row is minted below.
285
+ raw.prepare("INSERT INTO worker_harness_protocol (instance, harness_protocol, updated_at) VALUES (?, ?, ?)").run("wk-a", 2, now);
286
+
287
+ // A data layer providing BOTH the presence `source().db` handle and the RAD `table()` surface over
288
+ // the SAME db, so the presence family and the harness registry read one store.
289
+ const sqlite = memSqliteOver(raw);
290
+ const quote = (id: string) => `"${id.replace(/"/g, '""')}"`;
291
+ const table = (name: string, pk = "id") => ({
292
+ // biome-ignore lint/suspicious/noExplicitAny: test-only gateway.
293
+ async find(where: any = {}): Promise<any[]> {
294
+ const keys = Object.keys(where);
295
+ const clause = keys.length ? `WHERE ${keys.map((k) => `${quote(k)} = ?`).join(" AND ")}` : "";
296
+ return raw.prepare(`SELECT * FROM ${quote(name)} ${clause}`).all(...keys.map((k) => where[k])) as any[];
297
+ },
298
+ // biome-ignore lint/suspicious/noExplicitAny: test-only gateway.
299
+ async findOne(where: any = {}): Promise<any> {
300
+ return (await this.find(where))[0];
301
+ },
302
+ _pk: pk,
303
+ });
304
+ const data = {
305
+ source: () => ({ db: sqlite }),
306
+ table,
307
+ // The raw-SQL surface the harness registry's bounded `WHERE instance IN (…)` read binds to, over
308
+ // the SAME db as `table`/presence.
309
+ // biome-ignore lint/suspicious/noExplicitAny: test-only gateway.
310
+ open: () => ({ query: async (sql: string, params: any[] = []) => raw.prepare(sql).all(...params) as any[] }),
311
+ } as unknown as DataLayer;
312
+
313
+ const transport = memTransport();
314
+ const hub = new AgenticHub({ transport: transport.transport, authenticator, sweepIntervalMs: 0 });
315
+ await family.mount({ hub, registry: hub.registry, transport: transport.transport as never, data, log: noopLog() });
316
+ const healthyConn = fakeConn("c1", "leafA");
317
+ transport.connect(healthyConn.conn);
318
+ await flush();
319
+ healthyConn.feed({ lane: "control", family: "register", seq: 1, payload: { instance: "wk-a", capability: {} } });
320
+ const staleConn = fakeConn("c2", "leafA");
321
+ transport.connect(staleConn.conn);
322
+ await flush();
323
+ // wk-b registers but has NO harness-protocol row → absent = stale.
324
+ staleConn.feed({ lane: "control", family: "register", seq: 1, payload: { instance: "wk-b", capability: {} } });
325
+ await flush();
326
+
327
+ const withData = { log: noopLog(), data } as unknown as AppApi;
328
+ try {
329
+ const res = (await handler(input(), withData)) as { status: number; body: { workers: Array<Record<string, unknown>> } };
330
+ assertEquals(res.status, 200);
331
+ const byInstance = new Map(res.body.workers.map((w) => [w.instance, w]));
332
+ assertEquals(byInstance.get("wk-a")?.harnessStale, false, "healthy protocol is not stale");
333
+ assertEquals(byInstance.get("wk-a")?.harnessProtocol, 2);
334
+ assertEquals(byInstance.get("wk-b")?.harnessStale, true, "no recorded protocol = stale");
335
+ assertEquals("harnessProtocol" in (byInstance.get("wk-b") ?? {}), false, "no protocol echoed for a stale worker");
336
+ } finally {
337
+ family.teardown?.();
338
+ await hub.close();
339
+ raw.close();
340
+ }
341
+ });
@@ -23,6 +23,7 @@
23
23
  import { type ClaimRegistry, currentClaimRegistry } from "../app/agentic/claim-registry.ts";
24
24
  import { currentCorrelation, type JobCorrelation } from "../app/agentic/correlation.ts";
25
25
  import { currentPresenceRegistry, type SupplyWorker } from "../app/agentic/families/presence.family.ts";
26
+ import { assessWorkers, type HarnessAssessment } from "../app/harnessProtocol.ts";
26
27
  import { envVar } from "../app/version.ts";
27
28
  import type { AgenticJobCorrelation, AgenticSupplyReport, AgenticSupplyWorker } from "../nano-generated/api-io.d.ts";
28
29
  import { defineOperation } from "../nano-generated/operations.ts";
@@ -36,7 +37,7 @@ const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
36
37
  // (`composeStreamId(instance, jobKey)`, issue #738) when the claim registry knows a current claim for
37
38
  // it (#713) — keyed by the CLAIM, not by the connection — so drilling in opens the LIVE job's terminal
38
39
  // (the exact stream the producer writes) even before any transcript lands.
39
- function toWorker(w: SupplyWorker, claims: ClaimRegistry | undefined): AgenticSupplyWorker {
40
+ function toWorker(w: SupplyWorker, claims: ClaimRegistry | undefined, harness: HarnessAssessment | undefined): AgenticSupplyWorker {
40
41
  const out: AgenticSupplyWorker = {
41
42
  instance: w.instance,
42
43
  identity: w.identity,
@@ -44,9 +45,13 @@ function toWorker(w: SupplyWorker, claims: ClaimRegistry | undefined): AgenticSu
44
45
  jobKeys: [...w.jobKeys],
45
46
  live: w.live,
46
47
  staleMs: w.staleMs,
48
+ // Harness staleness (issue #802) — an absent registry entry / unmounted data layer reads as stale
49
+ // (fail loud). Distinct from the liveness `staleMs` heartbeat grade above.
50
+ harnessStale: harness?.stale ?? true,
47
51
  };
48
52
  if (w.family !== undefined) out.family = w.family;
49
53
  if (w.host !== undefined) out.host = w.host;
54
+ if (harness?.harnessProtocol !== undefined) out.harnessProtocol = harness.harnessProtocol;
50
55
  return out;
51
56
  }
52
57
 
@@ -82,11 +87,18 @@ export default defineOperation("getAgenticSupply", async ({ req }, app) => {
82
87
  // process-instance / plan context surfaced in `correlations`, but no longer feeds visibility.
83
88
  const correlation = currentCorrelation();
84
89
  const snapshot = registry.snapshot(claims ? { jobKeysFor: (instance) => claims.jobKeysFor(instance) } : {});
90
+ // Harness-staleness (issue #802): assess every visible worker's advertised protocol against the
91
+ // configured minimum — the ONE canonical staleness derivation (no second heuristic). Best-effort:
92
+ // an unmounted data layer / read failure reads as stale (fail loud).
93
+ const harness = await assessWorkers(app.data, snapshot.workers.map((w) => w.instance));
85
94
  const report: AgenticSupplyReport = {
86
95
  count: snapshot.count,
87
96
  generatedAt: new Date().toISOString(),
88
- workers: snapshot.workers.map((w) => toWorker(w, claims)),
89
- leaves: snapshot.leaves.map((leaf) => ({ token: leaf.token, workers: leaf.workers.map((w) => toWorker(w, claims)) })),
97
+ workers: snapshot.workers.map((w) => toWorker(w, claims, harness.get(w.instance))),
98
+ leaves: snapshot.leaves.map((leaf) => ({
99
+ token: leaf.token,
100
+ workers: leaf.workers.map((w) => toWorker(w, claims, harness.get(w.instance))),
101
+ })),
90
102
  correlations: correlation ? correlation.snapshot().correlations.map(toCorrelation) : [],
91
103
  };
92
104
  return { status: 200, body: report };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.188.0",
3
+ "version": "0.189.0",
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",
@@ -84,6 +84,10 @@ function workerView(worker, staleAfterMs, byJobKey) {
84
84
  correlations,
85
85
  liveness: liveness(worker, staleAfterMs),
86
86
  staleMs: worker.staleMs,
87
+ // Fail loud: default a missing harness verdict to STALE (mirrors the typed cockpit view and the
88
+ // server's fail-loud assessment) so an older/cached response can't hide a stale/unknown worker.
89
+ harnessStale: worker.harnessStale ?? true,
90
+ ...(worker.harnessProtocol !== undefined ? { harnessProtocol: worker.harnessProtocol } : {}),
87
91
  };
88
92
  }
89
93
 
@@ -141,6 +145,7 @@ function workerRow(doc, worker, onDrill, onOpenWorker) {
141
145
  row.setAttribute("data-worker", worker.instance);
142
146
  row.setAttribute("data-liveness", worker.liveness);
143
147
  row.setAttribute("data-stream", worker.stream);
148
+ row.setAttribute("data-harness-stale", String(worker.harnessStale));
144
149
 
145
150
  const nameCell = el(doc, "td", "cockpit-td cockpit-supply-name");
146
151
  nameCell.appendChild(dot(doc, worker.liveness));
@@ -162,6 +167,19 @@ function workerRow(doc, worker, onDrill, onOpenWorker) {
162
167
  if (onDrill) drill.addEventListener("click", () => onDrill(worker.stream));
163
168
  nameCell.appendChild(drill);
164
169
  }
170
+ // A stale harness silently swallows machine-readable artifacts (issue #802) — surface it as a
171
+ // distinct badge (mirrors app/agentic/cockpit/supply-render.ts).
172
+ if (worker.harnessStale) {
173
+ const badge = el(
174
+ doc,
175
+ "span",
176
+ "cockpit-supply-harness-stale",
177
+ worker.harnessProtocol === undefined ? "stale harness" : `stale harness (v${worker.harnessProtocol})`,
178
+ );
179
+ badge.setAttribute("data-harness-stale", "true");
180
+ badge.setAttribute("title", "Harness protocol below the configured minimum (or none advertised); jobs may dead-end.");
181
+ nameCell.appendChild(badge);
182
+ }
165
183
  row.appendChild(nameCell);
166
184
 
167
185
  row.appendChild(el(doc, "td", "cockpit-td cockpit-supply-family", worker.family));
@@ -174,6 +174,7 @@
174
174
  <zeebe:calledElement processId="implement-cell" />
175
175
  <zeebe:ioMapping>
176
176
  <zeebe:input source="=task" target="task" />
177
+ <zeebe:input source="=baseBranch" target="baseBranch" />
177
178
  <zeebe:input source="=if (is defined(baseBranchBrief)) then baseBranchBrief else null" target="baseBranchBrief" />
178
179
  <zeebe:input source="=if (is defined(resolvedArtifacts)) then resolvedArtifacts else null" target="resolvedArtifacts" />
179
180
  <zeebe:input source="=if (is defined(customInstructions)) then customInstructions else null" target="customInstructions" />