@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.
- package/CHANGELOG.md +12 -0
- package/SPEC.md +18 -0
- package/app/deliveryGraphReadModel.test.ts +4 -2
- package/app/deliveryGraphRun.ts +12 -0
- package/app/github.ts +29 -0
- package/app/incidentStatus.test.ts +175 -0
- package/app/mcpExclusions.test.ts +4 -3
- package/app/mcpToolSurface.ts +11 -2
- package/app/mergeLoopBehaviour.test.ts +136 -1
- package/app/pullRequestReadModel.test.ts +1 -1
- package/app/service.test.ts +266 -0
- package/app/service.ts +311 -96
- package/build-info.json +4 -4
- package/db/migrations/126_pull_requests_incident_type_since.sql +8 -0
- package/db/migrations/127_delivery_graph_run_incidents.sql +60 -0
- package/db/migrations/128_pull_requests_read_model_incidents.sql +58 -0
- package/db/migrations/129_delivery_graph_read_model_incidents.sql +58 -0
- package/docs/mcp-runbook.md +2 -2
- package/nano.app.json +8 -0
- package/openapi.yaml +238 -0
- package/operations/listIncidents.test.ts +70 -0
- package/operations/listIncidents.ts +78 -0
- package/operations/retryIncident.test.ts +280 -0
- package/operations/retryIncident.ts +201 -0
- package/package.json +1 -1
- package/pages/delivery-graphs.page.json +16 -3
- package/pages/home.page.json +19 -0
- package/pages/overview.page.json +9 -0
- package/resources/processes/merge-loop.bpmn +401 -213
- package/workers/merge/worker.test.ts +30 -9
- package/workers/merge/worker.ts +23 -12
- package/workers/reconverge/worker.test.ts +110 -0
- package/workers/reconverge/worker.ts +81 -0
- package/workers/request-review/worker.test.ts +54 -0
- package/workers/request-review/worker.ts +68 -0
package/app/service.test.ts
CHANGED
|
@@ -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
|
+
});
|