@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.
- package/CHANGELOG.md +14 -0
- package/app/agentCompletion.ts +14 -2
- package/app/convergeGate.test.ts +5 -2
- package/app/deliveryConnector.test.ts +215 -0
- package/app/deliveryConnector.ts +210 -0
- package/app/deliveryGraphCompiler.test.ts +35 -13
- package/app/deliveryGraphCompiler.ts +394 -47
- package/app/deliveryHuman.ts +13 -0
- package/app/deliveryRunner.test.ts +111 -0
- package/app/deliveryRunner.ts +169 -0
- package/app/persist-escalation.test.ts +33 -0
- package/app/scopeGuard.test.ts +38 -0
- package/app/scopeGuard.ts +34 -0
- package/app/userTasks.test.ts +36 -0
- package/app/userTasks.ts +12 -2
- package/db/migrations/055_delivery_connector_dedupe.sql +26 -0
- package/db/migrations/056_escalation_head_override.sql +23 -0
- package/e2e/delivery-graph.e2e.ts +197 -0
- package/nano.app.json +4 -0
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +6 -0
- package/workers/converge-gate/worker.test.ts +116 -0
- package/workers/converge-gate/worker.ts +112 -4
- package/workers/delivery-connector/worker.test.ts +44 -0
- package/workers/delivery-connector/worker.ts +83 -0
- package/workers/persist-escalation/worker.ts +8 -1
|
@@ -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
|
+
});
|
package/app/scopeGuard.test.ts
CHANGED
|
@@ -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
|
+
}
|
package/app/userTasks.test.ts
CHANGED
|
@@ -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 =
|
|
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;
|