Files
openclaw/src/gateway/node-runtime-state.ts
xingzhou 186c25f7f7 fix(gateway): avoid stale wake delays after node re-pairing (#109647)
* fix(gateway): clear wake state on node removal

* fix(gateway): invalidate wake flows on node removal

* fix(gateway): preserve active wakes on disconnect

* fix(gateway): share node removal runtime cleanup

* fix(gateway): clean up pending node work reliably

* fix(gateway): bind node work to pairing generations

* fix(gateway): fence stale node pairing sessions

* fix(gateway): tighten node pairing ownership

* fix(gateway): close node pairing mutation races

* fix(gateway): bind node sessions to pairing generation

* fix(gateway): harden node pairing generation guards

* fix(gateway): close node pairing generation races

* fix(gateway): guard APNs transport ownership

* fix(gateway): revoke reapproved node transports

* fix(gateway): guard retired node lifecycles

* fix(gateway): preserve pending work across reconnects

* fix(gateway): bind node subscriptions to pairing generations

* fix(gateway): settle subscription fanout failures

* fix(gateway): fence pairing generation ownership

* fix(gateway): fence asynchronous node events

* fix(gateway): fence watch results and voice wake fanout

* fix(gateway): guard voice wake generation fanout

* fix(gateway): carry authenticated snapshot generation

* fix(gateway): bind node state to pairing generation

* fix(gateway): align generation guards with current base

* fix(gateway): enforce pairing-owned node lifecycle

* fix(gateway): fence detached voice session writes

* chore(deps): bump proxyline to 0.3.4

* fix(gateway): close pairing lifecycle races

Co-authored-by: zhang-guiping <zhang.guiping@xydigit.com>

* fix(gateway): distinguish pending action mutation

Co-authored-by: zhang-guiping <zhang.guiping@xydigit.com>

* test(gateway): align lifecycle coverage

Co-authored-by: zhang-guiping <zhang.guiping@xydigit.com>

* test(skills): split remote reconciliation coverage

Co-authored-by: zhang-guiping <zhang.guiping@xydigit.com>

* style: format pairing lifecycle changes

Co-authored-by: zhang-guiping <zhang.guiping@xydigit.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-21 05:10:08 -07:00

186 lines
5.1 KiB
TypeScript

// Process-local node state shared by node and full-device pairing removal.
import { randomUUID } from "node:crypto";
import { removeRemoteNodeInfo } from "../skills/runtime/remote.js";
import { clearNodePendingWork } from "./node-pending-work.js";
import type { NodeRegistry } from "./node-registry.js";
import { invalidateNodeWakeState } from "./node-wake-state.js";
export type PendingNodeAction = {
id: string;
nodeId: string;
pairingGeneration: string;
command: string;
paramsJSON?: string;
idempotencyKey: string;
enqueuedAtMs: number;
};
const pendingNodeActionsById = new Map<string, PendingNodeAction[]>();
function prunePendingNodeActions(params: {
nodeId: string;
nowMs: number;
ttlMs: number;
pairingGeneration?: string;
}): PendingNodeAction[] {
const queue = pendingNodeActionsById.get(params.nodeId) ?? [];
const minTimestampMs = params.nowMs - params.ttlMs;
const live = queue.filter((entry) => entry.enqueuedAtMs >= minTimestampMs);
if (live.length === 0) {
pendingNodeActionsById.delete(params.nodeId);
return [];
}
pendingNodeActionsById.set(params.nodeId, live);
return params.pairingGeneration
? live.filter((entry) => entry.pairingGeneration === params.pairingGeneration)
: live;
}
export function replacePendingNodeActionsForGeneration(params: {
nodeId: string;
pairingGeneration: string;
replacement: PendingNodeAction[];
ttlMs: number;
nowMs?: number;
}): void {
const live = prunePendingNodeActions({
nodeId: params.nodeId,
nowMs: params.nowMs ?? Date.now(),
ttlMs: params.ttlMs,
});
const next = [
...live.filter((entry) => entry.pairingGeneration !== params.pairingGeneration),
...params.replacement,
];
if (next.length === 0) {
pendingNodeActionsById.delete(params.nodeId);
return;
}
pendingNodeActionsById.set(params.nodeId, next);
}
export function enqueuePendingNodeAction(params: {
nodeId: string;
pairingGeneration: string;
command: string;
paramsJSON?: string;
idempotencyKey: string;
ttlMs: number;
maxPerNode: number;
nowMs?: number;
}): { action: PendingNodeAction; created: boolean } {
const nowMs = params.nowMs ?? Date.now();
const queue = prunePendingNodeActions({
nodeId: params.nodeId,
nowMs,
ttlMs: params.ttlMs,
pairingGeneration: params.pairingGeneration,
});
const existing = queue.find((entry) => entry.idempotencyKey === params.idempotencyKey);
if (existing) {
return { action: existing, created: false };
}
const action: PendingNodeAction = {
id: randomUUID(),
nodeId: params.nodeId,
pairingGeneration: params.pairingGeneration,
command: params.command,
paramsJSON: params.paramsJSON,
idempotencyKey: params.idempotencyKey,
enqueuedAtMs: nowMs,
};
queue.push(action);
if (queue.length > params.maxPerNode) {
queue.splice(0, queue.length - params.maxPerNode);
}
replacePendingNodeActionsForGeneration({
nodeId: params.nodeId,
pairingGeneration: params.pairingGeneration,
replacement: queue,
ttlMs: params.ttlMs,
nowMs,
});
return { action, created: true };
}
export function listPendingNodeActions(params: {
nodeId: string;
pairingGeneration?: string;
ttlMs: number;
nowMs?: number;
}): PendingNodeAction[] {
return prunePendingNodeActions({
nodeId: params.nodeId,
nowMs: params.nowMs ?? Date.now(),
ttlMs: params.ttlMs,
pairingGeneration: params.pairingGeneration,
});
}
export function acknowledgePendingNodeActions(params: {
nodeId: string;
pairingGeneration: string;
ids: readonly string[];
ttlMs: number;
}): PendingNodeAction[] {
const pending = prunePendingNodeActions({
nodeId: params.nodeId,
pairingGeneration: params.pairingGeneration,
nowMs: Date.now(),
ttlMs: params.ttlMs,
});
if (params.ids.length === 0) {
return pending;
}
const ids = new Set(params.ids);
const remaining = pending.filter((entry) => !ids.has(entry.id));
replacePendingNodeActionsForGeneration({
...params,
replacement: remaining,
});
return remaining;
}
export function removePendingNodeAction(params: {
nodeId: string;
pairingGeneration: string;
actionId: string;
ttlMs: number;
}): void {
const pending = prunePendingNodeActions({
nodeId: params.nodeId,
pairingGeneration: params.pairingGeneration,
nowMs: Date.now(),
ttlMs: params.ttlMs,
});
const remaining = pending.filter((entry) => entry.id !== params.actionId);
if (remaining.length === pending.length) {
return;
}
replacePendingNodeActionsForGeneration({
...params,
replacement: remaining,
});
}
function clearPendingNodeActions(nodeId: string): void {
pendingNodeActionsById.delete(nodeId);
}
export function clearRemovedNodeRuntimeState(params: {
nodeId: string;
context: {
nodeRegistry: Pick<NodeRegistry, "updateSurface">;
};
}) {
clearPendingNodeActions(params.nodeId);
clearNodePendingWork(params.nodeId);
invalidateNodeWakeState(params.nodeId);
params.context.nodeRegistry.updateSurface(params.nodeId, {
caps: [],
commands: [],
permissions: undefined,
});
removeRemoteNodeInfo(params.nodeId);
}