@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
@@ -1,11 +1,12 @@
1
- // Red/green regression for pr.persist-round's round recording + parking behaviour.
1
+ // Red/green regression for pr.persist-round's round recording behaviour.
2
2
  //
3
3
  // The convergence loop routes both `addressed` (the agent pushed changes) and the new `waiting`
4
4
  // (nothing to triage yet — round 1, awaiting the first review) statuses through gw-guard into
5
- // persist-round. Both must be recorded in `rounds` under their own status and both must park the
6
- // PR in `waiting_review` so the deterministic poller (app/service.ts) starts soliciting a review.
7
- // A `waiting` round is what replaced the old failure mode where an agent with nothing to do
8
- // re-requested the review destructively and escalated `blocked`.
5
+ // persist-round. Both must be recorded in `rounds` under their own status and both must advance the
6
+ // PR's `current_round`. The PARK into `waiting_review` is owned by the downstream pr.progress-check
7
+ // step (the single writer of the post-round wait status), NOT persist-round persist-round runs
8
+ // before the husk decision, so parking here would let the poller fire a spurious review re-request
9
+ // against a husk-retry round before progress-check resolves it (#786).
9
10
  import { test } from "node:test";
10
11
  import { assertEquals } from "#test-assert";
11
12
  import handler from "../workers/persist-round/worker.ts";
@@ -14,6 +15,7 @@ function fakeApp() {
14
15
  const inserts: Record<string, unknown[]> = { rounds: [] };
15
16
  const updates: Record<string, unknown[]> = { pull_requests: [] };
16
17
  const rows: Record<string, Map<string, unknown>> = {};
18
+ let roundsId = 0;
17
19
  const app = {
18
20
  data: {
19
21
  table(name: string, _key: string) {
@@ -22,14 +24,25 @@ function fakeApp() {
22
24
  async get(key: string) {
23
25
  return store.get(key);
24
26
  },
27
+ async find(criteria: Record<string, unknown>) {
28
+ return [...store.values()].filter((r) =>
29
+ Object.entries(criteria).every(([k, v]) => (r as any)[k] === v),
30
+ );
31
+ },
25
32
  async insert(row: unknown) {
26
- (inserts[name] ??= []).push(row);
27
33
  const pk = name === "rounds" ? "id" : "pr_key";
34
+ // The rounds table has an AUTOINCREMENT id; mint one so find/update can key on it.
35
+ if (name === "rounds" && (row as any).id === undefined) {
36
+ (row as any).id = ++roundsId;
37
+ }
38
+ (inserts[name] ??= []).push(row);
28
39
  store.set((row as any)[pk], row);
29
40
  return 1;
30
41
  },
31
- async update(key: string, patch: unknown) {
42
+ async update(key: string, patch: Record<string, unknown>) {
32
43
  (updates[name] ??= []).push({ key, patch });
44
+ const existing = store.get(key);
45
+ if (existing) store.set(key, { ...(existing as object), ...patch });
33
46
  },
34
47
  };
35
48
  },
@@ -39,7 +52,7 @@ function fakeApp() {
39
52
  }
40
53
 
41
54
  for (const status of ["addressed", "waiting"]) {
42
- test(`persist-round records a '${status}' round and parks the PR in waiting_review`, async () => {
55
+ test(`persist-round records a '${status}' round and advances current_round without parking`, async () => {
43
56
  const { app, inserts, updates } = fakeApp();
44
57
  const job = { variables: { prKey: "o/r#1", round: 1, status, summary: `round was ${status}` } };
45
58
  await handler(job as any, app as any);
@@ -51,7 +64,11 @@ for (const status of ["addressed", "waiting"]) {
51
64
 
52
65
  assertEquals(updates.pull_requests!.length, 1, "the PR is updated once");
53
66
  const patch = (updates.pull_requests![0] as any).patch;
54
- assertEquals(patch.status, "waiting_review", "the PR parks in waiting_review for the poller");
67
+ // The park into `waiting_review` is owned by pr.progress-check (the single writer of the
68
+ // post-round wait status), NOT persist-round — persist-round runs before the husk decision, so
69
+ // parking here would race the poller against a husk retry (#786). It only advances the round.
70
+ assertEquals(patch.status, undefined, "persist-round does NOT park the PR in waiting_review");
71
+ assertEquals(patch.waiting_since, undefined, "persist-round does NOT stamp the review-wait start");
55
72
  assertEquals(patch.current_round, 1);
56
73
  });
57
74
  }
@@ -97,9 +114,11 @@ test("persist-round heals a missing pull_requests parent before recording the ro
97
114
  assertEquals(healed.status, "converging", "the healed parent starts in the converging aggregate");
98
115
  assertEquals(healed.url, "https://github.com/o/r/pull/7", "URL is derived canonically");
99
116
  assertEquals(inserts.rounds.length, 1, "the round is still recorded after the heal");
100
- // And the worker still parks the (now-present) PR in waiting_review as its final state.
117
+ // And the worker advances current_round on the (now-present) PR but does NOT park it in
118
+ // waiting_review (that is pr.progress-check's job now, #786).
101
119
  assertEquals(updates.pull_requests!.length, 1, "the PR is updated once after the heal");
102
- assertEquals((updates.pull_requests![0] as any).patch.status, "waiting_review");
120
+ assertEquals((updates.pull_requests![0] as any).patch.status, undefined, "no park in persist-round");
121
+ assertEquals((updates.pull_requests![0] as any).patch.current_round, 3);
103
122
  });
104
123
 
105
124
  // rather than writing a NULL status — the round history stays readable.
@@ -135,3 +154,151 @@ test("persist-round heals from the prKey when repo/prNumber are absent", async (
135
154
  "the running agent's abandon token is preserved from abandonUrl, not re-minted",
136
155
  );
137
156
  });
157
+
158
+ // Idempotent round recording (issue #786): a husk auto-retry re-enters `review-round` WITHOUT
159
+ // advancing the round counter, so pr.persist-round is reached again for the SAME (pr_key, round_no).
160
+ // The `rounds` table has no UNIQUE(pr_key, round_no), so the worker must UPSERT — update the existing
161
+ // row in place, never manufacture a duplicate history row that would corrupt the durable round
162
+ // history the cockpit and the no-progress guard both read.
163
+ test("persist-round is idempotent on (pr_key, round_no) — a retry updates, never duplicates", async () => {
164
+ const { app, inserts, updates } = fakeApp();
165
+ const first = { variables: { prKey: "o/r#1", round: 4, status: "addressed", summary: "first attempt" } };
166
+ await handler(first as any, app as any);
167
+ assertEquals(inserts.rounds.length, 1, "the first attempt inserts a round row");
168
+
169
+ // A husk retry: same round_no, a fresh summary/transcript.
170
+ const retry = { variables: { prKey: "o/r#1", round: 4, status: "addressed", summary: "retry attempt" } };
171
+ await handler(retry as any, app as any);
172
+ assertEquals(inserts.rounds.length, 1, "the retry does NOT insert a second round row");
173
+
174
+ const roundUpdate = (updates.rounds ?? []).at(-1) as any;
175
+ assertEquals(roundUpdate?.patch.summary, "retry attempt", "the retry updates the existing round in place");
176
+ assertEquals((inserts.rounds[0] as any).round_no, 4);
177
+ });
178
+
179
+ // Regression (issue #786): the idempotent upsert must reuse only a row THIS worker wrote — never an
180
+ // escalation row. On a needs_input/blocked escalation, pr.persist-escalation records a `rounds` row
181
+ // (status needs_input/blocked) for the SAME (pr_key, round_no); the human-answered resume re-enters
182
+ // that same numeric round and lands here. Blindly updating the newest matching row would overwrite
183
+ // the escalation row to `addressed`, ERASING the escalation attempt from the durable history. The
184
+ // resume must INSERT a fresh row so both the escalation and its resolution survive.
185
+ test("persist-round does NOT overwrite a same-round escalation row — it inserts the resumed attempt", async () => {
186
+ const { app, inserts, updates, rows } = fakeApp();
187
+ // Simulate pr.persist-escalation having recorded a needs_input round row for round 5.
188
+ const roundsStore = (rows.rounds ??= new Map());
189
+ roundsStore.set(101, {
190
+ id: 101,
191
+ pr_key: "o/r#1",
192
+ round_no: 5,
193
+ status: "needs_input",
194
+ summary: "escalated: which API shape?",
195
+ transcript: "escalation transcript",
196
+ started_at: "t0",
197
+ ended_at: "t0",
198
+ });
199
+
200
+ // The human answers; the same numeric round resumes and reaches persist-round as `addressed`.
201
+ const resume = { variables: { prKey: "o/r#1", round: 5, status: "addressed", summary: "resumed and pushed" } };
202
+ await handler(resume as any, app as any);
203
+
204
+ assertEquals(inserts.rounds.length, 1, "the resumed attempt inserts a NEW round row");
205
+ assertEquals((inserts.rounds[0] as any).status, "addressed", "the new row is the addressed resume");
206
+ // The escalation row is untouched — never updated to `addressed`.
207
+ const escalationTouched = (updates.rounds ?? []).some((u: any) => u.key === 101);
208
+ assertEquals(escalationTouched, false, "the needs_input escalation row is preserved, not overwritten");
209
+ assertEquals((roundsStore.get(101) as any).status, "needs_input", "the escalation row keeps its status");
210
+ });
211
+
212
+ // But a genuine husk retry (a prior pr.persist-round row, status addressed/waiting) is still reused
213
+ // in place — only escalation rows are excluded, so idempotency for the retry path is preserved even
214
+ // when an escalation row for the same round also exists.
215
+ test("persist-round reuses a prior addressed round-record row while skipping an escalation row", async () => {
216
+ const { app, inserts, updates, rows } = fakeApp();
217
+ const roundsStore = (rows.rounds ??= new Map());
218
+ // An escalation row AND a prior persist-round row for the same round.
219
+ roundsStore.set(200, { id: 200, pr_key: "o/r#1", round_no: 6, status: "blocked", summary: "blocked earlier" });
220
+ roundsStore.set(201, { id: 201, pr_key: "o/r#1", round_no: 6, status: "addressed", summary: "first addressed" });
221
+
222
+ const retry = { variables: { prKey: "o/r#1", round: 6, status: "addressed", summary: "husk retry" } };
223
+ await handler(retry as any, app as any);
224
+
225
+ assertEquals(inserts.rounds.length, 0, "no new row — the prior addressed row is reused");
226
+ const roundUpdate = (updates.rounds ?? []).at(-1) as any;
227
+ assertEquals(roundUpdate?.key, 201, "the addressed round-record row is updated, not the blocked escalation row");
228
+ assertEquals(roundUpdate?.patch.summary, "husk retry");
229
+ assertEquals((roundsStore.get(200) as any).status, "blocked", "the escalation row is left intact");
230
+ });
231
+
232
+ // Regression (issue #786): the idempotent upsert must be scoped to the writing RUN, not inferred
233
+ // from status. `submitPr` re-opens a previously converged/abandoned PR at round 1 WITHOUT deleting
234
+ // `rounds` history, so a fresh convergence run (a NEW process instance) at round 1 finds the prior
235
+ // run's `addressed`/`waiting`/`converged` round-1 row. Reusing it (its status is not human-hold)
236
+ // would clobber another run's canonical summary/transcript/worker/timestamps. Scoping reuse by the
237
+ // writing `process_instance_key` means the new run INSERTS a fresh row and the prior run's history
238
+ // survives verbatim.
239
+ test("persist-round scopes idempotency to the process instance — a resubmission inserts a fresh row", async () => {
240
+ const { app, inserts, updates, rows } = fakeApp();
241
+ const roundsStore = (rows.rounds ??= new Map());
242
+ // A prior run's round-1 row (its own process instance) with real history.
243
+ roundsStore.set(300, {
244
+ id: 300,
245
+ pr_key: "o/r#1",
246
+ round_no: 1,
247
+ status: "converged",
248
+ summary: "prior run summary",
249
+ transcript: "prior run transcript",
250
+ worker: "senior",
251
+ process_instance_key: "proc-OLD",
252
+ started_at: "t0",
253
+ ended_at: "t0",
254
+ });
255
+
256
+ // A resubmission: submitPr restarts convergence at round 1 in a NEW process instance.
257
+ const resubmit = {
258
+ processInstanceKey: "proc-NEW",
259
+ variables: { prKey: "o/r#1", round: 1, status: "addressed", summary: "fresh run" },
260
+ };
261
+ await handler(resubmit as any, app as any);
262
+
263
+ assertEquals(inserts.rounds.length, 1, "the resubmission inserts its OWN round row");
264
+ assertEquals((inserts.rounds[0] as any).process_instance_key, "proc-NEW", "the new row carries the new run's key");
265
+ const priorTouched = (updates.rounds ?? []).some((u: any) => u.key === 300);
266
+ assertEquals(priorTouched, false, "the prior run's round-1 row is never updated");
267
+ assertEquals((roundsStore.get(300) as any).summary, "prior run summary", "the prior run's history is intact");
268
+ });
269
+
270
+ // But a husk retry WITHIN the same run (same process instance key, same round_no) is still reused in
271
+ // place — process-instance scoping preserves husk-retry idempotency, it does not disable it.
272
+ test("persist-round reuses the same-process-instance row on a husk retry", async () => {
273
+ const { app, inserts, updates, rows } = fakeApp();
274
+ const roundsStore = (rows.rounds ??= new Map());
275
+ // This run's own round-4 row, plus an UNRELATED prior run's round-4 row.
276
+ roundsStore.set(400, {
277
+ id: 400,
278
+ pr_key: "o/r#1",
279
+ round_no: 4,
280
+ status: "addressed",
281
+ summary: "other run",
282
+ process_instance_key: "proc-OTHER",
283
+ });
284
+ roundsStore.set(401, {
285
+ id: 401,
286
+ pr_key: "o/r#1",
287
+ round_no: 4,
288
+ status: "addressed",
289
+ summary: "this run first attempt",
290
+ process_instance_key: "proc-THIS",
291
+ });
292
+
293
+ const retry = {
294
+ processInstanceKey: "proc-THIS",
295
+ variables: { prKey: "o/r#1", round: 4, status: "addressed", summary: "this run husk retry" },
296
+ };
297
+ await handler(retry as any, app as any);
298
+
299
+ assertEquals(inserts.rounds.length, 0, "no new row — this run's own row is reused");
300
+ const roundUpdate = (updates.rounds ?? []).at(-1) as any;
301
+ assertEquals(roundUpdate?.key, 401, "the reused row is THIS run's row, not the other run's");
302
+ assertEquals(roundUpdate?.patch.summary, "this run husk retry");
303
+ assertEquals((roundsStore.get(400) as any).summary, "other run", "the unrelated run's row is untouched");
304
+ });
@@ -33,7 +33,7 @@ import {
33
33
  const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
34
34
  const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
35
35
 
36
- const READ_MODEL_MIGRATION = "094_pull_requests_read_model.sql";
36
+ const READ_MODEL_MIGRATION = "104_pull_requests_read_model_progress_idempotency.sql";
37
37
 
38
38
  // The real base `pull_requests` columns, in schema order — DERIVED from the migration chain (not a
39
39
  // hand-kept list that could silently omit one), used by both the drift guard and the e2e stand-in.
@@ -13,6 +13,13 @@ import {
13
13
  reviewWaitTimeout,
14
14
  } from "./reviewWait.ts";
15
15
 
16
+ test("DEFAULT_REVIEW_WAIT_TIMEOUT: pins the user-visible default to PT30M", () => {
17
+ // A direct regression guard on the literal default — the fallback-based assertions below compare
18
+ // against DEFAULT_REVIEW_WAIT_TIMEOUT itself, so they would still pass if it were accidentally
19
+ // reverted to PT20M. This anchors the intended value so that change is caught.
20
+ assertEquals(DEFAULT_REVIEW_WAIT_TIMEOUT, "PT30M");
21
+ });
22
+
16
23
  test("reviewWaitTimeout: blank / absent / malformed → default", () => {
17
24
  assertEquals(reviewWaitTimeout(undefined), DEFAULT_REVIEW_WAIT_TIMEOUT);
18
25
  assertEquals(reviewWaitTimeout(""), DEFAULT_REVIEW_WAIT_TIMEOUT);
package/app/reviewWait.ts CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  /** Default review-wait timeout (ISO-8601 duration): how long the loop waits for a fresh review
14
14
  * before the timer arm of the event-based gateway fires and it escalates to a human. */
15
- export const DEFAULT_REVIEW_WAIT_TIMEOUT = "PT20M";
15
+ export const DEFAULT_REVIEW_WAIT_TIMEOUT = "PT30M";
16
16
 
17
17
  // A pragmatic ISO-8601 duration matcher: requires a leading `P`, at least one component, and a
18
18
  // `T` before any time components (with at least one time component after it). Good enough to