@nanobpm/nano-workforce 0.187.3 → 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 +12 -0
- package/README.md +1 -1
- package/SPEC.md +71 -9
- package/app/contracts.ts +1 -0
- package/app/convergenceEscalationGuard.test.ts +3 -2
- package/app/github.test.ts +125 -1
- package/app/github.ts +43 -8
- package/app/persist-escalation.test.ts +8 -6
- package/app/persist-round.test.ts +178 -11
- package/app/pullRequestReadModel.test.ts +1 -1
- package/app/reviewWait.test.ts +7 -0
- package/app/reviewWait.ts +1 -1
- 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 +35 -0
- 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/docs/agent-guide.md +1 -1
- 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 +211 -142
- 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/persist-escalation/worker.ts +4 -0
- package/workers/persist-round/worker.ts +75 -9
- package/workers/progress-check/worker.ts +394 -35
|
@@ -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;
|
|
@@ -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
|
|