@nanobpm/nano-workforce 0.203.0 β 0.204.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/app/feature.ts +9 -0
- package/app/github.ts +14 -6
- package/app/issuePrLink.test.ts +703 -0
- package/app/issuePrLink.ts +312 -0
- package/app/service.ts +3 -0
- package/db/migrations/125_feature_runs_pr_linked.sql +78 -0
- package/docs/agent-guide.md +282 -855
- package/e2e/feature-run.e2e.ts +58 -1
- package/e2e/plan-fanout.e2e.ts +24 -1
- package/e2e/support/github-admit.ts +33 -0
- package/package.json +1 -1
- package/resources/processes/feature.bpmn +1 -0
- package/resources/processes/implement-cell.bpmn +1 -0
- package/skills/nano-workforce/SKILL.md +20 -65
- package/workers/record-feature/worker.test.ts +256 -0
- package/workers/record-feature/worker.ts +27 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// The canonical "PR opened" issue-link decision (issue #907).
|
|
2
|
+
//
|
|
3
|
+
// When a single-issue feature run delivers its PR, the claimed issue's comment thread must link to
|
|
4
|
+
// it: the agent posts `π€ Starting workβ¦` (resources/prompts/feature.md) but nothing ever said
|
|
5
|
+
// "PR opened: #N", so a reader saw the claim and then silence β and concluded the agent stalled
|
|
6
|
+
// (camunda/camunda#19090: the PR existed, opened by a retry, but the thread never showed it).
|
|
7
|
+
//
|
|
8
|
+
// This module is the ONE host-side writer (derivation over duplication β NOT a second instruction
|
|
9
|
+
// in each prompt). `pr.record-feature` calls it on the run's delivery point, which EVERY
|
|
10
|
+
// PR-delivering path passes through: the agent's own `opened`, a retry's `opened`, and the
|
|
11
|
+
// implement-cell reconcile step's ADOPTED `opened` (app/implementReconcile.ts) all converge there.
|
|
12
|
+
//
|
|
13
|
+
// Gating mirrors the claim rule: only when the run seeded `claimIssue: true` (a single-issue
|
|
14
|
+
// feature run β app/feature.ts). An epic slice never sets it, so a wave slice NEVER comments on
|
|
15
|
+
// the shared parent epic.
|
|
16
|
+
//
|
|
17
|
+
// Durable state machine (migration 125) β three columns on `feature_runs`:
|
|
18
|
+
// β’ `pr_link_want_pr_key` β the PR the issue is OWED a link for. Written by `requestIssuePrLink`
|
|
19
|
+
// (pr.record-feature) ONLY for a claimed single-issue run with a keyed PR.
|
|
20
|
+
// β’ `pr_linked_pr_key` β CONFIRMED: written only after a successful POST or a marker match. A
|
|
21
|
+
// pending/in-flight claim is NEVER mistaken for a confirmed link.
|
|
22
|
+
// β’ `pr_link_lease_at` β an EXPIRING in-flight lease, CAS-claimed before any GitHub I/O so two
|
|
23
|
+
// racing passes never both post. On ANY failure (unreadable scan, rejected/thrown POST, crash)
|
|
24
|
+
// the lease is simply left to expire β that is the retry backoff β and the poller's
|
|
25
|
+
// `pollIssuePrLinks` pass (app/service.ts β `retryOutstandingIssuePrLinks`) re-attempts every
|
|
26
|
+
// outstanding link through this same `attemptIssuePrLink`, so a transient failure never leaves
|
|
27
|
+
// the delivered PR unannounced and never fails or restarts the run.
|
|
28
|
+
// Each comment also carries a hidden `<!-- nwf:pr-linked:#n <canonical-pr-url> -->` marker; the
|
|
29
|
+
// issue's comments are scanned (ALL pages) before posting, which heals a crash between a successful
|
|
30
|
+
// POST and the confirm. The marker keys on the canonical PR URL, not just the number, because PR
|
|
31
|
+
// numbers are repository-local β a same-number PR in another repo must NOT satisfy the scan.
|
|
32
|
+
//
|
|
33
|
+
// Stale-lease safety: the lease is re-validated with a guarded CAS (re-claiming a FRESH timestamp)
|
|
34
|
+
// IMMEDIATELY before the POST. A worker that stalls on the marker scan / title lookup past the lease
|
|
35
|
+
// expiry can have its lease re-claimed by the poller (which then posts); when the worker resumes it
|
|
36
|
+
// must NOT post a duplicate. The pre-post CAS rejects that stale holder (its claimed ts no longer
|
|
37
|
+
// matches) BEFORE the write β the after-the-fact confirm guard would be too late.
|
|
38
|
+
//
|
|
39
|
+
// Targeting: the comment goes to the CLAIMED ISSUE (the run row's `repo` + `issue_number`), never
|
|
40
|
+
// to the PR's repository β a cross-repo PR is looked up (title) in its own repo only.
|
|
41
|
+
//
|
|
42
|
+
// Best-effort throughout: every path resolves to a status string and NEVER throws.
|
|
43
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
44
|
+
import { featureRuns } from "./feature.ts";
|
|
45
|
+
import { fetchPrMeta, runGh, useGh } from "./github.ts";
|
|
46
|
+
import { parsePr } from "./prParse.ts";
|
|
47
|
+
|
|
48
|
+
/** The hidden per-PR dedupe marker prefix carried by every PR-opened comment. The full marker is
|
|
49
|
+
* `<!-- nwf:pr-linked:#<number> <canonical-pr-url> -->`; scanning for it makes a repeated pass
|
|
50
|
+
* idempotent even when the durable claim was lost (crash between the POST and the record write).
|
|
51
|
+
* The canonical PR URL is part of the marker because PR numbers are repository-local: a number-only
|
|
52
|
+
* marker would let a same-number PR in a DIFFERENT repo satisfy the scan and confirm a link that
|
|
53
|
+
* was never announced (issue #907 review). */
|
|
54
|
+
export const ISSUE_PR_LINK_MARKER_PREFIX = "<!-- nwf:pr-linked:#";
|
|
55
|
+
|
|
56
|
+
/** Build the full dedupe marker for one PR: `<!-- nwf:pr-linked:#<number> <canonical-pr-url> -->`.
|
|
57
|
+
* The URL disambiguates a same-number PR in another repository. */
|
|
58
|
+
export function issuePrLinkMarker(pr: { number: number; url: string }): string {
|
|
59
|
+
return `${ISSUE_PR_LINK_MARKER_PREFIX}${pr.number} ${pr.url} -->`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The discriminated outcome of a link attempt β logged by the caller so a skipped/failed link is
|
|
63
|
+
* observable without ever failing the run. */
|
|
64
|
+
export type IssuePrLinkOutcome =
|
|
65
|
+
| "posted" // the comment was posted and the link confirmed
|
|
66
|
+
| "already-recorded" // the confirmed record already names the owed PR β no GitHub I/O at all
|
|
67
|
+
| "already-marked" // the marker scan found the comment β link confirmed without re-posting
|
|
68
|
+
| "claim-lost" // another pass holds a live lease (or the CAS lost) β that pass owns the attempt
|
|
69
|
+
| "post-failed" // GitHub rejected/threw on the POST β lease left to expire; the poller retries
|
|
70
|
+
| "list-failed" // the marker scan was unreadable β nothing posted blind; the poller retries
|
|
71
|
+
| "skipped"; // not a claimed single-issue run, no PR, no row, or a guard failure
|
|
72
|
+
|
|
73
|
+
/** The comment body: one line naming the PR (number, optional title, URL) plus the hidden marker. */
|
|
74
|
+
export function prOpenedCommentBody(pr: { number: number; title?: string | null; url: string }): string {
|
|
75
|
+
const title = typeof pr.title === "string" && pr.title.trim() !== "" ? ` β ${pr.title.trim()}` : "";
|
|
76
|
+
return `π€ Pull request opened: #${pr.number}${title} (${pr.url})\n\n${issuePrLinkMarker(pr)}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** How long an in-flight lease blocks other passes. A crash or failure leaves it to expire, after
|
|
80
|
+
* which the poller retries β so this is also the retry backoff. */
|
|
81
|
+
export const ISSUE_PR_LINK_LEASE_MS = 5 * 60 * 1000;
|
|
82
|
+
|
|
83
|
+
/** Hard deadline on EVERY GitHub read/write this module makes (both transports). Kept well below the
|
|
84
|
+
* lease so a pass that re-validated its lease immediately before the POST still owns it for the
|
|
85
|
+
* whole pending write: a POST can never outlive the lease and race a poller re-claim into a
|
|
86
|
+
* duplicate. A timed-out POST is `post-failed` (never confirmed); the lease then expires and the
|
|
87
|
+
* retry's marker scan heals the case where GitHub committed it anyway. */
|
|
88
|
+
export const ISSUE_PR_LINK_IO_TIMEOUT_MS = 60 * 1000;
|
|
89
|
+
|
|
90
|
+
const PAGE_SIZE = 100;
|
|
91
|
+
/** Hard bound on pages scanned (10k comments) so a pathological issue can't loop forever. Hitting it
|
|
92
|
+
* is treated as UNREADABLE (fail closed: never post blind). */
|
|
93
|
+
const MAX_PAGES = 100;
|
|
94
|
+
|
|
95
|
+
/** List the bodies of EVERY comment on an issue, across all pages. `null` when no transport is
|
|
96
|
+
* usable; THROWS on a transport failure (or an over-long thread) so the caller can distinguish
|
|
97
|
+
* "unreadable" (post nothing) from "empty". */
|
|
98
|
+
async function listIssueCommentBodies(
|
|
99
|
+
repo: string,
|
|
100
|
+
issueNumber: number,
|
|
101
|
+
token: string,
|
|
102
|
+
timeoutMs: number,
|
|
103
|
+
): Promise<string[] | null> {
|
|
104
|
+
const ghMode = await useGh();
|
|
105
|
+
if (!ghMode && !token) return null;
|
|
106
|
+
const bodies: string[] = [];
|
|
107
|
+
for (let page = 1; page <= MAX_PAGES; page++) {
|
|
108
|
+
const path = `repos/${repo}/issues/${issueNumber}/comments?per_page=${PAGE_SIZE}&page=${page}`;
|
|
109
|
+
let arr: unknown;
|
|
110
|
+
if (ghMode) {
|
|
111
|
+
arr = JSON.parse(await runGh(["api", path], { timeoutMs }));
|
|
112
|
+
} else {
|
|
113
|
+
const r = await fetch(`https://api.github.com/${path}`, {
|
|
114
|
+
headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
|
|
115
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
116
|
+
});
|
|
117
|
+
if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
|
|
118
|
+
arr = await r.json();
|
|
119
|
+
}
|
|
120
|
+
if (!Array.isArray(arr)) throw new Error("github: unexpected comments payload");
|
|
121
|
+
for (const c of arr) {
|
|
122
|
+
const body = c !== null && typeof c === "object" && "body" in c ? c.body : "";
|
|
123
|
+
bodies.push(typeof body === "string" ? body : "");
|
|
124
|
+
}
|
|
125
|
+
if (arr.length < PAGE_SIZE) return bodies;
|
|
126
|
+
}
|
|
127
|
+
throw new Error(`github: issue ${repo}#${issueNumber} has more than ${MAX_PAGES * PAGE_SIZE} comments`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Post one comment on an issue. Returns whether GitHub accepted it; `false` when no transport is
|
|
131
|
+
* usable or EITHER transport rejected or threw (a connection failure is normalised, never thrown). */
|
|
132
|
+
async function postIssueComment(
|
|
133
|
+
repo: string,
|
|
134
|
+
issueNumber: number,
|
|
135
|
+
body: string,
|
|
136
|
+
token: string,
|
|
137
|
+
timeoutMs: number,
|
|
138
|
+
): Promise<boolean> {
|
|
139
|
+
try {
|
|
140
|
+
if (await useGh()) {
|
|
141
|
+
await runGh(["issue", "comment", String(issueNumber), "--repo", repo, "--body", body], { timeoutMs });
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
if (!token) return false;
|
|
145
|
+
const r = await fetch(`https://api.github.com/repos/${repo}/issues/${issueNumber}/comments`, {
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers: {
|
|
148
|
+
authorization: `Bearer ${token}`,
|
|
149
|
+
accept: "application/vnd.github+json",
|
|
150
|
+
"content-type": "application/json",
|
|
151
|
+
},
|
|
152
|
+
body: JSON.stringify({ body }),
|
|
153
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
154
|
+
});
|
|
155
|
+
return r.ok;
|
|
156
|
+
} catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface RequestIssuePrLinkInput {
|
|
162
|
+
/** The run's `feature_key` (`owner/repo#N`) β the durable record key. */
|
|
163
|
+
featureKey: string;
|
|
164
|
+
/** The delivered PR key (null/undefined β nothing to announce). */
|
|
165
|
+
prKey: string | null | undefined;
|
|
166
|
+
/** The run's `claimIssue` seed β true ONLY for single-issue feature runs (app/feature.ts). */
|
|
167
|
+
claimIssue: unknown;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Record that the claimed issue is OWED a "PR opened" link for `prKey`, then attempt it once.
|
|
171
|
+
* Called by pr.record-feature; a failed attempt is retried by the poller (`retryOutstandingIssuePrLinks`).
|
|
172
|
+
* Resolves to the outcome (never throws). */
|
|
173
|
+
export async function requestIssuePrLink(
|
|
174
|
+
input: RequestIssuePrLinkInput,
|
|
175
|
+
data: DataLayer,
|
|
176
|
+
token: string = process.env.GITHUB_TOKEN ?? "",
|
|
177
|
+
): Promise<IssuePrLinkOutcome> {
|
|
178
|
+
try {
|
|
179
|
+
// Gate: claimed single-issue runs only, with a keyed PR. An epic slice (`claimIssue` unset) and a
|
|
180
|
+
// blocked/skipped run (no PR) are a pure no-op β no state, no GitHub I/O.
|
|
181
|
+
if (input.claimIssue !== true) return "skipped";
|
|
182
|
+
if (!input.prKey || !parsePr(input.prKey)) return "skipped";
|
|
183
|
+
const r = await data
|
|
184
|
+
.open()
|
|
185
|
+
.exec(`UPDATE "feature_runs" SET "pr_link_want_pr_key" = ? WHERE "feature_key" = ?`, [
|
|
186
|
+
input.prKey,
|
|
187
|
+
input.featureKey,
|
|
188
|
+
]);
|
|
189
|
+
if (r.changed === 0) return "skipped";
|
|
190
|
+
return await attemptIssuePrLink(input.featureKey, data, token);
|
|
191
|
+
} catch {
|
|
192
|
+
return "skipped";
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Attempt the OWED link for one run (the single canonical attempt, shared by record-feature and the
|
|
197
|
+
* poller retry). Never throws. */
|
|
198
|
+
export async function attemptIssuePrLink(
|
|
199
|
+
featureKey: string,
|
|
200
|
+
data: DataLayer,
|
|
201
|
+
token: string = process.env.GITHUB_TOKEN ?? "",
|
|
202
|
+
now: () => Date = () => new Date(),
|
|
203
|
+
ioTimeoutMs: number = ISSUE_PR_LINK_IO_TIMEOUT_MS,
|
|
204
|
+
): Promise<IssuePrLinkOutcome> {
|
|
205
|
+
try {
|
|
206
|
+
const row = await featureRuns(data).get(featureKey);
|
|
207
|
+
if (!row) return "skipped";
|
|
208
|
+
const want = row.pr_link_want_pr_key ?? null;
|
|
209
|
+
const pr = want ? parsePr(want) : null;
|
|
210
|
+
if (!want || !pr) return "skipped";
|
|
211
|
+
// The confirmed record already names the owed PR β no GitHub I/O at all.
|
|
212
|
+
if (row.pr_linked_pr_key === want) return "already-recorded";
|
|
213
|
+
const issueRepo = typeof row.repo === "string" ? row.repo : "";
|
|
214
|
+
const issueNumber = Number(row.issue_number);
|
|
215
|
+
if (!issueRepo || !Number.isInteger(issueNumber) || issueNumber <= 0) return "skipped";
|
|
216
|
+
// Claim an EXPIRING lease before any GitHub I/O (guarded CAS): only a free or expired lease, for
|
|
217
|
+
// a still-owed and still-unconfirmed PR, flips β so two racing passes never both post.
|
|
218
|
+
const t = now();
|
|
219
|
+
const ts = t.toISOString();
|
|
220
|
+
const expired = new Date(t.getTime() - ISSUE_PR_LINK_LEASE_MS).toISOString();
|
|
221
|
+
const claim = await data
|
|
222
|
+
.open()
|
|
223
|
+
.exec(
|
|
224
|
+
`UPDATE "feature_runs" SET "pr_link_lease_at" = ? WHERE "feature_key" = ? AND "pr_link_want_pr_key" = ? AND ("pr_linked_pr_key" IS NULL OR "pr_linked_pr_key" != ?) AND ("pr_link_lease_at" IS NULL OR "pr_link_lease_at" < ?)`,
|
|
225
|
+
[ts, featureKey, want, want, expired],
|
|
226
|
+
);
|
|
227
|
+
if (claim.changed === 0) return "claim-lost";
|
|
228
|
+
// Confirm the link, releasing the lease. Guards on the CURRENT lease ts (the one this pass last
|
|
229
|
+
// (re-)claimed), so only the live holder confirms.
|
|
230
|
+
const confirm = async (leaseTs: string) => {
|
|
231
|
+
await data
|
|
232
|
+
.open()
|
|
233
|
+
.exec(
|
|
234
|
+
`UPDATE "feature_runs" SET "pr_linked_pr_key" = ?, "pr_link_lease_at" = NULL WHERE "feature_key" = ? AND "pr_link_lease_at" = ?`,
|
|
235
|
+
[want, featureKey, leaseTs],
|
|
236
|
+
);
|
|
237
|
+
};
|
|
238
|
+
// The marker scan (every page) heals a crash between a successful POST and the confirm.
|
|
239
|
+
let bodies: string[] | null;
|
|
240
|
+
try {
|
|
241
|
+
bodies = await listIssueCommentBodies(issueRepo, issueNumber, token, ioTimeoutMs);
|
|
242
|
+
} catch {
|
|
243
|
+
// UNREADABLE β posting blind could double-post. Leave the lease to expire; the poller retries.
|
|
244
|
+
return "list-failed";
|
|
245
|
+
}
|
|
246
|
+
const marker = issuePrLinkMarker(pr);
|
|
247
|
+
if (bodies?.some((b) => b.includes(marker))) {
|
|
248
|
+
await confirm(ts);
|
|
249
|
+
return "already-marked";
|
|
250
|
+
}
|
|
251
|
+
// The title is best-effort, read from the PR's OWN repository.
|
|
252
|
+
let title: string | null = null;
|
|
253
|
+
try {
|
|
254
|
+
title = (await fetchPrMeta(pr.repo, pr.number, token, ioTimeoutMs))?.title ?? null;
|
|
255
|
+
} catch {
|
|
256
|
+
title = null;
|
|
257
|
+
}
|
|
258
|
+
// Re-validate lease ownership IMMEDIATELY before the write. The scan + title lookup above are
|
|
259
|
+
// each bounded by ioTimeoutMs (so a hung read can never wedge pr.record-feature or the poller
|
|
260
|
+
// pass), but together they can still approach the lease: if this pass stalled past the lease expiry, the poller may have re-claimed the
|
|
261
|
+
// lease and posted β and the after-the-fact confirm guard runs too late to stop a duplicate.
|
|
262
|
+
// Re-claim a FRESH timestamp guarded on the one this pass holds: only the CURRENT holder flips
|
|
263
|
+
// (changed=1), extending its lease through the write; a stale holder whose ts was overwritten
|
|
264
|
+
// gets changed=0 and backs off WITHOUT posting.
|
|
265
|
+
const ts2 = now().toISOString();
|
|
266
|
+
const stillHolder = await data
|
|
267
|
+
.open()
|
|
268
|
+
.exec(`UPDATE "feature_runs" SET "pr_link_lease_at" = ? WHERE "feature_key" = ? AND "pr_link_lease_at" = ?`, [
|
|
269
|
+
ts2,
|
|
270
|
+
featureKey,
|
|
271
|
+
ts,
|
|
272
|
+
]);
|
|
273
|
+
if (stillHolder.changed === 0) return "claim-lost";
|
|
274
|
+
const posted = await postIssueComment(
|
|
275
|
+
issueRepo,
|
|
276
|
+
issueNumber,
|
|
277
|
+
prOpenedCommentBody({ number: pr.number, title, url: pr.url }),
|
|
278
|
+
token,
|
|
279
|
+
ioTimeoutMs,
|
|
280
|
+
);
|
|
281
|
+
// A failure leaves the lease to expire (never confirmed) β the poller's retry pass re-attempts.
|
|
282
|
+
if (!posted) return "post-failed";
|
|
283
|
+
await confirm(ts2);
|
|
284
|
+
return "posted";
|
|
285
|
+
} catch {
|
|
286
|
+
return "skipped";
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The poller retry pass: re-attempt every OUTSTANDING owed link (owed, unconfirmed, lease free or
|
|
291
|
+
* expired). Only rows pr.record-feature marked as owed are scanned β never pre-#907 rows or epic
|
|
292
|
+
* slices. Never throws. */
|
|
293
|
+
export async function retryOutstandingIssuePrLinks(
|
|
294
|
+
data: DataLayer,
|
|
295
|
+
token: string = process.env.GITHUB_TOKEN ?? "",
|
|
296
|
+
now: () => Date = () => new Date(),
|
|
297
|
+
): Promise<Record<string, IssuePrLinkOutcome>> {
|
|
298
|
+
const out: Record<string, IssuePrLinkOutcome> = {};
|
|
299
|
+
try {
|
|
300
|
+
const expired = new Date(now().getTime() - ISSUE_PR_LINK_LEASE_MS).toISOString();
|
|
301
|
+
const rows = await data
|
|
302
|
+
.open()
|
|
303
|
+
.query<{ feature_key: string }>(
|
|
304
|
+
`SELECT "feature_key" FROM "feature_runs" WHERE "pr_link_want_pr_key" IS NOT NULL AND ("pr_linked_pr_key" IS NULL OR "pr_linked_pr_key" != "pr_link_want_pr_key") AND ("pr_link_lease_at" IS NULL OR "pr_link_lease_at" < ?)`,
|
|
305
|
+
[expired],
|
|
306
|
+
);
|
|
307
|
+
for (const r of rows) out[r.feature_key] = await attemptIssuePrLink(r.feature_key, data, token, now);
|
|
308
|
+
} catch {
|
|
309
|
+
// Best-effort: a link retry must never break the poll loop.
|
|
310
|
+
}
|
|
311
|
+
return out;
|
|
312
|
+
}
|
package/app/service.ts
CHANGED
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
requestCopilotReview,
|
|
60
60
|
} from "./github.ts";
|
|
61
61
|
import { activeStatusesFor, derivedTrackingTable } from "./instanceTracking.ts";
|
|
62
|
+
import { retryOutstandingIssuePrLinks } from "./issuePrLink.ts";
|
|
62
63
|
import { pollLineage } from "./lineage.ts";
|
|
63
64
|
import { mergeableRepollInterval, mergeableWaitTimeout } from "./mergeableWait.ts";
|
|
64
65
|
import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
|
|
@@ -3922,6 +3923,8 @@ export async function pollOnce(
|
|
|
3922
3923
|
await pollWaitGate(data);
|
|
3923
3924
|
await pollPromotion(data, engine, token);
|
|
3924
3925
|
await pollFeatureDelivery(data, engine);
|
|
3926
|
+
// Issue #907: retry every owed-but-unconfirmed "PR opened" issue link (best-effort, never throws).
|
|
3927
|
+
await retryOutstandingIssuePrLinks(data, token);
|
|
3925
3928
|
await pollLineage(data);
|
|
3926
3929
|
await pollUserTasks(data, engine, engineRest);
|
|
3927
3930
|
// The escalation steward is an OPTIONAL, best-effort pass: a steward-specific failure (a search or
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
-- Issue #907: the host posts one idempotent "PR opened" comment on the claimed issue when a
|
|
2
|
+
-- single-issue feature run delivers its PR (app/issuePrLink.ts β attempted inline by
|
|
3
|
+
-- pr.record-feature and RETRIED by the poller's `pollIssuePrLinks` pass until it lands).
|
|
4
|
+
--
|
|
5
|
+
-- Three columns carry the durable link state machine:
|
|
6
|
+
-- β’ `pr_link_want_pr_key` β the `owner/repo#N` PR the claimed issue is OWED a link for. Written by
|
|
7
|
+
-- pr.record-feature ONLY for a claimed single-issue run that delivered a keyed PR, so the
|
|
8
|
+
-- poller's retry pass never touches pre-#907 rows or epic slices.
|
|
9
|
+
-- β’ `pr_linked_pr_key` β CONFIRMED: set only after a successful POST or a marker match. A link is
|
|
10
|
+
-- outstanding while it differs from `pr_link_want_pr_key`.
|
|
11
|
+
-- β’ `pr_link_lease_at` β a short-lived in-flight lease (ISO time), claimed with a guarded CAS
|
|
12
|
+
-- before any GitHub write so two racing passes never both post. A crash or failure leaves the
|
|
13
|
+
-- lease to EXPIRE (it is never mistaken for a confirmed link), which doubles as retry backoff.
|
|
14
|
+
-- The hidden `<!-- nwf:pr-linked:#n -->` marker scanned on the issue's comments heals a crash
|
|
15
|
+
-- between a successful POST and the confirm write.
|
|
16
|
+
--
|
|
17
|
+
-- Additive (expand): a nullable column with no default write β existing rows read NULL (never
|
|
18
|
+
-- linked), exactly the pre-#907 state (no comment was ever posted for them).
|
|
19
|
+
--
|
|
20
|
+
-- The column is mirrored through the `delivery_units` aggregate so the `feature_runs__units` compat
|
|
21
|
+
-- VIEW stays row-for-row equal to `feature_runs` (ADR 0006, parity-tested in
|
|
22
|
+
-- app/deliveryUnit.test.ts): the Feature sync triggers (089) and VIEW (091) are re-created verbatim
|
|
23
|
+
-- plus `pr_linked_pr_key`, exactly as migration 116 did for `human_approval` and 120 for
|
|
24
|
+
-- `auto_resolve_escalations`.
|
|
25
|
+
|
|
26
|
+
ALTER TABLE feature_runs ADD COLUMN pr_linked_pr_key TEXT;
|
|
27
|
+
ALTER TABLE feature_runs ADD COLUMN pr_link_want_pr_key TEXT;
|
|
28
|
+
ALTER TABLE feature_runs ADD COLUMN pr_link_lease_at TEXT;
|
|
29
|
+
|
|
30
|
+
ALTER TABLE delivery_units ADD COLUMN pr_linked_pr_key TEXT;
|
|
31
|
+
ALTER TABLE delivery_units ADD COLUMN pr_link_want_pr_key TEXT;
|
|
32
|
+
ALTER TABLE delivery_units ADD COLUMN pr_link_lease_at TEXT;
|
|
33
|
+
|
|
34
|
+
DROP TRIGGER IF EXISTS feature_runs__du_ai;
|
|
35
|
+
CREATE TRIGGER feature_runs__du_ai AFTER INSERT ON feature_runs
|
|
36
|
+
BEGIN
|
|
37
|
+
INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, base_branch, status, process_key, pr_key, converge, auto_merge, human_approval, auto_resolve_escalations, outcome, delivery_label, pr_linked_pr_key, pr_link_want_pr_key, pr_link_lease_at, acknowledged_at, stage, stage_state, stage_skipped, attention, list_bucket, created_at, updated_at)
|
|
38
|
+
VALUES ('feature:' || NEW.feature_key, 'feature', NEW.feature_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'escalated' THEN 'escalated' WHEN NEW.status = 'opened' THEN 'opened' WHEN NEW.status = 'converging' THEN 'converging' WHEN NEW.status = 'awaiting_operator' THEN 'awaiting_operator' WHEN NEW.status = 'merged' THEN 'merged' WHEN NEW.status = 'converged' THEN 'converged' WHEN NEW.status = 'blocked' THEN 'blocked' WHEN NEW.status = 'skipped' THEN 'skipped' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('opened', 'converging', 'merged', 'converged', 'blocked', 'skipped', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running', 'escalated', 'awaiting_operator') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.base_branch, NEW.status, NEW.process_key, NEW.pr_key, NEW.converge, NEW.auto_merge, NEW.human_approval, NEW.auto_resolve_escalations, NEW.outcome, NEW.delivery_label, NEW.pr_linked_pr_key, NEW.pr_link_want_pr_key, NEW.pr_link_lease_at, NEW.acknowledged_at, NEW.stage, NEW.stage_state, NEW.stage_skipped, NEW.attention, NEW.list_bucket, NEW.created_at, NEW.updated_at);
|
|
39
|
+
END;
|
|
40
|
+
|
|
41
|
+
DROP TRIGGER IF EXISTS feature_runs__du_au;
|
|
42
|
+
CREATE TRIGGER feature_runs__du_au AFTER UPDATE ON feature_runs
|
|
43
|
+
BEGIN
|
|
44
|
+
INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, base_branch, status, process_key, pr_key, converge, auto_merge, human_approval, auto_resolve_escalations, outcome, delivery_label, pr_linked_pr_key, pr_link_want_pr_key, pr_link_lease_at, acknowledged_at, stage, stage_state, stage_skipped, attention, list_bucket, created_at, updated_at)
|
|
45
|
+
VALUES ('feature:' || NEW.feature_key, 'feature', NEW.feature_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'escalated' THEN 'escalated' WHEN NEW.status = 'opened' THEN 'opened' WHEN NEW.status = 'converging' THEN 'converging' WHEN NEW.status = 'awaiting_operator' THEN 'awaiting_operator' WHEN NEW.status = 'merged' THEN 'merged' WHEN NEW.status = 'converged' THEN 'converged' WHEN NEW.status = 'blocked' THEN 'blocked' WHEN NEW.status = 'skipped' THEN 'skipped' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('opened', 'converging', 'merged', 'converged', 'blocked', 'skipped', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running', 'escalated', 'awaiting_operator') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.base_branch, NEW.status, NEW.process_key, NEW.pr_key, NEW.converge, NEW.auto_merge, NEW.human_approval, NEW.auto_resolve_escalations, NEW.outcome, NEW.delivery_label, NEW.pr_linked_pr_key, NEW.pr_link_want_pr_key, NEW.pr_link_lease_at, NEW.acknowledged_at, NEW.stage, NEW.stage_state, NEW.stage_skipped, NEW.attention, NEW.list_bucket, NEW.created_at, NEW.updated_at);
|
|
46
|
+
END;
|
|
47
|
+
|
|
48
|
+
DROP VIEW IF EXISTS feature_runs__units;
|
|
49
|
+
CREATE VIEW feature_runs__units AS
|
|
50
|
+
SELECT
|
|
51
|
+
du.legacy_key AS feature_key,
|
|
52
|
+
du.repo AS repo,
|
|
53
|
+
du.issue_number AS issue_number,
|
|
54
|
+
du.issue_url AS issue_url,
|
|
55
|
+
du.base_branch AS base_branch,
|
|
56
|
+
du.status AS status,
|
|
57
|
+
du.process_key AS process_key,
|
|
58
|
+
du.pr_key AS pr_key,
|
|
59
|
+
du.converge AS converge,
|
|
60
|
+
du.auto_merge AS auto_merge,
|
|
61
|
+
du.human_approval AS human_approval,
|
|
62
|
+
du.auto_resolve_escalations AS auto_resolve_escalations,
|
|
63
|
+
du.outcome AS outcome,
|
|
64
|
+
du.created_at AS created_at,
|
|
65
|
+
du.updated_at AS updated_at,
|
|
66
|
+
du.delivery_label AS delivery_label,
|
|
67
|
+
du.pr_linked_pr_key AS pr_linked_pr_key,
|
|
68
|
+
du.pr_link_want_pr_key AS pr_link_want_pr_key,
|
|
69
|
+
du.pr_link_lease_at AS pr_link_lease_at,
|
|
70
|
+
du.title AS title,
|
|
71
|
+
du.acknowledged_at AS acknowledged_at,
|
|
72
|
+
du.stage AS stage,
|
|
73
|
+
du.stage_state AS stage_state,
|
|
74
|
+
du.stage_skipped AS stage_skipped,
|
|
75
|
+
du.attention AS attention,
|
|
76
|
+
du.list_bucket AS list_bucket
|
|
77
|
+
FROM delivery_units du
|
|
78
|
+
WHERE du.kind = 'feature';
|