openclaw/src/auto-reply/reply/queue/enqueue.ts
sid1943 82efb719ab fix: preserve pending tasks when subagent completes
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>
2026-01-27 19:31:33 -05:00

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