@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.
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 {
@@ -1592,6 +1698,77 @@ export async function maybeEnsureFreshHeadRun(
1592
1698
  * EVERY branch first runs {@link advanceIfTerminalOutOfBand} — one shared "is this PR already
1593
1699
  * terminal (merged/closed) out-of-band?" pre-check — so no merge stage can silently strand when a PR
1594
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
+
1595
1772
  export async function pollMerges(data: DataLayer, engine: EngineClient, token: string) {
1596
1773
  // 1) Dependencies merged?
1597
1774
  for (const pr of await prs(data).find({ status: "waiting_deps" })) {
@@ -1625,7 +1802,12 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
1625
1802
  for (const pr of await prs(data).find({ status: "waiting_merge" })) {
1626
1803
  const { repo, number, pr_key: prKey } = pr;
1627
1804
  try {
1628
- 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 });
1629
1811
  if (st === null) continue; // no transport → skip this PR (others may still advance)
1630
1812
  // Out-of-band terminal (merged/closed) → the shared pre-check publishes `merge-ready {ready}`,
1631
1813
  // routing through `gw-mergeable → attempt-merge` whose idempotent already-merged check completes
@@ -1633,6 +1815,39 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
1633
1815
  // (#342/#350). Reuse the `st` we just read so we don't double-fetch. This is the proven terminal
1634
1816
  // path the whole class (#368) now shares.
1635
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
+ }
1636
1851
  // Load the repo's merge protocol ONCE per PR iteration and pass it into the classifier so the
1637
1852
  // protocol-aware backstop (#392) can gate a red DECLARED-required check even when GitHub reports
1638
1853
  // the PR as UNSTABLE. The same handle is reused by the frugal-CI fresh-head-run branch below.
@@ -1682,6 +1897,25 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
1682
1897
  }
1683
1898
  continue; // GitHub still computing / checks pending
1684
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
+ }
1685
1919
  if (verdict === "ready") {
1686
1920
  const lane = await mergeLaneDecisionForPr(data, prKey);
1687
1921
  if (lane?.isHeld) {
@@ -1701,6 +1935,16 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
1701
1935
  // fix-ci task appends it to the agent prompt so the agent knows which gates to green.
1702
1936
  failingChecks: st.failingChecks,
1703
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,
1704
1948
  },
1705
1949
  });
1706
1950
  console.log(`[poller] mergeable=${verdict} (${st.mergeStateStatus}) -> ${prKey}`);
@@ -2784,13 +3028,16 @@ export async function pollFeatureDelivery(
2784
3028
  parsed,
2785
3029
  [],
2786
3030
  MAX_ROUNDS,
2787
- run.auto_merge !== 1,
3031
+ false,
2788
3032
  run.feature_key,
2789
3033
  run.human_approval === 1,
2790
3034
  // Thread the run's escalation-steward opt-in (issue #889 review) so a re-enrolled PR
2791
3035
  // inherits the feature run's enrolment — and a partial-enrollment repair never overwrites
2792
3036
  // the PR row's previously persisted opt-in with the `false` default.
2793
3037
  run.auto_resolve_escalations === 1,
3038
+ // Merge intent (issue #916): the canonical positive `autoMerge`, derived from the run's
3039
+ // durable `auto_merge` flag (mirrors `converge-feature`).
3040
+ run.auto_merge === 1,
2794
3041
  );
2795
3042
  }
2796
3043
  }
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
@@ -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.1",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",