@nanobpm/nano-workforce 0.176.1 → 0.177.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.177.0](https://github.com/nanobpm/nano-workforce/compare/v0.176.1...v0.177.0) (2026-09-02)
2
+
3
+ ### Features
4
+
5
+ * **agentic:** explicit claim registry drives cockpit jobKeys visibility ([#714](https://github.com/nanobpm/nano-workforce/issues/714)) ([173ae9f](https://github.com/nanobpm/nano-workforce/commit/173ae9f61ec23f1d5807517e24afe62d034de727)), closes [nano-ide#542](https://github.com/nanobpm/nano-ide/issues/542) [#713](https://github.com/nanobpm/nano-workforce/issues/713)
6
+
1
7
  ## [0.176.1](https://github.com/nanobpm/nano-workforce/compare/v0.176.0...v0.176.1) (2026-09-02)
2
8
 
3
9
  ### Bug Fixes
@@ -0,0 +1,155 @@
1
+ // Unit tests for the explicit job-ownership CLAIM registry (#713, upstream keystone nano-ide#542).
2
+ //
3
+ // The claim registry is the AUTHORITATIVE visibility source that replaces the fragile relay-derived
4
+ // jobKeys. These tests pin: idempotent claim / release; the two derived-from-one-write projections
5
+ // (instance→jobKeys and jobKey→owner) staying consistent across claim / re-claim (move) / release /
6
+ // releaseInstance; the late/duplicate-release no-op; the presence `jobKeysFor` seam; the claim-keyed
7
+ // drill `primaryStreamFor`; the reconnect-resync rebuild; the bounded-memory `reconcile`; and the
8
+ // sorted snapshot. Mirrors `app/agentic/correlation.test.ts`.
9
+ import assert from "node:assert/strict";
10
+ import { test } from "node:test";
11
+
12
+ import {
13
+ ClaimRegistry,
14
+ currentClaimRegistry,
15
+ setCurrentClaimRegistry,
16
+ } from "./claim-registry.ts";
17
+
18
+ test("claim records both projections; jobKeysFor and primaryStreamFor resolve against it", () => {
19
+ const reg = new ClaimRegistry();
20
+ reg.claim("wk-a", "8420");
21
+
22
+ assert.deepEqual(reg.jobKeysFor("wk-a"), ["8420"]);
23
+ assert.equal(reg.ownerOf("8420"), "wk-a");
24
+ assert.equal(reg.isClaimed("8420"), true);
25
+ assert.equal(reg.primaryStreamFor("wk-a"), "job:8420");
26
+ assert.equal(reg.count(), 1);
27
+ });
28
+
29
+ test("claim is idempotent — a duplicate claim of the same {instance, jobKey} is a no-op re-assertion", () => {
30
+ const reg = new ClaimRegistry();
31
+ reg.claim("wk-a", "8420");
32
+ reg.claim("wk-a", "8420");
33
+ reg.claim("wk-a", "8420");
34
+ assert.deepEqual(reg.jobKeysFor("wk-a"), ["8420"]);
35
+ assert.equal(reg.count(), 1);
36
+ });
37
+
38
+ test("empty instance or jobKey is ignored (an empty value is invalid)", () => {
39
+ const reg = new ClaimRegistry();
40
+ reg.claim("", "8420");
41
+ reg.claim("wk-a", "");
42
+ assert.equal(reg.count(), 0);
43
+ assert.deepEqual(reg.jobKeysFor("wk-a"), []);
44
+ reg.release("", "8420");
45
+ reg.release("wk-a", "");
46
+ assert.equal(reg.count(), 0);
47
+ });
48
+
49
+ test("re-claiming a jobKey under a different instance MOVES it (drops the stale reverse edge)", () => {
50
+ const reg = new ClaimRegistry();
51
+ reg.claim("wk-a", "8420");
52
+ reg.claim("wk-b", "8420");
53
+ assert.deepEqual(reg.jobKeysFor("wk-a"), [], "the old owner no longer holds it");
54
+ assert.deepEqual(reg.jobKeysFor("wk-b"), ["8420"], "the new owner holds it");
55
+ assert.equal(reg.ownerOf("8420"), "wk-b");
56
+ assert.equal(reg.count(), 1, "a moved job never double-counts");
57
+ });
58
+
59
+ test("a worker can hold several claims; jobKeysFor is sorted and primaryStreamFor is the lowest", () => {
60
+ const reg = new ClaimRegistry();
61
+ reg.claim("wk-a", "8420");
62
+ reg.claim("wk-a", "8419");
63
+ reg.claim("wk-a", "8421");
64
+ assert.deepEqual(reg.jobKeysFor("wk-a"), ["8419", "8420", "8421"]);
65
+ assert.equal(reg.primaryStreamFor("wk-a"), "job:8419");
66
+ });
67
+
68
+ test("release clears one claim; a late / duplicate release is a no-op", () => {
69
+ const reg = new ClaimRegistry();
70
+ reg.claim("wk-a", "8420");
71
+ reg.release("wk-a", "8420");
72
+ assert.deepEqual(reg.jobKeysFor("wk-a"), []);
73
+ assert.equal(reg.isClaimed("8420"), false);
74
+ assert.equal(reg.count(), 0);
75
+ // Late / duplicate release — a no-op, never throws.
76
+ reg.release("wk-a", "8420");
77
+ // A release with no preceding claim — a no-op.
78
+ reg.release("wk-a", "9999");
79
+ assert.equal(reg.count(), 0);
80
+ });
81
+
82
+ test("release from a non-owner (the job already moved) is a strict no-op — it can't blank the live owner", () => {
83
+ const reg = new ClaimRegistry();
84
+ reg.claim("wk-a", "8420");
85
+ reg.claim("wk-b", "8420"); // moved to wk-b
86
+ reg.release("wk-a", "8420"); // stale release from the former owner
87
+ assert.deepEqual(reg.jobKeysFor("wk-b"), ["8420"], "the current owner's job is untouched");
88
+ assert.equal(reg.ownerOf("8420"), "wk-b");
89
+ });
90
+
91
+ test("releaseInstance clears every claim a worker holds", () => {
92
+ const reg = new ClaimRegistry();
93
+ reg.claim("wk-a", "8420");
94
+ reg.claim("wk-a", "8421");
95
+ reg.claim("wk-b", "8500");
96
+ reg.releaseInstance("wk-a");
97
+ assert.deepEqual(reg.jobKeysFor("wk-a"), []);
98
+ assert.equal(reg.isClaimed("8420"), false);
99
+ assert.equal(reg.isClaimed("8421"), false);
100
+ assert.deepEqual(reg.jobKeysFor("wk-b"), ["8500"], "another instance's claims survive");
101
+ reg.releaseInstance("nobody"); // unknown instance — no-op
102
+ });
103
+
104
+ test("reconnect-resync: re-register + re-claim rebuilds the same claim (never blanks a still-running job)", () => {
105
+ const reg = new ClaimRegistry();
106
+ reg.claim("wk-a", "8420");
107
+ // A WS reconnect drops the connection but presence survives (keyed by instance); the supervisor
108
+ // re-claims every active jobKey. The idempotent claim leaves the jobKey in place across the churn.
109
+ reg.claim("wk-a", "8420");
110
+ assert.deepEqual(reg.jobKeysFor("wk-a"), ["8420"], "the jobKey never blanked across the reconnect");
111
+ assert.equal(reg.count(), 1);
112
+ });
113
+
114
+ test("reconcile drops claims for absent instances, keeps present ones, and reports the released", () => {
115
+ const reg = new ClaimRegistry();
116
+ reg.claim("wk-a", "8420");
117
+ reg.claim("wk-b", "8500");
118
+ reg.claim("wk-c", "8600");
119
+ const released = reg.reconcile(new Set(["wk-b"]));
120
+ assert.deepEqual(released.sort(), ["wk-a", "wk-c"]);
121
+ assert.deepEqual(reg.jobKeysFor("wk-a"), []);
122
+ assert.deepEqual(reg.jobKeysFor("wk-c"), []);
123
+ assert.deepEqual(reg.jobKeysFor("wk-b"), ["8500"], "a present instance's claim survives");
124
+ assert.equal(reg.isClaimed("8420"), false);
125
+ assert.equal(reg.isClaimed("8500"), true);
126
+ });
127
+
128
+ test("primaryStreamFor / jobKeysFor for an unknown instance are undefined / empty", () => {
129
+ const reg = new ClaimRegistry();
130
+ assert.equal(reg.primaryStreamFor("nobody"), undefined);
131
+ assert.deepEqual(reg.jobKeysFor("nobody"), []);
132
+ });
133
+
134
+ test("snapshot is sorted by instance then jobKey and counts held claims", () => {
135
+ const reg = new ClaimRegistry();
136
+ reg.claim("wk-b", "8500");
137
+ reg.claim("wk-a", "8421");
138
+ reg.claim("wk-a", "8420");
139
+ const snap = reg.snapshot();
140
+ assert.deepEqual(snap.claims, [
141
+ { instance: "wk-a", jobKey: "8420" },
142
+ { instance: "wk-a", jobKey: "8421" },
143
+ { instance: "wk-b", jobKey: "8500" },
144
+ ]);
145
+ assert.equal(snap.count, 3);
146
+ });
147
+
148
+ test("currentClaimRegistry / setCurrentClaimRegistry install and clear the singleton", () => {
149
+ assert.equal(currentClaimRegistry(), undefined);
150
+ const reg = new ClaimRegistry();
151
+ setCurrentClaimRegistry(reg);
152
+ assert.equal(currentClaimRegistry(), reg);
153
+ setCurrentClaimRegistry(undefined);
154
+ assert.equal(currentClaimRegistry(), undefined);
155
+ });
@@ -0,0 +1,194 @@
1
+ // nano-workforce — the explicit job-ownership CLAIM registry (#713, upstream keystone nano-ide#542).
2
+ //
3
+ // The authoritative source of "which jobs a worker instance is currently running", replacing the
4
+ // fragile relay-DERIVED visibility it supersedes. Cockpit `jobKeys` used to be INFERRED from the
5
+ // `job:<jobKey>` relay-terminal stream correlated to a worker via its CONNECTION id — a path that in
6
+ // production went effectively dead (every live worker reported `jobKeys:[]` while actively running a
7
+ // job) and that breaks by construction the moment one per-host supervisor multiplexes N workers over
8
+ // a single connection (the instance can no longer be derived from `conn.id`).
9
+ //
10
+ // This registry fixes the failure MODE, not one instance of it: job ownership becomes a first-class,
11
+ // EXPLICITLY-attributed fact carried by `claim` / `release` frames (wire codes 8/9, added to
12
+ // `@nanobpm/agentic` by nano-ide#542). Each frame names its OWNING `instance` explicitly, so one
13
+ // connection can carry the ownership frames of many distinct workers. A worker that holds a claim
14
+ // reads "working" even with ZERO transcript — visibility no longer depends on terminal bytes landing
15
+ // or correlating.
16
+ //
17
+ // Relationship to the correlation registry (`./correlation.ts`): correlation is DEMOTED to drill-in
18
+ // context only (the process-instance / plan a terminal belongs to, keyed by jobKey). This registry —
19
+ // keyed by the frame's explicit `instance` — is the visibility source the supply snapshot's
20
+ // `jobKeysFor` seam resolves against. The two are distinct signals: presence "connected" and job
21
+ // "claimed" must not be conflated (a live channel is not an observed agent).
22
+ //
23
+ // Invariants (ADR 0056): app-tier only, never the engine; the Camunda-8 job protocol (worker⇄engine)
24
+ // is untouched — a claim is an app-side ownership announcement on the agentic channel, not a new wire
25
+ // type on the job protocol; ADVISORY — the registry is a read-only visibility source and NEVER
26
+ // hard-locks or gates a BPMN sequence flow.
27
+
28
+ import { jobStream } from "./correlation.ts";
29
+
30
+ /** One ownership record: a worker instance's claim over a job. */
31
+ export interface Claim {
32
+ /** The owning worker instance (carried EXPLICITLY in the frame, never inferred from a connection). */
33
+ readonly instance: string;
34
+ /** The Camunda-8 job key the instance owns. */
35
+ readonly jobKey: string;
36
+ }
37
+
38
+ /** The read-only claim snapshot: every currently-held claim, sorted by instance then jobKey. */
39
+ export interface ClaimSnapshot {
40
+ readonly claims: readonly Claim[];
41
+ readonly count: number;
42
+ }
43
+
44
+ /**
45
+ * The advisory in-memory job-ownership registry. It holds two derived-from-one-write projections of
46
+ * the same `claim` call: `instance → jobKeys` (the presence resolver) and `jobKey → instance` (the
47
+ * owner lookup, so `release` and a re-claim can move a job cleanly). A jobKey is owned by at most one
48
+ * instance at a time; re-claiming it under a different instance MOVES it (dropping the stale reverse
49
+ * edge) so a re-dispatched job never double-counts. Every mutation is idempotent, matching the wire
50
+ * contract: a duplicate `claim` is a no-op re-assertion; a duplicate or late `release` is a no-op.
51
+ *
52
+ * It needs no durable backing: claims are ephemeral and re-announced. On a WS reconnect the supervisor
53
+ * re-`register`s every worker and re-`claim`s every active jobKey, so the registry is rebuilt from that
54
+ * full-state resync — which is exactly what makes it survive the reconnect that the old relay-derived
55
+ * path stranded the jobKey across.
56
+ */
57
+ export class ClaimRegistry {
58
+ /** worker instance → the set of jobKeys it currently owns (insertion-ordered). */
59
+ readonly #jobsOf = new Map<string, Set<string>>();
60
+ /** jobKey → the worker instance that currently owns it. */
61
+ readonly #ownerOf = new Map<string, string>();
62
+
63
+ /**
64
+ * Record that `instance` now OWNS `jobKey` (the authoritative ownership window opens). Idempotent:
65
+ * a repeat claim of the same `{ instance, jobKey }` is a no-op re-assertion. If the jobKey is
66
+ * currently owned by a DIFFERENT instance, it MOVES to the new owner (the stale reverse edge is
67
+ * dropped) — a re-dispatched or reconnected-under-new-instance job never double-counts. Both args
68
+ * must be non-empty; an empty value is ignored.
69
+ */
70
+ claim(instance: string, jobKey: string): void {
71
+ if (instance === "" || jobKey === "") return;
72
+ const previousOwner = this.#ownerOf.get(jobKey);
73
+ if (previousOwner === instance) return; // idempotent re-assertion
74
+ if (previousOwner !== undefined) {
75
+ this.#jobsOf.get(previousOwner)?.delete(jobKey);
76
+ this.#pruneInstance(previousOwner);
77
+ }
78
+ this.#ownerOf.set(jobKey, instance);
79
+ const jobs = this.#jobsOf.get(instance) ?? new Set<string>();
80
+ jobs.add(jobKey);
81
+ this.#jobsOf.set(instance, jobs);
82
+ }
83
+
84
+ /**
85
+ * Release `instance`'s claim over `jobKey` (the ownership window closes — the job finished, failed,
86
+ * or moved on). Idempotent and a strict no-op unless `instance` is the CURRENT owner: a duplicate or
87
+ * late `release`, a `release` with no preceding `claim`, or a `release` from an instance that no
88
+ * longer owns the job (it already moved to another owner) all leave the registry untouched — so a
89
+ * stale release can never blank a still-running job another instance now owns.
90
+ */
91
+ release(instance: string, jobKey: string): void {
92
+ if (instance === "" || jobKey === "") return;
93
+ if (this.#ownerOf.get(jobKey) !== instance) return;
94
+ this.#ownerOf.delete(jobKey);
95
+ this.#jobsOf.get(instance)?.delete(jobKey);
96
+ this.#pruneInstance(instance);
97
+ }
98
+
99
+ /**
100
+ * Release EVERY claim a worker instance holds (e.g. its supervisor connection dropped, or presence
101
+ * aged it out). No-op for an unknown instance.
102
+ */
103
+ releaseInstance(instance: string): void {
104
+ const jobs = this.#jobsOf.get(instance);
105
+ if (!jobs) return;
106
+ for (const jobKey of jobs) this.#ownerOf.delete(jobKey);
107
+ this.#jobsOf.delete(instance);
108
+ }
109
+
110
+ /**
111
+ * Drop every claim whose owning instance is NOT in `presentInstances` — the bounded-memory
112
+ * reconcile a maintenance tick drives against the live presence set, so a departed supervisor's
113
+ * claims do not linger. A reconnecting worker keeps its presence row (presence is keyed by instance
114
+ * across reconnects), so this never drops a mid-job reconnect's claim; a truly-exited worker loses
115
+ * its presence row and its claims are reclaimed here. Returns the released instance ids. This is a
116
+ * safety net, not the primary path — `release` frames and reconnect-resync are.
117
+ */
118
+ reconcile(presentInstances: ReadonlySet<string>): string[] {
119
+ const released: string[] = [];
120
+ for (const instance of [...this.#jobsOf.keys()]) {
121
+ if (!presentInstances.has(instance)) {
122
+ this.releaseInstance(instance);
123
+ released.push(instance);
124
+ }
125
+ }
126
+ return released;
127
+ }
128
+
129
+ /**
130
+ * The jobKeys a worker instance currently owns, sorted for a stable render. This is the resolver
131
+ * injected into {@link PresenceRegistry.snapshot}'s `jobKeysFor` seam — the visibility source the
132
+ * supply feed / cockpit reads (superseding the relay correlation).
133
+ */
134
+ jobKeysFor(instance: string): string[] {
135
+ const jobs = this.#jobsOf.get(instance);
136
+ return jobs ? [...jobs].sort((a, b) => a.localeCompare(b)) : [];
137
+ }
138
+
139
+ /**
140
+ * The jobKey-scoped relay stream a worker's terminal should drill into: its lowest-sorted current
141
+ * claim's stream (`job:<jobKey>`). A worker runs one job at a time in this fleet, but sorting keeps
142
+ * it stable if it ever holds several. Undefined when the worker holds no claim — the caller then
143
+ * falls back to the instance-keyed stream. This is the "relay demoted to drill-in, keyed by the
144
+ * CLAIM (not by `instanceForConnection`)" seam.
145
+ */
146
+ primaryStreamFor(instance: string): string | undefined {
147
+ const [first] = this.jobKeysFor(instance);
148
+ return first === undefined ? undefined : jobStream(first);
149
+ }
150
+
151
+ /** Whether any instance currently owns `jobKey`. */
152
+ isClaimed(jobKey: string): boolean {
153
+ return this.#ownerOf.has(jobKey);
154
+ }
155
+
156
+ /** The instance currently owning `jobKey`, or undefined when it is unclaimed. */
157
+ ownerOf(jobKey: string): string | undefined {
158
+ return this.#ownerOf.get(jobKey);
159
+ }
160
+
161
+ /** The number of currently-held claims. */
162
+ count(): number {
163
+ return this.#ownerOf.size;
164
+ }
165
+
166
+ /** The read-only claim snapshot: every held claim, sorted by instance then jobKey. */
167
+ snapshot(): ClaimSnapshot {
168
+ const claims: Claim[] = [];
169
+ for (const [instance, jobs] of this.#jobsOf) {
170
+ for (const jobKey of jobs) claims.push({ instance, jobKey });
171
+ }
172
+ claims.sort((a, b) => a.instance.localeCompare(b.instance) || a.jobKey.localeCompare(b.jobKey));
173
+ return { claims, count: claims.length };
174
+ }
175
+
176
+ /** Drop a worker's reverse-edge entry once it holds no more claims, so the map stays bounded. */
177
+ #pruneInstance(instance: string): void {
178
+ const jobs = this.#jobsOf.get(instance);
179
+ if (jobs && jobs.size === 0) this.#jobsOf.delete(instance);
180
+ }
181
+ }
182
+
183
+ /** The live claim registry from the most recent mount, so the supply report (H5) can read it. */
184
+ let currentRegistry: ClaimRegistry | undefined;
185
+
186
+ /** The mounted claim registry, or undefined before mount / after teardown. */
187
+ export function currentClaimRegistry(): ClaimRegistry | undefined {
188
+ return currentRegistry;
189
+ }
190
+
191
+ /** Install the live registry (called by the claim family's `mount`). */
192
+ export function setCurrentClaimRegistry(registry: ClaimRegistry | undefined): void {
193
+ currentRegistry = registry;
194
+ }
@@ -0,0 +1,196 @@
1
+ // Unit tests for the agentic `claim` / `release` job-ownership family (#713).
2
+ //
3
+ // Drives REAL `claim` / `release` frames through a live AgenticHub over an in-memory transport —
4
+ // exactly as the presence family is exercised — so the singleton the supply operation reads is the
5
+ // live one. Pins: mount attaches the two handlers and installs the registry singleton; a `claim`
6
+ // populates jobKeys with ZERO transcript; ONE connection attributes N distinct instances by the
7
+ // frame's EXPLICIT `instance` (never `conn.id`); `release` clears it and a late/duplicate release is a
8
+ // no-op; a reconnect re-`claim` never blanks a still-running job; a malformed payload is rejected
9
+ // without touching the registry; teardown clears the singleton.
10
+ import { test } from "node:test";
11
+ import { AgenticHub } from "@nanobpm/agentic/channel";
12
+ import type { Authenticator, ChannelConnection, ChannelTransport } from "@nanobpm/agentic/channel";
13
+ import { encodeFrame, type Frame } from "@nanobpm/agentic/protocol";
14
+ import { assert, assertEquals } from "#test-assert";
15
+ import { noopLog } from "../../../test/log.ts";
16
+ import { currentClaimRegistry } from "../claim-registry.ts";
17
+ import type { AgenticContext } from "../registry.ts";
18
+ import { CLAIM_FAMILY, family, RELEASE_FAMILY } from "./claim.family.ts";
19
+
20
+ interface FakeConn {
21
+ readonly conn: ChannelConnection;
22
+ feed(frame: Frame): void;
23
+ }
24
+
25
+ function fakeConn(id: string, identity: string): FakeConn {
26
+ let onMessage: ((bytes: Uint8Array) => void) | undefined;
27
+ const conn: ChannelConnection = {
28
+ id,
29
+ handshake: { query: { identity }, token: "t", credential: "c" },
30
+ send: () => {},
31
+ close: () => {},
32
+ onMessage: (l) => { onMessage = l; },
33
+ onClose: () => {},
34
+ };
35
+ return { conn, feed: (frame) => onMessage?.(encodeFrame(frame)) };
36
+ }
37
+
38
+ function memTransport(): { transport: ChannelTransport; connect(conn: ChannelConnection): void } {
39
+ let onConnection: ((conn: ChannelConnection) => void) | undefined;
40
+ const transport: ChannelTransport = {
41
+ onConnection: (l) => { onConnection = l; },
42
+ address: { port: 0 },
43
+ close: async () => {},
44
+ };
45
+ return { transport, connect: (conn) => onConnection?.(conn) };
46
+ }
47
+
48
+ const authenticator: Authenticator = (req) => ({ ok: true, grant: { identity: req.query?.identity ?? "anon" } });
49
+ const flush = () => new Promise((resolve) => setImmediate(resolve));
50
+
51
+ function claimFrame(instance: string, jobKey: string): Frame {
52
+ return { lane: "control", family: CLAIM_FAMILY, seq: 1, payload: { instance, jobKey } };
53
+ }
54
+ function releaseFrame(instance: string, jobKey: string): Frame {
55
+ return { lane: "control", family: RELEASE_FAMILY, seq: 1, payload: { instance, jobKey } };
56
+ }
57
+
58
+ function mountClaim(): { hub: AgenticHub; transport: ReturnType<typeof memTransport> } {
59
+ const transport = memTransport();
60
+ const hub = new AgenticHub({ transport: transport.transport, authenticator, sweepIntervalMs: 0 });
61
+ const ctx: AgenticContext = {
62
+ hub,
63
+ registry: hub.registry,
64
+ transport: transport.transport as never,
65
+ data: undefined,
66
+ log: noopLog(),
67
+ };
68
+ family.mount(ctx);
69
+ return { hub, transport };
70
+ }
71
+
72
+ test("mount attaches the claim and release handlers and installs the registry singleton", () => {
73
+ const { hub } = mountClaim();
74
+ try {
75
+ assertEquals(hub.router.families().sort(), ["claim", "release"]);
76
+ assert(currentClaimRegistry() !== undefined, "the claim family installs the singleton");
77
+ } finally {
78
+ family.teardown?.();
79
+ }
80
+ });
81
+
82
+ test("a claim populates the worker's jobKeys with ZERO transcript (visibility no longer needs the relay)", async () => {
83
+ const { transport } = mountClaim();
84
+ try {
85
+ const peer = fakeConn("c1", "leafA");
86
+ transport.connect(peer.conn);
87
+ await flush();
88
+ peer.feed(claimFrame("wk-a", "8420"));
89
+ await flush();
90
+
91
+ const reg = currentClaimRegistry();
92
+ assert(reg, "registry mounted");
93
+ assertEquals(reg.jobKeysFor("wk-a"), ["8420"]);
94
+ assertEquals(reg.primaryStreamFor("wk-a"), "job:8420", "the drill stream repoints at the claimed job");
95
+ } finally {
96
+ family.teardown?.();
97
+ }
98
+ });
99
+
100
+ test("ONE connection attributes N distinct instances by the frame's EXPLICIT instance (never conn.id)", async () => {
101
+ const { transport } = mountClaim();
102
+ try {
103
+ // A single per-host supervisor multiplexes two workers over ONE connection.
104
+ const supervisor = fakeConn("c1", "host-supervisor");
105
+ transport.connect(supervisor.conn);
106
+ await flush();
107
+ supervisor.feed(claimFrame("wk-a", "8420"));
108
+ supervisor.feed(claimFrame("wk-b", "8500"));
109
+ await flush();
110
+
111
+ const reg = currentClaimRegistry();
112
+ assert(reg);
113
+ assertEquals(reg.jobKeysFor("wk-a"), ["8420"], "attributed to the frame's instance, not the connection");
114
+ assertEquals(reg.jobKeysFor("wk-b"), ["8500"]);
115
+ assertEquals(reg.count(), 2);
116
+ } finally {
117
+ family.teardown?.();
118
+ }
119
+ });
120
+
121
+ test("release clears the jobKey; a late / duplicate release is a no-op", async () => {
122
+ const { transport } = mountClaim();
123
+ try {
124
+ const peer = fakeConn("c1", "leafA");
125
+ transport.connect(peer.conn);
126
+ await flush();
127
+ peer.feed(claimFrame("wk-a", "8420"));
128
+ await flush();
129
+ peer.feed(releaseFrame("wk-a", "8420"));
130
+ await flush();
131
+
132
+ const reg = currentClaimRegistry();
133
+ assert(reg);
134
+ assertEquals(reg.jobKeysFor("wk-a"), []);
135
+ // Late / duplicate release — a no-op, never throws or wedges the handler.
136
+ peer.feed(releaseFrame("wk-a", "8420"));
137
+ await flush();
138
+ assertEquals(reg.count(), 0);
139
+ } finally {
140
+ family.teardown?.();
141
+ }
142
+ });
143
+
144
+ test("reconnect-resync: a mid-job re-claim over a fresh connection never blanks the still-running job", async () => {
145
+ const { transport } = mountClaim();
146
+ try {
147
+ const first = fakeConn("c1", "leafA");
148
+ transport.connect(first.conn);
149
+ await flush();
150
+ first.feed(claimFrame("wk-a", "8420"));
151
+ await flush();
152
+
153
+ // The WS reconnects on a new connection; the supervisor re-claims every active jobKey.
154
+ const second = fakeConn("c2", "leafA");
155
+ transport.connect(second.conn);
156
+ await flush();
157
+ second.feed(claimFrame("wk-a", "8420"));
158
+ await flush();
159
+
160
+ const reg = currentClaimRegistry();
161
+ assert(reg);
162
+ assertEquals(reg.jobKeysFor("wk-a"), ["8420"], "the jobKey survived the reconnect");
163
+ assertEquals(reg.count(), 1);
164
+ } finally {
165
+ family.teardown?.();
166
+ }
167
+ });
168
+
169
+ test("a malformed claim payload is rejected without touching the registry (advisory, connection kept)", async () => {
170
+ const { transport } = mountClaim();
171
+ try {
172
+ const peer = fakeConn("c1", "leafA");
173
+ transport.connect(peer.conn);
174
+ await flush();
175
+ // Missing jobKey → validatePayload rejects it.
176
+ peer.feed({ lane: "control", family: CLAIM_FAMILY, seq: 1, payload: { instance: "wk-a" } });
177
+ await flush();
178
+ // A well-formed claim still works afterwards — the handler was not wedged.
179
+ peer.feed(claimFrame("wk-a", "8420"));
180
+ await flush();
181
+
182
+ const reg = currentClaimRegistry();
183
+ assert(reg);
184
+ assertEquals(reg.jobKeysFor("wk-a"), ["8420"]);
185
+ assertEquals(reg.count(), 1);
186
+ } finally {
187
+ family.teardown?.();
188
+ }
189
+ });
190
+
191
+ test("teardown clears the singleton", () => {
192
+ mountClaim();
193
+ assert(currentClaimRegistry() !== undefined);
194
+ family.teardown?.();
195
+ assertEquals(currentClaimRegistry(), undefined);
196
+ });
@@ -0,0 +1,148 @@
1
+ // nano-workforce — the agentic-channel `claim` / `release` job-ownership family (#713).
2
+ //
3
+ // H0 (#143) seam plug-in: ONE new file under `app/agentic/families/`, discovered by the loader's
4
+ // `*.family.ts` convention and mounted by the seam — it never edits `main.ts`, `drainAndExit`, or any
5
+ // shared boot line. It owns the `claim` (wire code 8) and `release` (wire code 9) message families —
6
+ // the explicit job-ownership frames nano-ide#542 appended to `@nanobpm/agentic` — attaching each
7
+ // handler through the hub's `registerFamilyHandler` seam (one family, one owning module), never a
8
+ // shared dispatch switch.
9
+ //
10
+ // What it gives the fleet: a first-class {@link ClaimRegistry} (`instance → set<jobKey>`) that becomes
11
+ // the AUTHORITATIVE source the supply snapshot reads for `jobKeys` — replacing the fragile
12
+ // relay-derived visibility. Each frame carries its OWNING `instance` EXPLICITLY, so attribution reads
13
+ // the frame, NOT the connection id (`conn.id`); that is what lets one per-host supervisor multiplex N
14
+ // distinct workers' ownership over a single connection. On a reconnect the supervisor re-`register`s
15
+ // every worker and re-`claim`s every active jobKey, and the (idempotent) claim handler rebuilds the
16
+ // registry from that resync.
17
+ //
18
+ // Liveness: a worker holding a claim reads "working" even with ZERO transcript — visibility no longer
19
+ // depends on terminal bytes landing/correlating. A bounded-memory maintenance tick reconciles the
20
+ // claim registry against the live presence set so a departed supervisor's claims are reclaimed (the
21
+ // `release` frame is the primary clear; this is the safety net for an unclean drop).
22
+ //
23
+ // Invariants (ADR 0056): app-tier only, never the engine; the Camunda-8 job protocol (worker⇄engine)
24
+ // is untouched; ADVISORY — the registry is a read-only visibility source and NEVER gates a BPMN
25
+ // sequence flow.
26
+ import type { HubConnection } from "@nanobpm/agentic/channel";
27
+ import { type Frame, validatePayload } from "@nanobpm/agentic/protocol";
28
+ import { ClaimRegistry, setCurrentClaimRegistry } from "../claim-registry.ts";
29
+ import type { AgenticContext, AgenticFamily } from "../registry.ts";
30
+ import { currentPresenceRegistry } from "./presence.family.ts";
31
+
32
+ /** The message-family names this module owns (the two ownership frames). */
33
+ export const CLAIM_FAMILY = "claim";
34
+ export const RELEASE_FAMILY = "release";
35
+
36
+ /** The reconcile tick runs at a third of the presence TTL — matching the presence-maintenance cadence. */
37
+ const SWEEP_DIVISOR = 3;
38
+ /** Fallback reconcile cadence when no presence registry is mounted to source a TTL. */
39
+ const DEFAULT_RECONCILE_MS = 10_000;
40
+
41
+ /** Read a string property from an unknown frame payload, or undefined when absent / non-string. */
42
+ function readString(value: unknown, key: string): string | undefined {
43
+ if (!value || typeof value !== "object") return undefined;
44
+ if (!Object.hasOwn(value, key)) return undefined;
45
+ const field = Object.getOwnPropertyDescriptor(value, key)?.value;
46
+ return typeof field === "string" ? field : undefined;
47
+ }
48
+
49
+ interface MountState {
50
+ readonly registry: ClaimRegistry;
51
+ timer: ReturnType<typeof setTimeout> | undefined;
52
+ stopped: boolean;
53
+ }
54
+
55
+ let state: MountState | undefined;
56
+
57
+ /**
58
+ * The `claim` / `release` family module. `mount` installs a fresh {@link ClaimRegistry} as the
59
+ * process-wide singleton, attaches the two frame handlers via the S1 seam, and starts ONE bounded
60
+ * reconcile tick. `teardown` stops the tick, detaches nothing (the hub owns handler lifetime for the
61
+ * remount-guarded test path) and clears the singleton.
62
+ */
63
+ export const family: AgenticFamily = {
64
+ name: CLAIM_FAMILY,
65
+
66
+ mount(ctx: AgenticContext): void {
67
+ const registry = new ClaimRegistry();
68
+ setCurrentClaimRegistry(registry);
69
+
70
+ // `claim` opens the ownership window; `release` closes it. Attribution reads the frame's EXPLICIT
71
+ // `instance` — never `conn.id` — so one connection can carry many instances' ownership frames. A
72
+ // malformed payload is rejected before it touches the registry (advisory: logged, connection
73
+ // kept). Both mutations are idempotent, matching the wire contract.
74
+ ctx.hub.registerFamilyHandler(CLAIM_FAMILY, (frame: Frame, _conn: HubConnection) => {
75
+ const result = validatePayload(CLAIM_FAMILY, frame.payload);
76
+ if (!result.ok) {
77
+ ctx.log.warn("agentic claim: malformed payload", { errors: result.errors.map((e) => e.code) });
78
+ return;
79
+ }
80
+ const instance = readString(frame.payload, "instance");
81
+ const jobKey = readString(frame.payload, "jobKey");
82
+ if (!instance || !jobKey) return;
83
+ registry.claim(instance, jobKey);
84
+ });
85
+
86
+ ctx.hub.registerFamilyHandler(RELEASE_FAMILY, (frame: Frame, _conn: HubConnection) => {
87
+ const result = validatePayload(RELEASE_FAMILY, frame.payload);
88
+ if (!result.ok) {
89
+ ctx.log.warn("agentic release: malformed payload", { errors: result.errors.map((e) => e.code) });
90
+ return;
91
+ }
92
+ const instance = readString(frame.payload, "instance");
93
+ const jobKey = readString(frame.payload, "jobKey");
94
+ if (!instance || !jobKey) return;
95
+ registry.release(instance, jobKey);
96
+ });
97
+
98
+ // Bounded-memory reconcile: drop claims whose owning instance no longer has a presence row (a
99
+ // dropped supervisor / aged-out worker). BOTH the drop-set AND the cadence are recomputed per
100
+ // tick from the live presence registry via a self-rescheduling timer, so the reconcile is truly
101
+ // independent of family mount order: whether presence mounts before or after this family, once it
102
+ // is present each tick reclaims absent instances' claims AND adjusts its cadence to the real TTL
103
+ // (a fixed-at-mount interval would stay pinned to the fallback cadence when claim mounts first).
104
+ // Advisory — a fault is logged, never thrown, and the tick never keeps the process alive on its
105
+ // own.
106
+ const reconcileMs = (): number => {
107
+ const presenceTtl = currentPresenceRegistry()?.ttlMs;
108
+ return Math.max(1, Math.floor((presenceTtl ?? DEFAULT_RECONCILE_MS) / SWEEP_DIVISOR));
109
+ };
110
+ const schedule = (): void => {
111
+ if (!state || state.stopped) return;
112
+ const timer = setTimeout(tick, reconcileMs());
113
+ timer.unref?.();
114
+ state.timer = timer;
115
+ };
116
+ const tick = () => {
117
+ try {
118
+ const presence = currentPresenceRegistry();
119
+ if (presence) {
120
+ const present = new Set(presence.registeredWorkers().map((w) => w.instance));
121
+ const released = registry.reconcile(present);
122
+ if (released.length > 0) {
123
+ ctx.log.info("agentic claim reconcile released absent instances", { released: released.length });
124
+ }
125
+ }
126
+ // else: no presence source → keep claims until one mounts (resync repopulates)
127
+ } catch (err) {
128
+ ctx.log.warn("agentic claim reconcile failed", { err: String(err) });
129
+ }
130
+ schedule();
131
+ };
132
+
133
+ state = { registry, timer: undefined, stopped: false };
134
+ schedule();
135
+ ctx.log.info("agentic claim family mounted", { families: [CLAIM_FAMILY, RELEASE_FAMILY] });
136
+ },
137
+
138
+ teardown(): void {
139
+ if (state) {
140
+ state.stopped = true;
141
+ if (state.timer !== undefined) clearTimeout(state.timer);
142
+ }
143
+ state = undefined;
144
+ setCurrentClaimRegistry(undefined);
145
+ },
146
+ };
147
+
148
+ export default family;
@@ -12,7 +12,9 @@ import { encodeFrame, type Frame } from "@nanobpm/agentic/protocol";
12
12
  import type { SqliteDb } from "@nanobpm/agentic/presence";
13
13
  import type { AppApi, DataLayer } from "@nanobpm/urban";
14
14
  import { assert, assertEquals } from "#test-assert";
15
+ import { currentClaimRegistry } from "../app/agentic/claim-registry.ts";
15
16
  import { currentCorrelation } from "../app/agentic/correlation.ts";
17
+ import { family as claimFamily } from "../app/agentic/families/claim.family.ts";
16
18
  import { family as correlationFamily } from "../app/agentic/families/correlation.family.ts";
17
19
  import { family } from "../app/agentic/families/presence.family.ts";
18
20
  import type { AgenticContext } from "../app/agentic/registry.ts";
@@ -154,7 +156,32 @@ test("shared-secret guard rejects a missing secret when configured", async () =>
154
156
  }
155
157
  });
156
158
 
157
- test("H6: with the correlation family mounted, jobKeys populate, stream repoints, and correlations are reported", async () => {
159
+ test("#713: a claim populates jobKeys and repoints the drill stream with ZERO transcript (claim is the visibility source)", async () => {
160
+ const hub = await mountPresence(memSqlite());
161
+ const ctx: AgenticContext = { hub, registry: hub.registry, transport: undefined as never, data: undefined, log: noopLog() };
162
+ claimFamily.mount(ctx);
163
+ const claims = currentClaimRegistry();
164
+ assert(claims !== undefined, "the claim family installs the singleton");
165
+ // An explicit claim — no relay produce frame, no correlation link, zero transcript.
166
+ claims.claim("wk-a", "8420");
167
+ try {
168
+ const res = (await handler(input(), app)) as {
169
+ status: number;
170
+ body: { workers: Array<Record<string, unknown>>; correlations: unknown[] };
171
+ };
172
+ assertEquals(res.status, 200);
173
+ const w = res.body.workers[0];
174
+ assertEquals(w.jobKeys, ["8420"], "the claim registry feeds the jobKeys seam");
175
+ assertEquals(w.stream, "job:8420", "the drill stream repoints at the claimed job, keyed by the claim");
176
+ assertEquals(res.body.correlations.length, 0, "no correlation context until a terminal lands (drill-in only)");
177
+ } finally {
178
+ claimFamily.teardown?.();
179
+ family.teardown?.();
180
+ await hub.close();
181
+ }
182
+ });
183
+
184
+ test("#713: the correlation registry is demoted to drill-in context — a link alone no longer feeds jobKeys", async () => {
158
185
  const hub = await mountPresence(memSqlite());
159
186
  correlationFamily.mount({
160
187
  hub,
@@ -165,6 +192,7 @@ test("H6: with the correlation family mounted, jobKeys populate, stream repoints
165
192
  });
166
193
  const correlation = currentCorrelation();
167
194
  assert(correlation !== undefined, "the correlation family installs the singleton");
195
+ // A correlation link (drill-in context) WITHOUT a claim: visibility must NOT light up from it.
168
196
  correlation.link("wk-a", "6494", { processInstanceKey: "4612", bpmnProcessId: "plan-fanout", elementId: "implement-task", planKey: "o/r#142" });
169
197
  try {
170
198
  const res = (await handler(input(), app)) as {
@@ -176,8 +204,9 @@ test("H6: with the correlation family mounted, jobKeys populate, stream repoints
176
204
  };
177
205
  assertEquals(res.status, 200);
178
206
  const w = res.body.workers[0];
179
- assertEquals(w.jobKeys, ["6494"], "the correlation registry feeds the jobKeys seam");
180
- assertEquals(w.stream, "job:6494", "the drill stream repoints at the live job's stream");
207
+ assertEquals(w.jobKeys, [], "correlation alone no longer feeds jobKeys (relay demoted)");
208
+ assertEquals(w.stream, "wk-a", "the drill stream stays instance-keyed without a claim");
209
+ // The correlation context is still reported for drill-in.
181
210
  assertEquals(res.body.correlations.length, 1);
182
211
  const c = res.body.correlations[0];
183
212
  assertEquals(c.jobKey, "6494");
@@ -3,11 +3,14 @@
3
3
  // worker list — family, host, current jobs, liveness — grouped by leaf token, sourced from the H1
4
4
  // presence registry (#144). Read-only projection; it NEVER gates control flow (advisory-only, ADR 0056).
5
5
  //
6
- // H6 (#149) closes the loop: the correlation registry (`app/agentic/correlation.ts`) supplies the
7
- // `jobKeysFor` resolver the presence snapshot exposes as a seam, so each worker's current jobKeys light
8
- // up; each worker's drill `stream` is repointed at its jobKey-scoped relay stream (`job:<jobKey>`); and
9
- // the report carries the `correlations` the process-instance / plan context for every current job —
10
- // so the cockpit lines a worker's terminal up with "that process instance / this plan".
6
+ // H6/#713 closes the loop with an EXPLICIT claim registry (`app/agentic/claim-registry.ts`): it is the
7
+ // AUTHORITATIVE source the presence snapshot's `jobKeysFor` seam resolves against, so each worker's
8
+ // current jobKeys light up from `claim` frames not inferred from the relay terminal and appear even
9
+ // with ZERO transcript. Each worker's drill `stream` is repointed at its claimed jobKey-scoped relay
10
+ // stream (`job:<jobKey>`), keyed by the CLAIM (explicit instance+jobKey), not by a connection. The
11
+ // relay correlation registry is DEMOTED to drill-in context only: it still supplies the `correlations`
12
+ // — the process-instance / plan context for a job's terminal — so the cockpit lines a worker's terminal
13
+ // up with "that process instance / this plan", but it is no longer the visibility source.
11
14
  //
12
15
  // This is the supply half of the visibility plane only. The demand×supply matrix, missing-agent-type
13
16
  // reds, and diversity-SLO lights are DE-SCOPED to the enrolment epic #152 — this report carries no
@@ -16,7 +19,8 @@
16
19
  // The optional shared-secret guard stays HERE (the runtime does not enforce OpenAPI `security`): when
17
20
  // NANO_PR_WEBHOOK_SECRET is set, callers must present it via the x-hook-secret header. Unset → open.
18
21
 
19
- import { type CorrelationRegistry, currentCorrelation, type JobCorrelation } from "../app/agentic/correlation.ts";
22
+ import { type ClaimRegistry, currentClaimRegistry } from "../app/agentic/claim-registry.ts";
23
+ import { currentCorrelation, type JobCorrelation } from "../app/agentic/correlation.ts";
20
24
  import { currentPresenceRegistry, type SupplyWorker } from "../app/agentic/families/presence.family.ts";
21
25
  import { envVar } from "../app/version.ts";
22
26
  import type { AgenticJobCorrelation, AgenticSupplyReport, AgenticSupplyWorker } from "../nano-generated/api-io.d.ts";
@@ -27,13 +31,14 @@ import { defineOperation } from "../nano-generated/operations.ts";
27
31
  const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
28
32
 
29
33
  // Project a presence-registry row to the wire worker. The drill `stream` defaults to the worker
30
- // instance (H5) but is repointed at the worker's jobKey-scoped relay stream (`job:<jobKey>`) when the
31
- // correlation registry knows a current job for it (H6) — so drilling in opens the LIVE job's terminal.
32
- function toWorker(w: SupplyWorker, correlation: CorrelationRegistry | undefined): AgenticSupplyWorker {
34
+ // instance (H5) but is repointed at the worker's claimed jobKey-scoped relay stream (`job:<jobKey>`)
35
+ // when the claim registry knows a current claim for it (#713) — keyed by the CLAIM, not by the
36
+ // connection so drilling in opens the LIVE job's terminal even before any transcript lands.
37
+ function toWorker(w: SupplyWorker, claims: ClaimRegistry | undefined): AgenticSupplyWorker {
33
38
  const out: AgenticSupplyWorker = {
34
39
  instance: w.instance,
35
40
  identity: w.identity,
36
- stream: correlation?.primaryStreamFor(w.instance) ?? w.instance,
41
+ stream: claims?.primaryStreamFor(w.instance) ?? w.instance,
37
42
  jobKeys: [...w.jobKeys],
38
43
  live: w.live,
39
44
  staleMs: w.staleMs,
@@ -67,15 +72,19 @@ export default defineOperation("getAgenticSupply", async ({ req }, app) => {
67
72
  return { status: 200, body: empty };
68
73
  }
69
74
 
70
- // Thread the H6 correlation registry (if mounted) as the presence snapshot's jobKeysFor resolver so a
71
- // worker's current jobKeys populate; absent jobKeys stay empty (advisory, never an error).
75
+ // #713: the CLAIM registry (if mounted) is the authoritative `jobKeysFor` source the presence
76
+ // snapshot resolves against — a worker's current jobKeys come from explicit `claim` frames, not the
77
+ // relay terminal, so they populate with zero transcript. Absent → jobKeys stay empty (advisory).
78
+ const claims = currentClaimRegistry();
79
+ // The correlation registry is demoted to drill-in context only: it still carries the per-job
80
+ // process-instance / plan context surfaced in `correlations`, but no longer feeds visibility.
72
81
  const correlation = currentCorrelation();
73
- const snapshot = registry.snapshot(correlation ? { jobKeysFor: (instance) => correlation.jobKeysFor(instance) } : {});
82
+ const snapshot = registry.snapshot(claims ? { jobKeysFor: (instance) => claims.jobKeysFor(instance) } : {});
74
83
  const report: AgenticSupplyReport = {
75
84
  count: snapshot.count,
76
85
  generatedAt: new Date().toISOString(),
77
- workers: snapshot.workers.map((w) => toWorker(w, correlation)),
78
- leaves: snapshot.leaves.map((leaf) => ({ token: leaf.token, workers: leaf.workers.map((w) => toWorker(w, correlation)) })),
86
+ workers: snapshot.workers.map((w) => toWorker(w, claims)),
87
+ leaves: snapshot.leaves.map((leaf) => ({ token: leaf.token, workers: leaf.workers.map((w) => toWorker(w, claims)) })),
79
88
  correlations: correlation ? correlation.snapshot().correlations.map(toCorrelation) : [],
80
89
  };
81
90
  return { status: 200, body: report };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.176.1",
3
+ "version": "0.177.0",
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",
@@ -62,7 +62,7 @@
62
62
  "lint:fix": "biome check --write app operations workers pages components scripts e2e main.ts"
63
63
  },
64
64
  "dependencies": {
65
- "@nanobpm/agentic": "^0.10.0",
65
+ "@nanobpm/agentic": "^0.11.0",
66
66
  "@nanobpm/urban": "^0.90.0",
67
67
  "bpmn-auto-layout": "^2.0.0-alpha.2"
68
68
  },
@@ -154,6 +154,7 @@ test("E2E: the whole visibility plane wires up — presence, correlation, supply
154
154
  assert(names.includes("presence"), "H1 presence family is discovered by the H0 seam");
155
155
  assert(names.includes("relay"), "H3 relay family is discovered by the H0 seam");
156
156
  assert(names.includes("correlation"), "H6 correlation family is discovered by the H0 seam");
157
+ assert(names.includes("claim"), "#713 claim family is discovered by the H0 seam");
157
158
 
158
159
  // ── H1: a worker connects and REGISTERs; a live presence row appears with its family/host. ──
159
160
  const worker = conn("wk-conn-1", "leafA");
@@ -162,7 +163,14 @@ test("E2E: the whole visibility plane wires up — presence, correlation, supply
162
163
  worker.feed({ lane: "control", family: "register", seq: 1, payload: { instance: "wk-a", capability: { family: "opus", host: "boxA" } } });
163
164
  await flush();
164
165
 
165
- // ── H6: the orchestrator links the worker's active jobKey to its process instance / plan. ──
166
+ // ── #713: the worker CLAIMs its active jobKey the authoritative visibility source (explicit
167
+ // instance, no relay/connection inference). This is what lights up jobKeys, even before any
168
+ // transcript lands. ──
169
+ worker.feed({ lane: "control", family: "claim", seq: 2, payload: { instance: "wk-a", jobKey: JOB } });
170
+ await flush();
171
+
172
+ // ── H6: the orchestrator links the worker's active jobKey to its process instance / plan (drill-in
173
+ // context only, since #713 — no longer the visibility source). ──
166
174
  const correlation = currentCorrelation();
167
175
  assert(correlation !== undefined, "the correlation family installed the singleton");
168
176
  correlation.link("wk-a", JOB, { processInstanceKey: "4612", bpmnProcessId: "plan-fanout", elementId: "implement-task", planKey: "nanobpm/nano-workforce#142" });
@@ -179,8 +187,8 @@ test("E2E: the whole visibility plane wires up — presence, correlation, supply
179
187
  assertEquals(w.instance, "wk-a");
180
188
  assertEquals(w.family, "opus");
181
189
  assertEquals(w.host, "boxA");
182
- assertEquals(w.jobKeys, [JOB], "H1×H6: the correlation registry feeds the presence jobKeys seam");
183
- assertEquals(w.stream, STREAM, "H6: the drill stream repoints at the live job's stream");
190
+ assertEquals(w.jobKeys, [JOB], "#713: the claim registry feeds the presence jobKeys seam");
191
+ assertEquals(w.stream, STREAM, "#713: the drill stream repoints at the claimed job's stream");
184
192
  assertEquals(res.body.correlations.length, 1);
185
193
  const c = res.body.correlations[0];
186
194
  assertEquals(c.jobKey, JOB);