mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
6df0fb818d
* 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.
203 lines
6.6 KiB
TypeScript
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();
|
|
}
|