@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.
- package/CHANGELOG.md +12 -0
- package/SPEC.md +93 -7
- package/app/convergeGate.test.ts +161 -4
- package/app/convergenceEscalationGuard.test.ts +3 -2
- package/app/currentHead.ts +60 -0
- package/app/github.test.ts +189 -1
- package/app/github.ts +134 -27
- package/app/persist-escalation.test.ts +7 -5
- package/app/persist-round.test.ts +178 -11
- package/app/pollReviewsStale.test.ts +187 -0
- package/app/pullRequestReadModel.test.ts +1 -1
- package/app/reviewWait.test.ts +33 -0
- package/app/reviewWait.ts +21 -0
- package/app/roundProgress.test.ts +755 -33
- package/app/roundProgress.ts +144 -0
- package/app/roundResultDefault.test.ts +10 -8
- package/app/service.test.ts +14 -0
- package/app/service.ts +72 -2
- package/db/migrations/102_rounds_process_instance_key.sql +28 -0
- package/db/migrations/103_pr_progress_idempotency.sql +29 -0
- package/db/migrations/104_pull_requests_read_model_progress_idempotency.sql +55 -0
- package/e2e/convergence-escalation.e2e.ts +5 -4
- package/e2e/feature-run.e2e.ts +6 -1
- package/e2e/plan-fanout-sla.e2e.ts +5 -2
- package/e2e/plan-fanout.e2e.ts +6 -2
- package/e2e/support/time.ts +34 -0
- package/nano.app.json +4 -0
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +238 -148
- package/resources/prompts/review-round.md +10 -0
- package/test/derivation-parity/README.md +3 -3
- package/test/derivation-parity/derivation-parity.test.ts +9 -3
- package/test/derivation-parity/flows.ts +4 -4
- package/workers/capture-head/worker.test.ts +77 -0
- package/workers/capture-head/worker.ts +64 -0
- package/workers/converge-gate/worker.ts +48 -21
- package/workers/persist-escalation/worker.ts +4 -0
- package/workers/persist-round/worker.ts +75 -9
- package/workers/progress-check/worker.ts +359 -38
|
@@ -156,6 +156,16 @@ Because several agents may run on the same host at once:
|
|
|
156
156
|
`path:line`, it is blind to the advisory's prose, so it would let a resolved ack
|
|
157
157
|
for one advisory silently acknowledge a genuinely new advisory re-emitted at that
|
|
158
158
|
same line. Always use the `<path> :: <text>` form.
|
|
159
|
+
|
|
160
|
+
> **Fixing an advisory in code is NOT enough — you MUST also post its resolved
|
|
161
|
+
> `nano-ack:` thread (issues #799 / #789).** Copilot re-lists every suppressed
|
|
162
|
+
> advisory in each review body, *including ones you already fixed in code*. The
|
|
163
|
+
> converge-gate cannot see your diff; it sees only the review body's advisory
|
|
164
|
+
> list and your resolved ack threads. So an advisory you **Applied** (fixed) but
|
|
165
|
+
> left un-acked is still an *outstanding* advisory to the gate — it re-blocks and
|
|
166
|
+
> re-escalates to a human every round even though the code is already correct.
|
|
167
|
+
> Post an `Applied. nano-ack: <path> :: <text>` thread for **every** advisory you
|
|
168
|
+
> fix, not only for the ones you decline.
|
|
159
169
|
6. **Do NOT request, re-request, or remove the reviewer yourself.** Keeping
|
|
160
170
|
Copilot attached is the **process's** job: a deterministic poller ensures a
|
|
161
171
|
Copilot review is requested (idempotently) whenever this PR is waiting, and it
|
|
@@ -72,7 +72,7 @@ sufficient_. Three golden features have no structured-builder derivation, each
|
|
|
72
72
|
pinned by a diagnostic in `derivation-parity.test.ts`:
|
|
73
73
|
|
|
74
74
|
1. **Task-level back-edge merge.** The loop head `review-round` is a
|
|
75
|
-
`serviceTask` that merges **
|
|
75
|
+
`serviceTask` that merges **four** back-edges directly (`in=4`). But `loop()`
|
|
76
76
|
always inserts an exclusive-gateway loop head that absorbs the back-edge, so
|
|
77
77
|
the body task stays `in=1` — empirically demonstrated by the `loop() inserts a
|
|
78
78
|
gateway head` test.
|
|
@@ -81,8 +81,8 @@ pinned by a diagnostic in `derivation-parity.test.ts`:
|
|
|
81
81
|
complex boolean, one default). No `switch` (equalities + default) or `branch`
|
|
82
82
|
(one condition + default) emits that.
|
|
83
83
|
3. **Shared merge+split gateway.** `gw-escalated` is a single exclusive gateway
|
|
84
|
-
that is at once a **
|
|
85
|
-
from
|
|
84
|
+
that is at once a **six-way merge and a two-way split**, reached by back-edges
|
|
85
|
+
from six distinct points.
|
|
86
86
|
|
|
87
87
|
The fix is an **arbitrary-graph / explicit-join (named-target)** builder upstream
|
|
88
88
|
in `@nanobpm/workflow` — a **superset** of the class-1 gap.
|
|
@@ -125,8 +125,14 @@ test("convergence-loop golden has arbitrary-graph features the structured builde
|
|
|
125
125
|
const body = rest.slice(0, close);
|
|
126
126
|
return (body.match(new RegExp(`<bpmn:${tag}\\b`, "g")) ?? []).length;
|
|
127
127
|
};
|
|
128
|
-
// (a) the loop head is a serviceTask that MERGES
|
|
129
|
-
|
|
128
|
+
// (a) the loop head is a serviceTask that MERGES four back-edges directly — after #786/#789 the
|
|
129
|
+
// round-entry head capture (`capture-head`) is the loop head sitting BEFORE `review-round`, so it
|
|
130
|
+
// is `capture-head` that absorbs the four back-edges (the review loop, the answer resume, the
|
|
131
|
+
// escalation re-enter, and the #786 husk auto-retry); `review-round` then takes its single
|
|
132
|
+
// `f_capture` in-edge. A serviceTask merging four back-edges directly is the arbitrary-graph shape
|
|
133
|
+
// the structured builder cannot emit — the feature this asserts, now on `capture-head`.
|
|
134
|
+
assertEquals(between("capture-head", "serviceTask", "incoming"), 4, "capture-head should merge 4 flows on the task itself");
|
|
135
|
+
assertEquals(between("review-round", "serviceTask", "incoming"), 1, "review-round now takes the single f_capture in-edge");
|
|
130
136
|
// (b) a single exclusive gateway forks FOUR heterogeneous-condition out-edges.
|
|
131
137
|
assertEquals(between("gw-status", "exclusiveGateway", "outgoing"), 4, "gw-status should be a 4-way exclusive gateway");
|
|
132
138
|
// (c) a single exclusive gateway is at once a 6-way merge and a 2-way split.
|
|
@@ -138,7 +144,7 @@ test("convergence-loop golden has arbitrary-graph features the structured builde
|
|
|
138
144
|
// (a): a `loop()` whose body starts with a task derives an exclusive-gateway
|
|
139
145
|
// loop head that absorbs the back-edge (in>=2), leaving the task itself at
|
|
140
146
|
// in=1. The golden instead merges its back-edges directly into `review-round`
|
|
141
|
-
// (in=
|
|
147
|
+
// (in=4) with no loop-head gateway — a shape the builder cannot express.
|
|
142
148
|
test("loop() inserts a gateway head, so back-edges cannot merge into a task", () => {
|
|
143
149
|
const probe = defineFlow("loop-head-probe", (w) => {
|
|
144
150
|
w.loop((b) => {
|
|
@@ -24,12 +24,12 @@
|
|
|
24
24
|
// so it clears class (1) — but its topology is NOT expressible with the
|
|
25
25
|
// structured-only builder (`loop`/`switch`/`branch`), empirically proven
|
|
26
26
|
// (see `derivation-parity.test.ts`): its loop head `review-round` is a
|
|
27
|
-
// serviceTask that MERGES
|
|
27
|
+
// serviceTask that MERGES four back-edges directly (in=4), whereas
|
|
28
28
|
// `loop()` always inserts an exclusive-gateway loop head (the task stays
|
|
29
29
|
// in=1); `gw-status` is a single exclusive gateway with FOUR
|
|
30
30
|
// heterogeneous-condition out-edges (two `=x = "v"`, one complex boolean,
|
|
31
31
|
// one default) which no `switch`/`branch` emits; and `gw-escalated` is a
|
|
32
|
-
// single gateway that is simultaneously a
|
|
32
|
+
// single gateway that is simultaneously a six-way merge and a two-way
|
|
33
33
|
// split. Single start/end is necessary but NOT sufficient. Needs an
|
|
34
34
|
// arbitrary-graph / explicit-join (named-target) builder — a SUPERSET of
|
|
35
35
|
// the class-(1) gap.
|
|
@@ -207,8 +207,8 @@ export const PORTS: readonly PortEntry[] = [
|
|
|
207
207
|
"blocked (arbitrary control-flow graph): single top-level start/end, but " +
|
|
208
208
|
"its topology is not expressible with the published @nanobpm/workflow's " +
|
|
209
209
|
"structured-only builder (loop/switch/branch). Proven in the test suite: " +
|
|
210
|
-
"the loop head `review-round` is a serviceTask that merges
|
|
211
|
-
"directly (in=
|
|
210
|
+
"the loop head `review-round` is a serviceTask that merges 4 back-edges " +
|
|
211
|
+
"directly (in=4), but loop() always inserts an exclusive-gateway head " +
|
|
212
212
|
"(task stays in=1); `gw-status` is one gateway with 4 heterogeneous-" +
|
|
213
213
|
"condition out-edges (no switch/branch emits that); `gw-escalated` is one " +
|
|
214
214
|
"gateway that is at once a 6-way merge and a 2-way split. Awaits an " +
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { makeHandler } from "./worker.ts";
|
|
4
|
+
|
|
5
|
+
const assertEquals = (a: unknown, b: unknown, m?: string) => assert.deepStrictEqual(a, b, m);
|
|
6
|
+
|
|
7
|
+
// The worker reads only `job.variables`, so a bare stub job suffices.
|
|
8
|
+
const job = (variables: Record<string, unknown>) => ({ variables }) as never;
|
|
9
|
+
|
|
10
|
+
test("capture-head: reads the head via the carried repo/prNumber and publishes it as roundEntryHead", async () => {
|
|
11
|
+
let seen: [string, number] | null = null;
|
|
12
|
+
const handler = makeHandler({
|
|
13
|
+
readHead: async (repo, n) => {
|
|
14
|
+
seen = [repo, n];
|
|
15
|
+
return "sha-abc";
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
const out = await handler(job({ prKey: "o/r#1", repo: "o/r", prNumber: 1 }), {} as never);
|
|
19
|
+
assertEquals(out, { roundEntryHead: "sha-abc" });
|
|
20
|
+
assertEquals(seen, ["o/r", 1], "the carried repo/prNumber drive the head read");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("capture-head: falls back to parsing the prKey when repo/prNumber are absent", async () => {
|
|
24
|
+
let seen: [string, number] | null = null;
|
|
25
|
+
const handler = makeHandler({
|
|
26
|
+
readHead: async (repo, n) => {
|
|
27
|
+
seen = [repo, n];
|
|
28
|
+
return "sha-def";
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
const out = await handler(job({ prKey: "owner/repo#42" }), {} as never);
|
|
32
|
+
assertEquals(out, { roundEntryHead: "sha-def" });
|
|
33
|
+
assertEquals(seen, ["owner/repo", 42], "repo/prNumber resolved from the canonical prKey");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("capture-head: an unreadable head fails OPEN to the empty-string sentinel (never a fabricated baseline)", async () => {
|
|
37
|
+
// A transient GitHub hiccup must never publish a wrong baseline. The empty string is the "unknown"
|
|
38
|
+
// sentinel progress-check treats as no round-entry baseline (→ it falls back to last_round_head).
|
|
39
|
+
const handler = makeHandler({ readHead: async () => null });
|
|
40
|
+
const out = await handler(job({ prKey: "o/r#1", repo: "o/r", prNumber: 1 }), {} as never);
|
|
41
|
+
assertEquals(out, { roundEntryHead: "" });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("capture-head: a THROWING head read is swallowed to the empty-string sentinel", async () => {
|
|
45
|
+
const handler = makeHandler({
|
|
46
|
+
readHead: async () => {
|
|
47
|
+
throw new Error("network down");
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
const out = await handler(job({ prKey: "o/r#1", repo: "o/r", prNumber: 1 }), {} as never);
|
|
51
|
+
assertEquals(out, { roundEntryHead: "" });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("capture-head: an unresolvable target (unparseable prKey, no repo/prNumber) publishes the empty sentinel without calling the reader", async () => {
|
|
55
|
+
let called = false;
|
|
56
|
+
const handler = makeHandler({
|
|
57
|
+
readHead: async () => {
|
|
58
|
+
called = true;
|
|
59
|
+
return "sha-x";
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
const out = await handler(job({ prKey: "not-a-pr-key" }), {} as never);
|
|
63
|
+
assertEquals(out, { roundEntryHead: "" });
|
|
64
|
+
assertEquals(called, false, "no target ⇒ the head reader is never invoked");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("capture-head: always publishes roundEntryHead so a husk retry / new round OVERWRITES a prior entry SHA", async () => {
|
|
68
|
+
// The field is returned on every invocation (string or ""), so a re-entry never carries a stale
|
|
69
|
+
// captured head forward — the value is re-derived from the live head each round entry.
|
|
70
|
+
const heads = ["sha-1", "sha-2"];
|
|
71
|
+
let i = 0;
|
|
72
|
+
const handler = makeHandler({ readHead: async () => heads[i++] ?? null });
|
|
73
|
+
const first = await handler(job({ prKey: "o/r#1", repo: "o/r", prNumber: 1 }), {} as never);
|
|
74
|
+
const second = await handler(job({ prKey: "o/r#1", repo: "o/r", prNumber: 1 }), {} as never);
|
|
75
|
+
assertEquals(first, { roundEntryHead: "sha-1" });
|
|
76
|
+
assertEquals(second, { roundEntryHead: "sha-2" }, "the re-entry overwrites with a freshly-read head");
|
|
77
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// pr.capture-head — captures the PR's head SHA at the START of a convergence round, BEFORE the
|
|
2
|
+
// `review-round` agent runs.
|
|
3
|
+
//
|
|
4
|
+
// Every entry into `review-round` (the first round from Start, a review-loop re-enter, a human-answer
|
|
5
|
+
// resume, and a husk auto-retry) now routes through this step first. It reads the head as it stands
|
|
6
|
+
// immediately before the agent acts and publishes it as the `roundEntryHead` process variable, which
|
|
7
|
+
// `pr.progress-check` (workers/progress-check/worker.ts) then reads as the round's baseline.
|
|
8
|
+
//
|
|
9
|
+
// WHY this exists (issue #786 / Copilot #789 — closing both sides of the no-baseline husk problem):
|
|
10
|
+
// progress-check classifies a no-advance `addressed` round by diffing the head against a baseline. It
|
|
11
|
+
// previously used the PREVIOUS round's recorded head (`last_round_head`), which is null on the FIRST
|
|
12
|
+
// addressed round (a fresh submission clears it), leaving two opposing failure modes:
|
|
13
|
+
// • fail-open on no baseline → a first-round HUSK is waved through as progress (bypasses gw-husk);
|
|
14
|
+
// • special-case husk on no baseline → a real commit an agent pushed just before dying non-terminal
|
|
15
|
+
// is mis-routed into gw-husk.
|
|
16
|
+
// Capturing the entry head structurally eliminates the no-baseline case: a real push advances the head
|
|
17
|
+
// WITHIN the round (→ progress, never a false husk), and a husk that pushed nothing leaves the head at
|
|
18
|
+
// the captured entry (→ a genuine no-advance the agent-instance corroboration splits into husk vs.
|
|
19
|
+
// no-advance). The special no-baseline branch is gone.
|
|
20
|
+
//
|
|
21
|
+
// `roundEntryHead` is an INSTANCE-SCOPED process variable, not a persisted column, so a straggler from
|
|
22
|
+
// a superseded convergence instance can never contaminate a re-opened run's baseline. It is captured
|
|
23
|
+
// fresh on every round entry (including husk retries), so it never carries a stale prior-round value.
|
|
24
|
+
// The read fails OPEN to `null` (a transient GitHub hiccup must never fabricate a no-progress verdict);
|
|
25
|
+
// progress-check then falls back to the persisted `last_round_head`.
|
|
26
|
+
import type { AppJobHandler } from "@nanobpm/urban";
|
|
27
|
+
import { fetchBranchHead, fetchPrHead } from "../../app/github.ts";
|
|
28
|
+
import { parsePr } from "../../app/service.ts";
|
|
29
|
+
import type { WorkerInputs, WorkerOutputs } from "../../nano-generated/worker-io.d.ts";
|
|
30
|
+
import { type HeadReader, makeDefaultReadHead } from "../progress-check/worker.ts";
|
|
31
|
+
|
|
32
|
+
// Input/output typed off the model data envelopes (`PrCaptureHeadIn` / `PrCaptureHeadOut` in
|
|
33
|
+
// convergence-loop.bpmn), the single source of truth for this worker's wire contract (ADR 0040).
|
|
34
|
+
type In = WorkerInputs["pr.capture-head"];
|
|
35
|
+
type Out = WorkerOutputs["pr.capture-head"];
|
|
36
|
+
|
|
37
|
+
const defaultReadHead: HeadReader = makeDefaultReadHead({ fetchPrHead, fetchBranchHead });
|
|
38
|
+
|
|
39
|
+
/** Injectable head reader so unit tests never touch git/network; the default binds the same
|
|
40
|
+
* branch-ref-over-stale-`head.sha` reader progress-check uses (#786), keeping the entry-head and the
|
|
41
|
+
* exit-head reads byte-for-byte comparable. */
|
|
42
|
+
export function makeHandler(deps: { readHead: HeadReader }): AppJobHandler<In, Out> {
|
|
43
|
+
return async (job) => {
|
|
44
|
+
const { prKey, repo, prNumber } = job.variables;
|
|
45
|
+
// Prefer the carried repo/prNumber; fall back to parsing the canonical `owner/repo#N` prKey so an
|
|
46
|
+
// older in-flight instance still resolves a target. If neither yields one, publish a null baseline
|
|
47
|
+
// — progress-check then falls back to `last_round_head` and, absent that, fails open.
|
|
48
|
+
const parsed = parsePr(prKey);
|
|
49
|
+
const ghRepo = repo ?? parsed?.repo;
|
|
50
|
+
const ghNumber = typeof prNumber === "number" ? prNumber : parsed?.number;
|
|
51
|
+
if (!ghRepo || typeof ghNumber !== "number") {
|
|
52
|
+
return { roundEntryHead: "" };
|
|
53
|
+
}
|
|
54
|
+
// ALWAYS publish the variable so a husk retry / new round OVERWRITES any prior round's captured
|
|
55
|
+
// head — it must never carry a stale entry SHA forward. An unreadable head yields the empty
|
|
56
|
+
// string (the "unknown" sentinel), which progress-check treats as no round-entry baseline and
|
|
57
|
+
// falls back to the persisted `last_round_head`; a non-empty string is the fresh entry baseline.
|
|
58
|
+
const roundEntryHead = (await deps.readHead(ghRepo, ghNumber).catch(() => null)) ?? "";
|
|
59
|
+
return { roundEntryHead };
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const handler = makeHandler({ readHead: defaultReadHead });
|
|
64
|
+
export default handler;
|
|
@@ -30,14 +30,18 @@
|
|
|
30
30
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
31
31
|
import { type ConvergeGateResult, evaluateConvergeGate } from "../../app/convergeGate.ts";
|
|
32
32
|
import {
|
|
33
|
-
|
|
33
|
+
fetchBranchHead,
|
|
34
|
+
fetchLatestCopilotReview,
|
|
35
|
+
fetchPrHead,
|
|
34
36
|
fetchReviewThreads,
|
|
35
37
|
parseAckedAdvisories,
|
|
36
38
|
parseSuppressedAdvisories,
|
|
37
39
|
type ReviewThread,
|
|
38
40
|
} from "../../app/github.ts";
|
|
41
|
+
import { isReviewStale } from "../../app/reviewWait.ts";
|
|
39
42
|
import { parsePr } from "../../app/service.ts";
|
|
40
43
|
import type { WorkerInputs, WorkerOutputs } from "../../nano-generated/worker-io.d.ts";
|
|
44
|
+
import { type HeadReader, makeDefaultReadHead } from "../progress-check/worker.ts";
|
|
41
45
|
|
|
42
46
|
// Input/output typed off the model data envelopes (`PrConvergeGateIn` / `PrConvergeGateOut` in
|
|
43
47
|
// convergence-loop.bpmn), the single source of truth for this worker's wire contract (ADR 0040).
|
|
@@ -47,14 +51,20 @@ type Out = WorkerOutputs["pr.converge-gate"];
|
|
|
47
51
|
// Reads a PR's review threads. `null` = no usable transport (treated as an unverifiable read →
|
|
48
52
|
// fail closed). Throws propagate to the fail-closed catch below.
|
|
49
53
|
export type ThreadsReader = (repo: string, prNumber: number) => Promise<ReviewThread[] | null>;
|
|
50
|
-
// Reads the latest Copilot review body. `null` = no
|
|
51
|
-
//
|
|
52
|
-
|
|
54
|
+
// Reads the latest Copilot review — body AND the commit SHA it was submitted against. `null` = no
|
|
55
|
+
// usable transport (unverifiable → fail closed); `{ body: "", commitId: null }` = transport usable
|
|
56
|
+
// but no Copilot review yet (verified: no suppressed advisories). The `commitId` drives the
|
|
57
|
+
// stale-review detection below (issue #799).
|
|
58
|
+
export type ReviewReader = (repo: string, prNumber: number) => Promise<{ body: string; commitId: string | null } | null>;
|
|
53
59
|
|
|
54
60
|
const defaultReadThreads: ThreadsReader = (repo, prNumber) =>
|
|
55
61
|
fetchReviewThreads(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
|
|
56
|
-
const
|
|
57
|
-
|
|
62
|
+
const defaultReadReview: ReviewReader = (repo, prNumber) =>
|
|
63
|
+
fetchLatestCopilotReview(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
|
|
64
|
+
// The PR's current HEAD SHA, read via the same branch-ref-over-stale-`head.sha` reader the
|
|
65
|
+
// capture-head / progress-check steps use (#786), so the gate's staleness comparison sees the exact
|
|
66
|
+
// head those steps do. Fails OPEN to `null` (unreadable head → not-stale, per {@link isReviewStale}).
|
|
67
|
+
const defaultReadHead: HeadReader = makeDefaultReadHead({ fetchPrHead, fetchBranchHead });
|
|
58
68
|
|
|
59
69
|
const BLOCK_UNVERIFIABLE =
|
|
60
70
|
"Convergence blocked: could not verify the PR's review comments against GitHub. A human must confirm every Copilot review thread is resolved and every suppressed advisory acknowledged before this PR converges (reply to resume the loop).";
|
|
@@ -63,8 +73,10 @@ const BLOCK_UNVERIFIABLE =
|
|
|
63
73
|
* tests inject stubs. Fails CLOSED — any unreadable/errored state blocks convergence. */
|
|
64
74
|
export function makeHandler(deps: {
|
|
65
75
|
readThreads: ThreadsReader;
|
|
66
|
-
|
|
76
|
+
readReview: ReviewReader;
|
|
77
|
+
readHeadSha?: HeadReader;
|
|
67
78
|
}): AppJobHandler<In, Out> {
|
|
79
|
+
const readHeadSha = deps.readHeadSha ?? defaultReadHead;
|
|
68
80
|
return async (job) => {
|
|
69
81
|
const { prKey, repo, prNumber } = job.variables;
|
|
70
82
|
// `parsePr` is total on any input (fails closed to `null` on a missing/non-string prKey), so
|
|
@@ -73,43 +85,58 @@ export function makeHandler(deps: {
|
|
|
73
85
|
const ghRepo = repo ?? parsed?.repo;
|
|
74
86
|
const ghNumber = typeof prNumber === "number" ? prNumber : parsed?.number;
|
|
75
87
|
if (!ghRepo || typeof ghNumber !== "number") {
|
|
76
|
-
return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE };
|
|
88
|
+
return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE, reviewStale: false };
|
|
77
89
|
}
|
|
78
90
|
|
|
79
91
|
let result: ConvergeGateResult;
|
|
80
92
|
try {
|
|
81
|
-
const
|
|
93
|
+
const threadsRead = await deps.readThreads(ghRepo, ghNumber);
|
|
82
94
|
// A null threads read is an unverifiable gate — fail closed. (An empty ARRAY is a verified
|
|
83
95
|
// "no threads" and is fine.)
|
|
84
|
-
if (
|
|
85
|
-
return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE };
|
|
96
|
+
if (threadsRead === null) {
|
|
97
|
+
return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE, reviewStale: false };
|
|
86
98
|
}
|
|
87
|
-
const
|
|
88
|
-
// A null review
|
|
89
|
-
// null threads read. (
|
|
90
|
-
|
|
91
|
-
|
|
99
|
+
const review = await deps.readReview(ghRepo, ghNumber);
|
|
100
|
+
// A null review read is an unverifiable read (no usable transport) — fail closed, same as a
|
|
101
|
+
// null threads read. (A `{ body: "", commitId: null }` result is a verified "no Copilot
|
|
102
|
+
// review / no advisories".)
|
|
103
|
+
if (review === null) {
|
|
104
|
+
return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE, reviewStale: false };
|
|
92
105
|
}
|
|
93
|
-
|
|
94
|
-
|
|
106
|
+
// STALE-REVIEW GUARD (issue #799). When the PR HEAD has advanced PAST the commit the latest
|
|
107
|
+
// Copilot review was submitted against, that review's suppressed advisories describe code the
|
|
108
|
+
// head has moved past — e.g. an advisory the agent already FIXED IN CODE (but did not ack) is
|
|
109
|
+
// still re-listed in the obsolete body. Blocking/escalating on it re-escalates a human
|
|
110
|
+
// indefinitely (PR #789). Instead of gating on the stale body, signal `reviewStale` so the
|
|
111
|
+
// process re-enters the review wait and the poller re-solicits a fresh review of the current
|
|
112
|
+
// HEAD to gate on. The head read fails OPEN (null → not stale), so a transport hiccup can never
|
|
113
|
+
// fabricate a stale verdict; the ordinary gate below still runs on a HEAD-current review.
|
|
114
|
+
const headSha = await readHeadSha(ghRepo, ghNumber).catch(() => null);
|
|
115
|
+
if (isReviewStale(review.commitId, headSha)) {
|
|
116
|
+
return { convergeBlocked: false, convergeBlockReason: "", reviewStale: true };
|
|
117
|
+
}
|
|
118
|
+
const unresolvedThreadCount = threadsRead.filter((t) => !t.isResolved).length;
|
|
119
|
+
const advisories = parseSuppressedAdvisories(review.body);
|
|
95
120
|
result = evaluateConvergeGate({
|
|
96
121
|
unresolvedThreadCount,
|
|
97
122
|
suppressedAdvisories: advisories.map((a) => ({ key: a.key, label: a.label })),
|
|
98
|
-
acknowledgedKeys: parseAckedAdvisories(
|
|
123
|
+
acknowledgedKeys: parseAckedAdvisories(threadsRead),
|
|
99
124
|
});
|
|
100
125
|
} catch {
|
|
101
|
-
return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE };
|
|
126
|
+
return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE, reviewStale: false };
|
|
102
127
|
}
|
|
103
128
|
|
|
104
129
|
return {
|
|
105
130
|
convergeBlocked: result.convergeBlocked,
|
|
106
131
|
convergeBlockReason: result.convergeBlockReason,
|
|
132
|
+
reviewStale: false,
|
|
107
133
|
};
|
|
108
134
|
};
|
|
109
135
|
}
|
|
110
136
|
|
|
111
137
|
const handler = makeHandler({
|
|
112
138
|
readThreads: defaultReadThreads,
|
|
113
|
-
|
|
139
|
+
readReview: defaultReadReview,
|
|
140
|
+
readHeadSha: defaultReadHead,
|
|
114
141
|
});
|
|
115
142
|
export default handler;
|
|
@@ -105,6 +105,10 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
105
105
|
worker,
|
|
106
106
|
started_at: now,
|
|
107
107
|
ended_at: now,
|
|
108
|
+
// Stamp the writing run's identity (issue #786) so the round-record row carries the same
|
|
109
|
+
// run-identity column pr.persist-round scopes its idempotent upsert by. A pr.persist-round
|
|
110
|
+
// resume for this same numeric round then reuses ONLY its own row, never this escalation row.
|
|
111
|
+
process_instance_key: job.processInstanceKey != null ? String(job.processInstanceKey) : null,
|
|
108
112
|
});
|
|
109
113
|
}
|
|
110
114
|
const escalationId = await app.data.table("escalations", "id").insert({
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// pr.persist-round — records a completed round (an `addressed` round where the agent pushed
|
|
2
|
-
// changes, or a `waiting` round where there was nothing to triage yet) and
|
|
3
|
-
// `
|
|
2
|
+
// changes, or a `waiting` round where there was nothing to triage yet) and advances the PR's
|
|
3
|
+
// `current_round`. It does NOT park the PR in `waiting_review`: that transition is owned by the
|
|
4
|
+
// downstream pr.progress-check step, the single writer of the post-round wait status. persist-round
|
|
5
|
+
// runs BEFORE the husk decision, so parking here would momentarily expose a husk-retry round (which
|
|
6
|
+
// re-enters review-round WITHOUT waiting for a review) to the poller's `waiting_review` scan and let
|
|
7
|
+
// it solicit a spurious Copilot review before progress-check flips the row back (#786).
|
|
4
8
|
//
|
|
5
9
|
// Data access goes through the injected app datasource gateway (`app.data.table<T>`), the RAD
|
|
6
10
|
// `Table<T>` surface — `rounds.insert(...)` / `pull_requests.update(...)`, not hand-written SQL.
|
|
@@ -107,20 +111,82 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
107
111
|
});
|
|
108
112
|
}
|
|
109
113
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
114
|
+
// Idempotent round record (issue #786): a husk auto-retry re-enters `review-round` WITHOUT
|
|
115
|
+
// advancing the round counter, so the SAME `(pr_key, round_no)` reaches this worker more than once.
|
|
116
|
+
// The `rounds` table has no UNIQUE(pr_key, round_no), so an unconditional insert would manufacture
|
|
117
|
+
// a DUPLICATE history row per retry — the durable round history is the SoT the cockpit and the
|
|
118
|
+
// no-progress guard read, so a duplicate row corrupts both. Upsert on `(pr_key, round_no)`:
|
|
119
|
+
// update the existing round-record row in place, else insert. Application-level (no
|
|
120
|
+
// migration/UNIQUE) so it heals installs that already carry pre-#786 duplicates rather than
|
|
121
|
+
// crashing on a new constraint.
|
|
122
|
+
//
|
|
123
|
+
// But the upsert MUST reuse only a row THIS run's `pr.persist-round` wrote — identity, not a
|
|
124
|
+
// status heuristic. Two ways a stale-but-status-eligible row can share `(pr_key, round_no)`:
|
|
125
|
+
// • An escalation row: on a `needs_input`/`blocked` escalation, `pr.persist-escalation` inserts
|
|
126
|
+
// a `rounds` row (status `needs_input`/`blocked`) for the SAME `(pr_key, round_no)`, and the
|
|
127
|
+
// human-answered resume re-enters that numeric round → back here. Reusing it would overwrite
|
|
128
|
+
// the escalation to `addressed`, ERASING the escalation attempt from the durable history.
|
|
129
|
+
// • A prior RUN's row: `submitPr` re-opens a previously converged/abandoned/merged PR at
|
|
130
|
+
// `current_round = 1` WITHOUT deleting `rounds` history, so a fresh convergence run (a NEW
|
|
131
|
+
// process instance) at round 1 finds the prior run's `addressed`/`waiting`/`converged` round-1
|
|
132
|
+
// row. Reusing it (its status is not a human-hold) would clobber another run's canonical
|
|
133
|
+
// history — the resubmission drift the reviewer flagged.
|
|
134
|
+
// The idempotency target is precisely "the row a husk auto-retry of THIS process instance wrote",
|
|
135
|
+
// and a husk retry re-enters `review-round` in the SAME process instance while a resubmission is a
|
|
136
|
+
// NEW one. So scope reuse by the writing `process_instance_key` (persisted below) AND exclude
|
|
137
|
+
// human-hold rows: reuse a row only when it carries the current instance's key and a non-hold
|
|
138
|
+
// status; otherwise insert a fresh row so every prior run's history — and every escalation — is
|
|
139
|
+
// preserved. `job.processInstanceKey` is always present for an engine job; when it is absent (a
|
|
140
|
+
// testkit/synthetic job) we fall back to the status-only heuristic so idempotency still holds
|
|
141
|
+
// within that single run.
|
|
142
|
+
const roundsTbl = app.data.table<{
|
|
143
|
+
id: number;
|
|
144
|
+
pr_key: string;
|
|
145
|
+
round_no: number;
|
|
146
|
+
status: string;
|
|
147
|
+
summary?: string;
|
|
148
|
+
transcript: string | null;
|
|
149
|
+
worker?: string;
|
|
150
|
+
started_at: string;
|
|
151
|
+
ended_at: string;
|
|
152
|
+
process_instance_key?: string | null;
|
|
153
|
+
}>("rounds", "id");
|
|
154
|
+
const HUMAN_HOLD_STATUSES = new Set(["needs_input", "blocked"]);
|
|
155
|
+
const processInstanceKey = job.processInstanceKey != null ? String(job.processInstanceKey) : null;
|
|
156
|
+
const matching = await roundsTbl.find({ pr_key: prKey, round_no: round });
|
|
157
|
+
// Reuse only a round-record row written by THIS run (matching process instance, when known) and
|
|
158
|
+
// never an escalation (human-hold) row; of those, the newest (greatest id).
|
|
159
|
+
const reusable = matching
|
|
160
|
+
.filter((r) => !HUMAN_HOLD_STATUSES.has(r.status))
|
|
161
|
+
.filter((r) => processInstanceKey === null || r.process_instance_key === processInstanceKey)
|
|
162
|
+
.reduce<{ id: number } | null>((newest, r) => (newest && newest.id >= r.id ? newest : r), null);
|
|
163
|
+
const roundRow = {
|
|
113
164
|
status,
|
|
114
165
|
summary,
|
|
115
166
|
transcript: transcriptOf(job.variables),
|
|
116
167
|
worker: workerOf(job.variables),
|
|
117
|
-
started_at: now,
|
|
118
168
|
ended_at: now,
|
|
119
|
-
}
|
|
169
|
+
};
|
|
170
|
+
if (reusable) {
|
|
171
|
+
await roundsTbl.update(reusable.id, roundRow);
|
|
172
|
+
} else {
|
|
173
|
+
await roundsTbl.insert({
|
|
174
|
+
pr_key: prKey,
|
|
175
|
+
round_no: round,
|
|
176
|
+
started_at: now,
|
|
177
|
+
process_instance_key: processInstanceKey,
|
|
178
|
+
...roundRow,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
// Advance the round pointer only; the PARK into `waiting_review` is owned by pr.progress-check,
|
|
182
|
+
// the single writer of the post-round wait status. persist-round must NOT park here — it runs
|
|
183
|
+
// BEFORE the husk decision, so writing `waiting_review` now would expose a husk-retry round (which
|
|
184
|
+
// re-enters review-round WITHOUT waiting for a review) to the poller's `waiting_review` scan for
|
|
185
|
+
// the window until progress-check resolves the outcome, letting the poller fire a spurious Copilot
|
|
186
|
+
// re-request (#786). Leaving the row on its running `converging` status until progress-check
|
|
187
|
+
// decides closes that window; only the genuine review-wait park sets `waiting_review`.
|
|
120
188
|
await app.data.table("pull_requests", "pr_key").update(prKey, {
|
|
121
|
-
status: "waiting_review",
|
|
122
189
|
current_round: round,
|
|
123
|
-
waiting_since: now,
|
|
124
190
|
updated_at: now,
|
|
125
191
|
});
|
|
126
192
|
|