mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-21 07:36:25 +00:00
* feat: serialize bound actor event mailboxes * test: harden actor mailbox batch ordering * fix: treat batch roots as mailbox authority * test: pin mailbox replay semantics * fix: preserve mailbox authority through settlement
761 lines
23 KiB
TypeScript
761 lines
23 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { logger, runAsSystem } from '@librechat/data-schemas';
|
|
import type { AgentTriggerExecutionResult } from './host';
|
|
import { createAgentTriggerBatchEnvelope } from './batch';
|
|
import { AgentTriggerDispatchError } from './dispatch';
|
|
import { AgentTriggerExecutionError } from './host';
|
|
|
|
const DEFAULT_CONCURRENCY = 4;
|
|
const DEFAULT_LEASE_MS = 2 * 60_000;
|
|
const DEFAULT_MAX_ATTEMPTS = 8;
|
|
const DEFAULT_RETRY_BASE_MS = 1_000;
|
|
const DEFAULT_RETRY_CAP_MS = 5 * 60_000;
|
|
const DEFAULT_TICK_MS = 1_000;
|
|
const DEFAULT_MAX_IDLE_TICK_MS = 15_000;
|
|
const ORDERING_RECHECK_MS = 250;
|
|
const ACTIVE_HANDLING_RECHECK_MS = 5_000;
|
|
const DEFAULT_DEFER_MS = 5_000;
|
|
const MAX_RETRY_AFTER_MS = 24 * 60 * 60_000;
|
|
|
|
function startedHandling(
|
|
delivery: Pick<AgentTriggerDeliveryRecord, 'envelope'>,
|
|
result: AgentTriggerExecutionResult,
|
|
startedAt: Date,
|
|
): AgentTriggerDeliveryRecord['handling'] | undefined {
|
|
const envelope = delivery.envelope;
|
|
if (
|
|
envelope == null ||
|
|
typeof envelope !== 'object' ||
|
|
!('mode' in envelope) ||
|
|
envelope.mode !== 'continue' ||
|
|
!('target' in envelope) ||
|
|
envelope.target == null ||
|
|
typeof envelope.target !== 'object' ||
|
|
!('bindingId' in envelope.target) ||
|
|
result.mode !== 'continue' ||
|
|
result.status === 'settled' ||
|
|
result.streamId == null ||
|
|
result.generationCreatedAt == null
|
|
) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
status: 'started',
|
|
conversationId: result.conversationId,
|
|
streamId: result.streamId,
|
|
generationCreatedAt: result.generationCreatedAt,
|
|
startedAt,
|
|
};
|
|
}
|
|
|
|
/** A pre-dispatch condition that must not consume the delivery's retry budget. */
|
|
export class AgentTriggerDeliveryDeferredError extends Error {
|
|
readonly delayMs: number;
|
|
|
|
constructor(message: string, delayMs: number = DEFAULT_DEFER_MS) {
|
|
super(message);
|
|
this.name = 'AgentTriggerDeliveryDeferredError';
|
|
this.delayMs = positiveInteger(delayMs, DEFAULT_DEFER_MS, 'delayMs');
|
|
}
|
|
}
|
|
|
|
export type AgentTriggerDeliveryStatus =
|
|
| 'staging'
|
|
| 'batched'
|
|
| 'pending'
|
|
| 'leased'
|
|
| 'succeeded'
|
|
| 'dead';
|
|
|
|
export interface AgentTriggerDeliveryFailure {
|
|
code: string;
|
|
message: string;
|
|
certainty: 'definite' | 'ambiguous';
|
|
retryable: boolean;
|
|
attemptedAt: Date;
|
|
status?: number;
|
|
}
|
|
|
|
export interface AgentTriggerDeliveryRecord {
|
|
id: string;
|
|
user: string;
|
|
claimToken: string;
|
|
deliveryKey: string;
|
|
fingerprint: string;
|
|
orderingKey: string;
|
|
laneSequence: number;
|
|
envelope: unknown;
|
|
status: AgentTriggerDeliveryStatus;
|
|
attempts: number;
|
|
availableAt: Date;
|
|
createdAt: Date;
|
|
envelopeBytes?: number;
|
|
coalesceKey?: string;
|
|
coalesceFrom?: Date;
|
|
coalesceUntil?: Date;
|
|
batchSize?: number;
|
|
batchBytes?: number;
|
|
batchMemberIds?: Array<{ toString(): string } | string>;
|
|
batchRootId?: { toString(): string } | string;
|
|
batchMembersSettledAt?: Date;
|
|
awaitTerminalHandling?: boolean;
|
|
leaseBy?: string;
|
|
leaseUntil?: Date;
|
|
lastError?: AgentTriggerDeliveryFailure;
|
|
handling?: {
|
|
status: 'started' | 'applied' | 'completed_no_action' | 'failed' | 'cancelled';
|
|
conversationId: string;
|
|
streamId: string;
|
|
generationCreatedAt: number;
|
|
startedAt: Date;
|
|
settledAt?: Date;
|
|
error?: string;
|
|
action?: { toolName: string; toolCallId?: string };
|
|
};
|
|
}
|
|
|
|
export interface AgentTriggerOrderingBlock {
|
|
availableAt: Date;
|
|
leaseUntil?: Date;
|
|
reason?: 'active_handling';
|
|
}
|
|
|
|
export interface AgentTriggerDeliveryStore {
|
|
claimNext: (input: {
|
|
workerId: string;
|
|
claimToken: string;
|
|
now: Date;
|
|
leaseUntil: Date;
|
|
}) => Promise<AgentTriggerDeliveryRecord | null>;
|
|
findEarlierUnsettled: (
|
|
delivery: AgentTriggerDeliveryRecord,
|
|
) => Promise<AgentTriggerOrderingBlock | null>;
|
|
getBatch: (
|
|
delivery: Pick<AgentTriggerDeliveryRecord, 'id' | 'batchMemberIds'>,
|
|
) => Promise<Array<Pick<AgentTriggerDeliveryRecord, 'id' | 'deliveryKey' | 'envelope'>>>;
|
|
release: (input: {
|
|
id: string;
|
|
workerId: string;
|
|
claimToken: string;
|
|
availableAt: Date;
|
|
}) => Promise<boolean>;
|
|
beginAttempt: (input: {
|
|
id: string;
|
|
workerId: string;
|
|
claimToken: string;
|
|
now: Date;
|
|
}) => Promise<number | null>;
|
|
defer: (input: {
|
|
id: string;
|
|
workerId: string;
|
|
claimToken: string;
|
|
attempt: number;
|
|
availableAt: Date;
|
|
}) => Promise<boolean>;
|
|
complete: (input: {
|
|
id: string;
|
|
workerId: string;
|
|
claimToken: string;
|
|
attempt: number;
|
|
result: AgentTriggerExecutionResult;
|
|
settledAt: Date;
|
|
handling?: AgentTriggerDeliveryRecord['handling'];
|
|
awaitTerminalHandling?: true;
|
|
}) => Promise<boolean>;
|
|
retry: (input: {
|
|
id: string;
|
|
workerId: string;
|
|
claimToken: string;
|
|
attempt: number;
|
|
error: AgentTriggerDeliveryFailure;
|
|
availableAt: Date;
|
|
}) => Promise<boolean>;
|
|
dead: (input: {
|
|
id: string;
|
|
workerId: string;
|
|
claimToken: string;
|
|
attempt: number;
|
|
error: AgentTriggerDeliveryFailure;
|
|
settledAt: Date;
|
|
}) => Promise<boolean>;
|
|
}
|
|
|
|
export interface AgentTriggerDeliveryEngineOptions {
|
|
concurrency?: number;
|
|
leaseMs?: number;
|
|
maxAttempts?: number;
|
|
retryBaseMs?: number;
|
|
retryCapMs?: number;
|
|
tickMs?: number;
|
|
/** Ceiling for the poll interval while the queue stays empty; any wake or claimed
|
|
* delivery snaps polling back to `tickMs`, so only true idleness ever waits this long. */
|
|
maxIdleTickMs?: number;
|
|
}
|
|
|
|
export interface AgentTriggerDeliveryEngineDeps {
|
|
store: AgentTriggerDeliveryStore;
|
|
dispatch: (
|
|
envelope: unknown,
|
|
options?: { signal?: AbortSignal },
|
|
) => Promise<AgentTriggerExecutionResult>;
|
|
now?: () => Date;
|
|
random?: () => number;
|
|
workerId?: string;
|
|
}
|
|
|
|
interface ClaimPassResult {
|
|
count: number;
|
|
processing: Promise<void>[];
|
|
claimFailed?: boolean;
|
|
}
|
|
|
|
export interface AgentTriggerDeliveryEngine {
|
|
start: () => void;
|
|
/** Registers a future eligibility time so the idle poll never sleeps past it. */
|
|
noteEligibleAt: (at: Date) => void;
|
|
stop: () => Promise<void>;
|
|
cancelUser: (userId: string) => Promise<void>;
|
|
releaseUserCancellation: (userId: string) => void;
|
|
wake: () => void;
|
|
runTick: () => Promise<number>;
|
|
}
|
|
|
|
function positiveInteger(value: number | undefined, fallback: number, name: string): number {
|
|
const resolved = value ?? fallback;
|
|
if (!Number.isSafeInteger(resolved) || resolved <= 0) {
|
|
throw new TypeError(`${name} must be a positive integer`);
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
function failure(error: unknown, attemptedAt: Date): AgentTriggerDeliveryFailure {
|
|
if (error instanceof AgentTriggerExecutionError) {
|
|
return {
|
|
code: error.code ?? 'DELIVERY_REJECTED',
|
|
message: error.message,
|
|
certainty: error.certainty,
|
|
retryable: error.retryable,
|
|
attemptedAt,
|
|
...(error.status != null && { status: error.status }),
|
|
};
|
|
}
|
|
if (error instanceof AgentTriggerDispatchError) {
|
|
return {
|
|
code: 'INVALID_ENVELOPE',
|
|
message: error.message,
|
|
certainty: 'definite',
|
|
retryable: false,
|
|
attemptedAt,
|
|
};
|
|
}
|
|
return {
|
|
code: 'DELIVERY_FAILED',
|
|
message: error instanceof Error ? error.message : String(error),
|
|
certainty: 'definite',
|
|
retryable: true,
|
|
attemptedAt,
|
|
};
|
|
}
|
|
|
|
function retryAt(
|
|
error: unknown,
|
|
attempt: number,
|
|
now: Date,
|
|
baseMs: number,
|
|
capMs: number,
|
|
random: () => number,
|
|
): Date {
|
|
if (error instanceof AgentTriggerExecutionError && error.retryAfter != null) {
|
|
const seconds = Number(error.retryAfter);
|
|
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
return new Date(now.getTime() + Math.min(seconds * 1_000, MAX_RETRY_AFTER_MS));
|
|
}
|
|
const absolute = Date.parse(error.retryAfter);
|
|
if (Number.isFinite(absolute) && absolute > now.getTime()) {
|
|
return new Date(Math.min(absolute, now.getTime() + MAX_RETRY_AFTER_MS));
|
|
}
|
|
}
|
|
const exponent = Math.min(attempt - 1, 30);
|
|
const delay = Math.min(baseMs * 2 ** exponent, capMs);
|
|
return new Date(now.getTime() + Math.floor(delay / 2 + random() * (delay / 2)));
|
|
}
|
|
|
|
function isAccountDeletionDeferral(error: unknown): boolean {
|
|
return (
|
|
error instanceof AgentTriggerExecutionError &&
|
|
error.code === 'ACCOUNT_DELETION_IN_PROGRESS' &&
|
|
(error.status === 401 || error.status === 409)
|
|
);
|
|
}
|
|
|
|
function isRuntimeReadinessDeferral(error: unknown): boolean {
|
|
return error instanceof AgentTriggerExecutionError && error.deferWithoutAttempt;
|
|
}
|
|
|
|
/** Durable, lease-fenced delivery runner shared by every trusted event source. */
|
|
export function createAgentTriggerDeliveryEngine(
|
|
deps: AgentTriggerDeliveryEngineDeps,
|
|
options: AgentTriggerDeliveryEngineOptions = {},
|
|
): AgentTriggerDeliveryEngine {
|
|
const concurrency = positiveInteger(options.concurrency, DEFAULT_CONCURRENCY, 'concurrency');
|
|
const leaseMs = positiveInteger(options.leaseMs, DEFAULT_LEASE_MS, 'leaseMs');
|
|
const maxAttempts = positiveInteger(options.maxAttempts, DEFAULT_MAX_ATTEMPTS, 'maxAttempts');
|
|
const retryBaseMs = positiveInteger(options.retryBaseMs, DEFAULT_RETRY_BASE_MS, 'retryBaseMs');
|
|
const retryCapMs = positiveInteger(options.retryCapMs, DEFAULT_RETRY_CAP_MS, 'retryCapMs');
|
|
const tickMs = positiveInteger(options.tickMs, DEFAULT_TICK_MS, 'tickMs');
|
|
const maxIdleTickMs = Math.max(
|
|
tickMs,
|
|
positiveInteger(options.maxIdleTickMs, DEFAULT_MAX_IDLE_TICK_MS, 'maxIdleTickMs'),
|
|
);
|
|
const now = deps.now ?? (() => new Date());
|
|
const random = deps.random ?? Math.random;
|
|
const workerId = deps.workerId ?? `${process.pid}-${randomUUID()}`;
|
|
const controllers = new Map<AbortController, string>();
|
|
let idleStreak = 0;
|
|
/** Future eligibility times this process has seen (sorted, deduplicated, bounded); the
|
|
* idle timer never sleeps past the earliest, so retries and defers are claimed when
|
|
* due, not when the backoff happens to wake. On overflow the latest deadline is
|
|
* dropped and that delivery degrades to idle-poll pickup, bounded by `maxIdleTickMs`
|
|
* — the same bound that covers deliveries delayed by other replicas. */
|
|
const eligibleDeadlinesMs: number[] = [];
|
|
const MAX_TRACKED_DEADLINES = 64;
|
|
const processing = new Set<Promise<void>>();
|
|
const processingByUser = new Map<string, Set<Promise<void>>>();
|
|
const cancelledUsers = new Set<string>();
|
|
let stopped = false;
|
|
let started = false;
|
|
let repumpRequested = false;
|
|
let timer: NodeJS.Timeout | undefined;
|
|
let activeClaim: Promise<ClaimPassResult> | undefined;
|
|
|
|
const processDelivery = async (delivery: AgentTriggerDeliveryRecord): Promise<void> => {
|
|
const userId = String(delivery.user);
|
|
if (cancelledUsers.has(userId)) {
|
|
await deps.store.release({
|
|
id: delivery.id,
|
|
workerId,
|
|
claimToken: delivery.claimToken,
|
|
availableAt: now(),
|
|
});
|
|
return;
|
|
}
|
|
|
|
const block = await deps.store.findEarlierUnsettled(delivery);
|
|
if (block != null) {
|
|
const recheckAt =
|
|
now().getTime() +
|
|
(block.reason === 'active_handling' ? ACTIVE_HANDLING_RECHECK_MS : ORDERING_RECHECK_MS);
|
|
const nextCheck =
|
|
block.leaseUntil == null ? Math.max(recheckAt, block.availableAt.getTime()) : recheckAt;
|
|
noteEligibleAt(new Date(nextCheck));
|
|
await deps.store.release({
|
|
id: delivery.id,
|
|
workerId,
|
|
claimToken: delivery.claimToken,
|
|
availableAt: new Date(nextCheck),
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (delivery.attempts >= maxAttempts) {
|
|
const recorded =
|
|
delivery.lastError ??
|
|
failure(new Error('Delivery attempt limit was already exhausted'), now());
|
|
const deadLettered = await deps.store.dead({
|
|
id: delivery.id,
|
|
workerId,
|
|
claimToken: delivery.claimToken,
|
|
attempt: delivery.attempts,
|
|
error: recorded,
|
|
settledAt: now(),
|
|
});
|
|
if (deadLettered) {
|
|
logger.error('[agent-triggers] delivery dead-lettered after exhausting retries', {
|
|
deliveryKey: delivery.deliveryKey,
|
|
attempts: delivery.attempts,
|
|
code: recorded.code,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (stopped) {
|
|
await deps.store.release({
|
|
id: delivery.id,
|
|
workerId,
|
|
claimToken: delivery.claimToken,
|
|
availableAt: now(),
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (cancelledUsers.has(userId)) {
|
|
await deps.store.release({
|
|
id: delivery.id,
|
|
workerId,
|
|
claimToken: delivery.claimToken,
|
|
availableAt: now(),
|
|
});
|
|
return;
|
|
}
|
|
|
|
const attempt = await deps.store.beginAttempt({
|
|
id: delivery.id,
|
|
workerId,
|
|
claimToken: delivery.claimToken,
|
|
now: now(),
|
|
});
|
|
if (attempt == null) {
|
|
return;
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
controllers.set(controller, userId);
|
|
if (stopped || cancelledUsers.has(userId)) {
|
|
controller.abort(new Error('Agent trigger delivery engine is stopping'));
|
|
}
|
|
try {
|
|
let result: AgentTriggerExecutionResult;
|
|
try {
|
|
const members = await deps.store.getBatch(delivery);
|
|
const dispatchEnvelope =
|
|
members.length === 0
|
|
? delivery.envelope
|
|
: createAgentTriggerBatchEnvelope(delivery, members);
|
|
result = await deps.dispatch(dispatchEnvelope, { signal: controller.signal });
|
|
} catch (error) {
|
|
const attemptedAt = now();
|
|
const deletionCancelled = controller.signal.aborted && cancelledUsers.has(userId);
|
|
const deletionRejected = isAccountDeletionDeferral(error);
|
|
const runtimeNotReady = isRuntimeReadinessDeferral(error);
|
|
if (
|
|
error instanceof AgentTriggerDeliveryDeferredError ||
|
|
deletionCancelled ||
|
|
deletionRejected ||
|
|
runtimeNotReady
|
|
) {
|
|
const delayMs =
|
|
error instanceof AgentTriggerDeliveryDeferredError ? error.delayMs : DEFAULT_DEFER_MS;
|
|
const availableAt = new Date(attemptedAt.getTime() + delayMs);
|
|
noteEligibleAt(availableAt);
|
|
const deferred = await deps.store.defer({
|
|
id: delivery.id,
|
|
workerId,
|
|
claimToken: delivery.claimToken,
|
|
attempt,
|
|
availableAt,
|
|
});
|
|
if (deferred) {
|
|
let reason = 'pre_dispatch';
|
|
if (deletionCancelled || deletionRejected) {
|
|
reason = 'account_deletion';
|
|
} else if (runtimeNotReady) {
|
|
reason = 'runtime_readiness';
|
|
}
|
|
logger.info('[agent-triggers] delivery deferred without consuming an attempt', {
|
|
deliveryKey: delivery.deliveryKey,
|
|
reason,
|
|
availableAt: availableAt.toISOString(),
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
const recorded = failure(error, attemptedAt);
|
|
if (!recorded.retryable || attempt >= maxAttempts) {
|
|
const deadLettered = await deps.store.dead({
|
|
id: delivery.id,
|
|
workerId,
|
|
claimToken: delivery.claimToken,
|
|
attempt,
|
|
error: recorded,
|
|
settledAt: attemptedAt,
|
|
});
|
|
if (deadLettered) {
|
|
logger.error('[agent-triggers] delivery dead-lettered', {
|
|
deliveryKey: delivery.deliveryKey,
|
|
attempt,
|
|
code: recorded.code,
|
|
certainty: recorded.certainty,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
const availableAt = retryAt(error, attempt, attemptedAt, retryBaseMs, retryCapMs, random);
|
|
noteEligibleAt(availableAt);
|
|
const retrying = await deps.store.retry({
|
|
id: delivery.id,
|
|
workerId,
|
|
claimToken: delivery.claimToken,
|
|
attempt,
|
|
error: recorded,
|
|
availableAt,
|
|
});
|
|
if (retrying) {
|
|
logger.warn('[agent-triggers] delivery scheduled for retry', {
|
|
deliveryKey: delivery.deliveryKey,
|
|
attempt,
|
|
code: recorded.code,
|
|
certainty: recorded.certainty,
|
|
availableAt: availableAt.toISOString(),
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
const settledAt = now();
|
|
try {
|
|
const handling = startedHandling(delivery, result, settledAt);
|
|
await deps.store.complete({
|
|
id: delivery.id,
|
|
workerId,
|
|
claimToken: delivery.claimToken,
|
|
attempt,
|
|
result,
|
|
settledAt,
|
|
...(delivery.awaitTerminalHandling === true && { awaitTerminalHandling: true }),
|
|
...(handling != null && { handling }),
|
|
});
|
|
} catch (error) {
|
|
const recorded: AgentTriggerDeliveryFailure = {
|
|
code: 'RESULT_PERSISTENCE_FAILED',
|
|
message: error instanceof Error ? error.message : String(error),
|
|
certainty: 'ambiguous',
|
|
retryable: true,
|
|
attemptedAt: settledAt,
|
|
};
|
|
const availableAt = retryAt(error, attempt, settledAt, retryBaseMs, retryCapMs, random);
|
|
noteEligibleAt(availableAt);
|
|
const retrying = await deps.store.retry({
|
|
id: delivery.id,
|
|
workerId,
|
|
claimToken: delivery.claimToken,
|
|
attempt,
|
|
error: recorded,
|
|
availableAt,
|
|
});
|
|
if (retrying) {
|
|
logger.warn(
|
|
'[agent-triggers] accepted delivery result could not be persisted; retrying',
|
|
{
|
|
deliveryKey: delivery.deliveryKey,
|
|
attempt,
|
|
availableAt: availableAt.toISOString(),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
} finally {
|
|
controllers.delete(controller);
|
|
}
|
|
};
|
|
|
|
const claimOne = (): Promise<AgentTriggerDeliveryRecord | null> => {
|
|
const claimedAt = now();
|
|
return deps.store.claimNext({
|
|
workerId,
|
|
claimToken: randomUUID(),
|
|
now: claimedAt,
|
|
leaseUntil: new Date(claimedAt.getTime() + leaseMs),
|
|
});
|
|
};
|
|
|
|
const runClaimPass = async (): Promise<ClaimPassResult> => {
|
|
if (stopped) {
|
|
return { count: 0, processing: [] };
|
|
}
|
|
const openSlots = concurrency - processing.size;
|
|
if (openSlots <= 0) {
|
|
return { count: 0, processing: [] };
|
|
}
|
|
|
|
const deliveries: AgentTriggerDeliveryRecord[] = [];
|
|
try {
|
|
const first = await claimOne();
|
|
if (first == null) {
|
|
return { count: 0, processing: [] };
|
|
}
|
|
deliveries.push(first);
|
|
} catch (error) {
|
|
logger.error('[agent-triggers] delivery claim failed:', error);
|
|
return { count: 0, processing: [], claimFailed: true };
|
|
}
|
|
|
|
if (openSlots > 1) {
|
|
const claimed = await Promise.allSettled(
|
|
Array.from({ length: openSlots - 1 }, () => claimOne()),
|
|
);
|
|
for (const result of claimed) {
|
|
if (result.status === 'fulfilled' && result.value != null) {
|
|
deliveries.push(result.value);
|
|
} else if (result.status === 'rejected') {
|
|
logger.error('[agent-triggers] delivery claim failed:', result.reason);
|
|
}
|
|
}
|
|
}
|
|
|
|
const batch: Promise<void>[] = [];
|
|
for (const delivery of deliveries) {
|
|
const userId = String(delivery.user);
|
|
const task = runAsSystem(() => processDelivery(delivery)).catch((error) => {
|
|
logger.error('[agent-triggers] delivery processing failed:', error);
|
|
});
|
|
const tracked = task.finally(() => {
|
|
processing.delete(tracked);
|
|
const userProcessing = processingByUser.get(userId);
|
|
userProcessing?.delete(tracked);
|
|
if (userProcessing?.size === 0) {
|
|
processingByUser.delete(userId);
|
|
}
|
|
if (started && !stopped) {
|
|
queueMicrotask(wake);
|
|
}
|
|
});
|
|
processing.add(tracked);
|
|
const userProcessing = processingByUser.get(userId) ?? new Set<Promise<void>>();
|
|
userProcessing.add(tracked);
|
|
processingByUser.set(userId, userProcessing);
|
|
batch.push(tracked);
|
|
}
|
|
return { count: deliveries.length, processing: batch };
|
|
};
|
|
|
|
const claimAvailable = (): Promise<ClaimPassResult> => {
|
|
if (activeClaim != null) {
|
|
return activeClaim;
|
|
}
|
|
activeClaim = runAsSystem(runClaimPass)
|
|
.then((result) => {
|
|
/** Only a pass that confirmed an empty queue may advance the idle backoff: work
|
|
* resets it, and a failed claim proves nothing, so it polls on at the base
|
|
* cadence — the pre-backoff status quo through an outage and at recovery. */
|
|
idleStreak = result.count === 0 && result.claimFailed !== true ? idleStreak + 1 : 0;
|
|
return result;
|
|
})
|
|
.finally(() => {
|
|
activeClaim = undefined;
|
|
if (repumpRequested && !stopped) {
|
|
repumpRequested = false;
|
|
queueMicrotask(wake);
|
|
}
|
|
});
|
|
return activeClaim;
|
|
};
|
|
|
|
const runTick = async (): Promise<number> => {
|
|
const batch = await claimAvailable();
|
|
await Promise.allSettled(batch.processing);
|
|
return batch.count;
|
|
};
|
|
|
|
const claimOnce = (): void => {
|
|
if (activeClaim != null) {
|
|
repumpRequested = true;
|
|
return;
|
|
}
|
|
void claimAvailable().catch((error) =>
|
|
logger.error('[agent-triggers] delivery claim pass failed:', error),
|
|
);
|
|
};
|
|
|
|
function noteEligibleAt(at: Date): void {
|
|
const eligibleAtMs = at.getTime();
|
|
if (!Number.isFinite(eligibleAtMs) || stopped) {
|
|
return;
|
|
}
|
|
const insertAt = eligibleDeadlinesMs.findIndex((deadline) => deadline >= eligibleAtMs);
|
|
if (insertAt !== -1 && eligibleDeadlinesMs[insertAt] === eligibleAtMs) {
|
|
return;
|
|
}
|
|
eligibleDeadlinesMs.splice(
|
|
insertAt === -1 ? eligibleDeadlinesMs.length : insertAt,
|
|
0,
|
|
eligibleAtMs,
|
|
);
|
|
if (eligibleDeadlinesMs.length > MAX_TRACKED_DEADLINES) {
|
|
eligibleDeadlinesMs.pop();
|
|
}
|
|
if (started && eligibleDeadlinesMs[0] === eligibleAtMs) {
|
|
schedule();
|
|
}
|
|
}
|
|
|
|
/** A wake is evidence of work — an enqueue or a finished delivery — so it snaps the
|
|
* idle backoff and the poll timer back to the base cadence before claiming. */
|
|
function wake(): void {
|
|
if (stopped) {
|
|
return;
|
|
}
|
|
idleStreak = 0;
|
|
if (started) {
|
|
schedule();
|
|
}
|
|
claimOnce();
|
|
}
|
|
|
|
const schedule = () => {
|
|
if (stopped) {
|
|
return;
|
|
}
|
|
if (timer != null) {
|
|
clearTimeout(timer);
|
|
}
|
|
let delay = Math.min(tickMs * 2 ** idleStreak, maxIdleTickMs);
|
|
if (eligibleDeadlinesMs.length > 0) {
|
|
delay = Math.max(0, Math.min(delay, eligibleDeadlinesMs[0] - now().getTime()));
|
|
}
|
|
timer = setTimeout(async () => {
|
|
if (stopped) {
|
|
return;
|
|
}
|
|
const nowMs = now().getTime();
|
|
while (eligibleDeadlinesMs.length > 0 && eligibleDeadlinesMs[0] <= nowMs) {
|
|
eligibleDeadlinesMs.shift();
|
|
}
|
|
await claimAvailable().catch((error) =>
|
|
logger.error('[agent-triggers] delivery claim pass failed:', error),
|
|
);
|
|
schedule();
|
|
}, delay);
|
|
timer.unref();
|
|
};
|
|
|
|
return {
|
|
start: () => {
|
|
if (started || stopped) {
|
|
return;
|
|
}
|
|
started = true;
|
|
wake();
|
|
},
|
|
stop: async () => {
|
|
stopped = true;
|
|
if (timer != null) {
|
|
clearTimeout(timer);
|
|
}
|
|
for (const controller of controllers.keys()) {
|
|
controller.abort();
|
|
}
|
|
await activeClaim?.catch(() => undefined);
|
|
await Promise.allSettled([...processing]);
|
|
},
|
|
cancelUser: async (userId) => {
|
|
cancelledUsers.add(userId);
|
|
for (const [controller, activeUserId] of controllers) {
|
|
if (activeUserId === userId) {
|
|
controller.abort(new Error('Agent trigger delivery cancelled for account deletion'));
|
|
}
|
|
}
|
|
await activeClaim?.catch(() => undefined);
|
|
const userProcessing = processingByUser.get(userId);
|
|
if (userProcessing != null) {
|
|
await Promise.allSettled([...userProcessing]);
|
|
}
|
|
},
|
|
releaseUserCancellation: (userId) => {
|
|
cancelledUsers.delete(userId);
|
|
},
|
|
wake,
|
|
noteEligibleAt,
|
|
runTick,
|
|
};
|
|
}
|