@nanobpm/nano-workforce 0.132.0 → 0.133.1
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/deliveryConnector.test.ts +19 -0
- package/app/deliveryConnector.ts +50 -10
- package/app/deliveryGraphCompiler.test.ts +23 -0
- package/app/featureReadModel.test.ts +62 -17
- package/app/featureReadModel.ts +28 -9
- package/app/interEpicRegression.test.ts +3 -2
- package/app/plan.test.ts +5 -4
- package/app/plan.ts +26 -3
- package/app/planLowering.test.ts +2 -2
- package/app/plansReadModel.test.ts +38 -1
- package/app/service.test.ts +28 -1
- package/app/service.ts +56 -16
- package/app/stage.ts +8 -3
- package/app/terminalReaderBehaviour.test.ts +289 -0
- package/app/terminalReaderGuard.test.ts +106 -0
- package/db/migrations/080_plan_read_model_derive_terminal.sql +75 -0
- package/db/migrations/081_feature_read_model_derive_terminal.sql +65 -0
- package/docs/adr/0005-agent-authored-delivery-graphs.md +18 -0
- package/docs/agent-guide.md +47 -1
- package/operations/listActivePrs.test.ts +2 -1
- package/operations/startEpicSet.admission.integration.test.ts +3 -2
- package/operations/startPlanFanout.admission.integration.test.ts +2 -1
- package/package.json +1 -1
- package/test/trackingViews.ts +11 -1
- package/workers/delivery-connector/worker.test.ts +239 -1
- package/workers/delivery-connector/worker.ts +87 -0
package/app/service.test.ts
CHANGED
|
@@ -11,7 +11,9 @@ import { memDataFor } from "../test/worldDb.ts";
|
|
|
11
11
|
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
12
12
|
import { DurableResumeRegistry } from "./durableResume.ts";
|
|
13
13
|
import { WorldStore } from "./world/index.ts";
|
|
14
|
-
import { abandonClosedPr, parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
|
|
14
|
+
import { abandonClosedPr, isPrSettled, parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
|
|
15
|
+
import { trackingTargetFor } from "./instanceTracking.ts";
|
|
16
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
15
17
|
|
|
16
18
|
function memTable(rows: any[], key: string) {
|
|
17
19
|
return {
|
|
@@ -49,6 +51,31 @@ function withGithubOff(run: () => Promise<void>): Promise<void> {
|
|
|
49
51
|
});
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
test("isPrSettled reads the derived tracking view — an out-of-band-abandoned PR (base row still converging) is settled", async () => {
|
|
55
|
+
// The base `pull_requests` row still reads `converging`, but the ADR-0065 derived tracking VIEW
|
|
56
|
+
// folds the reconciler's out-of-band terminal edge into `derived_status: "abandoned"`. Terminal-edge
|
|
57
|
+
// classification must read `derived_status`, not the stale base `status`, or a crash-window RESUME
|
|
58
|
+
// of the delivery-connector enrollment action re-runs `submitPr` against a PR that has actually
|
|
59
|
+
// already settled (and the ledger detail falsely claims it enrolled).
|
|
60
|
+
const PR_KEY = "owner/repo#7";
|
|
61
|
+
const view = trackingTargetFor("pull_requests").view;
|
|
62
|
+
const base = { pr_key: PR_KEY, status: "converging" };
|
|
63
|
+
function make(derived: string) {
|
|
64
|
+
return {
|
|
65
|
+
table(name: string) {
|
|
66
|
+
if (name === "pull_requests") return { get: async (k: string) => (k === PR_KEY ? { ...base } : null) };
|
|
67
|
+
if (name === view) return { get: async (k: string) => (k === PR_KEY ? { ...base, derived_status: derived } : null) };
|
|
68
|
+
throw new Error(`unexpected table ${name}`);
|
|
69
|
+
},
|
|
70
|
+
} as any as DataLayer;
|
|
71
|
+
}
|
|
72
|
+
assertEquals(await isPrSettled(make("abandoned"), PR_KEY), true, "out-of-band-abandoned PR is settled via derived_status");
|
|
73
|
+
assertEquals(await isPrSettled(make("converging"), PR_KEY), false, "a genuinely live PR is not settled");
|
|
74
|
+
const empty = { table: () => ({ get: async () => null }) } as any as DataLayer;
|
|
75
|
+
assertEquals(await isPrSettled(empty, PR_KEY), false, "an absent PR row is not settled");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
|
|
52
79
|
test("re-submit of a cancelled PR marks stale open escalations", async () => {
|
|
53
80
|
await withGithubOff(async () => {
|
|
54
81
|
const PR_KEY = "owner/repo#42";
|
package/app/service.ts
CHANGED
|
@@ -482,6 +482,21 @@ export async function worldRestoreSha(data: DataLayer, prKey: string): Promise<s
|
|
|
482
482
|
return lastPushedSha(data, prKey);
|
|
483
483
|
}
|
|
484
484
|
|
|
485
|
+
/** Whether the `pull_requests` row for `prKey` already exists AND is in a TERMINAL state
|
|
486
|
+
* (`converged`/`merged`/`abandoned`). Reads the ADR-0065 derived tracking VIEW's `derived_status`
|
|
487
|
+
* (via `prsTracking`), NOT the base `status`, so an out-of-band-terminated PR — whose base row is
|
|
488
|
+
* still `converging` but whose reconciled edge is `abandoned` — is correctly seen as settled. The
|
|
489
|
+
* delivery-connector's converge-enrollment action guards on this so a crash-window RESUME (a
|
|
490
|
+
* `claimed`-but-not-`delivered` ledger row whose first attempt already enrolled the PR and let it
|
|
491
|
+
* settle) never re-runs `submitPr` against a settled PR — `submitPr` deliberately RE-OPENS a terminal
|
|
492
|
+
* row, so an unconditional re-perform would flip the PR back to `converging`, regressing a settled PR.
|
|
493
|
+
* Reuses the canonical `TERMINAL_STATUSES` so the terminal-safety check can't drift from the one the
|
|
494
|
+
* loop/incident logic uses. */
|
|
495
|
+
export async function isPrSettled(data: DataLayer, prKey: string): Promise<boolean> {
|
|
496
|
+
const existing = await prsTracking(data).get(prKey);
|
|
497
|
+
return !!existing && TERMINAL_STATUSES.includes(existing.derived_status);
|
|
498
|
+
}
|
|
499
|
+
|
|
485
500
|
/** Register a PR row (if new) and start the convergence process. Idempotent on prKey. Optional
|
|
486
501
|
* `dependsOn` (explicit refs) is unioned with any `Depends-on:` line parsed from the PR body and
|
|
487
502
|
* recorded as the PR's merge-stage dependency set. */
|
|
@@ -496,7 +511,14 @@ export async function submitPr(
|
|
|
496
511
|
) {
|
|
497
512
|
const table = prs(data);
|
|
498
513
|
const existing = await table.get(parsed.prKey);
|
|
499
|
-
|
|
514
|
+
// ADR-0065: classify "already running" on the DERIVED terminal edge, not the base transient. A
|
|
515
|
+
// PR whose engine instance was terminated out-of-band (or by an ordinary in-app cancel — derive-only
|
|
516
|
+
// under urban 0.81.0) has a base row frozen at its last worker transient (e.g. `converging`) but a
|
|
517
|
+
// `pull_requests__tracking.derived_status` of `abandoned`; reading the base `status` here would wedge
|
|
518
|
+
// it `alreadyRunning` forever (the #497 phantom). Route the idempotency gate through the derived view
|
|
519
|
+
// so a cancelled PR is correctly seen terminal and RESUBMITTABLE.
|
|
520
|
+
const trackedExisting = existing ? await prsTracking(data).get(parsed.prKey) : undefined;
|
|
521
|
+
if (trackedExisting && !TERMINAL_STATUSES.includes(trackedExisting.derived_status)) {
|
|
500
522
|
return { prKey: parsed.prKey, alreadyRunning: true };
|
|
501
523
|
}
|
|
502
524
|
|
|
@@ -732,9 +754,17 @@ export interface ActivePr {
|
|
|
732
754
|
* BOTH loops' escalations uniformly. Once answered the row leaves `open`, so `openEscalation`
|
|
733
755
|
* derives back to null. */
|
|
734
756
|
export async function activePrs(data: DataLayer): Promise<ActivePr[]> {
|
|
735
|
-
const all = await
|
|
757
|
+
const all = await prsTracking(data).all();
|
|
736
758
|
const active = all
|
|
737
|
-
.
|
|
759
|
+
// ADR-0065: classify "in flight" on the DERIVED terminal edge, not the base transient. A PR whose
|
|
760
|
+
// engine instance was terminated out-of-band (or by an ordinary in-app cancel — derive-only under
|
|
761
|
+
// urban 0.81.0) keeps its base `status` frozen at the last worker transient but reads `abandoned`
|
|
762
|
+
// on `pull_requests__tracking.derived_status`; filtering on the base `status` here left the
|
|
763
|
+
// Convergence tab showing a cancelled PR active indefinitely (the #497 phantom). The base
|
|
764
|
+
// `status`/worker-owned transient columns are still surfaced on each row below (the view re-exports
|
|
765
|
+
// `base.*`), and `pull_requests` has no `onWaitingHuman` edge, so for a still-active PR
|
|
766
|
+
// `derived_status === status`.
|
|
767
|
+
.filter((p) => !TERMINAL_STATUSES.includes(p.derived_status))
|
|
738
768
|
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : 0));
|
|
739
769
|
// Only an `escalated` PR is parked awaiting a human answer (either loop). Surface the question
|
|
740
770
|
// from its latest still-open `escalations` row; a resubmit retires stale rows and finalize/merge
|
|
@@ -957,9 +987,9 @@ async function classifyWaveTarget(
|
|
|
957
987
|
prKey: string,
|
|
958
988
|
token: string,
|
|
959
989
|
): Promise<"cleared" | "closed" | "pending"> {
|
|
960
|
-
const tracked = await
|
|
961
|
-
if (tracked && tracked.status === "merged") return "cleared";
|
|
962
|
-
if (tracked && tracked.
|
|
990
|
+
const tracked = await prsTracking(data).get(prKey);
|
|
991
|
+
if (tracked && tracked.status === "merged") return "cleared"; // worker-owned terminal, passes through the derive edge unchanged
|
|
992
|
+
if (tracked && tracked.derived_status === ABANDONED_STATUS) return "cleared"; // ADR-0065 derive-only terminal → non-blocking, no re-reconcile needed
|
|
963
993
|
const parsed = parsePr(prKey);
|
|
964
994
|
if (!parsed) return "cleared"; // unparseable ref can't be checked → never wedge the barrier
|
|
965
995
|
let st: Awaited<ReturnType<typeof fetchPrState>>;
|
|
@@ -1001,7 +1031,7 @@ async function flipToMergingThenPublish(
|
|
|
1001
1031
|
}
|
|
1002
1032
|
}
|
|
1003
1033
|
|
|
1004
|
-
async function mergeLaneDecisionForPr(data: DataLayer, prKey: string): Promise<PrLaneDecision | null> {
|
|
1034
|
+
export async function mergeLaneDecisionForPr(data: DataLayer, prKey: string): Promise<PrLaneDecision | null> {
|
|
1005
1035
|
const taskRows = await planTasks(data).find({ pr_key: prKey });
|
|
1006
1036
|
const task = taskRows[0];
|
|
1007
1037
|
if (!task) return null;
|
|
@@ -1019,8 +1049,12 @@ async function mergeLaneDecisionForPr(data: DataLayer, prKey: string): Promise<P
|
|
|
1019
1049
|
const lanePrKeys = new Set([...taskToPr.values()]);
|
|
1020
1050
|
const completedPrKeys = new Set<string>();
|
|
1021
1051
|
for (const lanePrKey of lanePrKeys) {
|
|
1022
|
-
const lanePr = await
|
|
1023
|
-
|
|
1052
|
+
const lanePr = await prsTracking(data).get(lanePrKey);
|
|
1053
|
+
// ADR-0065: a lane member is complete when it MERGED (worker-owned terminal, base `status`) OR was
|
|
1054
|
+
// cancelled/terminated (derive-only terminal → `derived_status === "abandoned"`; the base row is
|
|
1055
|
+
// still frozen at its transient). Reading only base `status` here missed a cancelled member and
|
|
1056
|
+
// stalled/misrouted the lane.
|
|
1057
|
+
if (lanePr && (lanePr.status === "merged" || lanePr.derived_status === "abandoned")) {
|
|
1024
1058
|
completedPrKeys.add(lanePr.pr_key);
|
|
1025
1059
|
}
|
|
1026
1060
|
}
|
|
@@ -1385,12 +1419,15 @@ async function pollJobActivation(
|
|
|
1385
1419
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
1386
1420
|
if (engineToken) headers.authorization = `Bearer ${engineToken}`;
|
|
1387
1421
|
|
|
1388
|
-
const all = await
|
|
1422
|
+
const all = await prsTracking(data).all();
|
|
1389
1423
|
for (const pr of all) {
|
|
1390
1424
|
// Only a `converging` PR has a live review-round job. Any other status with a stale worker
|
|
1391
1425
|
// set (e.g. it just moved to `waiting_review`) gets cleared so the grid can't show a
|
|
1392
|
-
// phantom "agent working".
|
|
1393
|
-
|
|
1426
|
+
// phantom "agent working". ADR-0065: classify on the DERIVED status, not the base transient —
|
|
1427
|
+
// a PR whose instance was terminated out-of-band keeps its base `status` frozen at `converging`
|
|
1428
|
+
// but reads `abandoned` on `derived_status`, so a base read would leave `active_worker`/
|
|
1429
|
+
// `lease_until` set on a dead run (a phantom "agent working"). Writes stay on the base `prs`.
|
|
1430
|
+
if (pr.derived_status !== "converging") {
|
|
1394
1431
|
if (pr.active_worker || pr.lease_until) {
|
|
1395
1432
|
await prs(data).update(pr.pr_key, {
|
|
1396
1433
|
active_worker: null,
|
|
@@ -1497,13 +1534,16 @@ export async function pollIncidentsImpl(
|
|
|
1497
1534
|
base: string,
|
|
1498
1535
|
headers: Record<string, string>,
|
|
1499
1536
|
) {
|
|
1500
|
-
const all = await
|
|
1537
|
+
const all = await prsTracking(data).all();
|
|
1501
1538
|
for (const pr of all) {
|
|
1502
1539
|
// No live instance to inspect (never created, mid-transition, or terminal — the run has
|
|
1503
1540
|
// finished or was given up, so its instance is gone) → make sure no stale incident lingers on
|
|
1504
|
-
// the row, then move on.
|
|
1505
|
-
//
|
|
1506
|
-
|
|
1541
|
+
// the row, then move on. ADR-0065: classify on the DERIVED terminal edge, not the base transient
|
|
1542
|
+
// — a PR terminated out-of-band keeps its base `status` frozen at `converging` but reads
|
|
1543
|
+
// `abandoned` on `derived_status`, so a base read would keep reconciling incidents against a dead
|
|
1544
|
+
// instance. Reuses the canonical `TERMINAL_STATUSES` so incident logic can't drift from the rest
|
|
1545
|
+
// of the status machine. Writes stay on the base `prs`.
|
|
1546
|
+
if (!pr.process_key || TERMINAL_STATUSES.includes(pr.derived_status)) {
|
|
1507
1547
|
if (pr.incident_key || pr.incident_message) {
|
|
1508
1548
|
await prs(data).update(pr.pr_key, {
|
|
1509
1549
|
incident_key: null,
|
package/app/stage.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// anywhere (not in SQL, not in the page, not in each poller/worker): every reader flows through the
|
|
15
15
|
// VIEW or these adapters, both sourced from the one declaration.
|
|
16
16
|
|
|
17
|
-
import { type FeatureReadModelDerivedColumn, featureReadModel, STAGE_DONE_STATUSES, USER_TASKS_PROJECTION } from "./featureReadModel.ts";
|
|
17
|
+
import { EFFECTIVE_STATUS_COLUMN, type FeatureReadModelDerivedColumn, featureReadModel, STAGE_DONE_STATUSES, USER_TASKS_PROJECTION } from "./featureReadModel.ts";
|
|
18
18
|
|
|
19
19
|
// Re-exported for back-compat with existing importers (operations/acknowledgeDone.ts). Its canonical
|
|
20
20
|
// home is now app/featureReadModel.ts, where it feeds the terminal tier of the derived columns.
|
|
@@ -90,7 +90,12 @@ function evalDerived<T>(column: FeatureReadModelDerivedColumn, baseRow: Record<s
|
|
|
90
90
|
export function deriveStage(run: StageInput): DerivedStage {
|
|
91
91
|
const baseRow = {
|
|
92
92
|
feature_key: SELF_KEY,
|
|
93
|
-
status
|
|
93
|
+
// The status-classifying derivations read the tracking VIEW's terminal-folded `derived_status`
|
|
94
|
+
// (ADR-0065), so this façade feeds the caller's effective `status` under that column name — the SQL
|
|
95
|
+
// VIEW reads `fr."derived_status"` off `feature_runs__tracking`, and both lowerings agree by
|
|
96
|
+
// construction (`assertReadModelParity`). Callers off the write path pass the run's effective
|
|
97
|
+
// status (which equals the base transient for any non-terminated run).
|
|
98
|
+
[EFFECTIVE_STATUS_COLUMN]: run.status,
|
|
94
99
|
pr_key: run.pr_key ?? null,
|
|
95
100
|
converge: run.converge ?? null,
|
|
96
101
|
auto_merge: run.auto_merge ?? null,
|
|
@@ -111,5 +116,5 @@ export function deriveStage(run: StageInput): DerivedStage {
|
|
|
111
116
|
* `feature_runs.list_bucket` base column is vestigial (retired as a write projection, issue #439). This
|
|
112
117
|
* adapter is the TS lowering of that derivation, used off the write path (redispatch gating, tests). */
|
|
113
118
|
export function deriveListBucket(status: string, acknowledgedAt: string | null | undefined): "active" | "history" {
|
|
114
|
-
return evalDerived<"active" | "history">("list_bucket", { status, acknowledged_at: acknowledgedAt ?? null });
|
|
119
|
+
return evalDerived<"active" | "history">("list_bucket", { [EFFECTIVE_STATUS_COLUMN]: status, acknowledged_at: acknowledgedAt ?? null });
|
|
115
120
|
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// Behaviour coverage for issue #503 — the ADR-0065 derive-only-terminal divergence at the terminal
|
|
2
|
+
// EDGE readers. Under `@nanobpm/urban@0.81.0` the `instanceTracking` reconciler no longer WRITES the
|
|
3
|
+
// terminal `abandoned`/`failed`/`reviewed` onto the base `status`; it re-derives it on read via the
|
|
4
|
+
// `<table>__tracking.derived_status` VIEW. A PR/plan whose engine instance was terminated out-of-band
|
|
5
|
+
// (or by an ordinary in-app cancel) therefore keeps its base `status` frozen at its last worker
|
|
6
|
+
// transient (e.g. `converging`/`dispatched`) while `derived_status` reads `abandoned`.
|
|
7
|
+
//
|
|
8
|
+
// Each test seeds that EXACT divergence (base row `status: "converging"`, `derived_status: "abandoned"`)
|
|
9
|
+
// via the `withTrackingViews` seam, and asserts the reader classifies on the derived edge:
|
|
10
|
+
// - a terminated PR is RESUBMITTABLE (not wedged `alreadyRunning`) and absent from `activePrs`,
|
|
11
|
+
// - a terminated instance sheds its stale incident,
|
|
12
|
+
// - a terminated lane member counts as COMPLETE (does not stall the merge lane),
|
|
13
|
+
// - a terminated epic is not counted active (no false same-base conflict) and is RE-ADMITTABLE.
|
|
14
|
+
// Reading only the base `status` (the pre-#503 behaviour) fails every one of these — the RED.
|
|
15
|
+
|
|
16
|
+
import { test } from "node:test";
|
|
17
|
+
import { assertEquals } from "#test-assert";
|
|
18
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
19
|
+
import { findActivePlansByBase, startPlan } from "./plan.ts";
|
|
20
|
+
import { activePrs, mergeLaneDecisionForPr, pollIncidentsImpl, pollWaveGatesImpl, submitPr } from "./service.ts";
|
|
21
|
+
|
|
22
|
+
function memTable(rows: any[], key: string) {
|
|
23
|
+
return {
|
|
24
|
+
get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
25
|
+
all: () => Promise.resolve([...rows]),
|
|
26
|
+
find: (q: any) =>
|
|
27
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
28
|
+
findOne: (q: any) =>
|
|
29
|
+
Promise.resolve(rows.find((r) => Object.entries(q).every(([f, v]) => r[f] === v)) ?? null),
|
|
30
|
+
count: (q: any) =>
|
|
31
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v)).length),
|
|
32
|
+
insert: (r: any) => {
|
|
33
|
+
rows.push(r);
|
|
34
|
+
return Promise.resolve(r);
|
|
35
|
+
},
|
|
36
|
+
update: (k: any, patch: any) => {
|
|
37
|
+
const r = rows.find((x) => x[key] === k);
|
|
38
|
+
if (r) Object.assign(r, patch);
|
|
39
|
+
return Promise.resolve(r);
|
|
40
|
+
},
|
|
41
|
+
delete: (k: any) => {
|
|
42
|
+
for (let i = rows.length - 1; i >= 0; i--) if (rows[i][key] === k) rows.splice(i, 1);
|
|
43
|
+
return Promise.resolve();
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type Stores = Record<string, { rows: any[]; key: string }>;
|
|
49
|
+
|
|
50
|
+
function memData(stores: Stores) {
|
|
51
|
+
return {
|
|
52
|
+
table: withTrackingViews((name: string, key: string) =>
|
|
53
|
+
memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
54
|
+
} as any;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function withGithubOff(run: () => Promise<void>): Promise<void> {
|
|
58
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
59
|
+
const prevTok = process.env["GITHUB_TOKEN"];
|
|
60
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token"; // no token below -> fetchPrMeta returns null
|
|
61
|
+
delete process.env["GITHUB_TOKEN"];
|
|
62
|
+
return run().finally(() => {
|
|
63
|
+
if (prevMode !== undefined) process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
64
|
+
else delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
65
|
+
if (prevTok !== undefined) process.env["GITHUB_TOKEN"] = prevTok;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// #503 / #497: `submitPr`'s idempotency gate reads `derived_status`, so a derive-only-terminated PR
|
|
70
|
+
// (base frozen at `converging`, `derived_status = abandoned`) is seen terminal and RESUBMITTABLE —
|
|
71
|
+
// it re-opens for a fresh convergence run instead of wedging `alreadyRunning`.
|
|
72
|
+
test("submitPr re-opens a derive-only-terminated PR (base 'converging', derived 'abandoned') — not alreadyRunning", async () => {
|
|
73
|
+
await withGithubOff(async () => {
|
|
74
|
+
const PR_KEY = "owner/repo#42";
|
|
75
|
+
const stores: Stores = {
|
|
76
|
+
pull_requests: {
|
|
77
|
+
rows: [{
|
|
78
|
+
pr_key: PR_KEY,
|
|
79
|
+
repo: "owner/repo",
|
|
80
|
+
number: 42,
|
|
81
|
+
url: "https://github.com/owner/repo/pull/42",
|
|
82
|
+
title: "t",
|
|
83
|
+
status: "converging", // base transient FROZEN — reconciler no longer writes the terminal
|
|
84
|
+
derived_status: "abandoned", // ADR-0065 derive-only terminal
|
|
85
|
+
current_round: 3,
|
|
86
|
+
}],
|
|
87
|
+
key: "pr_key",
|
|
88
|
+
},
|
|
89
|
+
escalations: { rows: [], key: "id" },
|
|
90
|
+
pr_dependencies: { rows: [], key: "pr_key" },
|
|
91
|
+
};
|
|
92
|
+
const data = memData(stores);
|
|
93
|
+
const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-9" }) } as any;
|
|
94
|
+
|
|
95
|
+
const res = await submitPr(data, engine, {
|
|
96
|
+
repo: "owner/repo",
|
|
97
|
+
number: 42,
|
|
98
|
+
url: "https://github.com/owner/repo/pull/42",
|
|
99
|
+
prKey: PR_KEY,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
assertEquals((res as any).alreadyRunning, undefined); // NOT wedged
|
|
103
|
+
assertEquals(res.processKey, "PI-9");
|
|
104
|
+
const pr = stores.pull_requests.rows[0];
|
|
105
|
+
assertEquals(pr.status, "converging");
|
|
106
|
+
assertEquals(pr.current_round, 1); // re-opened for a fresh run
|
|
107
|
+
assertEquals(pr.process_key, "PI-9");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// #503 / #497 phantom: `activePrs` filters on `derived_status`, so a derive-only-terminated PR drops
|
|
112
|
+
// off the Convergence tab instead of showing "active" indefinitely.
|
|
113
|
+
test("activePrs excludes a derive-only-terminated PR and keeps a genuinely active one", async () => {
|
|
114
|
+
const stores: Stores = {
|
|
115
|
+
pull_requests: {
|
|
116
|
+
rows: [
|
|
117
|
+
{ pr_key: "o/r#1", repo: "o/r", number: 1, url: "u1", status: "converging", derived_status: "abandoned", current_round: 2, updated_at: "2024-01-02" },
|
|
118
|
+
{ pr_key: "o/r#2", repo: "o/r", number: 2, url: "u2", status: "converging", derived_status: "converging", current_round: 1, updated_at: "2024-01-01" },
|
|
119
|
+
],
|
|
120
|
+
key: "pr_key",
|
|
121
|
+
},
|
|
122
|
+
escalations: { rows: [], key: "id" },
|
|
123
|
+
};
|
|
124
|
+
const active = await activePrs(memData(stores));
|
|
125
|
+
assertEquals(active.map((p) => p.prKey), ["o/r#2"]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// #503: `pollIncidentsImpl` classifies a dead instance on `derived_status`, so a derive-only-terminated
|
|
129
|
+
// PR sheds its stale incident (rather than re-reconciling against a gone instance), and NEVER queries
|
|
130
|
+
// the engine for it. A live PR still gets queried.
|
|
131
|
+
test("pollIncidentsImpl clears a stale incident off a derive-only-terminated PR without querying the engine", async () => {
|
|
132
|
+
const stores: Stores = {
|
|
133
|
+
pull_requests: {
|
|
134
|
+
rows: [{
|
|
135
|
+
pr_key: "o/r#7",
|
|
136
|
+
repo: "o/r",
|
|
137
|
+
number: 7,
|
|
138
|
+
url: "u",
|
|
139
|
+
status: "converging",
|
|
140
|
+
derived_status: "abandoned",
|
|
141
|
+
process_key: "PI-DEAD",
|
|
142
|
+
incident_key: "INC-1",
|
|
143
|
+
incident_message: "boom",
|
|
144
|
+
}],
|
|
145
|
+
key: "pr_key",
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
const prevFetch = globalThis.fetch;
|
|
149
|
+
let queried = false;
|
|
150
|
+
globalThis.fetch = (() => {
|
|
151
|
+
queried = true;
|
|
152
|
+
throw new Error("engine must not be queried for a derive-only-terminated PR");
|
|
153
|
+
}) as any;
|
|
154
|
+
try {
|
|
155
|
+
await pollIncidentsImpl(memData(stores), "http://engine", {});
|
|
156
|
+
} finally {
|
|
157
|
+
globalThis.fetch = prevFetch;
|
|
158
|
+
}
|
|
159
|
+
assertEquals(queried, false);
|
|
160
|
+
const pr = stores.pull_requests.rows[0];
|
|
161
|
+
assertEquals(pr.incident_key, null);
|
|
162
|
+
assertEquals(pr.incident_message, null);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// #503: the merge-lane decision counts a lane member COMPLETE on the derived terminal edge, so a
|
|
166
|
+
// derive-only-abandoned member (base 'converging') no longer holds its lane-mate behind a dead PR.
|
|
167
|
+
test("mergeLaneDecisionForPr treats a derive-only-abandoned lane member as complete (does not hold the lane)", async () => {
|
|
168
|
+
const PLAN_KEY = "o/r#100";
|
|
169
|
+
const stores: Stores = {
|
|
170
|
+
plan_tasks: {
|
|
171
|
+
rows: [
|
|
172
|
+
{ id: 1, plan_key: PLAN_KEY, task_id: "a", pr_key: "o/r#1" },
|
|
173
|
+
{ id: 2, plan_key: PLAN_KEY, task_id: "b", pr_key: "o/r#2" },
|
|
174
|
+
],
|
|
175
|
+
key: "id",
|
|
176
|
+
},
|
|
177
|
+
plan_merge_exclusions: {
|
|
178
|
+
// a & b collide on a shared surface → one landing lane, land one-at-a-time
|
|
179
|
+
rows: [{ id: 1, plan_key: PLAN_KEY, task_a: "a", task_b: "b", files: JSON.stringify(["shared.ts"]), source: "file-overlap" }],
|
|
180
|
+
key: "id",
|
|
181
|
+
},
|
|
182
|
+
plan_task_deps: { rows: [], key: "plan_key" },
|
|
183
|
+
pull_requests: {
|
|
184
|
+
rows: [
|
|
185
|
+
// lane head candidate `a`: derive-only-terminated (base frozen, derived abandoned)
|
|
186
|
+
{ pr_key: "o/r#1", repo: "o/r", number: 1, status: "converging", derived_status: "abandoned" },
|
|
187
|
+
// `b`: the PR we ask about — still converging
|
|
188
|
+
{ pr_key: "o/r#2", repo: "o/r", number: 2, status: "converging", derived_status: "converging" },
|
|
189
|
+
],
|
|
190
|
+
key: "pr_key",
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
const decision = await mergeLaneDecisionForPr(memData(stores), "o/r#2");
|
|
194
|
+
// With `a` counted complete, `b` is free to land — NOT held behind the dead member.
|
|
195
|
+
assertEquals(decision?.isHeld, false);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// #503: `classifyWaveTarget` classifies a wave member on the derived terminal edge, so a
|
|
199
|
+
// derive-only-abandoned member (base frozen at `converging`, still notionally open) is treated
|
|
200
|
+
// NON-BLOCKING (`cleared`) WITHOUT a GitHub round-trip — the wave gate advances instead of wedging on
|
|
201
|
+
// a dead member. Driven through `pollWaveGatesImpl`: with the sole gate member cleared and the token
|
|
202
|
+
// parked at `wait-wave-merged`, the barrier is released (`wave-merged` published). Reading the base
|
|
203
|
+
// `status` (the RED) would fall through to a GitHub liveness read (which, with no live "merged"
|
|
204
|
+
// signal, returns `pending`) and never publish.
|
|
205
|
+
test("classifyWaveTarget treats a derive-only-abandoned wave member as cleared and releases the gate (no GitHub read)", async () => {
|
|
206
|
+
await withGithubOff(async () => {
|
|
207
|
+
const PLAN_KEY = "o/r#200";
|
|
208
|
+
const stores: Stores = {
|
|
209
|
+
plans: { rows: [{ plan_key: PLAN_KEY, gate_wave: 0, process_key: "PI-1" }], key: "plan_key" },
|
|
210
|
+
plan_tasks: {
|
|
211
|
+
rows: [{ id: 1, plan_key: PLAN_KEY, task_id: "a", wave: 0, status: "opened", pr_key: "o/r#1" }],
|
|
212
|
+
key: "id",
|
|
213
|
+
},
|
|
214
|
+
pull_requests: {
|
|
215
|
+
rows: [{ pr_key: "o/r#1", repo: "o/r", number: 1, status: "converging", derived_status: "abandoned" }],
|
|
216
|
+
key: "pr_key",
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
const published: unknown[] = [];
|
|
220
|
+
const engine = { publishMessage: (m: unknown) => (published.push(m), Promise.resolve()) } as any;
|
|
221
|
+
const prevFetch = globalThis.fetch;
|
|
222
|
+
// Confirm the token is parked at the `wave-merged` wait so the barrier is releasable; a GitHub
|
|
223
|
+
// liveness read for the abandoned member would be a bug (it's classified `cleared` off the view).
|
|
224
|
+
globalThis.fetch = ((url: string) => {
|
|
225
|
+
if (String(url).endsWith("/message-subscriptions/search")) {
|
|
226
|
+
return Promise.resolve(
|
|
227
|
+
new Response(
|
|
228
|
+
JSON.stringify({ items: [{ messageName: "wave-merged", correlationKey: PLAN_KEY, messageSubscriptionState: "CREATED" }] }),
|
|
229
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
230
|
+
),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
throw new Error(`no GitHub read expected for a derive-only-abandoned wave member: ${url}`);
|
|
234
|
+
}) as any;
|
|
235
|
+
try {
|
|
236
|
+
await pollWaveGatesImpl(memData(stores), engine, "", "http://engine", {});
|
|
237
|
+
} finally {
|
|
238
|
+
globalThis.fetch = prevFetch;
|
|
239
|
+
}
|
|
240
|
+
assertEquals(published.length, 1); // wave released — the abandoned member did not block it
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
// derive-only-abandoned epic (base frozen at `dispatched`) is NOT counted active and raises no false
|
|
246
|
+
// same-base conflict.
|
|
247
|
+
test("findActivePlansByBase excludes a derive-only-abandoned epic", async () => {
|
|
248
|
+
const stores: Stores = {
|
|
249
|
+
plans: {
|
|
250
|
+
rows: [
|
|
251
|
+
{ plan_key: "o/r#10", repo: "o/r", base_branch: "epic/x", status: "dispatched", derived_status: "abandoned" },
|
|
252
|
+
{ plan_key: "o/r#11", repo: "o/r", base_branch: "epic/x", status: "dispatched", derived_status: "dispatched" },
|
|
253
|
+
],
|
|
254
|
+
key: "plan_key",
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
const active = await findActivePlansByBase(memData(stores), "o/r", "epic/x");
|
|
258
|
+
assertEquals(active.map((p) => p.plan_key), ["o/r#11"]);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// #503: `startPlan`'s idempotency gate reads `derived_status`, so a derive-only-abandoned epic (base
|
|
262
|
+
// frozen at `dispatched`) is seen terminal and RE-ADMITTABLE — it re-plans instead of wedging
|
|
263
|
+
// `alreadyRunning`.
|
|
264
|
+
test("startPlan re-admits a derive-only-abandoned epic (base 'dispatched', derived 'abandoned') — not alreadyRunning", async () => {
|
|
265
|
+
await withGithubOff(async () => {
|
|
266
|
+
const PLAN_KEY = "owner/repo#7";
|
|
267
|
+
const stores: Stores = {
|
|
268
|
+
plans: {
|
|
269
|
+
rows: [{ plan_key: PLAN_KEY, repo: "owner/repo", base_branch: "epic/x", status: "dispatched", derived_status: "abandoned", task_count: 1 }],
|
|
270
|
+
key: "plan_key",
|
|
271
|
+
},
|
|
272
|
+
plan_tasks: { rows: [{ id: 1, plan_key: PLAN_KEY }], key: "id" },
|
|
273
|
+
plan_reviews: { rows: [], key: "plan_key" },
|
|
274
|
+
plan_task_deps: { rows: [], key: "plan_key" },
|
|
275
|
+
};
|
|
276
|
+
const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-1" }) } as any;
|
|
277
|
+
|
|
278
|
+
const res = await startPlan(memData(stores), engine, {
|
|
279
|
+
repo: "owner/repo",
|
|
280
|
+
number: 7,
|
|
281
|
+
url: "https://github.com/owner/repo/issues/7",
|
|
282
|
+
planKey: PLAN_KEY,
|
|
283
|
+
}, "epic/x");
|
|
284
|
+
|
|
285
|
+
assertEquals((res as any).alreadyRunning, undefined); // NOT wedged — re-planned
|
|
286
|
+
// The prior epic's tasks were cleared on the re-plan path (proves it did NOT short-circuit).
|
|
287
|
+
assertEquals(stores.plan_tasks.rows.length, 0);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Class guard for the ADR-0065 terminal-edge reader migration (issue #503).
|
|
2
|
+
//
|
|
3
|
+
// Since ADR-0065 (`@nanobpm/urban@0.81.0`) the `instanceTracking` reconciler is a SOURCE, not a
|
|
4
|
+
// writer: on cancel/terminate it feeds urban's instance projection and the terminal edge
|
|
5
|
+
// (`onTerminated`) is RECOMPUTED ON READ as `<table>__tracking.derived_status` — it NO LONGER writes
|
|
6
|
+
// the terminal (`abandoned`/`failed`/`reviewed`) onto the base `status` column. A classifying reader
|
|
7
|
+
// that inspects the BASE `status` column of a DERIVE-ONLY tracked table therefore sees a row frozen at
|
|
8
|
+
// its last worker-owned transient after the instance ends → phantom-active / wedged-idempotency bugs
|
|
9
|
+
// (the #497 / #503 class).
|
|
10
|
+
//
|
|
11
|
+
// This is a SOURCE-SCAN guard over the defect CLASS, not a single instance: it asserts that every
|
|
12
|
+
// terminal/active classification (`TERMINAL_STATUSES.includes` / `=== ABANDONED_STATUS` /
|
|
13
|
+
// `PLAN_TERMINAL_STATUSES` / the feature read model's status DSL) for the three derive-only tracked
|
|
14
|
+
// tables (`pull_requests`, `plans`, `feature_runs`) reads the DERIVED effective status
|
|
15
|
+
// (`.derived_status`), never the frozen base `.status`. A future reader that silently re-drifts onto
|
|
16
|
+
// the base column fails here.
|
|
17
|
+
//
|
|
18
|
+
// Worker-owned terminals that PASS THROUGH the derive edge unchanged (`merged`) are exempt: a base
|
|
19
|
+
// `=== "merged"` read is legitimate (see `isDepMerged` / `classifyWaveTarget` / `mergeLaneDecisionForPr`
|
|
20
|
+
// in app/service.ts). Only the DERIVE-ONLY terminals (`abandoned`/`failed`/`reviewed`) must route
|
|
21
|
+
// through the derived accessor. Writers (`data.table(<table>).update({ status: … })`) are unaffected —
|
|
22
|
+
// they still write the base column.
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
import { test } from "node:test";
|
|
26
|
+
import { assert, assertEquals } from "#test-assert";
|
|
27
|
+
import { EFFECTIVE_STATUS_COLUMN } from "./featureReadModel.ts";
|
|
28
|
+
|
|
29
|
+
const SRC = (name: string): string => readFileSync(fileURLToPath(new URL(`./${name}`, import.meta.url)), "utf8");
|
|
30
|
+
|
|
31
|
+
/** Strip line (`//`) and block (`/* … */`) comments so the scan only inspects executable code — a
|
|
32
|
+
* doc comment may legitimately mention `.status` in prose without being a classification. The line
|
|
33
|
+
* stripper skips a `//` preceded by `:` so a URL scheme inside a string/template literal (e.g.
|
|
34
|
+
* `https://…` in app/service.ts) is not mistaken for a comment start and does not corrupt the scan. */
|
|
35
|
+
function stripComments(src: string): string {
|
|
36
|
+
return src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test("class guard: every TERMINAL_STATUSES classification in service.ts reads derived_status, not base status", () => {
|
|
40
|
+
const code = stripComments(SRC("service.ts"));
|
|
41
|
+
const calls = [...code.matchAll(/TERMINAL_STATUSES\.includes\(([^)]*)\)/g)];
|
|
42
|
+
assert(calls.length > 0, "expected TERMINAL_STATUSES.includes classifications in service.ts");
|
|
43
|
+
for (const m of calls) {
|
|
44
|
+
const arg = m[1];
|
|
45
|
+
assert(
|
|
46
|
+
/\.derived_status\b/.test(arg),
|
|
47
|
+
`TERMINAL_STATUSES.includes(${arg}) classifies on the BASE status of a derive-only tracked table — ` +
|
|
48
|
+
`route it through prsTracking and read \`.derived_status\` (ADR-0065, #503)`,
|
|
49
|
+
);
|
|
50
|
+
assert(
|
|
51
|
+
!/[A-Za-z0-9_)\]]\.status\b/.test(arg),
|
|
52
|
+
`TERMINAL_STATUSES.includes(${arg}) still reads a base \`.status\` — the terminal edge is derive-only (#503)`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("class guard: no base `.status === ABANDONED_STATUS` / `.status === \"abandoned\"` read classification in service.ts", () => {
|
|
58
|
+
const code = stripComments(SRC("service.ts"));
|
|
59
|
+
// A READ classification against the derive-only `abandoned` terminal must use `.derived_status`. A
|
|
60
|
+
// base `.status === ABANDONED_STATUS`/`"abandoned"` would miss a derive-only-terminated PR. (Writers
|
|
61
|
+
// use the object-literal form `{ status: ABANDONED_STATUS }`, which this pattern never matches.)
|
|
62
|
+
const bad = [
|
|
63
|
+
...code.matchAll(/[A-Za-z0-9_)\]]\.status\s*===\s*ABANDONED_STATUS/g),
|
|
64
|
+
...code.matchAll(/[A-Za-z0-9_)\]]\.status\s*===\s*["']abandoned["']/g),
|
|
65
|
+
];
|
|
66
|
+
assertEquals(
|
|
67
|
+
bad.length,
|
|
68
|
+
0,
|
|
69
|
+
`a base \`.status\` is compared to the derive-only \`abandoned\` terminal — read \`.derived_status\` off ` +
|
|
70
|
+
`prsTracking instead (ADR-0065, #503): ${bad.map((m) => m[0]).join(", ")}`,
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("class guard: every PLAN_TERMINAL_STATUSES classification in plan.ts reads derived_status, not base status", () => {
|
|
75
|
+
const code = stripComments(SRC("plan.ts"));
|
|
76
|
+
// Match the `.some((s) => s === <ref>)` classification form used at both admission sites.
|
|
77
|
+
const calls = [...code.matchAll(/PLAN_TERMINAL_STATUSES\.some\(\([^)]*\)\s*=>\s*[^)]*===\s*([A-Za-z0-9_.]+)\)/g)];
|
|
78
|
+
assert(calls.length > 0, "expected PLAN_TERMINAL_STATUSES classifications in plan.ts");
|
|
79
|
+
for (const m of calls) {
|
|
80
|
+
const ref = m[1];
|
|
81
|
+
assert(
|
|
82
|
+
/\.derived_status$/.test(ref),
|
|
83
|
+
`PLAN_TERMINAL_STATUSES classification reads \`${ref}\` — route it through plansTracking and read ` +
|
|
84
|
+
`\`.derived_status\` so a derive-only-terminated epic is seen terminal (ADR-0065, #503)`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("class guard: the feature read model classifies on derived_status, never the base status column", () => {
|
|
90
|
+
// The feature history read model (app/featureReadModel.ts) buckets a run's pipeline `stage`/
|
|
91
|
+
// `list_bucket` off its status. Under ADR-0065 that must be the terminal-folded `derived_status`
|
|
92
|
+
// (off `feature_runs__tracking`), or a terminated run renders "Implementing" forever. Assert the DSL
|
|
93
|
+
// never references the base `col("status")` for classification and that the effective-status column
|
|
94
|
+
// is the derived one.
|
|
95
|
+
assertEquals(EFFECTIVE_STATUS_COLUMN, "derived_status", "the feature read model's effective status must be the derived column");
|
|
96
|
+
const code = stripComments(SRC("featureReadModel.ts"));
|
|
97
|
+
assert(
|
|
98
|
+
!/col\(\s*["']status["']\s*\)/.test(code),
|
|
99
|
+
'app/featureReadModel.ts still references col("status") — the status-classifying derivations must ' +
|
|
100
|
+
'read col("derived_status") off feature_runs__tracking (ADR-0065, #503)',
|
|
101
|
+
);
|
|
102
|
+
assert(
|
|
103
|
+
/baseTable:\s*FEATURE_READ_MODEL_BASE_TABLE/.test(code) || /feature_runs__tracking/.test(code),
|
|
104
|
+
"the feature read model must be based on the feature_runs__tracking derived VIEW (ADR-0065, #503)",
|
|
105
|
+
);
|
|
106
|
+
});
|