@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.
- package/CHANGELOG.md +6 -0
- package/app/agentic/cockpit/mount.test.ts +50 -0
- package/app/agentic/cockpit/supply-render.test.ts +20 -0
- package/app/agentic/cockpit/supply-render.ts +15 -0
- package/app/agentic/cockpit/supply-view.ts +19 -2
- package/app/agentic/vocab/demand-report.test.ts +66 -1
- package/app/agentic/vocab/demand-report.ts +54 -6
- package/app/contracts.ts +24 -0
- package/app/harnessProtocol.test.ts +170 -0
- package/app/harnessProtocol.ts +312 -0
- package/app/mcpToolSurface.ts +7 -1
- package/db/migrations/107_worker_harness_protocol.sql +30 -0
- package/openapi.yaml +71 -1
- package/operations/enrolAgenticWorker.test.ts +84 -0
- package/operations/enrolAgenticWorker.ts +67 -7
- package/operations/getAgenticRegistry.ts +1 -1
- package/operations/getAgenticSupply.test.ts +80 -0
- package/operations/getAgenticSupply.ts +15 -3
- package/package.json +1 -1
- package/pages/cockpit/mount.js +18 -0
- package/test/worldDb.ts +6 -0
|
@@ -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
|
+
}
|
package/app/mcpToolSurface.ts
CHANGED
|
@@ -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 =
|
|
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:
|
|
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
|
+
});
|