Skip to content

Commit 92c243a

Browse files
omarshahineclaude
andcommitted
fix(imessage): clamp catchup watermark to last dispatched row when truncating
clawsweeper review on PR #79387 caught a regression I introduced when adding the parse-rejected high-watermark fix in 8c6421b: when the cross-chat fetch returns more valid rows than `perRunLimit`, the fetcher caps `capped = collected.slice(0, limit)` but `rawWatermarkRowid` still reflects the highest rowid across ALL raw rows — including the cap-truncated tail. The catchup loop applies that watermark as a cursor floor, so the persisted cursor leapfrogs past valid-but- undispatched rows. Same silent message-loss class as the original codex finding, just on a different code path. Concrete repro in `catchup-bridge.test.ts > caps cross-chat results at perRunLimit, oldest first`: 8 valid rows (chat 1: rowids 100-103, chat 2: rowids 200-203), perRunLimit=5. Dispatched: 100, 101, 102, 103, 200. Before this fix: cursor persisted at 203 (raw watermark of chat 2's last row), so rowids 201, 202, 203 — explicitly promised by the WARN log to be picked up on the next startup — were silently dropped. After this fix: cursor stops at 200 (last dispatched rowid). Fix: in `catchup-bridge.ts`, derive `effectiveWatermarkRowid` / `effectiveWatermarkMs` from `rawWatermarkRowid` / `rawWatermarkMs` clamped to `capped[capped.length-1]` when truncation hits. When no truncation, the watermark still covers parse-rejected rows interspersed with the dispatched batch (the original forward-progress guarantee from 8c6421b). When `capped.length === 0` (pathological limit=0 or all rows parse-rejected past the cap boundary), suppress the watermark entirely so the cursor stays put. Regression test: extended the existing "caps cross-chat results at perRunLimit, oldest first" test to assert `summary.cursorAfter.lastSeenRowid === 200` (the last dispatched rowid), not 203 (the raw watermark). Acceptance criteria from clawsweeper: - pnpm vitest run extensions/imessage/src/monitor/catchup.test.ts extensions/imessage/src/monitor/catchup-bridge.test.ts → 29/29 pass - pnpm exec oxfmt --check --threads=1 ... → All matched files use the correct format - pnpm lint:extensions:bundled → 0 warnings, 0 errors Also drops `[...collected].sort()` for `collected.toSorted()` in the same block (oxlint no-array-sort, latent in the 8c6421b commit because oxlint missed it on one path). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent 4dbf956 commit 92c243a

3 files changed

Lines changed: 54 additions & 21 deletions

File tree

extensions/imessage/src/monitor/catchup-bridge.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,15 @@ describe("runIMessageCatchup", () => {
270270
expect(summary.replayed).toBe(5);
271271
// Oldest-first by rowid: 100, 101, 102, 103, 200 (chat 1's first 4, then chat 2's first).
272272
expect(dispatched).toEqual(["g-100", "g-101", "g-102", "g-103", "g-200"]);
273+
// Regression for clawsweeper #79387 finding: the cursor must NOT
274+
// advance past the last dispatched row when perRunLimit truncates
275+
// the cross-chat page. Without the cap-aware watermark clamp, the
276+
// bridge would emit a watermark covering the raw rows it dropped
277+
// (rowids 201, 202, 203 from chat 2), and the catchup loop would
278+
// persist `lastSeenRowid` past them — so the promised "next startup
279+
// picks up the rest" warning would lie and those rows would be
280+
// permanently lost. Cursor must stop at the last dispatched rowid (200).
281+
expect(summary.cursorAfter.lastSeenRowid).toBe(200);
273282
});
274283

275284
it("treats a dispatch throw as a failure and holds the cursor", async () => {

extensions/imessage/src/monitor/catchup-bridge.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -193,17 +193,16 @@ export async function runIMessageCatchup(
193193
}
194194
}
195195

196-
collected.sort((a, b) => a.rowid - b.rowid);
197-
const capped = collected.slice(0, limit);
198-
if (capped.length < collected.length) {
196+
const sorted = collected.toSorted((a, b) => a.rowid - b.rowid);
197+
const capped = sorted.slice(0, limit);
198+
const isCapTruncated = capped.length < sorted.length;
199+
if (isCapTruncated) {
199200
warnLog(
200-
`imessage catchup: fetched ${collected.length} rows across chats, ` +
201+
`imessage catchup: fetched ${sorted.length} rows across chats, ` +
201202
`capped to perRunLimit=${limit} (oldest first); next startup picks up the rest`,
202203
);
203-
}
204-
// Drop payloads we are no longer going to dispatch so the dispatch
205-
// adapter does not have to defend against the discarded ones.
206-
if (capped.length < collected.length) {
204+
// Drop payloads we are no longer going to dispatch so the dispatch
205+
// adapter does not have to defend against the discarded ones.
207206
const keep = new Set(capped.map((row) => row.guid));
208207
for (const guid of payloadByGuid.keys()) {
209208
if (!keep.has(guid)) {
@@ -212,11 +211,36 @@ export async function runIMessageCatchup(
212211
}
213212
}
214213

214+
// Clamp the raw watermark when cap-truncation hits so the catchup loop
215+
// cannot persist a cursor past undispatched valid rows. Without this,
216+
// a `messages.history` page wider than `perRunLimit` would silently
217+
// skip the cap-truncated tail forever — the WARN above promises the
218+
// next startup picks up the rest, and that promise relies on the
219+
// cursor staying at the last dispatched rowid. When no truncation
220+
// happens, the watermark covers parse-rejected rows interspersed
221+
// with the dispatched batch (the original forward-progress fix).
222+
let effectiveWatermarkRowid = rawWatermarkRowid;
223+
let effectiveWatermarkMs = rawWatermarkMs;
224+
if (isCapTruncated && capped.length > 0) {
225+
const last = capped.at(-1);
226+
if (last) {
227+
effectiveWatermarkRowid = Math.min(effectiveWatermarkRowid, last.rowid);
228+
effectiveWatermarkMs = Math.min(effectiveWatermarkMs, last.date);
229+
}
230+
} else if (isCapTruncated && capped.length === 0) {
231+
// Pathological: cap=0. Don't emit any watermark; preserve the prior
232+
// cursor and let the next pass try again.
233+
effectiveWatermarkRowid = Number.NaN;
234+
effectiveWatermarkMs = Number.NaN;
235+
}
236+
215237
return {
216238
resolved: true,
217239
rows: capped,
218-
...(Number.isFinite(rawWatermarkRowid) ? { highWatermarkRowid: rawWatermarkRowid } : {}),
219-
...(Number.isFinite(rawWatermarkMs) ? { highWatermarkMs: rawWatermarkMs } : {}),
240+
...(Number.isFinite(effectiveWatermarkRowid)
241+
? { highWatermarkRowid: effectiveWatermarkRowid }
242+
: {}),
243+
...(Number.isFinite(effectiveWatermarkMs) ? { highWatermarkMs: effectiveWatermarkMs } : {}),
220244
};
221245
};
222246

0 commit comments

Comments
 (0)