@nanobpm/nano-workforce 0.188.0 → 0.189.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -0,0 +1,166 @@
1
+ // Red-first coverage for the implement-cell reconcile decision (issue #801) — the implement-stage twin
2
+ // of #796. The defect: an implement-step that returns NO machine-readable `status` but has an OPEN PR
3
+ // on its `feat/<task.id>` branch was dead-ended at a human escalation instead of adopting that PR and
4
+ // converging. These tests pin the canonical `ic_reconcile_gw` decision that reconciles from GitHub
5
+ // before escalating.
6
+ import { test } from "node:test";
7
+ import { assertEquals } from "#test-assert";
8
+ import type { HeadPr } from "./github.ts";
9
+ import {
10
+ implementCellBranch,
11
+ pickAdoptablePr,
12
+ reconcileImplement,
13
+ shouldReconcileImplement,
14
+ } from "./implementReconcile.ts";
15
+
16
+ const openPr = (number: number, base = "main"): HeadPr => ({
17
+ number,
18
+ url: `https://github.com/owner/repo/pull/${number}`,
19
+ state: "open",
20
+ baseRef: base,
21
+ });
22
+
23
+ test("implementCellBranch: the deterministic feat/<task.id> branch", () => {
24
+ assertEquals(implementCellBranch("issue-796"), "feat/issue-796");
25
+ });
26
+
27
+ test("shouldReconcileImplement: only a blank/absent status reconciles", () => {
28
+ assertEquals(shouldReconcileImplement(null), true);
29
+ assertEquals(shouldReconcileImplement(undefined), true);
30
+ assertEquals(shouldReconcileImplement(" "), true);
31
+ assertEquals(shouldReconcileImplement("escalated"), false);
32
+ assertEquals(shouldReconcileImplement("opened"), false);
33
+ });
34
+
35
+ test("pickAdoptablePr: the first OPEN PR wins; merged/closed are not adoptable", () => {
36
+ assertEquals(pickAdoptablePr(null), null);
37
+ assertEquals(pickAdoptablePr([]), null);
38
+ assertEquals(pickAdoptablePr([{ ...openPr(1), state: "merged" }]), null);
39
+ assertEquals(pickAdoptablePr([{ ...openPr(2), state: "closed" }, openPr(3)])?.number, 3);
40
+ });
41
+
42
+ test("pickAdoptablePr: a known baseBranch adopts only an open PR that targets it", () => {
43
+ // Multiple open PRs from the same head branch to different bases — only the one matching the
44
+ // run's pinned base is adoptable; a stale/wrong-base PR (even if first) is never adopted.
45
+ const prs = [openPr(10, "old-epic-base"), openPr(11, "epic/feat-x")];
46
+ assertEquals(pickAdoptablePr(prs, "epic/feat-x")?.number, 11);
47
+ // No open PR targets the pinned base → nothing adoptable (escalate rather than converge the wrong PR).
48
+ assertEquals(pickAdoptablePr([openPr(12, "some-other-base")], "epic/feat-x"), null);
49
+ // Whitespace-only base is treated as "unknown" → first-open fallback.
50
+ assertEquals(pickAdoptablePr([openPr(13, "main")], " ")?.number, 13);
51
+ });
52
+
53
+ // The core defect reproduction: blank status + an open PR on the branch → adopt & converge, no escalation.
54
+ test("reconcileImplement: blank status + open PR on feat/<task.id> → adopt (status=opened, pr set)", async () => {
55
+ const calls: Array<{ repo: string; branch: string }> = [];
56
+ const lookup = async (repo: string, branch: string): Promise<HeadPr[]> => {
57
+ calls.push({ repo, branch });
58
+ return [openPr(800)];
59
+ };
60
+ const res = await reconcileImplement(
61
+ { status: null, subjectKey: "nanobpm/nano-workforce#796", taskId: "issue-796" },
62
+ lookup,
63
+ "token",
64
+ );
65
+ assertEquals(res, { reconciled: true, status: "opened", pr: "nanobpm/nano-workforce#800" });
66
+ assertEquals(calls, [{ repo: "nanobpm/nano-workforce", branch: "feat/issue-796" }]);
67
+ });
68
+
69
+ test("reconcileImplement: blank status but NO branch/PR → escalate (unchanged behaviour)", async () => {
70
+ const res = await reconcileImplement(
71
+ { status: null, subjectKey: "owner/repo#7", taskId: "issue-7" },
72
+ async () => [],
73
+ "token",
74
+ );
75
+ assertEquals(res, { reconciled: false, status: null, pr: null });
76
+ });
77
+
78
+ test("reconcileImplement: a genuine escalation status is honoured, GitHub never consulted", async () => {
79
+ let consulted = false;
80
+ const res = await reconcileImplement(
81
+ { status: "escalated", subjectKey: "owner/repo#7", taskId: "issue-7" },
82
+ async () => {
83
+ consulted = true;
84
+ return [openPr(9)];
85
+ },
86
+ "token",
87
+ );
88
+ assertEquals(res, { reconciled: false, status: "escalated", pr: null });
89
+ assertEquals(consulted, false);
90
+ });
91
+
92
+ test("reconcileImplement: only merged/closed PRs on the branch → escalate (nothing in-flight to adopt)", async () => {
93
+ const res = await reconcileImplement(
94
+ { status: null, subjectKey: "owner/repo#7", taskId: "issue-7" },
95
+ async () => [{ ...openPr(5), state: "merged" }],
96
+ "token",
97
+ );
98
+ assertEquals(res.reconciled, false);
99
+ });
100
+
101
+ test("reconcileImplement: a lookup transport failure falls through to escalate (best-effort)", async () => {
102
+ const res = await reconcileImplement(
103
+ { status: null, subjectKey: "owner/repo#7", taskId: "issue-7" },
104
+ async () => {
105
+ throw new Error("github 502");
106
+ },
107
+ "token",
108
+ );
109
+ assertEquals(res, { reconciled: false, status: null, pr: null });
110
+ });
111
+
112
+ test("reconcileImplement: an existing pr is carried through unchanged on fall-through (never wiped)", async () => {
113
+ // A genuine escalation status → escalate; any pr already in scope must survive the re-emit.
114
+ const escalated = await reconcileImplement(
115
+ { status: "escalated", subjectKey: "owner/repo#7", taskId: "issue-7", pr: "owner/repo#42" },
116
+ async () => [],
117
+ "token",
118
+ );
119
+ assertEquals(escalated, { reconciled: false, status: "escalated", pr: "owner/repo#42" });
120
+
121
+ // Blank status but no adoptable PR → escalate; an existing pr still survives.
122
+ const noAdopt = await reconcileImplement(
123
+ { status: null, subjectKey: "owner/repo#7", taskId: "issue-7", pr: "owner/repo#42" },
124
+ async () => [],
125
+ "token",
126
+ );
127
+ assertEquals(noAdopt, { reconciled: false, status: null, pr: "owner/repo#42" });
128
+ });
129
+
130
+ test("reconcileImplement: a successful adoption overwrites any existing pr with the adopted key", async () => {
131
+ const res = await reconcileImplement(
132
+ { status: null, subjectKey: "owner/repo#7", taskId: "issue-7", pr: "owner/repo#42" },
133
+ async () => [openPr(99)],
134
+ "token",
135
+ );
136
+ assertEquals(res, { reconciled: true, status: "opened", pr: "owner/repo#99" });
137
+ });
138
+
139
+ test("reconcileImplement: with a pinned baseBranch, only a PR targeting it is adopted", async () => {
140
+ // The head branch carries two open PRs to different bases — adopt the one matching the run's base.
141
+ const adopt = await reconcileImplement(
142
+ { status: null, subjectKey: "owner/repo#7", taskId: "issue-7", baseBranch: "epic/feat-x" },
143
+ async () => [openPr(50, "stale-base"), openPr(51, "epic/feat-x")],
144
+ "token",
145
+ );
146
+ assertEquals(adopt, { reconciled: true, status: "opened", pr: "owner/repo#51" });
147
+
148
+ // Only a wrong-base PR exists → escalate rather than converge the wrong branch.
149
+ const escalate = await reconcileImplement(
150
+ { status: null, subjectKey: "owner/repo#7", taskId: "issue-7", baseBranch: "epic/feat-x", pr: "owner/repo#42" },
151
+ async () => [openPr(52, "stale-base")],
152
+ "token",
153
+ );
154
+ assertEquals(escalate, { reconciled: false, status: null, pr: "owner/repo#42" });
155
+ });
156
+
157
+ test("reconcileImplement: a missing taskId or unparseable subjectKey → escalate, no lookup", async () => {
158
+ let consulted = false;
159
+ const lookup = async (): Promise<HeadPr[]> => {
160
+ consulted = true;
161
+ return [openPr(1)];
162
+ };
163
+ assertEquals((await reconcileImplement({ status: null, subjectKey: "owner/repo#7", taskId: null }, lookup, "t")).reconciled, false);
164
+ assertEquals((await reconcileImplement({ status: null, subjectKey: "not-a-key", taskId: "issue-7" }, lookup, "t")).reconciled, false);
165
+ assertEquals(consulted, false);
166
+ });
@@ -0,0 +1,112 @@
1
+ // Implement-step reconcile — reconcile the implement-cell result from GitHub BEFORE escalating to a
2
+ // human (issue #801).
3
+ //
4
+ // The shared `implement-cell` (resources/processes/implement-cell.bpmn) routes ANY implement-step
5
+ // outcome that is not a clean terminal (`opened`/`blocked`/`skipped`) to a human escalation. But a
6
+ // harness that returns NO machine-readable result envelope (a blank/absent `status`) can still have
7
+ // pushed the slice's branch and opened a green PR — a machine-observable, recoverable outcome the
8
+ // escalation question literally asked a human to go and check (#796's implement-stage twin). Dead-
9
+ // ending that at a person is the defect.
10
+ //
11
+ // This is the CANONICAL, pure decision for the cell's reconcile step: on a blank/absent `status`,
12
+ // look for an OPEN PR opened from the cell's deterministic branch (`feat/<task.id>`, the agent-guide
13
+ // convention every implement-cell caller shares — see resources/prompts/feature.md) and, when one
14
+ // exists, ADOPT it (derive `status = "opened"` + a `pr` key) so the run converges exactly as if the
15
+ // agent had reported it — no human escalation. Only when nothing is observable does the run escalate
16
+ // as today. The GitHub read is injected (the canonical `listPrsForHead`) so this stays a pure,
17
+ // exhaustively testable mirror of the `ic_reconcile_gw` gateway — no second GitHub reconciler.
18
+ import type { HeadPr } from "./github.ts";
19
+ import { parsePr } from "./prParse.ts";
20
+
21
+ /** The escalate-arm inputs the reconcile step reads from the implement-cell scope. `subjectKey` is the
22
+ * cell's `owner/repo#N` subject (a feature run's `feature_key`, or a wave slice's epic `plan_key`) —
23
+ * its `owner/repo` half is the repository to look in. `taskId` is `task.id`, which fixes the
24
+ * deterministic implement branch `feat/<task.id>`. `status` is the (blank, on this arm) implement-step
25
+ * status. `pr` is any PR key already in scope (the implement harness may have set it) — carried through
26
+ * unchanged on the non-adopt fall-through so re-emitting the output never wipes it. `baseBranch` is the
27
+ * run's pinned base branch (the epic/graph integration branch every implement-cell caller maps into the
28
+ * cell scope): when known, only a PR whose base matches it is adoptable, so a stale/unrelated PR sharing
29
+ * the deterministic head branch but targeting a different base is never adopted. */
30
+ export interface ReconcileImplementInput {
31
+ status: unknown;
32
+ subjectKey: unknown;
33
+ taskId: unknown;
34
+ pr?: unknown;
35
+ baseBranch?: unknown;
36
+ }
37
+
38
+ /** The reconcile decision. `reconciled` is the `ic_reconcile_gw` gate: true → adopt-and-converge (with
39
+ * `status = "opened"` + `pr` set); false → escalate as today. `status`/`pr` are re-emitted so the
40
+ * cell (and its caller) route on the adopted values. */
41
+ export interface ReconcileImplementResult {
42
+ reconciled: boolean;
43
+ status: string | null;
44
+ pr: string | null;
45
+ }
46
+
47
+ /** The injected GitHub read — the canonical `listPrsForHead(repo, headBranch, token)` (so this module
48
+ * never grows a second PR-lookup transport). */
49
+ export type OpenPrLookup = (repo: string, branch: string, token: string) => Promise<HeadPr[] | null>;
50
+
51
+ const str = (v: unknown): string | undefined =>
52
+ typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
53
+
54
+ /** Reconcile ONLY when the agent left no machine-readable status (blank/absent) — the #796/#801
55
+ * no-result condition. A genuine escalation that carries its own status string is honoured (escalate),
56
+ * never silently overridden by a branch PR that may be unrelated to the agent's question. */
57
+ export function shouldReconcileImplement(status: unknown): boolean {
58
+ return str(status) === undefined;
59
+ }
60
+
61
+ /** The cell's deterministic implement branch — `feat/<task.id>` (resources/prompts/feature.md). */
62
+ export function implementCellBranch(taskId: string): string {
63
+ return `feat/${taskId}`;
64
+ }
65
+
66
+ /** The adoptable PR from a head-branch listing: the first OPEN one (a merged/closed PR on the branch is
67
+ * not an in-flight result to converge). When `baseBranch` is given, only an open PR whose `baseRef`
68
+ * matches it is adoptable — GitHub can carry multiple open PRs from one head branch to different bases,
69
+ * so adopting blind to the base could converge a stale/unrelated PR; with no base known, fall back to
70
+ * the first open PR (unchanged best-effort behaviour). `null` when the listing is absent (no transport)
71
+ * or has no adoptable PR. */
72
+ export function pickAdoptablePr(prs: HeadPr[] | null, baseBranch?: string): HeadPr | null {
73
+ if (!prs) return null;
74
+ const open = prs.filter((p) => p.state === "open");
75
+ const base = typeof baseBranch === "string" ? baseBranch.trim() : "";
76
+ if (base) return open.find((p) => p.baseRef === base) ?? null;
77
+ return open[0] ?? null;
78
+ }
79
+
80
+ /** The canonical implement-cell reconcile decision (mirror of `ic_reconcile_gw`). Best-effort: any
81
+ * missing input, unusable transport, or lookup failure falls through to `escalate` — never worse than
82
+ * today's behaviour, and idempotent (a pure GitHub read that adopts the SAME open PR on a re-run, so
83
+ * a re-dispatch never opens or double-adopts a second PR). */
84
+ export async function reconcileImplement(
85
+ input: ReconcileImplementInput,
86
+ lookup: OpenPrLookup,
87
+ token: string,
88
+ ): Promise<ReconcileImplementResult> {
89
+ const escalate: ReconcileImplementResult = {
90
+ reconciled: false,
91
+ status: str(input.status) ?? null,
92
+ // Carry any existing PR key through unchanged — the reconcile step's `pr` output is mapped back
93
+ // into the process variable, so returning a bare `null` here would wipe a `pr` the implement
94
+ // harness already set. Only a successful adoption below overwrites it.
95
+ pr: str(input.pr) ?? null,
96
+ };
97
+ if (!shouldReconcileImplement(input.status)) return escalate;
98
+ const taskId = str(input.taskId);
99
+ // `subjectKey` shares the `owner/repo#N` shape parsePr validates; we use only its `repo` half.
100
+ const parsed = parsePr(input.subjectKey);
101
+ if (!taskId || !parsed) return escalate;
102
+ const branch = implementCellBranch(taskId);
103
+ let prs: HeadPr[] | null;
104
+ try {
105
+ prs = await lookup(parsed.repo, branch, token);
106
+ } catch {
107
+ return escalate; // transport hiccup → escalate as today
108
+ }
109
+ const adopt = pickAdoptablePr(prs, str(input.baseBranch));
110
+ if (!adopt) return escalate;
111
+ return { reconciled: true, status: "opened", pr: `${parsed.repo}#${adopt.number}` };
112
+ }
@@ -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
+ );