@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 +6 -0
- package/app/agentic/claim-registry.test.ts +155 -0
- package/app/agentic/claim-registry.ts +194 -0
- package/app/agentic/families/claim.family.test.ts +196 -0
- package/app/agentic/families/claim.family.ts +148 -0
- package/operations/getAgenticSupply.test.ts +32 -3
- package/operations/getAgenticSupply.ts +24 -15
- package/package.json +2 -2
- package/test/agentic-e2e.test.ts +11 -3
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("
|
|
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, [
|
|
180
|
-
assertEquals(w.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
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
|
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>`)
|
|
31
|
-
//
|
|
32
|
-
|
|
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:
|
|
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
|
-
//
|
|
71
|
-
// worker's current jobKeys
|
|
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(
|
|
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,
|
|
78
|
-
leaves: snapshot.leaves.map((leaf) => ({ token: leaf.token, workers: leaf.workers.map((w) => toWorker(w,
|
|
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.
|
|
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.
|
|
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
|
},
|
package/test/agentic-e2e.test.ts
CHANGED
|
@@ -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
|
-
// ──
|
|
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], "
|
|
183
|
-
assertEquals(w.stream, 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);
|