@nanobpm/nano-workforce 0.51.0 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/app/agentic/families/presence.family.test.ts +341 -0
- package/app/agentic/families/presence.family.ts +279 -0
- package/app/agentic/families/relay.family.test.ts +402 -0
- package/app/agentic/families/relay.family.ts +331 -0
- package/db/migrations/023_agentic_presence.sql +30 -0
- package/db/migrations/024_agentic_transcript.sql +43 -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
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// nano-workforce — the relay ring + transcript store agentic family (ADR 0056, H3 / #146).
|
|
2
|
+
//
|
|
3
|
+
// This is a sibling slice of the agentic-visibility epic (#142). It plugs into the H0 (#143)
|
|
4
|
+
// family-registration SEAM (`../registry.ts`) as ONE NEW FILE and NOTHING ELSE — it never edits
|
|
5
|
+
// `main.ts`, `drainAndExit`, or any shared boot line. The auto-discovery loader (`../loader.ts`)
|
|
6
|
+
// finds it by the `*.family.ts` suffix and the seam mounts + tears it down.
|
|
7
|
+
//
|
|
8
|
+
// It composes two published primitives — it re-implements NEITHER:
|
|
9
|
+
// - `@nanobpm/agentic/relay` — the bounded replay ring, three-lane QoS scheduler
|
|
10
|
+
// (control > interactive > bulk), resume-from-offset, and incarnation/generation fencing,
|
|
11
|
+
// all inside {@link RelayHub}. We mount it on the hub's `registerFamilyHandler` seam.
|
|
12
|
+
// - `@nanobpm/agentic/transcript` — {@link TranscriptStore}, retention-by-lifecycle over the app's
|
|
13
|
+
// SQLite DataLayer. Ephemeral streams flush the ring to a durable transcript on job completion;
|
|
14
|
+
// long-lived streams retain chunks so a reconnecting consumer resumes-from-offset (reattach).
|
|
15
|
+
//
|
|
16
|
+
// Its DB schema ships as the reserved forward-only additive migration
|
|
17
|
+
// `db/migrations/024_agentic_transcript.sql` (H0 pre-allocated prefix 024 for H3), a byte-for-byte
|
|
18
|
+
// mirror of the package's canonical `TRANSCRIPT_SCHEMA_SQL`, drift-guarded by this slice's test.
|
|
19
|
+
//
|
|
20
|
+
// Invariants (ADR 0056): app-tier only, never the engine; the Camunda-8 job protocol (worker⇄engine)
|
|
21
|
+
// is untouched — the agentic channel is the only new conversation; advisory semantics preserved (the
|
|
22
|
+
// relay/transcript never hard-lock or gate a BPMN sequence flow).
|
|
23
|
+
import type { ConnectionRegistry } from "@nanobpm/agentic/channel";
|
|
24
|
+
import type { Frame } from "@nanobpm/agentic/protocol";
|
|
25
|
+
import { RELAY_FAMILY, RelayHub, type RelayHubOptions } from "@nanobpm/agentic/relay";
|
|
26
|
+
import {
|
|
27
|
+
type SqliteDb,
|
|
28
|
+
type TranscriptLifecycle,
|
|
29
|
+
type TranscriptRing,
|
|
30
|
+
type TranscriptSlice,
|
|
31
|
+
TranscriptStore,
|
|
32
|
+
type TranscriptStoreOptions,
|
|
33
|
+
type TranscriptStream,
|
|
34
|
+
} from "@nanobpm/agentic/transcript";
|
|
35
|
+
import type { Logger } from "@nanobpm/urban";
|
|
36
|
+
import type { AgenticContext, AgenticFamily } from "../registry.ts";
|
|
37
|
+
|
|
38
|
+
/** The stable family name this slice registers under the seam (distinct from the wire family key). */
|
|
39
|
+
export const RELAY_FAMILY_NAME = "relay";
|
|
40
|
+
|
|
41
|
+
/** Read a property off an unknown value without an unsafe `as` cast (mirrors the loader's helper). */
|
|
42
|
+
function readProp(value: unknown, key: string): unknown {
|
|
43
|
+
if (!value || typeof value !== "object") return undefined;
|
|
44
|
+
return Object.hasOwn(value, key) ? Object.getOwnPropertyDescriptor(value, key)?.value : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A resume-from-offset source with no retained chunks — used to flush/complete a stream that a
|
|
49
|
+
* producer opened logically but never wrote to, so its transcript is still stamped `completed`
|
|
50
|
+
* rather than left dangling `open`.
|
|
51
|
+
*/
|
|
52
|
+
const EMPTY_SOURCE: TranscriptRing = { since: () => ({ entries: [] }), nextOffset: 0 };
|
|
53
|
+
|
|
54
|
+
/** Per-stream bookkeeping the service keeps to drive lifecycle-aware persistence. */
|
|
55
|
+
interface StreamState {
|
|
56
|
+
/** Retention lifecycle: `ephemeral` (flush+complete on job end) vs `long-lived` (reattach). */
|
|
57
|
+
lifecycle: TranscriptLifecycle;
|
|
58
|
+
/** The connection id of the most recent producer, for disconnect-driven completion. */
|
|
59
|
+
producer?: string;
|
|
60
|
+
/** Set once an ephemeral stream has been flushed & completed (so it is not re-completed). */
|
|
61
|
+
completed: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface RelayTranscriptServiceOptions {
|
|
65
|
+
/** The app-tier hub; the service claims the `relay` family key via its registration seam. */
|
|
66
|
+
readonly hub: {
|
|
67
|
+
registerFamilyHandler(family: string, handler: (frame: Frame, conn: RelayConnectionCtx) => void): void;
|
|
68
|
+
};
|
|
69
|
+
/** The shared connection registry — its `has(id)` is the liveness source of truth. */
|
|
70
|
+
readonly registry: ConnectionRegistry;
|
|
71
|
+
/** The raw SQLite handle (the app DataLayer's `source().db`); absent → transcripts disabled. */
|
|
72
|
+
readonly db: SqliteDb | undefined;
|
|
73
|
+
/** A structured logger for lifecycle lines. */
|
|
74
|
+
readonly log: Logger;
|
|
75
|
+
/** Options forwarded to the underlying {@link RelayHub} (ring/bulk capacity, default credit). */
|
|
76
|
+
readonly relay?: RelayHubOptions;
|
|
77
|
+
/** Options forwarded to the {@link TranscriptStore} (retention window, injectable clock). */
|
|
78
|
+
readonly transcript?: TranscriptStoreOptions;
|
|
79
|
+
/**
|
|
80
|
+
* Apply the transcript DDL from the store on mount (idempotent `CREATE ... IF NOT EXISTS`).
|
|
81
|
+
* Default `true`: the boot migration is the canonical path, but this makes the service usable
|
|
82
|
+
* against a bare source too (and is harmless when the migration already ran).
|
|
83
|
+
*/
|
|
84
|
+
readonly ensureSchema?: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The minimal per-connection surface the relay handler receives from the hub (a {@link RelayHub} `RelayConnection`). */
|
|
88
|
+
interface RelayConnectionCtx {
|
|
89
|
+
readonly id: string;
|
|
90
|
+
readonly registry: { has(id: string): boolean };
|
|
91
|
+
send(frame: Frame): void;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Composes the S5 relay ({@link RelayHub}) with the S6 transcript store ({@link TranscriptStore})
|
|
96
|
+
* and wires retention-by-lifecycle. It owns NO ring/scheduler/store logic of its own — it observes
|
|
97
|
+
* `produce` ownership so an ephemeral stream is flushed & completed when its producer disconnects,
|
|
98
|
+
* and exposes the completion/checkpoint/reattach/sweep surface H6 (#149) and the app drive.
|
|
99
|
+
*/
|
|
100
|
+
export class RelayTranscriptService {
|
|
101
|
+
/** The mounted relay hub (ring + QoS scheduler + incarnation fence). Exposed for inspection/tests. */
|
|
102
|
+
readonly relay: RelayHub;
|
|
103
|
+
/** The transcript store, or `undefined` when no DataLayer is mounted (relay still works, unpersisted). */
|
|
104
|
+
readonly store: TranscriptStore | undefined;
|
|
105
|
+
|
|
106
|
+
readonly #registry: ConnectionRegistry;
|
|
107
|
+
readonly #log: Logger;
|
|
108
|
+
readonly #streams = new Map<string, StreamState>();
|
|
109
|
+
|
|
110
|
+
constructor(options: RelayTranscriptServiceOptions) {
|
|
111
|
+
this.#registry = options.registry;
|
|
112
|
+
this.#log = options.log;
|
|
113
|
+
// Persistence is advisory: a store that can't be constructed or whose schema can't be applied
|
|
114
|
+
// (locked/permission-denied/unavailable SQLite) must NOT fail the family mount — fall back to
|
|
115
|
+
// running the relay unpersisted rather than tearing down the whole agentic channel.
|
|
116
|
+
let store: TranscriptStore | undefined;
|
|
117
|
+
if (options.db) {
|
|
118
|
+
try {
|
|
119
|
+
store = new TranscriptStore(options.db, options.transcript);
|
|
120
|
+
if (options.ensureSchema !== false) store.ensureSchema();
|
|
121
|
+
} catch (err) {
|
|
122
|
+
this.#log.warn("agentic transcript store unavailable — relay runs unpersisted", {
|
|
123
|
+
err: String(err),
|
|
124
|
+
});
|
|
125
|
+
store = undefined;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
this.store = store;
|
|
129
|
+
|
|
130
|
+
this.relay = new RelayHub({
|
|
131
|
+
...options.relay,
|
|
132
|
+
onFenced: (stream, incarnation, current) => {
|
|
133
|
+
this.#log.warn("agentic relay fenced a stale producer", { stream, incarnation, current });
|
|
134
|
+
options.relay?.onFenced?.(stream, incarnation, current);
|
|
135
|
+
},
|
|
136
|
+
onError: (err, connectionId) => {
|
|
137
|
+
this.#log.warn("agentic relay message error", { connectionId, err: String(err) });
|
|
138
|
+
options.relay?.onError?.(err, connectionId);
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Register the `relay` family ourselves (rather than via `registerRelayFamily`) so we can observe
|
|
143
|
+
// `produce` ownership before delegating to the hub — the hub's own routing still derives purely
|
|
144
|
+
// from this single registration, and a second `relay` registration is rejected by the seam.
|
|
145
|
+
options.hub.registerFamilyHandler(RELAY_FAMILY, (frame, conn) => this.#onFrame(frame, conn));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The tracked stream names (those a producer opened or that were declared). */
|
|
149
|
+
streams(): string[] {
|
|
150
|
+
return [...this.#streams.keys()];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Declare a stream's retention lifecycle before (or independently of) its first `produce`. The
|
|
155
|
+
* durable store records lifecycle write-once (first call wins there); in memory the latest call
|
|
156
|
+
* wins until the stream completes, which is how {@link checkpointStream} upgrades an as-yet
|
|
157
|
+
* ephemeral stream to `long-lived`. No-op once the stream has already been completed.
|
|
158
|
+
*/
|
|
159
|
+
declareLifecycle(stream: string, lifecycle: TranscriptLifecycle): void {
|
|
160
|
+
const state = this.#stateFor(stream);
|
|
161
|
+
if (!state.completed) state.lifecycle = lifecycle;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Complete a stream on job end: flush the relay ring (its whole retained window) to the durable
|
|
166
|
+
* transcript under the stream's lifecycle. For an `ephemeral` stream this stamps `completed_at`
|
|
167
|
+
* (so {@link reattach} then serves the durable transcript and {@link sweep} may later retire it);
|
|
168
|
+
* for a `long-lived` stream it is a snapshot checkpoint that leaves the stream `open`. Idempotent:
|
|
169
|
+
* re-completing already-persisted offsets is a no-op. Returns the number of newly-persisted chunks.
|
|
170
|
+
*/
|
|
171
|
+
completeStream(stream: string): number {
|
|
172
|
+
if (!this.store) return 0;
|
|
173
|
+
const state = this.#stateFor(stream);
|
|
174
|
+
const source = this.relay.ring(stream) ?? EMPTY_SOURCE;
|
|
175
|
+
let flushed: number;
|
|
176
|
+
try {
|
|
177
|
+
flushed = this.store.flush(stream, source, state.lifecycle);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
// Persistence is advisory: a flush failure must not bubble into the hub's frame handler and
|
|
180
|
+
// take down unrelated streams. Log and leave the stream uncompleted so a later pass retries.
|
|
181
|
+
this.#log.warn("agentic relay stream flush failed — leaving stream uncompleted", {
|
|
182
|
+
stream,
|
|
183
|
+
lifecycle: state.lifecycle,
|
|
184
|
+
err: String(err),
|
|
185
|
+
});
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
188
|
+
if (state.lifecycle === "ephemeral") state.completed = true;
|
|
189
|
+
// Drop producer ownership so a later reconcile does not re-flush a completed stream.
|
|
190
|
+
state.producer = undefined;
|
|
191
|
+
this.#log.info("agentic relay stream flushed", {
|
|
192
|
+
stream,
|
|
193
|
+
lifecycle: state.lifecycle,
|
|
194
|
+
flushed,
|
|
195
|
+
completed: state.completed,
|
|
196
|
+
});
|
|
197
|
+
return flushed;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Snapshot a long-lived stream's ring into the durable transcript without completing it — the
|
|
202
|
+
* checkpoint path a growing stream uses so a reconnecting consumer can {@link reattach} past the
|
|
203
|
+
* ring's resume window. Returns the number of newly-persisted chunks.
|
|
204
|
+
*/
|
|
205
|
+
checkpointStream(stream: string): number {
|
|
206
|
+
if (!this.store) return 0;
|
|
207
|
+
this.declareLifecycle(stream, "long-lived");
|
|
208
|
+
const source = this.relay.ring(stream) ?? EMPTY_SOURCE;
|
|
209
|
+
try {
|
|
210
|
+
return this.store.flush(stream, source, "long-lived");
|
|
211
|
+
} catch (err) {
|
|
212
|
+
// Advisory: a checkpoint failure keeps the relay usable — return 0, the stream stays open.
|
|
213
|
+
this.#log.warn("agentic relay checkpoint flush failed", { stream, err: String(err) });
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Reattach a consumer from offset `from` (inclusive) against the durable transcript — mirrors the
|
|
220
|
+
* relay ring's `since` contract exactly, so a late/reconnecting consumer resumes identically
|
|
221
|
+
* whether from the live ring or the persisted transcript. Returns `undefined` with no store.
|
|
222
|
+
*/
|
|
223
|
+
reattach(stream: string, from: number): TranscriptSlice | undefined {
|
|
224
|
+
return this.store?.since(stream, from);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Retention sweep: retire completed-ephemeral transcripts past the retention window. */
|
|
228
|
+
sweep(now?: number): string[] {
|
|
229
|
+
const retired = this.store?.sweep(now) ?? [];
|
|
230
|
+
// Forget the in-memory state of every retired stream so `#streams` stays bounded (and
|
|
231
|
+
// `#reconcile`'s scan stays cheap) even after many ephemeral streams complete and age out.
|
|
232
|
+
for (const stream of retired) this.#streams.delete(stream);
|
|
233
|
+
return retired;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** A stream's persisted transcript metadata, if any (lifecycle/status/offset window). */
|
|
237
|
+
transcriptOf(stream: string): TranscriptStream | undefined {
|
|
238
|
+
return this.store?.get(stream);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Complete every still-open ephemeral stream (e.g. on shutdown) so no in-flight terminal is lost,
|
|
243
|
+
* then forget all tracking. Long-lived streams are left for reattach and are not force-completed.
|
|
244
|
+
*/
|
|
245
|
+
teardown(): void {
|
|
246
|
+
for (const [stream, state] of this.#streams) {
|
|
247
|
+
if (state.lifecycle === "ephemeral" && !state.completed) this.completeStream(stream);
|
|
248
|
+
}
|
|
249
|
+
this.#streams.clear();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Handle one inbound `relay` frame: reconcile dead producers, observe ownership, then delegate. */
|
|
253
|
+
#onFrame(frame: Frame, conn: RelayConnectionCtx): void {
|
|
254
|
+
this.#reconcile();
|
|
255
|
+
this.#observe(frame, conn);
|
|
256
|
+
this.relay.handle(frame, conn);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Record `produce` ownership so a producer disconnect can drive ephemeral completion. */
|
|
260
|
+
#observe(frame: Frame, conn: RelayConnectionCtx): void {
|
|
261
|
+
if (readProp(frame.payload, "op") !== "produce") return;
|
|
262
|
+
const stream = readProp(frame.payload, "stream");
|
|
263
|
+
if (typeof stream !== "string" || stream === "") return;
|
|
264
|
+
this.#stateFor(stream).producer = conn.id;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Flush + complete every ephemeral stream whose producer connection is no longer live (the S1
|
|
269
|
+
* registry dropped it on close or liveness timeout). Lazy, like the relay hub's own dead-subscriber
|
|
270
|
+
* prune: it runs on each inbound frame, and shutdown covers the quiescent tail via {@link teardown}.
|
|
271
|
+
*/
|
|
272
|
+
#reconcile(): void {
|
|
273
|
+
for (const [stream, state] of this.#streams) {
|
|
274
|
+
if (state.completed || state.lifecycle !== "ephemeral") continue;
|
|
275
|
+
if (state.producer !== undefined && !this.#registry.has(state.producer)) {
|
|
276
|
+
this.completeStream(stream);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
#stateFor(stream: string): StreamState {
|
|
282
|
+
let state = this.#streams.get(stream);
|
|
283
|
+
if (state === undefined) {
|
|
284
|
+
state = { lifecycle: "ephemeral", completed: false };
|
|
285
|
+
this.#streams.set(stream, state);
|
|
286
|
+
}
|
|
287
|
+
return state;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Build the H3 relay family. Copy-of-the-seam pattern: it constructs the {@link RelayTranscriptService}
|
|
293
|
+
* in `mount` (threading the seam's hub/registry/DataLayer/log) and tears it down in `teardown`. The
|
|
294
|
+
* created service is exposed to `onMounted` so a driver (H6 correlation, tests) can reach the
|
|
295
|
+
* completion/reattach surface without re-mounting anything.
|
|
296
|
+
*/
|
|
297
|
+
export function createRelayFamily(options: {
|
|
298
|
+
readonly relay?: RelayHubOptions;
|
|
299
|
+
readonly transcript?: TranscriptStoreOptions;
|
|
300
|
+
readonly ensureSchema?: boolean;
|
|
301
|
+
/** Called with the live service once mounted, so a driver can drive completion/reattach. */
|
|
302
|
+
readonly onMounted?: (service: RelayTranscriptService) => void;
|
|
303
|
+
} = {}): AgenticFamily {
|
|
304
|
+
let service: RelayTranscriptService | undefined;
|
|
305
|
+
return {
|
|
306
|
+
name: RELAY_FAMILY_NAME,
|
|
307
|
+
mount(ctx: AgenticContext): void {
|
|
308
|
+
service = new RelayTranscriptService({
|
|
309
|
+
hub: ctx.hub,
|
|
310
|
+
registry: ctx.registry,
|
|
311
|
+
// The app's SQLite handle: the same store the advisory blackboard uses. Absent → relay runs
|
|
312
|
+
// unpersisted (still advisory-correct), rather than failing the whole channel boot.
|
|
313
|
+
db: ctx.data ? ctx.data.source().db : undefined,
|
|
314
|
+
log: ctx.log,
|
|
315
|
+
relay: options.relay,
|
|
316
|
+
transcript: options.transcript,
|
|
317
|
+
ensureSchema: options.ensureSchema,
|
|
318
|
+
});
|
|
319
|
+
options.onMounted?.(service);
|
|
320
|
+
},
|
|
321
|
+
teardown(): void {
|
|
322
|
+
service?.teardown();
|
|
323
|
+
service = undefined;
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** The discovered family instance (the loader picks up this `family` export). */
|
|
329
|
+
export const family: AgenticFamily = createRelayFamily();
|
|
330
|
+
|
|
331
|
+
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,43 @@
|
|
|
1
|
+
-- Agentic visibility plane (ADR 0056, epic #142) — H3 relay transcript store (#146).
|
|
2
|
+
--
|
|
3
|
+
-- The relay family (app/agentic/families/relay.family.ts) mounts @nanobpm/agentic/relay (a bounded
|
|
4
|
+
-- replay ring + three-lane QoS scheduler + incarnation fence) on the app-tier agentic channel and
|
|
5
|
+
-- persists terminal transcripts through @nanobpm/agentic/transcript with retention-by-lifecycle:
|
|
6
|
+
--
|
|
7
|
+
-- • ephemeral streams → the relay ring is flushed to a durable transcript on job completion
|
|
8
|
+
-- (and the stream marked `completed`, then retired by a retention sweep);
|
|
9
|
+
-- • long-lived streams → chunks are retained/checkpointed so a reconnecting consumer can resume
|
|
10
|
+
-- from any offset (reattach), bounded by a rolling offset window.
|
|
11
|
+
--
|
|
12
|
+
-- This DDL is the forward-only, additive boot migration the DataLayer runner applies from
|
|
13
|
+
-- nano.app.json (`data.sources.app.migrations`). It is a byte-for-byte mirror of the package's
|
|
14
|
+
-- canonical `TRANSCRIPT_SCHEMA_SQL` (@nanobpm/agentic/transcript `schema.ts`), which the store also
|
|
15
|
+
-- applies via `ensureSchema()`. The two application paths are kept from drifting by the drift-guard
|
|
16
|
+
-- test `app/agentic/families/relay.family.test.ts` — divergence is a red test, not a silent boot vs.
|
|
17
|
+
-- store mismatch. Additive only (CREATE ... IF NOT EXISTS): it adds no column to an existing table
|
|
18
|
+
-- and drops nothing, so it is safe to apply forward over any earlier schema.
|
|
19
|
+
--
|
|
20
|
+
-- H0 (#143) pre-allocated this exact prefix (024) for H3 so no two sibling slices independently grab
|
|
21
|
+
-- "the next" number (H1=023_agentic_presence, H4=025_agentic_blackboard). `chunk_offset` (not
|
|
22
|
+
-- `offset`) is deliberate: OFFSET is a SQLite keyword, so the column avoids quoting in every query.
|
|
23
|
+
--
|
|
24
|
+
-- Invariants (ADR 0056): app-tier only, never the engine; the Camunda-8 job protocol (worker⇄engine)
|
|
25
|
+
-- is untouched — the agentic channel is the only new conversation; advisory semantics preserved (the
|
|
26
|
+
-- transcript never hard-locks or gates a BPMN sequence flow).
|
|
27
|
+
CREATE TABLE IF NOT EXISTS agentic_transcript_stream (
|
|
28
|
+
stream TEXT PRIMARY KEY,
|
|
29
|
+
lifecycle TEXT NOT NULL,
|
|
30
|
+
status TEXT NOT NULL DEFAULT 'open',
|
|
31
|
+
created_at TEXT NOT NULL,
|
|
32
|
+
completed_at TEXT,
|
|
33
|
+
first_offset INTEGER,
|
|
34
|
+
next_offset INTEGER NOT NULL DEFAULT 0
|
|
35
|
+
);
|
|
36
|
+
CREATE TABLE IF NOT EXISTS agentic_transcript_chunk (
|
|
37
|
+
stream TEXT NOT NULL,
|
|
38
|
+
chunk_offset INTEGER NOT NULL,
|
|
39
|
+
chunk TEXT NOT NULL,
|
|
40
|
+
appended_at TEXT NOT NULL,
|
|
41
|
+
PRIMARY KEY (stream, chunk_offset)
|
|
42
|
+
);
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_agentic_transcript_stream_retention ON agentic_transcript_stream (lifecycle, status, completed_at);
|
|
@@ -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.)
|