Files
openclaw/extensions/telegram/src/bot-processing-outcome.ts
Peter Steinberger ee3d084048 fix(telegram): coalesce durable album ingress into one turn (#115401)
* 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
2026-07-28 17:50:20 -04:00

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))
);
}