@nanobpm/nano-workforce 0.97.1 → 0.98.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 +7 -0
- package/app/capabilityNeed.test.ts +156 -0
- package/app/capabilityNeed.ts +200 -0
- package/app/capsWait.test.ts +39 -0
- package/app/capsWait.ts +33 -0
- package/app/contracts.ts +8 -0
- package/app/plan.ts +63 -0
- package/app/service.test.ts +375 -1
- package/app/service.ts +239 -0
- package/app/waitGateVisibility.test.ts +5 -2
- package/db/migrations/049_plan_task_needs.sql +30 -0
- package/db/migrations/050_capability_gates.sql +40 -0
- package/e2e/plan-fanout.e2e.ts +145 -0
- package/nano.app.json +4 -0
- package/package.json +1 -1
- package/resources/processes/plan-fanout.bpmn +347 -194
- package/resources/prompts/plan.md +29 -1
- package/workers/caps-prepare/worker.test.ts +62 -0
- package/workers/caps-prepare/worker.ts +38 -0
- package/workers/record-plan/worker.test.ts +33 -1
- package/workers/record-plan/worker.ts +24 -1
- package/workers/select-wave/worker.test.ts +34 -2
- package/workers/select-wave/worker.ts +26 -2
package/app/service.test.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// GitHub transport forced off so it is hermetic.
|
|
8
8
|
import { test } from "node:test";
|
|
9
9
|
import { assertEquals } from "#test-assert";
|
|
10
|
-
import { parsePr, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr } from "./service.ts";
|
|
10
|
+
import { parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr } from "./service.ts";
|
|
11
11
|
|
|
12
12
|
function memTable(rows: any[], key: string) {
|
|
13
13
|
return {
|
|
@@ -15,6 +15,8 @@ function memTable(rows: any[], key: string) {
|
|
|
15
15
|
all: () => Promise.resolve([...rows]),
|
|
16
16
|
find: (q: any) =>
|
|
17
17
|
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
18
|
+
findOne: (q: any) =>
|
|
19
|
+
Promise.resolve(rows.find((r) => Object.entries(q).every(([f, v]) => r[f] === v)) ?? null),
|
|
18
20
|
insert: (r: any) => {
|
|
19
21
|
rows.push(r);
|
|
20
22
|
return Promise.resolve(r);
|
|
@@ -704,3 +706,375 @@ test("pollWaveGatesImpl never releases the barrier on an unverifiable subscripti
|
|
|
704
706
|
assertEquals(plan.gate_wave, 1, "gate_wave must stay armed while no OPEN subscription is confirmed");
|
|
705
707
|
});
|
|
706
708
|
});
|
|
709
|
+
|
|
710
|
+
// ── pollCapabilityGatesImpl — the host half of the cross-repo capability edge (issue #289) ──────────
|
|
711
|
+
//
|
|
712
|
+
// plan-fanout parks a task with capability `needs` at the `wait-caps-resolved` message barrier. This
|
|
713
|
+
// reconciler, on every pass, (a) starts the durable `readiness-gate` once per need, (b) does a single
|
|
714
|
+
// DETERMINISTIC provenance lookup (`probeOnce`, reused verbatim), and (c) publishes `caps-resolved`
|
|
715
|
+
// (correlated on the per-task barrier key `<planKey>:<taskId>`) with the late-bound resolved-deps brief
|
|
716
|
+
// ONLY when EVERY need has shipped as a published `pkg@version` AND the barrier subscription is open.
|
|
717
|
+
//
|
|
718
|
+
// Stubs: `/message-subscriptions/search` (toggle the barrier open per barrier key), a capture engine
|
|
719
|
+
// (`createInstance`/`publishMessage`), and a `ProbeExec` returning a canned `gh api .../releases`
|
|
720
|
+
// payload so `matchCapability` resolves the lowest capability-bearing version — all hermetic.
|
|
721
|
+
|
|
722
|
+
function capsSubscriptionFetch(openKeys: Set<string>) {
|
|
723
|
+
return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
724
|
+
const u = typeof url === "string" ? url : url.toString();
|
|
725
|
+
if (!u.endsWith("/message-subscriptions/search")) throw new Error(`unexpected fetch: ${u}`);
|
|
726
|
+
const body = JSON.parse(String(init?.body ?? "{}")) as {
|
|
727
|
+
filter?: { messageName?: string; processInstanceKey?: string };
|
|
728
|
+
};
|
|
729
|
+
const key = body.filter?.processInstanceKey ?? "";
|
|
730
|
+
// The reconciler filters by processInstanceKey + messageName; we toggle by barrier correlationKey,
|
|
731
|
+
// which the search response carries back on each item. Return an open item for every requested key
|
|
732
|
+
// registered in `openKeys` (keyed by the barrier correlationKey the caller expects).
|
|
733
|
+
const items = [...openKeys]
|
|
734
|
+
.filter((k) => k.startsWith(`${key}|`))
|
|
735
|
+
.map((k) => ({
|
|
736
|
+
messageName: "caps-resolved",
|
|
737
|
+
correlationKey: k.slice(k.indexOf("|") + 1),
|
|
738
|
+
messageSubscriptionState: "CREATED",
|
|
739
|
+
}));
|
|
740
|
+
return Promise.resolve(
|
|
741
|
+
new Response(JSON.stringify({ items }), { status: 200, headers: { "content-type": "application/json" } }),
|
|
742
|
+
);
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// A `ProbeExec` whose `gh api .../releases` output resolves `capabilityRef` #274 to `@nanobpm/urban@0.54.0`
|
|
747
|
+
// (the LOWEST version whose body references #274) — or resolves nothing when `ready` is false.
|
|
748
|
+
function capsProbeExec(ready: boolean) {
|
|
749
|
+
const releases = ready
|
|
750
|
+
? [
|
|
751
|
+
{ tag_name: "@nanobpm/urban@0.55.0", body: "## Provenance\n- nanobpm/nano-ide#274" },
|
|
752
|
+
{ tag_name: "@nanobpm/urban@0.54.0", body: "## Provenance\n- nanobpm/nano-ide#274" },
|
|
753
|
+
{ tag_name: "@nanobpm/urban@0.53.0", body: "unrelated" },
|
|
754
|
+
]
|
|
755
|
+
: [{ tag_name: "@nanobpm/urban@0.53.0", body: "unrelated" }];
|
|
756
|
+
const calls: string[] = [];
|
|
757
|
+
return {
|
|
758
|
+
calls,
|
|
759
|
+
exec: {
|
|
760
|
+
httpGet: () => Promise.reject(new Error("no http probe expected")),
|
|
761
|
+
run: (command: string) => {
|
|
762
|
+
calls.push(command);
|
|
763
|
+
return Promise.resolve({ code: 0, stdout: JSON.stringify(releases), stderr: "" });
|
|
764
|
+
},
|
|
765
|
+
},
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function capsDataLayer(stores: Record<string, { rows: any[]; key: string }>) {
|
|
770
|
+
return {
|
|
771
|
+
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
772
|
+
} as any;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function capsEngine() {
|
|
776
|
+
const created: { processDefinitionId?: string; variables?: Record<string, unknown> }[] = [];
|
|
777
|
+
const published: { name: string; correlationKey?: string; variables?: Record<string, unknown> }[] = [];
|
|
778
|
+
let seq = 0;
|
|
779
|
+
return {
|
|
780
|
+
created,
|
|
781
|
+
published,
|
|
782
|
+
engine: {
|
|
783
|
+
createInstance: (req: { processDefinitionId?: string; variables?: Record<string, unknown> }) => {
|
|
784
|
+
created.push(req);
|
|
785
|
+
return Promise.resolve({ processInstanceKey: `RG-${++seq}` });
|
|
786
|
+
},
|
|
787
|
+
publishMessage: (input: { name: string; correlationKey?: string; variables?: Record<string, unknown> }) => {
|
|
788
|
+
published.push(input);
|
|
789
|
+
return Promise.resolve();
|
|
790
|
+
},
|
|
791
|
+
} as any,
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
test("pollCapabilityGatesImpl: releases a task once every need ships, starting the gate + publishing the resolved brief (#289)", async () => {
|
|
796
|
+
const PLAN_KEY = "owner/repo#7";
|
|
797
|
+
const PI = "PI-289";
|
|
798
|
+
const TASK = "gap-a";
|
|
799
|
+
const barrierKey = `${PLAN_KEY}:${TASK}`;
|
|
800
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
801
|
+
plans: { rows: [{ plan_key: PLAN_KEY, process_key: PI }], key: "plan_key" },
|
|
802
|
+
plan_task_needs: {
|
|
803
|
+
rows: [
|
|
804
|
+
{
|
|
805
|
+
plan_key: PLAN_KEY,
|
|
806
|
+
task_id: TASK,
|
|
807
|
+
capability_ref: "nanobpm/nano-ide#274",
|
|
808
|
+
package: "@nanobpm/urban",
|
|
809
|
+
verify_command: null,
|
|
810
|
+
},
|
|
811
|
+
],
|
|
812
|
+
key: "plan_key",
|
|
813
|
+
},
|
|
814
|
+
capability_gates: { rows: [], key: "gate_key" },
|
|
815
|
+
};
|
|
816
|
+
const data = capsDataLayer(stores);
|
|
817
|
+
const { engine, created, published } = capsEngine();
|
|
818
|
+
const { exec, calls } = capsProbeExec(true);
|
|
819
|
+
const headers = { "content-type": "application/json" };
|
|
820
|
+
const open = new Set<string>([`${PI}|${barrierKey}`]);
|
|
821
|
+
const prevFetch = globalThis.fetch;
|
|
822
|
+
globalThis.fetch = capsSubscriptionFetch(open) as typeof fetch;
|
|
823
|
+
try {
|
|
824
|
+
await pollCapabilityGatesImpl(data, engine, "http://engine/v2", headers, exec, {});
|
|
825
|
+
} finally {
|
|
826
|
+
globalThis.fetch = prevFetch;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// The durable readiness-gate was started exactly once, its instance key persisted.
|
|
830
|
+
assertEquals(created.length, 1, "readiness-gate started exactly once");
|
|
831
|
+
assertEquals(created[0]?.processDefinitionId, "readiness-gate");
|
|
832
|
+
assertEquals(stores.capability_gates.rows.length, 1);
|
|
833
|
+
assertEquals(stores.capability_gates.rows[0]?.process_key, "RG-1");
|
|
834
|
+
assertEquals(stores.capability_gates.rows[0]?.status, "resolved");
|
|
835
|
+
assertEquals(stores.capability_gates.rows[0]?.resolved_artifact, "@nanobpm/urban@0.54.0");
|
|
836
|
+
|
|
837
|
+
// The barrier was released once with the late-bound brief pinning the LOWEST capability-bearing version.
|
|
838
|
+
assertEquals(published.length, 1, "caps-resolved published once");
|
|
839
|
+
assertEquals(published[0]?.name, "caps-resolved");
|
|
840
|
+
assertEquals(published[0]?.correlationKey, barrierKey);
|
|
841
|
+
const brief = String(published[0]?.variables?.["resolvedDepsBrief"] ?? "");
|
|
842
|
+
assertEquals(brief.includes("@nanobpm/urban@0.54.0"), true, "brief pins the resolved artifact");
|
|
843
|
+
assertEquals(brief.includes("nanobpm/nano-ide#274"), true, "brief names the capability ref");
|
|
844
|
+
assertEquals(calls.length, 1, "one deterministic provenance lookup");
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
test("pollCapabilityGatesImpl: an unresolved need starts the gate but never releases the barrier (#289)", async () => {
|
|
848
|
+
const PLAN_KEY = "owner/repo#8";
|
|
849
|
+
const PI = "PI-290";
|
|
850
|
+
const TASK = "gap-b";
|
|
851
|
+
const barrierKey = `${PLAN_KEY}:${TASK}`;
|
|
852
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
853
|
+
plans: { rows: [{ plan_key: PLAN_KEY, process_key: PI }], key: "plan_key" },
|
|
854
|
+
plan_task_needs: {
|
|
855
|
+
rows: [
|
|
856
|
+
{
|
|
857
|
+
plan_key: PLAN_KEY,
|
|
858
|
+
task_id: TASK,
|
|
859
|
+
capability_ref: "nanobpm/nano-ide#274",
|
|
860
|
+
package: "@nanobpm/urban",
|
|
861
|
+
verify_command: null,
|
|
862
|
+
},
|
|
863
|
+
],
|
|
864
|
+
key: "plan_key",
|
|
865
|
+
},
|
|
866
|
+
capability_gates: { rows: [], key: "gate_key" },
|
|
867
|
+
};
|
|
868
|
+
const data = capsDataLayer(stores);
|
|
869
|
+
const { engine, created, published } = capsEngine();
|
|
870
|
+
const { exec } = capsProbeExec(false); // capability not published yet
|
|
871
|
+
const headers = { "content-type": "application/json" };
|
|
872
|
+
const open = new Set<string>([`${PI}|${barrierKey}`]);
|
|
873
|
+
const prevFetch = globalThis.fetch;
|
|
874
|
+
globalThis.fetch = capsSubscriptionFetch(open) as typeof fetch;
|
|
875
|
+
try {
|
|
876
|
+
await pollCapabilityGatesImpl(data, engine, "http://engine/v2", headers, exec, {});
|
|
877
|
+
} finally {
|
|
878
|
+
globalThis.fetch = prevFetch;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// The gate is still started (bounded/durable wait + operator escalation) but no release.
|
|
882
|
+
assertEquals(created.length, 1, "gate started even while unresolved");
|
|
883
|
+
assertEquals(stores.capability_gates.rows[0]?.status, "pending");
|
|
884
|
+
assertEquals(stores.capability_gates.rows[0]?.resolved_artifact, null);
|
|
885
|
+
assertEquals(published.length, 0, "barrier NOT released until the capability ships");
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
test("pollCapabilityGatesImpl: level-triggered — no publish and no re-probe when the barrier subscription is not open (#289)", async () => {
|
|
889
|
+
const PLAN_KEY = "owner/repo#9";
|
|
890
|
+
const PI = "PI-291";
|
|
891
|
+
const TASK = "gap-c";
|
|
892
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
893
|
+
plans: { rows: [{ plan_key: PLAN_KEY, process_key: PI }], key: "plan_key" },
|
|
894
|
+
plan_task_needs: {
|
|
895
|
+
rows: [
|
|
896
|
+
{
|
|
897
|
+
plan_key: PLAN_KEY,
|
|
898
|
+
task_id: TASK,
|
|
899
|
+
capability_ref: "nanobpm/nano-ide#274",
|
|
900
|
+
package: "@nanobpm/urban",
|
|
901
|
+
verify_command: null,
|
|
902
|
+
},
|
|
903
|
+
],
|
|
904
|
+
key: "plan_key",
|
|
905
|
+
},
|
|
906
|
+
capability_gates: { rows: [], key: "gate_key" },
|
|
907
|
+
};
|
|
908
|
+
const data = capsDataLayer(stores);
|
|
909
|
+
const { engine, created, published } = capsEngine();
|
|
910
|
+
const { exec, calls } = capsProbeExec(true);
|
|
911
|
+
const headers = { "content-type": "application/json" };
|
|
912
|
+
const prevFetch = globalThis.fetch;
|
|
913
|
+
globalThis.fetch = capsSubscriptionFetch(new Set<string>()) as typeof fetch; // barrier not parked
|
|
914
|
+
try {
|
|
915
|
+
await pollCapabilityGatesImpl(data, engine, "http://engine/v2", headers, exec, {});
|
|
916
|
+
} finally {
|
|
917
|
+
globalThis.fetch = prevFetch;
|
|
918
|
+
}
|
|
919
|
+
assertEquals(published.length, 0, "no publish into a subscription that is not open");
|
|
920
|
+
assertEquals(created.length, 0, "no gate work until the task is parked at the barrier");
|
|
921
|
+
assertEquals(calls.length, 0, "no provenance probe until the task is parked at the barrier");
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
test("pollCapabilityGatesImpl: idempotent — a resolved gate is reused without a re-probe or a second publish (#289)", async () => {
|
|
925
|
+
const PLAN_KEY = "owner/repo#10";
|
|
926
|
+
const PI = "PI-292";
|
|
927
|
+
const TASK = "gap-d";
|
|
928
|
+
const barrierKey = `${PLAN_KEY}:${TASK}`;
|
|
929
|
+
const gateKey = `${PLAN_KEY}:${TASK}:nanobpm/nano-ide#274:@nanobpm/urban`;
|
|
930
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
931
|
+
plans: { rows: [{ plan_key: PLAN_KEY, process_key: PI }], key: "plan_key" },
|
|
932
|
+
plan_task_needs: {
|
|
933
|
+
rows: [
|
|
934
|
+
{
|
|
935
|
+
plan_key: PLAN_KEY,
|
|
936
|
+
task_id: TASK,
|
|
937
|
+
capability_ref: "nanobpm/nano-ide#274",
|
|
938
|
+
package: "@nanobpm/urban",
|
|
939
|
+
verify_command: null,
|
|
940
|
+
},
|
|
941
|
+
],
|
|
942
|
+
key: "plan_key",
|
|
943
|
+
},
|
|
944
|
+
capability_gates: {
|
|
945
|
+
rows: [
|
|
946
|
+
{
|
|
947
|
+
gate_key: gateKey,
|
|
948
|
+
plan_key: PLAN_KEY,
|
|
949
|
+
task_id: TASK,
|
|
950
|
+
capability_ref: "nanobpm/nano-ide#274",
|
|
951
|
+
package: "@nanobpm/urban",
|
|
952
|
+
status: "resolved",
|
|
953
|
+
resolved_artifact: "@nanobpm/urban@0.54.0",
|
|
954
|
+
process_key: "RG-EXISTING",
|
|
955
|
+
created_at: "t0",
|
|
956
|
+
updated_at: "t0",
|
|
957
|
+
},
|
|
958
|
+
],
|
|
959
|
+
key: "gate_key",
|
|
960
|
+
},
|
|
961
|
+
};
|
|
962
|
+
const data = capsDataLayer(stores);
|
|
963
|
+
const { engine, created, published } = capsEngine();
|
|
964
|
+
const { exec, calls } = capsProbeExec(true);
|
|
965
|
+
const headers = { "content-type": "application/json" };
|
|
966
|
+
const open = new Set<string>([`${PI}|${barrierKey}`]);
|
|
967
|
+
const prevFetch = globalThis.fetch;
|
|
968
|
+
globalThis.fetch = capsSubscriptionFetch(open) as typeof fetch;
|
|
969
|
+
try {
|
|
970
|
+
await pollCapabilityGatesImpl(data, engine, "http://engine/v2", headers, exec, {});
|
|
971
|
+
} finally {
|
|
972
|
+
globalThis.fetch = prevFetch;
|
|
973
|
+
}
|
|
974
|
+
assertEquals(created.length, 0, "already-started gate is never re-started");
|
|
975
|
+
assertEquals(calls.length, 0, "already-resolved need is never re-probed");
|
|
976
|
+
assertEquals(published.length, 1, "the still-parked barrier is released from the pinned artifact");
|
|
977
|
+
assertEquals(published[0]?.correlationKey, barrierKey);
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
test("pollCapabilityGatesImpl: two needs sharing a capabilityRef across packages get distinct gate rows (#290)", async () => {
|
|
981
|
+
// Regression for the gate_key collision: `capabilityGateKey` folds `package` into the key, so a task
|
|
982
|
+
// that declares the SAME `capabilityRef` for two different packages tracks each `(capabilityRef,
|
|
983
|
+
// package)` edge on its OWN gate row and starts its OWN readiness-gate. With the old package-blind key
|
|
984
|
+
// the second need would alias the first row, only one gate would ever start, and the second package
|
|
985
|
+
// could never resolve — wedging the barrier forever.
|
|
986
|
+
const PLAN_KEY = "owner/repo#12";
|
|
987
|
+
const PI = "PI-294";
|
|
988
|
+
const TASK = "gap-f";
|
|
989
|
+
const barrierKey = `${PLAN_KEY}:${TASK}`;
|
|
990
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
991
|
+
plans: { rows: [{ plan_key: PLAN_KEY, process_key: PI }], key: "plan_key" },
|
|
992
|
+
plan_task_needs: {
|
|
993
|
+
rows: [
|
|
994
|
+
{ plan_key: PLAN_KEY, task_id: TASK, capability_ref: "nanobpm/nano-ide#274", package: "@nanobpm/urban", verify_command: null },
|
|
995
|
+
{ plan_key: PLAN_KEY, task_id: TASK, capability_ref: "nanobpm/nano-ide#274", package: "@nanobpm/urban-testkit", verify_command: null },
|
|
996
|
+
],
|
|
997
|
+
key: "plan_key",
|
|
998
|
+
},
|
|
999
|
+
capability_gates: { rows: [], key: "gate_key" },
|
|
1000
|
+
};
|
|
1001
|
+
const data = capsDataLayer(stores);
|
|
1002
|
+
const { engine, created, published } = capsEngine();
|
|
1003
|
+
const { exec } = capsProbeExec(false); // neither capability published yet — both stay pending
|
|
1004
|
+
const headers = { "content-type": "application/json" };
|
|
1005
|
+
const open = new Set<string>([`${PI}|${barrierKey}`]);
|
|
1006
|
+
const prevFetch = globalThis.fetch;
|
|
1007
|
+
globalThis.fetch = capsSubscriptionFetch(open) as typeof fetch;
|
|
1008
|
+
try {
|
|
1009
|
+
await pollCapabilityGatesImpl(data, engine, "http://engine/v2", headers, exec, {});
|
|
1010
|
+
} finally {
|
|
1011
|
+
globalThis.fetch = prevFetch;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// Two distinct gate rows (one per package) — no collision, no aliasing.
|
|
1015
|
+
assertEquals(stores.capability_gates.rows.length, 2, "one gate row per (capabilityRef, package) need");
|
|
1016
|
+
const gateKeys = stores.capability_gates.rows.map((r) => r.gate_key).sort();
|
|
1017
|
+
assertEquals(gateKeys, [
|
|
1018
|
+
`${PLAN_KEY}:${TASK}:nanobpm/nano-ide#274:@nanobpm/urban`,
|
|
1019
|
+
`${PLAN_KEY}:${TASK}:nanobpm/nano-ide#274:@nanobpm/urban-testkit`,
|
|
1020
|
+
]);
|
|
1021
|
+
assertEquals(created.length, 2, "each need starts its own readiness-gate");
|
|
1022
|
+
assertEquals(published.length, 0, "barrier NOT released while either need is unresolved");
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
test("pollCapabilityGatesImpl: scopes the barrier subscription search server-side by correlationKey (#290)", async () => {
|
|
1026
|
+
// Regression for the page-limit false negative: the capability barrier opens ONE subscription per
|
|
1027
|
+
// task, so a plan with many parked siblings can overflow a process+message-only search page and omit
|
|
1028
|
+
// THIS task's subscription — wedging its gate forever. The reconciler must therefore scope the search
|
|
1029
|
+
// by `correlationKey`. This stub emulates an engine that HONOURS the server-side `correlationKey`
|
|
1030
|
+
// filter: it returns the open item only when the request carries the matching key (as the real engine
|
|
1031
|
+
// does), so the old process+message-only filter would come back empty and never release the barrier.
|
|
1032
|
+
const PLAN_KEY = "owner/repo#11";
|
|
1033
|
+
const PI = "PI-293";
|
|
1034
|
+
const TASK = "gap-e";
|
|
1035
|
+
const barrierKey = `${PLAN_KEY}:${TASK}`;
|
|
1036
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
1037
|
+
plans: { rows: [{ plan_key: PLAN_KEY, process_key: PI }], key: "plan_key" },
|
|
1038
|
+
plan_task_needs: {
|
|
1039
|
+
rows: [
|
|
1040
|
+
{
|
|
1041
|
+
plan_key: PLAN_KEY,
|
|
1042
|
+
task_id: TASK,
|
|
1043
|
+
capability_ref: "nanobpm/nano-ide#274",
|
|
1044
|
+
package: "@nanobpm/urban",
|
|
1045
|
+
verify_command: null,
|
|
1046
|
+
},
|
|
1047
|
+
],
|
|
1048
|
+
key: "plan_key",
|
|
1049
|
+
},
|
|
1050
|
+
capability_gates: { rows: [], key: "gate_key" },
|
|
1051
|
+
};
|
|
1052
|
+
const data = capsDataLayer(stores);
|
|
1053
|
+
const { engine, published } = capsEngine();
|
|
1054
|
+
const { exec } = capsProbeExec(true);
|
|
1055
|
+
const headers = { "content-type": "application/json" };
|
|
1056
|
+
const seenFilters: Array<Record<string, unknown>> = [];
|
|
1057
|
+
const prevFetch = globalThis.fetch;
|
|
1058
|
+
globalThis.fetch = ((url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
1059
|
+
const u = typeof url === "string" ? url : url.toString();
|
|
1060
|
+
if (!u.endsWith("/message-subscriptions/search")) throw new Error(`unexpected fetch: ${u}`);
|
|
1061
|
+
const filter = (JSON.parse(String(init?.body ?? "{}")) as { filter?: Record<string, unknown> }).filter ?? {};
|
|
1062
|
+
seenFilters.push(filter);
|
|
1063
|
+
// Engine honours the server-side correlationKey filter: only the exactly-scoped query sees the item.
|
|
1064
|
+
const items =
|
|
1065
|
+
filter.correlationKey === barrierKey
|
|
1066
|
+
? [{ messageName: "caps-resolved", correlationKey: barrierKey, messageSubscriptionState: "CREATED" }]
|
|
1067
|
+
: [];
|
|
1068
|
+
return Promise.resolve(
|
|
1069
|
+
new Response(JSON.stringify({ items }), { status: 200, headers: { "content-type": "application/json" } }),
|
|
1070
|
+
);
|
|
1071
|
+
}) as typeof fetch;
|
|
1072
|
+
try {
|
|
1073
|
+
await pollCapabilityGatesImpl(data, engine, "http://engine/v2", headers, exec, {});
|
|
1074
|
+
} finally {
|
|
1075
|
+
globalThis.fetch = prevFetch;
|
|
1076
|
+
}
|
|
1077
|
+
assertEquals(seenFilters[0]?.correlationKey, barrierKey, "search is scoped server-side by the barrier key");
|
|
1078
|
+
assertEquals(published.length, 1, "the scoped search still finds THIS task's subscription and releases it");
|
|
1079
|
+
assertEquals(published[0]?.correlationKey, barrierKey);
|
|
1080
|
+
});
|
package/app/service.ts
CHANGED
|
@@ -11,6 +11,16 @@ import { readFileSync } from "node:fs";
|
|
|
11
11
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
12
12
|
import { abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
|
|
13
13
|
import { agentSlaTimeout } from "./agentSla.ts";
|
|
14
|
+
import {
|
|
15
|
+
CAPS_RESOLVED_MESSAGE,
|
|
16
|
+
type CapabilityNeed,
|
|
17
|
+
capabilityGateKey,
|
|
18
|
+
capabilityNeedToProbeInput,
|
|
19
|
+
capabilityTaskBarrierKey,
|
|
20
|
+
type ResolvedCapability,
|
|
21
|
+
renderResolvedDepsBrief,
|
|
22
|
+
UnresolvableCapabilityRefError,
|
|
23
|
+
} from "./capabilityNeed.ts";
|
|
14
24
|
import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
|
|
15
25
|
import { backfillFeatureStages, deriveFeatureBlockedPatch, deriveFeatureDelivery, deriveFeatureEscalationPatch, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRun, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
|
|
16
26
|
import {
|
|
@@ -35,13 +45,16 @@ import { freshHeadRunAction, headRunPresenceCount, loadMergeProtocol } from "./m
|
|
|
35
45
|
import { type PrLaneDecision, planPrLane, taskDependencyDepths } from "./mergeTrain.ts";
|
|
36
46
|
import {
|
|
37
47
|
backfillPlanBuckets,
|
|
48
|
+
capabilityGates,
|
|
38
49
|
inboundPlanDeps,
|
|
39
50
|
planReviews,
|
|
40
51
|
plans,
|
|
41
52
|
planTaskDeps,
|
|
53
|
+
planTaskNeeds,
|
|
42
54
|
planTasks,
|
|
43
55
|
} from "./plan.ts";
|
|
44
56
|
import { derivePromotionState, isPromotable, promotionPrBody, promotionPrTitle } from "./promotion.ts";
|
|
57
|
+
import { defaultProbeExec, type ProbeExec, probeOnce, type ReadinessProbe, readinessTimeout } from "./readiness.ts";
|
|
45
58
|
import { clampNudgeMinutes, reviewWaitTimeout } from "./reviewWait.ts";
|
|
46
59
|
import { trialMergeAudits } from "./trialMerge.ts";
|
|
47
60
|
import {
|
|
@@ -1307,6 +1320,231 @@ export async function pollWaveGatesImpl(
|
|
|
1307
1320
|
}
|
|
1308
1321
|
}
|
|
1309
1322
|
|
|
1323
|
+
/** The readiness-gate process id (`resources/processes/readiness-gate.bpmn`) the capability reconciler
|
|
1324
|
+
* starts one instance of per unresolved need — the durable, bounded, resumable wait that escalates to
|
|
1325
|
+
* an operator if the capability never ships (#258). Single source of truth for the string. */
|
|
1326
|
+
const READINESS_GATE_PROCESS_ID = "readiness-gate";
|
|
1327
|
+
|
|
1328
|
+
/** Generic sibling of {@link waveMergedSubscriptionOpen}: is `processKey` right now parked at a catch
|
|
1329
|
+
* event with an OPEN (`CREATED`) subscription for `messageName` correlated on `correlationKey`? The
|
|
1330
|
+
* capability barrier (`wait-caps-resolved`) uses this to stay level-triggered exactly like the
|
|
1331
|
+
* wave-merge barrier — we publish `caps-resolved` ONLY into a subscription we've observed open, so a
|
|
1332
|
+
* signal is never dropped into the void nor buffered to trip a later task's barrier. Returns `true`
|
|
1333
|
+
* (open — safe to release), `false` (no open subscription), or `null` (transport unhappy / unparseable
|
|
1334
|
+
* — "unknown", retry next pass). Every field must be present and match explicitly (an item omitting a
|
|
1335
|
+
* field is "unknown", not a match) — a false negative only costs a retry, a false positive is a wedge.
|
|
1336
|
+
*
|
|
1337
|
+
* Unlike the wave-merge barrier (one subscription per plan, correlated on `planKey`), the capability
|
|
1338
|
+
* barrier opens ONE subscription PER TASK (each on its own `<planKey>:<taskId>` key), so a single
|
|
1339
|
+
* plan-fanout instance can have MANY `caps-resolved` subscriptions open at once. We therefore scope the
|
|
1340
|
+
* search server-side by `correlationKey` too — filtering only on process + message could return a page
|
|
1341
|
+
* of sibling tasks' subscriptions that overflows the page limit and omits THIS task's, a false negative
|
|
1342
|
+
* that wedges the gate forever. The client-side re-filter below is retained defensively. */
|
|
1343
|
+
async function messageSubscriptionOpen(
|
|
1344
|
+
base: string,
|
|
1345
|
+
headers: Record<string, string>,
|
|
1346
|
+
processKey: string,
|
|
1347
|
+
messageName: string,
|
|
1348
|
+
correlationKey: string,
|
|
1349
|
+
): Promise<boolean | null> {
|
|
1350
|
+
try {
|
|
1351
|
+
const res = await fetch(`${base}/message-subscriptions/search`, {
|
|
1352
|
+
method: "POST",
|
|
1353
|
+
headers,
|
|
1354
|
+
body: JSON.stringify({
|
|
1355
|
+
filter: { processInstanceKey: processKey, messageName, correlationKey, messageSubscriptionState: "CREATED" },
|
|
1356
|
+
page: { limit: 50 },
|
|
1357
|
+
}),
|
|
1358
|
+
});
|
|
1359
|
+
if (!res.ok) return null;
|
|
1360
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
1361
|
+
const body = (await res.json()) as { items?: MessageSubscriptionSearchItem[] };
|
|
1362
|
+
return (body.items ?? []).some(
|
|
1363
|
+
(it) =>
|
|
1364
|
+
it.messageName === messageName &&
|
|
1365
|
+
it.correlationKey === correlationKey &&
|
|
1366
|
+
typeof it.messageSubscriptionState === "string" &&
|
|
1367
|
+
it.messageSubscriptionState.toUpperCase() === "CREATED",
|
|
1368
|
+
);
|
|
1369
|
+
} catch (err) {
|
|
1370
|
+
console.error(`[poller] caps-resolved subscription ${correlationKey}: ${err}`);
|
|
1371
|
+
return null;
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/** The gate timeout (ISO-8601) seeded onto every capability readiness-gate. A capability need carries
|
|
1376
|
+
* no per-probe poll policy, so the bound is always the env/default (`NANO_READINESS_POLL_TIMEOUT`,
|
|
1377
|
+
* else 30m) — derived from readiness.ts's ONE place so it can't drift from the worker's local budget. */
|
|
1378
|
+
function capabilityGateTimeout(env: Record<string, string | undefined>): string {
|
|
1379
|
+
return readinessTimeout({ kind: "capability", target: "" } satisfies ReadinessProbe, env);
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/** Capability-edge reconcile pass (issue #289). The host half of the "consumer readiness edge":
|
|
1383
|
+
* plan-fanout's per-task fan-out parks at the `wait-caps-resolved` message barrier for any task that
|
|
1384
|
+
* declared cross-repo capability `needs` (049_plan_task_needs.sql). Here we reconcile, on EVERY pass
|
|
1385
|
+
* and idempotently, each such parked task against the external fact "has every one of its needed
|
|
1386
|
+
* capabilities shipped as a published `pkg@version`?", publishing `caps-resolved` (correlated on the
|
|
1387
|
+
* per-task barrier key) with the late-bound resolved-dependencies brief to release the agent whenever
|
|
1388
|
+
* ALL its needs resolve.
|
|
1389
|
+
*
|
|
1390
|
+
* The bounded/durable/resumable WAIT is NOT re-implemented here — it lives in the EXISTING
|
|
1391
|
+
* `readiness-gate` process (#258), which we start exactly once per need (recording its instance key on
|
|
1392
|
+
* `capability_gates.process_key`) so a capability that never ships escalates to an operator instead of
|
|
1393
|
+
* wedging the epic. This pass only (a) starts those gates and (b) performs a single DETERMINISTIC
|
|
1394
|
+
* provenance lookup per unresolved need (`probeOnce`, the gate's OWN matcher, reused verbatim — never a
|
|
1395
|
+
* second poll loop) to capture the resolved `pkg@version` for the late-bind. All state is durable in
|
|
1396
|
+
* `capability_gates`, so a host restart re-derives the picture from the DB: it never re-starts a gate,
|
|
1397
|
+
* never re-probes a resolved need, and — being level-triggered on the open subscription — never
|
|
1398
|
+
* re-publishes into a released barrier.
|
|
1399
|
+
*
|
|
1400
|
+
* Concurrency correctness (issue #289 §4, inherited from #274): an unrelated upstream release during
|
|
1401
|
+
* the wait does NOT resolve the edge (the provenance predicate matches only the capability-bearing
|
|
1402
|
+
* version) and does NOT spin an agent (this pass uses the deterministic lookup only — the gated
|
|
1403
|
+
* empirical `verifyCommand` fallback lives solely in the gate's worker, never here, so it can never
|
|
1404
|
+
* fire per unrelated release). */
|
|
1405
|
+
export async function pollCapabilityGatesImpl(
|
|
1406
|
+
data: DataLayer,
|
|
1407
|
+
engine: EngineClient,
|
|
1408
|
+
base: string,
|
|
1409
|
+
headers: Record<string, string>,
|
|
1410
|
+
exec: ProbeExec = defaultProbeExec(),
|
|
1411
|
+
env: Record<string, string | undefined> = process.env,
|
|
1412
|
+
) {
|
|
1413
|
+
const gateTable = capabilityGates(data);
|
|
1414
|
+
const probeTimeout = capabilityGateTimeout(env);
|
|
1415
|
+
for (const plan of await plans(data).all()) {
|
|
1416
|
+
const planKey = plan.plan_key;
|
|
1417
|
+
const processKey = plan.process_key;
|
|
1418
|
+
if (!processKey) continue; // no instance key to correlate the barrier against yet
|
|
1419
|
+
const needRows = await planTaskNeeds(data).find({ plan_key: planKey });
|
|
1420
|
+
if (needRows.length === 0) continue;
|
|
1421
|
+
// Group needs by consuming task — a task's barrier releases ONCE, fanning in ALL its needs.
|
|
1422
|
+
const needsByTask = new Map<string, CapabilityNeed[]>();
|
|
1423
|
+
for (const n of needRows) {
|
|
1424
|
+
const list = needsByTask.get(n.task_id) ?? [];
|
|
1425
|
+
list.push({
|
|
1426
|
+
capabilityRef: n.capability_ref,
|
|
1427
|
+
package: n.package,
|
|
1428
|
+
...(n.verify_command ? { verifyCommand: n.verify_command } : {}),
|
|
1429
|
+
});
|
|
1430
|
+
needsByTask.set(n.task_id, list);
|
|
1431
|
+
}
|
|
1432
|
+
for (const [taskId, needs] of needsByTask) {
|
|
1433
|
+
const barrierKey = capabilityTaskBarrierKey(planKey, taskId);
|
|
1434
|
+
try {
|
|
1435
|
+
// Only reconcile a task whose fan-out is actually parked at `wait-caps-resolved` (an OPEN
|
|
1436
|
+
// subscription): otherwise publishing would be a signal into the void (the #262 wedge class).
|
|
1437
|
+
const open = await messageSubscriptionOpen(base, headers, processKey, CAPS_RESOLVED_MESSAGE, barrierKey);
|
|
1438
|
+
if (open !== true) continue; // not parked here yet / already released / unknown → retry next pass
|
|
1439
|
+
|
|
1440
|
+
const resolved: ResolvedCapability[] = [];
|
|
1441
|
+
let allResolved = true;
|
|
1442
|
+
for (const need of needs) {
|
|
1443
|
+
const gateKey = capabilityGateKey(planKey, taskId, need.capabilityRef, need.package);
|
|
1444
|
+
let row = await gateTable.findOne({ gate_key: gateKey });
|
|
1445
|
+
|
|
1446
|
+
// Shape the need into the readiness-gate's probe input. A handle that names no owner/repo
|
|
1447
|
+
// releases source is un-pollable — record it so the operator sees the wedge, and treat the
|
|
1448
|
+
// need as unresolved (it can only clear once the handle is corrected on a re-plan).
|
|
1449
|
+
let probeInput: ReturnType<typeof capabilityNeedToProbeInput>;
|
|
1450
|
+
try {
|
|
1451
|
+
probeInput = capabilityNeedToProbeInput(need, { planKey, taskId, probeTimeout });
|
|
1452
|
+
} catch (err) {
|
|
1453
|
+
if (err instanceof UnresolvableCapabilityRefError) {
|
|
1454
|
+
if (!row) {
|
|
1455
|
+
await gateTable.insert({
|
|
1456
|
+
gate_key: gateKey,
|
|
1457
|
+
plan_key: planKey,
|
|
1458
|
+
task_id: taskId,
|
|
1459
|
+
capability_ref: need.capabilityRef,
|
|
1460
|
+
package: need.package,
|
|
1461
|
+
status: "pending",
|
|
1462
|
+
resolved_artifact: null,
|
|
1463
|
+
process_key: null,
|
|
1464
|
+
created_at: now(),
|
|
1465
|
+
updated_at: now(),
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
console.error(`[poller] capability-gate ${gateKey}: ${err.message}`);
|
|
1469
|
+
allResolved = false;
|
|
1470
|
+
continue;
|
|
1471
|
+
}
|
|
1472
|
+
throw err;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// First sighting: record the gate row so we start it exactly once and survive a restart.
|
|
1476
|
+
if (!row) {
|
|
1477
|
+
await gateTable.insert({
|
|
1478
|
+
gate_key: gateKey,
|
|
1479
|
+
plan_key: planKey,
|
|
1480
|
+
task_id: taskId,
|
|
1481
|
+
capability_ref: need.capabilityRef,
|
|
1482
|
+
package: need.package,
|
|
1483
|
+
status: "pending",
|
|
1484
|
+
resolved_artifact: null,
|
|
1485
|
+
process_key: null,
|
|
1486
|
+
created_at: now(),
|
|
1487
|
+
updated_at: now(),
|
|
1488
|
+
});
|
|
1489
|
+
row = await gateTable.findOne({ gate_key: gateKey });
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
// Start the EXISTING durable readiness-gate exactly once (bounded wait + operator escalation
|
|
1493
|
+
// if the capability never ships). Idempotent: guarded on `process_key`, so a restart never
|
|
1494
|
+
// double-starts. A start failure is non-fatal — we retry the start next pass.
|
|
1495
|
+
if (row && !row.process_key) {
|
|
1496
|
+
try {
|
|
1497
|
+
const { processInstanceKey } = await engine.createInstance({
|
|
1498
|
+
processDefinitionId: READINESS_GATE_PROCESS_ID,
|
|
1499
|
+
variables: {
|
|
1500
|
+
gateKey: probeInput.gateKey,
|
|
1501
|
+
probeTimeout: probeInput.probeTimeout,
|
|
1502
|
+
onTimeout: probeInput.onTimeout,
|
|
1503
|
+
probe: probeInput.probe,
|
|
1504
|
+
},
|
|
1505
|
+
});
|
|
1506
|
+
await gateTable.update(gateKey, { process_key: processInstanceKey, updated_at: now() });
|
|
1507
|
+
row.process_key = processInstanceKey;
|
|
1508
|
+
} catch (err) {
|
|
1509
|
+
console.error(`[poller] capability-gate ${gateKey} start: ${err}`);
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// Already resolved on an earlier pass → reuse the pinned artifact (never re-probe).
|
|
1514
|
+
if (row && row.status === "resolved" && row.resolved_artifact) {
|
|
1515
|
+
resolved.push({ capabilityRef: need.capabilityRef, resolvedArtifact: row.resolved_artifact });
|
|
1516
|
+
continue;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
// One deterministic provenance lookup (NOT a wait loop): has the capability shipped?
|
|
1520
|
+
const result = await probeOnce(probeInput.probe, exec, env);
|
|
1521
|
+
const artifact = result.bind?.resolvedArtifact;
|
|
1522
|
+
if (result.ready && artifact) {
|
|
1523
|
+
await gateTable.update(gateKey, { status: "resolved", resolved_artifact: artifact, updated_at: now() });
|
|
1524
|
+
resolved.push({ capabilityRef: need.capabilityRef, resolvedArtifact: artifact });
|
|
1525
|
+
} else {
|
|
1526
|
+
allResolved = false;
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
// Fan-in: release the task ONLY when every need resolved. The brief pins each
|
|
1531
|
+
// `capabilityRef → pkg@version` into the agent's prompt (late-bind, issue #289 §3).
|
|
1532
|
+
if (allResolved && resolved.length === needs.length) {
|
|
1533
|
+
const resolvedDepsBrief = renderResolvedDepsBrief(resolved);
|
|
1534
|
+
await engine.publishMessage({
|
|
1535
|
+
name: CAPS_RESOLVED_MESSAGE,
|
|
1536
|
+
correlationKey: barrierKey,
|
|
1537
|
+
variables: { resolvedDepsBrief },
|
|
1538
|
+
});
|
|
1539
|
+
console.log(`[poller] capabilities resolved -> ${barrierKey} (${resolved.length})`);
|
|
1540
|
+
}
|
|
1541
|
+
} catch (err) {
|
|
1542
|
+
console.error(`[poller] capability-gate ${barrierKey}: ${err}`);
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1310
1548
|
/** Idempotent read-model pass: recompute each plan's derived `delivery` signal (issue #171) by
|
|
1311
1549
|
* joining its slice tasks' `pr_key` → `pull_requests.status`, and denormalise it onto the `plans`
|
|
1312
1550
|
* row so the epics overview / detail views can read it as a flat column (Urban's datasource can't
|
|
@@ -1866,6 +2104,7 @@ export async function pollOnce(
|
|
|
1866
2104
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
1867
2105
|
if (engineRest.token) headers.authorization = `Bearer ${engineRest.token}`;
|
|
1868
2106
|
await pollWaveGatesImpl(data, engine, token, base, headers);
|
|
2107
|
+
await pollCapabilityGatesImpl(data, engine, base, headers);
|
|
1869
2108
|
await pollJobActivation(data, engineRest.restAddress, engineRest.token);
|
|
1870
2109
|
await pollIncidents(data, engineRest.restAddress, engineRest.token);
|
|
1871
2110
|
}
|