@nanobpm/nano-workforce 0.204.1 → 0.205.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/SPEC.md +10 -7
- package/app/agentOutputEnvelope.test.ts +50 -0
- package/app/feature.ts +3 -3
- package/app/featureDelivery.test.ts +2 -0
- package/app/mergeApproval.test.ts +20 -1
- package/app/mergeStatus.test.ts +169 -0
- package/app/service.test.ts +25 -0
- package/app/service.ts +118 -9
- package/docs/agent-guide.md +33 -0
- package/openapi.yaml +34 -0
- package/operations/getAgentInstructions.test.ts +16 -1
- package/operations/listActivePrs.ts +1 -1
- package/operations/startAndMessage.test.ts +13 -3
- package/operations/startConvergenceLoop.ts +10 -4
- package/operations/startFeature.readiness.integration.test.ts +15 -0
- package/operations/startFeature.ts +6 -0
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +8 -0
- package/skills/nano-workforce/SKILL.md +7 -0
- package/workers/converge-feature/worker.ts +8 -6
- package/workers/delivery-connector/worker.ts +1 -1
- package/workers/finalize/worker.test.ts +35 -0
- package/workers/finalize/worker.ts +9 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.205.0](https://github.com/nanobpm/nano-workforce/compare/v0.204.1...v0.205.0) (2026-11-04)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **convergence:** make PR merge intent explicit — carry autoMerge end to end and show the effective merge policy in /status ([#917](https://github.com/nanobpm/nano-workforce/issues/917)) ([31e5b26](https://github.com/nanobpm/nano-workforce/commit/31e5b26f745f58920a9528bb448fe650af9631e3)), closes [#916](https://github.com/nanobpm/nano-workforce/issues/916) [#916](https://github.com/nanobpm/nano-workforce/issues/916)
|
|
6
|
+
|
|
1
7
|
## [0.204.1](https://github.com/nanobpm/nano-workforce/compare/v0.204.0...v0.204.1) (2026-11-04)
|
|
2
8
|
|
|
3
9
|
### Bug Fixes
|
package/SPEC.md
CHANGED
|
@@ -454,13 +454,16 @@ Notes:
|
|
|
454
454
|
three because the unfiltered merge lets an input-echo replace the identity that
|
|
455
455
|
`progress-check`, `converge-gate`, and `finalize` go on to read (a wrong
|
|
456
456
|
`prNumber` would make `finalize` start the merge loop for the wrong PR) — **and**
|
|
457
|
-
the gate controls `humanApproval` and
|
|
458
|
-
decision flags the loop acts on after the agent
|
|
459
|
-
routes a converged PR through the
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
457
|
+
the gate controls `humanApproval` and the merge-intent pair `autoMerge` /
|
|
458
|
+
`convergeOnly`. These are decision flags the loop acts on after the agent
|
|
459
|
+
returns: `humanApproval = true` routes a converged PR through the
|
|
460
|
+
`gw-approval` human gate instead of auto-merging, and the merge intent —
|
|
461
|
+
`autoMerge` (the canonical positive variable, issue #916), with the legacy
|
|
462
|
+
inverted `convergeOnly` as its in-flight fallback — decides whether a
|
|
463
|
+
converged PR is handed to the merge loop at all. Left unguarded, a converged
|
|
464
|
+
completion that echoed `humanApproval: false` would **bypass** the approval
|
|
465
|
+
gate, and one that echoed `convergeOnly: false` would hand a review-only PR
|
|
466
|
+
to the merge loop.
|
|
464
467
|
`convergeOnly` is a plain seeded constant, so the usual `=guardConvergeOnly`
|
|
465
468
|
restore applies. `humanApproval` is the one engine-owned flag that is **also
|
|
466
469
|
legitimately mutated mid-flight** — a gated caller that adopts a live loop
|
|
@@ -319,6 +319,56 @@ test("a scope-classify agent echoing humanApproval/convergeOnly cannot alter the
|
|
|
319
319
|
assertEquals(await variableValue(engine, processInstanceKey, "convergeOnly"), true, "convergeOnly flag must be unchanged");
|
|
320
320
|
});
|
|
321
321
|
|
|
322
|
+
// Issue #916: `autoMerge` is the canonical positive merge intent and is engine-owned like
|
|
323
|
+
// `convergeOnly` — an agent echo in EITHER direction must not flip it.
|
|
324
|
+
for (const seeded of [true, false]) {
|
|
325
|
+
test(`a review-round agent echoing autoMerge:${!seeded} cannot overwrite seeded autoMerge:${seeded}`, async () => {
|
|
326
|
+
const { engine } = await boot({
|
|
327
|
+
vars: { autoMerge: seeded },
|
|
328
|
+
responses: { "senior:pr-review": { status: "addressed", summary: "fixed", autoMerge: !seeded } },
|
|
329
|
+
});
|
|
330
|
+
const incidents = await engine.searchIncidents();
|
|
331
|
+
assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
|
|
332
|
+
assertEquals(instanceVars(engine).autoMerge, seeded, "seeded autoMerge must survive the agent echo");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test(`an adversarial-review agent echoing autoMerge:${!seeded} cannot overwrite seeded autoMerge:${seeded}`, async () => {
|
|
336
|
+
const { engine } = await boot({
|
|
337
|
+
vars: { advMax: 1, autoMerge: seeded },
|
|
338
|
+
responses: {
|
|
339
|
+
"senior:adversarial-review": { adversarialFindings: "", adversarialSummary: "clean", autoMerge: !seeded },
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
const incidents = await engine.searchIncidents();
|
|
343
|
+
assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
|
|
344
|
+
assertEquals(instanceVars(engine).autoMerge, seeded, "seeded autoMerge must survive the agent echo");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test(`a scope-classify agent echoing autoMerge:${!seeded} cannot overwrite seeded autoMerge:${seeded}`, async () => {
|
|
348
|
+
const { engine, processInstanceKey } = await boot({
|
|
349
|
+
vars: { humanApproval: true, autoMerge: seeded },
|
|
350
|
+
responses: {
|
|
351
|
+
"senior:pr-review": { status: "converged", summary: "done" },
|
|
352
|
+
"senior:scope-classify": { scopeBlocked: false, scopeBlockReason: "", autoMerge: !seeded },
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
const incidents = await engine.searchIncidents();
|
|
356
|
+
assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
|
|
357
|
+
assertEquals(await variableValue(engine, processInstanceKey, "autoMerge"), seeded, "autoMerge must be unchanged");
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
test("an agent echoing autoMerge on a legacy (autoMerge-absent) run cannot inject a merge intent", async () => {
|
|
362
|
+
const { engine } = await boot({
|
|
363
|
+
vars: { convergeOnly: true },
|
|
364
|
+
responses: { "senior:pr-review": { status: "addressed", summary: "fixed", autoMerge: true } },
|
|
365
|
+
});
|
|
366
|
+
const incidents = await engine.searchIncidents();
|
|
367
|
+
assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
|
|
368
|
+
const v = instanceVars(engine).autoMerge;
|
|
369
|
+
assertEquals(v == null, true, `legacy run must keep autoMerge absent so the convergeOnly fallback applies; got ${v}`);
|
|
370
|
+
});
|
|
371
|
+
|
|
322
372
|
// ---------------------------------------------------------------------------
|
|
323
373
|
// Issue #887 (round 4 — Copilot "previously missed"): `reviewStale` and
|
|
324
374
|
// `roundEntryHead` are ALSO process-owned and readable by downstream decisions,
|
package/app/feature.ts
CHANGED
|
@@ -524,9 +524,9 @@ export async function startFeature(
|
|
|
524
524
|
`(\`gh issue view ${parsed.number} -R ${parsed.repo}\`), implement it completely, ` +
|
|
525
525
|
`and open exactly one pull request that closes it.`,
|
|
526
526
|
},
|
|
527
|
-
// Follow-on knobs, read by the record/converge workers. `autoMerge`
|
|
528
|
-
// `
|
|
529
|
-
// merge-loop. `converge=false` ⇒ merge is moot.
|
|
527
|
+
// Follow-on knobs, read by the record/converge workers. `autoMerge` is the canonical
|
|
528
|
+
// positive merge intent (issue #916), threaded to `submitPr` as-is: converge-only stops at
|
|
529
|
+
// `converged`; auto-merge drives the merge-loop. `converge=false` ⇒ merge is moot.
|
|
530
530
|
converge,
|
|
531
531
|
autoMerge,
|
|
532
532
|
// Park the converged PR at a human approval user task before the merge-loop (issue #826).
|
|
@@ -215,6 +215,8 @@ test("pollFeatureDelivery: the re-enroll keeps the run's human approval gate (is
|
|
|
215
215
|
await pollFeatureDelivery(data, engine);
|
|
216
216
|
|
|
217
217
|
assertEquals(vars?.convergeOnly, false);
|
|
218
|
+
// Issue #916: the healed enrollment also carries the canonical positive merge-intent variable.
|
|
219
|
+
assertEquals(vars?.autoMerge, true);
|
|
218
220
|
assertEquals(vars?.humanApproval, true, "a healed enrollment must not silently drop the approval gate");
|
|
219
221
|
});
|
|
220
222
|
|
|
@@ -80,7 +80,7 @@ test("with humanApproval a converged PR parks for a human, and approve finalizes
|
|
|
80
80
|
});
|
|
81
81
|
|
|
82
82
|
test("request changes sends the guidance to the review agent on the same PR, then asks again", async () => {
|
|
83
|
-
const { engine, answers } = await boot({ humanApproval: true });
|
|
83
|
+
const { engine, answers, processInstanceKey } = await boot({ humanApproval: true });
|
|
84
84
|
const first = await openApproval(engine);
|
|
85
85
|
assert(first !== undefined, "the approval task is open");
|
|
86
86
|
await engine.completeUserTask(first, { mergeDecision: "revise", answer: "Rename foo to bar." });
|
|
@@ -92,6 +92,10 @@ test("request changes sends the guidance to the review agent on the same PR, the
|
|
|
92
92
|
assertEquals(completions(engine, "persist-converged"), 0);
|
|
93
93
|
const second = await openApproval(engine);
|
|
94
94
|
assert(second !== undefined && second !== first, "approval is asked again after the revision");
|
|
95
|
+
// A revise is NOT an approval: the gate must stay set, so /status keeps reporting
|
|
96
|
+
// `blockedBy: "human-approval"` while the re-asked approval is outstanding (PR #917 review).
|
|
97
|
+
const [row] = await engine.searchVariables({ processInstanceKey: String(processInstanceKey), name: "humanApproval" });
|
|
98
|
+
assertEquals(row ? JSON.parse(row.value) : undefined, true, "a revise keeps the human-approval gate set");
|
|
95
99
|
});
|
|
96
100
|
|
|
97
101
|
test("a live ungated loop narrowed to humanApproval (a gated submitPr adopting it) parks for approval", async () => {
|
|
@@ -113,6 +117,21 @@ test("fail-closed: a completion without an explicit approve never merges", async
|
|
|
113
117
|
assertEquals(completions(engine, "persist-converged"), 0);
|
|
114
118
|
});
|
|
115
119
|
|
|
120
|
+
test("an approve clears the humanApproval gate while finalization is still pending (PR #917 review)", async () => {
|
|
121
|
+
// The /status merge projection derives `blockedBy: "human-approval"` from the loop's
|
|
122
|
+
// `humanApproval` variable alone, so an approved PR whose `pr.finalize` handoff is still queued
|
|
123
|
+
// (or incidented) would otherwise keep reporting a human-approval blocker even though the
|
|
124
|
+
// approval is complete. Hold `pr.finalize` so the loop stays ACTIVE after the approve, then read
|
|
125
|
+
// the variable: it must be cleared by the approval itself, not by some later task.
|
|
126
|
+
const { engine, processInstanceKey } = await boot({ humanApproval: true }, ["pr.finalize"]);
|
|
127
|
+
const key = await openApproval(engine);
|
|
128
|
+
assert(key !== undefined, "the approval task is open");
|
|
129
|
+
await engine.completeUserTask(key, { mergeDecision: "approve" });
|
|
130
|
+
loop(engine).isActive().hasNoIncident();
|
|
131
|
+
const [row] = await engine.searchVariables({ processInstanceKey: String(processInstanceKey), name: "humanApproval" });
|
|
132
|
+
assertEquals(row ? JSON.parse(row.value) : undefined, false, "approval complete — the blocker must clear");
|
|
133
|
+
});
|
|
134
|
+
|
|
116
135
|
test("merge-approval is human-only, and its form requires guidance only for request changes", () => {
|
|
117
136
|
assert(HUMAN_COMPLETABLE_ELEMENTS.has(PR_MERGE_APPROVAL_ELEMENT), "the human completer accepts it");
|
|
118
137
|
assert(!ESCALATION_TASK_ELEMENTS.has(PR_MERGE_APPROVAL_ELEMENT), "an agent must never approve a merge");
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Red/green for issue #916 — the effective merge policy must be VISIBLE on every PR status row.
|
|
2
|
+
//
|
|
3
|
+
// The defect: whether a PR run will merge was explicit nowhere an operator looks. The
|
|
4
|
+
// convergence-loop instance carried only the INVERTED legacy `convergeOnly` variable (a feature
|
|
5
|
+
// run's `autoMerge: true` became `convergeOnly: false`, so searching the loop's variables for
|
|
6
|
+
// `autoMerge` found nothing and it looked as if auto-merge had been dropped), and the `/status`
|
|
7
|
+
// projection (`activePrs`) carried no merge-intent field at all — `waiting_merge` only appears once
|
|
8
|
+
// merging has STARTED. An operator concluded auto-merge was lost and wrote `autoMerge=true` onto a
|
|
9
|
+
// running loop — a no-op write, because the loop had `convergeOnly=false` from the start.
|
|
10
|
+
//
|
|
11
|
+
// The fix projects a structured `merge` block onto every ActivePr:
|
|
12
|
+
// merge.requested — the run's merge intent: the canonical positive `autoMerge` process variable
|
|
13
|
+
// when present, falling back to the INVERSE of the legacy `convergeOnly` variable for in-flight
|
|
14
|
+
// loops seeded before `autoMerge` existed (and to `true` — the historical default — when the
|
|
15
|
+
// instance key is unreadable or carries neither variable).
|
|
16
|
+
// merge.enabled — the deployment's global `NANO_PR_AUTO_MERGE` switch.
|
|
17
|
+
// merge.effective — requested && enabled && !humanApproval: the run merges with no human gate.
|
|
18
|
+
// merge.blockedBy — "env" | "human-approval" | null: WHY a requested merge will not proceed.
|
|
19
|
+
import { test } from "node:test";
|
|
20
|
+
import { assertEquals } from "#test-assert";
|
|
21
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
22
|
+
import { activePrs } from "./service.ts";
|
|
23
|
+
|
|
24
|
+
function memTable(rows: any[], key: string) {
|
|
25
|
+
return {
|
|
26
|
+
get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
27
|
+
all: () => Promise.resolve([...rows]),
|
|
28
|
+
find: (q: any) =>
|
|
29
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
30
|
+
findOne: (q: any) =>
|
|
31
|
+
Promise.resolve(rows.find((r) => Object.entries(q).every(([f, v]) => r[f] === v)) ?? null),
|
|
32
|
+
insert: (r: any) => {
|
|
33
|
+
rows.push(r);
|
|
34
|
+
return Promise.resolve(r);
|
|
35
|
+
},
|
|
36
|
+
update: (k: any, patch: any) => {
|
|
37
|
+
const r = rows.find((x) => x[key] === k);
|
|
38
|
+
if (r) Object.assign(r, patch);
|
|
39
|
+
return Promise.resolve(r);
|
|
40
|
+
},
|
|
41
|
+
delete: (k: any) => {
|
|
42
|
+
for (let i = rows.length - 1; i >= 0; i--) if (rows[i][key] === k) rows.splice(i, 1);
|
|
43
|
+
return Promise.resolve();
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type Stores = Record<string, { rows: any[]; key: string }>;
|
|
49
|
+
|
|
50
|
+
function memData(stores: Stores) {
|
|
51
|
+
return {
|
|
52
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
53
|
+
} as any;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A fake engine whose `searchVariables` serves the given per-instance variables. */
|
|
57
|
+
function fakeEngine(varsByInstance: Record<string, Record<string, unknown>>) {
|
|
58
|
+
return {
|
|
59
|
+
searchVariables: (q: { processInstanceKey?: string; name?: string }) => {
|
|
60
|
+
const vars = varsByInstance[String(q.processInstanceKey)] ?? {};
|
|
61
|
+
return Promise.resolve(
|
|
62
|
+
Object.entries(vars)
|
|
63
|
+
.filter(([name]) => q.name == null || name === q.name)
|
|
64
|
+
.map(([name, v]) => ({ name, value: JSON.stringify(v) })),
|
|
65
|
+
);
|
|
66
|
+
},
|
|
67
|
+
} as any;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function prRow(over: Record<string, unknown>) {
|
|
71
|
+
return {
|
|
72
|
+
pr_key: "owner/repo#1",
|
|
73
|
+
repo: "owner/repo",
|
|
74
|
+
number: 1,
|
|
75
|
+
url: "https://github.com/owner/repo/pull/1",
|
|
76
|
+
title: "t",
|
|
77
|
+
status: "converging",
|
|
78
|
+
current_round: 1,
|
|
79
|
+
process_key: "PI-1",
|
|
80
|
+
updated_at: "2024-01-01",
|
|
81
|
+
...over,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function mergeOf(vars: Record<string, unknown>, rowOver: Record<string, unknown> = {}) {
|
|
86
|
+
return mergeOfWith({ activePrs }, vars, rowOver);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** `mergeOf` against an explicitly-loaded service module — for the env-switched tests that import
|
|
90
|
+
* a fresh copy of app/service.ts (e.g. `NANO_PR_AUTO_MERGE=0`). */
|
|
91
|
+
async function mergeOfWith(
|
|
92
|
+
mod: Pick<typeof import("./service.ts"), "activePrs">,
|
|
93
|
+
vars: Record<string, unknown>,
|
|
94
|
+
rowOver: Record<string, unknown> = {},
|
|
95
|
+
) {
|
|
96
|
+
const stores: Stores = {
|
|
97
|
+
pull_requests: { rows: [prRow(rowOver)], key: "pr_key" },
|
|
98
|
+
user_tasks: { rows: [], key: "id" },
|
|
99
|
+
};
|
|
100
|
+
const [pr] = await mod.activePrs(memData(stores), fakeEngine({ "PI-1": vars }));
|
|
101
|
+
return pr.merge;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// AUTO_MERGE is computed once at app/service.ts import time; the test env leaves NANO_PR_AUTO_MERGE
|
|
105
|
+
// unset, so the global switch is ON here (the default).
|
|
106
|
+
|
|
107
|
+
test("merge policy: a loop seeded autoMerge=true reads requested+effective (the #916 phantom)", async () => {
|
|
108
|
+
const merge = await mergeOf({ autoMerge: true, convergeOnly: false, humanApproval: false });
|
|
109
|
+
assertEquals(merge, { requested: true, enabled: true, effective: true, blockedBy: null });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("merge policy: a legacy in-flight loop (convergeOnly only) derives requested from its inverse", async () => {
|
|
113
|
+
// Seeded BEFORE `autoMerge` existed: only the legacy inverted variable is present.
|
|
114
|
+
const review = await mergeOf({ convergeOnly: true });
|
|
115
|
+
assertEquals(review, { requested: false, enabled: true, effective: false, blockedBy: null });
|
|
116
|
+
const merging = await mergeOf({ convergeOnly: false });
|
|
117
|
+
assertEquals(merging, { requested: true, enabled: true, effective: true, blockedBy: null });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("merge policy: humanApproval gates the effective merge and is named as the blocker", async () => {
|
|
121
|
+
const merge = await mergeOf({ autoMerge: true, humanApproval: true });
|
|
122
|
+
assertEquals(merge, { requested: true, enabled: true, effective: false, blockedBy: "human-approval" });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("merge policy: an approved PR with finalization still pending reports no blocker (PR #917 review)", async () => {
|
|
126
|
+
// The `merge-approval` user task clears the loop's `humanApproval` variable on an explicit
|
|
127
|
+
// "approve" (convergence-loop.bpmn), so while `pr.finalize` is still queued or incidented the
|
|
128
|
+
// projection reads humanApproval=false and must NOT keep reporting a human-approval blocker for
|
|
129
|
+
// an approval that is already complete. (`status` stays non-terminal: `converged_at` is only
|
|
130
|
+
// stamped once `pr.finalize` actually runs.)
|
|
131
|
+
const merge = await mergeOf({ autoMerge: true, humanApproval: false }, { status: "converging" });
|
|
132
|
+
assertEquals(merge, { requested: true, enabled: true, effective: true, blockedBy: null });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("merge policy: a review-only request reads requested=false (distinct from env/human blocking)", async () => {
|
|
136
|
+
const merge = await mergeOf({ autoMerge: false, convergeOnly: true });
|
|
137
|
+
assertEquals(merge, { requested: false, enabled: true, effective: false, blockedBy: null });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("merge policy: no readable instance key (or neither variable) defaults to the historical merge default", async () => {
|
|
141
|
+
// A mid-handoff row (process_key null) or a legacy loop carrying neither variable: the pre-#916
|
|
142
|
+
// behaviour merged by default, so the projection must NOT read such a PR as review-only.
|
|
143
|
+
const stores: Stores = {
|
|
144
|
+
pull_requests: { rows: [prRow({ process_key: null })], key: "pr_key" },
|
|
145
|
+
user_tasks: { rows: [], key: "id" },
|
|
146
|
+
};
|
|
147
|
+
const [noKey] = await activePrs(memData(stores), fakeEngine({}));
|
|
148
|
+
assertEquals(noKey.merge, { requested: true, enabled: true, effective: true, blockedBy: null });
|
|
149
|
+
const bare = await mergeOf({});
|
|
150
|
+
assertEquals(bare, { requested: true, enabled: true, effective: true, blockedBy: null });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("merge policy: NANO_PR_AUTO_MERGE=0 blocks a requested merge as env, never a review-only one", async () => {
|
|
154
|
+
// `AUTO_MERGE` is read once at app/service.ts import time, so load a fresh copy of the module
|
|
155
|
+
// under the env (the same pattern app/service.test.ts uses) — every other test here runs with
|
|
156
|
+
// the switch ON (the default) and none exercised `blockedBy: "env"` before this one.
|
|
157
|
+
const prev = process.env["NANO_PR_AUTO_MERGE"];
|
|
158
|
+
process.env["NANO_PR_AUTO_MERGE"] = "0";
|
|
159
|
+
try {
|
|
160
|
+
const mod: typeof import("./service.ts") = await import(`./service.ts?mergeStatusAutoMergeOff=${Date.now()}`);
|
|
161
|
+
const requested = await mergeOfWith(mod, { autoMerge: true, humanApproval: true });
|
|
162
|
+
assertEquals(requested, { requested: true, enabled: false, effective: false, blockedBy: "env" });
|
|
163
|
+
const reviewOnly = await mergeOfWith(mod, { autoMerge: false });
|
|
164
|
+
assertEquals(reviewOnly, { requested: false, enabled: false, effective: false, blockedBy: null });
|
|
165
|
+
} finally {
|
|
166
|
+
if (prev === undefined) delete process.env["NANO_PR_AUTO_MERGE"];
|
|
167
|
+
else process.env["NANO_PR_AUTO_MERGE"] = prev;
|
|
168
|
+
}
|
|
169
|
+
});
|
package/app/service.test.ts
CHANGED
|
@@ -1005,6 +1005,31 @@ test("submitPr defaults convergeOnly to false so the global auto-merge default g
|
|
|
1005
1005
|
});
|
|
1006
1006
|
});
|
|
1007
1007
|
|
|
1008
|
+
// Issue #916: the loop must ALSO carry the POSITIVE `autoMerge` variable — an operator searching a
|
|
1009
|
+
// live convergence loop's variables for `autoMerge` previously found nothing (only the inverted
|
|
1010
|
+
// `convergeOnly`), and concluded auto-merge had been dropped. `submitPr` seeds both, keeping them
|
|
1011
|
+
// consistent: `autoMerge` is canonical, `convergeOnly` its inverse mirror for in-flight compat.
|
|
1012
|
+
test("submitPr seeds the positive autoMerge variable alongside the convergeOnly mirror (issue #916)", async () => {
|
|
1013
|
+
await withGithubOff(async () => {
|
|
1014
|
+
const pr = (n: number) => ({ repo: "owner/repo", number: n, url: `https://github.com/owner/repo/pull/${n}`, prKey: `owner/repo#${n}` });
|
|
1015
|
+
// Legacy positional form: convergeOnly=true ⇒ autoMerge=false (both seeded, consistent).
|
|
1016
|
+
const legacy = captureVars();
|
|
1017
|
+
await submitPr(legacy.data, legacy.engine, pr(30), [], 20, true);
|
|
1018
|
+
assertEquals(legacy.get()?.autoMerge, false);
|
|
1019
|
+
assertEquals(legacy.get()?.convergeOnly, true);
|
|
1020
|
+
// Default (no override): merge intent on — the global default governs.
|
|
1021
|
+
const dflt = captureVars();
|
|
1022
|
+
await submitPr(dflt.data, dflt.engine, pr(31));
|
|
1023
|
+
assertEquals(dflt.get()?.autoMerge, true);
|
|
1024
|
+
assertEquals(dflt.get()?.convergeOnly, false);
|
|
1025
|
+
// The positive trailing form wins over the legacy alias when both are supplied.
|
|
1026
|
+
const both = captureVars();
|
|
1027
|
+
await submitPr(both.data, both.engine, pr(32), [], 20, true, null, false, false, true);
|
|
1028
|
+
assertEquals(both.get()?.autoMerge, true);
|
|
1029
|
+
assertEquals(both.get()?.convergeOnly, false);
|
|
1030
|
+
});
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1008
1033
|
// #796 auto-ack budget seeding: `submitPr` is the ONLY production write that makes the retry budget
|
|
1009
1034
|
// available to a fresh convergence instance — the engine behaviour tests seed `ackRetryRound` /
|
|
1010
1035
|
// `ackRetryMax` directly and never exercise `submitPr`, so a regression dropping or misconfiguring
|
package/app/service.ts
CHANGED
|
@@ -627,9 +627,10 @@ export async function submitPr(
|
|
|
627
627
|
rootRequestKey: string | null = null,
|
|
628
628
|
humanApproval = false,
|
|
629
629
|
autoResolveEscalations = false,
|
|
630
|
+
autoMerge?: boolean,
|
|
630
631
|
) {
|
|
631
632
|
return withPrSubmitLock(parsed.prKey, () =>
|
|
632
|
-
submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey, humanApproval, autoResolveEscalations),
|
|
633
|
+
submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey, humanApproval, autoResolveEscalations, autoMerge),
|
|
633
634
|
);
|
|
634
635
|
}
|
|
635
636
|
|
|
@@ -643,11 +644,20 @@ async function submitPrCritical(
|
|
|
643
644
|
rootRequestKey: string | null = null,
|
|
644
645
|
humanApproval = false,
|
|
645
646
|
autoResolveEscalations = false,
|
|
647
|
+
autoMerge?: boolean,
|
|
646
648
|
) {
|
|
649
|
+
// Merge intent (issue #916): `autoMerge` is the CANONICAL positive form end to end — the same name
|
|
650
|
+
// the feature/convergence-loop/plan-fanout doors accept. The legacy inverted `convergeOnly`
|
|
651
|
+
// positional remains as a fallback: an explicit `autoMerge` wins; otherwise intent derives from
|
|
652
|
+
// `convergeOnly`. The instance carries BOTH (below): `autoMerge` so the intent is searchable under
|
|
653
|
+
// its positive name, and the `convergeOnly` mirror so in-flight consumers (and the guard-restore
|
|
654
|
+
// ioMappings on the loop's agent tasks) keep working unchanged.
|
|
655
|
+
const mergeIntent = autoMerge ?? !convergeOnly;
|
|
656
|
+
const effectiveConvergeOnly = !mergeIntent;
|
|
647
657
|
// Human approval before merge (issue #826) gates only a PR that `pr.finalize` would hand to the
|
|
648
|
-
// merge-loop — its own predicate, `AUTO_MERGE &&
|
|
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 &&
|
|
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
|
|
822
|
-
//
|
|
823
|
-
//
|
|
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(
|
|
1096
|
+
export async function activePrs(
|
|
1097
|
+
data: DataLayer,
|
|
1098
|
+
engine?: Pick<EngineClient, "searchVariables">,
|
|
1099
|
+
): Promise<ActivePr[]> {
|
|
1062
1100
|
const all = await prsTracking(data).all();
|
|
1063
1101
|
const active = all
|
|
1064
1102
|
// ADR-0065: classify "in flight" on the DERIVED terminal edge, not the base transient. A PR whose
|
|
@@ -1086,6 +1124,16 @@ export async function activePrs(data: DataLayer): Promise<ActivePr[]> {
|
|
|
1086
1124
|
if (openEscByPr.has(t.subject_key)) continue;
|
|
1087
1125
|
openEscByPr.set(t.subject_key, toOpenEscalation(t));
|
|
1088
1126
|
}
|
|
1127
|
+
// Merge intent (issue #916): read the loop's `autoMerge` / `convergeOnly` / `humanApproval`
|
|
1128
|
+
// variables per PR so the effective merge policy is visible without opening the engine. One
|
|
1129
|
+
// best-effort read per PR; a failure (engine unreachable, instance gone) degrades to the
|
|
1130
|
+
// historical default (merge requested) rather than failing the whole status read.
|
|
1131
|
+
const mergeByPr = new Map<string, MergePolicy>();
|
|
1132
|
+
await Promise.all(
|
|
1133
|
+
active.map(async (p) => {
|
|
1134
|
+
mergeByPr.set(p.pr_key, await mergePolicyFor(p.process_key ?? null, engine));
|
|
1135
|
+
}),
|
|
1136
|
+
);
|
|
1089
1137
|
return active.map((p) => ({
|
|
1090
1138
|
prKey: p.pr_key,
|
|
1091
1139
|
repo: p.repo,
|
|
@@ -1101,9 +1149,67 @@ export async function activePrs(data: DataLayer): Promise<ActivePr[]> {
|
|
|
1101
1149
|
activeWorker: p.active_worker ?? null,
|
|
1102
1150
|
leaseUntil: p.lease_until ?? null,
|
|
1103
1151
|
autoResolveEscalations: p.auto_resolve_escalations === 1,
|
|
1152
|
+
merge: mergeByPr.get(p.pr_key) ?? mergePolicyDefault(),
|
|
1104
1153
|
}));
|
|
1105
1154
|
}
|
|
1106
1155
|
|
|
1156
|
+
/** The default merge policy when no instance variables are readable (mid-handoff row, engine
|
|
1157
|
+
* unreachable, or a legacy loop carrying neither variable): the pre-#916 behaviour merged by
|
|
1158
|
+
* default, so the projection must NOT read such a PR as review-only. */
|
|
1159
|
+
function mergePolicyDefault(): MergePolicy {
|
|
1160
|
+
return mergePolicyFrom(true, false);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/** Combine a merge intent + human-approval gate with the global switch into the effective policy.
|
|
1164
|
+
* `humanApproval` is the OUTSTANDING-approval signal: the loop seeds it when approval is required
|
|
1165
|
+
* and the `merge-approval` user task clears it on an explicit "approve", so an approved PR whose
|
|
1166
|
+
* finalization is still queued/incidented reads `effective` with no blocker rather than a stale
|
|
1167
|
+
* `blockedBy: "human-approval"`. */
|
|
1168
|
+
function mergePolicyFrom(requested: boolean, humanApproval: boolean): MergePolicy {
|
|
1169
|
+
const enabled = AUTO_MERGE;
|
|
1170
|
+
const effective = requested && enabled && !humanApproval;
|
|
1171
|
+
const blockedBy = !requested ? null : !enabled ? "env" : humanApproval ? "human-approval" : null;
|
|
1172
|
+
return { requested, enabled, effective, blockedBy };
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/** Read one boolean process variable off a live instance; undefined when absent/unreadable. */
|
|
1176
|
+
async function readBoolVar(
|
|
1177
|
+
engine: Pick<EngineClient, "searchVariables">,
|
|
1178
|
+
processKey: string,
|
|
1179
|
+
name: string,
|
|
1180
|
+
): Promise<boolean | undefined> {
|
|
1181
|
+
const [row] = await engine.searchVariables({ processInstanceKey: processKey, name });
|
|
1182
|
+
if (!row) return undefined;
|
|
1183
|
+
try {
|
|
1184
|
+
const v: unknown = JSON.parse(row.value);
|
|
1185
|
+
return typeof v === "boolean" ? v : undefined;
|
|
1186
|
+
} catch {
|
|
1187
|
+
return undefined;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/** Derive a PR's effective merge policy from its loop's process variables (issue #916). The
|
|
1192
|
+
* canonical positive `autoMerge` wins; the legacy inverted `convergeOnly` is the fallback for
|
|
1193
|
+
* in-flight loops seeded before `autoMerge` existed. Best-effort: any read failure degrades to the
|
|
1194
|
+
* historical default (merge requested) rather than failing the status read. */
|
|
1195
|
+
async function mergePolicyFor(
|
|
1196
|
+
processKey: string | null,
|
|
1197
|
+
engine: Pick<EngineClient, "searchVariables"> | undefined,
|
|
1198
|
+
): Promise<MergePolicy> {
|
|
1199
|
+
if (processKey == null || !engine) return mergePolicyDefault();
|
|
1200
|
+
try {
|
|
1201
|
+
const [autoMerge, convergeOnly, humanApproval] = await Promise.all([
|
|
1202
|
+
readBoolVar(engine, processKey, "autoMerge"),
|
|
1203
|
+
readBoolVar(engine, processKey, "convergeOnly"),
|
|
1204
|
+
readBoolVar(engine, processKey, "humanApproval"),
|
|
1205
|
+
]);
|
|
1206
|
+
const requested = autoMerge ?? (convergeOnly === undefined ? true : !convergeOnly);
|
|
1207
|
+
return mergePolicyFrom(requested, humanApproval === true);
|
|
1208
|
+
} catch {
|
|
1209
|
+
return mergePolicyDefault();
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1107
1213
|
/** A delivery-graph run currently in flight, as reported by the status endpoint (issue #889 §1 —
|
|
1108
1214
|
* "Show it in `/status` for each PR or delivery"). Mirrors {@link ActivePr} for the delivery side. */
|
|
1109
1215
|
export interface ActiveDelivery {
|
|
@@ -2784,13 +2890,16 @@ export async function pollFeatureDelivery(
|
|
|
2784
2890
|
parsed,
|
|
2785
2891
|
[],
|
|
2786
2892
|
MAX_ROUNDS,
|
|
2787
|
-
|
|
2893
|
+
false,
|
|
2788
2894
|
run.feature_key,
|
|
2789
2895
|
run.human_approval === 1,
|
|
2790
2896
|
// Thread the run's escalation-steward opt-in (issue #889 review) so a re-enrolled PR
|
|
2791
2897
|
// inherits the feature run's enrolment — and a partial-enrollment repair never overwrites
|
|
2792
2898
|
// the PR row's previously persisted opt-in with the `false` default.
|
|
2793
2899
|
run.auto_resolve_escalations === 1,
|
|
2900
|
+
// Merge intent (issue #916): the canonical positive `autoMerge`, derived from the run's
|
|
2901
|
+
// durable `auto_merge` flag (mirrors `converge-feature`).
|
|
2902
|
+
run.auto_merge === 1,
|
|
2794
2903
|
);
|
|
2795
2904
|
}
|
|
2796
2905
|
}
|
package/docs/agent-guide.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
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,
|
|
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.
|
|
3
|
+
"version": "0.205.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -103,6 +103,7 @@
|
|
|
103
103
|
<nano:extend name="prUrl" type="string" />
|
|
104
104
|
<nano:extend name="round" type="integer" />
|
|
105
105
|
<nano:extend name="summary" type="string" optional="true" />
|
|
106
|
+
<nano:extend name="autoMerge" type="boolean" optional="true" />
|
|
106
107
|
<nano:extend name="convergeOnly" type="boolean" optional="true" />
|
|
107
108
|
<nano:extend name="abandonUrl" type="string" optional="true" />
|
|
108
109
|
</nano:shape>
|
|
@@ -161,6 +162,7 @@
|
|
|
161
162
|
<zeebe:input source="=reviewWaitTimeout" target="guardReviewWaitTimeout" />
|
|
162
163
|
<zeebe:input source="=humanApproval" target="guardHumanApproval" />
|
|
163
164
|
<zeebe:input source="=convergeOnly" target="guardConvergeOnly" />
|
|
165
|
+
<zeebe:input source="=autoMerge" target="guardAutoMerge" />
|
|
164
166
|
<zeebe:input source="=reviewStale" target="guardReviewStale" />
|
|
165
167
|
<zeebe:input source="=roundEntryHead" target="guardRoundEntryHead" />
|
|
166
168
|
<zeebe:input source="=abandonUrl" target="guardAbandonUrl" />
|
|
@@ -182,6 +184,7 @@
|
|
|
182
184
|
<zeebe:output source="=guardReviewWaitTimeout" target="reviewWaitTimeout" />
|
|
183
185
|
<zeebe:output source="=(guardHumanApproval = true) or (humanApproval = true)" target="humanApproval" />
|
|
184
186
|
<zeebe:output source="=guardConvergeOnly" target="convergeOnly" />
|
|
187
|
+
<zeebe:output source="=guardAutoMerge" target="autoMerge" />
|
|
185
188
|
<zeebe:output source="=guardReviewStale" target="reviewStale" />
|
|
186
189
|
<zeebe:output source="=guardRoundEntryHead" target="roundEntryHead" />
|
|
187
190
|
<zeebe:output source="=guardAbandonUrl" target="abandonUrl" />
|
|
@@ -255,6 +258,7 @@
|
|
|
255
258
|
<zeebe:input source="=reviewWaitTimeout" target="guardReviewWaitTimeout" />
|
|
256
259
|
<zeebe:input source="=humanApproval" target="guardHumanApproval" />
|
|
257
260
|
<zeebe:input source="=convergeOnly" target="guardConvergeOnly" />
|
|
261
|
+
<zeebe:input source="=autoMerge" target="guardAutoMerge" />
|
|
258
262
|
<zeebe:input source="=reviewStale" target="guardReviewStale" />
|
|
259
263
|
<zeebe:input source="=roundEntryHead" target="guardRoundEntryHead" />
|
|
260
264
|
<zeebe:input source="=abandonUrl" target="guardAbandonUrl" />
|
|
@@ -277,6 +281,7 @@
|
|
|
277
281
|
<zeebe:output source="=guardReviewWaitTimeout" target="reviewWaitTimeout" />
|
|
278
282
|
<zeebe:output source="=(guardHumanApproval = true) or (humanApproval = true)" target="humanApproval" />
|
|
279
283
|
<zeebe:output source="=guardConvergeOnly" target="convergeOnly" />
|
|
284
|
+
<zeebe:output source="=guardAutoMerge" target="autoMerge" />
|
|
280
285
|
<zeebe:output source="=guardReviewStale" target="reviewStale" />
|
|
281
286
|
<zeebe:output source="=guardRoundEntryHead" target="roundEntryHead" />
|
|
282
287
|
<zeebe:output source="=guardAbandonUrl" target="abandonUrl" />
|
|
@@ -474,6 +479,7 @@
|
|
|
474
479
|
<zeebe:ioMapping>
|
|
475
480
|
<zeebe:input source="=prKey" target="prKey" />
|
|
476
481
|
<zeebe:output source="=0" target="huskRetries" />
|
|
482
|
+
<zeebe:output source="=if mergeDecision = "approve" then false else humanApproval" target="humanApproval" />
|
|
477
483
|
</zeebe:ioMapping>
|
|
478
484
|
</bpmn:extensionElements>
|
|
479
485
|
<bpmn:incoming>f_needApproval</bpmn:incoming>
|
|
@@ -563,6 +569,7 @@
|
|
|
563
569
|
<zeebe:input source="=reviewWaitTimeout" target="guardReviewWaitTimeout" />
|
|
564
570
|
<zeebe:input source="=humanApproval" target="guardHumanApproval" />
|
|
565
571
|
<zeebe:input source="=convergeOnly" target="guardConvergeOnly" />
|
|
572
|
+
<zeebe:input source="=autoMerge" target="guardAutoMerge" />
|
|
566
573
|
<zeebe:input source="=reviewStale" target="guardReviewStale" />
|
|
567
574
|
<zeebe:input source="=roundEntryHead" target="guardRoundEntryHead" />
|
|
568
575
|
<zeebe:input source="=abandonUrl" target="guardAbandonUrl" />
|
|
@@ -582,6 +589,7 @@
|
|
|
582
589
|
<zeebe:output source="=guardReviewWaitTimeout" target="reviewWaitTimeout" />
|
|
583
590
|
<zeebe:output source="=(guardHumanApproval = true) or (humanApproval = true)" target="humanApproval" />
|
|
584
591
|
<zeebe:output source="=guardConvergeOnly" target="convergeOnly" />
|
|
592
|
+
<zeebe:output source="=guardAutoMerge" target="autoMerge" />
|
|
585
593
|
<zeebe:output source="=guardReviewStale" target="reviewStale" />
|
|
586
594
|
<zeebe:output source="=guardRoundEntryHead" target="roundEntryHead" />
|
|
587
595
|
<zeebe:output source="=guardAbandonUrl" target="abandonUrl" />
|
|
@@ -126,3 +126,10 @@ debugging. **Re-fetch it every session.** For one section, use
|
|
|
126
126
|
- **Submissions are idempotent.** Re-submitting the same work must not double-dispatch.
|
|
127
127
|
- **Escalations are for humans.** Surface them with options; don't silently answer design or
|
|
128
128
|
product decisions.
|
|
129
|
+
- **Merge intent is a read, not a write.** Every `/status` PR row carries a `merge` block —
|
|
130
|
+
`requested` (the run's `autoMerge`), `enabled` (the deployment's `NANO_PR_AUTO_MERGE`),
|
|
131
|
+
`effective`, and `blockedBy` (`"env"` | `"human-approval"` | `null`). Read it there; never
|
|
132
|
+
write variables onto a running loop to change it. Such writes are unsupported and
|
|
133
|
+
timing-dependent, not reliable no-ops — one landing while the loop awaits merge approval
|
|
134
|
+
reaches `pr.finalize` unrestored and can change whether merging starts. Cancel and re-submit
|
|
135
|
+
instead.
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// Reached only via the `converge?` gateway's true branch (converge requested AND the agent opened a
|
|
4
4
|
// keyed PR). Reuses `submitPr` — the SAME enrollment the epic waves and the standalone
|
|
5
5
|
// `startConvergenceLoop` action use — so a feature run's PR gets identical review-round + merge
|
|
6
|
-
// behaviour with no duplicated machinery. `autoMerge`
|
|
7
|
-
// (
|
|
6
|
+
// behaviour with no duplicated machinery. The run's positive `autoMerge` is threaded to `submitPr`
|
|
7
|
+
// as-is (issue #916): auto-merge → drive the merge-loop; otherwise stop at `converged`.
|
|
8
8
|
//
|
|
9
9
|
// The feature-run's OWN process ends here; the PR's live convergence/merge state lives on the
|
|
10
10
|
// `pull_requests` row keyed by `pr_key`, which the feature page links to. We therefore leave the
|
|
@@ -40,8 +40,10 @@ const handler: AppJobHandler<In, Record<string, never>> = async (job, app) => {
|
|
|
40
40
|
// (edge 1) heals by re-enrolling when the PR row is still missing (idempotent, mirroring the
|
|
41
41
|
// promotion-PR re-enroll in `pollPromotion`). The reorder is safe: `submitPr` is idempotent on the PR
|
|
42
42
|
// key (it early-returns `alreadyRunning` for a non-terminal PR), and a `converging` row is owned by
|
|
43
|
-
// edge (1), never by the handoff edge.
|
|
44
|
-
//
|
|
43
|
+
// edge (1), never by the handoff edge.
|
|
44
|
+
//
|
|
45
|
+
// `autoMerge` is the canonical positive merge intent (issue #916): converge-only stops at
|
|
46
|
+
// `converged`; auto-merge lets the merge-loop drive the merge.
|
|
45
47
|
await featureRuns(app.data).update(featureKey, {
|
|
46
48
|
status: "converging",
|
|
47
49
|
pr_key: parsed.prKey,
|
|
@@ -51,11 +53,11 @@ const handler: AppJobHandler<In, Record<string, never>> = async (job, app) => {
|
|
|
51
53
|
// read the durable flag off the `feature_runs` row (the source of truth) and hand it to `submitPr`.
|
|
52
54
|
const featureRow = await featureRuns(app.data).get(featureKey);
|
|
53
55
|
const autoResolve = featureRow?.auto_resolve_escalations === 1;
|
|
54
|
-
await submitPr(app.data, app.engine, parsed, [], MAX_ROUNDS,
|
|
56
|
+
await submitPr(app.data, app.engine, parsed, [], MAX_ROUNDS, false, featureKey, humanApproval, autoResolve, autoMerge);
|
|
55
57
|
app.log.info("converge-feature: enrolled PR into convergence loop", {
|
|
56
58
|
featureKey,
|
|
57
59
|
prKey: parsed.prKey,
|
|
58
|
-
|
|
60
|
+
autoMerge,
|
|
59
61
|
humanApproval,
|
|
60
62
|
autoResolveEscalations: autoResolve,
|
|
61
63
|
});
|
|
@@ -217,7 +217,7 @@ const handler: AppJobHandler<In, ConnectorDispatchResult> = async (job, app) =>
|
|
|
217
217
|
// completion can clobber that variable, so the durable row is the only trustworthy source
|
|
218
218
|
// (issue #889 review). Fails closed (false) when no run row matches.
|
|
219
219
|
const autoResolve = await resolveAutoResolveEscalations(app.data, job.processInstanceKey != null ? String(job.processInstanceKey) : null);
|
|
220
|
-
await submitPr(app.data, app.engine, converge.parsed, converge.dependsOn, MAX_ROUNDS, converge.convergeOnly, dedupeKey, false, autoResolve);
|
|
220
|
+
await submitPr(app.data, app.engine, converge.parsed, converge.dependsOn, MAX_ROUNDS, converge.convergeOnly, dedupeKey, false, autoResolve, !converge.convergeOnly);
|
|
221
221
|
app.log.info("delivery-connector: enrolled PR into convergence loop", {
|
|
222
222
|
target,
|
|
223
223
|
dedupeKey,
|
|
@@ -103,3 +103,38 @@ test("finalize with convergeOnly absent hands off to the merge-loop when auto-me
|
|
|
103
103
|
assertEquals(pr.status, "waiting_deps");
|
|
104
104
|
});
|
|
105
105
|
});
|
|
106
|
+
|
|
107
|
+
// Issue #916: `autoMerge` is the CANONICAL positive merge-intent variable on the convergence loop.
|
|
108
|
+
// `pr.finalize` reads it first and falls back to the legacy inverted `convergeOnly` only when
|
|
109
|
+
// `autoMerge` is absent — so an in-flight loop seeded before `autoMerge` existed keeps its behaviour.
|
|
110
|
+
test("finalize with autoMerge=false rests at converged even when the legacy convergeOnly mirror is absent", async () => {
|
|
111
|
+
await withRetroOff(async () => {
|
|
112
|
+
const { app, stores, createdProcesses } = fakeApp();
|
|
113
|
+
// biome-ignore lint/plugin: constructing the framework's job envelope for the handler under test
|
|
114
|
+
await handler({ variables: { ...BASE_VARS, autoMerge: false } } as never, app as never);
|
|
115
|
+
assertEquals(createdProcesses.includes(MERGE_PROCESS_ID), false);
|
|
116
|
+
assertEquals(stores.pull_requests[0].status, "converged");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("finalize with autoMerge=true hands off to the merge-loop (canonical positive variable)", async () => {
|
|
121
|
+
await withRetroOff(async () => {
|
|
122
|
+
const { app, stores, createdProcesses } = fakeApp();
|
|
123
|
+
// biome-ignore lint/plugin: constructing the framework's job envelope for the handler under test
|
|
124
|
+
await handler({ variables: { ...BASE_VARS, autoMerge: true } } as never, app as never);
|
|
125
|
+
assertEquals(createdProcesses.includes(MERGE_PROCESS_ID), true);
|
|
126
|
+
assertEquals(stores.pull_requests[0].status, "waiting_deps");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("finalize prefers autoMerge over the legacy convergeOnly mirror when they disagree", async () => {
|
|
131
|
+
await withRetroOff(async () => {
|
|
132
|
+
const { app, stores, createdProcesses } = fakeApp();
|
|
133
|
+
// A contradictory pair (autoMerge=true, convergeOnly=true) can only arise from a hand-seeded or
|
|
134
|
+
// half-migrated instance: the positive canonical variable wins.
|
|
135
|
+
// biome-ignore lint/plugin: constructing the framework's job envelope for the handler under test
|
|
136
|
+
await handler({ variables: { ...BASE_VARS, autoMerge: true, convergeOnly: true } } as never, app as never);
|
|
137
|
+
assertEquals(createdProcesses.includes(MERGE_PROCESS_ID), true);
|
|
138
|
+
assertEquals(stores.pull_requests[0].status, "waiting_deps");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -22,7 +22,7 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
22
22
|
// `summary` is left undefined when absent so the write boundary omits it: the
|
|
23
23
|
// nullable `rounds.summary` stays NULL and `pull_requests.outcome` is untouched
|
|
24
24
|
// rather than being coerced to "".
|
|
25
|
-
const { prKey, repo, prNumber, prUrl, round, summary, abandonUrl, convergeOnly } = job.variables;
|
|
25
|
+
const { prKey, repo, prNumber, prUrl, round, summary, abandonUrl, convergeOnly, autoMerge } = job.variables;
|
|
26
26
|
const now = new Date().toISOString();
|
|
27
27
|
|
|
28
28
|
// Heal a missing FK parent (engine/app.db desync) before the child `rounds` insert.
|
|
@@ -52,10 +52,15 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
52
52
|
// with no process behind it, and `submitPr` refuses to restart it (only `cancel` recovers). On
|
|
53
53
|
// failure we leave the PR terminal as `converged` so a human/operator can (re)start merge.
|
|
54
54
|
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
55
|
+
// Merge intent (issue #916): `autoMerge` is the CANONICAL positive variable — read it first and
|
|
56
|
+
// fall back to the legacy inverted `convergeOnly` only when `autoMerge` is absent, so an in-flight
|
|
57
|
+
// loop seeded before `autoMerge` existed keeps its behaviour. A review-only intent
|
|
58
|
+
// (`autoMerge:false`, or legacy `convergeOnly:true`) forces the review-only path for this PR
|
|
59
|
+
// regardless of the global auto-merge default — the PR rests at `converged` and is never handed
|
|
60
|
+
// to the merge-loop.
|
|
61
|
+
const mergeRequested = autoMerge === true || (autoMerge == null && convergeOnly !== true);
|
|
57
62
|
let status = "converged";
|
|
58
|
-
if (AUTO_MERGE &&
|
|
63
|
+
if (AUTO_MERGE && mergeRequested) {
|
|
59
64
|
try {
|
|
60
65
|
const { mergeProcessKey } = await startMerge(app.data, app.engine, {
|
|
61
66
|
repo,
|