fix(gateway): dedupe exec followup continuations (#82717)

Co-authored-by: Miya <miya@Miyas-Mac-mini.local>
This commit is contained in:
Zennn
2026-05-16 17:39:26 -04:00
committed by GitHub
parent 842e6f1643
commit 91f45d9c8a
7 changed files with 1229 additions and 745 deletions

View File

@@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai
- CLI/config: show concise human config-write output with an indented backup path instead of printing checksum-heavy overwrite audit details by default.
- CLI/docs: call the canonical lowercase docs MCP search tool and surface MCP errors instead of returning empty search results. Fixes #82702. (#82704) Thanks @hclsys.
- QA-Lab: ignore heartbeat-only operational transcripts when capturing runtime parity cells so background checks cannot replace the scenario reply. (#80323) Thanks @100yenadmin.
- Gateway/exec approvals: wait for accepted async approval follow-up runs instead of direct-fallback sending duplicate completions when retries use different nonce keys. Fixes #82711. (#82717) Thanks @udaymanish6.
- CLI/config: add `--dry-run` support to `openclaw config unset`, with `--json` output and allow-exec validation parity with `config set`/`config patch` dry-run handling. (#81895) Thanks @giodl73-repo.
- Memory-core: retry disabled dreaming cron cleanup until cron is available after startup, so persisted managed dreaming jobs are removed after restart. Fixes #82383. (#82389) Thanks @neeravmakwana.
- Providers/xAI: keep retired Grok 3, Grok 4 Fast, Grok 4.1 Fast, and Grok Code slugs out of model pickers while preserving compatibility resolution for existing configs.

View File

@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("./tools/gateway.js", () => ({
callGatewayTool: vi.fn(async () => ({ ok: true })),
callGatewayTool: vi.fn(async () => ({ status: "ok" })),
}));
vi.mock("../infra/outbound/message.js", () => ({
@@ -42,7 +42,22 @@ function expectGatewayAgentFollowup(expected: Record<string, unknown>) {
for (const [key, value] of Object.entries(expected)) {
expect(params[key]).toBe(value);
}
expect(call[3]).toEqual({ expectFinal: true });
expect(call[3]).toBeUndefined();
return params;
}
function expectGatewayAgentWait(expected: Record<string, unknown>) {
const call = (callGatewayTool as { mock?: { calls?: unknown[][] } }).mock?.calls?.[1];
if (!call) {
throw new Error("expected agent.wait call");
}
expect(call[0]).toBe("agent.wait");
requireRecord(call[1], "gateway wait context");
const params = requireRecord(call[2], "gateway wait params");
for (const [key, value] of Object.entries(expected)) {
expect(params[key]).toBe(value);
}
expect(call[3]).toBeUndefined();
return params;
}
@@ -134,6 +149,41 @@ describe("exec approval followup", () => {
expect(sendMessage).not.toHaveBeenCalled();
});
it("waits for accepted agent followups without direct fallback", async () => {
vi.mocked(callGatewayTool)
.mockResolvedValueOnce({
runId: "exec-approval-followup:req-wait:nonce:nonce-wait",
status: "accepted",
})
.mockResolvedValueOnce({
runId: "exec-approval-followup:req-wait:nonce:nonce-wait",
status: "ok",
});
await sendExecApprovalFollowup({
approvalId: "req-wait",
sessionKey: "agent:main:telegram:direct:123",
turnSourceChannel: "telegram",
turnSourceTo: "123",
turnSourceAccountId: "default",
resultText: "Exec finished (gateway id=req-wait, session=sess_1, code 0)\nall good",
idempotencyKey: "exec-approval-followup:req-wait:nonce:nonce-wait",
});
expectGatewayAgentFollowup({
sessionKey: "agent:main:telegram:direct:123",
deliver: true,
channel: "telegram",
to: "123",
idempotencyKey: "exec-approval-followup:req-wait:nonce:nonce-wait",
});
expectGatewayAgentWait({
runId: "exec-approval-followup:req-wait:nonce:nonce-wait",
timeoutMs: 60_000,
});
expect(sendMessage).not.toHaveBeenCalled();
});
it("falls back to sanitized direct external delivery only when no session exists", async () => {
await sendExecApprovalFollowup({
approvalId: "req-no-session",

View File

@@ -4,7 +4,10 @@ import {
} from "../infra/outbound/best-effort-delivery.js";
import { sendMessage } from "../infra/outbound/message.js";
import { isCronSessionKey, isSubagentSessionKey } from "../sessions/session-key-utils.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "../shared/string-coerce.js";
import { isGatewayMessageChannel, normalizeMessageChannel } from "../utils/message-channel.js";
import { buildExecApprovalFollowupIdempotencyKey } from "./bash-tools.exec-approval-followup-state.js";
import {
@@ -124,6 +127,51 @@ function buildSessionResumeFallbackPrefix(): string {
return "Automatic session resume failed, so sending the status directly.\n\n";
}
function readGatewayStatus(value: unknown): string | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? normalizeOptionalString((value as { status?: unknown }).status)
: undefined;
}
function readGatewayRunId(value: unknown): string | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? normalizeOptionalString((value as { runId?: unknown }).runId)
: undefined;
}
function buildFollowupWaitError(params: { status?: string; error?: unknown }): Error {
const suffix =
typeof params.error === "string" && params.error.trim()
? `: ${params.error.trim()}`
: params.status
? `: ${params.status}`
: "";
return new Error(`exec approval followup session resume failed${suffix}`);
}
function isSuccessfulFollowupStatus(status: string | undefined): boolean {
return status === "ok";
}
async function waitForAgentFollowupRun(params: {
runId: string;
timeoutMs: number;
}): Promise<void> {
const wait = await callGatewayTool(
"agent.wait",
{ timeoutMs: params.timeoutMs + 2_000 },
{
runId: params.runId,
timeoutMs: params.timeoutMs,
},
);
const status = readGatewayStatus(wait);
if (isSuccessfulFollowupStatus(status)) {
return;
}
throw buildFollowupWaitError({ status, error: wait.error });
}
function shouldPrefixDirectFollowupWithSessionResumeFailure(params: {
resultText: string;
sessionError: unknown;
@@ -249,25 +297,34 @@ export async function sendExecApprovalFollowup(
if (sessionKey && params.direct !== true) {
try {
await callGatewayTool(
"agent",
{ timeoutMs: 60_000 },
buildAgentFollowupArgs({
approvalId: params.approvalId,
sessionKey,
resultText,
deliveryTarget,
sessionOnlyOriginChannel,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
internalRuntimeHandoffId: params.internalRuntimeHandoffId,
idempotencyKey: params.idempotencyKey,
}),
{ expectFinal: true },
);
return true;
const agentArgs = buildAgentFollowupArgs({
approvalId: params.approvalId,
sessionKey,
resultText,
deliveryTarget,
sessionOnlyOriginChannel,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
internalRuntimeHandoffId: params.internalRuntimeHandoffId,
idempotencyKey: params.idempotencyKey,
});
const accepted = await callGatewayTool("agent", { timeoutMs: 60_000 }, agentArgs);
const status = readGatewayStatus(accepted);
if (isSuccessfulFollowupStatus(status)) {
return true;
}
if (status === "accepted" || status === "in_flight" || status === "pending") {
const runId =
readGatewayRunId(accepted) ?? normalizeOptionalString(agentArgs.idempotencyKey);
if (!runId) {
throw buildFollowupWaitError({ status: "missing-run-id" });
}
await waitForAgentFollowupRun({ runId, timeoutMs: 60_000 });
return true;
}
throw buildFollowupWaitError({ status, error: accepted.error });
} catch (err) {
sessionError = err;
}

View File

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

View File

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

View File

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