Skip to content

Commit 210ea65

Browse files
committed
fix(outbound): prevent partial-send recovery replay
1 parent c0a61f5 commit 210ea65

5 files changed

Lines changed: 44 additions & 104 deletions

File tree

src/infra/outbound/deliver.queue-integration.test.ts

Lines changed: 27 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,3 @@
1-
// Integration test: real SQLite delivery queue + mock adapter.
2-
// Verifies the patch end-to-end at the queue layer: when a required-mode
3-
// batch send fails mid-batch after an earlier payload already succeeded,
4-
// the queue entry advances to recovery_state=unknown_after_send (not left
5-
// in send_attempt_started), so reconnect-drain routes it through
6-
// reconcileUnknownQueuedDelivery instead of blind replay.
71
import { describe, expect, it, vi, beforeAll, beforeEach } from "vitest";
82
import type { OpenClawConfig } from "../../config/config.js";
93
import { drainPendingDeliveries, type DeliverFn, loadPendingDeliveries } from "./delivery-queue.js";
@@ -14,7 +8,6 @@ import {
148

159
let deliverOutboundPayloads: typeof import("./deliver.js").deliverOutboundPayloads;
1610

17-
// Minimal reconnect drain helper (no adapter → reconcileUnknownQueuedDelivery returns null).
1811
async function drainMatrixReconnect(opts: { deliver: DeliverFn; stateDir: string }): Promise<void> {
1912
await drainPendingDeliveries({
2013
drainKey: "matrix:reconnect-test",
@@ -27,6 +20,27 @@ async function drainMatrixReconnect(opts: { deliver: DeliverFn; stateDir: string
2720
});
2821
}
2922

23+
function createPartialSendFailure() {
24+
return vi
25+
.fn()
26+
.mockResolvedValueOnce({ messageId: "m1" })
27+
.mockRejectedValueOnce(new Error("second payload send failed"));
28+
}
29+
30+
async function deliverPartialMatrixBatch(sendMatrix: ReturnType<typeof vi.fn>, tmpDir: string) {
31+
process.env.OPENCLAW_STATE_DIR = tmpDir;
32+
await expect(
33+
deliverOutboundPayloads({
34+
cfg: {} as OpenClawConfig,
35+
channel: "matrix",
36+
to: "!room:example",
37+
payloads: [{ text: "first" }, { text: "second" }],
38+
deps: { matrix: sendMatrix },
39+
queuePolicy: "required",
40+
}),
41+
).rejects.toThrow("second payload send failed");
42+
}
43+
3044
describe("deliverOutboundPayloads queue integration: mid-batch failure with send evidence", () => {
3145
const fixtures = installDeliveryQueueTmpDirHooks();
3246
let tmpDir: string;
@@ -40,79 +54,36 @@ describe("deliverOutboundPayloads queue integration: mid-batch failure with send
4054
});
4155

4256
it("advances queued entry to unknown_after_send when a later payload fails after an earlier one succeeded", async () => {
43-
process.env.OPENCLAW_STATE_DIR = tmpDir;
44-
// First payload succeeds (send evidence), second payload throws.
45-
const sendMatrix = vi
46-
.fn()
47-
.mockResolvedValueOnce({ messageId: "m1" })
48-
.mockRejectedValueOnce(new Error("second payload send failed"));
57+
const sendMatrix = createPartialSendFailure();
4958

50-
await expect(
51-
deliverOutboundPayloads({
52-
cfg: {} as OpenClawConfig,
53-
channel: "matrix",
54-
to: "!room:example",
55-
payloads: [{ text: "first" }, { text: "second" }],
56-
deps: { matrix: sendMatrix },
57-
queuePolicy: "required",
58-
}),
59-
).rejects.toThrow("second payload send failed");
59+
await deliverPartialMatrixBatch(sendMatrix, tmpDir);
6060

61-
// The entry must exist in the real SQLite queue and be in unknown_after_send.
62-
const entries = await import("./delivery-queue.js").then((m) =>
63-
m.loadPendingDeliveries(tmpDir),
64-
);
61+
const entries = await loadPendingDeliveries(tmpDir);
6562
expect(entries).toHaveLength(1);
6663
const entry = entries[0];
6764
expect(entry.recoveryState).toBe("unknown_after_send");
6865
expect(entry.retryCount).toBe(0);
6966
expect(entry.lastError).toBeUndefined();
70-
// Sanity: the send actually happened for the first payload.
7167
expect(sendMatrix).toHaveBeenCalledTimes(2);
7268
});
7369

7470
it("drain does not replay an unknown_after_send entry when no adapter reconciliation is available", async () => {
75-
// Regression guard for the recovery/drain semantics: an entry in
76-
// unknown_after_send (written by the patch above) must NOT be blindly
77-
// replayed when the channel adapter cannot reconcile the unknown send.
78-
// Without the patch the entry would stay in send_attempt_started, which
79-
// has the same drain behaviour — but this test pins the contract so that
80-
// any future regression that accidentally advances the state in a way that
81-
// re-enables blind replay is caught.
82-
process.env.OPENCLAW_STATE_DIR = tmpDir;
83-
const sendMatrix = vi
84-
.fn()
85-
.mockResolvedValueOnce({ messageId: "m1" })
86-
.mockRejectedValueOnce(new Error("second payload send failed"));
71+
const sendMatrix = createPartialSendFailure();
8772

88-
// Drive the patch: entry lands in unknown_after_send.
89-
await expect(
90-
deliverOutboundPayloads({
91-
cfg: {} as OpenClawConfig,
92-
channel: "matrix",
93-
to: "!room:example",
94-
payloads: [{ text: "first" }, { text: "second" }],
95-
deps: { matrix: sendMatrix },
96-
queuePolicy: "required",
97-
}),
98-
).rejects.toThrow("second payload send failed");
73+
await deliverPartialMatrixBatch(sendMatrix, tmpDir);
9974

10075
const beforeDrain = await loadPendingDeliveries(tmpDir);
10176
expect(beforeDrain[0]?.recoveryState).toBe("unknown_after_send");
10277

103-
// Reconnect drain with no adapter (cfg={}) — reconcileUnknownQueuedDelivery
104-
// returns null → "refusing blind replay" branch → deliver is never called.
10578
const deliver = vi.fn<DeliverFn>(async () => {});
10679
await drainMatrixReconnect({ deliver, stateDir: tmpDir });
10780

10881
expect(deliver).not.toHaveBeenCalled();
109-
// The entry is moved to failed (not re-queued as pending), closing the drain loop.
11082
expect(await loadPendingDeliveries(tmpDir)).toHaveLength(0);
11183
});
11284

113-
it("leaves entry for retry (failDelivery, recovery_state stays null) when no send evidence", async () => {
85+
it("leaves entry for retry in send_attempt_started when no send evidence exists", async () => {
11486
process.env.OPENCLAW_STATE_DIR = tmpDir;
115-
// First (and only) payload fails immediately — no send evidence.
11687
const sendMatrix = vi.fn().mockRejectedValueOnce(new Error("first payload send failed"));
11788

11889
await expect(
@@ -131,7 +102,6 @@ describe("deliverOutboundPayloads queue integration: mid-batch failure with send
131102
);
132103
expect(entries).toHaveLength(1);
133104
const entry = entries[0];
134-
// No send evidence -> failDelivery path: retryCount bumped, recovery_state not advanced.
135105
expect(entry.retryCount).toBe(1);
136106
expect(entry.recoveryState).toBe("send_attempt_started");
137107
expect(entry.lastError).toContain("first payload send failed");

src/infra/outbound/deliver.test.ts

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1046,14 +1046,6 @@ describe("deliverOutboundPayloads", () => {
10461046
});
10471047

10481048
it("marks queued delivery as unknown-after-send (not failed) when a later payload fails after an earlier one succeeded", async () => {
1049-
// Regression: required-mode batch send where an earlier payload succeeded
1050-
// (results.length > 0, OutboundDeliveryError.sentBeforeError === true) but a
1051-
// later payload throws. Previously the wrapper catch called failDelivery,
1052-
// leaving the entry in `send_attempt_started` so reconnect drain later
1053-
// replayed it as "not yet sent" — producing duplicate messages when the
1054-
// adapter's unknown-send reconciliation misreported `not_sent`. The fix
1055-
// advances to `unknown_after_send` so drain routes the entry through
1056-
// reconcileUnknownQueuedDelivery instead of blind replay.
10571049
const sendMatrix = vi
10581050
.fn()
10591051
.mockResolvedValueOnce({ messageId: "m1" })
@@ -1072,16 +1064,11 @@ describe("deliverOutboundPayloads", () => {
10721064

10731065
expect(sendMatrix).toHaveBeenCalledTimes(2);
10741066
expect(queueMocks.markDeliveryPlatformOutcomeUnknown).toHaveBeenCalledWith("mock-queue-id");
1075-
// Must NOT failDelivery — that would leave send_attempt_started for drain to replay.
10761067
expect(queueMocks.failDelivery).not.toHaveBeenCalled();
1077-
// Must NOT ack — the batch did not fully succeed; reconcile must confirm the
1078-
// partial send rather than silently dropping the queue entry.
10791068
expect(queueMocks.ackDelivery).not.toHaveBeenCalled();
10801069
});
10811070

10821071
it("still calls failDelivery when a payload fails before any send succeeded", async () => {
1083-
// No send evidence (sentBeforeError === false): failDelivery is correct —
1084-
// nothing reached the channel, so leaving the entry for retry is safe.
10851072
const sendMatrix = vi.fn().mockRejectedValueOnce(new Error("first payload send failed"));
10861073

10871074
await expect(

src/infra/outbound/deliver.ts

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1390,29 +1390,13 @@ async function deliverOutboundPayloadsWithQueueCleanup(
13901390
if (isDeliveryAbortError(err)) {
13911391
await ackDelivery(queueId).catch(() => {});
13921392
} else if (!platformResultsReturned) {
1393-
// If the platform send already started and the error carries partial
1394-
// send evidence (OutboundDeliveryError with sentBeforeError), the
1395-
// message may already have reached the channel. Calling failDelivery
1396-
// here leaves the entry in `send_attempt_started`, which reconnect
1397-
// drain later replays as "not yet sent" — producing duplicate
1398-
// messages when the adapter's unknown-send reconciliation misreports
1399-
// `not_sent`. Advance to `unknown_after_send` instead so drain routes
1400-
// the entry through reconcileUnknownQueuedDelivery (query the adapter
1401-
// for actual send state) rather than blind replay.
14021393
const sendEvidence =
14031394
platformSendStarted && err instanceof OutboundDeliveryError && err.sentBeforeError;
14041395
if (sendEvidence) {
14051396
await markQueuedPlatformOutcomeUnknown({
14061397
queueId,
14071398
queuePolicy,
14081399
}).catch((markErr: unknown) => {
1409-
// markQueuedPlatformOutcomeUnknown failed (e.g. DB write error).
1410-
// Fall back to failDelivery so the entry is not silently abandoned.
1411-
// This is a last-resort path: the entry will re-enter
1412-
// send_attempt_started and be subject to the same drain/reconcile
1413-
// semantics as before the patch. It does NOT mean failDelivery is
1414-
// correct when send evidence exists — only that we cannot safely
1415-
// advance the state without a successful DB write.
14161400
log.warn(
14171401
`failed to mark queued delivery ${queueId} as platform-outcome-unknown after mid-send error; falling back to fail: ${formatErrorMessage(markErr)}`,
14181402
);

src/infra/outbound/delivery-queue-recovery.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -389,15 +389,19 @@ async function drainQueuedEntry(opts: {
389389
return "failed";
390390
}
391391
}
392-
if (reconciliation?.status === "not_sent") {
392+
const reconciliationProvedPreSendFailure =
393+
reconciliation?.status === "not_sent" && entry.recoveryState === "send_attempt_started";
394+
if (reconciliationProvedPreSendFailure) {
393395
opts.log.info(
394396
`Delivery entry ${entry.id} reconciled ${entry.recoveryState} as not sent; replaying`,
395397
);
396398
} else {
397-
const errMsg =
398-
reconciliation?.status === "unresolved" && reconciliation.error
399-
? `delivery state is ${entry.recoveryState} and reconciliation is unresolved: ${reconciliation.error}`
400-
: `delivery state is ${entry.recoveryState}; refusing blind replay without adapter reconciliation`;
399+
let errMsg = `delivery state is ${entry.recoveryState}; refusing blind replay without adapter reconciliation`;
400+
if (reconciliation?.status === "not_sent") {
401+
errMsg = `delivery state is ${entry.recoveryState}; refusing full replay after post-send evidence`;
402+
} else if (reconciliation?.status === "unresolved" && reconciliation.error) {
403+
errMsg = `delivery state is ${entry.recoveryState} and reconciliation is unresolved: ${reconciliation.error}`;
404+
}
401405
opts.log.warn(`Delivery entry ${entry.id} ${errMsg}`);
402406
opts.onFailed?.(entry, errMsg);
403407
if (reconciliation?.status === "unresolved" && reconciliation.retryable === true) {

src/infra/outbound/delivery-queue.recovery.test.ts

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ describe("delivery-queue recovery", () => {
328328
expect(await loadPendingDeliveries(tmpDir())).toHaveLength(0);
329329
});
330330

331-
it("replays unknown-after-send entries only after adapter proves they were not sent", async () => {
331+
it("moves unknown-after-send entries to failed when adapter reports not sent", async () => {
332332
const id = await enqueueDelivery(
333333
{ channel: "demo-channel-a", to: "+1", payloads: [{ text: "not sent" }] },
334334
tmpDir(),
@@ -346,24 +346,19 @@ describe("delivery-queue recovery", () => {
346346
});
347347

348348
const deliver = vi.fn().mockResolvedValue([]);
349-
const { result } = await runRecovery({ deliver });
349+
const log = createRecoveryLog();
350+
const { result } = await runRecovery({ deliver, log });
350351

351-
expect(deliver).toHaveBeenCalledTimes(1);
352-
const deliverInput = mockCallArg(deliver) as {
353-
channel?: string;
354-
to?: string;
355-
skipQueue?: boolean;
356-
};
357-
expect(deliverInput.channel).toBe("demo-channel-a");
358-
expect(deliverInput.to).toBe("+1");
359-
expect(deliverInput.skipQueue).toBe(true);
352+
expect(deliver).not.toHaveBeenCalled();
360353
expect(result).toEqual({
361-
recovered: 1,
362-
failed: 0,
354+
recovered: 0,
355+
failed: 1,
363356
skippedMaxRetries: 0,
364357
deferredBackoff: 0,
365358
});
366359
expect(await loadPendingDeliveries(tmpDir())).toHaveLength(0);
360+
expect(readOutboundQueueStatus(tmpDir(), id)).toBe("failed");
361+
expectMockMessageContaining(log.warn, "refusing full replay after post-send evidence");
367362
});
368363

369364
it("keeps retryable unresolved unknown-after-send entries on the queue without replaying", async () => {

0 commit comments

Comments
 (0)