@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +1 -1
  3. package/SPEC.md +71 -9
  4. package/app/contracts.ts +1 -0
  5. package/app/convergenceEscalationGuard.test.ts +3 -2
  6. package/app/github.test.ts +125 -1
  7. package/app/github.ts +43 -8
  8. package/app/persist-escalation.test.ts +8 -6
  9. package/app/persist-round.test.ts +178 -11
  10. package/app/pullRequestReadModel.test.ts +1 -1
  11. package/app/reviewWait.test.ts +7 -0
  12. package/app/reviewWait.ts +1 -1
  13. package/app/roundProgress.test.ts +755 -33
  14. package/app/roundProgress.ts +144 -0
  15. package/app/roundResultDefault.test.ts +10 -8
  16. package/app/service.test.ts +14 -0
  17. package/app/service.ts +35 -0
  18. package/db/migrations/102_rounds_process_instance_key.sql +28 -0
  19. package/db/migrations/103_pr_progress_idempotency.sql +29 -0
  20. package/db/migrations/104_pull_requests_read_model_progress_idempotency.sql +55 -0
  21. package/docs/agent-guide.md +1 -1
  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 +211 -142
  30. package/test/derivation-parity/README.md +3 -3
  31. package/test/derivation-parity/derivation-parity.test.ts +9 -3
  32. package/test/derivation-parity/flows.ts +4 -4
  33. package/workers/capture-head/worker.test.ts +77 -0
  34. package/workers/capture-head/worker.ts +64 -0
  35. package/workers/persist-escalation/worker.ts +4 -0
  36. package/workers/persist-round/worker.ts +75 -9
  37. 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 **three** back-edges directly (`in=3`). But `loop()`
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 **five-way merge and a two-way split**, reached by back-edges
85
- from five distinct points.
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 three back-edges directly.
129
- assertEquals(between("review-round", "serviceTask", "incoming"), 3, "review-round should merge 3 flows on the task itself");
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=3) with no loop-head gateway — a shape the builder cannot express.
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 three back-edges directly (in=3), whereas
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 five-way merge and a two-way
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 3 back-edges " +
211
- "directly (in=3), but loop() always inserts an exclusive-gateway head " +
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 parks the PR in
3
- // `waiting_review` so the poller starts watching for / soliciting the next review.
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
- await app.data.table("rounds", "id").insert({
111
- pr_key: prKey,
112
- round_no: round,
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