Merge 6d9e21f244 into 4583f88626
This commit is contained in:
commit
fdfb74d090
@ -6,9 +6,31 @@ import {
|
|||||||
waitForQueueDebounce,
|
waitForQueueDebounce,
|
||||||
} from "../../../utils/queue-helpers.js";
|
} from "../../../utils/queue-helpers.js";
|
||||||
import { isRoutableChannel } from "../route-reply.js";
|
import { isRoutableChannel } from "../route-reply.js";
|
||||||
import { FOLLOWUP_QUEUES } from "./state.js";
|
import { FOLLOWUP_QUEUES, persistFollowupQueues } from "./state.js";
|
||||||
import type { FollowupRun } from "./types.js";
|
import type { FollowupRun } from "./types.js";
|
||||||
|
|
||||||
|
// Grace period before deleting empty queues (to handle subagent announce race conditions)
|
||||||
|
const EMPTY_QUEUE_GRACE_PERIOD_MS = 30_000;
|
||||||
|
|
||||||
|
// Periodic cleanup of expired empty queues
|
||||||
|
let cleanupTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
function scheduleQueueCleanup() {
|
||||||
|
if (cleanupTimer) return;
|
||||||
|
cleanupTimer = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, queue] of FOLLOWUP_QUEUES.entries()) {
|
||||||
|
if (queue.items.length === 0 && queue.droppedCount === 0 && queue.emptyAt) {
|
||||||
|
const emptyDuration = now - queue.emptyAt;
|
||||||
|
if (emptyDuration >= EMPTY_QUEUE_GRACE_PERIOD_MS) {
|
||||||
|
FOLLOWUP_QUEUES.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
persistFollowupQueues();
|
||||||
|
}, EMPTY_QUEUE_GRACE_PERIOD_MS);
|
||||||
|
}
|
||||||
|
|
||||||
export function scheduleFollowupDrain(
|
export function scheduleFollowupDrain(
|
||||||
key: string,
|
key: string,
|
||||||
runFollowup: (run: FollowupRun) => Promise<void>,
|
runFollowup: (run: FollowupRun) => Promise<void>,
|
||||||
@ -16,6 +38,7 @@ export function scheduleFollowupDrain(
|
|||||||
const queue = FOLLOWUP_QUEUES.get(key);
|
const queue = FOLLOWUP_QUEUES.get(key);
|
||||||
if (!queue || queue.draining) return;
|
if (!queue || queue.draining) return;
|
||||||
queue.draining = true;
|
queue.draining = true;
|
||||||
|
scheduleQueueCleanup();
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
let forceIndividualCollect = false;
|
let forceIndividualCollect = false;
|
||||||
@ -113,11 +136,27 @@ export function scheduleFollowupDrain(
|
|||||||
defaultRuntime.error?.(`followup queue drain failed for ${key}: ${String(err)}`);
|
defaultRuntime.error?.(`followup queue drain failed for ${key}: ${String(err)}`);
|
||||||
} finally {
|
} finally {
|
||||||
queue.draining = false;
|
queue.draining = false;
|
||||||
if (queue.items.length === 0 && queue.droppedCount === 0) {
|
const isEmpty = queue.items.length === 0 && queue.droppedCount === 0;
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
// Mark when queue became empty
|
||||||
|
if (!queue.emptyAt) {
|
||||||
|
queue.emptyAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only delete if it's been empty for the grace period
|
||||||
|
// This prevents race conditions with subagent announces
|
||||||
|
const emptyDuration = Date.now() - queue.emptyAt;
|
||||||
|
if (emptyDuration >= EMPTY_QUEUE_GRACE_PERIOD_MS) {
|
||||||
FOLLOWUP_QUEUES.delete(key);
|
FOLLOWUP_QUEUES.delete(key);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Queue has items, clear emptyAt and continue draining
|
||||||
|
queue.emptyAt = undefined;
|
||||||
scheduleFollowupDrain(key, runFollowup);
|
scheduleFollowupDrain(key, runFollowup);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
persistFollowupQueues();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { applyQueueDropPolicy, shouldSkipQueueItem } from "../../../utils/queue-helpers.js";
|
import { applyQueueDropPolicy, shouldSkipQueueItem } from "../../../utils/queue-helpers.js";
|
||||||
import { FOLLOWUP_QUEUES, getFollowupQueue } from "./state.js";
|
import { FOLLOWUP_QUEUES, getFollowupQueue, persistFollowupQueues } from "./state.js";
|
||||||
import type { FollowupRun, QueueDedupeMode, QueueSettings } from "./types.js";
|
import type { FollowupRun, QueueDedupeMode, QueueSettings } from "./types.js";
|
||||||
|
|
||||||
function isRunAlreadyQueued(
|
function isRunAlreadyQueued(
|
||||||
@ -39,14 +39,20 @@ export function enqueueFollowupRun(
|
|||||||
|
|
||||||
queue.lastEnqueuedAt = Date.now();
|
queue.lastEnqueuedAt = Date.now();
|
||||||
queue.lastRun = run.run;
|
queue.lastRun = run.run;
|
||||||
|
// Clear emptyAt since we're adding an item
|
||||||
|
queue.emptyAt = undefined;
|
||||||
|
|
||||||
const shouldEnqueue = applyQueueDropPolicy({
|
const shouldEnqueue = applyQueueDropPolicy({
|
||||||
queue,
|
queue,
|
||||||
summarize: (item) => item.summaryLine?.trim() || item.prompt.trim(),
|
summarize: (item) => item.summaryLine?.trim() || item.prompt.trim(),
|
||||||
});
|
});
|
||||||
if (!shouldEnqueue) return false;
|
if (!shouldEnqueue) {
|
||||||
|
persistFollowupQueues();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
queue.items.push(run);
|
queue.items.push(run);
|
||||||
|
persistFollowupQueues();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
57
src/auto-reply/reply/queue/state.store.ts
Normal file
57
src/auto-reply/reply/queue/state.store.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { STATE_DIR } from "../../../config/paths.js";
|
||||||
|
import { loadJsonFile, saveJsonFile } from "../../../infra/json-file.js";
|
||||||
|
import type { FollowupQueueState } from "./state.js";
|
||||||
|
|
||||||
|
export type PersistedFollowupQueueVersion = 1;
|
||||||
|
|
||||||
|
type PersistedFollowupQueueRegistry = {
|
||||||
|
version: 1;
|
||||||
|
queues: Record<string, PersistedFollowupQueueState>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PersistedFollowupQueueState = FollowupQueueState;
|
||||||
|
|
||||||
|
const REGISTRY_VERSION = 1 as const;
|
||||||
|
|
||||||
|
export function resolveFollowupQueueRegistryPath(): string {
|
||||||
|
return path.join(STATE_DIR, "followup-queues", "queues.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadFollowupQueuesFromDisk(): Map<string, FollowupQueueState> {
|
||||||
|
const pathname = resolveFollowupQueueRegistryPath();
|
||||||
|
const raw = loadJsonFile(pathname);
|
||||||
|
if (!raw || typeof raw !== "object") return new Map();
|
||||||
|
const record = raw as Partial<PersistedFollowupQueueRegistry>;
|
||||||
|
if (record.version !== 1) return new Map();
|
||||||
|
const queuesRaw = record.queues;
|
||||||
|
if (!queuesRaw || typeof queuesRaw !== "object") return new Map();
|
||||||
|
const out = new Map<string, FollowupQueueState>();
|
||||||
|
for (const [key, entry] of Object.entries(queuesRaw)) {
|
||||||
|
if (!entry || typeof entry !== "object") continue;
|
||||||
|
// Reset draining state on restore - will be restarted if items exist
|
||||||
|
const restored: FollowupQueueState = {
|
||||||
|
...entry,
|
||||||
|
draining: false,
|
||||||
|
};
|
||||||
|
out.set(key, restored);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveFollowupQueuesToDisk(queues: Map<string, FollowupQueueState>) {
|
||||||
|
const pathname = resolveFollowupQueueRegistryPath();
|
||||||
|
const serialized: Record<string, PersistedFollowupQueueState> = {};
|
||||||
|
for (const [key, entry] of queues.entries()) {
|
||||||
|
// Only persist queues that have items or have been used recently
|
||||||
|
if (entry.items.length > 0 || entry.droppedCount > 0 || entry.lastEnqueuedAt > 0) {
|
||||||
|
serialized[key] = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out: PersistedFollowupQueueRegistry = {
|
||||||
|
version: REGISTRY_VERSION,
|
||||||
|
queues: serialized,
|
||||||
|
};
|
||||||
|
saveJsonFile(pathname, out);
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import type { FollowupRun, QueueDropPolicy, QueueMode, QueueSettings } from "./types.js";
|
import type { FollowupRun, QueueDropPolicy, QueueMode, QueueSettings } from "./types.js";
|
||||||
|
import { loadFollowupQueuesFromDisk, saveFollowupQueuesToDisk } from "./state.store.js";
|
||||||
|
|
||||||
export type FollowupQueueState = {
|
export type FollowupQueueState = {
|
||||||
items: FollowupRun[];
|
items: FollowupRun[];
|
||||||
@ -11,6 +12,8 @@ export type FollowupQueueState = {
|
|||||||
droppedCount: number;
|
droppedCount: number;
|
||||||
summaryLines: string[];
|
summaryLines: string[];
|
||||||
lastRun?: FollowupRun["run"];
|
lastRun?: FollowupRun["run"];
|
||||||
|
/** Timestamp when queue became empty (used for grace period before deletion) */
|
||||||
|
emptyAt?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_QUEUE_DEBOUNCE_MS = 1000;
|
export const DEFAULT_QUEUE_DEBOUNCE_MS = 1000;
|
||||||
@ -19,7 +22,39 @@ export const DEFAULT_QUEUE_DROP: QueueDropPolicy = "summarize";
|
|||||||
|
|
||||||
export const FOLLOWUP_QUEUES = new Map<string, FollowupQueueState>();
|
export const FOLLOWUP_QUEUES = new Map<string, FollowupQueueState>();
|
||||||
|
|
||||||
|
// Track if we've restored from disk
|
||||||
|
let restoreAttempted = false;
|
||||||
|
|
||||||
|
export function persistFollowupQueues() {
|
||||||
|
try {
|
||||||
|
saveFollowupQueuesToDisk(FOLLOWUP_QUEUES);
|
||||||
|
} catch {
|
||||||
|
// ignore persistence failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreFollowupQueuesOnce() {
|
||||||
|
if (restoreAttempted) return;
|
||||||
|
restoreAttempted = true;
|
||||||
|
try {
|
||||||
|
const restored = loadFollowupQueuesFromDisk();
|
||||||
|
if (restored.size === 0) return;
|
||||||
|
for (const [key, entry] of restored.entries()) {
|
||||||
|
if (!key || !entry) continue;
|
||||||
|
// Keep any newer in-memory entries
|
||||||
|
if (!FOLLOWUP_QUEUES.has(key)) {
|
||||||
|
FOLLOWUP_QUEUES.set(key, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore restore failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getFollowupQueue(key: string, settings: QueueSettings): FollowupQueueState {
|
export function getFollowupQueue(key: string, settings: QueueSettings): FollowupQueueState {
|
||||||
|
// Restore queues from disk on first access
|
||||||
|
restoreFollowupQueuesOnce();
|
||||||
|
|
||||||
const existing = FOLLOWUP_QUEUES.get(key);
|
const existing = FOLLOWUP_QUEUES.get(key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.mode = settings.mode;
|
existing.mode = settings.mode;
|
||||||
@ -53,6 +88,7 @@ export function getFollowupQueue(key: string, settings: QueueSettings): Followup
|
|||||||
summaryLines: [],
|
summaryLines: [],
|
||||||
};
|
};
|
||||||
FOLLOWUP_QUEUES.set(key, created);
|
FOLLOWUP_QUEUES.set(key, created);
|
||||||
|
persistFollowupQueues();
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,5 +104,6 @@ export function clearFollowupQueue(key: string): number {
|
|||||||
queue.lastRun = undefined;
|
queue.lastRun = undefined;
|
||||||
queue.lastEnqueuedAt = 0;
|
queue.lastEnqueuedAt = 0;
|
||||||
FOLLOWUP_QUEUES.delete(cleaned);
|
FOLLOWUP_QUEUES.delete(cleaned);
|
||||||
|
persistFollowupQueues();
|
||||||
return cleared;
|
return cleared;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user