@nanobpm/nano-workforce 0.200.8 → 0.201.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/SPEC.md +35 -0
  3. package/app/deliveryGraph.test.ts +10 -0
  4. package/app/deliveryGraphCompiler.test.ts +354 -10
  5. package/app/deliveryGraphCompiler.ts +296 -40
  6. package/app/deliveryRunner.ts +14 -5
  7. package/app/implementEscalationReason.test.ts +216 -0
  8. package/app/implementEscalationReason.ts +213 -0
  9. package/app/implementReconcile.test.ts +168 -11
  10. package/app/implementReconcile.ts +112 -18
  11. package/app/pullRequestReadModel.test.ts +1 -1
  12. package/app/roundChurn.test.ts +388 -0
  13. package/app/roundChurn.ts +265 -0
  14. package/app/roundProgress.test.ts +224 -1
  15. package/app/service.test.ts +7 -0
  16. package/app/service.ts +12 -0
  17. package/db/migrations/117_pull_requests_churn_escalated_round.sql +12 -0
  18. package/db/migrations/118_pull_requests_read_model_churn_escalated_round.sql +55 -0
  19. package/docs/agent-guide.md +10 -7
  20. package/e2e/delivery-graph.e2e.ts +570 -1
  21. package/e2e/feature-run.e2e.ts +8 -1
  22. package/e2e/plan-fanout.e2e.ts +75 -2
  23. package/package.json +1 -1
  24. package/resources/processes/implement-cell.bpmn +42 -17
  25. package/resources/processes/plan-fanout.bpmn +236 -211
  26. package/resources/prompts/feature.md +16 -1
  27. package/workers/progress-check/worker.ts +98 -1
  28. package/workers/readiness-probe/worker.test.ts +72 -1
  29. package/workers/readiness-probe/worker.ts +39 -0
  30. package/workers/reconcile-implement/worker.test.ts +31 -3
  31. package/workers/reconcile-implement/worker.ts +5 -0
  32. package/workers/record-feature-escalation/worker.test.ts +58 -3
  33. package/workers/record-feature-escalation/worker.ts +24 -10
@@ -0,0 +1,265 @@
1
+ // Non-converging churn guard — the canonical, testable detector the `pr.progress-check` worker uses
2
+ // to escalate a review loop that is making head-advancing "progress" every round yet never reaches a
3
+ // fixed point (issue #870).
4
+ //
5
+ // The no-progress guard in app/roundProgress.ts catches the OTHER failure mode: an `addressed` round
6
+ // whose PR head did NOT advance (no commit pushed). Churn is the opposite shape — EVERY round pushes
7
+ // a commit, so the head advances and `routeProgress` legitimately returns `continue`, round after
8
+ // round. The trap is that the findings never end: a contested surface (e.g. a fail-closed static
9
+ // analyzer whose bypass forms are unbounded) keeps producing 1–3 new findings in the SAME file,
10
+ // the review agent patches them, the patch adds more surface for the next review, and the loop runs
11
+ // to `maxRounds` (or forever) without a human ever being asked to make the scope call.
12
+ //
13
+ // This detector reads the durable `rounds` history and reports churn when the trailing run of
14
+ // consecutive `addressed` rounds keeps landing findings in the SAME file(s) over a window of rounds.
15
+ // It is deliberately conservative — it requires the SAME file to appear in EVERY round of the window
16
+ // — so normal multi-round convergence (findings in different areas, trending down) is never escalated
17
+ // early. When it fires, the worker routes the round to the SAME human escalation path the no-advance
18
+ // guard uses (gw-husk → persist-escalation-noprogress), carrying an actionable scope question.
19
+ import { isAddressedStatus } from "./roundProgress.ts";
20
+
21
+ /** How many CONSECUTIVE `addressed` rounds must all land findings in the same file before the loop is
22
+ * declared non-converging and escalated for a human scope decision. Four matches the issue's
23
+ * suggested threshold (#870) — long enough that a legitimately-converging loop whose findings move
24
+ * between areas never reaches it, short enough to catch a stuck surface well below a high `maxRounds`
25
+ * cap. */
26
+ export const CHURN_WINDOW = 4;
27
+
28
+ /** One recorded round, projected to just the fields churn detection reads: its number (for ordering),
29
+ * its `status` (to find the trailing consecutive `addressed` run), and its `summary` (the agent's
30
+ * round summary, the durable free-text the contested file names are mined from). */
31
+ export interface ChurnRound {
32
+ readonly roundNo: number;
33
+ readonly status: string | null | undefined;
34
+ readonly summary: string | null | undefined;
35
+ }
36
+
37
+ /** The churn verdict. `churning:false` is the common case (continue the loop). When `churning:true`,
38
+ * `file` is the contested surface, `rounds` is how many consecutive rounds hit it, and `question` is
39
+ * the human-facing scope decision to carry into the escalation. */
40
+ export interface ChurnResult {
41
+ readonly churning: boolean;
42
+ readonly file?: string;
43
+ readonly rounds?: number;
44
+ readonly question?: string;
45
+ }
46
+
47
+ // A maximal run of path characters (`\w`, plus `.@~+/-`). Splitting the summary into these runs
48
+ // first — a single linear scan — isolates each candidate token (a non-path char like a space, comma,
49
+ // backtick, or `:` ends a run), so the path shape below is only ever tested ANCHORED at a run's start
50
+ // and never re-tried at every offset of the whole summary. The trailing `-` keeps that metacharacter
51
+ // literal (a filename hyphen) rather than a range.
52
+ const PATH_RUN_RE = /[\w.@~+/-]+/g;
53
+
54
+ // The KNOWN source/config/doc/build extensions a mined file path's FINAL dotted component must be —
55
+ // shared by BOTH the nested and root matchers below. A permissive `[A-Za-z0-9]+`/`[A-Za-z]{2,}`
56
+ // extension mines dotted API symbols and prose as fake files — `z.object`, `object.keys`,
57
+ // `example.com`, `schema.ts.parse` all look like `name.ext` — so four rounds that each mention the
58
+ // same qualified symbol while fixing DIFFERENT real files would intersect on that symbol and falsely
59
+ // escalate (Copilot review of #870). Restricting the extension to a real-file allowlist rejects those
60
+ // (`object`, `keys`, `com`, `parse` are not file extensions) while keeping every genuine file. It is
61
+ // fail-open in the safe direction: an exotic-but-real extension it omits (`.svelte` is listed; a
62
+ // brand-new one is not) only ever MISSES a churn signal, never fabricates one. The allowlist contains
63
+ // no digits or single letters, so it also excludes version strings (`4.8`), sentence-ending
64
+ // abbreviations (`e.g`, `i.e`), and initialisms (`U.S`).
65
+ const ROOT_FILE_EXT =
66
+ "(?:ts|tsx|js|jsx|mjs|cjs|mts|cts|json|jsonc|json5|md|markdown|yml|yaml|toml|xml|html|css|scss|" +
67
+ "less|sql|sh|bash|zsh|py|rb|go|rs|java|kt|kts|swift|c|h|cc|hh|cpp|cxx|hpp|cs|fs|vue|svelte|php|" +
68
+ "pl|pm|lua|dart|ex|exs|erl|hrl|clj|cljs|scala|groovy|gradle|properties|ini|cfg|conf|config|env|" +
69
+ "lock|mod|sum|mk|cmake|txt|bpmn|form|dmn|proto|graphql|gql|prisma|tf|hcl)";
70
+
71
+ // A NESTED repository-relative file path, ANCHORED at the start of a candidate run: one or more `dir/`
72
+ // segments followed by a `name.ext`. Requiring at least one slash AND an extension keeps bare words
73
+ // ("addressed", "zod") and prose out, so only genuine file references are mined. Because it is matched
74
+ // against one bounded run (not scanned across the whole text), the inner `(?:…\/)+` backtracking on a
75
+ // long UNTERMINATED `dir/` run (e.g. `"a/".repeat(n)` with no closing `name.ext`) is paid ONCE at a
76
+ // single start position — linear in the run length — rather than the O(n²) a global re-scan would
77
+ // cost, which would otherwise let an adversarial summary (a long directory listing, minified stack
78
+ // trace, or base64/data-URI blob) stall the `pr.progress-check` worker inside the convergence loop.
79
+ //
80
+ // Like the root form below, the FINAL dotted component must be an allowlisted ROOT_FILE_EXT and the
81
+ // trailing `(?![\w.@~+-])` negative lookahead refuses any following path char — crucially another
82
+ // `.segment`. Without those, a SLASH-qualified API chain like `src/schema.ts.parse` /
83
+ // `lib/config.json.parse` was mined WHOLE as a fake repo file: four summaries that cite that same
84
+ // qualified symbol while fixing different real files intersect on it and falsely escalate, exactly the
85
+ // class the root allowlist already closes (Copilot review of #870). The basename's first char is a
86
+ // non-dot path char so a true `name.ext` anchors the stem; the greedy `[\w.@~+-]*` still lets a
87
+ // multi-dot basename (`dir/foo.test.ts`, `lib/nano.app.json`) match whole by backtracking the LAST
88
+ // allowlisted dot as the separator.
89
+ const NESTED_PATH_RE = new RegExp(
90
+ `^(?:[\\w.@~+-]+\\/)+[\\w@~+-][\\w.@~+-]*\\.${ROOT_FILE_EXT}(?![\\w.@~+-])`,
91
+ "i",
92
+ );
93
+
94
+ // A ROOT-LEVEL file (no directory segment): a bare `name.ext`. Root repository files — `package.json`,
95
+ // `README.md`, `tsconfig.json`, `nano.app.json` — are legitimate churn surfaces too, but the nested
96
+ // form above excludes them because it REQUIRES a slash, so a loop repeatedly editing `package.json`
97
+ // would never escalate (issue #870 follow-up). Matching a bare `name.ext` re-admits the prose that the
98
+ // required slash kept out, so this form is deliberately STRICTER to compensate, on three axes — all
99
+ // now shared with the nested matcher above via ROOT_FILE_EXT + the trailing lookahead:
100
+ //
101
+ // 1. The extension must be a KNOWN source/config/doc/build extension (ROOT_FILE_EXT above), not just
102
+ // "any letters" — see that constant's comment for why (`z.object`, `example.com`, `Deno.land` are
103
+ // rejected because `object`/`com`/`land` are not file extensions).
104
+ // 2. The extension must still be at least two ALPHABETIC characters (the allowlist contains no digits
105
+ // or single letters), which keeps excluding version strings (`4.8`), sentence-ending abbreviations
106
+ // (`e.g`, `i.e`), and initialisms (`U.S`).
107
+ // 3. The allowlisted extension must be the FINAL dotted component of the token. The trailing
108
+ // `(?![\w.@~+-])` negative lookahead refuses any following path character — crucially another
109
+ // `.segment` — so an API chain like `schema.ts.parse` / `config.json.parse` is NOT truncated to a
110
+ // fake `schema.ts` / `config.json` root file (a `\b`-only anchor succeeds before that next dot and
111
+ // mined the truncation, Copilot review of #870). Trailing SENTENCE punctuation is stripped from the
112
+ // candidate token in {@link extractFiles} BEFORE matching, so `README.md.` still mines `README.md`.
113
+ //
114
+ // The stem is greedy and MAY contain dots (`foo.test.ts`, `nano.app.json`): backtracking lets the LAST
115
+ // dot be the separator so a multi-dot root file matches whole, while the allowlisted final extension
116
+ // still anchors the match. Tried only AFTER the nested form, and like it anchored at the run start (no
117
+ // global re-scan), so it adds no backtracking cost — it only ever ADDS real root-level files, never
118
+ // removes a nested path the form above already mines.
119
+ const ROOT_FILE_RE = new RegExp(`^[\\w@~+-][\\w.@~+-]*\\.${ROOT_FILE_EXT}(?![\\w.@~+-])`, "i");
120
+
121
+ // A ROOT-LEVEL dotfile or extensionless basename — the churn surfaces the `name.ext` form above can
122
+ // NEVER match because they have a leading dot and no basename (`.gitignore`, `.env`, `.editorconfig`,
123
+ // `.gitattributes`) or no extension at all (`Dockerfile`, `Makefile`). The allowlist/comment above
124
+ // name these as legitimate surfaces, so a loop repeatedly editing `.gitignore` must escalate too
125
+ // (Copilot review of #870). A CLOSED allowlist of well-known basenames — not "any dotfile" — keeps the
126
+ // match conservative (prose like "the environment" or "docker build" is never mined), and the trailing
127
+ // `(?![\w.@~+-])` requires an EXACT whole-token match so a dotted variant (`.env.local`,
128
+ // `Dockerfile.prod`) is left un-mined (fail-open) rather than collapsed to the base name.
129
+ const ROOT_BASENAME_RE =
130
+ /^(?:\.(?:gitignore|gitattributes|editorconfig|env|dockerignore|npmrc|nvmrc|prettierrc|eslintrc|babelrc)|Dockerfile|Makefile|Rakefile|Gemfile|Procfile|Caddyfile|CODEOWNERS)(?![\w.@~+-])/i;
131
+
132
+ // A whole URL span (`scheme://…host/path…`). A citation link's path (e.g.
133
+ // `github.com/o/r/blob/main/docs/guide.md`) otherwise looks exactly like a repo-relative file, so a
134
+ // summary that cites the SAME link every round while fixing DIFFERENT real files would false-escalate
135
+ // as churn naming the URL as the contested file. Strip URL spans before mining so only genuine repo
136
+ // paths remain. `\S+` is linear (no backtracking).
137
+ const URL_RE = /\b[a-z][a-z0-9+.-]*:\/\/\S+/giu;
138
+
139
+ // A SCHEME-LESS citation URL's host+path (`github.com/o/r/blob/main/docs/guide.md`), ANCHORED at a
140
+ // candidate run's start. URL_RE only strips spans with an explicit `scheme://`; a bare `host.tld/path`
141
+ // citation survives it and then satisfies NESTED_PATH_RE (dir segments + an allowlisted `guide.md`), so
142
+ // four summaries that cite the SAME link while fixing DIFFERENT real files would intersect on the URL
143
+ // and falsely escalate as churn (Copilot review of #870). A repo-relative path's FIRST segment is a
144
+ // plain directory name — a dotfile dir (`.github/…`) has a LEADING dot (no label before it, so this
145
+ // `(?:label\.)+` prefix can't match) and an ordinary dir (`src/…`) has no dot at all — whereas a URL
146
+ // host is `label(.label)*.tld/`, so a leading dot-containing host segment followed by `/` reliably
147
+ // marks a citation URL, not a file. Tested ONCE per bounded run (like NESTED_PATH_RE), the
148
+ // `(?:[\w-]+\.)+` prefix is linear: the `\.` delimiter makes label boundaries unambiguous, so there is
149
+ // no catastrophic backtracking even on an adversarial dotted run. REJECTING (not truncating) the whole
150
+ // token is fail-open: the rare real path whose first directory literally contains a dot only MISSES a
151
+ // churn signal, never fabricates one — the same safe direction the extension allowlist already favours.
152
+ const SCHEMELESS_URL_RE = /^(?:[\w-]+\.)+[a-z]{2,}\/[\w.@~+-]/i;
153
+
154
+ // Defensive cap on the free text scanned. The matcher above is linear, so this is belt-and-suspenders
155
+ // (bounding Set growth and any unforeseen pathological input), not the primary perf guard; real round
156
+ // summaries are far shorter, and truncating one only ever drops a churn SIGNAL (fail-open), never
157
+ // fabricates one.
158
+ const MAX_SCAN = 20000;
159
+
160
+ /** Mine the set of distinct file paths referenced in a round summary. A non-string / empty summary
161
+ * yields an empty set. The summary is length-bounded and URL spans are stripped, then each maximal
162
+ * path-char run is tested for the path shape anchored at its start (see MAX_SCAN / URL_RE /
163
+ * PATH_RUN_RE / NESTED_PATH_RE / ROOT_FILE_RE / ROOT_BASENAME_RE). Each run's trailing sentence
164
+ * punctuation and closing brackets are stripped BEFORE matching so the same file referenced with
165
+ * different surrounding punctuation collapses to one key and the allowlisted extension anchors to the
166
+ * token end. */
167
+ export function extractFiles(summary: string | null | undefined): Set<string> {
168
+ const files = new Set<string>();
169
+ if (typeof summary !== "string" || summary.trim() === "") return files;
170
+ const scanned = summary.slice(0, MAX_SCAN).replace(URL_RE, " ");
171
+ for (const run of scanned.matchAll(PATH_RUN_RE)) {
172
+ // Strip trailing sentence punctuation / closing brackets BEFORE matching: `.` is itself a path
173
+ // char, so a sentence-ending `README.md.` arrives in the run with its period attached. Removing it
174
+ // first lets the allowlisted extension anchor cleanly to the END of the token (ROOT_FILE_RE's
175
+ // `(?![\w.@~+-])` lookahead), instead of a `\b` that also fired before that trailing dot.
176
+ const token = run[0].replace(/[),.;:'"`\]]+$/u, "").trim();
177
+ if (token === "") continue;
178
+ // Reject a scheme-less citation URL (`github.com/o/r/blob/main/docs/guide.md`) before mining: its
179
+ // host+path otherwise looks exactly like a nested repo file (see SCHEMELESS_URL_RE). URL_RE already
180
+ // removed any `scheme://` span, so this closes the remaining citation-URL hole.
181
+ if (SCHEMELESS_URL_RE.test(token)) continue;
182
+ const hit = token.match(NESTED_PATH_RE) ?? token.match(ROOT_FILE_RE) ?? token.match(ROOT_BASENAME_RE);
183
+ if (hit === null) continue;
184
+ const path = hit[0].trim();
185
+ if (path !== "") files.add(path);
186
+ }
187
+ return files;
188
+ }
189
+
190
+ /** Build the human-facing escalation question for a churning loop, framed — per the issue — as a
191
+ * SCOPE decision, not a request to keep looping. The answer the human gives is threaded into the next
192
+ * round's context (the `answer` variable), so it must offer concrete, actionable choices. */
193
+ export function churnQuestion(file: string, rounds: number): string {
194
+ return (
195
+ `Convergence is not converging: the last ${rounds} review rounds all reported the comments were ` +
196
+ `addressed, yet the findings keep landing in the same file (\`${file}\`) with no fixed point in ` +
197
+ `sight — a contested surface whose findings never end (e.g. a fail-closed analyzer whose bypass ` +
198
+ `forms are unbounded). Rather than burn more rounds, this needs a human SCOPE decision: ` +
199
+ `(a) narrow or simplify the contested surface in \`${file}\`; (b) defer the remaining findings ` +
200
+ `to a follow-up issue and accept the current state as non-blocking; or (c) accept the residual ` +
201
+ `findings as non-blocking and let the loop converge. Reply with the decision to resume the loop.`
202
+ );
203
+ }
204
+
205
+ /** Detect non-converging churn over a PR's recorded rounds.
206
+ *
207
+ * Churn = the trailing run of CONSECUTIVE `addressed` rounds is at least `window` long AND some single
208
+ * file appears in the summary of EVERY round of the most recent `window`. Requiring the same file in
209
+ * all `window` rounds is what keeps normal convergence (findings that move between areas and trend
210
+ * down) from escalating: a loop whose findings are in different files each round has no file common to
211
+ * the whole window, so it continues. A non-`addressed` round (a `waiting` round, or a prior human
212
+ * escalation recorded as `needs_input`/`blocked`) BREAKS the consecutive run — a human already made a
213
+ * call, so the churn clock restarts after it.
214
+ *
215
+ * The verdict is conservative by construction: a round with NO extractable file path in its summary
216
+ * also breaks the signal (the intersection can't include a file absent from one round), so churn is
217
+ * only ever reported on a genuinely same-file, same-surface loop.
218
+ *
219
+ * `resetAfterRound` is the run-scoped churn-reset watermark (issue #870): the round number at which a
220
+ * churn escalation was last raised AND answered by a human for THIS run. A churn escalation routes
221
+ * through `persist-escalation-noprogress` with `recordRound=false`, so it writes NO `blocked` round —
222
+ * and the human-answer resume re-enters the SAME numeric round (the round counter only advances at the
223
+ * review-wait gateway). Without this watermark `detectChurn` would therefore see the identical trailing
224
+ * `addressed` window the instant the resumed round is recorded and re-raise the SAME question forever
225
+ * (which durable adjudication may auto-resume repeatedly). Dropping every round at or before the
226
+ * watermark makes the human's scope decision restart the churn clock exactly as a recorded
227
+ * `needs_input`/`blocked` round would: churn can only fire again after a fresh `window` of same-file
228
+ * rounds ACCUMULATES past the decision. */
229
+ export function detectChurn(
230
+ rounds: readonly ChurnRound[],
231
+ window: number = CHURN_WINDOW,
232
+ resetAfterRound = 0,
233
+ ): ChurnResult {
234
+ // Drop rounds at or before the churn-reset watermark so a human scope decision restarts the clock.
235
+ const live = resetAfterRound > 0 ? rounds.filter((r) => r.roundNo > resetAfterRound) : rounds;
236
+ if (window < 1 || live.length < window) return { churning: false };
237
+
238
+ // Order by round number, then walk back over the trailing CONSECUTIVE `addressed` run.
239
+ const sorted = [...live].sort((a, b) => a.roundNo - b.roundNo);
240
+ const trailing: ChurnRound[] = [];
241
+ for (let i = sorted.length - 1; i >= 0; i--) {
242
+ const r = sorted[i];
243
+ if (r === undefined || !isAddressedStatus(r.status)) break;
244
+ trailing.unshift(r);
245
+ }
246
+ if (trailing.length < window) return { churning: false };
247
+
248
+ // Intersect the file sets of the most-recent `window` addressed rounds. A single empty set means no
249
+ // same-file signal — bail immediately.
250
+ const recent = trailing.slice(-window);
251
+ const fileSets = recent.map((r) => extractFiles(r.summary));
252
+ if (fileSets.some((s) => s.size === 0)) return { churning: false };
253
+ let common = fileSets[0] ?? new Set<string>();
254
+ for (let i = 1; i < fileSets.length && common.size > 0; i++) {
255
+ const next = fileSets[i] ?? new Set<string>();
256
+ common = new Set([...common].filter((f) => next.has(f)));
257
+ }
258
+ if (common.size === 0) return { churning: false };
259
+
260
+ // Deterministic choice when several files are common to the whole window: the lexicographically
261
+ // smallest, so the escalation question is stable across redeliveries of the same round.
262
+ const [file] = [...common].sort();
263
+ if (file === undefined) return { churning: false };
264
+ return { churning: true, file, rounds: recent.length, question: churnQuestion(file, recent.length) };
265
+ }
@@ -222,13 +222,21 @@ async function makeUnderTest(
222
222
  processKey: string | null,
223
223
  stillOwns: () => Promise<boolean>,
224
224
  ) => Promise<{ healed: boolean; sha?: string; reason?: string }>,
225
+ readRounds?: (
226
+ prKey: string,
227
+ processInstanceKey: string | null | undefined,
228
+ ) => Promise<{ roundNo: number; status: string | null | undefined; summary: string | null | undefined }[]>,
225
229
  ) {
226
230
  const { makeHandler } = await import("../workers/progress-check/worker.ts");
227
231
  // Default the self-heal OFF (always `{ healed: false }`) so the existing escalation tests keep
228
232
  // escalating without touching the real WorldStore/GitHub deps; the #818 tests inject a healing stub.
229
233
  const heal = selfHeal ?? (async () => ({ healed: false }));
234
+ // Default the round-history reader to an EMPTY history so the existing tests never trip the churn
235
+ // guard (#870); the churn tests inject a stub history. An injected reader also avoids the fakeApp
236
+ // table double (which implements only get/update, not find).
237
+ const rounds = readRounds ?? (async () => []);
230
238
  // biome-ignore lint/suspicious/noExplicitAny: test double for the injectable worker deps.
231
- return makeHandler({ readHead, ...(readAgentWork ? { readAgentWork } : {}), selfHeal: heal as any });
239
+ return makeHandler({ readHead, ...(readAgentWork ? { readAgentWork } : {}), selfHeal: heal as any, readRounds: rounds as any });
232
240
  }
233
241
 
234
242
  test("progress-check: a non-addressed round records the baseline and consumes the attempt watermark but never escalates", async () => {
@@ -378,6 +386,221 @@ test("progress-check: self-heal is NOT attempted on a progressed round (only bef
378
386
  assertEquals(healCalls, 0, "a genuinely-progressing round never consults the self-heal");
379
387
  });
380
388
 
389
+ // ── Non-converging churn guard (#870) ────────────────────────────────────────
390
+
391
+ /** A trailing run of `count` `addressed` rounds whose summaries all name `file`. */
392
+ function churnHistory(count: number, file: string) {
393
+ return Array.from({ length: count }, (_, i) => ({
394
+ roundNo: i + 1,
395
+ status: "addressed" as const,
396
+ summary: `chore: fail closed on bypass form ${i} in \`${file}\``,
397
+ }));
398
+ }
399
+
400
+ test("progress-check: a head-advancing round whose findings keep hitting the same file ESCALATES for a scope decision (#870 repro)", async () => {
401
+ // The repro: the head ADVANCED (sha-1 → sha-2), so the no-progress guard continues — but the
402
+ // durable round history shows the findings keep landing in the same analyzer file round after
403
+ // round. Instead of parking for yet another review, the worker escalates with a scope question.
404
+ const file = "hooks/post/720-pure-zod-schemas.ts";
405
+ const handler = await makeUnderTest(
406
+ async () => "sha-2", // head advanced → decideProgress says "progressed"
407
+ async () => true,
408
+ undefined,
409
+ async () => churnHistory(4, file),
410
+ );
411
+ const { app, updates } = fakeApp({ last_round_head: "sha-1" });
412
+ const out = await handler(
413
+ { processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 22, huskRetries: 0 } } as any,
414
+ app as any,
415
+ );
416
+ assertEquals(out.progressed, false, "a churning loop is routed to escalation, not another review");
417
+ assertEquals(out.huskRetry, false, "churn escapes the husk auto-retry straight to the human");
418
+ assertEquals(out.noProgressReason, "churn");
419
+ assertStringIncludes(String(out.noProgressQuestion), file);
420
+ assertStringIncludes(String(out.noProgressQuestion), "SCOPE");
421
+ // It does NOT park at waiting_review — the escalation worker owns the status (like no-advance).
422
+ const parked = updates.find((u) => u.patch.status === "waiting_review");
423
+ assertEquals(parked, undefined, "a churn escalation never parks the PR at waiting_review");
424
+ // The baseline still advances so a redelivery replays rather than recomputing.
425
+ assertEquals(updates.at(-1)!.patch.last_round_head, "sha-2");
426
+ });
427
+
428
+ test("progress-check: the churn history read is SCOPED to the current run (#870)", async () => {
429
+ // Regression for cross-run history mixing: `rounds` is retained across resubmissions (migration
430
+ // 102), so the reader must filter by the job's processInstanceKey. The worker must pass its own
431
+ // `processInstanceKey` through to the reader — not a bare prKey — so a prior run's rows never leak
432
+ // into THIS run's churn window.
433
+ let seenKey: string | null | undefined = "unset";
434
+ const handler = await makeUnderTest(
435
+ async () => "sha-2",
436
+ async () => true,
437
+ undefined,
438
+ async (_prKey, pik) => {
439
+ seenKey = pik;
440
+ return []; // this run has no churning history of its own
441
+ },
442
+ );
443
+ const { app, updates } = fakeApp({ last_round_head: "sha-1" });
444
+ const out = await handler(
445
+ { processInstanceKey: "run-B", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 7, huskRetries: 0 } } as any,
446
+ app as any,
447
+ );
448
+ assertEquals(seenKey, "run-B", "the reader is scoped to the calling job's process instance");
449
+ assertEquals(out.progressed, true, "with no in-run churn history the loop parks for review");
450
+ assertEquals(updates.at(-1)!.patch.status, "waiting_review");
451
+ });
452
+
453
+ test("projectRunScopedRounds: only CURRENT-run rows can contribute to churn (#870 review)", async () => {
454
+ // The SCOPED-reader test above injects a reader returning [], so it exercises only the key forwarding
455
+ // — the actual row-filtering would still pass if removed or inverted. This covers the pure projection
456
+ // directly: `rounds` is retained across resubmissions (migration 102), so the default reader MUST
457
+ // drop any row not stamped with the calling job's processInstanceKey, including a legacy NULL-key row.
458
+ const { projectRunScopedRounds } = await import("../workers/progress-check/worker.ts");
459
+ const rows = [
460
+ { round_no: 1, status: "addressed", summary: "run B edit `src/a.ts`", process_instance_key: "run-B" },
461
+ { round_no: 2, status: "addressed", summary: "run B edit `src/b.ts`", process_instance_key: "run-B" },
462
+ { round_no: 9, status: "converged", summary: "prior run finished `src/z.ts`", process_instance_key: "run-A" },
463
+ { round_no: 3, status: "addressed", summary: "legacy row `src/c.ts`", process_instance_key: null },
464
+ ];
465
+ // Known key: ONLY the two run-B rows survive — the prior run's terminal `converged` row (which would
466
+ // break the trailing addressed run) and the legacy NULL-key row are both dropped.
467
+ const scoped = projectRunScopedRounds(rows, "run-B");
468
+ assertEquals(
469
+ scoped.map((r) => r.roundNo).sort((a, b) => a - b),
470
+ [1, 2],
471
+ "a prior run's rows and a legacy NULL-key row never leak into this run's window",
472
+ );
473
+ assertEquals(scoped.every((r) => r.status === "addressed"), true);
474
+ // Absent key (testkit/synthetic job that persisted no key): fall back to the WHOLE history, matching
475
+ // persist-round's own null-key fallback.
476
+ assertEquals(projectRunScopedRounds(rows, null).length, rows.length, "absent key falls back to full history");
477
+ assertEquals(projectRunScopedRounds(rows, undefined).length, rows.length, "undefined key falls back to full history");
478
+ // A numeric processInstanceKey is coerced to string before comparison.
479
+ const numeric = [{ round_no: 1, status: "addressed", summary: "x", process_instance_key: "42" }];
480
+ assertEquals(projectRunScopedRounds(numeric, 42 as unknown as string).length, 1, "numeric key is coerced to string");
481
+ });
482
+
483
+ test("progress-check: a churn escalation STAMPS the run-scoped churn-reset watermark (#870)", async () => {
484
+ // When churn fires, the worker must persist `churn_escalated_round = roundNo` so the human-answer
485
+ // resume (which re-enters the same numeric round and writes no `blocked` row) restarts the clock.
486
+ const file = "hooks/post/720-pure-zod-schemas.ts";
487
+ const handler = await makeUnderTest(
488
+ async () => "sha-2",
489
+ async () => true,
490
+ undefined,
491
+ async () => churnHistory(4, file),
492
+ );
493
+ const { app, updates } = fakeApp({ last_round_head: "sha-1" });
494
+ const out = await handler(
495
+ { processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 22, huskRetries: 0 } } as any,
496
+ app as any,
497
+ );
498
+ assertEquals(out.noProgressReason, "churn");
499
+ assertEquals(updates.at(-1)!.patch.churn_escalated_round, 22, "the escalating round is stamped as the churn-reset watermark");
500
+ });
501
+
502
+ test("progress-check: the churn-reset watermark SUPPRESSES re-escalation on the human-answer resume (#870)", async () => {
503
+ // The resumed round re-enters the SAME numeric round the escalation fired on (the counter only
504
+ // advances at review-wait), so the history is the identical churning window. With the row's
505
+ // `churn_escalated_round` already set to that round, `detectChurn` drops the answered window and the
506
+ // loop continues instead of re-raising the identical question.
507
+ const file = "hooks/post/720-pure-zod-schemas.ts";
508
+ const handler = await makeUnderTest(
509
+ async () => "sha-3",
510
+ async () => true,
511
+ undefined,
512
+ async () => churnHistory(4, file), // rounds 1..4, same file
513
+ );
514
+ // The PR row already carries the watermark stamped when the escalation was raised at round 4.
515
+ const { app, updates } = fakeApp({ last_round_head: "sha-2", churn_escalated_round: 4 });
516
+ const out = await handler(
517
+ { processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 4, huskRetries: 0 } } as any,
518
+ app as any,
519
+ );
520
+ assertEquals(out.progressed, true, "the answered churn window never re-escalates — the loop continues");
521
+ assertEquals(out.noProgressReason, undefined);
522
+ assertEquals(updates.at(-1)!.patch.status, "waiting_review");
523
+ });
524
+
525
+ test("progress-check: normal convergence (findings trending down across different files) does NOT escalate early (#870)", async () => {
526
+ // The counter-case the acceptance calls out: head advances AND the findings move between areas, so
527
+ // no single file spans the window → the loop continues to the next review as before.
528
+ const handler = await makeUnderTest(
529
+ async () => "sha-2",
530
+ async () => true,
531
+ undefined,
532
+ async () => [
533
+ { roundNo: 1, status: "addressed", summary: "fix src/a.ts" },
534
+ { roundNo: 2, status: "addressed", summary: "fix src/b.ts" },
535
+ { roundNo: 3, status: "addressed", summary: "fix src/c.ts" },
536
+ { roundNo: 4, status: "addressed", summary: "fix src/d.ts" },
537
+ ],
538
+ );
539
+ const { app, updates } = fakeApp({ last_round_head: "sha-1" });
540
+ const out = await handler(
541
+ { processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 5, huskRetries: 0 } } as any,
542
+ app as any,
543
+ );
544
+ assertEquals(out.progressed, true, "a converging loop continues to the next review");
545
+ assertEquals(out.noProgressReason, undefined);
546
+ assertEquals(updates.at(-1)!.patch.status, "waiting_review", "it parks for the next review as normal");
547
+ });
548
+
549
+ test("progress-check: a NO-ADVANCE round is still caught by the no-progress guard even without churn history (#870 is additive)", async () => {
550
+ // Churn is additive to — never a replacement for — the no-advance guard: an unchanged head with a
551
+ // terminal agent-instance still escalates as no-advance, regardless of the (empty) churn history.
552
+ const handler = await makeUnderTest(async () => "sha-1", async () => true, undefined, async () => []);
553
+ const { app } = fakeApp({ last_round_head: "sha-1" });
554
+ const out = await handler(
555
+ { processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 3, huskRetries: 0 } } as any,
556
+ app as any,
557
+ );
558
+ assertEquals(out.progressed, false);
559
+ assertEquals(out.noProgressReason, "no-advance", "the no-advance path is unchanged by the churn guard");
560
+ });
561
+
562
+ test("progress-check: the churn guard fails OPEN when the round-history read throws", async () => {
563
+ // A datasource hiccup must never fabricate a churn escalation — the read rejects, the worker
564
+ // `.catch`es to an empty history, and the progressing round parks for review as normal.
565
+ const handler = await makeUnderTest(
566
+ async () => "sha-2",
567
+ async () => true,
568
+ undefined,
569
+ async () => {
570
+ throw new Error("db down");
571
+ },
572
+ );
573
+ const { app, updates } = fakeApp({ last_round_head: "sha-1" });
574
+ const out = await handler(
575
+ { processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 10, huskRetries: 0 } } as any,
576
+ app as any,
577
+ );
578
+ assertEquals(out.progressed, true, "an unreadable round history fails open to continue");
579
+ assertEquals(updates.at(-1)!.patch.status, "waiting_review");
580
+ });
581
+
582
+ test("progress-check: a non-addressed `waiting` round never consults the churn guard", async () => {
583
+ // A `waiting` round short-circuits before the progressed branch, so even a churning history is
584
+ // irrelevant — it parks for review.
585
+ let readRoundsCalled = false;
586
+ const handler = await makeUnderTest(
587
+ async () => "sha-2",
588
+ async () => ({ work: true, consumedKey: "9" }),
589
+ undefined,
590
+ async () => {
591
+ readRoundsCalled = true;
592
+ return churnHistory(4, "dir/file.ts");
593
+ },
594
+ );
595
+ const { app } = fakeApp({ last_round_head: "sha-1" });
596
+ const out = await handler(
597
+ { processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "waiting", repo: "o/r", prNumber: 1, round: 8 } } as any,
598
+ app as any,
599
+ );
600
+ assertEquals(out.progressed, true);
601
+ assertEquals(readRoundsCalled, false, "a non-addressed round never reaches the churn check");
602
+ });
603
+
381
604
  test("progress-check: a no-advance round with no recoverable checkpoint still escalates (#818)", async () => {
382
605
  // The self-heal declines (no reachable fast-forward checkpoint) → the round escalates exactly as
383
606
  // before the self-heal existed.
@@ -205,6 +205,9 @@ test("re-submit of a cancelled PR marks stale open escalations", async () => {
205
205
  last_progress_job_key: "old-job-key-from-prior-run",
206
206
  last_progress_result: "{\"progressed\":false,\"huskRetries\":0}",
207
207
  last_progress_agent_watermark: "999",
208
+ // The run-scoped churn-reset watermark names a round number in the PRIOR run's numbering
209
+ // (issue #870); a fresh run must clear it (see the assertion below).
210
+ churn_escalated_round: 2,
208
211
  }],
209
212
  key: "pr_key",
210
213
  },
@@ -247,6 +250,10 @@ test("re-submit of a cancelled PR marks stale open escalations", async () => {
247
250
  assertEquals(pr.last_progress_job_key, null);
248
251
  assertEquals(pr.last_progress_result, null);
249
252
  assertEquals(pr.last_progress_agent_watermark, null);
253
+ // The run-scoped churn-reset watermark is ALSO run-scoped and must be cleared: it names a round
254
+ // number in the PRIOR run's numbering, so a carried-over watermark could drop the fresh run's
255
+ // early rounds from the churn window and silently suppress its first churn escalation (#870).
256
+ assertEquals(pr.churn_escalated_round, null);
250
257
  assertEquals(pr.open_escalation_id, undefined);
251
258
  assertEquals(pr.open_escalation_question, undefined);
252
259
  assertEquals(pr.process_key, "PI-9");
package/app/service.ts CHANGED
@@ -370,6 +370,13 @@ export interface PullRequest {
370
370
  // BEFORE registering its AgentInstance is classified as a husk instead of masked by a prior round's
371
371
  // terminal instance. Cleared on re-open with the rest of the per-run state. NULL before any read.
372
372
  last_progress_agent_watermark: string | null;
373
+ // Run-scoped churn-reset watermark (117_pull_requests_churn_escalated_round.sql, issue #870): the
374
+ // round number at which the non-converging same-surface churn guard last escalated for a human
375
+ // scope decision on THIS run. `detectChurn` (app/roundChurn.ts) drops rounds at or before it, so a
376
+ // human's answer restarts the churn clock (the escalation writes no `blocked` round and the resume
377
+ // re-enters the same numeric round). Cleared on re-open with the rest of the per-run state; NULL
378
+ // before any churn escalation.
379
+ churn_escalated_round: number | null;
373
380
  // Merge-protocol liveness (012_merge_protocol_attempt.sql): head commit last nudged by the
374
381
  // frugal-CI fresh-head-run remedy. A rebase changes the head and therefore permits a new nudge.
375
382
  fresh_head_run_head: string | null;
@@ -866,6 +873,11 @@ async function submitPrCritical(
866
873
  // normal, freshly-computed decision) rather than replaying the prior run's outcome.
867
874
  last_progress_job_key: null,
868
875
  last_progress_result: null,
876
+ // Clear the run-scoped churn-reset watermark (issue #870): it names a round number in the
877
+ // PRIOR convergence run's numbering. A fresh run restarts at round 1, so a carried-over
878
+ // watermark could drop the new run's early rounds from the churn window (or, after the new
879
+ // run passes that number, silently expire). Reset it so the new run's churn clock starts clean.
880
+ churn_escalated_round: null,
869
881
  outcome: null,
870
882
  converged_at: null,
871
883
  merged_at: null,
@@ -0,0 +1,12 @@
1
+ -- Run-scoped churn-reset watermark (issue #870). Records the round number at which the
2
+ -- non-converging same-surface churn guard last escalated for a human scope decision on THIS
3
+ -- convergence run. The churn escalation routes through `persist-escalation-noprogress` with
4
+ -- `recordRound=false` (it writes no `blocked` round) and the human-answer resume re-enters the SAME
5
+ -- numeric round (the round counter only advances at the review-wait gateway), so without this
6
+ -- watermark `detectChurn` (app/roundChurn.ts) would re-observe the identical trailing `addressed`
7
+ -- window the instant the resumed round is recorded and re-raise the SAME question forever. Dropping
8
+ -- every round at or before the watermark makes the human's decision restart the churn clock exactly
9
+ -- as a recorded `needs_input`/`blocked` round would. Nullable + no default: NULL means "no churn
10
+ -- escalation yet" (treated as 0 by the reader). Scoped to the run by `submitPr`, which clears it in
11
+ -- the atomic reopen reset alongside the other per-run baselines.
12
+ ALTER TABLE pull_requests ADD COLUMN churn_escalated_round INTEGER;
@@ -0,0 +1,55 @@
1
+ -- Re-create the `pull_requests_read_model` VIEW so it re-exports the base column added by
2
+ -- 117_pull_requests_churn_escalated_round.sql (`churn_escalated_round`). The read-model VIEW must
3
+ -- pass through EVERY base `pull_requests` column (the static pages↔schema contract guard +
4
+ -- app/pullRequestReadModel.test.ts DRIFT GUARD assert it), so adding a base column obliges a fresh
5
+ -- VIEW definition — 094/104 are immutable and cannot be edited in place.
6
+ --
7
+ -- Every DERIVED column below is emitted VERBATIM from the ONE declaration in
8
+ -- app/pullRequestReadModel.ts (`pullRequestReadModel.sqlSelectFor(col, { baseAlias: "pr" })`) — the
9
+ -- same closed-DSL AST that drives the runtime TS via `fnFor`, so the two lowerings cannot diverge.
10
+ -- This file is a mechanical re-emission of 104 with the one new base pass-through added; the
11
+ -- derived `list_bucket`/`ack_open` expressions are unchanged. The drift guard now points at THIS
12
+ -- migration (the latest VIEW definition).
13
+ --
14
+ -- Forward-only VIEW definition (DROP then CREATE), sourced off the managed `pull_requests__tracking`
15
+ -- re-export of the base table so a terminated PR classifies on ENGINE TRUTH. SQLite does not validate
16
+ -- a view body at CREATE time. The runner wraps each file in its own transaction, so this file must
17
+ -- NOT contain BEGIN/COMMIT. Numbered after 117.
18
+
19
+ DROP VIEW IF EXISTS pull_requests_read_model;
20
+
21
+ CREATE VIEW pull_requests_read_model AS
22
+ SELECT
23
+ pr.pr_key AS pr_key,
24
+ pr.repo AS repo,
25
+ pr.number AS number,
26
+ pr.url AS url,
27
+ pr.title AS title,
28
+ COALESCE(pr.derived_status, pr.status) AS status,
29
+ pr.current_round AS current_round,
30
+ pr.process_key AS process_key,
31
+ pr.waiting_since AS waiting_since,
32
+ pr.last_review_id AS last_review_id,
33
+ pr.outcome AS outcome,
34
+ pr.created_at AS created_at,
35
+ pr.updated_at AS updated_at,
36
+ pr.converged_at AS converged_at,
37
+ pr.merged_at AS merged_at,
38
+ pr.active_worker AS active_worker,
39
+ pr.lease_until AS lease_until,
40
+ pr.last_nudge_at AS last_nudge_at,
41
+ pr.fresh_head_run_head AS fresh_head_run_head,
42
+ pr.abandon_token AS abandon_token,
43
+ pr.incident_key AS incident_key,
44
+ pr.incident_message AS incident_message,
45
+ pr.last_round_head AS last_round_head,
46
+ pr.last_progress_job_key AS last_progress_job_key,
47
+ pr.last_progress_result AS last_progress_result,
48
+ pr.last_progress_agent_watermark AS last_progress_agent_watermark,
49
+ pr.churn_escalated_round AS churn_escalated_round,
50
+ pr.root_request_key AS root_request_key,
51
+ pr.epic_phase_label AS epic_phase_label,
52
+ pr.acknowledged_at AS acknowledged_at,
53
+ CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket,
54
+ CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND (NOT COALESCE(COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0), 0))), 0) THEN 1 ELSE 0 END AS ack_open
55
+ FROM pull_requests__tracking pr;