mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-26 18:35:15 +00:00
fix(gateway): dedupe exec followup continuations (#82717)
Co-authored-by: Miya <miya@Miyas-Mac-mini.local>
This commit is contained in:
@@ -306,6 +306,35 @@ describe("startGatewayMaintenanceTimers", () => {
|
||||
stopMaintenanceTimers(timers);
|
||||
});
|
||||
|
||||
it("keeps active exec approval dedupe aliases past the normal ttl", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-22T00:00:00Z"));
|
||||
const { startGatewayMaintenanceTimers } = await import("./server-maintenance.js");
|
||||
const deps = createMaintenanceTimerDeps();
|
||||
const now = Date.now();
|
||||
const runId = "exec-approval-followup:req-active:nonce:retry-1";
|
||||
deps.chatAbortControllers.set(runId, createActiveRun("agent:main:main", "agent"));
|
||||
deps.dedupe.set("agent:exec-approval-followup:req-active", {
|
||||
ts: now - DEDUPE_TTL_MS - 1,
|
||||
ok: true,
|
||||
payload: { runId, status: "accepted" },
|
||||
});
|
||||
deps.dedupe.set("agent:exec-approval-followup:req-stale", {
|
||||
ts: now - DEDUPE_TTL_MS - 1,
|
||||
ok: true,
|
||||
payload: { runId: "exec-approval-followup:req-stale:nonce:retry-1", status: "accepted" },
|
||||
});
|
||||
|
||||
const timers = startGatewayMaintenanceTimers(deps);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
|
||||
expect(deps.dedupe.has("agent:exec-approval-followup:req-active")).toBe(true);
|
||||
expect(deps.dedupe.has("agent:exec-approval-followup:req-stale")).toBe(false);
|
||||
|
||||
stopMaintenanceTimers(timers);
|
||||
});
|
||||
|
||||
it("evicts dedupe overflow by oldest timestamp even after reinsertion", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-22T00:00:00Z"));
|
||||
|
||||
@@ -88,11 +88,29 @@ export function startGatewayMaintenanceTimers(params: {
|
||||
const dedupeCleanup = setInterval(() => {
|
||||
const AGENT_RUN_SEQ_MAX = 10_000;
|
||||
const now = Date.now();
|
||||
const isActiveRunDedupeKey = (key: string) => {
|
||||
const resolveDedupeRunId = (key: string, entry: DedupeEntry) => {
|
||||
if (!key.startsWith("agent:") && !key.startsWith("chat:")) {
|
||||
return undefined;
|
||||
}
|
||||
const keyRunId = key.slice(key.indexOf(":") + 1);
|
||||
if (keyRunId) {
|
||||
const directEntry = params.chatAbortControllers.get(keyRunId);
|
||||
if (directEntry) {
|
||||
return keyRunId;
|
||||
}
|
||||
}
|
||||
const payload = entry.payload;
|
||||
return payload && typeof payload === "object" && !Array.isArray(payload)
|
||||
? typeof (payload as { runId?: unknown }).runId === "string"
|
||||
? (payload as { runId: string }).runId.trim() || undefined
|
||||
: undefined
|
||||
: undefined;
|
||||
};
|
||||
const isActiveRunDedupeKey = (key: string, dedupeEntry: DedupeEntry) => {
|
||||
if (!key.startsWith("agent:") && !key.startsWith("chat:")) {
|
||||
return false;
|
||||
}
|
||||
const runId = key.slice(key.indexOf(":") + 1);
|
||||
const runId = resolveDedupeRunId(key, dedupeEntry);
|
||||
const entry = runId ? params.chatAbortControllers.get(runId) : undefined;
|
||||
if (!entry) {
|
||||
return false;
|
||||
@@ -100,7 +118,7 @@ export function startGatewayMaintenanceTimers(params: {
|
||||
return key.startsWith("agent:") ? entry.kind === "agent" : entry.kind !== "agent";
|
||||
};
|
||||
for (const [k, v] of params.dedupe) {
|
||||
if (isActiveRunDedupeKey(k)) {
|
||||
if (isActiveRunDedupeKey(k, v)) {
|
||||
continue;
|
||||
}
|
||||
if (now - v.ts > DEDUPE_TTL_MS) {
|
||||
@@ -110,7 +128,7 @@ export function startGatewayMaintenanceTimers(params: {
|
||||
if (params.dedupe.size > DEDUPE_MAX) {
|
||||
const excess = params.dedupe.size - DEDUPE_MAX;
|
||||
const oldestKeys = [...params.dedupe.entries()]
|
||||
.filter(([key]) => !isActiveRunDedupeKey(key))
|
||||
.filter(([key, entry]) => !isActiveRunDedupeKey(key, entry))
|
||||
.toSorted(([, left], [, right]) => left.ts - right.ts)
|
||||
.slice(0, excess)
|
||||
.map(([key]) => key);
|
||||
|
||||
@@ -1696,6 +1696,239 @@ describe("gateway agent handler", () => {
|
||||
expect(callArgs.bashElevated).toEqual(bashElevated);
|
||||
});
|
||||
|
||||
it("dedupes elevated exec approval followups across nonce idempotency keys", async () => {
|
||||
const bashElevated = {
|
||||
enabled: true,
|
||||
allowed: true,
|
||||
defaultLevel: "on" as const,
|
||||
};
|
||||
const firstRegistration = registerExecApprovalFollowupRuntimeHandoff({
|
||||
approvalId: "req-elevated-duplicate",
|
||||
sessionKey: "agent:main:telegram:direct:123",
|
||||
bashElevated,
|
||||
});
|
||||
const secondRegistration = registerExecApprovalFollowupRuntimeHandoff({
|
||||
approvalId: "req-elevated-duplicate",
|
||||
sessionKey: "agent:main:telegram:direct:123",
|
||||
bashElevated,
|
||||
});
|
||||
if (!firstRegistration || !secondRegistration) {
|
||||
throw new Error("expected runtime handoff ids");
|
||||
}
|
||||
mockMainSessionEntry({
|
||||
sessionId: "existing-session-id",
|
||||
lastChannel: "telegram",
|
||||
lastTo: "123",
|
||||
});
|
||||
mocks.agentCommand.mockImplementation(() => new Promise(() => {}));
|
||||
const context = makeContext();
|
||||
const agentCommandCallsBefore = mocks.agentCommand.mock.calls.length;
|
||||
|
||||
await invokeAgent(
|
||||
{
|
||||
message: "exec followup",
|
||||
sessionKey: "agent:main:telegram:direct:123",
|
||||
channel: "telegram",
|
||||
idempotencyKey: firstRegistration.idempotencyKey,
|
||||
internalRuntimeHandoffId: firstRegistration.handoffId,
|
||||
},
|
||||
{ reqId: "exec-followup-duplicate-1", client: backendGatewayClient(), context },
|
||||
);
|
||||
expect(mocks.agentCommand).toHaveBeenCalledTimes(agentCommandCallsBefore + 1);
|
||||
|
||||
const secondRespond = await invokeAgent(
|
||||
{
|
||||
message: "exec followup duplicate",
|
||||
sessionKey: "agent:main:telegram:direct:123",
|
||||
channel: "telegram",
|
||||
idempotencyKey: secondRegistration.idempotencyKey,
|
||||
internalRuntimeHandoffId: secondRegistration.handoffId,
|
||||
},
|
||||
{
|
||||
reqId: "exec-followup-duplicate-2",
|
||||
client: backendGatewayClient(),
|
||||
context,
|
||||
flushDispatch: false,
|
||||
},
|
||||
);
|
||||
await flushScheduledDispatchStep();
|
||||
await flushScheduledDispatchStep();
|
||||
|
||||
expect(mocks.agentCommand).toHaveBeenCalledTimes(agentCommandCallsBefore + 1);
|
||||
expect(mockCallArg(secondRespond, 0, 3)).toEqual({ cached: true });
|
||||
});
|
||||
|
||||
it("reserves exec approval followup dedupe before awaited session work", async () => {
|
||||
const bashElevated = {
|
||||
enabled: true,
|
||||
allowed: true,
|
||||
defaultLevel: "on" as const,
|
||||
};
|
||||
const firstRegistration = registerExecApprovalFollowupRuntimeHandoff({
|
||||
approvalId: "req-elevated-overlap",
|
||||
sessionKey: "agent:main:telegram:direct:123",
|
||||
bashElevated,
|
||||
});
|
||||
const secondRegistration = registerExecApprovalFollowupRuntimeHandoff({
|
||||
approvalId: "req-elevated-overlap",
|
||||
sessionKey: "agent:main:telegram:direct:123",
|
||||
bashElevated,
|
||||
});
|
||||
if (!firstRegistration || !secondRegistration) {
|
||||
throw new Error("expected runtime handoff ids");
|
||||
}
|
||||
mockMainSessionEntry({
|
||||
sessionId: "existing-session-id",
|
||||
lastChannel: "telegram",
|
||||
lastTo: "123",
|
||||
});
|
||||
let releaseFirstSessionWrite: (() => void) | undefined;
|
||||
let sessionWriteCalls = 0;
|
||||
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
|
||||
sessionWriteCalls += 1;
|
||||
if (sessionWriteCalls === 1) {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseFirstSessionWrite = resolve;
|
||||
});
|
||||
}
|
||||
const store = {
|
||||
"agent:main:main": buildExistingMainStoreEntry({
|
||||
lastChannel: "telegram",
|
||||
lastTo: "123",
|
||||
}),
|
||||
};
|
||||
return await updater(store);
|
||||
});
|
||||
mocks.agentCommand.mockImplementation(() => new Promise(() => {}));
|
||||
const context = makeContext();
|
||||
const agentCommandCallsBefore = mocks.agentCommand.mock.calls.length;
|
||||
|
||||
const first = invokeAgent(
|
||||
{
|
||||
message: "exec followup",
|
||||
sessionKey: "agent:main:telegram:direct:123",
|
||||
channel: "telegram",
|
||||
idempotencyKey: firstRegistration.idempotencyKey,
|
||||
internalRuntimeHandoffId: firstRegistration.handoffId,
|
||||
},
|
||||
{
|
||||
reqId: "exec-followup-overlap-1",
|
||||
client: backendGatewayClient(),
|
||||
context,
|
||||
flushDispatch: false,
|
||||
},
|
||||
);
|
||||
await waitForAssertion(() => expect(sessionWriteCalls).toBe(1));
|
||||
|
||||
const secondRespond = await invokeAgent(
|
||||
{
|
||||
message: "exec followup duplicate",
|
||||
sessionKey: "agent:main:telegram:direct:123",
|
||||
channel: "telegram",
|
||||
idempotencyKey: secondRegistration.idempotencyKey,
|
||||
internalRuntimeHandoffId: secondRegistration.handoffId,
|
||||
},
|
||||
{
|
||||
reqId: "exec-followup-overlap-2",
|
||||
client: backendGatewayClient(),
|
||||
context,
|
||||
flushDispatch: false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(mocks.agentCommand).toHaveBeenCalledTimes(agentCommandCallsBefore);
|
||||
expect(sessionWriteCalls).toBe(1);
|
||||
expect(mockCallArg(secondRespond, 0, 1)).toMatchObject({
|
||||
runId: firstRegistration.idempotencyKey,
|
||||
status: "accepted",
|
||||
});
|
||||
expect(mockCallArg(secondRespond, 0, 3)).toEqual({ cached: true });
|
||||
|
||||
releaseFirstSessionWrite?.();
|
||||
await first;
|
||||
await flushScheduledDispatchStep();
|
||||
await flushScheduledDispatchStep();
|
||||
|
||||
expect(mocks.agentCommand).toHaveBeenCalledTimes(agentCommandCallsBefore + 1);
|
||||
});
|
||||
|
||||
it("clears reserved exec approval dedupe when pre-run session work fails", async () => {
|
||||
const bashElevated = {
|
||||
enabled: true,
|
||||
allowed: true,
|
||||
defaultLevel: "on" as const,
|
||||
};
|
||||
const firstRegistration = registerExecApprovalFollowupRuntimeHandoff({
|
||||
approvalId: "req-elevated-pre-run-fail",
|
||||
sessionKey: "agent:main:telegram:direct:123",
|
||||
bashElevated,
|
||||
});
|
||||
const secondRegistration = registerExecApprovalFollowupRuntimeHandoff({
|
||||
approvalId: "req-elevated-pre-run-fail",
|
||||
sessionKey: "agent:main:telegram:direct:123",
|
||||
bashElevated,
|
||||
});
|
||||
if (!firstRegistration || !secondRegistration) {
|
||||
throw new Error("expected runtime handoff ids");
|
||||
}
|
||||
mockMainSessionEntry({
|
||||
sessionId: "existing-session-id",
|
||||
lastChannel: "telegram",
|
||||
lastTo: "123",
|
||||
});
|
||||
const context = makeContext();
|
||||
const agentCommandCallsBefore = mocks.agentCommand.mock.calls.length;
|
||||
mocks.updateSessionStore.mockRejectedValueOnce(new Error("session write failed"));
|
||||
|
||||
await expect(
|
||||
invokeAgent(
|
||||
{
|
||||
message: "exec followup",
|
||||
sessionKey: "agent:main:telegram:direct:123",
|
||||
channel: "telegram",
|
||||
idempotencyKey: firstRegistration.idempotencyKey,
|
||||
internalRuntimeHandoffId: firstRegistration.handoffId,
|
||||
},
|
||||
{
|
||||
reqId: "exec-followup-pre-run-fail-1",
|
||||
client: backendGatewayClient(),
|
||||
context,
|
||||
flushDispatch: false,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("session write failed");
|
||||
|
||||
expect(context.dedupe.get(`agent:${firstRegistration.idempotencyKey}`)).toBeUndefined();
|
||||
expect(
|
||||
context.dedupe.get("agent:exec-approval-followup:req-elevated-pre-run-fail"),
|
||||
).toBeUndefined();
|
||||
expect(mocks.agentCommand).toHaveBeenCalledTimes(agentCommandCallsBefore);
|
||||
|
||||
const secondRespond = await invokeAgent(
|
||||
{
|
||||
message: "exec followup retry",
|
||||
sessionKey: "agent:main:telegram:direct:123",
|
||||
channel: "telegram",
|
||||
idempotencyKey: secondRegistration.idempotencyKey,
|
||||
internalRuntimeHandoffId: secondRegistration.handoffId,
|
||||
},
|
||||
{
|
||||
reqId: "exec-followup-pre-run-fail-2",
|
||||
client: backendGatewayClient(),
|
||||
context,
|
||||
flushDispatch: false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(mockCallArg(secondRespond, 0, 1)).toMatchObject({
|
||||
runId: secondRegistration.idempotencyKey,
|
||||
status: "accepted",
|
||||
});
|
||||
await flushScheduledDispatchStep();
|
||||
await flushScheduledDispatchStep();
|
||||
expect(mocks.agentCommand).toHaveBeenCalledTimes(agentCommandCallsBefore + 1);
|
||||
});
|
||||
|
||||
it("does not consume exec approval runtime handoffs from non-backend callers", async () => {
|
||||
const bashElevated = {
|
||||
enabled: true,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user