Files
openclaw/src/shared/store-writer-queue.ts
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

192 lines
5.9 KiB
TypeScript

import { AsyncLocalStorage } from "node:async_hooks";
import { resolveGlobalSingleton } from "./global-singleton.js";
/** Pending exclusive store write plus the promise hooks for its caller. */
export type StoreWriterTask = {
/** Write operation to run once earlier tasks for the same store path finish. */
fn: () => Promise<unknown>;
/** Resolves the caller's promise with the write result. */
resolve: (value: unknown) => void;
/** Rejects the caller's promise with the write failure or test cleanup error. */
reject: (reason: unknown) => void;
};
/** Per-store-path FIFO queue that serializes file writes within one process. */
export type StoreWriterQueue = {
/** True while a drain loop owns this queue. */
running: boolean;
/** Writes waiting behind the active drain. */
pending: StoreWriterTask[];
/** Active drain promise, reused by waiters until the current batch settles. */
drainPromise: Promise<void> | null;
};
/** Store writer queues keyed by the canonical store path. */
type StoreWriterQueues = Map<string, StoreWriterQueue>;
type ActiveStoreWriter = {
active: boolean;
parent: ActiveStoreWriter | undefined;
queues: StoreWriterQueues;
storePath: string;
};
// Queue maps are often global singletons shared by separately bundled runtime
// chunks. Their reentrancy context must cross the same module boundary.
const activeStoreWriters = resolveGlobalSingleton(
Symbol.for("openclaw.activeStoreWriters"),
() => new AsyncLocalStorage<ActiveStoreWriter>(),
);
function isActiveStoreWriter(queues: StoreWriterQueues, storePath: string): boolean {
let active = activeStoreWriters.getStore();
while (active) {
if (active.active && active.queues === queues && active.storePath === storePath) {
return true;
}
active = active.parent;
}
return false;
}
async function runActiveStoreWriter<T>(
queues: StoreWriterQueues,
storePath: string,
fn: () => Promise<T>,
): Promise<T> {
const writer = { active: true, parent: activeStoreWriters.getStore(), queues, storePath };
try {
return await activeStoreWriters.run(writer, fn);
} finally {
writer.active = false;
}
}
function getOrCreateStoreWriterQueue(
queues: StoreWriterQueues,
storePath: string,
): StoreWriterQueue {
const existing = queues.get(storePath);
if (existing) {
return existing;
}
const created: StoreWriterQueue = { running: false, pending: [], drainPromise: null };
queues.set(storePath, created);
return created;
}
async function drainStoreWriterQueue(queues: StoreWriterQueues, storePath: string): Promise<void> {
const queue = queues.get(storePath);
if (!queue) {
return;
}
if (queue.drainPromise) {
await queue.drainPromise;
return;
}
queue.running = true;
queue.drainPromise = (async () => {
try {
while (queue.pending.length > 0) {
const task = queue.pending.shift();
if (!task) {
continue;
}
let result: unknown;
let failed: unknown;
let hasFailure = false;
try {
result = await task.fn();
} catch (err) {
hasFailure = true;
failed = err;
}
if (hasFailure) {
task.reject(failed);
continue;
}
task.resolve(result);
}
} finally {
queue.running = false;
queue.drainPromise = null;
if (queue.pending.length === 0) {
queues.delete(storePath);
} else {
// Late enqueues after the loop drained run in a fresh microtask so this
// drainPromise can settle before the next writer batch starts.
queueMicrotask(() => {
void drainStoreWriterQueue(queues, storePath);
});
}
}
})();
await queue.drainPromise;
}
/** Runs one store write after prior writes for the same store path have finished. */
export async function runQueuedStoreWrite<T>(params: {
queues: StoreWriterQueues;
storePath: string;
label: string;
fn: () => Promise<T>;
reentrant?: boolean;
}): Promise<T> {
if (!params.storePath || typeof params.storePath !== "string") {
throw new Error(
`${params.label}: storePath must be a non-empty string, got ${JSON.stringify(
params.storePath,
)}`,
);
}
// Explicit reentrancy keeps one logical read/decide/write section on the
// active lane; ordinary async children must queue behind the current writer.
if (params.reentrant === true && isActiveStoreWriter(params.queues, params.storePath)) {
return await params.fn();
}
const queue = getOrCreateStoreWriterQueue(params.queues, params.storePath);
return await new Promise<T>((resolve, reject) => {
const task: StoreWriterTask = {
fn: async () => await runActiveStoreWriter(params.queues, params.storePath, params.fn),
resolve: (value) => resolve(value as T),
reject,
};
queue.pending.push(task);
void drainStoreWriterQueue(params.queues, params.storePath);
});
}
/** Rejects pending queued writes and clears queue state for test cleanup. */
export function clearStoreWriterQueuesForTest(queues: StoreWriterQueues, message: string): void {
for (const queue of queues.values()) {
for (const task of queue.pending) {
task.reject(new Error(message));
}
}
queues.clear();
}
/** Waits for active drains to settle while rejecting still-pending test writes. */
export async function drainStoreWriterQueuesForTest(
queues: StoreWriterQueues,
message: string,
): Promise<void> {
while (queues.size > 0) {
const activeQueues = [...queues.values()];
for (const queue of activeQueues) {
for (const task of queue.pending) {
task.reject(new Error(message));
}
queue.pending.length = 0;
}
const activeDrains = activeQueues.flatMap((queue) =>
queue.drainPromise ? [queue.drainPromise] : [],
);
if (activeDrains.length === 0) {
queues.clear();
return;
}
await Promise.allSettled(activeDrains);
}
}