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.
216 lines
6.3 KiB
TypeScript
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);
|
|
}
|
|
}
|