Skip to content

Commit 77c84de

Browse files
committed
fix(telegram): complete spooled updates at turn adoption, rescope ingress watchdog
1 parent ef10320 commit 77c84de

6 files changed

Lines changed: 354 additions & 57 deletions

File tree

extensions/telegram/AGENTS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,15 @@ Verified against Telegram Bot API 10.1, July 1 2026.
2525
- Never swallow inbound processing errors. A transient store error on a
2626
spooled replay must record a `failed-retryable` processing result; a
2727
swallowed throw acks the update as completed and deletes the message.
28+
- Spool completes at turn adoption, not settle. Once the recovery-relevant
29+
session/run state is durably persisted (`restartRecoveryDeliveryContext` +
30+
run id), the spooled row tombstones via `complete()` and the per-chat lane
31+
frees. Run health after that is owned by run lifecycle / main-session
32+
restart recovery — not by the ingress spool. Pre-adoption timeout
33+
(`ISOLATED_INGRESS_ADOPTION_STALL_MS`, default 5 minutes, overridable via
34+
`OPENCLAW_TELEGRAM_SPOOLED_HANDLER_TIMEOUT_MS`) is the only ingress
35+
guillotine; it dead-letters with `handler-timeout` when claim→adoption
36+
stalls. Healthy long turns must not be killed by the spool watchdog.
2837
- No per-message full-store writes. Hot-path SQLite writes are per-entry.
2938
Rewriting a cache on every send or read stalls the event loop, and that
3039
stall masquerades as a polling stall (the sent-message-cache regression).

extensions/telegram/src/bot-message-dispatch.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,8 @@ type DispatchTelegramMessageParams = {
248248
opts: Pick<TelegramBotOptions, "token" | "mediaMaxMb">;
249249
retryDispatchErrors?: boolean;
250250
suppressFailureFallback?: boolean;
251+
/** Fires after recovery-relevant session/run state is durably persisted. */
252+
onTurnAdopted?: () => void | Promise<void>;
251253
};
252254

253255
type TelegramDispatchResult = { kind: "completed" } | { kind: "failed-retryable"; error: unknown };
@@ -787,6 +789,7 @@ export const dispatchTelegramMessage = async ({
787789
opts,
788790
retryDispatchErrors = false,
789791
suppressFailureFallback = false,
792+
onTurnAdopted,
790793
}: DispatchTelegramMessageParams): Promise<TelegramDispatchResult> => {
791794
const dispatchStartedAt = Date.now();
792795
const dispatchContext = resolveDispatchTelegramContext({ context });
@@ -2615,6 +2618,7 @@ export const dispatchTelegramMessage = async ({
26152618
skillFilter,
26162619
disableBlockStreaming,
26172620
abortSignal: replyAbortController.signal,
2621+
...(onTurnAdopted ? { onTurnAdopted } : {}),
26182622
sourceReplyDeliveryMode: isRoomEvent ? "message_tool_only" : undefined,
26192623
queuedDeliveryCorrelations: isRoomEvent
26202624
? [{ begin: beginDeliveryCorrelation }]

extensions/telegram/src/bot-message.test.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,13 @@ vi.mock("./bot-message-dispatch.js", () => ({
3131
let createTelegramMessageProcessor: typeof import("./bot-message.js").createTelegramMessageProcessor;
3232
let formatTelegramInboundLogLine: typeof import("./bot-message.js").formatTelegramInboundLogLine;
3333
let runWithTelegramUpdateProcessingFrame: typeof import("./bot-processing-outcome.js").runWithTelegramUpdateProcessingFrame;
34-
let withTelegramSpooledReplayUpdate: typeof import("./bot-processing-outcome.js").withTelegramSpooledReplayUpdate;
34+
let runWithTelegramSpooledReplayUpdate: typeof import("./bot-processing-outcome.js").runWithTelegramSpooledReplayUpdate;
3535

3636
describe("telegram bot message processor", () => {
3737
beforeAll(async () => {
3838
({ createTelegramMessageProcessor, formatTelegramInboundLogLine } =
3939
await import("./bot-message.js"));
40-
({ runWithTelegramUpdateProcessingFrame, withTelegramSpooledReplayUpdate } =
40+
({ runWithTelegramUpdateProcessingFrame, runWithTelegramSpooledReplayUpdate } =
4141
await import("./bot-processing-outcome.js"));
4242
});
4343

@@ -334,11 +334,17 @@ describe("telegram bot message processor", () => {
334334
sendMessage,
335335
);
336336
const update = { update_id: 123456 };
337-
const result = await withTelegramSpooledReplayUpdate(update, async () =>
337+
// Spooled agent turns detach at adoption: processMessage returns once the
338+
// deferred participant is registered; the real result is on deferred.task.
339+
const replay = await runWithTelegramSpooledReplayUpdate(update, async () =>
338340
processSampleMessage(processMessage, undefined, { update }),
339341
);
340-
341-
expect(result).toEqual({ kind: "failed-retryable", error: dispatchError });
342+
expect(replay.value).toEqual({ kind: "completed" });
343+
expect(replay.deferredWork).toBeDefined();
344+
await expect(replay.deferredWork!.task).resolves.toEqual({
345+
kind: "failed-retryable",
346+
error: dispatchError,
347+
});
342348
expect(sendMessage).not.toHaveBeenCalled();
343349
expect(runtimeError).toHaveBeenCalledWith(
344350
"telegram message processing failed: Error: dispatch exploded",
@@ -404,11 +410,15 @@ describe("telegram bot message processor", () => {
404410
runtime: { error: runtimeError },
405411
} as unknown as Parameters<typeof createTelegramMessageProcessor>[0]);
406412
const update = { update_id: 123457 };
407-
const result = await withTelegramSpooledReplayUpdate(update, async () =>
413+
const replay = await runWithTelegramSpooledReplayUpdate(update, async () =>
408414
processSampleMessage(processMessage, undefined, { update }),
409415
);
410-
411-
expect(result).toEqual({ kind: "failed-retryable", error: dispatchError });
416+
expect(replay.value).toEqual({ kind: "completed" });
417+
expect(replay.deferredWork).toBeDefined();
418+
await expect(replay.deferredWork!.task).resolves.toEqual({
419+
kind: "failed-retryable",
420+
error: dispatchError,
421+
});
412422
expect(sendMessage).not.toHaveBeenCalled();
413423
expect(dispatchTelegramMessage).toHaveBeenCalledWith(
414424
expect.objectContaining({

extensions/telegram/src/bot-message.ts

Lines changed: 91 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import type { TelegramMessageContextOptions } from "./bot-message-context.types.
1919
import type { TelegramPromptContextEntry } from "./bot-message-context.types.js";
2020
import { dispatchTelegramMessage } from "./bot-message-dispatch.js";
2121
import {
22+
createTelegramSpooledReplayDeferredParticipant,
23+
getTelegramSpooledReplayDeferredParticipant,
2224
isTelegramSpooledReplayUpdate,
2325
recordTelegramMessageProcessingResult,
2426
type TelegramMessageProcessingResult,
@@ -243,55 +245,103 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep
243245
await turnContext.onDispatchStart?.();
244246
const spooledReplay =
245247
options?.spooledReplay === true || isTelegramSpooledReplayUpdate(primaryCtx.update);
246-
try {
247-
const dispatchResult = await dispatchTelegramMessage({
248-
context,
249-
bot,
250-
cfg: context.cfg,
251-
runtime,
252-
replyToMode: turnSettings.replyToMode,
253-
streamMode: turnSettings.streamMode,
254-
textLimit: turnSettings.textLimit,
255-
telegramCfg: turnTelegramCfg,
256-
telegramDeps,
257-
opts,
258-
retryDispatchErrors: spooledReplay,
259-
suppressFailureFallback: spooledReplay,
260-
});
261-
if (dispatchResult?.kind === "failed-retryable") {
248+
249+
const runDispatch = async (params: {
250+
onTurnAdopted?: () => void | Promise<void>;
251+
}): Promise<TelegramMessageProcessingResult> => {
252+
try {
253+
const dispatchResult = await dispatchTelegramMessage({
254+
context,
255+
bot,
256+
cfg: context.cfg,
257+
runtime,
258+
replyToMode: turnSettings.replyToMode,
259+
streamMode: turnSettings.streamMode,
260+
textLimit: turnSettings.textLimit,
261+
telegramCfg: turnTelegramCfg,
262+
telegramDeps,
263+
opts,
264+
retryDispatchErrors: spooledReplay,
265+
suppressFailureFallback: spooledReplay,
266+
onTurnAdopted: params.onTurnAdopted,
267+
});
268+
if (dispatchResult?.kind === "failed-retryable") {
269+
const result: TelegramMessageProcessingResult = {
270+
kind: "failed-retryable",
271+
error: dispatchResult.error,
272+
};
273+
recordCurrentUpdateProcessingResult(result);
274+
return result;
275+
}
276+
if (ingressDebugEnabled && ingressReceivedAtMs) {
277+
logVerbose(
278+
`telegram ingress: chatId=${context.chatId} dispatchCompleteMs=${Date.now() - ingressReceivedAtMs}` +
279+
(options?.ingressBuffer ? ` buffer=${options.ingressBuffer}` : ""),
280+
);
281+
}
282+
const result: TelegramMessageProcessingResult = { kind: "completed" };
283+
recordCurrentUpdateProcessingResult(result);
284+
return result;
285+
} catch (err) {
286+
runtime.error?.(danger(`telegram message processing failed: ${String(err)}`));
287+
if (!spooledReplay) {
288+
try {
289+
await bot.api.sendMessage(
290+
context.chatId,
291+
"Something went wrong while processing your request. Please try again.",
292+
buildTelegramThreadParams(context.threadSpec),
293+
);
294+
} catch {}
295+
}
262296
const result: TelegramMessageProcessingResult = {
263297
kind: "failed-retryable",
264-
error: dispatchResult.error,
298+
error: err,
265299
};
266300
recordCurrentUpdateProcessingResult(result);
267301
return result;
268302
}
269-
if (ingressDebugEnabled && ingressReceivedAtMs) {
270-
logVerbose(
271-
`telegram ingress: chatId=${context.chatId} dispatchCompleteMs=${Date.now() - ingressReceivedAtMs}` +
272-
(options?.ingressBuffer ? ` buffer=${options.ingressBuffer}` : ""),
303+
};
304+
305+
// Spooled ingress: complete the spool row at turn adoption (recovery state
306+
// persisted), not settle. The deferred participant hands ownership back to
307+
// the spool drain so the per-chat lane frees while the agent turn continues.
308+
if (spooledReplay) {
309+
const existingParticipant = getTelegramSpooledReplayDeferredParticipant();
310+
const participant =
311+
existingParticipant ??
312+
createTelegramSpooledReplayDeferredParticipant(
313+
`agent-turn:${context.chatId}:${context.ctxPayload.MessageSid ?? Date.now()}`,
273314
);
315+
if (participant) {
316+
let adopted = false;
317+
const settleIfNeeded = (result: TelegramMessageProcessingResult) => {
318+
if (adopted) {
319+
return;
320+
}
321+
participant.settle(result);
322+
};
323+
const run = async () => {
324+
const result = await runDispatch({
325+
onTurnAdopted: async () => {
326+
if (adopted) {
327+
return;
328+
}
329+
adopted = true;
330+
participant.settle({ kind: "completed" });
331+
},
332+
});
333+
settleIfNeeded(result);
334+
return result;
335+
};
336+
if (existingParticipant) {
337+
return await run();
338+
}
339+
void run();
340+
const detached: TelegramMessageProcessingResult = { kind: "completed" };
341+
return detached;
274342
}
275-
const result: TelegramMessageProcessingResult = { kind: "completed" };
276-
recordCurrentUpdateProcessingResult(result);
277-
return result;
278-
} catch (err) {
279-
runtime.error?.(danger(`telegram message processing failed: ${String(err)}`));
280-
if (!spooledReplay) {
281-
try {
282-
await bot.api.sendMessage(
283-
context.chatId,
284-
"Something went wrong while processing your request. Please try again.",
285-
buildTelegramThreadParams(context.threadSpec),
286-
);
287-
} catch {}
288-
}
289-
const result: TelegramMessageProcessingResult = {
290-
kind: "failed-retryable",
291-
error: err,
292-
};
293-
recordCurrentUpdateProcessingResult(result);
294-
return result;
295343
}
344+
345+
return await runDispatch({});
296346
};
297347
};

0 commit comments

Comments
 (0)