@nanobpm/nano-workforce 0.204.1 → 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" });
@@ -0,0 +1,169 @@
1
+ // Red/green for issue #916 — the effective merge policy must be VISIBLE on every PR status row.
2
+ //
3
+ // The defect: whether a PR run will merge was explicit nowhere an operator looks. The
4
+ // convergence-loop instance carried only the INVERTED legacy `convergeOnly` variable (a feature
5
+ // run's `autoMerge: true` became `convergeOnly: false`, so searching the loop's variables for
6
+ // `autoMerge` found nothing and it looked as if auto-merge had been dropped), and the `/status`
7
+ // projection (`activePrs`) carried no merge-intent field at all — `waiting_merge` only appears once
8
+ // merging has STARTED. An operator concluded auto-merge was lost and wrote `autoMerge=true` onto a
9
+ // running loop — a no-op write, because the loop had `convergeOnly=false` from the start.
10
+ //
11
+ // The fix projects a structured `merge` block onto every ActivePr:
12
+ // merge.requested — the run's merge intent: the canonical positive `autoMerge` process variable
13
+ // when present, falling back to the INVERSE of the legacy `convergeOnly` variable for in-flight
14
+ // loops seeded before `autoMerge` existed (and to `true` — the historical default — when the
15
+ // instance key is unreadable or carries neither variable).
16
+ // merge.enabled — the deployment's global `NANO_PR_AUTO_MERGE` switch.
17
+ // merge.effective — requested && enabled && !humanApproval: the run merges with no human gate.
18
+ // merge.blockedBy — "env" | "human-approval" | null: WHY a requested merge will not proceed.
19
+ import { test } from "node:test";
20
+ import { assertEquals } from "#test-assert";
21
+ import { withTrackingViews } from "../test/trackingViews.ts";
22
+ import { activePrs } from "./service.ts";
23
+
24
+ function memTable(rows: any[], key: string) {
25
+ return {
26
+ get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
27
+ all: () => Promise.resolve([...rows]),
28
+ find: (q: any) =>
29
+ Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
30
+ findOne: (q: any) =>
31
+ Promise.resolve(rows.find((r) => Object.entries(q).every(([f, v]) => r[f] === v)) ?? null),
32
+ insert: (r: any) => {
33
+ rows.push(r);
34
+ return Promise.resolve(r);
35
+ },
36
+ update: (k: any, patch: any) => {
37
+ const r = rows.find((x) => x[key] === k);
38
+ if (r) Object.assign(r, patch);
39
+ return Promise.resolve(r);
40
+ },
41
+ delete: (k: any) => {
42
+ for (let i = rows.length - 1; i >= 0; i--) if (rows[i][key] === k) rows.splice(i, 1);
43
+ return Promise.resolve();
44
+ },
45
+ };
46
+ }
47
+
48
+ type Stores = Record<string, { rows: any[]; key: string }>;
49
+
50
+ function memData(stores: Stores) {
51
+ return {
52
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
53
+ } as any;
54
+ }
55
+
56
+ /** A fake engine whose `searchVariables` serves the given per-instance variables. */
57
+ function fakeEngine(varsByInstance: Record<string, Record<string, unknown>>) {
58
+ return {
59
+ searchVariables: (q: { processInstanceKey?: string; name?: string }) => {
60
+ const vars = varsByInstance[String(q.processInstanceKey)] ?? {};
61
+ return Promise.resolve(
62
+ Object.entries(vars)
63
+ .filter(([name]) => q.name == null || name === q.name)
64
+ .map(([name, v]) => ({ name, value: JSON.stringify(v) })),
65
+ );
66
+ },
67
+ } as any;
68
+ }
69
+
70
+ function prRow(over: Record<string, unknown>) {
71
+ return {
72
+ pr_key: "owner/repo#1",
73
+ repo: "owner/repo",
74
+ number: 1,
75
+ url: "https://github.com/owner/repo/pull/1",
76
+ title: "t",
77
+ status: "converging",
78
+ current_round: 1,
79
+ process_key: "PI-1",
80
+ updated_at: "2024-01-01",
81
+ ...over,
82
+ };
83
+ }
84
+
85
+ async function mergeOf(vars: Record<string, unknown>, rowOver: Record<string, unknown> = {}) {
86
+ return mergeOfWith({ activePrs }, vars, rowOver);
87
+ }
88
+
89
+ /** `mergeOf` against an explicitly-loaded service module — for the env-switched tests that import
90
+ * a fresh copy of app/service.ts (e.g. `NANO_PR_AUTO_MERGE=0`). */
91
+ async function mergeOfWith(
92
+ mod: Pick<typeof import("./service.ts"), "activePrs">,
93
+ vars: Record<string, unknown>,
94
+ rowOver: Record<string, unknown> = {},
95
+ ) {
96
+ const stores: Stores = {
97
+ pull_requests: { rows: [prRow(rowOver)], key: "pr_key" },
98
+ user_tasks: { rows: [], key: "id" },
99
+ };
100
+ const [pr] = await mod.activePrs(memData(stores), fakeEngine({ "PI-1": vars }));
101
+ return pr.merge;
102
+ }
103
+
104
+ // AUTO_MERGE is computed once at app/service.ts import time; the test env leaves NANO_PR_AUTO_MERGE
105
+ // unset, so the global switch is ON here (the default).
106
+
107
+ test("merge policy: a loop seeded autoMerge=true reads requested+effective (the #916 phantom)", async () => {
108
+ const merge = await mergeOf({ autoMerge: true, convergeOnly: false, humanApproval: false });
109
+ assertEquals(merge, { requested: true, enabled: true, effective: true, blockedBy: null });
110
+ });
111
+
112
+ test("merge policy: a legacy in-flight loop (convergeOnly only) derives requested from its inverse", async () => {
113
+ // Seeded BEFORE `autoMerge` existed: only the legacy inverted variable is present.
114
+ const review = await mergeOf({ convergeOnly: true });
115
+ assertEquals(review, { requested: false, enabled: true, effective: false, blockedBy: null });
116
+ const merging = await mergeOf({ convergeOnly: false });
117
+ assertEquals(merging, { requested: true, enabled: true, effective: true, blockedBy: null });
118
+ });
119
+
120
+ test("merge policy: humanApproval gates the effective merge and is named as the blocker", async () => {
121
+ const merge = await mergeOf({ autoMerge: true, humanApproval: true });
122
+ assertEquals(merge, { requested: true, enabled: true, effective: false, blockedBy: "human-approval" });
123
+ });
124
+
125
+ test("merge policy: an approved PR with finalization still pending reports no blocker (PR #917 review)", async () => {
126
+ // The `merge-approval` user task clears the loop's `humanApproval` variable on an explicit
127
+ // "approve" (convergence-loop.bpmn), so while `pr.finalize` is still queued or incidented the
128
+ // projection reads humanApproval=false and must NOT keep reporting a human-approval blocker for
129
+ // an approval that is already complete. (`status` stays non-terminal: `converged_at` is only
130
+ // stamped once `pr.finalize` actually runs.)
131
+ const merge = await mergeOf({ autoMerge: true, humanApproval: false }, { status: "converging" });
132
+ assertEquals(merge, { requested: true, enabled: true, effective: true, blockedBy: null });
133
+ });
134
+
135
+ test("merge policy: a review-only request reads requested=false (distinct from env/human blocking)", async () => {
136
+ const merge = await mergeOf({ autoMerge: false, convergeOnly: true });
137
+ assertEquals(merge, { requested: false, enabled: true, effective: false, blockedBy: null });
138
+ });
139
+
140
+ test("merge policy: no readable instance key (or neither variable) defaults to the historical merge default", async () => {
141
+ // A mid-handoff row (process_key null) or a legacy loop carrying neither variable: the pre-#916
142
+ // behaviour merged by default, so the projection must NOT read such a PR as review-only.
143
+ const stores: Stores = {
144
+ pull_requests: { rows: [prRow({ process_key: null })], key: "pr_key" },
145
+ user_tasks: { rows: [], key: "id" },
146
+ };
147
+ const [noKey] = await activePrs(memData(stores), fakeEngine({}));
148
+ assertEquals(noKey.merge, { requested: true, enabled: true, effective: true, blockedBy: null });
149
+ const bare = await mergeOf({});
150
+ assertEquals(bare, { requested: true, enabled: true, effective: true, blockedBy: null });
151
+ });
152
+
153
+ test("merge policy: NANO_PR_AUTO_MERGE=0 blocks a requested merge as env, never a review-only one", async () => {
154
+ // `AUTO_MERGE` is read once at app/service.ts import time, so load a fresh copy of the module
155
+ // under the env (the same pattern app/service.test.ts uses) — every other test here runs with
156
+ // the switch ON (the default) and none exercised `blockedBy: "env"` before this one.
157
+ const prev = process.env["NANO_PR_AUTO_MERGE"];
158
+ process.env["NANO_PR_AUTO_MERGE"] = "0";
159
+ try {
160
+ const mod: typeof import("./service.ts") = await import(`./service.ts?mergeStatusAutoMergeOff=${Date.now()}`);
161
+ const requested = await mergeOfWith(mod, { autoMerge: true, humanApproval: true });
162
+ assertEquals(requested, { requested: true, enabled: false, effective: false, blockedBy: "env" });
163
+ const reviewOnly = await mergeOfWith(mod, { autoMerge: false });
164
+ assertEquals(reviewOnly, { requested: false, enabled: false, effective: false, blockedBy: null });
165
+ } finally {
166
+ if (prev === undefined) delete process.env["NANO_PR_AUTO_MERGE"];
167
+ else process.env["NANO_PR_AUTO_MERGE"] = prev;
168
+ }
169
+ });
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
  };
@@ -1005,6 +1005,31 @@ test("submitPr defaults convergeOnly to false so the global auto-merge default g
1005
1005
  });
1006
1006
  });
1007
1007
 
1008
+ // Issue #916: the loop must ALSO carry the POSITIVE `autoMerge` variable — an operator searching a
1009
+ // live convergence loop's variables for `autoMerge` previously found nothing (only the inverted
1010
+ // `convergeOnly`), and concluded auto-merge had been dropped. `submitPr` seeds both, keeping them
1011
+ // consistent: `autoMerge` is canonical, `convergeOnly` its inverse mirror for in-flight compat.
1012
+ test("submitPr seeds the positive autoMerge variable alongside the convergeOnly mirror (issue #916)", async () => {
1013
+ await withGithubOff(async () => {
1014
+ const pr = (n: number) => ({ repo: "owner/repo", number: n, url: `https://github.com/owner/repo/pull/${n}`, prKey: `owner/repo#${n}` });
1015
+ // Legacy positional form: convergeOnly=true ⇒ autoMerge=false (both seeded, consistent).
1016
+ const legacy = captureVars();
1017
+ await submitPr(legacy.data, legacy.engine, pr(30), [], 20, true);
1018
+ assertEquals(legacy.get()?.autoMerge, false);
1019
+ assertEquals(legacy.get()?.convergeOnly, true);
1020
+ // Default (no override): merge intent on — the global default governs.
1021
+ const dflt = captureVars();
1022
+ await submitPr(dflt.data, dflt.engine, pr(31));
1023
+ assertEquals(dflt.get()?.autoMerge, true);
1024
+ assertEquals(dflt.get()?.convergeOnly, false);
1025
+ // The positive trailing form wins over the legacy alias when both are supplied.
1026
+ const both = captureVars();
1027
+ await submitPr(both.data, both.engine, pr(32), [], 20, true, null, false, false, true);
1028
+ assertEquals(both.get()?.autoMerge, true);
1029
+ assertEquals(both.get()?.convergeOnly, false);
1030
+ });
1031
+ });
1032
+
1008
1033
  // #796 auto-ack budget seeding: `submitPr` is the ONLY production write that makes the retry budget
1009
1034
  // available to a fresh convergence instance — the engine behaviour tests seed `ackRetryRound` /
1010
1035
  // `ackRetryMax` directly and never exercise `submitPr`, so a regression dropping or misconfiguring