Files
openclaw/test/slack-outbound-permanent-rejection-loopback.test.ts
Heming Zeng 2555e9fa4e fix: stop retry storms after definitive channel rejections (#127353)
* fix(outbound): terminalize definitive channel rejections

* refactor(outbound): rethrow unclassified Slack rejections by identity

The Slack send boundary replaced every non-Error rejection with a synthetic
Error before classifying. That changed the propagated value at all four send
call sites, contradicting the stated contract that unlisted rejections keep
their previous path, and forced a compensating one-level `cause` walk in
`isSlackInvalidBlocksError` so the downstream `invalid_blocks` fallback could
still match. The guard's second operand was also dead: `isRecord` accepts Error
instances, so `!(err instanceof Error) || !isRecord(err)` never reached its
right side for a plain object.

Classify off the raw value and rethrow unclassified rejections by identity;
the `cause`-walking compensator and its test go away with it. Distill the
Telegram migration classifier's three-state result object into a
message-or-nothing, and document the description-first and 52-bit id contracts
inline.

Production surface for the PR drops from +71/-11 to +62/-8.

* test(agents): remove clock-tick race from workspace bootstrap ctime coverage

The in-place-edit case added in #127769 assumes restoring mtime leaves ctime as
the only changed stat field, and that it therefore differs. Linux and macOS
stamp ctime from a coarse per-tick clock, so an edit landing in the same tick as
the cached stat leaves ctimeMs equal: the ctime-only scenario never occurs, the
cache correctly serves its entry, and the assertion fails. Measured 199/200
identical ctimes in a tight loop; the test failed 1 in 8 local runs and broke
checks-node-compact-large-14 on CI.

Re-touch until the kernel advances ctimeMs, then assert mtimeMs and size are
unchanged so the scenario is provably ctime-only. Stripping ctimeMs from the
cache identity still fails the test.

* fix(slack): keep post-dispatch upload completion rejections ambiguous

PlatformMessageNotDispatchedError is a provider assertion that no
recipient-visible send began, and its contract says never use it after an
ambiguous send. files.completeUploadExternal runs after onPlatformSendDispatch
and is the one-time share operation, so a rejection there cannot prove the file
was never shared however definitive its code reads.

Drop the permanent-rejection classifier from that call and keep it on the
pre-dispatch calls only (chat.postMessage, files.getUploadURLExternal,
resolveChannelId). The upload test that pinned the old behavior asserted
onPlatformSendDispatch had already fired, which is exactly the condition that
forbids the claim; it now pins ambiguity instead.

Also widens the workspace bootstrap ctime wait to a 1s deadline and reshapes it
as a while loop, so a coarser filesystem tick cannot exhaust the bound.

* test(slack): prove permanent rejection recovery

* fix(test): stabilize Slack channel action routing

* fix(test): retain channel parity for precise targets

* refactor(outbound): drop unrelated test-routing changes

* fix(telegram): require Bot API error code for migration rejection

* test: repair Telegram tuples and preserve Slack test routing

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-08-26 13:16:48 -07:00

235 lines
8.3 KiB
TypeScript

// Root-owned integration may combine the public Slack plugin with the durable queue runtime.
import { createServer, type Server } from "node:http";
import type { AddressInfo, Socket } from "node:net";
import { sendDurableMessageBatch } from "openclaw/plugin-sdk/channel-outbound";
import {
createEmptyPluginRegistry,
createTestRegistry,
resetPluginRuntimeStateForTest,
resetGlobalHookRunner,
setActivePluginRegistry,
} from "openclaw/plugin-sdk/channel-test-helpers";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { drainPendingDeliveries } from "openclaw/plugin-sdk/delivery-queue-runtime";
import { PlatformMessageNotDispatchedError } from "openclaw/plugin-sdk/error-runtime";
import {
closeOpenClawAgentDatabasesForTest,
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
} from "openclaw/plugin-sdk/sqlite-runtime-testing";
import { withStateDirEnv } from "openclaw/plugin-sdk/test-env";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getDeliveryQueueEntryStatus } from "../src/infra/delivery-queue-sqlite.js";
import { OUTBOUND_DELIVERY_QUEUE_NAME } from "../src/infra/outbound/delivery-queue-media-staging.js";
const CLASSIFIED_CODES = ["messages_tab_disabled", "account_inactive"] as const;
const DELIVERY_INTENT_PREFIX = "slack-loopback-permanent-rejection";
type SlackPermanentRejectionCode = (typeof CLASSIFIED_CODES)[number];
type SlackLoopbackRequest = {
body: string;
code?: SlackPermanentRejectionCode;
method: string | undefined;
text: string;
url: string;
};
type SlackLoopback = {
apiUrl: string;
requests: SlackLoopbackRequest[];
close: () => Promise<void>;
};
function readQueueTerminal(
stateDir: string,
intentId: string,
): { retryCount: number; status: string } | undefined {
const { db } = openOpenClawStateDatabase({
env: { ...process.env, OPENCLAW_STATE_DIR: stateDir },
});
const row = db
// sqlite-allow-raw: The proof reads one exact queue owner after terminalization.
.prepare(
"SELECT status, retry_count FROM delivery_queue_entries WHERE queue_name = ? AND id = ?",
)
.get(OUTBOUND_DELIVERY_QUEUE_NAME, intentId) as
| { retry_count: number; status: string }
| undefined;
return row ? { retryCount: row.retry_count, status: row.status } : undefined;
}
async function startSlackPermanentRejectionLoopback(): Promise<SlackLoopback> {
const requests: SlackLoopbackRequest[] = [];
const sockets = new Set<Socket>();
const server: Server = createServer((request, response) => {
const chunks: Buffer[] = [];
request.on("data", (chunk: Buffer) => chunks.push(chunk));
request.on("end", () => {
const body = Buffer.concat(chunks).toString("utf8");
const text = new URLSearchParams(body).get("text") ?? "";
const code = CLASSIFIED_CODES.find((candidate) => text.includes(candidate));
requests.push({
body,
...(code ? { code } : {}),
method: request.method,
text,
url: request.url ?? "",
});
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: false, error: code ?? "unexpected_test_request" }));
});
});
server.on("connection", (socket) => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const { port } = server.address() as AddressInfo;
return {
apiUrl: `http://127.0.0.1:${port}/api/`,
requests,
close: async () => {
for (const socket of sockets) {
socket.destroy();
}
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
},
};
}
describe("Slack permanent rejections over real Web API transport", () => {
afterEach(() => {
vi.unstubAllEnvs();
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
resetGlobalHookRunner();
resetPluginRuntimeStateForTest();
setActivePluginRegistry(createEmptyPluginRegistry());
});
it("dead-letters both real platform rejections and never replays them after restart", async () => {
const loopback = await startSlackPermanentRejectionLoopback();
vi.stubEnv("SLACK_API_URL", loopback.apiUrl);
try {
const { slackPlugin } = await import("../extensions/slack/api.js");
const cfg = {
channels: { slack: { botToken: "xoxb-loopback" } },
} satisfies OpenClawConfig;
setActivePluginRegistry(
createTestRegistry([{ pluginId: "slack", plugin: slackPlugin, source: "test" }]),
);
await withStateDirEnv("openclaw-slack-permanent-loopback-", async ({ stateDir }) => {
try {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
for (const [index, code] of CLASSIFIED_CODES.entries()) {
const intentId = `${DELIVERY_INTENT_PREFIX}-${code}`;
const text = `real transport permanent rejection: ${code}`;
const staged = await sendDurableMessageBatch({
cfg,
channel: "slack",
to: "channel:C123",
accountId: "default",
durability: "required",
deliveryIntentId: intentId,
completionRetention: {
idPrefix: "slack-loopback-",
maxAgeMs: 60_000,
maxEntries: 10,
},
maxRetries: 10,
payloads: [{ text }],
deps: {
slack: async () => {
throw new PlatformMessageNotDispatchedError(
"staged before transport for recovery proof",
{ cause: new Error("loopback transport not released yet") },
);
},
},
});
expect(staged.status).toBe("failed");
expect(loopback.requests).toHaveLength(index);
expect(
getDeliveryQueueEntryStatus(OUTBOUND_DELIVERY_QUEUE_NAME, intentId, stateDir),
).toBe("pending");
log.warn.mockClear();
await drainPendingDeliveries({
drainKey: `slack:default:${code}`,
logLabel: `Slack loopback ${code} recovery`,
cfg,
stateDir,
log,
selectEntry: (entry) => ({
match: entry.id === intentId,
bypassBackoff: true,
}),
});
expect(loopback.requests).toHaveLength(index + 1);
expect(loopback.requests[index]).toMatchObject({
code,
method: "POST",
text,
url: "/api/chat.postMessage",
});
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining(code));
expect(readQueueTerminal(stateDir, intentId)).toEqual({
retryCount: 1,
status: "failed",
});
}
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
for (const code of CLASSIFIED_CODES) {
expect(
getDeliveryQueueEntryStatus(
OUTBOUND_DELIVERY_QUEUE_NAME,
`${DELIVERY_INTENT_PREFIX}-${code}`,
stateDir,
),
).toBe("failed");
}
await drainPendingDeliveries({
drainKey: "slack:default:post-restart",
logLabel: "Slack loopback post-restart recovery",
cfg,
stateDir,
log,
selectEntry: (entry) => ({
match: entry.channel === "slack",
bypassBackoff: true,
}),
});
expect(loopback.requests).toHaveLength(CLASSIFIED_CODES.length);
console.log(
`[slack permanent-rejection proof] ${JSON.stringify({
queueTerminal: "failed",
restartReplayCount: 0,
providerStatus: 200,
classifiedCodes: CLASSIFIED_CODES,
classification: "typed non-retryable",
transport: "@slack/web-api HTTP to 127.0.0.1:<redacted>",
})}`,
);
} finally {
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
}
});
} finally {
await loopback.close();
}
});
});