@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.
@@ -56,11 +56,14 @@ jobs:
56
56
  - name: Check BPMN diagram freshness (layout)
57
57
  run: npm run layout:check
58
58
 
59
- # Generated-artifact freshness (`urban gen --check`): landscape.gen.html, JSON Schemas / OpenAPI,
60
- # the processos grammar (ir.gbnf) and friends are `derived == f(sources)` re-derive over the
61
- # merged sources and fail if a committed artifact drifted.
62
- - name: Check generated artifacts (urban gen --check)
63
- run: npm run gen:check
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
- push:
5
- branches: [main]
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-${{ github.ref }}
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: number;
198
- try {
199
- flushed = this.store.flush(stream, source, state.lifecycle);
200
- } catch (err) {
201
- // Persistence is advisory: a flush failure must not bubble into the hub's frame handler and
202
- // take down unrelated streams. Log and leave the stream uncompleted so a later pass retries.
203
- this.#log.warn("agentic relay stream flush failed — leaving stream uncompleted", {
204
- stream,
205
- lifecycle: state.lifecycle,
206
- err: String(err),
207
- });
208
- return 0;
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).producer = conn.id;
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). Lazy, like the relay hub's own dead-subscriber
292
- * prune: it runs on each inbound frame, and shutdown covers the quiescent tail via {@link teardown}.
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
- this.completeStream(stream);
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.0",
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.68.1"
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.13.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",