Skip to content

Commit 4abcade

Browse files
neo-opus-adatobiu
andauthored
feat(ai): topological lock conflict-detection core (#13125) (#13126)
* feat(ai): topological lock conflict-detection core (#13125) Adds Neo.ai.LockRegistry, the pure conflict-detection core for multi-writer Neural Link coordination (a #13056 leaf closing the isolation/conflict-semantics surface). Topological locking is sequenced before any multi-writer slice. Static, fail-closed operations over a caller-held lock table: subtree-overlap conflict semantics keyed on (agentId, sessionId, subtreePath), an explicit-target predicate for the auto-targeting deprecation, idempotent release, and a disconnect/restart sweep. It is the logic an in-heap (App-Worker) authority enforces, not a transport-side lock owner. 36 unit tests (validation, overlap matrix, conflict, acquire/release/sweep, explicit-target), all green. In-heap enforcement, Bridge advisory mirroring, lease expiry, and the canonical-id binding are named follow-up slices. Co-authored-by: tobiu <tobiasuhlig78@gmail.com> * fix(ai): fail-safe requiresExplicitTarget on non-finite counts (#13125) Per cross-family review on #13126: requiresExplicitTarget returned the permissive value (false = implicit auto-targeting allowed) for non-finite / unexpected counts — fail-open for a safety gate. The realistic trigger is a consumer's sessions?.length resolving to undefined, masking a count-source bug as 'implicit OK' exactly when co-habitation is live. Flip to fail-safe: only a count known to be exactly 0 or 1 permits implicit; anything else (2+, non-finite, negative) requires an explicit target. Updates the spec and drops the self-contradictory 'fails closed to false' label. Co-authored-by: tobiu <tobiasuhlig78@gmail.com> --------- Co-authored-by: tobiu <tobiasuhlig78@gmail.com>
1 parent 212b1a9 commit 4abcade

2 files changed

Lines changed: 501 additions & 0 deletions

File tree

src/ai/LockRegistry.mjs

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import Base from '../core/Base.mjs';
2+
3+
/**
4+
* @summary Deterministic topological-lock conflict logic for multi-writer Neural Link coordination.
5+
*
6+
* `Neo.ai.LockRegistry` is the pure, side-effect-free *conflict-detection core* an in-heap (App-Worker)
7+
* authority consults before applying a write-class Neural Link operation when more than one agent shares
8+
* a live application heap — the co-habitation premise of the agent harness, where agents and humans mutate
9+
* the same live components. Topological locking is sequenced ahead of any multi-writer slice: a slice either
10+
* acquires its locks through this logic or explicitly declares itself single-writer.
11+
*
12+
* It is deliberately **not** a transport-side lock owner: the coordination epic rejects Bridge-authoritative
13+
* locking because transport-held locks desync from in-heap reality under reconnects and worker restarts.
14+
* The heap is the truth; this class is the logic the heap's authority enforces. Accordingly every method
15+
* is **static and operates on a caller-held `lockTable`** (a plain array the authority owns), returning a
16+
* new array rather than mutating shared state — so the logic carries no singleton lifecycle and is trivial
17+
* to test and reason about.
18+
*
19+
* A lock is `{agentId, sessionId, subtreePath}`: a writer — identified by the `(agentId, sessionId)` pair —
20+
* holds a component **subtree**, named by its root→node component-id path (`subtreePath`). Two locks conflict
21+
* iff their subtrees overlap (equal, ancestor, or descendant paths) **and** they belong to *different*
22+
* writers; sibling subtrees never conflict and same-writer re-acquisition is re-entrant. `sessionId` is
23+
* consumed as an **opaque** string here — the canonical-session-id mechanics are a separate concern.
24+
*
25+
* Scope boundary: this leaf is the conflict logic only. In-heap enforcement (intercepting the write path),
26+
* Bridge-side advisory mirroring, lock-lease expiry policy, and the canonical-id binding are named
27+
* follow-up consumer slices, not this module.
28+
* @class Neo.ai.LockRegistry
29+
* @extends Neo.core.Base
30+
*/
31+
class LockRegistry extends Base {
32+
static config = {
33+
/**
34+
* @member {String} className='Neo.ai.LockRegistry'
35+
* @protected
36+
*/
37+
className: 'Neo.ai.LockRegistry'
38+
}
39+
40+
/**
41+
* @summary Validate a lock descriptor, failing closed.
42+
* Returns a normalized copy of the lock, or `null` when any field is missing or malformed — callers
43+
* treat `null` as a denial, never as an empty or wildcard lock.
44+
* @param {Object} lock
45+
* @param {String} lock.agentId Non-empty writer (agent) identity.
46+
* @param {String} lock.sessionId Non-empty, opaque, harness-native session id.
47+
* @param {String[]} lock.subtreePath Ordered root→node component-id path; non-empty, all non-empty strings.
48+
* @returns {Object|null}
49+
*/
50+
static normalizeLock(lock) {
51+
if (!lock || typeof lock !== 'object') {
52+
return null
53+
}
54+
55+
const {agentId, sessionId, subtreePath} = lock;
56+
57+
if (typeof agentId !== 'string' || agentId === '') {return null}
58+
if (typeof sessionId !== 'string' || sessionId === '') {return null}
59+
if (!Array.isArray(subtreePath) || subtreePath.length === 0) {return null}
60+
if (!subtreePath.every(segment => typeof segment === 'string' && segment !== '')) {return null}
61+
62+
return {agentId, sessionId, subtreePath: [...subtreePath]}
63+
}
64+
65+
/**
66+
* @summary Stable string identity of a lock (writer + exact subtree), for dedup and release.
67+
* Built with `JSON.stringify` over the field tuple, so distinct `(agentId, sessionId, subtreePath)`
68+
* triples never collide regardless of the characters an id contains.
69+
* @param {Object} lock A normalized lock.
70+
* @returns {String}
71+
*/
72+
static lockKey({agentId, sessionId, subtreePath}) {
73+
return JSON.stringify([agentId, sessionId, subtreePath])
74+
}
75+
76+
/**
77+
* @summary Whether two locks belong to the same `(agentId, sessionId)` writer.
78+
* @param {Object} a A normalized lock.
79+
* @param {Object} b A normalized lock.
80+
* @returns {Boolean}
81+
*/
82+
static sameWriter(a, b) {
83+
return a.agentId === b.agentId && a.sessionId === b.sessionId
84+
}
85+
86+
/**
87+
* @summary Whether two component subtrees overlap (one equals or contains the other).
88+
* Overlap ⇔ one path is a prefix of the other (a prefix includes the equal case); sibling paths, which
89+
* diverge before either ends, never overlap. A lock on the root path therefore overlaps everything.
90+
* @param {String[]} a Ordered root→node id path.
91+
* @param {String[]} b Ordered root→node id path.
92+
* @returns {Boolean}
93+
*/
94+
static pathsOverlap(a, b) {
95+
const min = Math.min(a.length, b.length);
96+
97+
for (let i = 0; i < min; i++) {
98+
if (a[i] !== b[i]) {
99+
return false // diverged before either subtree root → siblings, disjoint
100+
}
101+
}
102+
103+
return true // one path is a prefix of the other (incl. equal) → subtrees overlap
104+
}
105+
106+
/**
107+
* @summary Find an existing lock held by a *different* writer whose subtree overlaps `candidate`.
108+
* Returns the conflicting held lock (the first match), or `null` when `candidate` can be granted.
109+
* Both arguments are expected to be normalized locks (see {@link normalizeLock}).
110+
* @param {Object[]} lockTable Caller-held active locks (normalized).
111+
* @param {Object} candidate A normalized lock.
112+
* @returns {Object|null}
113+
*/
114+
static findConflict(lockTable, candidate) {
115+
for (const held of lockTable) {
116+
if (!LockRegistry.sameWriter(held, candidate) &&
117+
LockRegistry.pathsOverlap(held.subtreePath, candidate.subtreePath)) {
118+
return held
119+
}
120+
}
121+
122+
return null
123+
}
124+
125+
/**
126+
* @summary Attempt to acquire a lock, returning a new table and the outcome — never mutates the input.
127+
* Fail-closed in three layers: a malformed lock is denied with an error and an unchanged table; a
128+
* cross-writer subtree overlap is denied with the conflicting holder; otherwise the lock is granted.
129+
* Re-acquiring an identical lock by the same writer is re-entrant (granted, no duplicate appended).
130+
* @param {Object[]} lockTable The caller-held active locks.
131+
* @param {Object} lock The lock to acquire.
132+
* @returns {{lockTable: Object[], granted: Boolean, conflict: Object|null, errors: String[]}}
133+
*/
134+
static acquire(lockTable, lock) {
135+
const table = Array.isArray(lockTable) ? lockTable : [],
136+
norm = LockRegistry.normalizeLock(lock);
137+
138+
if (!norm) {
139+
return {lockTable: table, granted: false, conflict: null, errors: ['invalid lock descriptor']}
140+
}
141+
142+
const conflict = LockRegistry.findConflict(table, norm);
143+
144+
if (conflict) {
145+
return {lockTable: table, granted: false, conflict, errors: []}
146+
}
147+
148+
const key = LockRegistry.lockKey(norm);
149+
150+
if (table.some(held => LockRegistry.lockKey(held) === key)) {
151+
return {lockTable: table, granted: true, conflict: null, errors: []} // re-entrant, idempotent
152+
}
153+
154+
return {lockTable: [...table, norm], granted: true, conflict: null, errors: []}
155+
}
156+
157+
/**
158+
* @summary Release one exact lock (same writer + same subtree), idempotently.
159+
* Releasing a lock that is not held is a no-op — the table is returned unchanged — so a double-release
160+
* or a release after a sweep never throws. A lock on the same subtree held by a *different* writer is
161+
* never released by this call.
162+
* @param {Object[]} lockTable The caller-held active locks.
163+
* @param {Object} lock The lock to release.
164+
* @returns {{lockTable: Object[]}}
165+
*/
166+
static release(lockTable, lock) {
167+
const table = Array.isArray(lockTable) ? lockTable : [],
168+
norm = LockRegistry.normalizeLock(lock);
169+
170+
if (!norm) {
171+
return {lockTable: table}
172+
}
173+
174+
const key = LockRegistry.lockKey(norm);
175+
176+
return {lockTable: table.filter(held => LockRegistry.lockKey(held) !== key)}
177+
}
178+
179+
/**
180+
* @summary Release every lock matching a writer selector — the disconnect / worker-restart sweep hook.
181+
* Pass `agentId` and/or `sessionId`; a lock is swept when it matches *all* provided fields. Passing
182+
* neither field selects nothing (fail-closed: a selector-less sweep never clears the whole table).
183+
* @param {Object[]} lockTable The caller-held active locks.
184+
* @param {Object} [selector={}]
185+
* @param {String} [selector.agentId]
186+
* @param {String} [selector.sessionId]
187+
* @returns {{lockTable: Object[], released: Number}}
188+
*/
189+
static releaseAll(lockTable, selector = {}) {
190+
const table = Array.isArray(lockTable) ? lockTable : [],
191+
{agentId, sessionId} = selector;
192+
193+
if (agentId === undefined && sessionId === undefined) {
194+
return {lockTable: table, released: 0}
195+
}
196+
197+
const kept = table.filter(held => !(
198+
(agentId === undefined || held.agentId === agentId) &&
199+
(sessionId === undefined || held.sessionId === sessionId)
200+
));
201+
202+
return {lockTable: kept, released: table.length - kept.length}
203+
}
204+
205+
/**
206+
* @summary Whether an explicit write target is mandatory for the given live-session count (fail-safe).
207+
* Only a count *known* to be exactly 0 or 1 permits the implicit "most recent session" shortcut
208+
* (`ConnectionService.call()`, single-writer-safe). Any other value — 2+, or a non-finite / unexpected
209+
* count such as a consumer's `sessions?.length` resolving to `undefined` — requires an explicit target:
210+
* if the live-session count cannot be confirmed safe, we must not silently re-enable auto-targeting
211+
* while co-habitation may be live. The fail direction is the strict one (`true`).
212+
* @param {Number} liveSessionCount
213+
* @returns {Boolean}
214+
*/
215+
static requiresExplicitTarget(liveSessionCount) {
216+
return liveSessionCount !== 0 && liveSessionCount !== 1
217+
}
218+
}
219+
220+
export default Neo.setupClass(LockRegistry);

0 commit comments

Comments
 (0)