@nanobpm/nano-workforce 0.140.0 → 0.142.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 +12 -0
- package/app/agentic/channel.ts +15 -1
- package/app/agentic/correlation-store.test.ts +105 -12
- package/app/agentic/correlation-store.ts +64 -10
- package/app/agentic/correlation.test.ts +46 -0
- package/app/agentic/correlation.ts +41 -4
- package/app/agentic/element-instance.test.ts +111 -0
- package/app/agentic/element-instance.ts +97 -0
- package/app/agentic/families/relay.family.test.ts +90 -0
- package/app/agentic/families/relay.family.ts +84 -0
- package/app/agentic/registry.ts +17 -3
- package/app/agentic/transcript-read.test.ts +27 -0
- package/app/agentic/transcript-read.ts +11 -0
- package/app/convergeTargets.ts +30 -0
- package/app/deliveryConnector.ts +31 -20
- package/app/deliveryGraph.test.ts +111 -0
- package/app/deliveryGraph.ts +159 -5
- package/app/deliveryGraphCompiler.test.ts +51 -1
- package/app/deliveryGraphCompiler.ts +21 -3
- package/db/migrations/086_agentic_correlation_element_instance.sql +21 -0
- package/docs/adr/0005-agent-authored-delivery-graphs.md +5 -3
- package/docs/agent-guide.md +23 -10
- package/e2e/delivery-graph.e2e.ts +30 -0
- package/main.ts +6 -0
- package/openapi.yaml +21 -2
- package/operations/listAgenticTranscripts.ts +1 -0
- package/package.json +2 -2
- package/workers/delivery-connector/worker.test.ts +26 -8
- package/workers/delivery-connector/worker.ts +15 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.142.0](https://github.com/nanobpm/nano-workforce/compare/v0.141.0...v0.142.0) (2026-08-25)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **delivery-graph:** late-bind converge/wait PR from an emitted `pr` fact ([#548](https://github.com/nanobpm/nano-workforce/issues/548)) ([#550](https://github.com/nanobpm/nano-workforce/issues/550)) ([dbb3aab](https://github.com/nanobpm/nano-workforce/commit/dbb3aabfe8aeaa9951d2ce9f5a2b732eaa99748b))
|
|
6
|
+
|
|
7
|
+
## [0.141.0](https://github.com/nanobpm/nano-workforce/compare/v0.140.0...v0.141.0) (2026-08-25)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **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)
|
|
12
|
+
|
|
1
13
|
## [0.140.0](https://github.com/nanobpm/nano-workforce/compare/v0.139.4...v0.140.0) (2026-08-25)
|
|
2
14
|
|
|
3
15
|
### Features
|
package/app/agentic/channel.ts
CHANGED
|
@@ -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({
|
|
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:
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
"
|
|
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
|
-
|
|
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
|
|
16
|
-
// idempotently on store construction (so unit tests over an in-memory DB have the table)
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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)
|
|
90
|
-
*
|
|
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
|
-
|
|
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
|
+
});
|