@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/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 +8 -0
- package/app/mergeEscalationQuestion.test.ts +140 -0
- package/app/plan.ts +63 -0
- package/app/service.test.ts +375 -1
- package/app/service.ts +239 -0
- package/app/waitGateVisibility.test.ts +5 -2
- package/db/migrations/049_plan_task_needs.sql +30 -0
- package/db/migrations/050_capability_gates.sql +40 -0
- package/e2e/plan-fanout.e2e.ts +145 -0
- package/nano.app.json +4 -0
- package/package.json +1 -1
- package/resources/processes/merge-loop.bpmn +54 -17
- package/resources/processes/plan-fanout.bpmn +347 -194
- package/resources/prompts/plan.md +29 -1
- package/workers/caps-prepare/worker.test.ts +62 -0
- package/workers/caps-prepare/worker.ts +38 -0
- package/workers/record-plan/worker.test.ts +33 -1
- package/workers/record-plan/worker.ts +24 -1
- package/workers/select-wave/worker.test.ts +34 -2
- package/workers/select-wave/worker.ts +26 -2
package/app/service.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
|
}
|
|
@@ -23,12 +23,15 @@ test("select-wave's input envelope carries resolvedArtifacts so the worker can c
|
|
|
23
23
|
assertStringIncludes(shape![0], 'name="resolvedArtifacts" type="string" list="true" optional="true"');
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
-
test("the epics index
|
|
26
|
+
test("the epics index is the lean list; the wait-gate lives on the detail panel", () => {
|
|
27
|
+
// The Epics index was redesigned to a lean list (issue #327): the Wait gate (like Base branch and
|
|
28
|
+
// Promotion) moved OFF the index onto the epic-detail panel, where it is asserted below. So the
|
|
29
|
+
// index no longer carries the wait-gate column — it only needs the lean grid to exist.
|
|
27
30
|
const page = JSON.parse(readFileSync("pages/epic.page.json", "utf8"));
|
|
28
31
|
const grid = page.nodes.find((n: any) => n.id === "epic-plans");
|
|
29
32
|
assert(grid, "epics index must have the epic-plans grid");
|
|
30
33
|
const cols: string[] = grid.props.columns.map((c: any) => c.field);
|
|
31
|
-
assert(cols.includes("wait_gate_label"), "the
|
|
34
|
+
assert(!cols.includes("wait_gate_label"), "the wait-gate moved to the epic-detail panel");
|
|
32
35
|
});
|
|
33
36
|
|
|
34
37
|
test("the epic detail projects both the DAG (plan_deps) and the wait-gate state", () => {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
-- 049_plan_task_needs.sql — issue #289: the cross-repo CAPABILITY EDGE on a plan task.
|
|
2
|
+
--
|
|
3
|
+
-- A plan task may declare a cross-repo capability dependency (the "consumer readiness edge",
|
|
4
|
+
-- ADR 0001 §4, #263): it consumes an upstream capability C that is published as some
|
|
5
|
+
-- `pkg@version` from ANOTHER repo, and must not start until that capability first ships. The
|
|
6
|
+
-- planner emits this as an optional `needs: CapabilityNeed[]` on each `RecordPlanTask`
|
|
7
|
+
-- (plan-fanout.bpmn); this table is where `pr.record-plan` levelizes it to, mirroring
|
|
8
|
+
-- `plan_task_deps` (the intra-epic edge table). One row per (task, capability need).
|
|
9
|
+
--
|
|
10
|
+
-- `capability_ref` is the STABLE upstream handle (`owner/repo#NNN`, `repo#NNN`, or `#NNN`) —
|
|
11
|
+
-- NEVER a version (the #263 core decision: declare the handle, resolve the version at the gate).
|
|
12
|
+
-- `package` is the artifact whose GitHub Releases carry the publish provenance (`@nanobpm/urban`),
|
|
13
|
+
-- per-package scoped so a sibling package's provenance can't leak in. `verify_command` is the
|
|
14
|
+
-- OPTIONAL gated empirical fallback (#274 decision 5) — deterministic provenance stays the default.
|
|
15
|
+
--
|
|
16
|
+
-- Keyed on `plan_key` (like plan_task_deps) so a single delete clears a plan's whole need set on a
|
|
17
|
+
-- re-plan / idempotent re-run. Forward-only, additive (expand): a brand-new table, so pre-#289 rows
|
|
18
|
+
-- and plans carry no needs and behave exactly as before. Numbered after the current highest prefix
|
|
19
|
+
-- on origin/main (048); the runner wraps each file in its own transaction, so no BEGIN/COMMIT here.
|
|
20
|
+
|
|
21
|
+
CREATE TABLE plan_task_needs (
|
|
22
|
+
plan_key TEXT NOT NULL REFERENCES plans(plan_key),
|
|
23
|
+
task_id TEXT NOT NULL, -- the consuming task's slug
|
|
24
|
+
capability_ref TEXT NOT NULL, -- upstream handle: owner/repo#NNN | repo#NNN | #NNN (never a version)
|
|
25
|
+
package TEXT NOT NULL, -- artifact whose releases carry provenance, e.g. @nanobpm/urban
|
|
26
|
+
verify_command TEXT, -- optional gated empirical fallback (NULL = deterministic-only)
|
|
27
|
+
PRIMARY KEY (plan_key, task_id, capability_ref, package)
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
CREATE INDEX idx_plan_task_needs_plan ON plan_task_needs(plan_key);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
-- 050_capability_gates.sql — issue #289: the host-orchestrated CAPABILITY GATE tracker.
|
|
2
|
+
--
|
|
3
|
+
-- When plan-fanout dispatches a wave task that declared a cross-repo capability edge (`needs`, see
|
|
4
|
+
-- 049_plan_task_needs.sql), its per-task fan-out parks at the `wait-caps-resolved` message barrier
|
|
5
|
+
-- (plan-fanout.bpmn) instead of starting the agent. The host reconciler (`pollCapabilityGatesImpl`
|
|
6
|
+
-- in app/service.ts) then, for each such parked task, starts the EXISTING durable `readiness-gate`
|
|
7
|
+
-- process (readiness-gate.bpmn, #258) once per need — the bounded/idempotent/resumable wait that
|
|
8
|
+
-- escalates to an operator if the capability never ships — and reconciles, each pass, whether the
|
|
9
|
+
-- capability has shipped as a published `pkg@version`. When ALL of a task's needs have resolved it
|
|
10
|
+
-- publishes `caps-resolved` with the late-bound resolved-dependencies brief, releasing the barrier.
|
|
11
|
+
--
|
|
12
|
+
-- This table is that reconciler's durable, idempotent state: ONE row per (plan, task, need),
|
|
13
|
+
-- identified by the readiness-gate correlation key `<plan_key>:<task_id>:<capability_ref>:<package>`
|
|
14
|
+
-- (capabilityGateKey). `package` is part of the key because a task can declare the same
|
|
15
|
+
-- `capability_ref` for different packages (plan_task_needs' PK includes `package`), so each
|
|
16
|
+
-- `(capability_ref, package)` edge must map 1:1 to its own gate row. It records the started gate's
|
|
17
|
+
-- instance key (so we start it exactly once) and
|
|
18
|
+
-- the resolved `pkg@version` once the deterministic provenance lookup goes green, so a host restart
|
|
19
|
+
-- re-derives the whole picture from the DB (never re-starts a gate, never re-publishes a settled
|
|
20
|
+
-- barrier). `status` is 'pending' until the need resolves, then 'resolved'.
|
|
21
|
+
--
|
|
22
|
+
-- Numbered after the current highest prefix on origin/main (048); forward-only, additive (expand):
|
|
23
|
+
-- a brand-new table, so pre-#289 plans carry no gate rows and behave exactly as before. The runner
|
|
24
|
+
-- wraps each file in its own transaction, so no BEGIN/COMMIT here.
|
|
25
|
+
|
|
26
|
+
CREATE TABLE capability_gates (
|
|
27
|
+
gate_key TEXT PRIMARY KEY, -- readiness-gate correlation key: <plan_key>:<task_id>:<capability_ref>:<package>
|
|
28
|
+
plan_key TEXT NOT NULL REFERENCES plans(plan_key),
|
|
29
|
+
task_id TEXT NOT NULL, -- the consuming task's slug
|
|
30
|
+
capability_ref TEXT NOT NULL, -- upstream handle: owner/repo#NNN | repo#NNN | #NNN (never a version)
|
|
31
|
+
package TEXT NOT NULL, -- artifact whose releases carry provenance, e.g. @nanobpm/urban
|
|
32
|
+
status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'resolved'
|
|
33
|
+
resolved_artifact TEXT, -- the late-bound pkg@version once resolved (NULL while pending)
|
|
34
|
+
process_key TEXT, -- the started readiness-gate instance key (NULL until started)
|
|
35
|
+
created_at TEXT NOT NULL,
|
|
36
|
+
updated_at TEXT NOT NULL
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
CREATE INDEX idx_capability_gates_plan ON capability_gates(plan_key);
|
|
40
|
+
CREATE INDEX idx_capability_gates_task ON capability_gates(plan_key, task_id);
|
package/e2e/plan-fanout.e2e.ts
CHANGED
|
@@ -300,4 +300,149 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
|
|
|
300
300
|
`abandon routed to record-results (flows: ${flows.join(", ")})`,
|
|
301
301
|
);
|
|
302
302
|
});
|
|
303
|
+
|
|
304
|
+
// Capability edge (issue #289): a task that declares cross-repo `needs` must PARK at the
|
|
305
|
+
// `wait-caps-resolved` barrier (never take the no-needs shortcut), and publishing `caps-resolved`
|
|
306
|
+
// correlated on the per-task barrier key `<planKey>:<taskId>` must release it into `implement-task`
|
|
307
|
+
// with the late-bound resolved-deps brief appended to the agent prompt. This ALSO proves the
|
|
308
|
+
// subprocess-level `capsGateKey = planKey + ":" + task.id` ioMapping resolves (the correlation would
|
|
309
|
+
// never match otherwise).
|
|
310
|
+
test("capability edge: a task with needs parks at wait-caps-resolved and caps-resolved releases it (#289)", async () => {
|
|
311
|
+
const capTaskPlan: Stub = () => ({
|
|
312
|
+
tasks: [
|
|
313
|
+
{
|
|
314
|
+
id: "t1",
|
|
315
|
+
title: "T1",
|
|
316
|
+
prompt: "do t1",
|
|
317
|
+
needs: [{ capabilityRef: "nanobpm/nano-ide#274", package: "@nanobpm/urban" }],
|
|
318
|
+
},
|
|
319
|
+
],
|
|
320
|
+
});
|
|
321
|
+
let featureBrief: unknown;
|
|
322
|
+
await withApp(
|
|
323
|
+
{
|
|
324
|
+
"senior:plan": capTaskPlan,
|
|
325
|
+
"senior:plan-review": approveReview,
|
|
326
|
+
"senior:feature": (job) => {
|
|
327
|
+
featureBrief = (job.variables as Record<string, unknown>)?.["resolvedDepsBrief"];
|
|
328
|
+
return { status: "opened", summary: "built t1", pr: "owner/repo#2" };
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
async ({ app, planKey, processKey }) => {
|
|
332
|
+
const needRows = await app.db
|
|
333
|
+
.table<{ plan_key: string; task_id: string; capability_ref: string }>("plan_task_needs", "plan_key")
|
|
334
|
+
.find({ plan_key: planKey });
|
|
335
|
+
assert.ok(needRows.length > 0, "the task's capability need was persisted");
|
|
336
|
+
// The fan-out must have reached the barrier gateway's needs branch, NOT the no-needs shortcut,
|
|
337
|
+
// and must be parked at `wait-caps-resolved` (the feature agent has NOT run yet).
|
|
338
|
+
const parked = takenFlows(app);
|
|
339
|
+
assert.ok(
|
|
340
|
+
parked.includes("w_gw_needs->w_gw_caps") && parked.includes("w_gw_caps->wait-caps-resolved"),
|
|
341
|
+
`task with needs routed through the barrier gateway to wait-caps-resolved (flows: ${parked.join(", ")})`,
|
|
342
|
+
);
|
|
343
|
+
assert.ok(
|
|
344
|
+
!parked.includes("w_gw_needs->implement-task"),
|
|
345
|
+
"the no-needs shortcut was NOT taken for a task that declares needs",
|
|
346
|
+
);
|
|
347
|
+
assert.equal(featureBrief, undefined, "the agent has not been dispatched while parked at the barrier");
|
|
348
|
+
|
|
349
|
+
// Release the barrier exactly as the host reconciler would: publish `caps-resolved` correlated
|
|
350
|
+
// on the per-task barrier key with the late-bound resolved-deps brief.
|
|
351
|
+
const barrierKey = `${planKey}:t1`;
|
|
352
|
+
await app.engine.publishMessage({
|
|
353
|
+
name: "caps-resolved",
|
|
354
|
+
correlationKey: barrierKey,
|
|
355
|
+
variables: { resolvedDepsBrief: "\n\nRESOLVED: @nanobpm/urban@0.54.0" },
|
|
356
|
+
});
|
|
357
|
+
await app.settle();
|
|
358
|
+
|
|
359
|
+
const flows = takenFlows(app);
|
|
360
|
+
assert.ok(
|
|
361
|
+
flows.includes("wait-caps-resolved->implement-task"),
|
|
362
|
+
`caps-resolved released the barrier into implement-task (flows: ${flows.join(", ")})`,
|
|
363
|
+
);
|
|
364
|
+
assert.equal(
|
|
365
|
+
featureBrief,
|
|
366
|
+
"\n\nRESOLVED: @nanobpm/urban@0.54.0",
|
|
367
|
+
`the late-bound resolved-deps brief reached the agent (got: ${JSON.stringify(featureBrief)})`,
|
|
368
|
+
);
|
|
369
|
+
assert.ok(processKey, "processKey resolved");
|
|
370
|
+
},
|
|
371
|
+
);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// Capability barrier liveness (issue #289): a task whose declared cross-repo capability NEVER
|
|
375
|
+
// resolves (the reviewer's `UnresolvableCapabilityRefError` wedge: a `caps-resolved` message the
|
|
376
|
+
// host reconciler can never publish) must NOT park at `wait-caps-resolved` forever. The barrier is
|
|
377
|
+
// an event-based gateway racing the resolve message against a bounded `capsWaitTimeout` timer; when
|
|
378
|
+
// the bound elapses the timer arm fires and the token escalates to the `feature-escalation` operator
|
|
379
|
+
// user task (with a seeded operator `question`). We never publish `caps-resolved` — advancing the
|
|
380
|
+
// virtual clock past the seeded bound is the only way the token can move, so a green here proves the
|
|
381
|
+
// durable in-process bound (a barrier with no timer would simply hang, taking no flow).
|
|
382
|
+
test("capability edge: an unresolvable barrier escalates to the operator when the bound elapses (#289)", async () => {
|
|
383
|
+
const capTaskPlan: Stub = () => ({
|
|
384
|
+
tasks: [
|
|
385
|
+
{
|
|
386
|
+
id: "t1",
|
|
387
|
+
title: "T1",
|
|
388
|
+
prompt: "do t1",
|
|
389
|
+
// A bare handle names no owner/repo releases source — unresolvable, so caps-resolved never comes.
|
|
390
|
+
needs: [{ capabilityRef: "#274", package: "@nanobpm/urban" }],
|
|
391
|
+
},
|
|
392
|
+
],
|
|
393
|
+
});
|
|
394
|
+
let featureRan = false;
|
|
395
|
+
await withApp(
|
|
396
|
+
{
|
|
397
|
+
"senior:plan": capTaskPlan,
|
|
398
|
+
"senior:plan-review": approveReview,
|
|
399
|
+
"senior:feature": () => {
|
|
400
|
+
featureRan = true;
|
|
401
|
+
return { status: "opened", summary: "built t1", pr: "owner/repo#2" };
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
async ({ app, processKey }) => {
|
|
405
|
+
// Parked at the barrier: the agent has not run and no timeout flow has been taken yet.
|
|
406
|
+
assert.equal(featureRan, false, "the agent is not dispatched while parked at the barrier");
|
|
407
|
+
const parked = takenFlows(app);
|
|
408
|
+
assert.ok(
|
|
409
|
+
parked.includes("w_gw_caps->wait-caps-resolved") && parked.includes("w_gw_caps->wait-caps-timeout"),
|
|
410
|
+
`both barrier arms are armed by the event-based gateway (flows: ${parked.join(", ")})`,
|
|
411
|
+
);
|
|
412
|
+
assert.ok(!parked.includes("wait-caps-timeout->feature-escalation"), "the timeout has not fired yet");
|
|
413
|
+
|
|
414
|
+
// Never publish caps-resolved — let the bound (default P1D) elapse. Advancing past it is the
|
|
415
|
+
// ONLY way the token can move, so this proves the wait is genuinely bounded.
|
|
416
|
+
await app.advanceTime(25 * 60 * 60 * 1000);
|
|
417
|
+
await app.settle();
|
|
418
|
+
|
|
419
|
+
const flows = takenFlows(app);
|
|
420
|
+
assert.ok(
|
|
421
|
+
flows.includes("wait-caps-timeout->feature-escalation"),
|
|
422
|
+
`the caps bound escalated the parked task to the operator (flows: ${flows.join(", ")})`,
|
|
423
|
+
);
|
|
424
|
+
assert.ok(
|
|
425
|
+
!flows.includes("wait-caps-resolved->implement-task"),
|
|
426
|
+
"the resolve arm was withdrawn — the token did not also proceed as if resolved",
|
|
427
|
+
);
|
|
428
|
+
assert.equal(featureRan, false, "the agent was NOT dispatched — the unresolved task escalated instead");
|
|
429
|
+
|
|
430
|
+
// The escalation is a genuine, operable operator decision point: answering it loops the
|
|
431
|
+
// child back to re-dispatch the task (the operator having unblocked/decided), exactly like an
|
|
432
|
+
// agent-raised escalation — proving this is a real bounded wait + operator escalation, not a
|
|
433
|
+
// dead end.
|
|
434
|
+
const task = await openTask(app, processKey, "feature-escalation");
|
|
435
|
+
assert.ok(task.userTaskKey, "the caps-timeout escalation carries a completable userTaskKey");
|
|
436
|
+
await app.engine.completeUserTask(task.userTaskKey, { resolution: "answer", answer: "shipped it manually" });
|
|
437
|
+
await app.settle();
|
|
438
|
+
|
|
439
|
+
const answered = takenFlows(app);
|
|
440
|
+
assert.ok(
|
|
441
|
+
answered.includes("w_gw_answer->implement-task"),
|
|
442
|
+
`answering the caps escalation routed back to implement-task (flows: ${answered.join(", ")})`,
|
|
443
|
+
);
|
|
444
|
+
assert.equal(featureRan, true, "the agent was dispatched once the operator answered the caps escalation");
|
|
445
|
+
},
|
|
446
|
+
);
|
|
447
|
+
});
|
|
303
448
|
});
|
package/nano.app.json
CHANGED
|
@@ -118,6 +118,10 @@
|
|
|
118
118
|
"taskType": "pr.select-wave",
|
|
119
119
|
"handler": "workers/select-wave/worker.ts"
|
|
120
120
|
},
|
|
121
|
+
{
|
|
122
|
+
"taskType": "pr.caps-prepare",
|
|
123
|
+
"handler": "workers/caps-prepare/worker.ts"
|
|
124
|
+
},
|
|
121
125
|
{
|
|
122
126
|
"taskType": "pr.record-wave",
|
|
123
127
|
"handler": "workers/record-wave/worker.ts"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.98.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|