@nanobpm/nano-workforce 0.205.0 → 0.205.2

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" });
@@ -559,6 +688,18 @@ test("a retry that exhausts the budget escalates as a repeated race, not a gener
559
688
  assertStringIncludes(String(escalation(engine).question ?? ""), "retry budget", "the retry escalation must read as a repeated race");
560
689
  });
561
690
 
691
+ test("a worker-supplied block question (review backstop #923) reaches the human escalation verbatim", async () => {
692
+ const engine = await boot({
693
+ responses: { "pr.merge": { mergeStatus: "blocked", status: "blocked", question: "Merge refused: no head-current Copilot review exists." } },
694
+ });
695
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
696
+ await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
697
+ await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
698
+ const q = String(escalation(engine).question ?? "");
699
+ assertStringIncludes(q, "no head-current Copilot review", "the backstop's recovery instructions must reach the human");
700
+ assert(!q.includes("Investigate why GitHub refused"), "must not be overwritten by the generic refusal text");
701
+ });
702
+
562
703
  // ---------------------------------------------------------------------------
563
704
  // CI-fix sub-process (SP_cifix) — mergeCiReattempt (#134), #329
564
705
  // ---------------------------------------------------------------------------
@@ -43,6 +43,33 @@ test("isTransientMergeRace: genuine blocks are NOT transient (never swallowed)",
43
43
  assertEquals(isTransientMergeRace("Merge conflict; base branch has conflicts"), false); // a base conflict is a real block
44
44
  });
45
45
 
46
+ // #923 review (Copilot "previously missed"): the `--match-head-commit` / REST `sha` pin refuses the
47
+ // merge when the PR head advanced past the verified head between the review backstop and the merge
48
+ // mutation. That refusal is the SAME class as the base/head-moved race — a transient check-then-act
49
+ // race GitHub resolves by re-attempting — so it must classify as `retry` (the merge loop's bounded
50
+ // retry re-runs the review backstop on the new head), never `blocked` (which pages a human for a
51
+ // race the loop is designed to absorb).
52
+ test("isTransientMergeRace: the pinned-head mismatch refusal is transient", () => {
53
+ // gh CLI `--match-head-commit` refusal (gh's own wording).
54
+ assertEquals(
55
+ isTransientMergeRace(
56
+ "Head commit of pull request #924 does not match '--match-head-commit' expected f5a6f20",
57
+ ),
58
+ true,
59
+ );
60
+ // gh wording variant (no "expected" tail).
61
+ assertEquals(
62
+ isTransientMergeRace("Head commit of pull request #924 does not match '--match-head-commit'"),
63
+ true,
64
+ );
65
+ // REST PUT /pulls/{n}/merge `sha` mismatch — GitHub returns 409 Conflict.
66
+ assertEquals(
67
+ isTransientMergeRace("github 409 Conflict: Head branch was modified. Review and try the merge again."),
68
+ true,
69
+ );
70
+ assertEquals(isTransientMergeRace("github 409 Conflict: SHA does not match expected head"), true);
71
+ });
72
+
46
73
  // Drive `mergePr`'s REST (token) transport: the 405 base-moved race must surface as `retry`, while
47
74
  // a genuine 405 refusal stays `blocked`.
48
75
  async function withMergePut(
@@ -88,3 +115,25 @@ test("mergePr: a genuine 405 refusal → outcome 'blocked' (not swallowed as tra
88
115
  assertEquals(res?.outcome, "blocked");
89
116
  });
90
117
  });
118
+
119
+ test("mergePr: a REST 409 sha-mismatch refusal → outcome 'retry' (not blocked)", async () => {
120
+ await withMergePut(409, "Conflict", "Head branch was modified. Review and try the merge again.", async () => {
121
+ const res = await mergePr("acme/widgets", 42, "test-token", {
122
+ method: "squash",
123
+ admin: false,
124
+ expectedHeadSha: "f5a6f20",
125
+ });
126
+ assertEquals(res?.outcome, "retry");
127
+ });
128
+ });
129
+
130
+ test("mergePr: a genuine 409 refusal → outcome 'blocked' (not swallowed as transient)", async () => {
131
+ await withMergePut(409, "Conflict", "Pull Request is not mergeable", async () => {
132
+ const res = await mergePr("acme/widgets", 42, "test-token", {
133
+ method: "squash",
134
+ admin: false,
135
+ expectedHeadSha: "f5a6f20",
136
+ });
137
+ assertEquals(res?.outcome, "blocked");
138
+ });
139
+ });
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
  };
@@ -9,6 +9,7 @@ import {
9
9
  DEFAULT_REVIEW_NUDGE_MINUTES,
10
10
  DEFAULT_REVIEW_WAIT_TIMEOUT,
11
11
  isoDurationToMs,
12
+ isReviewMissingOrStale,
12
13
  isReviewStale,
13
14
  MAX_REVIEW_NUDGE_MINUTES,
14
15
  reviewWaitTimeout,
@@ -128,3 +129,39 @@ test("isReviewStale: a missing HEAD sha fails safe to NOT stale", () => {
128
129
  test("isReviewStale: both missing is NOT stale", () => {
129
130
  assertEquals(isReviewStale(null, null), false);
130
131
  });
132
+
133
+ // ── isReviewMissingOrStale (issue #923) ──────────────────────────────────────
134
+ // The CONVERGENCE-GATE variant of the staleness predicate. `isReviewStale` intentionally fails
135
+ // safe to "not stale" on a missing review commit_id (a review GitHub carried no `commit_id` for
136
+ // must not fabricate a re-solicit loop) — but that same fail-safe is a fail-OPEN when the null
137
+ // means "no Copilot review exists AT ALL" (nano-bpm#1443 merged with ZERO reviews). The gate's
138
+ // precondition is a HEAD-CURRENT reviewer verdict, so a missing review must route to the review
139
+ // wait exactly like a stale one.
140
+
141
+ test("isReviewMissingOrStale: NO review (null commit_id) with a readable HEAD is stale/missing — fail CLOSED", () => {
142
+ // The #923 case: `readReview` verified "no Copilot review" (`commitId: null`) and the head read
143
+ // succeeded — the gate must NOT treat "no review" as "a verified clean review".
144
+ assertEquals(isReviewMissingOrStale(null, "bbb2222"), true);
145
+ assertEquals(isReviewMissingOrStale(undefined, "bbb2222"), true);
146
+ assertEquals(isReviewMissingOrStale("", "bbb2222"), true);
147
+ assertEquals(isReviewMissingOrStale(" ", "bbb2222"), true);
148
+ });
149
+
150
+ test("isReviewMissingOrStale: a differing review commit_id and HEAD sha is stale (same as isReviewStale)", () => {
151
+ assertEquals(isReviewMissingOrStale("aaa1111", "bbb2222"), true);
152
+ });
153
+
154
+ test("isReviewMissingOrStale: a head-current review is NOT stale/missing", () => {
155
+ assertEquals(isReviewMissingOrStale("aaa1111", "aaa1111"), false);
156
+ assertEquals(isReviewMissingOrStale(" aaa1111 ", "aaa1111"), false);
157
+ });
158
+
159
+ test("isReviewMissingOrStale: an UNREADABLE head fails safe to NOT stale (never fabricates a re-solicit loop)", () => {
160
+ // Without the head we cannot prove the review predates it NOR that a missing review should
161
+ // re-solicit — a transport hiccup must not strand the loop re-requesting forever (the same
162
+ // fail-safe `isReviewStale` keeps; the review-wait timeout remains the backstop).
163
+ assertEquals(isReviewMissingOrStale(null, null), false);
164
+ assertEquals(isReviewMissingOrStale("aaa1111", null), false);
165
+ assertEquals(isReviewMissingOrStale("aaa1111", ""), false);
166
+ assertEquals(isReviewMissingOrStale(null, ""), false);
167
+ });
package/app/reviewWait.ts CHANGED
@@ -114,3 +114,29 @@ export function isReviewStale(
114
114
  if (rev === "" || head === "") return false;
115
115
  return rev !== head;
116
116
  }
117
+
118
+ /** Is there NO head-current Copilot review to gate on? (issue #923) — the CONVERGENCE-GATE /
119
+ * merge-backstop variant of {@link isReviewStale}.
120
+ *
121
+ * `isReviewStale` intentionally fails safe to "not stale" on a missing review `commit_id` — for
122
+ * the POLLER, a review GitHub carried no `commit_id` for must not fabricate a re-solicit loop. But
123
+ * the gate's consumer is different: `fetchLatestCopilotReview` returns `commitId: null` both when
124
+ * no Copilot review exists AT ALL and when the newest one lacks a `commit_id`, and the gate's
125
+ * precondition is a HEAD-CURRENT reviewer verdict. Treating "no review" as "a verified clean
126
+ * review" is the fail-open that merged nano-bpm#1443 with ZERO reviews — so for the GATE, a
127
+ * missing review commit is stale/missing, not current.
128
+ *
129
+ * The head-side fail-safe is kept: with an UNREADABLE head we can prove nothing, so we must not
130
+ * fabricate a re-solicit loop on a transport hiccup (the review-wait timeout remains the
131
+ * backstop). Only a READABLE head + a missing/mismatched review commit routes to the review wait. */
132
+ export function isReviewMissingOrStale(
133
+ reviewCommitId: string | null | undefined,
134
+ headSha: string | null | undefined,
135
+ ): boolean {
136
+ const head = (headSha ?? "").trim();
137
+ if (head === "") return false; // unverifiable head → fail safe, exactly as isReviewStale
138
+ const rev = (reviewCommitId ?? "").trim();
139
+ // A missing review commit with a readable head = NO head-current verdict → stale/missing.
140
+ if (rev === "") return true;
141
+ return rev !== head;
142
+ }
@@ -24,8 +24,11 @@ test("routeProgress: an addressed round whose head advanced continues", () => {
24
24
 
25
25
  test("routeProgress: an explicit non-addressed round always continues (no push is expected)", () => {
26
26
  // These are the statuses gw-status routes AWAY from the addressed/default arm — a legit no-push
27
- // round. They always continue regardless of the head.
28
- for (const status of ["waiting", "converged", "needs_input", "blocked"]) {
27
+ // round. They always continue regardless of the head. `review-stale` (#923 §4) joins them: the
28
+ // round re-entered via the converge gate's stale/missing-review path, so no reviewer verdict was
29
+ // obtained and no push was claimed — it must continue to the review wait, never escalate as
30
+ // no-progress.
31
+ for (const status of ["waiting", "converged", "needs_input", "blocked", "review-stale"]) {
29
32
  assertEquals(
30
33
  routeProgress(status, "sha-1", "sha-1"),
31
34
  "continue",
@@ -280,6 +283,64 @@ test("progress-check: a non-addressed round records the baseline and consumes th
280
283
  );
281
284
  });
282
285
 
286
+ test("progress-check: a review-stale re-entry parks with waiting_since at the gate's reviewWaitCutoff, not the park time", async () => {
287
+ // Issue #923 review ("previously missed"): the converge-gate decides stale/missing from ITS review
288
+ // read, but the loop parks several tasks later — and the old park stamped `waiting_since` at the
289
+ // (later) park time. A head-current review landing in that read→park window was then REJECTED by
290
+ // `pollReviews` (`submitted_at >= waiting_since`), so the loop re-solicited and could time out
291
+ // despite a valid verdict already sitting unconsumed on the PR. The gate emits `reviewWaitCutoff`
292
+ // (its read time); the park must stamp `waiting_since` at THAT cutoff so the in-window review
293
+ // satisfies the wait.
294
+ const CUTOFF = "2026-11-08T01:00:00.000Z"; // the gate's review-read time (earlier than "now")
295
+ const handler = await makeUnderTest(
296
+ async () => "sha-2",
297
+ async () => ({ work: true, consumedKey: "9" }),
298
+ );
299
+ const { app, updates } = fakeApp({ last_round_head: "sha-1" });
300
+ const out = await handler(
301
+ {
302
+ processInstanceKey: "pik",
303
+ variables: {
304
+ prKey: "o/r#1",
305
+ status: "waiting",
306
+ repo: "o/r",
307
+ prNumber: 1,
308
+ reviewWaitCutoff: CUTOFF,
309
+ },
310
+ } as any,
311
+ app as any,
312
+ );
313
+ assertEquals(out, { progressed: true, huskRetries: 0 });
314
+ assertEquals(updates.length, 1, "one atomic park write");
315
+ assertEquals(updates[0]!.patch.status, "waiting_review", "the round parks for review");
316
+ assertEquals(
317
+ updates[0]!.patch.waiting_since,
318
+ CUTOFF,
319
+ "waiting_since is stamped at the gate's review-read cutoff, so a review landing in the read→park window satisfies the wait",
320
+ );
321
+ });
322
+
323
+ test("progress-check: a normal round (no reviewWaitCutoff) still stamps waiting_since at the park time", async () => {
324
+ // The cutoff is only emitted on a review-stale re-entry. A normal round carries no
325
+ // `reviewWaitCutoff`, so the park falls back to "now" — the pre-fix behaviour every existing test
326
+ // relies on.
327
+ const handler = await makeUnderTest(
328
+ async () => "sha-2",
329
+ async () => ({ work: true, consumedKey: "9" }),
330
+ );
331
+ const { app, updates } = fakeApp({ last_round_head: "sha-1" });
332
+ await handler(
333
+ { processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "waiting", repo: "o/r", prNumber: 1 } } as any,
334
+ app as any,
335
+ );
336
+ assertEquals(updates[0]!.patch.status, "waiting_review");
337
+ const stamped = updates[0]!.patch.waiting_since;
338
+ assertEquals(typeof stamped, "string", "a park time is stamped");
339
+ // Not the (absent) cutoff — a fresh "now" timestamp, i.e. close to the wall clock.
340
+ const driftMs = Math.abs(Date.now() - Date.parse(stamped as string));
341
+ assertEquals(driftMs < 60_000, true, "waiting_since falls back to the park time when no cutoff is carried");
342
+ });
343
+
283
344
  test("progress-check: a blank/unknown status is treated as addressed — reads the head and can report no progress", async () => {
284
345
  let called = false;
285
346
  const handler = await makeUnderTest(async () => {
@@ -39,6 +39,12 @@ const NON_ADDRESSED_STATUSES: ReadonlySet<string> = new Set([
39
39
  "converged",
40
40
  "needs_input",
41
41
  "blocked",
42
+ // `review-stale` (issue #923 §4): the round re-entered persist-round via the converge gate's
43
+ // stale/missing-review path (f_convergeStale) — no head-current reviewer verdict was ever
44
+ // obtained, so the round claims no push and must always continue to the review wait (the poller
45
+ // re-solicits a fresh review of the current head). Treating it as `addressed` would fabricate a
46
+ // no-progress escalation on a round that never claimed progress.
47
+ "review-stale",
42
48
  ]);
43
49
 
44
50
  /** True when a round's status is `addressed` for no-progress purposes — i.e. NOT one of the
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.2",
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",
@@ -42,6 +42,7 @@
42
42
  <nano:extend name="round" type="integer" optional="true" />
43
43
  <nano:extend name="huskRetries" type="integer" optional="true" />
44
44
  <nano:extend name="roundEntryHead" type="string" optional="true" />
45
+ <nano:extend name="reviewWaitCutoff" type="string" optional="true" />
45
46
  </nano:shape>
46
47
  <nano:shape id="PrCaptureHeadIn" name="Capture round-entry head — input">
47
48
  <nano:extend name="prKey" type="string" />
@@ -68,6 +69,7 @@
68
69
  <nano:extend name="convergeBlockReason" type="string" optional="true" />
69
70
  <nano:extend name="convergeAckOnly" type="boolean" optional="true" />
70
71
  <nano:extend name="reviewStale" type="boolean" optional="true" />
72
+ <nano:extend name="reviewWaitCutoff" type="string" optional="true" />
71
73
  </nano:shape>
72
74
  <nano:shape id="EscalationIn" name="Record escalation — input">
73
75
  <nano:extend name="prKey" type="string" />
@@ -307,6 +309,9 @@
307
309
  <zeebe:properties>
308
310
  <zeebe:property name="io.nanobpm.dataEnvelope.in" value="PrPersistRoundIn" />
309
311
  </zeebe:properties>
312
+ <zeebe:ioMapping>
313
+ <zeebe:input source="=if reviewStale = true then &#34;review-stale&#34; else status" target="status" />
314
+ </zeebe:ioMapping>
310
315
  </bpmn:extensionElements>
311
316
  <bpmn:incoming>f_advSkip</bpmn:incoming>
312
317
  <bpmn:incoming>f_advClean</bpmn:incoming>