Skip to content
Closed
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
73 changes: 62 additions & 11 deletions packages/agent/src/dkg-agent-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,10 @@ import {
import { runSyncOnConnect, SyncOnConnectPostSyncError, type SyncOnConnectOutcome, type SyncOnConnectPeerOutcome } from './sync/on-connect/sync-on-connect.js';
import { mapWithConcurrency } from './map-with-concurrency.js';
import { CATCHUP_MAX_CONCURRENT_PEER_SYNCS } from './sync/catchup-concurrency.js';
import {
runCatchupPlanesWithPolicy,
type CatchupMode,
} from './sync/catchup-policy.js';
import {
classifyDurableProgress,
createDurableSyncAccumulator,
Expand Down Expand Up @@ -621,12 +625,14 @@ function contextGraphCatchupSingleFlightKey(params: {
includeSharedMemory: boolean;
maxPeers?: number;
peerRotationKey?: string;
mode: CatchupMode;
}): string {
return syncSingleFlightKey('context-graph-catchup', {
contextGraphId: params.contextGraphId,
includeSharedMemory: params.includeSharedMemory,
maxPeers: normalizedCatchupMaxPeers(params.maxPeers),
peerRotationKey: params.peerRotationKey ?? null,
mode: params.mode,
});
}

Expand All @@ -640,6 +646,7 @@ function durableSyncSingleFlightKey(params: {
hasAccessDeniedCallback: boolean;
hasSinceBatchIdResolver: boolean;
exactAssetUals?: readonly string[];
priority?: number;
}): string | null {
if (params.hasPhaseCallback || params.hasAccessDeniedCallback || params.hasSinceBatchIdResolver) {
return null;
Expand All @@ -651,6 +658,7 @@ function durableSyncSingleFlightKey(params: {
totalTimeoutMs: params.totalTimeoutMs,
syncAgentsMeta: params.syncAgentsMeta,
exactAssetUals: params.exactAssetUals ?? null,
priority: params.priority ?? null,
});
}

Expand All @@ -660,13 +668,15 @@ function sharedMemorySyncSingleFlightKey(params: {
stopOnBackoffWorthyFailure?: boolean;
publicContextGraphIds: readonly string[];
privateRecoverFromCurator: readonly string[];
priority?: number;
}): string {
return syncSingleFlightKey('shared-memory-sync', {
remotePeerId: params.remotePeerId,
contextGraphIds: params.contextGraphIds,
stopOnBackoffWorthyFailure: params.stopOnBackoffWorthyFailure === true,
publicContextGraphIds: params.publicContextGraphIds,
privateRecoverFromCurator: params.privateRecoverFromCurator,
priority: params.priority ?? null,
});
}

Expand Down Expand Up @@ -768,6 +778,17 @@ interface RecoverContextGraphSwmFromPeerDependencies {

type SyncReconcilerAttemptOutcome = SyncOnConnectOutcome | 'not-started' | 'deferred-backpressure';

export interface ContextGraphCatchupOptions {
includeSharedMemory?: boolean;
maxPeers?: number;
peerRotationKey?: string;
/**
* Foreground mode receives scheduler priority and bounded local-deferral
* retries. Background mode remains best-effort and never waits for capacity.
*/
mode?: CatchupMode;
}

export type DurableSyncOptions = {
stopOnBackoffWorthyFailure?: boolean;
/**
Expand Down Expand Up @@ -4002,7 +4023,13 @@ export class LifecycleSyncMethods extends DKGAgentBase {
// the same flag that makes it a responder. Same signal SC4 uses to advertise the protocol.
if (asChangelogReader(this.store) !== null && contextGraphIds.length > 0) {
try {
const lane = await this.runChangelogLane(ctx, remotePeerId, contextGraphIds, onAccessDenied);
const lane = await this.runChangelogLane(
ctx,
remotePeerId,
contextGraphIds,
onAccessDenied,
options?.priority,
);
changelogResult = lane.result;
legacyContextGraphIds = lane.remainingLegacyCgs;
if (changelogResult && (changelogResult.deferredBackpressure ?? 0) > 0) {
Expand Down Expand Up @@ -4113,6 +4140,7 @@ export class LifecycleSyncMethods extends DKGAgentBase {
hasAccessDeniedCallback: Boolean(onAccessDenied),
hasSinceBatchIdResolver: Boolean(sinceBatchIdFor),
exactAssetUals: options?.exactAssetUals,
priority: options?.priority,
});
return singleFlightKey ? runSyncSingleFlight(this, singleFlightKey, runSync) : runSync();
}
Expand Down Expand Up @@ -4284,6 +4312,7 @@ export class LifecycleSyncMethods extends DKGAgentBase {
remotePeerId: string,
contextGraphIds: string[],
onAccessDenied?: (contextGraphId: string) => void,
priority?: number,
): Promise<{ result?: DurableSyncResult; remainingLegacyCgs: string[] }> {
const peerProtocols = await this.getPeerProtocols(remotePeerId);
if (!peerProtocols.includes(PROTOCOL_SYNC_CHANGELOG)) {
Expand Down Expand Up @@ -4324,6 +4353,7 @@ export class LifecycleSyncMethods extends DKGAgentBase {
item.lane,
item.operationId,
run,
priority,
),
merge: mergeDurableSyncAccumulatorInto,
markDeferred: (summary) => {
Expand Down Expand Up @@ -4754,6 +4784,8 @@ export class LifecycleSyncMethods extends DKGAgentBase {
options?: {
stopOnBackoffWorthyFailure?: boolean;
sharedMemorySyncPlan?: SharedMemorySyncContextGraphPlan;
/** Admission override for foreground catch-up. */
priority?: number;
},
): Promise<SharedMemorySyncResult> {
const ctx = createOperationContext('sync');
Expand Down Expand Up @@ -4820,6 +4852,7 @@ export class LifecycleSyncMethods extends DKGAgentBase {
stopOnBackoffWorthyFailure,
publicContextGraphIds,
privateRecoverFromCurator,
priority: options?.priority,
});

const runSync = async (): Promise<SharedMemorySyncResult> => {
Expand Down Expand Up @@ -4973,6 +5006,7 @@ export class LifecycleSyncMethods extends DKGAgentBase {
item.lane,
item.operationId,
run,
options?.priority,
),
merge: mergeSharedMemorySyncResults,
markDeferred: (summary) => ({
Expand Down Expand Up @@ -5077,7 +5111,7 @@ export class LifecycleSyncMethods extends DKGAgentBase {
*/
async syncContextGraphFromConnectedPeers(this: DKGAgent,
contextGraphId: string,
options?: { includeSharedMemory?: boolean; maxPeers?: number; peerRotationKey?: string },
options?: ContextGraphCatchupOptions,
): Promise<{
/** Ordered connected peers before optional maxPeers windowing. */
connectedPeers: number;
Expand Down Expand Up @@ -5122,6 +5156,7 @@ export class LifecycleSyncMethods extends DKGAgentBase {
}> {
const ctx = createOperationContext('sync');
const includeSharedMemory = options?.includeSharedMemory ?? false;
const mode = options?.mode ?? 'background';

this.trackSyncContextGraph(contextGraphId);

Expand All @@ -5130,6 +5165,7 @@ export class LifecycleSyncMethods extends DKGAgentBase {
includeSharedMemory,
maxPeers: options?.maxPeers,
peerRotationKey: options?.peerRotationKey,
mode,
});

return runSyncSingleFlight(this, singleFlightKey, async (): Promise<ContextGraphCatchupResult> => {
Expand Down Expand Up @@ -5179,6 +5215,7 @@ export class LifecycleSyncMethods extends DKGAgentBase {
);
return this.runCatchupOverPeers(contextGraphId, includeSharedMemory, peers, {
totalPeers: orderedPeers.length,
mode,

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: Foreground mode is not verified through the public catch-up API

What's wrong
The changed behavior introduces a public mode option, but the current agent-path regression test bypasses the code that reads and forwards that option. This leaves the user-facing foreground catch-up path under-verified.

Example
If mode were accidentally omitted from the object passed to runCatchupOverPeers, syncContextGraphFromConnectedPeers(..., { includeSharedMemory: true, mode: 'foreground' }) would still run as background catch-up, but the new private-helper test would continue to pass.

Suggested direction
Cover the public API propagation path, not only runCatchupOverPeers directly.

For Agents
Add or adjust a test to call agent.syncContextGraphFromConnectedPeers('coalesced-cg', { includeSharedMemory: true, mode: 'foreground' }) with one connected peer and stubbed durable/SWM sync methods. Prove the first durable deferral is retried with FOREGROUND_CATCHUP_SYNC_PRIORITY before SWM starts, while preserving existing background behavior.

});
});
}
Expand Down Expand Up @@ -5269,7 +5306,7 @@ export class LifecycleSyncMethods extends DKGAgentBase {
contextGraphId: string,
includeSharedMemory: boolean,
peers: Array<{ toString(): string }>,
stats?: { totalPeers?: number },
stats?: { totalPeers?: number; mode?: CatchupMode },
): Promise<{
/** Ordered connected peers before optional caller windowing. */
connectedPeers: number;
Expand Down Expand Up @@ -5405,14 +5442,28 @@ export class LifecycleSyncMethods extends DKGAgentBase {
syncCapable,
CATCHUP_MAX_CONCURRENT_PEER_SYNCS,
async (remotePeerId) => {
const durable = await this.syncFromPeerDetailed(
remotePeerId,
[contextGraphId],
).catch(() => createFailedPeerDurableSyncResult());
const shared = includeSharedMemory
? await this.syncSharedMemoryFromPeerDetailed(remotePeerId, [contextGraphId]).catch(emptyShared)
: null;
return { durable, shared };
const mode = stats?.mode ?? 'background';
return runCatchupPlanesWithPolicy({
mode,
includeSharedMemory,
syncDurable: ({ priority }) => (
priority === undefined
? this.syncFromPeerDetailed(remotePeerId, [contextGraphId])
: this.syncFromPeerDetailed(
remotePeerId,
[contextGraphId],
undefined,
undefined,
undefined,
{ priority },
)
).catch(() => createFailedPeerDurableSyncResult()),
syncSharedMemory: ({ priority }) => (
priority === undefined
? this.syncSharedMemoryFromPeerDetailed(remotePeerId, [contextGraphId])
: this.syncSharedMemoryFromPeerDetailed(remotePeerId, [contextGraphId], { priority })
).catch(emptyShared),
});
},
);
let accessDeniedPeers = 0;
Expand Down
11 changes: 11 additions & 0 deletions packages/agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,17 @@ export {
// deep-importing the compiled `dist/` module.
export { mapWithConcurrency } from './map-with-concurrency.js';
export { CATCHUP_MAX_CONCURRENT_PEER_SYNCS } from './sync/catchup-concurrency.js';
export {

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: The catch-up policy helper leaks internal orchestration onto the public agent surface

What's wrong
This export block turns a low-level orchestration helper, its generic callback context, and its testability hooks into public package API. That makes the retry strategy and priority plumbing harder to change later, because consumers can start depending on details that are really implementation policy between the agent and CLI worker.

Example
External callers can now import runCatchupPlanesWithPolicy and supply their own retryDelaysMs / wait, even though those knobs appear to be deterministic test seams and the only production consumer is the CLI catch-up worker.

Suggested direction
Either keep this helper behind an internal subpath/module used by the CLI worker, or expose a narrower production-facing API that does not include wait, retryDelaysMs, and the generic plane context types. Public exports should reflect stable concepts like CatchupMode, not the current retry-loop implementation details.

Confidence note
The CLI worker does need a way to share this helper with the agent package, so this may need a small internal export strategy rather than simply making the function private.

For Agents
Review packages/agent/src/index.ts and packages/agent/src/sync/catchup-policy.ts. Preserve the shared foreground/background behavior for agent and CLI worker paths, but avoid publishing test seams and low-level plane orchestration as general agent API. Verify the CLI worker still imports the shared implementation cleanly after the boundary change.

CATCHUP_BACKPRESSURE_RETRY_DELAYS_MS,
FOREGROUND_CATCHUP_SYNC_PRIORITY,
catchupPriorityForMode,
runCatchupPlanesWithPolicy,
type CatchupMode,
type CatchupPlaneContext,
type CatchupPlanePolicyOptions,
type CatchupPlanePolicyResult,
type CatchupPlaneResult,
} from './sync/catchup-policy.js';
export {
classifyDurableProgress,
createFailedPeerDurableSyncResult,
Expand Down
78 changes: 78 additions & 0 deletions packages/agent/src/sync/catchup-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
export type CatchupMode = 'background' | 'foreground';

export const FOREGROUND_CATCHUP_SYNC_PRIORITY = 2_000;
export const CATCHUP_BACKPRESSURE_RETRY_DELAYS_MS = [100, 250, 500] as const;

export interface CatchupPlaneResult {
deferredBackpressure?: number;
}

export interface CatchupPlaneContext {
priority?: number;
}

export interface CatchupPlanePolicyOptions<
TDurable extends CatchupPlaneResult,
TShared extends CatchupPlaneResult,
> {
mode: CatchupMode;
includeSharedMemory: boolean;
syncDurable: (context: CatchupPlaneContext) => Promise<TDurable>;
syncSharedMemory: (context: CatchupPlaneContext) => Promise<TShared>;
retryDelaysMs?: readonly number[];
wait?: (delayMs: number) => Promise<void>;
}

export interface CatchupPlanePolicyResult<
TDurable extends CatchupPlaneResult,
TShared extends CatchupPlaneResult,
> {
durable: TDurable;
shared: TShared | null;
}

export function catchupPriorityForMode(mode: CatchupMode): number | undefined {
return mode === 'foreground' ? FOREGROUND_CATCHUP_SYNC_PRIORITY : undefined;
}

async function runCatchupPlane<T extends CatchupPlaneResult>(
mode: CatchupMode,
run: (context: CatchupPlaneContext) => Promise<T>,
options: Pick<CatchupPlanePolicyOptions<T, T>, 'retryDelaysMs' | 'wait'>,
): Promise<T> {
const context = { priority: catchupPriorityForMode(mode) };
let result = await run(context);
if (mode !== 'foreground') return result;

const retryDelaysMs = options.retryDelaysMs ?? CATCHUP_BACKPRESSURE_RETRY_DELAYS_MS;
const wait = options.wait ?? ((delayMs: number) => new Promise<void>((resolve) => {
setTimeout(resolve, delayMs);
}));
for (const delayMs of retryDelaysMs) {
if ((result.deferredBackpressure ?? 0) === 0) return result;
await wait(delayMs);
result = await run(context);
}
return result;
}

/**
* Canonical foreground/background catch-up policy shared by the in-agent and
* worker-backed runners. Durable metadata must settle before SWM starts; when
* only SWM is deferred, retries never refetch the already-completed durable
* plane.
*/
export async function runCatchupPlanesWithPolicy<
TDurable extends CatchupPlaneResult,
TShared extends CatchupPlaneResult,
>(
options: CatchupPlanePolicyOptions<TDurable, TShared>,
): Promise<CatchupPlanePolicyResult<TDurable, TShared>> {
const durable = await runCatchupPlane(options.mode, options.syncDurable, options);
if (!options.includeSharedMemory || (durable.deferredBackpressure ?? 0) > 0) {
return { durable, shared: null };
}

const shared = await runCatchupPlane(options.mode, options.syncSharedMemory, options);
return { durable, shared };
}
Loading
Loading