Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
25 changes: 24 additions & 1 deletion packages/agent/src/dkg-agent-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6976,7 +6976,7 @@ export class LifecycleSyncMethods extends DKGAgentBase {

updateContextGraphSubscriptionRehydrationStatusAfterPersist(this: DKGAgent,
contextGraphId: string,
next?: Pick<ContextGraphSubscriptionRecord, 'subscribed' | 'coreHosted'>,
next?: Pick<ContextGraphSubscriptionRecord, 'subscribed' | 'coreHosted' | 'syncAdmission'>,
): void {
const status = this.contextGraphSubscriptionRehydrationStatus;
if (!status) return;
Expand All @@ -6992,9 +6992,27 @@ export class LifecycleSyncMethods extends DKGAgentBase {
let activated = status.activated;
let dormantIds = status.dormantIds.filter((id) => id !== contextGraphId);
let nextHostedActivatedIds = hostedActivatedIds.filter((id) => id !== contextGraphId);
let nextRehydratedAlwaysOnIds = (status.rehydratedAlwaysOnIds ?? [])
.filter((id) => id !== contextGraphId);
if (next?.coreHosted === true) {
nextHostedActivatedIds = sortIds([...nextHostedActivatedIds, contextGraphId]);
}
// A freshly configured Edge selection is just as durable as one loaded
Comment thread
branarakic marked this conversation as resolved.
Outdated
// from a previous process once this save commits. Admit it to the bounded
// periodic lane now so a first cold boot with broad sync-on-connect
// disabled does not require an otherwise pointless restart. On-demand
// subscriptions never reach this branch because their persistence
// projection is `skip`.
if (
(this.config.nodeRole ?? 'edge') === 'edge'
&& next?.subscribed === true
&& next.syncAdmission === 'explicit'
) {
nextRehydratedAlwaysOnIds = sortIds([
...nextRehydratedAlwaysOnIds,
contextGraphId,
]);
}

if (isPersisted) {
if (wasDormant) {
Expand All @@ -7017,6 +7035,7 @@ export class LifecycleSyncMethods extends DKGAgentBase {
persistedTotal,
hostedActivated: nextHostedActivatedIds.length,
hostedActivatedIds: nextHostedActivatedIds,
rehydratedAlwaysOnIds: nextRehydratedAlwaysOnIds,
activated,
dormant: dormantIds.length,
dormantIds,
Expand All @@ -7033,6 +7052,7 @@ export class LifecycleSyncMethods extends DKGAgentBase {
const systemContextGraphs = new Set<string>(Object.values(SYSTEM_CONTEXT_GRAPHS) as string[]);
const dormantIds = [...status.dormantIds];
const hostedActivatedIds = [...(status.hostedActivatedIds ?? [])];
const rehydratedAlwaysOnIds = [...(status.rehydratedAlwaysOnIds ?? [])];
const removeFrom = (ids: string[], id: string): boolean => {
const index = ids.indexOf(id);
if (index < 0) return false;
Expand All @@ -7048,6 +7068,7 @@ export class LifecycleSyncMethods extends DKGAgentBase {
const wasAccounted = this.contextGraphSubscriptionRehydrationAccountedIds.delete(id);
const wasDormant = removeFrom(dormantIds, id);
removeFrom(hostedActivatedIds, id);
removeFrom(rehydratedAlwaysOnIds, id);
Comment thread
branarakic marked this conversation as resolved.
Outdated
if (!wasAccounted) continue;
persistedTotal = Math.max(0, persistedTotal - 1);
if (!wasDormant) {
Expand All @@ -7058,6 +7079,7 @@ export class LifecycleSyncMethods extends DKGAgentBase {
if (systemContextGraphs.has(id) || clearedSet.has(id)) continue;
if (!this.contextGraphSubscriptionRehydrationAccountedIds.has(id)) continue;
removeFrom(hostedActivatedIds, id);
removeFrom(rehydratedAlwaysOnIds, id);
if (!dormantIds.includes(id)) {
activated = Math.max(0, activated - 1);
dormantIds.push(id);
Expand All @@ -7070,6 +7092,7 @@ export class LifecycleSyncMethods extends DKGAgentBase {
persistedTotal,
hostedActivated: hostedActivatedIds.length,
hostedActivatedIds,
rehydratedAlwaysOnIds,
activated,
dormant: dormantIds.length,
dormantIds,
Expand Down
6 changes: 5 additions & 1 deletion packages/agent/src/dkg-agent-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -809,7 +809,11 @@ export interface ContextGraphSubscriptionRehydrationStatus {
systemExcluded: number;
hostedActivated: number;
hostedActivatedIds: string[];
/** Edge always-on selections restored during this process's startup wave. */
/**
* Edge always-on selections backed by durable subscription intent in this
* process. This includes rows restored during startup and selections whose
* first durable write completes after startup.
*/
rehydratedAlwaysOnIds?: string[];
activated: number;
dormant: number;
Expand Down
103 changes: 103 additions & 0 deletions packages/agent/test/sync-coverage-evidence-edge-periodic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,110 @@ async function createRehydratedEdgeEvidenceAgent(contextGraphId: string): Promis
return agent;
}

async function createFreshConfiguredEdgeEvidenceAgent(contextGraphId: string): Promise<DKGAgent> {
Comment thread
branarakic marked this conversation as resolved.
const persisted = new Map<string, ContextGraphSubscriptionRecord>();
const contextGraphSubscriptionStore: ContextGraphSubscriptionStore = {
loadAll: async () => [...persisted.values()],
save: async (record) => { persisted.set(record.id, { ...record }); },
delete: async (id) => { persisted.delete(id); },
};
const agent = await DKGAgent.create({
name: 'SyncEvidenceFreshConfiguredEdgePeriodic',
listenHost: '127.0.0.1',
nodeRole: 'edge',
syncContextGraphs: [contextGraphId],
syncOnConnectEnabled: false,
syncSharedMemoryOnConnect: false,
chainAdapter: new MockChainAdapter(),
contextGraphSubscriptionStore,
});
(agent as any).started = true;
(agent as any).networkAdmissionCoordinator.isAcceptedPeer = () => true;
(agent as any).getPeerProtocols = async () => [PROTOCOL_SYNC];
(agent as any).discoverContextGraphsFromStore = async () => 0;
(agent as any).planSharedMemorySyncContextGraphs = async (
_peerId: string,
contextGraphIds: string[],
) => ({
publicContextGraphIds: [...contextGraphIds],
privateRecoverFromCurator: [],
eligibleContextGraphIds: [...contextGraphIds],
});
(agent as any).refreshMetaSyncedFlags = async () => new Set<string>();
(agent as any).hasConfirmedMetaState = async () => true;
(agent as any).gossip = {
subscribe: () => undefined,
unsubscribe: () => undefined,
onMessage: () => undefined,
offMessage: () => undefined,
};
(agent.node as any).node = {
peerId: { toString: () => '12D3KooWLocalEvidencePeer' },
};
await (agent as any).rehydrateContextGraphSubscriptions();
(agent as any).setContextGraphSubscription(contextGraphId, {
subscribed: true,
synced: false,
sharedMemorySynced: false,
metaSynced: false,
syncMode: 'always-on',
syncAdmission: 'explicit',
});
await waitFor(() => (
persisted.has(contextGraphId)
&& agent.getContextGraphSubscriptionRehydrationStatus()
?.rehydratedAlwaysOnIds?.includes(contextGraphId) === true
));
(agent.node as any).node = {
getPeers: () => [{ toString: () => PEER }],
getConnections: () => [],
};
(agent as any).getSyncReconcilerProbe = async () => ({
protocolsKey: PROTOCOL_SYNC,
connectionKey: PEER,
});
return agent;
}

describe('Edge periodic sync scope evidence', () => {
it('admits a freshly persisted configured Edge selection on its first periodic tick', async () => {
const configured = 'cg-fresh-configured-periodic';
const agent = await createFreshConfiguredEdgeEvidenceAgent(configured);
const durableScopes: string[][] = [];
const sharedMemoryScopes: string[][] = [];
(agent as any).syncFromPeerDetailed = async (
_peerId: string,
contextGraphIds: string[],
) => {
durableScopes.push([...contextGraphIds]);
return cleanDurableSyncResult();
};
(agent as any).syncSharedMemoryFromPeerDetailed = async (
_peerId: string,
contextGraphIds: string[],
) => {
sharedMemoryScopes.push([...contextGraphIds]);
const summary = cleanSharedMemorySyncResult();
return {
...summary,
contextGraphTerminals: contextGraphIds.map((id) => ({
contextGraphId: id,
lane: 'shared_memory' as const,
disposition: 'settled' as const,
result: { ...summary },
})),
};
};

await (agent as any).reconcileSyncFromConnectedPeers();
await waitFor(() => (agent as any).lastSuccessfulSyncAt.has(PEER));

expect(durableScopes).toEqual([[configured]]);
expect(sharedMemoryScopes).toEqual([[configured]]);
expect(durableScopes.flat()).not.toContain(SYSTEM_CONTEXT_GRAPHS.AGENTS);
expect(durableScopes.flat()).not.toContain(SYSTEM_CONTEXT_GRAPHS.ONTOLOGY);
});

it('keeps the normal Edge periodic scope when broad sync-on-connect is enabled', async () => {
const rehydrated = 'cg-rehydrated-normal-periodic';
const runtimeSelected = 'cg-runtime-normal-periodic';
Expand Down
Loading