mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-04 07:11:41 +00:00
* fix(telegram): coalesce durable album ingress Split durable claim lifetime from lane occupancy so Telegram can admit later album members while every deferred claim remains heartbeated, recoverable, and independently settled. With deferredLaneOccupancy=release, an unrelated later same-lane Telegram message can reach reply admission before an album that is still inside its 500 ms flush window. That restores the pre-f7786a16 contract, not a new defect; grammY sequentialize still preserves handler-entry order and the reply lane serializes once a turn is admitted. * fix(telegram): preserve deferred abort semantics Release deferred claims when their owner aborts before settlement, while preserving adoption when settlement won the race. Supersede every accepted pre-adoption state on released lanes without admitting past a surviving lane owner. * fix(telegram): separate participant rejection from settlement failure The detached deferred continuation chained its rejection handler with .catch after .then, so it observed not only a participant.task rejection but also any error thrown by onFailed()/onAdopted() and re-drove that infrastructure error through onFailed(). That applied the wrong disposition when the claim was still pre-adoption, and silently discarded the error once it was not: onAdopted() sets phase to adopted before its tombstone write, so a wedged write reached a re-entrant onFailed() that returned early on the phase guard and never reached the logging handler. Use the two-argument then form so task rejection and lifecycle settlement failure stay on separate paths. * fix(channels): satisfy ingress CI guards
197 lines
6.4 KiB
TypeScript
197 lines
6.4 KiB
TypeScript
// Telegram plugin module tracks per-update processing outcomes.
|
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
|
export type TelegramMessageProcessingResult =
|
|
| { kind: "completed" }
|
|
| { kind: "skipped" }
|
|
| { kind: "failed-retryable"; error: unknown };
|
|
|
|
type TelegramUpdateProcessingFrame = {
|
|
result?: TelegramMessageProcessingResult;
|
|
};
|
|
|
|
type TelegramSpooledReplayLifecycle = {
|
|
abortSignal: AbortSignal;
|
|
onAdopted: () => void | Promise<void>;
|
|
onDeferred: () => void;
|
|
/** Clears pre-adoption stall while durable adoption finalization is held. */
|
|
onAdoptionFinalizing?: () => void;
|
|
onAbandoned: () => void | Promise<void>;
|
|
};
|
|
|
|
type TelegramSpooledReplayFrame = {
|
|
deferredWork?: TelegramSpooledReplayDeferredParticipant;
|
|
lifecycle?: TelegramSpooledReplayLifecycle;
|
|
};
|
|
|
|
export type TelegramSpooledReplayDeferredParticipant = {
|
|
key: string;
|
|
abortSignal: AbortSignal;
|
|
task: Promise<TelegramMessageProcessingResult>;
|
|
isSettled: () => boolean;
|
|
wasOwnerAbortedWhilePending: () => boolean;
|
|
/** Defers external timeout settlement while durable adoption decides ownership. */
|
|
beginSettlementHold: () => TelegramSpooledReplaySettlementHold | undefined;
|
|
settle: (result: TelegramMessageProcessingResult) => void;
|
|
};
|
|
|
|
export type TelegramSpooledReplaySettlementHold = {
|
|
release: (mode: "discard-pending" | "replay-pending") => void;
|
|
};
|
|
|
|
const telegramUpdateProcessingFrames = new AsyncLocalStorage<TelegramUpdateProcessingFrame>();
|
|
const telegramSpooledReplayFrames = new AsyncLocalStorage<TelegramSpooledReplayFrame>();
|
|
const telegramSpooledReplayUpdates = new WeakSet<object>();
|
|
|
|
export class TelegramSpooledReplayProcessingError extends Error {
|
|
override readonly cause: unknown;
|
|
|
|
constructor(cause: unknown) {
|
|
super(`telegram spooled update processing failed: ${String(cause)}`);
|
|
this.name = "TelegramSpooledReplayProcessingError";
|
|
this.cause = cause;
|
|
}
|
|
}
|
|
|
|
export async function runWithTelegramUpdateProcessingFrame<T>(
|
|
fn: () => Promise<T>,
|
|
): Promise<{ value: T; result?: TelegramMessageProcessingResult }> {
|
|
const frame: TelegramUpdateProcessingFrame = {};
|
|
const value = await telegramUpdateProcessingFrames.run(frame, fn);
|
|
return frame.result ? { value, result: frame.result } : { value };
|
|
}
|
|
|
|
export function recordTelegramMessageProcessingResult(
|
|
result: TelegramMessageProcessingResult,
|
|
): void {
|
|
const frame = telegramUpdateProcessingFrames.getStore();
|
|
if (!frame) {
|
|
return;
|
|
}
|
|
if (result.kind === "failed-retryable") {
|
|
frame.result = result;
|
|
return;
|
|
}
|
|
if (!frame.result || frame.result.kind === "skipped") {
|
|
frame.result = result;
|
|
}
|
|
}
|
|
|
|
export function createTelegramSpooledReplayParticipant(
|
|
key: string,
|
|
): TelegramSpooledReplayDeferredParticipant {
|
|
const abortController = new AbortController();
|
|
const ownerAbortSignal = telegramSpooledReplayFrames.getStore()?.lifecycle?.abortSignal;
|
|
let settled = false;
|
|
let ownerAbortedWhilePending = ownerAbortSignal?.aborted === true;
|
|
let settlementHeld = false;
|
|
let pendingSettlement: TelegramMessageProcessingResult | undefined;
|
|
let resolveTask: (result: TelegramMessageProcessingResult) => void = () => {};
|
|
const task = new Promise<TelegramMessageProcessingResult>((resolve) => {
|
|
resolveTask = resolve;
|
|
});
|
|
const onOwnerAbort = () => {
|
|
if (!settled) {
|
|
ownerAbortedWhilePending = true;
|
|
}
|
|
};
|
|
ownerAbortSignal?.addEventListener("abort", onOwnerAbort, { once: true });
|
|
const settleNow = (result: TelegramMessageProcessingResult) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
ownerAbortSignal?.removeEventListener("abort", onOwnerAbort);
|
|
if (result.kind !== "completed") {
|
|
abortController.abort(result.kind === "failed-retryable" ? result.error : result.kind);
|
|
}
|
|
resolveTask(result);
|
|
};
|
|
return {
|
|
key,
|
|
abortSignal: abortController.signal,
|
|
task,
|
|
isSettled: () => settled,
|
|
wasOwnerAbortedWhilePending: () => ownerAbortedWhilePending,
|
|
beginSettlementHold: () => {
|
|
if (settled || settlementHeld) {
|
|
return undefined;
|
|
}
|
|
settlementHeld = true;
|
|
// Timeout settlement must wait for durable adoption finalization: pause
|
|
// the drain stall watchdog while the hold is active.
|
|
telegramSpooledReplayFrames.getStore()?.lifecycle?.onAdoptionFinalizing?.();
|
|
let released = false;
|
|
return {
|
|
release: (mode) => {
|
|
if (released) {
|
|
return;
|
|
}
|
|
released = true;
|
|
settlementHeld = false;
|
|
const pending = pendingSettlement;
|
|
pendingSettlement = undefined;
|
|
if (mode === "replay-pending" && pending) {
|
|
settleNow(pending);
|
|
}
|
|
},
|
|
};
|
|
},
|
|
settle: (result) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
if (settlementHeld) {
|
|
pendingSettlement ??= result;
|
|
return;
|
|
}
|
|
settleNow(result);
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createTelegramSpooledReplayDeferredParticipant(
|
|
key: string,
|
|
): TelegramSpooledReplayDeferredParticipant | null {
|
|
const frame = telegramSpooledReplayFrames.getStore();
|
|
if (!frame) {
|
|
return null;
|
|
}
|
|
const participant = createTelegramSpooledReplayParticipant(key);
|
|
frame.deferredWork = participant;
|
|
return participant;
|
|
}
|
|
|
|
export function getTelegramSpooledReplayDeferredParticipant():
|
|
| TelegramSpooledReplayDeferredParticipant
|
|
| undefined {
|
|
return telegramSpooledReplayFrames.getStore()?.deferredWork;
|
|
}
|
|
|
|
export async function runWithTelegramSpooledReplayUpdate<T>(
|
|
update: object,
|
|
fn: () => Promise<T>,
|
|
lifecycle?: TelegramSpooledReplayLifecycle,
|
|
): Promise<{ value: T; deferredWork?: TelegramSpooledReplayDeferredParticipant }> {
|
|
const frame: TelegramSpooledReplayFrame = lifecycle ? { lifecycle } : {};
|
|
telegramSpooledReplayUpdates.add(update);
|
|
try {
|
|
const value = await telegramSpooledReplayFrames.run(frame, fn);
|
|
return frame.deferredWork ? { value, deferredWork: frame.deferredWork } : { value };
|
|
} finally {
|
|
telegramSpooledReplayUpdates.delete(update);
|
|
}
|
|
}
|
|
|
|
/** Drain lifecycle for the active spooled-replay ALS frame, if any. */
|
|
export function getTelegramSpooledReplayLifecycle(): TelegramSpooledReplayLifecycle | undefined {
|
|
return telegramSpooledReplayFrames.getStore()?.lifecycle;
|
|
}
|
|
|
|
export function isTelegramSpooledReplayUpdate(update: unknown): boolean {
|
|
return (
|
|
telegramSpooledReplayFrames.getStore() !== undefined ||
|
|
(typeof update === "object" && update !== null && telegramSpooledReplayUpdates.has(update))
|
|
);
|
|
}
|