@nanobpm/nano-workforce 0.188.1 → 0.189.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/SPEC.md +16 -0
- package/app/adjudications.test.ts +735 -0
- package/app/adjudications.ts +378 -0
- package/app/agentCompletion.test.ts +282 -10
- package/app/agentCompletion.ts +163 -23
- 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/permission-bridge.test.ts +2 -2
- package/app/agentic/vocab/demand-report.test.ts +66 -1
- package/app/agentic/vocab/demand-report.ts +54 -6
- package/app/answer-escalation.test.ts +415 -2
- package/app/answerContextMapping.test.ts +83 -0
- package/app/contracts.ts +24 -0
- package/app/convergenceAdjudicationResume.test.ts +274 -0
- package/app/github.ts +10 -0
- package/app/harnessProtocol.test.ts +170 -0
- package/app/harnessProtocol.ts +312 -0
- package/app/mcpToolSurface.ts +7 -1
- package/app/service.test.ts +178 -1
- package/app/service.ts +104 -4
- package/app/terminalReaderBehaviour.test.ts +21 -0
- package/db/migrations/107_worker_harness_protocol.sql +30 -0
- package/db/migrations/109_pr_adjudications.sql +61 -0
- package/db/migrations/110_task_completions_auto_applied.sql +34 -0
- package/openapi.yaml +71 -1
- package/operations/completeUserTask.test.ts +5 -5
- 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/operations/listEscalations.test.ts +1 -1
- package/package.json +1 -1
- package/pages/cockpit/mount.js +18 -0
- package/resources/processes/convergence-loop.bpmn +9 -0
- package/resources/processes/merge-loop.bpmn +1 -0
- package/test/worldDb.ts +6 -0
- package/workers/answer-escalation/worker.ts +191 -11
|
@@ -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
|
package/app/service.test.ts
CHANGED
|
@@ -39,6 +39,33 @@ function memTable(rows: any[], key: string) {
|
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// Emulates the `data.open().exec` raw-SQL path submitPr uses to atomically reset a PR's adjudication
|
|
43
|
+
// memory (`resetAdjudications` → `DELETE FROM "pr_adjudications" WHERE "pr_key" = ?`, Copilot review of
|
|
44
|
+
// #806). The bulk-DELETE SQL itself is validated against real SQLite in app/adjudications.test.ts; here
|
|
45
|
+
// it need only mutate the in-memory `pr_adjudications` store so submitPr's reset is observable. Pushes
|
|
46
|
+
// an optional ordering token so the fence-ordering test can assert the reset runs AFTER `process_key`.
|
|
47
|
+
function memOpen(stores: Record<string, { rows: any[]; key: string }>, ops?: string[]) {
|
|
48
|
+
return {
|
|
49
|
+
exec: async (sql: string, params: any[] = []) => {
|
|
50
|
+
if (/DELETE FROM "pr_adjudications" WHERE "pr_key" = \?/.test(sql)) {
|
|
51
|
+
ops?.push("adjudication-delete");
|
|
52
|
+
const store = stores.pr_adjudications;
|
|
53
|
+
let changed = 0;
|
|
54
|
+
if (store) {
|
|
55
|
+
for (let i = store.rows.length - 1; i >= 0; i--) {
|
|
56
|
+
if (store.rows[i].pr_key === params[0]) {
|
|
57
|
+
store.rows.splice(i, 1);
|
|
58
|
+
changed++;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { changed };
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`unexpected exec sql: ${sql}`);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
42
69
|
function withGithubOff(run: () => Promise<void>): Promise<void> {
|
|
43
70
|
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
44
71
|
const prevTok = process.env["GITHUB_TOKEN"];
|
|
@@ -104,6 +131,7 @@ test("re-submit of a cancelled PR marks stale open escalations", async () => {
|
|
|
104
131
|
};
|
|
105
132
|
const data = {
|
|
106
133
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
134
|
+
open: () => memOpen(stores),
|
|
107
135
|
} as any;
|
|
108
136
|
const engine = {
|
|
109
137
|
createInstance: () => Promise.resolve({ processInstanceKey: "PI-9" }),
|
|
@@ -140,7 +168,142 @@ test("re-submit of a cancelled PR marks stale open escalations", async () => {
|
|
|
140
168
|
});
|
|
141
169
|
});
|
|
142
170
|
|
|
143
|
-
// Red/green regression for
|
|
171
|
+
// Red/green regression for issue #806 (Copilot review): re-submitting a PR must ALSO invalidate its
|
|
172
|
+
// durable adjudication memory. The auto-resume replays a prior `(PR, question)` answer forever, so a
|
|
173
|
+
// re-opened PR whose question recurs would silently auto-apply the stale decision and an operator
|
|
174
|
+
// could never force a fresh one. `submitPr`'s reopen path clears `pr_adjudications` for the PR.
|
|
175
|
+
test("re-submit of a PR invalidates its durable adjudications (#806 review)", async () => {
|
|
176
|
+
await withGithubOff(async () => {
|
|
177
|
+
const PR_KEY = "owner/repo#42";
|
|
178
|
+
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
179
|
+
pull_requests: {
|
|
180
|
+
rows: [{ pr_key: PR_KEY, repo: "owner/repo", number: 42, url: "https://github.com/owner/repo/pull/42", title: "t", status: "converged" }],
|
|
181
|
+
key: "pr_key",
|
|
182
|
+
},
|
|
183
|
+
escalations: { rows: [], key: "id" },
|
|
184
|
+
pr_adjudications: {
|
|
185
|
+
rows: [
|
|
186
|
+
{ id: 1, pr_key: PR_KEY, question_fingerprint: "fp-a", answer: "prior A", adjudicated_by: "alice", adjudicated_kind: "human", adjudicated_at: "t" },
|
|
187
|
+
{ id: 2, pr_key: "owner/repo#99", question_fingerprint: "fp-b", answer: "other PR", adjudicated_by: "bob", adjudicated_kind: "human", adjudicated_at: "t" },
|
|
188
|
+
],
|
|
189
|
+
key: "id",
|
|
190
|
+
},
|
|
191
|
+
pr_dependencies: { rows: [], key: "pr_key" },
|
|
192
|
+
};
|
|
193
|
+
const data = {
|
|
194
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
195
|
+
open: () => memOpen(stores),
|
|
196
|
+
} as any;
|
|
197
|
+
const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-9" }) } as any;
|
|
198
|
+
|
|
199
|
+
await submitPr(data, engine, { repo: "owner/repo", number: 42, url: "https://github.com/owner/repo/pull/42", prKey: PR_KEY });
|
|
200
|
+
|
|
201
|
+
const remaining = stores.pr_adjudications.rows as Record<string, unknown>[];
|
|
202
|
+
assertEquals(remaining.length, 1, "this PR's adjudication is invalidated; another PR's is untouched");
|
|
203
|
+
assertEquals(remaining[0].pr_key, "owner/repo#99", "only the re-submitted PR's adjudications are cleared");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Red/green regression for issue #806 (Copilot review): the durable adjudication RESET must happen
|
|
208
|
+
// AFTER `process_key` is advanced to the new instance — not before createInstance. Clearing the memory
|
|
209
|
+
// while `process_key` still names the OLD instance leaves a window where a delayed old-instance answer
|
|
210
|
+
// job passes the worker's staleness gate and reinserts its adjudication into the fresh run. Advancing
|
|
211
|
+
// the run identity FIRST fences that job, so the ordering is the fix. This asserts the observable
|
|
212
|
+
// invariant: the `pull_requests.process_key` write is issued BEFORE any `pr_adjudications.delete`.
|
|
213
|
+
test("re-submit advances process_key BEFORE resetting adjudications (fence ordering, #806 review)", async () => {
|
|
214
|
+
await withGithubOff(async () => {
|
|
215
|
+
const PR_KEY = "owner/repo#42";
|
|
216
|
+
const ops: string[] = [];
|
|
217
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
218
|
+
pull_requests: {
|
|
219
|
+
rows: [{ pr_key: PR_KEY, repo: "owner/repo", number: 42, url: "https://github.com/owner/repo/pull/42", title: "t", status: "converged", process_key: "PI-OLD" }],
|
|
220
|
+
key: "pr_key",
|
|
221
|
+
},
|
|
222
|
+
escalations: { rows: [], key: "id" },
|
|
223
|
+
pr_adjudications: {
|
|
224
|
+
rows: [{ id: 1, pr_key: PR_KEY, question_fingerprint: "fp-a", answer: "prior A", adjudicated_by: "alice", adjudicated_kind: "human", adjudicated_at: "t" }],
|
|
225
|
+
key: "id",
|
|
226
|
+
},
|
|
227
|
+
pr_dependencies: { rows: [], key: "pr_key" },
|
|
228
|
+
};
|
|
229
|
+
const wrap = (name: string, key: string) => {
|
|
230
|
+
const t = memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key);
|
|
231
|
+
return {
|
|
232
|
+
...t,
|
|
233
|
+
update: (k: any, patch: any) => {
|
|
234
|
+
if (name === "pull_requests" && Object.prototype.hasOwnProperty.call(patch, "process_key")) ops.push("process_key");
|
|
235
|
+
return t.update(k, patch);
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
};
|
|
239
|
+
// The adjudication reset is now the atomic bulk `DELETE` via `data.open().exec` (Copilot review of
|
|
240
|
+
// #806), so `memOpen(stores, ops)` records the `adjudication-delete` ordering token — the table
|
|
241
|
+
// `delete` gateway is no longer on the reset path.
|
|
242
|
+
const data = { table: withTrackingViews(wrap), open: () => memOpen(stores, ops) } as any;
|
|
243
|
+
const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-NEW" }) } as any;
|
|
244
|
+
|
|
245
|
+
await submitPr(data, engine, { repo: "owner/repo", number: 42, url: "https://github.com/owner/repo/pull/42", prKey: PR_KEY });
|
|
246
|
+
|
|
247
|
+
assertEquals(stores.pr_adjudications.rows.length, 0, "the re-submitted PR's adjudication is invalidated");
|
|
248
|
+
const pkIdx = ops.indexOf("process_key");
|
|
249
|
+
const delIdx = ops.indexOf("adjudication-delete");
|
|
250
|
+
assertEquals(pkIdx >= 0, true, "process_key is advanced on reopen");
|
|
251
|
+
assertEquals(delIdx >= 0, true, "adjudications are reset on reopen");
|
|
252
|
+
assertEquals(pkIdx < delIdx, true, "process_key is advanced BEFORE the adjudication memory is reset (the fence ordering)");
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Red/green regression (Copilot review): the durable adjudication RESET runs AFTER the new instance is
|
|
257
|
+
// created and `process_key` is advanced. If the reset DELETE fails, the new convergence instance is
|
|
258
|
+
// already live while the OLD adjudications remain — and because the new instance is ACTIVE the
|
|
259
|
+
// `alreadyRunning` idempotency gate short-circuits every retry, so the reset is never re-run and the
|
|
260
|
+
// fresh run replays STALE decisions forever. `submitPr` must instead ROLL THE NEW RUN BACK on a reset
|
|
261
|
+
// failure: terminate the just-created instance (so nothing auto-applies stale memory) and rethrow, so
|
|
262
|
+
// the submission is not treated as started and a retry re-creates a clean run.
|
|
263
|
+
test("re-submit rolls back (cancels) the new instance when the adjudication reset fails (#806 review)", async () => {
|
|
264
|
+
await withGithubOff(async () => {
|
|
265
|
+
const PR_KEY = "owner/repo#42";
|
|
266
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
267
|
+
pull_requests: {
|
|
268
|
+
rows: [{ pr_key: PR_KEY, repo: "owner/repo", number: 42, url: "https://github.com/owner/repo/pull/42", title: "t", status: "converged", process_key: "PI-OLD" }],
|
|
269
|
+
key: "pr_key",
|
|
270
|
+
},
|
|
271
|
+
escalations: { rows: [], key: "id" },
|
|
272
|
+
pr_adjudications: {
|
|
273
|
+
rows: [{ id: 1, pr_key: PR_KEY, question_fingerprint: "fp-a", answer: "prior A", adjudicated_by: "alice", adjudicated_kind: "human", adjudicated_at: "t" }],
|
|
274
|
+
key: "id",
|
|
275
|
+
},
|
|
276
|
+
pr_dependencies: { rows: [], key: "pr_key" },
|
|
277
|
+
};
|
|
278
|
+
// The reset DELETE throws (a transient DB failure), leaving the new instance live but the memory
|
|
279
|
+
// uncleared — the exact half-committed state the rollback guards against.
|
|
280
|
+
const failingOpen = () => ({
|
|
281
|
+
exec: async (sql: string) => {
|
|
282
|
+
if (/DELETE FROM "pr_adjudications" WHERE "pr_key" = \?/.test(sql)) throw new Error("boom: reset DELETE failed");
|
|
283
|
+
throw new Error(`unexpected exec sql: ${sql}`);
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
const data = { table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)), open: failingOpen } as any;
|
|
287
|
+
const cancelled: string[] = [];
|
|
288
|
+
const engine = {
|
|
289
|
+
createInstance: () => Promise.resolve({ processInstanceKey: "PI-NEW" }),
|
|
290
|
+
cancelInstance: (input: { processInstanceKey: string }) => {
|
|
291
|
+
cancelled.push(input.processInstanceKey);
|
|
292
|
+
return Promise.resolve();
|
|
293
|
+
},
|
|
294
|
+
} as any;
|
|
295
|
+
|
|
296
|
+
let threw = false;
|
|
297
|
+
try {
|
|
298
|
+
await submitPr(data, engine, { repo: "owner/repo", number: 42, url: "https://github.com/owner/repo/pull/42", prKey: PR_KEY });
|
|
299
|
+
} catch {
|
|
300
|
+
threw = true;
|
|
301
|
+
}
|
|
302
|
+
assertEquals(threw, true, "a failed reset propagates so the caller can retry");
|
|
303
|
+
assertEquals(cancelled, ["PI-NEW"], "the just-created instance is terminated (rolled back), never left live with stale memory");
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
144
307
|
// can hit an engine incident that parks the token; until `pollIncidents` nothing on the PR row
|
|
145
308
|
// reflected it, so the grid kept showing "converging" while the run was dead in the water. This
|
|
146
309
|
// drives the pass's reconciliation core against a stubbed `/v2/incidents/search`:
|
|
@@ -180,6 +343,7 @@ test("pollIncidents mirrors an ACTIVE incident onto the PR row, then clears it,
|
|
|
180
343
|
};
|
|
181
344
|
const data = {
|
|
182
345
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
346
|
+
open: () => memOpen(stores),
|
|
183
347
|
} as any;
|
|
184
348
|
const headers = { "content-type": "application/json" };
|
|
185
349
|
|
|
@@ -233,6 +397,7 @@ test("pollIncidents never queries a PR with no live instance and clears any stal
|
|
|
233
397
|
};
|
|
234
398
|
const data = {
|
|
235
399
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
400
|
+
open: () => memOpen(stores),
|
|
236
401
|
} as any;
|
|
237
402
|
const headers = { "content-type": "application/json" };
|
|
238
403
|
|
|
@@ -266,6 +431,7 @@ test("pollIncidents picks the oldest incident by creationTime, sorting a missing
|
|
|
266
431
|
};
|
|
267
432
|
const data = {
|
|
268
433
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
434
|
+
open: () => memOpen(stores),
|
|
269
435
|
} as any;
|
|
270
436
|
const headers = { "content-type": "application/json" };
|
|
271
437
|
|
|
@@ -305,6 +471,7 @@ test("submitPr stringifies a numeric processInstanceKey (contract: string | null
|
|
|
305
471
|
};
|
|
306
472
|
const data = {
|
|
307
473
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
474
|
+
open: () => memOpen(stores),
|
|
308
475
|
} as any;
|
|
309
476
|
const engine = {
|
|
310
477
|
// A large key delivered as a JS number — the exact case that breaks dev response validation
|
|
@@ -339,6 +506,7 @@ function captureConvergeOnly() {
|
|
|
339
506
|
};
|
|
340
507
|
const data = {
|
|
341
508
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
509
|
+
open: () => memOpen(stores),
|
|
342
510
|
} as any;
|
|
343
511
|
let captured: unknown;
|
|
344
512
|
const engine = {
|
|
@@ -391,6 +559,7 @@ function captureVars() {
|
|
|
391
559
|
};
|
|
392
560
|
const data = {
|
|
393
561
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
562
|
+
open: () => memOpen(stores),
|
|
394
563
|
} as any;
|
|
395
564
|
let captured: Record<string, unknown> | undefined;
|
|
396
565
|
const engine = {
|
|
@@ -429,6 +598,7 @@ function captureRoot() {
|
|
|
429
598
|
};
|
|
430
599
|
const data = {
|
|
431
600
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
601
|
+
open: () => memOpen(stores),
|
|
432
602
|
} as any;
|
|
433
603
|
let captured: unknown;
|
|
434
604
|
const engine = {
|
|
@@ -795,6 +965,7 @@ test("pollWaveGatesImpl is level-triggered: PRs merged before the token arrives
|
|
|
795
965
|
};
|
|
796
966
|
const data = {
|
|
797
967
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
968
|
+
open: () => memOpen(stores),
|
|
798
969
|
} as any;
|
|
799
970
|
|
|
800
971
|
const published: { name: string; correlationKey?: string }[] = [];
|
|
@@ -884,6 +1055,7 @@ test("pollWaveGatesImpl never releases the barrier on an unverifiable subscripti
|
|
|
884
1055
|
};
|
|
885
1056
|
const data = {
|
|
886
1057
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
1058
|
+
open: () => memOpen(stores),
|
|
887
1059
|
} as any;
|
|
888
1060
|
|
|
889
1061
|
const published: { name: string; correlationKey?: string }[] = [];
|
|
@@ -988,6 +1160,7 @@ test("pollWaveGatesImpl releases the wave when a member PR is closed-unmerged an
|
|
|
988
1160
|
};
|
|
989
1161
|
const data = {
|
|
990
1162
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
1163
|
+
open: () => memOpen(stores),
|
|
991
1164
|
} as any;
|
|
992
1165
|
|
|
993
1166
|
const published: { name: string; correlationKey?: string }[] = [];
|
|
@@ -1044,6 +1217,7 @@ test("abandonClosedPr is idempotent — the terminal merges audit row is written
|
|
|
1044
1217
|
};
|
|
1045
1218
|
const data = {
|
|
1046
1219
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
1220
|
+
open: () => memOpen(stores),
|
|
1047
1221
|
} as any;
|
|
1048
1222
|
|
|
1049
1223
|
await abandonClosedPr(data, "owner/repo#70", "closed without merging");
|
|
@@ -1072,6 +1246,7 @@ test("abandonClosedPr self-heals a missing pull_requests parent row before the F
|
|
|
1072
1246
|
};
|
|
1073
1247
|
const data = {
|
|
1074
1248
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
1249
|
+
open: () => memOpen(stores),
|
|
1075
1250
|
} as any;
|
|
1076
1251
|
|
|
1077
1252
|
await abandonClosedPr(data, "owner/repo#71", "closed without merging");
|
|
@@ -1098,6 +1273,7 @@ test("abandonClosedPr rejects a malformed prKey with a clear error before any FK
|
|
|
1098
1273
|
};
|
|
1099
1274
|
const data = {
|
|
1100
1275
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
1276
|
+
open: () => memOpen(stores),
|
|
1101
1277
|
} as any;
|
|
1102
1278
|
|
|
1103
1279
|
const err = await assertRejects(() => abandonClosedPr(data, "not-a-valid-pr-key", "closed without merging"));
|
|
@@ -1170,6 +1346,7 @@ function capsProbeExec(ready: boolean) {
|
|
|
1170
1346
|
function capsDataLayer(stores: Record<string, { rows: any[]; key: string }>) {
|
|
1171
1347
|
return {
|
|
1172
1348
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
1349
|
+
open: () => memOpen(stores),
|
|
1173
1350
|
} as any;
|
|
1174
1351
|
}
|
|
1175
1352
|
|