fix(plugin-sdk): preserve system event snapshot identity (#120984)

Assign queue-owned opaque IDs to modern system-event snapshots so copied stale snapshots cannot acknowledge a later identical event. Keep structural matching only for shipped legacy ID-less consumers.
This commit is contained in:
Peter Steinberger
2026-08-09 02:05:36 -07:00
committed by GitHub
parent 00854a7002
commit 0e56fce87b
7 changed files with 102 additions and 17 deletions
+11 -3
View File
@@ -458,12 +458,12 @@ For local media read policy, import `getAgentScopedMediaLocalRoots(...)` or
<Step title="Replace broad infra-runtime imports">
`openclaw/plugin-sdk/infra-runtime` still exists for external
compatibility, but new code should import the focused surface it actually
compatibility, but new code should use the supported surface it actually
needs:
| Need | Import |
| Need | Replacement |
| --- | --- |
| System event queue helpers | `openclaw/plugin-sdk/system-event-runtime` |
| New system event producers | `api.runtime.system.enqueueSystemEvent` |
| Heartbeat wake, event, and visibility helpers | `openclaw/plugin-sdk/heartbeat-runtime` |
| Pending delivery queue drain | `openclaw/plugin-sdk/delivery-queue-runtime` |
| Channel activity telemetry | `openclaw/plugin-sdk/channel-activity-runtime` |
@@ -483,6 +483,14 @@ For local media read policy, import `getAgentScopedMediaLocalRoots(...)` or
| Process-local async lock | `openclaw/plugin-sdk/async-lock-runtime` |
| File locks | `openclaw/plugin-sdk/file-lock` |
System event snapshot inspection and consume helpers remain available only
through the deprecated `openclaw/plugin-sdk/infra-runtime` compatibility
surface; there is no modern public replacement. Current snapshots carry an
opaque `id` for one queued occurrence. Preserve it through copies and
serialization when returning a snapshot to consume. Legacy ID-less callers
retain structural matching, which can be ambiguous after queue churn. Do
not treat the ID as persistent or valid across restarts.
File-lock nesting is owner-scoped. Pass the same `reentrantOwner` only for
nested acquisitions in one logical operation; omit it for ordinary locking.
Never use a process-wide constant, because unrelated work would incorrectly
+1 -1
View File
@@ -720,7 +720,7 @@ two-party event loops that do not go through the shared inbound reply runner.
System-level utilities.
```typescript
await api.runtime.system.enqueueSystemEvent(event);
const accepted = api.runtime.system.enqueueSystemEvent(text, options);
api.runtime.system.requestHeartbeat({
source: "other",
intent: "event",
+2 -2
View File
@@ -302,10 +302,10 @@ Use `isLoopbackHost(host)` when a plugin must accept only the local machine. It
| `plugin-sdk/expect-runtime` | Private-local after July 2026; Required-value assertion helper for provable runtime invariants |
| `plugin-sdk/number-runtime` | Private-local after July 2026; Numeric coercion helper |
| `plugin-sdk/secure-random-runtime` | Private-local after July 2026; Secure token/UUID helpers |
| `plugin-sdk/system-event-runtime` | Private-local after July 2026; System event queue helpers |
| `plugin-sdk/system-event-runtime` | Private-local after July 2026; Narrow system event enqueue/peek helpers |
| `plugin-sdk/transport-ready-runtime` | Private-local after July 2026; Transport readiness wait helper |
| `plugin-sdk/exec-approvals-runtime` | Private-local after July 2026; Exec approval policy file helpers without the broad infra-runtime barrel |
| `plugin-sdk/infra-runtime` | Deprecated compatibility shim; use the focused runtime subpaths above |
| `plugin-sdk/infra-runtime` | Deprecated compatibility shim; use injected runtime APIs or documented typed-public subpaths |
| `plugin-sdk/collection-runtime` | Small bounded cache helpers |
| `plugin-sdk/diagnostic-runtime` | Diagnostic flag, event, trace-context, and low-cardinality dimension normalization helpers |
| `plugin-sdk/error-runtime` | Error graph, formatting, unknown-value coercion, shared error classification helpers, `PlatformMessageNotDispatchedError`, `isApprovalNotFoundError` |
+66 -6
View File
@@ -16,12 +16,14 @@ import {
consumeSystemEventEntries,
drainSystemEventEntries,
enqueueSystemEvent,
enqueueSystemEventEntry,
hasSystemEvents,
isSystemEventContextChanged,
peekSystemEventEntries,
peekSystemEvents,
resetSystemEventsForTest,
resolveSystemEventDeliveryContext,
type SystemEvent,
} from "./system-events.js";
type SystemEventsModule = typeof import("./system-events.js");
@@ -218,6 +220,55 @@ describe("system events (session routing)", () => {
expect(peekSystemEvents(key)).toEqual(["second"]);
});
it.each([
{
name: "prefix consume with object spread",
consume: consumeSystemEventEntries,
copy: (event: SystemEvent): SystemEvent => ({ ...event }),
},
{
name: "selected consume with structuredClone",
consume: consumeSelectedSystemEventEntries,
copy: (event: SystemEvent): SystemEvent => structuredClone(event),
},
{
name: "prefix consume with JSON round trip",
consume: consumeSystemEventEntries,
// oxlint-disable-next-line unicorn/prefer-structured-clone -- This case exercises JSON transport.
copy: (event: SystemEvent): SystemEvent => JSON.parse(JSON.stringify(event)) as SystemEvent,
},
])("does not consume an identical successor from a stale copy: $name", ({ consume, copy }) => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-08-08T00:00:00Z"));
const key = "agent:main:test-stale-copied-snapshot";
const options = {
sessionKey: key,
contextKey: "build:123",
deliveryContext: { channel: "telegram", to: "-100123", threadId: "42" },
};
const original = expectDefined(
enqueueSystemEventEntry("Build completed", options),
"original event",
);
const staleCopy = copy(original);
expect(staleCopy.id).toBe(original.id);
expect(consume(key, [original]).map((event) => event.id)).toEqual([original.id]);
const successor = expectDefined(
enqueueSystemEventEntry("Build completed", options),
"successor event",
);
expect(successor.id).not.toBe(original.id);
expect(successor).toEqual({ ...original, id: successor.id });
expect(consume(key, [staleCopy])).toStrictEqual([]);
expect(peekSystemEventEntries(key).map((event) => event.id)).toEqual([successor.id]);
expect(consume(key, [successor]).map((event) => event.id)).toEqual([successor.id]);
expect(peekSystemEventEntries(key)).toStrictEqual([]);
});
it("matches consumed delivery contexts through normalized route identity", () => {
const key = "agent:main:test-consume-route-context";
enqueueSystemEvent("first", {
@@ -228,13 +279,22 @@ describe("system events (session routing)", () => {
threadId: 42.9,
},
});
const inspected = peekSystemEventEntries(key);
expectDefined(
expectDefined(inspected[0], "inspected event").deliveryContext,
"inspected delivery context",
).threadId = "42";
const current = expectDefined(peekSystemEventEntries(key)[0], "queued event");
const legacyCopy: SystemEvent = {
text: current.text,
ts: current.ts,
contextKey: current.contextKey,
deliveryContext: {
channel: current.deliveryContext?.channel,
to: current.deliveryContext?.to,
threadId: "42",
},
};
expect(legacyCopy).not.toHaveProperty("id");
expect(consumeSystemEventEntries(key, inspected).map((entry) => entry.text)).toEqual(["first"]);
expect(consumeSystemEventEntries(key, [legacyCopy]).map((entry) => entry.text)).toEqual([
"first",
]);
expect(peekSystemEvents(key)).toStrictEqual([]);
});
+20 -3
View File
@@ -14,6 +14,7 @@ import {
normalizeDeliveryContext,
} from "../utils/delivery-context.shared.js";
import type { DeliveryContext } from "../utils/delivery-context.types.js";
import { generateSecureUuid } from "./secure-random.js";
import {
cloneSystemEventOwner,
recordSystemEventOwner,
@@ -22,6 +23,12 @@ import {
} from "./system-event-ownership.js";
export type SystemEvent = {
/**
* OpenClaw-assigned opaque identity for one queued occurrence. Preserve it when returning a
* snapshot to consume. It changes on replacement or re-enqueue; optional only for legacy
* ID-less compatibility.
*/
id?: string;
text: string;
ts: number;
contextKey?: string | null;
@@ -148,6 +155,7 @@ function enqueueOwnedSystemEventEntry(
entry.lastContextKey = normalizedContextKey;
}
const event: SystemEvent = {
id: generateSecureUuid(),
text: cleaned,
ts: Date.now(),
contextKey: normalizedContextKey,
@@ -220,6 +228,7 @@ function replaceSystemEventEntry(text: string, options: SystemEventOptions): Sys
!areDeliveryContextsEqual(event.deliveryContext, normalizedDeliveryContext),
);
const event: SystemEvent = {
id: generateSecureUuid(),
text: cleaned,
ts: Date.now(),
contextKey: normalizedContextKey,
@@ -248,7 +257,7 @@ function isDuplicateSystemEvent(
);
}
function areSystemEventsEqual(left: SystemEvent, right: SystemEvent): boolean {
function areLegacySystemEventsEqual(left: SystemEvent, right: SystemEvent): boolean {
return (
left.text === right.text &&
left.ts === right.ts &&
@@ -258,6 +267,14 @@ function areSystemEventsEqual(left: SystemEvent, right: SystemEvent): boolean {
);
}
function matchesConsumedSystemEvent(queued: SystemEvent, consumed: SystemEvent): boolean {
if (consumed.id !== undefined) {
// Queue-owned IDs govern modern consumption; only legacy ID-less snapshots use structure.
return queued.id === consumed.id;
}
return areLegacySystemEventsEqual(queued, consumed);
}
function resetQueueState(key: string, entry: SessionQueue) {
if (entry.queue.length === 0) {
entry.lastContextKey = null;
@@ -286,7 +303,7 @@ export function consumeSystemEventEntries(
if (
consumedEntries.length > entry.queue.length ||
!consumedEntries.every((event, index) =>
areSystemEventsEqual(expectDefined(entry.queue[index], "queue entry at index"), event),
matchesConsumedSystemEvent(expectDefined(entry.queue[index], "queue entry at index"), event),
)
) {
// A keyed replacement may remove one inspected entry while a prompt is in flight.
@@ -310,7 +327,7 @@ export function consumeSelectedSystemEventEntries(
}
const removed: SystemEvent[] = [];
for (const consumed of consumedEntries) {
const index = entry.queue.findIndex((event) => areSystemEventsEqual(event, consumed));
const index = entry.queue.findIndex((event) => matchesConsumedSystemEvent(event, consumed));
if (index === -1) {
continue;
}
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* @deprecated Compatibility shim only. Keep old plugins working, but do not
* add new imports here and do not use this subpath from repo code.
* Prefer focused openclaw/plugin-sdk/<domain> runtime subpaths instead.
* Prefer injected runtime APIs or documented typed-public subpaths instead.
*/
export * from "./delivery-queue-runtime.js";
+1 -1
View File
@@ -1,4 +1,4 @@
// System event queue helpers without the broad infra-runtime barrel.
// Narrow system event enqueue/peek helper surface without the broad infra-runtime barrel.
export {
enqueueSystemEvent,