@nanobpm/nano-workforce 0.127.0 → 0.129.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.
Files changed (51) hide show
  1. package/.github/workflows/release.yml +29 -6
  2. package/AGENTS.md +19 -0
  3. package/CHANGELOG.md +14 -0
  4. package/app/abandon.test.ts +16 -2
  5. package/app/abandon.ts +39 -17
  6. package/app/agentic/cockpit/cockpit-route.test.ts +21 -0
  7. package/app/agentic/cockpit/cockpit-route.ts +17 -0
  8. package/app/agentic/cockpit/index.ts +16 -0
  9. package/app/agentic/cockpit/supply-boot-past.test.ts +44 -0
  10. package/app/agentic/cockpit/supply-boot.test.ts +2 -2
  11. package/app/agentic/cockpit/supply-boot.ts +76 -10
  12. package/app/agentic/cockpit/supply-render.test.ts +13 -4
  13. package/app/agentic/cockpit/supply-render.ts +14 -2
  14. package/app/agentic/cockpit/transcript-render.ts +6 -2
  15. package/app/agentic/cockpit/transcript-view.ts +9 -0
  16. package/app/agentic/cockpit/worker-detail-render.test.ts +86 -0
  17. package/app/agentic/cockpit/worker-detail-render.ts +88 -0
  18. package/app/agentic/cockpit/worker-detail-view.ts +43 -0
  19. package/app/agentic/correlation-store.test.ts +99 -0
  20. package/app/agentic/correlation-store.ts +162 -0
  21. package/app/agentic/families/presence.family.test.ts +12 -0
  22. package/app/agentic/families/presence.family.ts +14 -0
  23. package/app/agentic/families/relay.family.test.ts +72 -0
  24. package/app/agentic/families/relay.family.ts +130 -1
  25. package/app/agentic/transcript-read.test.ts +55 -3
  26. package/app/agentic/transcript-read.ts +49 -9
  27. package/app/conformance.test.ts +2 -1
  28. package/app/conformance.ts +9 -3
  29. package/app/featureDelivery.test.ts +2 -1
  30. package/app/instanceTracking.ts +97 -0
  31. package/app/lineage.test.ts +2 -1
  32. package/app/lineage.ts +15 -2
  33. package/app/promotionPoll.test.ts +2 -1
  34. package/app/retro.test.ts +2 -1
  35. package/app/retro.ts +9 -2
  36. package/app/service.test.ts +15 -14
  37. package/app/service.ts +17 -24
  38. package/db/migrations/078_agentic_correlation.sql +32 -0
  39. package/e2e/convergence-loop.e2e.ts +41 -8
  40. package/openapi.yaml +26 -0
  41. package/operations/acknowledgeEpic.test.ts +2 -1
  42. package/operations/checkAbandon.test.ts +2 -1
  43. package/operations/getAgenticTranscript.ts +3 -2
  44. package/operations/getLineage.test.ts +2 -1
  45. package/operations/listAgenticTranscripts.ts +2 -1
  46. package/package.json +3 -3
  47. package/pages/cockpit/cockpit.css +65 -2
  48. package/pages/cockpit/mount.js +187 -16
  49. package/test/trackingViews.ts +50 -0
  50. package/test/worldDb.ts +2 -1
  51. package/workers/retro-gather/worker.test.ts +2 -1
@@ -26,6 +26,9 @@ export interface TranscriptSummaryReport {
26
26
  readonly bpmnProcessId?: string;
27
27
  readonly elementId?: string;
28
28
  readonly planKey?: string;
29
+ readonly instance?: string;
30
+ readonly identity?: string;
31
+ readonly host?: string;
29
32
  }
30
33
 
31
34
  /** The transcript list report the cockpit polls (mirrors `AgenticTranscriptList`). */
@@ -54,6 +57,9 @@ export interface TranscriptView {
54
57
  readonly byteLength: number;
55
58
  /** When the session was captured — completedAt when sealed, else createdAt. */
56
59
  readonly capturedAt: string;
60
+ readonly instance?: string;
61
+ readonly identity?: string;
62
+ readonly host?: string;
57
63
  }
58
64
 
59
65
  /** The full renderable "past sessions" view. */
@@ -106,6 +112,9 @@ function sessionView(t: TranscriptSummaryReport): TranscriptView {
106
112
  size: humanBytes(t.byteLength),
107
113
  byteLength: t.byteLength,
108
114
  capturedAt: t.completedAt ?? t.createdAt,
115
+ ...(t.instance !== undefined ? { instance: t.instance } : {}),
116
+ ...(t.identity !== undefined ? { identity: t.identity } : {}),
117
+ ...(t.host !== undefined ? { host: t.host } : {}),
109
118
  };
110
119
  }
111
120
 
@@ -0,0 +1,86 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import { FakeDocument, FakeElement } from "../../../test/agentic-cockpit-doubles.ts";
5
+ import { renderWorkerDetail } from "./worker-detail-render.ts";
6
+ import { supplyView, type SupplyReport } from "./supply-view.ts";
7
+ import { workerDetailView } from "./worker-detail-view.ts";
8
+
9
+ const doc = new FakeDocument();
10
+
11
+ const report: SupplyReport = {
12
+ count: 1,
13
+ workers: [
14
+ {
15
+ instance: "wk-a",
16
+ identity: "leaf-1",
17
+ stream: "job:6494",
18
+ family: "senior",
19
+ host: "h1",
20
+ jobKeys: ["6494"],
21
+ live: true,
22
+ staleMs: 0,
23
+ },
24
+ ],
25
+ leaves: [
26
+ {
27
+ token: "leaf-1",
28
+ workers: [
29
+ {
30
+ instance: "wk-a",
31
+ identity: "leaf-1",
32
+ stream: "job:6494",
33
+ family: "senior",
34
+ host: "h1",
35
+ jobKeys: ["6494"],
36
+ live: true,
37
+ staleMs: 0,
38
+ },
39
+ ],
40
+ },
41
+ ],
42
+ correlations: [
43
+ {
44
+ jobKey: "6494",
45
+ stream: "job:6494",
46
+ bpmnProcessId: "plan-fanout",
47
+ processInstanceKey: "4612",
48
+ planKey: "o/r#142",
49
+ },
50
+ ],
51
+ };
52
+
53
+ test("derives and renders the worker detail header and current job drill", () => {
54
+ const host = new FakeElement("body");
55
+ const drilled: string[] = [];
56
+ const backed: string[] = [];
57
+ const detail = workerDetailView(supplyView(report), "wk-a");
58
+ renderWorkerDetail(host, doc, detail, { onBack: () => backed.push("back"), onDrill: (stream) => drilled.push(stream) });
59
+
60
+ assert.equal(host.byData("worker-detail", "wk-a").length, 1);
61
+ assert.equal(host.byClass("cockpit-worker-detail-identity")[0]?.text(), "leaf-1");
62
+ assert.equal(host.byClass("cockpit-worker-detail-family")[0]?.text(), "senior");
63
+ assert.equal(host.byClass("cockpit-worker-detail-host")[0]?.text(), "h1");
64
+ assert.equal(host.byData("liveness", "live").length >= 1, true);
65
+
66
+ const job = host.byClass("cockpit-worker-current-job")[0];
67
+ assert.equal(job?.getAttribute("data-job-key"), "6494");
68
+ assert.equal(job?.getAttribute("data-stream"), "job:6494");
69
+ assert.equal(job?.text(), "plan-fanout · inst 4612 · o/r#142");
70
+ job?.dispatch("click");
71
+ assert.deepEqual(drilled, ["job:6494"]);
72
+
73
+ host.byClass("cockpit-worker-detail-back")[0]?.dispatch("click");
74
+ assert.deepEqual(backed, ["back"]);
75
+ });
76
+
77
+ test("renders a missing-worker detail state with a back action", () => {
78
+ const host = new FakeElement("body");
79
+ const backed: string[] = [];
80
+ renderWorkerDetail(host, doc, workerDetailView(supplyView(report), "missing"), { onBack: () => backed.push("back") });
81
+
82
+ assert.equal(host.byData("worker-missing", "missing").length, 1);
83
+ assert.equal(host.byClass("cockpit-worker-detail-empty")[0]?.text(), "Worker missing is not in the current supply report.");
84
+ host.byClass("cockpit-worker-detail-back")[0]?.dispatch("click");
85
+ assert.deepEqual(backed, ["back"]);
86
+ });
@@ -0,0 +1,88 @@
1
+ import type { DocumentLike, ElementLike } from "@nanobpm/agentic/cockpit";
2
+ import type { WorkerDetailView } from "./worker-detail-view.ts";
3
+
4
+ export interface RenderWorkerDetailOptions {
5
+ readonly onBack?: () => void;
6
+ readonly onDrill?: (stream: string) => void;
7
+ }
8
+
9
+ export interface WorkerDetailDom {
10
+ readonly root: ElementLike;
11
+ }
12
+
13
+ function el(doc: DocumentLike, tag: string, className?: string, text?: string): ElementLike {
14
+ const node = doc.createElement(tag);
15
+ if (className !== undefined) node.className = className;
16
+ if (text !== undefined) node.textContent = text;
17
+ return node;
18
+ }
19
+
20
+ function backButton(doc: DocumentLike, onBack: (() => void) | undefined): ElementLike {
21
+ const button = el(doc, "button", "cockpit-worker-detail-back", "← Workers");
22
+ button.setAttribute("type", "button");
23
+ if (onBack !== undefined) button.addEventListener("click", onBack);
24
+ return button;
25
+ }
26
+
27
+ export function renderWorkerDetail(
28
+ host: ElementLike,
29
+ doc: DocumentLike,
30
+ view: WorkerDetailView,
31
+ options: RenderWorkerDetailOptions = {},
32
+ ): WorkerDetailDom {
33
+ host.replaceChildren();
34
+ const root = el(doc, "section", "cockpit-worker-detail");
35
+ root.appendChild(backButton(doc, options.onBack));
36
+
37
+ if (view.kind === "missing") {
38
+ root.setAttribute("data-worker-missing", view.instance);
39
+ root.appendChild(el(doc, "h1", "cockpit-title", `Worker ${view.instance}`));
40
+ root.appendChild(el(doc, "div", "cockpit-worker-detail-empty", `Worker ${view.instance} is not in the current supply report.`));
41
+ host.appendChild(root);
42
+ return { root };
43
+ }
44
+
45
+ const worker = view.worker;
46
+ root.setAttribute("data-worker-detail", worker.instance);
47
+ root.setAttribute("data-liveness", worker.liveness);
48
+
49
+ const header = el(doc, "header", "cockpit-worker-detail-header");
50
+ const title = el(doc, "h1", "cockpit-title", worker.instance);
51
+ header.appendChild(title);
52
+ const meta = el(doc, "dl", "cockpit-worker-detail-meta");
53
+ for (const [klass, label, value] of [
54
+ ["identity", "identity", worker.identity],
55
+ ["host", "host", worker.host],
56
+ ["family", "family", worker.family],
57
+ ["liveness", "liveness", worker.liveness],
58
+ ] as const) {
59
+ const item = el(doc, "div", "cockpit-worker-detail-meta-item");
60
+ item.setAttribute("data-field", klass);
61
+ item.appendChild(el(doc, "dt", undefined, label));
62
+ const dd = el(doc, "dd", `cockpit-worker-detail-${klass}`, value);
63
+ if (klass === "liveness") dd.setAttribute("data-liveness", worker.liveness);
64
+ item.appendChild(dd);
65
+ meta.appendChild(item);
66
+ }
67
+ header.appendChild(meta);
68
+ root.appendChild(header);
69
+
70
+ const current = el(doc, "section", "cockpit-worker-current");
71
+ current.appendChild(el(doc, "h2", "cockpit-panel-title", "Current job"));
72
+ if (view.currentJob === undefined) {
73
+ current.appendChild(el(doc, "div", "cockpit-worker-current-empty", "No active job."));
74
+ } else {
75
+ const currentJob = view.currentJob;
76
+ const button = el(doc, "button", "cockpit-worker-current-job", currentJob.label);
77
+ button.setAttribute("type", "button");
78
+ button.setAttribute("data-job-key", currentJob.jobKey);
79
+ button.setAttribute("data-stream", currentJob.stream);
80
+ const onDrill = options.onDrill;
81
+ if (onDrill !== undefined) button.addEventListener("click", () => onDrill(currentJob.stream));
82
+ current.appendChild(button);
83
+ }
84
+ root.appendChild(current);
85
+
86
+ host.appendChild(root);
87
+ return { root };
88
+ }
@@ -0,0 +1,43 @@
1
+ import type { JobCorrelationView, SupplyView, SupplyWorkerView } from "./supply-view.ts";
2
+
3
+ export interface WorkerCurrentJobView {
4
+ readonly jobKey: string;
5
+ readonly stream: string;
6
+ readonly label: string;
7
+ }
8
+
9
+ export interface FoundWorkerDetailView {
10
+ readonly kind: "found";
11
+ readonly worker: SupplyWorkerView;
12
+ readonly currentJob?: WorkerCurrentJobView;
13
+ }
14
+
15
+ export interface MissingWorkerDetailView {
16
+ readonly kind: "missing";
17
+ readonly instance: string;
18
+ }
19
+
20
+ export type WorkerDetailView = FoundWorkerDetailView | MissingWorkerDetailView;
21
+
22
+ function currentJob(worker: SupplyWorkerView): WorkerCurrentJobView | undefined {
23
+ const correlation: JobCorrelationView | undefined = worker.correlations[0];
24
+ if (correlation !== undefined) {
25
+ return { jobKey: correlation.jobKey, stream: correlation.stream, label: correlation.label };
26
+ }
27
+ const jobKey = worker.jobKeys[0];
28
+ if (jobKey === undefined) return undefined;
29
+ return { jobKey, stream: worker.stream, label: `job ${jobKey}` };
30
+ }
31
+
32
+ export function workerDetailView(view: SupplyView, instance: string): WorkerDetailView {
33
+ const worker = view.workers.find((w) => w.instance === instance);
34
+ if (worker === undefined) return { kind: "missing", instance };
35
+ return {
36
+ kind: "found",
37
+ worker,
38
+ ...(() => {
39
+ const job = currentJob(worker);
40
+ return job === undefined ? {} : { currentJob: job };
41
+ })(),
42
+ };
43
+ }
@@ -0,0 +1,99 @@
1
+ // Unit tests for the durable worker-attribution store (app/agentic/correlation-store.ts, #485).
2
+ // - drift guard: db/migrations/078 mirrors AGENTIC_CORRELATION_SCHEMA_SQL;
3
+ // - record/get/byStream round-trips, including the optional (nullable) engine-context columns;
4
+ // - upsert semantics (re-recording a jobKey is last-write-wins).
5
+ import { readFile } from "node:fs/promises";
6
+ import { dirname, join } from "node:path";
7
+ import { DatabaseSync } from "node:sqlite";
8
+ import { test } from "node:test";
9
+ import { fileURLToPath } from "node:url";
10
+ import type { SqliteDb } from "@nanobpm/agentic/transcript";
11
+ import { assert, assertEquals } from "#test-assert";
12
+ import { jobStream } from "./correlation.ts";
13
+ import { AGENTIC_CORRELATION_SCHEMA_SQL, AgenticCorrelationStore } from "./correlation-store.ts";
14
+
15
+ const HERE = dirname(fileURLToPath(import.meta.url));
16
+
17
+ function memoryDb(): SqliteDb {
18
+ const raw = new DatabaseSync(":memory:");
19
+ return {
20
+ exec: (sql) => raw.exec(sql),
21
+ run: (sql, params = []) => raw.prepare(sql).run(...params),
22
+ all: <T = Record<string, unknown>>(sql: string, params: unknown[] = []): T[] =>
23
+ raw.prepare(sql).all(...params) as T[],
24
+ };
25
+ }
26
+
27
+ test("drift guard: migration 078 mirrors AGENTIC_CORRELATION_SCHEMA_SQL", async () => {
28
+ const migrationPath = join(HERE, "..", "..", "db", "migrations", "078_agentic_correlation.sql");
29
+ const raw = await readFile(migrationPath, "utf8");
30
+ const ddl = raw
31
+ .split("\n")
32
+ .filter((line) => !line.trimStart().startsWith("--"))
33
+ .join("\n");
34
+ const normalise = (s: string) => s.trim().replace(/\s+/g, " ");
35
+ assertEquals(
36
+ normalise(ddl),
37
+ normalise(AGENTIC_CORRELATION_SCHEMA_SQL),
38
+ "078_agentic_correlation.sql drifted from AGENTIC_CORRELATION_SCHEMA_SQL",
39
+ );
40
+ assert(ddl.includes("agentic_correlation"));
41
+ });
42
+
43
+ test("record + get round-trips full attribution, and byStream decodes the jobKey", () => {
44
+ const store = new AgenticCorrelationStore(memoryDb());
45
+ store.record({
46
+ jobKey: "job-1",
47
+ stream: jobStream("job-1"),
48
+ instance: "worker-A",
49
+ identity: "leaf:token",
50
+ host: "merlin.local",
51
+ processInstanceKey: "pi-9",
52
+ bpmnProcessId: "pr-flow",
53
+ elementId: "agent",
54
+ planKey: "owner/repo#142",
55
+ linkedAt: "2026-08-23T00:00:00.000Z",
56
+ completedAt: "2026-08-23T00:05:00.000Z",
57
+ });
58
+
59
+ const got = store.get("job-1");
60
+ assertEquals(got?.instance, "worker-A");
61
+ assertEquals(got?.identity, "leaf:token");
62
+ assertEquals(got?.host, "merlin.local");
63
+ assertEquals(got?.processInstanceKey, "pi-9");
64
+ assertEquals(got?.planKey, "owner/repo#142");
65
+ assertEquals(got?.completedAt, "2026-08-23T00:05:00.000Z");
66
+ // The same row is reachable from the stream id.
67
+ assertEquals(store.byStream(jobStream("job-1"))?.instance, "worker-A");
68
+ });
69
+
70
+ test("optional context columns are omitted (not null) when unknown", () => {
71
+ const store = new AgenticCorrelationStore(memoryDb());
72
+ store.record({
73
+ jobKey: "job-2",
74
+ stream: jobStream("job-2"),
75
+ instance: "worker-B",
76
+ completedAt: "2026-08-23T01:00:00.000Z",
77
+ });
78
+ const got = store.get("job-2");
79
+ assertEquals(got?.instance, "worker-B");
80
+ assert(!("processInstanceKey" in (got ?? {})), "unknown context is omitted, never a null key");
81
+ assert(!("identity" in (got ?? {})), "unknown identity is omitted");
82
+ });
83
+
84
+ test("record is an upsert: re-recording a jobKey is last-write-wins", () => {
85
+ const store = new AgenticCorrelationStore(memoryDb());
86
+ const base = { jobKey: "job-3", stream: jobStream("job-3"), completedAt: "2026-08-23T02:00:00.000Z" };
87
+ store.record({ ...base, instance: "worker-C" });
88
+ store.record({ ...base, instance: "worker-C", host: "second.local", completedAt: "2026-08-23T02:10:00.000Z" });
89
+ const got = store.get("job-3");
90
+ assertEquals(got?.host, "second.local");
91
+ assertEquals(got?.completedAt, "2026-08-23T02:10:00.000Z");
92
+ });
93
+
94
+ test("get is undefined for an unknown jobKey and byStream undefined for a non-job stream", () => {
95
+ const store = new AgenticCorrelationStore(memoryDb());
96
+ assertEquals(store.get("nope"), undefined);
97
+ assertEquals(store.byStream("worker-A"), undefined);
98
+ assertEquals(store.get(""), undefined);
99
+ });
@@ -0,0 +1,162 @@
1
+ // nano-workforce — the DURABLE jobKey ⇄ worker-attribution store (#485, provisioning #232).
2
+ //
3
+ // The in-memory {@link ./correlation.ts | CorrelationRegistry} is the live join, but it is RELEASED
4
+ // on job end / worker disconnect (`releaseJob` / `releaseInstance`) and is empty after a restart. So a
5
+ // COMPLETED (past) session — the exact case the cockpit "past sessions" / worker-history view reads —
6
+ // loses which worker ran it (instance / identity / host) and its process-instance / plan context: the
7
+ // live registry no longer holds the row, and the package-mirrored transcript store
8
+ // (`db/migrations/024_agentic_transcript.sql`, byte-for-byte guarded) carries no correlation columns.
9
+ //
10
+ // This app-side table closes that gap WITHOUT touching the mirrored transcript schema (exactly the
11
+ // shape #232 called for): at job-completion time the relay slice records the attribution here, keyed
12
+ // by jobKey, so the transcript read path can recover a past session's worker + context after the
13
+ // worker has exited. Advisory / read-only (ADR 0056) — it NEVER gates a BPMN sequence flow.
14
+ //
15
+ // Single source of truth: {@link AGENTIC_CORRELATION_SCHEMA_SQL} is the canonical DDL. It is applied
16
+ // idempotently on store construction (so unit tests over an in-memory DB have the table) AND mirrored
17
+ // byte-for-byte by the forward-only migration `db/migrations/078_agentic_correlation.sql`, which a
18
+ // drift-guard test (`correlation-store.test.ts`) pins so the two can never diverge.
19
+ import type { SqliteDb } from "@nanobpm/agentic/transcript";
20
+ import { jobKeyOfStream } from "./correlation.ts";
21
+
22
+ /** The canonical DDL for the durable correlation table. The `078_*` migration mirrors this exactly. */
23
+ export const AGENTIC_CORRELATION_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS agentic_correlation (
24
+ job_key TEXT PRIMARY KEY,
25
+ stream TEXT NOT NULL,
26
+ instance TEXT NOT NULL,
27
+ identity TEXT,
28
+ host TEXT,
29
+ process_instance_key TEXT,
30
+ bpmn_process_id TEXT,
31
+ element_id TEXT,
32
+ plan_key TEXT,
33
+ linked_at TEXT,
34
+ completed_at TEXT NOT NULL
35
+ );
36
+ CREATE INDEX IF NOT EXISTS ix_agentic_correlation_instance ON agentic_correlation (instance);
37
+ CREATE INDEX IF NOT EXISTS ix_agentic_correlation_process_instance ON agentic_correlation (process_instance_key);
38
+ CREATE INDEX IF NOT EXISTS ix_agentic_correlation_plan ON agentic_correlation (plan_key);
39
+ `;
40
+
41
+ /** One durable attribution row: which worker ran a job, plus its (best-effort) engine context. */
42
+ export interface DurableCorrelation {
43
+ readonly jobKey: string;
44
+ readonly stream: string;
45
+ readonly instance: string;
46
+ readonly identity?: string;
47
+ readonly host?: string;
48
+ readonly processInstanceKey?: string;
49
+ readonly bpmnProcessId?: string;
50
+ readonly elementId?: string;
51
+ readonly planKey?: string;
52
+ /** When the job was first linked (its first `produce`), ISO-8601, when known. */
53
+ readonly linkedAt?: string;
54
+ /** When the job completed (was flushed / released), ISO-8601. */
55
+ readonly completedAt: string;
56
+ }
57
+
58
+ /** A row as stored (nullable columns come back as `null`). */
59
+ interface Row {
60
+ job_key: string;
61
+ stream: string;
62
+ instance: string;
63
+ identity: string | null;
64
+ host: string | null;
65
+ process_instance_key: string | null;
66
+ bpmn_process_id: string | null;
67
+ element_id: string | null;
68
+ plan_key: string | null;
69
+ linked_at: string | null;
70
+ completed_at: string;
71
+ }
72
+
73
+ function fromRow(r: Row): DurableCorrelation {
74
+ const out: DurableCorrelation = {
75
+ jobKey: r.job_key,
76
+ stream: r.stream,
77
+ instance: r.instance,
78
+ completedAt: r.completed_at,
79
+ };
80
+ return {
81
+ ...out,
82
+ ...(r.identity !== null ? { identity: r.identity } : {}),
83
+ ...(r.host !== null ? { host: r.host } : {}),
84
+ ...(r.process_instance_key !== null ? { processInstanceKey: r.process_instance_key } : {}),
85
+ ...(r.bpmn_process_id !== null ? { bpmnProcessId: r.bpmn_process_id } : {}),
86
+ ...(r.element_id !== null ? { elementId: r.element_id } : {}),
87
+ ...(r.plan_key !== null ? { planKey: r.plan_key } : {}),
88
+ ...(r.linked_at !== null ? { linkedAt: r.linked_at } : {}),
89
+ };
90
+ }
91
+
92
+ /**
93
+ * The durable worker-attribution store over the app's SQLite handle. Synchronous (mirrors
94
+ * {@link SqliteDb} and the relay slice's sync frame handling), advisory — a persistence failure never
95
+ * bubbles into a frame handler; callers wrap {@link record} defensively.
96
+ */
97
+ export class AgenticCorrelationStore {
98
+ readonly #db: SqliteDb;
99
+
100
+ constructor(db: SqliteDb) {
101
+ this.#db = db;
102
+ // Idempotent — the migration applies the same DDL at boot; this makes the table present for
103
+ // unit tests over an in-memory DB (and is a no-op alongside the migration).
104
+ this.#db.exec(AGENTIC_CORRELATION_SCHEMA_SQL);
105
+ }
106
+
107
+ /** Upsert a completed job's attribution (last write wins on jobKey). */
108
+ record(entry: DurableCorrelation): void {
109
+ this.#db.run(
110
+ `INSERT INTO agentic_correlation
111
+ (job_key, stream, instance, identity, host, process_instance_key, bpmn_process_id, element_id, plan_key, linked_at, completed_at)
112
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
113
+ ON CONFLICT(job_key) DO UPDATE SET
114
+ stream = excluded.stream,
115
+ instance = excluded.instance,
116
+ identity = excluded.identity,
117
+ host = excluded.host,
118
+ process_instance_key = excluded.process_instance_key,
119
+ bpmn_process_id = excluded.bpmn_process_id,
120
+ element_id = excluded.element_id,
121
+ plan_key = excluded.plan_key,
122
+ linked_at = excluded.linked_at,
123
+ completed_at = excluded.completed_at`,
124
+ [
125
+ entry.jobKey,
126
+ entry.stream,
127
+ entry.instance,
128
+ entry.identity ?? null,
129
+ entry.host ?? null,
130
+ entry.processInstanceKey ?? null,
131
+ entry.bpmnProcessId ?? null,
132
+ entry.elementId ?? null,
133
+ entry.planKey ?? null,
134
+ entry.linkedAt ?? null,
135
+ entry.completedAt,
136
+ ],
137
+ );
138
+ }
139
+
140
+ /** The durable attribution for a jobKey, or undefined when none was recorded. */
141
+ get(jobKey: string): DurableCorrelation | undefined {
142
+ if (jobKey === "") return undefined;
143
+ const rows = this.#db.all<Row>("SELECT * FROM agentic_correlation WHERE job_key = ?", [jobKey]);
144
+ return rows.length > 0 ? fromRow(rows[0]) : undefined;
145
+ }
146
+
147
+ /** The durable attribution for a `job:<jobKey>` stream id, or undefined for a non-job stream. */
148
+ byStream(stream: string): DurableCorrelation | undefined {
149
+ const jobKey = jobKeyOfStream(stream);
150
+ return jobKey === undefined ? undefined : this.get(jobKey);
151
+ }
152
+
153
+ /** Every durable attribution recorded for a worker instance, newest completion first. */
154
+ byInstance(instance: string): DurableCorrelation[] {
155
+ if (instance === "") return [];
156
+ const rows = this.#db.all<Row>(
157
+ "SELECT * FROM agentic_correlation WHERE instance = ? ORDER BY completed_at DESC, job_key DESC",
158
+ [instance],
159
+ );
160
+ return rows.map(fromRow);
161
+ }
162
+ }
@@ -138,6 +138,18 @@ test("instanceForConnection: resolves the worker instance owning a connection (H
138
138
  assertEquals(registry.instanceForConnection(""), undefined, "empty connection → undefined");
139
139
  });
140
140
 
141
+ test("attributionOf: resolves a worker instance's durable identity + host for job attribution (#485)", () => {
142
+ const store = createPresenceStore(memSqlite());
143
+ store.ensureSchema();
144
+ store.register({ instance: "w1", connectionId: "c1", identity: "leafA", capability: { host: "boxA1" } });
145
+ store.register({ instance: "w2", connectionId: "c2", identity: "leafB", capability: {} });
146
+ const registry = new PresenceRegistry(store, () => new Set(["c1", "c2"]));
147
+ assertEquals(registry.attributionOf("w1"), { identity: "leafA", host: "boxA1" });
148
+ assertEquals(registry.attributionOf("w2"), { identity: "leafB", host: undefined }, "no host → host undefined");
149
+ assertEquals(registry.attributionOf("nope"), undefined, "unknown instance → undefined");
150
+ assertEquals(registry.attributionOf(""), undefined, "empty instance → undefined");
151
+ });
152
+
141
153
  test("reconcile: removes rows whose connection the hub has closed, keeps live ones", () => {
142
154
  const store = createPresenceStore(memSqlite());
143
155
  store.ensureSchema();
@@ -148,6 +148,20 @@ export class PresenceRegistry {
148
148
  return undefined;
149
149
  }
150
150
 
151
+ /**
152
+ * Resolve a worker instance's durable identity attributes (presence identity + host) for job
153
+ * attribution (#485). Returns the most recently registered matching row's attributes, or undefined
154
+ * when the instance is unknown (e.g. it already deregistered).
155
+ */
156
+ attributionOf(instance: string): { identity?: string; host?: string } | undefined {
157
+ if (instance === "") return undefined;
158
+ let match: { identity?: string; host?: string } | undefined;
159
+ for (const row of this.#store.list()) {
160
+ if (row.instance === instance) match = { identity: row.identity, host: row.capability.host };
161
+ }
162
+ return match;
163
+ }
164
+
151
165
  /**
152
166
  * Eagerly drop presence rows whose connection the hub has already closed (a disconnect the hub's
153
167
  * single close listener removed from its in-memory registry). Rows also age out on the presence
@@ -20,6 +20,7 @@ import { type SqliteDb, TRANSCRIPT_SCHEMA_SQL } from "@nanobpm/agentic/transcrip
20
20
  import { assert, assertEquals } from "#test-assert";
21
21
  import { noopLog } from "../../../test/log.ts";
22
22
  import { CorrelationRegistry, jobStream } from "../correlation.ts";
23
+ import { AgenticCorrelationStore } from "../correlation-store.ts";
23
24
  import {
24
25
  type CorrelationLink,
25
26
  createRelayFamily,
@@ -139,6 +140,11 @@ function mkCorrelatedService(
139
140
  db: SqliteDb | undefined,
140
141
  correlation: CorrelationRegistry,
141
142
  byConnection: Map<string, string>,
143
+ extra: {
144
+ attributionForInstance?: (instance: string) => { identity?: string; host?: string } | undefined;
145
+ correlationStore?: AgenticCorrelationStore;
146
+ now?: () => string;
147
+ } = {},
142
148
  ): { service: RelayTranscriptService; hub: CapturingHub } {
143
149
  const hub = capturingHub();
144
150
  const service = new RelayTranscriptService({
@@ -148,6 +154,9 @@ function mkCorrelatedService(
148
154
  log: noopLog(),
149
155
  correlation: () => correlation,
150
156
  instanceForConnection: (id) => byConnection.get(id),
157
+ attributionForInstance: extra.attributionForInstance,
158
+ correlationStore: extra.correlationStore,
159
+ now: extra.now,
151
160
  });
152
161
  return { service, hub };
153
162
  }
@@ -349,6 +358,69 @@ test("H6 correlation write-side: a late produce after completion does not resurr
349
358
  service.teardown();
350
359
  });
351
360
 
361
+ test("H6 correlation write-side: a worker starting a NEW job over its live connection supersedes its prior job", () => {
362
+ const registry = new ConnectionRegistry();
363
+ const correlation = new CorrelationRegistry();
364
+ // One worker, ONE long-lived connection — exactly the fleet reality: a worker relays every job it
365
+ // runs over the same channel connection, one job at a time (correlation.ts). The connection never
366
+ // disconnects between jobs, so the disconnect-driven #reconcile release never fires; without a
367
+ // supersede-on-next-job rule the worker's supply row would accumulate EVERY job it ever ran.
368
+ const byConnection = new Map([["prod", "worker-A"]]);
369
+ const { service, hub } = mkCorrelatedService(registry, memoryDb(), correlation, byConnection);
370
+ const p = connect("prod", registry);
371
+
372
+ // Job 1: the worker relays k1's terminal → linked.
373
+ hub.handler?.(produce(jobStream("k1"), 1, "job-1 line"), p.conn);
374
+ assertEquals(correlation.jobKeysFor("worker-A"), ["k1"], "job 1 is the current job");
375
+
376
+ // Job 2 begins on the SAME live connection (no disconnect). The worker relaying k2's terminal PROVES
377
+ // k1 finished (one job at a time) → k1 is superseded: released AND flushed to a durable past session.
378
+ hub.handler?.(produce(jobStream("k2"), 1, "job-2 line"), p.conn);
379
+ assertEquals(correlation.jobKeysFor("worker-A"), ["k2"], "the supply row shows ONLY the current job — no accumulation");
380
+ assertEquals(correlation.resolve("k1"), undefined, "the prior job's correlation is released");
381
+ assertEquals(correlation.count(), 1);
382
+
383
+ // The superseded job is not lost — its transcript is flushed and completed, i.e. a replayable past session.
384
+ const priorMeta = service.transcriptOf(jobStream("k1"));
385
+ assertEquals(priorMeta?.status, "completed", "the superseded job becomes a completed past session");
386
+ assertEquals(service.reattach(jobStream("k1"), 0)?.entries.length, 1, "the past session replays its captured terminal");
387
+ service.teardown();
388
+ });
389
+
390
+ test("H6 durable attribution: completing/superseding a job persists the worker's attribution to the correlation store", () => {
391
+ const registry = new ConnectionRegistry();
392
+ const correlation = new CorrelationRegistry();
393
+ const db = memoryDb();
394
+ const store = new AgenticCorrelationStore(db);
395
+ const byConnection = new Map([["prod", "worker-A"]]);
396
+ // The presence-backed resolver: worker-A's durable identity/host, available while it is registered
397
+ // but gone once it exits — which is exactly why the job's attribution must be persisted at completion.
398
+ const attributionForInstance = (instance: string) =>
399
+ instance === "worker-A" ? { identity: "gpu-box-7", host: "us-east-1a" } : undefined;
400
+ const { service, hub } = mkCorrelatedService(registry, db, correlation, byConnection, {
401
+ attributionForInstance,
402
+ correlationStore: store,
403
+ now: () => "2024-01-02T03:04:05.000Z",
404
+ });
405
+ const p = connect("prod", registry);
406
+
407
+ // Job 1 runs, then the worker starts job 2 on the same live connection → job 1 is superseded and
408
+ // released. Its attribution must be durably recorded BEFORE the live correlation forgets it.
409
+ hub.handler?.(produce(jobStream("k1"), 1, "job-1 line"), p.conn);
410
+ hub.handler?.(produce(jobStream("k2"), 1, "job-2 line"), p.conn);
411
+
412
+ const durable = store.get("k1");
413
+ assert(durable !== undefined, "the superseded job's attribution is persisted");
414
+ assertEquals(durable?.instance, "worker-A");
415
+ assertEquals(durable?.identity, "gpu-box-7");
416
+ assertEquals(durable?.host, "us-east-1a");
417
+ assertEquals(durable?.stream, jobStream("k1"));
418
+ assertEquals(durable?.completedAt, "2024-01-02T03:04:05.000Z");
419
+ // The still-active job is NOT yet recorded (attribution is written on completion, not on link).
420
+ assertEquals(store.get("k2"), undefined, "the active job has no completion attribution yet");
421
+ service.teardown();
422
+ });
423
+
352
424
  /**
353
425
  * A {@link CorrelationLink} wrapper that delegates to a real registry but can be flipped to throw on
354
426
  * `link()`/`releaseJob()`, exercising the advisory-resilience contract: `#link`/`#unlink` are