@nanobpm/nano-workforce 0.189.1 → 0.189.3
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/feature.test.ts +74 -1
- package/app/feature.ts +67 -0
- package/app/featureDelivery.test.ts +466 -7
- package/app/featureReadModel.test.ts +80 -1
- package/app/featureReadModel.ts +83 -19
- package/app/github.test.ts +46 -1
- package/app/reconcile.ts +1 -1
- package/app/service.test.ts +162 -1
- package/app/service.ts +180 -13
- package/app/stage.ts +21 -4
- package/db/migrations/111_feature_read_model_ack_opened.sql +61 -0
- package/db/migrations/112_feature_read_model_opened_midhandoff.sql +63 -0
- package/package.json +1 -1
- package/workers/converge-feature/worker.ts +12 -3
package/app/featureReadModel.ts
CHANGED
|
@@ -31,16 +31,32 @@
|
|
|
31
31
|
// LATER ADR-0065 rollout steps (3/4), deliberately out of scope here.
|
|
32
32
|
|
|
33
33
|
import { and, caseWhen, col, defineReadModel, type Expr, eq, exists, lit, neq, not, or, pcol, type ReadModel, when } from "@nanobpm/urban";
|
|
34
|
-
import {
|
|
34
|
+
import { deriveAckOpenFromTerminal, deriveListBucketFromTerminal, terminalStatusIn } from "./listBucket.ts";
|
|
35
35
|
|
|
36
36
|
/** The 6 TRULY-terminal statuses that map to the `Done` stage — the single source of truth for the
|
|
37
|
-
* terminal tier of
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* back-compat with its existing importers.
|
|
37
|
+
* terminal tier of the pipeline `stage`/`stage_state` derivations (and the `stepAxis` render tier).
|
|
38
|
+
* Distinct from `FEATURE_TERMINAL_STATUSES` (app/feature.ts), the redispatch-settled set, which also
|
|
39
|
+
* counts `opened`/`converging` as terminal — those are LIVE pipeline stages (`PR open`/`Converging`),
|
|
40
|
+
* NOT `Done`, so the two lists stay separate for the STAGE derivations. Re-exported from app/stage.ts
|
|
41
|
+
* for back-compat with its existing importers.
|
|
42
|
+
*
|
|
43
|
+
* NOTE — the Active/History `list_bucket`/`ack_open` derivations do NOT use this set; they use the
|
|
44
|
+
* WIDER {@link FEATURE_ACK_TERMINAL_STATUSES} (issue #808). The dismissable-terminal set is
|
|
45
|
+
* deliberately decoupled from the pipeline `Done` set: a raise-only run that ends at `opened` is a live
|
|
46
|
+
* `PR open` STAGE yet a FINISHED run that must be acknowledgeable into History. */
|
|
42
47
|
export const STAGE_DONE_STATUSES: readonly string[] = ["merged", "converged", "blocked", "failed", "skipped", "abandoned"];
|
|
43
48
|
|
|
49
|
+
/** The status-set half of the DISMISSABLE-terminal predicate for the Active/History `list_bucket` +
|
|
50
|
+
* operator-`ack_open` derivations (issue #808) — the pipeline `Done` set ({@link STAGE_DONE_STATUSES})
|
|
51
|
+
* PLUS `opened`. A run folded to `opened` (a PR was raised) is dismissable-terminal, so it can drop to
|
|
52
|
+
* History on dismissal even though the pipeline still renders it in the LIVE `PR open` STAGE (which
|
|
53
|
+
* classifies on {@link STAGE_DONE_STATUSES}, so `opened` stays `PR open`, never `Done`). This is WIDER
|
|
54
|
+
* than `STAGE_DONE_STATUSES` (adds `opened`) and NARROWER than `FEATURE_TERMINAL_STATUSES` (excludes
|
|
55
|
+
* `converging`, still live in the convergence loop). NOTE — the derivation does NOT dismiss EVERY
|
|
56
|
+
* `opened` row on this set alone: an `opened` row that is still mid-handoff to the convergence loop is
|
|
57
|
+
* excluded by {@link featureDismissableTerminal} (see there). Exported for the drift/parity tests. */
|
|
58
|
+
export const FEATURE_ACK_TERMINAL_STATUSES: readonly string[] = [...STAGE_DONE_STATUSES, "opened"];
|
|
59
|
+
|
|
44
60
|
/** The DSL projection name the `attention` derivation `exists(...)`-reads. Unregistered in the
|
|
45
61
|
* `projectionRegistry`, so it resolves to the same-named physical table — the app's own `user_tasks`
|
|
46
62
|
* inbox (034_user_tasks_inbox.sql), reconciled by `pollUserTasks` (app/service.ts): a row exists IFF a
|
|
@@ -129,21 +145,69 @@ const attention: Expr = caseWhen(
|
|
|
129
145
|
lit(null),
|
|
130
146
|
);
|
|
131
147
|
|
|
132
|
-
/** The
|
|
148
|
+
/** The one transient to EXCLUDE from the dismissable-terminal set: an `opened` row still mid-handoff
|
|
149
|
+
* into the convergence loop.
|
|
150
|
+
*
|
|
151
|
+
* Why (issue #808 follow-up): `record-feature` writes `status="opened"` for a converge-REQUESTED run
|
|
152
|
+
* too, BEFORE `gw-converge` routes it into `converge-feature` (which flips it to `converging`). During
|
|
153
|
+
* that in-between window the row reads `opened` while the engine instance is still ACTIVE, so a plain
|
|
154
|
+
* "any `opened` is dismissable" rule would offer Dismiss on an in-flight handoff and let a premature
|
|
155
|
+
* ack later drag a still-converging run to History — the SAME failure mode the epic model guards
|
|
156
|
+
* against by excluding its own mid-flight `converging`. The row itself distinguishes the two: a
|
|
157
|
+
* converge-requested run that actually handed off carries `converge=1 AND pr_key IS NOT NULL` — exactly
|
|
158
|
+
* the transient to exclude. Every other `opened` is finished: a raise-only run (`converge=0`), or a
|
|
159
|
+
* keyless `opened` that never satisfied the gateway's `prKey != null` and so fell through to `End`
|
|
160
|
+
* (`pr_key IS NULL`). The status guard (`derived_status = 'opened'`) scopes the exclusion to `opened`
|
|
161
|
+
* ONLY, so a genuinely-terminal row (e.g. `merged`) with `converge=1 AND pr_key` stays dismissable.
|
|
162
|
+
* `eq(col,col)` is the DSL's null-safe `IS NOT NULL` (see {@link isAckStamped}). A run stranded in this
|
|
163
|
+
* window by an out-of-band termination is reconciled to terminal by `pollFeatureDelivery`'s handoff
|
|
164
|
+
* edge (app/service.ts), which owns its liveness — so excluding it here never wedges it in Active. */
|
|
165
|
+
const openedMidHandoff: Expr = and(
|
|
166
|
+
eq(col(EFFECTIVE_STATUS_COLUMN), lit("opened")),
|
|
167
|
+
eq(col("converge"), lit(1)),
|
|
168
|
+
eq(col("pr_key"), col("pr_key")),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
/* DEFERRED (issue #810): `openedMidHandoff` only excludes the `converge=1 AND pr_key` handoff window,
|
|
172
|
+
* so a raise-only (`converge=0`) or keyless `opened` row IS treated as dismissable-terminal even in the
|
|
173
|
+
* brief window before `record-feature`'s token reaches `gw-blocked`/`gw-converge`/End. This is an
|
|
174
|
+
* accepted, bounded sub-transaction transient: `record-feature` writes `opened` only AFTER the
|
|
175
|
+
* productive work (the PR was raised) is done, so the sole still-"live" work in that window is one inert
|
|
176
|
+
* gateway→End hop the instance auto-completes near-instantly — there is nothing productive for an
|
|
177
|
+
* ack/resubmission to hide or replace. The categorical fix — gating dismissability on a PERSISTED
|
|
178
|
+
* engine-completion marker (which would reverse the option-1a `pr_key` heuristic adjudicated in
|
|
179
|
+
* escalation 162) — is tracked in issue #810; do NOT reverse option-1a here. */
|
|
180
|
+
|
|
181
|
+
/** The feature DISMISSABLE-terminal PREDICATE — the epic read model's mid-flight refinement
|
|
182
|
+
* (app/planReadModel.ts / app/listBucket.ts) applied to features. A row is dismissable-terminal IFF its
|
|
183
|
+
* effective (terminal-folded) status is in the ONE canonical {@link FEATURE_ACK_TERMINAL_STATUSES} set
|
|
184
|
+
* (the pipeline-`Done` set PLUS `opened`) AND it is NOT a mid-handoff `opened` transient
|
|
185
|
+
* ({@link openedMidHandoff}). Built from the single {@link FEATURE_ACK_TERMINAL_STATUSES} source of
|
|
186
|
+
* truth — never a re-listed copy of its members — so a newly-acknowledgeable status added there flows
|
|
187
|
+
* through to `list_bucket`/`ack_open` without a second edit that could drift (issue #808 review). */
|
|
188
|
+
export const featureDismissableTerminal: Expr = and(
|
|
189
|
+
terminalStatusIn(EFFECTIVE_STATUS_COLUMN, FEATURE_ACK_TERMINAL_STATUSES),
|
|
190
|
+
not(openedMidHandoff),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
/** The Active/History partition: `history` IFF the row is DISMISSABLE-terminal AND has been
|
|
133
194
|
* acknowledged; otherwise `active` (live runs + terminal-but-UNACKNOWLEDGED runs). Delegates to the ONE
|
|
134
|
-
* shared
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
195
|
+
* shared oracle (app/listBucket.ts, issue #641) over the feature {@link featureDismissableTerminal}
|
|
196
|
+
* predicate — WIDER than the pipeline `Done` set so a finished raise-only `opened` run is dismissable
|
|
197
|
+
* into History while the STAGE derivations still render it `PR open`, yet excluding a mid-handoff
|
|
198
|
+
* `opened` so a stray/premature ack never drags a still-converging run to History. All four "Active …"
|
|
199
|
+
* grids share the identical AST shape. */
|
|
200
|
+
const listBucket: Expr = deriveListBucketFromTerminal(featureDismissableTerminal);
|
|
201
|
+
|
|
202
|
+
/** The operator "Dismiss" (tick-off) affordance flag — `1` IFF the run is DISMISSABLE-terminal AND not
|
|
203
|
+
* yet acknowledged (so the page's `showWhenField` Dismiss button renders only for a terminal-but-
|
|
142
204
|
* unacknowledged run), else `0`. The feature twin of the PR/Delivery-Graph/Epic `ack_open`, from the
|
|
143
|
-
* ONE shared oracle (app/listBucket.ts, issue #641)
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
|
|
205
|
+
* ONE shared oracle (app/listBucket.ts, issue #641) over the SAME {@link featureDismissableTerminal}
|
|
206
|
+
* predicate `list_bucket` uses — so the Dismiss button and the `acknowledgeDone` guard consume the
|
|
207
|
+
* identical predicate and cannot drift (issue #654). Including a FINISHED `opened` (issue #808) lets a
|
|
208
|
+
* raise-only run be ticked off so it no longer wedges in Active; excluding a mid-handoff `opened` keeps
|
|
209
|
+
* an in-flight convergence handoff non-dismissable. */
|
|
210
|
+
const ackOpen: Expr = deriveAckOpenFromTerminal(featureDismissableTerminal);
|
|
147
211
|
|
|
148
212
|
/** The keys of {@link featureReadModel}'s DERIVED columns, in the order migration 099 emits them.
|
|
149
213
|
* Base columns are identity pass-throughs (not derivations) and are listed in the migration directly. */
|
package/app/github.test.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
|
|
4
4
|
import { test } from "node:test";
|
|
5
5
|
import { assertEquals, assertRejects } from "#test-assert";
|
|
6
|
-
import { BaseBranchMustExistError, checkConclusions, classifyMergeability, classifyPrLiveness, coalesceTitle, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchBranchHead, fetchIssueTitle, fetchPrFiles, fetchPrHead, fetchPrReviews, isNotAPullRequestError, listPrsForHead, type Mergeability, type PrState } from "./github.ts";
|
|
6
|
+
import { BaseBranchMustExistError, checkConclusions, classifyMergeability, classifyPrLiveness, coalesceTitle, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchBranchHead, fetchIssueTitle, fetchPrFiles, fetchPrHead, fetchPrReviews, isNotAPullRequestError, listPrsForHead, type GhReview, type Mergeability, type PrState } from "./github.ts";
|
|
7
7
|
import { DEFAULT_MERGE_PROTOCOL, type MergeProtocol, type RequiredCheck } from "./mergeProtocol.ts";
|
|
8
8
|
|
|
9
9
|
// A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
|
|
@@ -40,6 +40,51 @@ async function withTokenTransport<T>(pages: number[], fn: () => Promise<T>): Pro
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
function reviewFetch(pages: GhReview[][], requests: string[]) {
|
|
44
|
+
return (url: string | URL | Request): Promise<Response> => {
|
|
45
|
+
const u = new URL(String(url));
|
|
46
|
+
requests.push(u.toString());
|
|
47
|
+
const page = Number(u.searchParams.get("page") ?? "1");
|
|
48
|
+
const headers = new Headers();
|
|
49
|
+
if (page < pages.length) {
|
|
50
|
+
headers.set(
|
|
51
|
+
"link",
|
|
52
|
+
`<https://api.github.com/repos/o/r/pulls/1/reviews?per_page=100&page=${page + 1}>; rel="next", ` +
|
|
53
|
+
`<https://api.github.com/repos/o/r/pulls/1/reviews?per_page=100&page=${pages.length}>; rel="last"`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return Promise.resolve(new Response(JSON.stringify(pages[page - 1] ?? []), { status: 200, headers }));
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function withTokenFetch<T>(fetchImpl: typeof fetch, fn: () => Promise<T>): Promise<T> {
|
|
61
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
62
|
+
const prevFetch = globalThis.fetch;
|
|
63
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
64
|
+
globalThis.fetch = fetchImpl;
|
|
65
|
+
try {
|
|
66
|
+
return await fn();
|
|
67
|
+
} finally {
|
|
68
|
+
globalThis.fetch = prevFetch;
|
|
69
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
70
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
test("fetchPrReviews: returns the newest review when it is beyond page one", async () => {
|
|
75
|
+
const requests: string[] = [];
|
|
76
|
+
const pages = [
|
|
77
|
+
Array.from({ length: 100 }, (_, i) => ({ id: i + 1, state: "COMMENTED" })),
|
|
78
|
+
[{ id: 101, state: "APPROVED", submitted_at: "2026-09-15T12:00:00Z" }],
|
|
79
|
+
];
|
|
80
|
+
const reviews = await withTokenFetch(reviewFetch(pages, requests) as typeof fetch, () =>
|
|
81
|
+
fetchPrReviews("o/r", 1, "tok"),
|
|
82
|
+
);
|
|
83
|
+
assertEquals(reviews?.length, 101);
|
|
84
|
+
assertEquals(reviews?.[reviews.length - 1]?.id, 101);
|
|
85
|
+
assertEquals(requests.length, 2, "the final page must be fetched after page one");
|
|
86
|
+
});
|
|
87
|
+
|
|
43
88
|
test("fetchPrFiles: returns the complete list for a sub-cap PR (short final page)", async () => {
|
|
44
89
|
const files = await withTokenTransport([100, 42], () => fetchPrFiles("o/r", 1, "tok"));
|
|
45
90
|
assertEquals(files?.length, 142);
|
package/app/reconcile.ts
CHANGED
|
@@ -232,7 +232,7 @@ interface InstanceSearchStateItem {
|
|
|
232
232
|
* outside this set is a partial/malformed read this app cannot interpret, so it degrades to `null`
|
|
233
233
|
* ("unknown" → the caller spares the row). Classifying an unrecognized state as "gone" would fold
|
|
234
234
|
* live work off a wire shape we misread — the exact failure mode the #736 cross-check exists to stop. */
|
|
235
|
-
const ENGINE_TERMINAL_STATES: ReadonlySet<string> = new Set(["COMPLETED", "TERMINATED", "CANCELED", "FAILED"]);
|
|
235
|
+
export const ENGINE_TERMINAL_STATES: ReadonlySet<string> = new Set(["COMPLETED", "TERMINATED", "CANCELED", "FAILED"]);
|
|
236
236
|
|
|
237
237
|
/** Build an {@link EngineActiveProbe} that queries the engine's own `/v2/process-instances/search` for
|
|
238
238
|
* a single process instance key and reports whether the engine still considers it ACTIVE. This is the
|
package/app/service.test.ts
CHANGED
|
@@ -11,9 +11,10 @@ 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, isPrSettled, MAX_ACK_RETRIES, parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
|
|
14
|
+
import { abandonClosedPr, isPrSettled, MAX_ACK_RETRIES, parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollReviews, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
|
|
15
15
|
import { trackingTargetFor } from "./instanceTracking.ts";
|
|
16
16
|
import type { DataLayer } from "@nanobpm/urban";
|
|
17
|
+
import { READINESS_READY_MESSAGE } from "./readiness.ts";
|
|
17
18
|
|
|
18
19
|
function memTable(rows: any[], key: string) {
|
|
19
20
|
return {
|
|
@@ -78,6 +79,90 @@ function withGithubOff(run: () => Promise<void>): Promise<void> {
|
|
|
78
79
|
});
|
|
79
80
|
}
|
|
80
81
|
|
|
82
|
+
function reviewPagesFetch(pages: Record<string, unknown>[][], requests: string[]) {
|
|
83
|
+
return (url: string | URL | Request): Promise<Response> => {
|
|
84
|
+
const u = new URL(String(url));
|
|
85
|
+
if (!u.pathname.endsWith("/reviews")) {
|
|
86
|
+
return Promise.resolve(
|
|
87
|
+
new Response(
|
|
88
|
+
JSON.stringify({ head: { ref: null, sha: "SHA_CURRENT" } }),
|
|
89
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
requests.push(u.toString());
|
|
94
|
+
const page = Number(u.searchParams.get("page") ?? "1");
|
|
95
|
+
const headers = new Headers();
|
|
96
|
+
if (page < pages.length) {
|
|
97
|
+
headers.set(
|
|
98
|
+
"link",
|
|
99
|
+
`<https://api.github.com/repos/owner/repo/pulls/42/reviews?per_page=100&page=${page + 1}>; rel="next", ` +
|
|
100
|
+
`<https://api.github.com/repos/owner/repo/pulls/42/reviews?per_page=100&page=${pages.length}>; rel="last"`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return Promise.resolve(new Response(JSON.stringify(pages[page - 1] ?? []), { status: 200, headers }));
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
test("pollReviews publishes readiness-ready for a fresh review on the final page (#793)", async () => {
|
|
108
|
+
const oldReviews = Array.from({ length: 100 }, (_, i) => ({
|
|
109
|
+
id: i + 1,
|
|
110
|
+
state: "COMMENTED",
|
|
111
|
+
submitted_at: "2026-09-01T00:00:00Z",
|
|
112
|
+
commit_id: "SHA_CURRENT",
|
|
113
|
+
}));
|
|
114
|
+
const pages = [
|
|
115
|
+
oldReviews,
|
|
116
|
+
[{ id: 101, state: "APPROVED", submitted_at: "2026-09-15T12:00:00Z", commit_id: "SHA_CURRENT" }],
|
|
117
|
+
];
|
|
118
|
+
const requests: string[] = [];
|
|
119
|
+
const pr = {
|
|
120
|
+
pr_key: "owner/repo#42",
|
|
121
|
+
repo: "owner/repo",
|
|
122
|
+
number: 42,
|
|
123
|
+
status: "waiting_review",
|
|
124
|
+
waiting_since: "2026-09-10T00:00:00Z",
|
|
125
|
+
last_review_id: 100,
|
|
126
|
+
};
|
|
127
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
128
|
+
pull_requests: { rows: [pr], key: "pr_key" },
|
|
129
|
+
};
|
|
130
|
+
const data = {
|
|
131
|
+
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], key),
|
|
132
|
+
} as any as DataLayer;
|
|
133
|
+
const messages: { name: string; correlationKey?: string; variables?: Record<string, unknown> }[] = [];
|
|
134
|
+
const engine = {
|
|
135
|
+
publishMessage: async (message: { name: string; correlationKey?: string; variables?: Record<string, unknown> }) => {
|
|
136
|
+
messages.push(message);
|
|
137
|
+
},
|
|
138
|
+
} as any;
|
|
139
|
+
|
|
140
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
141
|
+
const prevFetch = globalThis.fetch;
|
|
142
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
143
|
+
globalThis.fetch = reviewPagesFetch(pages, requests) as typeof fetch;
|
|
144
|
+
try {
|
|
145
|
+
await pollReviews(data, engine, "tok");
|
|
146
|
+
} finally {
|
|
147
|
+
globalThis.fetch = prevFetch;
|
|
148
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
149
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
assertEquals(requests.length, 2, "the poller must read the final reviews page");
|
|
153
|
+
assertEquals(
|
|
154
|
+
messages,
|
|
155
|
+
[{
|
|
156
|
+
name: READINESS_READY_MESSAGE,
|
|
157
|
+
correlationKey: "owner/repo#42",
|
|
158
|
+
variables: { ready: true, detail: "review 101 (APPROVED)" },
|
|
159
|
+
}],
|
|
160
|
+
"fresh review must release the review wait",
|
|
161
|
+
);
|
|
162
|
+
assertEquals(pr.last_review_id, 101);
|
|
163
|
+
assertEquals(pr.status, "converging");
|
|
164
|
+
});
|
|
165
|
+
|
|
81
166
|
test("isPrSettled reads the derived tracking view — an out-of-band-abandoned PR (base row still converging) is settled", async () => {
|
|
82
167
|
// The base `pull_requests` row still reads `converging`, but the ADR-0065 derived tracking VIEW
|
|
83
168
|
// folds the reconciler's out-of-band terminal edge into `derived_status: "abandoned"`. Terminal-edge
|
|
@@ -495,6 +580,82 @@ test("submitPr stringifies a numeric processInstanceKey (contract: string | null
|
|
|
495
580
|
});
|
|
496
581
|
});
|
|
497
582
|
|
|
583
|
+
// Phantom-enrollment guard (#704/#497, PR #809 review): `submitPr` INSERTS the `pull_requests` row
|
|
584
|
+
// (`status='converging'`, `process_key` NULL) BEFORE `createInstance` and writes `process_key` only
|
|
585
|
+
// after it returns. A create-instance failure/crash in that window strands a NON-terminal row with NO
|
|
586
|
+
// `process_key` and NO live instance. Such a row must NOT be classified `alreadyRunning` (that would
|
|
587
|
+
// wedge it forever, and `pollFeatureDelivery` edge (1) would skip it as non-terminal) — it must fall
|
|
588
|
+
// through and RE-ENROLL, exactly as a genuinely-live row (non-terminal + `process_key` present) is
|
|
589
|
+
// still short-circuited to `alreadyRunning`.
|
|
590
|
+
test("submitPr re-enrolls a phantom row (non-terminal, process_key NULL) rather than reporting alreadyRunning (#704/#497)", async () => {
|
|
591
|
+
await withGithubOff(async () => {
|
|
592
|
+
const PR_KEY = "owner/repo#77";
|
|
593
|
+
const phantom = { pr_key: PR_KEY, repo: "owner/repo", number: 77, status: "converging", process_key: null };
|
|
594
|
+
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
595
|
+
pull_requests: { rows: [phantom], key: "pr_key" },
|
|
596
|
+
escalations: { rows: [], key: "id" },
|
|
597
|
+
pr_dependencies: { rows: [], key: "pr_key" },
|
|
598
|
+
};
|
|
599
|
+
const data = {
|
|
600
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
601
|
+
open: () => memOpen(stores as any),
|
|
602
|
+
} as any;
|
|
603
|
+
let created = 0;
|
|
604
|
+
const engine = {
|
|
605
|
+
createInstance: () => {
|
|
606
|
+
created++;
|
|
607
|
+
return Promise.resolve({ processInstanceKey: "PI-77" });
|
|
608
|
+
},
|
|
609
|
+
} as any;
|
|
610
|
+
|
|
611
|
+
const res = await submitPr(data, engine, {
|
|
612
|
+
repo: "owner/repo",
|
|
613
|
+
number: 77,
|
|
614
|
+
url: "https://github.com/owner/repo/pull/77",
|
|
615
|
+
prKey: PR_KEY,
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
assertEquals((res as any).alreadyRunning, undefined, "a phantom row is not short-circuited as alreadyRunning");
|
|
619
|
+
assertEquals(created, 1, "the phantom row is re-enrolled with a fresh instance");
|
|
620
|
+
const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
|
|
621
|
+
assertEquals(pr.process_key, "PI-77", "the previously-missing process_key is installed");
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test("submitPr still short-circuits a genuinely-live non-terminal row (process_key present) as alreadyRunning (#704/#497)", async () => {
|
|
626
|
+
await withGithubOff(async () => {
|
|
627
|
+
const PR_KEY = "owner/repo#78";
|
|
628
|
+
const live = { pr_key: PR_KEY, repo: "owner/repo", number: 78, status: "converging", process_key: "PI-live" };
|
|
629
|
+
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
630
|
+
pull_requests: { rows: [live], key: "pr_key" },
|
|
631
|
+
escalations: { rows: [], key: "id" },
|
|
632
|
+
pr_dependencies: { rows: [], key: "pr_key" },
|
|
633
|
+
};
|
|
634
|
+
const data = {
|
|
635
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
636
|
+
} as any;
|
|
637
|
+
let created = 0;
|
|
638
|
+
const engine = {
|
|
639
|
+
createInstance: () => {
|
|
640
|
+
created++;
|
|
641
|
+
return Promise.resolve({ processInstanceKey: "PI-new" });
|
|
642
|
+
},
|
|
643
|
+
} as any;
|
|
644
|
+
|
|
645
|
+
const res = await submitPr(data, engine, {
|
|
646
|
+
repo: "owner/repo",
|
|
647
|
+
number: 78,
|
|
648
|
+
url: "https://github.com/owner/repo/pull/78",
|
|
649
|
+
prKey: PR_KEY,
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
assertEquals((res as any).alreadyRunning, true, "a live enrolled PR is still short-circuited");
|
|
653
|
+
assertEquals(created, 0, "no duplicate instance is created for a live PR");
|
|
654
|
+
const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
|
|
655
|
+
assertEquals(pr.process_key, "PI-live", "the live PR's process_key is untouched");
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
|
|
498
659
|
// Per-request review-only override: `submitPr` carries `convergeOnly` onto the convergence
|
|
499
660
|
// instance so `pr.finalize` can stop at `converged` without handing off to the merge-loop. Default
|
|
500
661
|
// false (so the global auto-merge default governs); true when the caller pins review-only.
|
package/app/service.ts
CHANGED
|
@@ -35,7 +35,7 @@ import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels } from "./deli
|
|
|
35
35
|
import { deliveryHumanContextQuestion, isDeliveryHumanElement } from "./deliveryHuman.ts";
|
|
36
36
|
import { fleetSupportsDurableResume } from "./durableResume.ts";
|
|
37
37
|
import { deriveEpicPhaseLive, deriveTerminalEpicPhase } from "./epicPhase.ts";
|
|
38
|
-
import { deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
|
|
38
|
+
import { deriveFeatureCompletion, deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns, featureRunsTracking, foldCompletedFeatureRun } from "./feature.ts";
|
|
39
39
|
import {
|
|
40
40
|
classifyMergeability,
|
|
41
41
|
classifyPrLiveness,
|
|
@@ -88,6 +88,7 @@ import {
|
|
|
88
88
|
readinessPollEvery,
|
|
89
89
|
readinessTimeout,
|
|
90
90
|
} from "./readiness.ts";
|
|
91
|
+
import { ENGINE_TERMINAL_STATES } from "./reconcile.ts";
|
|
91
92
|
// The repository-provisioning envelope builder lives in its own module (app/repoEnvelope.ts) so the
|
|
92
93
|
// PRE-PR implementation dispatch (`feature.ts`/`plan.ts`) can reuse the ONE canonical implementation
|
|
93
94
|
// without a `plan.ts ↔ service.ts` import cycle (issue #684). Imported for the PR-based callers
|
|
@@ -534,7 +535,16 @@ export async function submitPr(
|
|
|
534
535
|
// it `alreadyRunning` forever (the #497 phantom). Route the idempotency gate through the derived view
|
|
535
536
|
// so a cancelled PR is correctly seen terminal and RESUBMITTABLE.
|
|
536
537
|
const trackedExisting = existing ? await prsTracking(data).get(parsed.prKey) : undefined;
|
|
537
|
-
|
|
538
|
+
// Phantom-enrollment guard (#704/#497, PR #809 review): a non-terminal row is "already running" ONLY
|
|
539
|
+
// if an engine instance was actually dispatched — i.e. it holds a `process_key`. `submitPr` INSERTS
|
|
540
|
+
// the `pull_requests` row (`status='converging'`, `process_key` NULL) BEFORE `createInstance` and
|
|
541
|
+
// writes `process_key` only AFTER it returns (below), so a create-instance failure/crash in that
|
|
542
|
+
// window strands a `converging` row with NO `process_key` and NO live instance. Classifying it off
|
|
543
|
+
// the derived status alone would wedge it `alreadyRunning` forever (and `pollFeatureDelivery` edge (1)
|
|
544
|
+
// would skip it as non-terminal, never re-enrolling). Treat a keyless non-terminal row as
|
|
545
|
+
// RESUBMITTABLE — fall through and re-enroll, exactly as `startFeature`'s intake guard (feature.ts)
|
|
546
|
+
// treats a keyless `running` feature row. A terminal row (any `process_key`) already falls through.
|
|
547
|
+
if (trackedExisting && existing?.process_key != null && !TERMINAL_STATUSES.includes(trackedExisting.derived_status)) {
|
|
538
548
|
return { prKey: parsed.prKey, alreadyRunning: true };
|
|
539
549
|
}
|
|
540
550
|
|
|
@@ -2294,19 +2304,69 @@ export async function pollPromotion(data: DataLayer, engine: EngineClient, token
|
|
|
2294
2304
|
}
|
|
2295
2305
|
}
|
|
2296
2306
|
|
|
2297
|
-
/** Reconcile each in-flight FEATURE run against
|
|
2298
|
-
* `converging
|
|
2299
|
-
*
|
|
2300
|
-
*
|
|
2301
|
-
*
|
|
2302
|
-
*
|
|
2303
|
-
*
|
|
2304
|
-
*
|
|
2305
|
-
|
|
2307
|
+
/** Reconcile each in-flight FEATURE run against ENGINE truth (fix: Feature history stuck at
|
|
2308
|
+
* `converging`; issue #808: raise-only runs wedge in Active). Two idempotent, best-effort edges over
|
|
2309
|
+
* the canonical feature reconciliation — no parallel reconciler:
|
|
2310
|
+
*
|
|
2311
|
+
* 1. CONVERGING → terminal (from its handed-off PR). A feature run ends its own process with
|
|
2312
|
+
* `status = converging` and its PR's live outcome (merged / converged / abandoned) thereafter lives
|
|
2313
|
+
* only on the `pull_requests` row keyed by `pr_key` — so the Feature history grid, which reads
|
|
2314
|
+
* `feature_runs`, showed `converging` forever. The `feature_runs` twin of `pollDelivery`: for each
|
|
2315
|
+
* run currently `converging` with a `pr_key`, project the PR's status onto `feature_runs.status`
|
|
2316
|
+
* (advancing it to the matching terminal outcome once the PR settles) + a human `delivery_label`.
|
|
2317
|
+
*
|
|
2318
|
+
* 2. COMPLETED → terminal (from the engine instance; issue #808). A raise-only / normally-completing
|
|
2319
|
+
* run ends its process with the non-terminal base `status = running` and is NEVER handed to a PR
|
|
2320
|
+
* loop, so neither edge (1) nor `instanceTracking`'s TERMINATED → `abandoned` edge folds it — it
|
|
2321
|
+
* would wedge in Active forever ("poller owns liveness; never leave a run on a status no pass
|
|
2322
|
+
* scans"). This owns the COMPLETED → terminal fold for such runs, mirroring the taskless-plan pass
|
|
2323
|
+
* ({@link pollTasklessPlanTermination}, #624) and {@link pollDeliveryGraphPhase}. Scoped to
|
|
2324
|
+
* `running` — the only non-terminal, non-parked status that can reach COMPLETED (`escalated` /
|
|
2325
|
+
* `awaiting_operator` keep the instance ALIVE at a user task, owned by their own edges) — so a
|
|
2326
|
+
* `converging` run is untouched by this fold. Liveness is read off the ADR-0065 derived tracking
|
|
2327
|
+
* VIEW (`derived_status`), so a run TERMINATED out of band (folding `derived_status` → `abandoned`)
|
|
2328
|
+
* is left to the `onTerminated` edge and never re-queried every pass. Writes only on a real
|
|
2329
|
+
* COMPLETED read (idempotent — a pass over a still-active or already-terminal run is a no-op).
|
|
2330
|
+
*
|
|
2331
|
+
* 3. MID-HANDOFF `opened` → terminal (PR #809 review / escalation 162). `record-feature` writes base
|
|
2332
|
+
* `status = opened` for a converge-REQUESTED run too, BEFORE `gw-converge` hands it to
|
|
2333
|
+
* `converge-feature` (which flips it to `converging` and enrolls the PR via `submitPr`). That
|
|
2334
|
+
* transient `opened` + `converge=1` + `pr_key` window is NOT dismissable (`featureReadModel` excludes
|
|
2335
|
+
* it so a premature ack can't drag a still-converging run to History), yet `opened` is no
|
|
2336
|
+
* `instanceTracking` activeStatus and neither edge (1) nor (2) scans it — so a row stranded there has
|
|
2337
|
+
* no owner. This owns that liveness, deciding PR-FIRST (never off the FEATURE instance state alone —
|
|
2338
|
+
* that was the drift surface): the `pull_requests` row keyed by `pr_key` is the single source of truth
|
|
2339
|
+
* for whether `submitPr` enrollment happened. If a PR row EXISTS, enrollment succeeded and the PR is a
|
|
2340
|
+
* live/settled SEPARATE process whose outcome edge (1) owns — fold `opened` → `converging`/terminal via
|
|
2341
|
+
* the same `deriveFeatureDelivery`, NEVER `abandoned` (it is structurally impossible for this edge to
|
|
2342
|
+
* abandon an enrolled PR). ONLY when NO PR row exists (enrollment never happened) is the engine probed:
|
|
2343
|
+
* a mid-handoff row can then only be stranded if its instance died before `submitPr`, so a KNOWN
|
|
2344
|
+
* terminal (or genuinely absent) instance means the handoff was interrupted — fold to the terminal
|
|
2345
|
+
* `abandoned` (mirroring `onTerminated`). That probe applies the reconcile-probe tri-state
|
|
2346
|
+
* (app/reconcile.ts): ACTIVE is left for `converge-feature`, and an instance whose snapshot carries an
|
|
2347
|
+
* empty/unknown state is SPARED (never folded off a wire shape we misread). A key edge (2) folded THIS
|
|
2348
|
+
* pass is skipped (it is already terminalized, not stranded). Read off the derived tracking VIEW so an
|
|
2349
|
+
* out-of-band fold is not re-queried; scoped to `converge=1 AND pr_key` (a raise-only or keyless
|
|
2350
|
+
* `opened` is genuinely terminal, owned by its own dismissable edge). */
|
|
2351
|
+
export async function pollFeatureDelivery(
|
|
2352
|
+
data: DataLayer,
|
|
2353
|
+
// Full `EngineClient`: edge (1) re-enrolls a converging run whose PR row is still missing via
|
|
2354
|
+
// `submitPr` (which needs `createInstance`), not just the `searchProcessInstances` the fold edges use.
|
|
2355
|
+
engine: EngineClient,
|
|
2356
|
+
) {
|
|
2306
2357
|
// Preload every PR status once per pass (mirrors pollDelivery — avoids an N+1 `prs(data).get`).
|
|
2307
2358
|
// Read the ADR-0065 derived edge (`derived_status`) so a terminated run reads `abandoned`.
|
|
2308
2359
|
const statusByPrKey = new Map<string, string>();
|
|
2309
|
-
|
|
2360
|
+
// Also index each tracked PR row so edge (1) can distinguish a NEVER-enrolled PR (no row) from a
|
|
2361
|
+
// PARTIALLY-enrolled one (row present, but `submitPr` crashed after the row insert and before
|
|
2362
|
+
// `createInstance` returned, so `process_key` is NULL and no instance exists — #704/#497 phantom).
|
|
2363
|
+
// Keep the whole row (not just the key) so the terminal-status classification below reads the
|
|
2364
|
+
// ADR-0065 `.derived_status`, satisfying the derive-only-reader class guard (#503/#704).
|
|
2365
|
+
const prRowByKey = new Map<string, TrackedPullRequest>();
|
|
2366
|
+
for (const pr of await prsTracking(data).all()) {
|
|
2367
|
+
statusByPrKey.set(pr.pr_key, pr.derived_status);
|
|
2368
|
+
prRowByKey.set(pr.pr_key, pr);
|
|
2369
|
+
}
|
|
2310
2370
|
// Only `converging` runs are ever reconciled — query them via the `feature_runs(status)` index
|
|
2311
2371
|
// (db/migrations/028) instead of scanning all history, so this pass stays O(in-flight), not
|
|
2312
2372
|
// O(total runs), as the table grows.
|
|
@@ -2314,6 +2374,27 @@ export async function pollFeatureDelivery(data: DataLayer) {
|
|
|
2314
2374
|
if (!run.pr_key) continue;
|
|
2315
2375
|
try {
|
|
2316
2376
|
const prStatus = statusByPrKey.get(run.pr_key) ?? null;
|
|
2377
|
+
const trackedPr = prRowByKey.get(run.pr_key) ?? null;
|
|
2378
|
+
// RE-ENROLL a converging run whose PR is not actually live, in EITHER of two shapes (PR #809
|
|
2379
|
+
// review):
|
|
2380
|
+
// (a) NO `pull_requests` row (`prStatus === null`) — since `converge-feature` writes
|
|
2381
|
+
// `converging` BEFORE `submitPr`, a terminate/crash in that gap (or an old submit failure /
|
|
2382
|
+
// store desync) leaves a converging row whose PR was never enrolled; and
|
|
2383
|
+
// (b) a PARTIALLY-enrolled row — present but with NO `process_key` and a non-terminal status:
|
|
2384
|
+
// `submitPr` inserts the row before `createInstance`, so a create-instance failure/crash
|
|
2385
|
+
// after the insert leaves a `converging` PR row with `process_key` NULL and no instance
|
|
2386
|
+
// (#704/#497 phantom). `prStatus` is non-null there, so the (a) check alone would skip it and
|
|
2387
|
+
// it would wedge `converging` forever with no PR process.
|
|
2388
|
+
// Both heal via the SAME idempotent `submitPr`: it is idempotent on the PR key (a redundant call
|
|
2389
|
+
// on an already-live PR early-returns `alreadyRunning`), and now treats a keyless non-terminal row
|
|
2390
|
+
// as resubmittable, so it re-creates the instance and installs `process_key`. `convergeOnly`
|
|
2391
|
+
// mirrors `converge-feature` — the inverse of the run's `auto_merge` flag.
|
|
2392
|
+
const partiallyEnrolled =
|
|
2393
|
+
trackedPr != null && trackedPr.process_key == null && !TERMINAL_STATUSES.includes(trackedPr.derived_status);
|
|
2394
|
+
if (prStatus === null || partiallyEnrolled) {
|
|
2395
|
+
const parsed = parsePr(run.pr_key);
|
|
2396
|
+
if (parsed) await submitPr(data, engine, parsed, [], MAX_ROUNDS, run.auto_merge !== 1, run.feature_key);
|
|
2397
|
+
}
|
|
2317
2398
|
const { status, label } = deriveFeatureDelivery(prStatus);
|
|
2318
2399
|
if (run.status !== status || run.delivery_label !== label) {
|
|
2319
2400
|
await featureRuns(data).update(run.feature_key, {
|
|
@@ -2326,6 +2407,92 @@ export async function pollFeatureDelivery(data: DataLayer) {
|
|
|
2326
2407
|
console.error(`[poller] feature delivery ${run.feature_key}: ${err}`);
|
|
2327
2408
|
}
|
|
2328
2409
|
}
|
|
2410
|
+
// Edge (2): COMPLETED → terminal for a normally-completing `running` run (issue #808). Read the
|
|
2411
|
+
// derived tracking VIEW so a base-`running` row whose instance TERMINATED out of band (derived
|
|
2412
|
+
// `abandoned`) is skipped here (the `onTerminated` edge owns it) and never re-queried the engine.
|
|
2413
|
+
// Feature keys this edge folds this pass are tracked in `foldedByCompletion` so edge (3) — which
|
|
2414
|
+
// re-reads the DB and would otherwise see a row edge (2) just moved to `opened` in the SAME pass —
|
|
2415
|
+
// leaves them alone (PR #809 review): edge (2) has already assigned such a row its terminal outcome.
|
|
2416
|
+
const foldedByCompletion = new Set<string>();
|
|
2417
|
+
for (const run of await featureRunsTracking(data).find({ status: "running" })) {
|
|
2418
|
+
if (!run.process_key) continue;
|
|
2419
|
+
if (run.derived_status !== "running") continue;
|
|
2420
|
+
const processKey = run.process_key;
|
|
2421
|
+
try {
|
|
2422
|
+
const snapshots = await engine.searchProcessInstances({ processInstanceKeys: [processKey] });
|
|
2423
|
+
const state = snapshots.find((s) => String(s.processInstanceKey) === processKey)?.state ?? null;
|
|
2424
|
+
if (state !== "COMPLETED") continue;
|
|
2425
|
+
const { status, label } = deriveFeatureCompletion(run);
|
|
2426
|
+
// Guarded CAS (not a blind update-by-key): a concurrent `startFeature` may have re-seeded this
|
|
2427
|
+
// same row to a fresh `running` incarnation (new `process_key`) in the window since the engine
|
|
2428
|
+
// read above — the guard makes such a lost race a no-op instead of clobbering the new run.
|
|
2429
|
+
if (await foldCompletedFeatureRun(data, run.feature_key, processKey, status, label)) {
|
|
2430
|
+
foldedByCompletion.add(run.feature_key);
|
|
2431
|
+
}
|
|
2432
|
+
} catch (err) {
|
|
2433
|
+
console.error(`[poller] feature completion ${run.feature_key}: ${err}`);
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
// Edge (3): mid-handoff `opened` → `abandoned` for a run whose instance died before convergence (PR
|
|
2437
|
+
// #809 review, issue #808 follow-up). `record-feature` writes base `status="opened"` for a
|
|
2438
|
+
// converge-REQUESTED run TOO, BEFORE `gw-converge` hands it to `converge-feature` (which flips it to
|
|
2439
|
+
// `converging`). That transient `opened` + `converge=1` + non-null `pr_key` window is deliberately
|
|
2440
|
+
// NOT dismissable (`featureReadModel` excludes it, so a premature ack can't drag a still-converging
|
|
2441
|
+
// run to History) — but `opened` is not an `instanceTracking` activeStatus (nano.app.json), and
|
|
2442
|
+
// neither the converging edge (1) nor the running edge (2) scans it, so an instance TERMINATED in
|
|
2443
|
+
// that window would strand the row in Active FOREVER with `ack_open=0` and no reconciler. This edge
|
|
2444
|
+
// owns that liveness: a mid-handoff row can only LEAVE `opened` by writing `converging` (a clean
|
|
2445
|
+
// handoff) — so if its instance is a KNOWN terminal (or genuinely absent) while the row still reads
|
|
2446
|
+
// `opened`, the handoff was interrupted (out-of-band terminate / crash) and the run is folded to the
|
|
2447
|
+
// terminal `abandoned` (the same edge `instanceTracking.onTerminated` applies to a tracked run's
|
|
2448
|
+
// out-of-band termination). The snapshot is read with the SAME tri-state as the reconcile probe
|
|
2449
|
+
// (app/reconcile.ts): a still-ACTIVE instance is left untouched (`converge-feature` will advance it),
|
|
2450
|
+
// and an instance present with an empty/unknown/newly-introduced state is SPARED, never folded off a
|
|
2451
|
+
// wire shape we misread. A row edge (2) folded to `opened` in THIS pass is skipped (`foldedByCompletion`)
|
|
2452
|
+
// — it is already terminalized, not a stranded handoff. Read the derived tracking VIEW so a row
|
|
2453
|
+
// already folded `abandoned` out of band is skipped and never re-queried. Guarded CAS on the OLD
|
|
2454
|
+
// `process_key` + `status='opened'`, so a concurrent `startFeature` re-seed is a no-op, never a
|
|
2455
|
+
// clobber. Scoped to `converge=1 AND pr_key` — a raise-only (`converge=0`) or keyless (`pr_key IS
|
|
2456
|
+
// NULL`) `opened` is genuinely terminal and owned by its own dismissable edge, so it is never queried here.
|
|
2457
|
+
for (const run of await featureRunsTracking(data).find({ status: "opened" })) {
|
|
2458
|
+
if (!run.process_key || !run.pr_key || run.converge !== 1) continue;
|
|
2459
|
+
if (run.derived_status !== "opened") continue;
|
|
2460
|
+
// Edge (2) may have folded a COMPLETED `running` run to `opened` in THIS pass (thread 1): that row
|
|
2461
|
+
// is already terminalized by its own derivation, not a stranded mid-handoff — never re-fold it here.
|
|
2462
|
+
if (foldedByCompletion.has(run.feature_key)) continue;
|
|
2463
|
+
const processKey = run.process_key;
|
|
2464
|
+
try {
|
|
2465
|
+
// PR-DERIVED liveness FIRST (PR #809 review / escalation 162 decision 2): a `pull_requests` row
|
|
2466
|
+
// keyed by `pr_key` is the single source of truth for "did `submitPr` enrollment actually happen".
|
|
2467
|
+
// If one EXISTS, `converge-feature` already enrolled the PR — it is a live/settled SEPARATE process
|
|
2468
|
+
// whose outcome edge (1) owns — so the handoff was NOT interrupted regardless of the FEATURE
|
|
2469
|
+
// instance's engine state. Project the PR's derived outcome onto the row (fold `opened` →
|
|
2470
|
+
// `converging`/terminal via the SAME `deriveFeatureDelivery` edge (1) uses) and NEVER abandon it.
|
|
2471
|
+
// This makes it structurally impossible for the handoff edge to abandon an enrolled PR — inferring
|
|
2472
|
+
// "interrupted" from the FEATURE instance state alone was the drift surface. The engine probe below
|
|
2473
|
+
// is reached ONLY when no PR row exists (enrollment never happened), the genuine interrupted handoff.
|
|
2474
|
+
const prStatus = statusByPrKey.get(run.pr_key) ?? null;
|
|
2475
|
+
if (prStatus !== null) {
|
|
2476
|
+
const { status, label } = deriveFeatureDelivery(prStatus);
|
|
2477
|
+
await foldCompletedFeatureRun(data, run.feature_key, processKey, status, label, "opened");
|
|
2478
|
+
continue;
|
|
2479
|
+
}
|
|
2480
|
+
const snapshots = await engine.searchProcessInstances({ processInstanceKeys: [processKey] });
|
|
2481
|
+
const match = snapshots.find((s) => String(s.processInstanceKey) === processKey);
|
|
2482
|
+
// Interpret the snapshot with the SAME tri-state semantics as the reconcile engine-probe
|
|
2483
|
+
// (app/reconcile.ts): ACTIVE → still mid-handoff; a KNOWN terminal state or a genuinely-absent
|
|
2484
|
+
// instance → the handoff was interrupted (fold); an instance present with an empty/unknown/
|
|
2485
|
+
// newly-introduced state → a partial answer this app cannot interpret, so spare the row rather
|
|
2486
|
+
// than fold a possibly still-live run to `abandoned` off a wire shape we misread (thread 3).
|
|
2487
|
+
const state = match ? String(match.state ?? "").trim().toUpperCase() : null;
|
|
2488
|
+
if (state === "ACTIVE") continue; // still mid-handoff — converge-feature will advance it
|
|
2489
|
+
if (state !== null && !ENGINE_TERMINAL_STATES.has(state)) continue; // unknown/empty → spare
|
|
2490
|
+
// state === null (engine answered, instance absent) OR a known terminal → interrupted handoff.
|
|
2491
|
+
await foldCompletedFeatureRun(data, run.feature_key, processKey, "abandoned", "handoff interrupted", "opened");
|
|
2492
|
+
} catch (err) {
|
|
2493
|
+
console.error(`[poller] feature handoff ${run.feature_key}: ${err}`);
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2329
2496
|
}
|
|
2330
2497
|
|
|
2331
2498
|
/** The `pull_requests` statuses a PR instance can be parked-and-active on, DERIVED from the single
|
|
@@ -3102,7 +3269,7 @@ export async function pollOnce(
|
|
|
3102
3269
|
await pollMerges(data, engine, token);
|
|
3103
3270
|
await pollWaitGate(data);
|
|
3104
3271
|
await pollPromotion(data, engine, token);
|
|
3105
|
-
await pollFeatureDelivery(data);
|
|
3272
|
+
await pollFeatureDelivery(data, engine);
|
|
3106
3273
|
await pollLineage(data);
|
|
3107
3274
|
await pollUserTasks(data, engine, engineRest);
|
|
3108
3275
|
await pollEpicPhase(data, engine, engineRest);
|