@nanobpm/nano-workforce 0.70.0 → 0.70.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/app/agentSla.test.ts +39 -0
- package/app/agentSla.ts +33 -0
- package/app/convergeGate.test.ts +406 -0
- package/app/convergeGate.ts +48 -0
- package/app/github.ts +225 -0
- package/app/roundProgress.test.ts +229 -0
- package/app/roundProgress.ts +70 -0
- package/app/service.test.ts +18 -1
- package/app/service.ts +15 -1
- package/db/migrations/033_pr_round_head.sql +16 -0
- package/nano.app.json +8 -0
- package/package.json +1 -1
- package/prompts/review-round.md +55 -9
- package/resources/processes/convergence-loop.bpmn +236 -65
- package/resources/processes/merge-loop.bpmn +182 -120
- package/workers/converge-gate/worker.ts +101 -0
- package/workers/progress-check/worker.ts +77 -0
package/app/github.ts
CHANGED
|
@@ -77,6 +77,231 @@ export async function fetchPrReviews(
|
|
|
77
77
|
return (await r.json()) as GhReview[];
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// ── Review-comment convergence gate (don't converge with unaddressed comments) ──────────────
|
|
81
|
+
//
|
|
82
|
+
// A PR must not be declared converged while Copilot still has unaddressed review comments. Two
|
|
83
|
+
// kinds must be gated:
|
|
84
|
+
// • unresolved review THREADS — deterministic (GraphQL `isResolved`).
|
|
85
|
+
// • SUPPRESSED / low-confidence advisories — Copilot folds these into the review BODY under a
|
|
86
|
+
// "Suppressed comments (N)" block; they are NOT threads, cannot be resolved, and are re-listed
|
|
87
|
+
// every round. To make "acknowledged" trackable, the review-round agent must post a RESOLVED
|
|
88
|
+
// review thread carrying a `nano-ack: <path>:<line>` marker (the exact key from Copilot's
|
|
89
|
+
// `**path:line**` header) for each advisory it applies or declines. The gate then treats an
|
|
90
|
+
// advisory as addressed iff a resolved thread carries its ack marker.
|
|
91
|
+
|
|
92
|
+
/** One PR review thread, narrowed to what the convergence gate needs. */
|
|
93
|
+
export interface ReviewThread {
|
|
94
|
+
isResolved: boolean;
|
|
95
|
+
path: string | null;
|
|
96
|
+
bodies: string[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The `nano-ack:` acknowledgement marker the review-round agent stamps into the resolved thread
|
|
100
|
+
* it opens per suppressed advisory. The captured group is the advisory key (`path:line`). */
|
|
101
|
+
const ACK_MARKER = /nano-ack:\s*([^\s)>*]+:\d+)/gi;
|
|
102
|
+
|
|
103
|
+
/** Parse the `path:line` keys of Copilot's suppressed / low-confidence advisories out of a review
|
|
104
|
+
* body. Copilot renders them under a `<summary>Suppressed comments (N)</summary>` block, each as a
|
|
105
|
+
* bold `**path:line**` header. Returns the de-duplicated keys (empty when there is no such block). */
|
|
106
|
+
export function parseSuppressedAdvisories(reviewBody: string | null | undefined): string[] {
|
|
107
|
+
const body = reviewBody ?? "";
|
|
108
|
+
const idx = body.search(/Suppressed comments\s*\(/i);
|
|
109
|
+
if (idx < 0) return [];
|
|
110
|
+
// Scan only from the "Suppressed comments" marker onward so a `**path:line**` elsewhere in the
|
|
111
|
+
// overview prose can never be mistaken for an advisory.
|
|
112
|
+
const region = body.slice(idx);
|
|
113
|
+
const keys = new Set<string>();
|
|
114
|
+
const re = /\*\*([^*]+?:\d+)\*\*/g;
|
|
115
|
+
let m: RegExpExecArray | null;
|
|
116
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: canonical regex-exec accumulation loop
|
|
117
|
+
while ((m = re.exec(region)) !== null) keys.add(m[1].trim());
|
|
118
|
+
return [...keys];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Extract the acknowledged advisory keys from a set of review threads (only RESOLVED threads
|
|
122
|
+
* count — an open ack thread is not yet an acknowledgement). */
|
|
123
|
+
export function parseAckedAdvisories(threads: ReviewThread[]): string[] {
|
|
124
|
+
const acked = new Set<string>();
|
|
125
|
+
for (const t of threads) {
|
|
126
|
+
if (!t.isResolved) continue;
|
|
127
|
+
for (const body of t.bodies) {
|
|
128
|
+
ACK_MARKER.lastIndex = 0;
|
|
129
|
+
let m: RegExpExecArray | null;
|
|
130
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: canonical regex-exec accumulation loop
|
|
131
|
+
while ((m = ACK_MARKER.exec(body)) !== null) acked.add(m[1].trim());
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return [...acked];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Pick the newest Copilot review body from a reviews list (GitHub returns them oldest→newest).
|
|
138
|
+
* `truncated = true` means we could NOT read every page — the genuinely-latest review may be unread,
|
|
139
|
+
* so the result is UNVERIFIABLE and we fail CLOSED (`null`) rather than return a stale page's body;
|
|
140
|
+
* a fail-OPEN on the advisory dimension (reading an old review and missing a newer suppressed
|
|
141
|
+
* advisory) is the exact class this gate exists to prevent. A verified-complete read with no Copilot
|
|
142
|
+
* review returns `""` (a verified "no advisories"). Pure; unit-tested. */
|
|
143
|
+
export function pickLatestCopilotReviewBody(
|
|
144
|
+
reviews: { user?: { login?: string }; body?: string }[],
|
|
145
|
+
truncated: boolean,
|
|
146
|
+
): string | null {
|
|
147
|
+
if (truncated) return null;
|
|
148
|
+
const copilot = reviews.filter((rv) => isCopilot(rv.user?.login));
|
|
149
|
+
return copilot[copilot.length - 1]?.body ?? "";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Fetch the latest Copilot review body for a PR (the newest review authored by the automated
|
|
153
|
+
* Copilot reviewer). Returns `null` ONLY when no transport is usable (unverifiable → the worker
|
|
154
|
+
* fails closed); returns `""` when transport is usable but the PR has no Copilot review yet (a
|
|
155
|
+
* verified "no suppressed advisories"). Throws on a genuine transport failure. This split keeps
|
|
156
|
+
* `null` from conflating "unverifiable" with "empty" and fail-OPENing the advisory dimension. */
|
|
157
|
+
export async function fetchLatestCopilotReviewBody(
|
|
158
|
+
repo: string,
|
|
159
|
+
number: number | string,
|
|
160
|
+
token: string,
|
|
161
|
+
): Promise<string | null> {
|
|
162
|
+
const mode = githubTransport();
|
|
163
|
+
const useGh = mode === "gh" || (mode === "auto" && (await isGhAvailable()));
|
|
164
|
+
const basePath = `repos/${repo}/pulls/${number}/reviews?per_page=100`;
|
|
165
|
+
interface Review {
|
|
166
|
+
user?: { login?: string };
|
|
167
|
+
body?: string;
|
|
168
|
+
}
|
|
169
|
+
if (useGh) {
|
|
170
|
+
// `--paginate` merges EVERY page of the (oldest→newest) reviews array, so a >100-review
|
|
171
|
+
// convergence loop still surfaces the genuinely newest Copilot review rather than the oldest
|
|
172
|
+
// 100 — reading only the first page here would fail-OPEN the advisory dimension.
|
|
173
|
+
const out = await runGh(["api", "--paginate", basePath, "-H", "Accept: application/vnd.github+json"]);
|
|
174
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
175
|
+
const reviews = JSON.parse(out) as Review[];
|
|
176
|
+
return pickLatestCopilotReviewBody(reviews, false);
|
|
177
|
+
}
|
|
178
|
+
if (!token) return null;
|
|
179
|
+
// Page the token transport the same way; 20×100 reviews is far past any real convergence loop, and
|
|
180
|
+
// a genuinely deeper history we can't reach is unverifiable → fail closed.
|
|
181
|
+
const reviews: Review[] = [];
|
|
182
|
+
const MAX_PAGES = 20;
|
|
183
|
+
for (let page = 1; page <= MAX_PAGES; page++) {
|
|
184
|
+
const r = await fetch(`https://api.github.com/${basePath}&page=${page}`, {
|
|
185
|
+
headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
|
|
186
|
+
});
|
|
187
|
+
if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
|
|
188
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
189
|
+
const batch = (await r.json()) as Review[];
|
|
190
|
+
reviews.push(...batch);
|
|
191
|
+
// A short page means we've read every review — the list is complete.
|
|
192
|
+
if (batch.length < 100) return pickLatestCopilotReviewBody(reviews, false);
|
|
193
|
+
// A full page on the last allowed page is only truncated if GitHub says there's more; trust the
|
|
194
|
+
// `Link` header's `rel="next"` so an exact multiple of 100 isn't a false positive.
|
|
195
|
+
if (page === MAX_PAGES && /<[^>]*>;\s*rel="next"/.test(r.headers.get("link") ?? "")) {
|
|
196
|
+
return pickLatestCopilotReviewBody(reviews, true);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return pickLatestCopilotReviewBody(reviews, false);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Raw GraphQL response shape for the review-threads query. */
|
|
203
|
+
export interface ReviewThreadsResponse {
|
|
204
|
+
data?: {
|
|
205
|
+
repository?: {
|
|
206
|
+
pullRequest?: {
|
|
207
|
+
reviewThreads?: {
|
|
208
|
+
pageInfo?: { hasNextPage?: boolean; endCursor?: string | null };
|
|
209
|
+
nodes?: { isResolved?: boolean; path?: string | null; comments?: { nodes?: { body?: string }[] } }[];
|
|
210
|
+
};
|
|
211
|
+
};
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** One page of a review-threads GraphQL response, plus the cursor to advance to the next page. */
|
|
217
|
+
export interface ReviewThreadsPage {
|
|
218
|
+
threads: ReviewThread[];
|
|
219
|
+
hasNextPage: boolean;
|
|
220
|
+
endCursor: string | null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Map ONE page of a review-threads GraphQL response, FAILING CLOSED (returns `null`) on an
|
|
224
|
+
* UNVERIFIABLE read: a missing `reviewThreads` block (GraphQL errors, permission issues, a malformed
|
|
225
|
+
* payload) OR a page whose completeness signal (`pageInfo.hasNextPage`) is not a readable boolean.
|
|
226
|
+
* A readable page yields its mapped nodes plus `hasNextPage`/`endCursor` so the CALLER can page to
|
|
227
|
+
* completeness (`fetchReviewThreads` follows `endCursor` up to a bounded cap). A `first:100` page
|
|
228
|
+
* cannot see thread 101+, so a truncated read must be paged, not silently mapped to "no more
|
|
229
|
+
* threads" and converged (a fail-OPEN, the exact class this gate exists to prevent). Exceeding the
|
|
230
|
+
* caller's page cap while GitHub still reports more is the caller's fail-closed decision, not this
|
|
231
|
+
* mapper's. Pure; unit-tested. */
|
|
232
|
+
export function parseReviewThreadsPage(payload: ReviewThreadsResponse): ReviewThreadsPage | null {
|
|
233
|
+
const block = payload.data?.repository?.pullRequest?.reviewThreads;
|
|
234
|
+
if (!block || typeof block.pageInfo?.hasNextPage !== "boolean") return null;
|
|
235
|
+
const nodes = block.nodes ?? [];
|
|
236
|
+
return {
|
|
237
|
+
threads: nodes.map((t) => ({
|
|
238
|
+
isResolved: !!t.isResolved,
|
|
239
|
+
path: t.path ?? null,
|
|
240
|
+
bodies: (t.comments?.nodes ?? []).map((c) => c.body ?? ""),
|
|
241
|
+
})),
|
|
242
|
+
hasNextPage: block.pageInfo.hasNextPage,
|
|
243
|
+
endCursor: block.pageInfo.endCursor ?? null,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** 20×100 review threads is far past any real convergence loop; a genuinely deeper set we can't
|
|
248
|
+
* page to is unverifiable → fail closed. */
|
|
249
|
+
const MAX_THREAD_PAGES = 20;
|
|
250
|
+
|
|
251
|
+
/** Fetch ALL of a PR's review threads (resolution state + path + comment bodies) via GraphQL, paging
|
|
252
|
+
* to completeness up to `MAX_THREAD_PAGES`. `null` when no transport is usable, a page is
|
|
253
|
+
* unreadable, or the set is still truncated past the page cap (fail closed); throws on a genuine
|
|
254
|
+
* transport failure. */
|
|
255
|
+
export async function fetchReviewThreads(
|
|
256
|
+
repo: string,
|
|
257
|
+
number: number | string,
|
|
258
|
+
token: string,
|
|
259
|
+
): Promise<ReviewThread[] | null> {
|
|
260
|
+
const [owner, name] = repo.split("/");
|
|
261
|
+
const query =
|
|
262
|
+
"query($o:String!,$r:String!,$n:Int!,$after:String){repository(owner:$o,name:$r){pullRequest(number:$n){" +
|
|
263
|
+
"reviewThreads(first:100,after:$after){pageInfo{hasNextPage endCursor}nodes{isResolved path comments(first:100){nodes{body}}}}}}}";
|
|
264
|
+
const mode = githubTransport();
|
|
265
|
+
const useGh = mode === "gh" || (mode === "auto" && (await isGhAvailable()));
|
|
266
|
+
if (!useGh && !token) return null;
|
|
267
|
+
|
|
268
|
+
const fetchPage = async (after: string | null): Promise<ReviewThreadsResponse> => {
|
|
269
|
+
if (useGh) {
|
|
270
|
+
const args = ["api", "graphql", "-f", `query=${query}`, "-F", `o=${owner}`, "-F", `r=${name}`, "-F", `n=${number}`];
|
|
271
|
+
if (after !== null) args.push("-F", `after=${after}`);
|
|
272
|
+
const out = await runGh(args);
|
|
273
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
274
|
+
return JSON.parse(out) as ReviewThreadsResponse;
|
|
275
|
+
}
|
|
276
|
+
const variables: Record<string, unknown> = { o: owner, r: name, n: Number(number) };
|
|
277
|
+
if (after !== null) variables.after = after;
|
|
278
|
+
const r = await fetch("https://api.github.com/graphql", {
|
|
279
|
+
method: "POST",
|
|
280
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
281
|
+
body: JSON.stringify({ query, variables }),
|
|
282
|
+
});
|
|
283
|
+
if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
|
|
284
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
285
|
+
return (await r.json()) as ReviewThreadsResponse;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const all: ReviewThread[] = [];
|
|
289
|
+
let after: string | null = null;
|
|
290
|
+
for (let page = 1; page <= MAX_THREAD_PAGES; page++) {
|
|
291
|
+
const parsed = parseReviewThreadsPage(await fetchPage(after));
|
|
292
|
+
// An unreadable page is unverifiable — fail closed rather than converging on a partial read.
|
|
293
|
+
if (parsed === null) return null;
|
|
294
|
+
all.push(...parsed.threads);
|
|
295
|
+
// A confirmed last page is the only complete read.
|
|
296
|
+
if (!parsed.hasNextPage) return all;
|
|
297
|
+
// More pages exist but no cursor to advance — unverifiable, fail closed.
|
|
298
|
+
if (parsed.endCursor === null) return null;
|
|
299
|
+
after = parsed.endCursor;
|
|
300
|
+
}
|
|
301
|
+
// Exceeded the page cap and GitHub still reports more — unverifiable, fail closed.
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
|
|
80
305
|
// ── Copilot re-request (review-wait liveness) ───────────────────────────────
|
|
81
306
|
// A PR parked in `waiting_review` blocks on a *fresh* Copilot review. Copilot won't
|
|
82
307
|
// spontaneously re-review a round with no new commit, and routinely dismisses a re-request, so
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// No-progress guard — unit tests for the canonical router (app/roundProgress.ts), the
|
|
2
|
+
// pr.progress-check worker, and a structural guard over the committed convergence-loop BPMN.
|
|
3
|
+
//
|
|
4
|
+
// The convergence loop used to trust the agent's self-reported `addressed` status to trigger the
|
|
5
|
+
// next Copilot review round. An agent could return `addressed` (or fall back to the safe default)
|
|
6
|
+
// WITHOUT pushing a commit, so Copilot re-reviewed byte-identical code and the loop burned rounds
|
|
7
|
+
// making no progress until the round cap escalated. The fix inserts a deterministic
|
|
8
|
+
// `pr.progress-check` step that compares the PR head SHA across rounds and routes an `addressed`
|
|
9
|
+
// round whose head did not advance to the human `wait-answer` escalation instead of another review.
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { test } from "node:test";
|
|
12
|
+
import { assert, assertEquals, assertStringIncludes } from "#test-assert";
|
|
13
|
+
import { routeProgress } from "./roundProgress.ts";
|
|
14
|
+
|
|
15
|
+
// ── The canonical router ────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
test("routeProgress: an addressed round whose head did not advance escalates", () => {
|
|
18
|
+
assertEquals(routeProgress("addressed", "sha-1", "sha-1"), "escalate");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("routeProgress: an addressed round whose head advanced continues", () => {
|
|
22
|
+
assertEquals(routeProgress("addressed", "sha-1", "sha-2"), "continue");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("routeProgress: an explicit non-addressed round always continues (no push is expected)", () => {
|
|
26
|
+
// These are the statuses gw-status routes AWAY from the addressed/default arm — a legit no-push
|
|
27
|
+
// round. They always continue regardless of the head.
|
|
28
|
+
for (const status of ["waiting", "converged", "needs_input", "blocked"]) {
|
|
29
|
+
assertEquals(
|
|
30
|
+
routeProgress(status, "sha-1", "sha-1"),
|
|
31
|
+
"continue",
|
|
32
|
+
`status ${JSON.stringify(status)} must continue even with an unchanged head`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("routeProgress: blank/unknown status behaves like addressed (the safe-default trap)", () => {
|
|
38
|
+
// gw-status defaults blank/unknown/unrecognized status down the addressed arm and pr.persist-round
|
|
39
|
+
// records a missing status as `addressed`, so the no-progress guard must apply to them too: an
|
|
40
|
+
// unchanged head escalates, an advanced head continues.
|
|
41
|
+
for (const status of [undefined, null, "", "x"]) {
|
|
42
|
+
assertEquals(
|
|
43
|
+
routeProgress(status, "sha-1", "sha-1"),
|
|
44
|
+
"escalate",
|
|
45
|
+
`blank/unknown status ${JSON.stringify(status)} with an unchanged head must escalate like addressed`,
|
|
46
|
+
);
|
|
47
|
+
assertEquals(
|
|
48
|
+
routeProgress(status, "sha-1", "sha-2"),
|
|
49
|
+
"continue",
|
|
50
|
+
`blank/unknown status ${JSON.stringify(status)} with an advanced head must continue`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("routeProgress: fails OPEN when either head is unknown (no baseline / unreadable head)", () => {
|
|
56
|
+
for (const [prev, cur] of [
|
|
57
|
+
[null, "sha-1"],
|
|
58
|
+
[undefined, "sha-1"],
|
|
59
|
+
["sha-1", null],
|
|
60
|
+
["sha-1", undefined],
|
|
61
|
+
[null, null],
|
|
62
|
+
] as const) {
|
|
63
|
+
assertEquals(
|
|
64
|
+
routeProgress("addressed", prev, cur),
|
|
65
|
+
"continue",
|
|
66
|
+
`unknown head (${JSON.stringify(prev)} -> ${JSON.stringify(cur)}) must fail open`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ── The worker (with an injected head reader — never touches git/network) ────
|
|
72
|
+
|
|
73
|
+
function fakeApp(row?: { last_round_head: string | null }) {
|
|
74
|
+
const updates: { key: string; patch: Record<string, unknown> }[] = [];
|
|
75
|
+
const store = new Map<string, unknown>();
|
|
76
|
+
if (row) store.set("o/r#1", { pr_key: "o/r#1", ...row });
|
|
77
|
+
const app = {
|
|
78
|
+
data: {
|
|
79
|
+
table(_name: string, _key: string) {
|
|
80
|
+
return {
|
|
81
|
+
async get(key: string) {
|
|
82
|
+
return store.get(key);
|
|
83
|
+
},
|
|
84
|
+
async update(key: string, patch: Record<string, unknown>) {
|
|
85
|
+
updates.push({ key, patch });
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
return { app, updates };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function makeUnderTest(readHead: (repo: string, n: number) => Promise<string | null>) {
|
|
95
|
+
const { makeHandler } = await import("../workers/progress-check/worker.ts");
|
|
96
|
+
return makeHandler({ readHead });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
test("progress-check: skips the head read entirely for a non-addressed round", async () => {
|
|
100
|
+
let called = false;
|
|
101
|
+
const handler = await makeUnderTest(async () => {
|
|
102
|
+
called = true;
|
|
103
|
+
return "sha-2";
|
|
104
|
+
});
|
|
105
|
+
const { app, updates } = fakeApp({ last_round_head: "sha-1" });
|
|
106
|
+
const out = await handler({ variables: { prKey: "o/r#1", status: "waiting", repo: "o/r", prNumber: 1 } } as any, app as any);
|
|
107
|
+
assertEquals(out, { progressed: true });
|
|
108
|
+
assertEquals(called, false, "a waiting round never reads the head");
|
|
109
|
+
assertEquals(updates.length, 0, "and never rewrites the baseline");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("progress-check: a blank/unknown status is treated as addressed — reads the head and can report no progress", async () => {
|
|
113
|
+
let called = false;
|
|
114
|
+
const handler = await makeUnderTest(async () => {
|
|
115
|
+
called = true;
|
|
116
|
+
return "sha-1";
|
|
117
|
+
});
|
|
118
|
+
const { app } = fakeApp({ last_round_head: "sha-1" });
|
|
119
|
+
const out = await handler({ variables: { prKey: "o/r#1", status: "", repo: "o/r", prNumber: 1 } } as any, app as any);
|
|
120
|
+
assertEquals(out, { progressed: false }, "a blank-status no-progress round is caught, not waved through");
|
|
121
|
+
assertEquals(called, true, "a blank status (the safe-default addressed trap) still reads the head");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("progress-check: an addressed round whose head is unchanged reports progressed:false", async () => {
|
|
125
|
+
const handler = await makeUnderTest(async () => "sha-1");
|
|
126
|
+
const { app } = fakeApp({ last_round_head: "sha-1" });
|
|
127
|
+
const out = await handler({ variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1 } } as any, app as any);
|
|
128
|
+
assertEquals(out, { progressed: false });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("progress-check: an addressed round whose head advanced reports progressed:true and rebaselines", async () => {
|
|
132
|
+
const handler = await makeUnderTest(async () => "sha-2");
|
|
133
|
+
const { app, updates } = fakeApp({ last_round_head: "sha-1" });
|
|
134
|
+
const out = await handler({ variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1 } } as any, app as any);
|
|
135
|
+
assertEquals(out, { progressed: true });
|
|
136
|
+
assertEquals(updates.length, 1, "the observed head is recorded as the new baseline");
|
|
137
|
+
assertEquals(updates[0]!.patch.last_round_head, "sha-2");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("progress-check: the first observed round (no baseline) continues and records the baseline", async () => {
|
|
141
|
+
const handler = await makeUnderTest(async () => "sha-1");
|
|
142
|
+
const { app, updates } = fakeApp(); // no row yet -> previousHead null
|
|
143
|
+
const out = await handler({ variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1 } } as any, app as any);
|
|
144
|
+
assertEquals(out, { progressed: true }, "no baseline yet fails open");
|
|
145
|
+
assertEquals(updates[0]!.patch.last_round_head, "sha-1");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("progress-check: an unreadable head fails open and does not clobber the baseline", async () => {
|
|
149
|
+
const handler = await makeUnderTest(async () => null);
|
|
150
|
+
const { app, updates } = fakeApp({ last_round_head: "sha-1" });
|
|
151
|
+
const out = await handler({ variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1 } } as any, app as any);
|
|
152
|
+
assertEquals(out, { progressed: true }, "a null head fails open");
|
|
153
|
+
assertEquals(updates.length, 0, "a null head never overwrites the good baseline");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("progress-check: resolves repo/prNumber from the prKey when the vars are absent", async () => {
|
|
157
|
+
let seen: [string, number] | null = null;
|
|
158
|
+
const handler = await makeUnderTest(async (repo, n) => {
|
|
159
|
+
seen = [repo, n];
|
|
160
|
+
return "sha-2";
|
|
161
|
+
});
|
|
162
|
+
const { app } = fakeApp({ last_round_head: "sha-1" });
|
|
163
|
+
await handler({ variables: { prKey: "o/r#1", status: "addressed" } } as any, app as any);
|
|
164
|
+
assertEquals(seen, ["o/r", 1], "falls back to parsing owner/repo#N from the prKey");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ── Structural guard over the committed BPMN (no engine) ─────────────────────
|
|
168
|
+
|
|
169
|
+
const bpmn = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
|
|
170
|
+
const flat = bpmn.replace(/\s+/g, " ");
|
|
171
|
+
|
|
172
|
+
function flowElement(id: string): string | null {
|
|
173
|
+
const re = new RegExp(
|
|
174
|
+
`<bpmn:sequenceFlow\\b[^>]*?\\bid="${id}"[^>]*?(?:/>|>(?:(?!<bpmn:sequenceFlow\\b).)*?</bpmn:sequenceFlow>)`,
|
|
175
|
+
);
|
|
176
|
+
const m = flat.match(re);
|
|
177
|
+
return m ? m[0] : null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
test("persist-round routes through check-progress before the review wait", () => {
|
|
181
|
+
const f = flowElement("f_roundWait");
|
|
182
|
+
assert(f, "f_roundWait flow missing");
|
|
183
|
+
assertStringIncludes(f, 'sourceRef="persist-round"');
|
|
184
|
+
assertStringIncludes(f, 'targetRef="check-progress"');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("check-progress feeds the gw-progress gateway", () => {
|
|
188
|
+
const f = flowElement("f_checkGate");
|
|
189
|
+
assert(f, "f_checkGate flow missing");
|
|
190
|
+
assertStringIncludes(f, 'sourceRef="check-progress"');
|
|
191
|
+
assertStringIncludes(f, 'targetRef="gw-progress"');
|
|
192
|
+
// The task runs the deterministic no-progress guard job.
|
|
193
|
+
assertStringIncludes(flat, 'type="pr.progress-check"');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("gw-progress escalates a no-progress round on an explicit progressed = false condition", () => {
|
|
197
|
+
const f = flowElement("f_noProgress");
|
|
198
|
+
assert(f, "f_noProgress flow missing");
|
|
199
|
+
assertStringIncludes(f, 'targetRef="persist-escalation-noprogress"');
|
|
200
|
+
assertStringIncludes(f, "progressed = false");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("gw-progress default arm re-enters the review wait with no condition", () => {
|
|
204
|
+
const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-progress"[^>]*>/);
|
|
205
|
+
assert(gw, "gw-progress gateway missing");
|
|
206
|
+
assertStringIncludes(gw[0], 'default="f_progressOk"');
|
|
207
|
+
const ok = flowElement("f_progressOk");
|
|
208
|
+
assert(ok, "f_progressOk flow missing");
|
|
209
|
+
assertStringIncludes(ok, 'targetRef="gw-review-wait"');
|
|
210
|
+
assert(!/conditionExpression/.test(ok), "the default arm must carry no conditionExpression");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("the no-progress escalation lands on the human wait-answer task", () => {
|
|
214
|
+
const f = flowElement("f_noprogressWait");
|
|
215
|
+
assert(f, "f_noprogressWait flow missing");
|
|
216
|
+
assertStringIncludes(f, 'sourceRef="persist-escalation-noprogress"');
|
|
217
|
+
assertStringIncludes(f, 'targetRef="wait-answer"');
|
|
218
|
+
// It opens a real, answerable escalation (blocked status + a concrete question) so it is never a
|
|
219
|
+
// blank-question non-escalation that would wedge the token on the wait.
|
|
220
|
+
const task = flat.match(
|
|
221
|
+
/<bpmn:serviceTask\b[^>]*\bid="persist-escalation-noprogress"[^>]*>.*?<\/bpmn:serviceTask>/,
|
|
222
|
+
);
|
|
223
|
+
assert(task, "persist-escalation-noprogress task missing");
|
|
224
|
+
assertStringIncludes(task[0], 'type="pr.persist-escalation"');
|
|
225
|
+
assertStringIncludes(task[0], 'target="status"');
|
|
226
|
+
assertStringIncludes(task[0], 'target="question"');
|
|
227
|
+
// It must NOT double-record the round persist-round already recorded.
|
|
228
|
+
assertStringIncludes(task[0], 'target="recordRound"');
|
|
229
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// No-progress guard routing — the canonical, testable mirror of the convergence-loop
|
|
2
|
+
// `gw-progress` exclusive gateway (see resources/processes/convergence-loop.bpmn and the
|
|
3
|
+
// structural guard in roundProgress.test.ts).
|
|
4
|
+
//
|
|
5
|
+
// The convergence loop trusts the agent's self-reported `addressed` status to trigger the next
|
|
6
|
+
// Copilot review round. But an agent can return `addressed` (or fall back to the safe `addressed`
|
|
7
|
+
// default) WITHOUT actually pushing a commit — nothing landed on the PR head. Copilot then
|
|
8
|
+
// re-reviews byte-identical code, produces the same comments, and the loop burns rounds making no
|
|
9
|
+
// progress until the round cap escalates. That is the trap.
|
|
10
|
+
//
|
|
11
|
+
// So a deterministic step (`pr.progress-check`, workers/progress-check/worker.ts) reads the PR's
|
|
12
|
+
// head SHA and compares it to the head observed at the previous round. When an `addressed` round's
|
|
13
|
+
// head did NOT advance, the process must NOT request another review round — it escalates to the
|
|
14
|
+
// human `wait-answer` task instead. This module is the single source of truth for that decision so
|
|
15
|
+
// the worker and the BPMN gateway can never drift apart.
|
|
16
|
+
|
|
17
|
+
/** Where a recorded round routes after the progress check:
|
|
18
|
+
* • `continue` — request another review round (the head advanced, or the round carried an
|
|
19
|
+
* explicit non-addressed status — e.g. `waiting` — that claims no push, or the
|
|
20
|
+
* head could not be read so we fail OPEN rather than fabricate a no-progress
|
|
21
|
+
* escalation).
|
|
22
|
+
* • `escalate` — an `addressed` round whose PR head did not move: no commit was really pushed,
|
|
23
|
+
* so re-review would loop. Escalate to a human instead of looping. */
|
|
24
|
+
export type ProgressRouting = "continue" | "escalate";
|
|
25
|
+
|
|
26
|
+
// The statuses that route AWAY from gw-status's `addressed`/default arm (see the explicit
|
|
27
|
+
// conditions on f_converged/f_waiting/f_escalate in convergence-loop.bpmn). A round carrying one of
|
|
28
|
+
// these legitimately claims no push and must always continue past the no-progress guard.
|
|
29
|
+
//
|
|
30
|
+
// Everything else — an explicit `addressed`, a blank/unknown/empty status, or any unrecognized
|
|
31
|
+
// string — takes gw-status's `addressed`/default arm, and pr.persist-round records a missing status
|
|
32
|
+
// as `addressed` too. That safe-default `addressed` round is EXACTLY the no-progress trap this guard
|
|
33
|
+
// exists for, so blank/unknown status must be treated as `addressed` here (subject to the head
|
|
34
|
+
// comparison), never waved through. Matching is exact (no trim) to mirror gw-status's `=status =
|
|
35
|
+
// "waiting"` equality: a padded `" waiting "` matches no arm there, so it defaults to `addressed`
|
|
36
|
+
// here as well.
|
|
37
|
+
const NON_ADDRESSED_STATUSES: ReadonlySet<string> = new Set([
|
|
38
|
+
"waiting",
|
|
39
|
+
"converged",
|
|
40
|
+
"needs_input",
|
|
41
|
+
"blocked",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/** True when a round's status is `addressed` for no-progress purposes — i.e. NOT one of the
|
|
45
|
+
* explicitly recognized non-addressed statuses. Blank/unknown/empty status is `addressed` here,
|
|
46
|
+
* mirroring gw-status's default arm and pr.persist-round's missing-status default. The single
|
|
47
|
+
* source of truth for both {@link routeProgress} and the pr.progress-check worker's early skip. */
|
|
48
|
+
export function isAddressedStatus(status: string | null | undefined): boolean {
|
|
49
|
+
return !NON_ADDRESSED_STATUSES.has(status ?? "");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Decide whether a recorded round made real progress, exactly as `gw-progress` does.
|
|
53
|
+
*
|
|
54
|
+
* Only an `addressed` round claims the agent pushed changes, so only it can be a no-progress
|
|
55
|
+
* round — and blank/unknown status counts as `addressed` (see {@link isAddressedStatus}): it is the
|
|
56
|
+
* safe-default round this guard exists to catch. An explicitly recognized non-addressed status
|
|
57
|
+
* (`waiting` on round 1, awaiting the first review, etc.) legitimately has no push and must always
|
|
58
|
+
* continue. The check FAILS OPEN: when either head SHA is unknown — no GitHub transport, a transient
|
|
59
|
+
* read error, or no baseline recorded yet (the first observed round) — we never fabricate a
|
|
60
|
+
* no-progress escalation; we continue and let the round cap / review-wait timeout remain the safety
|
|
61
|
+
* nets. */
|
|
62
|
+
export function routeProgress(
|
|
63
|
+
status: string | null | undefined,
|
|
64
|
+
previousHead: string | null | undefined,
|
|
65
|
+
currentHead: string | null | undefined,
|
|
66
|
+
): ProgressRouting {
|
|
67
|
+
if (!isAddressedStatus(status)) return "continue";
|
|
68
|
+
if (!previousHead || !currentHead) return "continue";
|
|
69
|
+
return currentHead === previousHead ? "escalate" : "continue";
|
|
70
|
+
}
|
package/app/service.test.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// GitHub transport forced off so it is hermetic.
|
|
8
8
|
import { test } from "node:test";
|
|
9
9
|
import { assertEquals } from "#test-assert";
|
|
10
|
-
import { pollIncidentsImpl, repoEnvelopeVars, submitPr } from "./service.ts";
|
|
10
|
+
import { parsePr, pollIncidentsImpl, repoEnvelopeVars, submitPr } from "./service.ts";
|
|
11
11
|
|
|
12
12
|
function memTable(rows: any[], key: string) {
|
|
13
13
|
return {
|
|
@@ -382,3 +382,20 @@ test("repoEnvelopeVars emits nothing for a malformed repo (not owner/repo)", ()
|
|
|
382
382
|
);
|
|
383
383
|
}
|
|
384
384
|
});
|
|
385
|
+
|
|
386
|
+
// `parsePr` is total on any input: it is called unguarded from several workers (progress-check,
|
|
387
|
+
// persist-round, persist-escalation, record-dependency) with a process variable that a regression
|
|
388
|
+
// — or an older in-flight instance — could carry as a non-string. `.trim()` on a non-string throws,
|
|
389
|
+
// which would turn a should-fail-open caller into a retrying job. A non-string must resolve to
|
|
390
|
+
// `null` (fail closed) so every caller's fail-open path runs instead of the handler crashing.
|
|
391
|
+
test("parsePr fails closed to null on a non-string input (no throw)", () => {
|
|
392
|
+
for (const bad of [undefined, null, 123, {}, [], true] as unknown[]) {
|
|
393
|
+
assertEquals(parsePr(bad as any), null, `expected null for ${JSON.stringify(bad)}`);
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("parsePr still resolves a well-formed prKey and PR URL", () => {
|
|
398
|
+
assertEquals(parsePr("owner/repo#42")?.prKey, "owner/repo#42");
|
|
399
|
+
assertEquals(parsePr(" owner/repo#42 ")?.number, 42);
|
|
400
|
+
assertEquals(parsePr("https://github.com/owner/repo/pull/7")?.repo, "owner/repo");
|
|
401
|
+
});
|
package/app/service.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// `Table<T>` surface), not hand-written SQL. Row shapes are declared inline here.
|
|
10
10
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
11
11
|
import { abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
|
|
12
|
+
import { agentSlaTimeout } from "./agentSla.ts";
|
|
12
13
|
import { deriveFeatureBlockedPatch, deriveFeatureDelivery, deriveFeatureEscalationPatch, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, type FeatureRun, featureRuns } from "./feature.ts";
|
|
13
14
|
import {
|
|
14
15
|
classifyMergeability,
|
|
@@ -58,6 +59,14 @@ export const MAX_CI_FIX_ROUNDS = clampCiFixBudget(process.env.NANO_PR_MAX_CI_FIX
|
|
|
58
59
|
* immediately). Reuses the CI-fix budget clamp (allows 0 = disable, ceiling-capped). */
|
|
59
60
|
export const MAX_REBASE_ROUNDS = clampCiFixBudget(process.env.NANO_PR_MAX_REBASE_ROUNDS, 3);
|
|
60
61
|
|
|
62
|
+
/** How long a merge-loop AGENT service task (rebase / fix-ci) may sit without completing before its
|
|
63
|
+
* interrupting timer boundary fires and the PR escalates for human attention. Seeded as the
|
|
64
|
+
* `agentSlaTimeout` process variable at merge start and evaluated by those tasks' boundary timers.
|
|
65
|
+
* Unlike a human-decision escalation (PT24H), an agent task has no human in the loop — if its
|
|
66
|
+
* capability is unstaffed or the agent hangs/crashes without failing the job, the token would
|
|
67
|
+
* otherwise park forever. Override with `NANO_PR_AGENT_SLA_TIMEOUT` (ISO-8601 duration). */
|
|
68
|
+
export const AGENT_SLA_TIMEOUT = agentSlaTimeout(process.env.NANO_PR_AGENT_SLA_TIMEOUT);
|
|
69
|
+
|
|
61
70
|
/** How long the convergence loop waits for a fresh review before escalating to a human. Seeded as
|
|
62
71
|
* the `reviewWaitTimeout` process variable at submit and evaluated by the process's
|
|
63
72
|
* `wait-review-timeout` timer catch (the timer arm of the event-based-gateway race against
|
|
@@ -295,7 +304,11 @@ export async function ensurePr(
|
|
|
295
304
|
}
|
|
296
305
|
|
|
297
306
|
/** Parse "owner/repo#123" or a canonical PR URL into its parts. */
|
|
298
|
-
export function parsePr(input:
|
|
307
|
+
export function parsePr(input: unknown): ParsedPr | null {
|
|
308
|
+
// Total on any input: a process-variable regression (or an older in-flight instance) can carry a
|
|
309
|
+
// non-string prKey, and `.trim()` on a non-string throws — turning a should-fail-open caller into
|
|
310
|
+
// a retrying job. Fail closed to `null` here so every caller resolves safely instead of throwing.
|
|
311
|
+
if (typeof input !== "string") return null;
|
|
299
312
|
const s = input.trim();
|
|
300
313
|
let m = s.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/i);
|
|
301
314
|
if (m) {
|
|
@@ -527,6 +540,7 @@ export async function startMerge(
|
|
|
527
540
|
ciFixMax: MAX_CI_FIX_ROUNDS,
|
|
528
541
|
rebaseRound: 0,
|
|
529
542
|
rebaseMax: MAX_REBASE_ROUNDS,
|
|
543
|
+
agentSlaTimeout: AGENT_SLA_TIMEOUT,
|
|
530
544
|
abandonUrl: abUrl,
|
|
531
545
|
abandonBrief: renderAbandonBrief(abUrl),
|
|
532
546
|
// Host-git provisioning (c8ctl): same repository envelope as the convergence loop, so the
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
-- Track the PR head SHA observed at each recorded convergence round so the loop can detect a
|
|
2
|
+
-- NO-PROGRESS `addressed` round: the agent reported it addressed the review comments, but the
|
|
3
|
+
-- branch head never moved — no commit was actually pushed. Requesting another Copilot review then
|
|
4
|
+
-- loops on byte-identical code (same comments, round after round) until the round cap escalates.
|
|
5
|
+
--
|
|
6
|
+
-- The deterministic `pr.progress-check` guard (workers/progress-check/worker.ts) reads the PR's
|
|
7
|
+
-- current head SHA each continue-round, compares it to this column (the head at the PREVIOUS
|
|
8
|
+
-- round), and — when an `addressed` round's head did not advance — routes the convergence-loop
|
|
9
|
+
-- `gw-progress` gateway to the human `wait-answer` escalation instead of soliciting another
|
|
10
|
+
-- review. It then writes the freshly observed head here as the baseline for the next round. NULL
|
|
11
|
+
-- until the first round observes a head; the guard fails OPEN on a NULL/unreadable head.
|
|
12
|
+
--
|
|
13
|
+
-- Forward-only, additive (expand): the column is nullable with no default. Numbered after the
|
|
14
|
+
-- current highest prefix (032); the runner wraps each file in its own transaction, so this file
|
|
15
|
+
-- must NOT contain BEGIN/COMMIT.
|
|
16
|
+
ALTER TABLE pull_requests ADD COLUMN last_round_head TEXT;
|
package/nano.app.json
CHANGED
|
@@ -164,6 +164,14 @@
|
|
|
164
164
|
{
|
|
165
165
|
"taskType": "pr.retro-record",
|
|
166
166
|
"handler": "workers/retro-record/worker.ts"
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
"taskType": "pr.progress-check",
|
|
170
|
+
"handler": "workers/progress-check/worker.ts"
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
"taskType": "pr.converge-gate",
|
|
174
|
+
"handler": "workers/converge-gate/worker.ts"
|
|
167
175
|
}
|
|
168
176
|
],
|
|
169
177
|
"externalTaskTypes": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.70.
|
|
3
|
+
"version": "0.70.2",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|