@nanobpm/nano-workforce 0.118.0 → 0.118.2
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/.github/workflows/invariants.yml +8 -5
- package/.github/workflows/release.yml +55 -6
- package/CHANGELOG.md +15 -0
- package/app/agentic/families/presence.family.test.ts +12 -0
- package/app/agentic/families/presence.family.ts +15 -0
- package/app/agentic/families/relay.family.test.ts +227 -0
- package/app/agentic/families/relay.family.ts +137 -20
- package/package.json +3 -3
|
@@ -56,11 +56,14 @@ jobs:
|
|
|
56
56
|
- name: Check BPMN diagram freshness (layout)
|
|
57
57
|
run: npm run layout:check
|
|
58
58
|
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
-
|
|
63
|
-
|
|
59
|
+
# NOTE: there is deliberately NO `urban gen --check` (`gen:check`) step here. In THIS repo the
|
|
60
|
+
# only `urban gen` output is `nano-generated/`, which is GIT-IGNORED (since the initial commit)
|
|
61
|
+
# and regenerated at build time via `pretypecheck`/`pretest` (`=> urban gen`). It is never a
|
|
62
|
+
# committed artifact, so there is nothing to freshness-check — on a clean checkout `urban gen
|
|
63
|
+
# --check` only reports it "missing" and fails every run (#423). Because it is regenerated from
|
|
64
|
+
# the MERGED sources on every typecheck/test, it cannot merge-skew; merged-tree correctness of
|
|
65
|
+
# the generated code is already covered by the `typecheck + test (Node)` job, which runs
|
|
66
|
+
# `urban gen` then compiles on the `merge_group`/`push` commit. Do not re-add `gen:check` here.
|
|
64
67
|
|
|
65
68
|
# Navigation index is another checked-in derived artifact; re-assert it on the merged tree too.
|
|
66
69
|
- name: Check navigation index freshness
|
|
@@ -1,24 +1,72 @@
|
|
|
1
1
|
name: Release
|
|
2
2
|
|
|
3
3
|
on:
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
# Gate releases on green CI: run only AFTER the required workflows finish, and
|
|
5
|
+
# only release when EVERY required check is `success` for that commit (#423).
|
|
6
|
+
# A release must never ship from a red `main`.
|
|
7
|
+
workflow_run:
|
|
8
|
+
workflows: ["CI", "Whole-repo invariants (merge-skew guard)"]
|
|
9
|
+
types: [completed]
|
|
6
10
|
|
|
7
11
|
# Prevent overlapping releases from racing on the same branch.
|
|
8
12
|
concurrency:
|
|
9
|
-
group: release
|
|
13
|
+
group: release-main
|
|
10
14
|
cancel-in-progress: false
|
|
11
15
|
|
|
12
16
|
permissions:
|
|
13
17
|
contents: read
|
|
14
18
|
|
|
15
19
|
jobs:
|
|
20
|
+
# Whichever gating workflow finishes LAST (successfully) re-triggers this and
|
|
21
|
+
# finds every required check green → releases. An earlier trigger, while a
|
|
22
|
+
# sibling gate is still pending, resolves to `should_release=false` (neutral —
|
|
23
|
+
# no release, no failure). If any required check fails, no trigger ever sees an
|
|
24
|
+
# all-green commit, so nothing releases.
|
|
25
|
+
gate:
|
|
26
|
+
name: release gate
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
if: >-
|
|
29
|
+
github.event.workflow_run.event == 'push' &&
|
|
30
|
+
github.event.workflow_run.head_branch == 'main' &&
|
|
31
|
+
github.event.workflow_run.conclusion == 'success'
|
|
32
|
+
permissions:
|
|
33
|
+
checks: read
|
|
34
|
+
contents: read
|
|
35
|
+
outputs:
|
|
36
|
+
should_release: ${{ steps.gate.outputs.should_release }}
|
|
37
|
+
steps:
|
|
38
|
+
- name: Require every required check green for this commit
|
|
39
|
+
id: gate
|
|
40
|
+
env:
|
|
41
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
42
|
+
SHA: ${{ github.event.workflow_run.head_sha }}
|
|
43
|
+
REPO: ${{ github.repository }}
|
|
44
|
+
run: |
|
|
45
|
+
set -euo pipefail
|
|
46
|
+
# These must ALL be `success` on the release commit before we ship. Keep
|
|
47
|
+
# in lock-step with the `main` ruleset's required status checks.
|
|
48
|
+
required=("typecheck + test (Node)" "whole-repo invariants")
|
|
49
|
+
# Dedupe to ONE row per check name — the latest run (max .id) — so re-runs
|
|
50
|
+
# can't leave us reading a stale conclusion. Slurp every page into a
|
|
51
|
+
# single array with `jq -s` (NOT `gh --slurp`, which gh rejects when
|
|
52
|
+
# combined with `--jq`) so `group_by` sees every run for the SHA; jq's
|
|
53
|
+
# `group_by` sorts by the key first, so unsorted input can't split a name.
|
|
54
|
+
runs="$(gh api "repos/$REPO/commits/$SHA/check-runs?per_page=100" --paginate \
|
|
55
|
+
| jq -r -s '[.[].check_runs[]] | group_by(.name) | map(max_by(.id))
|
|
56
|
+
| .[] | [.name, (.conclusion // "pending")] | @tsv')"
|
|
57
|
+
should=true
|
|
58
|
+
for name in "${required[@]}"; do
|
|
59
|
+
concl="$(printf '%s\n' "$runs" | awk -F'\t' -v n="$name" '$1==n{print $2}')"
|
|
60
|
+
echo "gate: '$name' = ${concl:-missing}"
|
|
61
|
+
[ "${concl:-missing}" = "success" ] || should=false
|
|
62
|
+
done
|
|
63
|
+
echo "should_release=$should" >> "$GITHUB_OUTPUT"
|
|
64
|
+
|
|
16
65
|
release:
|
|
17
66
|
name: semantic-release
|
|
67
|
+
needs: gate
|
|
68
|
+
if: needs.gate.outputs.should_release == 'true'
|
|
18
69
|
runs-on: ubuntu-latest
|
|
19
|
-
# `[skip ci]` on the release commit already prevents a re-trigger, but guard
|
|
20
|
-
# against loops from any automated release commit as well.
|
|
21
|
-
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
|
22
70
|
permissions:
|
|
23
71
|
contents: write # create tags, GitHub Releases, push CHANGELOG/version bump
|
|
24
72
|
issues: write # comment on released issues
|
|
@@ -28,6 +76,7 @@ jobs:
|
|
|
28
76
|
- name: Checkout
|
|
29
77
|
uses: actions/checkout@v4
|
|
30
78
|
with:
|
|
79
|
+
ref: ${{ github.event.workflow_run.head_sha }} # release the exact gated commit
|
|
31
80
|
fetch-depth: 0 # semantic-release needs full history + tags
|
|
32
81
|
persist-credentials: true # let @semantic-release/git push the release commit
|
|
33
82
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
## [0.118.2](https://github.com/nanobpm/nano-workforce/compare/v0.118.1...v0.118.2) (2026-08-21)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **deps:** update dependency @nanobpm/urban to ^0.75.0 ([#427](https://github.com/nanobpm/nano-workforce/issues/427)) ([ac52457](https://github.com/nanobpm/nano-workforce/commit/ac52457c9c07ab951ae1ed415cb62fed60df9e4e))
|
|
7
|
+
|
|
8
|
+
## [0.118.1](https://github.com/nanobpm/nano-workforce/compare/v0.118.0...v0.118.1) (2026-08-21)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **ci:** gate releases on green CI and drop perma-red gen:check ([#424](https://github.com/nanobpm/nano-workforce/issues/424)) ([a57746b](https://github.com/nanobpm/nano-workforce/commit/a57746bafa264bf480eb8053cc2e133aa450f8b7))
|
|
14
|
+
* **release:** use jq -s to slurp check-runs; gh --slurp rejects --jq ([#425](https://github.com/nanobpm/nano-workforce/issues/425)) ([d5c3fce](https://github.com/nanobpm/nano-workforce/commit/d5c3fce2652f0b275a5d71d6c8e45e47c403a289))
|
|
15
|
+
|
|
1
16
|
# [0.118.0](https://github.com/nanobpm/nano-workforce/compare/v0.117.0...v0.118.0) (2026-08-21)
|
|
2
17
|
|
|
3
18
|
|
|
@@ -126,6 +126,18 @@ test("registeredWorkers: returns the canonical {instance, capability} supply row
|
|
|
126
126
|
]);
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
+
test("instanceForConnection: resolves the worker instance owning a connection (H6 write-side seam)", () => {
|
|
130
|
+
const store = createPresenceStore(memSqlite());
|
|
131
|
+
store.ensureSchema();
|
|
132
|
+
store.register({ instance: "w1", connectionId: "c1", identity: "leaf", capability: {} });
|
|
133
|
+
store.register({ instance: "w2", connectionId: "c2", identity: "leaf", capability: {} });
|
|
134
|
+
const registry = new PresenceRegistry(store, () => new Set(["c1", "c2"]));
|
|
135
|
+
assertEquals(registry.instanceForConnection("c1"), "w1");
|
|
136
|
+
assertEquals(registry.instanceForConnection("c2"), "w2");
|
|
137
|
+
assertEquals(registry.instanceForConnection("nope"), undefined, "unknown connection → undefined");
|
|
138
|
+
assertEquals(registry.instanceForConnection(""), undefined, "empty connection → undefined");
|
|
139
|
+
});
|
|
140
|
+
|
|
129
141
|
test("reconcile: removes rows whose connection the hub has closed, keeps live ones", () => {
|
|
130
142
|
const store = createPresenceStore(memSqlite());
|
|
131
143
|
store.ensureSchema();
|
|
@@ -133,6 +133,21 @@ export class PresenceRegistry {
|
|
|
133
133
|
return this.#store.list().map((row) => ({ instance: row.instance, capability: { ...row.capability } }));
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/**
|
|
137
|
+
* The worker instance registered on a given connection, or undefined when none is. This is the
|
|
138
|
+
* connection → instance resolver the relay/correlation slice (H6, #149) uses to attribute a
|
|
139
|
+
* `produce` frame for a `job:<jobKey>` stream to the presence instance owning the producing
|
|
140
|
+
* connection — the one fact the relay holds (the connection id) turned into the join key the
|
|
141
|
+
* correlation registry needs. An empty connection id (or an unknown one) resolves to undefined.
|
|
142
|
+
*/
|
|
143
|
+
instanceForConnection(connectionId: string): string | undefined {
|
|
144
|
+
if (connectionId === "") return undefined;
|
|
145
|
+
for (const row of this.#store.list()) {
|
|
146
|
+
if (row.connectionId === connectionId) return row.instance;
|
|
147
|
+
}
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
136
151
|
/**
|
|
137
152
|
* Eagerly drop presence rows whose connection the hub has already closed (a disconnect the hub's
|
|
138
153
|
* single close listener removed from its in-memory registry). Rows also age out on the presence
|
|
@@ -19,7 +19,9 @@ import { RELAY_FAMILY } from "@nanobpm/agentic/relay";
|
|
|
19
19
|
import { type SqliteDb, TRANSCRIPT_SCHEMA_SQL } from "@nanobpm/agentic/transcript";
|
|
20
20
|
import { assert, assertEquals } from "#test-assert";
|
|
21
21
|
import { noopLog } from "../../../test/log.ts";
|
|
22
|
+
import { CorrelationRegistry, jobStream } from "../correlation.ts";
|
|
22
23
|
import {
|
|
24
|
+
type CorrelationLink,
|
|
23
25
|
createRelayFamily,
|
|
24
26
|
currentRelayTranscriptService,
|
|
25
27
|
family as relayFamily,
|
|
@@ -131,6 +133,25 @@ function mkService(registry: ConnectionRegistry, db: SqliteDb | undefined): {
|
|
|
131
133
|
return { service, hub };
|
|
132
134
|
}
|
|
133
135
|
|
|
136
|
+
/** Build a service with the H6 correlation write-side wired (a real registry + a connection→instance map). */
|
|
137
|
+
function mkCorrelatedService(
|
|
138
|
+
registry: ConnectionRegistry,
|
|
139
|
+
db: SqliteDb | undefined,
|
|
140
|
+
correlation: CorrelationRegistry,
|
|
141
|
+
byConnection: Map<string, string>,
|
|
142
|
+
): { service: RelayTranscriptService; hub: CapturingHub } {
|
|
143
|
+
const hub = capturingHub();
|
|
144
|
+
const service = new RelayTranscriptService({
|
|
145
|
+
hub,
|
|
146
|
+
registry,
|
|
147
|
+
db,
|
|
148
|
+
log: noopLog(),
|
|
149
|
+
correlation: () => correlation,
|
|
150
|
+
instanceForConnection: (id) => byConnection.get(id),
|
|
151
|
+
});
|
|
152
|
+
return { service, hub };
|
|
153
|
+
}
|
|
154
|
+
|
|
134
155
|
test("the family exports a valid AgenticFamily named 'relay'", () => {
|
|
135
156
|
assertEquals(relayFamily.name, RELAY_FAMILY_NAME);
|
|
136
157
|
assertEquals(relayFamily.name, "relay");
|
|
@@ -262,6 +283,212 @@ test("retention: a disconnected producer auto-completes its ephemeral stream on
|
|
|
262
283
|
service.teardown();
|
|
263
284
|
});
|
|
264
285
|
|
|
286
|
+
test("H6 correlation write-side: a produce on job:<k> links instance→[k]; stream completion releases it", () => {
|
|
287
|
+
const registry = new ConnectionRegistry();
|
|
288
|
+
const correlation = new CorrelationRegistry();
|
|
289
|
+
const byConnection = new Map([["prod", "worker-A"]]);
|
|
290
|
+
const { service, hub } = mkCorrelatedService(registry, memoryDb(), correlation, byConnection);
|
|
291
|
+
const p = connect("prod", registry);
|
|
292
|
+
|
|
293
|
+
// The first `produce` for a job-scoped stream links the producing worker instance → jobKey, from
|
|
294
|
+
// data already crossing the wire (jobKey decoded from the stream id; instance from the connection).
|
|
295
|
+
hub.handler?.(produce(jobStream("k1"), 1, "chunk"), p.conn);
|
|
296
|
+
assertEquals(correlation.jobKeysFor("worker-A"), ["k1"], "instance → [jobKey] is now linked");
|
|
297
|
+
assertEquals(correlation.resolve("k1")?.stream, jobStream("k1"), "context carries the job-scoped stream");
|
|
298
|
+
assertEquals(correlation.count(), 1);
|
|
299
|
+
|
|
300
|
+
// Job end (stream completion) releases the correlation, so the worker's supply row clears it.
|
|
301
|
+
service.completeStream(jobStream("k1"));
|
|
302
|
+
assertEquals(correlation.jobKeysFor("worker-A"), [], "completion releases the job");
|
|
303
|
+
assertEquals(correlation.resolve("k1"), undefined);
|
|
304
|
+
assertEquals(correlation.count(), 0);
|
|
305
|
+
service.teardown();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("H6 correlation write-side: an unpersisted stream completion releases its job correlation and stops reconcile retrying", () => {
|
|
309
|
+
const registry = new ConnectionRegistry();
|
|
310
|
+
const correlation = new CorrelationRegistry();
|
|
311
|
+
const byConnection = new Map([["prod", "worker-U"]]);
|
|
312
|
+
// No DataLayer → the relay runs unpersisted. A direct completeStream (job end, not a disconnect)
|
|
313
|
+
// must still transition in-memory state: unlink correlation, mark ephemeral completed, drop the
|
|
314
|
+
// producer — otherwise #reconcile keeps re-completing the stream on every subsequent frame.
|
|
315
|
+
const { service, hub } = mkCorrelatedService(registry, undefined, correlation, byConnection);
|
|
316
|
+
const p = connect("prod", registry);
|
|
317
|
+
hub.handler?.(produce(jobStream("kU"), 1, "x"), p.conn);
|
|
318
|
+
assertEquals(correlation.jobKeysFor("worker-U"), ["kU"]);
|
|
319
|
+
|
|
320
|
+
service.completeStream(jobStream("kU"));
|
|
321
|
+
assertEquals(correlation.jobKeysFor("worker-U"), [], "unpersisted completion releases the job");
|
|
322
|
+
assertEquals(correlation.count(), 0);
|
|
323
|
+
|
|
324
|
+
// The producer is still live, but the stream is now completed: a later frame's #reconcile must not
|
|
325
|
+
// re-link or otherwise resurrect the released correlation.
|
|
326
|
+
hub.handler?.(grant(0), p.conn);
|
|
327
|
+
assertEquals(correlation.count(), 0, "completed stream stays released; reconcile does not retry");
|
|
328
|
+
service.teardown();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("H6 correlation write-side: a late produce after completion does not resurrect the released correlation", () => {
|
|
332
|
+
const registry = new ConnectionRegistry();
|
|
333
|
+
const correlation = new CorrelationRegistry();
|
|
334
|
+
const byConnection = new Map([["prod", "worker-R"]]);
|
|
335
|
+
const { service, hub } = mkCorrelatedService(registry, undefined, correlation, byConnection);
|
|
336
|
+
const p = connect("prod", registry);
|
|
337
|
+
hub.handler?.(produce(jobStream("kR"), 1, "x"), p.conn);
|
|
338
|
+
assertEquals(correlation.jobKeysFor("worker-R"), ["kR"]);
|
|
339
|
+
|
|
340
|
+
// Job end completes the stream and releases the correlation.
|
|
341
|
+
service.completeStream(jobStream("kR"));
|
|
342
|
+
assertEquals(correlation.count(), 0, "completion releases the job");
|
|
343
|
+
|
|
344
|
+
// A late `produce` frame for the same, still-live producer must NOT re-link/re-own the completed
|
|
345
|
+
// stream — otherwise it resurrects a jobKey after it was released.
|
|
346
|
+
hub.handler?.(produce(jobStream("kR"), 1, "late"), p.conn);
|
|
347
|
+
assertEquals(correlation.count(), 0, "a late produce does not resurrect a completed stream");
|
|
348
|
+
assertEquals(correlation.jobKeysFor("worker-R"), [], "released jobKey stays released after a late produce");
|
|
349
|
+
service.teardown();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* A {@link CorrelationLink} wrapper that delegates to a real registry but can be flipped to throw on
|
|
354
|
+
* `link()`/`releaseJob()`, exercising the advisory-resilience contract: `#link`/`#unlink` are
|
|
355
|
+
* documented "never throws into the frame handler", so a throwing injectable correlation must not
|
|
356
|
+
* take down the relay frame handler, and the stream must stay retryable.
|
|
357
|
+
*/
|
|
358
|
+
function throwingCorrelation(inner: CorrelationRegistry): {
|
|
359
|
+
correlation: CorrelationLink;
|
|
360
|
+
failLink: (on: boolean) => void;
|
|
361
|
+
failRelease: (on: boolean) => void;
|
|
362
|
+
} {
|
|
363
|
+
let linkFails = false;
|
|
364
|
+
let releaseFails = false;
|
|
365
|
+
return {
|
|
366
|
+
correlation: {
|
|
367
|
+
link: (instance, jobKey, context) => {
|
|
368
|
+
if (linkFails) throw new Error("correlation.link boom");
|
|
369
|
+
inner.link(instance, jobKey, context);
|
|
370
|
+
},
|
|
371
|
+
releaseJob: (jobKey) => {
|
|
372
|
+
if (releaseFails) throw new Error("correlation.releaseJob boom");
|
|
373
|
+
inner.releaseJob(jobKey);
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
failLink: (on) => {
|
|
377
|
+
linkFails = on;
|
|
378
|
+
},
|
|
379
|
+
failRelease: (on) => {
|
|
380
|
+
releaseFails = on;
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
test("H6 correlation write-side: a throwing correlation.link() never escapes the frame handler and leaves the stream retryable", () => {
|
|
386
|
+
const registry = new ConnectionRegistry();
|
|
387
|
+
const inner = new CorrelationRegistry();
|
|
388
|
+
const { correlation, failLink } = throwingCorrelation(inner);
|
|
389
|
+
const byConnection = new Map([["prod", "worker-L"]]);
|
|
390
|
+
const hub = capturingHub();
|
|
391
|
+
const service = new RelayTranscriptService({
|
|
392
|
+
hub,
|
|
393
|
+
registry,
|
|
394
|
+
db: undefined,
|
|
395
|
+
log: noopLog(),
|
|
396
|
+
correlation: () => correlation,
|
|
397
|
+
instanceForConnection: (id) => byConnection.get(id),
|
|
398
|
+
});
|
|
399
|
+
const p = connect("prod", registry);
|
|
400
|
+
|
|
401
|
+
// The first `produce` links — but the injected correlation throws. It must be swallowed (advisory),
|
|
402
|
+
// so the frame handler does not throw and the stream is left UNLINKED so a later produce retries.
|
|
403
|
+
failLink(true);
|
|
404
|
+
hub.handler?.(produce(jobStream("kL"), 1, "x"), p.conn);
|
|
405
|
+
assertEquals(inner.count(), 0, "a throwing link is swallowed and records no correlation");
|
|
406
|
+
|
|
407
|
+
// A later produce (link now succeeds) retries the link — proving the stream was left unlinked.
|
|
408
|
+
failLink(false);
|
|
409
|
+
hub.handler?.(produce(jobStream("kL"), 1, "y"), p.conn);
|
|
410
|
+
assertEquals(inner.jobKeysFor("worker-L"), ["kL"], "the link retries and succeeds on a later frame");
|
|
411
|
+
assertEquals(inner.count(), 1);
|
|
412
|
+
service.teardown();
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("H6 correlation write-side: a throwing correlation.releaseJob() never escapes the frame handler; completion still finalizes", () => {
|
|
416
|
+
const registry = new ConnectionRegistry();
|
|
417
|
+
const inner = new CorrelationRegistry();
|
|
418
|
+
const { correlation, failRelease } = throwingCorrelation(inner);
|
|
419
|
+
const byConnection = new Map([["prod", "worker-X"]]);
|
|
420
|
+
const hub = capturingHub();
|
|
421
|
+
const service = new RelayTranscriptService({
|
|
422
|
+
hub,
|
|
423
|
+
registry,
|
|
424
|
+
db: undefined,
|
|
425
|
+
log: noopLog(),
|
|
426
|
+
correlation: () => correlation,
|
|
427
|
+
instanceForConnection: (id) => byConnection.get(id),
|
|
428
|
+
});
|
|
429
|
+
const p = connect("prod", registry);
|
|
430
|
+
hub.handler?.(produce(jobStream("kX"), 1, "x"), p.conn);
|
|
431
|
+
assertEquals(inner.jobKeysFor("worker-X"), ["kX"]);
|
|
432
|
+
|
|
433
|
+
// A producer disconnect drives #reconcile → #unlink, but releaseJob throws. The advisory contract
|
|
434
|
+
// is "never throws into the frame handler": the throw must be swallowed so the reconcile pass (and
|
|
435
|
+
// the completion it drives) do not bubble out and take down unrelated streams' relay processing.
|
|
436
|
+
failRelease(true);
|
|
437
|
+
registry.remove("prod");
|
|
438
|
+
const other = connect("cons", registry);
|
|
439
|
+
hub.handler?.(grant(0), other.conn); // must NOT throw despite releaseJob throwing
|
|
440
|
+
|
|
441
|
+
// The stream still finalizes (terminal) so #reconcile does not thrash it every frame, and a later
|
|
442
|
+
// frame's reconcile is a clean no-op rather than a repeated crash.
|
|
443
|
+
hub.handler?.(produce(jobStream("kX"), 2, "late"), p.conn);
|
|
444
|
+
assertEquals(
|
|
445
|
+
inner.jobKeysFor("worker-X"),
|
|
446
|
+
["kX"],
|
|
447
|
+
"a swallowed release leaves the correlation held (linked), never a crash",
|
|
448
|
+
);
|
|
449
|
+
service.teardown();
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("H6 correlation write-side: a producer disconnect releases its job correlation even unpersisted", () => {
|
|
453
|
+
const registry = new ConnectionRegistry();
|
|
454
|
+
const correlation = new CorrelationRegistry();
|
|
455
|
+
const byConnection = new Map([["prod", "worker-B"]]);
|
|
456
|
+
// No DataLayer → the relay runs unpersisted; correlation release must still fire (store-independent).
|
|
457
|
+
const { service, hub } = mkCorrelatedService(registry, undefined, correlation, byConnection);
|
|
458
|
+
const p = connect("prod", registry);
|
|
459
|
+
hub.handler?.(produce(jobStream("k2"), 1, "x"), p.conn);
|
|
460
|
+
assertEquals(correlation.jobKeysFor("worker-B"), ["k2"]);
|
|
461
|
+
|
|
462
|
+
// Producer drops; a subsequent frame from any live connection reconciles the dead producer.
|
|
463
|
+
registry.remove("prod");
|
|
464
|
+
const other = connect("cons", registry);
|
|
465
|
+
hub.handler?.(grant(0), other.conn);
|
|
466
|
+
assertEquals(correlation.jobKeysFor("worker-B"), [], "disconnect releases the job");
|
|
467
|
+
assertEquals(correlation.count(), 0);
|
|
468
|
+
service.teardown();
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("H6 correlation write-side: non-job streams are never linked; a link retries until the instance resolves", () => {
|
|
472
|
+
const registry = new ConnectionRegistry();
|
|
473
|
+
const correlation = new CorrelationRegistry();
|
|
474
|
+
const byConnection = new Map<string, string>();
|
|
475
|
+
const { service, hub } = mkCorrelatedService(registry, memoryDb(), correlation, byConnection);
|
|
476
|
+
const p = connect("prod", registry);
|
|
477
|
+
|
|
478
|
+
// A plain (non-`job:`) stream carries no jobKey → never correlated.
|
|
479
|
+
hub.handler?.(produce("plain-stream", 1, "x"), p.conn);
|
|
480
|
+
assertEquals(correlation.count(), 0, "non-job streams are not linked");
|
|
481
|
+
|
|
482
|
+
// A job stream whose producer's presence instance is not yet known does not link — but a later
|
|
483
|
+
// frame (after the register lands) retries and links, closing the register/produce race.
|
|
484
|
+
hub.handler?.(produce(jobStream("k3"), 1, "a"), p.conn);
|
|
485
|
+
assertEquals(correlation.count(), 0, "no presence instance yet → not linked");
|
|
486
|
+
byConnection.set("prod", "worker-C");
|
|
487
|
+
hub.handler?.(produce(jobStream("k3"), 1, "b"), p.conn);
|
|
488
|
+
assertEquals(correlation.jobKeysFor("worker-C"), ["k3"], "retried link succeeds once the instance resolves");
|
|
489
|
+
service.teardown();
|
|
490
|
+
});
|
|
491
|
+
|
|
265
492
|
test("retention: a long-lived stream is checkpointed + reattachable and never auto-completed", () => {
|
|
266
493
|
const registry = new ConnectionRegistry();
|
|
267
494
|
const db = memoryDb();
|
|
@@ -33,7 +33,9 @@ import {
|
|
|
33
33
|
type TranscriptStream,
|
|
34
34
|
} from "@nanobpm/agentic/transcript";
|
|
35
35
|
import type { Logger } from "@nanobpm/urban";
|
|
36
|
+
import { currentCorrelation, type JobContext, jobKeyOfStream } from "../correlation.ts";
|
|
36
37
|
import type { AgenticContext, AgenticFamily } from "../registry.ts";
|
|
38
|
+
import { currentPresenceRegistry } from "./presence.family.ts";
|
|
37
39
|
|
|
38
40
|
/** The stable family name this slice registers under the seam (distinct from the wire family key). */
|
|
39
41
|
export const RELAY_FAMILY_NAME = "relay";
|
|
@@ -81,6 +83,22 @@ interface StreamState {
|
|
|
81
83
|
producer?: string;
|
|
82
84
|
/** Set once an ephemeral stream has been flushed & completed (so it is not re-completed). */
|
|
83
85
|
completed: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Set once a `job:<jobKey>` stream has been linked into the correlation registry (H6, #149), so
|
|
88
|
+
* the link is attempted at most once per stream. It stays `false` while the producer's presence
|
|
89
|
+
* instance is not yet resolvable (a register/produce race), so a later `produce` frame retries.
|
|
90
|
+
*/
|
|
91
|
+
linked: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The minimal correlation write-side the relay slice drives (H6, #149): link a producing worker
|
|
96
|
+
* instance to the jobKey it is relaying, and release it when the job's stream ends. Structural so
|
|
97
|
+
* the service depends on a shape, not the whole {@link CorrelationRegistry}.
|
|
98
|
+
*/
|
|
99
|
+
export interface CorrelationLink {
|
|
100
|
+
link(instance: string, jobKey: string, context?: JobContext): void;
|
|
101
|
+
releaseJob(jobKey: string): void;
|
|
84
102
|
}
|
|
85
103
|
|
|
86
104
|
export interface RelayTranscriptServiceOptions {
|
|
@@ -104,6 +122,21 @@ export interface RelayTranscriptServiceOptions {
|
|
|
104
122
|
* against a bare source too (and is harmless when the migration already ran).
|
|
105
123
|
*/
|
|
106
124
|
readonly ensureSchema?: boolean;
|
|
125
|
+
/**
|
|
126
|
+
* The correlation write-side seam (H6, #149). When present, a first `produce` frame for a
|
|
127
|
+
* `job:<jobKey>` stream links the producing worker instance → jobKey here, and the stream's
|
|
128
|
+
* completion / producer disconnect releases it. Defaults to the process-wide correlation registry
|
|
129
|
+
* ({@link currentCorrelation}); absent (`() => undefined`) → no linking (advisory, never an error).
|
|
130
|
+
*/
|
|
131
|
+
readonly correlation?: () => CorrelationLink | undefined;
|
|
132
|
+
/**
|
|
133
|
+
* Resolve the presence instance that owns a connection — the connection → instance join the
|
|
134
|
+
* correlation write-side needs to attribute a `produce` frame's jobKey. When omitted,
|
|
135
|
+
* `RelayTranscriptService` defaults it to `() => undefined` (no linking); the family factory
|
|
136
|
+
* {@link createRelayFamily} is what wires it to the mounted presence registry's resolver
|
|
137
|
+
* ({@link currentPresenceRegistry}). A resolver returning undefined → no linking (advisory).
|
|
138
|
+
*/
|
|
139
|
+
readonly instanceForConnection?: (connectionId: string) => string | undefined;
|
|
107
140
|
}
|
|
108
141
|
|
|
109
142
|
/** The minimal per-connection surface the relay handler receives from the hub (a {@link RelayHub} `RelayConnection`). */
|
|
@@ -128,10 +161,16 @@ export class RelayTranscriptService {
|
|
|
128
161
|
readonly #registry: ConnectionRegistry;
|
|
129
162
|
readonly #log: Logger;
|
|
130
163
|
readonly #streams = new Map<string, StreamState>();
|
|
164
|
+
/** The correlation write-side accessor (H6, #149) — resolved per call so a late family mount wins. */
|
|
165
|
+
readonly #correlation: () => CorrelationLink | undefined;
|
|
166
|
+
/** The connection → producing-instance resolver (H6, #149). */
|
|
167
|
+
readonly #instanceForConnection: (connectionId: string) => string | undefined;
|
|
131
168
|
|
|
132
169
|
constructor(options: RelayTranscriptServiceOptions) {
|
|
133
170
|
this.#registry = options.registry;
|
|
134
171
|
this.#log = options.log;
|
|
172
|
+
this.#correlation = options.correlation ?? currentCorrelation;
|
|
173
|
+
this.#instanceForConnection = options.instanceForConnection ?? (() => undefined);
|
|
135
174
|
// Persistence is advisory: a store that can't be constructed or whose schema can't be applied
|
|
136
175
|
// (locked/permission-denied/unavailable SQLite) must NOT fail the family mount — fall back to
|
|
137
176
|
// running the relay unpersisted rather than tearing down the whole agentic channel.
|
|
@@ -191,23 +230,31 @@ export class RelayTranscriptService {
|
|
|
191
230
|
* re-completing already-persisted offsets is a no-op. Returns the number of newly-persisted chunks.
|
|
192
231
|
*/
|
|
193
232
|
completeStream(stream: string): number {
|
|
194
|
-
if (!this.store) return 0;
|
|
195
233
|
const state = this.#stateFor(stream);
|
|
196
234
|
const source = this.relay.ring(stream) ?? EMPTY_SOURCE;
|
|
197
|
-
let flushed
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
235
|
+
let flushed = 0;
|
|
236
|
+
// Persistence is advisory: with no store there is nothing to flush, but the in-memory lifecycle
|
|
237
|
+
// must still transition (mark completed, unlink correlation, drop producer) so an unpersisted
|
|
238
|
+
// ephemeral stream completes exactly once and #reconcile does not keep retrying it every frame.
|
|
239
|
+
if (this.store) {
|
|
240
|
+
try {
|
|
241
|
+
flushed = this.store.flush(stream, source, state.lifecycle);
|
|
242
|
+
} catch (err) {
|
|
243
|
+
// A flush failure must not bubble into the hub's frame handler and take down unrelated
|
|
244
|
+
// streams. Log and leave the stream uncompleted so a later pass retries.
|
|
245
|
+
this.#log.warn("agentic relay stream flush failed — leaving stream uncompleted", {
|
|
246
|
+
stream,
|
|
247
|
+
lifecycle: state.lifecycle,
|
|
248
|
+
err: String(err),
|
|
249
|
+
});
|
|
250
|
+
return 0;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (state.lifecycle === "ephemeral") {
|
|
254
|
+
state.completed = true;
|
|
255
|
+
// Job end: release the jobKey ⇄ instance correlation so the worker's supply row clears it.
|
|
256
|
+
this.#unlink(stream, state);
|
|
209
257
|
}
|
|
210
|
-
if (state.lifecycle === "ephemeral") state.completed = true;
|
|
211
258
|
// Drop producer ownership so a later reconcile does not re-flush a completed stream.
|
|
212
259
|
state.producer = undefined;
|
|
213
260
|
this.#log.info("agentic relay stream flushed", {
|
|
@@ -283,19 +330,83 @@ export class RelayTranscriptService {
|
|
|
283
330
|
if (readProp(frame.payload, "op") !== "produce") return;
|
|
284
331
|
const stream = readProp(frame.payload, "stream");
|
|
285
332
|
if (typeof stream !== "string" || stream === "") return;
|
|
286
|
-
this.#stateFor(stream)
|
|
333
|
+
const state = this.#stateFor(stream);
|
|
334
|
+
// A completed stream is terminal: a late `produce` frame (e.g. arriving after job-end
|
|
335
|
+
// completion released the correlation) must not re-own or re-link it — doing so would
|
|
336
|
+
// resurrect a jobKey after it was released. Ignore ownership updates once completed.
|
|
337
|
+
if (state.completed) return;
|
|
338
|
+
state.producer = conn.id;
|
|
339
|
+
this.#link(stream, conn.id, state);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* H6 write-side (#149): on the first `produce` for a `job:<jobKey>` stream, link the producing
|
|
344
|
+
* worker instance → jobKey in the correlation registry, from data already crossing the wire (the
|
|
345
|
+
* jobKey is decoded from the stream id; the instance is resolved from the producing connection).
|
|
346
|
+
* That lights up the worker's `jobKeys` in the supply feed and repoints its drill stream at the
|
|
347
|
+
* jobKey-scoped relay stream. Idempotent per stream; retries on a later frame while the producer's
|
|
348
|
+
* presence instance is not yet resolvable (a register/produce race). Advisory — never throws into
|
|
349
|
+
* the frame handler.
|
|
350
|
+
*/
|
|
351
|
+
#link(stream: string, connectionId: string, state: StreamState): void {
|
|
352
|
+
if (state.linked) return;
|
|
353
|
+
const jobKey = jobKeyOfStream(stream);
|
|
354
|
+
if (jobKey === undefined) return;
|
|
355
|
+
const instance = this.#instanceForConnection(connectionId);
|
|
356
|
+
if (instance === undefined || instance === "") return;
|
|
357
|
+
const correlation = this.#correlation();
|
|
358
|
+
if (!correlation) return;
|
|
359
|
+
try {
|
|
360
|
+
correlation.link(instance, jobKey);
|
|
361
|
+
state.linked = true;
|
|
362
|
+
} catch (err) {
|
|
363
|
+
// Advisory — never throws into the frame handler. Swallow a throwing injectable correlation and
|
|
364
|
+
// leave the stream UNLINKED so a later `produce` retries the link.
|
|
365
|
+
this.#log.warn("agentic relay correlation link failed — leaving stream unlinked", {
|
|
366
|
+
stream,
|
|
367
|
+
jobKey,
|
|
368
|
+
err: String(err),
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** H6 write-side (#149): release a `job:<jobKey>` stream's correlation on completion / disconnect. */
|
|
374
|
+
#unlink(stream: string, state: StreamState): void {
|
|
375
|
+
if (!state.linked) return;
|
|
376
|
+
const jobKey = jobKeyOfStream(stream);
|
|
377
|
+
if (jobKey === undefined) return;
|
|
378
|
+
try {
|
|
379
|
+
this.#correlation()?.releaseJob(jobKey);
|
|
380
|
+
state.linked = false;
|
|
381
|
+
} catch (err) {
|
|
382
|
+
// Advisory — never throws into the frame handler. Swallow a throwing injectable correlation and
|
|
383
|
+
// leave `state.linked` true so the flag honestly records that the release did NOT happen (rather
|
|
384
|
+
// than falsely clearing it). Note there is no automatic retry once the stream completes:
|
|
385
|
+
// `completeStream` sets `state.completed` and clears `state.producer`, so `#reconcile` no longer
|
|
386
|
+
// revisits it and `teardown` skips already-completed streams. A retry only occurs on the
|
|
387
|
+
// dead-producer path, where `#reconcile` re-invokes `#unlink` on a still-uncompleted stream.
|
|
388
|
+
this.#log.warn("agentic relay correlation release failed — leaving stream linked", {
|
|
389
|
+
stream,
|
|
390
|
+
jobKey,
|
|
391
|
+
err: String(err),
|
|
392
|
+
});
|
|
393
|
+
}
|
|
287
394
|
}
|
|
288
395
|
|
|
289
396
|
/**
|
|
290
397
|
* Flush + complete every ephemeral stream whose producer connection is no longer live (the S1
|
|
291
|
-
* registry dropped it on close or liveness timeout)
|
|
292
|
-
* prune: it runs on each inbound frame, and shutdown
|
|
398
|
+
* registry dropped it on close or liveness timeout), and release its job correlation (H6, #149).
|
|
399
|
+
* Lazy, like the relay hub's own dead-subscriber prune: it runs on each inbound frame, and shutdown
|
|
400
|
+
* covers the quiescent tail via {@link teardown}. The correlation release is store-independent (it
|
|
401
|
+
* runs even for an unpersisted relay), so a dropped worker's `jobKeys` always clear.
|
|
293
402
|
*/
|
|
294
403
|
#reconcile(): void {
|
|
295
404
|
for (const [stream, state] of this.#streams) {
|
|
296
|
-
if (state.completed || state.lifecycle !== "ephemeral") continue;
|
|
297
405
|
if (state.producer !== undefined && !this.#registry.has(state.producer)) {
|
|
298
|
-
|
|
406
|
+
// Producer connection gone → the job it was relaying ended: release its correlation.
|
|
407
|
+
this.#unlink(stream, state);
|
|
408
|
+
// ...and flush+complete an ephemeral, not-yet-completed transcript exactly as before.
|
|
409
|
+
if (!state.completed && state.lifecycle === "ephemeral") this.completeStream(stream);
|
|
299
410
|
}
|
|
300
411
|
}
|
|
301
412
|
}
|
|
@@ -303,7 +414,7 @@ export class RelayTranscriptService {
|
|
|
303
414
|
#stateFor(stream: string): StreamState {
|
|
304
415
|
let state = this.#streams.get(stream);
|
|
305
416
|
if (state === undefined) {
|
|
306
|
-
state = { lifecycle: "ephemeral", completed: false };
|
|
417
|
+
state = { lifecycle: "ephemeral", completed: false, linked: false };
|
|
307
418
|
this.#streams.set(stream, state);
|
|
308
419
|
}
|
|
309
420
|
return state;
|
|
@@ -344,6 +455,12 @@ export function createRelayFamily(options: {
|
|
|
344
455
|
relay: options.relay,
|
|
345
456
|
transcript: options.transcript,
|
|
346
457
|
ensureSchema: options.ensureSchema,
|
|
458
|
+
// H6 write-side (#149): resolve the producing connection's presence instance from the live
|
|
459
|
+
// presence registry, and link/release against the process-wide correlation registry. Both
|
|
460
|
+
// are read per call, so this works regardless of family mount order (relay may mount before
|
|
461
|
+
// presence/correlation). Absent registries → no linking, still advisory-correct.
|
|
462
|
+
instanceForConnection: (connectionId) => currentPresenceRegistry()?.instanceForConnection(connectionId),
|
|
463
|
+
correlation: currentCorrelation,
|
|
347
464
|
});
|
|
348
465
|
setCurrentRelayTranscriptService(service);
|
|
349
466
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.118.
|
|
3
|
+
"version": "0.118.2",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -59,12 +59,12 @@
|
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
61
|
"@nanobpm/agentic": "^0.1.0",
|
|
62
|
-
"@nanobpm/urban": "^0.
|
|
62
|
+
"@nanobpm/urban": "^0.75.0"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@biomejs/biome": "^2.4.11",
|
|
66
66
|
"@nanobpm/urban-testkit": "^0.5.0",
|
|
67
|
-
"@nanobpm/workflow": "^0.
|
|
67
|
+
"@nanobpm/workflow": "^0.14.0",
|
|
68
68
|
"@semantic-release/changelog": "^6.0.3",
|
|
69
69
|
"@semantic-release/git": "^10.0.1",
|
|
70
70
|
"@semantic-release/npm": "^13.1.5",
|