@nanobpm/nano-workforce 0.207.0 → 0.208.0

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 CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.208.0](https://github.com/nanobpm/nano-workforce/compare/v0.207.0...v0.208.0) (2026-11-10)
2
+
3
+ ### Features
4
+
5
+ * **merge:** request the head-current Copilot review after a loop-authored head move ([#941](https://github.com/nanobpm/nano-workforce/issues/941)) ([0e2d50e](https://github.com/nanobpm/nano-workforce/commit/0e2d50e8610beb5e939cd3a143d28e62d690f272)), closes [#939](https://github.com/nanobpm/nano-workforce/issues/939)
6
+
1
7
  ## [0.207.0](https://github.com/nanobpm/nano-workforce/compare/v0.206.4...v0.207.0) (2026-11-09)
2
8
 
3
9
  ### Features
package/SPEC.md CHANGED
@@ -855,6 +855,24 @@ start ─► wait: deps merged ─► arm merge ─► wait: mergeable ─┬─
855
855
  sets `ciBlockedReconciled`), and escalates only if the PR is **still** blocked on the
856
856
  re-derived state.
857
857
 
858
+ - **Head-current review after a loop-authored head move — request it, don't escalate**
859
+ (issue #939) — `pr.merge` refuses to land without a Copilot review of the PR's
860
+ current HEAD (#923 backstop). When the loop's own rebase / merge-from-base / fix push
861
+ moved the head, the only remedy is mechanical, so instead of escalating the worker
862
+ returns `mergeStatus: "review-pending"` and the model **requests the review itself**
863
+ (`pr.request-review` — REST `requested_reviewers` POST of
864
+ `copilot-pull-request-reviewer[bot]`; on a re-request whose prior ask is still pending,
865
+ i.e. silently ignored, it withdraws then re-adds the reviewer), waits on a timer
866
+ (`reviewRequestWait`, derived from the review-nudge window) and re-arms the poller so
867
+ the backstop re-checks the new head. It repeats up to `reviewRequestMax` (3) requests;
868
+ only when no review arrives in that budget — or Copilot is not an assignable reviewer —
869
+ does it escalate, with a question stating what was tried. A head-current review whose
870
+ summary reports open findings returns `mergeStatus: "review-findings"`: `pr.reconverge`
871
+ hands the PR back to the convergence loop through the canonical `submitPr` (declaring
872
+ the owning merge-loop instance as `handoffFrom`) and the merge loop ends — the fresh
873
+ convergence run fixes, pushes, re-reviews and starts a new merge loop on convergence.
874
+ An *unreadable* review/head still fails closed to a human (#927).
875
+
858
876
  - **Discovered dependency** — a `senior:fix-ci` or `senior:rebase` agent may find that
859
877
  the PR cannot land because **another PR must merge first** (a required linked-issue
860
878
  gate a sibling PR will close, a stacked base PR, or a `Depends-on:` the agent read
package/app/github.ts CHANGED
@@ -743,6 +743,35 @@ export async function requestCopilotReview(
743
743
  throw new Error(`github ${r.status} ${r.statusText}: ${(await r.text()).slice(0, 300)}`.trim());
744
744
  }
745
745
 
746
+ /** Withdraw a pending Copilot review request (REST reviewers DELETE). Used by the merge loop's
747
+ * head-current review request (issue #939) when a prior request was silently ignored: Copilot stays
748
+ * listed as pending but never reviews, and a second POST is a no-op — removing and re-adding it is
749
+ * the equivalent that re-triggers the review. `null` when no transport is usable; throws on a
750
+ * genuine transport failure. */
751
+ export async function removeCopilotReviewer(
752
+ repo: string,
753
+ number: number | string,
754
+ token: string,
755
+ ): Promise<"removed" | null> {
756
+ const path = `repos/${repo}/pulls/${number}/requested_reviewers`;
757
+ if (await useGh()) {
758
+ await runGh(["api", path, "-X", "DELETE", "-f", `reviewers[]=${COPILOT_REVIEWER}`]);
759
+ return "removed";
760
+ }
761
+ if (!token) return null;
762
+ const r = await fetch(`https://api.github.com/${path}`, {
763
+ method: "DELETE",
764
+ headers: {
765
+ authorization: `Bearer ${token}`,
766
+ accept: "application/vnd.github+json",
767
+ "content-type": "application/json",
768
+ },
769
+ body: JSON.stringify({ reviewers: [COPILOT_REVIEWER] }),
770
+ });
771
+ if (r.ok) return "removed";
772
+ throw new Error(`github ${r.status} ${r.statusText}: ${(await r.text()).slice(0, 300)}`.trim());
773
+ }
774
+
746
775
  // ── Merge stage (SPEC §11) ──────────────────────────────────────────────────
747
776
  // The same two-transport model (gh | token) backs the merge stage: read a PR's merge state to
748
777
  // decide when it is landable, and perform the merge (directly or via the repo's merge queue).
@@ -43,7 +43,8 @@ const MODEL = readFileSync("resources/processes/merge-loop.bpmn", "utf8");
43
43
  const AGENT_SLA_MS = 30 * 60 * 1000; // matches the PT30M we start instances with
44
44
  const LANDED_WAIT_MS = 30 * 60 * 1000; // matches the PT30M landedWaitTimeout we start instances with
45
45
  const MERGEABLE_WAIT_MS = 30 * 60 * 1000; // matches the PT30M mergeableWaitTimeout we start instances with
46
- const MERGEABLE_REPOLL_MS = 2 * 60 * 1000; // matches the PT2M mergeableRepollInterval we start instances with
46
+ const MERGEABLE_REPOLL_MS = 2 * 60 * 1000;
47
+ const REVIEW_REQUEST_WAIT_MS = 10 * 60 * 1000; // matches the PT10M reviewRequestWait we start instances with // matches the PT2M mergeableRepollInterval we start instances with
47
48
 
48
49
  type Output = Record<string, unknown>;
49
50
  type Responder = Output | Output[] | ((job: { variables: Record<string, unknown> }) => Output);
@@ -59,6 +60,8 @@ const ALL_JOB_TYPES = [
59
60
  "pr.persist-escalation",
60
61
  "pr.answer-escalation",
61
62
  "pr.record-dependency",
63
+ "pr.request-review",
64
+ "pr.reconverge",
62
65
  ] as const;
63
66
 
64
67
  const DEFAULT_RESPONSES: Record<string, Responder> = {
@@ -68,6 +71,8 @@ const DEFAULT_RESPONSES: Record<string, Responder> = {
68
71
  "pr.answer-escalation": {},
69
72
  "pr.persist-escalation": { escalated: true },
70
73
  "pr.landed-probe": { landedVerdict: "waiting" },
74
+ "pr.request-review": { reviewRequest: "requested" },
75
+ "pr.reconverge": {},
71
76
  };
72
77
 
73
78
  // Every FEEL expression in the model references these; start them defined (null,
@@ -91,6 +96,10 @@ const DEFAULT_VARS: Record<string, unknown> = {
91
96
  mergeableRepollInterval: "PT2M",
92
97
  mergeStallRounds: 0,
93
98
  mergeStallMax: 3,
99
+ reviewRequestRound: 0,
100
+ reviewRequestMax: 3,
101
+ reviewRequestWait: "PT10M",
102
+ reviewRequest: null,
94
103
  abandonBrief: "\n\n---\nAbort if cancelled: curl …",
95
104
  failingChecksList: null,
96
105
  status: null,
@@ -413,6 +422,59 @@ test("a fix-ci agent echoing abandonBrief cannot corrupt the seeded abort brief
413
422
  .hasVariable("abandonBrief", "\n\n---\nAbort if cancelled: curl …");
414
423
  });
415
424
 
425
+ test("a fix-ci agent echoing review-request state cannot clobber the seeded budget or timer (#941 round 4)", async () => {
426
+ // Same guard class as the #887 echoes: `reviewRequestRound`/`reviewRequestMax` bound the
427
+ // head-current review re-request loop (`reviewRequestRound < reviewRequestMax` gates a retry) and
428
+ // `reviewRequestWait` arms its timer. An agent completion echoing `reviewRequestMax: 0` would send
429
+ // the FIRST missing-review verdict straight to a human escalation, and a clobbered
430
+ // `reviewRequestWait` would reshape the wait. The snapshot/restore guard must pin all four.
431
+ const engine = await boot({
432
+ responses: {
433
+ "pr.merge-stall-probe": { mergeState: "blocked", failingChecks: 1, failingChecksList: "build" },
434
+ "senior:fix-ci": {
435
+ status: "fixed",
436
+ reviewRequestRound: 99,
437
+ reviewRequestMax: 0,
438
+ reviewRequestWait: "PT1S",
439
+ reviewRequest: "unavailable",
440
+ },
441
+ },
442
+ });
443
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
444
+ await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
445
+ assertThatInstance(engine, byProcessId("merge-loop"))
446
+ .isActive()
447
+ .hasCompletedElements("merge-stall-probe", "fix-ci")
448
+ .hasVariable("reviewRequestRound", 0)
449
+ .hasVariable("reviewRequestMax", 3)
450
+ .hasVariable("reviewRequestWait", "PT10M");
451
+ assert(instanceVars(engine).reviewRequest !== "unavailable", "a fix-ci agent must not forge reviewRequest:unavailable");
452
+ });
453
+
454
+ test("a rebase agent echoing review-request state cannot clobber the seeded budget or timer (#941 round 4)", async () => {
455
+ const engine = await boot({
456
+ responses: {
457
+ "pr.merge-stall-probe": { mergeState: "conflict", failingChecks: 0, failingChecksList: "" },
458
+ "senior:rebase": {
459
+ status: "rebased",
460
+ reviewRequestRound: 99,
461
+ reviewRequestMax: 0,
462
+ reviewRequestWait: "PT1S",
463
+ reviewRequest: "unavailable",
464
+ },
465
+ },
466
+ });
467
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
468
+ await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
469
+ assertThatInstance(engine, byProcessId("merge-loop"))
470
+ .isActive()
471
+ .hasCompletedElements("rebase")
472
+ .hasVariable("reviewRequestRound", 0)
473
+ .hasVariable("reviewRequestMax", 3)
474
+ .hasVariable("reviewRequestWait", "PT10M");
475
+ assert(instanceVars(engine).reviewRequest !== "unavailable", "a rebase agent must not forge reviewRequest:unavailable");
476
+ });
477
+
416
478
  test("a blocked PR probed on timeout reaches the CI-fix arm via the timeout path (#636)", async () => {
417
479
  const engine = await boot({
418
480
  responses: {
@@ -908,6 +970,79 @@ test("a worker-supplied block question (review backstop #923) reaches the human
908
970
  assert(!q.includes("Investigate why GitHub refused"), "must not be overwritten by the generic refusal text");
909
971
  });
910
972
 
973
+ // ---------------------------------------------------------------------------
974
+ // Head-current review after a loop-authored head move (issue #939)
975
+ // ---------------------------------------------------------------------------
976
+
977
+ async function driveToAttempt(engine: WasmEngineClient): Promise<void> {
978
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
979
+ await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
980
+ }
981
+
982
+ test("#939: a missing head-current review requests one itself, waits, and merges on the re-check — no human", async () => {
983
+ const engine = await boot({ responses: { "pr.merge": [{ mergeStatus: "review-pending" }, { mergeStatus: "merged" }] } });
984
+ await driveToAttempt(engine);
985
+ assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-review-requested");
986
+ assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("request-review");
987
+ assertEquals(instanceVars(engine).reviewRequestRound, 1);
988
+ await engine.advanceTime(REVIEW_REQUEST_WAIT_MS + 1);
989
+ assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable");
990
+ await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
991
+ assertThatInstance(engine, byProcessId("merge-loop")).hasCompleted().hasCompletedElements("mark-merged");
992
+ assert(!completedElementIds(engine).has("merge-esc-attempt"), "an automatable review gap must never escalate");
993
+ });
994
+
995
+ test("#939 defect-class guard: no automatable merge-gate refusal escalates to a human on its first occurrence", async () => {
996
+ for (const mergeStatus of ["review-pending", "review-findings"]) {
997
+ const engine = await boot({ responses: { "pr.merge": { mergeStatus } } });
998
+ await driveToAttempt(engine);
999
+ const done = completedElementIds(engine);
1000
+ assert(!done.has("merge-esc-attempt") && !done.has("merge-esc-conflict"), `${mergeStatus}: must not escalate`);
1001
+ const open = await engine.searchUserTasks({});
1002
+ assertEquals(open.length, 0, `${mergeStatus}: no human task on the first occurrence`);
1003
+ }
1004
+ });
1005
+
1006
+ test("#939: a head-current review with findings re-enters convergence and ends the merge loop", async () => {
1007
+ const engine = await boot({ responses: { "pr.merge": { mergeStatus: "review-findings" } } });
1008
+ await driveToAttempt(engine);
1009
+ assertThatInstance(engine, byProcessId("merge-loop")).hasCompleted().hasCompletedElements("reconverge", "MergeReconverged");
1010
+ assert(!completedElementIds(engine).has("mark-merged"), "findings must not merge");
1011
+ });
1012
+
1013
+ test("#939: a review that never arrives escalates after the bounded request budget, stating what was tried", async () => {
1014
+ const engine = await boot({
1015
+ responses: { "pr.merge": { mergeStatus: "review-pending" } },
1016
+ vars: { reviewRequestMax: 1 },
1017
+ });
1018
+ await driveToAttempt(engine);
1019
+ await engine.advanceTime(REVIEW_REQUEST_WAIT_MS + 1);
1020
+ await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
1021
+ await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
1022
+ const q = String(escalation(engine).question ?? "");
1023
+ assertStringIncludes(q, "requested a Copilot review of the current HEAD 1 time(s)");
1024
+ assertStringIncludes(q, "PT10M");
1025
+ });
1026
+
1027
+ test("#939: a clean re-check resets the request budget so a later head move gets a fresh budget", async () => {
1028
+ const engine = await boot({
1029
+ responses: { "pr.merge": [{ mergeStatus: "review-pending" }, { mergeStatus: "retry" }, { mergeStatus: "merged" }] },
1030
+ });
1031
+ await driveToAttempt(engine);
1032
+ await engine.advanceTime(REVIEW_REQUEST_WAIT_MS + 1);
1033
+ await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
1034
+ assertEquals(instanceVars(engine).reviewRequestRound, 0);
1035
+ });
1036
+
1037
+ test("#939: Copilot not assignable escalates immediately, naming the cause", async () => {
1038
+ const engine = await boot({
1039
+ responses: { "pr.merge": { mergeStatus: "review-pending" }, "pr.request-review": { reviewRequest: "unavailable" } },
1040
+ });
1041
+ await driveToAttempt(engine);
1042
+ await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
1043
+ assertStringIncludes(String(escalation(engine).question ?? ""), "not an assignable reviewer");
1044
+ });
1045
+
911
1046
  // ---------------------------------------------------------------------------
912
1047
  // CI-fix sub-process (SP_cifix) — mergeCiReattempt (#134), #329
913
1048
  // ---------------------------------------------------------------------------
@@ -2563,3 +2563,152 @@ test("pollCapabilityGatesImpl: scopes the barrier subscription search server-sid
2563
2563
  assertEquals(published.length, 1, "the scoped search still finds THIS task's subscription and releases it");
2564
2564
  assertEquals(published[0]?.correlationKey, barrierKey);
2565
2565
  });
2566
+
2567
+ // Issue #939: the merge loop hands a PR with head-current review findings back to convergence via
2568
+ // `submitPr`. The OWNING merge-loop instance (the row's `process_key`) is live while it calls, so the
2569
+ // hand-off must bypass the alreadyRunning gate — but ONLY for that exact owner; any other caller still
2570
+ // dedupes against the live merge loop.
2571
+ test("submitPr handoffFrom: only the owning merge-loop instance may re-enter convergence (#939)", async () => {
2572
+ await withGithubOff(async () => {
2573
+ const PR_KEY = "owner/repo#77";
2574
+ const mk = () => {
2575
+ const stores: Record<string, { rows: any[]; key: string }> = {
2576
+ pull_requests: {
2577
+ rows: [{ pr_key: PR_KEY, repo: "owner/repo", number: 77, url: "https://github.com/owner/repo/pull/77", title: "t", status: "waiting_merge", current_round: 4, process_key: "PI-MERGE" }],
2578
+ key: "pr_key",
2579
+ },
2580
+ escalations: { rows: [], key: "id" },
2581
+ pr_adjudications: { rows: [], key: "id" },
2582
+ pr_dependencies: { rows: [], key: "pr_key" },
2583
+ };
2584
+ const data = { table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)), open: () => memOpen(stores) } as any;
2585
+ return { stores, data };
2586
+ };
2587
+ let calls = 0;
2588
+ const engine = { createInstance: async () => ({ processInstanceKey: `PI-CONV-${++calls}` }) } as any;
2589
+ const parsed = { repo: "owner/repo", number: 77, url: "https://github.com/owner/repo/pull/77", prKey: PR_KEY };
2590
+
2591
+ const a = mk();
2592
+ const plain = await submitPr(a.data, engine, parsed);
2593
+ assertEquals((plain as any).alreadyRunning, true, "a plain submit dedupes against the live merge loop");
2594
+ const foreign = await submitPr(a.data, engine, parsed, [], undefined, false, null, false, false, true, "PI-OTHER");
2595
+ assertEquals((foreign as any).alreadyRunning, true, "a non-owner hand-off still dedupes");
2596
+ assertEquals(calls, 0);
2597
+
2598
+ const owned = await submitPr(a.data, engine, parsed, [], undefined, false, null, false, false, true, "PI-MERGE");
2599
+ assertEquals((owned as any).alreadyRunning, undefined);
2600
+ assertEquals(calls, 1);
2601
+ const row = a.stores.pull_requests.rows[0];
2602
+ assertEquals(row.status, "converging");
2603
+ assertEquals(row.process_key, "PI-CONV-1");
2604
+ });
2605
+ });
2606
+
2607
+ // Issue #941 review: a redelivered `pr.reconverge` can arrive AFTER its successor has already
2608
+ // settled the PR (merged/abandoned). The stale job still carries the OLD merge-loop instance key as
2609
+ // `handoffFrom`, and the settled row's `process_key` still names that same instance, so an owner
2610
+ // match ALONE would let the bypass treat the dead job as the row's live owner and restart
2611
+ // convergence, clearing the settled state. The internal hand-off channel is fenced against a
2612
+ // TERMINAL row outright: it dedupes idempotently and never resubmits. Only a DELIBERATE PUBLIC
2613
+ // submission (no `handoffFrom`) may re-open a settled PR.
2614
+ test("submitPr handoffFrom: a stale hand-off against a settled row is fenced, a public resubmit still re-opens (#941)", async () => {
2615
+ await withGithubOff(async () => {
2616
+ const PR_KEY = "owner/repo#78";
2617
+ const mk = () => {
2618
+ const stores: Record<string, { rows: any[]; key: string }> = {
2619
+ pull_requests: {
2620
+ // The successor convergence run MERGED the PR; the row is terminal (`merged`) but its
2621
+ // `process_key` still names the OLD merge-loop instance the redelivered job belongs to.
2622
+ rows: [{ pr_key: PR_KEY, repo: "owner/repo", number: 78, url: "https://github.com/owner/repo/pull/78", title: "t", status: "merged", current_round: 5, process_key: "PI-MERGE" }],
2623
+ key: "pr_key",
2624
+ },
2625
+ escalations: { rows: [], key: "id" },
2626
+ pr_adjudications: { rows: [], key: "id" },
2627
+ pr_dependencies: { rows: [], key: "pr_key" },
2628
+ };
2629
+ const data = { table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)), open: () => memOpen(stores) } as any;
2630
+ return { stores, data };
2631
+ };
2632
+ const parsed = { repo: "owner/repo", number: 78, url: "https://github.com/owner/repo/pull/78", prKey: PR_KEY };
2633
+
2634
+ // The stale hand-off (owner key matches, but the row is terminal) is FENCED: it dedupes
2635
+ // idempotently, mints no instance, and leaves the settled row byte-for-byte untouched.
2636
+ const a = mk();
2637
+ let calls = 0;
2638
+ const engine = { createInstance: async () => ({ processInstanceKey: `PI-CONV-${++calls}` }) } as any;
2639
+ const stale = await submitPr(a.data, engine, parsed, [], undefined, false, null, false, false, true, "PI-MERGE");
2640
+ assertEquals((stale as any).alreadyRunning, true, "a stale hand-off against a terminal row dedupes instead of re-entering convergence");
2641
+ assertEquals(calls, 0, "no fresh instance is minted for the stale hand-off");
2642
+ assertEquals(a.stores.pull_requests.rows[0].status, "merged", "the settled row's state is untouched");
2643
+ assertEquals(a.stores.pull_requests.rows[0].process_key, "PI-MERGE", "the settled row's owner is untouched");
2644
+
2645
+ // A deliberate PUBLIC resubmission of the same settled PR (no handoffFrom) still re-opens it —
2646
+ // the fence only closes the internal hand-off channel, never the terminal→resubmittable path.
2647
+ const b = mk();
2648
+ const resubmit = await submitPr(b.data, engine, parsed);
2649
+ assertEquals((resubmit as any).alreadyRunning, undefined);
2650
+ assertEquals(calls, 1);
2651
+ assertEquals(b.stores.pull_requests.rows[0].status, "converging");
2652
+ assertEquals(b.stores.pull_requests.rows[0].process_key, "PI-CONV-1");
2653
+ });
2654
+ });
2655
+
2656
+ // Issue #941 review: if the adjudication reset fails AFTER the atomic reopen advanced `process_key`
2657
+ // to the new convergence instance, the rollback cancels that instance — but leaving its key on the
2658
+ // row would make the cancelled run the row's "owner". Once tracking derives `abandoned`, the stale
2659
+ // hand-off fence would treat the reconverge retry as settled and end the merge loop with no live
2660
+ // convergence owner. The rollback must RESTORE the prior row (owner key + status), so the retry is
2661
+ // still the owning hand-off and re-enters convergence.
2662
+ test("submitPr handoffFrom: a failed reset restores the prior owner so the hand-off retry re-enters (#941)", async () => {
2663
+ await withGithubOff(async () => {
2664
+ const PR_KEY = "owner/repo#79";
2665
+ const stores: Record<string, { rows: any[]; key: string }> = {
2666
+ pull_requests: {
2667
+ rows: [{ pr_key: PR_KEY, repo: "owner/repo", number: 79, url: "https://github.com/owner/repo/pull/79", title: "t", status: "waiting_merge", current_round: 4, process_key: "PI-MERGE" }],
2668
+ key: "pr_key",
2669
+ },
2670
+ escalations: { rows: [], key: "id" },
2671
+ pr_adjudications: { rows: [], key: "id" },
2672
+ pr_dependencies: { rows: [], key: "pr_key" },
2673
+ };
2674
+ let failReset = true;
2675
+ const open = () => {
2676
+ const real = memOpen(stores);
2677
+ return {
2678
+ ...real,
2679
+ exec: async (sql: string, ...rest: unknown[]) => {
2680
+ if (failReset && /DELETE FROM "pr_adjudications"/.test(sql)) throw new Error("boom: reset failed");
2681
+ return (real as any).exec(sql, ...rest);
2682
+ },
2683
+ };
2684
+ };
2685
+ const data = { table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)), open } as any;
2686
+ let calls = 0;
2687
+ const cancelled: string[] = [];
2688
+ const engine = {
2689
+ createInstance: async () => ({ processInstanceKey: `PI-CONV-${++calls}` }),
2690
+ cancelInstance: async (i: { processInstanceKey: string }) => {
2691
+ cancelled.push(i.processInstanceKey);
2692
+ },
2693
+ } as any;
2694
+ const parsed = { repo: "owner/repo", number: 79, url: "https://github.com/owner/repo/pull/79", prKey: PR_KEY };
2695
+
2696
+ let threw = false;
2697
+ try {
2698
+ await submitPr(data, engine, parsed, [], undefined, false, null, false, false, true, "PI-MERGE");
2699
+ } catch {
2700
+ threw = true;
2701
+ }
2702
+ assertEquals(threw, true);
2703
+ assertEquals(cancelled, ["PI-CONV-1"]);
2704
+ const row = stores.pull_requests.rows[0];
2705
+ assertEquals(row.process_key, "PI-MERGE", "rollback restores the prior owner, never leaves the cancelled key");
2706
+ assertEquals(row.status, "waiting_merge", "rollback restores the prior status");
2707
+ assertEquals(row.current_round, 4);
2708
+
2709
+ failReset = false;
2710
+ const retry = await submitPr(data, engine, parsed, [], undefined, false, null, false, false, true, "PI-MERGE");
2711
+ assertEquals((retry as any).alreadyRunning, undefined, "the retried hand-off re-enters convergence");
2712
+ assertEquals(stores.pull_requests.rows[0].process_key, "PI-CONV-2");
2713
+ });
2714
+ });
package/app/service.ts CHANGED
@@ -288,6 +288,14 @@ export const MERGEABLE_REPOLL_INTERVAL = mergeableRepollInterval(
288
288
  * to one attempt per window. Set via `NANO_PR_REVIEW_NUDGE_MINUTES` (minutes). */
289
289
  export const REVIEW_NUDGE_MS = clampNudgeMinutes(process.env.NANO_PR_REVIEW_NUDGE_MINUTES) * 60_000;
290
290
 
291
+ /** Merge-loop head-current review request (issue #939). When the merge backstop finds no
292
+ * head-current Copilot review (the loop's own rebase/fix push moved the head), the merge loop
293
+ * requests the review itself and waits `REVIEW_REQUEST_WAIT` before re-checking — derived from the
294
+ * SAME nudge window the convergence poller uses, so there is one knob for "how long to give Copilot".
295
+ * It re-requests up to `MAX_REVIEW_REQUESTS` times before escalating (stating what was tried). */
296
+ export const REVIEW_REQUEST_WAIT = `PT${Math.round(REVIEW_NUDGE_MS / 60_000)}M`;
297
+ export const MAX_REVIEW_REQUESTS = 3;
298
+
291
299
  /** Grace window (ms) before the `escalated`→`running` self-heal (below) may act on a row. The sole
292
300
  * writer of `status="escalated"` — `record-feature-escalation` — runs on the `escalated` arm and stamps
293
301
  * `updated_at` IMMEDIATELY BEFORE the engine creates the `feature-escalation` user task. A poll landing
@@ -632,9 +640,11 @@ export async function submitPr(
632
640
  humanApproval = false,
633
641
  autoResolveEscalations = false,
634
642
  autoMerge?: boolean,
643
+ handoffFrom: string | null = null,
644
+ headShaOverride: string | null = null,
635
645
  ) {
636
646
  return withPrSubmitLock(parsed.prKey, () =>
637
- submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey, humanApproval, autoResolveEscalations, autoMerge),
647
+ submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey, humanApproval, autoResolveEscalations, autoMerge, handoffFrom, headShaOverride),
638
648
  );
639
649
  }
640
650
 
@@ -649,6 +659,8 @@ async function submitPrCritical(
649
659
  humanApproval = false,
650
660
  autoResolveEscalations = false,
651
661
  autoMerge?: boolean,
662
+ handoffFrom: string | null = null,
663
+ headShaOverride: string | null = null,
652
664
  ) {
653
665
  // Merge intent (issue #916): `autoMerge` is the CANONICAL positive form end to end — the same name
654
666
  // the feature/convergence-loop/plan-fanout doors accept. The legacy inverted `convergeOnly`
@@ -680,7 +692,28 @@ async function submitPrCritical(
680
692
  // would skip it as non-terminal, never re-enrolling). Treat a keyless non-terminal row as
681
693
  // RESUBMITTABLE — fall through and re-enroll, exactly as `startFeature`'s intake guard (feature.ts)
682
694
  // treats a keyless `running` feature row. A terminal row (any `process_key`) already falls through.
683
- if (trackedExisting && existing?.process_key != null && !TERMINAL_STATUSES.includes(trackedExisting.derived_status)) {
695
+ // Merge-loop → convergence hand-back (issue #939): the merge-loop instance that currently OWNS the
696
+ // row (`process_key`) re-enters convergence when a head-current review reports findings. That
697
+ // instance is live (it is the caller, and ends right after this submit), so it must not read as
698
+ // "already running" — only the exact owning instance may hand back; any other key still dedupes.
699
+ // The bypass additionally requires the row to be LIVE (non-terminal, #941 review): a redelivered
700
+ // `pr.reconverge` can arrive after its successor has already settled the PR (merged/abandoned),
701
+ // and the settled row's `process_key` still names that stale job's instance — an owner match alone
702
+ // would let the dead hand-off restart convergence and clear the settled state. So an internal
703
+ // hand-off against a TERMINAL row is fenced off entirely (below): it dedupes as `alreadyRunning`
704
+ // instead of resubmitting. Only a DELIBERATE PUBLIC submission (no `handoffFrom`) may re-open a
705
+ // settled PR — the internal hand-off channel never may. A partially-failed hand-off's retry
706
+ // against a keyless NON-terminal row still works (that row is not terminal, so it falls through
707
+ // and re-enrolls via the phantom-row path).
708
+ const trackedTerminal = trackedExisting != null && TERMINAL_STATUSES.includes(trackedExisting.derived_status);
709
+ const handingBack =
710
+ handoffFrom != null && existing?.process_key != null && existing.process_key === handoffFrom && !trackedTerminal;
711
+ if (handoffFrom != null && trackedTerminal) {
712
+ // Stale internal hand-off against a settled row: report the existing (terminal) owner without
713
+ // minting a fresh run or touching the settled state. The redelivered job completes idempotently.
714
+ return { prKey: parsed.prKey, alreadyRunning: true };
715
+ }
716
+ if (!handingBack && trackedExisting && existing?.process_key != null && !trackedTerminal) {
684
717
  // Human approval is monotonic (issue #826): a gated caller adopting a live loop — possibly an
685
718
  // ungated one another run started — narrows it to gated instead of inheriting its auto-merge.
686
719
  // Idempotent, so a retry against our own gated loop is a no-op. A loop the engine no longer reports
@@ -801,7 +834,14 @@ async function submitPrCritical(
801
834
  // fleet advertising `durable-resume`: with no participant it stays null, so the round redrives from
802
835
  // scratch (graceful degradation). Absent (null) on a first submit, which leaves the envelope
803
836
  // unchanged.
804
- const worldSha = await worldRestoreSha(data, parsed.prKey);
837
+ //
838
+ // `headShaOverride` (#941 round-4 review) WINS over the checkpoint: the checkpoint is written only
839
+ // by the convergence loop's `pr.persist-round`, so a merge-stage rebase/fix-ci push advances the PR
840
+ // head WITHOUT updating it. A merge→convergence hand-off (`pr.reconverge`) that restored the
841
+ // checkpoint would provision the fresh run at the PRE-MERGE tree, not the head whose review raised
842
+ // the findings — so the hand-off passes the live head SHA it read, and a fresh hand-off restores
843
+ // the CURRENT head. `null` (every other caller) keeps the checkpoint resume.
844
+ const worldSha = headShaOverride ?? (await worldRestoreSha(data, parsed.prKey));
805
845
  const { processInstanceKey } = await engine.createInstance({
806
846
  processDefinitionId: PROCESS_ID,
807
847
  variables: {
@@ -877,6 +917,11 @@ async function submitPrCritical(
877
917
  // run auto-applies stale decisions, and (crucially) the row stays in its OLD terminal shape until
878
918
  // the atomic reopen write lands, so no straggler ever sees a fresh-run row still owned by the old
879
919
  // instance.
920
+ let reopened = false;
921
+ // Snapshot BEFORE the reopen write — the row object may be a live reference the update mutates.
922
+ const prior = existing
923
+ ? { status: existing.status, current_round: existing.current_round, process_key: existing.process_key }
924
+ : null;
880
925
  try {
881
926
  if (existing) {
882
927
  // ATOMIC REOPEN (Copilot review of #819): reset the row to the fresh run's shape AND publish the
@@ -931,6 +976,7 @@ async function submitPrCritical(
931
976
  process_key: processKey,
932
977
  updated_at: ts,
933
978
  });
979
+ reopened = true;
934
980
  } else if (processKey != null) {
935
981
  // First submit: the row was INSERTED keyless before `createInstance` (the phantom-row pattern,
936
982
  // #704/#497); publish its `process_key` now that the instance exists.
@@ -949,6 +995,19 @@ async function submitPrCritical(
949
995
  await resetAdjudications(data, parsed.prKey);
950
996
  }
951
997
  } catch (err) {
998
+ // Restore the prior row (#941 review): the atomic reopen already advanced `process_key` to the
999
+ // instance we are about to cancel. Leaving that cancelled key on the row would make a dead run its
1000
+ // "owner" — once tracking derives `abandoned`, a retried merge-loop hand-off would be fenced as
1001
+ // stale (settled row) and the merge loop would end with no live convergence owner. Restoring the
1002
+ // pre-reopen shape (owner key + status) keeps the retry the owning hand-off, and a public retry
1003
+ // still sees the old terminal row as resubmittable.
1004
+ if (reopened && prior) {
1005
+ try {
1006
+ await table.update(parsed.prKey, { ...prior, updated_at: now() });
1007
+ } catch (restoreErr) {
1008
+ console.warn(`[submit] ${parsed.prKey} rollback-restore failed: ${restoreErr}`);
1009
+ }
1010
+ }
952
1011
  if (processInstanceKey != null) {
953
1012
  try {
954
1013
  await engine.cancelInstance({ processInstanceKey: String(processInstanceKey) });
@@ -1025,6 +1084,11 @@ export async function startMerge(
1025
1084
  mergeableRepollInterval: MERGEABLE_REPOLL_INTERVAL,
1026
1085
  mergeStallRounds: 0,
1027
1086
  mergeStallMax: MAX_MERGE_STALL_ROUNDS,
1087
+ // Head-current review request budget (issue #939): see REVIEW_REQUEST_WAIT.
1088
+ reviewRequestRound: 0,
1089
+ reviewRequestMax: MAX_REVIEW_REQUESTS,
1090
+ reviewRequestWait: REVIEW_REQUEST_WAIT,
1091
+ reviewRequest: null,
1028
1092
  // Lineage (issue #245): thread the origin identity onto the merge instance (see startMerge).
1029
1093
  rootRequestKey,
1030
1094
  abandonUrl: abUrl,
package/build-info.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.207.0",
3
- "gitSha": "e3955c8145a966a29806400872b7a9c362d1d992",
4
- "gitTag": "v0.207.0",
5
- "builtAt": "2026-11-09T23:52:15.727Z"
2
+ "version": "0.208.0",
3
+ "gitSha": "0e2d50e8610beb5e939cd3a143d28e62d690f272",
4
+ "gitTag": "v0.208.0",
5
+ "builtAt": "2026-11-10T03:09:52.732Z"
6
6
  }
package/nano.app.json CHANGED
@@ -144,6 +144,14 @@
144
144
  "taskType": "pr.merge",
145
145
  "handler": "workers/merge/worker.ts"
146
146
  },
147
+ {
148
+ "taskType": "pr.request-review",
149
+ "handler": "workers/request-review/worker.ts"
150
+ },
151
+ {
152
+ "taskType": "pr.reconverge",
153
+ "handler": "workers/reconverge/worker.ts"
154
+ },
147
155
  {
148
156
  "taskType": "pr.mark-merged",
149
157
  "handler": "workers/mark-merged/worker.ts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.207.0",
3
+ "version": "0.208.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",