@nanobpm/nano-workforce 0.113.0 → 0.114.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.
@@ -0,0 +1,197 @@
1
+ // End-to-end proof that a COMPILED delivery graph deploys and runs ENGINE-NATIVELY on the WASM engine
2
+ // + virtual clock (ADR 0005 slice S4) — the integration acceptance the whole slice hinges on. Driven
3
+ // via `bootTestApp`, hermetic (deterministic shell-builtin `command` probes, no network, no GitHub;
4
+ // the `pr` kind's merge-state semantics are S2's surface, proven there — S4 proves the wait NODE
5
+ // executes engine-natively and gates, whatever the probe kind):
6
+ //
7
+ // • RUNS END-TO-END + FAN-IN + LATE-BIND: a graph with `agent`, `wait`, `human` and `connector`
8
+ // nodes deploys and runs; the agent job fires, the wait gate resolves, the human task completes,
9
+ // the connector fires — and the graph reaches End only after the wait AND the human both feed the
10
+ // connector (fan-in). The human's emitted `artifact` fact LATE-BINDS into the connector's input.
11
+ // • RESUME NEVER DOUBLE-FIRES: after the connector has fired once, an at-least-once redelivery of the
12
+ // same dispatch (a resume) DEDUPES — the durable ledger still holds exactly one row (Decision 7).
13
+ // • CONCURRENCY-CORRECTNESS: while a `wait` is parked on a never-green probe, completing an UNRELATED
14
+ // parallel human node does NOT falsely resolve the wait (the node polls its OWN target — there is no
15
+ // shared message correlation an unrelated event could trip, inheriting #274/S2); the wait stays
16
+ // parked until its bounded budget elapses, then escalates (bounded → escalate, never wedged).
17
+ import { mkdtempSync, rmSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join, resolve } from "node:path";
20
+ import { after, before, describe, test } from "node:test";
21
+ import assert from "node:assert/strict";
22
+ import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
23
+ import { connectorDedupeKey, deliveryConnectorDispatches, dispatchConnector } from "../app/deliveryConnector.ts";
24
+ import { readConnectorInput } from "../workers/delivery-connector/worker.ts";
25
+ import { runDeliveryGraph } from "../app/deliveryRunner.ts";
26
+ import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
27
+
28
+ const APP_ROOT = resolve(import.meta.dirname, "..");
29
+ const GITHUB_ENV: Record<string, string> = { NANO_PR_GITHUB_TRANSPORT: "token", GITHUB_TOKEN: "" };
30
+
31
+ interface TakenFlow {
32
+ from: string;
33
+ to: string;
34
+ }
35
+ function takenFlows(app: TestApp): string[] {
36
+ const snap = app.snapshot();
37
+ const flows = Array.isArray(snap.takenSequenceFlows) ? snap.takenSequenceFlows : [];
38
+ return flows
39
+ .filter((f): f is TakenFlow => typeof f === "object" && f !== null && "from" in f && "to" in f)
40
+ .map((f) => `${f.from}->${f.to}`);
41
+ }
42
+
43
+ /** Boot a fresh app per scenario (the WASM engine's taken-flow snapshot is engine-global cumulative). */
44
+ async function boot(dir: string): Promise<TestApp> {
45
+ return bootTestApp(APP_ROOT, { env: { ...GITHUB_ENV, NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
46
+ }
47
+
48
+ describe("delivery-graph runner — engine-native execution (S4)", () => {
49
+ const dirs: string[] = [];
50
+ const apps: TestApp[] = [];
51
+ const freshDir = (): string => {
52
+ const d = mkdtempSync(join(tmpdir(), "nwf-delivery-e2e-"));
53
+ dirs.push(d);
54
+ return d;
55
+ };
56
+ const track = (app: TestApp): TestApp => {
57
+ apps.push(app);
58
+ return app;
59
+ };
60
+ after(async () => {
61
+ for (const app of apps) await app.stop?.();
62
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
63
+ });
64
+
65
+ test("runs end-to-end: agent, wait, human execute; edges gate; fan-in works; human fact late-binds into the connector", async () => {
66
+ const app = track(await boot(freshDir()));
67
+
68
+ let agentFired = 0;
69
+ let connectorBoundFacts: unknown;
70
+ await app.engine.registerWorker("senior:demo", async () => {
71
+ agentFired++;
72
+ return {};
73
+ });
74
+ // Wrap the REAL connector job path so we can observe the late-bound facts it received. The worker
75
+ // itself is registered from the manifest; here we register a same-type observer stub for the e2e.
76
+ // Mirror the REAL worker's normalization — `readConnectorInput` (trim+require `target`, coerce a
77
+ // wrong-shaped payload/boundFacts) and `connectorDedupeKey` (derive the effective key from the
78
+ // author key OR the engine identity `processInstanceKey:elementId`, fail closed if neither) — so
79
+ // this observer exercises the same fail-closed/derivation behavior the production worker does and
80
+ // a regression in that surface can't hide behind a `String(... ?? "")` coercion.
81
+ await app.engine.registerWorker(
82
+ "pr.delivery-connector",
83
+ async (job) => {
84
+ const vars = job.variables as Record<string, unknown>;
85
+ connectorBoundFacts = vars.boundFacts;
86
+ const { target, payload, boundFacts } = readConnectorInput(
87
+ vars as Parameters<typeof readConnectorInput>[0],
88
+ );
89
+ const dedupeKey = connectorDedupeKey({
90
+ dedupeKey: (vars.dedupeKey as string | null | undefined) ?? null,
91
+ processInstanceKey: job.processInstanceKey ?? null,
92
+ elementId: job.elementId ?? null,
93
+ });
94
+ if (!dedupeKey) {
95
+ throw new Error("delivery-connector: no dedupe key (author-supplied or graph-derived) available");
96
+ }
97
+ return await dispatchConnector(
98
+ app.db,
99
+ { dedupeKey, target, payload, boundFacts },
100
+ new Date().toISOString(),
101
+ );
102
+ },
103
+ { fetchVariables: ["boundFacts", "target", "dedupeKey", "payload"] },
104
+ );
105
+
106
+ const graph: DeliveryGraph = {
107
+ name: "e2e end-to-end",
108
+ nodes: [
109
+ { id: "a", kind: "agent", agent: { jobType: "senior:demo" } },
110
+ { id: "w", kind: "wait", wait: { kind: "command", target: "true", poll: { everyMs: 5, backoff: "fixed" } } },
111
+ { id: "h", kind: "human", emits: [{ name: "art", type: "artifact" }] },
112
+ { id: "c", kind: "connector", connector: { target: "slack", dedupeKey: "c-e2e-1" } },
113
+ ],
114
+ edges: [
115
+ { from: "a", to: "h" },
116
+ { from: "h.art", to: "c" },
117
+ { from: "w", to: "c" },
118
+ ],
119
+ };
120
+
121
+ const run = await runDeliveryGraph(app.engine, graph, { probeTimeout: "PT2S" });
122
+ assert.ok(run.ok, `graph should deploy + run, got ${JSON.stringify(run)}`);
123
+ await app.settle();
124
+
125
+ // The agent node executed via its engine-native serviceTask body.
126
+ assert.equal(agentFired, 1, "the agent node's job fired once");
127
+
128
+ // The human node scheduled its per-node user task (the isDeliveryHumanElement convention id).
129
+ const open = await app.engine.searchUserTasks({ state: "CREATED" });
130
+ const human = open.find((t) => t.elementId?.startsWith("delivery-human-task__") && !t.elementId?.endsWith("__esc"));
131
+ assert.ok(human, `a human user task is open, got ${JSON.stringify(open.map((t) => t.elementId))}`);
132
+
133
+ // Before the human completes, the connector has NOT fired — the fan-in edge from `h` gates it.
134
+ assert.equal((await deliveryConnectorDispatches(app.db).find({})).length, 0, "connector waits on the human edge");
135
+
136
+ // Complete the human with a resolved artifact — its typed emit late-binds downstream.
137
+ await app.engine.completeUserTask(human.userTaskKey, { resolvedArtifact: "ARTIFACT-1", humanOutcome: "completed" });
138
+ await app.settle();
139
+
140
+ // The connector fired exactly once (fan-in of the wait AND the human both satisfied), and it
141
+ // received the human's emitted fact as a late-bound input.
142
+ const rows = await deliveryConnectorDispatches(app.db).find({ dedupe_key: "c-e2e-1" });
143
+ assert.equal(rows.length, 1, "the connector fired exactly once");
144
+ assert.equal(rows[0].outcome, "delivered");
145
+ assert.deepEqual(connectorBoundFacts, [{ from: "h", name: "art", value: "ARTIFACT-1" }], "the human fact late-binds into the connector");
146
+
147
+ // The graph reached End — the fan-in join released only after BOTH upstream branches completed.
148
+ assert.ok(takenFlows(app).some((f) => f.endsWith("->End")), "the graph reached its End event");
149
+ });
150
+
151
+ test("resume never double-fires: an at-least-once redelivery of the connector dedupes", async () => {
152
+ const app = track(await boot(freshDir()));
153
+ // The connector fired once above's-style; here prove the idempotency directly against the ledger a
154
+ // resumed graph shares. First dispatch delivers; a redelivery of the SAME dispatch (the resume) is
155
+ // deduped and the durable ledger still holds exactly ONE row — the side effect never re-fires.
156
+ const first = await dispatchConnector(app.db, { dedupeKey: "resume-1", target: "slack" }, new Date().toISOString());
157
+ assert.equal(first.connectorOutcome, "delivered");
158
+ const replay = await dispatchConnector(app.db, { dedupeKey: "resume-1", target: "slack" }, new Date().toISOString());
159
+ assert.equal(replay.connectorOutcome, "deduped", "a resume redelivery dedupes");
160
+ assert.equal((await deliveryConnectorDispatches(app.db).find({ dedupe_key: "resume-1" })).length, 1, "exactly one durable dispatch");
161
+ });
162
+
163
+ test("concurrency-correctness: an unrelated human completion does not falsely resolve a parked wait", async () => {
164
+ const app = track(await boot(freshDir()));
165
+ // Two independent parallel branches: a NEVER-GREEN wait, and an unrelated human. The wait polls its
166
+ // own `false` target (never ready) — there is NO shared correlation an unrelated event could trip.
167
+ const graph: DeliveryGraph = {
168
+ name: "e2e concurrency",
169
+ nodes: [
170
+ { id: "gate", kind: "wait", wait: { kind: "command", target: "false", poll: { everyMs: 5, backoff: "fixed" } } },
171
+ { id: "side", kind: "human", emits: [{ name: "ok", type: "string" }] },
172
+ ],
173
+ edges: [],
174
+ };
175
+ const run = await runDeliveryGraph(app.engine, graph, { probeTimeout: "PT2S", escalationSlaTimeout: "PT1H" });
176
+ assert.ok(run.ok, `graph should deploy + run, got ${JSON.stringify(run)}`);
177
+ await app.settle();
178
+
179
+ // Complete the UNRELATED human node — an upstream event with no edge to the wait.
180
+ const open = await app.engine.searchUserTasks({ state: "CREATED" });
181
+ const side = open.find((t) => t.elementId?.startsWith("delivery-human-task__") && !t.elementId?.endsWith("__esc"));
182
+ assert.ok(side, "the unrelated human task is open");
183
+ await app.engine.completeUserTask(side.userTaskKey, { value: "done", humanOutcome: "completed" });
184
+ await app.settle();
185
+
186
+ // The wait polls `false` — it can NEVER resolve as ready, so completing the unrelated human could
187
+ // not trip it: the graph never reaches End (the wait never released its "ready" branch). Instead the
188
+ // wait is BOUNDED — its poll budget elapses and it escalates onto a human-completable task, parking
189
+ // for a human rather than silently wedging or falsely resolving.
190
+ assert.ok(!takenFlows(app).some((f) => f.endsWith("->End")), "the wait branch never falsely resolves to End");
191
+ const esc = (await app.engine.searchUserTasks({ state: "CREATED" })).filter((t) => t.elementId?.endsWith("__esc"));
192
+ assert.ok(
193
+ esc.length >= 1,
194
+ `the parked wait escalates (bounded), never falsely resolved by the unrelated event, got ${JSON.stringify((await app.engine.searchUserTasks({ state: "CREATED" })).map((t) => t.elementId))}`,
195
+ );
196
+ });
197
+ });
package/nano.app.json CHANGED
@@ -199,6 +199,10 @@
199
199
  {
200
200
  "taskType": "pr.readiness-probe",
201
201
  "handler": "workers/readiness-probe/worker.ts"
202
+ },
203
+ {
204
+ "taskType": "pr.delivery-connector",
205
+ "handler": "workers/delivery-connector/worker.ts"
202
206
  }
203
207
  ],
204
208
  "externalTaskTypes": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.113.0",
3
+ "version": "0.114.1",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -50,6 +50,8 @@
50
50
  <nano:shape id="PrConvergeGateOut" name="Converge gate — result">
51
51
  <nano:extend name="convergeBlocked" type="boolean" />
52
52
  <nano:extend name="convergeBlockReason" type="string" optional="true" />
53
+ <nano:extend name="headSha" type="string" optional="true" />
54
+ <nano:extend name="scopeBlocked" type="boolean" optional="true" />
53
55
  </nano:shape>
54
56
  <nano:shape id="EscalationIn" name="Record escalation — input">
55
57
  <nano:extend name="prKey" type="string" />
@@ -62,6 +64,8 @@
62
64
  <nano:extend name="prNumber" type="integer" optional="true" />
63
65
  <nano:extend name="prUrl" type="string" optional="true" />
64
66
  <nano:extend name="abandonUrl" type="string" optional="true" />
67
+ <nano:extend name="headSha" type="string" optional="true" />
68
+ <nano:extend name="scopeBlock" type="boolean" optional="true" />
65
69
  </nano:shape>
66
70
  <nano:shape id="EscalationOut" name="Record escalation — result">
67
71
  <nano:extend name="escalationId" type="integer" optional="true" />
@@ -299,6 +303,8 @@
299
303
  <zeebe:input source="=&#34;blocked&#34;" target="status" />
300
304
  <zeebe:input source="=false" target="recordRound" />
301
305
  <zeebe:input source="=convergeBlockReason" target="question" />
306
+ <zeebe:input source="=headSha" target="headSha" />
307
+ <zeebe:input source="=scopeBlocked" target="scopeBlock" />
302
308
  </zeebe:ioMapping>
303
309
  </bpmn:extensionElements>
304
310
  <bpmn:incoming>f_convergeBlocked</bpmn:incoming>
@@ -0,0 +1,116 @@
1
+ // pr.converge-gate — the human-override door for the scope-integrity block (issue #395).
2
+ //
3
+ // The scope-integrity gate re-derives `scopeBlocked` from the PR body every converged round. Before
4
+ // this fix, answering its escalation re-entered the loop, the gate re-blocked identically, and the
5
+ // operator was trapped in an infinite escalation (a fresh escalationId each cycle) — the only escape
6
+ // was mangling the PR body into a non-closing ref. These tests pin the override door: an escalation
7
+ // answer bound to the SAME reviewed HEAD satisfies the gate (audited), a different HEAD (a new push)
8
+ // re-opens it, and an unreadable HEAD keeps the block (fail closed).
9
+ import { test } from "node:test";
10
+ import { assert, assertEquals } from "#test-assert";
11
+ import { noopLog } from "../../test/log.ts";
12
+ import { makeHandler } from "./worker.ts";
13
+
14
+ // A PR body that trips the scope-integrity guard: it defers scope (`## Scope`) yet closes a
15
+ // broader-scoped parent (`Closes #631`) and links no filed follow-up.
16
+ const SCOPE_BLOCKING_BODY =
17
+ "Delivers the first half.\n\n## Scope\nThe embedded tools remain the deferred refinement.\n\nCloses #631";
18
+
19
+ // biome-ignore lint/suspicious/noExplicitAny: tiny in-memory app double, mirrors persist-escalation.test
20
+ function fakeApp(escalations: Record<string, unknown>[]): any {
21
+ const stores: Record<string, Record<string, unknown>[]> = { escalations };
22
+ return {
23
+ stores,
24
+ data: {
25
+ table(name: string, key: string) {
26
+ const store = (stores[name] ??= []);
27
+ return {
28
+ // biome-ignore lint/suspicious/noExplicitAny: test double
29
+ find: (q: any) => Promise.resolve(store.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
30
+ };
31
+ },
32
+ },
33
+ log: noopLog(),
34
+ };
35
+ }
36
+
37
+ function deps(overrides: {
38
+ headSha?: string | null;
39
+ prBody?: string;
40
+ headThrows?: boolean;
41
+ }) {
42
+ const headSha = "headSha" in overrides ? (overrides.headSha ?? null) : "HEAD1";
43
+ return {
44
+ readThreads: () => Promise.resolve([]),
45
+ readReviewBody: () => Promise.resolve(""),
46
+ readPrBody: () => Promise.resolve(overrides.prBody ?? SCOPE_BLOCKING_BODY),
47
+ readHeadSha: () => (overrides.headThrows ? Promise.reject(new Error("gh down")) : Promise.resolve(headSha)),
48
+ };
49
+ }
50
+
51
+ const job = { variables: { prKey: "o/r#5", repo: "o/r", prNumber: 5 } } as never;
52
+
53
+ test("scope blocks with no answered escalation → blocked, and surfaces the reviewed HEAD to bind the escalation", async () => {
54
+ const app = fakeApp([]);
55
+ const out = (await makeHandler(deps({ headSha: "HEAD1" }))(job, app)) as Record<string, unknown>;
56
+ assertEquals(out.convergeBlocked, true);
57
+ assertEquals(out.scopeBlocked, true);
58
+ assertEquals(out.headSha, "HEAD1", "the reviewed HEAD is returned so persist-escalation can bind it");
59
+ });
60
+
61
+ test("scope blocks but a human answered the escalation for the SAME HEAD → override honoured (loop broken)", async () => {
62
+ const app = fakeApp([
63
+ { id: 7, pr_key: "o/r#5", status: "answered", scope_block: 1, head_sha: "HEAD1", answer: "Full delivery — keep Closes." },
64
+ ]);
65
+ const out = (await makeHandler(deps({ headSha: "HEAD1" }))(job, app)) as Record<string, unknown>;
66
+ assertEquals(out.convergeBlocked, false, "the same-HEAD human answer satisfies the scope gate");
67
+ assertEquals(out.convergeBlockReason, "");
68
+ // A cleared scope block routes to finalize, so the block-only binding fields are not emitted.
69
+ assertEquals(out.scopeBlocked, undefined);
70
+ });
71
+
72
+ test("scope blocks and the answer was for a DIFFERENT HEAD (a new push) → still blocked", async () => {
73
+ const app = fakeApp([
74
+ { id: 7, pr_key: "o/r#5", status: "answered", scope_block: 1, head_sha: "OLDHEAD", answer: "Full delivery." },
75
+ ]);
76
+ const out = (await makeHandler(deps({ headSha: "HEAD1" }))(job, app)) as Record<string, unknown>;
77
+ assertEquals(out.convergeBlocked, true, "a stale override never carries across a new push");
78
+ assertEquals(out.scopeBlocked, true);
79
+ });
80
+
81
+ test("scope blocks and an answered NON-scope escalation sits at the same HEAD → not an override", async () => {
82
+ const app = fakeApp([
83
+ { id: 7, pr_key: "o/r#5", status: "answered", scope_block: 0, head_sha: "HEAD1", answer: "unrelated" },
84
+ ]);
85
+ const out = (await makeHandler(deps({ headSha: "HEAD1" }))(job, app)) as Record<string, unknown>;
86
+ assertEquals(out.convergeBlocked, true, "only a scope-integrity escalation opens the scope override door");
87
+ });
88
+
89
+ test("scope blocks but the reviewed HEAD is unreadable → keep the block (fail closed)", async () => {
90
+ const app = fakeApp([
91
+ { id: 7, pr_key: "o/r#5", status: "answered", scope_block: 1, head_sha: "HEAD1", answer: "override" },
92
+ ]);
93
+ const nullHead = (await makeHandler(deps({ headSha: null }))(job, app)) as Record<string, unknown>;
94
+ assertEquals(nullHead.convergeBlocked, true, "cannot verify an override against an unknown HEAD");
95
+ const throwHead = (await makeHandler(deps({ headThrows: true }))(job, app)) as Record<string, unknown>;
96
+ assertEquals(throwHead.convergeBlocked, true, "a HEAD read error keeps the block");
97
+ });
98
+
99
+ test("scope passes → not blocked, and no override lookup is needed", async () => {
100
+ const app = fakeApp([]);
101
+ const out = (await makeHandler(deps({ prBody: "Implements the whole thing.\n\nCloses #631" }))(job, app)) as Record<
102
+ string,
103
+ unknown
104
+ >;
105
+ assertEquals(out.convergeBlocked, false);
106
+ assertEquals(out.scopeBlocked, undefined);
107
+ });
108
+
109
+ test("newest answered scope escalation wins when a re-escalation was answered again at the same HEAD", async () => {
110
+ const app = fakeApp([
111
+ { id: 7, pr_key: "o/r#5", status: "answered", scope_block: 1, head_sha: "HEAD1", answer: "first" },
112
+ { id: 9, pr_key: "o/r#5", status: "answered", scope_block: 1, head_sha: "HEAD1", answer: "latest" },
113
+ ]);
114
+ const out = (await makeHandler(deps({ headSha: "HEAD1" }))(job, app)) as Record<string, unknown>;
115
+ assertEquals(out.convergeBlocked, false, "a re-answered override at the unchanged HEAD is honoured");
116
+ });
@@ -19,6 +19,15 @@
19
19
  // This is the enforcement backstop for the Magikcraft/nano-bpm#631 → PR #863 (`Closes #631`, `##
20
20
  // Scope` deferral, no follow-up → re-filed by hand as #872) failure class. See app/scopeGuard.ts.
21
21
  //
22
+ // The scope-integrity block also carries a HUMAN-OVERRIDE door (#395): before it re-blocks, it
23
+ // reads the commit now under review and consults the `escalations` answer bound to that SAME HEAD.
24
+ // An operator who answered the scope question for this exact commit ("this fully delivers the issue
25
+ // — keep the closing keyword") has explicitly overridden it, so the gate honours that answer
26
+ // (audited) instead of re-deriving `scopeBlocked` from the PR body and re-escalating the identical
27
+ // question forever. Binding to the HEAD sha keeps the override from carrying across a new push, and
28
+ // (via `PrConvergeGateOut.headSha`/`scopeBlocked`) lets `persist-escalation-blockedcomments` stamp
29
+ // the escalation with the reviewed commit so the door can open on the next round.
30
+ //
22
31
  // It FAILS CLOSED: if the live GitHub state cannot be read, it blocks (escalates) rather than
23
32
  // letting an unverifiable "converged" through — the opposite of the no-progress guard, because a
24
33
  // merge-gating check must escalate-on-uncertainty so #770 cannot recur.
@@ -26,13 +35,14 @@ import type { AppJobHandler } from "@nanobpm/urban";
26
35
  import { type ConvergeGateResult, evaluateConvergeGate } from "../../app/convergeGate.ts";
27
36
  import {
28
37
  fetchLatestCopilotReviewBody,
38
+ fetchPrHead,
29
39
  fetchPrMeta,
30
40
  fetchReviewThreads,
31
41
  parseAckedAdvisories,
32
42
  parseSuppressedAdvisories,
33
43
  type ReviewThread,
34
44
  } from "../../app/github.ts";
35
- import { evaluateScopeGuard } from "../../app/scopeGuard.ts";
45
+ import { evaluateScopeGuard, isScopeOverridden, type ScopeEscalationAnswer } from "../../app/scopeGuard.ts";
36
46
  import { parsePr } from "../../app/service.ts";
37
47
  import type { WorkerInputs, WorkerOutputs } from "../../nano-generated/worker-io.d.ts";
38
48
 
@@ -50,6 +60,9 @@ export type ReviewBodyReader = (repo: string, prNumber: number) => Promise<strin
50
60
  // Reads the PR's own description body. `null` = no usable transport (unverifiable → fail closed);
51
61
  // `""` = transport usable but the PR has an empty description (verified: nothing to scope-check).
52
62
  export type PrBodyReader = (repo: string, prNumber: number) => Promise<string | null>;
63
+ // Reads the PR's current HEAD sha (the commit under review). `null` = unreadable/no transport — the
64
+ // scope override cannot be verified or bound to a commit, so the gate keeps blocking (fail closed).
65
+ export type HeadShaReader = (repo: string, prNumber: number) => Promise<string | null>;
53
66
 
54
67
  const defaultReadThreads: ThreadsReader = (repo, prNumber) =>
55
68
  fetchReviewThreads(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
@@ -59,6 +72,10 @@ const defaultReadPrBody: PrBodyReader = async (repo, prNumber) => {
59
72
  const meta = await fetchPrMeta(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
60
73
  return meta ? meta.body : null;
61
74
  };
75
+ const defaultReadHeadSha: HeadShaReader = async (repo, prNumber) => {
76
+ const head = await fetchPrHead(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
77
+ return head ? head.headSha : null;
78
+ };
62
79
 
63
80
  const BLOCK_UNVERIFIABLE =
64
81
  "Convergence blocked: could not verify the PR's review comments against GitHub. A human must confirm every Copilot review thread is resolved and every suppressed advisory acknowledged before this PR converges (reply to resume the loop).";
@@ -66,14 +83,56 @@ const BLOCK_UNVERIFIABLE =
66
83
  const BLOCK_UNVERIFIABLE_BODY =
67
84
  "Convergence blocked: could not read the PR description from GitHub to verify scope integrity. A human must confirm this PR does not close a broader-scoped parent with an untracked deferred remainder before it converges (reply to resume the loop).";
68
85
 
86
+ // An `escalations` row as this worker reads it back when looking for a recorded human override.
87
+ interface EscalationRow extends Record<string, unknown> {
88
+ id: number;
89
+ head_sha: string | null;
90
+ answer: string | null;
91
+ scope_block: number | boolean | null;
92
+ }
93
+
94
+ // Find the newest ANSWERED scope-integrity escalation for this PR whose recorded HEAD matches the
95
+ // commit now under review (issue #395). This is the human-override door: `persist-escalation` binds
96
+ // a scope block to the HEAD it was raised against, `answer-escalation` marks the row `answered`, and
97
+ // here we honour that answer for the SAME HEAD so the gate stops re-deriving `scopeBlocked` from the
98
+ // PR body and re-escalating the identical question forever. Newest-first so a re-escalated-then-
99
+ // answered duplicate resolves to the operator's latest reply. Returns `null` on any read failure —
100
+ // the caller then keeps the block (fail closed), never fabricates an override.
101
+ async function findScopeOverride(
102
+ app: Parameters<AppJobHandler<In, Out>>[1],
103
+ prKey: string,
104
+ headSha: string,
105
+ ): Promise<ScopeEscalationAnswer | null> {
106
+ try {
107
+ // `scope_block` is a first-class column (persist-escalation writes it as 0/1), so filter on it
108
+ // in the query rather than reading every answered escalation and filtering in memory — a PR with
109
+ // many answered non-scope escalations no longer loads them all just to discard them.
110
+ const rows = await app.data.table<EscalationRow>("escalations", "id").find({
111
+ pr_key: prKey,
112
+ status: "answered",
113
+ scope_block: 1,
114
+ });
115
+ const scoped = rows
116
+ .map((r) => ({ escalationId: Number(r.id), headSha: r.head_sha ?? null, answer: r.answer ?? null }))
117
+ .sort((a, b) => (b.escalationId ?? 0) - (a.escalationId ?? 0));
118
+ for (const candidate of scoped) {
119
+ if (isScopeOverridden(headSha, candidate)) return candidate;
120
+ }
121
+ return null;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
69
127
  /** Build the handler with injectable GitHub readers. The default export binds the real readers;
70
128
  * tests inject stubs. Fails CLOSED — any unreadable/errored state blocks convergence. */
71
129
  export function makeHandler(deps: {
72
130
  readThreads: ThreadsReader;
73
131
  readReviewBody: ReviewBodyReader;
74
132
  readPrBody: PrBodyReader;
133
+ readHeadSha: HeadShaReader;
75
134
  }): AppJobHandler<In, Out> {
76
- return async (job) => {
135
+ return async (job, app) => {
77
136
  const { prKey, repo, prNumber } = job.variables;
78
137
  // `parsePr` is total on any input (fails closed to `null` on a missing/non-string prKey), so
79
138
  // pass it straight through — a malformed prKey degrades to the fail-closed target check below.
@@ -83,6 +142,9 @@ export function makeHandler(deps: {
83
142
  if (!ghRepo || typeof ghNumber !== "number") {
84
143
  return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE };
85
144
  }
145
+ // The canonical escalations key. Prefer the carried prKey; fall back to the parsed identity so
146
+ // the override lookup still keys off `owner/repo#N` when only repo/prNumber survived.
147
+ const escPrKey = typeof prKey === "string" && prKey !== "" ? prKey : `${ghRepo}#${ghNumber}`;
86
148
 
87
149
  let result: ConvergeGateResult;
88
150
  let scopeReason: string;
@@ -127,13 +189,58 @@ export function makeHandler(deps: {
127
189
  return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE_BODY };
128
190
  }
129
191
 
192
+ // The human-override door for the scope-integrity block (issue #395). When the deterministic
193
+ // scope guard would re-block, read the commit now under review and consult the recorded
194
+ // escalation answer bound to that SAME HEAD: an operator who answered the scope question for
195
+ // this exact commit has explicitly overridden it ("this fully delivers the issue — keep the
196
+ // closing keyword"), so honour it (audited) instead of re-deriving the block from the body and
197
+ // re-escalating forever. Binding to the HEAD sha keeps the override from carrying across a new
198
+ // push (a different HEAD legitimately re-opens the gate); and if the human instead asked for a
199
+ // real split, the servicing agent pushes a fix — moving the HEAD so this stale override never
200
+ // fires. This is what turns the infinite escalation loop into a resolvable one.
201
+ let headSha: string | null = null;
202
+ let scopeBlocked = scopeReason !== "";
203
+ if (scopeBlocked) {
204
+ try {
205
+ headSha = await deps.readHeadSha(ghRepo, ghNumber);
206
+ } catch {
207
+ headSha = null;
208
+ }
209
+ if (headSha) {
210
+ const override = await findScopeOverride(app, escPrKey, headSha);
211
+ if (override) {
212
+ app.log.info("converge-gate: scope-integrity block overridden by human answer", {
213
+ prKey: escPrKey,
214
+ headSha,
215
+ escalationId: override.escalationId ?? null,
216
+ // The human answer is free-form operator input — never log it verbatim (it can carry
217
+ // sensitive content into application logs). Record only stable identifiers plus a
218
+ // minimal presence/length signal for debugging.
219
+ hasAnswer: override.answer != null && override.answer !== "",
220
+ answerLength: override.answer?.length ?? 0,
221
+ });
222
+ scopeReason = "";
223
+ scopeBlocked = false;
224
+ }
225
+ }
226
+ }
227
+
130
228
  // Both guards gate the same handoff to the merge loop: block if EITHER the review-comment gate
131
229
  // or the scope-integrity gate blocks, joining their reasons so the human sees every cause.
132
230
  const reason = [result.convergeBlockReason, scopeReason].filter((r) => r !== "").join(" ");
133
- return {
134
- convergeBlocked: result.convergeBlocked || scopeReason !== "",
231
+ const out: Out = {
232
+ convergeBlocked: result.convergeBlocked || scopeBlocked,
135
233
  convergeBlockReason: reason,
136
234
  };
235
+ // Surface the reviewed HEAD and the scope-block flag ONLY when scope actually blocks — the
236
+ // `persist-escalation-blockedcomments` arm (which runs only on a blocked gate) binds the
237
+ // escalation to this commit with them, opening the override door on the next round. A clean
238
+ // converge keeps its original `{ convergeBlocked, convergeBlockReason }` shape.
239
+ if (scopeBlocked) {
240
+ out.scopeBlocked = true;
241
+ out.headSha = headSha ?? undefined;
242
+ }
243
+ return out;
137
244
  };
138
245
  }
139
246
 
@@ -141,5 +248,6 @@ const handler = makeHandler({
141
248
  readThreads: defaultReadThreads,
142
249
  readReviewBody: defaultReadReviewBody,
143
250
  readPrBody: defaultReadPrBody,
251
+ readHeadSha: defaultReadHeadSha,
144
252
  });
145
253
  export default handler;
@@ -0,0 +1,44 @@
1
+ // Unit coverage for the pr.delivery-connector worker's input validation (workers/delivery-connector/worker.ts).
2
+ // Job variables are UNTYPED at runtime, so the worker must not forward a misconfigured node's garbage
3
+ // into the (side-effecting) connector I/O surface: a blank `target` fails CLOSED, and a wrong-shaped
4
+ // `payload`/`boundFacts` is coerced to null with a surfaced warning rather than passed through.
5
+ import { test } from "node:test";
6
+ import { assert, assertEquals, assertThrows } from "#test-assert";
7
+ import { readConnectorInput } from "./worker.ts";
8
+
9
+ test("readConnectorInput: a blank/missing target fails closed (a connector with no destination is meaningless)", () => {
10
+ for (const target of [undefined, "", " "]) {
11
+ assertThrows(() => readConnectorInput({ target }), Error, "target");
12
+ }
13
+ });
14
+
15
+ test("readConnectorInput: a valid target is trimmed and passed through; well-shaped payload/boundFacts survive", () => {
16
+ const facts = [{ from: "n1", name: "mergedSha", value: "abc" }];
17
+ const r = readConnectorInput({ target: " slack ", payload: { channel: "#rel" }, boundFacts: facts });
18
+ assertEquals(r.target, "slack");
19
+ assertEquals(r.payload, { channel: "#rel" });
20
+ assertEquals(r.boundFacts, facts);
21
+ assertEquals(r.warnings.length, 0);
22
+ });
23
+
24
+ test("readConnectorInput: absent payload/boundFacts default to null with no warning", () => {
25
+ const r = readConnectorInput({ target: "slack" });
26
+ assertEquals(r.payload, null);
27
+ assertEquals(r.boundFacts, null);
28
+ assertEquals(r.warnings.length, 0);
29
+ });
30
+
31
+ test("readConnectorInput: a non-object payload / non-array boundFacts is coerced to null and warned (never forwarded)", () => {
32
+ const r = readConnectorInput({ target: "slack", payload: "oops" as unknown as Record<string, unknown>, boundFacts: "nope" as unknown as [] });
33
+ assertEquals(r.payload, null, "a scalar payload never reaches the connector surface");
34
+ assertEquals(r.boundFacts, null, "a non-array boundFacts never reaches the connector surface");
35
+ assertEquals(r.warnings.length, 2, "both coercions are surfaced for logging (not silent)");
36
+ assert(r.warnings.some((w) => w.includes("payload")), "the payload coercion is named");
37
+ assert(r.warnings.some((w) => w.includes("boundFacts")), "the boundFacts coercion is named");
38
+ });
39
+
40
+ test("readConnectorInput: an array payload is rejected (arrays are not plain objects)", () => {
41
+ const r = readConnectorInput({ target: "slack", payload: [1, 2, 3] as unknown as Record<string, unknown> });
42
+ assertEquals(r.payload, null);
43
+ assertEquals(r.warnings.length, 1);
44
+ });