@nanobpm/nano-workforce 0.97.0 → 0.98.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app/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 projects the wait-gate column", () => {
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 index shows the wait-gate at a glance");
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);
@@ -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.97.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",