@nanobpm/nano-workforce 0.52.0 → 0.54.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.
@@ -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,21 @@
1
+ // Schema-drift guard (#147). The `db/migrations/025_agentic_blackboard.sql` CREATE statements MUST be
2
+ // the canonical `BLACKBOARD_SCHEMA_SQL` verbatim — the exact DDL `@nanobpm/agentic/blackboard`'s
3
+ // `BlackboardStore.ensureSchema()` (and the agentic-channel family) apply. If the two ever drift, a
4
+ // board created by a migration on one host and by `ensureSchema()` on another would disagree — this
5
+ // test fails the build before that can ship.
6
+ import { readFileSync } from "node:fs";
7
+ import { fileURLToPath } from "node:url";
8
+ import test from "node:test";
9
+ import { BLACKBOARD_SCHEMA_SQL } from "@nanobpm/agentic/blackboard";
10
+ import { assert, assertEquals } from "#test-assert";
11
+
12
+ test("migration 025 CREATE statements equal BLACKBOARD_SCHEMA_SQL verbatim", () => {
13
+ const path = fileURLToPath(new URL("../db/migrations/025_agentic_blackboard.sql", import.meta.url));
14
+ const sql = readFileSync(path, "utf8");
15
+ const start = sql.indexOf("CREATE TABLE IF NOT EXISTS agentic_blackboard");
16
+ const end = sql.indexOf("(scope, id);");
17
+ assert(start !== -1, "migration 025 is missing the `CREATE TABLE IF NOT EXISTS agentic_blackboard` marker");
18
+ assert(end !== -1, "migration 025 is missing the `(scope, id);` index marker");
19
+ const createBlock = sql.slice(start, end + "(scope, id);".length).trim();
20
+ assertEquals(createBlock, BLACKBOARD_SCHEMA_SQL.trim());
21
+ });
@@ -1,7 +1,12 @@
1
1
  // Unit tests for the epic coordination blackboard (Tier 1, issues #51 / #49 D4).
2
+ //
3
+ // H4 (#147) migrated the storage onto `@nanobpm/agentic/blackboard`'s shared `BlackboardStore`
4
+ // (table `agentic_blackboard`), reached over the app DataLayer's raw SQLite handle. These tests run
5
+ // the adapter against a REAL in-memory SQLite engine (see `test/blackboardDb.ts`), so the
6
+ // idempotency, conflict, and incremental-read behaviour is verified end-to-end, not against a mock.
2
7
  import { test } from "node:test";
3
8
  import { assert, assertEquals, assertStringIncludes } from "#test-assert";
4
- import type { DataLayer } from "@nanobpm/urban";
9
+ import { memBlackboardData } from "../test/blackboardDb.ts";
5
10
  import {
6
11
  appendEntry,
7
12
  blackboardUrl,
@@ -10,41 +15,13 @@ import {
10
15
  mintBlackboardToken,
11
16
  normalizeKind,
12
17
  planKeyForToken,
18
+ planKeyForTokenSync,
13
19
  publicBaseUrl,
14
20
  readBlackboard,
15
21
  readBlackboardPage,
16
22
  renderCoordinationBrief,
17
23
  } from "./blackboard.ts";
18
24
 
19
- // A tiny in-memory stand-in for the record gateway, matching the subset of the Table<T> API the
20
- // blackboard uses (insert/find/findOne). Mirrors the fake-app style used across the app tests.
21
- function memData(): { data: DataLayer; stores: Record<string, any[]> } {
22
- const stores: Record<string, any[]> = {};
23
- const seq: Record<string, number> = {};
24
- function tbl(name: string, pk = "id") {
25
- const rows = (stores[name] ??= [] as any[]);
26
- return {
27
- async insert(row: any) {
28
- if (pk === "id") {
29
- const id = (seq[name] = (seq[name] ?? 0) + 1);
30
- rows.push({ id, ...row });
31
- return id;
32
- }
33
- rows.push({ ...row });
34
- return row[pk];
35
- },
36
- async find(where: any = {}) {
37
- return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
38
- },
39
- async findOne(where: any = {}) {
40
- return rows.find((r) => Object.entries(where).every(([k, v]) => r[k] === v));
41
- },
42
- };
43
- }
44
- const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
45
- return { data, stores };
46
- }
47
-
48
25
  test("mintBlackboardToken: URL-safe, unguessable, unique", () => {
49
26
  const a = mintBlackboardToken();
50
27
  const b = mintBlackboardToken();
@@ -106,16 +83,21 @@ test("renderCoordinationBrief: leads with a separator and teaches the protocol +
106
83
  assertStringIncludes(brief, "Share what you learn");
107
84
  });
108
85
 
109
- test("planKeyForToken: resolves a token to its plan, undefined otherwise", async () => {
110
- const { data } = memData();
86
+ test("planKeyForToken: resolves a token to its plan, undefined otherwise (async + sync agree)", async () => {
87
+ const { data, db } = memBlackboardData();
111
88
  await data.table("plans", "plan_key").insert({ plan_key: "o/r#7", blackboard_token: "tok7" });
112
89
  assertEquals(await planKeyForToken(data, "tok7"), "o/r#7");
113
90
  assertEquals(await planKeyForToken(data, "nope"), undefined);
114
91
  assertEquals(await planKeyForToken(data, ""), undefined);
92
+ // The sync resolver (used by the agentic channel's scopeOf) resolves the identical mapping, so the
93
+ // HTTP hook and the channel scope a plan's board to the same plan_key.
94
+ assertEquals(planKeyForTokenSync(db, "tok7"), "o/r#7");
95
+ assertEquals(planKeyForTokenSync(db, "nope"), undefined);
96
+ assertEquals(planKeyForTokenSync(db, ""), undefined);
115
97
  });
116
98
 
117
99
  test("appendEntry + readBlackboard: append, encode files, read back in write order", async () => {
118
- const { data } = memData();
100
+ const { data } = memBlackboardData();
119
101
  await appendEntry(data, "o/r#1", { author_task: "gap-2", kind: "file-claim", files: ["a.rs"], body: "touches a.rs" });
120
102
  await appendEntry(data, "o/r#1", { author_task: "gap-8", kind: "note", body: "heads up" });
121
103
  await appendEntry(data, "o/r#2", { body: "other plan" }); // must not leak across plans
@@ -128,14 +110,14 @@ test("appendEntry + readBlackboard: append, encode files, read back in write ord
128
110
  });
129
111
 
130
112
  test("appendEntry: trims whitespace-padded file paths so stored/read values are clean", async () => {
131
- const { data } = memData();
113
+ const { data } = memBlackboardData();
132
114
  await appendEntry(data, "p", { kind: "file-claim", files: [" engine/state.rs ", "\tengine/mine.rs\n"], body: "claims" });
133
115
  const [e] = await readBlackboard(data, "p");
134
116
  assertEquals(e.files, ["engine/state.rs", "engine/mine.rs"], "paths stored trimmed, not whitespace-padded");
135
117
  });
136
118
 
137
119
  test("appendEntry: a missing author defaults to 'system' and kind is normalised", async () => {
138
- const { data } = memData();
120
+ const { data } = memBlackboardData();
139
121
  await appendEntry(data, "p", { body: "x", kind: "weird" as unknown });
140
122
  const [e] = await readBlackboard(data, "p");
141
123
  assertEquals(e.author_task, "system");
@@ -143,44 +125,30 @@ test("appendEntry: a missing author defaults to 'system' and kind is normalised"
143
125
  });
144
126
 
145
127
  test("appendEntry: idempotent on dedupe_key (a job retry re-POST is a no-op)", async () => {
146
- const { data, stores } = memData();
128
+ const { data, db } = memBlackboardData();
147
129
  const first = await appendEntry(data, "p", { author_task: "t", body: "claim", dedupe_key: "t:claim:1" });
148
130
  const again = await appendEntry(data, "p", { author_task: "t", body: "claim", dedupe_key: "t:claim:1" });
149
131
  assertEquals(first.inserted, true);
150
132
  assertEquals(again.inserted, false, "second write with same dedupe_key is a no-op");
151
133
  assertEquals(again.id, first.id, "returns the existing id");
152
- assertEquals(stores["plan_blackboard"].length, 1, "exactly one row persisted");
134
+ const [{ n }] = db.all<{ n: number }>("SELECT COUNT(*) AS n FROM agentic_blackboard WHERE scope = ?", ["p"]);
135
+ assertEquals(n, 1, "exactly one row persisted");
153
136
  });
154
137
 
155
- test("appendEntry: a lost UNIQUE race collapses to a no-op instead of a 500", async () => {
156
- // Simulate the concurrency window: two POSTs share a dedupe_key, both miss the findOne
157
- // pre-check, then insert loses the race on the UNIQUE (plan_key, dedupe_key) index. The
158
- // catch branch must re-read the winner's row and return it rather than propagate the throw.
159
- const winner = { id: 42, plan_key: "p", dedupe_key: "t:claim:1", author_task: "t", body: "claim" };
160
- let preCheckDone = false;
161
- const table: any = {
162
- async findOne() {
163
- // Pre-check misses (row not yet visible); the recovery read after the collision hits.
164
- if (!preCheckDone) {
165
- preCheckDone = true;
166
- return undefined;
167
- }
168
- return winner;
169
- },
170
- async insert() {
171
- throw Object.assign(new Error("UNIQUE constraint failed: plan_blackboard.dedupe_key"), {
172
- code: "SQLITE_CONSTRAINT_UNIQUE",
173
- });
174
- },
175
- };
176
- const data = { table: () => table } as any as DataLayer;
177
- const res = await appendEntry(data, "p", { author_task: "t", body: "claim", dedupe_key: "t:claim:1" });
178
- assertEquals(res.inserted, false, "a lost race is not a fresh insert");
179
- assertEquals(res.id, 42, "returns the winning row's id");
138
+ test("appendEntry: a repeat dedupe_key collapses to the existing row instead of a fresh insert", async () => {
139
+ // The store's idempotent short-circuit (and its lost-UNIQUE-race recovery) means re-appending a
140
+ // fact under a stable dedupe_key returns the winning row as inserted:false rather than throwing
141
+ // an engine job retry never duplicates or 500s.
142
+ const { data } = memBlackboardData();
143
+ const winner = await appendEntry(data, "p", { author_task: "t", body: "claim", dedupe_key: "t:claim:1" });
144
+ assertEquals(winner.inserted, true);
145
+ const retry = await appendEntry(data, "p", { author_task: "t", body: "claim", dedupe_key: "t:claim:1" });
146
+ assertEquals(retry.inserted, false, "a repeat is not a fresh insert");
147
+ assertEquals(retry.id, winner.id, "returns the winning row's id");
180
148
  });
181
149
 
182
150
  test("appendEntry: a blank body is rejected", async () => {
183
- const { data } = memData();
151
+ const { data } = memBlackboardData();
184
152
  let threw = false;
185
153
  try {
186
154
  await appendEntry(data, "p", { body: " " });
@@ -191,7 +159,7 @@ test("appendEntry: a blank body is rejected", async () => {
191
159
  });
192
160
 
193
161
  test("readBlackboard: since returns only newer entries (incremental poll)", async () => {
194
- const { data } = memData();
162
+ const { data } = memBlackboardData();
195
163
  await appendEntry(data, "p", { body: "one" });
196
164
  await appendEntry(data, "p", { body: "two" });
197
165
  await appendEntry(data, "p", { body: "three" });
@@ -201,7 +169,7 @@ test("readBlackboard: since returns only newer entries (incremental poll)", asyn
201
169
  });
202
170
 
203
171
  test("readBlackboardPage: cursor is the plan head and lets an agent poll to caught-up (Tier 2)", async () => {
204
- const { data } = memData();
172
+ const { data } = memBlackboardData();
205
173
  await appendEntry(data, "p", { body: "one" });
206
174
  await appendEntry(data, "p", { body: "two" });
207
175
 
@@ -222,14 +190,14 @@ test("readBlackboardPage: cursor is the plan head and lets an agent poll to caug
222
190
  });
223
191
 
224
192
  test("readBlackboardPage: an empty plan yields no entries and a zero cursor", async () => {
225
- const { data } = memData();
193
+ const { data } = memBlackboardData();
226
194
  const page = await readBlackboardPage(data, "empty");
227
195
  assertEquals(page.entries, []);
228
196
  assertEquals(page.cursor, 0);
229
197
  });
230
198
 
231
199
  test("detectFileClaimConflicts: a sibling's prior claim on the same file is surfaced", async () => {
232
- const { data } = memData();
200
+ const { data } = memBlackboardData();
233
201
  await appendEntry(data, "p", { author_task: "gap-2", kind: "file-claim", files: ["engine/state.rs"], body: "owns state.rs" });
234
202
 
235
203
  const conflicts = await detectFileClaimConflicts(data, "p", {
@@ -242,7 +210,7 @@ test("detectFileClaimConflicts: a sibling's prior claim on the same file is surf
242
210
  });
243
211
 
244
212
  test("detectFileClaimConflicts: your own prior claim and non-file-claim entries are not conflicts", async () => {
245
- const { data } = memData();
213
+ const { data } = memBlackboardData();
246
214
  await appendEntry(data, "p", { author_task: "gap-2", kind: "file-claim", files: ["a.rs"], body: "my earlier claim" });
247
215
  await appendEntry(data, "p", { author_task: "gap-8", kind: "note", files: ["a.rs"], body: "just a note about a.rs" });
248
216
 
@@ -256,7 +224,7 @@ test("detectFileClaimConflicts: your own prior claim and non-file-claim entries
256
224
  });
257
225
 
258
226
  test("detectFileClaimConflicts: beforeId restricts to strictly prior claims (insertion order wins)", async () => {
259
- const { data } = memData();
227
+ const { data } = memBlackboardData();
260
228
  const prior = await appendEntry(data, "p", {
261
229
  author_task: "gap-2",
262
230
  kind: "file-claim",