@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 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`, or the branch is in `conflict` does it fall
428
- through to the human escalation path.
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
- function failingCheckNames(rollup: RollupEntry[]): string[] {
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.name || c.context || c.workflowName || "check");
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
- function allCheckNames(rollup: RollupEntry[]): string[] {
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(/&#34;/g, '"').replace(/&amp;/g, "&").replace(/&#10;/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", "merge-esc-attempt"), "gw-ci-result → merge-esc-attempt (blocked) missing");
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
+ });