@nanobpm/nano-workforce 0.176.0 → 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,15 @@
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
+
7
+ ## [0.176.1](https://github.com/nanobpm/nano-workforce/compare/v0.176.0...v0.176.1) (2026-09-02)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **cancel:** reconcile feature_runs in the app cancel door with a truthful result ([#712](https://github.com/nanobpm/nano-workforce/issues/712)) ([810979b](https://github.com/nanobpm/nano-workforce/commit/810979b3708a247b2ab4ee068c211abd5478363a)), closes [#667](https://github.com/nanobpm/nano-workforce/issues/667) [#705](https://github.com/nanobpm/nano-workforce/issues/705)
12
+
1
13
  ## [0.176.0](https://github.com/nanobpm/nano-workforce/compare/v0.175.2...v0.176.0) (2026-09-02)
2
14
 
3
15
  ### Features
@@ -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
+ });