@nanobpm/nano-workforce 0.210.0 → 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 +12 -0
- package/app/abandon.test.ts +31 -0
- package/app/abandon.ts +8 -2
- package/app/behindBaseRemedy.test.ts +1 -1
- package/app/conformance.test.ts +17 -0
- package/app/failingChecksSupersede.test.ts +288 -1
- package/app/github.test.ts +200 -10
- package/app/github.ts +365 -59
- package/app/loopTermination.test.ts +4 -0
- package/app/maybeEnsureFreshHeadRun.test.ts +1 -1
- package/app/migration139.test.ts +115 -0
- package/app/prTrackingReadModel.test.ts +175 -0
- package/app/prTrackingReadModel.ts +152 -0
- package/app/readiness.test.ts +90 -1
- package/app/readiness.ts +81 -3
- 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/landed-probe/worker.test.ts +1 -1
- package/workers/merge-stall-probe/worker.test.ts +2 -2
- package/workers/reconverge/worker.test.ts +137 -8
- package/workers/reconverge/worker.ts +53 -3
|
@@ -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/readiness.test.ts
CHANGED
|
@@ -554,6 +554,95 @@ test("#856 parsePrTarget agrees with the canonical parsePr on every PR shape (no
|
|
|
554
554
|
}
|
|
555
555
|
});
|
|
556
556
|
|
|
557
|
+
// ── PR #957 review round 3: the readiness `pr` path normalises events too ────────────────────────
|
|
558
|
+
//
|
|
559
|
+
// The `gh pr view` rollup omits `event`, so before this fix the readiness path grouped by name alone
|
|
560
|
+
// and a green `push` run hid a red required `pull_request` run of the same name — `checks-green`
|
|
561
|
+
// reported ready while the PR run was red. `probeOnce` now enriches the rollup through the injected
|
|
562
|
+
// executor (shared transport logic with the merge loop) before parsing.
|
|
563
|
+
|
|
564
|
+
test("#957 probeOnce pr checks-green: a green push run never hides a red pull_request run of the same name", async () => {
|
|
565
|
+
const head = "5fbd262a28987d647621da324bf1a7855a8da500";
|
|
566
|
+
// The `gh pr view` payload: two same-named runs, NO event on either (the CLI exporter omits it).
|
|
567
|
+
const prView = {
|
|
568
|
+
state: "OPEN",
|
|
569
|
+
mergedAt: null,
|
|
570
|
+
mergeStateStatus: "CLEAN",
|
|
571
|
+
isDraft: false,
|
|
572
|
+
headRefOid: head,
|
|
573
|
+
mergeCommit: null,
|
|
574
|
+
statusCheckRollup: [
|
|
575
|
+
{ name: "build", conclusion: "FAILURE", status: "COMPLETED", startedAt: "2026-11-12T00:30:00Z", completedAt: "2026-11-12T00:41:34Z" },
|
|
576
|
+
{ name: "build", conclusion: "SUCCESS", status: "COMPLETED", startedAt: "2026-11-12T00:41:30Z", completedAt: "2026-11-12T00:41:38Z" },
|
|
577
|
+
],
|
|
578
|
+
};
|
|
579
|
+
// The GraphQL event read, keyed by the SAME head, joining by (name, startedAt).
|
|
580
|
+
const events = {
|
|
581
|
+
data: {
|
|
582
|
+
repository: {
|
|
583
|
+
pullRequest: {
|
|
584
|
+
commits: {
|
|
585
|
+
nodes: [
|
|
586
|
+
{
|
|
587
|
+
commit: {
|
|
588
|
+
oid: head,
|
|
589
|
+
statusCheckRollup: {
|
|
590
|
+
contexts: {
|
|
591
|
+
nodes: [
|
|
592
|
+
{ __typename: "CheckRun", databaseId: 1001, name: "build", startedAt: "2026-11-12T00:30:00Z", checkSuite: { workflowRun: { event: "pull_request" } } },
|
|
593
|
+
{ __typename: "CheckRun", databaseId: 1002, name: "build", startedAt: "2026-11-12T00:41:30Z", checkSuite: { workflowRun: { event: "push" } } },
|
|
594
|
+
],
|
|
595
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
596
|
+
},
|
|
597
|
+
},
|
|
598
|
+
},
|
|
599
|
+
},
|
|
600
|
+
],
|
|
601
|
+
},
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
},
|
|
605
|
+
};
|
|
606
|
+
const exec: ProbeExec = {
|
|
607
|
+
async httpGet() {
|
|
608
|
+
return { status: 0, body: "" };
|
|
609
|
+
},
|
|
610
|
+
async run(command) {
|
|
611
|
+
// Route the two reads by shape: the GraphQL call carries `api graphql`, the rollup read `pr view`.
|
|
612
|
+
if (command.includes("api") && command.includes("graphql")) return { code: 0, stdout: JSON.stringify(events), stderr: "" };
|
|
613
|
+
return { code: 0, stdout: JSON.stringify(prView), stderr: "" };
|
|
614
|
+
},
|
|
615
|
+
};
|
|
616
|
+
const p = parseProbe({ kind: "pr", target: "o/r#12", match: { prState: "checks-green" } });
|
|
617
|
+
const res = await probeOnce(p, exec, {});
|
|
618
|
+
assertEquals(res.ready, false, "the red pull_request run survives event normalisation — checks-green is NOT ready");
|
|
619
|
+
assertStringIncludes(res.detail, "failing");
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
test("#957 probeOnce pr checks-green: an event-enrichment failure degrades to name-only, never throws", async () => {
|
|
623
|
+
const prView = {
|
|
624
|
+
state: "OPEN",
|
|
625
|
+
mergedAt: null,
|
|
626
|
+
mergeStateStatus: "CLEAN",
|
|
627
|
+
isDraft: false,
|
|
628
|
+
headRefOid: "abc",
|
|
629
|
+
mergeCommit: null,
|
|
630
|
+
statusCheckRollup: [{ name: "build", conclusion: "SUCCESS", status: "COMPLETED", startedAt: "2026-11-12T00:41:30Z", completedAt: "2026-11-12T00:41:38Z" }],
|
|
631
|
+
};
|
|
632
|
+
const exec: ProbeExec = {
|
|
633
|
+
async httpGet() {
|
|
634
|
+
return { status: 0, body: "" };
|
|
635
|
+
},
|
|
636
|
+
async run(command) {
|
|
637
|
+
if (command.includes("api") && command.includes("graphql")) return { code: 1, stdout: "", stderr: "boom" }; // the event read fails
|
|
638
|
+
return { code: 0, stdout: JSON.stringify(prView), stderr: "" };
|
|
639
|
+
},
|
|
640
|
+
};
|
|
641
|
+
const p = parseProbe({ kind: "pr", target: "o/r#12", match: { prState: "checks-green" } });
|
|
642
|
+
const res = await probeOnce(p, exec, {});
|
|
643
|
+
assertEquals(res.ready, true, "a failed event read leaves the rollup event-less — a single green run is still green");
|
|
644
|
+
});
|
|
645
|
+
|
|
557
646
|
// ── #857: an epic target is a CANONICAL planKey (`owner/repo#N`) — never a URL ────────────────────
|
|
558
647
|
// The epic probe keys off the durable lineage root (`owner/repo#N`, `parseIssue`'s `planKey` shape).
|
|
559
648
|
// Delegating its validation to `parsePr` (whose grammar includes GitHub PR URLs) let a URL pass the
|
|
@@ -780,7 +869,7 @@ function prObs(over: Partial<PrObservation> = {}): PrObservation {
|
|
|
780
869
|
totalChecks: 0,
|
|
781
870
|
presentCheckNames: [],
|
|
782
871
|
pendingCheckNames: [],
|
|
783
|
-
|
|
872
|
+
checkGateConclusions: {},
|
|
784
873
|
isDraft: false,
|
|
785
874
|
headRefOid: "abc123",
|
|
786
875
|
mergedSha: null,
|
package/app/readiness.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// any credential is read at execution time from the typed env-contract (`credentialEnv` names a
|
|
19
19
|
// declared {@link EnvKey}; ADR 0004 pinned decision 2) and is redacted from every log line.
|
|
20
20
|
import { isEnvKey, readEnv, readEnvOr } from "./contracts.ts";
|
|
21
|
-
import { allCheckNames,
|
|
21
|
+
import { allCheckNames, applyRollupEvents, CHECK_EVENTS_QUERY, checkGateConclusions, classifyMergeability, failingCheckNames, type PrState, pendingCheckNames } from "./github.ts";
|
|
22
22
|
import { parsePr } from "./prParse.ts";
|
|
23
23
|
import { isoDuration, isoDurationToMs } from "./reviewWait.ts";
|
|
24
24
|
|
|
@@ -764,7 +764,7 @@ export function parsePrView(payload: unknown): PrObservation {
|
|
|
764
764
|
totalChecks: rollup.length,
|
|
765
765
|
presentCheckNames: allCheckNames(rollup),
|
|
766
766
|
pendingCheckNames: pending,
|
|
767
|
-
|
|
767
|
+
checkGateConclusions: checkGateConclusions(rollup),
|
|
768
768
|
isDraft: j.isDraft === true,
|
|
769
769
|
headRefOid: str(j.headRefOid).trim() || null,
|
|
770
770
|
mergeQueueEntry: null, // readiness probe doesn't read native-queue membership (merge-loop-only signal)
|
|
@@ -857,6 +857,76 @@ export function prViewCommand(repo: string, number: string): string {
|
|
|
857
857
|
return `gh pr view ${shellQuote(number)} --repo ${shellQuote(repo)} --json ${shellQuote("state,mergedAt,mergeStateStatus,statusCheckRollup,isDraft,headRefOid,mergeCommit")}`;
|
|
858
858
|
}
|
|
859
859
|
|
|
860
|
+
/** The GraphQL page query backing {@link enrichPrViewEvents}, as a single-quoted shell argument.
|
|
861
|
+
* Mirrors the merge loop's enrichment (`enrichRollupEvents` in `app/github.ts`) but runs through the
|
|
862
|
+
* probe's injected {@link ProbeExec} so the readiness path stays host-agnostic and unit-testable. */
|
|
863
|
+
function prCheckEventsQuery(): string {
|
|
864
|
+
return CHECK_EVENTS_QUERY;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** Enrich a `pr`-probe rollup with each run's triggering event, through the probe's injected
|
|
868
|
+
* executor. The `gh pr view --json statusCheckRollup` read omits `event`, so without this the
|
|
869
|
+
* readiness path groups by name alone and a green `push` run hides a red required `pull_request` run
|
|
870
|
+
* of the same name — `checks-green` then reports ready while the PR run is red (PR #957 review).
|
|
871
|
+
* Pages `statusCheckRollup.contexts` to exhaustion and pins the read to the rollup's head SHA
|
|
872
|
+
* (aborting the stamp when the head moved between the two reads), exactly like the merge loop's
|
|
873
|
+
* `enrichRollupEvents`. Never throws: any transport/parse failure leaves the rollup event-less. */
|
|
874
|
+
async function enrichPrViewEvents(
|
|
875
|
+
exec: ProbeExec,
|
|
876
|
+
env: Record<string, string | undefined>,
|
|
877
|
+
repo: string,
|
|
878
|
+
number: string,
|
|
879
|
+
payload: Record<string, unknown>,
|
|
880
|
+
): Promise<void> {
|
|
881
|
+
const j = payload;
|
|
882
|
+
if (!Array.isArray(j.statusCheckRollup) || j.statusCheckRollup.length === 0) return;
|
|
883
|
+
const rollup = j.statusCheckRollup;
|
|
884
|
+
const headSha = typeof j.headRefOid === "string" ? j.headRefOid : "";
|
|
885
|
+
const [owner, name] = repo.split("/");
|
|
886
|
+
const nodes: unknown[] = [];
|
|
887
|
+
let after: string | null = null;
|
|
888
|
+
for (;;) {
|
|
889
|
+
const args = [
|
|
890
|
+
"api",
|
|
891
|
+
"graphql",
|
|
892
|
+
"-f",
|
|
893
|
+
`query=${prCheckEventsQuery()}`,
|
|
894
|
+
"-f",
|
|
895
|
+
`o=${owner}`,
|
|
896
|
+
"-f",
|
|
897
|
+
`r=${name}`,
|
|
898
|
+
"-F",
|
|
899
|
+
`n=${number}`,
|
|
900
|
+
];
|
|
901
|
+
if (after !== null) args.push("-f", `after=${after}`);
|
|
902
|
+
const command = `gh ${args.map(shellQuote).join(" ")}`;
|
|
903
|
+
const out = await exec.run(command, env);
|
|
904
|
+
if (out.code !== 0) return; // a transport hiccup leaves the rollup event-less (name-only gates)
|
|
905
|
+
const page = parseJson(out.stdout);
|
|
906
|
+
if (!isRecord(page)) return;
|
|
907
|
+
const data = isRecord(page.data) ? page.data : undefined;
|
|
908
|
+
const repository = data && isRecord(data.repository) ? data.repository : undefined;
|
|
909
|
+
const pullRequest = repository && isRecord(repository.pullRequest) ? repository.pullRequest : undefined;
|
|
910
|
+
const commits = pullRequest && isRecord(pullRequest.commits) ? pullRequest.commits : undefined;
|
|
911
|
+
const firstCommit = commits && Array.isArray(commits.nodes) ? commits.nodes[0] : undefined;
|
|
912
|
+
const commit = isRecord(firstCommit) && isRecord(firstCommit.commit) ? firstCommit.commit : undefined;
|
|
913
|
+
if (!isRecord(commit)) return;
|
|
914
|
+
// Head-pin: the rollup came from the earlier `gh pr view`; if the head moved since, these
|
|
915
|
+
// contexts belong to a different head — stamping them would join events across heads.
|
|
916
|
+
const oid = typeof commit.oid === "string" ? commit.oid : "";
|
|
917
|
+
if (headSha !== "" && oid !== "" && oid !== headSha) return;
|
|
918
|
+
const rollupObj = isRecord(commit.statusCheckRollup) ? commit.statusCheckRollup : undefined;
|
|
919
|
+
const contexts = rollupObj && isRecord(rollupObj.contexts) ? rollupObj.contexts : undefined;
|
|
920
|
+
if (contexts === undefined) return;
|
|
921
|
+
if (Array.isArray(contexts.nodes)) nodes.push(...contexts.nodes);
|
|
922
|
+
const pageInfo = isRecord(contexts.pageInfo) ? contexts.pageInfo : undefined;
|
|
923
|
+
const endCursor = pageInfo && typeof pageInfo.endCursor === "string" ? pageInfo.endCursor : "";
|
|
924
|
+
if (pageInfo?.hasNextPage !== true || endCursor === "") break;
|
|
925
|
+
after = endCursor;
|
|
926
|
+
}
|
|
927
|
+
applyRollupEvents(rollup, nodes);
|
|
928
|
+
}
|
|
929
|
+
|
|
860
930
|
// ── Epic / plan-fanout probe (issue #568 — gate a graph on an epic reaching "fully merged") ──────
|
|
861
931
|
|
|
862
932
|
/** A live epic (plan-fanout) observation, reduced to the fields {@link matchEpic} reads. An nwf epic
|
|
@@ -974,7 +1044,15 @@ export async function probeOnce(
|
|
|
974
1044
|
if (!ref) return { ready: false, detail: "pr: unparseable target (not ready)" };
|
|
975
1045
|
const out = await exec.run(prViewCommand(ref.repo, ref.number), env);
|
|
976
1046
|
if (out.code !== 0) return { ready: false, detail: "pr: gh pr view failed (not ready)" };
|
|
977
|
-
|
|
1047
|
+
const payload = parseJson(out.stdout);
|
|
1048
|
+
if (!isRecord(payload)) return matchPr(probe.match, parsePrView(payload));
|
|
1049
|
+
// Normalise each run's triggering event BEFORE parsing (PR #957 review): the `gh pr view`
|
|
1050
|
+
// rollup omits `event`, so an un-enriched read groups by name alone and a green `push` run
|
|
1051
|
+
// hides a red required `pull_request` run of the same name — `checks-green` would report
|
|
1052
|
+
// ready while the PR run is red. Shared transport logic with the merge loop, over the
|
|
1053
|
+
// injected executor.
|
|
1054
|
+
await enrichPrViewEvents(exec, env, ref.repo, ref.number, payload);
|
|
1055
|
+
return matchPr(probe.match, parsePrView(payload));
|
|
978
1056
|
}
|
|
979
1057
|
case "epic": {
|
|
980
1058
|
// "Fully merged" is the app's AGGREGATE, not a GitHub read — observe it over the app's own
|