@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 +7 -0
- package/app/contracts.ts +8 -0
- package/app/durableResume.test.ts +89 -0
- package/app/durableResume.ts +141 -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/openapi.yaml +17 -0
- package/operations/enrolAgenticWorker.test.ts +64 -0
- package/operations/enrolAgenticWorker.ts +30 -1
- package/package.json +1 -1
- package/test/worldDb.ts +16 -4
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
|
+
});
|
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);
|
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.
|
|
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
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
+
}
|