@nanobpm/nano-workforce 0.51.0 → 0.52.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/agentic/families/presence.family.test.ts +341 -0
- package/app/agentic/families/presence.family.ts +279 -0
- package/db/migrations/023_agentic_presence.sql +30 -0
- package/docs/adr/0002-escalations-are-user-tasks-and-forms.md +167 -0
- package/docs/adr/0003-epic-base-branch-admission.md +123 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [0.52.0](https://github.com/nanobpm/nano-workforce/compare/v0.51.0...v0.52.0) (2026-08-13)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* presence + registry family over app.data (H1) ([#161](https://github.com/nanobpm/nano-workforce/issues/161)) ([6666ff1](https://github.com/nanobpm/nano-workforce/commit/6666ff148d02bc0f7ae9740511fdc62bbe9c2e70)), closes [#142](https://github.com/nanobpm/nano-workforce/issues/142) [#152](https://github.com/nanobpm/nano-workforce/issues/152) [#152](https://github.com/nanobpm/nano-workforce/issues/152) [#144](https://github.com/nanobpm/nano-workforce/issues/144)
|
|
7
|
+
|
|
1
8
|
# [0.51.0](https://github.com/nanobpm/nano-workforce/compare/v0.50.0...v0.51.0) (2026-08-13)
|
|
2
9
|
|
|
3
10
|
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
// Unit tests for the agentic presence & registry family (ADR 0056, H1 / #144).
|
|
2
|
+
//
|
|
3
|
+
// Two layers:
|
|
4
|
+
// 1. PresenceRegistry over an in-memory SQLite DataLayer — snapshot grouping, liveness, the
|
|
5
|
+
// jobKeys seam, the canonical supply rows, reconcile, and the register/heartbeat/deregister/
|
|
6
|
+
// TTL lifecycle.
|
|
7
|
+
// 2. The `family` module end-to-end against a REAL AgenticHub driven by an in-memory transport:
|
|
8
|
+
// a REGISTER frame creates a durable row; HEARTBEAT keeps it; DEREGISTER and disconnect remove
|
|
9
|
+
// it; teardown stops cleanly; a mount with no DataLayer is a safe no-op.
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { DatabaseSync } from "node:sqlite";
|
|
13
|
+
import { test } from "node:test";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { AgenticHub } from "@nanobpm/agentic/channel";
|
|
16
|
+
import type {
|
|
17
|
+
Authenticator,
|
|
18
|
+
ChannelConnection,
|
|
19
|
+
ChannelTransport,
|
|
20
|
+
} from "@nanobpm/agentic/channel";
|
|
21
|
+
import { encodeFrame, type Frame, type MessageFamily } from "@nanobpm/agentic/protocol";
|
|
22
|
+
import type { SqliteDb } from "@nanobpm/agentic/presence";
|
|
23
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
24
|
+
import { assert, assertEquals } from "#test-assert";
|
|
25
|
+
import { noopLog } from "../../../test/log.ts";
|
|
26
|
+
import type { AgenticContext } from "../registry.ts";
|
|
27
|
+
import {
|
|
28
|
+
createPresenceStore,
|
|
29
|
+
currentPresenceRegistry,
|
|
30
|
+
family,
|
|
31
|
+
openPresenceDb,
|
|
32
|
+
PresenceRegistry,
|
|
33
|
+
} from "./presence.family.ts";
|
|
34
|
+
|
|
35
|
+
// ── in-memory SQLite (the app's synchronous SqliteDb shape) ────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
function memSqlite(): SqliteDb {
|
|
38
|
+
const db = new DatabaseSync(":memory:");
|
|
39
|
+
return {
|
|
40
|
+
exec: (sql) => db.exec(sql),
|
|
41
|
+
run: (sql, params = []) => {
|
|
42
|
+
const r = db.prepare(sql).run(...(params as never[]));
|
|
43
|
+
return { changes: Number(r.changes), lastInsertRowid: Number(r.lastInsertRowid) };
|
|
44
|
+
},
|
|
45
|
+
all: <T = Record<string, unknown>>(sql: string, params: unknown[] = []) =>
|
|
46
|
+
db.prepare(sql).all(...(params as never[])) as T[],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A DataLayer whose default source exposes the given synchronous SqliteDb (nothing else is used). */
|
|
51
|
+
function memData(db: SqliteDb): DataLayer {
|
|
52
|
+
return { source: () => ({ db }) } as unknown as DataLayer;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A mutable fake clock so TTL sweeps are deterministic. */
|
|
56
|
+
function fakeClock(start = 1_000): { now(): number; advance(ms: number): void } {
|
|
57
|
+
let t = start;
|
|
58
|
+
return { now: () => t, advance: (ms) => { t += ms; } };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── PresenceRegistry over an in-memory DataLayer ───────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
test("snapshot: groups registered workers by leaf token with family/host", () => {
|
|
64
|
+
const store = createPresenceStore(memSqlite());
|
|
65
|
+
store.ensureSchema();
|
|
66
|
+
// Two workers under leaf token "leafA", one under "leafB".
|
|
67
|
+
store.register({ instance: "w2", connectionId: "c2", identity: "leafA", capability: { family: "kimi", host: "boxA2" } });
|
|
68
|
+
store.register({ instance: "w1", connectionId: "c1", identity: "leafA", capability: { family: "opus", host: "boxA1" } });
|
|
69
|
+
store.register({ instance: "w3", connectionId: "c3", identity: "leafB", capability: { family: "qwen", host: "boxB" } });
|
|
70
|
+
|
|
71
|
+
const registry = new PresenceRegistry(store, () => new Set(["c1", "c2", "c3"]));
|
|
72
|
+
const snap = registry.snapshot({ now: 2_000 });
|
|
73
|
+
|
|
74
|
+
assertEquals(snap.count, 3);
|
|
75
|
+
assertEquals(snap.leaves.map((l) => l.token), ["leafA", "leafB"], "leaves sorted by token");
|
|
76
|
+
const leafA = snap.leaves[0];
|
|
77
|
+
assertEquals(leafA.workers.map((w) => w.instance), ["w1", "w2"], "workers sorted by instance");
|
|
78
|
+
assertEquals(leafA.workers[0].family, "opus");
|
|
79
|
+
assertEquals(leafA.workers[0].host, "boxA1");
|
|
80
|
+
assertEquals(snap.leaves[1].workers[0].family, "qwen");
|
|
81
|
+
assertEquals(snap.workers.map((w) => w.instance), ["w1", "w2", "w3"], "flat list sorted");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("snapshot: liveness reflects the open-connection set", () => {
|
|
85
|
+
const store = createPresenceStore(memSqlite());
|
|
86
|
+
store.ensureSchema();
|
|
87
|
+
store.register({ instance: "live", connectionId: "cLive", identity: "leaf", capability: {} });
|
|
88
|
+
store.register({ instance: "gone", connectionId: "cGone", identity: "leaf", capability: {} });
|
|
89
|
+
|
|
90
|
+
const registry = new PresenceRegistry(store, () => new Set(["cLive"]));
|
|
91
|
+
const byInstance = new Map(registry.snapshot().workers.map((w) => [w.instance, w]));
|
|
92
|
+
assertEquals(byInstance.get("live")?.live, true);
|
|
93
|
+
assertEquals(byInstance.get("gone")?.live, false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("snapshot: staleMs is measured from lastSeen and jobKeysFor seeds current jobKeys", () => {
|
|
97
|
+
const clock = fakeClock(5_000);
|
|
98
|
+
const store = createPresenceStore(memSqlite(), { clock });
|
|
99
|
+
store.ensureSchema();
|
|
100
|
+
store.register({ instance: "w1", connectionId: "c1", identity: "leaf", capability: {} });
|
|
101
|
+
|
|
102
|
+
const registry = new PresenceRegistry(store, () => new Set(["c1"]));
|
|
103
|
+
const snap = registry.snapshot({
|
|
104
|
+
now: 5_250,
|
|
105
|
+
jobKeysFor: (instance) => (instance === "w1" ? ["job-42", "job-43"] : []),
|
|
106
|
+
});
|
|
107
|
+
assertEquals(snap.workers[0].staleMs, 250);
|
|
108
|
+
assertEquals(snap.workers[0].jobKeys, ["job-42", "job-43"]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("snapshot: jobKeys default to none (presence carries no job attribution)", () => {
|
|
112
|
+
const store = createPresenceStore(memSqlite());
|
|
113
|
+
store.ensureSchema();
|
|
114
|
+
store.register({ instance: "w1", connectionId: "c1", identity: "leaf", capability: {} });
|
|
115
|
+
const registry = new PresenceRegistry(store, () => new Set(["c1"]));
|
|
116
|
+
assertEquals(registry.snapshot().workers[0].jobKeys, []);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("registeredWorkers: returns the canonical {instance, capability} supply rows", () => {
|
|
120
|
+
const store = createPresenceStore(memSqlite());
|
|
121
|
+
store.ensureSchema();
|
|
122
|
+
store.register({ instance: "w1", connectionId: "c1", identity: "leaf", capability: { family: "opus", weight: 4.8, cognition: "deep" } });
|
|
123
|
+
const registry = new PresenceRegistry(store, () => new Set(["c1"]));
|
|
124
|
+
assertEquals(registry.registeredWorkers(), [
|
|
125
|
+
{ instance: "w1", capability: { cognition: "deep", weight: 4.8, family: "opus" } },
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("reconcile: removes rows whose connection the hub has closed, keeps live ones", () => {
|
|
130
|
+
const store = createPresenceStore(memSqlite());
|
|
131
|
+
store.ensureSchema();
|
|
132
|
+
store.register({ instance: "keep", connectionId: "cLive", identity: "leaf", capability: {} });
|
|
133
|
+
store.register({ instance: "drop", connectionId: "cGone", identity: "leaf", capability: {} });
|
|
134
|
+
|
|
135
|
+
const registry = new PresenceRegistry(store, () => new Set(["cLive"]));
|
|
136
|
+
const removed = registry.reconcile();
|
|
137
|
+
assertEquals(removed, ["drop"]);
|
|
138
|
+
assertEquals(registry.count(), 1);
|
|
139
|
+
assertEquals(registry.snapshot().workers.map((w) => w.instance), ["keep"]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("lifecycle: register creates, heartbeat keeps live, deregister removes, TTL sweep ages out", () => {
|
|
143
|
+
const clock = fakeClock(0);
|
|
144
|
+
const store = createPresenceStore(memSqlite(), { ttlMs: 1_000, clock });
|
|
145
|
+
store.ensureSchema();
|
|
146
|
+
const registry = new PresenceRegistry(store, () => new Set(["c1", "c2"]));
|
|
147
|
+
|
|
148
|
+
store.register({ instance: "w1", connectionId: "c1", identity: "leaf", capability: {} });
|
|
149
|
+
assertEquals(registry.count(), 1);
|
|
150
|
+
|
|
151
|
+
// A heartbeat just before the TTL keeps the worker alive across the sweep.
|
|
152
|
+
clock.advance(900);
|
|
153
|
+
assert(store.heartbeat("w1", "leaf"), "heartbeat refreshes a registered instance");
|
|
154
|
+
clock.advance(900);
|
|
155
|
+
assertEquals(store.sweep().length, 0, "not stale — heartbeat kept it live");
|
|
156
|
+
assertEquals(registry.count(), 1);
|
|
157
|
+
|
|
158
|
+
// Without a further heartbeat it ages out past the TTL.
|
|
159
|
+
clock.advance(1_500);
|
|
160
|
+
assertEquals(store.sweep().map((r) => r.instance), ["w1"]);
|
|
161
|
+
assertEquals(registry.count(), 0);
|
|
162
|
+
|
|
163
|
+
// A graceful deregister removes a re-registered instance immediately.
|
|
164
|
+
store.register({ instance: "w2", connectionId: "c2", identity: "leaf", capability: {} });
|
|
165
|
+
assert(store.deregister("w2", "leaf"));
|
|
166
|
+
assertEquals(registry.count(), 0);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("migration 023 provisions the exact table the store reads/writes", () => {
|
|
170
|
+
const db = memSqlite();
|
|
171
|
+
const sql = readFileSync(
|
|
172
|
+
join(fileURLToPath(new URL("../../../db/migrations/023_agentic_presence.sql", import.meta.url))),
|
|
173
|
+
"utf8",
|
|
174
|
+
);
|
|
175
|
+
db.exec(sql);
|
|
176
|
+
// A store that does NOT call ensureSchema still works against the migrated table.
|
|
177
|
+
const store = createPresenceStore(db);
|
|
178
|
+
store.register({ instance: "w1", connectionId: "c1", identity: "leaf", capability: { family: "opus", host: "box" } });
|
|
179
|
+
assertEquals(store.get("w1")?.capability.host, "box");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("openPresenceDb: returns undefined when no DataLayer is mounted", () => {
|
|
183
|
+
assertEquals(openPresenceDb(undefined), undefined);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ── family module against a real hub + in-memory transport ─────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
interface FakeConn {
|
|
189
|
+
readonly conn: ChannelConnection;
|
|
190
|
+
feed(frame: Frame): void;
|
|
191
|
+
disconnect(): void;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function fakeConn(id: string, identity: string): FakeConn {
|
|
195
|
+
let onMessage: ((bytes: Uint8Array) => void) | undefined;
|
|
196
|
+
let onClose: ((code?: number, reason?: string) => void) | undefined;
|
|
197
|
+
const conn: ChannelConnection = {
|
|
198
|
+
id,
|
|
199
|
+
handshake: { query: { identity }, token: "t", credential: "c" },
|
|
200
|
+
send: () => {},
|
|
201
|
+
close: (code, reason) => onClose?.(code, reason),
|
|
202
|
+
onMessage: (l) => { onMessage = l; },
|
|
203
|
+
onClose: (l) => { onClose = l; },
|
|
204
|
+
};
|
|
205
|
+
return {
|
|
206
|
+
conn,
|
|
207
|
+
feed: (frame) => onMessage?.(encodeFrame(frame)),
|
|
208
|
+
disconnect: () => onClose?.(),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function memTransport(): { transport: ChannelTransport; connect(conn: ChannelConnection): void } {
|
|
213
|
+
let onConnection: ((conn: ChannelConnection) => void) | undefined;
|
|
214
|
+
const transport: ChannelTransport = {
|
|
215
|
+
onConnection: (l) => { onConnection = l; },
|
|
216
|
+
address: { port: 0 },
|
|
217
|
+
close: async () => {},
|
|
218
|
+
};
|
|
219
|
+
return { transport, connect: (conn) => onConnection?.(conn) };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Authenticate every peer, deriving its identity (the leaf token) from the handshake query. */
|
|
223
|
+
const authenticator: Authenticator = (req) => ({
|
|
224
|
+
ok: true,
|
|
225
|
+
grant: { identity: req.query?.identity ?? "anon" },
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
/** Flush the hub's microtasks (async auth + async frame routing) so assertions see the result. */
|
|
229
|
+
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
|
230
|
+
|
|
231
|
+
function registerFrame(instance: string, capability: Record<string, unknown>): Frame {
|
|
232
|
+
return { lane: "control", family: "register", seq: 1, payload: { instance, capability } };
|
|
233
|
+
}
|
|
234
|
+
function familyFrame(fam: MessageFamily, instance: string): Frame {
|
|
235
|
+
return { lane: "control", family: fam, seq: 1, payload: { instance } };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function mountFamily(db: SqliteDb | undefined): Promise<{ hub: AgenticHub; transport: ReturnType<typeof memTransport> }> {
|
|
239
|
+
const transport = memTransport();
|
|
240
|
+
const hub = new AgenticHub({ transport: transport.transport, authenticator, sweepIntervalMs: 0 });
|
|
241
|
+
const ctx: AgenticContext = {
|
|
242
|
+
hub,
|
|
243
|
+
registry: hub.registry,
|
|
244
|
+
// The transport handle is not exercised by the presence family; the in-memory one stands in.
|
|
245
|
+
transport: transport.transport as never,
|
|
246
|
+
data: db ? memData(db) : undefined,
|
|
247
|
+
log: noopLog(),
|
|
248
|
+
};
|
|
249
|
+
await family.mount(ctx);
|
|
250
|
+
return { hub, transport };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
test("family: mount attaches the three handlers and a REGISTER creates a durable presence row", async () => {
|
|
254
|
+
const { hub, transport } = await mountFamily(memSqlite());
|
|
255
|
+
try {
|
|
256
|
+
assertEquals(hub.router.families().sort(), ["deregister", "heartbeat", "register"]);
|
|
257
|
+
|
|
258
|
+
const peer = fakeConn("c1", "leafA");
|
|
259
|
+
transport.connect(peer.conn);
|
|
260
|
+
await flush();
|
|
261
|
+
peer.feed(registerFrame("w1", { family: "opus", host: "boxA" }));
|
|
262
|
+
await flush();
|
|
263
|
+
|
|
264
|
+
const snap = currentPresenceRegistry()?.snapshot();
|
|
265
|
+
assert(snap, "registry is mounted");
|
|
266
|
+
assertEquals(snap.count, 1);
|
|
267
|
+
assertEquals(snap.leaves[0].token, "leafA");
|
|
268
|
+
assertEquals(snap.leaves[0].workers[0].family, "opus");
|
|
269
|
+
assertEquals(snap.leaves[0].workers[0].host, "boxA");
|
|
270
|
+
assertEquals(snap.leaves[0].workers[0].live, true, "connection is open");
|
|
271
|
+
} finally {
|
|
272
|
+
family.teardown?.();
|
|
273
|
+
await hub.close();
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("family: HEARTBEAT keeps a worker and DEREGISTER removes it", async () => {
|
|
278
|
+
const { hub, transport } = await mountFamily(memSqlite());
|
|
279
|
+
try {
|
|
280
|
+
const peer = fakeConn("c1", "leaf");
|
|
281
|
+
transport.connect(peer.conn);
|
|
282
|
+
await flush();
|
|
283
|
+
peer.feed(registerFrame("w1", {}));
|
|
284
|
+
await flush();
|
|
285
|
+
assertEquals(currentPresenceRegistry()?.count(), 1);
|
|
286
|
+
|
|
287
|
+
peer.feed(familyFrame("heartbeat", "w1"));
|
|
288
|
+
await flush();
|
|
289
|
+
assertEquals(currentPresenceRegistry()?.count(), 1, "heartbeat keeps the row");
|
|
290
|
+
|
|
291
|
+
peer.feed(familyFrame("deregister", "w1"));
|
|
292
|
+
await flush();
|
|
293
|
+
assertEquals(currentPresenceRegistry()?.count(), 0, "deregister removes the row");
|
|
294
|
+
} finally {
|
|
295
|
+
family.teardown?.();
|
|
296
|
+
await hub.close();
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("family: a disconnect removes the worker via reconcile", async () => {
|
|
301
|
+
const { hub, transport } = await mountFamily(memSqlite());
|
|
302
|
+
try {
|
|
303
|
+
const peer = fakeConn("c1", "leaf");
|
|
304
|
+
transport.connect(peer.conn);
|
|
305
|
+
await flush();
|
|
306
|
+
peer.feed(registerFrame("w1", {}));
|
|
307
|
+
await flush();
|
|
308
|
+
assertEquals(currentPresenceRegistry()?.count(), 1);
|
|
309
|
+
|
|
310
|
+
// Simulate the peer vanishing: the hub's own close listener drops it from the live registry.
|
|
311
|
+
peer.disconnect();
|
|
312
|
+
assertEquals(hub.connectionCount, 0, "hub no longer tracks the connection");
|
|
313
|
+
|
|
314
|
+
const removed = currentPresenceRegistry()?.reconcile();
|
|
315
|
+
assertEquals(removed, ["w1"]);
|
|
316
|
+
assertEquals(currentPresenceRegistry()?.count(), 0);
|
|
317
|
+
} finally {
|
|
318
|
+
family.teardown?.();
|
|
319
|
+
await hub.close();
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("family: teardown stops the family and clears the current registry", async () => {
|
|
324
|
+
const { hub } = await mountFamily(memSqlite());
|
|
325
|
+
assert(currentPresenceRegistry(), "mounted");
|
|
326
|
+
family.teardown?.();
|
|
327
|
+
assertEquals(currentPresenceRegistry(), undefined, "cleared on teardown");
|
|
328
|
+
await hub.close();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("family: mounting without a DataLayer is a safe no-op", async () => {
|
|
332
|
+
const { hub } = await mountFamily(undefined);
|
|
333
|
+
try {
|
|
334
|
+
assertEquals(currentPresenceRegistry(), undefined, "no registry without data");
|
|
335
|
+
// The three presence handlers are not attached when there is nothing to persist to.
|
|
336
|
+
assertEquals(hub.router.families(), []);
|
|
337
|
+
} finally {
|
|
338
|
+
family.teardown?.();
|
|
339
|
+
await hub.close();
|
|
340
|
+
}
|
|
341
|
+
});
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// nano-workforce — the agentic presence & registry family (ADR 0056, H1 / #144).
|
|
2
|
+
//
|
|
3
|
+
// A pluggable {@link AgenticFamily} that plugs into the H0 seam (`app/agentic/registry.ts`) with NO
|
|
4
|
+
// edit to `main.ts`, `drainAndExit`, or any shared boot line — the loader discovers this file by its
|
|
5
|
+
// `*.family.ts` suffix and the seam mounts it. It owns the channel's `register` / `heartbeat` /
|
|
6
|
+
// `deregister` message families (attached through the hub's `registerFamilyHandler` seam, never a
|
|
7
|
+
// shared dispatch switch) and layers a DURABLE supply registry over the app's SQLite DataLayer — the
|
|
8
|
+
// same store the advisory blackboard uses; no separate database.
|
|
9
|
+
//
|
|
10
|
+
// What it gives the fleet:
|
|
11
|
+
// - REGISTER → a durable presence row (instance + declared capability + connection + liveness).
|
|
12
|
+
// - HEARTBEAT → refreshes the row's `last_seen` so a live worker stays visible.
|
|
13
|
+
// - DEREGISTER / disconnect / TTL timeout → removes the row (see the maintenance tick below).
|
|
14
|
+
// - {@link PresenceRegistry.snapshot} → the read-only SUPPLY mirror: connected workers grouped by
|
|
15
|
+
// leaf token, each with identity, family, host, liveness (and a seam for current jobKeys). This
|
|
16
|
+
// is the supply feed the enrolment epic (#152) reads and the cockpit (H5) renders.
|
|
17
|
+
//
|
|
18
|
+
// Invariants (ADR 0056): app-tier only, never the engine; the Camunda-8 job protocol (worker⇄engine)
|
|
19
|
+
// is untouched — presence rides the agentic channel only; ADVISORY — the registry is a read-only
|
|
20
|
+
// mirror and NEVER hard-locks or gates a BPMN sequence flow. Capability (cognition/weight/family/host)
|
|
21
|
+
// is an ENROLMENT attribute, never a routing token.
|
|
22
|
+
import {
|
|
23
|
+
attachPresenceFamily,
|
|
24
|
+
type PresenceFamilyHandle,
|
|
25
|
+
PresenceStore,
|
|
26
|
+
type PresenceStoreOptions,
|
|
27
|
+
type SqliteDb,
|
|
28
|
+
} from "@nanobpm/agentic/presence";
|
|
29
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
30
|
+
import type { AgenticContext, AgenticFamily } from "../registry.ts";
|
|
31
|
+
|
|
32
|
+
/** The message-family name this module owns (its three handlers are register/heartbeat/deregister). */
|
|
33
|
+
export const PRESENCE_FAMILY = "presence";
|
|
34
|
+
|
|
35
|
+
/** The maintenance tick runs at a third of the presence TTL — matching the hub/store sweep cadence. */
|
|
36
|
+
const SWEEP_DIVISOR = 3;
|
|
37
|
+
|
|
38
|
+
/** One worker in the supply mirror: a durable presence row projected for the cockpit/enrolment feed. */
|
|
39
|
+
export interface SupplyWorker {
|
|
40
|
+
/** The worker instance id (`register.instance`). */
|
|
41
|
+
readonly instance: string;
|
|
42
|
+
/** The authenticated ADR 0028 principal — the leaf token this worker registered under. */
|
|
43
|
+
readonly identity: string;
|
|
44
|
+
/** Declared cognition (enrolment attribute), if any. */
|
|
45
|
+
readonly cognition?: string;
|
|
46
|
+
/** Declared cognition weight (enrolment attribute), if any. */
|
|
47
|
+
readonly weight?: number;
|
|
48
|
+
/** Declared family (enrolment attribute) — the diversity-SLO seat filler, if any. */
|
|
49
|
+
readonly family?: string;
|
|
50
|
+
/** Declared host (enrolment attribute) — where the worker runs, if any. */
|
|
51
|
+
readonly host?: string;
|
|
52
|
+
/** The channel connection the worker last registered on. */
|
|
53
|
+
readonly connectionId: string;
|
|
54
|
+
/** When the worker first registered, ISO-8601. */
|
|
55
|
+
readonly registeredAt: string;
|
|
56
|
+
/** Last liveness refresh (register/heartbeat), epoch ms. */
|
|
57
|
+
readonly lastSeen: number;
|
|
58
|
+
/** Whether the worker's connection is still open in the hub's live connection registry. */
|
|
59
|
+
readonly live: boolean;
|
|
60
|
+
/** How long since the last liveness refresh, in ms (0 when fresh). */
|
|
61
|
+
readonly staleMs: number;
|
|
62
|
+
/**
|
|
63
|
+
* The jobKeys this worker is currently processing. Presence carries no job attribution of its own,
|
|
64
|
+
* so this is populated from the injected {@link SnapshotOptions.jobKeysFor} resolver — the seam the
|
|
65
|
+
* relay/correlation slice (H6) wires; it is `[]` until then.
|
|
66
|
+
*/
|
|
67
|
+
readonly jobKeys: readonly string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The supply for one leaf token: the workers registered under it. */
|
|
71
|
+
export interface SupplyLeaf {
|
|
72
|
+
/** The leaf token — the ADR 0028 identity principal. (Refined to SERVE tokens when vocab #152 lands.) */
|
|
73
|
+
readonly token: string;
|
|
74
|
+
/** The workers registered under this leaf token, sorted by instance. */
|
|
75
|
+
readonly workers: readonly SupplyWorker[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The read-only supply snapshot: the live registry grouped by leaf token, plus a flat worker list. */
|
|
79
|
+
export interface PresenceSnapshot {
|
|
80
|
+
/** Supply grouped by leaf token, sorted by token. */
|
|
81
|
+
readonly leaves: readonly SupplyLeaf[];
|
|
82
|
+
/** Every registered worker, flat, sorted by instance. */
|
|
83
|
+
readonly workers: readonly SupplyWorker[];
|
|
84
|
+
/** The number of registered workers. */
|
|
85
|
+
readonly count: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The canonical supply-row shape the enrolment epic (#152) resolves against the vocab. */
|
|
89
|
+
export interface RegisteredWorker {
|
|
90
|
+
readonly instance: string;
|
|
91
|
+
readonly capability: {
|
|
92
|
+
readonly cognition?: string;
|
|
93
|
+
readonly weight?: number;
|
|
94
|
+
readonly family?: string;
|
|
95
|
+
readonly host?: string;
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Options for {@link PresenceRegistry.snapshot}. */
|
|
100
|
+
export interface SnapshotOptions {
|
|
101
|
+
/** "Now" in epoch ms for the `staleMs` computation. Defaults to `Date.now()`. */
|
|
102
|
+
readonly now?: number;
|
|
103
|
+
/** Resolve the current jobKeys for a worker instance. Defaults to none (presence has no jobs). */
|
|
104
|
+
readonly jobKeysFor?: (instance: string) => readonly string[];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The durable presence registry: a read-only projection over the {@link PresenceStore}, cross-checked
|
|
109
|
+
* against the set of currently-open hub connections for liveness. It NEVER gates control flow — it is
|
|
110
|
+
* the supply mirror the enrolment epic and the cockpit read.
|
|
111
|
+
*/
|
|
112
|
+
export class PresenceRegistry {
|
|
113
|
+
readonly #store: PresenceStore;
|
|
114
|
+
readonly #liveConnectionIds: () => Set<string>;
|
|
115
|
+
|
|
116
|
+
constructor(store: PresenceStore, liveConnectionIds: () => Set<string>) {
|
|
117
|
+
this.#store = store;
|
|
118
|
+
this.#liveConnectionIds = liveConnectionIds;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The presence liveness TTL in ms. */
|
|
122
|
+
get ttlMs(): number {
|
|
123
|
+
return this.#store.ttlMs;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The number of registered workers. */
|
|
127
|
+
count(): number {
|
|
128
|
+
return this.#store.count();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The canonical supply rows (`{ instance, capability }`) the enrolment epic (#152) consumes. */
|
|
132
|
+
registeredWorkers(): RegisteredWorker[] {
|
|
133
|
+
return this.#store.list().map((row) => ({ instance: row.instance, capability: { ...row.capability } }));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Eagerly drop presence rows whose connection the hub has already closed (a disconnect the hub's
|
|
138
|
+
* single close listener removed from its in-memory registry). Rows also age out on the presence
|
|
139
|
+
* TTL via {@link PresenceStore.sweep}; this is the eager disconnect path. Returns the removed
|
|
140
|
+
* instance ids.
|
|
141
|
+
*/
|
|
142
|
+
reconcile(): string[] {
|
|
143
|
+
const live = this.#liveConnectionIds();
|
|
144
|
+
const deadConnections = new Set<string>();
|
|
145
|
+
for (const row of this.#store.list()) {
|
|
146
|
+
if (!live.has(row.connectionId)) deadConnections.add(row.connectionId);
|
|
147
|
+
}
|
|
148
|
+
const removed: string[] = [];
|
|
149
|
+
for (const connectionId of deadConnections) {
|
|
150
|
+
removed.push(...this.#store.removeByConnection(connectionId));
|
|
151
|
+
}
|
|
152
|
+
return removed;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The read-only supply snapshot: connected workers grouped by leaf token, with family/host/liveness. */
|
|
156
|
+
snapshot(options: SnapshotOptions = {}): PresenceSnapshot {
|
|
157
|
+
const now = options.now ?? Date.now();
|
|
158
|
+
const jobKeysFor = options.jobKeysFor ?? (() => []);
|
|
159
|
+
const live = this.#liveConnectionIds();
|
|
160
|
+
const workers: SupplyWorker[] = this.#store.list().map((row) => ({
|
|
161
|
+
instance: row.instance,
|
|
162
|
+
identity: row.identity,
|
|
163
|
+
cognition: row.capability.cognition,
|
|
164
|
+
weight: row.capability.weight,
|
|
165
|
+
family: row.capability.family,
|
|
166
|
+
host: row.capability.host,
|
|
167
|
+
connectionId: row.connectionId,
|
|
168
|
+
registeredAt: row.registeredAt,
|
|
169
|
+
lastSeen: row.lastSeen,
|
|
170
|
+
live: live.has(row.connectionId),
|
|
171
|
+
staleMs: Math.max(0, now - row.lastSeen),
|
|
172
|
+
jobKeys: [...jobKeysFor(row.instance)],
|
|
173
|
+
}));
|
|
174
|
+
|
|
175
|
+
const byToken = new Map<string, SupplyWorker[]>();
|
|
176
|
+
for (const worker of workers) {
|
|
177
|
+
const bucket = byToken.get(worker.identity);
|
|
178
|
+
if (bucket) bucket.push(worker);
|
|
179
|
+
else byToken.set(worker.identity, [worker]);
|
|
180
|
+
}
|
|
181
|
+
const byInstance = (a: SupplyWorker, b: SupplyWorker) => a.instance.localeCompare(b.instance);
|
|
182
|
+
const leaves: SupplyLeaf[] = [...byToken.entries()]
|
|
183
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
184
|
+
.map(([token, ws]) => ({ token, workers: ws.slice().sort(byInstance) }));
|
|
185
|
+
|
|
186
|
+
return { leaves, workers: workers.slice().sort(byInstance), count: workers.length };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Open the app's synchronous SQLite handle from the DataLayer, or undefined when data isn't mounted. */
|
|
191
|
+
export function openPresenceDb(data: DataLayer | undefined): SqliteDb | undefined {
|
|
192
|
+
if (!data) return undefined;
|
|
193
|
+
return data.source().db;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The live registry from the most recent mount, so the cockpit/report (H5) can read the supply feed. */
|
|
197
|
+
let currentRegistry: PresenceRegistry | undefined;
|
|
198
|
+
|
|
199
|
+
/** The mounted presence registry (the supply feed), or undefined before mount / after teardown. */
|
|
200
|
+
export function currentPresenceRegistry(): PresenceRegistry | undefined {
|
|
201
|
+
return currentRegistry;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
interface MountState {
|
|
205
|
+
readonly registry: PresenceRegistry;
|
|
206
|
+
readonly handle: PresenceFamilyHandle;
|
|
207
|
+
readonly timer: ReturnType<typeof setInterval> | undefined;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let state: MountState | undefined;
|
|
211
|
+
|
|
212
|
+
/** Build the presence store; exported so tests can inject a fake clock / TTL over an in-memory db. */
|
|
213
|
+
export function createPresenceStore(db: SqliteDb, options?: PresenceStoreOptions): PresenceStore {
|
|
214
|
+
return new PresenceStore(db, options);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The presence family module. `mount` attaches register/heartbeat/deregister to the hub, applies the
|
|
219
|
+
* schema, and starts ONE canonical maintenance tick that both ages out on the presence TTL and drops
|
|
220
|
+
* rows for disconnected connections. `teardown` stops the tick and the presence sweep.
|
|
221
|
+
*/
|
|
222
|
+
export const family: AgenticFamily = {
|
|
223
|
+
name: PRESENCE_FAMILY,
|
|
224
|
+
|
|
225
|
+
mount(ctx: AgenticContext): void {
|
|
226
|
+
const db = openPresenceDb(ctx.data);
|
|
227
|
+
if (!db) {
|
|
228
|
+
ctx.log.warn("agentic presence: no data layer mounted — presence registry disabled");
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const store = createPresenceStore(db);
|
|
232
|
+
store.ensureSchema();
|
|
233
|
+
|
|
234
|
+
const liveConnectionIds = () => new Set(ctx.hub.registry.list().map((conn) => conn.id));
|
|
235
|
+
const registry = new PresenceRegistry(store, liveConnectionIds);
|
|
236
|
+
|
|
237
|
+
// Attach the three presence handlers via the S1 seam. Disable the package's own TTL timer
|
|
238
|
+
// (`sweepIntervalMs: 0`) so this module runs a SINGLE maintenance loop rather than two — the
|
|
239
|
+
// canonical presence-maintenance pass, not a second poller (derivation over duplication).
|
|
240
|
+
const handle = attachPresenceFamily(ctx.hub, store, {
|
|
241
|
+
sweepIntervalMs: 0,
|
|
242
|
+
onError: (err, connectionId) =>
|
|
243
|
+
ctx.log.warn("agentic presence fault", { connectionId, err: String(err) }),
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const interval = Math.max(1, Math.floor(store.ttlMs / SWEEP_DIVISOR));
|
|
247
|
+
const tick = () => {
|
|
248
|
+
// TTL age-out (silent-worker liveness timeout) + eager disconnect cleanup, on one cadence.
|
|
249
|
+
handle.sweepNow();
|
|
250
|
+
try {
|
|
251
|
+
registry.reconcile();
|
|
252
|
+
} catch (err) {
|
|
253
|
+
ctx.log.warn("agentic presence reconcile failed", { err: String(err) });
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
// Run one maintenance pass eagerly at mount so the registry is correct immediately: the
|
|
257
|
+
// presence table is durable across restarts, so without this first sweep/reconcile
|
|
258
|
+
// `registeredWorkers()` / `snapshot()` could briefly surface stale rows from a previous run
|
|
259
|
+
// (all connections start closed) until the first interval tick fires.
|
|
260
|
+
tick();
|
|
261
|
+
const timer = interval > 0 ? setInterval(tick, interval) : undefined;
|
|
262
|
+
// Never keep the process alive for the presence sweep alone.
|
|
263
|
+
timer?.unref?.();
|
|
264
|
+
|
|
265
|
+
state = { registry, handle, timer };
|
|
266
|
+
currentRegistry = registry;
|
|
267
|
+
ctx.log.info("agentic presence mounted", { family: PRESENCE_FAMILY, ttlMs: store.ttlMs });
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
teardown(): void {
|
|
271
|
+
if (!state) return;
|
|
272
|
+
if (state.timer !== undefined) clearInterval(state.timer);
|
|
273
|
+
state.handle.stop();
|
|
274
|
+
state = undefined;
|
|
275
|
+
currentRegistry = undefined;
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
export default family;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
-- Agentic visibility plane — presence & registry (ADR 0056, H1 / #144).
|
|
2
|
+
--
|
|
3
|
+
-- The durable supply mirror behind the agentic channel's presence family. A worker that opens the
|
|
4
|
+
-- channel and sends `register` lands one row here (keyed by its instance id) carrying its declared
|
|
5
|
+
-- enrolment capability (cognition/weight/family/host — an ENROLMENT attribute, NEVER a routing
|
|
6
|
+
-- token), the connection it registered on, and its own heartbeat-refreshed `last_seen` liveness.
|
|
7
|
+
-- Heartbeats refresh `last_seen`; `deregister`, an observed disconnect, or the presence-TTL sweep
|
|
8
|
+
-- remove the row. This is the read-only supply feed the enrolment epic (#152) reads — it is advisory
|
|
9
|
+
-- and NEVER gates a BPMN sequence flow.
|
|
10
|
+
--
|
|
11
|
+
-- The very same DDL is the single source of truth the runtime's `PresenceStore` applies through
|
|
12
|
+
-- `ensureSchema()` (@nanobpm/agentic/presence, `PRESENCE_SCHEMA_SQL`). Keeping the two application
|
|
13
|
+
-- paths (this boot migration and the store's guard) statement-for-statement identical is what stops
|
|
14
|
+
-- a production/boot schema drift. Forward-only and additive: `CREATE ... IF NOT EXISTS` only.
|
|
15
|
+
--
|
|
16
|
+
-- This is the reserved prefix H0 pre-allocated for H1 (023) so parallel wave-1 siblings never
|
|
17
|
+
-- independently grab "the next" migration number (H3 → 024_agentic_transcript, H4 → 025_agentic_blackboard).
|
|
18
|
+
CREATE TABLE IF NOT EXISTS agentic_presence (
|
|
19
|
+
instance TEXT PRIMARY KEY,
|
|
20
|
+
connection_id TEXT NOT NULL,
|
|
21
|
+
identity TEXT NOT NULL,
|
|
22
|
+
cognition TEXT,
|
|
23
|
+
weight REAL,
|
|
24
|
+
family TEXT,
|
|
25
|
+
host TEXT,
|
|
26
|
+
registered_at TEXT NOT NULL,
|
|
27
|
+
last_seen INTEGER NOT NULL
|
|
28
|
+
);
|
|
29
|
+
CREATE INDEX IF NOT EXISTS idx_agentic_presence_last_seen ON agentic_presence (last_seen);
|
|
30
|
+
CREATE INDEX IF NOT EXISTS idx_agentic_presence_connection ON agentic_presence (connection_id);
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# ADR 0002 — Escalations are user tasks + forms
|
|
2
|
+
|
|
3
|
+
Status: **Proposed.**
|
|
4
|
+
Date: 2026-08-13.
|
|
5
|
+
|
|
6
|
+
> **Scope note.** This is a **nano-workforce-local** ADR — it governs how *this app's* agent workforce
|
|
7
|
+
> models human (and agent) decision points. Platform-wide ADRs live in `Magikcraft/nano-bpm/docs/adr`
|
|
8
|
+
> (referenced by number + repo, e.g. "nano-bpm ADR 0026"). nano-workforce's own series continues here
|
|
9
|
+
> after ADR 0001.
|
|
10
|
+
|
|
11
|
+
Relates to:
|
|
12
|
+
nano-bpm **ADR 0026** (Urban human surfaces + run model — the `taskInbox` surface this ADR builds on:
|
|
13
|
+
a hosted task list backed by the engine's user-task search that renders a linked `.form` and posts
|
|
14
|
+
completion),
|
|
15
|
+
nano-bpm **ADR 0037** (execution + task listeners — the user-task lifecycle hooks this ADR leans on),
|
|
16
|
+
nano-bpm **ADR 0046** (agent-as-worker vs agent-in-the-node — the duality that lets an **agent** be a
|
|
17
|
+
task assignee, answering the same form a human would),
|
|
18
|
+
nano-bpm **ADR 0051** (nano-workforce — the crew orchestrator whose escalations this reshapes),
|
|
19
|
+
nano-bpm **ADR 0056** (the Nano agentic protocol — this ADR is the **durable** human-in-the-loop lane,
|
|
20
|
+
complementary to that ADR's **ephemeral** live-steering cockpit),
|
|
21
|
+
nano-workforce **ADR 0001** (this repo's ADR series),
|
|
22
|
+
and the current bespoke escalation subsystem in this repo: `app/plan.ts` (`plan_escalations`,
|
|
23
|
+
`plan_review_escalations`, `answerTaskEscalation`, `answerPlanEscalation`, `refreshOpenTaskEscalation`),
|
|
24
|
+
`app/service.ts` (the `open_escalation_*` pointer on `pull_requests`), the `pr.persist-*-escalation`
|
|
25
|
+
service workers, and the `feature-escalation-answered` / `plan-escalation-answered` resume messages in
|
|
26
|
+
`resources/processes/plan-fanout.bpmn`.
|
|
27
|
+
|
|
28
|
+
## Context
|
|
29
|
+
|
|
30
|
+
When a fanned-out agent task cannot proceed on its own — an open question, a trial-merge conflict, a
|
|
31
|
+
plan-review budget cap, a stuck PR-review loop — nano-workforce **escalates**: it parks the process and
|
|
32
|
+
waits for a human decision. Today that is a hand-rolled subsystem, and the same shape recurs three times:
|
|
33
|
+
|
|
34
|
+
1. **Task escalation** (`plan_escalations`, issue #25) — a fanned-out task's open question. In
|
|
35
|
+
`plan-fanout.bpmn`: an `exclusiveGateway` (`escalated?`) routes to a **service task**
|
|
36
|
+
`persist-task-escalation` (`pr.persist-task-escalation`) which writes the row + a denormalised
|
|
37
|
+
`open_task_escalation_id` pointer on the plan, then an **intermediate message-catch**
|
|
38
|
+
`wait-feature-answer` parks on `feature-escalation-answered` (correlationKey `=escalationCorrKey`).
|
|
39
|
+
2. **Plan-review escalation** (`plan_review_escalations`) — a plan-review cap; a human returns a
|
|
40
|
+
`proceed | revise` directive. Same persist-service-task → message-catch shape
|
|
41
|
+
(`plan-escalation-answered`); the table is **append-only** and the review **epoch** is derived from
|
|
42
|
+
the count of answered rows.
|
|
43
|
+
3. **PR review-loop escalation** (`open_escalation_*` columns on `pull_requests`, #597/#599) — a review
|
|
44
|
+
convergence that will not settle; surfaced via denormalised columns on the PR row.
|
|
45
|
+
|
|
46
|
+
Answering, in every case, means: an app worker records the answer, **mirrors** it onto the task/PR row,
|
|
47
|
+
**publishes the resume message**, and **re-surfaces** the next open escalation by rewriting a denormalised
|
|
48
|
+
"oldest open" pointer. The "form" is a bespoke Urban page that fires when a pointer is set and prints the
|
|
49
|
+
free-text `question`; the answer is a free-text string.
|
|
50
|
+
|
|
51
|
+
This is a **user task + form, re-implemented by hand** — and the bug tail proves it. Every incident is a
|
|
52
|
+
denormalised-pointer or free-text-contract failure: stale rows resurfacing a *dead* form after a re-plan
|
|
53
|
+
(`refreshOpenTaskEscalation`), the "addressed-escalation paradox," `blank question fabricates an
|
|
54
|
+
answerable escalation` (a hack to avoid an incident on an empty question), and per-run one-by-one row
|
|
55
|
+
cleanup. None of these can occur under a single-source-of-truth user-task lifecycle.
|
|
56
|
+
|
|
57
|
+
Crucially, **the primitives already exist**:
|
|
58
|
+
|
|
59
|
+
- The engine has **native user tasks** — `UserTaskProps`, `Command::CompleteUserTask` / `UpdateUserTask`,
|
|
60
|
+
task listeners (ADR 0037), and `zeebe:assignmentDefinition` / priority / schedule parsed off the
|
|
61
|
+
`userTask` element (`engine-core/src/bpmn.rs`, `model.rs`).
|
|
62
|
+
- Urban ships the **`taskInbox` surface** (ADR 0026): `GET /tasks` (list), `GET /tasks/api/tasks`
|
|
63
|
+
(`engine.searchUserTasks`), `POST /tasks/api/complete` (`engine.completeUserTask(key, variables)`),
|
|
64
|
+
rendering the linked **`.form`**. It is manifest-enabled (`surfaces.taskInbox`) and unused by nwf today.
|
|
65
|
+
- Forms are `.form` assets; the Urban **form editor** (the "Delphi" authoring surface) is the tool that
|
|
66
|
+
authors them. This ADR is that editor's **first real internal customer**.
|
|
67
|
+
|
|
68
|
+
## Decision
|
|
69
|
+
|
|
70
|
+
**Model every decision-required escalation in nano-workforce as a native BPMN `userTask` with a linked
|
|
71
|
+
`.form`, surfaced through Urban's `taskInbox`, completed with typed variables that resume the process.**
|
|
72
|
+
Retire the bespoke `persist-escalation` service task → message-catch → resume-publish → denormalised-pointer
|
|
73
|
+
machinery.
|
|
74
|
+
|
|
75
|
+
### 1. A tiered taxonomy — not everything is a task
|
|
76
|
+
|
|
77
|
+
The current code conflates three tiers; draw the line explicitly at each raise site:
|
|
78
|
+
|
|
79
|
+
| Tier | Example | Mechanism |
|
|
80
|
+
| --- | --- | --- |
|
|
81
|
+
| **Transient** | empty-status backstop, re-request a review, a retriable step | stays **in-process** (retry / default arm) — **no task** |
|
|
82
|
+
| **Advisory** | a hint, a note for the next agent | the **blackboard** (`app/blackboard.ts`) — never gates a flow |
|
|
83
|
+
| **Decision-required** | proceed/revise, answer an open question, resolve a conflict, abandon | **user task + form** |
|
|
84
|
+
|
|
85
|
+
Only the third tier becomes a user task. This retires the "fabricate a blank answerable escalation" hack:
|
|
86
|
+
an empty question is a *non-escalation*, not a task.
|
|
87
|
+
|
|
88
|
+
### 2. `serviceTask(persist) + message-catch(wait)` → one `userTask`
|
|
89
|
+
|
|
90
|
+
Each `persist-*-escalation` service task and its paired intermediate message-catch collapse into a single
|
|
91
|
+
`userTask` bearing a `zeebe:formDefinition` (linked `.form`) and a `zeebe:assignmentDefinition`. The engine
|
|
92
|
+
owns the wait, the correlation, and the work-item state — so `escalationCorrKey`, the
|
|
93
|
+
`feature-escalation-answered` / `plan-escalation-answered` messages, and the `pr.persist-*-escalation`
|
|
94
|
+
workers are deleted. Completing the task carries typed variables straight back into the process.
|
|
95
|
+
|
|
96
|
+
### 3. Forms are the typed escalation contract
|
|
97
|
+
|
|
98
|
+
Each escalation kind gets a `.form` whose schema *is* its interface — replacing free-text question/answer:
|
|
99
|
+
|
|
100
|
+
- **Task escalation** → `{ resolution: "answer" | "abandon", answer?: string }`.
|
|
101
|
+
- **Plan-review escalation** → `{ directive: "proceed" | "revise", notes?: string }` — deleting the
|
|
102
|
+
hand-rolled `parsePlanEscalationDirective`; the enum + required-field validation live in the form/FEEL.
|
|
103
|
+
- **Trial-merge escalation** → `{ action: "proceed" | "rebase" | "abandon", notes?: string }`.
|
|
104
|
+
- **PR review-loop escalation** → `{ answer: string }` (or a kind-specific action enum).
|
|
105
|
+
|
|
106
|
+
### 4. One queryable task list replaces three denormalised pointers
|
|
107
|
+
|
|
108
|
+
`open_task_escalation_id`, `open_plan_escalation_id`, and the `open_escalation_*` columns on
|
|
109
|
+
`pull_requests` all collapse into `engine.searchUserTasks(...)` — filterable by assignee, candidate group,
|
|
110
|
+
process instance, element, age. There is **no "surfaced" field to go stale**, so the resurface / dead-form
|
|
111
|
+
bug class is eliminated at the root. The plans page and any inbox read the live task search; the
|
|
112
|
+
`inbox_entries` seed is the natural home for the cross-plan view.
|
|
113
|
+
|
|
114
|
+
### 5. The assignee may be a human **or** an agent
|
|
115
|
+
|
|
116
|
+
`zeebe:assignmentDefinition` routes a task to a specific human, a **candidate group** (e.g. the operator /
|
|
117
|
+
crew leads), or — per ADR 0046 — an **agent**. An LLM worker can complete the *same* form a human would,
|
|
118
|
+
via the `chat`/agent surface or a job-worker-style completer. This makes "auto-resolve with a
|
|
119
|
+
slower/smarter model, else route to a human" a single lifecycle with one contract — something the bespoke
|
|
120
|
+
subsystem cannot express. Agent-answered completion is still a first-class, audited task completion.
|
|
121
|
+
|
|
122
|
+
### 6. SLA via a timer boundary
|
|
123
|
+
|
|
124
|
+
A user task carries a due date; a **timer boundary event** provides escalation-of-the-escalation —
|
|
125
|
+
reassign, notify, or auto-proceed on a default — the durable replacement for the review poller's ad-hoc
|
|
126
|
+
nudge. A decision no longer hangs forever with no deadline.
|
|
127
|
+
|
|
128
|
+
### 7. Audit trail from user-task history
|
|
129
|
+
|
|
130
|
+
`plan_review_escalations` is append-only because the **review epoch** = count of answered plan-review
|
|
131
|
+
escalations. Under this ADR the epoch is derived from **completed plan-review user tasks** (native user-task
|
|
132
|
+
history / completion events), so the dedicated audit table is retired without losing the audit.
|
|
133
|
+
|
|
134
|
+
## Consequences
|
|
135
|
+
|
|
136
|
+
- **A whole bug class disappears.** No denormalised "surfaced" pointer ⇒ no stale/dead-form resurfacing, no
|
|
137
|
+
addressed-escalation paradox, no blank-question fabrication. The engine's single-source-of-truth
|
|
138
|
+
user-task lifecycle replaces three hand-maintained mirrors.
|
|
139
|
+
- **Less code.** Delete `pr.persist-*-escalation` workers, the two resume messages + their catch events,
|
|
140
|
+
`escalationCorrKey`, `answerTaskEscalation`/`answerPlanEscalation`/`refreshOpenTaskEscalation`, the
|
|
141
|
+
denormalised columns, and the bespoke answer page — replaced by `userTask` nodes + `.form`s + the
|
|
142
|
+
existing `taskInbox` surface.
|
|
143
|
+
- **Dogfoods the Delphi vision.** nwf becomes the first real consumer of the Urban form editor + user-task
|
|
144
|
+
inbox, exercising forms end to end on a live app.
|
|
145
|
+
- **The third human-in-the-loop lane.** Enrolment (#152) = what work exists; visibility (#142) =
|
|
146
|
+
watch/nudge a *live* agent (ephemeral); **escalation-as-user-task** = decide *durably* when blocked. The
|
|
147
|
+
cockpit can list a worker's open escalation tasks; the two planes reinforce each other.
|
|
148
|
+
- **Migration is a real refactor, not a rename.** The bespoke tables encode edge cases (epoch-from-count,
|
|
149
|
+
re-plan cleanup, trial-merge "proceed" override). The migration must preserve those semantics on the new
|
|
150
|
+
substrate and run behind tests, phased kind-by-kind.
|
|
151
|
+
- **New dependency on engine user-task depth.** Assignment, candidate groups, task listeners, and timer
|
|
152
|
+
boundaries on user tasks must be exercised (some may surface gaps to file against the engine). Form
|
|
153
|
+
rendering richness is bounded by the `taskInbox`/form-editor state of the art.
|
|
154
|
+
|
|
155
|
+
## Open questions
|
|
156
|
+
|
|
157
|
+
- **Form-rendering fidelity.** The current `taskInbox` page is minimal (lists key/element). How rich a
|
|
158
|
+
`.form` render is needed before the answer page can be deleted — and is that the form editor's job or a
|
|
159
|
+
`taskInbox` upgrade (an nano-ide concern)?
|
|
160
|
+
- **Agent-answer policy (§5).** When may an agent auto-complete vs must-route-to-human — a per-kind policy,
|
|
161
|
+
a confidence gate, or an operator toggle? How is an agent completion attributed and reversible?
|
|
162
|
+
- **Assignment model.** Candidate group vs named assignee for each kind; where the operator's routing
|
|
163
|
+
preference is persisted (manifest vs app state).
|
|
164
|
+
- **Cross-plan inbox surface.** Does nwf embed `taskInbox` directly, or render its own plan-aware inbox
|
|
165
|
+
page over `searchUserTasks` (matching the existing plans page), keyed through `inbox_entries`?
|
|
166
|
+
- **Back-compat window.** Do in-flight escalations at migration time drain on the old path, or are they
|
|
167
|
+
re-issued as user tasks? (Prefer drain-old, issue-new, per kind.)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# ADR 0003 — Epic base-branch admission: explicit, auto-created, and guarded
|
|
2
|
+
|
|
3
|
+
Status: **Proposed.**
|
|
4
|
+
Date: 2026-08-13.
|
|
5
|
+
|
|
6
|
+
> **Scope note.** A **nano-workforce-local** ADR — it governs how *this app* admits an epic for
|
|
7
|
+
> execution. Platform-wide ADRs live in `Magikcraft/nano-bpm/docs/adr` (referenced by number + repo).
|
|
8
|
+
> Continues nano-workforce's series after ADR 0001 (ADR 0002 is planned but not yet written; see below).
|
|
9
|
+
|
|
10
|
+
Relates to:
|
|
11
|
+
nano-workforce **ADR 0001** (cross-repo epics + integration branches — this ADR hardens *how* an epic's
|
|
12
|
+
integration branch is chosen, created, and protected),
|
|
13
|
+
nano-workforce **ADR 0002** *(planned — not yet written in this repo; escalations as user tasks)*: the
|
|
14
|
+
confirm / shared-base gates are human decisions that *could* later surface as user tasks; see Open questions,
|
|
15
|
+
nano-bpm **ADR 0058** (the OpenAPI endpoint surface — `startPlanFanout` is a spec operation whose request
|
|
16
|
+
schema this ADR changes),
|
|
17
|
+
migration `019_plan_base_branch.sql` (the `plans.base_branch` column, whose example is `epic/agent-protocol`),
|
|
18
|
+
and in this repo: `operations/startPlanFanout.ts` (the launch operation), `app/plan.ts`
|
|
19
|
+
(`startPlan`, `normalizeBaseBranch`, `renderBaseBranchBrief`, `PLAN_TERMINAL_STATUSES`), `app/baseGuard.ts`
|
|
20
|
+
+ `app/github.ts` (`baseBranchLanded`, `fetchDefaultBranch`), and `resources/processes/plan-fanout.bpmn`.
|
|
21
|
+
|
|
22
|
+
## Context
|
|
23
|
+
|
|
24
|
+
An epic is launched through the `startPlanFanout` operation, which calls `startPlan(data, engine, parsed,
|
|
25
|
+
baseBranch)`. The `base_branch` (migration 019) tells every fanned-out task agent to branch off it and open
|
|
26
|
+
its PR against it (`renderBaseBranchBrief`), landing the whole epic on a long-lived integration branch that
|
|
27
|
+
reaches the default branch — and any merge-to-default side effect such as auto-publishing — only when the
|
|
28
|
+
integration branch is deliberately merged.
|
|
29
|
+
|
|
30
|
+
Four gaps make this a footgun surface:
|
|
31
|
+
|
|
32
|
+
1. **Implicit default.** `normalizeBaseBranch` maps a blank/absent value to `null`, silently meaning "target
|
|
33
|
+
the repository default branch." An operator who *meant* to name an integration branch but omitted it
|
|
34
|
+
lands every task straight onto the default branch (e.g. `main`) with **no integration buffer** — and
|
|
35
|
+
any merge-to-default side effect fires per task.
|
|
36
|
+
2. **No branch creation.** Nothing creates the integration branch. `baseGuard`/`baseBranchLanded` only
|
|
37
|
+
*read* (`gh pr list`, `fetchPrBase`, `fetchDefaultBranch`). So if the named branch doesn't exist, the
|
|
38
|
+
**first task's** `git fetch origin <branch>` / `gh pr create --base <branch>` fails — a late, per-task
|
|
39
|
+
failure instead of a clean admission error.
|
|
40
|
+
3. **No typo guard.** A mistyped branch name is indistinguishable from an intended new one; without
|
|
41
|
+
creation it fails late, and *with* naive creation it would silently spawn a wrong-rooted branch.
|
|
42
|
+
4. **No collision guard.** Two in-flight epics can target the **same** integration branch, interleaving
|
|
43
|
+
commits and poisoning each other's base — with no warning.
|
|
44
|
+
|
|
45
|
+
The pieces to fix this already exist: `fetchDefaultBranch` (default-branch identity), `PLAN_TERMINAL_STATUSES`
|
|
46
|
+
(`done|failed|abandoned` → the complement is "active"), a strict `isPlausibleBranchName` allowlist, and the
|
|
47
|
+
`019` example convention `epic/*`.
|
|
48
|
+
|
|
49
|
+
## Decision
|
|
50
|
+
|
|
51
|
+
**Every epic launch must state its base branch explicitly, and `startPlanFanout` admits it through one
|
|
52
|
+
fail-fast gate** — `admitPlan(...)` — run before any task fans out, backed by a durable `ensure-base-branch`
|
|
53
|
+
head step in `plan-fanout.bpmn`. The gate has four ordered rules:
|
|
54
|
+
|
|
55
|
+
### 1. Required + explicit — no implicit default
|
|
56
|
+
|
|
57
|
+
`baseBranch` becomes a **required** field of `StartPlanFanoutRequest`. `normalizeBaseBranch` **rejects** a
|
|
58
|
+
blank/absent value (`MissingBaseBranchError` → HTTP 400) instead of returning `null`. "Land on the default
|
|
59
|
+
branch" is now a **conscious, named, confirmed** choice (rule 3), never a silent fallback. The
|
|
60
|
+
`base_branch == null ? default : brief` fork in the launch/prompt path is removed; `renderBaseBranchBrief`
|
|
61
|
+
is always rendered. (`plans.base_branch` stays nullable in the DB **only** to grandfather pre-migration
|
|
62
|
+
rows; new launches always set it.)
|
|
63
|
+
|
|
64
|
+
### 2. Create-if-missing, idempotently — with an `epic/*` guard
|
|
65
|
+
|
|
66
|
+
`ensureBaseBranch(repo, branch, token)`:
|
|
67
|
+
- **Exists** → no-op (never reset — a reset would nuke in-flight task PRs stacked on it; a stacked epic's
|
|
68
|
+
base already exists and is left alone).
|
|
69
|
+
- **Missing and matches `epic/*`** → create `refs/heads/<branch>` off the **default branch HEAD**.
|
|
70
|
+
- **Missing and *not* `epic/*`** → **reject** (`BaseBranchMustExistError`): a non-`epic/*` branch must
|
|
71
|
+
already exist, so a typo can't silently spawn a wrong-rooted branch. (`epic/*` is the `019` convention.)
|
|
72
|
+
|
|
73
|
+
Runs at admission (fail fast) **and** as a head `ensure-base-branch` service task in `plan-fanout.bpmn` so
|
|
74
|
+
it is durable + retriable even on a re-plan.
|
|
75
|
+
|
|
76
|
+
### 3. Confirm-default — naming the default branch is deliberate
|
|
77
|
+
|
|
78
|
+
If the explicit target **equals the repository default** (via `fetchDefaultBranch`), admission requires an
|
|
79
|
+
explicit `confirmDefaultBase: true`, else **reject** (`DefaultBaseNotConfirmedError` → 400) with a message
|
|
80
|
+
spelling out the consequence ("every task lands directly on `<default>` with no integration branch; any
|
|
81
|
+
merge-to-default side effect fires per task"). This is the single guardrail on the one dangerous explicit
|
|
82
|
+
value.
|
|
83
|
+
|
|
84
|
+
### 4. Shared-base guard — one integration branch, one epic
|
|
85
|
+
|
|
86
|
+
If another plan whose `status ∉ PLAN_TERMINAL_STATUSES` (i.e. **active**) targets the **same repo + same
|
|
87
|
+
base branch**, admission **rejects** (`SharedBaseError` → 409) unless `allowSharedBase: true`. **Exempt: the
|
|
88
|
+
default branch** — many epics target the default concurrently and don't collide (each task PR is
|
|
89
|
+
independent). The guard fires only for a **shared custom integration branch**, the genuinely dangerous case.
|
|
90
|
+
|
|
91
|
+
### Recommended defaults (confirm before build)
|
|
92
|
+
|
|
93
|
+
- **Hard-require flags**, not soft warnings, for rules 3 and 4: a warning in a headless submit flow is
|
|
94
|
+
ignorable; a required `confirmDefaultBase` / `allowSharedBase` is a "warn you can't skip."
|
|
95
|
+
- **`epic/*` auto-create guard** (rule 2): auto-create only `epic/*`; any other non-existent name is a
|
|
96
|
+
400. Matches the `019` convention.
|
|
97
|
+
|
|
98
|
+
## Consequences
|
|
99
|
+
|
|
100
|
+
- **A footgun class disappears:** no silent land-on-main, no first-task-fails-on-missing-branch, no typo'd
|
|
101
|
+
wrong-rooted branch, no two-epics-one-branch interleave — all become clean admission errors.
|
|
102
|
+
- **Breaking API + launch-path change.** `StartPlanFanoutRequest.baseBranch` is now required; any
|
|
103
|
+
fire-and-forget caller/CLI/epic template that omitted it will (intentionally) 400. The OpenAPI spec +
|
|
104
|
+
generated types (`nano-generated/api-io.d.ts`, controller) regenerate; the submit surface + docs update.
|
|
105
|
+
- **Back-compat:** pre-migration / in-flight `base_branch = null` plans are **grandfathered** (the column
|
|
106
|
+
stays nullable; the required-ness is enforced at *admission* of new launches, not by a DB `NOT NULL`).
|
|
107
|
+
- **New write permission exercised:** ref creation (already held by the token that pushes task branches).
|
|
108
|
+
- **Ties into ADR 0001/0002:** this is the admission half of the integration-branch story (0001), and its
|
|
109
|
+
human gates could later be modeled as user tasks (planned ADR 0002) rather than flags — see Open questions.
|
|
110
|
+
|
|
111
|
+
## Open questions
|
|
112
|
+
|
|
113
|
+
- **Gates as user tasks (planned ADR 0002)?** Should `confirmDefaultBase` / shared-base become an inbox **user
|
|
114
|
+
task** ("Epic X wants to target `main` / share `epic/y` — approve?") instead of a submit-time flag? For
|
|
115
|
+
*pre-fan-out* admission a synchronous flag is simpler and fail-fast; a user task fits only if we want a
|
|
116
|
+
human in the launch loop. Deferred.
|
|
117
|
+
- **Auto-create root.** Always off default HEAD — correct for a fresh integration branch. Should a stacked
|
|
118
|
+
epic be able to declare `stackOn: <branch>` so its base is auto-created off *another* epic's branch
|
|
119
|
+
rather than default? (Today: that base must pre-exist.)
|
|
120
|
+
- **Convention scope.** Is `epic/*` the only auto-createable prefix, or should the app config own the
|
|
121
|
+
allowlist?
|
|
122
|
+
- **Grandfathered nulls.** Leave historical `null` rows as-is, or backfill them to the (then-current)
|
|
123
|
+
default branch for a uniform read model?
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.52.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",
|