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:
Vincent Koc
2026-03-12 14:59:27 -04:00
committed by GitHub
parent 08aa57a3de
commit 4ca84acf24
21 changed files with 569 additions and 38 deletions

View File

@@ -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;