@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 +14 -0
- package/SPEC.md +24 -2
- package/app/failingChecksSupersede.test.ts +93 -0
- package/app/feature.test.ts +71 -0
- package/app/feature.ts +42 -0
- package/app/featureReadiness.test.ts +165 -0
- package/app/featureReadiness.ts +151 -0
- package/app/github.ts +69 -7
- package/app/mergeCiReattempt.test.ts +138 -0
- package/app/mergeRebaseArm.test.ts +5 -2
- package/e2e/feature-preflight.e2e.ts +191 -0
- package/openapi.yaml +102 -2
- package/operations/startFeature.ts +29 -1
- package/package.json +1 -1
- package/resources/processes/feature.bpmn +300 -77
- package/resources/processes/merge-loop.bpmn +162 -88
- package/resources/prompts/fix-ci.md +32 -12
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
|
-
|
|
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
|
|
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
|
-
|
|
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(/"/g, '"').replace(/&/g, "&").replace(/ /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", "
|
|
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`.
|
|
1161
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|