@nanobpm/nano-workforce 0.188.1 → 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.
@@ -0,0 +1,312 @@
1
+ // nano-workforce — the harness-protocol ENROLMENT GATE (issue #802).
2
+ //
3
+ // A stale worker harness — a `c8ctl-nano` build predating the AgentInstance-minting + transcript-flush
4
+ // + result-envelope path — silently services jobs and swallows every machine-readable artifact, so
5
+ // good agent work is lost to the orchestration and every run it touches dead-ends at a human
6
+ // (#796/#801). Job routing was BLIND to harness capability/version: enrolment advertised
7
+ // family/host/cognition/weight/durableResume but NOT a harness protocol version, so a stale harness
8
+ // won job leases indistinguishably from a healthy one.
9
+ //
10
+ // This module makes harness staleness OBSERVABLE and GATEABLE. It records, per worker instance, the
11
+ // protocol version the harness advertised at enrolment (a WORKER ATTRIBUTE — ADR 0056 §7, capability
12
+ // gates enrolment and is NEVER a routing token `network.role#seat`), and derives whether a worker is
13
+ // stale (below the minimum protocol, or advertising no version at all). The minimum protocol and the
14
+ // enforcement policy (flag-only vs refuse-routing) are declared env-contract knobs (app/contracts.ts).
15
+ //
16
+ // Advisory + app-tier only (ADR 0056): the registry NEVER hard-locks a BPMN sequence flow. The one
17
+ // place it may WITHHOLD is the SERVE-token resolution at enrol under the `refuse` policy — a stale
18
+ // harness is handed an empty SERVE set so it wins no job leases — which is a REGISTER→SERVE gate, not
19
+ // an engine/job-protocol change.
20
+ import type { DataLayer } from "@nanobpm/urban";
21
+ import { readEnvOr } from "./contracts.ts";
22
+
23
+ /**
24
+ * The canonical name of the harness-protocol enrolment attribute. A worker advertises it at enrol; the
25
+ * registry records it here. It is an ENROLMENT gate (ADR 0056 §7), never a routing token.
26
+ */
27
+ export const HARNESS_PROTOCOL_ATTR = "harness-protocol";
28
+
29
+ /** The default minimum harness protocol when {@link NANO_AGENTIC_MIN_HARNESS_PROTOCOL} is unset. Kept
30
+ * in the ONE registry entry (app/contracts.ts) — this literal only documents it for the reader. */
31
+ const DEFAULT_MIN_HARNESS_PROTOCOL = 1;
32
+
33
+ /** The staleness-enforcement policy: `flag` only marks a stale worker (observability); `refuse` also
34
+ * withholds its SERVE tokens at enrol so it wins no job leases. */
35
+ export type StaleHarnessPolicy = "flag" | "refuse";
36
+
37
+ /** The default enforcement policy — `flag`, so a fleet whose harnesses have not yet been upgraded is
38
+ * made VISIBLE but not abruptly drained. An operator opts into `refuse` to hard-gate routing. */
39
+ const DEFAULT_STALE_HARNESS_POLICY: StaleHarnessPolicy = "flag";
40
+
41
+ /** The durable table backing {@link HarnessProtocolRegistry} (`db/migrations/107_worker_harness_protocol.sql`).
42
+ * The ONE source of truth for the name so the `Table<T>` gateway and the bounded batch read below can
43
+ * never drift. */
44
+ const HARNESS_PROTOCOL_TABLE = "worker_harness_protocol";
45
+
46
+ /** Max bound host-parameters per `WHERE instance IN (…)` batch in {@link HarnessProtocolRegistry.protocolsFor}.
47
+ * SQLite caps the number of host parameters per statement (`SQLITE_MAX_VARIABLE_NUMBER` — historically
48
+ * 999, and still that low on many builds), so a single `IN (…)` binding one placeholder per live worker
49
+ * would THROW once the fleet outgrows that limit — and the caller's read-failure fallback marks every
50
+ * worker stale (a false fleet-wide drain/outage signal from mere scale). 900 stays safely under the
51
+ * conservative 999 floor while keeping the batch count minimal; the live keys are chunked into batches of
52
+ * this size and unioned, so the bounded hot-path read scales past the parameter cap. */
53
+ const IN_QUERY_MAX_PARAMS = 900;
54
+
55
+ /** A persisted enrolment row (`worker_harness_protocol`): one worker instance's advertised protocol. */
56
+ interface WorkerHarnessProtocolRow {
57
+ instance: string;
58
+ harness_protocol: number | null;
59
+ updated_at: string;
60
+ }
61
+
62
+ /**
63
+ * The configured minimum harness protocol a worker must advertise to be considered healthy. Read
64
+ * through the ONE typed env schema ({@link NANO_AGENTIC_MIN_HARNESS_PROTOCOL}); a non-integer / blank
65
+ * value degrades to the registered default rather than throwing (advisory — the report must never
66
+ * fail on a malformed knob).
67
+ */
68
+ export function minHarnessProtocol(env: Record<string, string | undefined> = process.env): number {
69
+ const raw = readEnvOr("NANO_AGENTIC_MIN_HARNESS_PROTOCOL", String(DEFAULT_MIN_HARNESS_PROTOCOL), env).trim();
70
+ // `Number.parseInt` accepts `"3junk"` (→ 3) and truncates `"1.9"` (→ 1), so a malformed/non-integer
71
+ // knob would NOT degrade to the registered default as this function and the env contract promise.
72
+ // Parse with `Number` and require a strict non-negative integer. A blank value (`Number("")` → 0)
73
+ // must also fall through to the default, hence the explicit non-empty guard.
74
+ const parsed = Number(raw);
75
+ return raw.length > 0 && Number.isInteger(parsed) && parsed >= 0 ? parsed : DEFAULT_MIN_HARNESS_PROTOCOL;
76
+ }
77
+
78
+ /**
79
+ * The configured staleness-enforcement policy. Read through the ONE typed env schema
80
+ * ({@link NANO_AGENTIC_STALE_HARNESS_POLICY}); anything other than the exact `refuse` token (case- and
81
+ * whitespace-insensitive) is the safe default `flag`, so a typo never silently drains the fleet.
82
+ */
83
+ export function staleHarnessPolicy(env: Record<string, string | undefined> = process.env): StaleHarnessPolicy {
84
+ const raw = readEnvOr("NANO_AGENTIC_STALE_HARNESS_POLICY", DEFAULT_STALE_HARNESS_POLICY, env).trim().toLowerCase();
85
+ return raw === "refuse" ? "refuse" : "flag";
86
+ }
87
+
88
+ /**
89
+ * Whether an advertised protocol is STALE against the minimum. A `undefined`/`null` protocol — a
90
+ * harness that advertised NO version, or one never enrolled through this app — is stale (absent version
91
+ * = stale, the #802 signature). Otherwise stale iff below the minimum.
92
+ */
93
+ export function isStaleProtocol(
94
+ protocol: number | undefined | null,
95
+ min: number = minHarnessProtocol(),
96
+ ): boolean {
97
+ if (protocol === undefined || protocol === null || !Number.isFinite(protocol)) return true;
98
+ return protocol < min;
99
+ }
100
+
101
+ /** A worker's harness-staleness assessment — the shape surfaced in `getAgenticSupply` / the registry. */
102
+ export interface HarnessAssessment {
103
+ /** The worker instance id. */
104
+ readonly instance: string;
105
+ /** The advertised protocol version, when a numeric one is known (omitted for absent/none). */
106
+ readonly harnessProtocol?: number;
107
+ /** Whether the worker's harness is stale (below minimum, or no version advertised). */
108
+ readonly stale: boolean;
109
+ }
110
+
111
+ /**
112
+ * The result of {@link assessWorkersWithAvailability}: the per-instance assessments plus whether the
113
+ * harness-protocol registry could actually be CONSULTED. `registryAvailable` is `false` when no data
114
+ * layer is mounted or the read threw (a legacy DB predating migration 107, an in-flight desync); the
115
+ * assessments still fail loud (every worker reads STALE) so per-worker supply visibility is unchanged,
116
+ * but the registry report uses this flag to OMIT its `staleWorkers` list rather than mislabel an
117
+ * outage as a fleet-wide drain signal (issue #802).
118
+ */
119
+ export interface WorkerAssessment {
120
+ /** False when the harness-protocol registry could not be consulted (no data layer, or the read threw). */
121
+ readonly registryAvailable: boolean;
122
+ /** The canonical per-instance staleness assessment (every requested instance is present). */
123
+ readonly assessments: Map<string, HarnessAssessment>;
124
+ }
125
+
126
+ /**
127
+ * The durable registry of per-worker advertised harness protocol, over the `worker_harness_protocol`
128
+ * table (`db/migrations/107_worker_harness_protocol.sql`). Backed by the app's SQLite DataLayer through
129
+ * the RAD `Table<T>` surface (`data.table(...)`) — NOT hand-written SQL — mirroring
130
+ * {@link ../durableResume.ts DurableResumeRegistry}.
131
+ */
132
+ export class HarnessProtocolRegistry {
133
+ readonly #data: DataLayer;
134
+
135
+ constructor(data: DataLayer) {
136
+ this.#data = data;
137
+ }
138
+
139
+ #table() {
140
+ return this.#data.table<WorkerHarnessProtocolRow>(HARNESS_PROTOCOL_TABLE, "instance");
141
+ }
142
+
143
+ /** Canonicalise an instance key: trim surrounding whitespace and reject a blank one — the instance
144
+ * is the PRIMARY KEY, so a whitespace/blank key would create an unreachable row or let unrelated
145
+ * workers collide. Returns the trimmed key, or `undefined` when empty/whitespace. */
146
+ static #normaliseInstance(instance: string): string | undefined {
147
+ const trimmed = instance.trim();
148
+ return trimmed.length > 0 ? trimmed : undefined;
149
+ }
150
+
151
+ /** Coerce an advertised protocol to the stored INTEGER (or NULL when none/malformed). A finite
152
+ * non-negative integer is stored; anything else records NULL — an "advertised no usable version"
153
+ * marker that reads back as STALE. */
154
+ static #coerce(protocol: number | undefined | null): number | null {
155
+ if (typeof protocol === "number" && Number.isInteger(protocol) && protocol >= 0) return protocol;
156
+ return null;
157
+ }
158
+
159
+ /**
160
+ * Record a worker's advertised harness protocol at enrolment (an idempotent UPSERT keyed by
161
+ * `instance`). A re-enrol overwrites the value so a harness that GAINS — or LOSES — protocol support
162
+ * across a redeploy is reflected (a downgrade re-enrol without a version clears a stale-healthy value
163
+ * to NULL → stale). The `findOne`-then-insert is racy under a concurrent duplicate enrol, so a
164
+ * PRIMARY KEY fence collision folds into the update path (same end-state either way). A
165
+ * blank/whitespace `instance` is a no-op — it cannot key a reachable row.
166
+ */
167
+ async recordEnrolment(instance: string, protocol: number | undefined | null): Promise<void> {
168
+ const key = HarnessProtocolRegistry.#normaliseInstance(instance);
169
+ if (key === undefined) return;
170
+ const value = HarnessProtocolRegistry.#coerce(protocol);
171
+ const table = this.#table();
172
+ const now = new Date().toISOString();
173
+ const existing = await table.findOne({ instance: key });
174
+ if (existing) {
175
+ await table.update(key, { harness_protocol: value, updated_at: now });
176
+ return;
177
+ }
178
+ try {
179
+ await table.insert({ instance: key, harness_protocol: value, updated_at: now });
180
+ } catch (err) {
181
+ if (!isFenceCollision(err)) throw err;
182
+ await table.update(key, { harness_protocol: value, updated_at: now });
183
+ }
184
+ }
185
+
186
+ /** The advertised protocol for a worker instance, or `undefined` when it advertised none (NULL row)
187
+ * or was never enrolled here. */
188
+ async protocolFor(instance: string): Promise<number | undefined> {
189
+ const key = HarnessProtocolRegistry.#normaliseInstance(instance);
190
+ if (key === undefined) return undefined;
191
+ const row = await this.#table().findOne({ instance: key });
192
+ const value = row?.harness_protocol;
193
+ return typeof value === "number" ? value : undefined;
194
+ }
195
+
196
+ /** Every recorded (instance → advertised protocol) mapping, for a bulk supply/registry join. */
197
+ async all(): Promise<Map<string, number | undefined>> {
198
+ const rows = await this.#table().find({});
199
+ const out = new Map<string, number | undefined>();
200
+ for (const row of rows) {
201
+ out.set(row.instance, typeof row.harness_protocol === "number" ? row.harness_protocol : undefined);
202
+ }
203
+ return out;
204
+ }
205
+
206
+ /**
207
+ * The recorded protocols for a bounded set of live instances — the supply/registry hot-path read.
208
+ * Unlike {@link all}, this scopes the query to the CURRENT `instances` set rather than scanning
209
+ * every historical row, so a table that grows with disconnected worker instances does not turn each
210
+ * 2-second cockpit poll into an O(history) full-table load (an instance is never removed on
211
+ * disconnect). It issues ONE bounded `WHERE instance IN (…)` query over the normalised, de-duplicated
212
+ * live keys — not a per-worker `findOne` — so the poll never degrades into an N+1 round-trip pattern
213
+ * as the fleet grows (Copilot #802). The bound keys are chunked into batches under SQLite's
214
+ * host-parameter cap ({@link IN_QUERY_MAX_PARAMS}) and unioned, so a very large fleet cannot overflow
215
+ * a single `IN (…)`'s placeholder limit and throw (which the caller would mislabel as a fleet-wide
216
+ * outage). A blank/whitespace instance is skipped (it can key no reachable
217
+ * row); an empty set short-circuits without a query (`IN ()` is not valid SQL). Any read error
218
+ * propagates so the caller can distinguish "registry unavailable" from "all healthy".
219
+ */
220
+ async protocolsFor(instances: readonly string[]): Promise<Map<string, number | undefined>> {
221
+ const out = new Map<string, number | undefined>();
222
+ // Normalise + de-duplicate the live keys to bind into a single bounded IN query.
223
+ const keys = new Set<string>();
224
+ for (const instance of instances) {
225
+ const key = HarnessProtocolRegistry.#normaliseInstance(instance);
226
+ if (key !== undefined) keys.add(key);
227
+ }
228
+ if (keys.size === 0) return out;
229
+ const keyList = [...keys];
230
+ // Chunk the bound keys into batches under SQLite's host-parameter cap (see IN_QUERY_MAX_PARAMS):
231
+ // a single IN (…) binding one placeholder per live worker would throw once the fleet outgrows the
232
+ // limit, and the caller then mislabels every worker stale (a false drain/outage signal from scale).
233
+ const byKey = new Map<string, number | undefined>();
234
+ for (let offset = 0; offset < keyList.length; offset += IN_QUERY_MAX_PARAMS) {
235
+ const batch = keyList.slice(offset, offset + IN_QUERY_MAX_PARAMS);
236
+ const placeholders = batch.map(() => "?").join(", ");
237
+ const rows = await this.#data
238
+ .open()
239
+ .query<WorkerHarnessProtocolRow>(
240
+ `SELECT instance, harness_protocol FROM ${HARNESS_PROTOCOL_TABLE} WHERE instance IN (${placeholders})`,
241
+ batch,
242
+ );
243
+ for (const row of rows) {
244
+ byKey.set(row.instance, typeof row.harness_protocol === "number" ? row.harness_protocol : undefined);
245
+ }
246
+ }
247
+ // Key the result back by the caller's ORIGINAL instance strings (the assessment reads it by the
248
+ // same key it passed in); an instance with no row reads back `undefined` → stale, unchanged.
249
+ for (const instance of instances) {
250
+ const key = HarnessProtocolRegistry.#normaliseInstance(instance);
251
+ if (key === undefined) continue;
252
+ out.set(instance, byKey.get(key));
253
+ }
254
+ return out;
255
+ }
256
+ }
257
+
258
+ /** True when `err` is the durable PRIMARY KEY fence firing (a SQLite `UNIQUE constraint failed` from a
259
+ * concurrent/duplicate enrol between our `findOne` and `insert`). `recordEnrolment` is an upsert, so a
260
+ * collision means "the row now exists" — the same intended outcome as the update branch. Matched on
261
+ * the message substring the RAD `Table` surface propagates verbatim (mirrors `DurableResumeRegistry`). */
262
+ function isFenceCollision(err: unknown): boolean {
263
+ return err instanceof Error && /UNIQUE constraint failed/i.test(err.message);
264
+ }
265
+
266
+ /**
267
+ * Assess a set of worker instances against the recorded harness protocols and the configured minimum,
268
+ * ALSO reporting whether the registry could be consulted — the ONE canonical staleness derivation
269
+ * shared by `getAgenticSupply` and the registry report (no second heuristic). The read is bounded to
270
+ * the current `instances` set (not an O(history) full-table scan). Best-effort: any registry read
271
+ * failure (a legacy DB predating migration 107, an in-flight desync, no data layer mounted) degrades
272
+ * to "unknown protocol" → every worker STALE (fail loud, per absent-version-is-stale) with
273
+ * `registryAvailable: false`, rather than throwing — so a caller can OMIT an aggregate stale list
274
+ * instead of mislabelling an outage as a drain signal.
275
+ */
276
+ export async function assessWorkersWithAvailability(
277
+ data: DataLayer | undefined,
278
+ instances: readonly string[],
279
+ env: Record<string, string | undefined> = process.env,
280
+ ): Promise<WorkerAssessment> {
281
+ const min = minHarnessProtocol(env);
282
+ let protocols: Map<string, number | undefined> = new Map();
283
+ let registryAvailable = false;
284
+ if (data) {
285
+ try {
286
+ protocols = await new HarnessProtocolRegistry(data).protocolsFor(instances);
287
+ registryAvailable = true;
288
+ } catch (err) {
289
+ console.warn(`[harness-protocol] supply assessment read failed: ${err}`);
290
+ }
291
+ }
292
+ const out = new Map<string, HarnessAssessment>();
293
+ for (const instance of instances) {
294
+ const protocol = protocols.get(instance);
295
+ const assessment: HarnessAssessment = { instance, stale: isStaleProtocol(protocol, min) };
296
+ out.set(instance, protocol !== undefined ? { ...assessment, harnessProtocol: protocol } : assessment);
297
+ }
298
+ return { registryAvailable, assessments: out };
299
+ }
300
+
301
+ /**
302
+ * The per-instance staleness assessments — the fail-loud supply path (a read failure reads every
303
+ * worker as STALE). A thin projection of {@link assessWorkersWithAvailability} for callers that only
304
+ * need the per-worker verdict and not the registry-availability signal (there is ONE derivation).
305
+ */
306
+ export async function assessWorkers(
307
+ data: DataLayer | undefined,
308
+ instances: readonly string[],
309
+ env: Record<string, string | undefined> = process.env,
310
+ ): Promise<Map<string, HarnessAssessment>> {
311
+ return (await assessWorkersWithAvailability(data, instances, env)).assessments;
312
+ }
@@ -105,8 +105,14 @@ export const MCP_TOOL_COUNT_BUDGET = 60;
105
105
  * `x-mcp` convention that read/orient doors stay exposed; only operator-only control doors are
106
106
  * excluded, see app/mcpExclusions.test.ts). Measured +1,846 bytes serialized (84,043 → 85,889 —
107
107
  * over the old ceiling's 457-byte headroom). Deliberate, documented growth — not schema fat.
108
+ *
109
+ * RAISE PROVENANCE — 86_500 → 87_000 (#802): the worker-harness protocol surface adds
110
+ * `harnessProtocol`/`harnessStale` observability fields to the `enrolAgenticWorker`,
111
+ * `getAgenticSupply`, and registry read schemas (plus the `StaleWorker` leaf) so an operator can spot
112
+ * and drain a stale harness from the tool surface alone. Measured 86,509 bytes serialized — 9 over the
113
+ * old ceiling's headroom. Deliberate, documented growth — not schema fat.
108
114
  */
109
- export const MCP_SURFACE_BYTES_BUDGET = 86_500;
115
+ export const MCP_SURFACE_BYTES_BUDGET = 87_000;
110
116
 
111
117
  /**
112
118
  * The eagerly-loaded curated subset MUST stay materially smaller than the full surface — otherwise it
@@ -0,0 +1,30 @@
1
+ -- 107_worker_harness_protocol.sql — issue #802: make stale worker harnesses observable and gateable.
2
+ --
3
+ -- A stale worker harness (a `c8ctl-nano` build predating the AgentInstance-minting + transcript-flush
4
+ -- + result-envelope path) silently services jobs and swallows every machine-readable artifact, so good
5
+ -- agent work is lost to the orchestration and every run it touches dead-ends at a human (#796/#801).
6
+ -- Job routing is blind to harness capability/version, so a stale harness wins job leases
7
+ -- indistinguishably from a healthy one.
8
+ --
9
+ -- The fix persists the protocol version a harness advertises at ENROLMENT — a WORKER ATTRIBUTE (ADR
10
+ -- 0056 §7 — capability gates enrolment, NEVER a routing token `network.role#seat`) — so the app can
11
+ -- (a) expose it in `getAgenticSupply` / the registry and (b) flag RED / refuse agent-job routing for
12
+ -- any worker below the minimum protocol. A MISSING version is treated as stale.
13
+ --
14
+ -- This mirrors `worker_durable_resume` (migration 052): one FK-free table keyed by the worker
15
+ -- instance (`register.instance` / the enrol `instance`). FK-free by design — enrolment is per-worker
16
+ -- and connection-agnostic, with no parent row to reference. EXPAND (additive) phase: one new table;
17
+ -- nothing is dropped or renamed. Migration-prefix block 107–108 pre-assigned to this slice
18
+ -- (issue #802) off the origin/main high-water mark (104). The runner wraps each file in its own
19
+ -- transaction, so this file must NOT contain BEGIN/COMMIT.
20
+
21
+ CREATE TABLE IF NOT EXISTS worker_harness_protocol (
22
+ instance TEXT PRIMARY KEY, -- the worker instance id (enrol `instance` / register.instance)
23
+ -- The protocol version the harness advertised, or NULL when it advertised NONE. NULL is a first-class
24
+ -- "advertised no version" marker — distinct in intent from an absent row (never enrolled here), but
25
+ -- BOTH resolve to STALE at read time (absent version = stale). Recorded even on a downgrade so a
26
+ -- harness that previously advertised a healthy protocol and later re-enrols WITHOUT one clears its
27
+ -- stale-healthy value (mirrors the worker_durable_resume degrade-to-scratch semantics).
28
+ harness_protocol INTEGER,
29
+ updated_at TEXT NOT NULL
30
+ );
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
+ });