@nanobpm/nano-workforce 0.187.4 → 0.187.5

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 CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.187.5](https://github.com/nanobpm/nano-workforce/compare/v0.187.4...v0.187.5) (2026-09-15)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **convergence:** classify husked review rounds and bound-retry the no-progress loop ([#789](https://github.com/nanobpm/nano-workforce/issues/789)) ([faebcdc](https://github.com/nanobpm/nano-workforce/commit/faebcdcaca017fab8d7436a50e3dc09354dc3022)), closes [jwulf/c8ctl-plugin-nano#230](https://github.com/jwulf/c8ctl-plugin-nano/issues/230) [#786](https://github.com/nanobpm/nano-workforce/issues/786) [#786](https://github.com/nanobpm/nano-workforce/issues/786)
6
+
1
7
  ## [0.187.4](https://github.com/nanobpm/nano-workforce/compare/v0.187.3...v0.187.4) (2026-09-14)
2
8
 
3
9
  ### Bug Fixes
package/SPEC.md CHANGED
@@ -101,11 +101,17 @@ known at submit time, carried as a process variable and stored on the DB row.
101
101
  │ <gateway: status>
102
102
  │ ├── converged → [Mark converged] → (end: converged)
103
103
  │ │
104
- │ ├── addressed → [Record round] → <event-based gateway: review ready or timeout?>
105
- │ │ ├── readiness-ready (msg catch, key = prKey)round++ ─┐
106
- │ │ └── =reviewWaitTimeout (timer catch)
107
- │ │ [Escalate: review stalled] (blocked)
108
- │ │ [Wait: wait-answer userTask] ─────────────────────┤
104
+ │ ├── addressed → [Record round] → [Check progress] (did the PR head advance?)
105
+ │ │ ├── progressed <guard: round maxRoundsescalate "not converged"> │
106
+ │ │ │ → <event-based gateway: review ready or timeout?>
107
+ │ │ │ ├── readiness-ready (msg catch, key = prKey) → round++ ─┐
108
+ │ │ │ └── =reviewWaitTimeout (timer catch) │
109
+ │ │ │ → [Escalate: review stalled] (blocked) │
110
+ │ │ │ → [Wait: wait-answer userTask] ────────────────────┤
111
+ │ │ └── no progress → <husk? no commit AND no terminal instance> │
112
+ │ │ ├── husk & retries < MAX → re-enter [Review round] (bypasses the round-cap guard) │
113
+ │ │ └── no-advance / husk cap → [Escalate: no progress] │
114
+ │ │ → [Wait: wait-answer userTask] ───────────────────┤
109
115
  │ │ │
110
116
  │ └── needs_input [Record escalation] │ │
111
117
  │ or blocked → (kind = question | blocker) │ │
@@ -124,8 +130,11 @@ step, then retry the same round with the human's `answer`. They differ only by e
124
130
  which the UI uses to label the card. Neither ends the run — a human always gets
125
131
  a chance to unblock and resume.
126
132
 
127
- Guard: before each Review round, if round > MAX_ROUNDS → force an escalation
128
- ("not converged after N rounds") so a human decides, rather than looping forever.
133
+ Guard: after progress classification, a **progressing** round with round
134
+ MAX_ROUNDS forces an escalation ("not converged after N rounds") so a human
135
+ decides rather than looping forever. The guard sits *after* `check-progress`
136
+ (not before), so a husk auto-retry — which does not consume a round — bypasses
137
+ the cap and is re-tried onto a healthy worker even on the final configured round.
129
138
  ```
130
139
 
131
140
  Notes:
@@ -149,6 +158,59 @@ Notes:
149
158
  backstop when even repeated nudges fail.
150
159
  - On `needs_input`, the same `round` is retried after the answer (the answer is
151
160
  added to the agent's context; the round number does not advance).
161
+ - **No-progress guard + husk classification (issue #786).** Before the review
162
+ wait, an `addressed` round passes through `pr.progress-check`
163
+ (`workers/progress-check/worker.ts`, mirrored by `app/roundProgress.ts`): it
164
+ reads the PR's current head SHA (the branch ref, atomic with the push) and
165
+ compares it to the **round-entry head** — the head captured by `pr.capture-head`
166
+ immediately BEFORE `review-round` ran this round, published as the
167
+ `roundEntryHead` process variable. `pr.capture-head` sits on EVERY entry into
168
+ `review-round` (the first round from `Start`, a review-loop re-enter, a
169
+ human-answer resume, and a husk auto-retry), so within any round there is always
170
+ a baseline captured against the agent's own starting point — closing the
171
+ no-baseline gap where a FIRST addressed round had no prior-round head to compare
172
+ against (and either waved a first-round husk through as progress, or risked
173
+ mis-escalating a straggler push). If `roundEntryHead` is absent — an older
174
+ in-flight instance whose flow predates `capture-head`, or a capture read that
175
+ failed open (it publishes the empty string as its "unknown" sentinel) —
176
+ progress-check falls back to the head persisted from the previous round
177
+ (`last_round_head`). A round whose head DID advance past the round-entry baseline
178
+ is real progress and continues to the review-wait gateway. A round whose head did
179
+ NOT advance pushed no commit, so re-requesting a review would loop on
180
+ byte-identical code; `gw-progress` routes it to `gw-husk`, which SPLITS it on a
181
+ corroboration correlated to the COMPLETING `review-round` element-instance (NOT
182
+ an aggregate terminal count — a same-round human-answered resume is classified on
183
+ its own fresh attempt):
184
+ - a **husk** — no commit AND the completing `review-round` attempt is
185
+ NON-TERMINAL (the producer harness died mid-run, leaving a stuck instance) —
186
+ is auto-re-run onto a healthy worker up to `MAX_HUSK_RETRIES` (2) before
187
+ escalating; and
188
+ - a **no-advance** — the completing attempt ran to a terminal instance but
189
+ nothing was pushed — (and a husk that exhausts its retries) escalates to the
190
+ human `wait-answer` task.
191
+ The agent-instance read is AVAILABILITY-AWARE via a **two-tier probe** and fails
192
+ SAFE (ADR 0056). `review-round` is an external-agent service task, so a job that
193
+ husks BEFORE it ever registers an AgentInstance leaves the scoped `review-round`
194
+ search EMPTY — indistinguishable, on that query alone, from an engine that has no
195
+ AgentInstance projection at all. The probe therefore resolves an empty
196
+ `review-round` search against a SECOND, process-wide read:
197
+ - if the process-wide read also finds NO instance, the **channel is absent**
198
+ (an engine with no AgentInstance projection) — UNKNOWN, treated as no-advance,
199
+ never an auto-retry that could duplicate genuinely-completed work;
200
+ - if the process-wide read finds ANOTHER instance (from `classify-scope`, an
201
+ earlier round, etc.), the **channel is PRESENT** but this round registered
202
+ nothing — a genuine **pre-registration husk**, so it is classified as a husk
203
+ and auto-retried.
204
+ A head that cannot be read fails OPEN (continue), so a transient GitHub hiccup
205
+ never fabricates a no-progress escalation. Two supporting invariants keep an
206
+ auto-retry clean: `pr.persist-round`
207
+ records a round IDEMPOTENTLY on `(pr_key, round_no, process_instance_key)` — a husk
208
+ retry (same process instance) updates its row in place, while a resubmission that
209
+ re-opens the PR at round 1 in a NEW process instance inserts a fresh row and so
210
+ never clobbers a prior run's durable round history (migration 102) — and the guard flips the PR back to
211
+ the running `converging` status before a retry re-enters `review-round` so the
212
+ poller does not solicit a spurious review against the still-running round. The
213
+ round cap and the review-wait timeout remain the outer safety nets.
152
214
 
153
215
 
154
216
  ## 5. Agent job contract (`senior:pr-review`)
@@ -19,7 +19,8 @@
19
19
  //
20
20
  // The fix (mirroring the merge loop's `gw-merge-escalated`, PR #331): route EVERY arm that can reach
21
21
  // `wait-answer` through the single `gw-escalated` guard, so a `persist-escalation` returning
22
- // `escalated:false` RE-ENTERS the loop (`gw-guard`) instead of parking a dead wait. This makes the
22
+ // `escalated:false` RE-ENTERS the loop (round processing, `persist-round`) instead of parking a dead
23
+ // wait. This makes the
23
24
  // invariant structural — `wait-answer` is reachable ONLY from a `gw-escalated == true` edge, so a
24
25
  // "durable answer-wait with no escalation" is unrepresentable.
25
26
  //
@@ -77,7 +78,7 @@ test("gw-escalated honours persist-escalation's escalated output for every arm",
77
78
  assertStringIncludes(escWait![0], "escalated = true", "the wait arm must be guarded by escalated = true");
78
79
  // escalated:false (a non-escalation, e.g. a blank convergeBlockReason) → re-enter the loop, not a dead wait.
79
80
  assert(gatewayDefault("gw-escalated", "f_escReenter"), "gw-escalated default must re-enter the loop");
80
- assert(flowHasId("f_escReenter", "gw-escalated", "gw-guard"), "the non-escalation arm must re-enter via gw-guard, not park a wait");
81
+ assert(flowHasId("f_escReenter", "gw-escalated", "persist-round"), "the non-escalation arm must re-enter round processing (persist-round), not park a wait");
81
82
  });
82
83
 
83
84
  test("wait-answer is reachable ONLY from the gw-escalated == true edge (structural invariant)", () => {
@@ -3,7 +3,7 @@
3
3
  // the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
4
4
  import { test } from "node:test";
5
5
  import { assertEquals, assertRejects } from "#test-assert";
6
- import { BaseBranchMustExistError, checkConclusions, classifyMergeability, classifyPrLiveness, coalesceTitle, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchIssueTitle, fetchPrFiles, isNotAPullRequestError, listPrsForHead, type Mergeability, type PrState } from "./github.ts";
6
+ import { BaseBranchMustExistError, checkConclusions, classifyMergeability, classifyPrLiveness, coalesceTitle, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchBranchHead, fetchIssueTitle, fetchPrFiles, fetchPrHead, isNotAPullRequestError, listPrsForHead, type Mergeability, type PrState } from "./github.ts";
7
7
  import { DEFAULT_MERGE_PROTOCOL, type MergeProtocol, type RequiredCheck } from "./mergeProtocol.ts";
8
8
 
9
9
  // A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
@@ -723,3 +723,127 @@ test("checkConclusions: in-flight runs map to '' for both CheckRun and StatusCon
723
723
  "legacy-error": "ERROR",
724
724
  });
725
725
  });
726
+
727
+ // ── fetchBranchHead — the atomic branch-ref reader (issue #786) ──────────────
728
+ //
729
+ // The no-progress guard reads the branch ref (git/ref/heads/<branch>), updated ATOMICALLY with the
730
+ // push, rather than the PR object's asynchronously-denormalized head.sha, so a lagging PR projection
731
+ // can never fabricate a stale-but-valid no-advance escalation. Force the token transport and stub
732
+ // `globalThis.fetch` to serve the git-ref endpoint.
733
+ async function withRefFetch<T>(
734
+ serve: (path: string) => { status: number; body: unknown },
735
+ fn: () => Promise<T>,
736
+ ): Promise<T> {
737
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
738
+ const prevFetch = globalThis.fetch;
739
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
740
+ globalThis.fetch = ((url: string | URL | Request): Promise<Response> => {
741
+ const path = new URL(String(url)).pathname.replace(/^\/repos\//, "");
742
+ const { status, body } = serve(path);
743
+ return Promise.resolve(new Response(JSON.stringify(body), { status }));
744
+ }) as typeof fetch;
745
+ try {
746
+ return await fn();
747
+ } finally {
748
+ globalThis.fetch = prevFetch;
749
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
750
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
751
+ }
752
+ }
753
+
754
+ test("fetchBranchHead: returns the branch ref's atomic head SHA", async () => {
755
+ const sha = await withRefFetch(
756
+ (path) => {
757
+ assertEquals(path, "o/r/git/ref/heads/feat/x");
758
+ return { status: 200, body: { object: { sha: "deadbeef" } } };
759
+ },
760
+ () => fetchBranchHead("o/r", "feat/x", "tok"),
761
+ );
762
+ assertEquals(sha, "deadbeef");
763
+ });
764
+
765
+ test("fetchBranchHead: a 404 (branch absent) resolves to null, never throws", async () => {
766
+ const sha = await withRefFetch(
767
+ () => ({ status: 404, body: { message: "Not Found" } }),
768
+ () => fetchBranchHead("o/r", "feat/missing", "tok"),
769
+ );
770
+ assertEquals(sha, null);
771
+ });
772
+
773
+ test("fetchBranchHead: no usable transport (token mode, empty token) resolves to null, never throws", async () => {
774
+ // The documented contract promises `null` when no transport is usable, matching fetchPrHead /
775
+ // fetchPrBase — a missing token under the token transport must not surface an exception to callers
776
+ // relying on the Promise<string | null> shape.
777
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
778
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
779
+ try {
780
+ const sha = await fetchBranchHead("o/r", "feat/x", "");
781
+ assertEquals(sha, null);
782
+ } finally {
783
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
784
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
785
+ }
786
+ });
787
+
788
+ // ── fetchPrHead — the PR head reader surfaces the head branch's OWNING repo (issue #786) ─────
789
+ //
790
+ // The no-progress head reader resolves the head ref in `headRepo`, so a cross-repo (fork) PR reads
791
+ // the fork's ref, not a same-named branch in the base repo (which would resolve to an unrelated
792
+ // SHA). These assert the transport-level mapping of the source repository through `fetchPrHead`'s
793
+ // REST branch (forced via the token transport), which the handler-level tests — injecting an
794
+ // already-parsed `{ headRepo }` — do not exercise.
795
+ test("fetchPrHead: REST maps head.repo.full_name to the fork's source repository", async () => {
796
+ const head = await withRefFetch(
797
+ (path) => {
798
+ assertEquals(path, "base/repo/pulls/789");
799
+ return {
800
+ status: 200,
801
+ body: {
802
+ head: { ref: "feat/x", sha: "cafef00d", repo: { full_name: "fork-owner/repo" } },
803
+ base: { ref: "main" },
804
+ },
805
+ };
806
+ },
807
+ () => fetchPrHead("base/repo", 789, "tok"),
808
+ );
809
+ assertEquals(head, { headRef: "feat/x", headSha: "cafef00d", baseRef: "main", headRepo: "fork-owner/repo" });
810
+ });
811
+
812
+ test("fetchPrHead: REST fails open to headRepo=null when the head repo is absent (deleted fork)", async () => {
813
+ const head = await withRefFetch(
814
+ () => ({
815
+ status: 200,
816
+ body: { head: { ref: "feat/x", sha: "cafef00d", repo: null }, base: { ref: "main" } },
817
+ }),
818
+ () => fetchPrHead("base/repo", 789, "tok"),
819
+ );
820
+ // A null head repo must surface as headRepo=null (the reader then fails open), never the base repo.
821
+ assertEquals(head?.headRepo, null);
822
+ });
823
+
824
+ test("fetchPrHead: no usable transport (token mode, empty token) resolves to null, never throws", async () => {
825
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
826
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
827
+ try {
828
+ const head = await fetchPrHead("o/r", 1, "");
829
+ assertEquals(head, null);
830
+ } finally {
831
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
832
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
833
+ }
834
+ });
835
+
836
+ // A branch name may legally contain `#`, `?`, or spaces. The reader must percent-encode each ref
837
+ // SEGMENT (preserving `/`) before building the API path/URL — otherwise a `#` starts a URL fragment,
838
+ // the path is truncated to the wrong ref, and the no-progress guard fails open (issue #786).
839
+ test("fetchBranchHead: percent-encodes a special-character branch ref (preserving '/')", async () => {
840
+ const sha = await withRefFetch(
841
+ (path) => {
842
+ // The `#` must survive as %23 inside the path, not truncate it into a URL fragment.
843
+ assertEquals(path, "o/r/git/ref/heads/feat/x%23123");
844
+ return { status: 200, body: { object: { sha: "cafef00d" } } };
845
+ },
846
+ () => fetchBranchHead("o/r", "feat/x#123", "tok"),
847
+ );
848
+ assertEquals(sha, "cafef00d");
849
+ });
package/app/github.ts CHANGED
@@ -1067,17 +1067,25 @@ export async function fetchPrFiles(
1067
1067
  return paths;
1068
1068
  }
1069
1069
 
1070
- /** The PR head ref/sha for D3's trial-merge gate. `null` when no transport is usable. */
1070
+ /** The PR head ref/sha for D3's trial-merge gate. `null` when no transport is usable. `headRepo` is
1071
+ * the head branch's OWNING repository as `owner/repo` — the FORK for a cross-repo PR, else the base
1072
+ * repo — so a caller that resolves the head ref (e.g. the no-progress head reader, #786) queries the
1073
+ * repository the head branch actually lives in, not the base repo (where a same-named branch would
1074
+ * resolve to an unrelated SHA). `null` when the head repository cannot be resolved (e.g. a deleted
1075
+ * fork). */
1071
1076
  export async function fetchPrHead(
1072
1077
  repo: string,
1073
1078
  number: number | string,
1074
1079
  token: string,
1075
- ): Promise<{ headRef: string | null; headSha: string | null; baseRef: string | null } | null> {
1080
+ ): Promise<{ headRef: string | null; headSha: string | null; baseRef: string | null; headRepo: string | null } | null> {
1076
1081
  if (await useGh()) {
1077
- const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "headRefName,headRefOid,baseRefName"]);
1082
+ const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "headRefName,headRefOid,baseRefName,headRepository,headRepositoryOwner"]);
1078
1083
  // 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 };
1084
+ 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 };
1085
+ const owner = j.headRepositoryOwner?.login;
1086
+ const name = j.headRepository?.name;
1087
+ const headRepo = owner && name ? `${owner}/${name}` : null;
1088
+ return { headRef: j.headRefName ?? null, headSha: j.headRefOid ?? null, baseRef: j.baseRefName ?? null, headRepo };
1081
1089
  }
1082
1090
  if (!token) return null;
1083
1091
  const r = await fetch(`https://api.github.com/repos/${repo}/pulls/${number}`, {
@@ -1085,8 +1093,29 @@ export async function fetchPrHead(
1085
1093
  });
1086
1094
  if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
1087
1095
  // 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 };
1096
+ const j = (await r.json()) as { head?: { ref?: string | null; sha?: string | null; repo?: { full_name?: string | null } | null }; base?: { ref?: string | null } };
1097
+ return { headRef: j.head?.ref ?? null, headSha: j.head?.sha ?? null, baseRef: j.base?.ref ?? null, headRepo: j.head?.repo?.full_name ?? null };
1098
+ }
1099
+
1100
+ /** The head commit SHA of `branch` on `repo`, read from the git-ref endpoint
1101
+ * (`git/ref/heads/<branch>`) — the ref that GitHub updates ATOMICALLY with the push, unlike a PR
1102
+ * object's `head.sha`, which is an asynchronously-denormalized projection that can briefly report a
1103
+ * stale-but-valid SHA after a push. The no-progress guard (#786) reads this in preference to the PR
1104
+ * head so a lagging PR denormalization can never fabricate a no-advance escalation. `null` when the
1105
+ * branch does not exist (a 404) or no transport is usable; throws only on a genuine transport
1106
+ * failure. */
1107
+ export async function fetchBranchHead(
1108
+ repo: string,
1109
+ branch: string,
1110
+ token: string,
1111
+ ): Promise<string | null> {
1112
+ // Honor the documented no-transport contract at this public boundary, exactly like the sibling
1113
+ // readers `fetchPrHead`/`fetchPrBase`: with no `gh` CLI and no token there is no usable transport,
1114
+ // which is the idle "unknown" case → `null`, NOT an exception. The internal `branchHeadSha` still
1115
+ // throws in that case for `ensureBaseBranch`'s callers, which treat a missing transport as a hard
1116
+ // failure; this wrapper's `Promise<string | null>` contract promises `null` instead.
1117
+ if (!(await useGh()) && !token) return null;
1118
+ return branchHeadSha(repo, branch, token);
1090
1119
  }
1091
1120
 
1092
1121
  /** The PR's current base branch ref — the branch this PR would land *into*. `null` when no
@@ -1483,7 +1512,13 @@ function isEpicBranch(branch: string): boolean {
1483
1512
  /** Resolve the head commit SHA of `branch` on `repo`, or `null` when the branch does not exist
1484
1513
  * (a 404 from the git-ref endpoint). Throws only on a genuine transport failure. */
1485
1514
  async function branchHeadSha(repo: string, branch: string, token: string): Promise<string | null> {
1486
- const apiPath = `repos/${repo}/git/ref/heads/${branch}`;
1515
+ // Percent-encode each ref SEGMENT (git permits `#`, `?`, spaces, etc. in a branch name) while
1516
+ // preserving the `/` separators that git uses for hierarchical refs (`feat/x`). Interpolating the
1517
+ // raw name would, in the direct `fetch` URL, let a `#` start a fragment (and `?` a query) — the
1518
+ // path is truncated, the wrong ref (or a 404) is read, and the no-progress guard fails open. gh
1519
+ // api receives the same already-encoded path.
1520
+ const encodedBranch = branch.split("/").map(encodeURIComponent).join("/");
1521
+ const apiPath = `repos/${repo}/git/ref/heads/${encodedBranch}`;
1487
1522
  if (await useGh()) {
1488
1523
  try {
1489
1524
  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
+ });
@@ -33,7 +33,7 @@ import {
33
33
  const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
34
34
  const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
35
35
 
36
- const READ_MODEL_MIGRATION = "094_pull_requests_read_model.sql";
36
+ const READ_MODEL_MIGRATION = "104_pull_requests_read_model_progress_idempotency.sql";
37
37
 
38
38
  // The real base `pull_requests` columns, in schema order — DERIVED from the migration chain (not a
39
39
  // hand-kept list that could silently omit one), used by both the drift guard and the e2e stand-in.