After a drain loop empties the queue it deletes the key from FOLLOWUP_QUEUES. If a new message arrives at that moment enqueueFollowupRun creates a fresh queue object with draining:false but never starts a drain, leaving the message stranded until the next run completes and calls finalizeWithFollowup. Fix: persist the most recent runFollowup callback per queue key in FOLLOWUP_RUN_CALLBACKS (drain.ts). enqueueFollowupRun now calls kickFollowupDrainIfIdle after a successful push; if a cached callback exists and no drain is running it calls scheduleFollowupDrain to restart immediately. clearSessionQueues cleans up the callback cache alongside the queue state.
174 lines
6.0 KiB
TypeScript
174 lines
6.0 KiB
TypeScript
import { defaultRuntime } from "../../../runtime.js";
|
|
import {
|
|
buildCollectPrompt,
|
|
beginQueueDrain,
|
|
clearQueueSummaryState,
|
|
drainCollectQueueStep,
|
|
drainNextQueueItem,
|
|
hasCrossChannelItems,
|
|
previewQueueSummaryPrompt,
|
|
waitForQueueDebounce,
|
|
} from "../../../utils/queue-helpers.js";
|
|
import { isRoutableChannel } from "../route-reply.js";
|
|
import { FOLLOWUP_QUEUES } from "./state.js";
|
|
import type { FollowupRun } from "./types.js";
|
|
|
|
// Persists the most recent runFollowup callback per queue key so that
|
|
// enqueueFollowupRun can restart a drain that finished and deleted the queue.
|
|
const FOLLOWUP_RUN_CALLBACKS = new Map<string, (run: FollowupRun) => Promise<void>>();
|
|
|
|
export function clearFollowupDrainCallback(key: string): void {
|
|
FOLLOWUP_RUN_CALLBACKS.delete(key);
|
|
}
|
|
|
|
/** Restart the drain for `key` if it is currently idle, using the stored callback. */
|
|
export function kickFollowupDrainIfIdle(key: string): void {
|
|
const cb = FOLLOWUP_RUN_CALLBACKS.get(key);
|
|
if (!cb) {
|
|
return;
|
|
}
|
|
scheduleFollowupDrain(key, cb);
|
|
}
|
|
|
|
type OriginRoutingMetadata = Pick<
|
|
FollowupRun,
|
|
"originatingChannel" | "originatingTo" | "originatingAccountId" | "originatingThreadId"
|
|
>;
|
|
|
|
function resolveOriginRoutingMetadata(items: FollowupRun[]): OriginRoutingMetadata {
|
|
return {
|
|
originatingChannel: items.find((item) => item.originatingChannel)?.originatingChannel,
|
|
originatingTo: items.find((item) => item.originatingTo)?.originatingTo,
|
|
originatingAccountId: items.find((item) => item.originatingAccountId)?.originatingAccountId,
|
|
// Support both number (Telegram topic) and string (Slack thread_ts) thread IDs.
|
|
originatingThreadId: items.find(
|
|
(item) => item.originatingThreadId != null && item.originatingThreadId !== "",
|
|
)?.originatingThreadId,
|
|
};
|
|
}
|
|
|
|
function resolveCrossChannelKey(item: FollowupRun): { cross?: true; key?: string } {
|
|
const { originatingChannel: channel, originatingTo: to, originatingAccountId: accountId } = item;
|
|
const threadId = item.originatingThreadId;
|
|
if (!channel && !to && !accountId && (threadId == null || threadId === "")) {
|
|
return {};
|
|
}
|
|
if (!isRoutableChannel(channel) || !to) {
|
|
return { cross: true };
|
|
}
|
|
// Support both number (Telegram topic IDs) and string (Slack thread_ts) thread IDs.
|
|
const threadKey = threadId != null && threadId !== "" ? String(threadId) : "";
|
|
return {
|
|
key: [channel, to, accountId || "", threadKey].join("|"),
|
|
};
|
|
}
|
|
|
|
export function scheduleFollowupDrain(
|
|
key: string,
|
|
runFollowup: (run: FollowupRun) => Promise<void>,
|
|
): void {
|
|
// Cache the callback so enqueueFollowupRun can restart drain after the queue
|
|
// has been deleted and recreated (the post-drain idle window race condition).
|
|
FOLLOWUP_RUN_CALLBACKS.set(key, runFollowup);
|
|
const queue = beginQueueDrain(FOLLOWUP_QUEUES, key);
|
|
if (!queue) {
|
|
return;
|
|
}
|
|
void (async () => {
|
|
try {
|
|
const collectState = { forceIndividualCollect: false };
|
|
while (queue.items.length > 0 || queue.droppedCount > 0) {
|
|
await waitForQueueDebounce(queue);
|
|
if (queue.mode === "collect") {
|
|
// Once the batch is mixed, never collect again within this drain.
|
|
// Prevents “collect after shift” collapsing different targets.
|
|
//
|
|
// Debug: `pnpm test src/auto-reply/reply/reply-flow.test.ts`
|
|
// Check if messages span multiple channels.
|
|
// If so, process individually to preserve per-message routing.
|
|
const isCrossChannel = hasCrossChannelItems(queue.items, resolveCrossChannelKey);
|
|
|
|
const collectDrainResult = await drainCollectQueueStep({
|
|
collectState,
|
|
isCrossChannel,
|
|
items: queue.items,
|
|
run: runFollowup,
|
|
});
|
|
if (collectDrainResult === "empty") {
|
|
break;
|
|
}
|
|
if (collectDrainResult === "drained") {
|
|
continue;
|
|
}
|
|
|
|
const items = queue.items.slice();
|
|
const summary = previewQueueSummaryPrompt({ state: queue, noun: "message" });
|
|
const run = items.at(-1)?.run ?? queue.lastRun;
|
|
if (!run) {
|
|
break;
|
|
}
|
|
|
|
const routing = resolveOriginRoutingMetadata(items);
|
|
|
|
const prompt = buildCollectPrompt({
|
|
title: "[Queued messages while agent was busy]",
|
|
items,
|
|
summary,
|
|
renderItem: (item, idx) => `---\nQueued #${idx + 1}\n${item.prompt}`.trim(),
|
|
});
|
|
await runFollowup({
|
|
prompt,
|
|
run,
|
|
enqueuedAt: Date.now(),
|
|
...routing,
|
|
});
|
|
queue.items.splice(0, items.length);
|
|
if (summary) {
|
|
clearQueueSummaryState(queue);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const summaryPrompt = previewQueueSummaryPrompt({ state: queue, noun: "message" });
|
|
if (summaryPrompt) {
|
|
const run = queue.lastRun;
|
|
if (!run) {
|
|
break;
|
|
}
|
|
if (
|
|
!(await drainNextQueueItem(queue.items, async (item) => {
|
|
await runFollowup({
|
|
prompt: summaryPrompt,
|
|
run,
|
|
enqueuedAt: Date.now(),
|
|
originatingChannel: item.originatingChannel,
|
|
originatingTo: item.originatingTo,
|
|
originatingAccountId: item.originatingAccountId,
|
|
originatingThreadId: item.originatingThreadId,
|
|
});
|
|
}))
|
|
) {
|
|
break;
|
|
}
|
|
clearQueueSummaryState(queue);
|
|
continue;
|
|
}
|
|
|
|
if (!(await drainNextQueueItem(queue.items, runFollowup))) {
|
|
break;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
queue.lastEnqueuedAt = Date.now();
|
|
defaultRuntime.error?.(`followup queue drain failed for ${key}: ${String(err)}`);
|
|
} finally {
|
|
queue.draining = false;
|
|
if (queue.items.length === 0 && queue.droppedCount === 0) {
|
|
FOLLOWUP_QUEUES.delete(key);
|
|
} else {
|
|
scheduleFollowupDrain(key, runFollowup);
|
|
}
|
|
}
|
|
})();
|
|
}
|