This commit is contained in:
sid1943 2026-01-29 14:57:12 -05:00 committed by GitHub
commit fdfb74d090
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 144 additions and 5 deletions

View File

@ -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;
FOLLOWUP_QUEUES.delete(key);
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);
}
} else { } else {
// Queue has items, clear emptyAt and continue draining
queue.emptyAt = undefined;
scheduleFollowupDrain(key, runFollowup); scheduleFollowupDrain(key, runFollowup);
} }
persistFollowupQueues();
} }
})(); })();
} }

View File

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

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

View File

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