@nanobpm/nano-workforce 0.97.1 → 0.98.1
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 +14 -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 +15 -0
- package/app/github.ts +22 -4
- package/app/mergeEscalationQuestion.test.ts +17 -0
- package/app/mergeRetry.test.ts +90 -0
- package/app/mergeRetryArm.test.ts +100 -0
- package/app/plan.ts +63 -0
- package/app/service.test.ts +375 -1
- package/app/service.ts +249 -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/merge-loop.bpmn +124 -68
- 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/merge/worker.test.ts +38 -0
- package/workers/merge/worker.ts +9 -3
- 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
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Structural regression guard for the merge-loop transient-retry arm (issue #334).
|
|
2
|
+
//
|
|
3
|
+
// #334: a transient, GitHub-flagged-retryable merge race ("Base branch was modified. Review and
|
|
4
|
+
// try the merge again.") was mapped to `blocked` → a decision-required human escalation on a PR
|
|
5
|
+
// that was actually mergeable once the base settled. The fix adds a `retry` merge outcome that
|
|
6
|
+
// re-enters the merge loop on the settled base through a *bounded* budget gate — mirroring the
|
|
7
|
+
// `gw-ci-fix` within-budget / budget-exhausted pattern — WITHOUT any remediation agent, and
|
|
8
|
+
// escalates via the existing `merge-esc-attempt` only when the retry budget is exhausted (so a
|
|
9
|
+
// continuously-moving base still escalates promptly rather than spinning forever).
|
|
10
|
+
//
|
|
11
|
+
// This test asserts the arm's topology on the committed model so it cannot regress silently. It is
|
|
12
|
+
// a pure text assertion over the BPMN (no engine), matching the repo's lightweight model-guard style.
|
|
13
|
+
|
|
14
|
+
import { test } from "node:test";
|
|
15
|
+
import { assert, assertStringIncludes } from "#test-assert";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
|
|
18
|
+
const bpmn = readFileSync("resources/processes/merge-loop.bpmn", "utf8");
|
|
19
|
+
const flat = bpmn.replace(/\s+/g, " ");
|
|
20
|
+
|
|
21
|
+
function hasFlow(source: string, target: string): boolean {
|
|
22
|
+
const re = new RegExp(
|
|
23
|
+
`<bpmn:sequenceFlow\\b[^>]*\\bsourceRef="${source}"[^>]*\\btargetRef="${target}"|` +
|
|
24
|
+
`<bpmn:sequenceFlow\\b[^>]*\\btargetRef="${target}"[^>]*\\bsourceRef="${source}"`,
|
|
25
|
+
);
|
|
26
|
+
return re.test(flat);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function flowHasId(id: string, source: string, target: string): boolean {
|
|
30
|
+
const m = flat.match(new RegExp(`<bpmn:sequenceFlow\\b[^>]*\\bid="${id}"[^>]*/?>`));
|
|
31
|
+
if (!m) return false;
|
|
32
|
+
const tag = m[0];
|
|
33
|
+
return tag.includes(`sourceRef="${source}"`) && tag.includes(`targetRef="${target}"`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function gatewayDefault(id: string, def: string): boolean {
|
|
37
|
+
const m = flat.match(new RegExp(`<bpmn:exclusiveGateway\\b[^>]*\\bid="${id}"[^>]*>`));
|
|
38
|
+
if (!m) return false;
|
|
39
|
+
return m[0].includes(`default="${def}"`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Assert the conditionExpression *of a specific sequenceFlow* contains `needle`, so the guard
|
|
43
|
+
// cannot be satisfied by the same substring appearing on an unrelated flow (e.g. the
|
|
44
|
+
// merge-esc-attempt question FEEL also mentions `mergeStatus = "retry"`).
|
|
45
|
+
function flowHasCondition(id: string, needle: string): boolean {
|
|
46
|
+
const m = flat.match(
|
|
47
|
+
new RegExp(`<bpmn:sequenceFlow\\b[^>]*\\bid="${id}"[^>]*>(.*?)</bpmn:sequenceFlow>`),
|
|
48
|
+
);
|
|
49
|
+
if (!m) return false;
|
|
50
|
+
const cond = m[1].match(/<bpmn:conditionExpression\b[^>]*>(.*?)<\/bpmn:conditionExpression>/);
|
|
51
|
+
return cond ? cond[1].includes(needle) : false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
test("gw-merge routes the retry outcome to a dedicated budget gate, not to a human", () => {
|
|
55
|
+
// A `retry` merge result must reach the retry-budget gate…
|
|
56
|
+
assert(hasFlow("gw-merge", "gw-merge-retry"), "gw-merge → gw-merge-retry (retry) missing");
|
|
57
|
+
// …guarded by the exact retry condition (mergeStatus = "retry").
|
|
58
|
+
assert(flowHasId("f_m_gRetry", "gw-merge", "gw-merge-retry"), "f_m_gRetry must be gw-merge → gw-merge-retry");
|
|
59
|
+
// Assert the retry condition on f_m_gRetry ITSELF — not merely anywhere in the model — so the
|
|
60
|
+
// guard cannot be satisfied by the identical substring in the merge-esc-attempt question FEEL.
|
|
61
|
+
assert(
|
|
62
|
+
flowHasCondition("f_m_gRetry", 'mergeStatus = "retry"'),
|
|
63
|
+
'f_m_gRetry conditionExpression must be mergeStatus = "retry"',
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("retry arm mirrors the fix-ci arm: budget gate → re-arm, exhausted → escalate", () => {
|
|
68
|
+
// Within budget → re-arm the merge poller (re-attempt on the settled base). No remediation agent.
|
|
69
|
+
assert(hasFlow("gw-merge-retry", "arm-merge"), "gw-merge-retry → arm-merge (within budget) missing");
|
|
70
|
+
assert(flowHasId("f_mr_go", "gw-merge-retry", "arm-merge"), "f_mr_go must be gw-merge-retry → arm-merge");
|
|
71
|
+
assertStringIncludes(flat, "mergeRetryRound <= mergeRetryMax");
|
|
72
|
+
|
|
73
|
+
// Budget exhausted → the EXISTING human escalation (merge-esc-attempt), and it is the gateway default
|
|
74
|
+
// so a continuously-moving base can never spin past the cap.
|
|
75
|
+
assert(
|
|
76
|
+
hasFlow("gw-merge-retry", "merge-esc-attempt"),
|
|
77
|
+
"gw-merge-retry → merge-esc-attempt (budget exhausted) missing",
|
|
78
|
+
);
|
|
79
|
+
assert(gatewayDefault("gw-merge-retry", "f_mr_giveup"), "gw-merge-retry must default to f_mr_giveup");
|
|
80
|
+
assert(
|
|
81
|
+
flowHasId("f_mr_giveup", "gw-merge-retry", "merge-esc-attempt"),
|
|
82
|
+
"f_mr_giveup must default gw-merge-retry → merge-esc-attempt",
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("the retry arm advances the attempt counter only on a transient retry outcome", () => {
|
|
87
|
+
// The counter advances ONLY when the merge attempt returned `retry` — mirroring how fix-ci/rebase
|
|
88
|
+
// advance their own rounds only on their own remediation — so unrelated merge attempts (initial,
|
|
89
|
+
// post-rebase, post-fix-ci, post-evict) can't consume the transient-retry budget. N consecutive
|
|
90
|
+
// transient races then trip the `mergeRetryRound <= mergeRetryMax` gate at exactly the cap.
|
|
91
|
+
assertStringIncludes(flat, "=if mergeStatus = "retry" then mergeRetryRound + 1 else mergeRetryRound");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("the retry arm has NO remediation agent (contrast the conflict/rebase arm)", () => {
|
|
95
|
+
// The base-moved race needs only a re-attempt on the settled base — no rebase/CI-fix agent. The
|
|
96
|
+
// within-budget flow goes straight back to arm-merge, never through a `senior:*` task.
|
|
97
|
+
assert(flowHasId("f_mr_go", "gw-merge-retry", "arm-merge"), "retry within-budget must go directly to arm-merge");
|
|
98
|
+
// Sanity: the untouched blocked path still escalates directly (a genuine refusal is unchanged).
|
|
99
|
+
assert(flowHasId("f_m_gBlocked", "gw-merge", "merge-esc-attempt"), "blocked → merge-esc-attempt must remain");
|
|
100
|
+
});
|
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
|
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
|
+
});
|