@nanobpm/nano-workforce 0.194.0 → 0.195.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +6 -0
- package/SPEC.md +19 -1
- package/app/agentCompletion.ts +5 -1
- package/app/convergeGate.test.ts +9 -2
- package/app/convergenceStaleRoundReset.test.ts +1 -1
- package/app/feature.test.ts +16 -0
- package/app/feature.ts +8 -0
- package/app/featureDelivery.test.ts +21 -0
- package/app/fineGrainedCells.test.ts +1 -1
- package/app/mcpToolSurface.ts +6 -1
- package/app/mergeApproval.test.ts +126 -0
- package/app/service.test.ts +105 -0
- package/app/service.ts +43 -4
- package/app/userTasks.ts +7 -0
- package/db/migrations/116_feature_runs_human_approval.sql +51 -0
- package/e2e/feature-run.e2e.ts +18 -0
- package/openapi.yaml +15 -0
- package/operations/startFeature.readiness.integration.test.ts +14 -0
- package/operations/startFeature.ts +4 -0
- package/package.json +1 -1
- package/pages/feature.page.json +1 -0
- package/resources/forms/merge-approval.form +36 -0
- package/resources/processes/convergence-loop.bpmn +216 -132
- package/resources/processes/feature.bpmn +2 -0
- package/resources/prompts/review-round.md +4 -3
- package/test/derivation-parity/README.md +4 -3
- package/test/derivation-parity/derivation-parity.test.ts +7 -7
- package/test/derivation-parity/flows.ts +6 -5
- package/workers/converge-feature/worker.ts +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.195.0](https://github.com/nanobpm/nano-workforce/compare/v0.194.0...v0.195.0) (2026-09-30)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **feature:** add optional human approval before merge ([#827](https://github.com/nanobpm/nano-workforce/issues/827)) ([fea6b54](https://github.com/nanobpm/nano-workforce/commit/fea6b54ee3fd63f0adccd230e45ee46ed9df24f8)), closes [#826](https://github.com/nanobpm/nano-workforce/issues/826) [#826](https://github.com/nanobpm/nano-workforce/issues/826)
|
|
6
|
+
|
|
1
7
|
## [0.194.0](https://github.com/nanobpm/nano-workforce/compare/v0.193.2...v0.194.0) (2026-09-30)
|
|
2
8
|
|
|
3
9
|
### Features
|
package/README.md
CHANGED
|
@@ -290,6 +290,12 @@ A single submission can choose its merge behavior explicitly with the positive
|
|
|
290
290
|
`converged` regardless of the global default, while `true` enables the merge-loop
|
|
291
291
|
after convergence only when `NANO_PR_AUTO_MERGE` is enabled.
|
|
292
292
|
|
|
293
|
+
A feature run started with `humanApproval` adds a human gate before the merge-loop
|
|
294
|
+
(so it applies only when the run would merge: `autoMerge` with `NANO_PR_AUTO_MERGE`
|
|
295
|
+
enabled): the converged PR waits in the Tasks inbox as "PR merge approval". Approve it
|
|
296
|
+
to merge, or request changes with guidance. The agent then updates the same PR,
|
|
297
|
+
convergence re-runs, and approval is asked again.
|
|
298
|
+
|
|
293
299
|
### Fleet mode: hand it an issue (plan → implement → converge)
|
|
294
300
|
|
|
295
301
|
```
|
package/SPEC.md
CHANGED
|
@@ -101,7 +101,7 @@ known at submit time, carried as a process variable and stored on the DB row.
|
|
|
101
101
|
│ <gateway: status>
|
|
102
102
|
│ ├── converged → [Check review comments] (pr.converge-gate; ++ackRetryRound on ack-only block)
|
|
103
103
|
│ │ → <gateway: comments addressed?>
|
|
104
|
-
│ │ ├── addressed → [Scope classifier] → … → [Mark converged] → (end)
|
|
104
|
+
│ │ ├── addressed → [Scope classifier] → … → <human approval?> → [Mark converged] → (end)
|
|
105
105
|
│ │ ├── review stale (#799) → [Record round] (re-solicit fresh review) ┐
|
|
106
106
|
│ │ └── unaddressed → <gateway: auto-ack within budget?> │
|
|
107
107
|
│ │ ├── convergeAckOnly and ackRetryRound ≤ ackRetryMax │
|
|
@@ -155,6 +155,24 @@ escalates, resolution failure fails open to the human, and re-submitting the PR
|
|
|
155
155
|
loop feeds and reads this memory — a merge-loop answer (same `pr.answer-escalation` step)
|
|
156
156
|
is tagged `answerContext = "merge"` and never recorded as a convergence adjudication.
|
|
157
157
|
|
|
158
|
+
**Human approval before merge (issue #826).** A feature run started with `humanApproval`
|
|
159
|
+
seeds `humanApproval = true` on its convergence instance — only when the PR would merge
|
|
160
|
+
(`autoMerge` on and `NANO_PR_AUTO_MERGE` enabled; otherwise it is pinned off).
|
|
161
|
+
After the scope gate passes, the `human approval?` gateway then parks the converged PR on the
|
|
162
|
+
native `merge-approval` user task (`merge-approval.form`, surfaced in the Tasks inbox as
|
|
163
|
+
"PR merge approval") instead of going straight to `[Mark converged]`. `mergeDecision = "approve"`
|
|
164
|
+
continues to `pr.finalize`, which hands the PR to the merge-loop. Approval covers the PR, not a
|
|
165
|
+
pinned SHA: as with any auto-merge, the merge-loop may still rebase it or fix CI before landing.
|
|
166
|
+
Anything else (the form's
|
|
167
|
+
`revise` plus required guidance) loops back to `capture-head` with the guidance as `answer`, so
|
|
168
|
+
the review agent updates the **same** PR. Convergence then re-runs and approval is asked again.
|
|
169
|
+
The default route is fail-closed: once parked, nothing merges without an explicit approval. A
|
|
170
|
+
gated run that adopts a PR already converging under a live loop (e.g. one an ungated run started)
|
|
171
|
+
narrows that loop to `humanApproval = true` rather than inheriting its auto-merge; a loop already
|
|
172
|
+
past its `human approval?` gateway can no longer be gated. The task is
|
|
173
|
+
human-only (never agent-answerable) and never auto-resumed from an adjudication. Without the
|
|
174
|
+
flag the gateway defaults straight through, so existing runs are unchanged.
|
|
175
|
+
|
|
158
176
|
Guard: after progress classification, a **progressing** round with round ≥
|
|
159
177
|
MAX_ROUNDS forces an escalation ("not converged after N rounds") so a human
|
|
160
178
|
decides rather than looping forever. The guard sits *after* `check-progress`
|
package/app/agentCompletion.ts
CHANGED
|
@@ -25,7 +25,7 @@ import type { DataLayer, EngineClient, GatewayDataSource } from "@nanobpm/urban"
|
|
|
25
25
|
import { invalidateAdjudication, invalidateAdjudicationByCompletion } from "./adjudications.ts";
|
|
26
26
|
import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
|
|
27
27
|
import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement } from "./deliveryHuman.ts";
|
|
28
|
-
import { ACP_PERMISSION_ELEMENT, EMPTY_PLAN_ELEMENT, READINESS_ESCALATION_ELEMENT, READINESS_ESCALATION_PF_ELEMENT } from "./userTasks.ts";
|
|
28
|
+
import { ACP_PERMISSION_ELEMENT, EMPTY_PLAN_ELEMENT, PR_MERGE_APPROVAL_ELEMENT, READINESS_ESCALATION_ELEMENT, READINESS_ESCALATION_PF_ELEMENT } from "./userTasks.ts";
|
|
29
29
|
|
|
30
30
|
const now = () => new Date().toISOString();
|
|
31
31
|
|
|
@@ -136,6 +136,9 @@ export const HUMAN_COMPLETABLE_ELEMENTS: ReadonlySet<string> = new Set([
|
|
|
136
136
|
READINESS_ESCALATION_PF_TASK_ELEMENT,
|
|
137
137
|
READINESS_ESCALATION_TASK_ELEMENT,
|
|
138
138
|
ACP_PERMISSION_ELEMENT,
|
|
139
|
+
// Human-only (issue #826): never agent-answerable, so it stays OUT of ESCALATION_TASK_ELEMENTS and the
|
|
140
|
+
// fleet can never auto-approve a merge.
|
|
141
|
+
PR_MERGE_APPROVAL_ELEMENT,
|
|
139
142
|
]);
|
|
140
143
|
|
|
141
144
|
/** Each escalation `elementId` → the `.form` whose contract governs its completion variables (the
|
|
@@ -153,6 +156,7 @@ const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
|
|
|
153
156
|
[CONFORMANCE_ESCALATION_TASK_ELEMENT]: "conformance-escalation",
|
|
154
157
|
[READINESS_ESCALATION_PF_TASK_ELEMENT]: "readiness-escalation",
|
|
155
158
|
[READINESS_ESCALATION_TASK_ELEMENT]: "readiness-escalation",
|
|
159
|
+
[PR_MERGE_APPROVAL_ELEMENT]: "merge-approval",
|
|
156
160
|
// NOTE: the delivery-graph `human` node (`DELIVERY_HUMAN_ELEMENT`, ADR 0005 S3) is intentionally
|
|
157
161
|
// ABSENT here. Unlike the fixed-form escalations above, ONE `delivery-human-task` element is DESIGNED
|
|
158
162
|
// to render DIFFERENT forms per node (explicit → category → generic → agent-router, `app/deliveryHuman.ts`
|
package/app/convergeGate.test.ts
CHANGED
|
@@ -1073,14 +1073,21 @@ test("classify-scope feeds gw-scope-gate, which blocks on scopeBlocked = true",
|
|
|
1073
1073
|
assertStringIncludes(blocked, "scopeBlocked = true");
|
|
1074
1074
|
});
|
|
1075
1075
|
|
|
1076
|
-
test("gw-scope-gate default arm
|
|
1076
|
+
test("gw-scope-gate default arm continues to the optional merge approval, whose default finalizes", () => {
|
|
1077
1077
|
const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-scope-gate"[^>]*>/);
|
|
1078
1078
|
assert(gw, "gw-scope-gate gateway missing");
|
|
1079
1079
|
assertStringIncludes(gw[0], 'default="f_scopeOk"');
|
|
1080
1080
|
const ok = flowElement("f_scopeOk");
|
|
1081
1081
|
assert(ok, "f_scopeOk flow missing");
|
|
1082
|
-
assertStringIncludes(ok, 'targetRef="
|
|
1082
|
+
assertStringIncludes(ok, 'targetRef="gw-approval"');
|
|
1083
1083
|
assert(!/conditionExpression/.test(ok), "the default arm must carry no conditionExpression");
|
|
1084
|
+
// Issue #826: without `humanApproval` the approval gateway's default arm finalizes as before.
|
|
1085
|
+
const approval = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-approval"[^>]*>/);
|
|
1086
|
+
assert(approval, "gw-approval gateway missing");
|
|
1087
|
+
assertStringIncludes(approval[0], 'default="f_noApproval"');
|
|
1088
|
+
const noApproval = flowElement("f_noApproval");
|
|
1089
|
+
assert(noApproval, "f_noApproval flow missing");
|
|
1090
|
+
assertStringIncludes(noApproval, 'targetRef="persist-converged"');
|
|
1084
1091
|
});
|
|
1085
1092
|
|
|
1086
1093
|
test("the scope escalation routes through gw-escalated with the classifier's specific reason", () => {
|
|
@@ -73,7 +73,7 @@ test("capture-head still precedes review-round on every loop entry (the reset is
|
|
|
73
73
|
const el = serviceTask("capture-head");
|
|
74
74
|
assert(el, "capture-head service task must exist");
|
|
75
75
|
const incoming = [...el!.matchAll(/<bpmn:incoming>([^<]+)<\/bpmn:incoming>/g)].map((m) => m[1]);
|
|
76
|
-
for (const flow of ["f_start", "f_reviewLoop", "f_answerLoop", "f_huskRetry", "f_ackRetry"]) {
|
|
76
|
+
for (const flow of ["f_start", "f_reviewLoop", "f_answerLoop", "f_huskRetry", "f_ackRetry", "f_revise"]) {
|
|
77
77
|
assert(incoming.includes(flow), `capture-head must be the entry join for ${flow} so the reset covers that re-entry (issue #822)`);
|
|
78
78
|
}
|
|
79
79
|
const capture = flat.match(/<bpmn:sequenceFlow[^>]*\bid="f_capture"[^>]*\/>/);
|
package/app/feature.test.ts
CHANGED
|
@@ -124,6 +124,22 @@ test("startFeature: converge/autoMerge flags are persisted as 0/1", async () =>
|
|
|
124
124
|
assertEquals(row.auto_merge, 1);
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
+
test("startFeature: humanApproval is persisted as 0/1 and seeded onto the instance (issue #826)", async () => {
|
|
128
|
+
for (const humanApproval of [true, false]) {
|
|
129
|
+
const stores = { feature_runs: { rows: [] as any[], key: "feature_key" } };
|
|
130
|
+
let vars: any = null;
|
|
131
|
+
const engine = {
|
|
132
|
+
createInstance: (req: any) => {
|
|
133
|
+
vars = req.variables;
|
|
134
|
+
return Promise.resolve({ processInstanceKey: "PI-1" });
|
|
135
|
+
},
|
|
136
|
+
} as any;
|
|
137
|
+
await startFeature(memData(stores), engine, PARSED, "main", true, true, null, {}, humanApproval);
|
|
138
|
+
assertEquals(stores.feature_runs.rows[0].human_approval, humanApproval ? 1 : 0);
|
|
139
|
+
assertEquals(vars.humanApproval, humanApproval);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
127
143
|
test("startFeature: seeds the single task slice + base-branch brief onto the instance", async () => {
|
|
128
144
|
let captured: any = null;
|
|
129
145
|
const engine = {
|
package/app/feature.ts
CHANGED
|
@@ -60,6 +60,9 @@ export interface FeatureRun {
|
|
|
60
60
|
pr_key: string | null;
|
|
61
61
|
converge: number;
|
|
62
62
|
auto_merge: number;
|
|
63
|
+
/** 1 ⇒ a human must approve the converged PR before the merge-loop runs (issue #826). Only
|
|
64
|
+
* meaningful with `auto_merge = 1`; the `startFeature` operation pins it to 0 otherwise. */
|
|
65
|
+
human_approval: number;
|
|
63
66
|
outcome: string | null;
|
|
64
67
|
/** Human rollup detail. Projected by `pollFeatureDelivery` (fix: Feature history stuck at
|
|
65
68
|
* `converging`) and also written by `pr.record-blocked-ack` with the operator's disposition
|
|
@@ -352,6 +355,7 @@ export async function startFeature(
|
|
|
352
355
|
autoMerge: boolean,
|
|
353
356
|
customInstructions: string | null = null,
|
|
354
357
|
readiness: FeatureReadinessOptions = {},
|
|
358
|
+
humanApproval = false,
|
|
355
359
|
): Promise<StartFeatureResult> {
|
|
356
360
|
// Intake-time readiness gate (issue #295): the probes the run must satisfy before it implements,
|
|
357
361
|
// and the bound its preflight escalation timers fire off. Both are load-bearing together —
|
|
@@ -450,6 +454,7 @@ export async function startFeature(
|
|
|
450
454
|
pr_key: null,
|
|
451
455
|
converge: converge ? 1 : 0,
|
|
452
456
|
auto_merge: autoMerge ? 1 : 0,
|
|
457
|
+
human_approval: humanApproval ? 1 : 0,
|
|
453
458
|
outcome: null,
|
|
454
459
|
delivery_label: null,
|
|
455
460
|
// Clear the operator tick-off so a re-dispatched run is NOT silently dropped into History when
|
|
@@ -472,6 +477,7 @@ export async function startFeature(
|
|
|
472
477
|
pr_key: null,
|
|
473
478
|
converge: converge ? 1 : 0,
|
|
474
479
|
auto_merge: autoMerge ? 1 : 0,
|
|
480
|
+
human_approval: humanApproval ? 1 : 0,
|
|
475
481
|
outcome: null,
|
|
476
482
|
delivery_label: null,
|
|
477
483
|
created_at: ts,
|
|
@@ -507,6 +513,8 @@ export async function startFeature(
|
|
|
507
513
|
// merge-loop. `converge=false` ⇒ merge is moot.
|
|
508
514
|
converge,
|
|
509
515
|
autoMerge,
|
|
516
|
+
// Park the converged PR at a human approval user task before the merge-loop (issue #826).
|
|
517
|
+
humanApproval,
|
|
510
518
|
// A single-issue feature run OWNS its issue (the whole issue is the slice), so the agent may
|
|
511
519
|
// claim it with a "starting work" comment on a first run (resources/prompts/feature.md). Epic slices
|
|
512
520
|
// (plan-fanout) deliberately DO NOT set this — their `issue` is the shared parent epic, which
|
|
@@ -197,6 +197,27 @@ test("pollFeatureDelivery: a converging run whose PR row is MISSING is re-enroll
|
|
|
197
197
|
assertEquals(stores.feature_runs[0].delivery_label, "PR record missing");
|
|
198
198
|
});
|
|
199
199
|
|
|
200
|
+
test("pollFeatureDelivery: the re-enroll keeps the run's human approval gate (issue #826)", async () => {
|
|
201
|
+
const { data, stores } = memData();
|
|
202
|
+
stores.feature_runs = [
|
|
203
|
+
{ feature_key: "o/r#7", status: "converging", pr_key: "o/r#407", auto_merge: 1, human_approval: 1, delivery_label: null },
|
|
204
|
+
];
|
|
205
|
+
stores.pull_requests = [];
|
|
206
|
+
let vars: Record<string, unknown> | undefined;
|
|
207
|
+
const engine = {
|
|
208
|
+
searchProcessInstances: async () => [],
|
|
209
|
+
createInstance: async (req: { variables?: Record<string, unknown> }) => {
|
|
210
|
+
vars = req.variables;
|
|
211
|
+
return { processInstanceKey: "pi-407" };
|
|
212
|
+
},
|
|
213
|
+
} as any;
|
|
214
|
+
|
|
215
|
+
await pollFeatureDelivery(data, engine);
|
|
216
|
+
|
|
217
|
+
assertEquals(vars?.convergeOnly, false);
|
|
218
|
+
assertEquals(vars?.humanApproval, true, "a healed enrollment must not silently drop the approval gate");
|
|
219
|
+
});
|
|
220
|
+
|
|
200
221
|
test("pollFeatureDelivery: a converging run whose PR row is PARTIALLY enrolled (present, non-terminal, process_key NULL) is re-enrolled — a create-instance crash after the row insert does not wedge it (PR #809)", async () => {
|
|
201
222
|
const { data, stores } = memData();
|
|
202
223
|
stores.feature_runs = [
|
|
@@ -115,7 +115,7 @@ test("feature.bpmn composes its converge step via callActivity to converge-cell
|
|
|
115
115
|
/<zeebe:ioMapping>[\s\S]*?<\/zeebe:ioMapping>/.test(convergeBlock),
|
|
116
116
|
"the converge callActivity must carry an explicit zeebe:ioMapping",
|
|
117
117
|
);
|
|
118
|
-
for (const field of ["featureKey", "prKey", "autoMerge"]) {
|
|
118
|
+
for (const field of ["featureKey", "prKey", "autoMerge", "humanApproval"]) {
|
|
119
119
|
assert(
|
|
120
120
|
new RegExp(`<zeebe:input\\b[^>]*\\btarget="${field}"`).test(convergeBlock),
|
|
121
121
|
`the converge callActivity ioMapping must map ConvergeFeatureIn.${field} into the child scope`,
|
package/app/mcpToolSurface.ts
CHANGED
|
@@ -111,8 +111,13 @@ export const MCP_TOOL_COUNT_BUDGET = 60;
|
|
|
111
111
|
* `getAgenticSupply`, and registry read schemas (plus the `StaleWorker` leaf) so an operator can spot
|
|
112
112
|
* and drain a stale harness from the tool surface alone. Measured 86,509 bytes serialized — 9 over the
|
|
113
113
|
* old ceiling's headroom. Deliberate, documented growth — not schema fat.
|
|
114
|
+
*
|
|
115
|
+
* RAISE PROVENANCE — 87_000 → 87_500 (#826): the per-run `humanApproval` start option on both
|
|
116
|
+
* `startFeature` body variants (`FeatureStartByIssue`/`FeatureStartByUrl`) — the Feature tab's
|
|
117
|
+
* "Human approval before merge" checkbox. Measured 87,293 bytes serialized (86,862 → 87,293 — over
|
|
118
|
+
* the old ceiling's 138-byte headroom). Deliberate, documented growth — not schema fat.
|
|
114
119
|
*/
|
|
115
|
-
export const MCP_SURFACE_BYTES_BUDGET =
|
|
120
|
+
export const MCP_SURFACE_BYTES_BUDGET = 87_500;
|
|
116
121
|
|
|
117
122
|
/**
|
|
118
123
|
* The eagerly-loaded curated subset MUST stay materially smaller than the full surface — otherwise it
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Human approval before merge (issue #826): an optional `merge-approval` user task in the convergence
|
|
2
|
+
// loop, between the scope gate and `persist-converged` (`pr.finalize`, the merge-loop handoff). These
|
|
3
|
+
// deploy the committed model into the WASM engine and drive tokens through it: flag off → finalizes
|
|
4
|
+
// straight away; flag on → parks for a human; approve → finalizes; request changes (or anything that is
|
|
5
|
+
// not an explicit approve) → the guidance goes back to the review agent on the same PR, then approval is
|
|
6
|
+
// asked again.
|
|
7
|
+
import { after, test } from "node:test";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { assert, assertEquals } from "#test-assert";
|
|
10
|
+
import { assertThatInstance, byProcessId, createWasmEngineClient, type WasmEngineClient } from "@nanobpm/urban-testkit";
|
|
11
|
+
import { ESCALATION_TASK_ELEMENTS, HUMAN_COMPLETABLE_ELEMENTS, validateEscalationVariables } from "./agentCompletion.ts";
|
|
12
|
+
import { PR_MERGE_APPROVAL_ELEMENT } from "./userTasks.ts";
|
|
13
|
+
|
|
14
|
+
const MODEL = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
|
|
15
|
+
|
|
16
|
+
const engines: WasmEngineClient[] = [];
|
|
17
|
+
after(async () => {
|
|
18
|
+
await Promise.all(engines.map((e) => e.close()));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/** Boots one loop whose every round converges cleanly; `answers` records the `answer` each review round saw.
|
|
22
|
+
* Job types in `held` get no worker until `register(type)`, so a test can act while the loop waits there. */
|
|
23
|
+
async function boot(vars: Record<string, unknown>, held: string[] = []) {
|
|
24
|
+
const engine = await createWasmEngineClient();
|
|
25
|
+
engines.push(engine);
|
|
26
|
+
await engine.deployResources([{ name: "convergence-loop.bpmn", content: MODEL, contentType: "text/xml" }]);
|
|
27
|
+
const answers: unknown[] = [];
|
|
28
|
+
const workers: Record<string, (v: Record<string, unknown>) => Record<string, unknown>> = {
|
|
29
|
+
"senior:pr-review": (v) => {
|
|
30
|
+
answers.push(v.answer);
|
|
31
|
+
return { status: "converged", summary: "done" };
|
|
32
|
+
},
|
|
33
|
+
"pr.capture-head": () => ({ roundEntryHead: "sha" }),
|
|
34
|
+
"pr.persist-round": () => ({}),
|
|
35
|
+
"pr.progress-check": () => ({ progressed: true }),
|
|
36
|
+
"pr.converge-gate": () => ({ convergeBlocked: false, convergeBlockReason: "", convergeAckOnly: false }),
|
|
37
|
+
"senior:scope-classify": () => ({ scopeBlocked: false, scopeBlockReason: "" }),
|
|
38
|
+
"pr.finalize": () => ({}),
|
|
39
|
+
};
|
|
40
|
+
const register = (type: string) =>
|
|
41
|
+
engine.registerWorker(type, (job) => workers[type]((job as { variables: Record<string, unknown> }).variables));
|
|
42
|
+
for (const type of Object.keys(workers)) if (!held.includes(type)) await register(type);
|
|
43
|
+
const { processInstanceKey } = await engine.createInstance({
|
|
44
|
+
processDefinitionId: "convergence-loop",
|
|
45
|
+
awaitCompletion: false,
|
|
46
|
+
variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1, round: 1, maxRounds: 20, answer: null, huskRetries: 0, ...vars },
|
|
47
|
+
});
|
|
48
|
+
return { engine, answers, register, processInstanceKey };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function completions(engine: WasmEngineClient, elementId: string): number {
|
|
52
|
+
const snap = engine.snapshot() as { elementStats?: { elementId: string; completed: number }[] };
|
|
53
|
+
return (snap.elementStats ?? []).find((s) => s.elementId === elementId)?.completed ?? 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function openApproval(engine: WasmEngineClient): Promise<string | undefined> {
|
|
57
|
+
return (await engine.openUserTasks()).find((t) => t.elementId === PR_MERGE_APPROVAL_ELEMENT)?.userTaskKey;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const loop = (engine: WasmEngineClient) => assertThatInstance(engine, byProcessId("convergence-loop"));
|
|
61
|
+
|
|
62
|
+
test("without humanApproval a converged PR finalizes straight away — no approval task", async () => {
|
|
63
|
+
// `humanApproval` deliberately unseeded: a missing flag must behave exactly like `false`.
|
|
64
|
+
const { engine } = await boot({});
|
|
65
|
+
loop(engine).hasCompleted().hasNoIncident();
|
|
66
|
+
assertEquals(completions(engine, "persist-converged"), 1);
|
|
67
|
+
assertEquals(completions(engine, PR_MERGE_APPROVAL_ELEMENT), 0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("with humanApproval a converged PR parks for a human, and approve finalizes it", async () => {
|
|
71
|
+
const { engine } = await boot({ humanApproval: true });
|
|
72
|
+
loop(engine).isActive().hasActiveElement(PR_MERGE_APPROVAL_ELEMENT).hasNoIncident();
|
|
73
|
+
assertEquals(completions(engine, "persist-converged"), 0, "nothing hands off to the merge-loop before approval");
|
|
74
|
+
|
|
75
|
+
const key = await openApproval(engine);
|
|
76
|
+
assert(key !== undefined, "the approval task is open");
|
|
77
|
+
await engine.completeUserTask(key, { mergeDecision: "approve" });
|
|
78
|
+
loop(engine).hasCompleted().hasNoIncident();
|
|
79
|
+
assertEquals(completions(engine, "persist-converged"), 1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("request changes sends the guidance to the review agent on the same PR, then asks again", async () => {
|
|
83
|
+
const { engine, answers } = await boot({ humanApproval: true });
|
|
84
|
+
const first = await openApproval(engine);
|
|
85
|
+
assert(first !== undefined, "the approval task is open");
|
|
86
|
+
await engine.completeUserTask(first, { mergeDecision: "revise", answer: "Rename foo to bar." });
|
|
87
|
+
|
|
88
|
+
loop(engine).isActive().hasActiveElement(PR_MERGE_APPROVAL_ELEMENT).hasNoIncident();
|
|
89
|
+
assertEquals(answers.length, 2, "the review agent ran again");
|
|
90
|
+
assertEquals(answers[1], "Rename foo to bar.", "the revise round received the human's guidance");
|
|
91
|
+
assertEquals(completions(engine, "capture-head"), 2, "the revise re-entered the same loop");
|
|
92
|
+
assertEquals(completions(engine, "persist-converged"), 0);
|
|
93
|
+
const second = await openApproval(engine);
|
|
94
|
+
assert(second !== undefined && second !== first, "approval is asked again after the revision");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("a live ungated loop narrowed to humanApproval (a gated submitPr adopting it) parks for approval", async () => {
|
|
98
|
+
// `submitPr` narrows an adopted live loop with setVariables (app/service.ts); the gateway must honour it.
|
|
99
|
+
const { engine, register, processInstanceKey } = await boot({ humanApproval: false }, ["senior:pr-review"]);
|
|
100
|
+
loop(engine).isActive().hasActiveElement("review-round");
|
|
101
|
+
await engine.setVariables({ scopeKey: String(processInstanceKey), variables: { humanApproval: true } });
|
|
102
|
+
await register("senior:pr-review");
|
|
103
|
+
loop(engine).isActive().hasActiveElement(PR_MERGE_APPROVAL_ELEMENT).hasNoIncident();
|
|
104
|
+
assertEquals(completions(engine, "persist-converged"), 0, "the adopted loop never merges unapproved");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("fail-closed: a completion without an explicit approve never merges", async () => {
|
|
108
|
+
const { engine } = await boot({ humanApproval: true });
|
|
109
|
+
const key = await openApproval(engine);
|
|
110
|
+
assert(key !== undefined, "the approval task is open");
|
|
111
|
+
await engine.completeUserTask(key, {});
|
|
112
|
+
loop(engine).isActive().hasActiveElement(PR_MERGE_APPROVAL_ELEMENT).hasNoIncident();
|
|
113
|
+
assertEquals(completions(engine, "persist-converged"), 0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("merge-approval is human-only, and its form requires guidance only for request changes", () => {
|
|
117
|
+
assert(HUMAN_COMPLETABLE_ELEMENTS.has(PR_MERGE_APPROVAL_ELEMENT), "the human completer accepts it");
|
|
118
|
+
assert(!ESCALATION_TASK_ELEMENTS.has(PR_MERGE_APPROVAL_ELEMENT), "an agent must never approve a merge");
|
|
119
|
+
const check = (vars: Record<string, unknown>) => validateEscalationVariables(PR_MERGE_APPROVAL_ELEMENT, vars);
|
|
120
|
+
assertEquals(check({ mergeDecision: "approve" }), null);
|
|
121
|
+
assertEquals(check({ mergeDecision: "revise", answer: "Rename foo." }), null);
|
|
122
|
+
assert(check({}) !== null, "a decision is required");
|
|
123
|
+
assert(check({ mergeDecision: "revise" }) !== null, "request changes needs guidance");
|
|
124
|
+
assert(check({ mergeDecision: "revise", answer: " " }) !== null, "blank guidance is rejected");
|
|
125
|
+
assert(check({ mergeDecision: "merge" }) !== null, "only approve/revise are accepted");
|
|
126
|
+
});
|
package/app/service.test.ts
CHANGED
|
@@ -876,6 +876,111 @@ test("submitPr seeds the #796 auto-ack budget onto the instance (ackRetryRound=0
|
|
|
876
876
|
});
|
|
877
877
|
});
|
|
878
878
|
|
|
879
|
+
test("submitPr seeds humanApproval (issue #826), pinned off for a converge-only run that never merges", async () => {
|
|
880
|
+
await withGithubOff(async () => {
|
|
881
|
+
const pr = (n: number) => ({ repo: "owner/repo", number: n, url: `https://github.com/owner/repo/pull/${n}`, prKey: `owner/repo#${n}` });
|
|
882
|
+
const merging = captureVars();
|
|
883
|
+
await submitPr(merging.data, merging.engine, pr(11), [], 20, false, null, true);
|
|
884
|
+
assertEquals(merging.get()?.humanApproval, true);
|
|
885
|
+
const convergeOnly = captureVars();
|
|
886
|
+
await submitPr(convergeOnly.data, convergeOnly.engine, pr(12), [], 20, true, null, true);
|
|
887
|
+
assertEquals(convergeOnly.get()?.humanApproval, false);
|
|
888
|
+
const unset = captureVars();
|
|
889
|
+
await submitPr(unset.data, unset.engine, pr(13));
|
|
890
|
+
assertEquals(unset.get()?.humanApproval, false);
|
|
891
|
+
});
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
// Adoption (issue #826, Copilot review): `submitPr` returns `alreadyRunning` for a PR whose loop is
|
|
895
|
+
// live, so a gated caller adopting an UNGATED loop would inherit its auto-merge. `humanApproval` is
|
|
896
|
+
// monotonic: the gated caller narrows the live loop (idempotent, so a retry against its own gated loop
|
|
897
|
+
// is a no-op). A PR already finalized (`converged_at` set) is past its approval point: `startMerge`
|
|
898
|
+
// re-pointed its `process_key` at the LIVE merge-loop, which must never be written to.
|
|
899
|
+
function adoptLoop(status: string, loopState: string, convergedAt: string | null = null) {
|
|
900
|
+
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
901
|
+
pull_requests: {
|
|
902
|
+
rows: [
|
|
903
|
+
{
|
|
904
|
+
pr_key: "owner/repo#14",
|
|
905
|
+
repo: "owner/repo",
|
|
906
|
+
number: 14,
|
|
907
|
+
url: "https://github.com/owner/repo/pull/14",
|
|
908
|
+
title: "t",
|
|
909
|
+
status,
|
|
910
|
+
current_round: 1,
|
|
911
|
+
process_key: "PI-LOOP",
|
|
912
|
+
converged_at: convergedAt,
|
|
913
|
+
},
|
|
914
|
+
],
|
|
915
|
+
key: "pr_key",
|
|
916
|
+
},
|
|
917
|
+
escalations: { rows: [], key: "id" },
|
|
918
|
+
pr_dependencies: { rows: [], key: "pr_key" },
|
|
919
|
+
};
|
|
920
|
+
const data = {
|
|
921
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
922
|
+
open: () => memOpen(stores),
|
|
923
|
+
} as any;
|
|
924
|
+
const narrowed: unknown[] = [];
|
|
925
|
+
const engine = {
|
|
926
|
+
createInstance: () => Promise.reject(new Error("a live PR is adopted, never re-created")),
|
|
927
|
+
searchProcessInstances: () => Promise.resolve([{ processInstanceKey: "PI-LOOP", state: loopState }]),
|
|
928
|
+
setVariables: (req: unknown) => {
|
|
929
|
+
narrowed.push(req);
|
|
930
|
+
return Promise.resolve();
|
|
931
|
+
},
|
|
932
|
+
} as any;
|
|
933
|
+
return { data, engine, narrowed };
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
test("a gated submitPr adopting a live loop narrows it to humanApproval (issue #826)", async () => {
|
|
937
|
+
await withGithubOff(async () => {
|
|
938
|
+
const pr = { repo: "owner/repo", number: 14, url: "https://github.com/owner/repo/pull/14", prKey: "owner/repo#14" };
|
|
939
|
+
const live = adoptLoop("converging", "ACTIVE");
|
|
940
|
+
assertEquals(await submitPr(live.data, live.engine, pr, [], 20, false, null, true), { prKey: pr.prKey, alreadyRunning: true });
|
|
941
|
+
assertEquals(live.narrowed, [{ scopeKey: "PI-LOOP", variables: { humanApproval: true } }]);
|
|
942
|
+
|
|
943
|
+
const ungated = adoptLoop("converging", "ACTIVE");
|
|
944
|
+
await submitPr(ungated.data, ungated.engine, pr);
|
|
945
|
+
assertEquals(ungated.narrowed, [], "an ungated adopter leaves the loop's mode alone");
|
|
946
|
+
|
|
947
|
+
const convergeOnly = adoptLoop("converging", "ACTIVE");
|
|
948
|
+
await submitPr(convergeOnly.data, convergeOnly.engine, pr, [], 20, true, null, true);
|
|
949
|
+
assertEquals(convergeOnly.narrowed, [], "a converge-only adopter never merges, so never gates");
|
|
950
|
+
|
|
951
|
+
const merging = adoptLoop("waiting_deps", "ACTIVE", "2026-01-01T00:00:00.000Z");
|
|
952
|
+
assertEquals(await submitPr(merging.data, merging.engine, pr, [], 20, false, null, true), { prKey: pr.prKey, alreadyRunning: true });
|
|
953
|
+
assertEquals(merging.narrowed, [], "the live merge-loop of a finalized PR is never written to");
|
|
954
|
+
|
|
955
|
+
const ended = adoptLoop("converging", "COMPLETED");
|
|
956
|
+
await submitPr(ended.data, ended.engine, pr, [], 20, false, null, true);
|
|
957
|
+
assertEquals(ended.narrowed, [], "a loop the engine no longer reports running is left alone");
|
|
958
|
+
});
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
test("human approval is pinned off when NANO_PR_AUTO_MERGE is off — the PR never merges (issue #826)", async () => {
|
|
962
|
+
// `AUTO_MERGE` is read once at import, so load a fresh copy of the module under the env.
|
|
963
|
+
const prev = process.env["NANO_PR_AUTO_MERGE"];
|
|
964
|
+
process.env["NANO_PR_AUTO_MERGE"] = "0";
|
|
965
|
+
try {
|
|
966
|
+
const mod: typeof import("./service.ts") = await import(`./service.ts?autoMergeOff=${Date.now()}`);
|
|
967
|
+
await withGithubOff(async () => {
|
|
968
|
+
const fresh = captureVars();
|
|
969
|
+
const pr15 = { repo: "owner/repo", number: 15, url: "https://github.com/owner/repo/pull/15", prKey: "owner/repo#15" };
|
|
970
|
+
await mod.submitPr(fresh.data, fresh.engine, pr15, [], 20, false, null, true);
|
|
971
|
+
assertEquals(fresh.get()?.humanApproval, false, "no approval task for a merge that never happens");
|
|
972
|
+
|
|
973
|
+
const live = adoptLoop("converging", "ACTIVE");
|
|
974
|
+
const pr14 = { repo: "owner/repo", number: 14, url: "https://github.com/owner/repo/pull/14", prKey: "owner/repo#14" };
|
|
975
|
+
await mod.submitPr(live.data, live.engine, pr14, [], 20, false, null, true);
|
|
976
|
+
assertEquals(live.narrowed, [], "nor is an adopted loop narrowed");
|
|
977
|
+
});
|
|
978
|
+
} finally {
|
|
979
|
+
if (prev === undefined) delete process.env["NANO_PR_AUTO_MERGE"];
|
|
980
|
+
else process.env["NANO_PR_AUTO_MERGE"] = prev;
|
|
981
|
+
}
|
|
982
|
+
});
|
|
983
|
+
|
|
879
984
|
// Lineage threading (issue #245): `submitPr` persists the origin `root_request_key` on the PR row
|
|
880
985
|
// and carries it onto the convergence instance; `startMerge` reads it back off the row onto the
|
|
881
986
|
// merge instance. A human/webhook submit that supplies no root self-roots on the `pr_key` (its own
|
package/app/service.ts
CHANGED
|
@@ -107,6 +107,7 @@ import {
|
|
|
107
107
|
type OpenEscalation,
|
|
108
108
|
PLAN_REVIEW_ELEMENT,
|
|
109
109
|
PR_ESCALATION_PRODUCER_ELEMENTS,
|
|
110
|
+
PR_MERGE_APPROVAL_ELEMENT,
|
|
110
111
|
PR_RECORD_ANSWER_ELEMENT,
|
|
111
112
|
PR_RECORD_MERGE_ANSWER_ELEMENT,
|
|
112
113
|
PR_WAIT_ANSWER_ELEMENT,
|
|
@@ -573,9 +574,10 @@ export async function submitPr(
|
|
|
573
574
|
maxRounds: number = MAX_ROUNDS,
|
|
574
575
|
convergeOnly = false,
|
|
575
576
|
rootRequestKey: string | null = null,
|
|
577
|
+
humanApproval = false,
|
|
576
578
|
) {
|
|
577
579
|
return withPrSubmitLock(parsed.prKey, () =>
|
|
578
|
-
submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey),
|
|
580
|
+
submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey, humanApproval),
|
|
579
581
|
);
|
|
580
582
|
}
|
|
581
583
|
|
|
@@ -587,7 +589,12 @@ async function submitPrCritical(
|
|
|
587
589
|
maxRounds: number = MAX_ROUNDS,
|
|
588
590
|
convergeOnly = false,
|
|
589
591
|
rootRequestKey: string | null = null,
|
|
592
|
+
humanApproval = false,
|
|
590
593
|
) {
|
|
594
|
+
// Human approval before merge (issue #826) gates only a PR that `pr.finalize` would hand to the
|
|
595
|
+
// merge-loop — its own predicate, `AUTO_MERGE && convergeOnly !== true`. A run that won't merge
|
|
596
|
+
// (review-only, or the global switch off) must not park for a pointless approval.
|
|
597
|
+
const gated = humanApproval && !convergeOnly && AUTO_MERGE;
|
|
591
598
|
const table = prs(data);
|
|
592
599
|
const existing = await table.get(parsed.prKey);
|
|
593
600
|
// ADR-0065: classify "already running" on the DERIVED terminal edge, not the base transient. A
|
|
@@ -607,6 +614,23 @@ async function submitPrCritical(
|
|
|
607
614
|
// RESUBMITTABLE — fall through and re-enroll, exactly as `startFeature`'s intake guard (feature.ts)
|
|
608
615
|
// treats a keyless `running` feature row. A terminal row (any `process_key`) already falls through.
|
|
609
616
|
if (trackedExisting && existing?.process_key != null && !TERMINAL_STATUSES.includes(trackedExisting.derived_status)) {
|
|
617
|
+
// Human approval is monotonic (issue #826): a gated caller adopting a live loop — possibly an
|
|
618
|
+
// ungated one another run started — narrows it to gated instead of inheriting its auto-merge.
|
|
619
|
+
// Idempotent, so a retry against our own gated loop is a no-op. A loop the engine no longer reports
|
|
620
|
+
// running is left alone; one already past its `human approval?` gateway can no longer be gated. A
|
|
621
|
+
// finalized PR (`converged_at` set) is past it, and `startMerge` has re-pointed its `process_key` at
|
|
622
|
+
// the merge-loop, so it is skipped. Mid-handoff (key re-pointed, `converged_at` not yet stamped) the
|
|
623
|
+
// write reaches the merge-loop but is inert: nothing there reads `humanApproval`.
|
|
624
|
+
if (gated && existing.converged_at == null) {
|
|
625
|
+
const loopKey = existing.process_key;
|
|
626
|
+
const match = (await engine.searchProcessInstances({ processInstanceKeys: [loopKey] })).find(
|
|
627
|
+
(s) => String(s.processInstanceKey) === loopKey,
|
|
628
|
+
);
|
|
629
|
+
const state = match ? String(match.state ?? "").trim().toUpperCase() : null;
|
|
630
|
+
if (state !== null && !ENGINE_TERMINAL_STATES.has(state)) {
|
|
631
|
+
await engine.setVariables({ scopeKey: loopKey, variables: { humanApproval: true } });
|
|
632
|
+
}
|
|
633
|
+
}
|
|
610
634
|
return { prKey: parsed.prKey, alreadyRunning: true };
|
|
611
635
|
}
|
|
612
636
|
|
|
@@ -724,6 +748,9 @@ async function submitPrCritical(
|
|
|
724
748
|
// `converged` for this PR without handing off to the merge-loop, independent of the global
|
|
725
749
|
// NANO_PR_AUTO_MERGE default. Only ever narrows (never forces merge on when auto-merge is off).
|
|
726
750
|
convergeOnly,
|
|
751
|
+
// Human approval before merge (issue #826): a converged PR parks at the `merge-approval` user
|
|
752
|
+
// task before `pr.finalize` hands it to the merge-loop. Pinned off when the PR won't merge.
|
|
753
|
+
humanApproval: gated,
|
|
727
754
|
// Cooperative abandon check (#76): the capability URL + the abort brief appended to the
|
|
728
755
|
// review-round agent's prompt, so it can stop before pushing if the run is cancelled.
|
|
729
756
|
abandonUrl: abUrl,
|
|
@@ -2469,13 +2496,24 @@ export async function pollFeatureDelivery(
|
|
|
2469
2496
|
// it would wedge `converging` forever with no PR process.
|
|
2470
2497
|
// Both heal via the SAME idempotent `submitPr`: it is idempotent on the PR key (a redundant call
|
|
2471
2498
|
// on an already-live PR early-returns `alreadyRunning`), and now treats a keyless non-terminal row
|
|
2472
|
-
// as resubmittable, so it re-creates the instance and installs `process_key`. `convergeOnly`
|
|
2473
|
-
//
|
|
2499
|
+
// as resubmittable, so it re-creates the instance and installs `process_key`. `convergeOnly` and
|
|
2500
|
+
// `humanApproval` mirror `converge-feature` — derived from the run's `auto_merge`/`human_approval`.
|
|
2474
2501
|
const partiallyEnrolled =
|
|
2475
2502
|
trackedPr != null && trackedPr.process_key == null && !TERMINAL_STATUSES.includes(trackedPr.derived_status);
|
|
2476
2503
|
if (prStatus === null || partiallyEnrolled) {
|
|
2477
2504
|
const parsed = parsePr(run.pr_key);
|
|
2478
|
-
if (parsed)
|
|
2505
|
+
if (parsed) {
|
|
2506
|
+
await submitPr(
|
|
2507
|
+
data,
|
|
2508
|
+
engine,
|
|
2509
|
+
parsed,
|
|
2510
|
+
[],
|
|
2511
|
+
MAX_ROUNDS,
|
|
2512
|
+
run.auto_merge !== 1,
|
|
2513
|
+
run.feature_key,
|
|
2514
|
+
run.human_approval === 1,
|
|
2515
|
+
);
|
|
2516
|
+
}
|
|
2479
2517
|
}
|
|
2480
2518
|
const { status, label } = deriveFeatureDelivery(prStatus);
|
|
2481
2519
|
if (run.status !== status || run.delivery_label !== label) {
|
|
@@ -3057,6 +3095,7 @@ export async function pollUserTasks(
|
|
|
3057
3095
|
[READINESS_ESCALATION_ELEMENT]: "plan",
|
|
3058
3096
|
[PR_WAIT_ANSWER_ELEMENT]: "pr",
|
|
3059
3097
|
[PR_WAIT_MERGE_ANSWER_ELEMENT]: "pr",
|
|
3098
|
+
[PR_MERGE_APPROVAL_ELEMENT]: "pr",
|
|
3060
3099
|
};
|
|
3061
3100
|
|
|
3062
3101
|
// The SINGLE enrichment derivation both discovery paths feed: resolve one open escalation task (by
|
package/app/userTasks.ts
CHANGED
|
@@ -52,6 +52,12 @@ export const PR_WAIT_ANSWER_ELEMENT = "wait-answer";
|
|
|
52
52
|
* canonical `completeUserTask` door and surfaced in this same Tasks inbox. */
|
|
53
53
|
export const PR_WAIT_MERGE_ANSWER_ELEMENT = "wait-merge-answer";
|
|
54
54
|
|
|
55
|
+
/** The optional human merge-approval user task (convergence-loop.bpmn, issue #826) — a converged PR
|
|
56
|
+
* parks here before `pr.finalize` hands it to the merge-loop. Approve merges; request changes loops
|
|
57
|
+
* the guidance (`answer`) back to the review agent on the same PR. HUMAN-only: never agent-answerable,
|
|
58
|
+
* so the fleet can never auto-approve a merge. */
|
|
59
|
+
export const PR_MERGE_APPROVAL_ELEMENT = "merge-approval";
|
|
60
|
+
|
|
55
61
|
/** The PR review-loop answer-recording service task (convergence-loop.bpmn) — the `pr.answer-escalation`
|
|
56
62
|
* job the token moves to IMMEDIATELY when `wait-answer` completes. Its ACTIVE element instance is the
|
|
57
63
|
* positive-evidence marker that an operator's answer is in-flight (recorded but the loop hasn't looped
|
|
@@ -237,6 +243,7 @@ export const USER_TASK_KIND_LABELS: Readonly<Record<string, string>> = {
|
|
|
237
243
|
[TRIAL_MERGE_ELEMENT]: "Trial merge",
|
|
238
244
|
[PR_WAIT_ANSWER_ELEMENT]: "PR review",
|
|
239
245
|
[PR_WAIT_MERGE_ANSWER_ELEMENT]: "PR merge",
|
|
246
|
+
[PR_MERGE_APPROVAL_ELEMENT]: "PR merge approval",
|
|
240
247
|
[CONFORMANCE_ESCALATION_ELEMENT]: "Conformance review",
|
|
241
248
|
[READINESS_ESCALATION_PF_ELEMENT]: "Upstream readiness stalled",
|
|
242
249
|
[READINESS_ESCALATION_ELEMENT]: "Readiness escalation",
|