@nanobpm/nano-workforce 0.102.0 → 0.103.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 CHANGED
@@ -1,3 +1,17 @@
1
+ # [0.103.0](https://github.com/nanobpm/nano-workforce/compare/v0.102.1...v0.103.0) (2026-08-19)
2
+
3
+
4
+ ### Features
5
+
6
+ * **feature:** intake-time readiness gate for single-issue runs ([#295](https://github.com/nanobpm/nano-workforce/issues/295)) ([#349](https://github.com/nanobpm/nano-workforce/issues/349)) ([09b519b](https://github.com/nanobpm/nano-workforce/commit/09b519be9ff21c8821b3ec3e0687541710be718b)), closes [owner/repo#N](https://github.com/owner/repo/issues/N)
7
+
8
+ ## [0.102.1](https://github.com/nanobpm/nano-workforce/compare/v0.102.0...v0.102.1) (2026-08-19)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **merge-loop:** re-attempt merge for stale/transient CI instead of paging a human ([#348](https://github.com/nanobpm/nano-workforce/issues/348)) ([#350](https://github.com/nanobpm/nano-workforce/issues/350)) ([87ce2d6](https://github.com/nanobpm/nano-workforce/commit/87ce2d69e893b4b0f965f481d6c247fb8fcb84e8))
14
+
1
15
  # [0.102.0](https://github.com/nanobpm/nano-workforce/compare/v0.101.1...v0.102.0) (2026-08-19)
2
16
 
3
17
 
package/SPEC.md CHANGED
@@ -424,8 +424,30 @@ start ─► wait: deps merged ─► arm merge ─► wait: mergeable ─┬─
424
424
  check names ride `appendPrompt`) to green the checks on the branch, then re-arms the
425
425
  poller. It repeats while `ciFixRound < ciFixMax`
426
426
  (`NANO_PR_MAX_CI_FIX_ROUNDS`, default 3; `0` disables). Only when the budget is
427
- exhausted, the agent reports `blocked`, or the branch is in `conflict` does it fall
428
- through to the human escalation path.
427
+ exhausted, the agent reports `blocked` *and the PR is still blocked after a
428
+ ground-truth reconcile*, or the branch is in `conflict` does it fall through to the
429
+ human escalation path.
430
+
431
+ - **Stale / transient checks — re-attempt, don't escalate** (issue #348) — GitHub's CI
432
+ concurrency **cancels** a superseded workflow run while a newer run on the *identical
433
+ head SHA* takes over. Both land in the head's `statusCheckRollup` under the same check
434
+ name — the stale one stamped `CANCELLED`, the live one green. This is a
435
+ **CI-concurrency-cancellation drift class**, not a code defect, defended at three
436
+ layers so it never pages a human:
437
+ - **Derivation (root cause)** — the merge poller's check derivation collapses the
438
+ rollup to the **newest run per `(headSha, checkName)`** (`latestRunPerCheck`) before
439
+ classifying, so a `CANCELLED` run superseded by a newer green run is **not** counted
440
+ as a failing gate. The phantom `blocked` never arises, so `fix-ci` is not even armed.
441
+ - **Agent verdict** — when `fix-ci` pushes nothing because the failing checks are
442
+ stale/transient (head already green), it returns `status: "reattempt"` (with
443
+ `pushed: false`). That routes to `arm-merge` — the merge is simply re-queued from
444
+ ground truth. The prompt reserves `blocked` for a genuine human decision (a missing
445
+ secret, an un-fixable failure), never a self-healing PR.
446
+ - **Reconcile-before-escalate guard** — even a *mislabelled* `blocked` self-heals: a
447
+ `blocked` verdict with no push (`pushed != true`) reconciles **once** via ground
448
+ truth (`gw-ci-blocked` → `ci-reconcile`, which re-arms the canonical merge poller and
449
+ sets `ciBlockedReconciled`), and escalates only if the PR is **still** blocked on the
450
+ re-derived state.
429
451
 
430
452
  - **Discovered dependency** — a `senior:fix-ci` or `senior:rebase` agent may find that
431
453
  the PR cannot land because **another PR must merge first** (a required linked-issue
@@ -0,0 +1,93 @@
1
+ // Derivation-layer guard for the CI-concurrency-cancellation drift class (issue #348).
2
+ //
3
+ // GitHub's CI concurrency cancels a superseded workflow run while a newer run on the *identical
4
+ // head SHA* takes over. Both land in the head's `statusCheckRollup` under the same check name — the
5
+ // stale one stamped `CANCELLED`, the live one green. The merge poller's check derivation used to
6
+ // count that stale `CANCELLED` as a failing required check, so a PR whose head is actually green
7
+ // read as `blocked`, armed `senior:fix-ci`, which honestly pushed nothing and (pre-#348) returned
8
+ // `blocked` → a human merge-escalation for a self-healing PR.
9
+ //
10
+ // The fix collapses the rollup to the NEWEST run per check before classifying, so a superseded
11
+ // `CANCELLED` never counts. These are pure unit tests over the exported derivation helpers.
12
+
13
+ import { test } from "node:test";
14
+ import { assert, assertEquals } from "#test-assert";
15
+ import { allCheckNames, failingCheckNames, latestRunPerCheck } from "./github.ts";
16
+
17
+ test("a CANCELLED run superseded by a newer green run on the same head does not count as failing", () => {
18
+ const rollup = [
19
+ // The superseded run: GitHub CI concurrency cancelled it when a newer run started.
20
+ { name: "engine-core", conclusion: "CANCELLED", startedAt: "2024-01-01T00:00:00Z", completedAt: "2024-01-01T00:01:00Z" },
21
+ // The live run on the identical head SHA: green.
22
+ { name: "engine-core", conclusion: "SUCCESS", startedAt: "2024-01-01T00:02:00Z", completedAt: "2024-01-01T00:05:00Z" },
23
+ ];
24
+ assertEquals(failingCheckNames(rollup), [], "the stale CANCELLED must not read as a failing gate");
25
+ });
26
+
27
+ test("every CANCELLED-superseded required check on one head SHA is dropped (the #348 instance)", () => {
28
+ // The exact PR #887 evidence: four required checks each with a superseded CANCELLED + a newer
29
+ // green run on the same head. None must count as failing.
30
+ const names = ["engine-core", "engine-wasm read-model wasm32 type-check", "processos", "server"];
31
+ const rollup = names.flatMap((name) => [
32
+ { name, conclusion: "CANCELLED", startedAt: "2024-01-01T00:00:00Z" },
33
+ { name, conclusion: "SUCCESS", startedAt: "2024-01-01T00:02:00Z" },
34
+ ]);
35
+ assertEquals(failingCheckNames(rollup), [], "no superseded CANCELLED may count as a failing check");
36
+ });
37
+
38
+ test("a genuine failure on the newest run still counts (no false-negative)", () => {
39
+ const rollup = [
40
+ { name: "engine-core", conclusion: "SUCCESS", startedAt: "2024-01-01T00:00:00Z" },
41
+ // Newest run genuinely failed — this must still be reported.
42
+ { name: "engine-core", conclusion: "FAILURE", startedAt: "2024-01-01T00:02:00Z" },
43
+ ];
44
+ assertEquals(failingCheckNames(rollup), ["engine-core"], "a real failure on the newest run must count");
45
+ });
46
+
47
+ test("a lone CANCELLED with no superseding run still counts (nothing green replaced it)", () => {
48
+ const rollup = [{ name: "engine-core", conclusion: "CANCELLED", startedAt: "2024-01-01T00:00:00Z" }];
49
+ assertEquals(failingCheckNames(rollup), ["engine-core"], "an unsuperseded CANCELLED remains a failing gate");
50
+ });
51
+
52
+ test("ties (missing timestamps) prefer the non-CANCELLED run so the real result wins", () => {
53
+ // GitHub sometimes omits run times; a superseded CANCELLED alongside a completed run must not
54
+ // shadow the real conclusion even when neither carries a timestamp.
55
+ const cancelledFirst = [
56
+ { name: "server", conclusion: "CANCELLED" },
57
+ { name: "server", conclusion: "SUCCESS" },
58
+ ];
59
+ const successFirst = [
60
+ { name: "server", conclusion: "SUCCESS" },
61
+ { name: "server", conclusion: "CANCELLED" },
62
+ ];
63
+ assertEquals(failingCheckNames(cancelledFirst), [], "CANCELLED-first tie resolves to the real (green) result");
64
+ assertEquals(failingCheckNames(successFirst), [], "success-first tie keeps the real (green) result");
65
+ });
66
+
67
+ test("latestRunPerCheck keeps exactly one run per check name (newest)", () => {
68
+ const rollup = [
69
+ { name: "a", conclusion: "CANCELLED", startedAt: "2024-01-01T00:00:00Z" },
70
+ { name: "a", conclusion: "SUCCESS", startedAt: "2024-01-01T00:02:00Z" },
71
+ { name: "b", conclusion: "FAILURE", startedAt: "2024-01-01T00:00:00Z" },
72
+ ];
73
+ const latest = latestRunPerCheck(rollup);
74
+ assertEquals(latest.length, 2, "one run per distinct check name");
75
+ const a = latest.find((c) => c.name === "a");
76
+ assert(a && a.conclusion === "SUCCESS", "check `a` resolves to its newest (green) run");
77
+ });
78
+
79
+ test("allCheckNames dedupes superseded reruns to a single name", () => {
80
+ const rollup = [
81
+ { name: "engine-core", conclusion: "CANCELLED", startedAt: "2024-01-01T00:00:00Z" },
82
+ { name: "engine-core", conclusion: "SUCCESS", startedAt: "2024-01-01T00:02:00Z" },
83
+ ];
84
+ assertEquals(allCheckNames(rollup), ["engine-core"], "a superseded rerun must not double-list the check name");
85
+ });
86
+
87
+ test("legacy StatusContext (state + context) supersession is handled by createdAt", () => {
88
+ const rollup = [
89
+ { context: "ci/legacy", state: "ERROR", createdAt: "2024-01-01T00:00:00Z" },
90
+ { context: "ci/legacy", state: "SUCCESS", createdAt: "2024-01-01T00:02:00Z" },
91
+ ];
92
+ assertEquals(failingCheckNames(rollup), [], "a superseded legacy status context is not a failing gate");
93
+ });
@@ -307,3 +307,74 @@ test("startFeature: persists the real issue title when the fetch succeeds", asyn
307
307
  else process.env["GITHUB_TOKEN"] = prevTok;
308
308
  }
309
309
  });
310
+
311
+ test("startFeature: no readiness ⇒ readinessProbes/probeTimeout/gateKey seeded null (gate skipped)", async () => {
312
+ let captured: any = null;
313
+ const engine = {
314
+ createInstance: (req: any) => {
315
+ captured = req;
316
+ return Promise.resolve({ processInstanceKey: "PI-R0" });
317
+ },
318
+ } as any;
319
+ await startFeature(memData({ feature_runs: { rows: [], key: "feature_key" } }), engine, PARSED, "main", false, false);
320
+ const v = captured.variables;
321
+ assertEquals(v.readinessProbes, null);
322
+ assertEquals(v.probeTimeout, null);
323
+ assertEquals(v.gateKey, null);
324
+ assertEquals(v.resolvedArtifacts, null);
325
+ });
326
+
327
+ test("startFeature: readiness probes seed the gate variables + a non-blank correlation key", async () => {
328
+ let captured: any = null;
329
+ const engine = {
330
+ createInstance: (req: any) => {
331
+ captured = req;
332
+ return Promise.resolve({ processInstanceKey: "PI-R1" });
333
+ },
334
+ } as any;
335
+ const probes = [
336
+ {
337
+ kind: "capability",
338
+ target: "github-releases:nanobpm/nano-bpm",
339
+ match: { package: "@nanobpm/engine-wasm", capabilityRef: "nanobpm/nano-bpm#631" },
340
+ onTimeout: "escalate",
341
+ },
342
+ ] as any;
343
+ await startFeature(
344
+ memData({ feature_runs: { rows: [], key: "feature_key" } }),
345
+ engine,
346
+ PARSED,
347
+ "main",
348
+ false,
349
+ false,
350
+ null,
351
+ { probes, probeTimeout: "PT30M" },
352
+ );
353
+ const v = captured.variables;
354
+ assertEquals(v.readinessProbes, probes);
355
+ assertEquals(v.probeTimeout, "PT30M");
356
+ // The preflight probe worker requires a non-blank gateKey to publish readiness-ready on.
357
+ assertEquals(v.gateKey, "feature-readiness:owner/repo#42");
358
+ assertEquals(v.resolvedArtifacts, null);
359
+ });
360
+
361
+ test("startFeature: probes without a probeTimeout fail fast (both are load-bearing together)", async () => {
362
+ const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-R2" }) } as any;
363
+ let threw = false;
364
+ try {
365
+ await startFeature(
366
+ memData({ feature_runs: { rows: [], key: "feature_key" } }),
367
+ engine,
368
+ PARSED,
369
+ "main",
370
+ false,
371
+ false,
372
+ null,
373
+ { probes: [{ kind: "command", target: "x" }] as any, probeTimeout: null },
374
+ );
375
+ } catch (err) {
376
+ threw = true;
377
+ assertEquals((err as Error).message.includes("probeTimeout"), true);
378
+ }
379
+ assertEquals(threw, true);
380
+ });
package/app/feature.ts CHANGED
@@ -17,8 +17,20 @@
17
17
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
18
18
  import { coalesceTitle, fetchIssueTitle } from "./github.ts";
19
19
  import { ESCALATION_SLA_TIMEOUT, normalizeBaseBranch, type ParsedIssue, renderBaseBranchBrief } from "./plan.ts";
20
+ import type { ReadinessProbe } from "./readiness.ts";
20
21
  import { deriveListBucket, deriveStage } from "./stage.ts";
21
22
 
23
+ /** Optional intake-time readiness gate for a feature run (issue #295): the `capability`/`command`/…
24
+ * probes the run must ALL satisfy before its implementation agent is dispatched (parked, durably, at
25
+ * the leading readiness preflight in feature.bpmn), plus the single ISO-8601 bound the preflight's
26
+ * escalation timers fire off. Both are DERIVED once from the submitted `readiness`/`blockedOn` intake
27
+ * by {@link parseFeatureReadiness} (app/featureReadiness.ts). Empty/absent ⇒ the gate is skipped and
28
+ * the run proceeds straight to implementation, exactly as today's submissions do. */
29
+ export interface FeatureReadinessOptions {
30
+ readonly probes?: ReadinessProbe[];
31
+ readonly probeTimeout?: string | null;
32
+ }
33
+
22
34
  /** The BPMN process this module drives (resources/processes/feature.bpmn). */
23
35
  export const FEATURE_PROCESS_ID = "feature";
24
36
 
@@ -339,7 +351,22 @@ export async function startFeature(
339
351
  converge: boolean,
340
352
  autoMerge: boolean,
341
353
  customInstructions: string | null = null,
354
+ readiness: FeatureReadinessOptions = {},
342
355
  ) {
356
+ // Intake-time readiness gate (issue #295): the probes the run must satisfy before it implements,
357
+ // and the bound its preflight escalation timers fire off. Both are load-bearing together —
358
+ // `pr.readiness-probe` rejects a blank `probeTimeout` and the preflight timers read `=probeTimeout`
359
+ // — so a non-empty probe set seeded without a bound would incident at runtime. Fail fast at the
360
+ // start door instead (mirroring `startPlan`); `parseFeatureReadiness` always derives the two
361
+ // together, so this only fires for a mis-seeded direct caller.
362
+ const readinessProbes = readiness.probes && readiness.probes.length > 0 ? readiness.probes : null;
363
+ if (readinessProbes && (readiness.probeTimeout ?? "").trim() === "") {
364
+ throw new Error(
365
+ `startFeature(${parsed.planKey}): ${readinessProbes.length} readiness probe(s) seeded without a ` +
366
+ "probeTimeout — the preflight escalation timers (=probeTimeout) and pr.readiness-probe both require " +
367
+ "a non-blank bound. Derive it via parseFeatureReadiness before starting a gated feature.",
368
+ );
369
+ }
343
370
  // Operator free-text steering for the implementation agent (issue #172 follow-on): blank/absent →
344
371
  // null so the implement task's `appendPrompt` FEEL (`customInstructions = null`) skips the block
345
372
  // rather than appending an empty "Operator custom instructions" heading.
@@ -447,6 +474,21 @@ export async function startFeature(
447
474
  // task's `appendPrompt` FEEL (feature.bpmn). Null when none was supplied; persists on the
448
475
  // instance so it also rides the answer-loop redispatch back into the same implement task.
449
476
  customInstructions: instructions,
477
+ // Intake-time readiness gate (issue #295): the leading preflight the feature run parks on until
478
+ // every declared probe goes green (feature.bpmn `gw-readiness` → `readiness-preflight`). A
479
+ // submission with NO readiness carries `null` here, so the gateway routes straight to
480
+ // `ensure-base-branch` and the run implements immediately — behaviour unchanged for today's
481
+ // features. `probeTimeout` bounds the preflight's escalation timers (derived once from the same
482
+ // probes); `gateKey` is the non-blank correlation key the probe worker publishes
483
+ // `readiness-ready` on (required even in the preflight, which reads the probe's synchronous
484
+ // result); `resolvedArtifacts` is filled by the preflight on green — the exact `pkg@version`s
485
+ // first carrying each awaited capability — and rides the implement task's `appendPrompt` so the
486
+ // agent bumps the consumer dependency to exactly the bound version. Seeded `null` so a
487
+ // gate-less run still resolves the variable in that FEEL instead of raising an incident.
488
+ readinessProbes,
489
+ probeTimeout: readinessProbes ? (readiness.probeTimeout ?? null) : null,
490
+ gateKey: readinessProbes ? `feature-readiness:${parsed.planKey}` : null,
491
+ resolvedArtifacts: null,
450
492
  },
451
493
  });
452
494
  const processKey = processInstanceKey == null ? null : String(processInstanceKey);
@@ -0,0 +1,165 @@
1
+ // Unit coverage for the feature-intake readiness gate desugaring (issue #295).
2
+ //
3
+ // `parseFeatureReadiness` turns a submitted feature's optional `readiness`/`blockedOn` intake into the
4
+ // `readinessProbes` + `probeTimeout` process variables the feature.bpmn preflight runs. These tests
5
+ // pin the desugaring: full descriptors round-trip through `parseProbe`, `blockedOn` desugars to
6
+ // `capability` probes (with `consumerPackage`) or `command` state probes (fallback), the bound is
7
+ // derived, and malformed intake fails loudly at submit.
8
+ import { test } from "node:test";
9
+ import { assertEquals } from "#test-assert";
10
+ import { parseFeatureReadiness } from "./featureReadiness.ts";
11
+
12
+ const ENV = { NANO_READINESS_POLL_TIMEOUT: "PT30M" } as Record<string, string | undefined>;
13
+
14
+ test("parseFeatureReadiness: no intake ⇒ empty probes, null bound (gate skipped)", () => {
15
+ assertEquals(parseFeatureReadiness(undefined, ENV), { probes: [], probeTimeout: null });
16
+ assertEquals(parseFeatureReadiness({}, ENV), { probes: [], probeTimeout: null });
17
+ assertEquals(parseFeatureReadiness({ readiness: [], blockedOn: [] }, ENV), { probes: [], probeTimeout: null });
18
+ });
19
+
20
+ test("parseFeatureReadiness: blockedOn + consumerPackage ⇒ capability probes with derived bound", () => {
21
+ const out = parseFeatureReadiness(
22
+ { blockedOn: ["nanobpm/nano-bpm#631", "nanobpm/nano-bpm#808"], consumerPackage: "@nanobpm/engine-wasm" },
23
+ ENV,
24
+ );
25
+ assertEquals(out.probes.length, 2);
26
+ assertEquals(out.probes[0], {
27
+ kind: "capability",
28
+ target: "github-releases:nanobpm/nano-bpm",
29
+ match: { package: "@nanobpm/engine-wasm", capabilityRef: "nanobpm/nano-bpm#631" },
30
+ onTimeout: "escalate",
31
+ });
32
+ assertEquals(out.probes[1].match?.capabilityRef, "nanobpm/nano-bpm#808");
33
+ // Every derived probe shares the env default, so the bound is that default.
34
+ assertEquals(out.probeTimeout, "PT30M");
35
+ });
36
+
37
+ test("parseFeatureReadiness: blockedOn without consumerPackage ⇒ command state probes (merged-is-enough)", () => {
38
+ const out = parseFeatureReadiness({ blockedOn: ["octo/cat#7"] }, ENV);
39
+ assertEquals(out.probes[0], {
40
+ kind: "command",
41
+ target: "gh api repos/octo/cat/issues/7 --jq .state",
42
+ match: { stdoutIncludes: "closed" },
43
+ onTimeout: "escalate",
44
+ });
45
+ assertEquals(out.probeTimeout, "PT30M");
46
+ });
47
+
48
+ test("parseFeatureReadiness: full readiness descriptors round-trip through parseProbe", () => {
49
+ const out = parseFeatureReadiness(
50
+ {
51
+ readiness: [
52
+ { kind: "http", target: "https://example.test/health", match: { status: 200 } },
53
+ { kind: "command", target: "make ready" },
54
+ ],
55
+ },
56
+ ENV,
57
+ );
58
+ assertEquals(out.probes.length, 2);
59
+ assertEquals(out.probes[0].kind, "http");
60
+ assertEquals(out.probes[0].match?.status, 200);
61
+ assertEquals(out.probes[1].kind, "command");
62
+ });
63
+
64
+ test("parseFeatureReadiness: readiness + blockedOn concatenate", () => {
65
+ const out = parseFeatureReadiness(
66
+ { readiness: [{ kind: "command", target: "make ready" }], blockedOn: ["octo/cat#7"] },
67
+ ENV,
68
+ );
69
+ assertEquals(out.probes.length, 2);
70
+ assertEquals(out.probes[0].kind, "command");
71
+ assertEquals(out.probes[1].target, "gh api repos/octo/cat/issues/7 --jq .state");
72
+ });
73
+
74
+ test("parseFeatureReadiness: a longer per-probe budget wins the derived bound", () => {
75
+ const out = parseFeatureReadiness(
76
+ {
77
+ readiness: [
78
+ { kind: "command", target: "a", poll: { timeoutMs: 60_000 } },
79
+ { kind: "command", target: "b", poll: { timeoutMs: 3_600_000 } },
80
+ ],
81
+ },
82
+ ENV,
83
+ );
84
+ assertEquals(out.probeTimeout, "PT3600S");
85
+ });
86
+
87
+ test("parseFeatureReadiness: a bare repo#N handle is rejected (cannot name a provenance repo)", () => {
88
+ let threw = false;
89
+ try {
90
+ parseFeatureReadiness({ blockedOn: ["nano-bpm#631"], consumerPackage: "@nanobpm/engine-wasm" }, ENV);
91
+ } catch (err) {
92
+ threw = true;
93
+ assertEquals((err as Error).message.includes("owner/repo#123"), true);
94
+ }
95
+ assertEquals(threw, true);
96
+ });
97
+
98
+ test("parseFeatureReadiness: a handle whose repo carries shell metacharacters is rejected (no injection into the command probe)", () => {
99
+ // The `command` fallback interpolates `parsed.repo` into a shell string run via `exec`. `parseIssue`'s
100
+ // `owner/repo#N` branch matches `[^#]+` for the slug, so a crafted handle could smuggle `;`/`$()`/backticks
101
+ // into the readiness worker's shell. Desugaring MUST reject any repo that isn't a valid GitHub slug.
102
+ for (const evil of [
103
+ "octo/cat; rm -rf /#7",
104
+ "octo/cat$(touch pwned)#7",
105
+ "octo/`whoami`#7",
106
+ "octo/cat rm#7",
107
+ ]) {
108
+ let threw = false;
109
+ try {
110
+ parseFeatureReadiness({ blockedOn: [evil] }, ENV);
111
+ } catch (err) {
112
+ threw = true;
113
+ assertEquals((err as Error).message.includes("owner/repo"), true);
114
+ }
115
+ assertEquals(threw, true);
116
+ }
117
+ });
118
+
119
+ test("parseFeatureReadiness: a non-string blockedOn entry is rejected", () => {
120
+ let threw = false;
121
+ try {
122
+ parseFeatureReadiness({ blockedOn: [42] }, ENV);
123
+ } catch {
124
+ threw = true;
125
+ }
126
+ assertEquals(threw, true);
127
+ });
128
+
129
+ test("parseFeatureReadiness: a blank consumerPackage is rejected", () => {
130
+ let threw = false;
131
+ try {
132
+ parseFeatureReadiness({ blockedOn: ["octo/cat#7"], consumerPackage: " " }, ENV);
133
+ } catch (err) {
134
+ threw = true;
135
+ assertEquals((err as Error).message.includes("consumerPackage"), true);
136
+ }
137
+ assertEquals(threw, true);
138
+ });
139
+
140
+ test("parseFeatureReadiness: a malformed readiness descriptor fails loudly (unknown kind)", () => {
141
+ let threw = false;
142
+ try {
143
+ parseFeatureReadiness({ readiness: [{ kind: "bogus", target: "x" }] }, ENV);
144
+ } catch {
145
+ threw = true;
146
+ }
147
+ assertEquals(threw, true);
148
+ });
149
+
150
+ test("parseFeatureReadiness: a non-array readiness/blockedOn is rejected", () => {
151
+ let a = false;
152
+ let b = false;
153
+ try {
154
+ parseFeatureReadiness({ readiness: { kind: "command", target: "x" } }, ENV);
155
+ } catch {
156
+ a = true;
157
+ }
158
+ try {
159
+ parseFeatureReadiness({ blockedOn: "octo/cat#7" }, ENV);
160
+ } catch {
161
+ b = true;
162
+ }
163
+ assertEquals(a, true);
164
+ assertEquals(b, true);
165
+ });
@@ -0,0 +1,151 @@
1
+ // nano-workforce — feature-intake readiness gate desugaring (issue #295).
2
+ //
3
+ // The intake-time half of the durable readiness gate for a SINGLE-issue feature run. A submitted
4
+ // feature may carry an optional `readiness` (one or more full {@link ReadinessProbe} descriptors) or
5
+ // the ergonomic shorthand `blockedOn` — a list of upstream issue/PR handles the feature must wait to
6
+ // land before its implementation agent is dispatched. This module turns either form into the SAME
7
+ // `readinessProbes` + `probeTimeout` process variables the (existing) readiness-gate preflight in
8
+ // `resources/processes/feature.bpmn` runs — reusing the production probe kinds, the escalation form,
9
+ // and the `capability` late-bind primitive verbatim (derivation over duplication). No new subsystem:
10
+ // this is intake plumbing on top of `app/readiness.ts` (#258) and the `capability` kind (#274).
11
+ //
12
+ // `blockedOn` desugars per handle:
13
+ // • With a declared `consumerPackage` (the cross-repo case — e.g. a wfd feature gated on
14
+ // `@nanobpm/engine-wasm` carrying `nanobpm/nano-bpm#631`) → a `capability` probe that resolves
15
+ // "which published `pkg@version` FIRST carries this handle?" from publish provenance and
16
+ // late-binds the resolved `pkg@version` back into the run (`resolvedArtifacts`), so the agent can
17
+ // bump the consumer dependency to exactly that version.
18
+ // • Without a `consumerPackage` (no published-artifact edge applies) → a `command` probe that goes
19
+ // green once the referenced issue/PR is closed/merged (`gh api …/issues/<n> --jq .state`), the
20
+ // "merged is enough" fallback.
21
+ //
22
+ // The derivation is pure (no I/O, no engine) so it is trivially unit-testable — the seam
23
+ // `startFeature` calls at submit to seed the gate.
24
+ import { parseIssue } from "./plan.ts";
25
+ import {
26
+ DEFAULT_READINESS_TIMEOUT,
27
+ parseProbe,
28
+ type ReadinessProbe,
29
+ readinessTimeout,
30
+ } from "./readiness.ts";
31
+ import { isoDurationToMs } from "./reviewWait.ts";
32
+
33
+ /** The raw intake shape a submitted feature may carry (all optional). `readiness` is one or more
34
+ * full {@link ReadinessProbe} descriptors; `blockedOn` is the ergonomic shorthand — a list of
35
+ * upstream `owner/repo#N` handles; `consumerPackage` is the npm package whose publish provenance the
36
+ * `blockedOn` shorthand resolves the handles against (e.g. `@nanobpm/engine-wasm`). */
37
+ export interface FeatureReadinessInput {
38
+ readonly readiness?: unknown;
39
+ readonly blockedOn?: unknown;
40
+ readonly consumerPackage?: unknown;
41
+ }
42
+
43
+ /** The desugared gate: the probes the feature must satisfy before it implements, and the single
44
+ * ISO-8601 bound the preflight's escalation timers fire off (the LONGEST of the probes' derived
45
+ * timeouts, so no probe is cut short). `probes` is empty when the feature declared no readiness —
46
+ * the gate is then skipped and the run proceeds straight to implementation (behaviour unchanged). */
47
+ export interface FeatureReadiness {
48
+ readonly probes: ReadinessProbe[];
49
+ readonly probeTimeout: string | null;
50
+ }
51
+
52
+ function isNonEmptyString(v: unknown): v is string {
53
+ return typeof v === "string" && v.trim() !== "";
54
+ }
55
+
56
+ /** A valid GitHub `owner/repo` slug: both segments are restricted to the characters GitHub itself
57
+ * allows (alphanumerics, `-`, `_`, `.`). `parseIssue`'s shorthand branch matches `[^#]+` for the
58
+ * slug, so it would otherwise admit shell metacharacters (`;`, `$( )`, backticks, spaces) that get
59
+ * interpolated verbatim into the `command` probe's `exec` string (and the `capability` target).
60
+ * Constraining the slug here shuts that injection surface for the whole `blockedOn` desugaring. */
61
+ const GITHUB_SLUG = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
62
+
63
+ /** Normalise a single `blockedOn` handle into a `capability` (with `consumerPackage`) or `command`
64
+ * (fallback) probe. The handle MUST parse as a full `owner/repo#N` reference — a bare `repo#N`
65
+ * cannot name a provenance source repo unambiguously, so it fails loudly here rather than desugaring
66
+ * to a probe that can never resolve. */
67
+ function desugarHandle(handle: string, consumerPackage: string | null): ReadinessProbe {
68
+ const parsed = parseIssue(handle.trim());
69
+ if (!parsed) {
70
+ throw new Error(
71
+ `feature readiness: blockedOn handle '${handle}' must be a full 'owner/repo#123' reference ` +
72
+ "(a bare 'repo#123' cannot name the upstream provenance repo)",
73
+ );
74
+ }
75
+ // `parsed.number` is numeric (via `Number`), but `parsed.repo` is an unconstrained slug that lands in
76
+ // a shell `command` target — reject anything that isn't a plain GitHub `owner/repo` before we build it.
77
+ if (!GITHUB_SLUG.test(parsed.repo)) {
78
+ throw new Error(
79
+ `feature readiness: blockedOn handle '${handle}' has an invalid 'owner/repo' slug — only ` +
80
+ "alphanumerics, '-', '_' and '.' are allowed in each segment",
81
+ );
82
+ }
83
+ if (consumerPackage) {
84
+ // The `capability` edge (#274): resolve which published `<consumerPackage>@version` first carries
85
+ // this upstream handle and late-bind that `pkg@version` back into the run. The provenance source
86
+ // repo is the handle's own repo (where the upstream lands and publishes).
87
+ return {
88
+ kind: "capability",
89
+ target: `github-releases:${parsed.repo}`,
90
+ match: { package: consumerPackage, capabilityRef: parsed.planKey },
91
+ // A stuck/never-publishing upstream must ESCALATE (bounded) — never fail or proceed unbound.
92
+ onTimeout: "escalate",
93
+ };
94
+ }
95
+ // Fallback (no published-artifact edge): "merged is enough". `gh` reads its token from the ambient
96
+ // env (like the `github-check`/`capability` kinds), and PRs are issues in the REST API, so a single
97
+ // `/issues/<n>` state check covers both an issue being closed and a PR being merged (→ closed).
98
+ return {
99
+ kind: "command",
100
+ target: `gh api repos/${parsed.repo}/issues/${parsed.number} --jq .state`,
101
+ match: { stdoutIncludes: "closed" },
102
+ onTimeout: "escalate",
103
+ };
104
+ }
105
+
106
+ /** Parse + desugar a feature's optional intake readiness into the gate's `readinessProbes` +
107
+ * `probeTimeout`. Accepts EITHER the full `readiness` descriptor list OR the `blockedOn` shorthand
108
+ * (or both — they concatenate). Returns an empty probe set (gate skipped) when neither is present.
109
+ *
110
+ * Throws a descriptive error on a malformed descriptor (via {@link parseProbe}), a `blockedOn` entry
111
+ * that is not a string, an unparseable handle, or a `consumerPackage` that is present but blank — a
112
+ * mis-declared gate must fail loudly at submit, never wait forever at runtime. */
113
+ export function parseFeatureReadiness(
114
+ input: FeatureReadinessInput | null | undefined,
115
+ env: Record<string, string | undefined> = process.env,
116
+ ): FeatureReadiness {
117
+ const probes: ReadinessProbe[] = [];
118
+ if (input && input.consumerPackage !== undefined && !isNonEmptyString(input.consumerPackage)) {
119
+ throw new Error("feature readiness: 'consumerPackage' must be a non-blank package name when supplied");
120
+ }
121
+ const consumerPackage = input && isNonEmptyString(input.consumerPackage) ? input.consumerPackage.trim() : null;
122
+
123
+ if (input?.readiness !== undefined && input.readiness !== null) {
124
+ if (!Array.isArray(input.readiness)) {
125
+ throw new Error("feature readiness: 'readiness' must be an array of probe descriptors");
126
+ }
127
+ for (const raw of input.readiness) probes.push(parseProbe(raw));
128
+ }
129
+
130
+ if (input?.blockedOn !== undefined && input.blockedOn !== null) {
131
+ if (!Array.isArray(input.blockedOn)) {
132
+ throw new Error("feature readiness: 'blockedOn' must be an array of 'owner/repo#123' handles");
133
+ }
134
+ for (const raw of input.blockedOn) {
135
+ if (!isNonEmptyString(raw)) {
136
+ throw new Error("feature readiness: each 'blockedOn' entry must be a non-blank 'owner/repo#123' handle");
137
+ }
138
+ probes.push(desugarHandle(raw, consumerPackage));
139
+ }
140
+ }
141
+
142
+ if (probes.length === 0) return { probes: [], probeTimeout: null };
143
+
144
+ // One bound governs the whole preflight's escalation timers — the LONGEST of the probes' derived
145
+ // timeouts (via the canonical `readinessTimeout`), so no probe is cut short. Mirrors the epic
146
+ // lowering (app/planLowering.ts) so the feature and epic gates derive the bound identically.
147
+ const probeTimeout = probes
148
+ .map((p) => readinessTimeout(p, env))
149
+ .reduce((a, b) => (isoDurationToMs(b, DEFAULT_READINESS_TIMEOUT) > isoDurationToMs(a, DEFAULT_READINESS_TIMEOUT) ? b : a));
150
+ return { probes, probeTimeout };
151
+ }