@nanobpm/nano-workforce 0.210.1 → 0.211.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/app/abandon.test.ts +31 -0
- package/app/abandon.ts +8 -2
- package/app/cliExtension.test.ts +40 -0
- package/app/conformance.test.ts +17 -0
- package/app/loopTermination.test.ts +4 -0
- package/app/migration139.test.ts +115 -0
- package/app/prTrackingReadModel.test.ts +175 -0
- package/app/prTrackingReadModel.ts +152 -0
- package/app/retro.test.ts +11 -0
- package/app/service.ts +11 -3
- package/app/terminalReaderBehaviour.test.ts +44 -0
- package/build-info.json +4 -4
- package/db/migrations/139_pull_requests_backfill_merged_not_abandoned.sql +40 -0
- package/main.ts +6 -0
- package/openapi.yaml +123 -0
- package/package.json +2 -2
- package/test/trackingViews.ts +16 -12
- package/workers/reconverge/worker.test.ts +137 -8
- package/workers/reconverge/worker.ts +53 -3
package/test/trackingViews.ts
CHANGED
|
@@ -17,6 +17,21 @@ import { baseStatusFieldFor, baseTableForTrackingView, trackingTargetFor } from
|
|
|
17
17
|
// biome-ignore lint/suspicious/noExplicitAny: test-only fake `table` resolver over dynamic row shapes.
|
|
18
18
|
type TableFn = (name: string, pk?: string) => any;
|
|
19
19
|
|
|
20
|
+
/** Project a base row onto its tracking-VIEW shape: the base columns plus `derived_status`. Mirrors
|
|
21
|
+
* the merge-aware derivation (app/prTrackingReadModel.ts, issue #959): an explicitly-seeded
|
|
22
|
+
* `derived_status` (a test modelling the ADR-0065 terminated divergence) wins ONLY when the row has
|
|
23
|
+
* NOT landed — a `merged_at` stamp folds the effective status to `merged` regardless, exactly as the
|
|
24
|
+
* real VIEW's merged-beats-terminated `WHEN` does. Absent an explicit override, the VIEW's
|
|
25
|
+
* `ELSE base.<statusField>` fall-through applies. */
|
|
26
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only projection over dynamic row shapes.
|
|
27
|
+
function projectRow(row: any, derivedColumn: string, statusField: string) {
|
|
28
|
+
if (row == null) return row;
|
|
29
|
+
const seeded = row[derivedColumn];
|
|
30
|
+
// merged beats terminated: a landed PR (merged_at set) is `merged` even when its run was terminated.
|
|
31
|
+
if (row.merged_at != null) return { ...row, [derivedColumn]: "merged" };
|
|
32
|
+
return { ...row, [derivedColumn]: seeded ?? row[statusField] };
|
|
33
|
+
}
|
|
34
|
+
|
|
20
35
|
/** Decorate a fake `table(name, pk)` resolver so it also serves every `<table>__tracking` derived
|
|
21
36
|
* VIEW off the corresponding base store, projecting `derived_status := base.<statusField>` onto each
|
|
22
37
|
* row the read methods (`findOne`/`get`/`find`/`all`) return. A non-tracking name is passed through
|
|
@@ -29,18 +44,7 @@ export function withTrackingViews<F extends TableFn>(base: F): F {
|
|
|
29
44
|
const derivedColumn = trackingTargetFor(baseName).statusColumn;
|
|
30
45
|
const statusField = baseStatusFieldFor(baseName);
|
|
31
46
|
// biome-ignore lint/suspicious/noExplicitAny: test-only projection over dynamic row shapes.
|
|
32
|
-
const project = (row: any) =>
|
|
33
|
-
row == null
|
|
34
|
-
? row
|
|
35
|
-
: // Honor an explicitly-seeded `derived_status` so a test can model the ADR-0065 divergence a
|
|
36
|
-
// real terminated instance produces — the base `<statusField>` frozen at its last transient
|
|
37
|
-
// while the derive edge reports the terminal (`abandoned`/`failed`/`reviewed`). When a row
|
|
38
|
-
// seeds no derived column the VIEW's `ELSE base.<statusField>` fall-through applies, so it
|
|
39
|
-
// stays byte-for-byte the pass-through the previous behaviour modelled.
|
|
40
|
-
{
|
|
41
|
-
...row,
|
|
42
|
-
[derivedColumn]: row[derivedColumn] ?? row[statusField],
|
|
43
|
-
};
|
|
47
|
+
const project = (row: any) => projectRow(row, derivedColumn, statusField);
|
|
44
48
|
// biome-ignore lint/suspicious/noExplicitAny: test-only Proxy over a dynamic DataLayer table.
|
|
45
49
|
return new Proxy(inner, {
|
|
46
50
|
get(target: any, prop: string) {
|
|
@@ -1,9 +1,106 @@
|
|
|
1
1
|
// pr.reconverge (issue #939): findings on a head-current review hand the PR back to the ONE canonical
|
|
2
2
|
// convergence entry (`submitPr`), declaring the merge-loop instance as the hand-off source.
|
|
3
3
|
import { test } from "node:test";
|
|
4
|
-
import { assertEquals, assertRejects, assertStringIncludes } from "#test-assert";
|
|
4
|
+
import { assert, assertEquals, assertRejects, assertStringIncludes } from "#test-assert";
|
|
5
5
|
import { noopLog } from "../../test/log.ts";
|
|
6
|
-
import { makeHandler, type FetchPrHead, type Submit } from "./worker.ts";
|
|
6
|
+
import { makeHandler, type FetchPrHead, type ReadLiveness, type Submit } from "./worker.ts";
|
|
7
|
+
|
|
8
|
+
// Deterministic liveness stub for hand-off tests: never reach the real GitHub transport.
|
|
9
|
+
const openLive: ReadLiveness = async () => "open";
|
|
10
|
+
const nullHead = (async () => null) as unknown as FetchPrHead;
|
|
11
|
+
|
|
12
|
+
// Issue #959: a reconverge hand-off can fire AFTER the PR landed out-of-band (a gate override /
|
|
13
|
+
// Mergify / a manual merge). Re-entering `submitPr` on a landed PR would restart a redundant
|
|
14
|
+
// convergence loop against a deleted head branch (the nano-bpm#1579 clone incident). The worker must
|
|
15
|
+
// instead stamp the row `merged` (so the merge-aware derivation reports `merged`, not `abandoned`,
|
|
16
|
+
// when the instance then ends), record the already-merged audit row, and end the loop cleanly — NOT
|
|
17
|
+
// reconverge.
|
|
18
|
+
test("an already-merged PR ends the loop cleanly instead of reconverging (#959)", async () => {
|
|
19
|
+
const merges: Record<string, unknown>[] = [];
|
|
20
|
+
const updates: Record<string, unknown>[] = [];
|
|
21
|
+
const app = {
|
|
22
|
+
data: {
|
|
23
|
+
table: (name: string) => ({
|
|
24
|
+
get: async () => (name === "pull_requests" ? { auto_resolve_escalations: 0 } : undefined),
|
|
25
|
+
find: async () => [],
|
|
26
|
+
findOne: async () => undefined,
|
|
27
|
+
insert: async (r: Record<string, unknown>) => (merges.push(r), 1),
|
|
28
|
+
update: async (_k: string, patch: Record<string, unknown>) => (updates.push(patch), undefined),
|
|
29
|
+
}),
|
|
30
|
+
source: () => ({ on: () => {}, off: () => {}, emit: () => {} }),
|
|
31
|
+
},
|
|
32
|
+
engine: { createInstance: async () => ({ processInstanceKey: "retro-1" }) },
|
|
33
|
+
log: noopLog(),
|
|
34
|
+
} as any;
|
|
35
|
+
let submitted = false;
|
|
36
|
+
const submit = (async () => {
|
|
37
|
+
submitted = true;
|
|
38
|
+
return { prKey: "acme/w#7", processKey: "999" };
|
|
39
|
+
}) as unknown as Submit;
|
|
40
|
+
const head = (async () => ({ headRef: "feat/x", headSha: "b".repeat(40), baseRef: "main", headRepo: "acme/w" })) as unknown as FetchPrHead;
|
|
41
|
+
const live: ReadLiveness = async () => "merged";
|
|
42
|
+
const out = await makeHandler(submit, head, live)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
|
|
43
|
+
// Ends cleanly — NO reconverge submit, no mergeStatus routing (the loop completes at MergeReconverged).
|
|
44
|
+
assertEquals(submitted, false);
|
|
45
|
+
assertEquals(out, {});
|
|
46
|
+
// The row is stamped landed (merged_at set) so the derivation reports `merged` on termination.
|
|
47
|
+
assert(updates.some((u) => u.status === "merged" && typeof u.merged_at === "string"), "row must be stamped merged");
|
|
48
|
+
// The audit trail carries the already-merged landing (alongside the reconverge-findings record).
|
|
49
|
+
const landing = merges.filter((m) => m.outcome === "merged");
|
|
50
|
+
assertEquals(landing.length, 1);
|
|
51
|
+
assertEquals(landing[0].method, "already-merged");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// The guard must NOT fire on a still-open PR: the normal reconverge hand-off proceeds.
|
|
55
|
+
test("a still-open PR proceeds with the normal reconverge hand-off", async () => {
|
|
56
|
+
const app = {
|
|
57
|
+
data: {
|
|
58
|
+
table: (name: string) => ({
|
|
59
|
+
get: async () => (name === "pull_requests" ? { auto_resolve_escalations: 0 } : undefined),
|
|
60
|
+
insert: async () => 1,
|
|
61
|
+
update: async () => undefined,
|
|
62
|
+
}),
|
|
63
|
+
},
|
|
64
|
+
engine: {},
|
|
65
|
+
log: noopLog(),
|
|
66
|
+
} as any;
|
|
67
|
+
let submitted = false;
|
|
68
|
+
const submit = (async () => {
|
|
69
|
+
submitted = true;
|
|
70
|
+
return { prKey: "acme/w#7", processKey: "999" };
|
|
71
|
+
}) as unknown as Submit;
|
|
72
|
+
const head = (async () => ({ headRef: "feat/x", headSha: "b".repeat(40), baseRef: "main", headRepo: "acme/w" })) as unknown as FetchPrHead;
|
|
73
|
+
const live: ReadLiveness = async () => "open";
|
|
74
|
+
const out = await makeHandler(submit, head, live)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
|
|
75
|
+
assertEquals(submitted, true);
|
|
76
|
+
assertEquals(out, {});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// An unreadable liveness read (transport hiccup) must not block a genuine reconverge — fail open.
|
|
80
|
+
test("an unreadable liveness read falls through to the normal hand-off (fail-open)", async () => {
|
|
81
|
+
const app = {
|
|
82
|
+
data: {
|
|
83
|
+
table: (name: string) => ({
|
|
84
|
+
get: async () => (name === "pull_requests" ? { auto_resolve_escalations: 0 } : undefined),
|
|
85
|
+
insert: async () => 1,
|
|
86
|
+
update: async () => undefined,
|
|
87
|
+
}),
|
|
88
|
+
},
|
|
89
|
+
engine: {},
|
|
90
|
+
log: noopLog(),
|
|
91
|
+
} as any;
|
|
92
|
+
let submitted = false;
|
|
93
|
+
const submit = (async () => {
|
|
94
|
+
submitted = true;
|
|
95
|
+
return { prKey: "acme/w#7", processKey: "999" };
|
|
96
|
+
}) as unknown as Submit;
|
|
97
|
+
const head = (async () => ({ headRef: "feat/x", headSha: "b".repeat(40), baseRef: "main", headRepo: "acme/w" })) as unknown as FetchPrHead;
|
|
98
|
+
const live: ReadLiveness = async () => {
|
|
99
|
+
throw new Error("github unreachable");
|
|
100
|
+
};
|
|
101
|
+
await makeHandler(submit, head, live)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
|
|
102
|
+
assertEquals(submitted, true);
|
|
103
|
+
});
|
|
7
104
|
|
|
8
105
|
test("re-submits through submitPr with auto-merge on and handoffFrom = this merge-loop instance", async () => {
|
|
9
106
|
const merges: Record<string, unknown>[] = [];
|
|
@@ -22,7 +119,7 @@ test("re-submits through submitPr with auto-merge on and handoffFrom = this merg
|
|
|
22
119
|
args = a;
|
|
23
120
|
return { prKey: "acme/w#7" };
|
|
24
121
|
}) as unknown as Submit;
|
|
25
|
-
await makeHandler(submit)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
|
|
122
|
+
await makeHandler(submit, nullHead, openLive)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
|
|
26
123
|
assertEquals((args[2] as { prKey: string }).prKey, "acme/w#7");
|
|
27
124
|
assertEquals(args[7], false, "no re-gate on human approval");
|
|
28
125
|
assertEquals(args[8], true, "steward opt-in carried from the row");
|
|
@@ -49,7 +146,7 @@ test("throws on a null processKey so the hand-off stays retryable (#941)", async
|
|
|
49
146
|
} as any;
|
|
50
147
|
const submit = (async () => ({ prKey: "acme/w#7", processKey: null })) as unknown as Submit;
|
|
51
148
|
const err = await assertRejects(
|
|
52
|
-
() => makeHandler(submit)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app),
|
|
149
|
+
() => makeHandler(submit, nullHead, openLive)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app),
|
|
53
150
|
Error,
|
|
54
151
|
"null processKey",
|
|
55
152
|
);
|
|
@@ -84,7 +181,7 @@ test("hands off at the CURRENT PR head, not the stale convergence checkpoint (#9
|
|
|
84
181
|
baseRef: "main",
|
|
85
182
|
headRepo: "acme/w",
|
|
86
183
|
})) as unknown as FetchPrHead;
|
|
87
|
-
await makeHandler(submit, head)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
|
|
184
|
+
await makeHandler(submit, head, openLive)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
|
|
88
185
|
assertEquals(args[11], "b".repeat(40), "the restore override is the live PR head SHA");
|
|
89
186
|
});
|
|
90
187
|
|
|
@@ -105,7 +202,7 @@ test("a head read that resolves no SHA degrades to the checkpoint restore (no ov
|
|
|
105
202
|
return { prKey: "acme/w#7", processKey: "999" };
|
|
106
203
|
}) as unknown as Submit;
|
|
107
204
|
const head = (async () => null) as unknown as FetchPrHead;
|
|
108
|
-
await makeHandler(submit, head)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
|
|
205
|
+
await makeHandler(submit, head, openLive)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
|
|
109
206
|
assertEquals(args[11], null, "no live head SHA → no override (checkpoint restore, the pre-fix behaviour)");
|
|
110
207
|
});
|
|
111
208
|
|
|
@@ -134,7 +231,7 @@ test("preserves the run's custom instructions across the merge→reconvergence h
|
|
|
134
231
|
return { prKey: "acme/w#7", processKey: "999" };
|
|
135
232
|
}) as unknown as Submit;
|
|
136
233
|
const head = (async () => null) as unknown as FetchPrHead;
|
|
137
|
-
await makeHandler(submit, head)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
|
|
234
|
+
await makeHandler(submit, head, openLive)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
|
|
138
235
|
assertEquals(args[12], " commit as josh.wulf@camunda.com ", "the durable instruction text rides the handoff into the fresh convergence run");
|
|
139
236
|
});
|
|
140
237
|
|
|
@@ -155,6 +252,38 @@ test("a PR row with no stored instructions hands off with null (no steering)", a
|
|
|
155
252
|
return { prKey: "acme/w#7", processKey: "999" };
|
|
156
253
|
}) as unknown as Submit;
|
|
157
254
|
const head = (async () => null) as unknown as FetchPrHead;
|
|
158
|
-
await makeHandler(submit, head)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
|
|
255
|
+
await makeHandler(submit, head, openLive)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
|
|
159
256
|
assertEquals(args[12], null, "no stored text → null (the fresh run's prompts stay byte-identical to no-instructions)");
|
|
160
257
|
});
|
|
258
|
+
|
|
259
|
+
// A PR closed WITHOUT merging before the hand-off must be settled abandoned via the canonical
|
|
260
|
+
// `abandonClosedPr` (row + plan tasks + audit) — never resubmitted into a replacement run.
|
|
261
|
+
test("a closed-unmerged PR is abandoned and no replacement run is submitted", async () => {
|
|
262
|
+
const merges: Record<string, unknown>[] = [];
|
|
263
|
+
const prUpdates: Record<string, unknown>[] = [];
|
|
264
|
+
const app = {
|
|
265
|
+
data: {
|
|
266
|
+
table: (name: string) => ({
|
|
267
|
+
get: async () => (name === "pull_requests" ? { pr_key: "acme/w#7", auto_resolve_escalations: 0 } : undefined),
|
|
268
|
+
find: async () => [],
|
|
269
|
+
findOne: async () => null,
|
|
270
|
+
insert: async (r: Record<string, unknown>) => (name === "merges" && merges.push(r), 1),
|
|
271
|
+
update: async (_k: string, patch: Record<string, unknown>) => (name === "pull_requests" && prUpdates.push(patch), undefined),
|
|
272
|
+
upsert: async () => undefined,
|
|
273
|
+
}),
|
|
274
|
+
},
|
|
275
|
+
engine: {},
|
|
276
|
+
log: noopLog(),
|
|
277
|
+
} as any;
|
|
278
|
+
let submitted = false;
|
|
279
|
+
const submit = (async () => {
|
|
280
|
+
submitted = true;
|
|
281
|
+
return { prKey: "acme/w#7", processKey: "999" };
|
|
282
|
+
}) as unknown as Submit;
|
|
283
|
+
const live: ReadLiveness = async () => "closed";
|
|
284
|
+
const out = await makeHandler(submit, nullHead, live)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
|
|
285
|
+
assertEquals(submitted, false);
|
|
286
|
+
assertEquals(out, {});
|
|
287
|
+
assert(merges.some((m) => m.outcome === "abandoned" && m.method === "pr-closed"), "closed audit row written");
|
|
288
|
+
assert(prUpdates.some((u) => u.status === "abandoned"), "row settled abandoned");
|
|
289
|
+
});
|
|
@@ -7,10 +7,16 @@
|
|
|
7
7
|
// (`submitPr`), declaring this merge-loop instance as the hand-off source so the live row it owns is
|
|
8
8
|
// not misread as "already running". The merge loop then ends; the fresh convergence run starts a new
|
|
9
9
|
// merge loop on convergence, exactly as a first pass does. No parallel review loop is introduced.
|
|
10
|
+
//
|
|
11
|
+
// EXCEPTION (issue #959): if the PR has ALREADY MERGED out-of-band by the time the hand-off fires
|
|
12
|
+
// (a gate override / Mergify / a manual merge landed it), there is nothing to reconverge — the worker
|
|
13
|
+
// stamps the row `merged`, records the already-merged audit, kicks the retrospective, and ends the
|
|
14
|
+
// loop cleanly instead of restarting convergence against a deleted head branch.
|
|
10
15
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
11
|
-
import { fetchPrHead } from "../../app/github.ts";
|
|
16
|
+
import { classifyPrLiveness, fetchPrHead, fetchPrState } from "../../app/github.ts";
|
|
12
17
|
import { parsePr } from "../../app/prParse.ts";
|
|
13
|
-
import {
|
|
18
|
+
import { maybeStartRetro } from "../../app/retro.ts";
|
|
19
|
+
import { abandonClosedPr, MAX_ROUNDS, submitPr } from "../../app/service.ts";
|
|
14
20
|
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
15
21
|
|
|
16
22
|
// Input typed off the model data envelope (`ReconvergeIn` in merge-loop.bpmn) — ADR 0040.
|
|
@@ -18,8 +24,18 @@ type In = WorkerInputs["pr.reconverge"];
|
|
|
18
24
|
|
|
19
25
|
export type Submit = typeof submitPr;
|
|
20
26
|
export type FetchPrHead = typeof fetchPrHead;
|
|
27
|
+
/** The PR-liveness reader used for the already-merged guard. Injectable so tests drive the
|
|
28
|
+
* merged/closed/open matrix without a transport; the default binds the canonical `fetchPrState`. */
|
|
29
|
+
export type ReadLiveness = (repo: string, prNumber: number) => Promise<"open" | "merged" | "closed" | "unknown">;
|
|
30
|
+
|
|
31
|
+
const defaultReadLiveness: ReadLiveness = async (repo, prNumber) =>
|
|
32
|
+
classifyPrLiveness(await fetchPrState(repo, prNumber, process.env.GITHUB_TOKEN ?? ""));
|
|
21
33
|
|
|
22
|
-
export function makeHandler(
|
|
34
|
+
export function makeHandler(
|
|
35
|
+
submit: Submit,
|
|
36
|
+
fetchHead: FetchPrHead = fetchPrHead,
|
|
37
|
+
readLiveness: ReadLiveness = defaultReadLiveness,
|
|
38
|
+
): AppJobHandler<In> {
|
|
23
39
|
return async (job, app) => {
|
|
24
40
|
const { prKey } = job.variables;
|
|
25
41
|
const parsed = parsePr(prKey);
|
|
@@ -54,6 +70,40 @@ export function makeHandler(submit: Submit, fetchHead: FetchPrHead = fetchPrHead
|
|
|
54
70
|
} catch (err) {
|
|
55
71
|
app.log.warn("reconverge: head read failed — falling back to the checkpoint restore", { prKey, err: String(err) });
|
|
56
72
|
}
|
|
73
|
+
// Already-merged guard (issue #959): a reconverge hand-off can fire AFTER the PR landed out-of-band
|
|
74
|
+
// (a gate override / Mergify / a manual merge) — the very race that left nano-bpm#1579's loop dying
|
|
75
|
+
// trying to clone a deleted head branch. Re-entering `submitPr` on a landed PR would restart a
|
|
76
|
+
// redundant convergence loop against a deleted head. Detect the terminal merge first and end the
|
|
77
|
+
// merge loop cleanly instead: stamp the row `merged` (so the merge-aware derivation reports
|
|
78
|
+
// `merged`, not `abandoned`, when this instance then ends), record the already-merged audit row, and
|
|
79
|
+
// kick the retrospective exactly as `pr.mark-merged` would. The merge loop then completes normally
|
|
80
|
+
// at `MergeReconverged` — a COMPLETED instance is NOT the `onTerminated` edge, so the row stays
|
|
81
|
+
// `merged`. Best-effort: an unreadable liveness (`unknown` / a throw) falls through to the normal
|
|
82
|
+
// hand-off rather than blocking a genuine reconverge.
|
|
83
|
+
const liveness = await readLiveness(parsed.repo, parsed.number).catch(() => "unknown" as const);
|
|
84
|
+
if (liveness === "merged") {
|
|
85
|
+
const ts = new Date().toISOString();
|
|
86
|
+
await app.data.table("pull_requests", "pr_key").update(prKey, { status: "merged", merged_at: ts, updated_at: ts });
|
|
87
|
+
await app.data.table("merges", "id").insert({
|
|
88
|
+
pr_key: prKey,
|
|
89
|
+
outcome: "merged",
|
|
90
|
+
method: "already-merged",
|
|
91
|
+
detail: "PR was already merged on GitHub (landed out-of-band) — ending the merge loop instead of reconverging",
|
|
92
|
+
at: ts,
|
|
93
|
+
});
|
|
94
|
+
// The merged-completion side effect `pr.mark-merged` runs: an externally merged PR may be its
|
|
95
|
+
// plan's last to settle, so its automatic retrospective must still start. Best-effort, never throws.
|
|
96
|
+
await maybeStartRetro(app.data, app.engine, prKey);
|
|
97
|
+
return {};
|
|
98
|
+
}
|
|
99
|
+
// A PR verified CLOSED without merging (it closed between the merge worker's findings and this
|
|
100
|
+
// hand-off) has nothing to reconverge either: settle it terminal through the ONE canonical
|
|
101
|
+
// `abandonClosedPr` (row + plan tasks + idempotent audit) and end the loop — never start a
|
|
102
|
+
// replacement convergence run on a closed PR.
|
|
103
|
+
if (liveness === "closed") {
|
|
104
|
+
await abandonClosedPr(app.data, prKey, "PR was closed without merging before the reconverge hand-off");
|
|
105
|
+
return {};
|
|
106
|
+
}
|
|
57
107
|
// The PR was already merging (merge intent on, any human approval already given), so the fresh
|
|
58
108
|
// run keeps auto-merge and does not re-gate on approval.
|
|
59
109
|
const result = await submit(
|