@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/app/github.ts CHANGED
@@ -521,17 +521,78 @@ interface RollupEntry {
521
521
  name?: string;
522
522
  context?: string;
523
523
  workflowName?: string;
524
+ /** CheckRun timestamps (GraphQL `statusCheckRollup`). A superseded run and the newer run that
525
+ * replaced it carry the same check name but different times, so they order the runs of one check.
526
+ * StatusContext carries `createdAt` instead. All are ISO-8601 or absent. */
527
+ startedAt?: string;
528
+ completedAt?: string;
529
+ createdAt?: string;
524
530
  }
531
+
532
+ /** The canonical identity of a check across its reruns: its name (CheckRun) or context
533
+ * (StatusContext), falling back to its `workflowName` and finally the sentinel `"check"` when
534
+ * neither is present. GitHub CI concurrency can leave several runs of the SAME check on one head
535
+ * commit — a superseded run plus the newer run that replaced it — so this is what we group by. */
536
+ function checkKey(c: RollupEntry): string {
537
+ return c.name || c.context || c.workflowName || "check";
538
+ }
539
+
540
+ /** A run's ordering timestamp (newest wins): its completion, else its start, else its creation.
541
+ * `0` when none is present (the shape carries no time) so a timed run always outranks an untimed
542
+ * one. */
543
+ function runOrder(c: RollupEntry): number {
544
+ const t = c.completedAt || c.startedAt || c.createdAt;
545
+ if (!t) return 0;
546
+ const ms = Date.parse(t);
547
+ return Number.isNaN(ms) ? 0 : ms;
548
+ }
549
+
550
+ /** True when a run's conclusion is `CANCELLED` — the state GitHub CI concurrency stamps on a run it
551
+ * supersedes with a newer run on the identical head SHA (a stale/transient cancellation, not a code
552
+ * defect). */
553
+ function isCancelled(c: RollupEntry): boolean {
554
+ return (c.conclusion || c.state || "").toUpperCase() === "CANCELLED";
555
+ }
556
+
557
+ /** Collapse a head's rollup to the **newest run per check**. GitHub's CI-concurrency cancellation
558
+ * (issue #348) leaves BOTH a superseded run (stamped `CANCELLED`) and the newer run that replaced
559
+ * it on the *same head SHA*, under the same check name. Counting the stale `CANCELLED` as a failure
560
+ * escalates a self-healing PR whose head is actually green. The rollup is already scoped to the head
561
+ * commit, so grouping by check name and keeping the newest run per name yields one ground-truth
562
+ * conclusion per `(headSha, checkName)`. Ties (equal/absent timestamps) prefer a non-`CANCELLED`
563
+ * run, so a superseded cancellation never shadows the real result even when GitHub omits times. */
564
+ export function latestRunPerCheck(rollup: RollupEntry[]): RollupEntry[] {
565
+ const newest = new Map<string, RollupEntry>();
566
+ for (const c of rollup) {
567
+ const key = checkKey(c);
568
+ const prev = newest.get(key);
569
+ if (prev === undefined) {
570
+ newest.set(key, c);
571
+ continue;
572
+ }
573
+ const dt = runOrder(c) - runOrder(prev);
574
+ if (dt > 0) {
575
+ newest.set(key, c);
576
+ } else if (dt === 0 && isCancelled(prev) && !isCancelled(c)) {
577
+ // Same/unknown time: a CANCELLED run is the superseded one — the real result wins.
578
+ newest.set(key, c);
579
+ }
580
+ }
581
+ return [...newest.values()];
582
+ }
583
+
525
584
  /** Names of the checks whose result is a hard failure (as opposed to pending/success). Covers
526
585
  * both the CheckRun shape (`conclusion` + `name`/`workflowName`) and the legacy StatusContext
527
586
  * shape (`state` + `context`). The names are what the CI-fix agent is handed so it knows which
528
- * gates to make green; `failingChecks` (the count) is derived from this list. */
529
- function failingCheckNames(rollup: RollupEntry[]): string[] {
587
+ * gates to make green; `failingChecks` (the count) is derived from this list. Derivation is over the
588
+ * **newest run per check** (`latestRunPerCheck`) so a `CANCELLED` run superseded by a newer green
589
+ * run on the identical head SHA is not counted as a failing gate (issue #348). */
590
+ export function failingCheckNames(rollup: RollupEntry[]): string[] {
530
591
  const bad = new Set(["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "ERROR"]);
531
592
  const names: string[] = [];
532
- for (const c of rollup) {
593
+ for (const c of latestRunPerCheck(rollup)) {
533
594
  const v = (c.conclusion || c.state || "").toUpperCase();
534
- if (bad.has(v)) names.push(c.name || c.context || c.workflowName || "check");
595
+ if (bad.has(v)) names.push(checkKey(c));
535
596
  }
536
597
  return names;
537
598
  }
@@ -539,10 +600,11 @@ function failingCheckNames(rollup: RollupEntry[]): string[] {
539
600
  /** Names of every head check present, regardless of state. Covers both the CheckRun shape
540
601
  * (`name`/`workflowName`) and the legacy StatusContext shape (`context`). Used to test whether a
541
602
  * repo's *required* checks are present on the head — so an unrelated always-on check (e.g.
542
- * Mergify's "Merge Queue") doesn't masquerade as the required CI run having already happened. */
543
- function allCheckNames(rollup: RollupEntry[]): string[] {
603
+ * Mergify's "Merge Queue") doesn't masquerade as the required CI run having already happened.
604
+ * Deduped to the newest run per check so a superseded rerun doesn't list a check name twice. */
605
+ export function allCheckNames(rollup: RollupEntry[]): string[] {
544
606
  const names: string[] = [];
545
- for (const c of rollup) {
607
+ for (const c of latestRunPerCheck(rollup)) {
546
608
  const name = c.name || c.context || c.workflowName;
547
609
  if (name) names.push(name);
548
610
  }
@@ -0,0 +1,138 @@
1
+ // Structural regression guard for the CI-concurrency-cancellation drift class (issue #348).
2
+ //
3
+ // The merge loop escalated a human whenever `senior:fix-ci` returned `blocked` — even when the
4
+ // failing required checks were STALE/TRANSIENT (CANCELLED runs superseded by a newer green run on
5
+ // the identical head SHA) and the agent honestly pushed nothing. The `fix-ci` prompt actively
6
+ // funnelled that self-healing case into `blocked`, and `blocked` routed straight to the merge
7
+ // escalation user task. A phantom-blocked, self-healing merge paged a human.
8
+ //
9
+ // The fix adds a first-class re-attempt path and a reconcile-before-escalate guard:
10
+ //
11
+ // 1. `status = "reattempt"` (a first-class fix-ci verdict for stale/transient checks) routes to
12
+ // `arm-merge`, re-queuing the merge from ground truth — no human, and declared explicitly
13
+ // beside the `f_ci_reconcile` empty-status default rather than relying on the fall-through.
14
+ // 2. A `blocked` verdict with no push (`pushed != true`) reconciles ONCE via ground truth
15
+ // (`gw-ci-blocked` → `ci-reconcile` → re-arm the poller) and escalates only if it is STILL
16
+ // blocked — so even a mislabelled `blocked` self-heals.
17
+ //
18
+ // Pure text assertions over the committed BPMN (no engine), matching the repo's lightweight
19
+ // model-guard style (see mergeEscalationQuestion.test.ts, mergeRebaseArm.test.ts).
20
+
21
+ import { test } from "node:test";
22
+ import { assert, assertStringIncludes } from "#test-assert";
23
+ import { readFileSync } from "node:fs";
24
+
25
+ const bpmn = readFileSync("resources/processes/merge-loop.bpmn", "utf8");
26
+ // Collapse whitespace so attribute-order / line-wrapping churn doesn't make the assertions brittle.
27
+ const flat = bpmn.replace(/\s+/g, " ");
28
+
29
+ function flowElement(id: string): string | null {
30
+ const re = new RegExp(
31
+ `<bpmn:sequenceFlow\\b[^>]*?\\bid="${id}"[^>]*?(?:/>|>(?:(?!<bpmn:sequenceFlow\\b).)*?</bpmn:sequenceFlow>)`,
32
+ );
33
+ const m = flat.match(re);
34
+ return m ? m[0] : null;
35
+ }
36
+
37
+ function flowHasId(id: string, source: string, target: string): boolean {
38
+ const el = flowElement(id);
39
+ if (!el) return false;
40
+ return el.includes(`sourceRef="${source}"`) && el.includes(`targetRef="${target}"`);
41
+ }
42
+
43
+ function serviceTask(id: string): string | null {
44
+ const m = flat.match(new RegExp(`<bpmn:serviceTask\\b[^>]*\\bid="${id}"[\\s\\S]*?</bpmn:serviceTask>`));
45
+ return m ? m[0].replace(/&#34;/g, '"').replace(/&amp;/g, "&").replace(/&#10;/g, "\n") : null;
46
+ }
47
+
48
+ test("a first-class `reattempt` verdict re-attempts the merge (arm-merge), not escalation", () => {
49
+ // gw-ci-result must carry an explicit `status = "reattempt"` arm to arm-merge, declared beside
50
+ // (not folded into) the empty-status `f_ci_reconcile` default.
51
+ const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-ci-result"[\s\S]*?<\/bpmn:exclusiveGateway>/);
52
+ assert(gw, "gw-ci-result gateway must exist");
53
+ assertStringIncludes(gw![0], "f_ci_reattempt", "gw-ci-result must declare the reattempt outgoing arm");
54
+
55
+ const reattempt = flowElement("f_ci_reattempt");
56
+ assert(reattempt, "f_ci_reattempt flow missing");
57
+ assert(
58
+ flowHasId("f_ci_reattempt", "gw-ci-result", "arm-merge"),
59
+ "a reattempt verdict must re-arm the merge poller (arm-merge), never escalate",
60
+ );
61
+ assertStringIncludes(reattempt!, 'status = "reattempt"', "the reattempt arm must be gated on status = reattempt");
62
+ // It must be an EXPLICIT labelled flow, not the empty-status default.
63
+ assert(!/default="f_ci_reattempt"/.test(flat), "reattempt must be an explicit arm, not the gateway default");
64
+ });
65
+
66
+ test("a fix-ci `reattempt` result does NOT create a merge escalation", () => {
67
+ // No flow originating from the reattempt classification may reach the merge-escalation task.
68
+ assert(
69
+ !flowHasId("f_ci_reattempt", "gw-ci-result", "merge-esc-attempt"),
70
+ "reattempt must never route to merge-esc-attempt",
71
+ );
72
+ });
73
+
74
+ test("a blocked-with-no-push verdict reconciles once from ground truth before escalating", () => {
75
+ // The `blocked` arm no longer flows straight into the escalation: it passes through gw-ci-blocked.
76
+ assert(
77
+ flowHasId("f_ci_blocked", "gw-ci-result", "gw-ci-blocked"),
78
+ "a blocked verdict must route through gw-ci-blocked, not straight to merge-esc-attempt",
79
+ );
80
+
81
+ const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-ci-blocked"[^>]*>/);
82
+ assert(gw, "gw-ci-blocked gateway must exist");
83
+ // Default is escalate (still blocked), so a missing/true reconcile flag never wedges.
84
+ assertStringIncludes(gw![0], 'default="f_cib_esc"', "gw-ci-blocked must default to escalation");
85
+
86
+ // The reconcile-once arm: pushed nothing AND not yet reconciled → re-derive via ci-reconcile.
87
+ const recon = flowElement("f_cib_recon");
88
+ assert(recon, "f_cib_recon flow missing");
89
+ assert(flowHasId("f_cib_recon", "gw-ci-blocked", "ci-reconcile"), "reconcile arm must target ci-reconcile");
90
+ assertStringIncludes(recon!, "pushed != true", "reconcile only when the agent pushed nothing");
91
+ assertStringIncludes(recon!, "ciBlockedReconciled != true", "reconcile at most once");
92
+
93
+ // The escalate arm (default): still blocked → the human merge escalation.
94
+ assert(flowHasId("f_cib_esc", "gw-ci-blocked", "merge-esc-attempt"), "the still-blocked arm must escalate");
95
+ assert(
96
+ !/conditionExpression/.test(flowElement("f_cib_esc") ?? ""),
97
+ "f_cib_esc is the default arm and must not carry a conditionExpression",
98
+ );
99
+ });
100
+
101
+ test("ci-reconcile re-arms the canonical merge poller and marks the reconcile as spent", () => {
102
+ const el = serviceTask("ci-reconcile");
103
+ assert(el, "ci-reconcile service task must exist");
104
+ // Reuses the canonical arm-merge worker — one poller implementation, no second poller pass.
105
+ assertStringIncludes(el!, 'type="pr.arm-merge"', "ci-reconcile must reuse the canonical pr.arm-merge worker");
106
+ // Marks the reconcile spent so the SECOND blocked (still blocked after re-derivation) escalates.
107
+ const outs = el!.match(/<zeebe:output\b[^>]*\/>/g) ?? [];
108
+ assert(
109
+ outs.some((t) => t.includes('target="ciBlockedReconciled"') && t.includes('source="=true"')),
110
+ "ci-reconcile must set ciBlockedReconciled = true so a still-blocked PR escalates on the next pass",
111
+ );
112
+ // Re-derivation flows back through the mergeable wait (re-runs the ground-truth mergeable gate).
113
+ assert(flowHasId("f_cib_armed", "ci-reconcile", "wait-mergeable"), "ci-reconcile must re-enter wait-mergeable");
114
+ });
115
+
116
+ test("arm-merge clears the reconcile flag each loop so a fresh block episode gets its own reconcile", () => {
117
+ const el = serviceTask("arm-merge");
118
+ assert(el, "arm-merge service task must exist");
119
+ const outs = el!.match(/<zeebe:output\b[^>]*\/>/g) ?? [];
120
+ assert(
121
+ outs.some((t) => t.includes('target="ciBlockedReconciled"') && t.includes('source="=null"')),
122
+ "arm-merge must reset ciBlockedReconciled each loop so a later, unrelated block still reconciles once",
123
+ );
124
+ });
125
+
126
+ test("regression: a stale/transient fix-ci result can no longer page a human", () => {
127
+ // The old wedge: `status = "blocked"` flowing directly into merge-esc-attempt. The blocked
128
+ // verdict now routes through gw-ci-blocked (reconcile-before-escalate), never straight to the
129
+ // escalation.
130
+ assert(
131
+ !flowHasId("f_ci_blocked", "gw-ci-result", "merge-esc-attempt"),
132
+ "the blocked verdict must not route directly into merge-esc-attempt (the #348 phantom escalation)",
133
+ );
134
+ // The blocked arm targets the reconcile gateway; the reattempt arm re-arms the poller. Neither
135
+ // gw-ci-result arm may target the escalation directly.
136
+ assertStringIncludes(flowElement("f_ci_blocked") ?? "", 'targetRef="gw-ci-blocked"');
137
+ assertStringIncludes(flowElement("f_ci_reattempt") ?? "", 'targetRef="arm-merge"');
138
+ });
@@ -118,11 +118,14 @@ test("ci-fix result: a missing/ambiguous status reconciles from ground truth, no
118
118
  flowHasId("f_ci_reconcile", "gw-ci-result", "arm-merge"),
119
119
  "f_ci_reconcile must default gw-ci-result → arm-merge (reconcile)",
120
120
  );
121
- // Escalation reserved for the agent's explicit `blocked` verdict.
121
+ // Escalation reserved for the agent's explicit `blocked` verdict — but now via a
122
+ // reconcile-before-escalate guard (issue #348): a `blocked` with no push reconciles once from
123
+ // ground truth, and only a still-blocked PR reaches the human escalation.
122
124
  const ciBlocked = flat.match(/<bpmn:sequenceFlow[^>]*id="f_ci_blocked"[\s\S]*?<\/bpmn:sequenceFlow>/);
123
125
  assert(ciBlocked, "f_ci_blocked flow missing");
124
126
  assertStringIncludes(ciBlocked![0], 'status = "blocked"');
125
- assert(hasFlow("gw-ci-result", "merge-esc-attempt"), "gw-ci-result → merge-esc-attempt (blocked) missing");
127
+ assert(hasFlow("gw-ci-result", "gw-ci-blocked"), "blocked verdict must pass through gw-ci-blocked (reconcile-before-escalate)");
128
+ assert(hasFlow("gw-ci-blocked", "merge-esc-attempt"), "gw-ci-blocked → merge-esc-attempt (still blocked) missing");
126
129
  });
127
130
 
128
131
  test("regression: the conflict verdict passes through the rebase actor, not straight to escalation", () => {
@@ -0,0 +1,191 @@
1
+ // End-to-end proof for the intake-time readiness gate seeded into a single-issue feature run
2
+ // (issue #295). Boots the whole app against the WASM engine + virtual clock and drives the REAL
3
+ // `feature.bpmn` with `readinessProbes` seeded — the exact shape `startFeature` seeds for a gated
4
+ // submission — proving the leading readiness-preflight executes on the engine BEFORE the fan-out
5
+ // head (`ensure-base-branch`) and the implement agent:
6
+ // • GATED — a feature seeded with a probe that is ready runs the reused `pr.readiness-probe`
7
+ // worker inside the multi-instance preflight, releases through `pf_gw → pf_end`, and only THEN
8
+ // reaches `ensure-base-branch` and `implement-task` — it never implements before the gate is
9
+ // green, and never escalates.
10
+ // • UNGATED — a feature seeded with `readinessProbes = null` skips the gate entirely
11
+ // (`gw-readiness → ensure-base-branch`), implementing immediately as today's ungated features do.
12
+ //
13
+ // The probe is a deterministic shell builtin (`true`) with a bound artifact, so the gate itself is
14
+ // hermetic (no network, no GitHub). The fan-out head (`pr.ensure-base-branch`) that follows a green
15
+ // gate is handled by the shared hermetic admit-github stub (installAdmitGithub) like the sibling
16
+ // preflight e2e, so the whole flow runs offline. The implement agent (`senior:feature`) has no
17
+ // worker registered here, so the instance simply parks on `implement-task` after the gate — we
18
+ // assert on the cumulative taken sequence flows (the WASM engine folds completed variables away).
19
+ import assert from "node:assert/strict";
20
+ import { mkdtempSync, rmSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { dirname, join, resolve } from "node:path";
23
+ import { after, before, describe, test } from "node:test";
24
+ import { fileURLToPath } from "node:url";
25
+ import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
26
+ import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
27
+
28
+ const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
29
+
30
+ const GITHUB_ENV_OVERRIDES: Record<string, string> = {
31
+ NANO_PR_GITHUB_TRANSPORT: "token",
32
+ GITHUB_TOKEN: "",
33
+ };
34
+ const savedEnv = new Map<string, string | undefined>();
35
+
36
+ interface TakenFlow {
37
+ from: string;
38
+ to: string;
39
+ }
40
+
41
+ function takenFlows(app: TestApp): string[] {
42
+ const snapshot = app.snapshot();
43
+ const flows = Array.isArray(snapshot.takenSequenceFlows) ? snapshot.takenSequenceFlows : [];
44
+ return flows
45
+ .filter((f): f is TakenFlow => typeof f === "object" && f !== null && "from" in f && "to" in f)
46
+ .map((f) => `${f.from}->${f.to}`);
47
+ }
48
+
49
+ // The full variable set `startFeature` seeds onto a feature instance (app/feature.ts). We seed it
50
+ // directly so we can inject `readinessProbes` (and the derived `probeTimeout`/`gateKey`) without
51
+ // standing up a whole upstream-dependency set. `baseBranch` is the admit-github default branch, so
52
+ // the fan-out head reads it without creating a ref.
53
+ function featureVars(overrides: Record<string, unknown>): Record<string, unknown> {
54
+ return {
55
+ featureKey: "owner/repo#7",
56
+ repo: "owner/repo",
57
+ issue: "owner/repo#7",
58
+ issueNumber: 7,
59
+ issueUrl: "https://github.com/owner/repo/issues/7",
60
+ task: {
61
+ id: "issue-7",
62
+ title: "owner/repo#7",
63
+ prompt: "Implement the GitHub issue owner/repo#7 end to end.",
64
+ },
65
+ converge: true,
66
+ autoMerge: false,
67
+ claimIssue: true,
68
+ answer: null,
69
+ status: null,
70
+ question: null,
71
+ summary: null,
72
+ pr: null,
73
+ escalationSlaTimeout: "PT24H",
74
+ escalationAssignee: null,
75
+ baseBranch: "main",
76
+ baseBranchBrief: "",
77
+ customInstructions: null,
78
+ readinessProbes: null,
79
+ probeTimeout: null,
80
+ gateKey: null,
81
+ resolvedArtifacts: null,
82
+ ...overrides,
83
+ };
84
+ }
85
+
86
+ async function boot(): Promise<{ app: TestApp; dbDir: string }> {
87
+ const dbDir = mkdtempSync(join(tmpdir(), "nwf-feature-preflight-"));
88
+ const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dbDir, "app.db")}` } });
89
+ return { app, dbDir };
90
+ }
91
+
92
+ describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)", () => {
93
+ let restoreGithub: (() => void) | undefined;
94
+
95
+ before(() => {
96
+ for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
97
+ savedEnv.set(k, process.env[k]);
98
+ process.env[k] = v;
99
+ }
100
+ // `pr.ensure-base-branch` reads the base ref via the token transport, which would throw
101
+ // `no GitHub transport available` under an empty token. Pin the shared hermetic admit-github
102
+ // stub (dummy token + fetch intercept) like the sibling preflight e2e so base-branch admission
103
+ // is deterministic and offline.
104
+ restoreGithub = installAdmitGithub(admitGithubState("owner/repo", "main"));
105
+ });
106
+ after(() => {
107
+ restoreGithub?.();
108
+ for (const [k, v] of savedEnv) {
109
+ if (v === undefined) delete process.env[k];
110
+ else process.env[k] = v;
111
+ }
112
+ });
113
+
114
+ test("GATED: a feature with a green probe parks on the preflight, releases green, and only THEN implements", async () => {
115
+ const { app, dbDir } = await boot();
116
+ try {
117
+ const { processInstanceKey } = await app.engine.createInstance({
118
+ processDefinitionId: "feature",
119
+ variables: featureVars({
120
+ // The shape `startFeature` seeds for a gated submission — here a hermetic green probe that
121
+ // binds a version, standing in for the `capability` probe (whose green/bind path is
122
+ // unit-tested in featureReadiness.test).
123
+ readinessProbes: [
124
+ {
125
+ kind: "command",
126
+ target: "true",
127
+ resolvedArtifact: "@scope/pkg@1.4.0",
128
+ poll: { everyMs: 5, timeoutMs: 5000, backoff: "fixed" },
129
+ },
130
+ ],
131
+ probeTimeout: "PT30M",
132
+ gateKey: "feature-readiness:owner/repo#7",
133
+ }),
134
+ });
135
+ await app.settle();
136
+
137
+ const flows = takenFlows(app);
138
+ // The feature was gated: it entered the preflight (not the ungated skip) and released green.
139
+ assert.ok(
140
+ flows.includes("gw-readiness->readiness-preflight"),
141
+ `a gated feature enters the preflight (flows: ${flows.join(", ")})`,
142
+ );
143
+ assert.ok(flows.includes("pf_gw->pf_end"), "the probe went green and settled the preflight");
144
+ // Only AFTER the gate does it reach ensure-base-branch and then implement-task — the gate is a
145
+ // true PREFLIGHT, not a parallel afterthought.
146
+ assert.ok(
147
+ flows.includes("readiness-preflight->ensure-base-branch"),
148
+ "the green gate leads into the fan-out head",
149
+ );
150
+ assert.ok(
151
+ flows.includes("ensure-base-branch->implement-task"),
152
+ `the run reaches the implement agent only after the gate (flows: ${flows.join(", ")})`,
153
+ );
154
+ // A green probe never escalates.
155
+ const tasks = await app.engine.searchUserTasks({ processInstanceKey });
156
+ assert.equal(
157
+ tasks.filter((t) => t.elementId === "readiness-escalation-pf").length,
158
+ 0,
159
+ "a green preflight never opens an escalation task",
160
+ );
161
+ } finally {
162
+ await app.stop();
163
+ rmSync(dbDir, { recursive: true, force: true });
164
+ }
165
+ });
166
+
167
+ test("UNGATED: readinessProbes = null skips the gate and implements immediately", async () => {
168
+ const { app, dbDir } = await boot();
169
+ try {
170
+ await app.engine.createInstance({
171
+ processDefinitionId: "feature",
172
+ variables: featureVars({ featureKey: "owner/repo#8", issue: "owner/repo#8", readinessProbes: null }),
173
+ });
174
+ await app.settle();
175
+
176
+ const flows = takenFlows(app);
177
+ assert.ok(
178
+ flows.includes("gw-readiness->ensure-base-branch"),
179
+ `an ungated feature skips straight to the fan-out head (flows: ${flows.join(", ")})`,
180
+ );
181
+ assert.ok(!flows.includes("gw-readiness->readiness-preflight"), "an ungated feature never enters the preflight");
182
+ assert.ok(
183
+ flows.includes("ensure-base-branch->implement-task"),
184
+ "an ungated feature reaches the implement agent",
185
+ );
186
+ } finally {
187
+ await app.stop();
188
+ rmSync(dbDir, { recursive: true, force: true });
189
+ }
190
+ });
191
+ });
package/openapi.yaml CHANGED
@@ -1153,12 +1153,65 @@ components:
1153
1153
  Accepted by the schema today but currently has no runtime effect. When implemented it
1154
1154
  will be the required acknowledgement when `baseBranch` names the repository default
1155
1155
  branch. See `PlanStartByIssue.confirmDefaultBase`.
1156
+ ReadinessProbe:
1157
+ description: >-
1158
+ A single durable readiness probe (issue #258, #295) the feature run must satisfy before its
1159
+ implementation agent is dispatched. `kind` selects the source; `target` + `match` are the
1160
+ per-kind predicate. The `capability` kind resolves "which published `pkg@version` first
1161
+ carries capability C?" from publish provenance and late-binds it into the run. See
1162
+ `app/readiness.ts` for the full per-kind semantics.
1163
+ type: object
1164
+ additionalProperties: false
1165
+ required:
1166
+ - kind
1167
+ - target
1168
+ properties:
1169
+ kind:
1170
+ type: string
1171
+ enum: [http, command, npm, github-check, capability]
1172
+ description: The readiness source. `command` is the escape hatch; `capability` resolves a cross-repo published-artifact edge.
1173
+ target:
1174
+ type: string
1175
+ minLength: 1
1176
+ description: The kind-specific target (a URL, a shell command, a `pkg@version`, an `owner/repo@ref`, or `github-releases:owner/repo`).
1177
+ onTimeout:
1178
+ type: string
1179
+ enum: [escalate, fail, continue]
1180
+ description: What the gate does when the bounded wait elapses (default `escalate`).
1181
+ credentialEnv:
1182
+ type: string
1183
+ description: A declared env-contract key supplying a credential (http kind only). Names a key, never a secret value.
1184
+ match:
1185
+ type: object
1186
+ additionalProperties: false
1187
+ description: The per-kind readiness predicate; every field is optional and read only by the kinds that understand it.
1188
+ properties:
1189
+ status: { type: integer, description: "http: the exact status that means ready (default any 2xx)." }
1190
+ bodyIncludes: { type: string, description: "http: a substring the response body must contain." }
1191
+ exitCode: { type: integer, description: "command: the exit code that means ready (default 0)." }
1192
+ stdoutIncludes: { type: string, description: "command/npm: a substring stdout must contain." }
1193
+ version: { type: string, description: "npm: the version that must be published." }
1194
+ conclusion: { type: string, description: "github-check: the conclusion that means ready (default success)." }
1195
+ checkName: { type: string, description: "github-check: restrict to the named check run." }
1196
+ capabilityRef: { type: string, description: "capability: the upstream issue/PR handle the resolved version must carry." }
1197
+ package: { type: string, description: "capability: the package whose releases are scanned for provenance." }
1198
+ verifyCommand: { type: string, description: "capability: optional empirical verifier run once at the gate boundary." }
1199
+ poll:
1200
+ type: object
1201
+ additionalProperties: false
1202
+ description: The poll cadence (how often to re-probe, how long to keep trying, and the backoff shape).
1203
+ properties:
1204
+ everyMs: { type: integer, description: Interval between poll attempts (ms). }
1205
+ timeoutMs: { type: integer, description: Bounded budget (ms) before the gate escalates. }
1206
+ backoff: { type: string, enum: [fixed, exponential], description: Backoff shape between attempts. }
1156
1207
  FeatureStart:
1157
1208
  description: The start-feature request body — a SINGLE-issue feature run. Names the target issue
1158
1209
  by EXACTLY ONE of `issue` (an `owner/repo#123` reference) or `url` (a bare issue URL), plus a
1159
1210
  REQUIRED `baseBranch` (ADR 0003, same admission as the epic path), and the two optional
1160
- follow-on knobs `converge` / `autoMerge`. Modeled as `oneOf` named variants (Camunda REST v2
1161
- pattern) so an ambiguous or empty target is a 400 at the edge.
1211
+ follow-on knobs `converge` / `autoMerge`. May also carry an intake-time readiness gate
1212
+ (`readiness` and/or `blockedOn` + `consumerPackage`, per issue 295) that parks the run until
1213
+ the declared upstreams land. Modeled as `oneOf` named variants (Camunda REST v2 pattern) so an
1214
+ ambiguous or empty target is a 400 at the edge.
1162
1215
  oneOf:
1163
1216
  - $ref: "#/components/schemas/FeatureStartByIssue"
1164
1217
  - $ref: "#/components/schemas/FeatureStartByUrl"
@@ -1210,6 +1263,36 @@ components:
1210
1263
  OPTIONAL free-text steering appended to the implementation agent's prompt for this run
1211
1264
  (via the implement task's `appendPrompt`). Blank/whitespace is treated as absent. Persists
1212
1265
  on the instance, so it also applies to the agent's answer-loop redispatch.
1266
+ readiness:
1267
+ type: array
1268
+ maxItems: 32
1269
+ items:
1270
+ $ref: "#/components/schemas/ReadinessProbe"
1271
+ description: >-
1272
+ OPTIONAL intake-time readiness gate (issue #295): one or more durable probes the run must
1273
+ ALL satisfy before its implementation agent is dispatched. The run parks (durably, bounded
1274
+ by the gate's escalating timer) at the leading readiness preflight until every probe goes
1275
+ green. Absent/empty ⇒ the run implements immediately, unchanged.
1276
+ blockedOn:
1277
+ type: array
1278
+ maxItems: 32
1279
+ items:
1280
+ type: string
1281
+ minLength: 1
1282
+ description: >-
1283
+ OPTIONAL ergonomic shorthand for `readiness` (issue #295): a list of upstream
1284
+ `owner/repo#123` issue/PR handles the run waits to land. With `consumerPackage` each
1285
+ desugars to a `capability` probe (resolve which published `pkg@version` first carries the
1286
+ handle, and late-bind it into the run); without it, to a `command` probe that goes green
1287
+ once the referenced issue/PR is closed/merged.
1288
+ consumerPackage:
1289
+ type: string
1290
+ minLength: 1
1291
+ description: >-
1292
+ OPTIONAL npm package name (e.g. `@nanobpm/engine-wasm`) the `blockedOn` shorthand resolves
1293
+ its handles against — the consumer dependency whose published provenance must carry each
1294
+ awaited upstream. When present, `blockedOn` desugars to `capability` probes and the
1295
+ resolved `pkg@version` is late-bound into the implementation agent's brief.
1213
1296
  FeatureStartByUrl:
1214
1297
  type: object
1215
1298
  additionalProperties: false
@@ -1246,6 +1329,23 @@ components:
1246
1329
  description: >-
1247
1330
  OPTIONAL free-text steering appended to the implementation agent's prompt for this run.
1248
1331
  See `FeatureStartByIssue.customInstructions`.
1332
+ readiness:
1333
+ type: array
1334
+ maxItems: 32
1335
+ items:
1336
+ $ref: "#/components/schemas/ReadinessProbe"
1337
+ description: OPTIONAL intake-time readiness gate. See `FeatureStartByIssue.readiness`.
1338
+ blockedOn:
1339
+ type: array
1340
+ maxItems: 32
1341
+ items:
1342
+ type: string
1343
+ minLength: 1
1344
+ description: OPTIONAL readiness shorthand — upstream `owner/repo#123` handles to wait on. See `FeatureStartByIssue.blockedOn`.
1345
+ consumerPackage:
1346
+ type: string
1347
+ minLength: 1
1348
+ description: OPTIONAL package the `blockedOn` handles resolve against. See `FeatureStartByIssue.consumerPackage`.
1249
1349
  MessageResult:
1250
1350
  type: object
1251
1351
  description: The result of publishing a message / answering an escalation. Shape varies by message
@@ -13,6 +13,7 @@
13
13
  // confirm-default / shared-base rules, with the same typed-error → HTTP mapping.
14
14
 
15
15
  import { startFeature } from "../app/feature.ts";
16
+ import { parseFeatureReadiness } from "../app/featureReadiness.ts";
16
17
  import { BaseBranchMustExistError } from "../app/github.ts";
17
18
  import {
18
19
  admitPlan,
@@ -22,6 +23,7 @@ import {
22
23
  parseIssue,
23
24
  SharedBaseError,
24
25
  } from "../app/plan.ts";
26
+ import type { ReadinessProbe } from "../app/readiness.ts";
25
27
  import { defineOperation } from "../nano-generated/operations.ts";
26
28
 
27
29
  export default defineOperation("startFeature", async ({ body }, app) => {
@@ -120,13 +122,39 @@ export default defineOperation("startFeature", async ({ body }, app) => {
120
122
  const customInstructions = "customInstructions" in body && typeof body.customInstructions === "string"
121
123
  ? body.customInstructions
122
124
  : null;
123
- const result = await startFeature(app.data, app.engine, parsed, normalizedBase, converge, autoMerge, customInstructions);
125
+ // Intake-time readiness gate (issue #295): desugar the optional `readiness` descriptors and/or the
126
+ // `blockedOn` shorthand (resolved against `consumerPackage`) into the probes + bound the run parks
127
+ // on before implementing. A malformed gate (bad descriptor, unparseable handle, blank package) is a
128
+ // 400 at the edge — it must never wait forever at runtime.
129
+ let readiness: { probes: ReadinessProbe[]; probeTimeout: string | null };
130
+ try {
131
+ readiness = parseFeatureReadiness({
132
+ readiness: "readiness" in body ? body.readiness : undefined,
133
+ blockedOn: "blockedOn" in body ? body.blockedOn : undefined,
134
+ consumerPackage: "consumerPackage" in body ? body.consumerPackage : undefined,
135
+ });
136
+ } catch (err) {
137
+ const message = err instanceof Error ? err.message : "invalid readiness gate";
138
+ app.log.warn("start-feature rejected: invalid readiness gate", { message });
139
+ return { status: 400, body: { error: message } };
140
+ }
141
+ const result = await startFeature(
142
+ app.data,
143
+ app.engine,
144
+ parsed,
145
+ normalizedBase,
146
+ converge,
147
+ autoMerge,
148
+ customInstructions,
149
+ { probes: readiness.probes, probeTimeout: readiness.probeTimeout },
150
+ );
124
151
  app.log.info("feature run started", {
125
152
  featureKey: parsed.planKey,
126
153
  requestedBaseBranch: normalizedBase,
127
154
  converge,
128
155
  autoMerge,
129
156
  hasCustomInstructions: typeof customInstructions === "string" && customInstructions.trim() !== "",
157
+ readinessProbes: readiness.probes.length,
130
158
  alreadyRunning: "alreadyRunning" in result && result.alreadyRunning === true,
131
159
  });
132
160
  return { status: 202, body: result };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.102.0",
3
+ "version": "0.103.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",