Files
openclaw/src/auto-reply/reply/queue/state.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

216 lines
6.3 KiB
TypeScript

// Tracks queue state for active, pending, and recently deduped reply runs.
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { resolveGlobalMap } from "../../../shared/global-singleton.js";
import { applyQueueRuntimeSettings } from "../../../utils/queue-helpers.js";
import {
completeFollowupRunLifecycle,
type FollowupRun,
type QueueDropPolicy,
type QueueMode,
type QueueSettings,
} from "./types.js";
export type FollowupQueueState = {
abortController: AbortController;
items: FollowupRun[];
draining: boolean;
lastEnqueuedAt: number;
mode: QueueMode;
debounceMs: number;
cap: number;
dropPolicy: QueueDropPolicy;
droppedCount: number;
summaryLines: string[];
summarySources: FollowupRun[];
summaryElisions: Array<{
contextKey: string;
count: number;
source: FollowupRun;
sourceRefs: WeakSet<FollowupRun>;
allRoomEvents: boolean;
}>;
evictedSummaryCount: number;
lastRun?: FollowupRun["run"];
};
export const DEFAULT_QUEUE_DEBOUNCE_MS = 500;
export const DEFAULT_QUEUE_CAP = 20;
export const DEFAULT_QUEUE_DROP: QueueDropPolicy = "summarize";
/**
* Share followup queues across bundled chunks so busy-session enqueue/drain
* logic observes one queue registry per process.
*/
const FOLLOWUP_QUEUES_KEY = Symbol.for("openclaw.followupQueues");
export const FOLLOWUP_QUEUES = resolveGlobalMap<string, FollowupQueueState>(FOLLOWUP_QUEUES_KEY);
export function getExistingFollowupQueue(key: string): FollowupQueueState | undefined {
const cleaned = key.trim();
if (!cleaned) {
return undefined;
}
return FOLLOWUP_QUEUES.get(cleaned);
}
function trimSummaryElisionsToCap(queue: FollowupQueueState): void {
while (queue.summaryElisions.length > queue.cap) {
const evicted = queue.summaryElisions.shift();
if (!evicted) {
return;
}
queue.evictedSummaryCount += evicted.count;
completeFollowupRunLifecycle(evicted.source);
}
}
export function getFollowupQueue(key: string, settings: QueueSettings): FollowupQueueState {
const existing = FOLLOWUP_QUEUES.get(key);
if (existing) {
applyQueueRuntimeSettings({
target: existing,
settings,
});
trimSummaryElisionsToCap(existing);
return existing;
}
const created: FollowupQueueState = {
abortController: new AbortController(),
items: [],
draining: false,
lastEnqueuedAt: 0,
mode: settings.mode,
debounceMs:
typeof settings.debounceMs === "number"
? Math.max(0, settings.debounceMs)
: DEFAULT_QUEUE_DEBOUNCE_MS,
cap:
typeof settings.cap === "number" && settings.cap > 0
? Math.floor(settings.cap)
: DEFAULT_QUEUE_CAP,
dropPolicy: settings.dropPolicy ?? DEFAULT_QUEUE_DROP,
droppedCount: 0,
summaryLines: [],
summarySources: [],
summaryElisions: [],
evictedSummaryCount: 0,
};
applyQueueRuntimeSettings({
target: created,
settings,
});
FOLLOWUP_QUEUES.set(key, created);
return created;
}
export function clearFollowupQueue(key: string): number {
const cleaned = key.trim();
const queue = getExistingFollowupQueue(cleaned);
if (!queue) {
return 0;
}
queue.abortController.abort();
const cleared = queue.items.length + queue.droppedCount;
for (const item of queue.items) {
completeFollowupRunLifecycle(item);
}
for (const item of queue.summarySources) {
completeFollowupRunLifecycle(item);
}
for (const entry of queue.summaryElisions) {
completeFollowupRunLifecycle(entry.source);
}
queue.items.length = 0;
queue.droppedCount = 0;
queue.summaryLines = [];
queue.summarySources = [];
queue.summaryElisions = [];
queue.evictedSummaryCount = 0;
queue.lastRun = undefined;
queue.lastEnqueuedAt = 0;
FOLLOWUP_QUEUES.delete(cleaned);
return cleared;
}
export function refreshQueuedFollowupSession(params: {
key: string;
previousSessionId?: string;
nextSessionId?: string;
nextSessionFile?: string;
nextProvider?: string;
nextModel?: string;
nextModelOverrideSource?: "auto" | "user";
nextAuthProfileId?: string;
nextAuthProfileIdSource?: "auto" | "user";
}): void {
const cleaned = params.key.trim();
if (!cleaned) {
return;
}
const queue = getExistingFollowupQueue(cleaned);
if (!queue) {
return;
}
const shouldRewriteSession =
Boolean(params.previousSessionId) &&
Boolean(params.nextSessionId) &&
params.previousSessionId !== params.nextSessionId;
const shouldRewriteModelSelection =
typeof params.nextProvider === "string" ||
typeof params.nextModel === "string" ||
Object.hasOwn(params, "nextModelOverrideSource");
const shouldRewriteSelection =
shouldRewriteModelSelection ||
Object.hasOwn(params, "nextAuthProfileId") ||
Object.hasOwn(params, "nextAuthProfileIdSource");
if (!shouldRewriteSession && !shouldRewriteSelection) {
return;
}
const rewriteRun = (run?: FollowupRun["run"]) => {
if (!run) {
return;
}
if (shouldRewriteSession && run.sessionId === params.previousSessionId) {
run.sessionId = params.nextSessionId!;
const nextSessionFile = normalizeOptionalString(params.nextSessionFile);
if (nextSessionFile) {
run.sessionFile = nextSessionFile;
}
}
if (shouldRewriteSelection) {
if (typeof params.nextProvider === "string") {
run.provider = params.nextProvider;
}
if (typeof params.nextModel === "string") {
run.model = params.nextModel;
}
if (shouldRewriteModelSelection) {
delete run.hasAutoFallbackProvenance;
}
if (Object.hasOwn(params, "nextModelOverrideSource")) {
run.hasSessionModelOverride = Boolean(run.provider || run.model);
run.modelOverrideSource = params.nextModelOverrideSource;
}
if (Object.hasOwn(params, "nextAuthProfileId")) {
run.authProfileId = normalizeOptionalString(params.nextAuthProfileId);
}
if (Object.hasOwn(params, "nextAuthProfileIdSource")) {
run.authProfileIdSource = run.authProfileId ? params.nextAuthProfileIdSource : undefined;
}
}
};
rewriteRun(queue.lastRun);
for (const item of queue.items) {
rewriteRun(item.run);
}
for (const item of queue.summarySources) {
rewriteRun(item.run);
}
for (const entry of queue.summaryElisions) {
rewriteRun(entry.source.run);
}
}