@nanobpm/nano-workforce 0.111.0 → 0.112.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,8 @@
3
3
  // the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
4
4
  import { test } from "node:test";
5
5
  import { assertEquals, assertRejects } from "#test-assert";
6
- import { BaseBranchMustExistError, classifyPrLiveness, coalesceTitle, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchIssueTitle, fetchPrFiles, isNotAPullRequestError, listPrsForHead, type PrState } from "./github.ts";
6
+ import { BaseBranchMustExistError, checkConclusions, classifyMergeability, classifyPrLiveness, coalesceTitle, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchIssueTitle, fetchPrFiles, isNotAPullRequestError, listPrsForHead, type Mergeability, type PrState } from "./github.ts";
7
+ import { DEFAULT_MERGE_PROTOCOL, type MergeProtocol, type RequiredCheck } from "./mergeProtocol.ts";
7
8
 
8
9
  // A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
9
10
  // files (named `f{index}`), setting a `Link: rel="next"` header whenever a later page exists.
@@ -440,6 +441,8 @@ function prState(over: Partial<PrState>): PrState {
440
441
  failingChecks: 0,
441
442
  failingCheckNames: [],
442
443
  presentCheckNames: [],
444
+ pendingCheckNames: [],
445
+ checkConclusions: {},
443
446
  totalChecks: 0,
444
447
  isDraft: false,
445
448
  headRefOid: null,
@@ -463,3 +466,224 @@ test("classifyPrLiveness: a closed-not-merged PR is terminal (abandon)", () => {
463
466
  test("classifyPrLiveness: a null read (transport hiccup) is unknown — never abandons blind", () => {
464
467
  assertEquals(classifyPrLiveness(null), "unknown");
465
468
  });
469
+
470
+ // ── classifyMergeability: protocol-aware required-checks backstop (issue #392) ────────────────────
471
+ //
472
+ // The merge poller must NOT merge a PR whose DECLARED-required check is red, even on a repo that
473
+ // under-specifies its GitHub-required checks (so GitHub reports the PR as UNSTABLE, i.e. "only
474
+ // non-required checks failing" from GitHub's view). `classifyMergeability` now intersects the repo's
475
+ // merge-protocol `requiredChecks[]`/`waitForChecks` against the head's latest-run-per-check
476
+ // conclusions (via `latestRunPerCheck`, preserving the #348 CANCELLED-supersede semantics) as an
477
+ // independent backstop that runs BEFORE the `mergeStateStatus` switch. These are pure unit tests.
478
+
479
+ // Build a `PrState` with sensible defaults; `over` supplies the fields a case cares about. `over`
480
+ // may pass a `rollup` shorthand (name → conclusion) that we compile into the exact per-check fields
481
+ // `classifyMergeability` reads (present/pending/conclusions), mirroring what `fetchPrState` derives.
482
+ function mergePrState(over: Partial<PrState> & { rollup?: { name: string; conclusion: string }[] } = {}): PrState {
483
+ const { rollup, ...rest } = over;
484
+ const base: PrState = {
485
+ merged: false,
486
+ state: "open",
487
+ mergeStateStatus: "CLEAN",
488
+ failingChecks: 0,
489
+ failingCheckNames: [],
490
+ totalChecks: 0,
491
+ presentCheckNames: [],
492
+ pendingCheckNames: [],
493
+ checkConclusions: {},
494
+ isDraft: false,
495
+ headRefOid: "abc123",
496
+ };
497
+ if (rollup) {
498
+ const bad = new Set(["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "ERROR"]);
499
+ const pendingStates = new Set(["", "PENDING", "QUEUED", "IN_PROGRESS", "EXPECTED", "WAITING"]);
500
+ const present: string[] = [];
501
+ const pending: string[] = [];
502
+ const failing: string[] = [];
503
+ const conclusions: Record<string, string> = {};
504
+ for (const c of rollup) {
505
+ const v = c.conclusion.toUpperCase();
506
+ present.push(c.name);
507
+ conclusions[c.name] = pendingStates.has(v) ? "" : v;
508
+ if (pendingStates.has(v)) pending.push(c.name);
509
+ else if (bad.has(v)) failing.push(c.name);
510
+ }
511
+ base.presentCheckNames = present;
512
+ base.pendingCheckNames = pending;
513
+ base.checkConclusions = conclusions;
514
+ base.failingCheckNames = failing;
515
+ base.failingChecks = failing.length;
516
+ base.totalChecks = rollup.length;
517
+ }
518
+ return { ...base, ...rest };
519
+ }
520
+
521
+ function reqChecks(...names: string[]): RequiredCheck[] {
522
+ return names.map((name) => ({ name, acceptedConclusions: ["success"] }));
523
+ }
524
+
525
+ function protocolWith(over: Partial<MergeProtocol>): MergeProtocol {
526
+ return { ...DEFAULT_MERGE_PROTOCOL, ...over };
527
+ }
528
+
529
+ interface MergeCase {
530
+ name: string;
531
+ state: Partial<PrState> & { rollup?: { name: string; conclusion: string }[] };
532
+ protocol?: MergeProtocol;
533
+ want: Mergeability;
534
+ }
535
+
536
+ const MERGE_CASES: MergeCase[] = [
537
+ // The exact defect: UNSTABLE + a red DECLARED-required check used to classify `ready` and merge.
538
+ {
539
+ name: "UNSTABLE + red declared-required check -> blocked (the #392 defect)",
540
+ state: { mergeStateStatus: "UNSTABLE", rollup: [{ name: "test (22.x, simple)", conclusion: "FAILURE" }] },
541
+ protocol: protocolWith({ requiredChecks: [{ name: "test (22.x, simple)", acceptedConclusions: ["success"] }] }),
542
+ want: "blocked",
543
+ },
544
+ {
545
+ name: "CLEAN + red declared-required check -> blocked (backstop runs before the switch)",
546
+ state: { mergeStateStatus: "CLEAN", rollup: [{ name: "build", conclusion: "FAILURE" }] },
547
+ protocol: protocolWith({ requiredChecks: reqChecks("build") }),
548
+ want: "blocked",
549
+ },
550
+ {
551
+ name: "declared-required check pending -> waiting",
552
+ state: { mergeStateStatus: "CLEAN", rollup: [{ name: "build", conclusion: "IN_PROGRESS" }] },
553
+ protocol: protocolWith({ requiredChecks: reqChecks("build") }),
554
+ want: "waiting",
555
+ },
556
+ {
557
+ name: "declared-required check absent from head -> waiting (absence is not a pass)",
558
+ state: { mergeStateStatus: "CLEAN", rollup: [{ name: "lint", conclusion: "SUCCESS" }] },
559
+ protocol: protocolWith({ requiredChecks: reqChecks("build") }),
560
+ want: "waiting",
561
+ },
562
+ {
563
+ name: "declared-required check passing, CLEAN -> ready",
564
+ state: { mergeStateStatus: "CLEAN", rollup: [{ name: "build", conclusion: "SUCCESS" }] },
565
+ protocol: protocolWith({ requiredChecks: reqChecks("build") }),
566
+ want: "ready",
567
+ },
568
+ {
569
+ name: "declared-required check passing, UNSTABLE -> ready (falls through to the switch)",
570
+ state: { mergeStateStatus: "UNSTABLE", rollup: [{ name: "build", conclusion: "SUCCESS" }] },
571
+ protocol: protocolWith({ requiredChecks: reqChecks("build") }),
572
+ want: "ready",
573
+ },
574
+ {
575
+ name: "non-required check failing (not declared-required), UNSTABLE -> ready (today's behaviour)",
576
+ state: {
577
+ mergeStateStatus: "UNSTABLE",
578
+ rollup: [{ name: "build", conclusion: "SUCCESS" }, { name: "flaky-optional", conclusion: "FAILURE" }],
579
+ },
580
+ protocol: protocolWith({ requiredChecks: reqChecks("build") }),
581
+ want: "ready",
582
+ },
583
+ {
584
+ name: "CANCELLED superseded by a newer green run on a required check -> ready (#348 semantics)",
585
+ // `prState`'s rollup shorthand keeps one conclusion per name (latest wins); model the superseded
586
+ // + re-run by asserting the green outcome the rollup helpers collapse to.
587
+ state: { mergeStateStatus: "UNSTABLE", rollup: [{ name: "engine-core", conclusion: "SUCCESS" }] },
588
+ protocol: protocolWith({ requiredChecks: reqChecks("engine-core") }),
589
+ want: "ready",
590
+ },
591
+ {
592
+ name: "acceptedConclusions beyond [success] honoured: NEUTRAL accepted -> ready",
593
+ state: { mergeStateStatus: "CLEAN", rollup: [{ name: "build", conclusion: "NEUTRAL" }] },
594
+ protocol: protocolWith({ requiredChecks: [{ name: "build", acceptedConclusions: ["success", "neutral"] }] }),
595
+ want: "ready",
596
+ },
597
+ {
598
+ name: "acceptedConclusions [success] does NOT accept a NEUTRAL required conclusion -> blocked",
599
+ state: { mergeStateStatus: "CLEAN", rollup: [{ name: "build", conclusion: "NEUTRAL" }] },
600
+ protocol: protocolWith({ requiredChecks: [{ name: "build", acceptedConclusions: ["success"] }] }),
601
+ want: "blocked",
602
+ },
603
+ {
604
+ name: "waitForChecks:true + pending required check -> waiting even when CLEAN",
605
+ state: { mergeStateStatus: "CLEAN", rollup: [{ name: "build", conclusion: "QUEUED" }] },
606
+ protocol: protocolWith({ requiredChecks: reqChecks("build"), waitForChecks: true }),
607
+ want: "waiting",
608
+ },
609
+ ];
610
+
611
+ for (const c of MERGE_CASES) {
612
+ test(`classifyMergeability: ${c.name}`, () => {
613
+ assertEquals(classifyMergeability(mergePrState(c.state), c.protocol), c.want);
614
+ });
615
+ }
616
+
617
+ // Empty requiredChecks (the DEFAULT protocol) — behaviour must be IDENTICAL to today across every
618
+ // mergeStateStatus, whether a protocol is passed or omitted entirely.
619
+ const DEFAULT_BEHAVIOUR: { status: string; failingChecks?: number; want: Mergeability }[] = [
620
+ { status: "CLEAN", want: "ready" },
621
+ { status: "HAS_HOOKS", want: "ready" },
622
+ { status: "UNSTABLE", want: "ready" },
623
+ { status: "BEHIND", want: "ready" },
624
+ { status: "DIRTY", want: "conflict" },
625
+ { status: "BLOCKED", failingChecks: 1, want: "blocked" },
626
+ { status: "BLOCKED", failingChecks: 0, want: "waiting" },
627
+ { status: "UNKNOWN", want: "waiting" },
628
+ { status: "", want: "waiting" },
629
+ ];
630
+
631
+ for (const c of DEFAULT_BEHAVIOUR) {
632
+ test(`classifyMergeability: empty requiredChecks keeps today's behaviour (${c.status || "''"} -> ${c.want})`, () => {
633
+ const s = prState({ mergeStateStatus: c.status, failingChecks: c.failingChecks ?? 0 });
634
+ // Explicit default protocol and omitted-protocol must agree.
635
+ assertEquals(classifyMergeability(s, DEFAULT_MERGE_PROTOCOL), c.want);
636
+ assertEquals(classifyMergeability(s), c.want);
637
+ });
638
+ }
639
+
640
+ // Token mode: the transport can't enumerate checks (`failingChecks === -1`, empty per-check lists),
641
+ // so even a repo that declares requiredChecks must fall through to today's `mergeStateStatus`
642
+ // behaviour — the backstop must NEVER newly block or wait when checks are unenumerable.
643
+ const TOKEN_MODE: { status: string; want: Mergeability }[] = [
644
+ { status: "CLEAN", want: "ready" },
645
+ { status: "UNSTABLE", want: "ready" },
646
+ { status: "BLOCKED", want: "waiting" }, // failingChecks<0 → conservative wait, exactly as before
647
+ { status: "DIRTY", want: "conflict" },
648
+ { status: "UNKNOWN", want: "waiting" },
649
+ ];
650
+
651
+ for (const c of TOKEN_MODE) {
652
+ test(`classifyMergeability: token mode falls through, never newly blocks (${c.status} -> ${c.want})`, () => {
653
+ const s = prState({
654
+ mergeStateStatus: c.status,
655
+ failingChecks: -1,
656
+ totalChecks: -1,
657
+ presentCheckNames: [],
658
+ pendingCheckNames: [],
659
+ checkConclusions: {},
660
+ });
661
+ const protocol = protocolWith({ requiredChecks: reqChecks("build"), waitForChecks: true });
662
+ assertEquals(classifyMergeability(s, protocol), c.want);
663
+ });
664
+ }
665
+
666
+ // `checkConclusions` must report a terminal conclusion per check but normalise a STILL-IN-FLIGHT run
667
+ // to "" for BOTH rollup shapes — a CheckRun whose `status` is not COMPLETED, and a legacy
668
+ // StatusContext whose `state` is PENDING/EXPECTED — so a caller never mistakes a pending
669
+ // status-context's upper-cased `state` (e.g. "PENDING") for a terminal conclusion.
670
+ test("checkConclusions: in-flight runs map to '' for both CheckRun and StatusContext shapes", () => {
671
+ const got = checkConclusions([
672
+ { name: "ci-success", status: "COMPLETED", conclusion: "SUCCESS" },
673
+ { name: "ci-failure", status: "COMPLETED", conclusion: "FAILURE" },
674
+ { name: "ci-running", status: "IN_PROGRESS" }, // CheckRun in flight -> ""
675
+ { name: "ci-queued", status: "QUEUED" }, // CheckRun queued -> ""
676
+ { context: "legacy-pending", state: "PENDING" }, // StatusContext in flight -> ""
677
+ { context: "legacy-expected", state: "EXPECTED" }, // StatusContext in flight -> ""
678
+ { context: "legacy-error", state: "ERROR" }, // StatusContext terminal -> preserved
679
+ ]);
680
+ assertEquals(got, {
681
+ "ci-success": "SUCCESS",
682
+ "ci-failure": "FAILURE",
683
+ "ci-running": "",
684
+ "ci-queued": "",
685
+ "legacy-pending": "",
686
+ "legacy-expected": "",
687
+ "legacy-error": "ERROR",
688
+ });
689
+ });
package/app/github.ts CHANGED
@@ -11,6 +11,11 @@
11
11
  // The poller is app-side host glue (main.ts), so host-specific subprocess I/O is allowed here.
12
12
  // Cross-runtime: runs under Node (`node:child_process`).
13
13
 
14
+ // Type-only import (erased at runtime, so no runtime cycle with mergeProtocol.ts, which imports
15
+ // `fetchRepoFile` from here): `classifyMergeability` reads a repo's declared required checks to gate
16
+ // a merge independently of GitHub branch protection.
17
+ import type { MergeProtocol } from "./mergeProtocol.ts";
18
+
14
19
  /** A GitHub pull-request review, narrowed to the fields the poller needs. */
15
20
  export interface GhReview {
16
21
  id: number;
@@ -502,6 +507,17 @@ export interface PrState {
502
507
  * unrelated always-on check (e.g. Mergify's "Merge Queue") must not read as "the required run
503
508
  * already happened". */
504
509
  presentCheckNames: string[];
510
+ /** Names of every head check still in flight (queued/in progress, not yet concluded and not a hard
511
+ * failure), derived over the newest run per check (`pendingCheckNames`). Empty in token mode (the
512
+ * REST fallback can't enumerate checks). Lets `classifyMergeability` hold a merge when a
513
+ * declared-required check has not yet concluded, without re-deriving conclusions by hand. */
514
+ pendingCheckNames: string[];
515
+ /** The newest concluded conclusion per head check (name → uppercase conclusion, e.g. `SUCCESS` /
516
+ * `FAILURE` / `NEUTRAL` / `SKIPPED`), derived over `latestRunPerCheck` so a `CANCELLED` run
517
+ * superseded by a newer green run on the same head reports the green result (#348). A still-pending
518
+ * run maps to `""` (it has no conclusion yet — use `pendingCheckNames`). Empty in token mode. Lets
519
+ * `classifyMergeability` honour a required check's `acceptedConclusions` precisely. */
520
+ checkConclusions: Record<string, string>;
505
521
  /** Whether the PR is a draft (a fresh head run is produced by marking it ready, not reopen). */
506
522
  isDraft: boolean;
507
523
  /** Current head commit. Used to scope one-shot merge-protocol nudges to a landing attempt. */
@@ -633,6 +649,29 @@ export function allCheckNames(rollup: RollupEntry[]): string[] {
633
649
  return names;
634
650
  }
635
651
 
652
+ /** The ground-truth conclusion of each head check, keyed by check name, derived over the **newest run
653
+ * per check** (`latestRunPerCheck`) so a `CANCELLED` run superseded by a newer green run on the
654
+ * identical head SHA reports the green result, not the stale cancellation (issue #348). The value is
655
+ * the run's `conclusion` (CheckRun) or `state` (legacy StatusContext), upper-cased; a still-in-flight
656
+ * run that has not concluded maps to `""` (it has no conclusion — `pendingCheckNames` tracks those).
657
+ * In-flight is normalised to `""` for BOTH shapes: a CheckRun whose `status` is not `COMPLETED`, and a
658
+ * legacy StatusContext whose `state` is `PENDING`/`EXPECTED`, so a caller never mistakes a pending
659
+ * status-context's `PENDING`/`EXPECTED` `state` for a terminal conclusion.
660
+ * Lets `classifyMergeability` intersect a repo's declared `requiredChecks` against actual head
661
+ * conclusions and honour each check's `acceptedConclusions` without re-deriving per-run state. */
662
+ export function checkConclusions(rollup: RollupEntry[]): Record<string, string> {
663
+ const out: Record<string, string> = {};
664
+ for (const c of latestRunPerCheck(rollup)) {
665
+ const status = (c.status || "").toUpperCase();
666
+ const state = (c.state || "").toUpperCase();
667
+ // A still-in-flight run has no terminal conclusion — normalise both shapes to "" (mirrors
668
+ // `pendingCheckNames`): CheckRun status != COMPLETED, or legacy StatusContext state PENDING/EXPECTED.
669
+ const inFlight = status !== "" ? status !== "COMPLETED" : state === "PENDING" || state === "EXPECTED";
670
+ out[checkKey(c)] = inFlight ? "" : (c.conclusion || c.state || "").toUpperCase();
671
+ }
672
+ return out;
673
+ }
674
+
636
675
  /** True when `err` is GitHub reporting that a ref which parsed as `owner/repo#N` is not a pull
637
676
  * request — either it's an issue (issues and PRs share GitHub's number space, so an issue number
638
677
  * is indistinguishable from a PR number by shape alone) or the number does not exist. Both
@@ -682,6 +721,8 @@ export async function fetchPrState(
682
721
  failingCheckNames: names,
683
722
  totalChecks: rollup.length,
684
723
  presentCheckNames: allCheckNames(rollup),
724
+ pendingCheckNames: pendingCheckNames(rollup),
725
+ checkConclusions: checkConclusions(rollup),
685
726
  isDraft: !!j.isDraft,
686
727
  headRefOid: j.headRefOid ?? null,
687
728
  };
@@ -713,6 +754,8 @@ export async function fetchPrState(
713
754
  failingCheckNames: [], // …and the CI-fix agent gets no per-check list in token mode
714
755
  totalChecks: -1, // …and the fresh-head-run remedy stays conservative (never reopens blind)
715
756
  presentCheckNames: [], // …can't enumerate checks in token mode → no required-check presence signal
757
+ pendingCheckNames: [], // …no per-check pending signal either → classifier degrades to today's switch
758
+ checkConclusions: {}, // …no per-check conclusions → protocol-aware gate falls through in token mode
716
759
  isDraft: !!j.draft,
717
760
  headRefOid: j.head?.sha ?? null,
718
761
  };
@@ -915,11 +958,68 @@ export async function baseBranchLanded(
915
958
  * verdict; `waiting` means re-poll later. */
916
959
  export type Mergeability = "ready" | "waiting" | "conflict" | "blocked";
917
960
 
918
- export function classifyMergeability(s: PrState): Mergeability {
961
+ /** Intersect a repo's declared `requiredChecks` against the head's actual per-check conclusions —
962
+ * an INDEPENDENT backstop that runs BEFORE the `mergeStateStatus` switch, so nwf never merges a red
963
+ * required check even on a repo that has NOT wired that check as a GitHub-required status check
964
+ * (issue #392). Returns:
965
+ * • `"blocked"` — a declared-required check is present, concluded, and its conclusion is NOT in that
966
+ * check's `acceptedConclusions` (a hard failure like `FAILURE`, or any other unaccepted terminal
967
+ * conclusion) → route to fix-ci, do not merge.
968
+ * • `"waiting"` — a declared-required check is still pending, or absent from the head entirely
969
+ * (not-yet-run counts as pending, NOT as pass): a declared-required check that has not
970
+ * concluded is never mergeable.
971
+ * • `"pass"` — every declared-required check is present and its conclusion accepted → fall through to
972
+ * today's `mergeStateStatus` logic (GitHub branch protection stays the primary gate).
973
+ * Degrades safely: with no declared `requiredChecks`, or in token mode where checks can't be
974
+ * enumerated (`failingChecks < 0`, so the per-check lists are empty), it returns `"pass"` and never
975
+ * newly blocks or waits — repos keep exactly today's behaviour. */
976
+ function requiredChecksVerdict(s: PrState, protocol?: MergeProtocol): "blocked" | "waiting" | "pass" {
977
+ const required = protocol?.requiredChecks ?? [];
978
+ if (required.length === 0) return "pass";
979
+ // Token mode: the transport can't enumerate checks (`failingChecks === -1`), so the per-check lists
980
+ // are empty and absence is indistinguishable from not-yet-run. Do NOT newly block/wait — fall
981
+ // through to today's `mergeStateStatus` behaviour. (A real gh-mode head with no checks yet reports
982
+ // `failingChecks === 0`, so absence there is correctly treated as not-yet-run below.)
983
+ if (s.failingChecks < 0) return "pass";
984
+ const present = new Set(s.presentCheckNames);
985
+ const pending = new Set(s.pendingCheckNames);
986
+ let anyBlocked = false;
987
+ let anyPending = false;
988
+ for (const rc of required) {
989
+ // Absent from the head, or still in flight → not-yet-run → wait (never treat absence as pass).
990
+ if (!present.has(rc.name) || pending.has(rc.name)) {
991
+ anyPending = true;
992
+ continue;
993
+ }
994
+ const conclusion = (s.checkConclusions[rc.name] ?? "").toUpperCase();
995
+ if (conclusion === "") {
996
+ // Present but no terminal conclusion yet (and not flagged pending) — treat conservatively as
997
+ // not-yet-concluded rather than as a pass.
998
+ anyPending = true;
999
+ continue;
1000
+ }
1001
+ const accepted = rc.acceptedConclusions.map((a) => a.toUpperCase());
1002
+ if (accepted.includes(conclusion)) continue; // satisfied
1003
+ anyBlocked = true; // present, concluded, NOT accepted → a red required check
1004
+ }
1005
+ // A failing required check outranks a pending one: it needs fix-ci now, not more waiting.
1006
+ if (anyBlocked) return "blocked";
1007
+ if (anyPending) return "waiting";
1008
+ return "pass";
1009
+ }
1010
+
1011
+ export function classifyMergeability(s: PrState, protocol?: MergeProtocol): Mergeability {
1012
+ // Protocol-aware backstop FIRST (issue #392): honour the repo's declared `requiredChecks`
1013
+ // against the actual head rollup, so an `UNSTABLE` PR with a red DECLARED-required
1014
+ // check is no longer blindly `ready`. This never weakens GitHub branch protection (the switch
1015
+ // below still gates) — it only tightens merges on repos that under-specify their required checks.
1016
+ const gate = requiredChecksVerdict(s, protocol);
1017
+ if (gate === "blocked") return "blocked";
1018
+ if (gate === "waiting") return "waiting";
919
1019
  switch (s.mergeStateStatus) {
920
1020
  case "CLEAN":
921
1021
  case "HAS_HOOKS":
922
- case "UNSTABLE": // only non-required checks failing — still mergeable
1022
+ case "UNSTABLE": // only non-required checks failing — still mergeable (no DECLARED-required red)
923
1023
  case "BEHIND": // out of date; a queue rebases, a direct merge is still allowed
924
1024
  return "ready";
925
1025
  case "DIRTY":
@@ -0,0 +1,40 @@
1
+ // Structural guard for the wave subprocess's "clean terminal?" gateway (w_gw) — the implement-stage
2
+ // escalation net (#358/#360). The whole point of the net is that a slice with NO clean terminal
3
+ // status escalates to a human. The no-result case (implement-task completes with `status`
4
+ // missing/undefined) is EXACTLY what must escalate, so the gateway must not depend on a `not(...)`
5
+ // negation that FEEL leaves `null` for a missing `status` (a null condition takes NO flow and would
6
+ // fall through to the default). We eliminate that failure mode categorically: ESCALATE is the
7
+ // DEFAULT flow and DONE is gated on the closed set of clean terminal statuses — so anything that is
8
+ // not a recognised clean terminal (including a missing/undefined status) escalates, regardless of
9
+ // how the engine evaluates equality against null.
10
+ //
11
+ // Pure text assertions over the committed BPMN (no engine), matching the repo's model-guard style.
12
+ import { readFileSync } from "node:fs";
13
+ import { test } from "node:test";
14
+ import { assert, assertStringIncludes } from "#test-assert";
15
+
16
+ const bpmn = readFileSync("resources/processes/plan-fanout.bpmn", "utf8");
17
+ const flat = bpmn.replace(/\s+/g, " ");
18
+
19
+ const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="w_gw"[^>]*>/)?.[0] ?? "";
20
+ const wDone = flat.match(/<bpmn:sequenceFlow\b[^>]*\bid="w_done"[^>]*\/>|<bpmn:sequenceFlow\b[^>]*\bid="w_done"[\s\S]*?<\/bpmn:sequenceFlow>/)?.[0] ?? "";
21
+ const wEscalate = flat.match(/<bpmn:sequenceFlow\b[^>]*\bid="w_escalate"[^>]*\/>|<bpmn:sequenceFlow\b[^>]*\bid="w_escalate"[\s\S]*?<\/bpmn:sequenceFlow>/)?.[0] ?? "";
22
+
23
+ test("w_gw: ESCALATE is the default flow, so a missing/undefined status can never fall through to done", () => {
24
+ assert(gw, "w_gw gateway must exist");
25
+ assertStringIncludes(gw, 'default="w_escalate"', "escalate must be the default — the no-result case escalates, never silently completes");
26
+ });
27
+
28
+ test("w_gw: DONE is gated on the closed set of clean terminal statuses (not a fragile not(...) negation)", () => {
29
+ assert(wDone, "w_done flow must exist");
30
+ assertStringIncludes(wDone, "conditionExpression", "the done flow must be conditional, not the default");
31
+ assertStringIncludes(wDone, 'status = "opened"', "done requires a recognised clean terminal status");
32
+ assertStringIncludes(wDone, 'status = "blocked"', "done requires a recognised clean terminal status");
33
+ assertStringIncludes(wDone, 'status = "skipped"', "done requires a recognised clean terminal status");
34
+ });
35
+
36
+ test("w_gw: the escalate flow carries no condition — it is the unconditional default sink", () => {
37
+ assert(wEscalate, "w_escalate flow must exist");
38
+ assert(!wEscalate.includes("conditionExpression"), "escalate is the default flow and must carry no condition");
39
+ assert(!wEscalate.includes("not("), "escalate must not depend on a not(...) negation that FEEL leaves null for a missing status");
40
+ });
@@ -131,6 +131,36 @@ test("pollUserTasks: projects feature / plan-review / trial-merge / PR-wait esca
131
131
  assertEquals(byKey["ut-pr"].subject_title, "Resolve the reviews");
132
132
  });
133
133
 
134
+ test("pollUserTasks: projects a feature-escalation that lands on a plan-fanout plan instance (issue #358)", async () => {
135
+ // plan-fanout embeds each wave slice as a multi-instance `implement` subprocess, so a slice that
136
+ // escalates parks on the `feature-escalation` user task on the PLAN-ROOT process instance — never on
137
+ // a standalone `feature_runs` instance. The feature scan above only walks `feature_runs`, so before
138
+ // #358 the plan scan's hardcoded {plan-review, trial-merge} whitelist silently dropped it and the
139
+ // escalation was invisible in the Tasks inbox (the instance-19153 orphan). The plan scan must project
140
+ // EVERY open user-task element in the canonical registry, keyed to the epic (plan) subject, sourcing
141
+ // the question from the `feature_escalations` audit log the escalate arm writes (keyed by plan_key).
142
+ const { data, stores } = memData({
143
+ plans: [
144
+ { plan_key: "o/r#64", status: "dispatched", process_key: "pp-64", issue_url: "https://github.com/o/r/issues/64", title: "Learn BPMN scaffold" },
145
+ ],
146
+ feature_escalations: [
147
+ { id: 1, feature_key: "o/r#64", question: "the agent returned no machine-readable result — enrol the PR?", created_at: "2025-01-01T00:00:00.000Z", job_key: "j1" },
148
+ ],
149
+ });
150
+ const engine = fakeEngine({ "pp-64": [{ userTaskKey: "ut-embedded-feat", elementId: "feature-escalation" }] });
151
+
152
+ await pollUserTasks(data, engine);
153
+
154
+ const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
155
+ assertEquals(Object.keys(byKey), ["ut-embedded-feat"]);
156
+ assertEquals(byKey["ut-embedded-feat"].element_id, "feature-escalation");
157
+ assertEquals(byKey["ut-embedded-feat"].kind_label, "Feature escalation");
158
+ assertEquals(byKey["ut-embedded-feat"].subject_type, "plan");
159
+ assertEquals(byKey["ut-embedded-feat"].subject_key, "o/r#64");
160
+ assertEquals(byKey["ut-embedded-feat"].subject_title, "Learn BPMN scaffold");
161
+ assertEquals(byKey["ut-embedded-feat"].question, "the agent returned no machine-readable result — enrol the PR?");
162
+ });
163
+
134
164
  test("pollUserTasks: projects a merge-loop wait-merge-answer escalation into user_tasks as \"PR merge\"", async () => {
135
165
  // During the merge phase a PR's process_key points at its merge-loop instance; the merge escalation
136
166
  // parks on a native `wait-merge-answer` userTask (#256) and writes the SAME `escalations` row the
@@ -328,3 +358,181 @@ test("pollUserTasks: an instance whose only task is COMPLETED surfaces no row",
328
358
 
329
359
  assertEquals(stores.user_tasks ?? [], []);
330
360
  });
361
+
362
+ // ── Engine-first sweep (issue #358) ────────────────────────────────────────────────────────────────
363
+ // When the raw-REST surface is available (production always supplies it), the projection's source of
364
+ // truth for WHICH escalations are open is the ENGINE, not the tracked subject set: every open escalation
365
+ // the engine reports is surfaced — even on an instance NO tracked subject row references (an
366
+ // orphaned/untracked instance, the reported 19153 case) — enriched by a subject row when one exists and
367
+ // by a per-kind fallback when it does not. These drive the sweep over a stubbed Camunda-8
368
+ // `/v2/user-tasks/search`, the raw surface that (unlike the typed `openUserTasks` seam) carries each
369
+ // task's `processInstanceKey`.
370
+
371
+ /** A single task as the raw Camunda-8 `/v2/user-tasks/search` reports it — carries `processInstanceKey`
372
+ * (the typed seam omits it) so the sweep can map a task back to its subject for enrichment. */
373
+ type RawTask = { userTaskKey: string; elementId?: string; processInstanceKey?: string; state?: string };
374
+
375
+ /** Stub `globalThis.fetch` so `pollUserTasks`' engine-first sweep reads its open tasks from `tasks`.
376
+ * Honours the `page.from`/`page.limit` pagination the sweep drives, and 404s any other path so a stray
377
+ * call is loud. Returns a restore fn. */
378
+ function stubUserTaskSearch(tasks: RawTask[]): () => void {
379
+ const orig = globalThis.fetch;
380
+ // biome-ignore lint/suspicious/noExplicitAny: minimal fetch double for the raw-REST search surface
381
+ globalThis.fetch = (async (url: string | URL, init?: any) => {
382
+ const u = String(url);
383
+ if (!u.endsWith("/user-tasks/search")) return new Response("not found", { status: 404 });
384
+ const body = JSON.parse(init?.body ?? "{}");
385
+ const from: number = body?.page?.from ?? 0;
386
+ const limit: number = body?.page?.limit ?? 100;
387
+ return new Response(JSON.stringify({ items: tasks.slice(from, from + limit) }), {
388
+ status: 200,
389
+ headers: { "content-type": "application/json" },
390
+ });
391
+ }) as typeof fetch;
392
+ return () => {
393
+ globalThis.fetch = orig;
394
+ };
395
+ }
396
+
397
+ const REST = { restAddress: "http://engine.test/v2" };
398
+
399
+ test("pollUserTasks (engine-first): surfaces an escalation on an UNTRACKED/orphaned instance — the 19153 case (issue #358)", async () => {
400
+ // No `feature_runs`/`plans`/`pull_requests` row references instance 19153, yet the engine reports its
401
+ // `feature-escalation` (key 27337) open. Before #358 the subject-tracking-gated scan dropped it and the
402
+ // operator could never see nor answer it. The engine-first sweep surfaces it, keyed to a stable
403
+ // non-blank fallback subject (the instance) so the row renders and stays answerable.
404
+ const { data, stores } = memData({});
405
+ const restore = stubUserTaskSearch([
406
+ { userTaskKey: "27337", elementId: "feature-escalation", processInstanceKey: "19153", state: "CREATED" },
407
+ ]);
408
+ try {
409
+ await pollUserTasks(data, fakeEngine({}), REST);
410
+ } finally {
411
+ restore();
412
+ }
413
+
414
+ const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
415
+ assertEquals(Object.keys(byKey), ["27337"]);
416
+ assertEquals(byKey["27337"].element_id, "feature-escalation");
417
+ assertEquals(byKey["27337"].kind_label, "Feature escalation");
418
+ assertEquals(byKey["27337"].subject_type, "feature");
419
+ assertEquals(byKey["27337"].subject_key, "19153"); // fallback to the instance — non-blank so it renders
420
+ assertEquals(byKey["27337"].subject_title, "19153");
421
+ assertEquals(byKey["27337"].question, null); // no tracked audit source for an orphan → null, still listed
422
+ });
423
+
424
+ test("pollUserTasks (engine-first): orphaned plan-review and PR-wait escalations are surfaced too (issue #358)", async () => {
425
+ // Same failure class across aggregates: a `plan-review-decision` with no `plans` row and a `wait-answer`
426
+ // with no `pull_requests` row are each surfaced, bucketed to the aggregate their kind implies.
427
+ const { data, stores } = memData({});
428
+ const restore = stubUserTaskSearch([
429
+ { userTaskKey: "ut-orphan-plan", elementId: "plan-review-decision", processInstanceKey: "pi-1", state: "CREATED" },
430
+ { userTaskKey: "ut-orphan-pr", elementId: "wait-answer", processInstanceKey: "pi-2", state: "CREATED" },
431
+ ]);
432
+ try {
433
+ await pollUserTasks(data, fakeEngine({}), REST);
434
+ } finally {
435
+ restore();
436
+ }
437
+
438
+ const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
439
+ assertEquals(Object.keys(byKey).sort(), ["ut-orphan-plan", "ut-orphan-pr"]);
440
+ assertEquals(byKey["ut-orphan-plan"].subject_type, "plan");
441
+ assertEquals(byKey["ut-orphan-plan"].subject_key, "pi-1");
442
+ assertEquals(byKey["ut-orphan-pr"].subject_type, "pr");
443
+ assertEquals(byKey["ut-orphan-pr"].kind_label, "PR review");
444
+ });
445
+
446
+ test("pollUserTasks (engine-first): a TRACKED task is still fully enriched from its subject row (no regression)", async () => {
447
+ // Enrich, don't gate: when a subject row DOES reference the task's instance, title/url/question come
448
+ // from it exactly as the per-subject scan produced — the sweep maps by `processInstanceKey`.
449
+ const { data, stores } = memData({
450
+ feature_runs: [
451
+ { feature_key: "o/r#10", status: "escalated", process_key: "fp-10", issue_url: "https://github.com/o/r/issues/10", title: "Add the framework selector", delivery_label: null },
452
+ ],
453
+ feature_escalations: [
454
+ { id: 1, feature_key: "o/r#10", question: "which framework?", created_at: "2025-01-01T00:00:00.000Z", job_key: "j1" },
455
+ ],
456
+ plans: [
457
+ { plan_key: "o/r#20", status: "dispatched", process_key: "pp-20", issue_url: "https://github.com/o/r/issues/20", title: "Broaden the epic scope" },
458
+ ],
459
+ plan_reviews: [
460
+ { plan_key: "o/r#20", epoch: 0, round: 1, approved: 0, findings: "scope too broad", created_at: "2025-01-02T00:00:00.000Z" },
461
+ ],
462
+ });
463
+ const restore = stubUserTaskSearch([
464
+ { userTaskKey: "ut-feat", elementId: "feature-escalation", processInstanceKey: "fp-10", state: "CREATED" },
465
+ { userTaskKey: "ut-plan", elementId: "plan-review-decision", processInstanceKey: "pp-20", state: "CREATED" },
466
+ ]);
467
+ try {
468
+ await pollUserTasks(data, fakeEngine({}), REST);
469
+ } finally {
470
+ restore();
471
+ }
472
+
473
+ const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
474
+ assertEquals(Object.keys(byKey).sort(), ["ut-feat", "ut-plan"]);
475
+ assertEquals(byKey["ut-feat"].subject_key, "o/r#10");
476
+ assertEquals(byKey["ut-feat"].subject_title, "Add the framework selector");
477
+ assertEquals(byKey["ut-feat"].question, "which framework?");
478
+ assertEquals(byKey["ut-plan"].subject_title, "Broaden the epic scope");
479
+ assertEquals(byKey["ut-plan"].question, "scope too broad");
480
+ });
481
+
482
+ test("pollUserTasks (engine-first): never leaks a non-escalation element nor a non-CREATED task", async () => {
483
+ // The `USER_TASK_KIND_LABELS` gate keeps an arbitrary internal user task out of the inbox, and the
484
+ // defensive state re-filter drops a lagging COMPLETED/CANCELED read (a dead affordance, #294) even if
485
+ // the wire `state` filter is ignored.
486
+ const { data, stores } = memData({});
487
+ const restore = stubUserTaskSearch([
488
+ { userTaskKey: "ut-internal", elementId: "some-internal-task", processInstanceKey: "pi-9", state: "CREATED" },
489
+ { userTaskKey: "ut-done", elementId: "feature-escalation", processInstanceKey: "pi-8", state: "COMPLETED" },
490
+ { userTaskKey: "ut-live", elementId: "feature-escalation", processInstanceKey: "pi-7", state: "CREATED" },
491
+ ]);
492
+ try {
493
+ await pollUserTasks(data, fakeEngine({}), REST);
494
+ } finally {
495
+ restore();
496
+ }
497
+
498
+ const keys = (stores.user_tasks ?? []).map((r) => r.user_task_key);
499
+ assertEquals(keys, ["ut-live"]);
500
+ });
501
+
502
+ test("pollUserTasks (engine-first): an answered task (no longer open) is deleted on the next pass", async () => {
503
+ // Feed the engine-derived desired set to the unchanged reconcile: a persisted row whose task the engine
504
+ // no longer reports open is deleted, so `showCount` tracks live work — identical to the scan path.
505
+ const { data, stores } = memData({
506
+ user_tasks: [
507
+ { user_task_key: "ut-gone", element_id: "wait-answer", kind_label: "PR review", subject_type: "pr", subject_key: "o/r#30", subject_url: null, question: null, process_key: "rp-30", created_at: "2025-01-01T00:00:00.000Z", updated_at: "2025-01-01T00:00:00.000Z" },
508
+ ],
509
+ });
510
+ const restore = stubUserTaskSearch([]); // engine reports nothing open
511
+ try {
512
+ await pollUserTasks(data, fakeEngine({}), REST);
513
+ } finally {
514
+ restore();
515
+ }
516
+
517
+ assertEquals(stores.user_tasks, []);
518
+ });
519
+
520
+ test("pollUserTasks (engine-first): pages through a large open set (no first-page truncation)", async () => {
521
+ // Open escalations are normally few, but the sweep must page defensively so a large set is not silently
522
+ // truncated to the first page. 150 open escalations across a 100-item page size → all 150 projected.
523
+ const { data, stores } = memData({});
524
+ const tasks: RawTask[] = Array.from({ length: 150 }, (_, i) => ({
525
+ userTaskKey: `ut-${i}`,
526
+ elementId: "feature-escalation",
527
+ processInstanceKey: `pi-${i}`,
528
+ state: "CREATED",
529
+ }));
530
+ const restore = stubUserTaskSearch(tasks);
531
+ try {
532
+ await pollUserTasks(data, fakeEngine({}), REST);
533
+ } finally {
534
+ restore();
535
+ }
536
+
537
+ assertEquals((stores.user_tasks ?? []).length, 150);
538
+ });