@nanobpm/nano-workforce 0.205.0 → 0.205.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.
@@ -96,6 +96,8 @@ const DEFAULT_VARS: Record<string, unknown> = {
96
96
  mergeStatus: null,
97
97
  ciBlockedReconciled: null,
98
98
  failingChecks: 0,
99
+ reviewThreads: null,
100
+ pendingChecks: null,
99
101
  round: 2,
100
102
  abandonUrl: "http://host/app/api/hooks/abandon?token=tok-seeded",
101
103
  };
@@ -521,6 +523,133 @@ test("an unclassified verdict routes to auto-rebase (DEFAULT), not to a human (#
521
523
  assert(!openTasks.some((t) => t.elementId === "wait-merge-answer"), "the default arm must not page a human");
522
524
  });
523
525
 
526
+ // ---------------------------------------------------------------------------
527
+ // BLOCKED by unresolved review threads (issue #921)
528
+ // ---------------------------------------------------------------------------
529
+ // A PR at MERGEABLE/BLOCKED with all checks green and ≥1 unresolved review thread (branch
530
+ // protection `required_conversation_resolution`) used to classify as "waiting": the poller never
531
+ // published, the mergeable-wait timer fired, the stall probe re-derived the same "waiting", and
532
+ // after the stall budget the escalation read "GitHub never settled this PR's mergeability" — when
533
+ // GitHub HAD settled. The `review-threads` verdict now routes to a dedicated escalation that names
534
+ // the real cause (and parks the shared `wait-merge-answer` task, whose answer re-arms the poller),
535
+ // never to the CI-fix or rebase agents.
536
+
537
+ test("a 'review-threads' verdict escalates naming the unresolved threads — never 'never settled', never an agent (#921)", async () => {
538
+ const engine = await boot();
539
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
540
+ await engine.publishMessage({
541
+ name: "merge-ready",
542
+ correlationKey: "pr-1",
543
+ variables: { mergeState: "review-threads", reviewThreads: 2, failingChecks: 0, failingChecksList: "" },
544
+ });
545
+ // The threads arm parks the shared answer task with the thread-specific question…
546
+ await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
547
+ assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasCompletedElements("merge-esc-threads");
548
+ const q = String(escalation(engine).question ?? "");
549
+ assertStringIncludes(q, "2 unresolved review thread(s)", "the escalation must name the actual blocker and count");
550
+ assert(!q.includes("never settled"), "a settled BLOCKED must never read as 'mergeability never settled'");
551
+ // …and never misroutes to the remediation agents or the generic conflict escalation.
552
+ assert(!completedElementIds(engine).has("fix-ci"), "green checks must not dispatch fix-ci");
553
+ assert(!completedElementIds(engine).has("rebase"), "a threads block must not dispatch rebase");
554
+ assert(!completedElementIds(engine).has("merge-esc-conflict"), "the threads arm has its own escalation");
555
+ });
556
+
557
+ test("answering the review-threads escalation re-arms the merge poller (#921)", async () => {
558
+ // The human resolves the threads on GitHub and replies; the answer re-arms `arm-merge` so the
559
+ // next poll re-derives mergeability — once the threads are gone the verdict flips to `ready` and
560
+ // the merge proceeds (the merge step is thereby gated on zero unresolved threads).
561
+ const engine = await boot({ responses: { "pr.merge": { mergeStatus: "merged" } } });
562
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
563
+ await engine.publishMessage({
564
+ name: "merge-ready",
565
+ correlationKey: "pr-1",
566
+ variables: { mergeState: "review-threads", reviewThreads: 1, failingChecks: 0 },
567
+ });
568
+ await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
569
+ await engine.completeUserTask(await mergeAnswerTaskKey(engine), { answer: "resolved the threads" });
570
+ // Re-armed at the poller wait — not merged yet (the poller must re-derive)…
571
+ assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable");
572
+ // …then the re-derived ready verdict lands the merge with no human in the loop.
573
+ await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready", reviewThreads: 0 } });
574
+ assertThatInstance(engine, byProcessId("merge-loop")).hasCompleted().hasNoIncident().hasCompletedElements("attempt-merge", "mark-merged");
575
+ });
576
+
577
+ test("a stalled probe that re-derives 'review-threads' routes to the threads arm, not the re-poll loop (#921)", async () => {
578
+ // The poller-dead path: the mergeable-wait timer fires, the stall probe re-derives ground truth,
579
+ // and a `review-threads` verdict takes the same dedicated arm a live poller message would.
580
+ const engine = await boot({
581
+ responses: {
582
+ "pr.merge-stall-probe": { mergeState: "review-threads", failingChecks: 0, failingChecksList: "", reviewThreads: 3 },
583
+ },
584
+ });
585
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
586
+ await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
587
+ await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
588
+ assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-stall-probe", "merge-esc-threads");
589
+ const q = String(escalation(engine).question ?? "");
590
+ assertStringIncludes(q, "3 unresolved review thread(s)", "the probe path must carry the thread count into the question");
591
+ assert(!completedElementIds(engine).has("wait-mergeable-repoll"), "a settled review-threads verdict must not re-poll");
592
+ });
593
+
594
+ test("a stall-budget exhaustion on a settled BLOCKED with zero threads names the review requirement, not 'never settled' (#921)", async () => {
595
+ // GitHub settled: MERGEABLE/BLOCKED, checks green (none failing, none pending), every thread
596
+ // resolved — the block is a required human review approval. The probe reports `waiting` (the
597
+ // conservative verdict) with the probed counts, and the exhausted-budget escalation must name
598
+ // THAT cause.
599
+ const engine = await boot({
600
+ vars: { mergeStallMax: 0 },
601
+ responses: {
602
+ "pr.merge-stall-probe": { mergeState: "waiting", failingChecks: 0, failingChecksList: "", reviewThreads: 0, pendingChecks: 0 },
603
+ },
604
+ });
605
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
606
+ await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
607
+ await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
608
+ assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-stall-probe", "merge-esc-conflict");
609
+ const q = String(escalation(engine).question ?? "");
610
+ assertStringIncludes(q, "required review approval", "a settled BLOCKED with clear threads must name the review requirement");
611
+ assert(!q.includes("never settled"), "a settled BLOCKED must never read as 'mergeability never settled'");
612
+ });
613
+
614
+ test("a stall-budget exhaustion with checks still pending must NOT claim a verified review-approval blocker (PR #922 review)", async () => {
615
+ // Zero failing checks + zero unresolved threads, but a check is still queued/running: the BLOCKED
616
+ // cause is undetermined (the pending run may come back red), so the escalation must NOT assert
617
+ // "all checks green … required review approval" — it keeps the conservative unsettled wording.
618
+ const engine = await boot({
619
+ vars: { mergeStallMax: 0 },
620
+ responses: {
621
+ "pr.merge-stall-probe": { mergeState: "waiting", failingChecks: 0, failingChecksList: "", reviewThreads: 0, pendingChecks: 1 },
622
+ },
623
+ });
624
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
625
+ await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
626
+ await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
627
+ assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-stall-probe", "merge-esc-conflict");
628
+ const q = String(escalation(engine).question ?? "");
629
+ assert(!q.includes("required review approval"), "a pending check must never read as a verified review-approval block");
630
+ assertStringIncludes(q, "never settled", "an undetermined BLOCKED keeps the conservative unsettled wording");
631
+ });
632
+
633
+ test("the 'never settled' question is reserved for a genuinely unreadable/unsettled mergeability (#921)", async () => {
634
+ // mergeStallMax: 0 + a probe that still reads `waiting` with NOTHING probed (reviewThreads -1,
635
+ // checks unenumerable) — GitHub genuinely never settled (or the state could not be read). Only
636
+ // this case keeps the original message.
637
+ const engine = await boot({
638
+ vars: { mergeStallMax: 0 },
639
+ responses: {
640
+ "pr.merge-stall-probe": { mergeState: "waiting", failingChecks: 0, failingChecksList: "", reviewThreads: -1 },
641
+ },
642
+ });
643
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
644
+ await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
645
+ await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
646
+ assertStringIncludes(
647
+ String(escalation(engine).question ?? ""),
648
+ "never settled",
649
+ "a genuinely unsettled/unreadable mergeability keeps the original message",
650
+ );
651
+ });
652
+
524
653
  test("an evicted queued merge re-arms the poller rather than completing", async () => {
525
654
  const engine = await boot({ responses: { "pr.merge": { mergeStatus: "queued" } } });
526
655
  await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
package/app/readiness.ts CHANGED
@@ -768,6 +768,7 @@ export function parsePrView(payload: unknown): PrObservation {
768
768
  isDraft: j.isDraft === true,
769
769
  headRefOid: str(j.headRefOid).trim() || null,
770
770
  mergeQueueEntry: null, // readiness probe doesn't read native-queue membership (merge-loop-only signal)
771
+ unresolvedThreads: -1, // …nor the review-threads probe (merge-stage-only signal, #921) → unprobed
771
772
  mergedSha,
772
773
  pendingChecks: pending.length,
773
774
  };
package/app/service.ts CHANGED
@@ -1698,6 +1698,77 @@ export async function maybeEnsureFreshHeadRun(
1698
1698
  * EVERY branch first runs {@link advanceIfTerminalOutOfBand} — one shared "is this PR already
1699
1699
  * terminal (merged/closed) out-of-band?" pre-check — so no merge stage can silently strand when a PR
1700
1700
  * is landed/closed outside the loop (the `waiting_deps` self-merged wedge, #368). */
1701
+ /** Durably hand a `waiting_merge` PR back into the canonical convergence loop (issue #921, PR #922
1702
+ * review): flip the row to `waiting_review` FIRST (so a review landing in the start window is still
1703
+ * collected by `pollReviews` — it scans on `waiting_review`, not on instance liveness), RETIRE the
1704
+ * live merge instance (while the row's `process_key` names a non-terminal merge instance, `submitPr`'s
1705
+ * idempotency gate classifies the PR `alreadyRunning` and starts NOTHING — the cancel flips the derived
1706
+ * tracking status terminal so the gate falls through), then `submitPr` re-opens the PR into a fresh
1707
+ * convergence instance (round 1). The review-round agent's backlog IS the open threads: it fixes or
1708
+ * pushes back, replies, and resolves them; on `converged` the converge-gate re-verifies zero
1709
+ * unresolved threads and `pr.finalize` re-enters the merge stage — merging resumes automatically.
1710
+ *
1711
+ * The escalation-steward opt-in is carried through the resubmission (PR #922 review): `submitPr`
1712
+ * defaults `autoResolveEscalations` to false and overwrites the row's flag on reopen, so an opted-in PR
1713
+ * would silently lose automated escalation resolution merely because late review threads appeared.
1714
+ *
1715
+ * RETRYABLE on failure (PR #922 review, thread 3): if the cancel succeeds but `submitPr` throws, the
1716
+ * row is restored to `waiting_merge` so the next pass re-attempts the handoff against the
1717
+ * already-retired (terminal) merge key — rather than stranding in `waiting_review`, which `pollMerges`
1718
+ * no longer scans and whose `readiness-ready` resume correlates to nothing once the merge instance is
1719
+ * cancelled. The restored row's `process_key` still names the now-terminal merge instance, so the
1720
+ * unfinished handoff is re-detected next pass by the dead-owner guard in `pollMerges` (which keys off
1721
+ * the terminal `derived_status`, independently of the GitHub verdict). Best-effort throughout: a failed
1722
+ * cancel or restore only logs, leaving the row for the next pass. */
1723
+ async function handoffWaitingMergeToConvergence(
1724
+ data: DataLayer,
1725
+ engine: EngineClient,
1726
+ pr: PullRequest,
1727
+ logContext: string,
1728
+ ) {
1729
+ const prKey = pr.pr_key;
1730
+ try {
1731
+ // `waiting_since` bounds the review the fresh loop collects: only a review submitted at or after
1732
+ // the handoff counts, so a pre-merge review (submitted before this flip) can never satisfy the new
1733
+ // run's wait.
1734
+ const handoffTs = now();
1735
+ await prs(data).update(prKey, { status: "waiting_review", waiting_since: handoffTs, updated_at: handoffTs });
1736
+ const mergeKey = pr.process_key;
1737
+ if (mergeKey != null) {
1738
+ try {
1739
+ await engine.cancelInstance({ processInstanceKey: String(mergeKey) });
1740
+ } catch (cancelErr) {
1741
+ console.error(`[poller] ${logContext} handoff ${prKey}: retire merge instance ${mergeKey}: ${cancelErr}`);
1742
+ }
1743
+ }
1744
+ await submitPr(
1745
+ data,
1746
+ engine,
1747
+ { repo: pr.repo, number: pr.number, url: pr.url, prKey },
1748
+ [],
1749
+ MAX_ROUNDS,
1750
+ false,
1751
+ null,
1752
+ false,
1753
+ pr.auto_resolve_escalations === 1,
1754
+ );
1755
+ console.log(`[poller] ${logContext} -> handed back into convergence -> ${prKey}`);
1756
+ } catch (err) {
1757
+ console.error(`[poller] ${logContext} handoff ${prKey}: ${err}`);
1758
+ // Restore the row to `waiting_merge` so the next pass re-attempts the handoff, and NULL the
1759
+ // `process_key`: the merge instance was retired above (or never lived), so the row genuinely has
1760
+ // NO live owner. The null key is the durable "unfinished handoff" marker the dead-owner guard in
1761
+ // `pollMerges` keys on — and it is what distinguishes this retryable shape from a HUMAN cancel of a
1762
+ // parked merge wait (which leaves `process_key` naming the terminated instance and must NOT be
1763
+ // resurrected). Best-effort: a failed restore still leaves the original error logged.
1764
+ try {
1765
+ await prs(data).update(prKey, { status: "waiting_merge", process_key: null, updated_at: now() });
1766
+ } catch (restoreErr) {
1767
+ console.error(`[poller] ${logContext} handoff ${prKey}: restore waiting_merge: ${restoreErr}`);
1768
+ }
1769
+ }
1770
+ }
1771
+
1701
1772
  export async function pollMerges(data: DataLayer, engine: EngineClient, token: string) {
1702
1773
  // 1) Dependencies merged?
1703
1774
  for (const pr of await prs(data).find({ status: "waiting_deps" })) {
@@ -1731,7 +1802,12 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
1731
1802
  for (const pr of await prs(data).find({ status: "waiting_merge" })) {
1732
1803
  const { repo, number, pr_key: prKey } = pr;
1733
1804
  try {
1734
- const st = await fetchPrState(repo, number, token);
1805
+ // Opt into the review-threads probe so a `BLOCKED` merge state is classified by its real
1806
+ // cause (issue #921): unresolved review threads (branch protection
1807
+ // `required_conversation_resolution`) surface as the `review-threads` verdict below instead
1808
+ // of polling as "waiting" until the mergeable-wait timer escalates the misleading
1809
+ // "mergeability never settled". One extra GraphQL read per pass per waiting PR.
1810
+ const st = await fetchPrState(repo, number, token, { withReviewThreads: true });
1735
1811
  if (st === null) continue; // no transport → skip this PR (others may still advance)
1736
1812
  // Out-of-band terminal (merged/closed) → the shared pre-check publishes `merge-ready {ready}`,
1737
1813
  // routing through `gw-mergeable → attempt-merge` whose idempotent already-merged check completes
@@ -1739,6 +1815,39 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
1739
1815
  // (#342/#350). Reuse the `st` we just read so we don't double-fetch. This is the proven terminal
1740
1816
  // path the whole class (#368) now shares.
1741
1817
  if (await advanceIfTerminalOutOfBand(data, engine, pr, token, st)) continue;
1818
+ // DEAD-OWNER guard (PR #922 review, round 5): a `waiting_merge` row with `process_key === null`
1819
+ // has NO live owner — the unfinished-handoff shape {@link handoffWaitingMergeToConvergence}
1820
+ // leaves when its `submitPr` failed after the merge instance was retired (the restore nulls the
1821
+ // key to mark exactly this). The verdict switch below must NOT run against it: every arm ends in
1822
+ // `flipToMergingThenPublish`, which would publish `merge-ready`/`merge-evicted`/remediation
1823
+ // correlated to a merge instance that no longer exists — the message is dropped and the row wedges
1824
+ // at the transient `merging` with no live owner (a fresh `ready`/`conflict`/`blocked` verdict after
1825
+ // the threads cleared is exactly the case the retry-restore leaves behind). Persist the handoff
1826
+ // INDEPENDENTLY of the current GitHub verdict: re-establish a live convergence owner FIRST,
1827
+ // publishing nothing. Keying on the NULL key — not on the terminal `derived_status` — is what
1828
+ // keeps a HUMAN cancel of a parked merge wait (which leaves `process_key` naming the terminated
1829
+ // instance) OUT of this path: a cancelled run must stay cancelled, never auto-resurrected. A
1830
+ // normal merge wait always carries a live `process_key`, so this fires only on the
1831
+ // ownerless-handoff shape. Placed after the out-of-band terminal pre-check so a merged/closed PR
1832
+ // still takes the proven terminal path first.
1833
+ if (pr.process_key == null) {
1834
+ await handoffWaitingMergeToConvergence(data, engine, pr, "dead merge owner (unfinished handoff)");
1835
+ continue;
1836
+ }
1837
+ // HUMAN-CANCEL guard (PR #922 review, round 5): a `waiting_merge` row whose `process_key` names a
1838
+ // TERMINAL merge instance (`derived_status` folded to `abandoned`) was cancelled out-of-band. The
1839
+ // verdict switch must NOT run against it either — publishing `merge-ready`/`merge-evicted`/
1840
+ // remediation to the dead instance is dropped and wedges the row at `merging` (the reviewer's "a
1841
+ // new conflict or check failure likewise sends remediation to the dead owner"). Unlike the
1842
+ // unfinished-handoff shape above, a cancelled run must NOT be resurrected into convergence — the
1843
+ // abandon protocol exists to stop exactly that orphaned side effect — so it is simply SKIPPED
1844
+ // (left cancelled) with nothing published. A LIVE parked merge wait reads a non-terminal
1845
+ // `derived_status` (`waiting_merge` ∈ activeStatuses), so this fires only on the cancelled shape.
1846
+ const trackedMerge = await prsTracking(data).get(prKey);
1847
+ if (trackedMerge && TERMINAL_STATUSES.includes(trackedMerge.derived_status)) {
1848
+ console.log(`[poller] merge owner terminal (cancelled) — leaving ${prKey} abandoned, no merge message published`);
1849
+ continue;
1850
+ }
1742
1851
  // Load the repo's merge protocol ONCE per PR iteration and pass it into the classifier so the
1743
1852
  // protocol-aware backstop (#392) can gate a red DECLARED-required check even when GitHub reports
1744
1853
  // the PR as UNSTABLE. The same handle is reused by the frugal-CI fresh-head-run branch below.
@@ -1788,6 +1897,25 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
1788
1897
  }
1789
1898
  continue; // GitHub still computing / checks pending
1790
1899
  }
1900
+ if (verdict === "review-threads") {
1901
+ // BLOCKED by unresolved review threads with every check green (issue #921): the threads are
1902
+ // findings from the reviewer this loop converges with, so route them back into convergence
1903
+ // rather than escalating to a human. Requesting another Copilot review alone does NOT
1904
+ // remediate them: `pollReviews` only scans `waiting_review` rows, so a review landing while
1905
+ // the PR sits in `waiting_merge` is never picked up and no review-fix agent ever activates
1906
+ // (PR #922 review). The handoff is a DURABLE re-entry into the canonical convergence loop
1907
+ // (see {@link handoffWaitingMergeToConvergence}). Once the fresh convergence instance exists,
1908
+ // `pr.progress-check` parks the row back in `waiting_merge` after the round, where this
1909
+ // block's verdict has by then flipped off `review-threads` (the gate guarantees it) — so this
1910
+ // fires at most once per thread-blocked episode.
1911
+ await handoffWaitingMergeToConvergence(
1912
+ data,
1913
+ engine,
1914
+ pr,
1915
+ `blocked by ${st.unresolvedThreads} unresolved review thread(s)`,
1916
+ );
1917
+ continue;
1918
+ }
1791
1919
  if (verdict === "ready") {
1792
1920
  const lane = await mergeLaneDecisionForPr(data, prKey);
1793
1921
  if (lane?.isHeld) {
@@ -1807,6 +1935,16 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
1807
1935
  // fix-ci task appends it to the agent prompt so the agent knows which gates to green.
1808
1936
  failingChecks: st.failingChecks,
1809
1937
  failingChecksList: st.failingCheckNames.join("\n"),
1938
+ // The probed unresolved-thread count (issue #921) — carried so a later stall-budget
1939
+ // escalation can name the real BLOCKED cause (and so the `review-threads` arm's question
1940
+ // can quote it). `-1` when the probe was unreadable.
1941
+ reviewThreads: st.unresolvedThreads,
1942
+ // The pending-check count (PR #922 review): zero failing checks does not mean checks are
1943
+ // green — a queued/running check leaves the BLOCKED cause undetermined, so the
1944
+ // stall-escalation question gates its "required review approval" wording on this being 0
1945
+ // too. 0 in token mode (checks unenumerable — the conservative `waiting` verdict already
1946
+ // keeps the approval wording from firing there).
1947
+ pendingChecks: st.pendingCheckNames.length,
1810
1948
  },
1811
1949
  });
1812
1950
  console.log(`[poller] mergeable=${verdict} (${st.mergeStateStatus}) -> ${prKey}`);
package/app/userTasks.ts CHANGED
@@ -90,6 +90,7 @@ export const PR_ESCALATION_PRODUCER_ELEMENTS: readonly string[] = [
90
90
  "merge-esc-conflict",
91
91
  "merge-esc-attempt",
92
92
  "merge-esc-landed",
93
+ "merge-esc-threads",
93
94
  ];
94
95
 
95
96
  /** The ACP permission-prompt escalation (issue #559, ADR 0056) — the Tasks-inbox kind a bridged
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.205.0",
3
+ "version": "0.205.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",
@@ -53,11 +53,15 @@
53
53
  <nano:extend name="mergeState" type="string" />
54
54
  <nano:extend name="failingChecks" type="integer" optional="true" />
55
55
  <nano:extend name="failingChecksList" type="string" optional="true" />
56
+ <nano:extend name="reviewThreads" type="integer" optional="true" />
57
+ <nano:extend name="pendingChecks" type="integer" optional="true" />
56
58
  </nano:shape>
57
59
  <nano:shape id="MergeReady" name="merge-ready message payload">
58
60
  <nano:extend name="mergeState" type="string" />
59
61
  <nano:extend name="failingChecks" type="integer" optional="true" />
60
62
  <nano:extend name="failingChecksList" type="string" optional="true" />
63
+ <nano:extend name="reviewThreads" type="integer" optional="true" />
64
+ <nano:extend name="pendingChecks" type="integer" optional="true" />
61
65
  </nano:shape>
62
66
  <nano:shape id="FixCiIn" name="CI fix — input">
63
67
  <nano:extend name="prKey" type="string" />
@@ -179,6 +183,7 @@
179
183
  <bpmn:outgoing>f_m_mRebase</bpmn:outgoing>
180
184
  <bpmn:outgoing>f_m_mWaiting</bpmn:outgoing>
181
185
  <bpmn:outgoing>f_m_mDraft</bpmn:outgoing>
186
+ <bpmn:outgoing>f_m_mThreads</bpmn:outgoing>
182
187
  <bpmn:outgoing>f_m_mBlocked</bpmn:outgoing>
183
188
  </bpmn:exclusiveGateway>
184
189
  <bpmn:serviceTask id="attempt-merge" name="Merge PR">
@@ -258,7 +263,7 @@
258
263
  </zeebe:properties>
259
264
  <zeebe:ioMapping>
260
265
  <zeebe:input source="=&#34;blocked&#34;" target="status" />
261
- <zeebe:input source="=if mergeState = &#34;draft&#34; then &#34;This PR is in draft and can&#39;t be merged. Mark it ready (gh pr ready), then reply to retry.&#34; else if mergeState = &#34;waiting&#34; then &#34;GitHub never settled this PR&#39;s mergeability (still computing after repeated re-polls, or the state could not be read). Check the PR on GitHub — resolve any conflict or failing required check if present — then reply to retry.&#34; else &#34;This PR is not mergeable (state: &#34; + mergeState + &#34;). Resolve the conflict or failing required check on the branch, then reply to retry.&#34;" target="question" />
266
+ <zeebe:input source="=if mergeState = &#34;draft&#34; then &#34;This PR is in draft and can&#39;t be merged. Mark it ready (gh pr ready), then reply to retry.&#34; else if mergeState = &#34;waiting&#34; and reviewThreads = 0 and failingChecks = 0 and pendingChecks = 0 then &#34;This PR is merge-blocked with all checks green (none failing, none pending) and every review thread resolved. GitHub still reports BLOCKED, which means a required review approval (or code-owner review) is outstanding — nothing the automated loop can produce. Approve the PR (or adjust the branch-protection review requirement), then reply to retry.&#34; else if mergeState = &#34;waiting&#34; then &#34;GitHub never settled this PR&#39;s mergeability (still computing or checks still pending after repeated re-polls, or the state could not be read). Check the PR on GitHub — resolve any conflict or failing required check if present — then reply to retry.&#34; else &#34;This PR is not mergeable (state: &#34; + mergeState + &#34;). Resolve the conflict or failing required check on the branch, then reply to retry.&#34;" target="question" />
262
267
  </zeebe:ioMapping>
263
268
  </bpmn:extensionElements>
264
269
  <bpmn:incoming>f_m_mDraft</bpmn:incoming>
@@ -287,9 +292,26 @@
287
292
  <bpmn:incoming>f_mr_giveup</bpmn:incoming>
288
293
  <bpmn:outgoing>f_m_escA</bpmn:outgoing>
289
294
  </bpmn:serviceTask>
295
+ <bpmn:serviceTask id="merge-esc-threads" name="Escalate: unresolved review threads">
296
+ <bpmn:extensionElements>
297
+ <zeebe:taskDefinition type="pr.persist-escalation" />
298
+ <zeebe:properties>
299
+ <zeebe:property name="io.nanobpm.dataEnvelope.in" value="EscalationIn" />
300
+ <zeebe:property name="io.nanobpm.dataEnvelope.out" value="EscalationOut" />
301
+ </zeebe:properties>
302
+ <zeebe:ioMapping>
303
+ <zeebe:input source="=&#34;blocked&#34;" target="status" />
304
+ <zeebe:input source="=&#34;This PR is merge-blocked by &#34; + string(reviewThreads) + &#34; unresolved review thread(s) (branch protection requires every conversation resolved; all checks are green and there is no conflict). These are review findings the loop already converged, so a fresh review round cannot clear them without a reply. Resolve the threads on the PR (or reply with guidance — e.g. which threads to push back on), then reply to retry.&#34;" target="question" />
305
+ <zeebe:input source="=null" target="agentSlaJobType" />
306
+ </zeebe:ioMapping>
307
+ </bpmn:extensionElements>
308
+ <bpmn:incoming>f_m_mThreads</bpmn:incoming>
309
+ <bpmn:outgoing>f_m_escT</bpmn:outgoing>
310
+ </bpmn:serviceTask>
290
311
  <bpmn:exclusiveGateway id="gw-merge-escalated" name="escalation opened?" default="f_m_escReenter">
291
312
  <bpmn:incoming>f_m_escA</bpmn:incoming>
292
313
  <bpmn:incoming>f_m_escLanded</bpmn:incoming>
314
+ <bpmn:incoming>f_m_escT</bpmn:incoming>
293
315
  <bpmn:outgoing>f_m_escWait</bpmn:outgoing>
294
316
  <bpmn:outgoing>f_m_escReenter</bpmn:outgoing>
295
317
  </bpmn:exclusiveGateway>
@@ -732,6 +754,9 @@
732
754
  <bpmn:sequenceFlow id="f_m_mDraft" name="draft — mark ready (human)" sourceRef="gw-mergeable" targetRef="merge-esc-conflict">
733
755
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=mergeState = "draft"</bpmn:conditionExpression>
734
756
  </bpmn:sequenceFlow>
757
+ <bpmn:sequenceFlow id="f_m_mThreads" name="blocked (unresolved review threads)" sourceRef="gw-mergeable" targetRef="merge-esc-threads">
758
+ <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=mergeState = "review-threads"</bpmn:conditionExpression>
759
+ </bpmn:sequenceFlow>
735
760
  <bpmn:sequenceFlow id="f_mrp_probe" sourceRef="wait-mergeable-repoll" targetRef="merge-stall-probe" />
736
761
  <bpmn:sequenceFlow id="f_m_mBlocked" name="unclassified — auto-rebase" sourceRef="gw-mergeable" targetRef="gw-rebase" />
737
762
  <bpmn:sequenceFlow id="f_ci_go" name="within budget" sourceRef="gw-ci-fix" targetRef="SP_cifix">
@@ -773,6 +798,7 @@
773
798
  <bpmn:sequenceFlow id="f_m_done" sourceRef="mark-merged" targetRef="MergeEnd" />
774
799
  <bpmn:sequenceFlow id="f_m_escC" sourceRef="merge-esc-conflict" targetRef="wait-merge-answer" />
775
800
  <bpmn:sequenceFlow id="f_m_escA" sourceRef="merge-esc-attempt" targetRef="gw-merge-escalated" />
801
+ <bpmn:sequenceFlow id="f_m_escT" sourceRef="merge-esc-threads" targetRef="gw-merge-escalated" />
776
802
  <bpmn:sequenceFlow id="f_m_escWait" sourceRef="gw-merge-escalated" targetRef="wait-merge-answer">
777
803
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=escalated = true</bpmn:conditionExpression>
778
804
  </bpmn:sequenceFlow>
@@ -833,7 +859,7 @@
833
859
  <bpmndi:BPMNShape id="BPMNShape_gw-merge-stall" bpmnElement="gw-merge-stall" isMarkerVisible="true">
834
860
  <dc:Bounds x="1538" y="1535" width="50" height="50" />
835
861
  <bpmndi:BPMNLabel>
836
- <dc:Bounds x="1521" y="1650" width="85" height="42" />
862
+ <dc:Bounds x="1521" y="1630" width="85" height="42" />
837
863
  </bpmndi:BPMNLabel>
838
864
  </bpmndi:BPMNShape>
839
865
  <bpmndi:BPMNShape id="BPMNShape_gw-mergeable" bpmnElement="gw-mergeable" isMarkerVisible="true">
@@ -902,6 +928,9 @@
902
928
  <bpmndi:BPMNShape id="BPMNShape_merge-esc-attempt" bpmnElement="merge-esc-attempt">
903
929
  <dc:Bounds x="1688" y="720" width="100" height="80" />
904
930
  </bpmndi:BPMNShape>
931
+ <bpmndi:BPMNShape id="BPMNShape_merge-esc-threads" bpmnElement="merge-esc-threads">
932
+ <dc:Bounds x="1138" y="1680" width="100" height="80" />
933
+ </bpmndi:BPMNShape>
905
934
  <bpmndi:BPMNShape id="BPMNShape_gw-merge-escalated" bpmnElement="gw-merge-escalated" isMarkerVisible="true">
906
935
  <dc:Bounds x="2088" y="95" width="50" height="50" />
907
936
  <bpmndi:BPMNLabel>
@@ -1002,7 +1031,7 @@
1002
1031
  <di:waypoint x="402" y="1300" />
1003
1032
  <di:waypoint x="402" y="320" />
1004
1033
  <bpmndi:BPMNLabel>
1005
- <dc:Bounds x="446" y="1239" width="88" height="56" />
1034
+ <dc:Bounds x="919" y="1239" width="88" height="56" />
1006
1035
  </bpmndi:BPMNLabel>
1007
1036
  </bpmndi:BPMNEdge>
1008
1037
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_rearm" bpmnElement="f_reb_rearm">
@@ -1179,6 +1208,14 @@
1179
1208
  <dc:Bounds x="1057" y="483" width="85" height="42" />
1180
1209
  </bpmndi:BPMNLabel>
1181
1210
  </bpmndi:BPMNEdge>
1211
+ <bpmndi:BPMNEdge id="BPMNEdge_f_m_mThreads" bpmnElement="f_m_mThreads">
1212
+ <di:waypoint x="1013" y="305" />
1213
+ <di:waypoint x="1013" y="1720" />
1214
+ <di:waypoint x="1138" y="1720" />
1215
+ <bpmndi:BPMNLabel>
1216
+ <dc:Bounds x="1018" y="1642" width="81" height="56" />
1217
+ </bpmndi:BPMNLabel>
1218
+ </bpmndi:BPMNEdge>
1182
1219
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_mBlocked" bpmnElement="f_m_mBlocked">
1183
1220
  <di:waypoint x="1013" y="305" />
1184
1221
  <di:waypoint x="1013" y="440" />
@@ -1255,7 +1292,7 @@
1255
1292
  <di:waypoint x="1668" y="740" />
1256
1293
  <di:waypoint x="1688" y="740" />
1257
1294
  <bpmndi:BPMNLabel>
1258
- <dc:Bounds x="1952" y="616" width="53" height="14" />
1295
+ <dc:Bounds x="1932" y="616" width="53" height="14" />
1259
1296
  </bpmndi:BPMNLabel>
1260
1297
  </bpmndi:BPMNEdge>
1261
1298
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_gAbandoned" bpmnElement="f_m_gAbandoned">
@@ -1281,8 +1318,9 @@
1281
1318
  <di:waypoint x="870" y="618" />
1282
1319
  <di:waypoint x="870" y="638" />
1283
1320
  <di:waypoint x="888" y="638" />
1284
- <di:waypoint x="888" y="1598" />
1285
- <di:waypoint x="1338" y="1598" />
1321
+ <di:waypoint x="888" y="1780" />
1322
+ <di:waypoint x="1388" y="1780" />
1323
+ <di:waypoint x="1388" y="1600" />
1286
1324
  </bpmndi:BPMNEdge>
1287
1325
  <bpmndi:BPMNEdge id="BPMNEdge_f_msp_gate" bpmnElement="f_msp_gate">
1288
1326
  <di:waypoint x="1438" y="1560" />
@@ -1308,6 +1346,14 @@
1308
1346
  <di:waypoint x="1988" y="120" />
1309
1347
  <di:waypoint x="2088" y="120" />
1310
1348
  </bpmndi:BPMNEdge>
1349
+ <bpmndi:BPMNEdge id="BPMNEdge_f_m_escT" bpmnElement="f_m_escT">
1350
+ <di:waypoint x="1238" y="1720" />
1351
+ <di:waypoint x="2008" y="1720" />
1352
+ <di:waypoint x="2008" y="145" />
1353
+ <di:waypoint x="2068" y="145" />
1354
+ <di:waypoint x="2068" y="120" />
1355
+ <di:waypoint x="2088" y="120" />
1356
+ </bpmndi:BPMNEdge>
1311
1357
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_escWait" bpmnElement="f_m_escWait">
1312
1358
  <di:waypoint x="2138" y="120" />
1313
1359
  <di:waypoint x="2238" y="120" />
@@ -1327,13 +1373,13 @@
1327
1373
  <di:waypoint x="1013" y="1620" />
1328
1374
  <di:waypoint x="1013" y="305" />
1329
1375
  <bpmndi:BPMNLabel>
1330
- <dc:Bounds x="1446" y="1587" width="85" height="28" />
1376
+ <dc:Bounds x="1246" y="1587" width="85" height="28" />
1331
1377
  </bpmndi:BPMNLabel>
1332
1378
  </bpmndi:BPMNEdge>
1333
1379
  <bpmndi:BPMNEdge id="BPMNEdge_f_dep_rewait" bpmnElement="f_dep_rewait">
1334
1380
  <di:waypoint x="1738" y="1280" />
1335
- <di:waypoint x="1738" y="1640" />
1336
- <di:waypoint x="234" y="1640" />
1381
+ <di:waypoint x="1738" y="1300" />
1382
+ <di:waypoint x="234" y="1300" />
1337
1383
  <di:waypoint x="234" y="298" />
1338
1384
  </bpmndi:BPMNEdge>
1339
1385
  <bpmndi:BPMNEdge id="BPMNEdge_f_mr_go" bpmnElement="f_mr_go">
@@ -33,6 +33,14 @@ interface Out extends Record<string, unknown> {
33
33
  mergeState: string;
34
34
  failingChecks: number;
35
35
  failingChecksList: string;
36
+ // The unresolved-review-thread count behind a `review-threads` verdict (issue #921), mirrored
37
+ // into the escalation question so a human sees exactly what blocks the merge. `-1` = unprobed.
38
+ reviewThreads: number;
39
+ // The pending-check count (PR #922 review): zero failing checks does not mean checks are green —
40
+ // a queued/running check leaves the BLOCKED cause undetermined, so the stall-escalation question
41
+ // gates its "required review approval" wording on this being 0 too (never claims a verified
42
+ // blocker off a half-settled rollup).
43
+ pendingChecks: number;
36
44
  }
37
45
 
38
46
  const handler: AppJobHandler<In, Out> = async (job, app) => {
@@ -44,10 +52,14 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
44
52
  // GitHub reports UNSTABLE. Any transport hiccup is treated as "still waiting" — never a spurious
45
53
  // ready/conflict verdict — so a bad read is surfaced as `waiting`, which `gw-mergeable` routes to
46
54
  // a bounded re-poll (#774) rather than misrouting the token to a wrong remediation arm; a
47
- // genuinely stuck verdict still escalates once the `mergeStallMax` budget is exhausted.
48
- const st = await fetchPrState(repo, prNumber, token).catch(() => null);
55
+ // genuinely stuck verdict still escalates once the `mergeStallMax` budget is exhausted. The read
56
+ // opts into the review-threads probe (`withReviewThreads`) so a `BLOCKED` merge state is
57
+ // classified by its real cause (issue #921) — unresolved review threads surface as the
58
+ // `review-threads` verdict and route to their own arm instead of re-polling until the budget
59
+ // exhausts into the misleading "mergeability never settled" escalation.
60
+ const st = await fetchPrState(repo, prNumber, token, { withReviewThreads: true }).catch(() => null);
49
61
  if (st === null) {
50
- return { mergeState: "waiting", failingChecks: 0, failingChecksList: "" };
62
+ return { mergeState: "waiting", failingChecks: 0, failingChecksList: "", reviewThreads: -1, pendingChecks: 0 };
51
63
  }
52
64
  const protocol = await loadMergeProtocol(repo, token).catch(() => null);
53
65
  const mergeState = classifyMergeability(st, protocol ?? undefined);
@@ -64,6 +76,8 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
64
76
  // poller's `merge-ready` payload so fix-ci knows which gates to green.
65
77
  failingChecks: st.failingChecks,
66
78
  failingChecksList: st.failingCheckNames.join("\n"),
79
+ reviewThreads: st.unresolvedThreads,
80
+ pendingChecks: st.pendingCheckNames.length,
67
81
  };
68
82
  };
69
83