@nanobpm/nano-workforce 0.187.4 → 0.187.6

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 (39) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/SPEC.md +93 -7
  3. package/app/convergeGate.test.ts +161 -4
  4. package/app/convergenceEscalationGuard.test.ts +3 -2
  5. package/app/currentHead.ts +60 -0
  6. package/app/github.test.ts +189 -1
  7. package/app/github.ts +134 -27
  8. package/app/persist-escalation.test.ts +7 -5
  9. package/app/persist-round.test.ts +178 -11
  10. package/app/pollReviewsStale.test.ts +187 -0
  11. package/app/pullRequestReadModel.test.ts +1 -1
  12. package/app/reviewWait.test.ts +33 -0
  13. package/app/reviewWait.ts +21 -0
  14. package/app/roundProgress.test.ts +755 -33
  15. package/app/roundProgress.ts +144 -0
  16. package/app/roundResultDefault.test.ts +10 -8
  17. package/app/service.test.ts +14 -0
  18. package/app/service.ts +72 -2
  19. package/db/migrations/102_rounds_process_instance_key.sql +28 -0
  20. package/db/migrations/103_pr_progress_idempotency.sql +29 -0
  21. package/db/migrations/104_pull_requests_read_model_progress_idempotency.sql +55 -0
  22. package/e2e/convergence-escalation.e2e.ts +5 -4
  23. package/e2e/feature-run.e2e.ts +6 -1
  24. package/e2e/plan-fanout-sla.e2e.ts +5 -2
  25. package/e2e/plan-fanout.e2e.ts +6 -2
  26. package/e2e/support/time.ts +34 -0
  27. package/nano.app.json +4 -0
  28. package/package.json +1 -1
  29. package/resources/processes/convergence-loop.bpmn +238 -148
  30. package/resources/prompts/review-round.md +10 -0
  31. package/test/derivation-parity/README.md +3 -3
  32. package/test/derivation-parity/derivation-parity.test.ts +9 -3
  33. package/test/derivation-parity/flows.ts +4 -4
  34. package/workers/capture-head/worker.test.ts +77 -0
  35. package/workers/capture-head/worker.ts +64 -0
  36. package/workers/converge-gate/worker.ts +48 -21
  37. package/workers/persist-escalation/worker.ts +4 -0
  38. package/workers/persist-round/worker.ts +75 -9
  39. package/workers/progress-check/worker.ts +359 -38
package/app/github.ts CHANGED
@@ -23,6 +23,10 @@ export interface GhReview {
23
23
  id: number;
24
24
  state: string;
25
25
  submitted_at?: string;
26
+ /** The commit SHA the review was submitted against (GitHub's `commit_id`). Used to detect a
27
+ * review that predates the PR's current HEAD — a STALE review whose advisories are about code the
28
+ * head has since moved past (issue #799). Absent on data GitHub did not carry a `commit_id` for. */
29
+ commit_id?: string | null;
26
30
  }
27
31
 
28
32
  export type GithubTransport = "gh" | "token" | "auto";
@@ -61,7 +65,12 @@ function isGhAvailable(): Promise<boolean> {
61
65
  }
62
66
 
63
67
  /** Fetch the reviews for one PR via the configured transport. Throws on transport failure so
64
- * the caller can log-and-continue; returns `null` when no transport is usable (idle). */
68
+ * the caller can log-and-continue; returns `null` when no transport is usable (idle). Pages the
69
+ * FULL (oldest→newest) reviews list — the poller picks the newest fresh review by id, so reading
70
+ * only the first `per_page=100` page would, on a >100-review convergence loop, surface the OLDEST
71
+ * 100 and miss the genuinely newest review (repeatedly nudging while a current-head review sits on a
72
+ * later page, or classifying an old review as stale). This mirrors {@link fetchLatestCopilotReview}'s
73
+ * paging so both readers agree on which review is newest. */
65
74
  export async function fetchPrReviews(
66
75
  repo: string,
67
76
  number: number | string,
@@ -71,17 +80,43 @@ export async function fetchPrReviews(
71
80
  const useGh = mode === "gh" || (mode === "auto" && (await isGhAvailable()));
72
81
  const path = `repos/${repo}/pulls/${number}/reviews?per_page=100`;
73
82
  if (useGh) {
74
- const out = await runGh(["api", path, "-H", "Accept: application/vnd.github+json"]);
83
+ // `--paginate --slurp` walks EVERY page of the (oldest→newest) reviews array, so a >100-review
84
+ // convergence loop still surfaces the genuinely newest review rather than the oldest 100. Plain
85
+ // `--paginate` concatenates one JSON array PER PAGE (multiple documents) which `JSON.parse`
86
+ // cannot read; `--slurp` wraps the pages in an outer array we flatten one level (mirrors
87
+ // {@link githubReleasesCommand}/{@link parseReleases}).
88
+ const out = await runGh([
89
+ "api", "--paginate", "--slurp", path, "-H", "Accept: application/vnd.github+json",
90
+ ]);
75
91
  // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
76
- return JSON.parse(out) as GhReview[];
92
+ return (JSON.parse(out) as GhReview[][]).flat();
77
93
  }
78
94
  if (!token) return null; // token mode with no token → poller idles
79
- const r = await fetch(`https://api.github.com/${path}`, {
80
- headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
81
- });
82
- if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
83
- // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
84
- return (await r.json()) as GhReview[];
95
+ // Page the token transport the same way; 20×100 reviews is far past any real convergence loop, and
96
+ // a genuinely deeper history we can't reach is unverifiable → fail CLOSED (throw) rather than
97
+ // return a partial list the poller would treat as complete (selecting an older review, re-nudging).
98
+ const reviews: GhReview[] = [];
99
+ const MAX_PAGES = 20;
100
+ for (let page = 1; page <= MAX_PAGES; page++) {
101
+ const r = await fetch(`https://api.github.com/${path}&page=${page}`, {
102
+ headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
103
+ });
104
+ if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
105
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
106
+ const batch = (await r.json()) as GhReview[];
107
+ reviews.push(...batch);
108
+ // A short final page means we've read every review — the list is complete.
109
+ if (batch.length < 100) return reviews;
110
+ // A full page on the last allowed page is only truncated if GitHub says there's more; trust the
111
+ // `Link` header's `rel="next"` (mirrors {@link fetchPrFiles}) so an exact multiple of 100 isn't a
112
+ // false positive, and throw when the cap genuinely truncates rather than under-reading history.
113
+ if (page === MAX_PAGES && /<[^>]*>;\s*rel="next"/.test(r.headers.get("link") ?? "")) {
114
+ throw new Error(
115
+ `github pr reviews truncated: ${repo}#${number} exceeds ${MAX_PAGES * 100}-review paging cap`,
116
+ );
117
+ }
118
+ }
119
+ return reviews;
85
120
  }
86
121
 
87
122
  // ── Review-comment convergence gate (don't converge with unaddressed comments) ──────────────
@@ -279,36 +314,73 @@ export function pickLatestCopilotReviewBody(
279
314
  reviews: { user?: { login?: string }; body?: string }[],
280
315
  truncated: boolean,
281
316
  ): string | null {
317
+ const picked = pickLatestCopilotReview(reviews, truncated);
318
+ return picked === null ? null : picked.body;
319
+ }
320
+
321
+ /** Pick the newest Copilot review — body AND the commit SHA it was submitted against — from a
322
+ * reviews list (GitHub returns them oldest→newest). Semantics mirror {@link pickLatestCopilotReviewBody}
323
+ * exactly: `truncated = true` fails CLOSED (`null`, unverifiable); a verified-complete read with no
324
+ * Copilot review returns `{ body: "", commitId: null }` (a verified "no advisories"). The `commitId`
325
+ * lets a caller detect a review that predates the PR's current HEAD — a STALE review whose advisories
326
+ * are about code the head has since moved past (issue #799). Pure; unit-tested. */
327
+ export function pickLatestCopilotReview(
328
+ reviews: { user?: { login?: string }; body?: string; commit_id?: string | null }[],
329
+ truncated: boolean,
330
+ ): { body: string; commitId: string | null } | null {
282
331
  if (truncated) return null;
283
332
  const copilot = reviews.filter((rv) => isCopilot(rv.user?.login));
284
- return copilot[copilot.length - 1]?.body ?? "";
333
+ const latest = copilot[copilot.length - 1];
334
+ return { body: latest?.body ?? "", commitId: latest?.commit_id ?? null };
285
335
  }
286
336
 
287
337
  /** Fetch the latest Copilot review body for a PR (the newest review authored by the automated
288
338
  * Copilot reviewer). Returns `null` ONLY when no transport is usable (unverifiable → the worker
289
339
  * fails closed); returns `""` when transport is usable but the PR has no Copilot review yet (a
290
340
  * verified "no suppressed advisories"). Throws on a genuine transport failure. This split keeps
291
- * `null` from conflating "unverifiable" with "empty" and fail-OPENing the advisory dimension. */
341
+ * `null` from conflating "unverifiable" with "empty" and fail-OPENing the advisory dimension.
342
+ * Thin wrapper over {@link fetchLatestCopilotReview} (the single fetch implementation). */
292
343
  export async function fetchLatestCopilotReviewBody(
293
344
  repo: string,
294
345
  number: number | string,
295
346
  token: string,
296
347
  ): Promise<string | null> {
348
+ const picked = await fetchLatestCopilotReview(repo, number, token);
349
+ return picked === null ? null : picked.body;
350
+ }
351
+
352
+ /** Fetch the latest Copilot review — body AND the commit SHA it was submitted against — for a PR.
353
+ * Same null/`""`-vs-unverifiable semantics as {@link fetchLatestCopilotReviewBody} (which delegates
354
+ * here): `null` ONLY when no transport is usable (unverifiable → fail closed); a verified read with
355
+ * no Copilot review yet returns `{ body: "", commitId: null }`. The `commitId` lets the convergence
356
+ * gate detect a review that predates the PR's current HEAD — a STALE review whose advisories are
357
+ * about code the head has since moved past (issue #799) — and re-solicit a fresh review rather than
358
+ * block/escalate against the obsolete body. Throws on a genuine transport failure. */
359
+ export async function fetchLatestCopilotReview(
360
+ repo: string,
361
+ number: number | string,
362
+ token: string,
363
+ ): Promise<{ body: string; commitId: string | null } | null> {
297
364
  const mode = githubTransport();
298
365
  const useGh = mode === "gh" || (mode === "auto" && (await isGhAvailable()));
299
366
  const basePath = `repos/${repo}/pulls/${number}/reviews?per_page=100`;
300
367
  interface Review {
301
368
  user?: { login?: string };
302
369
  body?: string;
370
+ commit_id?: string | null;
303
371
  }
304
372
  if (useGh) {
305
- // `--paginate` merges EVERY page of the (oldest→newest) reviews array, so a >100-review
373
+ // `--paginate --slurp` walks EVERY page of the (oldest→newest) reviews array, so a >100-review
306
374
  // convergence loop still surfaces the genuinely newest Copilot review rather than the oldest
307
- // 100 — reading only the first page here would fail-OPEN the advisory dimension.
308
- const out = await runGh(["api", "--paginate", basePath, "-H", "Accept: application/vnd.github+json"]);
375
+ // 100 — reading only the first page here would fail-OPEN the advisory dimension. Plain
376
+ // `--paginate` concatenates one JSON array PER PAGE (multiple documents) which `JSON.parse`
377
+ // cannot read; `--slurp` wraps the pages in an outer array we flatten one level.
378
+ const out = await runGh([
379
+ "api", "--paginate", "--slurp", basePath, "-H", "Accept: application/vnd.github+json",
380
+ ]);
309
381
  // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
310
- const reviews = JSON.parse(out) as Review[];
311
- return pickLatestCopilotReviewBody(reviews, false);
382
+ const reviews = (JSON.parse(out) as Review[][]).flat();
383
+ return pickLatestCopilotReview(reviews, false);
312
384
  }
313
385
  if (!token) return null;
314
386
  // Page the token transport the same way; 20×100 reviews is far past any real convergence loop, and
@@ -324,14 +396,14 @@ export async function fetchLatestCopilotReviewBody(
324
396
  const batch = (await r.json()) as Review[];
325
397
  reviews.push(...batch);
326
398
  // A short page means we've read every review — the list is complete.
327
- if (batch.length < 100) return pickLatestCopilotReviewBody(reviews, false);
399
+ if (batch.length < 100) return pickLatestCopilotReview(reviews, false);
328
400
  // A full page on the last allowed page is only truncated if GitHub says there's more; trust the
329
401
  // `Link` header's `rel="next"` so an exact multiple of 100 isn't a false positive.
330
402
  if (page === MAX_PAGES && /<[^>]*>;\s*rel="next"/.test(r.headers.get("link") ?? "")) {
331
- return pickLatestCopilotReviewBody(reviews, true);
403
+ return pickLatestCopilotReview(reviews, true);
332
404
  }
333
405
  }
334
- return pickLatestCopilotReviewBody(reviews, false);
406
+ return pickLatestCopilotReview(reviews, false);
335
407
  }
336
408
 
337
409
  /** Raw GraphQL response shape for the review-threads query. */
@@ -1067,17 +1139,25 @@ export async function fetchPrFiles(
1067
1139
  return paths;
1068
1140
  }
1069
1141
 
1070
- /** The PR head ref/sha for D3's trial-merge gate. `null` when no transport is usable. */
1142
+ /** The PR head ref/sha for D3's trial-merge gate. `null` when no transport is usable. `headRepo` is
1143
+ * the head branch's OWNING repository as `owner/repo` — the FORK for a cross-repo PR, else the base
1144
+ * repo — so a caller that resolves the head ref (e.g. the no-progress head reader, #786) queries the
1145
+ * repository the head branch actually lives in, not the base repo (where a same-named branch would
1146
+ * resolve to an unrelated SHA). `null` when the head repository cannot be resolved (e.g. a deleted
1147
+ * fork). */
1071
1148
  export async function fetchPrHead(
1072
1149
  repo: string,
1073
1150
  number: number | string,
1074
1151
  token: string,
1075
- ): Promise<{ headRef: string | null; headSha: string | null; baseRef: string | null } | null> {
1152
+ ): Promise<{ headRef: string | null; headSha: string | null; baseRef: string | null; headRepo: string | null } | null> {
1076
1153
  if (await useGh()) {
1077
- const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "headRefName,headRefOid,baseRefName"]);
1154
+ const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "headRefName,headRefOid,baseRefName,headRepository,headRepositoryOwner"]);
1078
1155
  // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
1079
- const j = JSON.parse(out) as { headRefName?: string | null; headRefOid?: string | null; baseRefName?: string | null };
1080
- return { headRef: j.headRefName ?? null, headSha: j.headRefOid ?? null, baseRef: j.baseRefName ?? null };
1156
+ const j = JSON.parse(out) as { headRefName?: string | null; headRefOid?: string | null; baseRefName?: string | null; headRepository?: { name?: string | null } | null; headRepositoryOwner?: { login?: string | null } | null };
1157
+ const owner = j.headRepositoryOwner?.login;
1158
+ const name = j.headRepository?.name;
1159
+ const headRepo = owner && name ? `${owner}/${name}` : null;
1160
+ return { headRef: j.headRefName ?? null, headSha: j.headRefOid ?? null, baseRef: j.baseRefName ?? null, headRepo };
1081
1161
  }
1082
1162
  if (!token) return null;
1083
1163
  const r = await fetch(`https://api.github.com/repos/${repo}/pulls/${number}`, {
@@ -1085,8 +1165,29 @@ export async function fetchPrHead(
1085
1165
  });
1086
1166
  if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
1087
1167
  // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
1088
- const j = (await r.json()) as { head?: { ref?: string | null; sha?: string | null }; base?: { ref?: string | null } };
1089
- return { headRef: j.head?.ref ?? null, headSha: j.head?.sha ?? null, baseRef: j.base?.ref ?? null };
1168
+ const j = (await r.json()) as { head?: { ref?: string | null; sha?: string | null; repo?: { full_name?: string | null } | null }; base?: { ref?: string | null } };
1169
+ return { headRef: j.head?.ref ?? null, headSha: j.head?.sha ?? null, baseRef: j.base?.ref ?? null, headRepo: j.head?.repo?.full_name ?? null };
1170
+ }
1171
+
1172
+ /** The head commit SHA of `branch` on `repo`, read from the git-ref endpoint
1173
+ * (`git/ref/heads/<branch>`) — the ref that GitHub updates ATOMICALLY with the push, unlike a PR
1174
+ * object's `head.sha`, which is an asynchronously-denormalized projection that can briefly report a
1175
+ * stale-but-valid SHA after a push. The no-progress guard (#786) reads this in preference to the PR
1176
+ * head so a lagging PR denormalization can never fabricate a no-advance escalation. `null` when the
1177
+ * branch does not exist (a 404) or no transport is usable; throws only on a genuine transport
1178
+ * failure. */
1179
+ export async function fetchBranchHead(
1180
+ repo: string,
1181
+ branch: string,
1182
+ token: string,
1183
+ ): Promise<string | null> {
1184
+ // Honor the documented no-transport contract at this public boundary, exactly like the sibling
1185
+ // readers `fetchPrHead`/`fetchPrBase`: with no `gh` CLI and no token there is no usable transport,
1186
+ // which is the idle "unknown" case → `null`, NOT an exception. The internal `branchHeadSha` still
1187
+ // throws in that case for `ensureBaseBranch`'s callers, which treat a missing transport as a hard
1188
+ // failure; this wrapper's `Promise<string | null>` contract promises `null` instead.
1189
+ if (!(await useGh()) && !token) return null;
1190
+ return branchHeadSha(repo, branch, token);
1090
1191
  }
1091
1192
 
1092
1193
  /** The PR's current base branch ref — the branch this PR would land *into*. `null` when no
@@ -1483,7 +1584,13 @@ function isEpicBranch(branch: string): boolean {
1483
1584
  /** Resolve the head commit SHA of `branch` on `repo`, or `null` when the branch does not exist
1484
1585
  * (a 404 from the git-ref endpoint). Throws only on a genuine transport failure. */
1485
1586
  async function branchHeadSha(repo: string, branch: string, token: string): Promise<string | null> {
1486
- const apiPath = `repos/${repo}/git/ref/heads/${branch}`;
1587
+ // Percent-encode each ref SEGMENT (git permits `#`, `?`, spaces, etc. in a branch name) while
1588
+ // preserving the `/` separators that git uses for hierarchical refs (`feat/x`). Interpolating the
1589
+ // raw name would, in the direct `fetch` URL, let a `#` start a fragment (and `?` a query) — the
1590
+ // path is truncated, the wrong ref (or a 404) is read, and the no-progress guard fails open. gh
1591
+ // api receives the same already-encoded path.
1592
+ const encodedBranch = branch.split("/").map(encodeURIComponent).join("/");
1593
+ const apiPath = `repos/${repo}/git/ref/heads/${encodedBranch}`;
1487
1594
  if (await useGh()) {
1488
1595
  try {
1489
1596
  const out = await runGh(["api", apiPath]);
@@ -4,8 +4,9 @@
4
4
  // `addressed` row for this `round`. Re-inserting a `rounds` row there would record one round as
5
5
  // both `addressed` and `blocked`, making round history/UI ambiguous. The stalled arm therefore
6
6
  // passes `recordRound=false`, which must suppress the round insert while still opening the
7
- // escalation. The agent-raised / max-rounds arms omit the flag (no prior round row) and must
8
- // still record the round.
7
+ // escalation. After #786/#789 the max-rounds arm ALSO runs after `persist-round` (the round-cap
8
+ // guard moved downstream of progress classification) and likewise passes `recordRound=false`; an
9
+ // agent-raised arm with no prior round row omits the flag and must still record the round.
9
10
  import { test } from "node:test";
10
11
  import { assertEquals } from "#test-assert";
11
12
  import handler from "../workers/persist-escalation/worker.ts";
@@ -49,9 +50,9 @@ test("stalled arm (recordRound=false) does not insert a duplicate rounds row", a
49
50
  assertEquals((out as any).escalationId, 42);
50
51
  });
51
52
 
52
- test("escalation arm without the flag still records the round", async () => {
53
+ test("an agent-raised arm without the flag still records the round", async () => {
53
54
  const { app, inserts } = fakeApp();
54
- const job = { variables: { prKey: "o/r#1", round: 3, status: "blocked", question: "max rounds" } };
55
+ const job = { variables: { prKey: "o/r#1", round: 3, status: "blocked", question: "needs input" } };
55
56
  await handler(job as any, app as any);
56
57
  assertEquals(inserts.rounds.length, 1);
57
58
  assertEquals((inserts.rounds[0] as any).round_no, 3);
@@ -183,7 +184,8 @@ test("persist-escalation heals from the prKey when repo/prNumber are absent", as
183
184
 
184
185
  // #333 — the control-flow escalation arms (no-progress / review-stalled / unaddressed-comments /
185
186
  // max-rounds) each set an explicit `status="blocked"` + a concrete `question` via `zeebe:input`
186
- // (recordRound=false for the three that run after `persist-round`). They now route through
187
+ // (recordRound=false on every arm that runs after `persist-round` which, after #786/#789 moved the
188
+ // round-cap guard downstream of progress classification, now INCLUDES max-rounds). They route through
187
189
  // `gw-escalated`, which branches on the worker's `escalated` output. This pins the contract that
188
190
  // gateway depends on: a control-flow arm with a real question OPENS an escalation and returns
189
191
  // `escalated:true` + the (trimmed) question, so gw-escalated parks a wait carrying that question —
@@ -1,11 +1,12 @@
1
- // Red/green regression for pr.persist-round's round recording + parking behaviour.
1
+ // Red/green regression for pr.persist-round's round recording behaviour.
2
2
  //
3
3
  // The convergence loop routes both `addressed` (the agent pushed changes) and the new `waiting`
4
4
  // (nothing to triage yet — round 1, awaiting the first review) statuses through gw-guard into
5
- // persist-round. Both must be recorded in `rounds` under their own status and both must park the
6
- // PR in `waiting_review` so the deterministic poller (app/service.ts) starts soliciting a review.
7
- // A `waiting` round is what replaced the old failure mode where an agent with nothing to do
8
- // re-requested the review destructively and escalated `blocked`.
5
+ // persist-round. Both must be recorded in `rounds` under their own status and both must advance the
6
+ // PR's `current_round`. The PARK into `waiting_review` is owned by the downstream pr.progress-check
7
+ // step (the single writer of the post-round wait status), NOT persist-round persist-round runs
8
+ // before the husk decision, so parking here would let the poller fire a spurious review re-request
9
+ // against a husk-retry round before progress-check resolves it (#786).
9
10
  import { test } from "node:test";
10
11
  import { assertEquals } from "#test-assert";
11
12
  import handler from "../workers/persist-round/worker.ts";
@@ -14,6 +15,7 @@ function fakeApp() {
14
15
  const inserts: Record<string, unknown[]> = { rounds: [] };
15
16
  const updates: Record<string, unknown[]> = { pull_requests: [] };
16
17
  const rows: Record<string, Map<string, unknown>> = {};
18
+ let roundsId = 0;
17
19
  const app = {
18
20
  data: {
19
21
  table(name: string, _key: string) {
@@ -22,14 +24,25 @@ function fakeApp() {
22
24
  async get(key: string) {
23
25
  return store.get(key);
24
26
  },
27
+ async find(criteria: Record<string, unknown>) {
28
+ return [...store.values()].filter((r) =>
29
+ Object.entries(criteria).every(([k, v]) => (r as any)[k] === v),
30
+ );
31
+ },
25
32
  async insert(row: unknown) {
26
- (inserts[name] ??= []).push(row);
27
33
  const pk = name === "rounds" ? "id" : "pr_key";
34
+ // The rounds table has an AUTOINCREMENT id; mint one so find/update can key on it.
35
+ if (name === "rounds" && (row as any).id === undefined) {
36
+ (row as any).id = ++roundsId;
37
+ }
38
+ (inserts[name] ??= []).push(row);
28
39
  store.set((row as any)[pk], row);
29
40
  return 1;
30
41
  },
31
- async update(key: string, patch: unknown) {
42
+ async update(key: string, patch: Record<string, unknown>) {
32
43
  (updates[name] ??= []).push({ key, patch });
44
+ const existing = store.get(key);
45
+ if (existing) store.set(key, { ...(existing as object), ...patch });
33
46
  },
34
47
  };
35
48
  },
@@ -39,7 +52,7 @@ function fakeApp() {
39
52
  }
40
53
 
41
54
  for (const status of ["addressed", "waiting"]) {
42
- test(`persist-round records a '${status}' round and parks the PR in waiting_review`, async () => {
55
+ test(`persist-round records a '${status}' round and advances current_round without parking`, async () => {
43
56
  const { app, inserts, updates } = fakeApp();
44
57
  const job = { variables: { prKey: "o/r#1", round: 1, status, summary: `round was ${status}` } };
45
58
  await handler(job as any, app as any);
@@ -51,7 +64,11 @@ for (const status of ["addressed", "waiting"]) {
51
64
 
52
65
  assertEquals(updates.pull_requests!.length, 1, "the PR is updated once");
53
66
  const patch = (updates.pull_requests![0] as any).patch;
54
- assertEquals(patch.status, "waiting_review", "the PR parks in waiting_review for the poller");
67
+ // The park into `waiting_review` is owned by pr.progress-check (the single writer of the
68
+ // post-round wait status), NOT persist-round — persist-round runs before the husk decision, so
69
+ // parking here would race the poller against a husk retry (#786). It only advances the round.
70
+ assertEquals(patch.status, undefined, "persist-round does NOT park the PR in waiting_review");
71
+ assertEquals(patch.waiting_since, undefined, "persist-round does NOT stamp the review-wait start");
55
72
  assertEquals(patch.current_round, 1);
56
73
  });
57
74
  }
@@ -97,9 +114,11 @@ test("persist-round heals a missing pull_requests parent before recording the ro
97
114
  assertEquals(healed.status, "converging", "the healed parent starts in the converging aggregate");
98
115
  assertEquals(healed.url, "https://github.com/o/r/pull/7", "URL is derived canonically");
99
116
  assertEquals(inserts.rounds.length, 1, "the round is still recorded after the heal");
100
- // And the worker still parks the (now-present) PR in waiting_review as its final state.
117
+ // And the worker advances current_round on the (now-present) PR but does NOT park it in
118
+ // waiting_review (that is pr.progress-check's job now, #786).
101
119
  assertEquals(updates.pull_requests!.length, 1, "the PR is updated once after the heal");
102
- assertEquals((updates.pull_requests![0] as any).patch.status, "waiting_review");
120
+ assertEquals((updates.pull_requests![0] as any).patch.status, undefined, "no park in persist-round");
121
+ assertEquals((updates.pull_requests![0] as any).patch.current_round, 3);
103
122
  });
104
123
 
105
124
  // rather than writing a NULL status — the round history stays readable.
@@ -135,3 +154,151 @@ test("persist-round heals from the prKey when repo/prNumber are absent", async (
135
154
  "the running agent's abandon token is preserved from abandonUrl, not re-minted",
136
155
  );
137
156
  });
157
+
158
+ // Idempotent round recording (issue #786): a husk auto-retry re-enters `review-round` WITHOUT
159
+ // advancing the round counter, so pr.persist-round is reached again for the SAME (pr_key, round_no).
160
+ // The `rounds` table has no UNIQUE(pr_key, round_no), so the worker must UPSERT — update the existing
161
+ // row in place, never manufacture a duplicate history row that would corrupt the durable round
162
+ // history the cockpit and the no-progress guard both read.
163
+ test("persist-round is idempotent on (pr_key, round_no) — a retry updates, never duplicates", async () => {
164
+ const { app, inserts, updates } = fakeApp();
165
+ const first = { variables: { prKey: "o/r#1", round: 4, status: "addressed", summary: "first attempt" } };
166
+ await handler(first as any, app as any);
167
+ assertEquals(inserts.rounds.length, 1, "the first attempt inserts a round row");
168
+
169
+ // A husk retry: same round_no, a fresh summary/transcript.
170
+ const retry = { variables: { prKey: "o/r#1", round: 4, status: "addressed", summary: "retry attempt" } };
171
+ await handler(retry as any, app as any);
172
+ assertEquals(inserts.rounds.length, 1, "the retry does NOT insert a second round row");
173
+
174
+ const roundUpdate = (updates.rounds ?? []).at(-1) as any;
175
+ assertEquals(roundUpdate?.patch.summary, "retry attempt", "the retry updates the existing round in place");
176
+ assertEquals((inserts.rounds[0] as any).round_no, 4);
177
+ });
178
+
179
+ // Regression (issue #786): the idempotent upsert must reuse only a row THIS worker wrote — never an
180
+ // escalation row. On a needs_input/blocked escalation, pr.persist-escalation records a `rounds` row
181
+ // (status needs_input/blocked) for the SAME (pr_key, round_no); the human-answered resume re-enters
182
+ // that same numeric round and lands here. Blindly updating the newest matching row would overwrite
183
+ // the escalation row to `addressed`, ERASING the escalation attempt from the durable history. The
184
+ // resume must INSERT a fresh row so both the escalation and its resolution survive.
185
+ test("persist-round does NOT overwrite a same-round escalation row — it inserts the resumed attempt", async () => {
186
+ const { app, inserts, updates, rows } = fakeApp();
187
+ // Simulate pr.persist-escalation having recorded a needs_input round row for round 5.
188
+ const roundsStore = (rows.rounds ??= new Map());
189
+ roundsStore.set(101, {
190
+ id: 101,
191
+ pr_key: "o/r#1",
192
+ round_no: 5,
193
+ status: "needs_input",
194
+ summary: "escalated: which API shape?",
195
+ transcript: "escalation transcript",
196
+ started_at: "t0",
197
+ ended_at: "t0",
198
+ });
199
+
200
+ // The human answers; the same numeric round resumes and reaches persist-round as `addressed`.
201
+ const resume = { variables: { prKey: "o/r#1", round: 5, status: "addressed", summary: "resumed and pushed" } };
202
+ await handler(resume as any, app as any);
203
+
204
+ assertEquals(inserts.rounds.length, 1, "the resumed attempt inserts a NEW round row");
205
+ assertEquals((inserts.rounds[0] as any).status, "addressed", "the new row is the addressed resume");
206
+ // The escalation row is untouched — never updated to `addressed`.
207
+ const escalationTouched = (updates.rounds ?? []).some((u: any) => u.key === 101);
208
+ assertEquals(escalationTouched, false, "the needs_input escalation row is preserved, not overwritten");
209
+ assertEquals((roundsStore.get(101) as any).status, "needs_input", "the escalation row keeps its status");
210
+ });
211
+
212
+ // But a genuine husk retry (a prior pr.persist-round row, status addressed/waiting) is still reused
213
+ // in place — only escalation rows are excluded, so idempotency for the retry path is preserved even
214
+ // when an escalation row for the same round also exists.
215
+ test("persist-round reuses a prior addressed round-record row while skipping an escalation row", async () => {
216
+ const { app, inserts, updates, rows } = fakeApp();
217
+ const roundsStore = (rows.rounds ??= new Map());
218
+ // An escalation row AND a prior persist-round row for the same round.
219
+ roundsStore.set(200, { id: 200, pr_key: "o/r#1", round_no: 6, status: "blocked", summary: "blocked earlier" });
220
+ roundsStore.set(201, { id: 201, pr_key: "o/r#1", round_no: 6, status: "addressed", summary: "first addressed" });
221
+
222
+ const retry = { variables: { prKey: "o/r#1", round: 6, status: "addressed", summary: "husk retry" } };
223
+ await handler(retry as any, app as any);
224
+
225
+ assertEquals(inserts.rounds.length, 0, "no new row — the prior addressed row is reused");
226
+ const roundUpdate = (updates.rounds ?? []).at(-1) as any;
227
+ assertEquals(roundUpdate?.key, 201, "the addressed round-record row is updated, not the blocked escalation row");
228
+ assertEquals(roundUpdate?.patch.summary, "husk retry");
229
+ assertEquals((roundsStore.get(200) as any).status, "blocked", "the escalation row is left intact");
230
+ });
231
+
232
+ // Regression (issue #786): the idempotent upsert must be scoped to the writing RUN, not inferred
233
+ // from status. `submitPr` re-opens a previously converged/abandoned PR at round 1 WITHOUT deleting
234
+ // `rounds` history, so a fresh convergence run (a NEW process instance) at round 1 finds the prior
235
+ // run's `addressed`/`waiting`/`converged` round-1 row. Reusing it (its status is not human-hold)
236
+ // would clobber another run's canonical summary/transcript/worker/timestamps. Scoping reuse by the
237
+ // writing `process_instance_key` means the new run INSERTS a fresh row and the prior run's history
238
+ // survives verbatim.
239
+ test("persist-round scopes idempotency to the process instance — a resubmission inserts a fresh row", async () => {
240
+ const { app, inserts, updates, rows } = fakeApp();
241
+ const roundsStore = (rows.rounds ??= new Map());
242
+ // A prior run's round-1 row (its own process instance) with real history.
243
+ roundsStore.set(300, {
244
+ id: 300,
245
+ pr_key: "o/r#1",
246
+ round_no: 1,
247
+ status: "converged",
248
+ summary: "prior run summary",
249
+ transcript: "prior run transcript",
250
+ worker: "senior",
251
+ process_instance_key: "proc-OLD",
252
+ started_at: "t0",
253
+ ended_at: "t0",
254
+ });
255
+
256
+ // A resubmission: submitPr restarts convergence at round 1 in a NEW process instance.
257
+ const resubmit = {
258
+ processInstanceKey: "proc-NEW",
259
+ variables: { prKey: "o/r#1", round: 1, status: "addressed", summary: "fresh run" },
260
+ };
261
+ await handler(resubmit as any, app as any);
262
+
263
+ assertEquals(inserts.rounds.length, 1, "the resubmission inserts its OWN round row");
264
+ assertEquals((inserts.rounds[0] as any).process_instance_key, "proc-NEW", "the new row carries the new run's key");
265
+ const priorTouched = (updates.rounds ?? []).some((u: any) => u.key === 300);
266
+ assertEquals(priorTouched, false, "the prior run's round-1 row is never updated");
267
+ assertEquals((roundsStore.get(300) as any).summary, "prior run summary", "the prior run's history is intact");
268
+ });
269
+
270
+ // But a husk retry WITHIN the same run (same process instance key, same round_no) is still reused in
271
+ // place — process-instance scoping preserves husk-retry idempotency, it does not disable it.
272
+ test("persist-round reuses the same-process-instance row on a husk retry", async () => {
273
+ const { app, inserts, updates, rows } = fakeApp();
274
+ const roundsStore = (rows.rounds ??= new Map());
275
+ // This run's own round-4 row, plus an UNRELATED prior run's round-4 row.
276
+ roundsStore.set(400, {
277
+ id: 400,
278
+ pr_key: "o/r#1",
279
+ round_no: 4,
280
+ status: "addressed",
281
+ summary: "other run",
282
+ process_instance_key: "proc-OTHER",
283
+ });
284
+ roundsStore.set(401, {
285
+ id: 401,
286
+ pr_key: "o/r#1",
287
+ round_no: 4,
288
+ status: "addressed",
289
+ summary: "this run first attempt",
290
+ process_instance_key: "proc-THIS",
291
+ });
292
+
293
+ const retry = {
294
+ processInstanceKey: "proc-THIS",
295
+ variables: { prKey: "o/r#1", round: 4, status: "addressed", summary: "this run husk retry" },
296
+ };
297
+ await handler(retry as any, app as any);
298
+
299
+ assertEquals(inserts.rounds.length, 0, "no new row — this run's own row is reused");
300
+ const roundUpdate = (updates.rounds ?? []).at(-1) as any;
301
+ assertEquals(roundUpdate?.key, 401, "the reused row is THIS run's row, not the other run's");
302
+ assertEquals(roundUpdate?.patch.summary, "this run husk retry");
303
+ assertEquals((roundsStore.get(400) as any).summary, "other run", "the unrelated run's row is untouched");
304
+ });