@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.
@@ -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
  }