@nanobpm/nano-workforce 0.101.1 → 0.102.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/SPEC.md +24 -2
- package/app/failingChecksSupersede.test.ts +93 -0
- package/app/github.ts +69 -7
- package/app/mergeCiReattempt.test.ts +138 -0
- package/app/mergeRebaseArm.test.ts +5 -2
- package/app/mergesPerDay.test.ts +173 -0
- package/app/mergesPerDay.ts +150 -0
- package/app/reviewReadinessGate.test.ts +15 -3
- package/app/service.ts +2 -0
- package/db/migrations/051_merges_per_day.sql +33 -0
- package/package.json +1 -1
- package/pages/_nav.json +2 -1
- package/pages/board.page.json +4 -0
- package/pages/cockpit.page.json +4 -0
- package/pages/epic-detail.page.json +4 -0
- package/pages/epic.page.json +4 -0
- package/pages/feature.page.json +4 -0
- package/pages/home.page.json +4 -0
- package/pages/lineage.page.json +4 -0
- package/pages/overview.page.json +4 -0
- package/pages/tasks.page.json +4 -0
- package/pages/velocity.page.json +92 -0
- package/resources/processes/merge-loop.bpmn +162 -88
- package/resources/prompts/fix-ci.md +32 -12
- package/scripts/check-migrations.ts +12 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## [0.102.1](https://github.com/nanobpm/nano-workforce/compare/v0.102.0...v0.102.1) (2026-08-19)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **merge-loop:** re-attempt merge for stale/transient CI instead of paging a human ([#348](https://github.com/nanobpm/nano-workforce/issues/348)) ([#350](https://github.com/nanobpm/nano-workforce/issues/350)) ([87ce2d6](https://github.com/nanobpm/nano-workforce/commit/87ce2d69e893b4b0f965f481d6c247fb8fcb84e8))
|
|
7
|
+
|
|
8
|
+
# [0.102.0](https://github.com/nanobpm/nano-workforce/compare/v0.101.1...v0.102.0) (2026-08-19)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **console:** merged-per-day burn-down/throughput chart ([#345](https://github.com/nanobpm/nano-workforce/issues/345)) ([d4b27a9](https://github.com/nanobpm/nano-workforce/commit/d4b27a9cad36217db7c8ffc49ef26e0f31fb688e)), closes [#344](https://github.com/nanobpm/nano-workforce/issues/344) [#290](https://github.com/nanobpm/nano-workforce/issues/290) [#337](https://github.com/nanobpm/nano-workforce/issues/337) [#339](https://github.com/nanobpm/nano-workforce/issues/339) [#340](https://github.com/nanobpm/nano-workforce/issues/340) [#338](https://github.com/nanobpm/nano-workforce/issues/338)
|
|
14
|
+
|
|
1
15
|
## [0.101.1](https://github.com/nanobpm/nano-workforce/compare/v0.101.0...v0.101.1) (2026-08-19)
|
|
2
16
|
|
|
3
17
|
|
package/SPEC.md
CHANGED
|
@@ -424,8 +424,30 @@ start ─► wait: deps merged ─► arm merge ─► wait: mergeable ─┬─
|
|
|
424
424
|
check names ride `appendPrompt`) to green the checks on the branch, then re-arms the
|
|
425
425
|
poller. It repeats while `ciFixRound < ciFixMax`
|
|
426
426
|
(`NANO_PR_MAX_CI_FIX_ROUNDS`, default 3; `0` disables). Only when the budget is
|
|
427
|
-
exhausted, the agent reports `blocked
|
|
428
|
-
|
|
427
|
+
exhausted, the agent reports `blocked` *and the PR is still blocked after a
|
|
428
|
+
ground-truth reconcile*, or the branch is in `conflict` does it fall through to the
|
|
429
|
+
human escalation path.
|
|
430
|
+
|
|
431
|
+
- **Stale / transient checks — re-attempt, don't escalate** (issue #348) — GitHub's CI
|
|
432
|
+
concurrency **cancels** a superseded workflow run while a newer run on the *identical
|
|
433
|
+
head SHA* takes over. Both land in the head's `statusCheckRollup` under the same check
|
|
434
|
+
name — the stale one stamped `CANCELLED`, the live one green. This is a
|
|
435
|
+
**CI-concurrency-cancellation drift class**, not a code defect, defended at three
|
|
436
|
+
layers so it never pages a human:
|
|
437
|
+
- **Derivation (root cause)** — the merge poller's check derivation collapses the
|
|
438
|
+
rollup to the **newest run per `(headSha, checkName)`** (`latestRunPerCheck`) before
|
|
439
|
+
classifying, so a `CANCELLED` run superseded by a newer green run is **not** counted
|
|
440
|
+
as a failing gate. The phantom `blocked` never arises, so `fix-ci` is not even armed.
|
|
441
|
+
- **Agent verdict** — when `fix-ci` pushes nothing because the failing checks are
|
|
442
|
+
stale/transient (head already green), it returns `status: "reattempt"` (with
|
|
443
|
+
`pushed: false`). That routes to `arm-merge` — the merge is simply re-queued from
|
|
444
|
+
ground truth. The prompt reserves `blocked` for a genuine human decision (a missing
|
|
445
|
+
secret, an un-fixable failure), never a self-healing PR.
|
|
446
|
+
- **Reconcile-before-escalate guard** — even a *mislabelled* `blocked` self-heals: a
|
|
447
|
+
`blocked` verdict with no push (`pushed != true`) reconciles **once** via ground
|
|
448
|
+
truth (`gw-ci-blocked` → `ci-reconcile`, which re-arms the canonical merge poller and
|
|
449
|
+
sets `ciBlockedReconciled`), and escalates only if the PR is **still** blocked on the
|
|
450
|
+
re-derived state.
|
|
429
451
|
|
|
430
452
|
- **Discovered dependency** — a `senior:fix-ci` or `senior:rebase` agent may find that
|
|
431
453
|
the PR cannot land because **another PR must merge first** (a required linked-issue
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Derivation-layer guard for the CI-concurrency-cancellation drift class (issue #348).
|
|
2
|
+
//
|
|
3
|
+
// GitHub's CI concurrency cancels a superseded workflow run while a newer run on the *identical
|
|
4
|
+
// head SHA* takes over. Both land in the head's `statusCheckRollup` under the same check name — the
|
|
5
|
+
// stale one stamped `CANCELLED`, the live one green. The merge poller's check derivation used to
|
|
6
|
+
// count that stale `CANCELLED` as a failing required check, so a PR whose head is actually green
|
|
7
|
+
// read as `blocked`, armed `senior:fix-ci`, which honestly pushed nothing and (pre-#348) returned
|
|
8
|
+
// `blocked` → a human merge-escalation for a self-healing PR.
|
|
9
|
+
//
|
|
10
|
+
// The fix collapses the rollup to the NEWEST run per check before classifying, so a superseded
|
|
11
|
+
// `CANCELLED` never counts. These are pure unit tests over the exported derivation helpers.
|
|
12
|
+
|
|
13
|
+
import { test } from "node:test";
|
|
14
|
+
import { assert, assertEquals } from "#test-assert";
|
|
15
|
+
import { allCheckNames, failingCheckNames, latestRunPerCheck } from "./github.ts";
|
|
16
|
+
|
|
17
|
+
test("a CANCELLED run superseded by a newer green run on the same head does not count as failing", () => {
|
|
18
|
+
const rollup = [
|
|
19
|
+
// The superseded run: GitHub CI concurrency cancelled it when a newer run started.
|
|
20
|
+
{ name: "engine-core", conclusion: "CANCELLED", startedAt: "2024-01-01T00:00:00Z", completedAt: "2024-01-01T00:01:00Z" },
|
|
21
|
+
// The live run on the identical head SHA: green.
|
|
22
|
+
{ name: "engine-core", conclusion: "SUCCESS", startedAt: "2024-01-01T00:02:00Z", completedAt: "2024-01-01T00:05:00Z" },
|
|
23
|
+
];
|
|
24
|
+
assertEquals(failingCheckNames(rollup), [], "the stale CANCELLED must not read as a failing gate");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("every CANCELLED-superseded required check on one head SHA is dropped (the #348 instance)", () => {
|
|
28
|
+
// The exact PR #887 evidence: four required checks each with a superseded CANCELLED + a newer
|
|
29
|
+
// green run on the same head. None must count as failing.
|
|
30
|
+
const names = ["engine-core", "engine-wasm read-model wasm32 type-check", "processos", "server"];
|
|
31
|
+
const rollup = names.flatMap((name) => [
|
|
32
|
+
{ name, conclusion: "CANCELLED", startedAt: "2024-01-01T00:00:00Z" },
|
|
33
|
+
{ name, conclusion: "SUCCESS", startedAt: "2024-01-01T00:02:00Z" },
|
|
34
|
+
]);
|
|
35
|
+
assertEquals(failingCheckNames(rollup), [], "no superseded CANCELLED may count as a failing check");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("a genuine failure on the newest run still counts (no false-negative)", () => {
|
|
39
|
+
const rollup = [
|
|
40
|
+
{ name: "engine-core", conclusion: "SUCCESS", startedAt: "2024-01-01T00:00:00Z" },
|
|
41
|
+
// Newest run genuinely failed — this must still be reported.
|
|
42
|
+
{ name: "engine-core", conclusion: "FAILURE", startedAt: "2024-01-01T00:02:00Z" },
|
|
43
|
+
];
|
|
44
|
+
assertEquals(failingCheckNames(rollup), ["engine-core"], "a real failure on the newest run must count");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a lone CANCELLED with no superseding run still counts (nothing green replaced it)", () => {
|
|
48
|
+
const rollup = [{ name: "engine-core", conclusion: "CANCELLED", startedAt: "2024-01-01T00:00:00Z" }];
|
|
49
|
+
assertEquals(failingCheckNames(rollup), ["engine-core"], "an unsuperseded CANCELLED remains a failing gate");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("ties (missing timestamps) prefer the non-CANCELLED run so the real result wins", () => {
|
|
53
|
+
// GitHub sometimes omits run times; a superseded CANCELLED alongside a completed run must not
|
|
54
|
+
// shadow the real conclusion even when neither carries a timestamp.
|
|
55
|
+
const cancelledFirst = [
|
|
56
|
+
{ name: "server", conclusion: "CANCELLED" },
|
|
57
|
+
{ name: "server", conclusion: "SUCCESS" },
|
|
58
|
+
];
|
|
59
|
+
const successFirst = [
|
|
60
|
+
{ name: "server", conclusion: "SUCCESS" },
|
|
61
|
+
{ name: "server", conclusion: "CANCELLED" },
|
|
62
|
+
];
|
|
63
|
+
assertEquals(failingCheckNames(cancelledFirst), [], "CANCELLED-first tie resolves to the real (green) result");
|
|
64
|
+
assertEquals(failingCheckNames(successFirst), [], "success-first tie keeps the real (green) result");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("latestRunPerCheck keeps exactly one run per check name (newest)", () => {
|
|
68
|
+
const rollup = [
|
|
69
|
+
{ name: "a", conclusion: "CANCELLED", startedAt: "2024-01-01T00:00:00Z" },
|
|
70
|
+
{ name: "a", conclusion: "SUCCESS", startedAt: "2024-01-01T00:02:00Z" },
|
|
71
|
+
{ name: "b", conclusion: "FAILURE", startedAt: "2024-01-01T00:00:00Z" },
|
|
72
|
+
];
|
|
73
|
+
const latest = latestRunPerCheck(rollup);
|
|
74
|
+
assertEquals(latest.length, 2, "one run per distinct check name");
|
|
75
|
+
const a = latest.find((c) => c.name === "a");
|
|
76
|
+
assert(a && a.conclusion === "SUCCESS", "check `a` resolves to its newest (green) run");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("allCheckNames dedupes superseded reruns to a single name", () => {
|
|
80
|
+
const rollup = [
|
|
81
|
+
{ name: "engine-core", conclusion: "CANCELLED", startedAt: "2024-01-01T00:00:00Z" },
|
|
82
|
+
{ name: "engine-core", conclusion: "SUCCESS", startedAt: "2024-01-01T00:02:00Z" },
|
|
83
|
+
];
|
|
84
|
+
assertEquals(allCheckNames(rollup), ["engine-core"], "a superseded rerun must not double-list the check name");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("legacy StatusContext (state + context) supersession is handled by createdAt", () => {
|
|
88
|
+
const rollup = [
|
|
89
|
+
{ context: "ci/legacy", state: "ERROR", createdAt: "2024-01-01T00:00:00Z" },
|
|
90
|
+
{ context: "ci/legacy", state: "SUCCESS", createdAt: "2024-01-01T00:02:00Z" },
|
|
91
|
+
];
|
|
92
|
+
assertEquals(failingCheckNames(rollup), [], "a superseded legacy status context is not a failing gate");
|
|
93
|
+
});
|
package/app/github.ts
CHANGED
|
@@ -521,17 +521,78 @@ interface RollupEntry {
|
|
|
521
521
|
name?: string;
|
|
522
522
|
context?: string;
|
|
523
523
|
workflowName?: string;
|
|
524
|
+
/** CheckRun timestamps (GraphQL `statusCheckRollup`). A superseded run and the newer run that
|
|
525
|
+
* replaced it carry the same check name but different times, so they order the runs of one check.
|
|
526
|
+
* StatusContext carries `createdAt` instead. All are ISO-8601 or absent. */
|
|
527
|
+
startedAt?: string;
|
|
528
|
+
completedAt?: string;
|
|
529
|
+
createdAt?: string;
|
|
524
530
|
}
|
|
531
|
+
|
|
532
|
+
/** The canonical identity of a check across its reruns: its name (CheckRun) or context
|
|
533
|
+
* (StatusContext), falling back to its `workflowName` and finally the sentinel `"check"` when
|
|
534
|
+
* neither is present. GitHub CI concurrency can leave several runs of the SAME check on one head
|
|
535
|
+
* commit — a superseded run plus the newer run that replaced it — so this is what we group by. */
|
|
536
|
+
function checkKey(c: RollupEntry): string {
|
|
537
|
+
return c.name || c.context || c.workflowName || "check";
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** A run's ordering timestamp (newest wins): its completion, else its start, else its creation.
|
|
541
|
+
* `0` when none is present (the shape carries no time) so a timed run always outranks an untimed
|
|
542
|
+
* one. */
|
|
543
|
+
function runOrder(c: RollupEntry): number {
|
|
544
|
+
const t = c.completedAt || c.startedAt || c.createdAt;
|
|
545
|
+
if (!t) return 0;
|
|
546
|
+
const ms = Date.parse(t);
|
|
547
|
+
return Number.isNaN(ms) ? 0 : ms;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** True when a run's conclusion is `CANCELLED` — the state GitHub CI concurrency stamps on a run it
|
|
551
|
+
* supersedes with a newer run on the identical head SHA (a stale/transient cancellation, not a code
|
|
552
|
+
* defect). */
|
|
553
|
+
function isCancelled(c: RollupEntry): boolean {
|
|
554
|
+
return (c.conclusion || c.state || "").toUpperCase() === "CANCELLED";
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Collapse a head's rollup to the **newest run per check**. GitHub's CI-concurrency cancellation
|
|
558
|
+
* (issue #348) leaves BOTH a superseded run (stamped `CANCELLED`) and the newer run that replaced
|
|
559
|
+
* it on the *same head SHA*, under the same check name. Counting the stale `CANCELLED` as a failure
|
|
560
|
+
* escalates a self-healing PR whose head is actually green. The rollup is already scoped to the head
|
|
561
|
+
* commit, so grouping by check name and keeping the newest run per name yields one ground-truth
|
|
562
|
+
* conclusion per `(headSha, checkName)`. Ties (equal/absent timestamps) prefer a non-`CANCELLED`
|
|
563
|
+
* run, so a superseded cancellation never shadows the real result even when GitHub omits times. */
|
|
564
|
+
export function latestRunPerCheck(rollup: RollupEntry[]): RollupEntry[] {
|
|
565
|
+
const newest = new Map<string, RollupEntry>();
|
|
566
|
+
for (const c of rollup) {
|
|
567
|
+
const key = checkKey(c);
|
|
568
|
+
const prev = newest.get(key);
|
|
569
|
+
if (prev === undefined) {
|
|
570
|
+
newest.set(key, c);
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
const dt = runOrder(c) - runOrder(prev);
|
|
574
|
+
if (dt > 0) {
|
|
575
|
+
newest.set(key, c);
|
|
576
|
+
} else if (dt === 0 && isCancelled(prev) && !isCancelled(c)) {
|
|
577
|
+
// Same/unknown time: a CANCELLED run is the superseded one — the real result wins.
|
|
578
|
+
newest.set(key, c);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return [...newest.values()];
|
|
582
|
+
}
|
|
583
|
+
|
|
525
584
|
/** Names of the checks whose result is a hard failure (as opposed to pending/success). Covers
|
|
526
585
|
* both the CheckRun shape (`conclusion` + `name`/`workflowName`) and the legacy StatusContext
|
|
527
586
|
* shape (`state` + `context`). The names are what the CI-fix agent is handed so it knows which
|
|
528
|
-
* gates to make green; `failingChecks` (the count) is derived from this list.
|
|
529
|
-
|
|
587
|
+
* gates to make green; `failingChecks` (the count) is derived from this list. Derivation is over the
|
|
588
|
+
* **newest run per check** (`latestRunPerCheck`) so a `CANCELLED` run superseded by a newer green
|
|
589
|
+
* run on the identical head SHA is not counted as a failing gate (issue #348). */
|
|
590
|
+
export function failingCheckNames(rollup: RollupEntry[]): string[] {
|
|
530
591
|
const bad = new Set(["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "ERROR"]);
|
|
531
592
|
const names: string[] = [];
|
|
532
|
-
for (const c of rollup) {
|
|
593
|
+
for (const c of latestRunPerCheck(rollup)) {
|
|
533
594
|
const v = (c.conclusion || c.state || "").toUpperCase();
|
|
534
|
-
if (bad.has(v)) names.push(c
|
|
595
|
+
if (bad.has(v)) names.push(checkKey(c));
|
|
535
596
|
}
|
|
536
597
|
return names;
|
|
537
598
|
}
|
|
@@ -539,10 +600,11 @@ function failingCheckNames(rollup: RollupEntry[]): string[] {
|
|
|
539
600
|
/** Names of every head check present, regardless of state. Covers both the CheckRun shape
|
|
540
601
|
* (`name`/`workflowName`) and the legacy StatusContext shape (`context`). Used to test whether a
|
|
541
602
|
* repo's *required* checks are present on the head — so an unrelated always-on check (e.g.
|
|
542
|
-
* Mergify's "Merge Queue") doesn't masquerade as the required CI run having already happened.
|
|
543
|
-
|
|
603
|
+
* Mergify's "Merge Queue") doesn't masquerade as the required CI run having already happened.
|
|
604
|
+
* Deduped to the newest run per check so a superseded rerun doesn't list a check name twice. */
|
|
605
|
+
export function allCheckNames(rollup: RollupEntry[]): string[] {
|
|
544
606
|
const names: string[] = [];
|
|
545
|
-
for (const c of rollup) {
|
|
607
|
+
for (const c of latestRunPerCheck(rollup)) {
|
|
546
608
|
const name = c.name || c.context || c.workflowName;
|
|
547
609
|
if (name) names.push(name);
|
|
548
610
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
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
|
+
});
|
|
@@ -118,11 +118,14 @@ test("ci-fix result: a missing/ambiguous status reconciles from ground truth, no
|
|
|
118
118
|
flowHasId("f_ci_reconcile", "gw-ci-result", "arm-merge"),
|
|
119
119
|
"f_ci_reconcile must default gw-ci-result → arm-merge (reconcile)",
|
|
120
120
|
);
|
|
121
|
-
// Escalation reserved for the agent's explicit `blocked` verdict
|
|
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.
|
|
122
124
|
const ciBlocked = flat.match(/<bpmn:sequenceFlow[^>]*id="f_ci_blocked"[\s\S]*?<\/bpmn:sequenceFlow>/);
|
|
123
125
|
assert(ciBlocked, "f_ci_blocked flow missing");
|
|
124
126
|
assertStringIncludes(ciBlocked![0], 'status = "blocked"');
|
|
125
|
-
assert(hasFlow("gw-ci-result", "
|
|
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");
|
|
126
129
|
});
|
|
127
130
|
|
|
128
131
|
test("regression: the conflict verdict passes through the rebase actor, not straight to escalation", () => {
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Read-model derivation + projection test for the merged-per-day throughput chart (issue #344).
|
|
2
|
+
//
|
|
3
|
+
// `deriveMergesPerDay` is the single source of truth behind the denormalised `merges_per_day` table
|
|
4
|
+
// the Velocity page reads. It must: count DISTINCT PRs per calendar day (a PR with several `merged`
|
|
5
|
+
// audit rows on one day — an `already-merged` short-circuit or a retry — counts once); ignore
|
|
6
|
+
// `queued`/`blocked` attempts entirely; order days ascending; carry a running burn-up `cumulative`;
|
|
7
|
+
// and scale each day's `bar` against the busiest day. `pollMergesPerDay` must project that onto the
|
|
8
|
+
// read table idempotently — a steady-state re-run writes nothing, and a day dropped from the audit is
|
|
9
|
+
// pruned.
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import { assert, assertEquals } from "#test-assert";
|
|
12
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
13
|
+
import { deriveMergesPerDay, type MergeAuditRow, pollMergesPerDay } from "./mergesPerDay.ts";
|
|
14
|
+
|
|
15
|
+
// A tiny in-memory record gateway (all/find/insert/update/delete), mirroring the fake-app style used
|
|
16
|
+
// across the app tests (see app/delivery.test.ts), enough to exercise the `pollMergesPerDay`
|
|
17
|
+
// projection.
|
|
18
|
+
function memData(): { data: DataLayer; stores: Record<string, any[]>; writes: () => number } {
|
|
19
|
+
const stores: Record<string, any[]> = {};
|
|
20
|
+
let writes = 0;
|
|
21
|
+
function tbl(name: string, pk = "id") {
|
|
22
|
+
const rows = (stores[name] ??= [] as any[]);
|
|
23
|
+
return {
|
|
24
|
+
async all() {
|
|
25
|
+
return rows.slice();
|
|
26
|
+
},
|
|
27
|
+
async get(id: any) {
|
|
28
|
+
return rows.find((r) => r[pk] === id);
|
|
29
|
+
},
|
|
30
|
+
async find(where: any = {}) {
|
|
31
|
+
return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
|
|
32
|
+
},
|
|
33
|
+
async insert(row: any) {
|
|
34
|
+
writes++;
|
|
35
|
+
rows.push({ ...row });
|
|
36
|
+
return row[pk];
|
|
37
|
+
},
|
|
38
|
+
async update(id: any, patch: any) {
|
|
39
|
+
writes++;
|
|
40
|
+
const r = rows.find((row) => row[pk] === id);
|
|
41
|
+
if (r) Object.assign(r, patch);
|
|
42
|
+
return 1;
|
|
43
|
+
},
|
|
44
|
+
async delete(id: any) {
|
|
45
|
+
const i = rows.findIndex((row) => row[pk] === id);
|
|
46
|
+
if (i >= 0) {
|
|
47
|
+
writes++;
|
|
48
|
+
rows.splice(i, 1);
|
|
49
|
+
}
|
|
50
|
+
return 1;
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
|
|
55
|
+
return { data, stores, writes: () => writes };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const merged = (pr_key: string, at: string): MergeAuditRow => ({ pr_key, outcome: "merged", at });
|
|
59
|
+
|
|
60
|
+
test("counts DISTINCT merged PRs per calendar day", () => {
|
|
61
|
+
const days = deriveMergesPerDay([
|
|
62
|
+
merged("o/r#1", "2026-01-01T09:00:00Z"),
|
|
63
|
+
merged("o/r#2", "2026-01-01T18:30:00Z"),
|
|
64
|
+
merged("o/r#3", "2026-01-02T10:00:00Z"),
|
|
65
|
+
]);
|
|
66
|
+
assertEquals(days.map((d) => [d.day, d.merged]), [
|
|
67
|
+
["2026-01-01", 2],
|
|
68
|
+
["2026-01-02", 1],
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("dedupes duplicate merged rows for the same PR on the same day (COUNT DISTINCT pr_key)", () => {
|
|
73
|
+
const days = deriveMergesPerDay([
|
|
74
|
+
merged("o/r#1", "2026-01-01T09:00:00Z"),
|
|
75
|
+
merged("o/r#1", "2026-01-01T09:00:05Z"), // retry / already-merged short-circuit
|
|
76
|
+
merged("o/r#1", "2026-01-01T23:59:00Z"),
|
|
77
|
+
]);
|
|
78
|
+
assertEquals(days.length, 1);
|
|
79
|
+
assertEquals(days[0].merged, 1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("the same PR merged on two different days counts once per day", () => {
|
|
83
|
+
// A defensive case: distinctness is per-day, not global.
|
|
84
|
+
const days = deriveMergesPerDay([
|
|
85
|
+
merged("o/r#1", "2026-01-01T09:00:00Z"),
|
|
86
|
+
merged("o/r#1", "2026-01-02T09:00:00Z"),
|
|
87
|
+
]);
|
|
88
|
+
assertEquals(days.map((d) => [d.day, d.merged]), [
|
|
89
|
+
["2026-01-01", 1],
|
|
90
|
+
["2026-01-02", 1],
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("ignores queued and blocked attempts", () => {
|
|
95
|
+
const days = deriveMergesPerDay([
|
|
96
|
+
merged("o/r#1", "2026-01-01T09:00:00Z"),
|
|
97
|
+
{ pr_key: "o/r#2", outcome: "queued", at: "2026-01-01T09:10:00Z" },
|
|
98
|
+
{ pr_key: "o/r#3", outcome: "blocked", at: "2026-01-01T09:20:00Z" },
|
|
99
|
+
]);
|
|
100
|
+
assertEquals(days.length, 1);
|
|
101
|
+
assertEquals(days[0].merged, 1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("orders days ascending and carries a running burn-up cumulative", () => {
|
|
105
|
+
const days = deriveMergesPerDay([
|
|
106
|
+
merged("o/r#5", "2026-01-03T10:00:00Z"),
|
|
107
|
+
merged("o/r#1", "2026-01-01T10:00:00Z"),
|
|
108
|
+
merged("o/r#2", "2026-01-01T11:00:00Z"),
|
|
109
|
+
merged("o/r#4", "2026-01-02T10:00:00Z"),
|
|
110
|
+
]);
|
|
111
|
+
assertEquals(days.map((d) => d.day), ["2026-01-01", "2026-01-02", "2026-01-03"]);
|
|
112
|
+
assertEquals(days.map((d) => d.merged), [2, 1, 1]);
|
|
113
|
+
assertEquals(days.map((d) => d.cumulative), [2, 3, 4]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("bar scales against the busiest day: full for the max, non-empty for a lone merge, empty for zero", () => {
|
|
117
|
+
const days = deriveMergesPerDay([
|
|
118
|
+
// day A: 4 merges (the max) → widest bar
|
|
119
|
+
merged("o/r#1", "2026-01-01T01:00:00Z"),
|
|
120
|
+
merged("o/r#2", "2026-01-01T02:00:00Z"),
|
|
121
|
+
merged("o/r#3", "2026-01-01T03:00:00Z"),
|
|
122
|
+
merged("o/r#4", "2026-01-01T04:00:00Z"),
|
|
123
|
+
// day B: 1 merge → short but visible bar
|
|
124
|
+
merged("o/r#5", "2026-01-02T01:00:00Z"),
|
|
125
|
+
]);
|
|
126
|
+
const [a, b] = days;
|
|
127
|
+
assert(a.bar.length > b.bar.length, "the busier day must draw a longer bar");
|
|
128
|
+
assert(b.bar.length >= 1, "a day with any merge must draw at least one glyph");
|
|
129
|
+
assert(a.bar.length <= 30, "the busiest bar must not exceed the configured width");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("empty audit yields no days", () => {
|
|
133
|
+
assertEquals(deriveMergesPerDay([]), []);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("pollMergesPerDay projects the aggregate onto merges_per_day", async () => {
|
|
137
|
+
const { data, stores } = memData();
|
|
138
|
+
stores.merges = [
|
|
139
|
+
{ id: 1, pr_key: "o/r#1", outcome: "merged", at: "2026-01-01T09:00:00Z" },
|
|
140
|
+
{ id: 2, pr_key: "o/r#1", outcome: "merged", at: "2026-01-01T09:05:00Z" }, // dup same day
|
|
141
|
+
{ id: 3, pr_key: "o/r#2", outcome: "merged", at: "2026-01-02T09:00:00Z" },
|
|
142
|
+
{ id: 4, pr_key: "o/r#3", outcome: "queued", at: "2026-01-02T09:10:00Z" }, // ignored
|
|
143
|
+
];
|
|
144
|
+
await pollMergesPerDay(data);
|
|
145
|
+
const rows = (stores.merges_per_day ?? []).slice().sort((x, y) => x.day.localeCompare(y.day));
|
|
146
|
+
assertEquals(rows.map((r) => [r.day, r.merged, r.cumulative]), [
|
|
147
|
+
["2026-01-01", 1, 1],
|
|
148
|
+
["2026-01-02", 1, 2],
|
|
149
|
+
]);
|
|
150
|
+
for (const r of rows) assert(typeof r.updated_at === "string" && r.updated_at.length > 0);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("pollMergesPerDay is idempotent — a steady-state re-run writes nothing", async () => {
|
|
154
|
+
const { data, stores, writes } = memData();
|
|
155
|
+
stores.merges = [{ id: 1, pr_key: "o/r#1", outcome: "merged", at: "2026-01-01T09:00:00Z" }];
|
|
156
|
+
await pollMergesPerDay(data);
|
|
157
|
+
const afterFirst = writes();
|
|
158
|
+
assert(afterFirst > 0, "the first pass must project at least one row");
|
|
159
|
+
await pollMergesPerDay(data);
|
|
160
|
+
assertEquals(writes(), afterFirst, "a steady-state re-run must not write");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("pollMergesPerDay prunes a day that no longer derives from the audit", async () => {
|
|
164
|
+
const { data, stores } = memData();
|
|
165
|
+
stores.merges_per_day = [
|
|
166
|
+
{ day: "2025-12-31", merged: 3, cumulative: 3, bar: "███", updated_at: "old" },
|
|
167
|
+
];
|
|
168
|
+
stores.merges = [{ id: 1, pr_key: "o/r#1", outcome: "merged", at: "2026-01-01T09:00:00Z" }];
|
|
169
|
+
await pollMergesPerDay(data);
|
|
170
|
+
const days = (stores.merges_per_day ?? []).map((r: any) => r.day);
|
|
171
|
+
assert(!days.includes("2025-12-31"), "a stale day must be pruned");
|
|
172
|
+
assert(days.includes("2026-01-01"), "the derived day must be present");
|
|
173
|
+
});
|