@nanobpm/nano-workforce 0.102.1 → 0.104.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/app/contracts.ts +8 -0
- package/app/durableResume.test.ts +89 -0
- package/app/durableResume.ts +141 -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/migration052.test.ts +66 -0
- package/app/service.test.ts +33 -1
- package/app/service.ts +23 -5
- package/db/migrations/052_worker_durable_resume.sql +35 -0
- package/e2e/feature-preflight.e2e.ts +191 -0
- package/openapi.yaml +119 -2
- package/operations/enrolAgenticWorker.test.ts +64 -0
- package/operations/enrolAgenticWorker.ts +30 -1
- package/operations/startFeature.ts +29 -1
- package/package.json +1 -1
- package/resources/processes/feature.bpmn +300 -77
- package/test/worldDb.ts +16 -4
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Regression guard for migration 052 (issue #325, ADR 0062 Slice 5/5): the durable `durable-resume`
|
|
2
|
+
// enrolment registry. The table is FK-free (enrolment is per-worker and connection-agnostic, with no
|
|
3
|
+
// `pull_requests`/`plans` parent), the `instance` PRIMARY KEY makes `recordEnrolment` an idempotent
|
|
4
|
+
// upsert, and `CHECK(durable_resume IN (0,1))` pins the gate's boolean domain.
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { DatabaseSync } from "node:sqlite";
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { assertEquals, assertThrows } from "#test-assert";
|
|
10
|
+
|
|
11
|
+
function migratedDb(): DatabaseSync {
|
|
12
|
+
const db = new DatabaseSync(":memory:");
|
|
13
|
+
db.exec("PRAGMA foreign_keys = ON;");
|
|
14
|
+
// Deliberately NO parent tables — the enrolment table must apply and accept rows on a bare db.
|
|
15
|
+
const sql = readFileSync(fileURLToPath(new URL("../db/migrations/052_worker_durable_resume.sql", import.meta.url)), "utf8");
|
|
16
|
+
db.exec(sql);
|
|
17
|
+
return db;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const upsert = (db: DatabaseSync, instance: string, flag: number) =>
|
|
21
|
+
db
|
|
22
|
+
.prepare(
|
|
23
|
+
`INSERT INTO worker_durable_resume (instance, durable_resume, updated_at) VALUES (?, ?, 't')
|
|
24
|
+
ON CONFLICT(instance) DO UPDATE SET durable_resume = excluded.durable_resume`,
|
|
25
|
+
)
|
|
26
|
+
.run(instance, flag);
|
|
27
|
+
|
|
28
|
+
test("migration 052 applies cleanly with NO parent tables (FK-free) and records an enrolment", () => {
|
|
29
|
+
const db = migratedDb();
|
|
30
|
+
upsert(db, "w1", 1);
|
|
31
|
+
const row = db.prepare("SELECT instance, durable_resume FROM worker_durable_resume WHERE instance = ?").get("w1") as {
|
|
32
|
+
instance: string;
|
|
33
|
+
durable_resume: number;
|
|
34
|
+
};
|
|
35
|
+
assertEquals(row.instance, "w1");
|
|
36
|
+
assertEquals(row.durable_resume, 1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("instance PRIMARY KEY makes a re-enrol an upsert (one row per worker, latest flag wins)", () => {
|
|
40
|
+
const db = migratedDb();
|
|
41
|
+
upsert(db, "w1", 1);
|
|
42
|
+
upsert(db, "w1", 0);
|
|
43
|
+
const count = Number((db.prepare("SELECT COUNT(*) c FROM worker_durable_resume").get() as { c: number }).c);
|
|
44
|
+
assertEquals(count, 1, "no duplicate row for one instance");
|
|
45
|
+
const row = db.prepare("SELECT durable_resume FROM worker_durable_resume WHERE instance = 'w1'").get() as { durable_resume: number };
|
|
46
|
+
assertEquals(row.durable_resume, 0, "the latest enrolment flag wins");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("durable_resume defaults to 0 (a non-participant) when unset", () => {
|
|
50
|
+
const db = migratedDb();
|
|
51
|
+
db.prepare("INSERT INTO worker_durable_resume (instance, updated_at) VALUES ('w1', 't')").run();
|
|
52
|
+
const row = db.prepare("SELECT durable_resume FROM worker_durable_resume WHERE instance = 'w1'").get() as { durable_resume: number };
|
|
53
|
+
assertEquals(row.durable_resume, 0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("CHECK(durable_resume IN (0,1)): the gate's boolean domain is pinned at the schema", () => {
|
|
57
|
+
const db = migratedDb();
|
|
58
|
+
assertThrows(
|
|
59
|
+
() => db.prepare("INSERT INTO worker_durable_resume (instance, durable_resume, updated_at) VALUES ('w1', 2, 't')").run(),
|
|
60
|
+
undefined,
|
|
61
|
+
"CHECK constraint failed",
|
|
62
|
+
);
|
|
63
|
+
upsert(db, "yes", 1);
|
|
64
|
+
upsert(db, "no", 0);
|
|
65
|
+
assertEquals(Number((db.prepare("SELECT COUNT(*) c FROM worker_durable_resume").get() as { c: number }).c), 2);
|
|
66
|
+
});
|
package/app/service.test.ts
CHANGED
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
// GitHub transport forced off so it is hermetic.
|
|
8
8
|
import { test } from "node:test";
|
|
9
9
|
import { assertEquals } from "#test-assert";
|
|
10
|
-
import {
|
|
10
|
+
import { memDataFor } from "../test/worldDb.ts";
|
|
11
|
+
import { DurableResumeRegistry } from "./durableResume.ts";
|
|
12
|
+
import { WorldStore } from "./world/index.ts";
|
|
13
|
+
import { parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
|
|
11
14
|
|
|
12
15
|
function memTable(rows: any[], key: string) {
|
|
13
16
|
return {
|
|
@@ -519,6 +522,35 @@ test("repoEnvelopeVars emits commitSha only for a well-formed 40-hex SHA (world-
|
|
|
519
522
|
assertEquals("commitSha" in none, false);
|
|
520
523
|
});
|
|
521
524
|
|
|
525
|
+
// Durable-resume enrolment gate (issue #325, ADR 0062 Slice 5/5): `worldRestoreSha` — the seam
|
|
526
|
+
// `submitPr`/`startMerge` thread into `repoEnvelopeVars` — hands the harness the last push-checkpoint
|
|
527
|
+
// ONLY when the enrolled fleet advertises `durable-resume`. With no participant it degrades to null,
|
|
528
|
+
// so the round redrives from scratch (exactly as today). Proven against a REAL in-memory SQLite db
|
|
529
|
+
// with the world (049) + enrolment (052) schemas applied.
|
|
530
|
+
test("worldRestoreSha is gated on the durable-resume enrolment: participant → SHA, none → null", async () => {
|
|
531
|
+
const { data } = memDataFor(["049_world_checkpoint.sql", "052_worker_durable_resume.sql"]);
|
|
532
|
+
const PR = "owner/repo#7";
|
|
533
|
+
const sha = "77ee0993cc6ad4493da0f7551212ef16722135db";
|
|
534
|
+
await new WorldStore(data).recordCheckpoint({ prKey: PR, roundNo: 1, commitSha: sha });
|
|
535
|
+
|
|
536
|
+
// No participant enrolled yet — graceful degradation: no resume marker even though a checkpoint exists.
|
|
537
|
+
assertEquals(await worldRestoreSha(data, PR), null, "no participant → redrive from scratch");
|
|
538
|
+
|
|
539
|
+
// A non-participant enrolment still does not open the gate (a fleet of only non-participants).
|
|
540
|
+
await new DurableResumeRegistry(data).recordEnrolment("legacy-1", false);
|
|
541
|
+
assertEquals(await worldRestoreSha(data, PR), null, "only non-participants → still scratch");
|
|
542
|
+
|
|
543
|
+
// One participant makes the mixed fleet resume-capable: the checkpoint SHA is now emitted.
|
|
544
|
+
await new DurableResumeRegistry(data).recordEnrolment("modern-1", true);
|
|
545
|
+
assertEquals(await worldRestoreSha(data, PR), sha, "a participant → resume at the checkpoint SHA");
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
test("worldRestoreSha is null when a participant is enrolled but the PR has no checkpoint yet", async () => {
|
|
549
|
+
const { data } = memDataFor(["049_world_checkpoint.sql", "052_worker_durable_resume.sql"]);
|
|
550
|
+
await new DurableResumeRegistry(data).recordEnrolment("modern-1", true);
|
|
551
|
+
assertEquals(await worldRestoreSha(data, "owner/repo#8"), null, "nothing to reconstruct on a first activation");
|
|
552
|
+
});
|
|
553
|
+
|
|
522
554
|
// `parsePr` is total on any input: it is called unguarded from several workers (progress-check,
|
|
523
555
|
// persist-round, persist-escalation, record-dependency) with a process variable that a regression
|
|
524
556
|
// — or an older in-flight instance — could carry as a non-string. `.trim()` on a non-string throws,
|
package/app/service.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
UnresolvableCapabilityRefError,
|
|
23
23
|
} from "./capabilityNeed.ts";
|
|
24
24
|
import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
|
|
25
|
+
import { fleetSupportsDurableResume } from "./durableResume.ts";
|
|
25
26
|
import { backfillFeatureStages, deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
|
|
26
27
|
import {
|
|
27
28
|
classifyMergeability,
|
|
@@ -441,6 +442,19 @@ async function lastPushedSha(data: DataLayer, prKey: string): Promise<string | n
|
|
|
441
442
|
}
|
|
442
443
|
}
|
|
443
444
|
|
|
445
|
+
/** The world-restore SHA to emit into the repo-provisioning envelope for a PR, GATED on the
|
|
446
|
+
* `durable-resume` enrolment (issue #325, ADR 0062 Slice 5/5). Only when the enrolled fleet includes a
|
|
447
|
+
* durable-resume participant (`fleetSupportsDurableResume`) do we hand the harness the last
|
|
448
|
+
* push-checkpoint so a replacement activation RESUMES by reconstructing the exact pushed tree
|
|
449
|
+
* (inverting `git push` → `git fetch && git checkout <sha>`). With no participant the marker is
|
|
450
|
+
* omitted (`null`), so the round redrives from scratch — graceful degradation, exactly as today.
|
|
451
|
+
* Resume is purely additive: gating on the enrolment attribute, not a sequence flow, keeps the
|
|
452
|
+
* engine/C8 job protocol untouched (ADR 0056 boundary). */
|
|
453
|
+
export async function worldRestoreSha(data: DataLayer, prKey: string): Promise<string | null> {
|
|
454
|
+
if (!(await fleetSupportsDurableResume(data))) return null;
|
|
455
|
+
return lastPushedSha(data, prKey);
|
|
456
|
+
}
|
|
457
|
+
|
|
444
458
|
/** Register a PR row (if new) and start the convergence process. Idempotent on prKey. Optional
|
|
445
459
|
* `dependsOn` (explicit refs) is unioned with any `Depends-on:` line parsed from the PR body and
|
|
446
460
|
* recorded as the PR's merge-stage dependency set. */
|
|
@@ -547,9 +561,11 @@ export async function submitPr(
|
|
|
547
561
|
const abUrl = abandonUrl(abandonToken);
|
|
548
562
|
// World-restore (issue #324, ADR 0062 Slice 4/5): a re-run of convergence for a PR that already
|
|
549
563
|
// pushed is a resume — carry its last durable push-checkpoint so a replacement activation on a
|
|
550
|
-
// fresh worktree reconstructs the tree to the EXACT pushed SHA.
|
|
551
|
-
//
|
|
552
|
-
|
|
564
|
+
// fresh worktree reconstructs the tree to the EXACT pushed SHA. GATED (issue #325, Slice 5/5) on the
|
|
565
|
+
// fleet advertising `durable-resume`: with no participant it stays null, so the round redrives from
|
|
566
|
+
// scratch (graceful degradation). Absent (null) on a first submit, which leaves the envelope
|
|
567
|
+
// unchanged.
|
|
568
|
+
const worldSha = await worldRestoreSha(data, parsed.prKey);
|
|
553
569
|
const { processInstanceKey } = await engine.createInstance({
|
|
554
570
|
processDefinitionId: PROCESS_ID,
|
|
555
571
|
variables: {
|
|
@@ -623,8 +639,10 @@ export async function startMerge(
|
|
|
623
639
|
console.warn(`[startMerge] ${pr.prKey} head branch unresolved — merge-agent workspace won't be provisioned`);
|
|
624
640
|
}
|
|
625
641
|
// World-restore (issue #324): the merge stage runs on the same durable working tree; carry the
|
|
626
|
-
// last push-checkpoint so a replacement fix-ci/rebase activation reconstructs the exact SHA.
|
|
627
|
-
|
|
642
|
+
// last push-checkpoint so a replacement fix-ci/rebase activation reconstructs the exact SHA. GATED
|
|
643
|
+
// (issue #325, Slice 5/5) on the fleet advertising `durable-resume` — otherwise null, so the merge
|
|
644
|
+
// agents redrive from scratch (graceful degradation).
|
|
645
|
+
const worldSha = await worldRestoreSha(data, pr.prKey);
|
|
628
646
|
const { processInstanceKey } = await engine.createInstance({
|
|
629
647
|
processDefinitionId: MERGE_PROCESS_ID,
|
|
630
648
|
variables: {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
-- 052_worker_durable_resume.sql — issue #325 (ADR 0062, Slice 5/5): the ENROLMENT GATE for durable
|
|
2
|
+
-- agent-session resume. Slices 1–4 built the mind (harness conversation) and world (git tree + effect
|
|
3
|
+
-- ledger) halves; this slice wires them into the running orchestration behind a `durable-resume`
|
|
4
|
+
-- enrolment gate so a re-leased `senior:pr-review` round RESUMES at the last push-checkpoint on a
|
|
5
|
+
-- participating harness, and gracefully DEGRADES (redriven from scratch, exactly as today) on one
|
|
6
|
+
-- that does not advertise it.
|
|
7
|
+
--
|
|
8
|
+
-- `durable-resume` is a WORKER ATTRIBUTE declared at enrolment (ADR 0056 §7 — capability gates
|
|
9
|
+
-- enrolment, it is NEVER in the routing token `network.role#seat`). The registry records, per worker
|
|
10
|
+
-- instance, whether that worker's harness advertises durable-resume (the probe result from Slice
|
|
11
|
+
-- 2/3). The world-restore `commitSha` is emitted into the repo-provisioning envelope ONLY when the
|
|
12
|
+
-- fleet includes a participant; a fleet with no participant emits no resume marker and clones the
|
|
13
|
+
-- head branch tip — the pre-#324 behaviour. Resume is purely additive, never a new sequence-flow
|
|
14
|
+
-- gate (ADR 0056 boundary): the engine/C8 job protocol is untouched.
|
|
15
|
+
--
|
|
16
|
+
-- One FK-free table keyed by the worker instance (`register.instance` / the enrol `instance`). It is
|
|
17
|
+
-- FK-free by design — enrolment is per-worker and connection-agnostic, with no `pull_requests`/`plans`
|
|
18
|
+
-- parent to reference. EXPAND (additive) phase: one new table + its index; nothing is dropped or
|
|
19
|
+
-- renamed. Numbered after the current highest prefix on origin/main (051). The runner wraps each file
|
|
20
|
+
-- in its own transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
21
|
+
|
|
22
|
+
CREATE TABLE IF NOT EXISTS worker_durable_resume (
|
|
23
|
+
instance TEXT PRIMARY KEY, -- the worker instance id (enrol `instance` / register.instance)
|
|
24
|
+
durable_resume INTEGER NOT NULL DEFAULT 0, -- 1 when the worker's harness advertises durable-resume, else 0
|
|
25
|
+
updated_at TEXT NOT NULL,
|
|
26
|
+
-- `durable_resume` is a strict boolean domain — the gate reads it as "does this worker participate?",
|
|
27
|
+
-- so a stray value (a future writer bug, a corrupt row on this externalised enrolment boundary) would
|
|
28
|
+
-- make the gate mis-decide whether to emit the resume marker. Pin it to {0,1} at the schema.
|
|
29
|
+
CHECK (durable_resume IN (0, 1))
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
-- The gate asks "does the fleet include a durable-resume participant?" — an existence probe over the
|
|
33
|
+
-- participants. Index the flag so that lookup is a covered scan, not a table walk.
|
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_worker_durable_resume_flag
|
|
35
|
+
ON worker_durable_resume(durable_resume);
|
|
@@ -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
|
+
});
|