* Tests: add fresh module import helper * Process: share command queue runtime state * Agents: share embedded run runtime state * Reply: share followup queue runtime state * Reply: share followup drain callback state * Reply: share queued message dedupe state * Reply: share inbound dedupe state * Tests: cover shared command queue runtime state * Tests: cover shared embedded run runtime state * Tests: cover shared followup queue runtime state * Tests: cover shared inbound dedupe state * Tests: cover shared Slack thread participation state * Slack: share sent thread participation state * Tests: document fresh import helper * Telegram: share draft stream runtime state * Tests: cover shared Telegram draft stream state * Telegram: share sent message cache state * Tests: cover shared Telegram sent message cache * Telegram: share thread binding runtime state * Tests: cover shared Telegram thread binding state * Tests: avoid duplicate shared queue reset * refactor(runtime): centralize global singleton access * refactor(runtime): preserve undefined global singleton values * test(runtime): cover undefined global singleton values --------- Co-authored-by: Nimrod Gutman <nimrod.gutman@gmail.com>
118 lines
3.7 KiB
TypeScript
118 lines
3.7 KiB
TypeScript
import { createDedupeCache } from "../../../infra/dedupe.js";
|
|
import { resolveGlobalSingleton } from "../../../shared/global-singleton.js";
|
|
import { applyQueueDropPolicy, shouldSkipQueueItem } from "../../../utils/queue-helpers.js";
|
|
import { kickFollowupDrainIfIdle } from "./drain.js";
|
|
import { getExistingFollowupQueue, getFollowupQueue } from "./state.js";
|
|
import type { FollowupRun, QueueDedupeMode, QueueSettings } from "./types.js";
|
|
|
|
/**
|
|
* Keep queued message-id dedupe shared across bundled chunks so redeliveries
|
|
* are rejected no matter which chunk receives the enqueue call.
|
|
*/
|
|
const RECENT_QUEUE_MESSAGE_IDS_KEY = Symbol.for("openclaw.recentQueueMessageIds");
|
|
|
|
const RECENT_QUEUE_MESSAGE_IDS = resolveGlobalSingleton(RECENT_QUEUE_MESSAGE_IDS_KEY, () =>
|
|
createDedupeCache({
|
|
ttlMs: 5 * 60 * 1000,
|
|
maxSize: 10_000,
|
|
}),
|
|
);
|
|
|
|
function buildRecentMessageIdKey(run: FollowupRun, queueKey: string): string | undefined {
|
|
const messageId = run.messageId?.trim();
|
|
if (!messageId) {
|
|
return undefined;
|
|
}
|
|
// Use JSON tuple serialization to avoid delimiter-collision edge cases when
|
|
// channel/to/account values contain "|" characters.
|
|
return JSON.stringify([
|
|
"queue",
|
|
queueKey,
|
|
run.originatingChannel ?? "",
|
|
run.originatingTo ?? "",
|
|
run.originatingAccountId ?? "",
|
|
run.originatingThreadId == null ? "" : String(run.originatingThreadId),
|
|
messageId,
|
|
]);
|
|
}
|
|
|
|
function isRunAlreadyQueued(
|
|
run: FollowupRun,
|
|
items: FollowupRun[],
|
|
allowPromptFallback = false,
|
|
): boolean {
|
|
const hasSameRouting = (item: FollowupRun) =>
|
|
item.originatingChannel === run.originatingChannel &&
|
|
item.originatingTo === run.originatingTo &&
|
|
item.originatingAccountId === run.originatingAccountId &&
|
|
item.originatingThreadId === run.originatingThreadId;
|
|
|
|
const messageId = run.messageId?.trim();
|
|
if (messageId) {
|
|
return items.some((item) => item.messageId?.trim() === messageId && hasSameRouting(item));
|
|
}
|
|
if (!allowPromptFallback) {
|
|
return false;
|
|
}
|
|
return items.some((item) => item.prompt === run.prompt && hasSameRouting(item));
|
|
}
|
|
|
|
export function enqueueFollowupRun(
|
|
key: string,
|
|
run: FollowupRun,
|
|
settings: QueueSettings,
|
|
dedupeMode: QueueDedupeMode = "message-id",
|
|
): boolean {
|
|
const queue = getFollowupQueue(key, settings);
|
|
const recentMessageIdKey = dedupeMode !== "none" ? buildRecentMessageIdKey(run, key) : undefined;
|
|
if (recentMessageIdKey && RECENT_QUEUE_MESSAGE_IDS.peek(recentMessageIdKey)) {
|
|
return false;
|
|
}
|
|
|
|
const dedupe =
|
|
dedupeMode === "none"
|
|
? undefined
|
|
: (item: FollowupRun, items: FollowupRun[]) =>
|
|
isRunAlreadyQueued(item, items, dedupeMode === "prompt");
|
|
|
|
// Deduplicate: skip if the same message is already queued.
|
|
if (shouldSkipQueueItem({ item: run, items: queue.items, dedupe })) {
|
|
return false;
|
|
}
|
|
|
|
queue.lastEnqueuedAt = Date.now();
|
|
queue.lastRun = run.run;
|
|
|
|
const shouldEnqueue = applyQueueDropPolicy({
|
|
queue,
|
|
summarize: (item) => item.summaryLine?.trim() || item.prompt.trim(),
|
|
});
|
|
if (!shouldEnqueue) {
|
|
return false;
|
|
}
|
|
|
|
queue.items.push(run);
|
|
if (recentMessageIdKey) {
|
|
RECENT_QUEUE_MESSAGE_IDS.check(recentMessageIdKey);
|
|
}
|
|
// If drain finished and deleted the queue before this item arrived, a new queue
|
|
// object was created (draining: false) but nobody scheduled a drain for it.
|
|
// Use the cached callback to restart the drain now.
|
|
if (!queue.draining) {
|
|
kickFollowupDrainIfIdle(key);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function getFollowupQueueDepth(key: string): number {
|
|
const queue = getExistingFollowupQueue(key);
|
|
if (!queue) {
|
|
return 0;
|
|
}
|
|
return queue.items.length;
|
|
}
|
|
|
|
export function resetRecentQueuedMessageIdDedupe(): void {
|
|
RECENT_QUEUE_MESSAGE_IDS.clear();
|
|
}
|