@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/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
+ }
@@ -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: string): ParsedPr | null {
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.0",
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",