@nanobpm/nano-workforce 0.129.0 → 0.130.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/.github/workflows/pr-title-lint.yml +5 -3
- package/.releaserc.json +36 -2
- package/AGENTS.md +10 -6
- package/CHANGELOG.md +12 -0
- package/app/delivery.ts +2 -2
- package/app/deliveryUnitStatus.test.ts +143 -0
- package/app/deliveryUnitStatus.ts +242 -0
- package/app/mergeEscalationUserTask.test.ts +21 -55
- package/app/mergeLoopBehaviour.test.ts +446 -0
- package/app/plan.ts +3 -3
- package/package.json +2 -1
- package/resources/processes/merge-loop.bpmn +692 -395
- package/app/mergeCiReattempt.test.ts +0 -138
- package/app/mergeEscalationQuestion.test.ts +0 -190
- package/app/mergeRebaseArm.test.ts +0 -140
- package/app/mergeRetryArm.test.ts +0 -100
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
// Structural regression guard for the CI-concurrency-cancellation drift class (issue #348).
|
|
2
|
-
//
|
|
3
|
-
// The merge loop escalated a human whenever `senior:fix-ci` returned `blocked` — even when the
|
|
4
|
-
// failing required checks were STALE/TRANSIENT (CANCELLED runs superseded by a newer green run on
|
|
5
|
-
// the identical head SHA) and the agent honestly pushed nothing. The `fix-ci` prompt actively
|
|
6
|
-
// funnelled that self-healing case into `blocked`, and `blocked` routed straight to the merge
|
|
7
|
-
// escalation user task. A phantom-blocked, self-healing merge paged a human.
|
|
8
|
-
//
|
|
9
|
-
// The fix adds a first-class re-attempt path and a reconcile-before-escalate guard:
|
|
10
|
-
//
|
|
11
|
-
// 1. `status = "reattempt"` (a first-class fix-ci verdict for stale/transient checks) routes to
|
|
12
|
-
// `arm-merge`, re-queuing the merge from ground truth — no human, and declared explicitly
|
|
13
|
-
// beside the `f_ci_reconcile` empty-status default rather than relying on the fall-through.
|
|
14
|
-
// 2. A `blocked` verdict with no push (`pushed != true`) reconciles ONCE via ground truth
|
|
15
|
-
// (`gw-ci-blocked` → `ci-reconcile` → re-arm the poller) and escalates only if it is STILL
|
|
16
|
-
// blocked — so even a mislabelled `blocked` self-heals.
|
|
17
|
-
//
|
|
18
|
-
// Pure text assertions over the committed BPMN (no engine), matching the repo's lightweight
|
|
19
|
-
// model-guard style (see mergeEscalationQuestion.test.ts, mergeRebaseArm.test.ts).
|
|
20
|
-
|
|
21
|
-
import { test } from "node:test";
|
|
22
|
-
import { assert, assertStringIncludes } from "#test-assert";
|
|
23
|
-
import { readFileSync } from "node:fs";
|
|
24
|
-
|
|
25
|
-
const bpmn = readFileSync("resources/processes/merge-loop.bpmn", "utf8");
|
|
26
|
-
// Collapse whitespace so attribute-order / line-wrapping churn doesn't make the assertions brittle.
|
|
27
|
-
const flat = bpmn.replace(/\s+/g, " ");
|
|
28
|
-
|
|
29
|
-
function flowElement(id: string): string | null {
|
|
30
|
-
const re = new RegExp(
|
|
31
|
-
`<bpmn:sequenceFlow\\b[^>]*?\\bid="${id}"[^>]*?(?:/>|>(?:(?!<bpmn:sequenceFlow\\b).)*?</bpmn:sequenceFlow>)`,
|
|
32
|
-
);
|
|
33
|
-
const m = flat.match(re);
|
|
34
|
-
return m ? m[0] : null;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function flowHasId(id: string, source: string, target: string): boolean {
|
|
38
|
-
const el = flowElement(id);
|
|
39
|
-
if (!el) return false;
|
|
40
|
-
return el.includes(`sourceRef="${source}"`) && el.includes(`targetRef="${target}"`);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function serviceTask(id: string): string | null {
|
|
44
|
-
const m = flat.match(new RegExp(`<bpmn:serviceTask\\b[^>]*\\bid="${id}"[\\s\\S]*?</bpmn:serviceTask>`));
|
|
45
|
-
return m ? m[0].replace(/"/g, '"').replace(/&/g, "&").replace(/ /g, "\n") : null;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
test("a first-class `reattempt` verdict re-attempts the merge (arm-merge), not escalation", () => {
|
|
49
|
-
// gw-ci-result must carry an explicit `status = "reattempt"` arm to arm-merge, declared beside
|
|
50
|
-
// (not folded into) the empty-status `f_ci_reconcile` default.
|
|
51
|
-
const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-ci-result"[\s\S]*?<\/bpmn:exclusiveGateway>/);
|
|
52
|
-
assert(gw, "gw-ci-result gateway must exist");
|
|
53
|
-
assertStringIncludes(gw![0], "f_ci_reattempt", "gw-ci-result must declare the reattempt outgoing arm");
|
|
54
|
-
|
|
55
|
-
const reattempt = flowElement("f_ci_reattempt");
|
|
56
|
-
assert(reattempt, "f_ci_reattempt flow missing");
|
|
57
|
-
assert(
|
|
58
|
-
flowHasId("f_ci_reattempt", "gw-ci-result", "arm-merge"),
|
|
59
|
-
"a reattempt verdict must re-arm the merge poller (arm-merge), never escalate",
|
|
60
|
-
);
|
|
61
|
-
assertStringIncludes(reattempt!, 'status = "reattempt"', "the reattempt arm must be gated on status = reattempt");
|
|
62
|
-
// It must be an EXPLICIT labelled flow, not the empty-status default.
|
|
63
|
-
assert(!/default="f_ci_reattempt"/.test(flat), "reattempt must be an explicit arm, not the gateway default");
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test("a fix-ci `reattempt` result does NOT create a merge escalation", () => {
|
|
67
|
-
// No flow originating from the reattempt classification may reach the merge-escalation task.
|
|
68
|
-
assert(
|
|
69
|
-
!flowHasId("f_ci_reattempt", "gw-ci-result", "merge-esc-attempt"),
|
|
70
|
-
"reattempt must never route to merge-esc-attempt",
|
|
71
|
-
);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test("a blocked-with-no-push verdict reconciles once from ground truth before escalating", () => {
|
|
75
|
-
// The `blocked` arm no longer flows straight into the escalation: it passes through gw-ci-blocked.
|
|
76
|
-
assert(
|
|
77
|
-
flowHasId("f_ci_blocked", "gw-ci-result", "gw-ci-blocked"),
|
|
78
|
-
"a blocked verdict must route through gw-ci-blocked, not straight to merge-esc-attempt",
|
|
79
|
-
);
|
|
80
|
-
|
|
81
|
-
const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-ci-blocked"[^>]*>/);
|
|
82
|
-
assert(gw, "gw-ci-blocked gateway must exist");
|
|
83
|
-
// Default is escalate (still blocked), so a missing/true reconcile flag never wedges.
|
|
84
|
-
assertStringIncludes(gw![0], 'default="f_cib_esc"', "gw-ci-blocked must default to escalation");
|
|
85
|
-
|
|
86
|
-
// The reconcile-once arm: pushed nothing AND not yet reconciled → re-derive via ci-reconcile.
|
|
87
|
-
const recon = flowElement("f_cib_recon");
|
|
88
|
-
assert(recon, "f_cib_recon flow missing");
|
|
89
|
-
assert(flowHasId("f_cib_recon", "gw-ci-blocked", "ci-reconcile"), "reconcile arm must target ci-reconcile");
|
|
90
|
-
assertStringIncludes(recon!, "pushed != true", "reconcile only when the agent pushed nothing");
|
|
91
|
-
assertStringIncludes(recon!, "ciBlockedReconciled != true", "reconcile at most once");
|
|
92
|
-
|
|
93
|
-
// The escalate arm (default): still blocked → the human merge escalation.
|
|
94
|
-
assert(flowHasId("f_cib_esc", "gw-ci-blocked", "merge-esc-attempt"), "the still-blocked arm must escalate");
|
|
95
|
-
assert(
|
|
96
|
-
!/conditionExpression/.test(flowElement("f_cib_esc") ?? ""),
|
|
97
|
-
"f_cib_esc is the default arm and must not carry a conditionExpression",
|
|
98
|
-
);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
test("ci-reconcile re-arms the canonical merge poller and marks the reconcile as spent", () => {
|
|
102
|
-
const el = serviceTask("ci-reconcile");
|
|
103
|
-
assert(el, "ci-reconcile service task must exist");
|
|
104
|
-
// Reuses the canonical arm-merge worker — one poller implementation, no second poller pass.
|
|
105
|
-
assertStringIncludes(el!, 'type="pr.arm-merge"', "ci-reconcile must reuse the canonical pr.arm-merge worker");
|
|
106
|
-
// Marks the reconcile spent so the SECOND blocked (still blocked after re-derivation) escalates.
|
|
107
|
-
const outs = el!.match(/<zeebe:output\b[^>]*\/>/g) ?? [];
|
|
108
|
-
assert(
|
|
109
|
-
outs.some((t) => t.includes('target="ciBlockedReconciled"') && t.includes('source="=true"')),
|
|
110
|
-
"ci-reconcile must set ciBlockedReconciled = true so a still-blocked PR escalates on the next pass",
|
|
111
|
-
);
|
|
112
|
-
// Re-derivation flows back through the mergeable wait (re-runs the ground-truth mergeable gate).
|
|
113
|
-
assert(flowHasId("f_cib_armed", "ci-reconcile", "wait-mergeable"), "ci-reconcile must re-enter wait-mergeable");
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
test("arm-merge clears the reconcile flag each loop so a fresh block episode gets its own reconcile", () => {
|
|
117
|
-
const el = serviceTask("arm-merge");
|
|
118
|
-
assert(el, "arm-merge service task must exist");
|
|
119
|
-
const outs = el!.match(/<zeebe:output\b[^>]*\/>/g) ?? [];
|
|
120
|
-
assert(
|
|
121
|
-
outs.some((t) => t.includes('target="ciBlockedReconciled"') && t.includes('source="=null"')),
|
|
122
|
-
"arm-merge must reset ciBlockedReconciled each loop so a later, unrelated block still reconciles once",
|
|
123
|
-
);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
test("regression: a stale/transient fix-ci result can no longer page a human", () => {
|
|
127
|
-
// The old wedge: `status = "blocked"` flowing directly into merge-esc-attempt. The blocked
|
|
128
|
-
// verdict now routes through gw-ci-blocked (reconcile-before-escalate), never straight to the
|
|
129
|
-
// escalation.
|
|
130
|
-
assert(
|
|
131
|
-
!flowHasId("f_ci_blocked", "gw-ci-result", "merge-esc-attempt"),
|
|
132
|
-
"the blocked verdict must not route directly into merge-esc-attempt (the #348 phantom escalation)",
|
|
133
|
-
);
|
|
134
|
-
// The blocked arm targets the reconcile gateway; the reattempt arm re-arms the poller. Neither
|
|
135
|
-
// gw-ci-result arm may target the escalation directly.
|
|
136
|
-
assertStringIncludes(flowElement("f_ci_blocked") ?? "", 'targetRef="gw-ci-blocked"');
|
|
137
|
-
assertStringIncludes(flowElement("f_ci_reattempt") ?? "", 'targetRef="arm-merge"');
|
|
138
|
-
});
|
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
// Regression guard for the question-less merge escalation defect (issue #329).
|
|
2
|
-
//
|
|
3
|
-
// The merge loop (`resources/processes/merge-loop.bpmn`) raised escalations with NO question
|
|
4
|
-
// whenever a PR was blocked by anything other than a merge conflict: `merge-esc-attempt` called
|
|
5
|
-
// `pr.persist-escalation` with no `question`/`status` ioMapping, and its output flowed
|
|
6
|
-
// UNCONDITIONALLY into `wait-merge-answer`. Two coupled defects fell out of that:
|
|
7
|
-
//
|
|
8
|
-
// 1. A blank question surfaced on the merge-driving inbox — the human was asked to answer but
|
|
9
|
-
// told nothing (observed live on nano-ide PR #354).
|
|
10
|
-
// 2. Per ADR 0002 §1 a blank question is a NON-escalation: `pr.persist-escalation` opens no row
|
|
11
|
-
// and returns `escalated:false`. The convergence loop honours this via a `gw-escalated`
|
|
12
|
-
// branch; the merge loop had none, so a question-less job still parked a dead
|
|
13
|
-
// `wait-merge-answer` with nothing for a human to answer.
|
|
14
|
-
//
|
|
15
|
-
// The fix (mirroring the convergence loop): give `merge-esc-attempt` a human-actionable
|
|
16
|
-
// `status`/`question` that distinguishes its four trigger conditions, and add a `gw-merge-escalated`
|
|
17
|
-
// guard so a `persist-escalation` returning `escalated:false` re-enters the loop (re-arms the
|
|
18
|
-
// poller) instead of parking a dead wait.
|
|
19
|
-
//
|
|
20
|
-
// These are pure text assertions over the committed BPMN (no engine), matching the repo's
|
|
21
|
-
// lightweight model-guard style (see mergeRebaseArm.test.ts, mergeEscalationUserTask.test.ts).
|
|
22
|
-
|
|
23
|
-
import { test } from "node:test";
|
|
24
|
-
import { assert, assertStringIncludes } from "#test-assert";
|
|
25
|
-
import { readFileSync } from "node:fs";
|
|
26
|
-
|
|
27
|
-
const bpmn = readFileSync("resources/processes/merge-loop.bpmn", "utf8");
|
|
28
|
-
// Collapse whitespace so attribute-order / line-wrapping churn doesn't make the assertions brittle.
|
|
29
|
-
const flat = bpmn.replace(/\s+/g, " ");
|
|
30
|
-
|
|
31
|
-
function flowHasId(id: string, source: string, target: string): boolean {
|
|
32
|
-
const m = flat.match(new RegExp(`<bpmn:sequenceFlow\\b[^>]*\\bid="${id}"[^>]*(?:/>|>)`));
|
|
33
|
-
if (!m) return false;
|
|
34
|
-
const tag = m[0];
|
|
35
|
-
return tag.includes(`sourceRef="${source}"`) && tag.includes(`targetRef="${target}"`);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function gatewayDefault(id: string, def: string): boolean {
|
|
39
|
-
const m = flat.match(new RegExp(`<bpmn:exclusiveGateway\\b[^>]*\\bid="${id}"[^>]*>`));
|
|
40
|
-
if (!m) return false;
|
|
41
|
-
return m[0].includes(`default="${def}"`);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// The <serviceTask> element for merge-esc-attempt, including its ioMapping. Unescape XML entities so
|
|
45
|
-
// FEEL string literals (authored as `"ready"` inside the attribute) read naturally here.
|
|
46
|
-
const escAttemptRaw = flat.match(/<bpmn:serviceTask\b[^>]*\bid="merge-esc-attempt"[\s\S]*?<\/bpmn:serviceTask>/);
|
|
47
|
-
const escAttempt = escAttemptRaw
|
|
48
|
-
? [escAttemptRaw[0].replace(/"/g, '"').replace(/&/g, "&").replace(/ /g, "\n")]
|
|
49
|
-
: null;
|
|
50
|
-
|
|
51
|
-
test("merge-esc-attempt carries a non-blank, human-actionable status + question", () => {
|
|
52
|
-
assert(escAttempt, "merge-esc-attempt service task must exist");
|
|
53
|
-
const el = escAttempt![0];
|
|
54
|
-
// Mirror the merge-esc-conflict mapping style: an explicit `blocked` status…
|
|
55
|
-
assertStringIncludes(el, "<zeebe:ioMapping", "merge-esc-attempt must set an ioMapping (was absent — the #329 defect)");
|
|
56
|
-
assertStringIncludes(el, 'target="status"', "merge-esc-attempt must set a `status`");
|
|
57
|
-
assertStringIncludes(el, 'target="question"', "merge-esc-attempt must set a non-blank `question`");
|
|
58
|
-
// Tighten: assert the explicit `status` INPUT MAPPING sets blocked, not merely the substring
|
|
59
|
-
// `="blocked"` (which the FEEL question's `agentVerdict = "blocked"` comparison would also satisfy
|
|
60
|
-
// even if the status mapping were removed/changed). Match tolerant of attribute order/spacing: the
|
|
61
|
-
// file is XML and a formatter could reorder `source`/`target` within the tag.
|
|
62
|
-
const escInputs = el.match(/<zeebe:input\b[^>]*\/>/g) ?? [];
|
|
63
|
-
const setsBlockedStatus = escInputs.some(
|
|
64
|
-
(t) => t.includes('target="status"') && t.includes('source="="blocked""'),
|
|
65
|
-
);
|
|
66
|
-
assert(setsBlockedStatus, "the explicit `status` input mapping must set `blocked`");
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test("arm-merge clears the prior verdict `status` so a stale `blocked` cannot misclassify the CI-fix SLA escalation", () => {
|
|
70
|
-
// merge-esc-attempt captures `agentVerdict = status` to split its CI could-not-fix vs SLA question
|
|
71
|
-
// arms. On the SLA boundary path (`f_ci_sla`) no worker sets a fresh `status`, and every escalation
|
|
72
|
-
// task overwrites `status = "blocked"` (merge-esc-attempt line 194, merge-esc-conflict line 174).
|
|
73
|
-
// Without a reset, a retry after any prior escalation re-enters fix-ci with `status` still
|
|
74
|
-
// "blocked", so an SLA timeout would render the wrong ("could not fix") question. arm-merge is the
|
|
75
|
-
// single loop hub every fix-ci entry passes through, so clearing `status` there (to null) each
|
|
76
|
-
// iteration guarantees a genuine SLA reads no stale verdict. Nothing between arm-merge and the next
|
|
77
|
-
// verdict-setter (fix-ci/rebase) reads `status`, so the reset is safe.
|
|
78
|
-
const armRaw = flat.match(/<bpmn:serviceTask\b[^>]*\bid="arm-merge"[\s\S]*?<\/bpmn:serviceTask>/);
|
|
79
|
-
assert(armRaw, "arm-merge service task must exist");
|
|
80
|
-
const armOutputs = armRaw![0].match(/<zeebe:output\b[^>]*\/>/g) ?? [];
|
|
81
|
-
const clearsStatus = armOutputs.some(
|
|
82
|
-
(t) => t.includes('target="status"') && t.includes('source="=null"'),
|
|
83
|
-
);
|
|
84
|
-
assert(clearsStatus, "arm-merge must reset `status` to null each loop iteration so a stale `blocked` cannot misclassify the SLA escalation");
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
test("the question distinguishes all four blocked/SLA triggers rather than a single generic string", () => {
|
|
88
|
-
// Four flows route into merge-esc-attempt — the gate `blocked` default, CI could-not-fix,
|
|
89
|
-
// rebase could-not-resolve, and the CI-fix SLA. Each is a legitimately different escalation and
|
|
90
|
-
// the question must explain which one fired.
|
|
91
|
-
assert(escAttempt, "merge-esc-attempt service task must exist");
|
|
92
|
-
const el = escAttempt![0];
|
|
93
|
-
// gate blocked (gw-merge default): distinguishes on the `ready` mergeState + surfaces mergeStatus.
|
|
94
|
-
assertStringIncludes(el, 'mergeState = "ready"', "must branch on the gate-blocked (ready) trigger");
|
|
95
|
-
assertStringIncludes(el, "mergeStatus", "the gate-blocked question must surface the merge result");
|
|
96
|
-
// rebase could-not-resolve (conflict arm).
|
|
97
|
-
assertStringIncludes(el, 'mergeState = "conflict"', "must branch on the rebase (conflict) trigger");
|
|
98
|
-
// CI could-not-fix vs CI SLA both arrive with mergeState = blocked — split on the agent verdict,
|
|
99
|
-
// captured into a dedicated `agentVerdict` binding so the escalation-classification `status =
|
|
100
|
-
// "blocked"` override in the SAME ioMapping cannot make the SLA branch unreachable (issue #329
|
|
101
|
-
// review). Assert the question branches on that binding, not on the overwritten `status`.
|
|
102
|
-
assertStringIncludes(el, "agentVerdict", "must capture the agent verdict into a dedicated binding");
|
|
103
|
-
assertStringIncludes(el, 'agentVerdict = "blocked"', "must branch CI could-not-fix vs SLA on the agent verdict binding, not the overwritten status");
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
test("retry-budget-exhausted escalation reads as a repeated race, not a generic merge refusal", () => {
|
|
107
|
-
// `f_mr_giveup` (transient merge-retry budget exhausted) routes into merge-esc-attempt with
|
|
108
|
-
// mergeState = "ready" AND mergeStatus = "retry". Without a dedicated branch this reused the
|
|
109
|
-
// generic gate-blocked ("Investigate why GitHub refused the merge") text, which is misleading for
|
|
110
|
-
// a repeated base/head-moved race whose retry budget simply ran out. The question must branch on
|
|
111
|
-
// mergeStatus = "retry" — ahead of the generic `mergeState = "ready"` arm — and name the budget.
|
|
112
|
-
assert(escAttempt, "merge-esc-attempt service task must exist");
|
|
113
|
-
const el = escAttempt![0];
|
|
114
|
-
assertStringIncludes(el, 'mergeStatus = "retry"', "must branch the retry-budget-exhausted escalation on mergeStatus = retry");
|
|
115
|
-
assertStringIncludes(el, "mergeRetryMax", "the retry-exhausted question must surface the retry budget");
|
|
116
|
-
// The retry branch must precede the generic `mergeState = "ready"` branch, or the generic arm
|
|
117
|
-
// (also true here) would shadow it and re-emit the misleading refusal text.
|
|
118
|
-
const retryIdx = el.indexOf('mergeStatus = "retry"');
|
|
119
|
-
const readyIdx = el.indexOf('mergeState = "ready"');
|
|
120
|
-
assert(retryIdx !== -1 && readyIdx !== -1 && retryIdx < readyIdx, "the retry branch must be evaluated before the generic ready branch");
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
test("a gw-merge-escalated guard honours persist-escalation's escalated:false (mirrors the convergence loop)", () => {
|
|
124
|
-
// The escalation output no longer flows UNCONDITIONALLY into the durable answer wait: it passes
|
|
125
|
-
// through a gateway that reads the worker's `escalated` output.
|
|
126
|
-
assert(
|
|
127
|
-
flowHasId("f_m_escA", "merge-esc-attempt", "gw-merge-escalated"),
|
|
128
|
-
"merge-esc-attempt must route through gw-merge-escalated, not straight to wait-merge-answer",
|
|
129
|
-
);
|
|
130
|
-
// escalated:true → park the native user task for a human to answer.
|
|
131
|
-
assert(
|
|
132
|
-
flowHasId("f_m_escWait", "gw-merge-escalated", "wait-merge-answer"),
|
|
133
|
-
"gw-merge-escalated → wait-merge-answer (escalated) missing",
|
|
134
|
-
);
|
|
135
|
-
const escWait = flat.match(/<bpmn:sequenceFlow[^>]*id="f_m_escWait"[\s\S]*?<\/bpmn:sequenceFlow>/);
|
|
136
|
-
assert(escWait, "f_m_escWait flow missing");
|
|
137
|
-
assertStringIncludes(escWait![0], "escalated = true", "the wait arm must be guarded by escalated = true");
|
|
138
|
-
// escalated:false (a non-escalation, e.g. a blank question) → re-enter the loop, NOT a dead wait.
|
|
139
|
-
assert(
|
|
140
|
-
gatewayDefault("gw-merge-escalated", "f_m_escReenter"),
|
|
141
|
-
"gw-merge-escalated must default to f_m_escReenter (re-enter, not park)",
|
|
142
|
-
);
|
|
143
|
-
assert(
|
|
144
|
-
flowHasId("f_m_escReenter", "gw-merge-escalated", "arm-merge"),
|
|
145
|
-
"f_m_escReenter must re-arm the merge poller instead of parking a dead wait-merge-answer",
|
|
146
|
-
);
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
test("regression: a question-less escalation can no longer park a dead wait-merge-answer", () => {
|
|
150
|
-
// The exact #329 wedge: `merge-esc-attempt → wait-merge-answer` as a direct, unconditional edge.
|
|
151
|
-
// It must be gone — the only path into the answer wait from the attempt arm is now guarded by
|
|
152
|
-
// `escalated = true`.
|
|
153
|
-
assert(
|
|
154
|
-
!flowHasId("f_m_escA", "merge-esc-attempt", "wait-merge-answer"),
|
|
155
|
-
"merge-esc-attempt must NOT flow directly into wait-merge-answer (the #329 dead-wait defect)",
|
|
156
|
-
);
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
// ── Draft PR escalation (issue #454) ─────────────────────────────────────────────────────────────
|
|
160
|
-
//
|
|
161
|
-
// A draft PR is never landable — `classifyMergeability` now yields a first-class `"draft"` verdict
|
|
162
|
-
// (app/github.ts) that the poller (app/service.ts) publishes as `mergeState = "draft"`. It routes
|
|
163
|
-
// through `gw-mergeable`'s default (`f_m_mBlocked → merge-esc-conflict`), so `merge-esc-conflict`'s
|
|
164
|
-
// question must recognise `draft` and give the ACTIONABLE remedy (mark it ready) instead of the
|
|
165
|
-
// generic "resolve the conflict or failing required check" text (which is the wrong remedy for a
|
|
166
|
-
// draft), and — before this fix — instead of `merge-esc-attempt`'s misleading "the merge attempt did
|
|
167
|
-
// not land (blocked), investigate why GitHub refused the merge".
|
|
168
|
-
const escConflictRaw = flat.match(/<bpmn:serviceTask\b[^>]*\bid="merge-esc-conflict"[\s\S]*?<\/bpmn:serviceTask>/);
|
|
169
|
-
const escConflict = escConflictRaw ? escConflictRaw[0].replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&") : null;
|
|
170
|
-
|
|
171
|
-
test("merge-esc-conflict gives a draft PR an actionable 'mark it ready' question (issue #454)", () => {
|
|
172
|
-
assert(escConflict, "merge-esc-conflict service task must exist");
|
|
173
|
-
const el = escConflict!;
|
|
174
|
-
// Branches on the draft verdict…
|
|
175
|
-
assertStringIncludes(el, 'mergeState = "draft"', "merge-esc-conflict must branch on the draft verdict");
|
|
176
|
-
// …with the actionable remedy (mark it ready), not the conflict/failing-check remedy.
|
|
177
|
-
assertStringIncludes(el, "draft and can't be merged", "the draft question must state the PR is in draft");
|
|
178
|
-
assertStringIncludes(el, "gh pr ready", "the draft question must tell the human to mark it ready");
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
test("merge-esc-conflict keeps the non-draft not-mergeable branch intact (regression guard, issue #454)", () => {
|
|
182
|
-
assert(escConflict, "merge-esc-conflict service task must exist");
|
|
183
|
-
const el = escConflict!;
|
|
184
|
-
// The original conflict/failing-check message must still be reachable for non-draft states.
|
|
185
|
-
assertStringIncludes(el, "This PR is not mergeable (state:", "the non-draft not-mergeable message must remain");
|
|
186
|
-
// The draft branch must precede the generic message so it isn't shadowed.
|
|
187
|
-
const draftIdx = el.indexOf('mergeState = "draft"');
|
|
188
|
-
const genericIdx = el.indexOf("This PR is not mergeable (state:");
|
|
189
|
-
assert(draftIdx !== -1 && genericIdx !== -1 && draftIdx < genericIdx, "the draft branch must be evaluated before the generic not-mergeable message");
|
|
190
|
-
});
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
// Structural regression guard for the merge-loop rebase remediation arm (issue #42).
|
|
2
|
-
//
|
|
3
|
-
// #42: the conflict arm routed a human's escalation answer straight back to `arm-merge`
|
|
4
|
-
// (`f_m_answer → arm-merge`) with NO actor that rebases the branch, so a `CONFLICTING`
|
|
5
|
-
// (moved-base) PR re-escalated forever — a human-in-the-loop livelock. The fix mirrors the
|
|
6
|
-
// CI-fix arm: a `mergeState = "conflict"` verdict goes to a budgeted `senior:rebase` agent, and
|
|
7
|
-
// escalates to a human only on the result gate.
|
|
8
|
-
//
|
|
9
|
-
// This test asserts the arm's topology on the committed model so it cannot regress silently the
|
|
10
|
-
// way it originally shipped. It is a pure text assertion over the BPMN (no engine), matching the
|
|
11
|
-
// repo's lightweight model-guard style.
|
|
12
|
-
|
|
13
|
-
import { test } from "node:test";
|
|
14
|
-
import { assert, assertStringIncludes } from "#test-assert";
|
|
15
|
-
import { readFileSync } from "node:fs";
|
|
16
|
-
|
|
17
|
-
const bpmn = readFileSync("resources/processes/merge-loop.bpmn", "utf8");
|
|
18
|
-
|
|
19
|
-
// Collapse whitespace so attribute-order / line-wrapping churn doesn't make the assertions brittle.
|
|
20
|
-
const flat = bpmn.replace(/\s+/g, " ");
|
|
21
|
-
|
|
22
|
-
// A `<sequenceFlow>` whose source/target match, regardless of attribute order or an inline
|
|
23
|
-
// conditionExpression child. Returns true if such a flow is present.
|
|
24
|
-
function hasFlow(source: string, target: string): boolean {
|
|
25
|
-
const re = new RegExp(
|
|
26
|
-
`<bpmn:sequenceFlow\\b[^>]*\\bsourceRef="${source}"[^>]*\\btargetRef="${target}"|` +
|
|
27
|
-
`<bpmn:sequenceFlow\\b[^>]*\\btargetRef="${target}"[^>]*\\bsourceRef="${source}"`,
|
|
28
|
-
);
|
|
29
|
-
return re.test(flat);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// Assert a specific `<sequenceFlow>` (matched by id) has the given source/target, regardless of
|
|
33
|
-
// attribute order. Unlike `hasFlow`, this pins the *named* flow so a test can't be satisfied by a
|
|
34
|
-
// sibling flow that happens to share the same source/target (e.g. a success arm masking a default).
|
|
35
|
-
function flowHasId(id: string, source: string, target: string): boolean {
|
|
36
|
-
const m = flat.match(new RegExp(`<bpmn:sequenceFlow\\b[^>]*\\bid="${id}"[^>]*/?>`));
|
|
37
|
-
if (!m) return false;
|
|
38
|
-
const tag = m[0];
|
|
39
|
-
return tag.includes(`sourceRef="${source}"`) && tag.includes(`targetRef="${target}"`);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Assert an `<exclusiveGateway>` (matched by id) declares the given `default` flow, regardless of
|
|
43
|
-
// attribute order. Matching the start tag by id and reading `default` from it keeps the guard
|
|
44
|
-
// robust to harmless XML reformatting (attribute reordering / wrapping) that a fixed-order literal
|
|
45
|
-
// substring would spuriously trip on.
|
|
46
|
-
function gatewayDefault(id: string, def: string): boolean {
|
|
47
|
-
const m = flat.match(new RegExp(`<bpmn:exclusiveGateway\\b[^>]*\\bid="${id}"[^>]*>`));
|
|
48
|
-
if (!m) return false;
|
|
49
|
-
return m[0].includes(`default="${def}"`);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
test("conflict routes to the rebase budget gate, not straight to a human", () => {
|
|
53
|
-
// The conflict verdict must reach the auto-rebase gate…
|
|
54
|
-
assert(hasFlow("gw-mergeable", "gw-rebase"), "gw-mergeable → gw-rebase (conflict) missing");
|
|
55
|
-
// …guarded by the exact conflict condition (DIRTY → mergeState = "conflict").
|
|
56
|
-
assertStringIncludes(flat, 'mergeState = "conflict"');
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test("rebase arm mirrors the fix-ci arm: budget gate → agent → result gate", () => {
|
|
60
|
-
// Budget gate: within budget → the agent; exhausted → the (existing) conflict escalation.
|
|
61
|
-
assert(hasFlow("gw-rebase", "rebase"), "gw-rebase → rebase (within budget) missing");
|
|
62
|
-
assert(hasFlow("gw-rebase", "merge-esc-conflict"), "gw-rebase → merge-esc-conflict (budget exhausted) missing");
|
|
63
|
-
assertStringIncludes(flat, "rebaseRound < rebaseMax");
|
|
64
|
-
|
|
65
|
-
// The agent is the senior:rebase fleet task, carrying its base prompt via the rebase.md linked resource.
|
|
66
|
-
assertStringIncludes(flat, 'type="senior:rebase"');
|
|
67
|
-
assertStringIncludes(flat, 'resourceId="rebase.md"');
|
|
68
|
-
|
|
69
|
-
// Agent → result gate; the round counter advances so the budget can actually be exhausted.
|
|
70
|
-
assert(hasFlow("rebase", "gw-rebase-result"), "rebase → gw-rebase-result missing");
|
|
71
|
-
assertStringIncludes(flat, "=rebaseRound + 1");
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test("rebase result: success re-arms the poller; unresolved escalates to a human", () => {
|
|
75
|
-
// Success loops back to re-attempt the merge — forward progress, no human needed.
|
|
76
|
-
assert(hasFlow("gw-rebase-result", "arm-merge"), "gw-rebase-result → arm-merge (rebased) missing");
|
|
77
|
-
assertStringIncludes(flat, 'status = "rebased"');
|
|
78
|
-
// A genuine (semantic) conflict the agent can't resolve escalates to the human attempt path.
|
|
79
|
-
assert(
|
|
80
|
-
hasFlow("gw-rebase-result", "merge-esc-attempt"),
|
|
81
|
-
"gw-rebase-result → merge-esc-attempt (could not resolve) missing",
|
|
82
|
-
);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test("rebase result: a missing/ambiguous status reconciles from ground truth, not escalation (#134)", () => {
|
|
86
|
-
// #134 / Magikcraft/nano-bpm#751: the rebase agent resolved everything and the PR became
|
|
87
|
-
// MERGEABLE, but it emitted no machine-readable `status`, so the old default arm escalated a
|
|
88
|
-
// PR that was already landable. Ground truth is authoritative: the default (no-verdict) arm
|
|
89
|
-
// now re-arms the merge poller, which re-checks `gw-mergeable` from GitHub state — bounded by
|
|
90
|
-
// the existing rebaseMax budget — instead of pulling in a human.
|
|
91
|
-
assert(
|
|
92
|
-
gatewayDefault("gw-rebase-result", "f_reb_reconcile"),
|
|
93
|
-
'gw-rebase-result must default to f_reb_reconcile (reconcile)',
|
|
94
|
-
);
|
|
95
|
-
// Pin the *default* reconcile flow itself, not just any gw-rebase-result → arm-merge edge (the
|
|
96
|
-
// success arm `f_reb_rebased` shares that target), so a mis-wired default can't pass silently.
|
|
97
|
-
assert(
|
|
98
|
-
flowHasId("f_reb_reconcile", "gw-rebase-result", "arm-merge"),
|
|
99
|
-
"f_reb_reconcile must default gw-rebase-result → arm-merge (reconcile)",
|
|
100
|
-
);
|
|
101
|
-
// Escalation is now reserved for the agent's explicit "I cannot proceed" verdict.
|
|
102
|
-
assertStringIncludes(flat, 'id="f_reb_blocked"');
|
|
103
|
-
const rebBlocked = flat.match(/<bpmn:sequenceFlow[^>]*id="f_reb_blocked"[\s\S]*?<\/bpmn:sequenceFlow>/);
|
|
104
|
-
assert(rebBlocked, "f_reb_blocked flow missing");
|
|
105
|
-
assertStringIncludes(rebBlocked![0], 'status = "blocked"');
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
test("ci-fix result: a missing/ambiguous status reconciles from ground truth, not escalation (#134)", () => {
|
|
109
|
-
// Symmetric to the rebase arm: the CI-fix agent's no-verdict default re-arms the merge poller
|
|
110
|
-
// (ground-truth re-check, bounded by ciFixMax) rather than escalating a possibly-green PR.
|
|
111
|
-
assert(
|
|
112
|
-
gatewayDefault("gw-ci-result", "f_ci_reconcile"),
|
|
113
|
-
'gw-ci-result must default to f_ci_reconcile (reconcile)',
|
|
114
|
-
);
|
|
115
|
-
// Pin the *default* reconcile flow itself, not just any gw-ci-result → arm-merge edge (the
|
|
116
|
-
// success arm `f_ci_fixed` shares that target), so a mis-wired default can't pass silently.
|
|
117
|
-
assert(
|
|
118
|
-
flowHasId("f_ci_reconcile", "gw-ci-result", "arm-merge"),
|
|
119
|
-
"f_ci_reconcile must default gw-ci-result → arm-merge (reconcile)",
|
|
120
|
-
);
|
|
121
|
-
// Escalation reserved for the agent's explicit `blocked` verdict — but now via a
|
|
122
|
-
// reconcile-before-escalate guard (issue #348): a `blocked` with no push reconciles once from
|
|
123
|
-
// ground truth, and only a still-blocked PR reaches the human escalation.
|
|
124
|
-
const ciBlocked = flat.match(/<bpmn:sequenceFlow[^>]*id="f_ci_blocked"[\s\S]*?<\/bpmn:sequenceFlow>/);
|
|
125
|
-
assert(ciBlocked, "f_ci_blocked flow missing");
|
|
126
|
-
assertStringIncludes(ciBlocked![0], 'status = "blocked"');
|
|
127
|
-
assert(hasFlow("gw-ci-result", "gw-ci-blocked"), "blocked verdict must pass through gw-ci-blocked (reconcile-before-escalate)");
|
|
128
|
-
assert(hasFlow("gw-ci-blocked", "merge-esc-attempt"), "gw-ci-blocked → merge-esc-attempt (still blocked) missing");
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
test("regression: the conflict verdict passes through the rebase actor, not straight to escalation", () => {
|
|
132
|
-
// The original #42 livelock had the conflict verdict escalate to a human with no remediation
|
|
133
|
-
// actor. The primary target of the conflict verdict must now be the rebase gate.
|
|
134
|
-
assert(
|
|
135
|
-
hasFlow("gw-mergeable", "gw-rebase"),
|
|
136
|
-
"conflict must reach the rebase gate (the #42 remediation actor)",
|
|
137
|
-
);
|
|
138
|
-
// Sanity: the untouched ready path still lands the merge directly.
|
|
139
|
-
assert(hasFlow("gw-mergeable", "attempt-merge"), "ready → attempt-merge path should still exist");
|
|
140
|
-
});
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
// Structural regression guard for the merge-loop transient-retry arm (issue #334).
|
|
2
|
-
//
|
|
3
|
-
// #334: a transient, GitHub-flagged-retryable merge race ("Base branch was modified. Review and
|
|
4
|
-
// try the merge again.") was mapped to `blocked` → a decision-required human escalation on a PR
|
|
5
|
-
// that was actually mergeable once the base settled. The fix adds a `retry` merge outcome that
|
|
6
|
-
// re-enters the merge loop on the settled base through a *bounded* budget gate — mirroring the
|
|
7
|
-
// `gw-ci-fix` within-budget / budget-exhausted pattern — WITHOUT any remediation agent, and
|
|
8
|
-
// escalates via the existing `merge-esc-attempt` only when the retry budget is exhausted (so a
|
|
9
|
-
// continuously-moving base still escalates promptly rather than spinning forever).
|
|
10
|
-
//
|
|
11
|
-
// This test asserts the arm's topology on the committed model so it cannot regress silently. It is
|
|
12
|
-
// a pure text assertion over the BPMN (no engine), matching the repo's lightweight model-guard style.
|
|
13
|
-
|
|
14
|
-
import { test } from "node:test";
|
|
15
|
-
import { assert, assertStringIncludes } from "#test-assert";
|
|
16
|
-
import { readFileSync } from "node:fs";
|
|
17
|
-
|
|
18
|
-
const bpmn = readFileSync("resources/processes/merge-loop.bpmn", "utf8");
|
|
19
|
-
const flat = bpmn.replace(/\s+/g, " ");
|
|
20
|
-
|
|
21
|
-
function hasFlow(source: string, target: string): boolean {
|
|
22
|
-
const re = new RegExp(
|
|
23
|
-
`<bpmn:sequenceFlow\\b[^>]*\\bsourceRef="${source}"[^>]*\\btargetRef="${target}"|` +
|
|
24
|
-
`<bpmn:sequenceFlow\\b[^>]*\\btargetRef="${target}"[^>]*\\bsourceRef="${source}"`,
|
|
25
|
-
);
|
|
26
|
-
return re.test(flat);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function flowHasId(id: string, source: string, target: string): boolean {
|
|
30
|
-
const m = flat.match(new RegExp(`<bpmn:sequenceFlow\\b[^>]*\\bid="${id}"[^>]*/?>`));
|
|
31
|
-
if (!m) return false;
|
|
32
|
-
const tag = m[0];
|
|
33
|
-
return tag.includes(`sourceRef="${source}"`) && tag.includes(`targetRef="${target}"`);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function gatewayDefault(id: string, def: string): boolean {
|
|
37
|
-
const m = flat.match(new RegExp(`<bpmn:exclusiveGateway\\b[^>]*\\bid="${id}"[^>]*>`));
|
|
38
|
-
if (!m) return false;
|
|
39
|
-
return m[0].includes(`default="${def}"`);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Assert the conditionExpression *of a specific sequenceFlow* contains `needle`, so the guard
|
|
43
|
-
// cannot be satisfied by the same substring appearing on an unrelated flow (e.g. the
|
|
44
|
-
// merge-esc-attempt question FEEL also mentions `mergeStatus = "retry"`).
|
|
45
|
-
function flowHasCondition(id: string, needle: string): boolean {
|
|
46
|
-
const m = flat.match(
|
|
47
|
-
new RegExp(`<bpmn:sequenceFlow\\b[^>]*\\bid="${id}"[^>]*>(.*?)</bpmn:sequenceFlow>`),
|
|
48
|
-
);
|
|
49
|
-
if (!m) return false;
|
|
50
|
-
const cond = m[1].match(/<bpmn:conditionExpression\b[^>]*>(.*?)<\/bpmn:conditionExpression>/);
|
|
51
|
-
return cond ? cond[1].includes(needle) : false;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
test("gw-merge routes the retry outcome to a dedicated budget gate, not to a human", () => {
|
|
55
|
-
// A `retry` merge result must reach the retry-budget gate…
|
|
56
|
-
assert(hasFlow("gw-merge", "gw-merge-retry"), "gw-merge → gw-merge-retry (retry) missing");
|
|
57
|
-
// …guarded by the exact retry condition (mergeStatus = "retry").
|
|
58
|
-
assert(flowHasId("f_m_gRetry", "gw-merge", "gw-merge-retry"), "f_m_gRetry must be gw-merge → gw-merge-retry");
|
|
59
|
-
// Assert the retry condition on f_m_gRetry ITSELF — not merely anywhere in the model — so the
|
|
60
|
-
// guard cannot be satisfied by the identical substring in the merge-esc-attempt question FEEL.
|
|
61
|
-
assert(
|
|
62
|
-
flowHasCondition("f_m_gRetry", 'mergeStatus = "retry"'),
|
|
63
|
-
'f_m_gRetry conditionExpression must be mergeStatus = "retry"',
|
|
64
|
-
);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
test("retry arm mirrors the fix-ci arm: budget gate → re-arm, exhausted → escalate", () => {
|
|
68
|
-
// Within budget → re-arm the merge poller (re-attempt on the settled base). No remediation agent.
|
|
69
|
-
assert(hasFlow("gw-merge-retry", "arm-merge"), "gw-merge-retry → arm-merge (within budget) missing");
|
|
70
|
-
assert(flowHasId("f_mr_go", "gw-merge-retry", "arm-merge"), "f_mr_go must be gw-merge-retry → arm-merge");
|
|
71
|
-
assertStringIncludes(flat, "mergeRetryRound <= mergeRetryMax");
|
|
72
|
-
|
|
73
|
-
// Budget exhausted → the EXISTING human escalation (merge-esc-attempt), and it is the gateway default
|
|
74
|
-
// so a continuously-moving base can never spin past the cap.
|
|
75
|
-
assert(
|
|
76
|
-
hasFlow("gw-merge-retry", "merge-esc-attempt"),
|
|
77
|
-
"gw-merge-retry → merge-esc-attempt (budget exhausted) missing",
|
|
78
|
-
);
|
|
79
|
-
assert(gatewayDefault("gw-merge-retry", "f_mr_giveup"), "gw-merge-retry must default to f_mr_giveup");
|
|
80
|
-
assert(
|
|
81
|
-
flowHasId("f_mr_giveup", "gw-merge-retry", "merge-esc-attempt"),
|
|
82
|
-
"f_mr_giveup must default gw-merge-retry → merge-esc-attempt",
|
|
83
|
-
);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test("the retry arm advances the attempt counter only on a transient retry outcome", () => {
|
|
87
|
-
// The counter advances ONLY when the merge attempt returned `retry` — mirroring how fix-ci/rebase
|
|
88
|
-
// advance their own rounds only on their own remediation — so unrelated merge attempts (initial,
|
|
89
|
-
// post-rebase, post-fix-ci, post-evict) can't consume the transient-retry budget. N consecutive
|
|
90
|
-
// transient races then trip the `mergeRetryRound <= mergeRetryMax` gate at exactly the cap.
|
|
91
|
-
assertStringIncludes(flat, "=if mergeStatus = "retry" then mergeRetryRound + 1 else mergeRetryRound");
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
test("the retry arm has NO remediation agent (contrast the conflict/rebase arm)", () => {
|
|
95
|
-
// The base-moved race needs only a re-attempt on the settled base — no rebase/CI-fix agent. The
|
|
96
|
-
// within-budget flow goes straight back to arm-merge, never through a `senior:*` task.
|
|
97
|
-
assert(flowHasId("f_mr_go", "gw-merge-retry", "arm-merge"), "retry within-budget must go directly to arm-merge");
|
|
98
|
-
// Sanity: the untouched blocked path still escalates directly (a genuine refusal is unchanged).
|
|
99
|
-
assert(flowHasId("f_m_gBlocked", "gw-merge", "merge-esc-attempt"), "blocked → merge-esc-attempt must remain");
|
|
100
|
-
});
|