Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/agent/src/chain-reconciler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,11 @@ export type OrdinalOutcome =
| { status: 'skip' };

export interface OrdinalRecoveryTarget {
localCgId: string;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Do not leak exact-recovery rotation identity into the pure chain reconciler target

What's wrong
The PR widens the generic reconciler contract with agent-local identity data. That couples the pure watermark engine to one recovery implementation and makes future callers/tests pay for fields they do not conceptually own.

Example
The tests now need a vmRecoveryTarget helper that fabricates localCgId, onChainCgId, and merkleRoot for every target even when the scenario only cares about ordinal scheduling.

Suggested direction
Keep OrdinalRecoveryTarget minimal or make the recovery payload owned by the VM recovery layer. The pure reconciler should not carry fields whose only purpose is process-local rotation/backoff bookkeeping.

For Agents
Keep chain-reconciler.ts focused on ordinal reconciliation. Move exact-recovery identity into an agent-local target type or construct a VmExactRecoveryTarget at the recoverVmReconcileBatch boundary from the batch context plus the pending outcome. Preserve the existing recovery callback behavior, and verify the chain reconciler tests no longer need VM rotation-specific fields.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Avoid duplicating batch identity on every recovery target

What's wrong
The new target shape creates two sources of truth for the context graph identity: the method parameters and the per-target fields. That weakens the type boundary and pushes stale-target defense into incidental control flow rather than making the invariant clear at construction time.

Example
recoverVmReconcileBatch('current', 1n, [{ localCgId: 'other', onChainCgId: '1', ... }], ...) is now a representable state and is silently filtered out. The type permits contradictory batch identity, so the implementation has to defend against that contradiction in multiple places.

Suggested direction
Replace the duplicated fields with a single explicit batch/slot model so contradictory target identity is impossible or isolated at one boundary. That should remove the ad-hoc filtering and reduce repeated equality checks in the recovery loop.

For Agents
Start with OrdinalRecoveryTarget in packages/agent/src/chain-reconciler.ts and its construction in reconcileChainOrdinal. Preserve stale in-flight response protection, but model graph identity once: either pass a typed RecoveryBatch/RecoverySlot object through the flow, or keep targets context-free and derive the slot key from the batch boundary. Add focused tests for mismatched/stale target handling after the boundary is explicit.

onChainCgId: string;
ordinal: number;
ual: string;
merkleRoot: string;
kaId: string;
reason: 'no-swm' | 'verified-vm-metadata-pending';
}
Expand Down
32 changes: 32 additions & 0 deletions packages/agent/src/context-graph-binding-generation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: Apache-2.0

export function bumpContextGraphBindingGeneration(
generations: Map<string, number>,
localCgId: string,
): number {
const generation = (generations.get(localCgId) ?? 0) + 1;
generations.set(localCgId, generation);
return generation;
}

export function captureContextGraphBindingGeneration(
generations: Map<string, number>,
localCgId: string,
): number {
return generations.get(localCgId) ?? 0;
}

export function isContextGraphBindingGenerationCurrent(
generations: Map<string, number>,
localCgId: string,
generation: number,
): boolean {
return captureContextGraphBindingGeneration(generations, localCgId) === generation;
}

export function clearContextGraphBindingGeneration(
generations: Map<string, number>,
localCgId: string,
): void {
generations.delete(localCgId);
}
159 changes: 159 additions & 0 deletions packages/agent/src/context-graph-membership-persist-scheduler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// SPDX-License-Identifier: Apache-2.0

export class ContextGraphMembershipPersistQueueFullError extends Error {
readonly code = 'CG_MEMBERSHIP_PERSIST_QUEUE_FULL';

constructor(message: string) {
super(message);
this.name = 'ContextGraphMembershipPersistQueueFullError';
}
}

export class ContextGraphMembershipPersistQueueClosedError extends Error {
readonly code = 'CG_MEMBERSHIP_PERSIST_QUEUE_CLOSED';

constructor() {
super('Context-graph membership persistence is closed');
this.name = 'ContextGraphMembershipPersistQueueClosedError';
}
}

export const CONTEXT_GRAPH_MEMBERSHIP_PERSIST_SHUTDOWN_TIMEOUT_ERROR_CODE =
'CG_MEMBERSHIP_PERSIST_SHUTDOWN_TIMEOUT';

export class ContextGraphMembershipPersistShutdownTimeoutError extends Error {
readonly code = CONTEXT_GRAPH_MEMBERSHIP_PERSIST_SHUTDOWN_TIMEOUT_ERROR_CODE;

constructor(timeoutMs: number) {
super(`Context-graph membership persistence did not drain within ${timeoutMs}ms`);
this.name = 'ContextGraphMembershipPersistShutdownTimeoutError';
}
}

interface PendingWrite {
strict: boolean;
write: () => Promise<void>;
resolve: () => void;
reject: (error: unknown) => void;
}

interface PersistLane {
active: boolean;
pending: PendingWrite[];
drained: Promise<void>;
resolveDrained: () => void;
}

export interface ContextGraphMembershipPersistSchedulerStatus {
closed: boolean;
lanes: number;
active: number;
pending: number;
}

/**
* Bounded keyed serialization for membership-store mutations.
*
* Strict operations preserve FIFO order and receive explicit backpressure.
* Adjacent background mutations coalesce to their latest write while the
* displaced caller settles successfully: those callers are deliberately
* best-effort, and only the final persisted state is meaningful.
*/
export class ContextGraphMembershipPersistScheduler {
private readonly lanes = new Map<string, PersistLane>();
private closed = false;

constructor(
private readonly maxLanes = 1_000,
private readonly maxPendingPerLane = 16,
) {
if (!Number.isSafeInteger(maxLanes) || maxLanes < 1) {
throw new Error('Membership persistence maxLanes must be a positive safe integer');
}
if (!Number.isSafeInteger(maxPendingPerLane) || maxPendingPerLane < 1) {
throw new Error('Membership persistence maxPendingPerLane must be a positive safe integer');
}
}

enqueue(
key: string,
write: () => Promise<void>,
options: { strict?: boolean } = {},
): Promise<void> {
if (this.closed) {
return Promise.reject(new ContextGraphMembershipPersistQueueClosedError());
}

let lane = this.lanes.get(key);
if (!lane) {
if (this.lanes.size >= this.maxLanes) {
return Promise.reject(new ContextGraphMembershipPersistQueueFullError(
`Context-graph membership persistence reached its ${this.maxLanes}-lane limit`,
));
}
let resolveDrained!: () => void;
const drained = new Promise<void>((resolve) => { resolveDrained = resolve; });
lane = { active: false, pending: [], drained, resolveDrained };
this.lanes.set(key, lane);
}

const strict = options.strict === true;
return new Promise<void>((resolve, reject) => {
const tail = lane!.pending.at(-1);
if (!strict && tail && !tail.strict) {
Comment thread
Jurij89 marked this conversation as resolved.
tail.resolve();
lane!.pending[lane!.pending.length - 1] = { strict, write, resolve, reject };
} else {
if (lane!.pending.length >= this.maxPendingPerLane) {
reject(new ContextGraphMembershipPersistQueueFullError(
`Context-graph membership persistence key "${key}" reached its `
+ `${this.maxPendingPerLane}-write pending limit`,
));
return;
}
lane!.pending.push({ strict, write, resolve, reject });
}
if (!lane!.active) {
lane!.active = true;
void this.runLane(key, lane!);
}
});
}

closeAndDrain(): Promise<void> {
this.closed = true;
return Promise.all([...this.lanes.values()].map((lane) => lane.drained)).then(() => undefined);
}

reopen(): void {
if (this.lanes.size > 0) {
throw new Error('Cannot reopen context-graph membership persistence before it drains');
}
this.closed = false;
}

status(): ContextGraphMembershipPersistSchedulerStatus {
let active = 0;
let pending = 0;
for (const lane of this.lanes.values()) {
if (lane.active) active += 1;
pending += lane.pending.length;
}
return { closed: this.closed, lanes: this.lanes.size, active, pending };
}

private async runLane(key: string, lane: PersistLane): Promise<void> {
while (lane.pending.length > 0) {
const operation = lane.pending.shift()!;
try {
await operation.write();
operation.resolve();
} catch (error) {
operation.reject(error);
}
}
lane.active = false;
if (this.lanes.get(key) === lane) this.lanes.delete(key);
lane.resolveDrained();
}
}
69 changes: 64 additions & 5 deletions packages/agent/src/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,19 @@ export class DiscoveryClient {
this.engine = engine;
}

async findAgents(options: { framework?: string; limit?: number } = {}): Promise<DiscoveredAgent[]> {
async findAgents(options: {
framework?: string;
agentAddress?: string;
limit?: number;
signal?: AbortSignal;
} = {}): Promise<DiscoveredAgent[]> {
let filter = '';
if (options.framework) {
filter += `\n ?agent <${SKILL}framework> "${escapeSparqlLiteral(options.framework)}" .`;
}
if (options.agentAddress) {
filter += `\n ?agent <${DKG}agentAddress> "${escapeSparqlLiteral(options.agentAddress)}" .`;
}

const limitClause = options.limit ? `LIMIT ${options.limit}` : '';

Expand All @@ -80,7 +88,10 @@ export class DiscoveryClient {
${limitClause}
`;

const result = await this.engine.query(sparql, { contextGraphId: AGENT_REGISTRY_CONTEXT_GRAPH });
const result = await this.engine.query(sparql, {
contextGraphId: AGENT_REGISTRY_CONTEXT_GRAPH,
signal: options.signal,
});

return result.bindings.map((row) => ({
agentUri: row['agent'],
Expand All @@ -93,6 +104,45 @@ export class DiscoveryClient {
}));
}

/**
* Deterministic, duplicate-free wallet-to-peer lookup for bounded recovery.
* Rich profile rows are deliberately not selected here: OPTIONAL profile
* properties can multiply rows before LIMIT and permanently hide a peer.
*/
async findAgentPeerIdsByAddress(
agentAddress: string,
options: { afterPeerId?: string; limit?: number; signal?: AbortSignal } = {},
): Promise<string[]> {
const isEvmAddress = /^0x[0-9a-fA-F]{40}$/.test(agentAddress);
const addressMatch = isEvmAddress
? `?agent <${DKG}agentAddress> ?storedAgentAddress .
FILTER(LCASE(STR(?storedAgentAddress)) = "${escapeSparqlLiteral(agentAddress.toLowerCase())}")`
: `?agent <${DKG}agentAddress> "${escapeSparqlLiteral(agentAddress)}" .`;
const limit = options.limit === undefined
? undefined
: Math.max(1, Math.floor(options.limit));
const afterFilter = options.afterPeerId
? `FILTER(STR(?peerId) > "${escapeSparqlLiteral(options.afterPeerId)}")`
: '';
const result = await this.engine.query(`
SELECT DISTINCT ?peerId WHERE {
?agent a <${DKG}Agent> ;
<${DKG}peerId> ?peerId .
${addressMatch}
${afterFilter}
}
ORDER BY ASC(STR(?peerId))
${limit === undefined ? '' : `LIMIT ${limit}`}
`, {
contextGraphId: AGENT_REGISTRY_CONTEXT_GRAPH,
signal: options.signal,
});

return result.bindings
.map((row) => stripQuotes(row['peerId'] ?? ''))
.filter((peerId) => peerId.length > 0);
}

async findSkillOfferings(options: SkillSearchOptions = {}): Promise<DiscoveredOffering[]> {
const filters: string[] = [];

Expand Down Expand Up @@ -144,7 +194,10 @@ export class DiscoveryClient {
}));
}

async findAgentByPeerId(peerId: string): Promise<DiscoveredAgent | null> {
async findAgentByPeerId(
peerId: string,
options: { signal?: AbortSignal } = {},
): Promise<DiscoveredAgent | null> {
// Two-query path keeps the existing single-row SELECT semantics
// for scalar columns (name, framework, nodeRole, relayAddress,
// lastSeen) while a separate query gathers all `dkg:multiaddr`
Expand Down Expand Up @@ -174,7 +227,10 @@ export class DiscoveryClient {
LIMIT 1
`;

const scalarResult = await this.engine.query(scalar, { contextGraphId: AGENT_REGISTRY_CONTEXT_GRAPH });
const scalarResult = await this.engine.query(scalar, {
contextGraphId: AGENT_REGISTRY_CONTEXT_GRAPH,
signal: options.signal,
});
if (scalarResult.bindings.length === 0) return null;

const row = scalarResult.bindings[0];
Expand Down Expand Up @@ -202,7 +258,10 @@ export class DiscoveryClient {
${sparqlIri(safeAgentIri)} <${DKG}multiaddr> ?multiaddr .
}
`;
const multiResult = await this.engine.query(multiSparql, { contextGraphId: AGENT_REGISTRY_CONTEXT_GRAPH });
const multiResult = await this.engine.query(multiSparql, {
contextGraphId: AGENT_REGISTRY_CONTEXT_GRAPH,
signal: options.signal,
});
const multiaddrs = multiResult.bindings
.map((r) => (r['multiaddr'] ? stripQuotes(r['multiaddr']) : ''))
.filter((s) => s.length > 0);
Expand Down
Loading
Loading