@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/openapi.yaml CHANGED
@@ -386,6 +386,15 @@ components:
386
386
  instance:
387
387
  type: string
388
388
  description: The worker instance id, echoed back for provenance (optional).
389
+ durableResume:
390
+ type: boolean
391
+ description: >-
392
+ Whether this worker's harness advertises durable-resume (issue #325, ADR 0062 Slice 5/5)
393
+ — an ENROLMENT attribute, never a routing token. Recorded per instance so the app emits
394
+ the world-restore marker only to a fleet with a participant; a harness that omits it (or
395
+ sets false) redrives a re-leased round from scratch. Recorded only when `instance` is
396
+ a non-blank string — a missing, empty, or whitespace-only `instance` is echoed back for
397
+ provenance but the flag is not persisted.
389
398
  EnrolledRole:
390
399
  type: object
391
400
  description: One matched role in an enrolment resolution — provenance for the resolved SERVE set.
@@ -411,6 +420,14 @@ components:
411
420
  instance:
412
421
  type: string
413
422
  description: The worker instance id, echoed from the request when supplied.
423
+ durableResume:
424
+ type: boolean
425
+ description: >-
426
+ Echo of the request's durable-resume declaration (issue #325, ADR 0062 Slice 5/5).
427
+ Present only when the request supplied it. This reflects the value the worker sent, not a
428
+ guarantee of durable persistence — recording into the durable-resume registry is
429
+ best-effort (skipped when `instance` is absent/blank, and a registry write hiccup is
430
+ logged without failing enrolment).
414
431
  serve:
415
432
  type: array
416
433
  description: The SERVE token set — sorted, de-duplicated leaf tokens the worker may serve.
@@ -1153,12 +1170,65 @@ components:
1153
1170
  Accepted by the schema today but currently has no runtime effect. When implemented it
1154
1171
  will be the required acknowledgement when `baseBranch` names the repository default
1155
1172
  branch. See `PlanStartByIssue.confirmDefaultBase`.
1173
+ ReadinessProbe:
1174
+ description: >-
1175
+ A single durable readiness probe (issue #258, #295) the feature run must satisfy before its
1176
+ implementation agent is dispatched. `kind` selects the source; `target` + `match` are the
1177
+ per-kind predicate. The `capability` kind resolves "which published `pkg@version` first
1178
+ carries capability C?" from publish provenance and late-binds it into the run. See
1179
+ `app/readiness.ts` for the full per-kind semantics.
1180
+ type: object
1181
+ additionalProperties: false
1182
+ required:
1183
+ - kind
1184
+ - target
1185
+ properties:
1186
+ kind:
1187
+ type: string
1188
+ enum: [http, command, npm, github-check, capability]
1189
+ description: The readiness source. `command` is the escape hatch; `capability` resolves a cross-repo published-artifact edge.
1190
+ target:
1191
+ type: string
1192
+ minLength: 1
1193
+ description: The kind-specific target (a URL, a shell command, a `pkg@version`, an `owner/repo@ref`, or `github-releases:owner/repo`).
1194
+ onTimeout:
1195
+ type: string
1196
+ enum: [escalate, fail, continue]
1197
+ description: What the gate does when the bounded wait elapses (default `escalate`).
1198
+ credentialEnv:
1199
+ type: string
1200
+ description: A declared env-contract key supplying a credential (http kind only). Names a key, never a secret value.
1201
+ match:
1202
+ type: object
1203
+ additionalProperties: false
1204
+ description: The per-kind readiness predicate; every field is optional and read only by the kinds that understand it.
1205
+ properties:
1206
+ status: { type: integer, description: "http: the exact status that means ready (default any 2xx)." }
1207
+ bodyIncludes: { type: string, description: "http: a substring the response body must contain." }
1208
+ exitCode: { type: integer, description: "command: the exit code that means ready (default 0)." }
1209
+ stdoutIncludes: { type: string, description: "command/npm: a substring stdout must contain." }
1210
+ version: { type: string, description: "npm: the version that must be published." }
1211
+ conclusion: { type: string, description: "github-check: the conclusion that means ready (default success)." }
1212
+ checkName: { type: string, description: "github-check: restrict to the named check run." }
1213
+ capabilityRef: { type: string, description: "capability: the upstream issue/PR handle the resolved version must carry." }
1214
+ package: { type: string, description: "capability: the package whose releases are scanned for provenance." }
1215
+ verifyCommand: { type: string, description: "capability: optional empirical verifier run once at the gate boundary." }
1216
+ poll:
1217
+ type: object
1218
+ additionalProperties: false
1219
+ description: The poll cadence (how often to re-probe, how long to keep trying, and the backoff shape).
1220
+ properties:
1221
+ everyMs: { type: integer, description: Interval between poll attempts (ms). }
1222
+ timeoutMs: { type: integer, description: Bounded budget (ms) before the gate escalates. }
1223
+ backoff: { type: string, enum: [fixed, exponential], description: Backoff shape between attempts. }
1156
1224
  FeatureStart:
1157
1225
  description: The start-feature request body — a SINGLE-issue feature run. Names the target issue
1158
1226
  by EXACTLY ONE of `issue` (an `owner/repo#123` reference) or `url` (a bare issue URL), plus a
1159
1227
  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.
1228
+ follow-on knobs `converge` / `autoMerge`. May also carry an intake-time readiness gate
1229
+ (`readiness` and/or `blockedOn` + `consumerPackage`, per issue 295) that parks the run until
1230
+ the declared upstreams land. Modeled as `oneOf` named variants (Camunda REST v2 pattern) so an
1231
+ ambiguous or empty target is a 400 at the edge.
1162
1232
  oneOf:
1163
1233
  - $ref: "#/components/schemas/FeatureStartByIssue"
1164
1234
  - $ref: "#/components/schemas/FeatureStartByUrl"
@@ -1210,6 +1280,36 @@ components:
1210
1280
  OPTIONAL free-text steering appended to the implementation agent's prompt for this run
1211
1281
  (via the implement task's `appendPrompt`). Blank/whitespace is treated as absent. Persists
1212
1282
  on the instance, so it also applies to the agent's answer-loop redispatch.
1283
+ readiness:
1284
+ type: array
1285
+ maxItems: 32
1286
+ items:
1287
+ $ref: "#/components/schemas/ReadinessProbe"
1288
+ description: >-
1289
+ OPTIONAL intake-time readiness gate (issue #295): one or more durable probes the run must
1290
+ ALL satisfy before its implementation agent is dispatched. The run parks (durably, bounded
1291
+ by the gate's escalating timer) at the leading readiness preflight until every probe goes
1292
+ green. Absent/empty ⇒ the run implements immediately, unchanged.
1293
+ blockedOn:
1294
+ type: array
1295
+ maxItems: 32
1296
+ items:
1297
+ type: string
1298
+ minLength: 1
1299
+ description: >-
1300
+ OPTIONAL ergonomic shorthand for `readiness` (issue #295): a list of upstream
1301
+ `owner/repo#123` issue/PR handles the run waits to land. With `consumerPackage` each
1302
+ desugars to a `capability` probe (resolve which published `pkg@version` first carries the
1303
+ handle, and late-bind it into the run); without it, to a `command` probe that goes green
1304
+ once the referenced issue/PR is closed/merged.
1305
+ consumerPackage:
1306
+ type: string
1307
+ minLength: 1
1308
+ description: >-
1309
+ OPTIONAL npm package name (e.g. `@nanobpm/engine-wasm`) the `blockedOn` shorthand resolves
1310
+ its handles against — the consumer dependency whose published provenance must carry each
1311
+ awaited upstream. When present, `blockedOn` desugars to `capability` probes and the
1312
+ resolved `pkg@version` is late-bound into the implementation agent's brief.
1213
1313
  FeatureStartByUrl:
1214
1314
  type: object
1215
1315
  additionalProperties: false
@@ -1246,6 +1346,23 @@ components:
1246
1346
  description: >-
1247
1347
  OPTIONAL free-text steering appended to the implementation agent's prompt for this run.
1248
1348
  See `FeatureStartByIssue.customInstructions`.
1349
+ readiness:
1350
+ type: array
1351
+ maxItems: 32
1352
+ items:
1353
+ $ref: "#/components/schemas/ReadinessProbe"
1354
+ description: OPTIONAL intake-time readiness gate. See `FeatureStartByIssue.readiness`.
1355
+ blockedOn:
1356
+ type: array
1357
+ maxItems: 32
1358
+ items:
1359
+ type: string
1360
+ minLength: 1
1361
+ description: OPTIONAL readiness shorthand — upstream `owner/repo#123` handles to wait on. See `FeatureStartByIssue.blockedOn`.
1362
+ consumerPackage:
1363
+ type: string
1364
+ minLength: 1
1365
+ description: OPTIONAL package the `blockedOn` handles resolve against. See `FeatureStartByIssue.consumerPackage`.
1249
1366
  MessageResult:
1250
1367
  type: object
1251
1368
  description: The result of publishing a message / answering an escalation. Shape varies by message
@@ -2,6 +2,8 @@
2
2
  import { test } from "node:test";
3
3
  import { assert, assertEquals } from "#test-assert";
4
4
  import type { AppApi } from "@nanobpm/urban";
5
+ import { memDataFor } from "../test/worldDb.ts";
6
+ import { DurableResumeRegistry } from "../app/durableResume.ts";
5
7
  import { noopLog } from "../test/log.ts";
6
8
  import handler from "./enrolAgenticWorker.ts";
7
9
 
@@ -69,6 +71,68 @@ test("rejects non-finite capability.weight (NaN/Infinity) as 400", async () => {
69
71
  assertEquals(infWeight.status, 400);
70
72
  });
71
73
 
74
+ // Durable-resume enrolment gate (issue #325, ADR 0062 Slice 5/5).
75
+ test("echoes durableResume back in the result when the worker declares it", async () => {
76
+ const on = (await handler(input({ capability: { cognition: "decide" }, instance: "w1", durableResume: true }), app)) as any;
77
+ assertEquals(on.status, 200);
78
+ assertEquals(on.body.durableResume, true);
79
+ const off = (await handler(input({ capability: { cognition: "decide" }, instance: "w2", durableResume: false }), app)) as any;
80
+ assertEquals(off.body.durableResume, false);
81
+ });
82
+
83
+ test("omits durableResume from the result when the worker does not declare it", async () => {
84
+ const res = (await handler(input({ capability: { cognition: "decide" }, instance: "w1" }), app)) as any;
85
+ assertEquals(res.status, 200);
86
+ assertEquals("durableResume" in res.body, false);
87
+ });
88
+
89
+ test("rejects a non-boolean durableResume as 400", async () => {
90
+ const res = (await handler(input({ capability: { cognition: "decide" }, instance: "w1", durableResume: "yes" }), app)) as any;
91
+ assertEquals(res.status, 400);
92
+ });
93
+
94
+ test("records durable-resume participation in the registry when a data layer + instance are present", async () => {
95
+ const { data } = memDataFor(["052_worker_durable_resume.sql"]);
96
+ const withData = { log: noopLog(), data } as unknown as AppApi;
97
+ const res = (await handler(input({ capability: { cognition: "decide" }, instance: "w1", durableResume: true }), withData)) as any;
98
+ assertEquals(res.status, 200);
99
+ assertEquals(await new DurableResumeRegistry(data).isParticipant("w1"), true);
100
+ assertEquals(await new DurableResumeRegistry(data).anyParticipant(), true);
101
+ });
102
+
103
+ test("a re-enrol omitting durableResume persists an explicit false, clearing a stale true (degrade to scratch)", async () => {
104
+ const { data } = memDataFor(["052_worker_durable_resume.sql"]);
105
+ const withData = { log: noopLog(), data } as unknown as AppApi;
106
+ // First enrol advertises durable-resume.
107
+ await handler(input({ capability: { cognition: "decide" }, instance: "w1", durableResume: true }), withData);
108
+ assertEquals(await new DurableResumeRegistry(data).isParticipant("w1"), true);
109
+ // Re-enrol WITHOUT the field (downgrade/rollback/client bug) must clear the stale flag.
110
+ const res = (await handler(input({ capability: { cognition: "decide" }, instance: "w1" }), withData)) as any;
111
+ assertEquals(res.status, 200);
112
+ assertEquals("durableResume" in res.body, false, "still omitted from the echo");
113
+ assertEquals(await new DurableResumeRegistry(data).isParticipant("w1"), false, "stale true cleared");
114
+ assertEquals(await new DurableResumeRegistry(data).anyParticipant(), false);
115
+ });
116
+
117
+ test("a declaration without an instance is echoed but not persisted (enrolment is per-instance)", async () => {
118
+ const { data } = memDataFor(["052_worker_durable_resume.sql"]);
119
+ const withData = { log: noopLog(), data } as unknown as AppApi;
120
+ const res = (await handler(input({ capability: { cognition: "decide" }, durableResume: true }), withData)) as any;
121
+ assertEquals(res.status, 200);
122
+ assertEquals(res.body.durableResume, true, "still echoed");
123
+ assertEquals(await new DurableResumeRegistry(data).anyParticipant(), false, "nothing recorded without an instance key");
124
+ });
125
+
126
+ test("a blank/whitespace instance is echoed but not persisted (avoids a shared registry-row collision)", async () => {
127
+ const { data } = memDataFor(["052_worker_durable_resume.sql"]);
128
+ const withData = { log: noopLog(), data } as unknown as AppApi;
129
+ const res = (await handler(input({ capability: { cognition: "decide" }, instance: " ", durableResume: true }), withData)) as any;
130
+ assertEquals(res.status, 200);
131
+ assertEquals(res.body.instance, " ", "still echoed verbatim");
132
+ assertEquals(res.body.durableResume, true, "still echoed");
133
+ assertEquals(await new DurableResumeRegistry(data).anyParticipant(), false, "nothing recorded for a blank instance key");
134
+ });
135
+
72
136
  test("enforces the shared secret when NANO_PR_WEBHOOK_SECRET is set", async () => {
73
137
  // The module captures the secret at load, so re-import a cache-busted copy with the env var set to
74
138
  // exercise the guarded 401 path and the authorized 200 path.
@@ -12,13 +12,14 @@
12
12
  // NANO_PR_WEBHOOK_SECRET is set, callers must present it via the x-hook-secret header. Unset → open.
13
13
  import type { Capability } from "@nanobpm/agentic/protocol";
14
14
  import { resolveEnrolment } from "../app/agentic/vocab/enrol.ts";
15
+ import { DurableResumeRegistry } from "../app/durableResume.ts";
15
16
  import { envVar } from "../app/version.ts";
16
17
  import type { EnrolResult } from "../nano-generated/api-io.d.ts";
17
18
  import { defineOperation } from "../nano-generated/operations.ts";
18
19
 
19
20
  const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
20
21
 
21
- export default defineOperation("enrolAgenticWorker", ({ req, body }, app) => {
22
+ export default defineOperation("enrolAgenticWorker", async ({ req, body }, app) => {
22
23
  if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
23
24
  app.log.warn("enrolAgenticWorker rejected: missing/invalid shared secret");
24
25
  return { status: 401, body: { error: "unauthorized" } };
@@ -65,6 +66,13 @@ export default defineOperation("enrolAgenticWorker", ({ req, body }, app) => {
65
66
  body: { error: "`capability.weight` must be a finite number when provided" },
66
67
  };
67
68
  }
69
+ // The durable-resume enrolment attribute (issue #325, ADR 0062 Slice 5/5) — a boolean the harness
70
+ // advertises. A directly-invoked delegate bypasses the OpenAPI runtime validation, so guard the type
71
+ // here (a non-boolean would corrupt the {0,1} enrolment flag the world-restore gate reads).
72
+ if (body.durableResume !== undefined && typeof body.durableResume !== "boolean") {
73
+ app.log.warn("enrolAgenticWorker rejected: non-boolean durableResume");
74
+ return { status: 400, body: { error: "`durableResume` must be a boolean when provided" } };
75
+ }
68
76
 
69
77
  // Fold a top-level `host` into the capability when the capability didn't carry its own — a worker
70
78
  // may declare its host either on the capability or beside it (ADR 0059 `{ capability, host }`).
@@ -74,6 +82,26 @@ export default defineOperation("enrolAgenticWorker", ({ req, body }, app) => {
74
82
  : body.capability;
75
83
 
76
84
  const resolved = resolveEnrolment(capability);
85
+
86
+ // Durable-resume enrolment gate (issue #325, ADR 0062 Slice 5/5): record whether this worker's
87
+ // harness advertises durable-resume so the world-restore marker is emitted only to a fleet with a
88
+ // participant. Recorded per instance (ADR 0056 §7 — an enrolment attribute, never a routing token),
89
+ // so it needs a non-blank `instance`; a declaration without one — or with a blank/whitespace
90
+ // string — is echoed but not persisted (a blank key would let unrelated workers collide on the
91
+ // same registry row and wrongly open/close the fleet-wide durable-resume gate). Omission of the
92
+ // field on a re-enrol persists an explicit `false` (degrade to scratch), so a harness that previously
93
+ // advertised durable-resume and later re-enrols without the field clears its stale `true` rather than
94
+ // leaving `fleetSupportsDurableResume()` true indefinitely. Best-effort — the enrolment resolution
95
+ // must not fail on a registry write hiccup.
96
+ const instanceKey = body.instance?.trim();
97
+ if (app.data && instanceKey) {
98
+ try {
99
+ await new DurableResumeRegistry(app.data).recordEnrolment(instanceKey, body.durableResume ?? false);
100
+ } catch (err) {
101
+ app.log.warn("enrolAgenticWorker: durable-resume record failed", { instance: instanceKey, err: String(err) });
102
+ }
103
+ }
104
+
77
105
  const result: EnrolResult = {
78
106
  serve: [...resolved.serve],
79
107
  roles: resolved.roles.map((role) => {
@@ -85,6 +113,7 @@ export default defineOperation("enrolAgenticWorker", ({ req, body }, app) => {
85
113
  leaseTtl: resolved.leaseTtl,
86
114
  };
87
115
  if (body.instance !== undefined) result.instance = body.instance;
116
+ if (body.durableResume !== undefined) result.durableResume = body.durableResume;
88
117
 
89
118
  app.log.info("agentic enrol resolved", { instance: body.instance, serve: result.serve, family: capability.family });
90
119
  return { status: 200, body: result };
@@ -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.1",
3
+ "version": "0.104.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",