Files
openclaw/src/auto-reply/reply/queue/enqueue.ts
T
Peter Steinberger 6df0fb818d feat: add session thread management (#98510)
* feat: add session thread management

Squash of codex/thread-management (025aefc3ad1) onto origin/main:
pin/archive/rename sessions via sessions.patch, archived-aware
sessions.list, lifecycle fencing, read-only archived chat, SDK +
Swift protocol support, Control UI session management.

* refactor(ui): minimal session rows with hover-revealed management

Chat picker and sidebar recents share session-row primitives: single-line
rows, relative timestamps, rename/archive/pin revealed on hover or focus,
accent pin badge for pinned rows, and an active-run spinner in the trail
slot. Sidebar floats pinned sessions above recency via the shared
comparator and gains archive/pin actions through the unified sessions-view
patch fallback. Archive eligibility is one shared policy
(canArchiveSessionRow); the sidebar/picker active-run tooltip now uses the
real sessionsView.activeRun locale key.

* fix: align session admission with mailbox-era main

Integration fixes after rebasing onto current main: sessions_list mailbox
test expectations learn the archived/pinned row fields and archived:false
list param; gateway agent admission treats a session as deleted only when
both the requested and canonical alias sets miss it (legacy bare-main
stores and exec-approval followups read under different spellings); cron
persist tests keep a consistent store across claim-guarded persist calls;
the ACP abort hook test asserts abort propagation instead of signal
identity; drop dead lifecycle writes flagged by no-useless-assignment and
fix the promise-executor return in the codex compact test.

* fix(qa): align UI e2e and shard fixtures with redesigned session rows

Sidebar session rows are wrapper divs with an inner link now: update the
navigation browser tests and chat-flow Playwright selectors. Seed a real
per-test session store for the auto-fallback admission guard instead of
depending on leftover host files at /tmp/sessions.json. Teach the
test-projects routing fixture about the suites that newly import the
shared temp-dir helper. Document the Codex thread-format contract for
archivedAt/pinnedAt (flag derived from server-stamped timestamp, epoch ms
here vs Codex epoch seconds) at the type and in the session docs.

* test: route auto-fallback suite through temp-dir helper plans

The auto-fallback suite now imports the shared temp-dir helper for its
seeded session store, so the top-level helper routing fixture must list
it in the auto-reply plan.
2026-07-04 14:30:47 -04:00

203 lines
6.6 KiB
TypeScript

// Enqueues follow-up reply runs and schedules queue drains.
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { normalizeChatType } from "../../../channels/chat-type.js";
import { resolveGlobalDedupeCache } from "../../../infra/dedupe.js";
import { channelRouteDedupeKey } from "../../../plugin-sdk/channel-route.js";
import { applyQueueDropPolicy, shouldSkipQueueItem } from "../../../utils/queue-helpers.js";
import {
createOverflowSummaryRetrySource,
kickFollowupDrainIfIdle,
rememberFollowupDrainCallback,
resolveFollowupDeliveryContextKey,
resolveFollowupReplyAnchor,
} from "./drain.js";
import { getExistingFollowupQueue, getFollowupQueue } from "./state.js";
import {
completeFollowupRunLifecycle,
isFollowupRunAborted,
markFollowupRunEnqueued,
type FollowupRun,
type QueueDedupeMode,
type QueueSettings,
} from "./types.js";
/**
* Keep queued message-id dedupe shared across bundled chunks so redeliveries
* are rejected no matter which chunk receives the enqueue call.
*/
const RECENT_QUEUE_MESSAGE_IDS_KEY = Symbol.for("openclaw.recentQueueMessageIds");
const RECENT_QUEUE_MESSAGE_IDS = resolveGlobalDedupeCache(RECENT_QUEUE_MESSAGE_IDS_KEY, {
ttlMs: 5 * 60 * 1000,
maxSize: 10_000,
});
function followupRouteIdentityKey(run: FollowupRun): string {
return JSON.stringify([
channelRouteDedupeKey({
channel: run.originatingChannel,
to: run.originatingTo,
accountId: run.originatingAccountId,
threadId: run.originatingThreadId,
}),
resolveFollowupReplyAnchor(run) ?? "",
run.originatingReplyToMode ?? "",
normalizeChatType(run.originatingChatType) ?? "",
]);
}
function followupMessageRouteIdentityKey(run: FollowupRun): string {
return JSON.stringify([
channelRouteDedupeKey({
channel: run.originatingChannel,
to: run.originatingTo,
accountId: run.originatingAccountId,
threadId: run.originatingThreadId,
}),
normalizeChatType(run.originatingChatType) ?? "",
]);
}
function buildRecentMessageIdKey(run: FollowupRun, queueKey: string): string | undefined {
const messageId = normalizeOptionalString(run.messageId);
if (!messageId) {
return undefined;
}
// Use JSON tuple serialization to avoid delimiter-collision edge cases when
// channel/to/account values contain "|" characters.
return JSON.stringify(["queue", queueKey, followupMessageRouteIdentityKey(run), messageId]);
}
function isRunAlreadyQueued(
run: FollowupRun,
items: FollowupRun[],
allowPromptFallback = false,
): boolean {
const messageId = normalizeOptionalString(run.messageId);
if (messageId) {
const messageRouteKey = followupMessageRouteIdentityKey(run);
return items.some(
(item) =>
normalizeOptionalString(item.messageId) === messageId &&
followupMessageRouteIdentityKey(item) === messageRouteKey,
);
}
if (!allowPromptFallback) {
return false;
}
const routeKey = followupRouteIdentityKey(run);
return items.some(
(item) => item.prompt === run.prompt && followupRouteIdentityKey(item) === routeKey,
);
}
export function enqueueFollowupRun(
key: string,
run: FollowupRun,
settings: QueueSettings,
dedupeMode: QueueDedupeMode = "message-id",
runFollowup?: (run: FollowupRun) => Promise<void>,
restartIfIdle = true,
): boolean {
if (isFollowupRunAborted(run)) {
return false;
}
const queue = getFollowupQueue(key, settings);
const recentMessageIdKey = dedupeMode !== "none" ? buildRecentMessageIdKey(run, key) : undefined;
if (recentMessageIdKey && RECENT_QUEUE_MESSAGE_IDS.peek(recentMessageIdKey)) {
return false;
}
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;
const shouldEnqueue = applyQueueDropPolicy({
queue,
summarize: (item) => normalizeOptionalString(item.summaryLine) || item.prompt.trim(),
onDrop: (dropped) => {
if (queue.dropPolicy === "summarize") {
queue.summarySources.push(...dropped);
return;
}
for (const item of dropped) {
completeFollowupRunLifecycle(item);
}
},
});
if (queue.dropPolicy === "summarize") {
const overflow = queue.summarySources.length - queue.summaryLines.length;
if (overflow > 0) {
const removed = queue.summarySources.splice(0, overflow);
for (const item of removed) {
const contextKey = resolveFollowupDeliveryContextKey(item);
const lastElision = queue.summaryElisions.at(-1);
if (lastElision?.contextKey === contextKey) {
lastElision.count += 1;
lastElision.source = createOverflowSummaryRetrySource(item);
lastElision.sourceRefs.add(item);
lastElision.allRoomEvents =
lastElision.allRoomEvents && item.currentInboundEventKind === "room_event";
} else {
if (queue.summaryElisions.length >= queue.cap) {
const evicted = queue.summaryElisions.shift();
if (evicted) {
queue.evictedSummaryCount += evicted.count;
completeFollowupRunLifecycle(evicted.source);
}
}
queue.summaryElisions.push({
contextKey,
count: 1,
source: createOverflowSummaryRetrySource(item),
sourceRefs: new WeakSet([item]),
allRoomEvents: item.currentInboundEventKind === "room_event",
});
}
completeFollowupRunLifecycle(item);
}
}
}
if (!shouldEnqueue) {
return false;
}
run.queueAbortSignal = queue.abortController.signal;
queue.items.push(run);
markFollowupRunEnqueued(run);
if (recentMessageIdKey) {
RECENT_QUEUE_MESSAGE_IDS.check(recentMessageIdKey);
}
if (runFollowup) {
rememberFollowupDrainCallback(key, runFollowup);
}
// If drain finished and deleted the queue before this item arrived, a new queue
// object was created (draining: false) but nobody scheduled a drain for it.
// Use the cached callback to restart the drain now.
if (restartIfIdle && !queue.draining) {
kickFollowupDrainIfIdle(key);
}
return true;
}
export function getFollowupQueueDepth(key: string): number {
const queue = getExistingFollowupQueue(key);
if (!queue) {
return 0;
}
return queue.items.length;
}
export function resetRecentQueuedMessageIdDedupe(): void {
RECENT_QUEUE_MESSAGE_IDS.clear();
}