@nanobpm/nano-workforce 0.189.2 → 0.190.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.
@@ -0,0 +1,63 @@
1
+ -- Feature-run read model: exclude a MID-HANDOFF `opened` run from the `list_bucket` / `ack_open`
2
+ -- DISMISSABLE-terminal predicate (issue #808 follow-up). SUPERSEDES 111's VIEW body — every DERIVED
3
+ -- column is emitted VERBATIM from the ONE declaration in app/featureReadModel.ts, now with `list_bucket`/
4
+ -- `ack_open` driven by the `featureDismissableTerminal` PREDICATE instead of a plain status set. 111 is a
5
+ -- MERGED, IMMUTABLE migration — never edited; this is a NEW migration superseding its VIEW body (the same
6
+ -- pattern by which 111 superseded 099, 099 superseded 081, 081 superseded 076).
7
+ --
8
+ -- WHY. 111 (issue #808) made EVERY `opened` row dismissable-terminal so a finished raise-only run stops
9
+ -- wedging in Active. But `record-feature` writes `status="opened"` for a converge-REQUESTED run TOO,
10
+ -- BEFORE the `gw-converge` gateway routes it into `converge-feature` (which flips it to `converging`).
11
+ -- In that in-between window the row reads `opened` while the feature engine instance is still ACTIVE, so
12
+ -- 111's "any opened is dismissable" rule offered Dismiss on an in-flight convergence handoff — and a
13
+ -- premature ack there stamps `acknowledged_at`, which would later drag a still-converging run to History.
14
+ -- This is the SAME mid-flight failure mode the epic read model already guards against by excluding its
15
+ -- own `converging` delivery (app/planReadModel.ts / app/listBucket.ts). The feature row distinguishes the
16
+ -- transient from a finished run by its own columns: a converge-requested run that actually handed off
17
+ -- carries `converge=1 AND pr_key IS NOT NULL` — exactly the window to exclude. Every other `opened` stays
18
+ -- dismissable: a raise-only run (`converge=0`), or a keyless `opened` that never satisfied the gateway's
19
+ -- `prKey != null` and fell through to `End` (`pr_key IS NULL`).
20
+ --
21
+ -- Every DERIVED column below is emitted VERBATIM from `featureReadModel.sqlSelectFor(col,
22
+ -- { baseAlias: "fr" })` (which ALSO drives the runtime TS via `fnFor`); the `stage`/`stage_state`/
23
+ -- `stage_skipped`/`attention` bodies are UNCHANGED from 111 and only `list_bucket`/`ack_open` gain the
24
+ -- mid-handoff exclusion. The drift guard (app/featureReadModel.test.ts) fails if this file stops matching
25
+ -- the declaration, and `assertReadModelParity` proves the SQL and TS lowerings agree.
26
+ --
27
+ -- SEMANTICS unchanged from 111 EXCEPT the two mid-handoff-narrowed columns: the status-classifying
28
+ -- columns still read the terminal-folded `feature_runs__tracking.derived_status`; base columns stay
29
+ -- aliased identity pass-throughs; `acknowledged_at` still passes through so the read model can classify
30
+ -- on it. The STAGE derivations are untouched, so a mid-handoff `opened` still renders `PR open`.
31
+ --
32
+ -- Forward-only VIEW redefinition (DROP then CREATE). `feature_runs__tracking` is the managed VIEW urban
33
+ -- provisions at mount; SQLite does not validate a view body at CREATE time, so this migration (which
34
+ -- runs before that mount) is created fine and resolves once the managed VIEW exists. The runner wraps
35
+ -- each file in its own transaction, so this file must NOT contain BEGIN/COMMIT. Numbered after 111.
36
+
37
+ DROP VIEW IF EXISTS feature_read_model;
38
+
39
+ CREATE VIEW feature_read_model AS
40
+ SELECT
41
+ fr.feature_key AS feature_key,
42
+ fr.repo AS repo,
43
+ fr.issue_number AS issue_number,
44
+ fr.issue_url AS issue_url,
45
+ fr.title AS title,
46
+ fr.base_branch AS base_branch,
47
+ fr.status AS status,
48
+ fr.process_key AS process_key,
49
+ fr.pr_key AS pr_key,
50
+ fr.converge AS converge,
51
+ fr.auto_merge AS auto_merge,
52
+ fr.outcome AS outcome,
53
+ fr.delivery_label AS delivery_label,
54
+ fr.acknowledged_at AS acknowledged_at,
55
+ fr.created_at AS created_at,
56
+ fr.updated_at AS updated_at,
57
+ CASE WHEN COALESCE((COALESCE(("fr"."derived_status" = 'merged'), 0) OR COALESCE(("fr"."derived_status" = 'converged'), 0) OR COALESCE(("fr"."derived_status" = 'blocked'), 0) OR COALESCE(("fr"."derived_status" = 'failed'), 0) OR COALESCE(("fr"."derived_status" = 'skipped'), 0) OR COALESCE(("fr"."derived_status" = 'abandoned'), 0)), 0) THEN 'Done' WHEN COALESCE(("fr"."derived_status" = 'converging'), 0) THEN 'Converging' WHEN COALESCE((COALESCE(("fr"."pr_key" <> ''), 0) OR COALESCE(("fr"."derived_status" = 'opened'), 0)), 0) THEN 'PR open' WHEN COALESCE((COALESCE(("fr"."derived_status" = 'running'), 0) OR COALESCE(("fr"."derived_status" = 'escalated'), 0) OR COALESCE(("fr"."derived_status" = 'awaiting_operator'), 0)), 0) THEN 'Implementing' ELSE 'Requested' END AS stage,
58
+ CASE WHEN COALESCE((COALESCE(("fr"."derived_status" = 'merged'), 0) OR COALESCE(("fr"."derived_status" = 'converged'), 0)), 0) THEN 'ok' WHEN COALESCE(("fr"."derived_status" = 'blocked'), 0) THEN 'blocked' WHEN COALESCE((COALESCE(("fr"."derived_status" = 'failed'), 0) OR COALESCE(("fr"."derived_status" = 'skipped'), 0) OR COALESCE(("fr"."derived_status" = 'abandoned'), 0)), 0) THEN 'failed' ELSE NULL END AS stage_state,
59
+ CASE WHEN (NOT COALESCE("fr"."converge", 0)) THEN 'Converging Merging' WHEN (NOT COALESCE("fr"."auto_merge", 0)) THEN 'Merging' ELSE '' END AS stage_skipped,
60
+ CASE WHEN EXISTS (SELECT 1 FROM "user_tasks" AS "__urban_proj_0" WHERE COALESCE((COALESCE(("__urban_proj_0"."subject_type" = 'feature'), 0) AND COALESCE(("__urban_proj_0"."subject_key" = "fr"."feature_key"), 0) AND COALESCE(("__urban_proj_0"."element_id" = 'feature-blocked'), 0)), 0)) THEN 'blocked' WHEN EXISTS (SELECT 1 FROM "user_tasks" AS "__urban_proj_0" WHERE COALESCE((COALESCE(("__urban_proj_0"."subject_type" = 'feature'), 0) AND COALESCE(("__urban_proj_0"."subject_key" = "fr"."feature_key"), 0) AND COALESCE(("__urban_proj_0"."element_id" = 'feature-escalation'), 0)), 0)) THEN '⚠' ELSE NULL END AS attention,
61
+ CASE WHEN COALESCE((COALESCE((COALESCE((COALESCE(("fr"."derived_status" = 'merged'), 0) OR COALESCE(("fr"."derived_status" = 'converged'), 0) OR COALESCE(("fr"."derived_status" = 'blocked'), 0) OR COALESCE(("fr"."derived_status" = 'failed'), 0) OR COALESCE(("fr"."derived_status" = 'skipped'), 0) OR COALESCE(("fr"."derived_status" = 'abandoned'), 0) OR COALESCE(("fr"."derived_status" = 'opened'), 0)), 0) AND (NOT COALESCE(COALESCE((COALESCE(("fr"."derived_status" = 'opened'), 0) AND COALESCE(("fr"."converge" = 1), 0) AND COALESCE(("fr"."pr_key" = "fr"."pr_key"), 0)), 0), 0))), 0) AND COALESCE(("fr"."acknowledged_at" = "fr"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket,
62
+ CASE WHEN COALESCE((COALESCE((COALESCE((COALESCE(("fr"."derived_status" = 'merged'), 0) OR COALESCE(("fr"."derived_status" = 'converged'), 0) OR COALESCE(("fr"."derived_status" = 'blocked'), 0) OR COALESCE(("fr"."derived_status" = 'failed'), 0) OR COALESCE(("fr"."derived_status" = 'skipped'), 0) OR COALESCE(("fr"."derived_status" = 'abandoned'), 0) OR COALESCE(("fr"."derived_status" = 'opened'), 0)), 0) AND (NOT COALESCE(COALESCE((COALESCE(("fr"."derived_status" = 'opened'), 0) AND COALESCE(("fr"."converge" = 1), 0) AND COALESCE(("fr"."pr_key" = "fr"."pr_key"), 0)), 0), 0))), 0) AND (NOT COALESCE(COALESCE(("fr"."acknowledged_at" = "fr"."acknowledged_at"), 0), 0))), 0) THEN 1 ELSE 0 END AS ack_open
63
+ FROM feature_runs__tracking fr;
@@ -113,7 +113,7 @@ The closed set (extensible only by a deliberate ADR/PR, never by graph authors):
113
113
  > PR back to `converging`. `submitPr`'s own `prKey` idempotency additionally makes a resumed re-perform
114
114
  > double-safe on a still-live row. This retires the manual `land-*` human gate whose only job was "go run convergence
115
115
  > yourself" — the canonical shape is now `agent (opens PR) → connector[converge-merge] →
116
- > wait[pr, merged]` with no human node. The payload is `{ pr, convergeOnly?, dependsOn? }`; the `pr`
116
+ > wait[pr, merged]` with no human node. The payload is `{ pr, autoMerge?, dependsOn? }`; the `pr`
117
117
  > may be a literal `owner/repo#N`, a `<node>.pr` fact reference late-bound from an upstream `agent`
118
118
  > node's emitted `pr` fact, or omitted to auto-bind the single incoming `pr` fact (issue #548 —
119
119
  > shipped: the connector resolves it via `resolveConvergePr`, and the `wait[pr]` target is late-bound
@@ -73,7 +73,7 @@ The body is flat. Fields:
73
73
  | field | type | meaning |
74
74
  |---|---|---|
75
75
  | `pr` (or `url`) | string | the PR — `owner/repo#123` or a full PR URL. Required. |
76
- | `convergeOnly` | boolean | **`true` = review only.** The PR stops at `converged` and is **never** handed to the merge loop, even when `NANO_PR_AUTO_MERGE` is on. Omit / `false` = converge **then merge**. |
76
+ | `autoMerge` | boolean | **Positive per-request setting.** `false` = review only, stopping at `converged`, regardless of the global default; `true` = converge **then merge** when `NANO_PR_AUTO_MERGE` is enabled. |
77
77
  | `maxRounds` | integer | per-submit cap before escalating (clamped 1–100; default from `NANO_PR_MAX_ROUNDS`, 20). |
78
78
  | `dependsOn` | string[] | other `prKey`s that must land before this one merges (merge-loop barrier). |
79
79
 
@@ -84,12 +84,12 @@ The body is flat. Fields:
84
84
  # or is only after a review pass):
85
85
  curl -sS -X POST __BASE__/actions/start/convergence-loop \
86
86
  -H 'content-type: application/json' \
87
- -d '{ "pr": "owner/repo#123", "convergeOnly": true }'
87
+ -d '{ "pr": "owner/repo#123", "autoMerge": false }'
88
88
 
89
89
  # Converge then merge, with a dependency barrier and a tighter round cap:
90
90
  curl -sS -X POST __BASE__/actions/start/convergence-loop \
91
91
  -H 'content-type: application/json' \
92
- -d '{ "pr": "owner/repo#42", "maxRounds": 8, "dependsOn": ["owner/repo#40"] }'
92
+ -d '{ "pr": "owner/repo#42", "autoMerge": true, "maxRounds": 8, "dependsOn": ["owner/repo#40"] }'
93
93
  ```
94
94
 
95
95
  Submitting is **idempotent on the PR key** — re-POSTing the same PR refreshes the
@@ -311,7 +311,7 @@ the answer becomes the agent's next-round context.
311
311
  ```
312
312
  submit ──► convergence-loop
313
313
  round (senior:pr-review) ──► addressed ──► wait review-ready ─┐
314
- ├─ converged ──► finalize ──► merge-loop (unless convergeOnly)
314
+ ├─ converged ──► finalize ──► merge-loop (when autoMerge and NANO_PR_AUTO_MERGE are enabled)
315
315
  └─ needs_input/blocked ──► escalate ──► wait-answer userTask (task inbox)
316
316
  merge-loop: wait deps ─► arm merge ─► (queue-aware) merge / land
317
317
  blocked (CI red) ─► senior:fix-ci ─► retry conflict ─► senior:rebase ─► retry
@@ -774,13 +774,13 @@ only job was "go run convergence yourself".
774
774
  - **`converge-merge`** — the **unit-level** land: drive review convergence **and then the merge
775
775
  loop**, landing the PR onto **its own base branch** (for a unit inside an epic that base is the
776
776
  epic integration branch, never `main` directly — ADR 0003 base-branch admission). Equivalent to a
777
- submit with `convergeOnly: false`.
777
+ submit with `autoMerge: true`.
778
778
  - **`merge-main`** — the **graph-level** top-level land: the second level of the two-level merge
779
779
  (ADR 0006 §3), landing the graph/epic **integration** PR onto **`main`**. Dispatch-identical to
780
780
  `converge-merge` (both enroll + merge); the distinction is the *level*, kept a first-class literal
781
781
  so the two levels are authored explicitly rather than left emergent.
782
782
  - **`converge`** — **converge-only**: drive review convergence and stop at `converged`, never
783
- handing off to the merge loop (equivalent to `convergeOnly: true`).
783
+ handing off to the merge loop (equivalent to `autoMerge: false`).
784
784
 
785
785
  > **converge/merge are cell POLICY, not raw nodes.** A raw `senior:converge` / `senior:merge`
786
786
  > **agent** job is **not expressible** — the compiler rejects it (`raw-converge-node`). Express
@@ -788,10 +788,10 @@ only job was "go run convergence yourself".
788
788
  > policy flags (`merge` requires `converge`), or, for enrolling an already-open PR, the
789
789
  > `connector` targets above (ADR 0006 §3 / S5).
790
790
 
791
- **Payload:** `{ pr: "owner/repo#123", convergeOnly?: boolean, dependsOn?: string[] }`. `pr` is
791
+ **Payload:** `{ pr: "owner/repo#123", autoMerge?: boolean, dependsOn?: string[] }`. `pr` is
792
792
  required (a literal `owner/repo#N`, identical to how a `wait: pr` node targets a known PR).
793
- `convergeOnly` defaults from the target and may be overridden per-node; `dependsOn` is unioned
794
- into the PR's merge-stage dependency set. The enrollment is idempotent (the connector's
793
+ `autoMerge` defaults from the target and may be overridden per-node; `dependsOn` is unioned into
794
+ the PR's merge-stage dependency set. The enrollment is idempotent (the connector's
795
795
  at-least-once dedupe fence **plus** `submitPr`'s own `prKey` idempotency), so a graph resume /
796
796
  redelivery never double-enrolls.
797
797
 
package/openapi.yaml CHANGED
@@ -1823,11 +1823,13 @@ components:
1823
1823
  type: integer
1824
1824
  minimum: 1
1825
1825
  description: Values above 100 are accepted and clamped to 100 by the delegate.
1826
+ autoMerge:
1827
+ type: boolean
1828
+ description: Preferred per-request setting. When false, stop at `converged`; when true, run the merge-loop after convergence only if `NANO_PR_AUTO_MERGE` is enabled. When omitted, the global default applies.
1826
1829
  convergeOnly:
1827
1830
  type: boolean
1828
- description: When true, run convergence only and stop at `converged` — the PR is never
1829
- handed to the merge-loop even if auto-merge is on globally (`NANO_PR_AUTO_MERGE`). A
1830
- per-request review-only override; defaults to false (the global auto-merge default applies).
1831
+ deprecated: true
1832
+ description: "Deprecated negative alias. If `autoMerge` is omitted, `true` means `autoMerge: false`; `false` means `autoMerge: true` (still gated by `NANO_PR_AUTO_MERGE`). Prefer `autoMerge`."
1831
1833
  ConvergenceStartByUrl:
1832
1834
  type: object
1833
1835
  additionalProperties: false
@@ -1845,11 +1847,13 @@ components:
1845
1847
  type: integer
1846
1848
  minimum: 1
1847
1849
  description: Values above 100 are accepted and clamped to 100 by the delegate.
1850
+ autoMerge:
1851
+ type: boolean
1852
+ description: Preferred per-request setting. When false, stop at `converged`; when true, run the merge-loop after convergence only if `NANO_PR_AUTO_MERGE` is enabled. When omitted, the global default applies.
1848
1853
  convergeOnly:
1849
1854
  type: boolean
1850
- description: When true, run convergence only and stop at `converged` — the PR is never
1851
- handed to the merge-loop even if auto-merge is on globally (`NANO_PR_AUTO_MERGE`). A
1852
- per-request review-only override; defaults to false (the global auto-merge default applies).
1855
+ deprecated: true
1856
+ description: "Deprecated negative alias. If `autoMerge` is omitted, `true` means `autoMerge: false`; `false` means `autoMerge: true` (still gated by `NANO_PR_AUTO_MERGE`). Prefer `autoMerge`."
1853
1857
  PlanStart:
1854
1858
  description: The start-plan-fanout request body. Names the target issue by EXACTLY ONE of
1855
1859
  `issue` (an `owner/repo#123` reference) or `url` (a bare issue URL), plus a REQUIRED
@@ -3435,8 +3439,8 @@ components:
3435
3439
  autoMerge:
3436
3440
  type: boolean
3437
3441
  description: >-
3438
- When true (and `converge` is true), drive the merge-loop after convergence — mirrors
3439
- `convergeOnly`/`NANO_PR_AUTO_MERGE` inverted. When false the run stops at `converged`.
3442
+ When true (and `converge` is true), drive the merge-loop after convergence only when
3443
+ `NANO_PR_AUTO_MERGE` is enabled. When false the run stops at `converged`.
3440
3444
  Moot when `converge` is false.
3441
3445
  confirmDefaultBase:
3442
3446
  type: boolean
@@ -4573,9 +4577,13 @@ paths:
4573
4577
  type: integer
4574
4578
  minimum: 1
4575
4579
  description: Values above 100 are accepted and clamped to 100 by the delegate.
4580
+ autoMerge:
4581
+ type: boolean
4582
+ description: Preferred per-request setting. When false, stop at `converged`; when true, run the merge-loop after convergence only if `NANO_PR_AUTO_MERGE` is enabled. When omitted, the global default applies.
4576
4583
  convergeOnly:
4577
4584
  type: boolean
4578
- description: When true, run convergence only and stop at `converged` — the PR is never handed to the merge-loop even if auto-merge is on globally (`NANO_PR_AUTO_MERGE`). A per-request review-only override; defaults to false (the global auto-merge default applies).
4585
+ deprecated: true
4586
+ description: 'Deprecated negative alias. If `autoMerge` is omitted, `true` means `autoMerge: false`; `false` means `autoMerge: true` (still gated by `NANO_PR_AUTO_MERGE`). Prefer `autoMerge`.'
4579
4587
  - type: object
4580
4588
  additionalProperties: false
4581
4589
  required:
@@ -4592,9 +4600,13 @@ paths:
4592
4600
  type: integer
4593
4601
  minimum: 1
4594
4602
  description: Values above 100 are accepted and clamped to 100 by the delegate.
4603
+ autoMerge:
4604
+ type: boolean
4605
+ description: Preferred per-request setting. When false, stop at `converged`; when true, run the merge-loop after convergence only if `NANO_PR_AUTO_MERGE` is enabled. When omitted, the global default applies.
4595
4606
  convergeOnly:
4596
4607
  type: boolean
4597
- description: When true, run convergence only and stop at `converged` — the PR is never handed to the merge-loop even if auto-merge is on globally (`NANO_PR_AUTO_MERGE`). A per-request review-only override; defaults to false (the global auto-merge default applies).
4608
+ deprecated: true
4609
+ description: 'Deprecated negative alias. If `autoMerge` is omitted, `true` means `autoMerge: false`; `false` means `autoMerge: true` (still gated by `NANO_PR_AUTO_MERGE`). Prefer `autoMerge`.'
4598
4610
  type: object
4599
4611
  # END generated:mcp-body
4600
4612
  responses:
@@ -5809,9 +5821,9 @@ paths:
5809
5821
  type: string
5810
5822
  enum: [real, forward-declared]
5811
5823
  description: real ⇒ dispatches a real side effect; forward-declared ⇒ a no-op stub.
5812
- convergeOnlyDefault:
5824
+ autoMergeDefault:
5813
5825
  type: boolean
5814
- description: The default `convergeOnly` for a real converge target.
5826
+ description: The default `autoMerge` for a real converge target.
5815
5827
  summary:
5816
5828
  type: string
5817
5829
  description: What the target does.
@@ -5872,7 +5884,7 @@ paths:
5872
5884
  optionalFields: [dedupeKey, payload]
5873
5885
  sideEffecting: true
5874
5886
  mayEmit: true
5875
- summary: "An automated outbound action; payload for a converge target is { pr, convergeOnly?, dependsOn? }."
5887
+ summary: "An automated outbound action; payload for a converge target is { pr, autoMerge?, dependsOn? }."
5876
5888
  factTypes: [string, number, boolean, artifact, version, url, pr]
5877
5889
  guardScalarTypes: [string, number, boolean]
5878
5890
  waitProbeKinds:
@@ -5893,15 +5905,15 @@ paths:
5893
5905
  connectorTargets:
5894
5906
  - target: converge
5895
5907
  status: real
5896
- convergeOnlyDefault: true
5908
+ autoMergeDefault: false
5897
5909
  summary: "Converge-only: drive review convergence and stop at converged."
5898
5910
  - target: converge-merge
5899
5911
  status: real
5900
- convergeOnlyDefault: false
5912
+ autoMergeDefault: true
5901
5913
  summary: "Unit-level land: converge AND merge onto the PR's own base branch."
5902
5914
  - target: merge-main
5903
5915
  status: real
5904
- convergeOnlyDefault: false
5916
+ autoMergeDefault: true
5905
5917
  summary: "Graph-level top-level land onto main (two-level merge)."
5906
5918
  - target: "<any other target>"
5907
5919
  status: forward-declared
@@ -6168,7 +6180,7 @@ paths:
6168
6180
  description: When true, hand the opened PR to the convergence loop (review rounds) as a follow-on step. When false (default) the run ends at "PR raised, not reviewed".
6169
6181
  autoMerge:
6170
6182
  type: boolean
6171
- description: When true (and `converge` is true), drive the merge-loop after convergence — mirrors `convergeOnly`/`NANO_PR_AUTO_MERGE` inverted. When false the run stops at `converged`. Moot when `converge` is false.
6183
+ description: When true (and `converge` is true), drive the merge-loop after convergence only when `NANO_PR_AUTO_MERGE` is enabled. When false the run stops at `converged`. Moot when `converge` is false.
6172
6184
  confirmDefaultBase:
6173
6185
  type: boolean
6174
6186
  description: Acknowledge that `baseBranch` is the repository default branch (the PR would target it directly). See `PlanStartByIssue.confirmDefaultBase`.
@@ -40,7 +40,8 @@ test("the guide covers every capability the endpoint promises", async () => {
40
40
  const md = ((await handler(input(), app)) as any).body.instructions as string;
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
- assert(md.includes("convergeOnly"), "documents review-only vs. merge");
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
45
  assert(md.includes("start/plan-fanout"), "covers submitting an epic");
45
46
  assert(md.includes("complete-user-task"), "covers answering escalations via the native user-task door");
46
47
  // …debug the system.
@@ -143,15 +143,57 @@ test("startConvergenceLoop forwards convergeOnly:true to the loop", async () =>
143
143
  });
144
144
  });
145
145
 
146
+ test("startConvergenceLoop maps autoMerge:true to merge-enabled convergence", async () => {
147
+ await withGithubOff(async () => {
148
+ const { app: capApp, get } = captureApp();
149
+ const res = await startConvergenceLoop(input({ pr: "owner/repo#9", autoMerge: true }), capApp);
150
+ assertEquals((res as any).status, 202);
151
+ assertEquals(get(), false);
152
+ });
153
+ });
154
+
155
+ test("startConvergenceLoop maps autoMerge:false to review-only convergence", async () => {
156
+ await withGithubOff(async () => {
157
+ const { app: capApp, get } = captureApp();
158
+ const res = await startConvergenceLoop(input({ pr: "owner/repo#10", autoMerge: false }), capApp);
159
+ assertEquals((res as any).status, 202);
160
+ assertEquals(get(), true);
161
+ });
162
+ });
163
+
164
+ test("startConvergenceLoop lets autoMerge override the legacy convergeOnly alias", async () => {
165
+ await withGithubOff(async () => {
166
+ const merge = captureApp();
167
+ const mergeRes = await startConvergenceLoop(
168
+ input({ pr: "owner/repo#13", autoMerge: true, convergeOnly: true }),
169
+ merge.app,
170
+ );
171
+ assertEquals((mergeRes as any).status, 202);
172
+ assertEquals(merge.get(), false);
173
+
174
+ const review = captureApp();
175
+ const reviewRes = await startConvergenceLoop(
176
+ input({ pr: "owner/repo#14", autoMerge: false, convergeOnly: false }),
177
+ review.app,
178
+ );
179
+ assertEquals((reviewRes as any).status, 202);
180
+ assertEquals(review.get(), true);
181
+ });
182
+ });
183
+
146
184
  test("startConvergenceLoop defaults convergeOnly to false and does not truthy-coerce a non-boolean", async () => {
147
185
  await withGithubOff(async () => {
148
186
  const omitted = captureApp();
149
- await startConvergenceLoop(input({ pr: "owner/repo#9" }), omitted.app);
187
+ await startConvergenceLoop(input({ pr: "owner/repo#11" }), omitted.app);
150
188
  assertEquals(omitted.get(), false);
151
189
 
152
190
  const stringy = captureApp();
153
- await startConvergenceLoop(input({ pr: "owner/repo#10", convergeOnly: "true" }), stringy.app);
191
+ await startConvergenceLoop(input({ pr: "owner/repo#12", convergeOnly: "true" }), stringy.app);
154
192
  assertEquals(stringy.get(), false);
193
+
194
+ const stringyAutoMerge = captureApp();
195
+ await startConvergenceLoop(input({ pr: "owner/repo#15", autoMerge: "true" }), stringyAutoMerge.app);
196
+ assertEquals(stringyAutoMerge.get(), false);
155
197
  });
156
198
  });
157
199
 
@@ -3,13 +3,15 @@
3
3
  // external webhook relay, a CI job, and Swagger all POST here. Parse the PR reference and
4
4
  // register/refresh the PR aggregate (idempotent on prKey) before starting the loop.
5
5
  //
6
- // The request body is FLAT (`{ pr | url, dependsOn?, maxRounds?, convergeOnly? }`), not wrapped in a
7
- // `variables` envelope: this is a purpose-built operation, not a generic engine "start process" call,
8
- // so it does not leak the engine's variable-map concept to callers. The body is a `oneOf` — EXACTLY
9
- // ONE of `pr` or `url` — so the runtime rejects an empty or ambiguous target at the edge (a 400 that
10
- // names the allowed shapes); this delegate no longer coalesces `pr ?? url`, it just narrows the
11
- // validated variant. It keeps the PR-parse guard because the reference FORMAT (owner/repo#123 or a
12
- // URL) is app logic the JSON schema can't express — an unparseable reference is a 400.
6
+ // The request body is FLAT (`{ pr | url, dependsOn?, maxRounds?, autoMerge?, convergeOnly? }`), not
7
+ // wrapped in a `variables` envelope: this is a purpose-built operation, not a generic engine "start
8
+ // process" call, so it does not leak the engine's variable-map concept to callers. `autoMerge` is
9
+ // the positive, preferred form for UI/API callers; `convergeOnly` remains a legacy negative alias.
10
+ // When both are present, `autoMerge` wins. The body is a `oneOf` — EXACTLY ONE of `pr` or `url` — so
11
+ // the runtime rejects an empty or ambiguous target at the edge (a 400 that names the allowed
12
+ // shapes); this delegate no longer coalesces `pr ?? url`, it just narrows the validated variant. It
13
+ // keeps the PR-parse guard because the reference FORMAT (owner/repo#123 or a URL) is app logic the
14
+ // JSON schema can't express — an unparseable reference is a 400.
13
15
 
14
16
  import { clampRounds, MAX_ROUNDS, parsePr, submitPr } from "../app/service.ts";
15
17
  import { defineOperation } from "../nano-generated/operations.ts";
@@ -29,9 +31,10 @@ export default defineOperation("startConvergenceLoop", async ({ body }, app) =>
29
31
  }
30
32
  const dependsOn = body.dependsOn ?? [];
31
33
  const maxRounds = clampRounds(body.maxRounds, MAX_ROUNDS);
32
- // Per-request review-only override: when true the PR stops at `converged` and is never
33
- // handed to the merge-loop, regardless of the global NANO_PR_AUTO_MERGE default.
34
- const convergeOnly = body.convergeOnly === true;
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.
36
+ const hasAutoMerge = typeof body.autoMerge === "boolean";
37
+ const convergeOnly = hasAutoMerge ? body.autoMerge !== true : body.convergeOnly === true;
35
38
  const result = await submitPr(app.data, app.engine, parsed, dependsOn, maxRounds, convergeOnly);
36
39
  app.log.info("convergence loop started", {
37
40
  prKey: parsed.prKey,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.189.2",
3
+ "version": "0.190.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",
@@ -105,8 +105,8 @@
105
105
  "type": "number"
106
106
  },
107
107
  {
108
- "key": "convergeOnly",
109
- "label": "Converge only \u2014 stop at review consensus, do not merge",
108
+ "key": "autoMerge",
109
+ "label": "Auto-merge \u2014 after convergence, drive the merge-loop",
110
110
  "type": "checkbox"
111
111
  }
112
112
  ]
@@ -30,14 +30,23 @@ const handler: AppJobHandler<In, Record<string, never>> = async (job, app) => {
30
30
  await featureRuns(app.data).update(featureKey, { status: "opened", updated_at: new Date().toISOString() });
31
31
  return {};
32
32
  }
33
- // `convergeOnly` is the inverse of auto-merge: converge-only stops at `converged`; auto-merge lets
34
- // the merge-loop drive the merge. `submitPr` is idempotent on the PR key.
35
- await submitPr(app.data, app.engine, parsed, [], MAX_ROUNDS, !autoMerge, featureKey);
33
+ // Write `converging` BEFORE enrolling the PR (issue #808 / PR #809 review). The old order —
34
+ // `submitPr` then the status write — left an `opened` + `converge=1` + `pr_key` window spanning an
35
+ // ALREADY-SUBMITTED, live PR: a terminate/crash in that gap stranded a row whose PR was live while
36
+ // `pollFeatureDelivery`'s handoff edge (edge 3) read the FEATURE instance state alone and wrongly
37
+ // folded it to `abandoned`. Flipping to `converging` first shrinks the interrupted-handoff window to
38
+ // the (now converging-owned) gap BEFORE `submitPr`, which `pollFeatureDelivery`'s converging edge
39
+ // (edge 1) heals by re-enrolling when the PR row is still missing (idempotent, mirroring the
40
+ // promotion-PR re-enroll in `pollPromotion`). The reorder is safe: `submitPr` is idempotent on the PR
41
+ // key (it early-returns `alreadyRunning` for a non-terminal PR), and a `converging` row is owned by
42
+ // edge (1), never by the handoff edge. `convergeOnly` is the inverse of auto-merge: converge-only
43
+ // stops at `converged`; auto-merge lets the merge-loop drive the merge.
36
44
  await featureRuns(app.data).update(featureKey, {
37
45
  status: "converging",
38
46
  pr_key: parsed.prKey,
39
47
  updated_at: new Date().toISOString(),
40
48
  });
49
+ await submitPr(app.data, app.engine, parsed, [], MAX_ROUNDS, !autoMerge, featureKey);
41
50
  app.log.info("converge-feature: enrolled PR into convergence loop", {
42
51
  featureKey,
43
52
  prKey: parsed.prKey,
@@ -149,6 +149,14 @@ test("readConvergeInput: an explicit payload.convergeOnly overrides the target d
149
149
  assertEquals(r.dependsOn, ["owner/repo#5"], "non-string dependsOn entries are dropped");
150
150
  });
151
151
 
152
+ test("readConvergeInput: autoMerge is the positive per-dispatch control", () => {
153
+ const merge = readConvergeInput("converge", { pr: "owner/repo#7", autoMerge: true }, null);
154
+ assertEquals(merge.convergeOnly, false);
155
+
156
+ const review = readConvergeInput("converge-merge", { pr: "owner/repo#7", autoMerge: false }, null);
157
+ assertEquals(review.convergeOnly, true);
158
+ });
159
+
152
160
  test("readConvergeInput: a missing / unparseable pr fails CLOSED (a converge connector with no target PR is meaningless)", () => {
153
161
  assertThrows(() => readConvergeInput("converge-merge", null, null), Error, "payload.pr");
154
162
  assertThrows(() => readConvergeInput("converge-merge", {}, null), Error, "payload.pr");
@@ -76,13 +76,14 @@ export function safeStringify(value: unknown): string {
76
76
  }
77
77
  }
78
78
 
79
- /** Parse + validate the converge connector's payload (`{ pr, convergeOnly?, dependsOn? }`) for a
79
+ /** Parse + validate the converge connector's payload (`{ pr, autoMerge?, dependsOn? }`) for a
80
80
  * `converge` / `converge-merge` / `merge-main` target. `pr` is REQUIRED and must parse to a canonical `owner/repo#N`
81
81
  * (fail CLOSED — a converge connector with no target PR is meaningless and could never enroll).
82
- * `convergeOnly` DEFAULTS from the target (`converge` → review-only `true`; `converge-merge`/`merge-main` → drive
83
- * the merge loop `false`) and may be overridden per-dispatch by an explicit boolean. `dependsOn` is an
84
- * optional list of PR refs unioned into the enrolled PR's merge-stage dependency set (only non-string
85
- * entries are dropped; `submitPr` itself ignores unparseable refs). Exported for unit coverage.
82
+ * `autoMerge` DEFAULTS from the target (`converge` → `false`; `converge-merge`/`merge-main` → `true`) and
83
+ * may be overridden per-dispatch by an explicit boolean. The deprecated `convergeOnly` alias is still
84
+ * accepted for compatibility. `dependsOn` is an optional list of PR refs unioned into the enrolled PR's
85
+ * merge-stage dependency set (only non-string entries are dropped; `submitPr` itself ignores unparseable
86
+ * refs). Exported for unit coverage.
86
87
  *
87
88
  * `pr` may be sourced three ways (issue #548), resolved by {@link resolveConvergePr} against the
88
89
  * threaded `boundFacts` BEFORE parsing: a LITERAL `owner/repo#N`, an explicit fact REFERENCE
@@ -104,7 +105,13 @@ export function readConvergeInput(
104
105
  `bound from an upstream \`agent\` node (got ${safeStringify(prValue ?? null)})`,
105
106
  );
106
107
  }
107
- const convergeOnly = typeof p.convergeOnly === "boolean" ? p.convergeOnly : convergeOnlyForTarget(target);
108
+ const autoMerge =
109
+ typeof p.autoMerge === "boolean"
110
+ ? p.autoMerge
111
+ : typeof p.convergeOnly === "boolean"
112
+ ? !p.convergeOnly
113
+ : !convergeOnlyForTarget(target);
114
+ const convergeOnly = !autoMerge;
108
115
  const dependsOn = Array.isArray(p.dependsOn) ? p.dependsOn.filter((d): d is string => typeof d === "string") : [];
109
116
  return { parsed, convergeOnly, dependsOn };
110
117
  }