@nanobpm/nano-workforce 0.103.0 → 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 CHANGED
@@ -1,3 +1,10 @@
1
+ # [0.104.0](https://github.com/nanobpm/nano-workforce/compare/v0.103.0...v0.104.0) (2026-08-19)
2
+
3
+
4
+ ### Features
5
+
6
+ * **durable-resume:** enrolment gate + re-lease world-restore wiring ([#325](https://github.com/nanobpm/nano-workforce/issues/325)) ([#351](https://github.com/nanobpm/nano-workforce/issues/351)) ([b495dfa](https://github.com/nanobpm/nano-workforce/commit/b495dfabfda3e9e0953c6637342b79ca0c8148cc))
7
+
1
8
  # [0.103.0](https://github.com/nanobpm/nano-workforce/compare/v0.102.1...v0.103.0) (2026-08-19)
2
9
 
3
10
 
package/app/contracts.ts CHANGED
@@ -402,6 +402,14 @@ export const TYPE_CONTRACTS = {
402
402
  "The mind/world checkpoint contract shape (issue #324, ADR 0062 Slice 4/5). `{ commitSha, effectLedger }` — the ONE type both the world marker (recorded in `world_checkpoints`/`world_effects`) and the mind checkpoint (Slice 1's `session.checkpoint`) derive from, so a single derivation feeds both halves and they cannot diverge. Its `effectLedger` is `Effect[]` (the fence-keyed irreversible-action ledger). The world half imports it from app/world; when Slice 1's harness-side `@nanobpm/agentic/session` lands it MUST reuse this shape, not re-declare a synonym.",
403
403
  module: "app/world/checkpoint.ts",
404
404
  },
405
+ DurableResumeRegistry: {
406
+ category: "type",
407
+ name: "DurableResumeRegistry",
408
+ owner: "app/durableResume.ts",
409
+ semantics:
410
+ "The `durable-resume` ENROLMENT GATE (issue #325, ADR 0062 Slice 5/5, the INTEGRATION slice). `durable-resume` is a worker attribute declared at enrolment (ADR 0056 §7 — capability gates enrolment, NEVER the routing token `network.role#seat`), recorded per worker instance in `worker_durable_resume` (migration 052). The enrol door (`operations/enrolAgenticWorker.ts`) records it via `recordEnrolment`; `app/service.ts` consults `fleetSupportsDurableResume` before emitting the world-restore `commitSha` (the `io.nanobpm.agentTask.repository` envelope) so a re-leased `senior:pr-review` round RESUMES only on a participating fleet and gracefully DEGRADES (redriven from scratch) otherwise. Consume this ONE module for the durable-resume gate — do not re-declare a synonym or read the flag off a second store.",
411
+ module: "app/durableResume.ts",
412
+ },
405
413
  } as const satisfies Record<string, TypeContract>;
406
414
 
407
415
  export const CAPABILITY_URL_CONTRACTS = {
@@ -0,0 +1,89 @@
1
+ // Tests for the `durable-resume` enrolment registry (issue #325, ADR 0062 Slice 5/5) against a REAL
2
+ // in-memory SQLite engine with migration 052 applied — so the upsert, the {0,1} flag domain, and the
3
+ // fleet-level participation probe are proven, not mocked.
4
+ import { test } from "node:test";
5
+ import { assert, assertEquals } from "#test-assert";
6
+ import { memDataFor } from "../test/worldDb.ts";
7
+ import { DurableResumeRegistry, DURABLE_RESUME_ATTR, fleetSupportsDurableResume } from "./durableResume.ts";
8
+
9
+ const mem = () => memDataFor(["052_worker_durable_resume.sql"]);
10
+
11
+ test("DURABLE_RESUME_ATTR is the canonical enrolment-attribute name", () => {
12
+ assertEquals(DURABLE_RESUME_ATTR, "durable-resume");
13
+ });
14
+
15
+ test("recordEnrolment persists a participant and isParticipant reads it back", async () => {
16
+ const { data } = mem();
17
+ const reg = new DurableResumeRegistry(data);
18
+ assertEquals(await reg.isParticipant("w1"), false, "unknown instance is a non-participant (safe default)");
19
+ await reg.recordEnrolment("w1", true);
20
+ assertEquals(await reg.isParticipant("w1"), true);
21
+ });
22
+
23
+ test("recordEnrolment records an explicit non-participant as false", async () => {
24
+ const { data } = mem();
25
+ const reg = new DurableResumeRegistry(data);
26
+ await reg.recordEnrolment("w1", false);
27
+ assertEquals(await reg.isParticipant("w1"), false);
28
+ assertEquals(await reg.anyParticipant(), false, "a recorded non-participant is not a participant");
29
+ });
30
+
31
+ test("recordEnrolment is an idempotent upsert — a re-enrol overwrites the flag", async () => {
32
+ const { data } = mem();
33
+ const reg = new DurableResumeRegistry(data);
34
+ await reg.recordEnrolment("w1", true);
35
+ assertEquals(await reg.isParticipant("w1"), true);
36
+ // A redeploy that drops durable-resume support flips the flag back — no duplicate row, no stale yes.
37
+ await reg.recordEnrolment("w1", false);
38
+ assertEquals(await reg.isParticipant("w1"), false);
39
+ await reg.recordEnrolment("w1", true);
40
+ assertEquals(await reg.isParticipant("w1"), true);
41
+ });
42
+
43
+ test("the registry normalises keys and ignores a blank/whitespace instance", async () => {
44
+ const { data } = mem();
45
+ const reg = new DurableResumeRegistry(data);
46
+ // A blank or whitespace-only key cannot key a reachable row and must never open the fleet gate.
47
+ await reg.recordEnrolment("", true);
48
+ await reg.recordEnrolment(" ", true);
49
+ assertEquals(await reg.anyParticipant(), false, "a blank/whitespace enrolment is ignored, gate stays closed");
50
+ assertEquals(await reg.isParticipant(" "), false, "a blank key is never a participant");
51
+ // A padded key is canonicalised (trimmed) so reads and writes agree on one row — no unreachable dup.
52
+ await reg.recordEnrolment(" w1 ", true);
53
+ assertEquals(await reg.isParticipant("w1"), true, "a padded write is readable by the trimmed key");
54
+ assertEquals(await reg.isParticipant(" w1 "), true, "a padded read normalises to the same row");
55
+ });
56
+
57
+ test("anyParticipant is the fleet-level existence probe over participants", async () => {
58
+ const { data } = mem();
59
+ const reg = new DurableResumeRegistry(data);
60
+ assertEquals(await reg.anyParticipant(), false, "no enrolment yet");
61
+ await reg.recordEnrolment("legacy-1", false);
62
+ await reg.recordEnrolment("legacy-2", false);
63
+ assertEquals(await reg.anyParticipant(), false, "a fleet of only non-participants does not support resume");
64
+ await reg.recordEnrolment("modern-1", true);
65
+ assertEquals(await reg.anyParticipant(), true, "one participant makes the mixed fleet resume-capable");
66
+ });
67
+
68
+ test("fleetSupportsDurableResume mirrors anyParticipant, and degrades to false without a data layer", async () => {
69
+ const { data } = mem();
70
+ assertEquals(await fleetSupportsDurableResume(undefined), false, "no data layer → additive-safe false");
71
+ assertEquals(await fleetSupportsDurableResume(data), false, "no participant enrolled");
72
+ await new DurableResumeRegistry(data).recordEnrolment("w1", true);
73
+ assertEquals(await fleetSupportsDurableResume(data), true);
74
+ });
75
+
76
+ test("fleetSupportsDurableResume degrades to false on a store read failure (legacy DB predating 052)", async () => {
77
+ // A DataLayer whose table has no `worker_durable_resume` — the read throws; the gate must degrade to
78
+ // false (redrive from scratch) rather than blocking a submit/merge on the enrolment registry.
79
+ const { data } = memDataFor([]);
80
+ assertEquals(await fleetSupportsDurableResume(data), false);
81
+ });
82
+
83
+ test("the flag domain is pinned to {0,1} — a participant reads as exactly true", async () => {
84
+ const { data, db } = mem();
85
+ await new DurableResumeRegistry(data).recordEnrolment("w1", true);
86
+ const rows = db.prepare("SELECT durable_resume FROM worker_durable_resume WHERE instance = ?").all("w1");
87
+ assertEquals(rows.length, 1);
88
+ assert(rows[0].durable_resume === 1, "true is stored as the integer 1");
89
+ });
@@ -0,0 +1,141 @@
1
+ // nano-workforce — the `durable-resume` ENROLMENT GATE (issue #325, ADR 0062 Slice 5/5).
2
+ //
3
+ // Durable agent-session resume splits into two halves already landed by the earlier slices: the MIND
4
+ // (the harness conversation — Slices 1–3, restored harness-side via `session/load` / native
5
+ // `--resume`) and the WORLD (the git working tree + irreversible effect ledger — Slice 4,
6
+ // `app/world`, restored by inverting the round's `git push` into `git fetch && git checkout <sha>`).
7
+ // This slice is the INTEGRATION that wires both halves into the running orchestration, behind an
8
+ // enrolment gate, so a re-leased `senior:pr-review` round (ADR 0002 lease-expiry redrive) RESUMES at
9
+ // the last push-checkpoint on a participating harness and gracefully DEGRADES — redriven from scratch,
10
+ // exactly as today — on a harness that does not advertise durable-resume.
11
+ //
12
+ // THE GATE (ADR 0056 §7). `durable-resume` is a WORKER ATTRIBUTE declared at ENROLMENT — never a
13
+ // routing token. The routing token `network.role#seat` is unchanged; there is no BPMN change and no
14
+ // job-type change. A worker's harness advertises durable-resume at enrol (the probe result from Slice
15
+ // 2/3); this registry records that per instance so the app can ask, before it emits the world-restore
16
+ // marker, "does the fleet serving this role include a participant?".
17
+ //
18
+ // WHY FLEET-LEVEL. At the moment the app emits the repo-provisioning envelope it does not yet know
19
+ // WHICH worker will lease the `senior:pr-review` job — any worker enrolled for that role may. So the
20
+ // gate is a fleet-level existence probe ({@link DurableResumeRegistry.anyParticipant}). This is
21
+ // well-defined for a MIXED fleet: emitting the world-restore `commitSha` when at least one participant
22
+ // is enrolled lets a participant RESUME, while a non-participant harness simply ignores the marker
23
+ // (the envelope validators are structurally forward-compatible) and clones the head branch tip —
24
+ // redriving from scratch. When NO participant is enrolled the marker is omitted entirely, so nothing
25
+ // regresses: resume is purely additive.
26
+ //
27
+ // Advisory, app-tier only (ADR 0056): this registry NEVER hard-locks or gates a BPMN sequence flow —
28
+ // it only decides whether an OPTIMISATION (world-restore) is offered inside an activation.
29
+ import type { DataLayer } from "@nanobpm/urban";
30
+
31
+ /**
32
+ * The canonical name of the durable-resume enrolment attribute. A worker advertises it at enrol; the
33
+ * registry records it here. It is an ENROLMENT gate (ADR 0056 §7), never a routing token — do not put
34
+ * it in `network.role#seat`.
35
+ */
36
+ export const DURABLE_RESUME_ATTR = "durable-resume";
37
+
38
+ /** A persisted enrolment row (`worker_durable_resume`): one worker instance's durable-resume flag. */
39
+ interface WorkerDurableResumeRow {
40
+ instance: string;
41
+ durable_resume: number;
42
+ updated_at: string;
43
+ }
44
+
45
+ /** True when `err` is the durable PRIMARY KEY fence firing — a SQLite `UNIQUE constraint failed`
46
+ * raised because a concurrent/duplicate enrol inserted the SAME instance BETWEEN our `findOne` and our
47
+ * `insert`. `recordEnrolment` is an upsert, so a collision means "the row now exists" — the same
48
+ * intended outcome as the update branch, not a surfaced error. Matched on the message substring the
49
+ * RAD `Table` surface propagates verbatim (mirrors `WorldStore`), because that surface hides the
50
+ * concrete driver error type. */
51
+ function isFenceCollision(err: unknown): boolean {
52
+ return err instanceof Error && /UNIQUE constraint failed/i.test(err.message);
53
+ }
54
+
55
+ /**
56
+ * The durable registry of per-worker durable-resume participation, over the `worker_durable_resume`
57
+ * table (`db/migrations/052_worker_durable_resume.sql`). Backed by the app's SQLite DataLayer through
58
+ * the RAD `Table<T>` surface (`data.table(...)`) — NOT hand-written SQL — mirroring `WorldStore`.
59
+ */
60
+ export class DurableResumeRegistry {
61
+ readonly #data: DataLayer;
62
+
63
+ constructor(data: DataLayer) {
64
+ this.#data = data;
65
+ }
66
+
67
+ #table() {
68
+ return this.#data.table<WorkerDurableResumeRow>("worker_durable_resume", "instance");
69
+ }
70
+
71
+ /** Canonicalise an instance key: trim surrounding whitespace and reject a blank one. The instance is
72
+ * the table PRIMARY KEY and drives the fleet-wide gate via {@link anyParticipant}, so a whitespace or
73
+ * differently-trimmed key would create an unreachable row — or, worse, open the gate on a blank key.
74
+ * Normalising here makes every registry entry point safe by default rather than relying on each call
75
+ * site to pre-trim. Returns the trimmed key, or `undefined` when it is empty/whitespace. */
76
+ static #normaliseInstance(instance: string): string | undefined {
77
+ const trimmed = instance.trim();
78
+ return trimmed.length > 0 ? trimmed : undefined;
79
+ }
80
+
81
+ /**
82
+ * Record a worker's durable-resume participation at enrolment (an idempotent UPSERT keyed by
83
+ * `instance`). A re-enrol overwrites the flag so a harness that gains — or loses — durable-resume
84
+ * support across a redeploy is reflected. The `findOne`-then-insert is racy under a concurrent
85
+ * duplicate enrol, so a PRIMARY KEY fence collision folds into the update path rather than surfacing
86
+ * as an error (the same end-state either way). A blank/whitespace `instance` is ignored (no-op) — it
87
+ * cannot key a reachable row and a blank key would let unrelated workers collide on one registry row.
88
+ */
89
+ async recordEnrolment(instance: string, durableResume: boolean): Promise<void> {
90
+ const key = DurableResumeRegistry.#normaliseInstance(instance);
91
+ if (key === undefined) return;
92
+ const table = this.#table();
93
+ const now = new Date().toISOString();
94
+ const flag = durableResume ? 1 : 0;
95
+ const existing = await table.findOne({ instance: key });
96
+ if (existing) {
97
+ await table.update(key, { durable_resume: flag, updated_at: now });
98
+ return;
99
+ }
100
+ try {
101
+ await table.insert({ instance: key, durable_resume: flag, updated_at: now });
102
+ } catch (err) {
103
+ if (!isFenceCollision(err)) throw err;
104
+ await table.update(key, { durable_resume: flag, updated_at: now });
105
+ }
106
+ }
107
+
108
+ /** Whether a specific worker instance is a durable-resume participant. `false` for an unknown
109
+ * instance (never enrolled) or a blank/whitespace key — the safe default (graceful degradation). */
110
+ async isParticipant(instance: string): Promise<boolean> {
111
+ const key = DurableResumeRegistry.#normaliseInstance(instance);
112
+ if (key === undefined) return false;
113
+ const row = await this.#table().findOne({ instance: key });
114
+ return row?.durable_resume === 1;
115
+ }
116
+
117
+ /** Whether the enrolled fleet includes AT LEAST ONE durable-resume participant — the fleet-level
118
+ * gate the world-restore emission consults. `false` when none is enrolled (nobody advertises
119
+ * durable-resume), so the resume marker is omitted and the round redrives from scratch. */
120
+ async anyParticipant(): Promise<boolean> {
121
+ const row = await this.#table().findOne({ durable_resume: 1 });
122
+ return row != null;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Whether the fleet supports durable resume — the app-tier gate for emitting the world-restore
128
+ * `commitSha` (see `app/service.ts`). Best-effort: any read failure (a legacy DB predating migration
129
+ * 052, an in-flight desync) degrades to `false`, so the round redrives from scratch rather than
130
+ * blocking a submit/merge on the enrolment registry. When no data layer is mounted it is likewise
131
+ * `false` — resume is purely additive, so its absence is always the safe direction.
132
+ */
133
+ export async function fleetSupportsDurableResume(data: DataLayer | undefined): Promise<boolean> {
134
+ if (!data) return false;
135
+ try {
136
+ return await new DurableResumeRegistry(data).anyParticipant();
137
+ } catch (err) {
138
+ console.warn(`[durable-resume] fleet participation read: ${err}`);
139
+ return false;
140
+ }
141
+ }
@@ -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
+ });
@@ -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 { parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr } from "./service.ts";
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. Absent (null) on a first submit,
551
- // which leaves the envelope unchanged.
552
- const worldSha = await lastPushedSha(data, parsed.prKey);
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
- const worldSha = await lastPushedSha(data, pr.prKey);
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);
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.
@@ -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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.103.0",
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",
package/test/worldDb.ts CHANGED
@@ -89,15 +89,27 @@ function openDataSource(db: DatabaseSync): MemDataSource {
89
89
  return ds;
90
90
  }
91
91
 
92
- /** A `DataLayer` stub over a fresh in-memory db with the world schema applied. */
93
- export function memWorldData(): { data: DataLayer; db: DatabaseSync } {
92
+ /** A `DataLayer` stub over a fresh in-memory db with the given migration files applied (in order).
93
+ * The generic factory behind {@link memWorldData} reused by tests that need a different durable
94
+ * table (e.g. `worker_durable_resume`, migration 052) without re-authoring the gateway/tx shim. */
95
+ export function memDataFor(migrationFiles: readonly string[]): { data: DataLayer; db: DatabaseSync } {
94
96
  const db = new DatabaseSync(":memory:");
95
97
  openDbs.add(db);
96
- const sql = readFileSync(fileURLToPath(new URL("../db/migrations/049_world_checkpoint.sql", import.meta.url)), "utf8");
97
- db.exec(sql);
98
+ // SQLite disables FK enforcement by default; enable it so migrations with foreign keys are
99
+ // exercised (and FK violations surface) exactly as the migration-specific tests do.
100
+ db.exec("PRAGMA foreign_keys = ON;");
101
+ for (const file of migrationFiles) {
102
+ const sql = readFileSync(fileURLToPath(new URL(`../db/migrations/${file}`, import.meta.url)), "utf8");
103
+ db.exec(sql);
104
+ }
98
105
  const data = {
99
106
  table: (name: string, pk = "id") => gateway(db, name, pk),
100
107
  open: () => openDataSource(db),
101
108
  } as unknown as DataLayer;
102
109
  return { data, db };
103
110
  }
111
+
112
+ /** A `DataLayer` stub over a fresh in-memory db with the world schema applied. */
113
+ export function memWorldData(): { data: DataLayer; db: DatabaseSync } {
114
+ return memDataFor(["049_world_checkpoint.sql"]);
115
+ }