@nanobpm/nano-workforce 0.139.4 → 0.141.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 CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.141.0](https://github.com/nanobpm/nano-workforce/compare/v0.140.0...v0.141.0) (2026-08-25)
2
+
3
+ ### Features
4
+
5
+ * **agentic:** key agentic_correlation on elementInstanceKey ([#544](https://github.com/nanobpm/nano-workforce/issues/544)) ([#549](https://github.com/nanobpm/nano-workforce/issues/549)) ([3399597](https://github.com/nanobpm/nano-workforce/commit/33995973f4d207612ba62d6d9c421c40de1bcfba)), closes [nano-ide#473](https://github.com/nanobpm/nano-ide/issues/473)
6
+
7
+ ## [0.140.0](https://github.com/nanobpm/nano-workforce/compare/v0.139.4...v0.140.0) (2026-08-25)
8
+
9
+ ### Features
10
+
11
+ * **agentic:** emit transcriptUrl as a job-output variable (Stage 0, [#543](https://github.com/nanobpm/nano-workforce/issues/543)) ([#547](https://github.com/nanobpm/nano-workforce/issues/547)) ([26b8410](https://github.com/nanobpm/nano-workforce/commit/26b84105405f283acad35ea4026e247fc92d607c)), closes [#544](https://github.com/nanobpm/nano-workforce/issues/544) [#486](https://github.com/nanobpm/nano-workforce/issues/486) [#486](https://github.com/nanobpm/nano-workforce/issues/486)
12
+
1
13
  ## [0.139.4](https://github.com/nanobpm/nano-workforce/compare/v0.139.3...v0.139.4) (2026-08-25)
2
14
 
3
15
  ### Bug Fixes
@@ -22,6 +22,7 @@ import {
22
22
  WebSocketChannelTransport,
23
23
  } from "@nanobpm/agentic/channel";
24
24
  import type { DataLayer, Logger } from "@nanobpm/urban";
25
+ import type { ElementInstanceResolver } from "./element-instance.ts";
25
26
  import { loadAgenticFamilies } from "./loader.ts";
26
27
  import { AgenticFamilyRegistry } from "./registry.ts";
27
28
 
@@ -83,6 +84,12 @@ export interface MountAgenticChannelOptions {
83
84
  readonly secure?: boolean;
84
85
  /** The app's SQLite data layer, threaded to family modules (may be absent when data isn't mounted). */
85
86
  readonly data: DataLayer | undefined;
87
+ /**
88
+ * An advisory, read-only element-instance resolver (#544), threaded to family modules via
89
+ * {@link AgenticContext.resolveElementInstance}. `main.ts` closes it over the shared engine's
90
+ * element-instance wait-state read; absent → families run without element-instance enrichment.
91
+ */
92
+ readonly resolveElementInstance?: ElementInstanceResolver;
86
93
  /** A structured logger for lifecycle lines. */
87
94
  readonly log: Logger;
88
95
  /**
@@ -193,7 +200,14 @@ export async function mountAgenticChannel(
193
200
  let registry: AgenticFamilyRegistry | undefined;
194
201
  try {
195
202
  registry = await (opts.families ? opts.families() : discoverRegistry(log));
196
- await registry.mountAll({ hub, registry: hub.registry, transport, data, log });
203
+ await registry.mountAll({
204
+ hub,
205
+ registry: hub.registry,
206
+ transport,
207
+ data,
208
+ resolveElementInstance: opts.resolveElementInstance,
209
+ log,
210
+ });
197
211
  } catch (err) {
198
212
  await registry?.teardownAll(log);
199
213
  await hub.close();
@@ -1,6 +1,7 @@
1
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;
2
+ // - drift guard: migrations 078 + 086 reproduce AGENTIC_CORRELATION_SCHEMA_SQL's effective schema;
3
3
  // - record/get/byStream round-trips, including the optional (nullable) engine-context columns;
4
+ // - byElementInstance read axis (#544) and its per-occupancy uniqueness;
4
5
  // - upsert semantics (re-recording a jobKey is last-write-wins).
5
6
  import { readFile } from "node:fs/promises";
6
7
  import { dirname, join } from "node:path";
@@ -24,20 +25,60 @@ function memoryDb(): SqliteDb {
24
25
  };
25
26
  }
26
27
 
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("--"))
28
+ /**
29
+ * The effective schema of `agentic_correlation` as SQLite reports it: the ordered column list
30
+ * (name/type/nullability/default/pk) plus the set of indexes (name + normalised definition). This is
31
+ * derivation-over-duplication: comparing the *observed* schema rather than raw DDL text means the
32
+ * migration path and the canonical DDL are pinned to each other regardless of formatting, so the two
33
+ * cannot silently drift even though (post expand-and-contract) they are no longer byte-identical text.
34
+ */
35
+ function effectiveSchema(db: DatabaseSync): string {
36
+ const columns = db
37
+ .prepare("PRAGMA table_info(agentic_correlation)")
38
+ .all()
39
+ .map((c) => {
40
+ const col = c as { name: string; type: string; notnull: number; dflt_value: unknown; pk: number };
41
+ return `${col.name}|${col.type}|${col.notnull}|${col.dflt_value ?? ""}|${col.pk}`;
42
+ })
33
43
  .join("\n");
34
- const normalise = (s: string) => s.trim().replace(/\s+/g, " ");
44
+ const indexes = db
45
+ .prepare(
46
+ "SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = 'agentic_correlation' ORDER BY name",
47
+ )
48
+ .all()
49
+ .map((i) => {
50
+ const idx = i as { name: string; sql: string | null };
51
+ return `${idx.name}|${(idx.sql ?? "").trim().replace(/\s+/g, " ")}`;
52
+ })
53
+ .join("\n");
54
+ return `COLUMNS\n${columns}\nINDEXES\n${indexes}`;
55
+ }
56
+
57
+ test("drift guard: migrations 078 + 086 reproduce AGENTIC_CORRELATION_SCHEMA_SQL's effective schema", async () => {
58
+ const migrationsDir = join(HERE, "..", "..", "db", "migrations");
59
+ const base = await readFile(join(migrationsDir, "078_agentic_correlation.sql"), "utf8");
60
+ const expand = await readFile(join(migrationsDir, "086_agentic_correlation_element_instance.sql"), "utf8");
61
+
62
+ const migrated = new DatabaseSync(":memory:");
63
+ migrated.exec(base);
64
+ migrated.exec(expand);
65
+
66
+ const canonical = new DatabaseSync(":memory:");
67
+ canonical.exec(AGENTIC_CORRELATION_SCHEMA_SQL);
68
+
35
69
  assertEquals(
36
- normalise(ddl),
37
- normalise(AGENTIC_CORRELATION_SCHEMA_SQL),
38
- "078_agentic_correlation.sql drifted from AGENTIC_CORRELATION_SCHEMA_SQL",
70
+ effectiveSchema(migrated),
71
+ effectiveSchema(canonical),
72
+ "migrations 078 + 086 drifted from AGENTIC_CORRELATION_SCHEMA_SQL",
73
+ );
74
+ // The #544 column and its index are present in both paths.
75
+ assert(effectiveSchema(canonical).includes("element_instance_key"), "canonical DDL has element_instance_key");
76
+ assert(
77
+ effectiveSchema(canonical).includes("ix_agentic_correlation_element_instance"),
78
+ "canonical DDL indexes element_instance_key",
39
79
  );
40
- assert(ddl.includes("agentic_correlation"));
80
+ migrated.close();
81
+ canonical.close();
41
82
  });
42
83
 
43
84
  test("record + get round-trips full attribution, and byStream decodes the jobKey", () => {
@@ -67,6 +108,41 @@ test("record + get round-trips full attribution, and byStream decodes the jobKey
67
108
  assertEquals(store.byStream(jobStream("job-1"))?.instance, "worker-A");
68
109
  });
69
110
 
111
+ test("byElementInstance keys per-occupancy, distinguishing a looping/retried activity's iterations", () => {
112
+ const store = new AgenticCorrelationStore(memoryDb());
113
+ // Two runs of the SAME static element (`agent`) in the same process instance — a loop / retry —
114
+ // occupy DISTINCT element instances. Keyed on element_id they'd be indistinguishable; keyed on the
115
+ // element-instance key each resolves to its own attribution (the whole point of #544).
116
+ store.record({
117
+ jobKey: "job-iter-1",
118
+ stream: jobStream("job-iter-1"),
119
+ instance: "worker-A",
120
+ processInstanceKey: "pi-9",
121
+ elementId: "agent",
122
+ elementInstanceKey: "ei-100",
123
+ completedAt: "2026-08-23T00:05:00.000Z",
124
+ });
125
+ store.record({
126
+ jobKey: "job-iter-2",
127
+ stream: jobStream("job-iter-2"),
128
+ instance: "worker-A",
129
+ processInstanceKey: "pi-9",
130
+ elementId: "agent",
131
+ elementInstanceKey: "ei-200",
132
+ completedAt: "2026-08-23T00:10:00.000Z",
133
+ });
134
+
135
+ assertEquals(store.get("job-iter-1")?.elementInstanceKey, "ei-100");
136
+ const first = store.byElementInstance("ei-100");
137
+ assertEquals(first.length, 1);
138
+ assertEquals(first[0].jobKey, "job-iter-1");
139
+ const second = store.byElementInstance("ei-200");
140
+ assertEquals(second.length, 1);
141
+ assertEquals(second[0].jobKey, "job-iter-2");
142
+ assertEquals(store.byElementInstance("ei-nope").length, 0);
143
+ assertEquals(store.byElementInstance("").length, 0);
144
+ });
145
+
70
146
  test("optional context columns are omitted (not null) when unknown", () => {
71
147
  const store = new AgenticCorrelationStore(memoryDb());
72
148
  store.record({
@@ -91,6 +167,23 @@ test("record is an upsert: re-recording a jobKey is last-write-wins", () => {
91
167
  assertEquals(got?.completedAt, "2026-08-23T02:10:00.000Z");
92
168
  });
93
169
 
170
+ test("record preserves an existing element_instance_key when a later re-record omits it (monotonic)", () => {
171
+ const store = new AgenticCorrelationStore(memoryDb());
172
+ const base = { jobKey: "job-4", stream: jobStream("job-4"), completedAt: "2026-08-23T02:00:00.000Z" };
173
+ // The durable backfill path (or a first record that carried the resolved key).
174
+ store.record({ ...base, instance: "worker-D", elementInstanceKey: "ei-777" });
175
+ // A later best-effort re-record that does NOT know the key must not wipe it back to NULL.
176
+ store.record({ ...base, instance: "worker-D", host: "later.local" });
177
+ const got = store.get("job-4");
178
+ assertEquals(got?.host, "later.local");
179
+ assertEquals(got?.elementInstanceKey, "ei-777");
180
+ // setElementInstanceKey backfill then a bare re-record likewise survives.
181
+ store.record({ jobKey: "job-5", stream: jobStream("job-5"), completedAt: "2026-08-23T03:00:00.000Z", instance: "worker-E" });
182
+ store.setElementInstanceKey("job-5", "ei-888");
183
+ store.record({ jobKey: "job-5", stream: jobStream("job-5"), completedAt: "2026-08-23T03:05:00.000Z", instance: "worker-E" });
184
+ assertEquals(store.get("job-5")?.elementInstanceKey, "ei-888");
185
+ });
186
+
94
187
  test("get is undefined for an unknown jobKey and byStream undefined for a non-job stream", () => {
95
188
  const store = new AgenticCorrelationStore(memoryDb());
96
189
  assertEquals(store.get("nope"), undefined);
@@ -12,14 +12,21 @@
12
12
  // by jobKey, so the transcript read path can recover a past session's worker + context after the
13
13
  // worker has exited. Advisory / read-only (ADR 0056) — it NEVER gates a BPMN sequence flow.
14
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.
15
+ // Single source of truth: {@link AGENTIC_CORRELATION_SCHEMA_SQL} is the canonical DDL, applied
16
+ // idempotently on store construction (so unit tests over an in-memory DB have the table). Its
17
+ // EFFECTIVE shape is reproduced by the forward-only migrations `db/migrations/078_agentic_correlation.sql`
18
+ // (base table) + `086_agentic_correlation_element_instance.sql` (#544 expand: the additive, nullable
19
+ // `element_instance_key`). A drift-guard test (`correlation-store.test.ts`) applies those migrations to
20
+ // one DB and the canonical DDL to another and asserts the two schemas are identical, so they can never
21
+ // diverge (the migrations, once merged, are immutable — the canonical DDL is what evolves).
19
22
  import type { SqliteDb } from "@nanobpm/agentic/transcript";
20
23
  import { jobKeyOfStream } from "./correlation.ts";
21
24
 
22
- /** The canonical DDL for the durable correlation table. The `078_*` migration mirrors this exactly. */
25
+ /**
26
+ * The canonical DDL for the durable correlation table. The `078_*` + `086_*` migrations reproduce this
27
+ * effective shape (see the drift-guard test); `element_instance_key` is appended LAST to match the
28
+ * order SQLite gives a column added via `ALTER TABLE ... ADD COLUMN`.
29
+ */
23
30
  export const AGENTIC_CORRELATION_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS agentic_correlation (
24
31
  job_key TEXT PRIMARY KEY,
25
32
  stream TEXT NOT NULL,
@@ -31,11 +38,13 @@ export const AGENTIC_CORRELATION_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS agenti
31
38
  element_id TEXT,
32
39
  plan_key TEXT,
33
40
  linked_at TEXT,
34
- completed_at TEXT NOT NULL
41
+ completed_at TEXT NOT NULL,
42
+ element_instance_key TEXT
35
43
  );
36
44
  CREATE INDEX IF NOT EXISTS ix_agentic_correlation_instance ON agentic_correlation (instance);
37
45
  CREATE INDEX IF NOT EXISTS ix_agentic_correlation_process_instance ON agentic_correlation (process_instance_key);
38
46
  CREATE INDEX IF NOT EXISTS ix_agentic_correlation_plan ON agentic_correlation (plan_key);
47
+ CREATE INDEX IF NOT EXISTS ix_agentic_correlation_element_instance ON agentic_correlation (element_instance_key);
39
48
  `;
40
49
 
41
50
  /** One durable attribution row: which worker ran a job, plus its (best-effort) engine context. */
@@ -53,6 +62,12 @@ export interface DurableCorrelation {
53
62
  readonly linkedAt?: string;
54
63
  /** When the job completed (was flushed / released), ISO-8601. */
55
64
  readonly completedAt: string;
65
+ /**
66
+ * The engine element-instance key the job's token occupies (#544). Unlike {@link elementId} (the
67
+ * STATIC BPMN id, ambiguous across a looping / retried job), this identifies the specific occupancy.
68
+ * Best-effort: undefined for pre-#544 rows and whenever resolution did not land.
69
+ */
70
+ readonly elementInstanceKey?: string;
56
71
  }
57
72
 
58
73
  /** A row as stored (nullable columns come back as `null`). */
@@ -68,6 +83,7 @@ interface Row {
68
83
  plan_key: string | null;
69
84
  linked_at: string | null;
70
85
  completed_at: string;
86
+ element_instance_key: string | null;
71
87
  }
72
88
 
73
89
  function fromRow(r: Row): DurableCorrelation {
@@ -86,6 +102,7 @@ function fromRow(r: Row): DurableCorrelation {
86
102
  ...(r.element_id !== null ? { elementId: r.element_id } : {}),
87
103
  ...(r.plan_key !== null ? { planKey: r.plan_key } : {}),
88
104
  ...(r.linked_at !== null ? { linkedAt: r.linked_at } : {}),
105
+ ...(r.element_instance_key !== null ? { elementInstanceKey: r.element_instance_key } : {}),
89
106
  };
90
107
  }
91
108
 
@@ -104,12 +121,17 @@ export class AgenticCorrelationStore {
104
121
  this.#db.exec(AGENTIC_CORRELATION_SCHEMA_SQL);
105
122
  }
106
123
 
107
- /** Upsert a completed job's attribution (last write wins on jobKey). */
124
+ /**
125
+ * Upsert a completed job's attribution (last write wins on jobKey). One exception: the async
126
+ * `element_instance_key` (#544) is written MONOTONICALLY — a re-record that omits it (a common
127
+ * best-effort enrichment, or a value already set via {@link setElementInstanceKey}) preserves the
128
+ * stored key via COALESCE rather than clobbering it back to NULL.
129
+ */
108
130
  record(entry: DurableCorrelation): void {
109
131
  this.#db.run(
110
132
  `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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
133
+ (job_key, stream, instance, identity, host, process_instance_key, bpmn_process_id, element_id, plan_key, linked_at, completed_at, element_instance_key)
134
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
113
135
  ON CONFLICT(job_key) DO UPDATE SET
114
136
  stream = excluded.stream,
115
137
  instance = excluded.instance,
@@ -120,7 +142,8 @@ export class AgenticCorrelationStore {
120
142
  element_id = excluded.element_id,
121
143
  plan_key = excluded.plan_key,
122
144
  linked_at = excluded.linked_at,
123
- completed_at = excluded.completed_at`,
145
+ completed_at = excluded.completed_at,
146
+ element_instance_key = COALESCE(excluded.element_instance_key, agentic_correlation.element_instance_key)`,
124
147
  [
125
148
  entry.jobKey,
126
149
  entry.stream,
@@ -133,10 +156,26 @@ export class AgenticCorrelationStore {
133
156
  entry.planKey ?? null,
134
157
  entry.linkedAt ?? null,
135
158
  entry.completedAt,
159
+ entry.elementInstanceKey ?? null,
136
160
  ],
137
161
  );
138
162
  }
139
163
 
164
+ /**
165
+ * Backfill the element-instance key onto an ALREADY-RECORDED row (#544), for the race where the
166
+ * asynchronous element-instance resolution returns AFTER the job completed and its attribution was
167
+ * persisted (the live correlation is gone, so it can no longer be enriched in memory). A no-op when
168
+ * no row exists yet (the resolution won the race — {@link record} will carry the key) or the key is
169
+ * empty. Advisory, like the rest of the store — never keyed on for control flow.
170
+ */
171
+ setElementInstanceKey(jobKey: string, elementInstanceKey: string): void {
172
+ if (jobKey === "" || elementInstanceKey === "") return;
173
+ this.#db.run("UPDATE agentic_correlation SET element_instance_key = ? WHERE job_key = ?", [
174
+ elementInstanceKey,
175
+ jobKey,
176
+ ]);
177
+ }
178
+
140
179
  /** The durable attribution for a jobKey, or undefined when none was recorded. */
141
180
  get(jobKey: string): DurableCorrelation | undefined {
142
181
  if (jobKey === "") return undefined;
@@ -159,4 +198,19 @@ export class AgenticCorrelationStore {
159
198
  );
160
199
  return rows.map(fromRow);
161
200
  }
201
+
202
+ /**
203
+ * Every durable attribution recorded for an engine element-instance key (#544), newest completion
204
+ * first. This is the #544 read axis: unlike a static {@link DurableCorrelation.elementId}, an
205
+ * element-instance key names one specific occupancy, so a looping / retried activity's distinct
206
+ * iterations resolve to distinct rows here.
207
+ */
208
+ byElementInstance(elementInstanceKey: string): DurableCorrelation[] {
209
+ if (elementInstanceKey === "") return [];
210
+ const rows = this.#db.all<Row>(
211
+ "SELECT * FROM agentic_correlation WHERE element_instance_key = ? ORDER BY completed_at DESC, job_key DESC",
212
+ [elementInstanceKey],
213
+ );
214
+ return rows.map(fromRow);
215
+ }
162
216
  }
@@ -42,6 +42,38 @@ test("link records context and both projections; resolve carries the job: stream
42
42
  assert.equal(reg.count(), 1);
43
43
  });
44
44
 
45
+ test("attachElementInstance enriches a linked job's context, preserving every other field (#544)", () => {
46
+ const reg = new CorrelationRegistry();
47
+ reg.link("wk-a", "6494", { processInstanceKey: "4612", elementId: "agent", planKey: "o/r#142" });
48
+ reg.attachElementInstance("6494", "ei-77");
49
+ const c = reg.resolve("6494");
50
+ assert.ok(c);
51
+ assert.equal(c.elementInstanceKey, "ei-77");
52
+ // Enrichment is additive — it must not clobber the fields the link established.
53
+ assert.equal(c.processInstanceKey, "4612");
54
+ assert.equal(c.elementId, "agent");
55
+ assert.equal(c.planKey, "o/r#142");
56
+ assert.equal(c.jobKey, "6494");
57
+ assert.equal(c.stream, "job:6494");
58
+ });
59
+
60
+ test("attachElementInstance is a no-op for a released (or never-linked) job or an empty key (#544)", () => {
61
+ const reg = new CorrelationRegistry();
62
+ // Never linked.
63
+ reg.attachElementInstance("nope", "ei-1");
64
+ assert.equal(reg.resolve("nope"), undefined);
65
+ // Released before the resolution landed (the completion-race path).
66
+ reg.link("wk-a", "6494");
67
+ reg.releaseJob("6494");
68
+ reg.attachElementInstance("6494", "ei-1");
69
+ assert.equal(reg.resolve("6494"), undefined);
70
+ // Empty inputs are ignored, and never materialise a context.
71
+ reg.link("wk-b", "7000");
72
+ reg.attachElementInstance("7000", "");
73
+ assert.equal(reg.resolve("7000")?.elementInstanceKey, undefined);
74
+ reg.attachElementInstance("", "ei-1");
75
+ });
76
+
45
77
  test("link ignores empty instance or jobKey", () => {
46
78
  const reg = new CorrelationRegistry();
47
79
  reg.link("", "6494");
@@ -70,6 +102,20 @@ test("re-linking a jobKey to a new instance MOVES it (drops the stale reverse ed
70
102
  assert.equal(reg.count(), 1);
71
103
  });
72
104
 
105
+ test("re-linking a jobKey preserves an already-attached elementInstanceKey (no clobber)", () => {
106
+ const reg = new CorrelationRegistry();
107
+ reg.link("wk-a", "6494");
108
+ reg.attachElementInstance("6494", "ei-42");
109
+ // A subsequent bare re-link (worker reconnect mid-job) must not wipe the async-resolved key.
110
+ reg.link("wk-a", "6494");
111
+ assert.equal(reg.resolve("6494")?.elementInstanceKey, "ei-42");
112
+ // A move to a new worker connection likewise keeps the element-instance the job occupies.
113
+ reg.link("wk-b", "6494", { planKey: "o/r#7" });
114
+ assert.equal(reg.resolve("6494")?.elementInstanceKey, "ei-42");
115
+ assert.equal(reg.resolve("6494")?.planKey, "o/r#7");
116
+ });
117
+
118
+
73
119
  test("releaseJob removes one job from both projections", () => {
74
120
  const reg = new CorrelationRegistry();
75
121
  reg.link("wk-a", "6494");
@@ -55,6 +55,13 @@ export interface JobCorrelation {
55
55
  readonly bpmnProcessId?: string;
56
56
  /** The BPMN element id (activity/task) the job is for, if known. */
57
57
  readonly elementId?: string;
58
+ /**
59
+ * The engine element-instance key the job's token occupies (#544), if resolved. Unlike
60
+ * {@link elementId} (the static id, ambiguous across a looping / retried activity) this names the
61
+ * specific occupancy. Best-effort: it is enriched asynchronously via {@link CorrelationRegistry.attachElementInstance}
62
+ * after the link, so it may be absent for a just-linked job and stays absent if resolution never lands.
63
+ */
64
+ readonly elementInstanceKey?: string;
58
65
  /** The plan / epic key this job is part of (e.g. `owner/repo#142`), if known. */
59
66
  readonly planKey?: string;
60
67
  /** The relay stream id the job's terminal is relayed on (`job:<jobKey>`). */
@@ -86,8 +93,12 @@ export class CorrelationRegistry {
86
93
 
87
94
  /**
88
95
  * Link a worker instance to a job it is now processing, recording the job's engine context. A
89
- * re-link of the same jobKey to a different instance moves it (dropping the old reverse edge); a
90
- * re-link with fresh context overwrites the context (last write wins). Both args must be non-empty.
96
+ * re-link of the same jobKey to a different instance moves it (dropping the old reverse edge). The
97
+ * context is MERGED over any existing attribution, not replaced: explicitly provided fields win
98
+ * (last write wins per field), but fields omitted from `context` are preserved — so a bare re-link
99
+ * cannot clear or clobber context another path already attached (e.g. an async-resolved
100
+ * `elementInstanceKey`). `jobKey`/`stream` are always re-derived canonically. Both args must be
101
+ * non-empty.
91
102
  */
92
103
  link(instance: string, jobKey: string, context: JobContext = {}): void {
93
104
  if (instance === "" || jobKey === "") return;
@@ -100,7 +111,32 @@ export class CorrelationRegistry {
100
111
  const jobs = this.#jobsOf.get(instance) ?? new Set<string>();
101
112
  jobs.add(jobKey);
102
113
  this.#jobsOf.set(instance, jobs);
103
- this.#context.set(jobKey, { jobKey, stream: jobStream(jobKey), ...stripUndefined(context) });
114
+ // Merge over any existing attribution rather than clobbering it: a jobKey is engine-unique, so a
115
+ // re-link (e.g. a worker reconnecting mid-job, or a richer orchestrator context arriving later)
116
+ // must not drop context another path already attached — notably the #544 `elementInstanceKey`,
117
+ // which is resolved asynchronously and could otherwise be wiped by a subsequent bare `link`.
118
+ // Explicitly provided fields still win; `jobKey`/`stream` are always re-derived canonically.
119
+ const existing = this.#context.get(jobKey);
120
+ this.#context.set(jobKey, {
121
+ ...existing,
122
+ jobKey,
123
+ stream: jobStream(jobKey),
124
+ ...stripUndefined(context),
125
+ });
126
+ }
127
+
128
+ /**
129
+ * Enrich a still-linked job's context with the engine element-instance key it occupies (#544),
130
+ * resolved asynchronously after the link (the element-instance read is an engine round-trip the
131
+ * synchronous link path cannot await). Best-effort and idempotent: a no-op if the job is no longer
132
+ * linked (it completed and released before resolution returned — the durable backfill covers that
133
+ * case instead) or if the key is empty. Preserves every other context field.
134
+ */
135
+ attachElementInstance(jobKey: string, elementInstanceKey: string): void {
136
+ if (jobKey === "" || elementInstanceKey === "") return;
137
+ const existing = this.#context.get(jobKey);
138
+ if (existing === undefined) return;
139
+ this.#context.set(jobKey, { ...existing, elementInstanceKey });
104
140
  }
105
141
 
106
142
  /** Release one job (it finished / moved on). No-op if it was never linked. */
@@ -170,11 +206,12 @@ export class CorrelationRegistry {
170
206
 
171
207
  /** Drop `undefined`-valued keys so the stored context never materializes an explicit `{ key: undefined }` hole. */
172
208
  function stripUndefined(context: JobContext): JobContext {
173
- const { processInstanceKey, bpmnProcessId, elementId, planKey } = context;
209
+ const { processInstanceKey, bpmnProcessId, elementId, elementInstanceKey, planKey } = context;
174
210
  return {
175
211
  ...(processInstanceKey !== undefined ? { processInstanceKey } : {}),
176
212
  ...(bpmnProcessId !== undefined ? { bpmnProcessId } : {}),
177
213
  ...(elementId !== undefined ? { elementId } : {}),
214
+ ...(elementInstanceKey !== undefined ? { elementInstanceKey } : {}),
178
215
  ...(planKey !== undefined ? { planKey } : {}),
179
216
  };
180
217
  }
@@ -0,0 +1,111 @@
1
+ // Acceptance test for the #544 element-instance resolver (app/agentic/element-instance.ts).
2
+ //
3
+ // The load-bearing case is the one the issue names: correlation must resolve the CORRECT element
4
+ // instance across a RETRIED / LOOPING job — where the same static BPMN `elementId` occupies several
5
+ // distinct element instances, each with its own jobKey. Keying on `elementId` would be ambiguous;
6
+ // keying on `jobKey` (engine-unique per activation) is not. These tests drive the resolver against a
7
+ // fake wait-state reader and assert each jobKey resolves to its own occupancy's `elementInstanceKey`.
8
+ import { test } from "node:test";
9
+ import type { ElementInstanceWaitState, ElementInstanceWaitStateFilter } from "@nanobpm/urban";
10
+ import { assertEquals } from "#test-assert";
11
+ import {
12
+ type ElementInstanceWaitStateReader,
13
+ resolveElementInstanceKey,
14
+ } from "./element-instance.ts";
15
+
16
+ /** A fake engine wait-state read model: returns the configured parks, honouring the filter. */
17
+ function fakeReader(
18
+ parks: readonly ElementInstanceWaitState[],
19
+ ): ElementInstanceWaitStateReader & { calls: ElementInstanceWaitStateFilter[] } {
20
+ const calls: ElementInstanceWaitStateFilter[] = [];
21
+ return {
22
+ calls,
23
+ searchElementInstanceWaitStates: (filter: ElementInstanceWaitStateFilter = {}) => {
24
+ calls.push(filter);
25
+ const matched = parks.filter((p) => {
26
+ if (filter.waitStateType !== undefined && p.waitStateType !== filter.waitStateType) return false;
27
+ if (filter.processInstanceKey !== undefined && p.processInstanceKey !== filter.processInstanceKey) {
28
+ return false;
29
+ }
30
+ if (filter.elementId !== undefined && p.elementId !== filter.elementId) return false;
31
+ return true;
32
+ });
33
+ return Promise.resolve(matched);
34
+ },
35
+ };
36
+ }
37
+
38
+ /** Build a JOB wait-state park (a service task awaiting a worker). */
39
+ function jobPark(over: {
40
+ elementInstanceKey: string;
41
+ jobKey: string;
42
+ processInstanceKey?: string;
43
+ elementId?: string;
44
+ }): ElementInstanceWaitState {
45
+ return {
46
+ elementInstanceKey: over.elementInstanceKey,
47
+ processInstanceKey: over.processInstanceKey ?? "pi-1",
48
+ elementId: over.elementId ?? "agent",
49
+ waitStateType: "JOB",
50
+ jobType: "senior:feature",
51
+ jobKey: over.jobKey,
52
+ };
53
+ }
54
+
55
+ test("resolves the element instance whose JOB park carries the matching jobKey", async () => {
56
+ const reader = fakeReader([jobPark({ elementInstanceKey: "ei-100", jobKey: "job-abc" })]);
57
+ assertEquals(await resolveElementInstanceKey(reader, "job-abc"), "ei-100");
58
+ });
59
+
60
+ test("looping/retried job: same elementId across iterations resolves each jobKey to its own instance", async () => {
61
+ // Three live parks for the SAME static element `agent` in the SAME process instance — the shape a
62
+ // retried / looping activity produces: distinct element instances, distinct jobKeys, one elementId.
63
+ const reader = fakeReader([
64
+ jobPark({ elementInstanceKey: "ei-1", jobKey: "job-iter-1", elementId: "agent" }),
65
+ jobPark({ elementInstanceKey: "ei-2", jobKey: "job-iter-2", elementId: "agent" }),
66
+ jobPark({ elementInstanceKey: "ei-3", jobKey: "job-iter-3", elementId: "agent" }),
67
+ ]);
68
+
69
+ // Keying on elementId would be ambiguous (all three share `agent`); keying on jobKey is exact.
70
+ assertEquals(await resolveElementInstanceKey(reader, "job-iter-1"), "ei-1");
71
+ assertEquals(await resolveElementInstanceKey(reader, "job-iter-2"), "ei-2");
72
+ assertEquals(await resolveElementInstanceKey(reader, "job-iter-3"), "ei-3");
73
+ });
74
+
75
+ test("scopes the engine search to JOB parks, and to the process instance when known", async () => {
76
+ const reader = fakeReader([
77
+ jobPark({ elementInstanceKey: "ei-a", jobKey: "job-a", processInstanceKey: "pi-1" }),
78
+ jobPark({ elementInstanceKey: "ei-b", jobKey: "job-b", processInstanceKey: "pi-2" }),
79
+ ]);
80
+ assertEquals(await resolveElementInstanceKey(reader, "job-b", { processInstanceKey: "pi-2" }), "ei-b");
81
+ assertEquals(reader.calls.length, 1);
82
+ assertEquals(reader.calls[0].waitStateType, "JOB");
83
+ assertEquals(reader.calls[0].processInstanceKey, "pi-2");
84
+ });
85
+
86
+ test("unscoped resolution still matches on jobKey across process instances", async () => {
87
+ const reader = fakeReader([
88
+ jobPark({ elementInstanceKey: "ei-a", jobKey: "job-a", processInstanceKey: "pi-1" }),
89
+ jobPark({ elementInstanceKey: "ei-b", jobKey: "job-b", processInstanceKey: "pi-2" }),
90
+ ]);
91
+ assertEquals(await resolveElementInstanceKey(reader, "job-b"), "ei-b");
92
+ assertEquals(reader.calls[0].processInstanceKey, undefined);
93
+ });
94
+
95
+ test("returns undefined when the job is not parked (completed / released) or jobKey is empty", async () => {
96
+ const reader = fakeReader([jobPark({ elementInstanceKey: "ei-1", jobKey: "job-live" })]);
97
+ assertEquals(await resolveElementInstanceKey(reader, "job-gone"), undefined);
98
+ assertEquals(await resolveElementInstanceKey(reader, ""), undefined);
99
+ });
100
+
101
+ test("ignores a non-JOB park that happens to share the process instance", async () => {
102
+ const parks: ElementInstanceWaitState[] = [
103
+ { elementInstanceKey: "ei-msg", processInstanceKey: "pi-1", elementId: "wait", waitStateType: "MESSAGE", messageName: "m" },
104
+ jobPark({ elementInstanceKey: "ei-job", jobKey: "job-x", processInstanceKey: "pi-1" }),
105
+ ];
106
+ // Even if the engine ignored the JOB filter, only the JOB park's jobKey can match.
107
+ const reader: ElementInstanceWaitStateReader = {
108
+ searchElementInstanceWaitStates: () => Promise.resolve(parks),
109
+ };
110
+ assertEquals(await resolveElementInstanceKey(reader, "job-x"), "ei-job");
111
+ });