@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.
- package/CHANGELOG.md +16 -0
- package/SPEC.md +35 -0
- package/app/deliveryGraph.test.ts +10 -0
- package/app/deliveryGraphCompiler.test.ts +354 -10
- package/app/deliveryGraphCompiler.ts +296 -40
- package/app/deliveryRunner.ts +14 -5
- package/app/implementEscalationReason.test.ts +216 -0
- package/app/implementEscalationReason.ts +213 -0
- package/app/implementReconcile.test.ts +168 -11
- package/app/implementReconcile.ts +112 -18
- package/app/pullRequestReadModel.test.ts +1 -1
- package/app/roundChurn.test.ts +388 -0
- package/app/roundChurn.ts +265 -0
- package/app/roundProgress.test.ts +224 -1
- package/app/service.test.ts +7 -0
- package/app/service.ts +12 -0
- package/db/migrations/117_pull_requests_churn_escalated_round.sql +12 -0
- package/db/migrations/118_pull_requests_read_model_churn_escalated_round.sql +55 -0
- package/docs/agent-guide.md +10 -7
- package/e2e/delivery-graph.e2e.ts +570 -1
- package/e2e/feature-run.e2e.ts +8 -1
- package/e2e/plan-fanout.e2e.ts +75 -2
- package/package.json +1 -1
- package/resources/processes/implement-cell.bpmn +42 -17
- package/resources/processes/plan-fanout.bpmn +236 -211
- package/resources/prompts/feature.md +16 -1
- package/workers/progress-check/worker.ts +98 -1
- package/workers/readiness-probe/worker.test.ts +72 -1
- package/workers/readiness-probe/worker.ts +39 -0
- package/workers/reconcile-implement/worker.test.ts +31 -3
- package/workers/reconcile-implement/worker.ts +5 -0
- package/workers/record-feature-escalation/worker.test.ts +58 -3
- 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.
|
package/app/service.test.ts
CHANGED
|
@@ -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;
|