mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-17 13:00:48 +00:00
fix(runtime): duplicate messages, share singleton state across bundled chunks (#43683)
* Tests: add fresh module import helper * Process: share command queue runtime state * Agents: share embedded run runtime state * Reply: share followup queue runtime state * Reply: share followup drain callback state * Reply: share queued message dedupe state * Reply: share inbound dedupe state * Tests: cover shared command queue runtime state * Tests: cover shared embedded run runtime state * Tests: cover shared followup queue runtime state * Tests: cover shared inbound dedupe state * Tests: cover shared Slack thread participation state * Slack: share sent thread participation state * Tests: document fresh import helper * Telegram: share draft stream runtime state * Tests: cover shared Telegram draft stream state * Telegram: share sent message cache state * Tests: cover shared Telegram sent message cache * Telegram: share thread binding runtime state * Tests: cover shared Telegram thread binding state * Tests: avoid duplicate shared queue reset * refactor(runtime): centralize global singleton access * refactor(runtime): preserve undefined global singleton values * test(runtime): cover undefined global singleton values --------- Co-authored-by: Nimrod Gutman <nimrod.gutman@gmail.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { diagnosticLogger as diag, logLaneDequeue, logLaneEnqueue } from "../logging/diagnostic.js";
|
||||
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
|
||||
import { CommandLane } from "./lanes.js";
|
||||
/**
|
||||
* Dedicated error type thrown when a queued command is rejected because
|
||||
@@ -23,9 +24,6 @@ export class GatewayDrainingError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Set while gateway is draining for restart; new enqueues are rejected.
|
||||
let gatewayDraining = false;
|
||||
|
||||
// Minimal in-process queue to serialize command executions.
|
||||
// Default lane ("main") preserves the existing behavior. Additional lanes allow
|
||||
// low-risk parallelism (e.g. cron jobs) without interleaving stdin / logs for
|
||||
@@ -49,11 +47,20 @@ type LaneState = {
|
||||
generation: number;
|
||||
};
|
||||
|
||||
const lanes = new Map<string, LaneState>();
|
||||
let nextTaskId = 1;
|
||||
/**
|
||||
* Keep queue runtime state on globalThis so every bundled entry/chunk shares
|
||||
* the same lanes, counters, and draining flag in production builds.
|
||||
*/
|
||||
const COMMAND_QUEUE_STATE_KEY = Symbol.for("openclaw.commandQueueState");
|
||||
|
||||
const queueState = resolveGlobalSingleton(COMMAND_QUEUE_STATE_KEY, () => ({
|
||||
gatewayDraining: false,
|
||||
lanes: new Map<string, LaneState>(),
|
||||
nextTaskId: 1,
|
||||
}));
|
||||
|
||||
function getLaneState(lane: string): LaneState {
|
||||
const existing = lanes.get(lane);
|
||||
const existing = queueState.lanes.get(lane);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
@@ -65,7 +72,7 @@ function getLaneState(lane: string): LaneState {
|
||||
draining: false,
|
||||
generation: 0,
|
||||
};
|
||||
lanes.set(lane, created);
|
||||
queueState.lanes.set(lane, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
@@ -105,7 +112,7 @@ function drainLane(lane: string) {
|
||||
);
|
||||
}
|
||||
logLaneDequeue(lane, waitedMs, state.queue.length);
|
||||
const taskId = nextTaskId++;
|
||||
const taskId = queueState.nextTaskId++;
|
||||
const taskGeneration = state.generation;
|
||||
state.activeTaskIds.add(taskId);
|
||||
void (async () => {
|
||||
@@ -148,7 +155,7 @@ function drainLane(lane: string) {
|
||||
* `GatewayDrainingError` instead of being silently killed on shutdown.
|
||||
*/
|
||||
export function markGatewayDraining(): void {
|
||||
gatewayDraining = true;
|
||||
queueState.gatewayDraining = true;
|
||||
}
|
||||
|
||||
export function setCommandLaneConcurrency(lane: string, maxConcurrent: number) {
|
||||
@@ -166,7 +173,7 @@ export function enqueueCommandInLane<T>(
|
||||
onWait?: (waitMs: number, queuedAhead: number) => void;
|
||||
},
|
||||
): Promise<T> {
|
||||
if (gatewayDraining) {
|
||||
if (queueState.gatewayDraining) {
|
||||
return Promise.reject(new GatewayDrainingError());
|
||||
}
|
||||
const cleaned = lane.trim() || CommandLane.Main;
|
||||
@@ -198,7 +205,7 @@ export function enqueueCommand<T>(
|
||||
|
||||
export function getQueueSize(lane: string = CommandLane.Main) {
|
||||
const resolved = lane.trim() || CommandLane.Main;
|
||||
const state = lanes.get(resolved);
|
||||
const state = queueState.lanes.get(resolved);
|
||||
if (!state) {
|
||||
return 0;
|
||||
}
|
||||
@@ -207,7 +214,7 @@ export function getQueueSize(lane: string = CommandLane.Main) {
|
||||
|
||||
export function getTotalQueueSize() {
|
||||
let total = 0;
|
||||
for (const s of lanes.values()) {
|
||||
for (const s of queueState.lanes.values()) {
|
||||
total += s.queue.length + s.activeTaskIds.size;
|
||||
}
|
||||
return total;
|
||||
@@ -215,7 +222,7 @@ export function getTotalQueueSize() {
|
||||
|
||||
export function clearCommandLane(lane: string = CommandLane.Main) {
|
||||
const cleaned = lane.trim() || CommandLane.Main;
|
||||
const state = lanes.get(cleaned);
|
||||
const state = queueState.lanes.get(cleaned);
|
||||
if (!state) {
|
||||
return 0;
|
||||
}
|
||||
@@ -242,9 +249,9 @@ export function clearCommandLane(lane: string = CommandLane.Main) {
|
||||
* `enqueueCommandInLane()` call (which may never come).
|
||||
*/
|
||||
export function resetAllLanes(): void {
|
||||
gatewayDraining = false;
|
||||
queueState.gatewayDraining = false;
|
||||
const lanesToDrain: string[] = [];
|
||||
for (const state of lanes.values()) {
|
||||
for (const state of queueState.lanes.values()) {
|
||||
state.generation += 1;
|
||||
state.activeTaskIds.clear();
|
||||
state.draining = false;
|
||||
@@ -264,7 +271,7 @@ export function resetAllLanes(): void {
|
||||
*/
|
||||
export function getActiveTaskCount(): number {
|
||||
let total = 0;
|
||||
for (const s of lanes.values()) {
|
||||
for (const s of queueState.lanes.values()) {
|
||||
total += s.activeTaskIds.size;
|
||||
}
|
||||
return total;
|
||||
@@ -283,7 +290,7 @@ export function waitForActiveTasks(timeoutMs: number): Promise<{ drained: boolea
|
||||
const POLL_INTERVAL_MS = 50;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const activeAtStart = new Set<number>();
|
||||
for (const state of lanes.values()) {
|
||||
for (const state of queueState.lanes.values()) {
|
||||
for (const taskId of state.activeTaskIds) {
|
||||
activeAtStart.add(taskId);
|
||||
}
|
||||
@@ -297,7 +304,7 @@ export function waitForActiveTasks(timeoutMs: number): Promise<{ drained: boolea
|
||||
}
|
||||
|
||||
let hasPending = false;
|
||||
for (const state of lanes.values()) {
|
||||
for (const state of queueState.lanes.values()) {
|
||||
for (const taskId of state.activeTaskIds) {
|
||||
if (activeAtStart.has(taskId)) {
|
||||
hasPending = true;
|
||||
|
||||
Reference in New Issue
Block a user