fix: repair stale agent run context sweep

This commit is contained in:
Josh Lehman
2026-04-09 13:39:57 -07:00
parent 08daee7f75
commit c8123c1865
3 changed files with 44 additions and 2 deletions

View File

@@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai
- Gateway/tailscale: start Tailscale exposure and the gateway update check before awaiting channel and plugin sidecar startup so remote operators are not locked out when startup sidecars stall.
- QQBot/streaming: make block streaming configurable per QQ bot account via `streaming.mode` (`"partial"` | `"off"`, default `"partial"`) instead of hardcoding it off, so responses can be delivered incrementally. (#63746)
- Dreaming/gateway: require `operator.admin` for persistent `/dreaming on|off` changes and treat missing gateway client scopes as unprivileged instead of silently allowing config writes. (#63872) Thanks @mbelinky.
- Gateway/agents: fix stale run-context TTL cleanup so the new maintenance sweep compiles and resets orphaned run sequence state correctly. (#52731) thanks @artwalker
## 2026.4.9

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, test } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import {
clearAgentRunContext,
emitAgentEvent,
@@ -7,6 +7,7 @@ import {
registerAgentRunContext,
resetAgentEventsForTest,
resetAgentRunContextForTest,
sweepStaleRunContexts,
} from "./agent-events.js";
type AgentEventsModule = typeof import("./agent-events.js");
@@ -194,4 +195,40 @@ describe("agent-events sequencing", () => {
first.resetAgentEventsForTest();
});
test("sweeps stale run contexts and clears their sequence state", async () => {
const stop = vi.spyOn(Date, "now");
stop.mockReturnValue(100);
registerAgentRunContext("run-stale", { sessionKey: "session-stale", registeredAt: 100 });
registerAgentRunContext("run-active", { sessionKey: "session-active", registeredAt: 100 });
stop.mockReturnValue(200);
emitAgentEvent({ runId: "run-stale", stream: "assistant", data: { text: "stale" } });
stop.mockReturnValue(900);
emitAgentEvent({ runId: "run-active", stream: "assistant", data: { text: "active" } });
stop.mockReturnValue(1_000);
expect(sweepStaleRunContexts(500)).toBe(1);
expect(getAgentRunContext("run-stale")).toBeUndefined();
expect(getAgentRunContext("run-active")).toMatchObject({ sessionKey: "session-active" });
const seen: Array<{ runId: string; seq: number }> = [];
const unsubscribe = onAgentEvent((evt) => {
if (evt.runId === "run-stale" || evt.runId === "run-active") {
seen.push({ runId: evt.runId, seq: evt.seq });
}
});
emitAgentEvent({ runId: "run-stale", stream: "assistant", data: { text: "restarted" } });
emitAgentEvent({ runId: "run-active", stream: "assistant", data: { text: "continued" } });
unsubscribe();
stop.mockRestore();
expect(seen).toEqual([
{ runId: "run-stale", seq: 1 },
{ runId: "run-active", seq: 2 },
]);
});
});

View File

@@ -140,7 +140,10 @@ export function registerAgentRunContext(runId: string, context: AgentRunContext)
const state = getAgentEventState();
const existing = state.runContextById.get(runId);
if (!existing) {
state.runContextById.set(runId, { ...context, registeredAt: context.registeredAt ?? Date.now() });
state.runContextById.set(runId, {
...context,
registeredAt: context.registeredAt ?? Date.now(),
});
return;
}
if (context.sessionKey && existing.sessionKey !== context.sessionKey) {
@@ -171,6 +174,7 @@ export function clearAgentRunContext(runId: string) {
* Guards against orphaned entries when lifecycle "end"/"error" events are missed.
*/
export function sweepStaleRunContexts(maxAgeMs = 30 * 60 * 1000): number {
const state = getAgentEventState();
const now = Date.now();
let swept = 0;
for (const [runId, ctx] of state.runContextById.entries()) {