@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.
- package/CHANGELOG.md +12 -0
- package/README.md +5 -4
- package/SPEC.md +5 -5
- package/app/agentGuide.ts +1 -1
- package/app/deliveryGraphVocabulary.ts +6 -6
- package/app/feature.test.ts +74 -1
- package/app/feature.ts +67 -0
- package/app/featureDelivery.test.ts +466 -7
- package/app/featureReadModel.test.ts +80 -1
- package/app/featureReadModel.ts +83 -19
- package/app/reconcile.ts +1 -1
- package/app/service.test.ts +76 -0
- package/app/service.ts +180 -13
- package/app/stage.ts +21 -4
- package/db/migrations/111_feature_read_model_ack_opened.sql +61 -0
- package/db/migrations/112_feature_read_model_opened_midhandoff.sql +63 -0
- package/docs/adr/0005-agent-authored-delivery-graphs.md +1 -1
- package/docs/agent-guide.md +9 -9
- package/openapi.yaml +29 -17
- package/operations/getAgentInstructions.test.ts +2 -1
- package/operations/startAndMessage.test.ts +44 -2
- package/operations/startConvergenceLoop.ts +13 -10
- package/package.json +1 -1
- package/pages/home.page.json +2 -2
- package/workers/converge-feature/worker.ts +12 -3
- package/workers/delivery-connector/worker.test.ts +8 -0
- package/workers/delivery-connector/worker.ts +13 -6
|
@@ -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,
|
|
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
|
package/docs/agent-guide.md
CHANGED
|
@@ -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
|
-
| `
|
|
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", "
|
|
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 (
|
|
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 `
|
|
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 `
|
|
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",
|
|
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
|
-
`
|
|
794
|
-
|
|
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
|
-
|
|
1829
|
-
|
|
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
|
-
|
|
1851
|
-
|
|
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
|
|
3439
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5824
|
+
autoMergeDefault:
|
|
5813
5825
|
type: boolean
|
|
5814
|
-
description: The default `
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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("
|
|
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#
|
|
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#
|
|
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
|
|
7
|
-
// `variables` envelope: this is a purpose-built operation, not a generic engine "start
|
|
8
|
-
// so it does not leak the engine's variable-map concept to callers.
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
-
//
|
|
33
|
-
//
|
|
34
|
-
const
|
|
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.
|
|
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",
|
package/pages/home.page.json
CHANGED
|
@@ -105,8 +105,8 @@
|
|
|
105
105
|
"type": "number"
|
|
106
106
|
},
|
|
107
107
|
{
|
|
108
|
-
"key": "
|
|
109
|
-
"label": "
|
|
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
|
-
// `
|
|
34
|
-
// the
|
|
35
|
-
|
|
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,
|
|
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
|
-
* `
|
|
83
|
-
*
|
|
84
|
-
* optional list of PR refs unioned into the enrolled PR's
|
|
85
|
-
* entries are dropped; `submitPr` itself ignores unparseable
|
|
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
|
|
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
|
}
|