@nanobpm/nano-workforce 0.102.0 → 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,10 @@
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
+
1
8
  # [0.102.0](https://github.com/nanobpm/nano-workforce/compare/v0.101.1...v0.102.0) (2026-08-19)
2
9
 
3
10
 
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", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.102.0",
3
+ "version": "0.102.1",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -61,6 +61,7 @@
61
61
  <nano:extend name="summary" type="string" optional="true" />
62
62
  <nano:extend name="question" type="string" optional="true" />
63
63
  <nano:extend name="dependsOn" type="string" optional="true" />
64
+ <nano:extend name="pushed" type="boolean" optional="true" />
64
65
  </nano:shape>
65
66
  <nano:shape id="RebaseIn" name="Rebase — input">
66
67
  <nano:extend name="prKey" type="string" />
@@ -98,11 +99,13 @@
98
99
  </zeebe:properties>
99
100
  <zeebe:ioMapping>
100
101
  <zeebe:output source="=null" target="status" />
102
+ <zeebe:output source="=null" target="ciBlockedReconciled" />
101
103
  </zeebe:ioMapping>
102
104
  </bpmn:extensionElements>
103
105
  <bpmn:incoming>f_m_deps</bpmn:incoming>
104
106
  <bpmn:incoming>f_m_answer</bpmn:incoming>
105
107
  <bpmn:incoming>f_ci_fixed</bpmn:incoming>
108
+ <bpmn:incoming>f_ci_reattempt</bpmn:incoming>
106
109
  <bpmn:incoming>f_reb_rebased</bpmn:incoming>
107
110
  <bpmn:incoming>f_m_evicted</bpmn:incoming>
108
111
  <bpmn:incoming>f_m_escReenter</bpmn:incoming>
@@ -111,6 +114,7 @@
111
114
  </bpmn:serviceTask>
112
115
  <bpmn:intermediateCatchEvent id="wait-mergeable" name="Wait: mergeable">
113
116
  <bpmn:incoming>f_m_arm</bpmn:incoming>
117
+ <bpmn:incoming>f_cib_armed</bpmn:incoming>
114
118
  <bpmn:outgoing>f_m_ready</bpmn:outgoing>
115
119
  <bpmn:messageEventDefinition id="med_mergeReady" messageRef="Message_mergeReady" />
116
120
  </bpmn:intermediateCatchEvent>
@@ -213,7 +217,7 @@
213
217
  </zeebe:ioMapping>
214
218
  </bpmn:extensionElements>
215
219
  <bpmn:incoming>f_m_gBlocked</bpmn:incoming>
216
- <bpmn:incoming>f_ci_blocked</bpmn:incoming>
220
+ <bpmn:incoming>f_cib_esc</bpmn:incoming>
217
221
  <bpmn:incoming>f_reb_blocked</bpmn:incoming>
218
222
  <bpmn:incoming>f_ci_sla</bpmn:incoming>
219
223
  <bpmn:incoming>f_mr_giveup</bpmn:incoming>
@@ -284,9 +288,29 @@
284
288
  <bpmn:incoming>f_ci_done</bpmn:incoming>
285
289
  <bpmn:outgoing>f_ci_fixed</bpmn:outgoing>
286
290
  <bpmn:outgoing>f_ci_wait</bpmn:outgoing>
291
+ <bpmn:outgoing>f_ci_reattempt</bpmn:outgoing>
287
292
  <bpmn:outgoing>f_ci_blocked</bpmn:outgoing>
288
293
  <bpmn:outgoing>f_ci_reconcile</bpmn:outgoing>
289
294
  </bpmn:exclusiveGateway>
295
+ <bpmn:exclusiveGateway id="gw-ci-blocked" name="reconcile then escalate?" default="f_cib_esc">
296
+ <bpmn:incoming>f_ci_blocked</bpmn:incoming>
297
+ <bpmn:outgoing>f_cib_recon</bpmn:outgoing>
298
+ <bpmn:outgoing>f_cib_esc</bpmn:outgoing>
299
+ </bpmn:exclusiveGateway>
300
+ <bpmn:serviceTask id="ci-reconcile" name="Reconcile blocked from ground truth">
301
+ <bpmn:extensionElements>
302
+ <zeebe:taskDefinition type="pr.arm-merge" />
303
+ <zeebe:properties>
304
+ <zeebe:property name="io.nanobpm.dataEnvelope.in" value="MergeArmIn" />
305
+ </zeebe:properties>
306
+ <zeebe:ioMapping>
307
+ <zeebe:output source="=null" target="status" />
308
+ <zeebe:output source="=true" target="ciBlockedReconciled" />
309
+ </zeebe:ioMapping>
310
+ </bpmn:extensionElements>
311
+ <bpmn:incoming>f_cib_recon</bpmn:incoming>
312
+ <bpmn:outgoing>f_cib_armed</bpmn:outgoing>
313
+ </bpmn:serviceTask>
290
314
  <bpmn:exclusiveGateway id="gw-rebase" name="auto-rebase?" default="f_reb_giveup">
291
315
  <bpmn:incoming>f_m_mRebase</bpmn:incoming>
292
316
  <bpmn:outgoing>f_reb_go</bpmn:outgoing>
@@ -356,9 +380,17 @@
356
380
  <bpmn:sequenceFlow id="f_ci_fixed" name="fixed" sourceRef="gw-ci-result" targetRef="arm-merge">
357
381
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "fixed"</bpmn:conditionExpression>
358
382
  </bpmn:sequenceFlow>
359
- <bpmn:sequenceFlow id="f_ci_blocked" name="could not fix" sourceRef="gw-ci-result" targetRef="merge-esc-attempt">
383
+ <bpmn:sequenceFlow id="f_ci_blocked" name="could not fix" sourceRef="gw-ci-result" targetRef="gw-ci-blocked">
360
384
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "blocked"</bpmn:conditionExpression>
361
385
  </bpmn:sequenceFlow>
386
+ <bpmn:sequenceFlow id="f_ci_reattempt" name="stale/transient checks — re-attempt" sourceRef="gw-ci-result" targetRef="arm-merge">
387
+ <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "reattempt"</bpmn:conditionExpression>
388
+ </bpmn:sequenceFlow>
389
+ <bpmn:sequenceFlow id="f_cib_recon" name="pushed nothing — reconcile once" sourceRef="gw-ci-blocked" targetRef="ci-reconcile">
390
+ <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=pushed != true and ciBlockedReconciled != true</bpmn:conditionExpression>
391
+ </bpmn:sequenceFlow>
392
+ <bpmn:sequenceFlow id="f_cib_esc" name="still blocked — escalate" sourceRef="gw-ci-blocked" targetRef="merge-esc-attempt" />
393
+ <bpmn:sequenceFlow id="f_cib_armed" sourceRef="ci-reconcile" targetRef="wait-mergeable" />
362
394
  <bpmn:sequenceFlow id="f_ci_reconcile" name="no verdict — reconcile from ground truth" sourceRef="gw-ci-result" targetRef="arm-merge" />
363
395
  <bpmn:sequenceFlow id="f_ci_wait" name="waits on another PR" sourceRef="gw-ci-result" targetRef="record-merge-dep">
364
396
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "waiting-on-pr"</bpmn:conditionExpression>
@@ -433,7 +465,7 @@
433
465
  <bpmndi:BPMNShape id="BPMNShape_wait-mergeable" bpmnElement="wait-mergeable">
434
466
  <dc:Bounds x="552" y="102" width="36" height="36" />
435
467
  <bpmndi:BPMNLabel>
436
- <dc:Bounds x="535" y="143" width="70" height="28" />
468
+ <dc:Bounds x="535" y="69" width="70" height="28" />
437
469
  </bpmndi:BPMNLabel>
438
470
  </bpmndi:BPMNShape>
439
471
  <bpmndi:BPMNShape id="BPMNShape_gw-mergeable" bpmnElement="gw-mergeable" isMarkerVisible="true">
@@ -454,7 +486,7 @@
454
486
  <bpmndi:BPMNShape id="BPMNShape_gw-merge-retry" bpmnElement="gw-merge-retry" isMarkerVisible="true">
455
487
  <dc:Bounds x="1263" y="1375" width="50" height="50" />
456
488
  <bpmndi:BPMNLabel>
457
- <dc:Bounds x="1344" y="1410" width="88" height="28" />
489
+ <dc:Bounds x="1224" y="1470" width="88" height="28" />
458
490
  </bpmndi:BPMNLabel>
459
491
  </bpmndi:BPMNShape>
460
492
  <bpmndi:BPMNShape id="BPMNShape_eg-landed" bpmnElement="eg-landed" isMarkerVisible="true">
@@ -472,16 +504,16 @@
472
504
  <bpmndi:BPMNShape id="BPMNShape_wait-evicted" bpmnElement="wait-evicted">
473
505
  <dc:Bounds x="1470" y="422" width="36" height="36" />
474
506
  <bpmndi:BPMNLabel>
475
- <dc:Bounds x="1444" y="375" width="88" height="42" />
507
+ <dc:Bounds x="1344" y="483" width="88" height="42" />
476
508
  </bpmndi:BPMNLabel>
477
509
  </bpmndi:BPMNShape>
478
510
  <bpmndi:BPMNShape id="BPMNShape_mark-merged" bpmnElement="mark-merged">
479
511
  <dc:Bounds x="1638" y="80" width="100" height="80" />
480
512
  </bpmndi:BPMNShape>
481
513
  <bpmndi:BPMNShape id="BPMNShape_MergeEnd" bpmnElement="MergeEnd">
482
- <dc:Bounds x="1870" y="102" width="36" height="36" />
514
+ <dc:Bounds x="1845" y="102" width="36" height="36" />
483
515
  <bpmndi:BPMNLabel>
484
- <dc:Bounds x="1863" y="143" width="50" height="14" />
516
+ <dc:Bounds x="1838" y="83" width="50" height="14" />
485
517
  </bpmndi:BPMNLabel>
486
518
  </bpmndi:BPMNShape>
487
519
  <bpmndi:BPMNShape id="BPMNShape_MergeAbandoned" bpmnElement="MergeAbandoned">
@@ -491,27 +523,27 @@
491
523
  </bpmndi:BPMNLabel>
492
524
  </bpmndi:BPMNShape>
493
525
  <bpmndi:BPMNShape id="BPMNShape_merge-esc-conflict" bpmnElement="merge-esc-conflict">
494
- <dc:Bounds x="1238" y="1840" width="100" height="80" />
526
+ <dc:Bounds x="1238" y="2000" width="100" height="80" />
495
527
  </bpmndi:BPMNShape>
496
528
  <bpmndi:BPMNShape id="BPMNShape_merge-esc-attempt" bpmnElement="merge-esc-attempt">
497
- <dc:Bounds x="1438" y="1360" width="100" height="80" />
529
+ <dc:Bounds x="1638" y="1360" width="100" height="80" />
498
530
  </bpmndi:BPMNShape>
499
531
  <bpmndi:BPMNShape id="BPMNShape_gw-merge-escalated" bpmnElement="gw-merge-escalated" isMarkerVisible="true">
500
- <dc:Bounds x="1663" y="1375" width="50" height="50" />
532
+ <dc:Bounds x="1838" y="1375" width="50" height="50" />
501
533
  <bpmndi:BPMNLabel>
502
- <dc:Bounds x="1651" y="1342" width="74" height="28" />
534
+ <dc:Bounds x="1826" y="1470" width="74" height="28" />
503
535
  </bpmndi:BPMNLabel>
504
536
  </bpmndi:BPMNShape>
505
537
  <bpmndi:BPMNShape id="BPMNShape_wait-merge-answer" bpmnElement="wait-merge-answer">
506
- <dc:Bounds x="1838" y="1360" width="100" height="80" />
538
+ <dc:Bounds x="1988" y="1360" width="100" height="80" />
507
539
  </bpmndi:BPMNShape>
508
540
  <bpmndi:BPMNShape id="BPMNShape_record-merge-answer" bpmnElement="record-merge-answer">
509
- <dc:Bounds x="2038" y="1360" width="100" height="80" />
541
+ <dc:Bounds x="2188" y="1360" width="100" height="80" />
510
542
  </bpmndi:BPMNShape>
511
543
  <bpmndi:BPMNShape id="BPMNShape_gw-ci-fix" bpmnElement="gw-ci-fix" isMarkerVisible="true">
512
- <dc:Bounds x="863" y="1855" width="50" height="50" />
544
+ <dc:Bounds x="863" y="2015" width="50" height="50" />
513
545
  <bpmndi:BPMNLabel>
514
- <dc:Bounds x="769" y="1873" width="89" height="14" />
546
+ <dc:Bounds x="769" y="2033" width="89" height="14" />
515
547
  </bpmndi:BPMNLabel>
516
548
  </bpmndi:BPMNShape>
517
549
  <bpmndi:BPMNShape id="BPMNShape_fix-ci" bpmnElement="fix-ci">
@@ -523,6 +555,15 @@
523
555
  <dc:Bounds x="1225" y="790" width="46" height="14" />
524
556
  </bpmndi:BPMNLabel>
525
557
  </bpmndi:BPMNShape>
558
+ <bpmndi:BPMNShape id="BPMNShape_gw-ci-blocked" bpmnElement="gw-ci-blocked" isMarkerVisible="true">
559
+ <dc:Bounds x="1463" y="735" width="50" height="50" />
560
+ <bpmndi:BPMNLabel>
561
+ <dc:Bounds x="1455" y="810" width="67" height="42" />
562
+ </bpmndi:BPMNLabel>
563
+ </bpmndi:BPMNShape>
564
+ <bpmndi:BPMNShape id="BPMNShape_ci-reconcile" bpmnElement="ci-reconcile">
565
+ <dc:Bounds x="1638" y="560" width="100" height="80" />
566
+ </bpmndi:BPMNShape>
526
567
  <bpmndi:BPMNShape id="BPMNShape_gw-rebase" bpmnElement="gw-rebase" isMarkerVisible="true">
527
568
  <dc:Bounds x="863" y="255" width="50" height="50" />
528
569
  <bpmndi:BPMNLabel>
@@ -589,13 +630,21 @@
589
630
  </bpmndi:BPMNEdge>
590
631
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_done" bpmnElement="f_m_done">
591
632
  <di:waypoint x="1738" y="120" />
592
- <di:waypoint x="1870" y="120" />
633
+ <di:waypoint x="1845" y="120" />
593
634
  </bpmndi:BPMNEdge>
594
635
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_giveup" bpmnElement="f_ci_giveup">
595
- <di:waypoint x="913" y="1880" />
596
- <di:waypoint x="1238" y="1880" />
636
+ <di:waypoint x="913" y="2040" />
637
+ <di:waypoint x="1238" y="2040" />
638
+ <bpmndi:BPMNLabel>
639
+ <dc:Bounds x="1032" y="2007" width="67" height="28" />
640
+ </bpmndi:BPMNLabel>
641
+ </bpmndi:BPMNEdge>
642
+ <bpmndi:BPMNEdge id="BPMNEdge_f_cib_esc" bpmnElement="f_cib_esc">
643
+ <di:waypoint x="1513" y="760" />
644
+ <di:waypoint x="1688" y="760" />
645
+ <di:waypoint x="1688" y="1360" />
597
646
  <bpmndi:BPMNLabel>
598
- <dc:Bounds x="1032" y="1847" width="67" height="28" />
647
+ <dc:Bounds x="1569" y="765" width="64" height="42" />
599
648
  </bpmndi:BPMNLabel>
600
649
  </bpmndi:BPMNEdge>
601
650
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_reconcile" bpmnElement="f_ci_reconcile">
@@ -612,11 +661,11 @@
612
661
  <di:waypoint x="933" y="280" />
613
662
  <di:waypoint x="933" y="305" />
614
663
  <di:waypoint x="1326" y="305" />
615
- <di:waypoint x="1326" y="1820" />
616
- <di:waypoint x="1288" y="1820" />
617
- <di:waypoint x="1288" y="1840" />
664
+ <di:waypoint x="1326" y="1980" />
665
+ <di:waypoint x="1288" y="1980" />
666
+ <di:waypoint x="1288" y="2000" />
618
667
  <bpmndi:BPMNLabel>
619
- <dc:Bounds x="1331" y="1566" width="67" height="28" />
668
+ <dc:Bounds x="1331" y="1646" width="67" height="28" />
620
669
  </bpmndi:BPMNLabel>
621
670
  </bpmndi:BPMNEdge>
622
671
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_reconcile" bpmnElement="f_reb_reconcile">
@@ -630,9 +679,9 @@
630
679
  </bpmndi:BPMNEdge>
631
680
  <bpmndi:BPMNEdge id="BPMNEdge_f_mr_giveup" bpmnElement="f_mr_giveup">
632
681
  <di:waypoint x="1313" y="1400" />
633
- <di:waypoint x="1438" y="1400" />
682
+ <di:waypoint x="1638" y="1400" />
634
683
  <bpmndi:BPMNLabel>
635
- <dc:Bounds x="1332" y="1367" width="67" height="28" />
684
+ <dc:Bounds x="1432" y="1367" width="67" height="28" />
636
685
  </bpmndi:BPMNLabel>
637
686
  </bpmndi:BPMNEdge>
638
687
  <bpmndi:BPMNEdge id="BPMNEdge_f_eg_landed" bpmnElement="f_eg_landed">
@@ -645,30 +694,30 @@
645
694
  <di:waypoint x="1688" y="160" />
646
695
  </bpmndi:BPMNEdge>
647
696
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_escC" bpmnElement="f_m_escC">
648
- <di:waypoint x="1338" y="1880" />
649
- <di:waypoint x="1888" y="1880" />
650
- <di:waypoint x="1888" y="1440" />
697
+ <di:waypoint x="1338" y="2040" />
698
+ <di:waypoint x="2038" y="2040" />
699
+ <di:waypoint x="2038" y="1440" />
651
700
  </bpmndi:BPMNEdge>
652
701
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_escA" bpmnElement="f_m_escA">
653
- <di:waypoint x="1538" y="1400" />
654
- <di:waypoint x="1663" y="1400" />
702
+ <di:waypoint x="1738" y="1400" />
703
+ <di:waypoint x="1838" y="1400" />
655
704
  </bpmndi:BPMNEdge>
656
705
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_escReenter" bpmnElement="f_m_escReenter">
657
- <di:waypoint x="1688" y="1425" />
658
- <di:waypoint x="1688" y="1460" />
706
+ <di:waypoint x="1863" y="1425" />
707
+ <di:waypoint x="1863" y="1460" />
659
708
  <di:waypoint x="402" y="1460" />
660
709
  <di:waypoint x="402" y="160" />
661
710
  <bpmndi:BPMNLabel>
662
- <dc:Bounds x="1008" y="1465" width="74" height="42" />
711
+ <dc:Bounds x="1096" y="1465" width="74" height="42" />
663
712
  </bpmndi:BPMNLabel>
664
713
  </bpmndi:BPMNEdge>
665
714
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_answerRecord" bpmnElement="f_m_answerRecord">
666
- <di:waypoint x="1938" y="1400" />
667
- <di:waypoint x="2038" y="1400" />
715
+ <di:waypoint x="2088" y="1400" />
716
+ <di:waypoint x="2188" y="1400" />
668
717
  </bpmndi:BPMNEdge>
669
718
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_answer" bpmnElement="f_m_answer">
670
- <di:waypoint x="2088" y="1440" />
671
- <di:waypoint x="2088" y="1460" />
719
+ <di:waypoint x="2238" y="1440" />
720
+ <di:waypoint x="2238" y="1460" />
672
721
  <di:waypoint x="402" y="1460" />
673
722
  <di:waypoint x="402" y="160" />
674
723
  </bpmndi:BPMNEdge>
@@ -678,9 +727,9 @@
678
727
  <di:waypoint x="1333" y="318" />
679
728
  <di:waypoint x="1333" y="1340" />
680
729
  <di:waypoint x="863" y="1340" />
681
- <di:waypoint x="863" y="1835" />
682
- <di:waypoint x="888" y="1835" />
683
- <di:waypoint x="888" y="1855" />
730
+ <di:waypoint x="863" y="1995" />
731
+ <di:waypoint x="888" y="1995" />
732
+ <di:waypoint x="888" y="2015" />
684
733
  <bpmndi:BPMNLabel>
685
734
  <dc:Bounds x="1011" y="1293" width="60" height="42" />
686
735
  </bpmndi:BPMNLabel>
@@ -699,64 +748,62 @@
699
748
  <di:waypoint x="1326" y="318" />
700
749
  <di:waypoint x="1326" y="1340" />
701
750
  <di:waypoint x="1218" y="1340" />
702
- <di:waypoint x="1218" y="1860" />
703
- <di:waypoint x="1238" y="1860" />
751
+ <di:waypoint x="1218" y="2020" />
752
+ <di:waypoint x="1238" y="2020" />
704
753
  <bpmndi:BPMNLabel>
705
754
  <dc:Bounds x="1236" y="862" width="85" height="14" />
706
755
  </bpmndi:BPMNLabel>
707
756
  </bpmndi:BPMNEdge>
708
757
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_go" bpmnElement="f_ci_go">
709
- <di:waypoint x="888" y="1905" />
710
- <di:waypoint x="888" y="1925" />
711
- <di:waypoint x="2158" y="1925" />
712
- <di:waypoint x="2158" y="715" />
758
+ <di:waypoint x="888" y="2065" />
759
+ <di:waypoint x="888" y="2085" />
760
+ <di:waypoint x="2308" y="2085" />
761
+ <di:waypoint x="2308" y="715" />
713
762
  <di:waypoint x="1018" y="715" />
714
763
  <di:waypoint x="1018" y="740" />
715
764
  <di:waypoint x="1038" y="740" />
716
765
  <bpmndi:BPMNLabel>
717
- <dc:Bounds x="2163" y="1306" width="49" height="28" />
766
+ <dc:Bounds x="2313" y="1386" width="49" height="28" />
718
767
  </bpmndi:BPMNLabel>
719
768
  </bpmndi:BPMNEdge>
720
- <bpmndi:BPMNEdge id="BPMNEdge_f_ci_blocked" bpmnElement="f_ci_blocked">
721
- <di:waypoint x="1313" y="760" />
722
- <di:waypoint x="1418" y="760" />
723
- <di:waypoint x="1418" y="1400" />
724
- <di:waypoint x="1438" y="1400" />
769
+ <bpmndi:BPMNEdge id="BPMNEdge_f_cib_recon" bpmnElement="f_cib_recon">
770
+ <di:waypoint x="1488" y="735" />
771
+ <di:waypoint x="1488" y="600" />
772
+ <di:waypoint x="1638" y="600" />
725
773
  <bpmndi:BPMNLabel>
726
- <dc:Bounds x="1423" y="1013" width="89" height="14" />
774
+ <dc:Bounds x="1513" y="460" width="67" height="56" />
727
775
  </bpmndi:BPMNLabel>
728
776
  </bpmndi:BPMNEdge>
729
777
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_wait" bpmnElement="f_ci_wait">
730
778
  <di:waypoint x="1313" y="760" />
731
- <di:waypoint x="1488" y="760" />
732
- <di:waypoint x="1488" y="880" />
779
+ <di:waypoint x="1418" y="760" />
780
+ <di:waypoint x="1418" y="920" />
781
+ <di:waypoint x="1438" y="920" />
733
782
  <bpmndi:BPMNLabel>
734
- <dc:Bounds x="1416" y="727" width="75" height="28" />
783
+ <dc:Bounds x="1338" y="826" width="75" height="28" />
735
784
  </bpmndi:BPMNLabel>
736
785
  </bpmndi:BPMNEdge>
737
786
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_go" bpmnElement="f_reb_go">
738
787
  <di:waypoint x="913" y="280" />
739
788
  <di:waypoint x="933" y="280" />
740
789
  <di:waypoint x="933" y="305" />
741
- <di:waypoint x="1733" y="305" />
742
- <di:waypoint x="1733" y="1820" />
743
- <di:waypoint x="1250" y="1820" />
744
- <di:waypoint x="1250" y="1355" />
745
- <di:waypoint x="1358" y="1355" />
746
- <di:waypoint x="1358" y="1035" />
747
- <di:waypoint x="1018" y="1035" />
790
+ <di:waypoint x="1443" y="305" />
791
+ <di:waypoint x="1443" y="805" />
792
+ <di:waypoint x="1558" y="805" />
793
+ <di:waypoint x="1558" y="980" />
794
+ <di:waypoint x="1018" y="980" />
748
795
  <di:waypoint x="1018" y="1060" />
749
796
  <di:waypoint x="1038" y="1060" />
750
797
  <bpmndi:BPMNLabel>
751
- <dc:Bounds x="1255" y="1574" width="49" height="28" />
798
+ <dc:Bounds x="1448" y="561" width="49" height="28" />
752
799
  </bpmndi:BPMNLabel>
753
800
  </bpmndi:BPMNEdge>
754
801
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_blocked" bpmnElement="f_reb_blocked">
755
802
  <di:waypoint x="1313" y="1080" />
756
- <di:waypoint x="1488" y="1080" />
757
- <di:waypoint x="1488" y="1360" />
803
+ <di:waypoint x="1688" y="1080" />
804
+ <di:waypoint x="1688" y="1360" />
758
805
  <bpmndi:BPMNLabel>
759
- <dc:Bounds x="1429" y="1047" width="64" height="28" />
806
+ <dc:Bounds x="1469" y="1047" width="64" height="28" />
760
807
  </bpmndi:BPMNLabel>
761
808
  </bpmndi:BPMNEdge>
762
809
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_wait" bpmnElement="f_reb_wait">
@@ -778,15 +825,15 @@
778
825
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_gRetry" bpmnElement="f_m_gRetry">
779
826
  <di:waypoint x="1088" y="95" />
780
827
  <di:waypoint x="1088" y="75" />
781
- <di:waypoint x="1926" y="75" />
782
- <di:waypoint x="1926" y="318" />
783
- <di:waypoint x="1643" y="318" />
784
- <di:waypoint x="1643" y="1445" />
828
+ <di:waypoint x="1901" y="75" />
829
+ <di:waypoint x="1901" y="145" />
830
+ <di:waypoint x="1825" y="145" />
831
+ <di:waypoint x="1825" y="1445" />
785
832
  <di:waypoint x="1263" y="1445" />
786
833
  <di:waypoint x="1288" y="1445" />
787
834
  <di:waypoint x="1288" y="1425" />
788
835
  <bpmndi:BPMNLabel>
789
- <dc:Bounds x="1746" y="285" width="78" height="28" />
836
+ <dc:Bounds x="1414" y="1412" width="78" height="28" />
790
837
  </bpmndi:BPMNLabel>
791
838
  </bpmndi:BPMNEdge>
792
839
  <bpmndi:BPMNEdge id="BPMNEdge_f_eg_evicted" bpmnElement="f_eg_evicted">
@@ -797,13 +844,15 @@
797
844
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_gBlocked" bpmnElement="f_m_gBlocked">
798
845
  <di:waypoint x="1088" y="95" />
799
846
  <di:waypoint x="1088" y="75" />
800
- <di:waypoint x="1926" y="75" />
801
- <di:waypoint x="1926" y="980" />
802
- <di:waypoint x="1418" y="980" />
803
- <di:waypoint x="1418" y="1380" />
804
- <di:waypoint x="1438" y="1380" />
847
+ <di:waypoint x="1901" y="75" />
848
+ <di:waypoint x="1901" y="145" />
849
+ <di:waypoint x="1825" y="145" />
850
+ <di:waypoint x="1825" y="1445" />
851
+ <di:waypoint x="1618" y="1445" />
852
+ <di:waypoint x="1618" y="1420" />
853
+ <di:waypoint x="1638" y="1420" />
805
854
  <bpmndi:BPMNLabel>
806
- <dc:Bounds x="1931" y="642" width="53" height="14" />
855
+ <dc:Bounds x="1837" y="153" width="53" height="14" />
807
856
  </bpmndi:BPMNLabel>
808
857
  </bpmndi:BPMNEdge>
809
858
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_gAbandoned" bpmnElement="f_m_gAbandoned">
@@ -822,20 +871,27 @@
822
871
  <di:waypoint x="1138" y="760" />
823
872
  <di:waypoint x="1263" y="760" />
824
873
  </bpmndi:BPMNEdge>
874
+ <bpmndi:BPMNEdge id="BPMNEdge_f_ci_blocked" bpmnElement="f_ci_blocked">
875
+ <di:waypoint x="1313" y="760" />
876
+ <di:waypoint x="1463" y="760" />
877
+ <bpmndi:BPMNLabel>
878
+ <dc:Bounds x="1341" y="738" width="89" height="14" />
879
+ </bpmndi:BPMNLabel>
880
+ </bpmndi:BPMNEdge>
825
881
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_done" bpmnElement="f_reb_done">
826
882
  <di:waypoint x="1138" y="1080" />
827
883
  <di:waypoint x="1263" y="1080" />
828
884
  </bpmndi:BPMNEdge>
829
885
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_escWait" bpmnElement="f_m_escWait">
830
- <di:waypoint x="1713" y="1400" />
831
- <di:waypoint x="1838" y="1400" />
886
+ <di:waypoint x="1888" y="1400" />
887
+ <di:waypoint x="1988" y="1400" />
832
888
  </bpmndi:BPMNEdge>
833
889
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_sla" bpmnElement="f_reb_sla">
834
890
  <di:waypoint x="1088" y="1138" />
835
891
  <di:waypoint x="1088" y="1158" />
836
892
  <di:waypoint x="1218" y="1158" />
837
- <di:waypoint x="1218" y="1880" />
838
- <di:waypoint x="1238" y="1880" />
893
+ <di:waypoint x="1218" y="2040" />
894
+ <di:waypoint x="1238" y="2040" />
839
895
  <bpmndi:BPMNLabel>
840
896
  <dc:Bounds x="1118" y="1125" width="70" height="28" />
841
897
  </bpmndi:BPMNLabel>
@@ -845,12 +901,15 @@
845
901
  <di:waypoint x="1088" y="838" />
846
902
  <di:waypoint x="1018" y="838" />
847
903
  <di:waypoint x="1018" y="700" />
848
- <di:waypoint x="1558" y="700" />
849
- <di:waypoint x="1558" y="980" />
850
- <di:waypoint x="1526" y="980" />
851
- <di:waypoint x="1526" y="1360" />
904
+ <di:waypoint x="1358" y="700" />
905
+ <di:waypoint x="1358" y="540" />
906
+ <di:waypoint x="1758" y="540" />
907
+ <di:waypoint x="1758" y="1980" />
908
+ <di:waypoint x="1618" y="1980" />
909
+ <di:waypoint x="1618" y="1400" />
910
+ <di:waypoint x="1638" y="1400" />
852
911
  <bpmndi:BPMNLabel>
853
- <dc:Bounds x="1233" y="667" width="70" height="28" />
912
+ <dc:Bounds x="1363" y="606" width="70" height="28" />
854
913
  </bpmndi:BPMNLabel>
855
914
  </bpmndi:BPMNEdge>
856
915
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_fixed" bpmnElement="f_ci_fixed">
@@ -859,9 +918,24 @@
859
918
  <di:waypoint x="402" y="840" />
860
919
  <di:waypoint x="402" y="160" />
861
920
  <bpmndi:BPMNLabel>
862
- <dc:Bounds x="826" y="848" width="39" height="14" />
921
+ <dc:Bounds x="906" y="818" width="39" height="14" />
863
922
  </bpmndi:BPMNLabel>
864
923
  </bpmndi:BPMNEdge>
924
+ <bpmndi:BPMNEdge id="BPMNEdge_f_ci_reattempt" bpmnElement="f_ci_reattempt">
925
+ <di:waypoint x="1288" y="785" />
926
+ <di:waypoint x="1288" y="840" />
927
+ <di:waypoint x="402" y="840" />
928
+ <di:waypoint x="402" y="160" />
929
+ <bpmndi:BPMNLabel>
930
+ <dc:Bounds x="801" y="845" width="88" height="42" />
931
+ </bpmndi:BPMNLabel>
932
+ </bpmndi:BPMNEdge>
933
+ <bpmndi:BPMNEdge id="BPMNEdge_f_cib_armed" bpmnElement="f_cib_armed">
934
+ <di:waypoint x="1688" y="640" />
935
+ <di:waypoint x="1688" y="660" />
936
+ <di:waypoint x="570" y="660" />
937
+ <di:waypoint x="570" y="138" />
938
+ </bpmndi:BPMNEdge>
865
939
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_rebased" bpmnElement="f_reb_rebased">
866
940
  <di:waypoint x="1288" y="1105" />
867
941
  <di:waypoint x="1288" y="1160" />
@@ -75,15 +75,30 @@ Return a structured result:
75
75
  first, as `owner/repo#N` refs (or PR URLs), separated by commas or spaces. The
76
76
  process records the dependency and automatically re-attempts the merge once every
77
77
  named PR has landed.
78
+ - `status: "reattempt"` — you pushed **no** commit because there was **nothing to fix
79
+ on the branch**: the failing required checks are **stale / transient** — e.g.
80
+ `CANCELLED` runs superseded by a newer run on the *identical head SHA* (GitHub CI
81
+ concurrency), a newer run on the same SHA that is already green, or an infrastructure
82
+ blip — and the PR's head is actually mergeable. This is **not** a defect and **not** a
83
+ human decision: the merge should simply be **re-queued from ground truth**. Set
84
+ `pushed: false` and say in `summary` why the checks are stale (which runs, on which
85
+ SHA). The process re-arms the merge poller, which re-derives mergeability and merges
86
+ when the head is green — **no human is pulled in**. Prefer this over `blocked` for
87
+ every "nothing was wrong / the failure was transient / the head already looks green"
88
+ case.
78
89
  - `status: "blocked"` — you could **not** fix it and it genuinely needs a human
79
90
  **decision** (a secret, an upstream change, or a judgement call). Set `question` to a
80
91
  concise, specific description of what is blocking and what a human must decide.
81
- Reserve this for a real decision — if the PR is merely waiting on another PR, use
82
- `waiting-on-pr` instead so no human is pulled in.
92
+ Reserve this for a **real** human decision — if the PR is merely waiting on another PR
93
+ use `waiting-on-pr`, and if the failing checks are stale/transient with nothing to fix
94
+ use `reattempt`. `blocked` **pages a human**, so never use it for a self-healing PR.
83
95
 
84
- Never report `fixed` unless you actually pushed a change. If nothing was wrong on
85
- the branch (the failure was transient infrastructure), say so in `summary` and
86
- return `blocked` so a human can decide whether to just retry the merge.
96
+ Report whether you pushed via `pushed` (`true` after you commit + push a fix, `false`
97
+ when you changed nothing). Never report `fixed` unless you actually pushed a change. If
98
+ nothing was wrong on the branch (the failure was transient infrastructure / stale
99
+ cancelled checks and the head is green), return **`reattempt`** (with `pushed: false`)
100
+ so the merge is simply re-attempted from ground truth — do **not** return `blocked`,
101
+ which would needlessly page a human.
87
102
 
88
103
  ### How to return it (the wire mechanism)
89
104
 
@@ -98,11 +113,13 @@ machine-readable result one of two ways:
98
113
 
99
114
  ```sh
100
115
  # pushed a fix you believe turns the failing checks green:
101
- printf '%s' '{"status":"fixed","summary":"Fixed the flaky timeout in auth.test.ts and pushed"}' > "$AGENT_RESULT_FILE"
116
+ printf '%s' '{"status":"fixed","pushed":true,"summary":"Fixed the flaky timeout in auth.test.ts and pushed"}' > "$AGENT_RESULT_FILE"
117
+ # nothing to fix — the failing checks are stale/transient (CANCELLED superseded on the same head SHA, head already green); just re-attempt the merge:
118
+ printf '%s' '{"status":"reattempt","pushed":false,"summary":"Failing checks are CANCELLED runs superseded by a green run on the same head SHA — nothing to fix, re-queue the merge"}' > "$AGENT_RESULT_FILE"
102
119
  # ordering constraint — must wait for another PR to land first:
103
- printf '%s' '{"status":"waiting-on-pr","summary":"Blocked by the linked-issue gate","dependsOn":"owner/repo#123"}' > "$AGENT_RESULT_FILE"
120
+ printf '%s' '{"status":"waiting-on-pr","pushed":false,"summary":"Blocked by the linked-issue gate","dependsOn":"owner/repo#123"}' > "$AGENT_RESULT_FILE"
104
121
  # genuinely stuck — a human must decide:
105
- printf '%s' '{"status":"blocked","summary":"CI needs an NPM_TOKEN secret I cannot set","question":"Add the NPM_TOKEN repo secret, then answer to rerun."}' > "$AGENT_RESULT_FILE"
122
+ printf '%s' '{"status":"blocked","pushed":false,"summary":"CI needs an NPM_TOKEN secret I cannot set","question":"Add the NPM_TOKEN repo secret, then answer to rerun."}' > "$AGENT_RESULT_FILE"
106
123
  ```
107
124
 
108
125
  Write this file **once**, at the very end, with your final result. Keep it a flat
@@ -125,7 +142,10 @@ a non-zero exit means a genuine crash and the job is retried.
125
142
  **Emitting a machine-readable result is your mandatory final step — never exit
126
143
  silently.** It is the last thing you do on every path out of this job (including after
127
144
  a push, or when you conclude nothing can be fixed). If you are ever unsure which status
128
- applies, return **`blocked`** with a `summary` and a concrete `question` rather than
129
- leaving without a result a missing result is treated as an unclassified merge
130
- escalation that pulls in a human and stalls the merge, so relying on that default
131
- wastes the attempt.
145
+ applies but the PR's **head looks green / the failure was transient** (stale or
146
+ cancelled checks, an infra blip), return **`reattempt`** (with `pushed: false`) so the
147
+ merge is simply re-attempted from ground truth do **not** default to `blocked`, which
148
+ pages a human. Reserve `blocked` for a genuine human decision with a concrete
149
+ `question`. A missing result is treated by the process as *no verdict* and reconciled
150
+ from ground truth (it re-arms the merge poller), which is slower and drops your explicit
151
+ classification — emit `reattempt` explicitly rather than relying on that fall-through.