@nanobpm/nano-workforce 0.204.1 → 0.205.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.205.0](https://github.com/nanobpm/nano-workforce/compare/v0.204.1...v0.205.0) (2026-11-04)
2
+
3
+ ### Features
4
+
5
+ * **convergence:** make PR merge intent explicit — carry autoMerge end to end and show the effective merge policy in /status ([#917](https://github.com/nanobpm/nano-workforce/issues/917)) ([31e5b26](https://github.com/nanobpm/nano-workforce/commit/31e5b26f745f58920a9528bb448fe650af9631e3)), closes [#916](https://github.com/nanobpm/nano-workforce/issues/916) [#916](https://github.com/nanobpm/nano-workforce/issues/916)
6
+
1
7
  ## [0.204.1](https://github.com/nanobpm/nano-workforce/compare/v0.204.0...v0.204.1) (2026-11-04)
2
8
 
3
9
  ### Bug Fixes
package/SPEC.md CHANGED
@@ -454,13 +454,16 @@ Notes:
454
454
  three because the unfiltered merge lets an input-echo replace the identity that
455
455
  `progress-check`, `converge-gate`, and `finalize` go on to read (a wrong
456
456
  `prNumber` would make `finalize` start the merge loop for the wrong PR) — **and**
457
- the gate controls `humanApproval` and `convergeOnly`. These last two are
458
- decision flags the loop acts on after the agent returns: `humanApproval = true`
459
- routes a converged PR through the `gw-approval` human gate instead of
460
- auto-merging, and `convergeOnly = true` ends a review-only run without ever
461
- handing the PR to the merge loop. Left unguarded, a converged completion that
462
- echoed `humanApproval: false` would **bypass** the approval gate, and one that
463
- echoed `convergeOnly: false` would hand a review-only PR to the merge loop.
457
+ the gate controls `humanApproval` and the merge-intent pair `autoMerge` /
458
+ `convergeOnly`. These are decision flags the loop acts on after the agent
459
+ returns: `humanApproval = true` routes a converged PR through the
460
+ `gw-approval` human gate instead of auto-merging, and the merge intent —
461
+ `autoMerge` (the canonical positive variable, issue #916), with the legacy
462
+ inverted `convergeOnly` as its in-flight fallback — decides whether a
463
+ converged PR is handed to the merge loop at all. Left unguarded, a converged
464
+ completion that echoed `humanApproval: false` would **bypass** the approval
465
+ gate, and one that echoed `convergeOnly: false` would hand a review-only PR
466
+ to the merge loop.
464
467
  `convergeOnly` is a plain seeded constant, so the usual `=guardConvergeOnly`
465
468
  restore applies. `humanApproval` is the one engine-owned flag that is **also
466
469
  legitimately mutated mid-flight** — a gated caller that adopts a live loop
@@ -319,6 +319,56 @@ test("a scope-classify agent echoing humanApproval/convergeOnly cannot alter the
319
319
  assertEquals(await variableValue(engine, processInstanceKey, "convergeOnly"), true, "convergeOnly flag must be unchanged");
320
320
  });
321
321
 
322
+ // Issue #916: `autoMerge` is the canonical positive merge intent and is engine-owned like
323
+ // `convergeOnly` — an agent echo in EITHER direction must not flip it.
324
+ for (const seeded of [true, false]) {
325
+ test(`a review-round agent echoing autoMerge:${!seeded} cannot overwrite seeded autoMerge:${seeded}`, async () => {
326
+ const { engine } = await boot({
327
+ vars: { autoMerge: seeded },
328
+ responses: { "senior:pr-review": { status: "addressed", summary: "fixed", autoMerge: !seeded } },
329
+ });
330
+ const incidents = await engine.searchIncidents();
331
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
332
+ assertEquals(instanceVars(engine).autoMerge, seeded, "seeded autoMerge must survive the agent echo");
333
+ });
334
+
335
+ test(`an adversarial-review agent echoing autoMerge:${!seeded} cannot overwrite seeded autoMerge:${seeded}`, async () => {
336
+ const { engine } = await boot({
337
+ vars: { advMax: 1, autoMerge: seeded },
338
+ responses: {
339
+ "senior:adversarial-review": { adversarialFindings: "", adversarialSummary: "clean", autoMerge: !seeded },
340
+ },
341
+ });
342
+ const incidents = await engine.searchIncidents();
343
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
344
+ assertEquals(instanceVars(engine).autoMerge, seeded, "seeded autoMerge must survive the agent echo");
345
+ });
346
+
347
+ test(`a scope-classify agent echoing autoMerge:${!seeded} cannot overwrite seeded autoMerge:${seeded}`, async () => {
348
+ const { engine, processInstanceKey } = await boot({
349
+ vars: { humanApproval: true, autoMerge: seeded },
350
+ responses: {
351
+ "senior:pr-review": { status: "converged", summary: "done" },
352
+ "senior:scope-classify": { scopeBlocked: false, scopeBlockReason: "", autoMerge: !seeded },
353
+ },
354
+ });
355
+ const incidents = await engine.searchIncidents();
356
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
357
+ assertEquals(await variableValue(engine, processInstanceKey, "autoMerge"), seeded, "autoMerge must be unchanged");
358
+ });
359
+ }
360
+
361
+ test("an agent echoing autoMerge on a legacy (autoMerge-absent) run cannot inject a merge intent", async () => {
362
+ const { engine } = await boot({
363
+ vars: { convergeOnly: true },
364
+ responses: { "senior:pr-review": { status: "addressed", summary: "fixed", autoMerge: true } },
365
+ });
366
+ const incidents = await engine.searchIncidents();
367
+ assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
368
+ const v = instanceVars(engine).autoMerge;
369
+ assertEquals(v == null, true, `legacy run must keep autoMerge absent so the convergeOnly fallback applies; got ${v}`);
370
+ });
371
+
322
372
  // ---------------------------------------------------------------------------
323
373
  // Issue #887 (round 4 — Copilot "previously missed"): `reviewStale` and
324
374
  // `roundEntryHead` are ALSO process-owned and readable by downstream decisions,
package/app/feature.ts CHANGED
@@ -524,9 +524,9 @@ export async function startFeature(
524
524
  `(\`gh issue view ${parsed.number} -R ${parsed.repo}\`), implement it completely, ` +
525
525
  `and open exactly one pull request that closes it.`,
526
526
  },
527
- // Follow-on knobs, read by the record/converge workers. `autoMerge` maps to submitPr's
528
- // `convergeOnly` (inverted): converge-only stops at `converged`; auto-merge drives the
529
- // merge-loop. `converge=false` ⇒ merge is moot.
527
+ // Follow-on knobs, read by the record/converge workers. `autoMerge` is the canonical
528
+ // positive merge intent (issue #916), threaded to `submitPr` as-is: converge-only stops at
529
+ // `converged`; auto-merge drives the merge-loop. `converge=false` ⇒ merge is moot.
530
530
  converge,
531
531
  autoMerge,
532
532
  // Park the converged PR at a human approval user task before the merge-loop (issue #826).
@@ -215,6 +215,8 @@ test("pollFeatureDelivery: the re-enroll keeps the run's human approval gate (is
215
215
  await pollFeatureDelivery(data, engine);
216
216
 
217
217
  assertEquals(vars?.convergeOnly, false);
218
+ // Issue #916: the healed enrollment also carries the canonical positive merge-intent variable.
219
+ assertEquals(vars?.autoMerge, true);
218
220
  assertEquals(vars?.humanApproval, true, "a healed enrollment must not silently drop the approval gate");
219
221
  });
220
222
 
@@ -80,7 +80,7 @@ test("with humanApproval a converged PR parks for a human, and approve finalizes
80
80
  });
81
81
 
82
82
  test("request changes sends the guidance to the review agent on the same PR, then asks again", async () => {
83
- const { engine, answers } = await boot({ humanApproval: true });
83
+ const { engine, answers, processInstanceKey } = await boot({ humanApproval: true });
84
84
  const first = await openApproval(engine);
85
85
  assert(first !== undefined, "the approval task is open");
86
86
  await engine.completeUserTask(first, { mergeDecision: "revise", answer: "Rename foo to bar." });
@@ -92,6 +92,10 @@ test("request changes sends the guidance to the review agent on the same PR, the
92
92
  assertEquals(completions(engine, "persist-converged"), 0);
93
93
  const second = await openApproval(engine);
94
94
  assert(second !== undefined && second !== first, "approval is asked again after the revision");
95
+ // A revise is NOT an approval: the gate must stay set, so /status keeps reporting
96
+ // `blockedBy: "human-approval"` while the re-asked approval is outstanding (PR #917 review).
97
+ const [row] = await engine.searchVariables({ processInstanceKey: String(processInstanceKey), name: "humanApproval" });
98
+ assertEquals(row ? JSON.parse(row.value) : undefined, true, "a revise keeps the human-approval gate set");
95
99
  });
96
100
 
97
101
  test("a live ungated loop narrowed to humanApproval (a gated submitPr adopting it) parks for approval", async () => {
@@ -113,6 +117,21 @@ test("fail-closed: a completion without an explicit approve never merges", async
113
117
  assertEquals(completions(engine, "persist-converged"), 0);
114
118
  });
115
119
 
120
+ test("an approve clears the humanApproval gate while finalization is still pending (PR #917 review)", async () => {
121
+ // The /status merge projection derives `blockedBy: "human-approval"` from the loop's
122
+ // `humanApproval` variable alone, so an approved PR whose `pr.finalize` handoff is still queued
123
+ // (or incidented) would otherwise keep reporting a human-approval blocker even though the
124
+ // approval is complete. Hold `pr.finalize` so the loop stays ACTIVE after the approve, then read
125
+ // the variable: it must be cleared by the approval itself, not by some later task.
126
+ const { engine, processInstanceKey } = await boot({ humanApproval: true }, ["pr.finalize"]);
127
+ const key = await openApproval(engine);
128
+ assert(key !== undefined, "the approval task is open");
129
+ await engine.completeUserTask(key, { mergeDecision: "approve" });
130
+ loop(engine).isActive().hasNoIncident();
131
+ const [row] = await engine.searchVariables({ processInstanceKey: String(processInstanceKey), name: "humanApproval" });
132
+ assertEquals(row ? JSON.parse(row.value) : undefined, false, "approval complete — the blocker must clear");
133
+ });
134
+
116
135
  test("merge-approval is human-only, and its form requires guidance only for request changes", () => {
117
136
  assert(HUMAN_COMPLETABLE_ELEMENTS.has(PR_MERGE_APPROVAL_ELEMENT), "the human completer accepts it");
118
137
  assert(!ESCALATION_TASK_ELEMENTS.has(PR_MERGE_APPROVAL_ELEMENT), "an agent must never approve a merge");
@@ -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
+ });
@@ -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
package/app/service.ts CHANGED
@@ -627,9 +627,10 @@ export async function submitPr(
627
627
  rootRequestKey: string | null = null,
628
628
  humanApproval = false,
629
629
  autoResolveEscalations = false,
630
+ autoMerge?: boolean,
630
631
  ) {
631
632
  return withPrSubmitLock(parsed.prKey, () =>
632
- submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey, humanApproval, autoResolveEscalations),
633
+ submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey, humanApproval, autoResolveEscalations, autoMerge),
633
634
  );
634
635
  }
635
636
 
@@ -643,11 +644,20 @@ async function submitPrCritical(
643
644
  rootRequestKey: string | null = null,
644
645
  humanApproval = false,
645
646
  autoResolveEscalations = false,
647
+ autoMerge?: boolean,
646
648
  ) {
649
+ // Merge intent (issue #916): `autoMerge` is the CANONICAL positive form end to end — the same name
650
+ // the feature/convergence-loop/plan-fanout doors accept. The legacy inverted `convergeOnly`
651
+ // positional remains as a fallback: an explicit `autoMerge` wins; otherwise intent derives from
652
+ // `convergeOnly`. The instance carries BOTH (below): `autoMerge` so the intent is searchable under
653
+ // its positive name, and the `convergeOnly` mirror so in-flight consumers (and the guard-restore
654
+ // ioMappings on the loop's agent tasks) keep working unchanged.
655
+ const mergeIntent = autoMerge ?? !convergeOnly;
656
+ const effectiveConvergeOnly = !mergeIntent;
647
657
  // Human approval before merge (issue #826) gates only a PR that `pr.finalize` would hand to the
648
- // merge-loop — its own predicate, `AUTO_MERGE && convergeOnly !== true`. A run that won't merge
658
+ // merge-loop — its own predicate, `AUTO_MERGE && merge intent on`. A run that won't merge
649
659
  // (review-only, or the global switch off) must not park for a pointless approval.
650
- const gated = humanApproval && !convergeOnly && AUTO_MERGE;
660
+ const gated = humanApproval && mergeIntent && AUTO_MERGE;
651
661
  const table = prs(data);
652
662
  const existing = await table.get(parsed.prKey);
653
663
  // ADR-0065: classify "already running" on the DERIVED terminal edge, not the base transient. A
@@ -818,10 +828,14 @@ async function submitPrCritical(
818
828
  // descendant (and any message it correlates) is stitched back to the originating request.
819
829
  // A human/webhook PR that is its own root carries its own `pr_key` (never NULL — see above).
820
830
  rootRequestKey: effectiveRoot,
821
- // Per-request review-only override: carried on the instance so `pr.finalize` can stop at
822
- // `converged` for this PR without handing off to the merge-loop, independent of the global
823
- // NANO_PR_AUTO_MERGE default. Only ever narrows (never forces merge on when auto-merge is off).
824
- convergeOnly,
831
+ // Per-request merge intent (issue #916): `autoMerge` is the CANONICAL positive variable — the
832
+ // same name the doors accept — so a live loop's merge intent is searchable under `autoMerge`.
833
+ // `convergeOnly` is its inverse mirror, kept for in-flight compat: `pr.finalize` reads
834
+ // `autoMerge` first and falls back to `convergeOnly` when it is absent (a loop seeded before
835
+ // this variable existed). Merge intent only ever narrows (never forces merge on when the
836
+ // global NANO_PR_AUTO_MERGE switch is off).
837
+ autoMerge: mergeIntent,
838
+ convergeOnly: effectiveConvergeOnly,
825
839
  // Human approval before merge (issue #826): a converged PR parks at the `merge-approval` user
826
840
  // task before `pr.finalize` hands it to the merge-loop. Pinned off when the PR won't merge.
827
841
  humanApproval: gated,
@@ -1024,6 +1038,24 @@ export async function startMerge(
1024
1038
  return { prKey: pr.prKey, mergeProcessKey: processInstanceKey };
1025
1039
  }
1026
1040
 
1041
+ /** The effective merge policy for a PR run, surfaced on every `/status` row (issue #916). */
1042
+ export interface MergePolicy {
1043
+ /** The run's merge intent: the canonical positive `autoMerge` process variable when present,
1044
+ * else the inverse of the legacy `convergeOnly` variable (in-flight compat), else the historical
1045
+ * default `true` when the instance key is unreadable or carries neither. */
1046
+ requested: boolean;
1047
+ /** The deployment's global `NANO_PR_AUTO_MERGE` switch. */
1048
+ enabled: boolean;
1049
+ /** `requested && enabled && !humanApproval` — the run merges with no further gate. */
1050
+ effective: boolean;
1051
+ /** WHY a requested merge will not proceed: the global env switch is off, or a human merge
1052
+ * approval is outstanding. Null when the merge is effective or none was requested. The
1053
+ * `merge-approval` task clears the loop's `humanApproval` variable on an explicit "approve"
1054
+ * (convergence-loop.bpmn), so the blocker lifts as soon as approval is complete — even while
1055
+ * the `pr.finalize` handoff is still queued or incidented. */
1056
+ blockedBy: "env" | "human-approval" | null;
1057
+ }
1058
+
1027
1059
  /** A PR currently in flight, as reported by the status endpoint. */
1028
1060
  export interface ActivePr {
1029
1061
  prKey: string;
@@ -1045,6 +1077,9 @@ export interface ActivePr {
1045
1077
  /** Whether this PR opted in to the escalation steward (issue #889) — its open escalations are
1046
1078
  * eligible for steward auto-resolution. Shown in `/status` per PR. */
1047
1079
  autoResolveEscalations: boolean;
1080
+ /** The effective merge policy (issue #916): what the run intends (`requested`), what the
1081
+ * deployment allows (`enabled`), and what will actually happen (`effective`/`blockedBy`). */
1082
+ merge: MergePolicy;
1048
1083
  }
1049
1084
 
1050
1085
  /** Every tracked PR not in a terminal state (converged/abandoned), newest-updated first. Backs
@@ -1058,7 +1093,10 @@ export interface ActivePr {
1058
1093
  * Both loops park on a native user task answered through the one canonical `completeUserTask` door
1059
1094
  * (#256), so deriving from the read model surfaces BOTH loops' escalations uniformly. Once the task is
1060
1095
  * completed its row is removed, so `openEscalation` derives back to null. */
1061
- export async function activePrs(data: DataLayer): Promise<ActivePr[]> {
1096
+ export async function activePrs(
1097
+ data: DataLayer,
1098
+ engine?: Pick<EngineClient, "searchVariables">,
1099
+ ): Promise<ActivePr[]> {
1062
1100
  const all = await prsTracking(data).all();
1063
1101
  const active = all
1064
1102
  // ADR-0065: classify "in flight" on the DERIVED terminal edge, not the base transient. A PR whose
@@ -1086,6 +1124,16 @@ export async function activePrs(data: DataLayer): Promise<ActivePr[]> {
1086
1124
  if (openEscByPr.has(t.subject_key)) continue;
1087
1125
  openEscByPr.set(t.subject_key, toOpenEscalation(t));
1088
1126
  }
1127
+ // Merge intent (issue #916): read the loop's `autoMerge` / `convergeOnly` / `humanApproval`
1128
+ // variables per PR so the effective merge policy is visible without opening the engine. One
1129
+ // best-effort read per PR; a failure (engine unreachable, instance gone) degrades to the
1130
+ // historical default (merge requested) rather than failing the whole status read.
1131
+ const mergeByPr = new Map<string, MergePolicy>();
1132
+ await Promise.all(
1133
+ active.map(async (p) => {
1134
+ mergeByPr.set(p.pr_key, await mergePolicyFor(p.process_key ?? null, engine));
1135
+ }),
1136
+ );
1089
1137
  return active.map((p) => ({
1090
1138
  prKey: p.pr_key,
1091
1139
  repo: p.repo,
@@ -1101,9 +1149,67 @@ export async function activePrs(data: DataLayer): Promise<ActivePr[]> {
1101
1149
  activeWorker: p.active_worker ?? null,
1102
1150
  leaseUntil: p.lease_until ?? null,
1103
1151
  autoResolveEscalations: p.auto_resolve_escalations === 1,
1152
+ merge: mergeByPr.get(p.pr_key) ?? mergePolicyDefault(),
1104
1153
  }));
1105
1154
  }
1106
1155
 
1156
+ /** The default merge policy when no instance variables are readable (mid-handoff row, engine
1157
+ * unreachable, or a legacy loop carrying neither variable): the pre-#916 behaviour merged by
1158
+ * default, so the projection must NOT read such a PR as review-only. */
1159
+ function mergePolicyDefault(): MergePolicy {
1160
+ return mergePolicyFrom(true, false);
1161
+ }
1162
+
1163
+ /** Combine a merge intent + human-approval gate with the global switch into the effective policy.
1164
+ * `humanApproval` is the OUTSTANDING-approval signal: the loop seeds it when approval is required
1165
+ * and the `merge-approval` user task clears it on an explicit "approve", so an approved PR whose
1166
+ * finalization is still queued/incidented reads `effective` with no blocker rather than a stale
1167
+ * `blockedBy: "human-approval"`. */
1168
+ function mergePolicyFrom(requested: boolean, humanApproval: boolean): MergePolicy {
1169
+ const enabled = AUTO_MERGE;
1170
+ const effective = requested && enabled && !humanApproval;
1171
+ const blockedBy = !requested ? null : !enabled ? "env" : humanApproval ? "human-approval" : null;
1172
+ return { requested, enabled, effective, blockedBy };
1173
+ }
1174
+
1175
+ /** Read one boolean process variable off a live instance; undefined when absent/unreadable. */
1176
+ async function readBoolVar(
1177
+ engine: Pick<EngineClient, "searchVariables">,
1178
+ processKey: string,
1179
+ name: string,
1180
+ ): Promise<boolean | undefined> {
1181
+ const [row] = await engine.searchVariables({ processInstanceKey: processKey, name });
1182
+ if (!row) return undefined;
1183
+ try {
1184
+ const v: unknown = JSON.parse(row.value);
1185
+ return typeof v === "boolean" ? v : undefined;
1186
+ } catch {
1187
+ return undefined;
1188
+ }
1189
+ }
1190
+
1191
+ /** Derive a PR's effective merge policy from its loop's process variables (issue #916). The
1192
+ * canonical positive `autoMerge` wins; the legacy inverted `convergeOnly` is the fallback for
1193
+ * in-flight loops seeded before `autoMerge` existed. Best-effort: any read failure degrades to the
1194
+ * historical default (merge requested) rather than failing the status read. */
1195
+ async function mergePolicyFor(
1196
+ processKey: string | null,
1197
+ engine: Pick<EngineClient, "searchVariables"> | undefined,
1198
+ ): Promise<MergePolicy> {
1199
+ if (processKey == null || !engine) return mergePolicyDefault();
1200
+ try {
1201
+ const [autoMerge, convergeOnly, humanApproval] = await Promise.all([
1202
+ readBoolVar(engine, processKey, "autoMerge"),
1203
+ readBoolVar(engine, processKey, "convergeOnly"),
1204
+ readBoolVar(engine, processKey, "humanApproval"),
1205
+ ]);
1206
+ const requested = autoMerge ?? (convergeOnly === undefined ? true : !convergeOnly);
1207
+ return mergePolicyFrom(requested, humanApproval === true);
1208
+ } catch {
1209
+ return mergePolicyDefault();
1210
+ }
1211
+ }
1212
+
1107
1213
  /** A delivery-graph run currently in flight, as reported by the status endpoint (issue #889 §1 —
1108
1214
  * "Show it in `/status` for each PR or delivery"). Mirrors {@link ActivePr} for the delivery side. */
1109
1215
  export interface ActiveDelivery {
@@ -2784,13 +2890,16 @@ export async function pollFeatureDelivery(
2784
2890
  parsed,
2785
2891
  [],
2786
2892
  MAX_ROUNDS,
2787
- run.auto_merge !== 1,
2893
+ false,
2788
2894
  run.feature_key,
2789
2895
  run.human_approval === 1,
2790
2896
  // Thread the run's escalation-steward opt-in (issue #889 review) so a re-enrolled PR
2791
2897
  // inherits the feature run's enrolment — and a partial-enrollment repair never overwrites
2792
2898
  // the PR row's previously persisted opt-in with the `false` default.
2793
2899
  run.auto_resolve_escalations === 1,
2900
+ // Merge intent (issue #916): the canonical positive `autoMerge`, derived from the run's
2901
+ // durable `auto_merge` flag (mirrors `converge-feature`).
2902
+ run.auto_merge === 1,
2794
2903
  );
2795
2904
  }
2796
2905
  }
@@ -31,6 +31,24 @@ Each `/status` entry carries `prKey` (`owner/repo#123`), `status`, `round`, `pro
31
31
  `activeWorker`/`leaseUntil` (an agent holds the round, or the job is just queued), and
32
32
  `updatedAt`.
33
33
 
34
+ It also carries the run's **merge intent** as a structured `merge` block (so "will this PR
35
+ merge?" is a read, not a guess):
36
+
37
+ ```json
38
+ "merge": { "requested": true, "enabled": true, "effective": false, "blockedBy": "human-approval" }
39
+ ```
40
+
41
+ | field | meaning |
42
+ |---|---|
43
+ | `requested` | the run's merge intent — the `autoMerge` process variable on its convergence loop (a loop seeded before that variable existed derives it from the inverse of the legacy flag). |
44
+ | `enabled` | the deployment's global `NANO_PR_AUTO_MERGE` switch. |
45
+ | `effective` | `requested && enabled && !humanApproval` — the run merges with no further gate. |
46
+ | `blockedBy` | why a *requested* merge will not proceed: `"env"` (the global switch is off) or `"human-approval"` (parked at the merge-approval task). `null` when the merge is effective — and when the run is converge-only (`requested: false`), which is a *choice*, not a block. |
47
+
48
+ The same intent is on the engine variables of the loop itself: query the instance
49
+ (`processKey`) for `autoMerge` — it is always seeded, so a missing `autoMerge` means a loop
50
+ started before this version (read `convergeOnly` and invert it there).
51
+
34
52
  ---
35
53
 
36
54
  ## 1. Submit a PR for review convergence
@@ -57,6 +75,16 @@ curl -sS -X POST __BASE__/actions/start/convergence-loop \
57
75
  Idempotent on the PR key: re-POSTing refreshes the record instead of starting a second
58
76
  loop. The 202 response echoes `prKey` and `processKey`.
59
77
 
78
+ `autoMerge` must be a JSON **boolean** — a string (`"true"`) is a `400` at every start door
79
+ (`startConvergenceLoop`, `startFeature`), never a silent review-only run. To *read* the intent
80
+ back, use the `merge` block in `/status` (§0). There is **no supported way to flip merge
81
+ intent on a running loop**. Direct variable writes onto a live instance (e.g. `autoMerge=true`)
82
+ are **unsupported and timing-dependent, not reliable no-ops**: the agent tasks guard-restore the
83
+ seeded value, so a write is usually overwritten — but a write that lands while the loop awaits
84
+ merge approval is never restored and reaches `pr.finalize`, where it can change whether merging
85
+ starts. To change the intent, cancel the run (`cancelInstance`, §7) and re-submit with the
86
+ intent you want.
87
+
60
88
  ---
61
89
 
62
90
  ## 2. Submit an epic (plan → implement → converge)
@@ -174,6 +202,11 @@ PR `status` values: `converging` (round live), `waiting_review`, `escalated`, `c
174
202
  `waiting_deps` / `waiting_merge` / `queued` / `merging`, `merged`, `abandoned`. An
175
203
  **incident** (§5) can overlay any live status when the engine parks the token on a fault.
176
204
 
205
+ The merge-stage statuses only ever appear when the run's merge policy is effective — read the
206
+ `merge` block in `/status` (§0) to distinguish "merge requested but disabled by env"
207
+ (`blockedBy: "env"`) and "waiting for human approval" (`blockedBy: "human-approval"`) from
208
+ "converge-only" (`requested: false`).
209
+
177
210
  ---
178
211
 
179
212
  ## 5. Debug: find engine instances and relate them to PRs
package/openapi.yaml CHANGED
@@ -92,6 +92,7 @@ components:
92
92
  - activeWorker
93
93
  - leaseUntil
94
94
  - autoResolveEscalations
95
+ - merge
95
96
  properties:
96
97
  prKey:
97
98
  type: string
@@ -155,6 +156,39 @@ components:
155
156
  Whether this PR's run opted into automatic escalation resolution by the escalation
156
157
  steward (issue #889). Derived from the subject row's durable `auto_resolve_escalations`
157
158
  flag, never a clobbered process variable.
159
+ merge:
160
+ type: object
161
+ additionalProperties: false
162
+ description: >-
163
+ The effective merge policy for this PR's run (issue #916). `requested` is the run's merge
164
+ intent — the canonical positive `autoMerge` process variable on its convergence loop
165
+ (falling back to the inverse of the legacy `convergeOnly` variable for a loop seeded
166
+ before `autoMerge` existed, and to the historical default `true` when the instance is
167
+ unreadable). `enabled` is the deployment's global `NANO_PR_AUTO_MERGE` switch.
168
+ `effective` is `requested && enabled && !humanApproval` — the run merges with no further
169
+ gate. `blockedBy` names WHY a requested merge will not proceed: `env` (the global switch
170
+ is off) or `human-approval` (parked at the merge-approval task); null when the merge is
171
+ effective or none was requested (converge-only).
172
+ required:
173
+ - requested
174
+ - enabled
175
+ - effective
176
+ - blockedBy
177
+ properties:
178
+ requested:
179
+ type: boolean
180
+ description: The run's merge intent (the loop's `autoMerge` variable, or the inverse of its legacy `convergeOnly`).
181
+ enabled:
182
+ type: boolean
183
+ description: The deployment's global NANO_PR_AUTO_MERGE switch.
184
+ effective:
185
+ type: boolean
186
+ description: requested && enabled && !humanApproval — the run merges with no further gate.
187
+ blockedBy:
188
+ type: string
189
+ nullable: true
190
+ enum: ["env", "human-approval", null]
191
+ description: Why a requested merge will not proceed; null when effective or converge-only.
158
192
  ActivePrList:
159
193
  type: object
160
194
  required:
@@ -41,7 +41,22 @@ test("the guide covers every capability the endpoint promises", async () => {
41
41
  // Submit a PR (converge vs. merge), submit an epic, answer escalations…
42
42
  assert(md.includes("start/convergence-loop"), "covers submitting a PR for convergence");
43
43
  assert(md.includes("autoMerge"), "documents the per-request merge control");
44
- assert(!md.includes("convergeOnly"), "does not expose the legacy merge control in agent guidance");
44
+ // Issue #916: the guide must document where to READ merge intent (the `/status` merge block) and
45
+ // that writing variables onto a running loop is not the way to change it. The legacy inverted
46
+ // `convergeOnly` name appears ONLY inside that sanctioned fallback explanation (a pre-`autoMerge`
47
+ // loop exposes its intent solely as `convergeOnly`) — never as a control the agent can set.
48
+ assert(md.includes('"merge": { "requested"'), "documents the /status merge block");
49
+ assert(md.includes("blockedBy"), "documents the env / human-approval distinction");
50
+ assert(
51
+ md.includes("no supported way to flip merge"),
52
+ "warns that writing variables onto a running loop is a no-op",
53
+ );
54
+ const legacyMentions = md.split("\n").filter((l) => l.includes("convergeOnly"));
55
+ assert(legacyMentions.length <= 1, `legacy convergeOnly appears only in the #916 fallback note; got: ${legacyMentions.join(" | ")}`);
56
+ assert(
57
+ legacyMentions.length === 1 && legacyMentions[0].includes("invert"),
58
+ "the one convergeOnly mention is the read-side fallback note (read it and invert it)",
59
+ );
45
60
  assert(md.includes("start/plan-fanout"), "covers submitting an epic");
46
61
  assert(md.includes("complete-user-task"), "covers answering escalations via the native user-task door");
47
62
  // …debug the system.
@@ -20,6 +20,6 @@ export default defineOperation("listActivePrs", async ({ req }, app) => {
20
20
  app.log.warn("listActivePrs rejected: missing/invalid shared secret");
21
21
  return { status: 401, body: { error: "unauthorized" } };
22
22
  }
23
- const [prs, deliveries] = await Promise.all([activePrs(app.data), activeDeliveries(app.data)]);
23
+ const [prs, deliveries] = await Promise.all([activePrs(app.data, app.engine), activeDeliveries(app.data)]);
24
24
  return { status: 200, body: { count: prs.length, prs, deliveries } };
25
25
  });
@@ -190,10 +190,20 @@ test("startConvergenceLoop defaults convergeOnly to false and does not truthy-co
190
190
  const stringy = captureApp();
191
191
  await startConvergenceLoop(input({ pr: "owner/repo#12", convergeOnly: "true" }), stringy.app);
192
192
  assertEquals(stringy.get(), false);
193
+ });
194
+ });
193
195
 
194
- const stringyAutoMerge = captureApp();
195
- await startConvergenceLoop(input({ pr: "owner/repo#15", autoMerge: "true" }), stringyAutoMerge.app);
196
- assertEquals(stringyAutoMerge.get(), false);
196
+ // Issue #916: `autoMerge: "true"` (a STRING) was silently FALSE at the door — the delegate read
197
+ // `body.autoMerge === true`, so a mistyped merge request ran review-only with no signal. The door
198
+ // now REJECTS a present-but-non-boolean `autoMerge` with a 400 at every start door.
199
+ test("startConvergenceLoop → 400 on a non-boolean autoMerge (issue #916)", async () => {
200
+ await withGithubOff(async () => {
201
+ const { app: capApp, get } = captureApp();
202
+ const res = await startConvergenceLoop(input({ pr: "owner/repo#15", autoMerge: "true" }), capApp);
203
+ const r = res as any;
204
+ assertEquals(r.status, 400);
205
+ assertEquals(typeof r.body.error, "string");
206
+ assertEquals(get(), undefined, "a rejected door never reaches the engine");
197
207
  });
198
208
  });
199
209
 
@@ -32,19 +32,25 @@ export default defineOperation("startConvergenceLoop", async ({ body }, app) =>
32
32
  const dependsOn = body.dependsOn ?? [];
33
33
  const maxRounds = clampRounds(body.maxRounds, MAX_ROUNDS);
34
34
  // Prefer the positive autoMerge form so an unchecked UI box is explicitly review-only. Legacy
35
- // callers that omit autoMerge retain the existing convergeOnly/global-default behavior.
35
+ // callers that omit autoMerge retain the existing convergeOnly/global-default behavior. A
36
+ // PRESENT-but-non-boolean `autoMerge` (e.g. the string "true") is a 400 (issue #916): it used to
37
+ // read as silently false (`=== true`), so a mistyped merge request ran review-only with no signal.
38
+ if ("autoMerge" in body && typeof body.autoMerge !== "boolean") {
39
+ app.log.warn("start-convergence rejected: autoMerge must be a boolean", { autoMerge: body.autoMerge });
40
+ return { status: 400, body: { error: "autoMerge must be a boolean (true or false)" } };
41
+ }
36
42
  const hasAutoMerge = typeof body.autoMerge === "boolean";
37
- const convergeOnly = hasAutoMerge ? body.autoMerge !== true : body.convergeOnly === true;
43
+ const autoMerge = hasAutoMerge ? body.autoMerge : body.convergeOnly === true ? false : undefined;
38
44
  // Opt-in escalation steward (issue #889): only when explicitly requested do this loop's
39
45
  // escalations become eligible for steward auto-resolution. Default off.
40
46
  const autoResolveEscalations = "autoResolveEscalations" in body && body.autoResolveEscalations === true;
41
- const result = await submitPr(app.data, app.engine, parsed, dependsOn, maxRounds, convergeOnly, null, false, autoResolveEscalations);
47
+ const result = await submitPr(app.data, app.engine, parsed, dependsOn, maxRounds, false, null, false, autoResolveEscalations, autoMerge);
42
48
  app.log.info("convergence loop started", {
43
49
  prKey: parsed.prKey,
44
50
  alreadyRunning: "alreadyRunning" in result && result.alreadyRunning === true,
45
51
  dependsOn: dependsOn.length,
46
52
  maxRounds,
47
- convergeOnly,
53
+ autoMerge: autoMerge ?? body.convergeOnly !== true,
48
54
  autoResolveEscalations,
49
55
  });
50
56
  return { status: 202, body: result };
@@ -207,3 +207,18 @@ test("humanApproval reaches the run only together with autoMerge", async () => {
207
207
  }
208
208
  });
209
209
  });
210
+
211
+ // ── issue #916: a present-but-non-boolean `autoMerge` is a 400 at the door, never a silent false ──
212
+
213
+ test("non-boolean autoMerge → 400 at the edge (never silently review-only)", async () => {
214
+ await withGithub(REPO, async () => {
215
+ const { app, started } = makeApp();
216
+ const res = (await startFeature(
217
+ input({ issue: `${REPO}#577`, ...GATED_BASE, converge: true, autoMerge: "true" }),
218
+ app,
219
+ )) as any;
220
+ assertEquals(res.status, 400);
221
+ assertEquals(typeof res.body.error, "string");
222
+ assertEquals(started.length, 0, "a rejected door never starts a run");
223
+ });
224
+ });
@@ -113,6 +113,12 @@ export default defineOperation("startFeature", async ({ body }, app) => {
113
113
  throw err;
114
114
  }
115
115
  const converge = "converge" in body && body.converge === true;
116
+ // A PRESENT-but-non-boolean `autoMerge` (e.g. the string "true") is a 400 (issue #916): it used to
117
+ // read as silently false (`=== true`), so a mistyped merge request ran review-only with no signal.
118
+ if ("autoMerge" in body && typeof body.autoMerge !== "boolean") {
119
+ app.log.warn("start-feature rejected: autoMerge must be a boolean", { autoMerge: body.autoMerge });
120
+ return { status: 400, body: { error: "autoMerge must be a boolean (true or false)" } };
121
+ }
116
122
  // Auto-merge is only meaningful as a follow-on to convergence; pin it off when converge is off so
117
123
  // the persisted row and the process variable can't disagree.
118
124
  const autoMerge = converge && "autoMerge" in body && body.autoMerge === true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.204.1",
3
+ "version": "0.205.0",
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",
@@ -103,6 +103,7 @@
103
103
  <nano:extend name="prUrl" type="string" />
104
104
  <nano:extend name="round" type="integer" />
105
105
  <nano:extend name="summary" type="string" optional="true" />
106
+ <nano:extend name="autoMerge" type="boolean" optional="true" />
106
107
  <nano:extend name="convergeOnly" type="boolean" optional="true" />
107
108
  <nano:extend name="abandonUrl" type="string" optional="true" />
108
109
  </nano:shape>
@@ -161,6 +162,7 @@
161
162
  <zeebe:input source="=reviewWaitTimeout" target="guardReviewWaitTimeout" />
162
163
  <zeebe:input source="=humanApproval" target="guardHumanApproval" />
163
164
  <zeebe:input source="=convergeOnly" target="guardConvergeOnly" />
165
+ <zeebe:input source="=autoMerge" target="guardAutoMerge" />
164
166
  <zeebe:input source="=reviewStale" target="guardReviewStale" />
165
167
  <zeebe:input source="=roundEntryHead" target="guardRoundEntryHead" />
166
168
  <zeebe:input source="=abandonUrl" target="guardAbandonUrl" />
@@ -182,6 +184,7 @@
182
184
  <zeebe:output source="=guardReviewWaitTimeout" target="reviewWaitTimeout" />
183
185
  <zeebe:output source="=(guardHumanApproval = true) or (humanApproval = true)" target="humanApproval" />
184
186
  <zeebe:output source="=guardConvergeOnly" target="convergeOnly" />
187
+ <zeebe:output source="=guardAutoMerge" target="autoMerge" />
185
188
  <zeebe:output source="=guardReviewStale" target="reviewStale" />
186
189
  <zeebe:output source="=guardRoundEntryHead" target="roundEntryHead" />
187
190
  <zeebe:output source="=guardAbandonUrl" target="abandonUrl" />
@@ -255,6 +258,7 @@
255
258
  <zeebe:input source="=reviewWaitTimeout" target="guardReviewWaitTimeout" />
256
259
  <zeebe:input source="=humanApproval" target="guardHumanApproval" />
257
260
  <zeebe:input source="=convergeOnly" target="guardConvergeOnly" />
261
+ <zeebe:input source="=autoMerge" target="guardAutoMerge" />
258
262
  <zeebe:input source="=reviewStale" target="guardReviewStale" />
259
263
  <zeebe:input source="=roundEntryHead" target="guardRoundEntryHead" />
260
264
  <zeebe:input source="=abandonUrl" target="guardAbandonUrl" />
@@ -277,6 +281,7 @@
277
281
  <zeebe:output source="=guardReviewWaitTimeout" target="reviewWaitTimeout" />
278
282
  <zeebe:output source="=(guardHumanApproval = true) or (humanApproval = true)" target="humanApproval" />
279
283
  <zeebe:output source="=guardConvergeOnly" target="convergeOnly" />
284
+ <zeebe:output source="=guardAutoMerge" target="autoMerge" />
280
285
  <zeebe:output source="=guardReviewStale" target="reviewStale" />
281
286
  <zeebe:output source="=guardRoundEntryHead" target="roundEntryHead" />
282
287
  <zeebe:output source="=guardAbandonUrl" target="abandonUrl" />
@@ -474,6 +479,7 @@
474
479
  <zeebe:ioMapping>
475
480
  <zeebe:input source="=prKey" target="prKey" />
476
481
  <zeebe:output source="=0" target="huskRetries" />
482
+ <zeebe:output source="=if mergeDecision = &#34;approve&#34; then false else humanApproval" target="humanApproval" />
477
483
  </zeebe:ioMapping>
478
484
  </bpmn:extensionElements>
479
485
  <bpmn:incoming>f_needApproval</bpmn:incoming>
@@ -563,6 +569,7 @@
563
569
  <zeebe:input source="=reviewWaitTimeout" target="guardReviewWaitTimeout" />
564
570
  <zeebe:input source="=humanApproval" target="guardHumanApproval" />
565
571
  <zeebe:input source="=convergeOnly" target="guardConvergeOnly" />
572
+ <zeebe:input source="=autoMerge" target="guardAutoMerge" />
566
573
  <zeebe:input source="=reviewStale" target="guardReviewStale" />
567
574
  <zeebe:input source="=roundEntryHead" target="guardRoundEntryHead" />
568
575
  <zeebe:input source="=abandonUrl" target="guardAbandonUrl" />
@@ -582,6 +589,7 @@
582
589
  <zeebe:output source="=guardReviewWaitTimeout" target="reviewWaitTimeout" />
583
590
  <zeebe:output source="=(guardHumanApproval = true) or (humanApproval = true)" target="humanApproval" />
584
591
  <zeebe:output source="=guardConvergeOnly" target="convergeOnly" />
592
+ <zeebe:output source="=guardAutoMerge" target="autoMerge" />
585
593
  <zeebe:output source="=guardReviewStale" target="reviewStale" />
586
594
  <zeebe:output source="=guardRoundEntryHead" target="roundEntryHead" />
587
595
  <zeebe:output source="=guardAbandonUrl" target="abandonUrl" />
@@ -126,3 +126,10 @@ debugging. **Re-fetch it every session.** For one section, use
126
126
  - **Submissions are idempotent.** Re-submitting the same work must not double-dispatch.
127
127
  - **Escalations are for humans.** Surface them with options; don't silently answer design or
128
128
  product decisions.
129
+ - **Merge intent is a read, not a write.** Every `/status` PR row carries a `merge` block —
130
+ `requested` (the run's `autoMerge`), `enabled` (the deployment's `NANO_PR_AUTO_MERGE`),
131
+ `effective`, and `blockedBy` (`"env"` | `"human-approval"` | `null`). Read it there; never
132
+ write variables onto a running loop to change it. Such writes are unsupported and
133
+ timing-dependent, not reliable no-ops — one landing while the loop awaits merge approval
134
+ reaches `pr.finalize` unrestored and can change whether merging starts. Cancel and re-submit
135
+ instead.
@@ -3,8 +3,8 @@
3
3
  // Reached only via the `converge?` gateway's true branch (converge requested AND the agent opened a
4
4
  // keyed PR). Reuses `submitPr` — the SAME enrollment the epic waves and the standalone
5
5
  // `startConvergenceLoop` action use — so a feature run's PR gets identical review-round + merge
6
- // behaviour with no duplicated machinery. `autoMerge` maps to `submitPr`'s `convergeOnly`
7
- // (inverted): auto-merge → drive the merge-loop; otherwise stop at `converged`.
6
+ // behaviour with no duplicated machinery. The run's positive `autoMerge` is threaded to `submitPr`
7
+ // as-is (issue #916): auto-merge → drive the merge-loop; otherwise stop at `converged`.
8
8
  //
9
9
  // The feature-run's OWN process ends here; the PR's live convergence/merge state lives on the
10
10
  // `pull_requests` row keyed by `pr_key`, which the feature page links to. We therefore leave the
@@ -40,8 +40,10 @@ const handler: AppJobHandler<In, Record<string, never>> = async (job, app) => {
40
40
  // (edge 1) heals by re-enrolling when the PR row is still missing (idempotent, mirroring the
41
41
  // promotion-PR re-enroll in `pollPromotion`). The reorder is safe: `submitPr` is idempotent on the PR
42
42
  // key (it early-returns `alreadyRunning` for a non-terminal PR), and a `converging` row is owned by
43
- // edge (1), never by the handoff edge. `convergeOnly` is the inverse of auto-merge: converge-only
44
- // stops at `converged`; auto-merge lets the merge-loop drive the merge.
43
+ // edge (1), never by the handoff edge.
44
+ //
45
+ // `autoMerge` is the canonical positive merge intent (issue #916): converge-only stops at
46
+ // `converged`; auto-merge lets the merge-loop drive the merge.
45
47
  await featureRuns(app.data).update(featureKey, {
46
48
  status: "converging",
47
49
  pr_key: parsed.prKey,
@@ -51,11 +53,11 @@ const handler: AppJobHandler<In, Record<string, never>> = async (job, app) => {
51
53
  // read the durable flag off the `feature_runs` row (the source of truth) and hand it to `submitPr`.
52
54
  const featureRow = await featureRuns(app.data).get(featureKey);
53
55
  const autoResolve = featureRow?.auto_resolve_escalations === 1;
54
- await submitPr(app.data, app.engine, parsed, [], MAX_ROUNDS, !autoMerge, featureKey, humanApproval, autoResolve);
56
+ await submitPr(app.data, app.engine, parsed, [], MAX_ROUNDS, false, featureKey, humanApproval, autoResolve, autoMerge);
55
57
  app.log.info("converge-feature: enrolled PR into convergence loop", {
56
58
  featureKey,
57
59
  prKey: parsed.prKey,
58
- convergeOnly: !autoMerge,
60
+ autoMerge,
59
61
  humanApproval,
60
62
  autoResolveEscalations: autoResolve,
61
63
  });
@@ -217,7 +217,7 @@ const handler: AppJobHandler<In, ConnectorDispatchResult> = async (job, app) =>
217
217
  // completion can clobber that variable, so the durable row is the only trustworthy source
218
218
  // (issue #889 review). Fails closed (false) when no run row matches.
219
219
  const autoResolve = await resolveAutoResolveEscalations(app.data, job.processInstanceKey != null ? String(job.processInstanceKey) : null);
220
- await submitPr(app.data, app.engine, converge.parsed, converge.dependsOn, MAX_ROUNDS, converge.convergeOnly, dedupeKey, false, autoResolve);
220
+ await submitPr(app.data, app.engine, converge.parsed, converge.dependsOn, MAX_ROUNDS, converge.convergeOnly, dedupeKey, false, autoResolve, !converge.convergeOnly);
221
221
  app.log.info("delivery-connector: enrolled PR into convergence loop", {
222
222
  target,
223
223
  dedupeKey,
@@ -103,3 +103,38 @@ test("finalize with convergeOnly absent hands off to the merge-loop when auto-me
103
103
  assertEquals(pr.status, "waiting_deps");
104
104
  });
105
105
  });
106
+
107
+ // Issue #916: `autoMerge` is the CANONICAL positive merge-intent variable on the convergence loop.
108
+ // `pr.finalize` reads it first and falls back to the legacy inverted `convergeOnly` only when
109
+ // `autoMerge` is absent — so an in-flight loop seeded before `autoMerge` existed keeps its behaviour.
110
+ test("finalize with autoMerge=false rests at converged even when the legacy convergeOnly mirror is absent", async () => {
111
+ await withRetroOff(async () => {
112
+ const { app, stores, createdProcesses } = fakeApp();
113
+ // biome-ignore lint/plugin: constructing the framework's job envelope for the handler under test
114
+ await handler({ variables: { ...BASE_VARS, autoMerge: false } } as never, app as never);
115
+ assertEquals(createdProcesses.includes(MERGE_PROCESS_ID), false);
116
+ assertEquals(stores.pull_requests[0].status, "converged");
117
+ });
118
+ });
119
+
120
+ test("finalize with autoMerge=true hands off to the merge-loop (canonical positive variable)", async () => {
121
+ await withRetroOff(async () => {
122
+ const { app, stores, createdProcesses } = fakeApp();
123
+ // biome-ignore lint/plugin: constructing the framework's job envelope for the handler under test
124
+ await handler({ variables: { ...BASE_VARS, autoMerge: true } } as never, app as never);
125
+ assertEquals(createdProcesses.includes(MERGE_PROCESS_ID), true);
126
+ assertEquals(stores.pull_requests[0].status, "waiting_deps");
127
+ });
128
+ });
129
+
130
+ test("finalize prefers autoMerge over the legacy convergeOnly mirror when they disagree", async () => {
131
+ await withRetroOff(async () => {
132
+ const { app, stores, createdProcesses } = fakeApp();
133
+ // A contradictory pair (autoMerge=true, convergeOnly=true) can only arise from a hand-seeded or
134
+ // half-migrated instance: the positive canonical variable wins.
135
+ // biome-ignore lint/plugin: constructing the framework's job envelope for the handler under test
136
+ await handler({ variables: { ...BASE_VARS, autoMerge: true, convergeOnly: true } } as never, app as never);
137
+ assertEquals(createdProcesses.includes(MERGE_PROCESS_ID), true);
138
+ assertEquals(stores.pull_requests[0].status, "waiting_deps");
139
+ });
140
+ });
@@ -22,7 +22,7 @@ const handler: AppJobHandler<In> = async (job, app) => {
22
22
  // `summary` is left undefined when absent so the write boundary omits it: the
23
23
  // nullable `rounds.summary` stays NULL and `pull_requests.outcome` is untouched
24
24
  // rather than being coerced to "".
25
- const { prKey, repo, prNumber, prUrl, round, summary, abandonUrl, convergeOnly } = job.variables;
25
+ const { prKey, repo, prNumber, prUrl, round, summary, abandonUrl, convergeOnly, autoMerge } = job.variables;
26
26
  const now = new Date().toISOString();
27
27
 
28
28
  // Heal a missing FK parent (engine/app.db desync) before the child `rounds` insert.
@@ -52,10 +52,15 @@ const handler: AppJobHandler<In> = async (job, app) => {
52
52
  // with no process behind it, and `submitPr` refuses to restart it (only `cancel` recovers). On
53
53
  // failure we leave the PR terminal as `converged` so a human/operator can (re)start merge.
54
54
  //
55
- // A per-request `convergeOnly` override forces the review-only path for this PR regardless of the
56
- // global auto-merge default — the PR rests at `converged` and is never handed to the merge-loop.
55
+ // Merge intent (issue #916): `autoMerge` is the CANONICAL positive variable — read it first and
56
+ // fall back to the legacy inverted `convergeOnly` only when `autoMerge` is absent, so an in-flight
57
+ // loop seeded before `autoMerge` existed keeps its behaviour. A review-only intent
58
+ // (`autoMerge:false`, or legacy `convergeOnly:true`) forces the review-only path for this PR
59
+ // regardless of the global auto-merge default — the PR rests at `converged` and is never handed
60
+ // to the merge-loop.
61
+ const mergeRequested = autoMerge === true || (autoMerge == null && convergeOnly !== true);
57
62
  let status = "converged";
58
- if (AUTO_MERGE && convergeOnly !== true) {
63
+ if (AUTO_MERGE && mergeRequested) {
59
64
  try {
60
65
  const { mergeProcessKey } = await startMerge(app.data, app.engine, {
61
66
  repo,