fix: retry delivery when outbound adapter is unavailable (#119371)

* fix(outbound): preserve pre-dispatch retryability

* test(outbound): assert lazy runtime sender

* fix(feishu): preflight direct message runtime

* test(gateway): preserve scoped registry fixture
This commit is contained in:
Jason (Json)
2026-08-04 21:22:07 -06:00
committed by GitHub
parent fd1662f49c
commit ab7b3ffd1f
10 changed files with 474 additions and 28 deletions
+46 -10
View File
@@ -29,6 +29,7 @@ import {
createChannelDirectoryAdapter,
createRuntimeDirectoryLiveAdapter,
} from "openclaw/plugin-sdk/directory-runtime";
import { PlatformMessageNotDispatchedError } from "openclaw/plugin-sdk/error-runtime";
import {
legacyInteractiveReplyToPresentation,
normalizeLegacyInteractiveReply,
@@ -180,6 +181,38 @@ const loadFeishuChannelRuntime = createLazyRuntimeNamedExport(
"feishuChannelRuntime",
);
async function resolveFeishuMessageSender<TSender>(params: {
resolve: (
runtime: Awaited<ReturnType<typeof loadFeishuChannelRuntime>>,
) => TSender | null | undefined;
unavailableMessage: string;
}): Promise<TSender> {
try {
const sender = params.resolve(await loadFeishuChannelRuntime());
if (sender) {
return sender;
}
throw new Error(params.unavailableMessage);
} catch (error) {
if (error instanceof PlatformMessageNotDispatchedError) {
throw error;
}
throw new PlatformMessageNotDispatchedError(params.unavailableMessage, { cause: error });
}
}
const resolveFeishuTextSender = () =>
resolveFeishuMessageSender({
resolve: (runtime) => runtime.feishuOutbound.sendText,
unavailableMessage: "Feishu text sending is not available.",
});
const resolveFeishuMediaSender = () =>
resolveFeishuMessageSender({
resolve: (runtime) => runtime.feishuOutbound.sendMedia,
unavailableMessage: "Feishu media sending is not available.",
});
function toFeishuMessageSendResult(
result: { messageId?: string; chatId?: string; receipt?: ChannelMessageSendResult["receipt"] },
kind: MessageReceiptPartKind,
@@ -206,12 +239,19 @@ const feishuMessageAdapter = defineChannelMessageAdapter({
},
},
send: {
lifecycle: {
// Resolve process-stable runtime methods before core records platform-send start.
// Provider invocation stays below so a lost provider result remains ambiguous.
beforeSendAttempt: async (ctx) => {
if (ctx.kind === "text") {
await resolveFeishuTextSender();
} else if (ctx.kind === "media") {
await resolveFeishuMediaSender();
}
},
},
text: async (ctx) => {
const runtime = await loadFeishuChannelRuntime();
const sendText = runtime.feishuOutbound.sendText;
if (!sendText) {
throw new Error("Feishu text sending is not available.");
}
const sendText = await resolveFeishuTextSender();
const { onDeliveryResult, ...outboundCtx } = ctx;
const result = await sendText({
...outboundCtx,
@@ -226,11 +266,7 @@ const feishuMessageAdapter = defineChannelMessageAdapter({
return toFeishuMessageSendResult(result, "text");
},
media: async (ctx) => {
const runtime = await loadFeishuChannelRuntime();
const sendMedia = runtime.feishuOutbound.sendMedia;
if (!sendMedia) {
throw new Error("Feishu media sending is not available.");
}
const sendMedia = await resolveFeishuMediaSender();
const { onDeliveryResult, ...outboundCtx } = ctx;
const result = await sendMedia({
...outboundCtx,
@@ -1,4 +1,6 @@
// Feishu tests cover the shared outbound delivery path.
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { sendDurableMessageBatch } from "openclaw/plugin-sdk/channel-outbound";
import {
createOutboundTestPlugin,
@@ -7,6 +9,8 @@ import {
resetGlobalHookRunner,
setActivePluginRegistry,
} from "openclaw/plugin-sdk/channel-test-helpers";
import { drainPendingDeliveries } from "openclaw/plugin-sdk/delivery-queue-runtime";
import { withStateDirEnv } from "openclaw/plugin-sdk/test-env";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
@@ -19,14 +23,47 @@ vi.mock("./media.js", () => ({
}));
vi.mock("./send.js", () => ({
editMessageFeishu: vi.fn(),
getMessageFeishu: vi.fn(),
sendCardFeishu: sendCardFeishuMock,
sendMarkdownCardFeishu: vi.fn(),
sendMessageFeishu: sendMessageFeishuMock,
sendStructuredCardFeishu: vi.fn(),
}));
import { feishuPlugin } from "./channel.js";
import { feishuChannelRuntime } from "./channel.runtime.js";
import { feishuOutbound } from "./outbound.js";
type DeliveryQueueRow = {
status: string;
recovery_state: string | null;
platform_send_started_at: number | null;
};
const completionRetention = {
idPrefix: "feishu-direct-",
maxAgeMs: 60_000,
maxEntries: 10,
} as const;
function readDeliveryQueueRow(stateDir: string, id: string): DeliveryQueueRow | undefined {
const database = new DatabaseSync(path.join(stateDir, "state", "openclaw.sqlite"), {
readOnly: true,
});
try {
return database
.prepare(
`SELECT status, recovery_state, platform_send_started_at
FROM delivery_queue_entries
WHERE queue_name = 'outbound-prepared-v1' AND id = ?`,
)
.get(id) as DeliveryQueueRow | undefined;
} finally {
database.close();
}
}
describe("Feishu outbound shared delivery", () => {
beforeEach(() => {
let textMessageIndex = 0;
@@ -119,4 +156,110 @@ describe("Feishu outbound shared delivery", () => {
expect(deliveredText).toContain("account-0-");
expect(deliveredText).toContain("account-399-");
});
it("replays a queued direct message after Feishu runtime availability is restored", async () => {
const originalSendText = feishuChannelRuntime.feishuOutbound.sendText;
if (!originalSendText) {
throw new Error("Expected Feishu runtime sendText");
}
const deliveryIntentId = "feishu-direct-runtime-availability";
setActivePluginRegistry(
createTestRegistry([{ pluginId: "feishu", plugin: feishuPlugin, source: "test" }]),
);
feishuChannelRuntime.feishuOutbound.sendText = undefined;
try {
await withStateDirEnv("openclaw-feishu-runtime-availability-", async ({ stateDir }) => {
const initial = await sendDurableMessageBatch({
cfg: {},
channel: "feishu",
to: "chat_1",
accountId: "default",
durability: "required",
deliveryIntentId,
completionRetention,
maxRetries: 2,
payloads: [{ text: "retry after runtime restoration" }],
});
expect(initial.status).toBe("failed");
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
expect(readDeliveryQueueRow(stateDir, deliveryIntentId)).toMatchObject({
status: "pending",
recovery_state: null,
platform_send_started_at: null,
});
feishuChannelRuntime.feishuOutbound.sendText = originalSendText;
await drainPendingDeliveries({
drainKey: "feishu:default",
logLabel: "Feishu runtime availability recovery",
cfg: {},
stateDir,
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
selectEntry: (entry) => ({
match: entry.channel === "feishu",
bypassBackoff: true,
}),
});
expect(sendMessageFeishuMock).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
to: "chat_1",
text: "retry after runtime restoration",
}),
);
expect(readDeliveryQueueRow(stateDir, deliveryIntentId)?.status).toBe("completed");
});
} finally {
feishuChannelRuntime.feishuOutbound.sendText = originalSendText;
}
});
it("does not replay a Feishu provider call after dispatch may have begun", async () => {
const deliveryIntentId = "feishu-direct-ambiguous-provider-result";
sendMessageFeishuMock.mockRejectedValueOnce(new Error("Feishu provider result was lost"));
setActivePluginRegistry(
createTestRegistry([{ pluginId: "feishu", plugin: feishuPlugin, source: "test" }]),
);
await withStateDirEnv("openclaw-feishu-ambiguous-provider-", async ({ stateDir }) => {
const initial = await sendDurableMessageBatch({
cfg: {},
channel: "feishu",
to: "chat_1",
accountId: "default",
durability: "required",
deliveryIntentId,
completionRetention,
maxRetries: 2,
payloads: [{ text: "do not replay an ambiguous provider call" }],
});
expect(initial.status).toBe("failed");
expect(sendMessageFeishuMock).toHaveBeenCalledOnce();
expect(readDeliveryQueueRow(stateDir, deliveryIntentId)).toMatchObject({
status: "pending",
recovery_state: "send_attempt_started",
});
expect(readDeliveryQueueRow(stateDir, deliveryIntentId)?.platform_send_started_at).toEqual(
expect.any(Number),
);
await drainPendingDeliveries({
drainKey: "feishu:default",
logLabel: "Feishu ambiguous provider recovery",
cfg: {},
stateDir,
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
selectEntry: (entry) => ({
match: entry.channel === "feishu",
bypassBackoff: true,
}),
});
expect(sendMessageFeishuMock).toHaveBeenCalledOnce();
});
});
});