@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,111 @@
1
+ // Unit coverage for the delivery-graph RUNNER's PURE prepare step (ADR 0005 slice S4). `prepareDeliveryGraph`
2
+ // compiles a graph, content-addresses its deploy id, rewrites the base process id, and builds the
3
+ // `nodeInputs` seed — all without touching the engine. These tests pin, directly:
4
+ // • the content-addressed id (`delivery-graph-<sha12>`), and that it is DETERMINISTIC (same graph → same
5
+ // id) but CONTENT-SENSITIVE (a different graph → a different id) — the property that makes redeploy
6
+ // idempotent and stale definitions GC-identifiable (the ADR definition-lifecycle open question),
7
+ // • the base process id is rewritten to the content-addressed id in the deployable BPMN,
8
+ // • each node kind seeds the exact `nodeInputs` fields its compiled subProcess ioMapping reads,
9
+ // • a malformed graph returns the S1 compile errors and prepares nothing.
10
+ // The engine-native EXECUTION of a prepared graph (deploy + run + gate + fan-in + late-bind + dedupe) is
11
+ // proven end-to-end in `e2e/delivery-graph.e2e.ts`.
12
+ import { test } from "node:test";
13
+ import { assert, assertEquals } from "#test-assert";
14
+ import { prepareDeliveryGraph, runDeliveryGraph } from "./deliveryRunner.ts";
15
+ import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
16
+
17
+ const GRAPH: DeliveryGraph = {
18
+ name: "release runbook",
19
+ nodes: [
20
+ { id: "open-b", kind: "agent", agent: { jobType: "senior:feature", prompt: "un-draft + merge #B" } },
21
+ { id: "watch-b", kind: "wait", wait: { kind: "pr", target: "owner/repo#42", match: { prState: "merged" } }, emits: [{ name: "mergedSha", type: "string" }] },
22
+ { id: "publish", kind: "human", human: { prompt: "run the manual OTP publish" }, emits: [{ name: "resolvedArtifact", type: "artifact" }] },
23
+ { id: "consume", kind: "connector", connector: { target: "npm:install", dedupeKey: "consume-1" } },
24
+ ],
25
+ edges: [
26
+ { from: "open-b", to: "watch-b" },
27
+ { from: "watch-b.mergedSha", to: "publish" },
28
+ { from: "publish.resolvedArtifact", to: "consume" },
29
+ ],
30
+ };
31
+
32
+ function prepareOk(graph: DeliveryGraph, options = {}) {
33
+ const r = prepareDeliveryGraph(graph, options);
34
+ assert(r.ok, `expected ok:true, got ${JSON.stringify(r)}`);
35
+ return r.prepared;
36
+ }
37
+
38
+ test("content-addressed id: deterministic for the same graph, content-sensitive across graphs", () => {
39
+ const a = prepareOk(GRAPH);
40
+ const b = prepareOk(GRAPH);
41
+ assert(/^delivery-graph-[0-9a-f]{12}$/.test(a.processDefinitionId), `id is content-addressed, got ${a.processDefinitionId}`);
42
+ assertEquals(a.processDefinitionId, b.processDefinitionId);
43
+
44
+ // A structurally different graph gets a DIFFERENT id (no collision / no accidental redeploy-as-same).
45
+ const other = prepareOk({ ...GRAPH, nodes: [...GRAPH.nodes, { id: "extra", kind: "agent", agent: { jobType: "senior:feature" } }], edges: [...GRAPH.edges, { from: "consume", to: "extra" }] });
46
+ assert(other.processDefinitionId !== a.processDefinitionId, "a different graph yields a different id");
47
+ });
48
+
49
+ test("the deployable BPMN rewrites the base process id to the content-addressed deploy id", () => {
50
+ const p = prepareOk(GRAPH);
51
+ assert(p.bpmn.includes(`<bpmn:process id="${p.processDefinitionId}"`), "process id is the content-addressed id");
52
+ assert(!p.bpmn.includes('<bpmn:process id="delivery-graph"'), "the base id no longer appears as the process id");
53
+ });
54
+
55
+ test("nodeInputs seeds the exact per-kind fields each node's subProcess ioMapping reads", () => {
56
+ const p = prepareOk(GRAPH, { nodeTimeout: "PT10M", probeTimeout: "PT20M", escalationSlaTimeout: "PT2H", escalationAssignee: "alice", runKey: "run-7" });
57
+ // Element ids are positional by sorted node id: consume, open-b, publish, watch-b → n0..n3.
58
+ const inputs = p.nodeInputs;
59
+ const byField = (pred: (v: Record<string, unknown>) => boolean) => Object.values(inputs).find((v) => pred(v as Record<string, unknown>)) as Record<string, unknown> | undefined;
60
+
61
+ const agent = byField((v) => v.jobType === "senior:feature");
62
+ assertEquals(agent, { jobType: "senior:feature", appendPrompt: "un-draft + merge #B", timeout: "PT10M" });
63
+
64
+ const wait = byField((v) => "gateKey" in v);
65
+ assertEquals(wait?.gateKey, "run-7:n3");
66
+ assertEquals(wait?.probeTimeout, "PT20M");
67
+ assert(wait?.probe && typeof wait.probe === "object", "the wait node carries its ReadinessProbe descriptor");
68
+
69
+ const human = byField((v) => "escalationSlaTimeout" in v);
70
+ assertEquals(human, { escalationSlaTimeout: "PT2H", escalationAssignee: "alice" });
71
+
72
+ const connector = byField((v) => v.target === "npm:install");
73
+ assertEquals(connector, { target: "npm:install", dedupeKey: "consume-1", payload: null, timeout: "PT10M" });
74
+ });
75
+
76
+ test("wait gateKeys default to a fresh per-run token so concurrent runs of one graph never cross-correlate", () => {
77
+ const gateKeyOf = (p: ReturnType<typeof prepareOk>) =>
78
+ (Object.values(p.nodeInputs).find((v) => "gateKey" in v) as { gateKey?: string } | undefined)?.gateKey;
79
+
80
+ const a = prepareOk(GRAPH);
81
+ const b = prepareOk(GRAPH);
82
+ assert(gateKeyOf(a) && gateKeyOf(b), "each run seeds a wait gateKey");
83
+ assert(gateKeyOf(a) !== gateKeyOf(b), "two runs of the same graph get DISTINCT default gate scopes");
84
+ // The gate key must NOT be derived from the (shared) content digest — that is the bug this guards.
85
+ assert(!gateKeyOf(a)?.startsWith(a.processDefinitionId.slice(-12)), "default gateKey is not the graph digest");
86
+ // The deployable definition (id + bpmn) stays deterministic regardless of the per-run gate scope.
87
+ assertEquals(a.processDefinitionId, b.processDefinitionId);
88
+ assertEquals(a.bpmn, b.bpmn);
89
+
90
+ // An explicit runKey is honoured verbatim (reproducible seed).
91
+ const seeded = prepareOk(GRAPH, { runKey: "run-7" });
92
+ assertEquals(gateKeyOf(seeded), "run-7:n3");
93
+ });
94
+
95
+ test("a malformed graph returns the S1 compile errors and prepares nothing", () => {
96
+ const r = prepareDeliveryGraph({ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }], edges: [{ from: "a", to: "ghost" }] } as unknown as DeliveryGraph);
97
+ assert(!r.ok, "a dangling edge fails to prepare");
98
+ assert(r.errors.some((e) => e.path === "edges[0].to"), `expected a dangling-edge error, got ${JSON.stringify(r.errors)}`);
99
+ });
100
+
101
+ test("runDeliveryGraph coerces a numeric engine processInstanceKey to a string handle", async () => {
102
+ // The engine can yield a NUMERIC key; the handle is typed `string` and downstream expects a string.
103
+ const engine = {
104
+ deployResources: async () => [],
105
+ createInstance: async () => ({ processInstanceKey: 987654321 as unknown as string }),
106
+ };
107
+ const r = await runDeliveryGraph(engine, GRAPH);
108
+ assert(r.ok, `expected ok:true, got ${JSON.stringify(r)}`);
109
+ assertEquals(r.handle.processInstanceKey, "987654321");
110
+ assertEquals(typeof r.handle.processInstanceKey, "string");
111
+ });
@@ -0,0 +1,169 @@
1
+ // nano-workforce — the delivery-graph RUNNER (ADR 0005 slice S4). The integration step that turns the
2
+ // PURE compiled preview (S1's `compileDeliveryGraph`) into a RUNNING, engine-native process: it deploys
3
+ // the compile-to-native one-shot definition and starts an instance, seeding each node's config so the
4
+ // inlined subProcess bodies (agent/wait/human/connector) delegate to their existing worker / user-task
5
+ // bodies. It builds NO execution machinery of its own (Decision 2 — the graph SCHEDULES; the engine
6
+ // runs it); its whole job is deploy + seed + start.
7
+ //
8
+ // Definition lifecycle (the ADR open question, resolved here): the deployed process id is
9
+ // CONTENT-ADDRESSED — `delivery-graph-<sha256(bpmn)[:12]>`. Identical graphs compile byte-identically
10
+ // (S1 determinism) → identical id → an idempotent redeploy (the engine versions the same id, never a
11
+ // duplicate definition per run); different graphs get different ids and never collide; and because the
12
+ // id ENCODES its content, a stale one-shot definition is GC-identifiable by a later sweeper (out of
13
+ // scope to implement the sweeper — the naming is what enables it). The base id the compiler emits
14
+ // (`DELIVERY_GRAPH_PROCESS_ID`) is the single substitution target, so the runner never hardcodes it.
15
+
16
+ import { createHash, randomUUID } from "node:crypto";
17
+ import type { EngineClient } from "@nanobpm/urban";
18
+ import type { DeliveryGraph, DeliveryNode } from "../nano-generated/api-io.d.ts";
19
+ import { assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
20
+
21
+ /** The bounded-timeout / SLA envelope every node inherits (Decision: bounded → escalate). ISO-8601
22
+ * durations. Defaults are conservative; a caller (the S5 door) may tighten them per run. */
23
+ export interface DeliveryRunTimeouts {
24
+ /** `agent`/`connector` service-node bounded timeout before it escalates onto a human-completable task. */
25
+ nodeTimeout?: string;
26
+ /** `wait` gate poll budget before it escalates (the engine bound; the probe itself is read-only). */
27
+ probeTimeout?: string;
28
+ /** `human` node SLA before it records an `escalated` outcome and settles. */
29
+ escalationSlaTimeout?: string;
30
+ /** Optional explicit assignee for `human` nodes + escalation tasks (else candidate-group routed). */
31
+ escalationAssignee?: string | null;
32
+ }
33
+
34
+ export interface DeliveryRunOptions extends DeliveryRunTimeouts {
35
+ /** A per-run token that scopes each `wait` node's gate key (`<runKey>:<element>`) so two concurrent
36
+ * runs of the same graph never share a gate correlation. Defaults to a fresh random per-run token
37
+ * (`randomUUID()`) — NOT the graph digest, which every run of an identical graph would share and so
38
+ * cross-correlate. Pass an explicit `runKey` only when you need a reproducible/externally-owned gate
39
+ * scope. */
40
+ runKey?: string;
41
+ }
42
+
43
+ const DEFAULTS: Required<Omit<DeliveryRunTimeouts, "escalationAssignee">> = {
44
+ nodeTimeout: "PT30M",
45
+ probeTimeout: "PT30M",
46
+ escalationSlaTimeout: "P1D",
47
+ };
48
+
49
+ /** The per-node config the compiled subProcess ioMappings read from `nodeInputs.<element>`. A closed
50
+ * union mirrored by the compiler's `ioMappingLines` — the two must agree on field names (a drift here
51
+ * silently seeds `null` into a node body), so both derive from the same node kinds. */
52
+ type NodeInput =
53
+ | { jobType: string; appendPrompt: string; timeout: string }
54
+ | { gateKey: string; probe: unknown; probeTimeout: string }
55
+ | { escalationSlaTimeout: string; escalationAssignee: string | null }
56
+ | { target: string; dedupeKey: string | null; payload: Record<string, unknown> | null; timeout: string };
57
+
58
+ /** The result of compiling + preparing a graph for deployment: the content-addressed process id, the
59
+ * deployable BPMN (base id rewritten), and the seeded `nodeInputs` map — everything `runDeliveryGraph`
60
+ * needs, exposed separately so a caller can deploy/inspect without starting an instance. */
61
+ export interface PreparedDeliveryGraph {
62
+ processDefinitionId: string;
63
+ bpmn: string;
64
+ nodeInputs: Record<string, NodeInput>;
65
+ }
66
+
67
+ export type PrepareDeliveryResult =
68
+ | { ok: true; prepared: PreparedDeliveryGraph }
69
+ | { ok: false; errors: { path: string; message: string }[] };
70
+
71
+ /** A live delivery-graph run: the deployed definition + the started instance + the seed it ran with. */
72
+ export interface DeliveryRunHandle extends PreparedDeliveryGraph {
73
+ processInstanceKey: string;
74
+ }
75
+
76
+ export type RunDeliveryResult =
77
+ | { ok: true; handle: DeliveryRunHandle }
78
+ | { ok: false; errors: { path: string; message: string }[] };
79
+
80
+ /** Compile a graph and prepare it for deployment WITHOUT touching the engine: content-address its id,
81
+ * rewrite the base process id, and build the `nodeInputs` seed. The deployable DEFINITION (the
82
+ * content-addressed `processDefinitionId` and the `bpmn`) is deterministic — the same graph yields the
83
+ * same id, which is what makes redeploy idempotent — because the gate scope lives in `nodeInputs`
84
+ * (runtime instance variables), not in the BPMN. The default `runKey` is a fresh random per-run token,
85
+ * so each call's `wait` `gateKey`s differ (two concurrent runs of the same graph never cross-correlate);
86
+ * pass an explicit `runKey` for a reproducible seed. Returns the S1 compile errors verbatim for a
87
+ * malformed graph. */
88
+ export function prepareDeliveryGraph(graph: DeliveryGraph, options: DeliveryRunOptions = {}): PrepareDeliveryResult {
89
+ const compiled = compileDeliveryGraph(graph);
90
+ if (!compiled.ok) return { ok: false, errors: compiled.errors };
91
+
92
+ const digest = createHash("sha256").update(compiled.bpmn).digest("hex").slice(0, 12);
93
+ const processDefinitionId = `${DELIVERY_GRAPH_PROCESS_ID}-${digest}`;
94
+ const bpmn = rewriteProcessId(compiled.bpmn, processDefinitionId);
95
+
96
+ const runKey = options.runKey?.trim() || randomUUID();
97
+ const timeouts = {
98
+ nodeTimeout: options.nodeTimeout ?? DEFAULTS.nodeTimeout,
99
+ probeTimeout: options.probeTimeout ?? DEFAULTS.probeTimeout,
100
+ escalationSlaTimeout: options.escalationSlaTimeout ?? DEFAULTS.escalationSlaTimeout,
101
+ escalationAssignee: options.escalationAssignee ?? null,
102
+ };
103
+ const elementByNodeId = new Map(compiled.resolved.nodes.map((n) => [n.id, n.element]));
104
+ const nodeInputs: Record<string, NodeInput> = {};
105
+ for (const node of graph.nodes) {
106
+ const element = elementByNodeId.get(node.id);
107
+ if (element === undefined) continue; // unreachable — resolved covers every node — but keep total.
108
+ nodeInputs[element] = buildNodeInput(node, { runKey, element, ...timeouts });
109
+ }
110
+ return { ok: true, prepared: { processDefinitionId, bpmn, nodeInputs } };
111
+ }
112
+
113
+ /** Deploy + start a compiled graph as a running engine-native instance. Idempotent at the DEFINITION
114
+ * level (content-addressed id — redeploying the same graph re-uses the definition); each call still
115
+ * starts a fresh INSTANCE (a distinct run of that definition). Returns the run handle, or the compile
116
+ * errors for a malformed graph (the engine is never touched in that case). */
117
+ export async function runDeliveryGraph(
118
+ engine: Pick<EngineClient, "deployResources" | "createInstance">,
119
+ graph: DeliveryGraph,
120
+ options: DeliveryRunOptions = {},
121
+ ): Promise<RunDeliveryResult> {
122
+ const prep = prepareDeliveryGraph(graph, options);
123
+ if (!prep.ok) return prep;
124
+ const { processDefinitionId, bpmn, nodeInputs } = prep.prepared;
125
+
126
+ await engine.deployResources([{ name: `${processDefinitionId}.bpmn`, content: bpmn, contentType: "application/xml" }]);
127
+ const { processInstanceKey } = await engine.createInstance({
128
+ processDefinitionId,
129
+ variables: { nodeInputs },
130
+ });
131
+ // The engine can yield a numeric key; `DeliveryRunHandle.processInstanceKey` is typed `string` and
132
+ // downstream consumers expect a string — coerce (codebase-wide `String(...)` pattern, e.g. app/plan.ts).
133
+ return {
134
+ ok: true,
135
+ handle: { processDefinitionId, bpmn, nodeInputs, processInstanceKey: String(processInstanceKey) },
136
+ };
137
+ }
138
+
139
+ /** Rewrite the compiled BPMN's base `bpmn:process` id to the content-addressed deploy id. The base id
140
+ * appears exactly once — as the process element's `id` attribute (element ids are `n<i>`/`gw*`/`Start`/
141
+ * `End`, never the process id) — so a single targeted replacement is unambiguous. */
142
+ function rewriteProcessId(bpmn: string, processDefinitionId: string): string {
143
+ return bpmn.replace(`id="${DELIVERY_GRAPH_PROCESS_ID}"`, `id="${processDefinitionId}"`);
144
+ }
145
+
146
+ /** Build the `nodeInputs.<element>` seed for one node, per its kind — the exact fields the compiled
147
+ * subProcess ioMapping pulls. Total over the closed kind set. */
148
+ function buildNodeInput(
149
+ node: DeliveryNode,
150
+ ctx: { runKey: string; element: string; nodeTimeout: string; probeTimeout: string; escalationSlaTimeout: string; escalationAssignee: string | null },
151
+ ): NodeInput {
152
+ switch (node.kind) {
153
+ case "agent":
154
+ return { jobType: node.agent.jobType, appendPrompt: node.agent.prompt ?? "", timeout: ctx.nodeTimeout };
155
+ case "wait":
156
+ return { gateKey: `${ctx.runKey}:${ctx.element}`, probe: node.wait, probeTimeout: ctx.probeTimeout };
157
+ case "human":
158
+ return { escalationSlaTimeout: ctx.escalationSlaTimeout, escalationAssignee: ctx.escalationAssignee };
159
+ case "connector":
160
+ return {
161
+ target: node.connector.target,
162
+ dedupeKey: node.connector.dedupeKey ?? null,
163
+ payload: node.connector.payload ?? null,
164
+ timeout: ctx.nodeTimeout,
165
+ };
166
+ default:
167
+ return assertNever(node, "buildNodeInput");
168
+ }
169
+ }
@@ -212,3 +212,36 @@ test("a control-flow arm with a blank question opens nothing so gw-escalated re-
212
212
  assertEquals(inserts.escalations.length, 0, "no dead escalation is fabricated");
213
213
  assertEquals(updates.pull_requests?.length ?? 0, 0, "the PR is never flipped to escalated");
214
214
  });
215
+
216
+ // The scope-integrity arm (persist-escalation-blockedcomments) binds the escalation to the reviewed
217
+ // commit (issue #395): it stamps `head_sha` and marks `scope_block` so the converge-gate can honour
218
+ // a same-HEAD human answer as an override instead of re-deriving the block and re-escalating forever.
219
+ test("persist-escalation binds a scope-integrity escalation to the reviewed HEAD (head_sha + scope_block)", async () => {
220
+ const { app, inserts } = fakeApp();
221
+ const job = {
222
+ variables: {
223
+ prKey: "o/r#5",
224
+ round: 2,
225
+ status: "blocked",
226
+ question: "Scope integrity blocked: ...",
227
+ recordRound: false,
228
+ headSha: "HEAD1",
229
+ scopeBlock: true,
230
+ },
231
+ };
232
+ await handler(job as any, app as any);
233
+ assertEquals(inserts.escalations.length, 1);
234
+ assertEquals((inserts.escalations[0] as any).head_sha, "HEAD1", "the escalation carries the reviewed commit");
235
+ assertEquals((inserts.escalations[0] as any).scope_block, 1, "flagged as a scope-integrity block");
236
+ });
237
+
238
+ // Every other escalation arm (agent verdict, no-progress, max-rounds, stalled) omits the scope
239
+ // binding: head_sha stays absent and scope_block defaults to 0, so the override door opens ONLY for
240
+ // the block a human can actually answer.
241
+ test("persist-escalation: a non-scope escalation records no HEAD binding and scope_block 0", async () => {
242
+ const { app, inserts } = fakeApp();
243
+ const job = { variables: { prKey: "o/r#1", round: 3, status: "blocked", question: "max rounds" } };
244
+ await handler(job as any, app as any);
245
+ assertEquals((inserts.escalations[0] as any).head_sha, undefined, "no reviewed HEAD to bind");
246
+ assertEquals((inserts.escalations[0] as any).scope_block, 0, "not a scope-integrity block");
247
+ });
@@ -14,6 +14,7 @@ import {
14
14
  findClosingKeywordRefs,
15
15
  hasDeferralMarker,
16
16
  hasFollowupIssueRef,
17
+ isScopeOverridden,
17
18
  } from "./scopeGuard.ts";
18
19
 
19
20
  // ── The canonical router ────────────────────────────────────────────────────
@@ -145,3 +146,40 @@ test("hasFollowupIssueRef: only an explicit tracking marker + issue ref counts",
145
146
  "Follow-up marker with a full issue URL",
146
147
  );
147
148
  });
149
+
150
+ // ── The human-override door (#395) ──────────────────────────────────────────
151
+ // The scope-integrity gate re-derives `scopeBlocked` from the PR body every round, so answering
152
+ // its escalation used to re-block identically (an infinite loop). An answer bound to the SAME
153
+ // reviewed HEAD is now honoured as an explicit override; a different HEAD (a new push) is not.
154
+
155
+ test("isScopeOverridden: an answer bound to the same HEAD overrides the block", () => {
156
+ assert(
157
+ isScopeOverridden("abc123", { escalationId: 7, headSha: "abc123", answer: "Full delivery — keep Closes." }),
158
+ "same-HEAD answered escalation is an override",
159
+ );
160
+ });
161
+
162
+ test("isScopeOverridden: an answer for a DIFFERENT HEAD does not override (a new push re-opens)", () => {
163
+ assert(
164
+ !isScopeOverridden("newHEAD", { escalationId: 7, headSha: "oldHEAD", answer: "Full delivery." }),
165
+ "an override never carries across a new push",
166
+ );
167
+ });
168
+
169
+ test("isScopeOverridden: no recorded answer is never an override", () => {
170
+ assertEquals(isScopeOverridden("abc123", null), false);
171
+ assertEquals(isScopeOverridden("abc123", undefined), false);
172
+ });
173
+
174
+ test("isScopeOverridden: a missing/blank HEAD on either side fails closed (no override)", () => {
175
+ assertEquals(isScopeOverridden(null, { headSha: "abc123", answer: "x" }), false, "unreadable current HEAD");
176
+ assertEquals(isScopeOverridden("", { headSha: "abc123", answer: "x" }), false, "blank current HEAD");
177
+ assertEquals(isScopeOverridden("abc123", { headSha: null, answer: "x" }), false, "unrecorded escalation HEAD");
178
+ assertEquals(isScopeOverridden("abc123", { headSha: " ", answer: "x" }), false, "blank escalation HEAD");
179
+ });
180
+
181
+ test("isScopeOverridden: the answer text is not parsed for intent — presence at the HEAD is the signal", () => {
182
+ // On an unchanged HEAD, the operator completing the escalation IS the explicit approval: had they
183
+ // wanted a real split, the servicing agent would have pushed a fix, moving the HEAD.
184
+ assert(isScopeOverridden("abc123", { headSha: "abc123", answer: null }), "a null answer at the HEAD still overrides");
185
+ });
package/app/scopeGuard.ts CHANGED
@@ -129,3 +129,37 @@ export function evaluateScopeGuard(input: ScopeGuardInput): ScopeGuardResult {
129
129
  scopeBlockReason: `Scope integrity blocked: ${reasons.join("; ")}.`,
130
130
  };
131
131
  }
132
+
133
+ // A recorded human answer to a scope-integrity escalation, bound to the PR HEAD it was raised
134
+ // against (issue #395). This is the override door the deterministic scope gate lacked: without it,
135
+ // the gate re-derives `scopeBlocked` from the PR body every round and re-escalates the identical
136
+ // question, so a legitimate human override ("this fully delivers the issue — keep the closing
137
+ // keyword") is unresolvable through the escalation the loop itself opens (infinite loop).
138
+ export interface ScopeEscalationAnswer {
139
+ /** The escalation row id, for the audit trail. */
140
+ escalationId?: number;
141
+ /** The PR HEAD sha this scope escalation was raised against (`escalations.head_sha`). */
142
+ headSha: string | null | undefined;
143
+ /** The operator's recorded answer/rationale (`escalations.answer`), surfaced in the audit. */
144
+ answer: string | null | undefined;
145
+ }
146
+
147
+ /** Decide whether a recorded human answer overrides the scope-integrity block for the commit
148
+ * currently under review. The override is honoured ONLY when the human answered a scope-integrity
149
+ * escalation that was raised against the SAME HEAD sha now being checked — binding the override to
150
+ * the reviewed commit so a later push (a different HEAD) re-opens the gate instead of silently
151
+ * carrying the override forward. Pure and total: a missing/blank current HEAD or a missing/blank
152
+ * recorded HEAD never matches, so an unverifiable HEAD fails closed (no override) rather than
153
+ * waving the gate through. The answer TEXT is not parsed for intent: on an unchanged HEAD the human
154
+ * completing the escalation IS the explicit approval (had they wanted a real fix, the servicing
155
+ * agent would have pushed a new commit, moving the HEAD and side-stepping this override). */
156
+ export function isScopeOverridden(
157
+ currentHeadSha: string | null | undefined,
158
+ answered: ScopeEscalationAnswer | null | undefined,
159
+ ): boolean {
160
+ if (!answered) return false;
161
+ const current = typeof currentHeadSha === "string" ? currentHeadSha.trim() : "";
162
+ const recorded = typeof answered.headSha === "string" ? answered.headSha.trim() : "";
163
+ if (current === "" || recorded === "") return false;
164
+ return current === recorded;
165
+ }
@@ -6,6 +6,7 @@
6
6
  import { test } from "node:test";
7
7
  import { assert, assertEquals } from "#test-assert";
8
8
  import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
9
+ import { DELIVERY_HUMAN_ELEMENT } from "./deliveryHuman.ts";
9
10
  import type { PlanReview } from "./plan.ts";
10
11
  import type { TrialMergeAuditRow } from "./trialMerge.ts";
11
12
  import {
@@ -19,6 +20,7 @@ import {
19
20
  type PrEscalationRow,
20
21
  reconcileUserTasks,
21
22
  TRIAL_MERGE_ELEMENT,
23
+ userTaskKindLabel,
22
24
  type UserTaskRow,
23
25
  } from "./userTasks.ts";
24
26
 
@@ -91,6 +93,40 @@ test("buildUserTaskRow: an unknown (non-escalation) element yields null — no a
91
93
  assertEquals(row, null);
92
94
  });
93
95
 
96
+ test("userTaskKindLabel/buildUserTaskRow: an INLINED delivery-human element surfaces under one label; a non-matching internal task is dropped (app/userTasks.ts:122)", () => {
97
+ // The S4 compiler inlines each `human` node (and its bounded-timeout escalation twin) with a
98
+ // per-node id `delivery-human-task__<el>[__esc]`, which a bare `USER_TASK_KIND_LABELS` lookup would
99
+ // miss — so the delivery-human convention MUST be recognised through `isDeliveryHumanElement`.
100
+ const LABEL = "Delivery: human step";
101
+ // The bare static body id and both inlined conventions resolve to the one delivery-human label…
102
+ assertEquals(userTaskKindLabel(DELIVERY_HUMAN_ELEMENT), LABEL);
103
+ assertEquals(userTaskKindLabel(`${DELIVERY_HUMAN_ELEMENT}__ship-it`), LABEL);
104
+ assertEquals(userTaskKindLabel(`${DELIVERY_HUMAN_ELEMENT}__ship-it__esc`), LABEL);
105
+ // …while a non-matching internal task (a near-miss that is NOT the delivery-human prefix) is not a
106
+ // surfaced kind, so it never leaks into the inbox.
107
+ assertEquals(userTaskKindLabel("delivery-human-taskish"), undefined);
108
+ assertEquals(userTaskKindLabel("some-internal-task"), undefined);
109
+
110
+ // And end-to-end through the row builder: an inlined delivery-human task projects a labelled row…
111
+ const inlined = buildUserTaskRow(
112
+ { userTaskKey: "ut-dh", elementId: `${DELIVERY_HUMAN_ELEMENT}__ship-it`, subjectType: "delivery", subjectKey: "graph-1", question: "approve the release?" },
113
+ AT,
114
+ );
115
+ assert(inlined !== null);
116
+ assertEquals(inlined?.element_id, `${DELIVERY_HUMAN_ELEMENT}__ship-it`);
117
+ assertEquals(inlined?.kind_label, LABEL);
118
+ assertEquals(inlined?.subject_type, "delivery");
119
+ assertEquals(inlined?.question, "approve the release?");
120
+ // …whereas a non-matching internal task still yields null (the leak guard holds for the near-miss).
121
+ assertEquals(
122
+ buildUserTaskRow(
123
+ { userTaskKey: "ut-dh2", elementId: "delivery-human-taskish", subjectType: "delivery", subjectKey: "graph-1" },
124
+ AT,
125
+ ),
126
+ null,
127
+ );
128
+ });
129
+
94
130
  test("buildUserTaskRow: a blank userTaskKey yields null; a known kind with a blank subject key still builds (issue #358)", () => {
95
131
  // The completable key is load-bearing (the page completes THROUGH it), so a blank one is still null.
96
132
  assertEquals(
package/app/userTasks.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  // it open.
20
20
  import type { DataLayer } from "@nanobpm/urban";
21
21
  import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
22
- import { DELIVERY_HUMAN_ELEMENT } from "./deliveryHuman.ts";
22
+ import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement } from "./deliveryHuman.ts";
23
23
  import { FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, type FeatureEscalationRow } from "./feature.ts";
24
24
  import type { PlanReview } from "./plan.ts";
25
25
  import type { TrialMergeAuditRow } from "./trialMerge.ts";
@@ -80,6 +80,16 @@ export const USER_TASK_KIND_LABELS: Readonly<Record<string, string>> = {
80
80
  [DELIVERY_HUMAN_ELEMENT]: "Delivery: human step",
81
81
  };
82
82
 
83
+ /** The Tasks-inbox label for an open user-task `elementId`, or `undefined` when the element is not a
84
+ * surfaced kind (so an arbitrary internal user task is never listed). Exact table lookup PLUS the
85
+ * delivery-human convention: the S4 compiler inlines each `human` node (and each service node's
86
+ * bounded-timeout escalation twin) with a per-node id `delivery-human-task__<el>[__esc]`, which a bare
87
+ * table lookup would miss — matched through the single-source-of-truth `isDeliveryHumanElement`
88
+ * predicate so every inlined human/escalation task surfaces under the one delivery-human label. */
89
+ export function userTaskKindLabel(elementId: string): string | undefined {
90
+ return USER_TASK_KIND_LABELS[elementId] ?? (isDeliveryHumanElement(elementId) ? USER_TASK_KIND_LABELS[DELIVERY_HUMAN_ELEMENT] : undefined);
91
+ }
92
+
83
93
  /** The denormalised context the poller has resolved for an open escalation user task. */
84
94
  export interface UserTaskContext {
85
95
  userTaskKey: string;
@@ -108,7 +118,7 @@ export interface UserTaskContext {
108
118
  * tasks out of the inbox). */
109
119
  export function buildUserTaskRow(ctx: UserTaskContext, at: string = now()): UserTaskRow | null {
110
120
  const userTaskKey = ctx.userTaskKey.trim();
111
- const kindLabel = USER_TASK_KIND_LABELS[ctx.elementId];
121
+ const kindLabel = userTaskKindLabel(ctx.elementId);
112
122
  if (!userTaskKey || !kindLabel) return null;
113
123
  const subjectKey = ctx.subjectKey.trim() || (ctx.processKey ?? "").trim() || userTaskKey;
114
124
  const question = typeof ctx.question === "string" && ctx.question.trim() ? ctx.question.trim() : null;
@@ -0,0 +1,26 @@
1
+ -- The delivery-graph `connector` node's idempotency ledger (ADR 0005 Decision 6/7, slice S4). A
2
+ -- `connector` is the epic's one SIDE-EFFECTING node kind (`agent`/`wait`/`human` are read-only or
3
+ -- human-gated): it drives an outbound action against the forward-declared connector I/O surface. The
4
+ -- engine delivers a service-task job AT-LEAST-ONCE — a worker/hub restart, a lost completion ack, or a
5
+ -- graph resume re-activates the same job — so a naive connector would double-fire its side effect on
6
+ -- every redelivery. This ledger makes each dispatch fire AT-MOST-ONCE per dedupe key: the worker
7
+ -- claims the key here BEFORE performing the action, and a redelivery that finds the key already
8
+ -- claimed short-circuits to the recorded outcome instead of re-dispatching.
9
+ --
10
+ -- The UNIQUE fence on `dedupe_key` is the durable at-most-once guarantee (the canonical durable-fence
11
+ -- idiom — cf. `ux_merges_abandon_pr_closed` in 053 and the world-store ledgers in 049): a concurrent
12
+ -- redelivery that races the claim loses the insert and is classified by `app/dbFence.ts`
13
+ -- (`isUniqueConstraintFence`) as the SAME idempotent outcome, never a spurious job failure. The key is
14
+ -- author-supplied (`connector.dedupeKey`) or graph-derived (`<processInstanceKey>:<elementId>`) — both
15
+ -- stable across a re-activation of the same node instance.
16
+ CREATE TABLE IF NOT EXISTS delivery_connector_dispatches (
17
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
18
+ dedupe_key TEXT NOT NULL,
19
+ target TEXT NOT NULL,
20
+ outcome TEXT NOT NULL,
21
+ detail TEXT,
22
+ dispatched_at TEXT NOT NULL
23
+ );
24
+
25
+ CREATE UNIQUE INDEX IF NOT EXISTS ux_delivery_connector_dedupe
26
+ ON delivery_connector_dispatches (dedupe_key);
@@ -0,0 +1,23 @@
1
+ -- Bind a scope-integrity escalation to the reviewed commit so a human answer can override it
2
+ -- (issue #395). The review-convergence scope-integrity gate (`workers/converge-gate`) raises a
3
+ -- human question ("this partial delivery closes a broader-scoped parent") but then re-derives the
4
+ -- block from scratch off the PR body every round, ignoring the recorded `answer` — so answering
5
+ -- the escalation re-enters the loop, the gate re-blocks identically, and the operator is trapped in
6
+ -- an infinite escalation with no human-override door. The only escape was mangling the PR body into
7
+ -- a non-closing ref, i.e. changing the PR to what the machine wants rather than answering it.
8
+ --
9
+ -- The fix gives the gate a real override door: an escalation now records the PR HEAD sha it was
10
+ -- raised against (`head_sha`) and whether it was a scope-integrity block (`scope_block`). When the
11
+ -- gate would re-block on scope, it consults the answered escalation for THIS PR at the SAME HEAD:
12
+ -- a human answer bound to the reviewed commit is honoured as an explicit override (audited), and
13
+ -- the gate is satisfied. Binding to the HEAD sha is deliberate — a later push (a new HEAD)
14
+ -- legitimately re-opens the gate rather than silently carrying the override forward, and if the
15
+ -- human instead asked for a real split the agent pushes a fix (new HEAD) so the stale override
16
+ -- never applies. This categorically kills the infinite-escalation loop on an unchanged HEAD.
17
+ --
18
+ -- Both columns are nullable/defaulted (expand phase, additive). Only the scope-integrity arm
19
+ -- (`persist-escalation-blockedcomments`) populates them; every other escalation arm leaves them
20
+ -- NULL/0 and is unaffected. Numbered after the current highest prefix (055); the runner wraps each
21
+ -- file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
22
+ ALTER TABLE escalations ADD COLUMN head_sha TEXT;
23
+ ALTER TABLE escalations ADD COLUMN scope_block INTEGER NOT NULL DEFAULT 0;