Fixes #3031 When a subagent completes and announces its result back to the main agent, the main agent's pending tasks were being lost due to a race condition between the followup queue drain and the announce flow. Changes: - Add persistence layer for followup queues (similar to subagent runs) - Implement 30-second grace period before deleting empty queues - Persist queue state after enqueue/dequeue operations - Track emptyAt timestamp to prevent premature deletion - Add periodic cleanup for expired empty queues This ensures that when a subagent completes and triggers a new main agent invocation, any pending tasks in the followup queue are preserved and restored from disk. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
66 lines
2.1 KiB
TypeScript
66 lines
2.1 KiB
TypeScript
import { applyQueueDropPolicy, shouldSkipQueueItem } from "../../../utils/queue-helpers.js";
|
|
import { FOLLOWUP_QUEUES, getFollowupQueue, persistFollowupQueues } from "./state.js";
|
|
import type { FollowupRun, QueueDedupeMode, QueueSettings } from "./types.js";
|
|
|
|
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 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;
|
|
// Clear emptyAt since we're adding an item
|
|
queue.emptyAt = undefined;
|
|
|
|
const shouldEnqueue = applyQueueDropPolicy({
|
|
queue,
|
|
summarize: (item) => item.summaryLine?.trim() || item.prompt.trim(),
|
|
});
|
|
if (!shouldEnqueue) {
|
|
persistFollowupQueues();
|
|
return false;
|
|
}
|
|
|
|
queue.items.push(run);
|
|
persistFollowupQueues();
|
|
return true;
|
|
}
|
|
|
|
export function getFollowupQueueDepth(key: string): number {
|
|
const cleaned = key.trim();
|
|
if (!cleaned) return 0;
|
|
const queue = FOLLOWUP_QUEUES.get(cleaned);
|
|
if (!queue) return 0;
|
|
return queue.items.length;
|
|
}
|