@nanobpm/nano-workforce 0.97.0 → 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/app/plan.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  // hand-written SQL — matching app/service.ts.
12
12
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
13
13
  import { blackboardUrl, mintBlackboardToken, renderCoordinationBrief } from "./blackboard.ts";
14
+ import { capsWaitTimeout, DEFAULT_CAPS_WAIT_TIMEOUT } from "./capsWait.ts";
14
15
  import { deriveEpicBucket, epicIsAcknowledgeable } from "./delivery.ts";
15
16
  import { EPIC_PHASE } from "./epicPhase.ts";
16
17
  import { DEFAULT_ESCALATION_SLA_TIMEOUT, escalationSlaTimeout } from "./escalationSla.ts";
@@ -37,6 +38,17 @@ export const ESCALATION_SLA_TIMEOUT = escalationSlaTimeout(
37
38
  DEFAULT_ESCALATION_SLA_TIMEOUT,
38
39
  );
39
40
 
41
+ /** The fleet-wide capability-barrier bound (ISO-8601 duration) seeded onto every plan-fanout instance
42
+ * as the `capsWaitTimeout` process variable and evaluated by the `wait-caps-timeout` timer arm of the
43
+ * `wait-caps-resolved` event-based gateway. An operator sets `NANO_CAPS_WAIT_TIMEOUT`; a malformed
44
+ * value falls back to {@link DEFAULT_CAPS_WAIT_TIMEOUT} so a bad env can never deploy an
45
+ * uninterpretable timer. Bounds a task's wait on an unresolvable cross-repo capability so it escalates
46
+ * to an operator instead of parking forever. */
47
+ export const CAPS_WAIT_TIMEOUT = capsWaitTimeout(
48
+ process.env.NANO_CAPS_WAIT_TIMEOUT,
49
+ DEFAULT_CAPS_WAIT_TIMEOUT,
50
+ );
51
+
40
52
  const now = () => new Date().toISOString();
41
53
 
42
54
  // Agent prompts are no longer read by the host. The `senior:plan`, `senior:plan-review`, and
@@ -263,6 +275,51 @@ export interface PlanTaskDep {
263
275
  export const planTaskDeps = (data: DataLayer) =>
264
276
  data.table<PlanTaskDep>("plan_task_deps", "plan_key");
265
277
 
278
+ /** One cross-repo CAPABILITY EDGE on a plan task (049_plan_task_needs.sql, issue #289): the
279
+ * consuming `task_id` must not start until the upstream capability `capability_ref` first ships as
280
+ * a published `package` version. Levelized from the planner's `RecordPlanTask.needs[]` by
281
+ * `pr.record-plan`, read back by `pr.select-wave` to gate the task before dispatch. Keyed on
282
+ * `plan_key` (like {@link PlanTaskDep}) so one delete clears a plan's whole need set on re-plan.
283
+ *
284
+ * `capability_ref` is the STABLE handle (`owner/repo#NNN` | `repo#NNN` | `#NNN`) — NEVER a version
285
+ * (the #263 core decision). `package` is the per-package-scoped provenance artifact. `verify_command`
286
+ * is the optional gated empirical fallback (#274 decision 5); NULL means deterministic-provenance-only. */
287
+ export interface PlanTaskNeed {
288
+ plan_key: string;
289
+ task_id: string;
290
+ capability_ref: string;
291
+ package: string;
292
+ verify_command: string | null;
293
+ }
294
+ export const planTaskNeeds = (data: DataLayer) =>
295
+ data.table<PlanTaskNeed>("plan_task_needs", "plan_key");
296
+
297
+ /** One host-orchestrated CAPABILITY GATE (050_capability_gates.sql, issue #289): the durable,
298
+ * idempotent state the `pollCapabilityGatesImpl` reconciler keeps for ONE (plan, task, capability
299
+ * need) while its plan-fanout fan-out is parked at the `wait-caps-resolved` barrier. The host starts
300
+ * the EXISTING `readiness-gate` process (#258) per need — recording its instance key on `process_key`
301
+ * so it starts exactly once — and, each pass, reconciles whether the capability has shipped as a
302
+ * published `pkg@version`; on match it stamps `resolved_artifact` and flips `status` to `resolved`.
303
+ * When every one of a task's needs is `resolved` the reconciler publishes `caps-resolved` (releasing
304
+ * the barrier with the late-bound brief). Keyed on the readiness-gate correlation key
305
+ * `<plan_key>:<task_id>:<capability_ref>:<package>` ({@link capabilityGateKey}) so a host restart
306
+ * re-derives the whole picture from the DB — never re-starting a gate nor re-publishing a settled
307
+ * barrier. `package` is part of the key so two needs sharing a `capability_ref` across different
308
+ * packages never collide on one gate row. */
309
+ export interface CapabilityGate {
310
+ gate_key: string;
311
+ plan_key: string;
312
+ task_id: string;
313
+ capability_ref: string;
314
+ package: string;
315
+ status: string;
316
+ resolved_artifact: string | null;
317
+ process_key: string | null;
318
+ created_at: string;
319
+ updated_at: string;
320
+ }
321
+ export const capabilityGates = (data: DataLayer) =>
322
+ data.table<CapabilityGate>("capability_gates", "gate_key");
266
323
  /** One INTER-epic dependency edge in the plan-set DAG (issue #292, slice S1): the epic `plan_key`
267
324
  * waits for the producer epic `depends_on_plan_key` to publish a capability before it may fan out.
268
325
  *
@@ -1014,6 +1071,12 @@ export async function startPlan(
1014
1071
  // `operators` candidate group); an operator/agent can claim/reassign via the task inbox.
1015
1072
  escalationSlaTimeout: ESCALATION_SLA_TIMEOUT,
1016
1073
  escalationAssignee: null,
1074
+ // Capability-barrier bound (#289): the validated ISO-8601 duration read by the
1075
+ // `wait-caps-timeout` timer arm of the `wait-caps-resolved` event-based gateway. A task whose
1076
+ // declared cross-repo capabilities never resolve (most acutely an unresolvable capabilityRef the
1077
+ // host reconciler can never gate) escalates to the `feature-escalation` operator user task when
1078
+ // this elapses, instead of parking at the barrier forever — durable in-process liveness.
1079
+ capsWaitTimeout: CAPS_WAIT_TIMEOUT,
1017
1080
  // Coordination blackboard (#51): the capability URL + the protocol brief that each
1018
1081
  // implementer agent gets appended to its prompt (composed into `appendPrompt` in
1019
1082
  // plan-fanout.bpmn's implement-task). Advisory shared state, delivered in-band, used
@@ -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
+ });