@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
@@ -4,17 +4,21 @@
4
4
  // compares it to the head observed at the previous recorded round. An `addressed` round whose head
5
5
  // did NOT advance pushed no commit, so requesting another Copilot review would loop on
6
6
  // byte-identical code — the same comments, round after round, until the round cap escalates. It
7
- // returns `progressed:false`, and the model's `gw-progress` gateway escalates to the human
8
- // `wait-answer` task instead of soliciting another review.
7
+ // then classifies WHY (issue #786): a **husk** (no commit AND no terminal `review-round`
8
+ // agent-instance for the round the producer harness died mid-run, jwulf/c8ctl-plugin-nano#230) is
9
+ // auto-re-run onto a healthy worker up to a bound before escalating; a **no-advance** (the agent DID
10
+ // run to a terminal instance but pushed nothing) escalates to a human straight away. The routing
11
+ // decision lives in app/roundProgress.ts, the single source of truth this worker, `gw-progress`, and
12
+ // `gw-husk` all mirror.
9
13
  //
10
14
  // Every other case returns `progressed:true` (continue): a `waiting` round (round 1, awaiting the
11
15
  // first review) legitimately has no push; an advanced head means real work landed; and a head we
12
16
  // could not read fails OPEN — the round cap and the review-wait timeout stay the safety nets so a
13
- // transient GitHub hiccup can never fabricate a no-progress escalation. The routing decision lives
14
- // in app/roundProgress.ts, the single source of truth this worker and `gw-progress` both mirror.
15
- import type { AppJobHandler } from "@nanobpm/urban";
16
- import { fetchPrHead } from "../../app/github.ts";
17
- import { isAddressedStatus, routeProgress } from "../../app/roundProgress.ts";
17
+ // transient GitHub hiccup can never fabricate a no-progress escalation.
18
+ import type { AgentInstanceSummary, AppApi, AppJobHandler } from "@nanobpm/urban";
19
+ import { type HeadReader, makeDefaultReadHead } from "../../app/currentHead.ts";
20
+ import { fetchBranchHead, fetchPrHead } from "../../app/github.ts";
21
+ import { decideProgress, isAddressedStatus } from "../../app/roundProgress.ts";
18
22
  import { parsePr } from "../../app/service.ts";
19
23
  import type { WorkerInputs, WorkerOutputs } from "../../nano-generated/worker-io.d.ts";
20
24
 
@@ -23,53 +27,370 @@ import type { WorkerInputs, WorkerOutputs } from "../../nano-generated/worker-io
23
27
  type In = WorkerInputs["pr.progress-check"];
24
28
  type Out = WorkerOutputs["pr.progress-check"];
25
29
 
26
- // Reads a PR's current head SHA. Injectable so unit tests never touch git/network; the default
27
- // binds the real GitHub reader (the shared gh | token transport) and swallows any failure to
28
- // `null` so the guard fails OPEN.
29
- export type HeadReader = (repo: string, prNumber: number) => Promise<string | null>;
30
+ /** The outcome of an agent-work corroboration, optionally carrying the ATTEMPT WATERMARK it
31
+ * consumed. `work` is the husk verdict decideProgress routes on (`true` no-advance / `false` husk /
32
+ * `null` unknown). `consumedKey` — when present — is the greatest `review-round` instance key this
33
+ * read has now ACCOUNTED FOR; the handler persists it (`last_progress_agent_watermark`) so the NEXT
34
+ * round can tell a freshly-registered attempt from a historical one (see {@link agentWorkFromEngine}
35
+ * and Copilot PR #789). A bare `boolean | null` return (the injected test readers) carries no
36
+ * watermark and leaves the persisted one untouched. */
37
+ export interface AgentWorkObservation {
38
+ readonly work: boolean | null;
39
+ readonly consumedKey?: string | null;
40
+ }
41
+
42
+ // Corroborates whether a no-advance `addressed` round produced DURABLE agent work: `true` when the
43
+ // completing `review-round` element-instance ran to a terminal agent-instance (→ `no-advance`),
44
+ // `false` when the completing attempt husked — a non-terminal `review-round` instance is present, OR
45
+ // the completing attempt registered NO instance at all on a channel-present engine (→ `husk`),
46
+ // `null` when the read is UNAVAILABLE (the engine has no AgentInstance channel, or the read threw →
47
+ // `no-advance`, never an auto-retry). May return a bare verdict or an {@link AgentWorkObservation}
48
+ // that also carries the consumed attempt watermark. Injectable for tests; the default is
49
+ // availability- and attempt-aware over the engine's AgentInstance channel (see {@link
50
+ // agentWorkFromEngine}). `priorWatermark` is the greatest `review-round` instance key an earlier
51
+ // progress-check already accounted for, so a current pre-registration husk is not masked by a prior
52
+ // round's terminal instance. The `round` argument is retained for the reader contract but no longer
53
+ // participates in the decision — correlation is by the COMPLETING element-instance, not an aggregate
54
+ // round count (#786).
55
+ export type AgentWorkReader = (
56
+ processInstanceKey: string | null | undefined,
57
+ round: number,
58
+ priorWatermark?: string | null,
59
+ ) => Promise<boolean | null | AgentWorkObservation>;
60
+
61
+ /** Normalize a reader's return (a bare verdict, or a full {@link AgentWorkObservation}) to the
62
+ * observation shape the handler threads: an injected `boolean | null` carries no watermark. */
63
+ function normalizeAgentWork(raw: boolean | null | AgentWorkObservation | undefined): AgentWorkObservation {
64
+ if (raw === true || raw === false || raw === null || raw === undefined) {
65
+ return { work: raw ?? null };
66
+ }
67
+ return raw;
68
+ }
69
+
70
+ /** Re-exported from {@link ../../app/currentHead.ts} (the single canonical implementation of the
71
+ * branch-ref-over-stale-`head.sha` head reader, #786/#799) so existing importers of these symbols
72
+ * from this worker keep resolving without a duplicated copy — the poller in `app/service.ts` binds
73
+ * the same reader from `currentHead.ts` directly. */
74
+ export { type HeadReader, makeDefaultReadHead };
75
+
76
+ const defaultReadHead: HeadReader = makeDefaultReadHead({ fetchPrHead, fetchBranchHead });
77
+
78
+ /** An agent-instance is "durable work" for husk purposes once it has reached a terminal state — it
79
+ * carries a `completionDate`, or a terminal lifecycle status. A husked instance never closes (it is
80
+ * stuck `THINKING` with null dates — jwulf/c8ctl-plugin-nano#230), so it is NOT counted. */
81
+ function isTerminalInstance(s: AgentInstanceSummary): boolean {
82
+ if (typeof s.completionDate === "string" && s.completionDate.trim() !== "") return true;
83
+ return /^(completed|complete|done|finished|failed|terminated)$/i.test(s.status ?? "");
84
+ }
30
85
 
31
- const defaultReadHead: HeadReader = async (repo, prNumber) => {
32
- const token = process.env.GITHUB_TOKEN ?? "";
33
- const head = await fetchPrHead(repo, prNumber, token).catch(() => null);
34
- return head?.headSha ?? null;
35
- };
86
+ /** Parse a 64-bit engine key string to a `BigInt` for monotonic ordering; an absent/blank/malformed
87
+ * key sorts oldest (`0n`). Engine keys are 64-bit, so a numeric `parseInt`/`Number` compare would
88
+ * lose precision `BigInt` compares them exactly. */
89
+ function engineKey(s: string | undefined | null): bigint {
90
+ if (typeof s !== "string" || s.trim() === "") return 0n;
91
+ try {
92
+ return BigInt(s.trim());
93
+ } catch {
94
+ return 0n;
95
+ }
96
+ }
97
+
98
+ /** The monotonic recency key that orders an AgentInstance by creation: the GREATEST of its occupancy
99
+ * `elementInstanceKeys` (the `review-round` element-instance keys the engine mints per attempt),
100
+ * falling back to its `agentInstanceKey`. Engine keys increase with creation, so the instance with
101
+ * the greatest recency key is the most-recently-created — the COMPLETING attempt in the
102
+ * single-threaded review-round loop. */
103
+ function recencyKey(s: AgentInstanceSummary): bigint {
104
+ let max = engineKey(s.agentInstanceKey);
105
+ for (const k of s.elementInstanceKeys ?? []) {
106
+ const v = engineKey(k);
107
+ if (v > max) max = v;
108
+ }
109
+ return max;
110
+ }
111
+
112
+ /** Default agent-work corroboration — AVAILABILITY-AWARE and correlated to the COMPLETING
113
+ * `review-round` element-instance (issue #786, Option 1):
114
+ *
115
+ * • AVAILABILITY PROBE (ADR 0056 fail-safe) — TWO-TIER, because an empty `review-round` search is
116
+ * AMBIGUOUS. `review-round` is an external-agent `serviceTask`: the engine creates an ORDINARY
117
+ * job and the WORKER registers its AgentInstance via `CreateAgentInstance` (nanobpmn engine-core
118
+ * `bpmn.rs`; a husked round is "a COMPLETED job that minted no AgentInstance", `event.rs`). So a
119
+ * completing review-round job that HUSKED *before the worker registered* mints NO instance — an
120
+ * empty scoped search is then a genuine husk, NOT proof of an absent channel. Distinguish the two
121
+ * with a process-instance-WIDE probe: (a) scoped `review-round` search non-empty → classify it
122
+ * (below); (b) scoped empty BUT the process instance has ANY agent instance at all (e.g.
123
+ * `classify-scope`, or an earlier round) → the channel is provably PRESENT, so a review-round
124
+ * that minted nothing is a genuine HUSK (`false`, auto-retry under the cap); (c) scoped empty AND
125
+ * the whole process instance has NO agent instance → the channel is UNKNOWN/ABSENT (the testkit
126
+ * WASM double, or a non-agentic engine) → `null` → `no-advance`, never an auto-retry, so a
127
+ * channel-absent engine can't loop. (The irreducible residual: the VERY FIRST agent task in a
128
+ * process husking before registering, with zero prior instances anywhere, is indistinguishable
129
+ * from an absent channel and fails safe to `no-advance` — a human resume, not a wedge.)
130
+ *
131
+ * • CORRELATION to the COMPLETING attempt via an ATTEMPT WATERMARK (Copilot PR #789). Once the
132
+ * channel is known present, the newest `review-round` instance is NOT necessarily the completing
133
+ * attempt: because a pre-registration husk mints NOTHING, a current attempt that husks before
134
+ * registering leaves the newest instance pointing at an EARLIER, terminal attempt — which, read
135
+ * naively, would classify as `no-advance` and bypass the bounded husk retry. So the completing
136
+ * attempt is correlated against `priorWatermark`: the greatest `review-round` instance key an
137
+ * earlier progress-check already accounted for. (a) A `review-round` instance NEWER than the
138
+ * watermark exists ⇒ the current attempt DID register ⇒ classify that newest instance — terminal
139
+ * ⇒ `no-advance` (`true`), non-terminal ⇒ the completing attempt husked (`false`) — and consume
140
+ * it (advance the watermark to its key). (b) NO instance newer than the watermark ⇒ the current
141
+ * attempt registered nothing new ⇒ it husked pre-registration ⇒ `husk` (`false`, auto-retry),
142
+ * leaving the watermark where it is. Each attempt (initial, review-loop, answer-loop resume, husk
143
+ * retry) re-enters `review-round` as a FRESH element-instance with a strictly greater monotonic
144
+ * key (its `elementInstanceKeys`, else its `agentInstanceKey`; see {@link recencyKey}), so "newer
145
+ * than the watermark" is exactly "a not-yet-accounted-for attempt". A stale terminal instance
146
+ * from an earlier attempt can therefore neither fabricate nor mask a current husk. The watermark
147
+ * is maintained on EVERY addressed round with a readable head (including progressing rounds), so a
148
+ * progressing round's fresh instance is consumed and can't be mistaken for the next round's
149
+ * completing attempt.
150
+ *
151
+ * Any read FAILURE degrades to `null` (unknown → `no-advance`, never an auto-retry), so a transient
152
+ * read outage can never duplicate genuinely-completed agent work. */
153
+ function agentWorkFromEngine(engine: AppApi["engine"]): AgentWorkReader {
154
+ return async (processInstanceKey, _round, priorWatermark): Promise<AgentWorkObservation> => {
155
+ if (!processInstanceKey) return { work: null };
156
+ try {
157
+ const instances = await engine.searchAgentInstances({
158
+ processInstanceKey: String(processInstanceKey),
159
+ elementId: "review-round",
160
+ });
161
+ if (instances.length === 0) {
162
+ // Two-tier availability probe (Copilot #789): the scoped search is empty, which is
163
+ // AMBIGUOUS for an external-agent task (a pre-registration husk mints no instance). Probe
164
+ // the process instance WIDE: if it holds ANY agent instance the channel is provably PRESENT,
165
+ // so a review-round that minted nothing genuinely HUSKED (`false`); if it holds NONE the
166
+ // channel is UNKNOWN/ABSENT → `null` (no-advance, never an auto-retry).
167
+ const anyInProcess = await engine.searchAgentInstances({
168
+ processInstanceKey: String(processInstanceKey),
169
+ });
170
+ return { work: anyInProcess.length === 0 ? null : false };
171
+ }
172
+ // Find the newest `review-round` instance (greatest monotonic engine key).
173
+ let newest = instances[0];
174
+ let newestKey = recencyKey(newest);
175
+ for (const inst of instances) {
176
+ const k = recencyKey(inst);
177
+ if (k > newestKey) {
178
+ newest = inst;
179
+ newestKey = k;
180
+ }
181
+ }
182
+ // ATTEMPT-WATERMARK correlation (Copilot #789): only an instance NEWER than the watermark is
183
+ // the current, not-yet-accounted-for attempt. If none is newer, the completing attempt
184
+ // registered nothing new — it husked before registering — even though a stale terminal instance
185
+ // from an earlier attempt is still present. That is a genuine husk (auto-retry), NOT a
186
+ // no-advance; classifying `newest` naively here would wrongly escalate it.
187
+ const prior = engineKey(priorWatermark);
188
+ if (newestKey <= prior) {
189
+ return { work: false, consumedKey: priorWatermark ?? null };
190
+ }
191
+ // A fresh attempt registered: classify it (terminal ⇒ no-advance, non-terminal ⇒ husk) and
192
+ // consume it so the next round can distinguish the attempt AFTER it from this one.
193
+ return { work: isTerminalInstance(newest), consumedKey: newestKey.toString() };
194
+ } catch {
195
+ return { work: null };
196
+ }
197
+ };
198
+ }
36
199
 
37
- /** Build the handler with an injectable head reader (see {@link HeadReader}). The default export
38
- * binds the real GitHub reader; tests inject a stub. */
39
- export function makeHandler(deps: { readHead: HeadReader }): AppJobHandler<In, Out> {
200
+ /** Build the handler with injectable readers (see {@link HeadReader} / {@link AgentWorkReader}). The
201
+ * default export binds the real GitHub reader; the agent-work reader defaults to the engine's
202
+ * AgentInstance channel when not injected. Tests inject stubs. */
203
+ export function makeHandler(deps: {
204
+ readHead: HeadReader;
205
+ readAgentWork?: AgentWorkReader;
206
+ }): AppJobHandler<In, Out> {
40
207
  return async (job, app) => {
41
- const { prKey, status, repo, prNumber } = job.variables;
208
+ const { prKey, status, repo, prNumber, round, huskRetries, roundEntryHead } = job.variables;
209
+ const jobKey = job.jobKey;
42
210
 
43
- // Only an `addressed` round claims a push, so only it can be a no-progress round — and a
44
- // blank/unknown status counts as `addressed` here (gw-status defaults it down the addressed
45
- // arm and pr.persist-round records a missing status as `addressed`), so it is the safe-default
46
- // trap this guard exists for. Skip the GitHub read entirely only for an explicitly recognized
47
- // non-addressed status a `waiting` round costs nothing and continues.
48
- if (!isAddressedStatus(status)) return { progressed: true };
211
+ const prs = app.data.table<{
212
+ pr_key: string;
213
+ process_key: string | null;
214
+ last_round_head: string | null;
215
+ status: string | null;
216
+ updated_at: string | null;
217
+ last_progress_job_key: string | null;
218
+ last_progress_result: string | null;
219
+ last_progress_agent_watermark: string | null;
220
+ }>("pull_requests", "pr_key");
221
+ const row = await prs.get(prKey);
222
+
223
+ // REDELIVERY REPLAY GUARD (Copilot #789 / engine at-least-once delivery). A job whose
224
+ // side-effects landed but whose completion-ack was lost is redelivered with the SAME job key.
225
+ // Replaying the recorded outcome — instead of re-deriving against the now-advanced
226
+ // `last_round_head` baseline — stops a redelivered PROGRESSED round from reading its own
227
+ // just-written head as "no advance" and mis-escalating an already-progressed round. A husk
228
+ // auto-retry re-enters `review-round` as a NEW job key, so it is never mistaken for a redelivery.
229
+ // The stamp and the baseline advance are written in ONE atomic row update (see `commit` below),
230
+ // so this guard can never disagree with the baseline it replays against.
231
+ if (jobKey && row?.last_progress_job_key === jobKey && typeof row.last_progress_result === "string") {
232
+ // biome-ignore lint/plugin: replay a worker outcome persisted as JSON in the idempotency stamp.
233
+ return JSON.parse(row.last_progress_result) as Out;
234
+ }
235
+
236
+ // The SINGLE atomic terminal write for this delivery. It advances the head baseline (when a head
237
+ // was read), applies the resting status effect, and stamps the idempotency key + serialized
238
+ // outcome. Folding all three into one row update is what makes the observation and the decision
239
+ // atomic (Copilot #789): a redelivery either sees the whole record (→ replay above) or none of
240
+ // it (→ recompute from the un-advanced baseline → same decision), never a half-state. It is also
241
+ // the single writer of the review-wait park (#786) — persist-round no longer sets
242
+ // `waiting_review`, so the row rests on its running `converging` status until exactly one write
243
+ // here resolves it, and the poller never sees a transient `waiting_review` for a husk-retry round.
244
+ const commit = async (
245
+ out: Out,
246
+ opts: { head?: string | null; status?: "waiting_review" | "converging"; agentWatermark?: string | null },
247
+ ): Promise<Out> => {
248
+ const ts = new Date().toISOString();
249
+ // PROCESS-INSTANCE FENCE (Copilot #789). A straggler progress-check from a SUPERSEDED
250
+ // convergence instance can slip past the replay guard (its idempotency stamp was cleared when
251
+ // `submitPr` re-opened the PR) and reach here AFTER `submitPr` has reset the per-run fields and
252
+ // started a NEW convergence instance. Its stale baseline / status / watermark / result must not
253
+ // clobber the fresh run (which could be parked on `waiting_review` or replay stale output).
254
+ // Re-read the row's CURRENT owner immediately before the write and drop the write when this
255
+ // job's own `processInstanceKey` no longer owns the row — the straggler's token lives in a
256
+ // terminated instance, so acking without persisting is correct. Fail open when either key is
257
+ // absent (an older instance, or a row whose `process_key` is not yet seeded) so normal
258
+ // single-run behaviour is untouched. Compare as strings — `process_key` is persisted via
259
+ // `String(processInstanceKey)`, and a job key can arrive numeric.
260
+ const currentOwner = (await prs.get(prKey))?.process_key ?? null;
261
+ const jobOwner = job.processInstanceKey ?? null;
262
+ if (currentOwner && jobOwner && String(currentOwner) !== String(jobOwner)) {
263
+ return out;
264
+ }
265
+ await prs.update(prKey, {
266
+ // Only overwrite the baseline when we actually read a head, so a null (unreadable) head
267
+ // never clobbers a good `last_round_head`.
268
+ ...(opts.head ? { last_round_head: opts.head } : {}),
269
+ ...(opts.status === "waiting_review" ? { status: "waiting_review", waiting_since: ts } : {}),
270
+ ...(opts.status === "converging" ? { status: "converging" } : {}),
271
+ // Advance the attempt watermark ONLY when an agent-work read consumed a `review-round`
272
+ // instance (Copilot #789); `undefined` leaves the persisted watermark untouched (an
273
+ // unreadable head, a non-addressed round, or an injected bare-verdict reader), so the next
274
+ // round still correlates against the last real attempt.
275
+ ...(opts.agentWatermark !== undefined ? { last_progress_agent_watermark: opts.agentWatermark } : {}),
276
+ ...(jobKey ? { last_progress_job_key: jobKey, last_progress_result: JSON.stringify(out) } : {}),
277
+ updated_at: ts,
278
+ });
279
+ return out;
280
+ };
49
281
 
50
282
  // Prefer the carried repo/prNumber; fall back to parsing the canonical `owner/repo#N` prKey so
51
283
  // an older in-flight instance (or a process-variable regression) still resolves a target. If
52
- // neither yields one, fail open rather than guess.
284
+ // neither yields one, fail open rather than guess — but still park for review, since the loop
285
+ // proceeds to wait-review and the poller must watch this PR.
53
286
  const parsed = parsePr(prKey);
54
287
  const ghRepo = repo ?? parsed?.repo;
55
288
  const ghNumber = typeof prNumber === "number" ? prNumber : parsed?.number;
56
- if (!ghRepo || typeof ghNumber !== "number") return { progressed: true };
289
+ if (!ghRepo || typeof ghNumber !== "number") {
290
+ return commit({ progressed: true, huskRetries: 0 }, { status: "waiting_review" });
291
+ }
57
292
 
293
+ // Read the head and record the baseline on EVERY round — including a non-addressed `waiting`
294
+ // round — BEFORE the addressed-only escalation logic. The within-round baseline is now
295
+ // `roundEntryHead` (captured by `pr.capture-head` before `review-round`); persisting each round's
296
+ // exit head into `last_round_head` still matters as the FALLBACK baseline for an older in-flight
297
+ // instance whose fixed flow predates capture-head, or when a capture read failed open to null.
58
298
  const currentHead = await deps.readHead(ghRepo, ghNumber).catch(() => null);
59
- const prs = app.data.table<{ pr_key: string; last_round_head: string | null }>(
60
- "pull_requests",
61
- "pr_key",
299
+ // Baseline = the head captured by `pr.capture-head` at THIS round's entry, BEFORE `review-round`
300
+ // ran (the `roundEntryHead` process variable). It is a within-round baseline, so it structurally
301
+ // closes the no-baseline gap: a real push advances `currentHead` past it (→ progress, never a
302
+ // false husk), and a husk that pushed nothing leaves `currentHead === roundEntryHead` (→ a
303
+ // genuine no-advance, split into husk vs. no-advance by the agent-instance corroboration).
304
+ // capture-head publishes the EMPTY string when it could not read the entry head (its "unknown"
305
+ // sentinel) — treat that as no round-entry baseline and fall back to the persisted prior-round
306
+ // head (an OLDER in-flight instance whose fixed flow lacks capture-head resolves the var to
307
+ // undefined and lands here too). Absent both, the no-advance path fails open (see decideProgress).
308
+ const previousHead =
309
+ typeof roundEntryHead === "string" && roundEntryHead !== ""
310
+ ? roundEntryHead
311
+ : (row?.last_round_head ?? null);
312
+
313
+ // Round numbers are 1-based; coerce a missing/invalid `round` to a positive 1. The round no
314
+ // longer gates the agent-work corroboration (correlation is by the completing element-instance,
315
+ // not an aggregate round count — #786); it only tunes the human-facing escalation question.
316
+ const roundNo = typeof round === "number" && round > 0 ? Math.floor(round) : 1;
317
+ // Corroborate durable agent work on EVERY round — the addressed rounds AND the non-addressed
318
+ // `waiting` round, AND even a round whose head could not be read. The husk verdict itself is only
319
+ // consulted when the head did not advance past the round-entry baseline, but the read must ALSO
320
+ // run to MAINTAIN THE ATTEMPT WATERMARK (Copilot
321
+ // #789): every round runs `review-round` BEFORE this progress-check
322
+ // (`…→review-round→gw-status→…→check-progress`), registering a fresh `review-round` instance that
323
+ // must be CONSUMED into the watermark, else the NEXT round's pre-registration husk would see that
324
+ // stale terminal instance as "newer than the watermark" and mis-escalate as no-advance. This
325
+ // includes the `waiting` round (Copilot #789 worker.ts:329): a `waiting` round's own review runs
326
+ // and can leave a terminal instance, so if its progress-check returned early WITHOUT consuming it,
327
+ // the first addressed round's pre-registration husk would inherit that unconsumed terminal
328
+ // instance and bypass the bounded husk retry. It ALSO includes a round whose CURRENT HEAD could
329
+ // not be read (Copilot #789): the head-diff fails open regardless, but if that GitHub outage
330
+ // coincided with a round whose review DID register a terminal instance, skipping the agent read
331
+ // would leave that instance UNCONSUMED — a later pre-registration husk would then see it as newer
332
+ // than the stale watermark and mis-classify itself as no-advance. So read the channel here too and
333
+ // let a successful read advance the watermark even when the head is unreadable.
334
+ const readAgentWork = deps.readAgentWork ?? agentWorkFromEngine(app.engine);
335
+ const priorWatermark = row?.last_progress_agent_watermark ?? null;
336
+ const observation = normalizeAgentWork(
337
+ await readAgentWork(job.processInstanceKey, roundNo, priorWatermark).catch(() => null),
62
338
  );
63
- const row = await prs.get(prKey);
64
- const previousHead = row?.last_round_head ?? null;
339
+ const agentWorkObserved = observation.work;
340
+ // The watermark to persist: the key this read consumed (a fresh attempt), else undefined so the
341
+ // stored one is left untouched (an unreadable head or an injected bare-verdict reader).
342
+ const agentWatermark = observation.consumedKey;
65
343
 
66
- // Record the observed head as the baseline for the next round's comparison but only when we
67
- // actually read one, so a null (unreadable) head never clobbers a good baseline.
68
- if (currentHead) {
69
- await prs.update(prKey, { last_round_head: currentHead });
344
+ // Only an `addressed` round claims a push, so only it can be a no-progress round and a
345
+ // blank/unknown status counts as `addressed` here (gw-status defaults it down the addressed arm
346
+ // and pr.persist-round records a missing status as `addressed`), so it is the safe-default trap
347
+ // this guard exists for. An explicitly recognized non-addressed status (`waiting`, etc.)
348
+ // legitimately has no push and always continues to the review wait — after the baseline write.
349
+ // Park it for review (persist-round no longer does), but still advance the attempt watermark so a
350
+ // following addressed husk isn't masked by this round's own review instance (Copilot #789).
351
+ if (!isAddressedStatus(status)) {
352
+ return commit(
353
+ { progressed: true, huskRetries: 0 },
354
+ { head: currentHead, status: "waiting_review", agentWatermark },
355
+ );
70
356
  }
71
357
 
72
- return { progressed: routeProgress(status, previousHead, currentHead) === "continue" };
358
+ const decision = decideProgress(
359
+ status,
360
+ previousHead,
361
+ currentHead,
362
+ roundNo,
363
+ agentWorkObserved,
364
+ typeof huskRetries === "number" ? huskRetries : null,
365
+ );
366
+
367
+ const out: Out = {
368
+ progressed: decision.progressed,
369
+ huskRetries: decision.huskRetries,
370
+ ...(decision.huskRetry !== undefined ? { huskRetry: decision.huskRetry } : {}),
371
+ ...(decision.reason !== undefined ? { noProgressReason: decision.reason } : {}),
372
+ ...(decision.question !== undefined ? { noProgressQuestion: decision.question } : {}),
373
+ };
374
+
375
+ // Resolve the row's resting status with the single atomic `commit`, now that the husk decision
376
+ // is known — closing the persist-round→progress-check race (#786).
377
+ if (decision.huskRetry === true) {
378
+ // Husk auto-retry re-enters `review-round` immediately (it does NOT wait for a new review), so
379
+ // keep the PR on the running `converging` aggregate: the poller must see an in-flight round,
380
+ // not a `waiting_review` it would solicit a spurious Copilot review for (and pollJobActivation
381
+ // treats only `converging` as live).
382
+ return commit(out, { head: currentHead, status: "converging", agentWatermark });
383
+ }
384
+ if (decision.progressed) {
385
+ // Genuine progress → the loop parks at wait-review. THIS is the review-wait park, written only
386
+ // once the husk retry has been ruled out, so a husk-retry round never transits `waiting_review`.
387
+ return commit(out, { head: currentHead, status: "waiting_review", agentWatermark });
388
+ }
389
+ // The remaining outcome (progressed:false, huskRetry:false) escalates to a human; the
390
+ // persist-escalation-noprogress worker owns the status, so leave it unset — but still advance the
391
+ // baseline and stamp the idempotency record so a redelivered escalation replays instead of
392
+ // recomputing.
393
+ return commit(out, { head: currentHead, agentWatermark });
73
394
  };
74
395
  }
75
396