@nanobpm/nano-workforce 0.210.1 → 0.210.2
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/app/abandon.test.ts +31 -0
- package/app/abandon.ts +8 -2
- 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/package.json +1 -1
- package/test/trackingViews.ts +16 -12
- package/workers/reconverge/worker.test.ts +137 -8
- package/workers/reconverge/worker.ts +53 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.210.2](https://github.com/nanobpm/nano-workforce/compare/v0.210.1...v0.210.2) (2026-11-14)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **tracking:** merged PRs no longer derive 'abandoned' when their run was terminated ([#960](https://github.com/nanobpm/nano-workforce/issues/960)) ([56f0ff4](https://github.com/nanobpm/nano-workforce/commit/56f0ff4c4a3a0d8a3cf68745b1f7b9bf539987da)), closes [nano-bpm#1579](https://github.com/nanobpm/nano-bpm/issues/1579) [#959](https://github.com/nanobpm/nano-workforce/issues/959)
|
|
6
|
+
|
|
1
7
|
## [0.210.1](https://github.com/nanobpm/nano-workforce/compare/v0.210.0...v0.210.1) (2026-11-12)
|
|
2
8
|
|
|
3
9
|
### Bug Fixes
|
package/app/abandon.test.ts
CHANGED
|
@@ -134,6 +134,37 @@ test("abandonStatusForToken derives abandoned from the row status", async () =>
|
|
|
134
134
|
assertEquals(await abandonStatusForToken(data, "nope"), undefined);
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
+
test("abandonStatusForToken aborts a MERGED PR whose owning run was terminated (owner_terminated)", async () => {
|
|
138
|
+
// derived_status reports a landed PR as `merged` even when its run was terminated (#959), so the
|
|
139
|
+
// cancellation signal rides the separate owner_terminated column — terminating a redundant
|
|
140
|
+
// post-merge round must still tell its agent to stop.
|
|
141
|
+
const data = memData();
|
|
142
|
+
await data.table("pull_requests", "pr_key").insert({
|
|
143
|
+
pr_key: "o/r#4",
|
|
144
|
+
abandon_token: "landed-term",
|
|
145
|
+
status: "merged",
|
|
146
|
+
merged_at: "t",
|
|
147
|
+
owner_terminated: 1,
|
|
148
|
+
});
|
|
149
|
+
await data.table("pull_requests", "pr_key").insert({
|
|
150
|
+
pr_key: "o/r#5",
|
|
151
|
+
abandon_token: "landed-live",
|
|
152
|
+
status: "merged",
|
|
153
|
+
merged_at: "t",
|
|
154
|
+
owner_terminated: null,
|
|
155
|
+
});
|
|
156
|
+
assertEquals(await abandonStatusForToken(data, "landed-term"), {
|
|
157
|
+
prKey: "o/r#4",
|
|
158
|
+
status: "merged",
|
|
159
|
+
abandoned: true,
|
|
160
|
+
});
|
|
161
|
+
assertEquals(await abandonStatusForToken(data, "landed-live"), {
|
|
162
|
+
prKey: "o/r#5",
|
|
163
|
+
status: "merged",
|
|
164
|
+
abandoned: false,
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
137
168
|
test("abandonStatusForToken fails CLOSED on a non-string derived status", async () => {
|
|
138
169
|
// A malformed/missing derived_status must never be reported as `abandoned:false`
|
|
139
170
|
// (that would let a cancelled run proceed with irreversible side effects). It throws
|
package/app/abandon.ts
CHANGED
|
@@ -24,12 +24,15 @@
|
|
|
24
24
|
import type { DataLayer } from "@nanobpm/urban";
|
|
25
25
|
import { publicBaseUrl } from "./blackboard.ts";
|
|
26
26
|
import { trackingTargetFor } from "./instanceTracking.ts";
|
|
27
|
+
import { OWNER_TERMINATED_COLUMN } from "./prTrackingReadModel.ts";
|
|
27
28
|
|
|
28
29
|
/** The one derived-status value meaning a PR is terminally abandoned — the run must not be worked on
|
|
29
30
|
* further. Two disjoint producers surface a row here, and both are non-completion terminals that must
|
|
30
31
|
* stop a servicing agent:
|
|
31
32
|
* 1. an explicit **cancel** of a live convergence/merge run (Urban's cancel primitive terminates
|
|
32
|
-
* the instance; the `instanceTracking` `onTerminated.set` edge derives `abandoned` on read)
|
|
33
|
+
* the instance; the `instanceTracking` `onTerminated.set` edge derives `abandoned` on read) —
|
|
34
|
+
* but ONLY when the PR did NOT land: the merge-aware derivation (app/prTrackingReadModel.ts,
|
|
35
|
+
* issue #959) folds a terminated-but-MERGED PR to `merged`, never `abandoned`, and
|
|
33
36
|
* 2. **`abandonClosedPr`** reconciling a wave-member PR that was **closed on GitHub without
|
|
34
37
|
* merging** (#352) — for both `pull_requests` and its `plan_tasks` — by writing the base row.
|
|
35
38
|
* Convergence/merge terminal states `converged`/`merged` are NOT abandonment. In either abandoned
|
|
@@ -116,7 +119,10 @@ export async function abandonStatusForToken(
|
|
|
116
119
|
);
|
|
117
120
|
}
|
|
118
121
|
const status = rawStatus;
|
|
119
|
-
|
|
122
|
+
// `derived_status` reports a landed PR as `merged` even when its run was terminated (issue #959),
|
|
123
|
+
// so termination is read from its own column: a terminated owner ALWAYS aborts the agent.
|
|
124
|
+
const ownerTerminated = Boolean(row[OWNER_TERMINATED_COLUMN]);
|
|
125
|
+
return { prKey: row.pr_key, status, abandoned: isAbandoned(status) || ownerTerminated };
|
|
120
126
|
}
|
|
121
127
|
|
|
122
128
|
/** The instruction block appended (verbatim, via `appendPrompt`) to each side-effecting agent's
|
package/app/conformance.test.ts
CHANGED
|
@@ -126,6 +126,23 @@ test("hasDeliveredImplementation: true iff at least one PR landed", async () =>
|
|
|
126
126
|
assert(hasDeliveredImplementation(await gatherConformance(data, PLAN)));
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
+
// Issue #959: a PR that MERGED but whose loop was terminated afterwards (operator cancel, out-of-band
|
|
130
|
+
// merge reconcile, restart cleanup) derived `abandoned` off the `onTerminated` edge — and conformance
|
|
131
|
+
// then refused to count it as landed. The merge-aware derivation folds it to `merged` (merged_at set),
|
|
132
|
+
// so a merged-then-terminated PR IS landed. Model the terminated divergence by seeding the base row at
|
|
133
|
+
// its frozen transient (`converging`) WITH `merged_at` set: the tracking view must report `merged`.
|
|
134
|
+
test("hasDeliveredImplementation: a merged-then-terminated PR (frozen base + merged_at) counts as landed (#959)", async () => {
|
|
135
|
+
const { data, stores } = memData();
|
|
136
|
+
seedPlan(stores);
|
|
137
|
+
seedTask(stores, { id: 1, task_index: 0, task_id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
138
|
+
// Base status frozen at the in-flight transient, but the PR landed (merged_at set) before termination.
|
|
139
|
+
stores["pull_requests"] = [{ pr_key: "acme/widgets#10", status: "converging", merged_at: "2026-11-01T00:00:00Z" }];
|
|
140
|
+
assertEquals(await hasDeliveredImplementationForPlan(data, PLAN), true);
|
|
141
|
+
const d = await gatherConformance(data, PLAN);
|
|
142
|
+
assertEquals(hasDeliveredImplementation(d), true);
|
|
143
|
+
assertEquals(d.slices.find((s) => s.taskId === "t1")?.landed, true);
|
|
144
|
+
});
|
|
145
|
+
|
|
129
146
|
test("hasDeliveredImplementationForPlan: matches the digest without a blackboard scan", async () => {
|
|
130
147
|
const { data, stores } = memData();
|
|
131
148
|
seedPlan(stores);
|
|
@@ -410,3 +410,7 @@ test("PR#944 round 6: a successor-owned converging row is NEVER demoted by a red
|
|
|
410
410
|
assertEquals(stores.pull_requests[0].process_key, "succ");
|
|
411
411
|
assertEquals(stores.pull_requests[0].retire_pending_key, "merge-1", "the pending retirement is left for retryPendingRetirements");
|
|
412
412
|
});
|
|
413
|
+
|
|
414
|
+
test("#959 isOrphanedPr: an audit-only landing (derived merged, base merging, no merged_at) is never orphaned", () => {
|
|
415
|
+
assert(!isOrphanedPr({ status: "merging", derived_status: "merged", termination_reason: null, merged_at: null }));
|
|
416
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Migration guard for 139_pull_requests_backfill_merged_not_abandoned.sql (issue #959): the backfill
|
|
2
|
+
// that re-derives the 23 merged-but-terminated PRs so they show as `merged`, not `abandoned`.
|
|
3
|
+
//
|
|
4
|
+
// Applies the migration to a real in-memory SQLite DB over a pre-existing `pull_requests` + `merges`
|
|
5
|
+
// shape and asserts the contract:
|
|
6
|
+
// * a row with a `merged` audit outcome but a non-`merged` base status (the loop terminated before
|
|
7
|
+
// `merged_at` was stamped) is settled to `status='merged'` + `merged_at` — so the merge-aware
|
|
8
|
+
// derivation reports `merged` however the instance ended;
|
|
9
|
+
// * a row already settled `merged` is left untouched (idempotent);
|
|
10
|
+
// * a row with NO `merged` audit outcome (a genuinely abandoned/closed-unmerged run) is NOT
|
|
11
|
+
// flipped — it stays at its frozen transient so the derivation still reports `abandoned`;
|
|
12
|
+
// * re-running the migration is a no-op (forward-only, idempotent).
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { DatabaseSync } from "node:sqlite";
|
|
15
|
+
import { test } from "node:test";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { assertEquals } from "#test-assert";
|
|
18
|
+
|
|
19
|
+
const MIGRATION_139 = fileURLToPath(
|
|
20
|
+
new URL("../db/migrations/139_pull_requests_backfill_merged_not_abandoned.sql", import.meta.url),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
function migratedDb(): DatabaseSync {
|
|
24
|
+
const db = new DatabaseSync(":memory:");
|
|
25
|
+
db.exec(
|
|
26
|
+
`CREATE TABLE pull_requests (
|
|
27
|
+
pr_key TEXT PRIMARY KEY,
|
|
28
|
+
repo TEXT NOT NULL,
|
|
29
|
+
number INTEGER NOT NULL,
|
|
30
|
+
status TEXT NOT NULL,
|
|
31
|
+
process_key TEXT,
|
|
32
|
+
merged_at TEXT,
|
|
33
|
+
updated_at TEXT
|
|
34
|
+
);
|
|
35
|
+
CREATE TABLE merges (
|
|
36
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
37
|
+
pr_key TEXT NOT NULL,
|
|
38
|
+
outcome TEXT NOT NULL,
|
|
39
|
+
method TEXT,
|
|
40
|
+
detail TEXT,
|
|
41
|
+
at TEXT NOT NULL
|
|
42
|
+
);`,
|
|
43
|
+
);
|
|
44
|
+
return db;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function addPr(db: DatabaseSync, prKey: string, status: string, mergedAt: string | null = null): void {
|
|
48
|
+
db.prepare(
|
|
49
|
+
"INSERT INTO pull_requests (pr_key, repo, number, status, process_key, merged_at, updated_at) VALUES (?, 'o/r', 1, ?, 'PI-1', ?, 't0')",
|
|
50
|
+
).run(prKey, status, mergedAt);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function addMerge(db: DatabaseSync, prKey: string, outcome: string, at: string): void {
|
|
54
|
+
db.prepare("INSERT INTO merges (pr_key, outcome, method, detail, at) VALUES (?, ?, 'squash', 'd', ?)").run(prKey, outcome, at);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function row(db: DatabaseSync, prKey: string): { status: string; merged_at: string | null } {
|
|
58
|
+
return db.prepare("SELECT status, merged_at FROM pull_requests WHERE pr_key = ?").get(prKey) as {
|
|
59
|
+
status: string;
|
|
60
|
+
merged_at: string | null;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const SQL = () => readFileSync(MIGRATION_139, "utf8");
|
|
65
|
+
|
|
66
|
+
test("139 backfills a merged-but-terminated PR (frozen transient + merged audit) to status='merged' + merged_at", () => {
|
|
67
|
+
const db = migratedDb();
|
|
68
|
+
// The #959 case: PR landed (merged audit row) but the loop was terminated before merged_at was set,
|
|
69
|
+
// leaving the base status frozen at its in-flight transient.
|
|
70
|
+
addPr(db, "o/r#landed", "converging", null);
|
|
71
|
+
addMerge(db, "o/r#landed", "merged", "2026-11-01T02:03:04Z");
|
|
72
|
+
db.exec(SQL());
|
|
73
|
+
const r = row(db, "o/r#landed");
|
|
74
|
+
assertEquals(r.status, "merged");
|
|
75
|
+
assertEquals(r.merged_at, "2026-11-01T02:03:04Z", "merged_at is stamped from the earliest merged audit row");
|
|
76
|
+
db.close();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("139 leaves an already-merged row untouched (idempotent, never overwrites merged_at)", () => {
|
|
80
|
+
const db = migratedDb();
|
|
81
|
+
addPr(db, "o/r#already", "merged", "2026-10-30T00:00:00Z");
|
|
82
|
+
addMerge(db, "o/r#already", "merged", "2026-10-30T00:00:00Z");
|
|
83
|
+
db.exec(SQL());
|
|
84
|
+
const r = row(db, "o/r#already");
|
|
85
|
+
assertEquals(r.status, "merged");
|
|
86
|
+
assertEquals(r.merged_at, "2026-10-30T00:00:00Z");
|
|
87
|
+
db.close();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("139 does NOT flip a closed-unmerged / genuinely-abandoned PR (no merged audit outcome)", () => {
|
|
91
|
+
const db = migratedDb();
|
|
92
|
+
// A PR closed without merging: terminated → abandoned, NO merged audit row. Must stay un-merged.
|
|
93
|
+
addPr(db, "o/r#closed", "converging", null);
|
|
94
|
+
addMerge(db, "o/r#closed", "abandoned", "2026-11-01T00:00:00Z");
|
|
95
|
+
// A still-in-flight PR with no merge record at all.
|
|
96
|
+
addPr(db, "o/r#inflight", "waiting_merge", null);
|
|
97
|
+
db.exec(SQL());
|
|
98
|
+
assertEquals(row(db, "o/r#closed").status, "converging", "no merged outcome → not flipped to merged");
|
|
99
|
+
assertEquals(row(db, "o/r#closed").merged_at, null);
|
|
100
|
+
assertEquals(row(db, "o/r#inflight").status, "waiting_merge");
|
|
101
|
+
assertEquals(row(db, "o/r#inflight").merged_at, null);
|
|
102
|
+
db.close();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("139 is idempotent — a second run is a no-op", () => {
|
|
106
|
+
const db = migratedDb();
|
|
107
|
+
addPr(db, "o/r#landed", "waiting_merge", null);
|
|
108
|
+
addMerge(db, "o/r#landed", "merged", "2026-11-01T02:03:04Z");
|
|
109
|
+
db.exec(SQL());
|
|
110
|
+
const first = row(db, "o/r#landed");
|
|
111
|
+
db.exec(SQL());
|
|
112
|
+
const second = row(db, "o/r#landed");
|
|
113
|
+
assertEquals(second, first);
|
|
114
|
+
db.close();
|
|
115
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Issue #959 — the canonical "merged beats terminated" effective-status derivation for a PR.
|
|
2
|
+
//
|
|
3
|
+
// THE DEFECT: `pull_requests__tracking.derived_status` mapped ANY terminated run to `abandoned`
|
|
4
|
+
// without consulting the PR's merge state, so the 23 November PRs that MERGED but whose loops were
|
|
5
|
+
// terminated afterwards (operator cancel, out-of-band merge reconcile, restart cleanup) all read
|
|
6
|
+
// "abandoned". These tests pin the fixed semantics against the REAL derivation SQL
|
|
7
|
+
// (`pullRequestTrackingReadModel`, app/prTrackingReadModel.ts) compiled to an in-memory SQLite VIEW
|
|
8
|
+
// over the SAME canonical `urban_instance_state` projection the framework feeds:
|
|
9
|
+
//
|
|
10
|
+
// terminated + merged PR → `merged` (landed wins over terminated)
|
|
11
|
+
// terminated + closed-unmerged PR → `abandoned` (no merged_at → the onTerminated edge)
|
|
12
|
+
// terminated + open PR → `abandoned`
|
|
13
|
+
// live (non-terminated) run → base `status` passes through
|
|
14
|
+
//
|
|
15
|
+
// and the downstream consequence: conformance/retro count a merged-then-terminated PR as LANDED.
|
|
16
|
+
import { DatabaseSync } from "node:sqlite";
|
|
17
|
+
import { test } from "node:test";
|
|
18
|
+
import { registerCanonicalProjections, readModelRegistry } from "@nanobpm/urban";
|
|
19
|
+
import { assert, assertEquals } from "#test-assert";
|
|
20
|
+
import {
|
|
21
|
+
pullRequestTrackingReadModel,
|
|
22
|
+
registerPullRequestTrackingReadModel,
|
|
23
|
+
} from "./prTrackingReadModel.ts";
|
|
24
|
+
|
|
25
|
+
// The projection NAME→physical-table mapping the VIEW's `EXISTS` resolves through.
|
|
26
|
+
registerCanonicalProjections();
|
|
27
|
+
|
|
28
|
+
/** A minimal in-memory DB: the real `pull_requests` base columns the derivation reads (`status`,
|
|
29
|
+
* `process_key`, `merged_at`), the canonical `_urban_instance_state` projection, and the
|
|
30
|
+
* app-owned tracking VIEW compiled from the ONE declaration. */
|
|
31
|
+
function viewDb(): DatabaseSync {
|
|
32
|
+
const db = new DatabaseSync(":memory:");
|
|
33
|
+
db.exec(
|
|
34
|
+
`CREATE TABLE pull_requests (pr_key TEXT PRIMARY KEY, status TEXT, process_key TEXT, merged_at TEXT);
|
|
35
|
+
CREATE TABLE merges (id INTEGER PRIMARY KEY, pr_key TEXT, outcome TEXT, method TEXT, detail TEXT, at TEXT);
|
|
36
|
+
CREATE TABLE _urban_instance_state (process_instance_key TEXT PRIMARY KEY, state TEXT, waiting_on_human INTEGER NOT NULL DEFAULT 0, updated_at TEXT);`,
|
|
37
|
+
);
|
|
38
|
+
db.exec(pullRequestTrackingReadModel.viewDdl());
|
|
39
|
+
return db;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function addPr(
|
|
43
|
+
db: DatabaseSync,
|
|
44
|
+
prKey: string,
|
|
45
|
+
opts: { status: string; processKey: string | null; mergedAt: string | null },
|
|
46
|
+
): void {
|
|
47
|
+
db.prepare("INSERT INTO pull_requests (pr_key, status, process_key, merged_at) VALUES (?, ?, ?, ?)").run(
|
|
48
|
+
prKey,
|
|
49
|
+
opts.status,
|
|
50
|
+
opts.processKey,
|
|
51
|
+
opts.mergedAt,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function terminate(db: DatabaseSync, processKey: string): void {
|
|
56
|
+
db.prepare(
|
|
57
|
+
"INSERT INTO _urban_instance_state (process_instance_key, state, waiting_on_human, updated_at) VALUES (?, 'TERMINATED', 0, 't')",
|
|
58
|
+
).run(processKey);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function derivedStatus(db: DatabaseSync, prKey: string): string {
|
|
62
|
+
const r = db.prepare("SELECT derived_status FROM pull_requests__tracking WHERE pr_key = ?").get(prKey) as {
|
|
63
|
+
derived_status: string;
|
|
64
|
+
};
|
|
65
|
+
return r.derived_status;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
test("terminated instance + merged PR (merged_at set) derives `merged`, not `abandoned`", () => {
|
|
69
|
+
const db = viewDb();
|
|
70
|
+
// The #959 case: the loop was terminated AFTER the PR landed; base status frozen at its transient.
|
|
71
|
+
addPr(db, "o/r#merged", { status: "converging", processKey: "514213", mergedAt: "2026-11-01T00:00:00Z" });
|
|
72
|
+
terminate(db, "514213");
|
|
73
|
+
assertEquals(derivedStatus(db, "o/r#merged"), "merged");
|
|
74
|
+
db.close();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("terminated instance + closed-unmerged PR (no merged_at) derives `abandoned`", () => {
|
|
78
|
+
const db = viewDb();
|
|
79
|
+
addPr(db, "o/r#closed", { status: "converging", processKey: "222", mergedAt: null });
|
|
80
|
+
terminate(db, "222");
|
|
81
|
+
assertEquals(derivedStatus(db, "o/r#closed"), "abandoned");
|
|
82
|
+
db.close();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("terminated instance + open PR (no merged_at) derives `abandoned`", () => {
|
|
86
|
+
const db = viewDb();
|
|
87
|
+
addPr(db, "o/r#open", { status: "waiting_review", processKey: "333", mergedAt: null });
|
|
88
|
+
terminate(db, "333");
|
|
89
|
+
assertEquals(derivedStatus(db, "o/r#open"), "abandoned");
|
|
90
|
+
db.close();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a live (non-terminated) in-flight run passes its base status through unchanged", () => {
|
|
94
|
+
const db = viewDb();
|
|
95
|
+
addPr(db, "o/r#live", { status: "converging", processKey: "444", mergedAt: null });
|
|
96
|
+
db.prepare(
|
|
97
|
+
"INSERT INTO _urban_instance_state (process_instance_key, state, waiting_on_human, updated_at) VALUES ('444', 'ACTIVE', 0, 't')",
|
|
98
|
+
).run();
|
|
99
|
+
assertEquals(derivedStatus(db, "o/r#live"), "converging");
|
|
100
|
+
db.close();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("a merged PR whose instance COMPLETED normally (no terminated edge) reads base `merged`", () => {
|
|
104
|
+
const db = viewDb();
|
|
105
|
+
addPr(db, "o/r#done", { status: "merged", processKey: "555", mergedAt: "2026-11-01T00:00:00Z" });
|
|
106
|
+
db.prepare(
|
|
107
|
+
"INSERT INTO _urban_instance_state (process_instance_key, state, waiting_on_human, updated_at) VALUES ('555', 'COMPLETED', 0, 't')",
|
|
108
|
+
).run();
|
|
109
|
+
assertEquals(derivedStatus(db, "o/r#done"), "merged");
|
|
110
|
+
db.close();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("a landed PR with no instance-state row (null process_key) still derives merged off merged_at", () => {
|
|
114
|
+
const db = viewDb();
|
|
115
|
+
// merged_at set but the instance row is gone/null: the merged WHEN keys on merged_at, not the join.
|
|
116
|
+
addPr(db, "o/r#nullkey", { status: "converging", processKey: null, mergedAt: "2026-11-01T00:00:00Z" });
|
|
117
|
+
assertEquals(derivedStatus(db, "o/r#nullkey"), "merged");
|
|
118
|
+
db.close();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("a landed PR whose instance-state row was pruned still derives merged off merged_at", () => {
|
|
122
|
+
const db = viewDb();
|
|
123
|
+
addPr(db, "o/r#pruned", { status: "waiting_merge", processKey: "777", mergedAt: "2026-11-01T00:00:00Z" });
|
|
124
|
+
assertEquals(derivedStatus(db, "o/r#pruned"), "merged");
|
|
125
|
+
db.close();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("a run terminated between pr.merge's audit and pr.mark-merged (merged_at NULL) derives merged", () => {
|
|
129
|
+
const db = viewDb();
|
|
130
|
+
addPr(db, "o/r#gap", { status: "merging", processKey: "888", mergedAt: null });
|
|
131
|
+
db.prepare("INSERT INTO merges (pr_key, outcome, method, detail, at) VALUES ('o/r#gap', 'merged', 'squash', '', 't')").run();
|
|
132
|
+
terminate(db, "888");
|
|
133
|
+
assertEquals(derivedStatus(db, "o/r#gap"), "merged");
|
|
134
|
+
db.close();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("a non-merged audit outcome does not fold a terminated run to merged", () => {
|
|
138
|
+
const db = viewDb();
|
|
139
|
+
addPr(db, "o/r#rc", { status: "merging", processKey: "889", mergedAt: null });
|
|
140
|
+
db.prepare("INSERT INTO merges (pr_key, outcome, method, detail, at) VALUES ('o/r#rc', 'reconverge', 'x', '', 't')").run();
|
|
141
|
+
terminate(db, "889");
|
|
142
|
+
assertEquals(derivedStatus(db, "o/r#rc"), "abandoned");
|
|
143
|
+
db.close();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("owner_terminated exposes termination separately: set for a terminated merged PR, NULL for a live one", () => {
|
|
147
|
+
const db = viewDb();
|
|
148
|
+
addPr(db, "o/r#mt", { status: "merged", processKey: "901", mergedAt: "t" });
|
|
149
|
+
terminate(db, "901");
|
|
150
|
+
addPr(db, "o/r#ml", { status: "merged", processKey: "902", mergedAt: "t" });
|
|
151
|
+
const get = (k: string) =>
|
|
152
|
+
(db.prepare("SELECT derived_status, owner_terminated FROM pull_requests__tracking WHERE pr_key = ?").get(k) as {
|
|
153
|
+
derived_status: string;
|
|
154
|
+
owner_terminated: number | null;
|
|
155
|
+
});
|
|
156
|
+
assertEquals(get("o/r#mt").derived_status, "merged");
|
|
157
|
+
assertEquals(get("o/r#mt").owner_terminated, 1);
|
|
158
|
+
assertEquals(get("o/r#ml").owner_terminated, null);
|
|
159
|
+
db.close();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// The whole fix hinges on the app-owned model claiming the EXACT view name urban's provisioner would
|
|
163
|
+
// otherwise manage, so the framework defers to it. Pin that contract: the model is named
|
|
164
|
+
// `pull_requests__tracking`, registers into the process-wide registry (idempotently), and the
|
|
165
|
+
// registration is what `main.ts` invokes before `runFromEnv`.
|
|
166
|
+
test("the app-owned model is named pull_requests__tracking and registers idempotently", () => {
|
|
167
|
+
assertEquals(pullRequestTrackingReadModel.decl.name, "pull_requests__tracking");
|
|
168
|
+
assertEquals(pullRequestTrackingReadModel.decl.baseTable, "pull_requests");
|
|
169
|
+
registerPullRequestTrackingReadModel();
|
|
170
|
+
const registered = readModelRegistry.get("pull_requests__tracking");
|
|
171
|
+
assert(registered, "the model must be registered so the framework defers to it");
|
|
172
|
+
assertEquals(registered.decl.name, "pull_requests__tracking");
|
|
173
|
+
// Idempotent: re-registering the identical definition is a no-op (never throws).
|
|
174
|
+
registerPullRequestTrackingReadModel();
|
|
175
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// nano-workforce — the ONE canonical "what is this PR's effective status" derivation (issue #959).
|
|
2
|
+
//
|
|
3
|
+
// THE DEFECT. `derived_status` came from the `instanceTracking` `onTerminated.set` edge, which maps
|
|
4
|
+
// **any** terminated run to `abandoned` without consulting the PR's merge state. But a run is
|
|
5
|
+
// terminated AFTER its PR merged in several legitimate ways — an operator cancels a loop that is
|
|
6
|
+
// redundant after a merge, an out-of-band merge (Mergify / a manual click) is reconciled, a restart
|
|
7
|
+
// or upgrade cleans up. Every one of those was recorded "abandoned", which made the status
|
|
8
|
+
// untrustworthy (an operator can't tell dropped work from shipped work) and skewed retro/conformance
|
|
9
|
+
// stats (`LANDED_PR_STATUSES` excludes `abandoned`, so merged PRs didn't count as landed).
|
|
10
|
+
//
|
|
11
|
+
// THE FIX (the defect class, not the instance). `abandoned` must mean "terminated AND the PR did not
|
|
12
|
+
// merge". When the PR landed — recorded on the base row's `merged_at` (set by `pr.mark-merged` or the
|
|
13
|
+
// out-of-band `retireTerminalReviewWait`) — the effective status is `merged` REGARDLESS of how the
|
|
14
|
+
// instance ended. This module owns that derivation ONCE, as a `defineReadModel` over the SAME
|
|
15
|
+
// canonical `urban_instance_state` projection the framework's tracking view uses, plus the base row's
|
|
16
|
+
// own `merged_at` column.
|
|
17
|
+
//
|
|
18
|
+
// HOW IT SHIPS. The model is named `pull_requests__tracking` — the EXACT managed-VIEW name urban's
|
|
19
|
+
// `instanceTracking` provisioner would otherwise create for the `pull_requests` binding
|
|
20
|
+
// (`instanceTrackingReadModelTarget`). Registering it into the process-wide `readModelRegistry` makes
|
|
21
|
+
// the framework's mount DEFER to this app-owned derivation: `provisionInstanceTrackingViews` refuses
|
|
22
|
+
// to clobber a registered read model of the same name (it logs and skips), and `mountWorkers`
|
|
23
|
+
// (`readModelRegistry.ensureViews`) — which runs BEFORE `mountInstanceTracking` — applies this VIEW.
|
|
24
|
+
// So every reader that already routes through `pull_requests__tracking.derived_status` (the abandon
|
|
25
|
+
// check, conformance, retro, the PR list, the read model) picks up the merge-aware edge with NO
|
|
26
|
+
// per-reader change — the "one canonical derivation, no per-reader logic" the issue asks for.
|
|
27
|
+
//
|
|
28
|
+
// PRECEDENCE (merged > terminated > waiting-human > base). The merged `WHEN` is emitted FIRST so a
|
|
29
|
+
// landed PR reads `merged` even when its instance was terminated; a terminated-unmerged run still
|
|
30
|
+
// reads `abandoned`; a waiting-on-human edge (none for pull_requests today, but kept for parity with
|
|
31
|
+
// the framework's derivation) still applies to a live, unmerged run; otherwise the worker-owned base
|
|
32
|
+
// `status` passes through.
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
and,
|
|
36
|
+
caseWhen,
|
|
37
|
+
col,
|
|
38
|
+
defineReadModel,
|
|
39
|
+
type Expr,
|
|
40
|
+
eq,
|
|
41
|
+
exists,
|
|
42
|
+
isNotNull,
|
|
43
|
+
lit,
|
|
44
|
+
not,
|
|
45
|
+
or,
|
|
46
|
+
pcol,
|
|
47
|
+
type ReadModel,
|
|
48
|
+
readModelRegistry,
|
|
49
|
+
when,
|
|
50
|
+
} from "@nanobpm/urban";
|
|
51
|
+
import { trackingBindingFor, trackingTargetFor } from "./instanceTracking.ts";
|
|
52
|
+
|
|
53
|
+
const TABLE = "pull_requests";
|
|
54
|
+
|
|
55
|
+
/** The engine-truth terminal lifecycle state the terminal edge keys on (mirrors urban's
|
|
56
|
+
* `TERMINATED_STATE`). Duplicated as a literal so this module derives from the public DSL primitives
|
|
57
|
+
* (`exists`/`pcol`) without reaching into urban's internals. */
|
|
58
|
+
const TERMINATED = "TERMINATED";
|
|
59
|
+
|
|
60
|
+
/** `EXISTS(urban_instance_state WHERE process_instance_key = base.<keyField> AND state='TERMINATED')`
|
|
61
|
+
* — the SAME terminal edge the framework's tracking derivation uses, keyed off the binding's
|
|
62
|
+
* `keyField` (`process_key`) so it can never drift from the reconciler's notion of "which column
|
|
63
|
+
* holds the engine instance key". */
|
|
64
|
+
function terminatedEdge(keyField: string): Expr {
|
|
65
|
+
return exists(
|
|
66
|
+
"urban_instance_state",
|
|
67
|
+
and(eq(pcol("process_instance_key"), col(keyField)), eq(pcol("state"), lit(TERMINATED))),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `EXISTS(urban_open_user_tasks WHERE process_instance_key = base.<keyField>)` — the wait-on-human
|
|
72
|
+
* edge, kept for parity with the framework's derivation (a binding that adds `onWaitingHuman` later
|
|
73
|
+
* still derives it correctly). */
|
|
74
|
+
function waitingHumanEdge(keyField: string): Expr {
|
|
75
|
+
return exists("urban_open_user_tasks", eq(pcol("process_instance_key"), col(keyField)));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The landed signal: the base row's `merged_at` stamp OR a durable `merges.outcome='merged'` audit
|
|
79
|
+
* row. `pr.merge` writes the audit BEFORE the separate `pr.mark-merged` task stamps `merged_at`, so a
|
|
80
|
+
* run terminated between the two still landed — the audit alone must be sufficient. Neither signal
|
|
81
|
+
* depends on a retained instance-state row (a pruned/null `process_key` still reads `merged`). */
|
|
82
|
+
function landedEdge(): Expr {
|
|
83
|
+
return or(
|
|
84
|
+
isNotNull(col("merged_at")),
|
|
85
|
+
exists("merges", and(eq(pcol("pr_key"), col("pr_key")), eq(pcol("outcome"), lit("merged")))),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The merge-aware effective-status derivation for the `pull_requests` binding:
|
|
90
|
+
*
|
|
91
|
+
* CASE
|
|
92
|
+
* WHEN <merged_at set OR merged audit row> THEN 'merged' -- landed wins, however the run ended
|
|
93
|
+
* WHEN <terminated> THEN <onTerminated> -- terminated-unmerged = abandoned
|
|
94
|
+
* WHEN <waiting-on-human> AND NOT <terminated> THEN <onWaitingHuman>
|
|
95
|
+
* ELSE base.<statusField> -- the worker-owned transient
|
|
96
|
+
* END
|
|
97
|
+
*
|
|
98
|
+
* The merged `WHEN` precedes the terminated one so a landed PR is `merged` no matter how its run
|
|
99
|
+
* ended; the terminated-unmerged branch preserves the manifest's `onTerminated.set.status` value
|
|
100
|
+
* (`abandoned`) rather than hard-coding it, so the manifest stays the source of truth for the
|
|
101
|
+
* not-landed terminal. */
|
|
102
|
+
export function derivePrEffectiveStatusExpr(): Expr {
|
|
103
|
+
const binding = trackingBindingFor(TABLE);
|
|
104
|
+
const statusField = binding.statusField ?? "status";
|
|
105
|
+
const keyField = binding.keyField;
|
|
106
|
+
const terminated = terminatedEdge(keyField);
|
|
107
|
+
|
|
108
|
+
const whens = [
|
|
109
|
+
// Landed beats terminated: a merged PR is `merged` even when its loop was terminated afterwards.
|
|
110
|
+
when(landedEdge(), lit("merged")),
|
|
111
|
+
];
|
|
112
|
+
const terminalStatus = binding.onTerminated?.set?.[statusField];
|
|
113
|
+
if (typeof terminalStatus === "string") {
|
|
114
|
+
whens.push(when(terminated, lit(terminalStatus)));
|
|
115
|
+
}
|
|
116
|
+
const waitingStatus = binding.onWaitingHuman?.set?.[statusField];
|
|
117
|
+
if (typeof waitingStatus === "string") {
|
|
118
|
+
whens.push(when(and(waitingHumanEdge(keyField), not(terminated)), lit(waitingStatus)));
|
|
119
|
+
}
|
|
120
|
+
return caseWhen(whens, col(statusField));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The derived column exposing "this PR's owning run was terminated" SEPARATELY from the effective
|
|
124
|
+
* status. `derived_status` deliberately reports a landed PR as `merged` even when its run was
|
|
125
|
+
* terminated, so it can no longer carry the cancellation signal on its own — the abort protocol
|
|
126
|
+
* (`abandonStatusForToken`) reads THIS column too, so terminating a redundant post-merge round still
|
|
127
|
+
* tells its agent to stop. `1` when terminated, otherwise `NULL`. */
|
|
128
|
+
export const OWNER_TERMINATED_COLUMN = "owner_terminated";
|
|
129
|
+
|
|
130
|
+
/** `CASE WHEN <terminated> THEN 1 END` over the same terminal edge `derived_status` uses. */
|
|
131
|
+
export function deriveOwnerTerminatedExpr(): Expr {
|
|
132
|
+
return caseWhen([when(terminatedEdge(trackingBindingFor(TABLE).keyField), lit(1))], lit(null));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The app-owned `pull_requests__tracking` read model. `selectBaseColumns: true` (the default) so the
|
|
136
|
+
* VIEW re-exports `pull_requests.*` plus the derived `derived_status` — byte-compatible with the
|
|
137
|
+
* framework's own tracking VIEW shape, so every existing reader keeps working unchanged. */
|
|
138
|
+
export const pullRequestTrackingReadModel: ReadModel = defineReadModel({
|
|
139
|
+
name: trackingTargetFor(TABLE).view,
|
|
140
|
+
baseTable: TABLE,
|
|
141
|
+
derive: {
|
|
142
|
+
[trackingTargetFor(TABLE).statusColumn]: derivePrEffectiveStatusExpr(),
|
|
143
|
+
[OWNER_TERMINATED_COLUMN]: deriveOwnerTerminatedExpr(),
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
/** Register the merge-aware tracking VIEW into the process-wide `readModelRegistry` so the framework
|
|
148
|
+
* provisions THIS derivation (and defers its own). Call ONCE at boot, BEFORE `runFromEnv` mounts
|
|
149
|
+
* workers/instance-tracking. Idempotent: re-registering the identical definition is a no-op. */
|
|
150
|
+
export function registerPullRequestTrackingReadModel(): void {
|
|
151
|
+
readModelRegistry.register(pullRequestTrackingReadModel);
|
|
152
|
+
}
|
package/app/retro.test.ts
CHANGED
|
@@ -140,6 +140,17 @@ test("isPlanComplete: true when every task is settled (terminal PR or skipped/bl
|
|
|
140
140
|
assertEquals(await isPlanComplete(data, PLAN), true);
|
|
141
141
|
});
|
|
142
142
|
|
|
143
|
+
// Issue #959: a PR that MERGED but whose loop was terminated afterwards derived `abandoned` off the
|
|
144
|
+
// `onTerminated` edge. Retro must still treat it as the landed terminal it is. Model the terminated
|
|
145
|
+
// divergence by seeding the base row at its frozen in-flight transient WITH `merged_at` set: the
|
|
146
|
+
// merge-aware tracking view folds it to `merged`, so the plan reads complete (not stuck-in-flight).
|
|
147
|
+
test("isPlanComplete: a merged-then-terminated PR (frozen base + merged_at) is terminal/landed (#959)", async () => {
|
|
148
|
+
const { data, stores } = memData();
|
|
149
|
+
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
150
|
+
stores["pull_requests"] = [{ pr_key: "acme/widgets#10", status: "converging", merged_at: "2026-11-01T00:00:00Z" }];
|
|
151
|
+
assertEquals(await isPlanComplete(data, PLAN), true);
|
|
152
|
+
});
|
|
153
|
+
|
|
143
154
|
test("isPlanComplete: an empty plan has nothing to retrospect", async () => {
|
|
144
155
|
const { data } = memData();
|
|
145
156
|
assertEquals(await isPlanComplete(data, PLAN), false);
|
package/app/service.ts
CHANGED
|
@@ -1464,7 +1464,10 @@ export function isOrphanedPr(p: {
|
|
|
1464
1464
|
// Provenance only explains the CURRENT owner's termination: a reason bound to a different
|
|
1465
1465
|
// (predecessor) instance never masks an orphaned successor.
|
|
1466
1466
|
!hasOwnTerminationReason(p) &&
|
|
1467
|
-
p.merged_at == null
|
|
1467
|
+
p.merged_at == null &&
|
|
1468
|
+
// #959: a landing recorded only in the merge audit derives `merged` (base still `merging`,
|
|
1469
|
+
// `merged_at` NULL) — that PR landed, it is not orphaned.
|
|
1470
|
+
p.derived_status !== "merged"
|
|
1468
1471
|
);
|
|
1469
1472
|
}
|
|
1470
1473
|
|
|
@@ -2053,7 +2056,7 @@ async function classifyWaveTarget(
|
|
|
2053
2056
|
token: string,
|
|
2054
2057
|
): Promise<"cleared" | "closed" | "pending"> {
|
|
2055
2058
|
const tracked = await prsTracking(data).get(prKey);
|
|
2056
|
-
if (tracked && tracked.status === "merged") return "cleared"; // worker-owned terminal, passes through the derive edge unchanged
|
|
2059
|
+
if (tracked && (tracked.status === "merged" || tracked.derived_status === "merged")) return "cleared"; // worker-owned terminal, passes through the derive edge unchanged
|
|
2057
2060
|
if (tracked && tracked.derived_status === ABANDONED_STATUS) return "cleared"; // ADR-0065 derive-only terminal → non-blocking, no re-reconcile needed
|
|
2058
2061
|
const parsed = parsePr(prKey);
|
|
2059
2062
|
if (!parsed) return "cleared"; // unparseable ref can't be checked → never wedge the barrier
|
|
@@ -2139,7 +2142,12 @@ export async function mergeLaneDecisionForPr(data: DataLayer, prKey: string): Pr
|
|
|
2139
2142
|
// cancelled/terminated (derive-only terminal → `derived_status === "abandoned"`; the base row is
|
|
2140
2143
|
// still frozen at its transient). Reading only base `status` here missed a cancelled member and
|
|
2141
2144
|
// stalled/misrouted the lane.
|
|
2142
|
-
|
|
2145
|
+
// #959: an audit-only landing (merge recorded, `pr.mark-merged` never ran) derives `merged` while
|
|
2146
|
+
// the base row is still `merging` — it is just as complete as a base `merged` row.
|
|
2147
|
+
if (
|
|
2148
|
+
lanePr &&
|
|
2149
|
+
(lanePr.status === "merged" || lanePr.derived_status === "merged" || lanePr.derived_status === "abandoned")
|
|
2150
|
+
) {
|
|
2143
2151
|
completedPrKeys.add(lanePr.pr_key);
|
|
2144
2152
|
}
|
|
2145
2153
|
}
|
|
@@ -319,3 +319,47 @@ test("startPlan re-admits a derive-only-abandoned epic (base 'dispatched', deriv
|
|
|
319
319
|
assertEquals(stores.plan_tasks.rows.length, 0);
|
|
320
320
|
});
|
|
321
321
|
});
|
|
322
|
+
|
|
323
|
+
// #959: an audit-only landing (merge recorded in `merges`, `pr.mark-merged` never ran) derives
|
|
324
|
+
// `merged` while the base row is still `merging`. It must count as lane-complete and never surface as
|
|
325
|
+
// `orphaned` in /status.
|
|
326
|
+
test("#959 mergeLaneDecisionForPr treats an audit-only landed (derived merged) predecessor as complete", async () => {
|
|
327
|
+
const PLAN_KEY = "o/r#100";
|
|
328
|
+
const stores: Stores = {
|
|
329
|
+
plan_tasks: {
|
|
330
|
+
rows: [
|
|
331
|
+
{ id: 1, plan_key: PLAN_KEY, task_id: "a", pr_key: "o/r#1" },
|
|
332
|
+
{ id: 2, plan_key: PLAN_KEY, task_id: "b", pr_key: "o/r#2" },
|
|
333
|
+
],
|
|
334
|
+
key: "id",
|
|
335
|
+
},
|
|
336
|
+
plan_merge_exclusions: {
|
|
337
|
+
rows: [{ id: 1, plan_key: PLAN_KEY, task_a: "a", task_b: "b", files: JSON.stringify(["shared.ts"]), source: "file-overlap" }],
|
|
338
|
+
key: "id",
|
|
339
|
+
},
|
|
340
|
+
plan_task_deps: { rows: [], key: "plan_key" },
|
|
341
|
+
pull_requests: {
|
|
342
|
+
rows: [
|
|
343
|
+
{ pr_key: "o/r#1", repo: "o/r", number: 1, status: "merging", derived_status: "merged", merged_at: null },
|
|
344
|
+
{ pr_key: "o/r#2", repo: "o/r", number: 2, status: "converging", derived_status: "converging" },
|
|
345
|
+
],
|
|
346
|
+
key: "pr_key",
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
const decision = await mergeLaneDecisionForPr(memData(stores), "o/r#2");
|
|
350
|
+
assertEquals(decision?.isHeld, false);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("#959 activePrs does not surface an audit-only landed (derived merged) PR as orphaned", async () => {
|
|
354
|
+
const stores: Stores = {
|
|
355
|
+
pull_requests: {
|
|
356
|
+
rows: [
|
|
357
|
+
{ pr_key: "o/r#1", repo: "o/r", number: 1, url: "u1", status: "merging", derived_status: "merged", merged_at: null, current_round: 1, updated_at: "2024-01-01" },
|
|
358
|
+
],
|
|
359
|
+
key: "pr_key",
|
|
360
|
+
},
|
|
361
|
+
escalations: { rows: [], key: "id" },
|
|
362
|
+
};
|
|
363
|
+
const active = await activePrs(memData(stores));
|
|
364
|
+
assertEquals(active.some((p) => p.status === "orphaned"), false);
|
|
365
|
+
});
|
package/build-info.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.210.
|
|
3
|
-
"gitSha": "
|
|
4
|
-
"gitTag": "v0.210.
|
|
5
|
-
"builtAt": "2026-11-
|
|
2
|
+
"version": "0.210.2",
|
|
3
|
+
"gitSha": "56f0ff4c4a3a0d8a3cf68745b1f7b9bf539987da",
|
|
4
|
+
"gitTag": "v0.210.2",
|
|
5
|
+
"builtAt": "2026-11-14T15:30:46.025Z"
|
|
6
6
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
-- Backfill for issue #959: merged PRs whose run was terminated afterwards showed as "abandoned".
|
|
2
|
+
--
|
|
3
|
+
-- ROOT CAUSE. `pull_requests__tracking.derived_status` mapped ANY terminated run to `abandoned`
|
|
4
|
+
-- without consulting the PR's merge state. Of the 24 top-level runs terminated in November, 23
|
|
5
|
+
-- belong to PRs that MERGED on GitHub — an operator cancelled a redundant post-merge loop, an
|
|
6
|
+
-- out-of-band merge (Mergify / a manual click) was reconciled, or a restart/upgrade cleaned up. Every
|
|
7
|
+
-- one read "abandoned", which made the status untrustworthy and skewed retro/conformance stats
|
|
8
|
+
-- (`LANDED_PR_STATUSES` excludes `abandoned`, so merged PRs didn't count as landed).
|
|
9
|
+
--
|
|
10
|
+
-- THE FIX is the merge-aware derivation (app/prTrackingReadModel.ts): a landed PR now reads `merged`
|
|
11
|
+
-- regardless of how its instance ended, keyed off the base row's `merged_at`. That derivation is
|
|
12
|
+
-- merge-aware ONLY when `merged_at` is set — but several out-of-band/reconciled merges terminated the
|
|
13
|
+
-- loop BEFORE `pr.mark-merged` (or the review-wait retire) could stamp `merged_at`, leaving the base
|
|
14
|
+
-- row frozen at its in-flight transient (`converging`/`waiting_merge`/…) with `merged_at` NULL. For
|
|
15
|
+
-- those rows the derivation still has no merge signal, so they would keep reading `abandoned`.
|
|
16
|
+
--
|
|
17
|
+
-- This migration re-derives the existing rows so the 23 show as `merged`: any `pull_requests` row
|
|
18
|
+
-- that has a `merged` outcome in its `merges` audit trail but whose base `status` is not already
|
|
19
|
+
-- `merged` (and that has no `merged_at` yet) is stamped `status='merged'` + `merged_at`. The audit
|
|
20
|
+
-- trail (`merges.outcome='merged'`) is the canonical record that the PR landed — written by
|
|
21
|
+
-- `pr.mark-merged`, the merge worker's already-merged short-circuit, and the out-of-band retire path.
|
|
22
|
+
-- With the base row settled to `merged`, the derivation reads `merged` whether or not the instance
|
|
23
|
+
-- was later terminated (the terminated edge is gated on `merged_at IS NULL`).
|
|
24
|
+
--
|
|
25
|
+
-- Idempotent and additive-only: it touches only rows with a recorded `merged` audit outcome that are
|
|
26
|
+
-- not already settled `merged`, and never overwrites an existing `merged_at`. `merged_at` is stamped
|
|
27
|
+
-- from the earliest `merged` audit row (`MIN(m.at)`), falling back to the current timestamp when that
|
|
28
|
+
-- is somehow NULL. Safe to re-run.
|
|
29
|
+
UPDATE pull_requests
|
|
30
|
+
SET status = 'merged',
|
|
31
|
+
merged_at = COALESCE(
|
|
32
|
+
merged_at,
|
|
33
|
+
(SELECT MIN(m.at) FROM merges m WHERE m.pr_key = pull_requests.pr_key AND m.outcome = 'merged'),
|
|
34
|
+
datetime('now')
|
|
35
|
+
),
|
|
36
|
+
updated_at = datetime('now')
|
|
37
|
+
WHERE status <> 'merged'
|
|
38
|
+
AND EXISTS (
|
|
39
|
+
SELECT 1 FROM merges m WHERE m.pr_key = pull_requests.pr_key AND m.outcome = 'merged'
|
|
40
|
+
);
|
package/main.ts
CHANGED
|
@@ -23,10 +23,16 @@ import { type AgenticChannelHandle, mountAgenticChannel } from "./app/agentic/ch
|
|
|
23
23
|
import { makeElementInstanceResolver } from "./app/agentic/element-instance.ts";
|
|
24
24
|
import { mountConsoleRedirect, resolveConsoleOrigin } from "./app/consoleRedirect.ts";
|
|
25
25
|
import { announceEngine, resolveEngineAddress } from "./app/enginePreflight.ts";
|
|
26
|
+
import { registerPullRequestTrackingReadModel } from "./app/prTrackingReadModel.ts";
|
|
26
27
|
import { runEngineReconcile } from "./app/reconcile.ts";
|
|
27
28
|
import { MAX_ROUNDS, pollOnce } from "./app/service.ts";
|
|
28
29
|
import { envVar } from "./app/version.ts";
|
|
29
30
|
|
|
31
|
+
// Issue #959: register the app-owned, merge-aware `pull_requests__tracking` read model BEFORE the
|
|
32
|
+
// runtime mounts workers/instance-tracking, so the framework provisions THIS derivation (a landed PR
|
|
33
|
+
// reads `merged`, never `abandoned`, however its loop ended) instead of the merge-blind default.
|
|
34
|
+
registerPullRequestTrackingReadModel();
|
|
35
|
+
|
|
30
36
|
const PORT = Number(process.env.PR_REVIEW_PORT ?? 3000);
|
|
31
37
|
const POLL_MS = Number(process.env.NANO_PR_POLL_MS ?? 60_000);
|
|
32
38
|
const GITHUB_TOKEN = process.env.GITHUB_TOKEN ?? "";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.210.
|
|
3
|
+
"version": "0.210.2",
|
|
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/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(
|