@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/SPEC.md +16 -0
  3. package/app/adjudications.test.ts +735 -0
  4. package/app/adjudications.ts +378 -0
  5. package/app/agentCompletion.test.ts +282 -10
  6. package/app/agentCompletion.ts +163 -23
  7. package/app/agentic/cockpit/mount.test.ts +50 -0
  8. package/app/agentic/cockpit/supply-render.test.ts +20 -0
  9. package/app/agentic/cockpit/supply-render.ts +15 -0
  10. package/app/agentic/cockpit/supply-view.ts +19 -2
  11. package/app/agentic/permission-bridge.test.ts +2 -2
  12. package/app/agentic/vocab/demand-report.test.ts +66 -1
  13. package/app/agentic/vocab/demand-report.ts +54 -6
  14. package/app/answer-escalation.test.ts +415 -2
  15. package/app/answerContextMapping.test.ts +83 -0
  16. package/app/contracts.ts +24 -0
  17. package/app/convergenceAdjudicationResume.test.ts +274 -0
  18. package/app/github.ts +10 -0
  19. package/app/harnessProtocol.test.ts +170 -0
  20. package/app/harnessProtocol.ts +312 -0
  21. package/app/mcpToolSurface.ts +7 -1
  22. package/app/service.test.ts +178 -1
  23. package/app/service.ts +104 -4
  24. package/app/terminalReaderBehaviour.test.ts +21 -0
  25. package/db/migrations/107_worker_harness_protocol.sql +30 -0
  26. package/db/migrations/109_pr_adjudications.sql +61 -0
  27. package/db/migrations/110_task_completions_auto_applied.sql +34 -0
  28. package/openapi.yaml +71 -1
  29. package/operations/completeUserTask.test.ts +5 -5
  30. package/operations/enrolAgenticWorker.test.ts +84 -0
  31. package/operations/enrolAgenticWorker.ts +67 -7
  32. package/operations/getAgenticRegistry.ts +1 -1
  33. package/operations/getAgenticSupply.test.ts +80 -0
  34. package/operations/getAgenticSupply.ts +15 -3
  35. package/operations/listEscalations.test.ts +1 -1
  36. package/package.json +1 -1
  37. package/pages/cockpit/mount.js +18 -0
  38. package/resources/processes/convergence-loop.bpmn +9 -0
  39. package/resources/processes/merge-loop.bpmn +1 -0
  40. package/test/worldDb.ts +6 -0
  41. 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
+ }
@@ -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
@@ -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 technical-incident surfacing (issue #94). A convergence/merge instance
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