@nanobpm/nano-workforce 0.206.4 → 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.
Files changed (35) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/SPEC.md +18 -0
  3. package/app/deliveryGraphReadModel.test.ts +4 -2
  4. package/app/deliveryGraphRun.ts +12 -0
  5. package/app/github.ts +29 -0
  6. package/app/incidentStatus.test.ts +175 -0
  7. package/app/mcpExclusions.test.ts +4 -3
  8. package/app/mcpToolSurface.ts +11 -2
  9. package/app/mergeLoopBehaviour.test.ts +136 -1
  10. package/app/pullRequestReadModel.test.ts +1 -1
  11. package/app/service.test.ts +266 -0
  12. package/app/service.ts +311 -96
  13. package/build-info.json +4 -4
  14. package/db/migrations/126_pull_requests_incident_type_since.sql +8 -0
  15. package/db/migrations/127_delivery_graph_run_incidents.sql +60 -0
  16. package/db/migrations/128_pull_requests_read_model_incidents.sql +58 -0
  17. package/db/migrations/129_delivery_graph_read_model_incidents.sql +58 -0
  18. package/docs/mcp-runbook.md +2 -2
  19. package/nano.app.json +8 -0
  20. package/openapi.yaml +238 -0
  21. package/operations/listIncidents.test.ts +70 -0
  22. package/operations/listIncidents.ts +78 -0
  23. package/operations/retryIncident.test.ts +280 -0
  24. package/operations/retryIncident.ts +201 -0
  25. package/package.json +1 -1
  26. package/pages/delivery-graphs.page.json +16 -3
  27. package/pages/home.page.json +19 -0
  28. package/pages/overview.page.json +9 -0
  29. package/resources/processes/merge-loop.bpmn +401 -213
  30. package/workers/merge/worker.test.ts +30 -9
  31. package/workers/merge/worker.ts +23 -12
  32. package/workers/reconverge/worker.test.ts +110 -0
  33. package/workers/reconverge/worker.ts +81 -0
  34. package/workers/request-review/worker.test.ts +54 -0
  35. package/workers/request-review/worker.ts +68 -0
@@ -675,6 +675,123 @@ test("pollIncidents picks the oldest incident by creationTime, sorting a missing
675
675
  assertEquals(row.incident_message, "the first fault");
676
676
  });
677
677
 
678
+ // Issue #931: the mirror must carry the incident's `errorType` and `creationTime` (not just key +
679
+ // message) so `/status` can show WHAT broke and SINCE WHEN, and it must cover a delivery-graph
680
+ // run's instance too — a delivery parked on an engine incident was just as invisible as a PR.
681
+ test("pollIncidents mirrors errorType + creationTime alongside the key/message, and clears them with the incident", async () => {
682
+ const row = {
683
+ pr_key: "owner/repo#12",
684
+ status: "converging",
685
+ process_key: "PI-12",
686
+ incident_key: null as string | null,
687
+ incident_type: null as string | null,
688
+ incident_message: null as string | null,
689
+ incident_since: null as string | null,
690
+ updated_at: "t0",
691
+ };
692
+ const stores: Record<string, { rows: unknown[]; key: string }> = {
693
+ pull_requests: { rows: [row], key: "pr_key" },
694
+ };
695
+ const data = {
696
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
697
+ open: () => memOpen(stores),
698
+ } as any;
699
+ const headers = { "content-type": "application/json" };
700
+
701
+ const prevFetch = globalThis.fetch;
702
+ globalThis.fetch = incidentFetch({
703
+ "PI-12": [{
704
+ incidentKey: "598235",
705
+ errorType: "JOB_NO_RETRIES",
706
+ errorMessage: 'agent "nano-coder" failed: no output and no subtree progress for >= 300000ms (idle)',
707
+ state: "ACTIVE",
708
+ creationTime: "2026-11-09T01:00:00.000Z",
709
+ }],
710
+ }) as typeof fetch;
711
+ try {
712
+ await pollIncidentsImpl(data, "http://engine/v2", headers);
713
+ } finally {
714
+ globalThis.fetch = prevFetch;
715
+ }
716
+ assertEquals(row.incident_key, "598235");
717
+ assertEquals(row.incident_type, "JOB_NO_RETRIES");
718
+ assertEquals(row.incident_message, 'agent "nano-coder" failed: no output and no subtree progress for >= 300000ms (idle)');
719
+ assertEquals(row.incident_since, "2026-11-09T01:00:00.000Z");
720
+ assertEquals(row.status, "converging"); // orthogonal: the workflow transient is never touched
721
+
722
+ // Once resolved, ALL FOUR columns clear idempotently — a stale `incident_since` must not linger.
723
+ globalThis.fetch = incidentFetch({ "PI-12": [] }) as typeof fetch;
724
+ try {
725
+ await pollIncidentsImpl(data, "http://engine/v2", headers);
726
+ } finally {
727
+ globalThis.fetch = prevFetch;
728
+ }
729
+ assertEquals(row.incident_key, null);
730
+ assertEquals(row.incident_type, null);
731
+ assertEquals(row.incident_message, null);
732
+ assertEquals(row.incident_since, null);
733
+ });
734
+
735
+ test("pollIncidents mirrors an ACTIVE incident onto a delivery-graph run row, and skips a terminal run", async () => {
736
+ const running = {
737
+ run_key: "dg-1",
738
+ status: "running",
739
+ process_key: "PI-DG-1",
740
+ incident_key: null as string | null,
741
+ incident_type: null as string | null,
742
+ incident_message: null as string | null,
743
+ incident_since: null as string | null,
744
+ updated_at: "t0",
745
+ };
746
+ const done = {
747
+ run_key: "dg-2",
748
+ status: "done",
749
+ process_key: "PI-DG-2",
750
+ incident_key: "STALE-DG",
751
+ incident_type: "JOB_NO_RETRIES",
752
+ incident_message: "left over",
753
+ incident_since: "2026-11-08T00:00:00.000Z",
754
+ updated_at: "t0",
755
+ };
756
+ const stores: Record<string, { rows: unknown[]; key: string }> = {
757
+ delivery_graph_runs: { rows: [running, done], key: "run_key" },
758
+ };
759
+ const data = {
760
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
761
+ open: () => memOpen(stores),
762
+ } as any;
763
+ const headers = { "content-type": "application/json" };
764
+
765
+ const prevFetch = globalThis.fetch;
766
+ globalThis.fetch = incidentFetch({
767
+ "PI-DG-1": [{
768
+ incidentKey: "INC-DG-1",
769
+ errorType: "JOB_NO_RETRIES",
770
+ errorMessage: "delivery node job exhausted retries",
771
+ state: "ACTIVE",
772
+ creationTime: "2026-11-09T03:00:00.000Z",
773
+ }],
774
+ // A terminal run's instance is gone — the pass must never query it (the stub would serve an
775
+ // incident, so a query would wrongly overwrite the clearing below).
776
+ "PI-DG-2": [{ incidentKey: "INC-DG-2", errorMessage: "ghost", state: "ACTIVE", creationTime: "2026-11-09T04:00:00.000Z" }],
777
+ }) as typeof fetch;
778
+ try {
779
+ await pollIncidentsImpl(data, "http://engine/v2", headers);
780
+ } finally {
781
+ globalThis.fetch = prevFetch;
782
+ }
783
+ assertEquals(running.incident_key, "INC-DG-1");
784
+ assertEquals(running.incident_type, "JOB_NO_RETRIES");
785
+ assertEquals(running.incident_message, "delivery node job exhausted retries");
786
+ assertEquals(running.incident_since, "2026-11-09T03:00:00.000Z");
787
+ assertEquals(running.status, "running"); // orthogonal: status untouched
788
+ // Terminal run: the stale incident is cleared without ever consulting the engine.
789
+ assertEquals(done.incident_key, null);
790
+ assertEquals(done.incident_type, null);
791
+ assertEquals(done.incident_message, null);
792
+ assertEquals(done.incident_since, null);
793
+ });
794
+
678
795
 
679
796
  // Red/green regression (nano-workforce#845 Copilot review): the convergence loop runs TWO external
680
797
  // agent task types while the PR reads `converging` — `review-round` (`senior:pr-review`) and, since
@@ -2446,3 +2563,152 @@ test("pollCapabilityGatesImpl: scopes the barrier subscription search server-sid
2446
2563
  assertEquals(published.length, 1, "the scoped search still finds THIS task's subscription and releases it");
2447
2564
  assertEquals(published[0]?.correlationKey, barrierKey);
2448
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
+ });