← back to Sample Followup Sweep
sent-stamp watcher: fold review findings (never overwrite a chase date)
d523fb1916a8e0cbb3b0ae043358665c4d56dac4 · 2026-09-26 08:56:41 -0700 · Steve Abrams
- covered-only is no longer terminal: a sibling item in the same letter
may not be in FileMaker yet, so the send keeps retrying.
- per-record read-before-write: skip any record that already carries a
chase date, independent of the already-stamped set; refuse to stamp a
send if that set hit its 800-row cap.
- an older unfinished send in backoff is pulled forward when a newer send
to the same recipient is due, so the older letter keeps its date.
Tests extended; negative fault injection still goes red. TK-12320
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NDCCv6ZcKPKfWxof41UzvC
Files touched
M scripts/sent-stamp-core.mjsM scripts/watch-sent-stamp.mjsM test/sent-stamp-retry.test.js
Diff
commit d523fb1916a8e0cbb3b0ae043358665c4d56dac4
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat Sep 26 08:56:41 2026 -0700
sent-stamp watcher: fold review findings (never overwrite a chase date)
- covered-only is no longer terminal: a sibling item in the same letter
may not be in FileMaker yet, so the send keeps retrying.
- per-record read-before-write: skip any record that already carries a
chase date, independent of the already-stamped set; refuse to stamp a
send if that set hit its 800-row cap.
- an older unfinished send in backoff is pulled forward when a newer send
to the same recipient is due, so the older letter keeps its date.
Tests extended; negative fault injection still goes red. TK-12320
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NDCCv6ZcKPKfWxof41UzvC
---
scripts/sent-stamp-core.mjs | 21 +++++++++++++++------
scripts/watch-sent-stamp.mjs | 6 ++++++
test/sent-stamp-retry.test.js | 16 ++++++++++++++--
3 files changed, 35 insertions(+), 8 deletions(-)
diff --git a/scripts/sent-stamp-core.mjs b/scripts/sent-stamp-core.mjs
index 5cad9e1..9da40e8 100644
--- a/scripts/sent-stamp-core.mjs
+++ b/scripts/sent-stamp-core.mjs
@@ -24,11 +24,11 @@ export function sendDateOf(msg, now) {
// Only a send that actually stamped something, with no failed commits, is finished. A skip
// (no vid mapping yet) or stamped:0 (mapping was wrong / records not visible yet) must be retried,
// otherwise a send that landed before its mapping existed is poisoned forever and re-chased.
-// `covered` = letter items found already stamped (by an earlier send of the same letter or another
-// path) — nothing left for this send to do, so it is finished too.
+// `covered` (letter items already stamped by an earlier send) deliberately does NOT finish a send:
+// a sibling item in the same letter may not be visible in FileMaker yet, and retries are cheap.
export function isTerminal(entry) {
if (!entry || entry.skip || Number(entry.failed) > 0) return false;
- return Number(entry.stamped) >= 1 || Number(entry.covered) >= 1;
+ return Number(entry.stamped) >= 1;
}
export function shouldProcess(entry, { now, retrySecs, dry }) {
@@ -97,11 +97,20 @@ export async function runPass({ msgs, ledger, now, dry, retrySecs, minAgeDays, m
let acted = 0, changed = false;
// Oldest send first: the FIRST letter that listed a memo owns its chase date; later resends then
// find the record already stamped instead of overwriting the slot with a later date.
- const ordered = [...msgs].sort((a, b) => sendDateOf(a, now).date - sendDateOf(b, now).date);
+ const ordered = msgs.filter(m => m && m.id && !m.error) // metadata fetch failed — try again next pass
+ .sort((a, b) => sendDateOf(a, now).date - sendDateOf(b, now).date);
+ const decision = new Map(ordered.map(m => [m.id, shouldProcess(seen[m.id], { now, retrySecs, dry })]));
+ // An older non-terminal send in backoff must not let a newer send to the same recipient take the
+ // chase date first: if any newer send is due, pull every older unfinished one forward with it.
+ const recip = m => (String(m.to || '').toLowerCase().match(/[\w.+-]+@[\w.-]+\.\w+/g) || []).sort().join(',');
+ const dueRecips = new Set(ordered.filter(m => decision.get(m.id).go).map(recip));
+ for (const m of ordered) {
+ const d = decision.get(m.id);
+ if (!d.go && d.why === 'backoff' && dueRecips.has(recip(m))) decision.set(m.id, { go: true, why: 'retry-ahead-of-newer' });
+ }
for (const m of ordered) {
- if (!m || !m.id || m.error) continue; // metadata fetch failed — try again next pass
const prev = seen[m.id];
- const d = shouldProcess(prev, { now, retrySecs, dry });
+ const d = decision.get(m.id);
if (!d.go) continue;
const vids = vidsForRecipient(deps.emailVid, m.to);
if (!vids.length) {
diff --git a/scripts/watch-sent-stamp.mjs b/scripts/watch-sent-stamp.mjs
index 1281216..641d694 100644
--- a/scripts/watch-sent-stamp.mjs
+++ b/scripts/watch-sent-stamp.mjs
@@ -82,6 +82,7 @@ async function stampVids(vids, bodyText, win, sentOn) {
let Bs;
try { Bs = await findAll(LAYOUT, { [FIELD_SENT]: '*', 'Date WP Sample Sent': '=', 'today for client': win }, 800); }
catch (e) { console.warn(` ⚠ FM already-stamped lookup failed (${win}): ${e.message}`); return { stamped: [], failed: 1, covered: 0 }; }
+ if (Bs.length >= 800) { console.warn(` ⚠ already-stamped lookup hit its 800 cap (${win}) — not stamping this send`); return { stamped: [], failed: 1, covered: 0 }; }
const already = new Set(Bs.map(r => String(r.recordId)));
const stamped = [], done = new Set(); let failed = 0, covered = 0;
for (const id of dryStamped) already.add(id); // dry: mirror what earlier sends in this pass would have stamped
@@ -96,6 +97,11 @@ async function stampVids(vids, bodyText, win, sentOn) {
done.add(id);
if (already.has(id)) { covered++; continue; } // already carries a chase date — never overwrite
if (DRY_RUN) { dryStamped.add(id); stamped.push(mfr + ' [dry]'); continue; }
+ // Last-line guard, independent of the set above: never overwrite an existing chase date.
+ let cur;
+ try { cur = await fm.getRecord(DB, LAYOUT, r.recordId); }
+ catch (e) { failed++; console.warn(` ⚠ FM read failed for ${mfr} (recordId ${r.recordId}): ${e.message}`); continue; }
+ if (String(cur?.fieldData?.[FIELD_SENT] || '').trim()) { covered++; continue; }
// Steve 8/25 asked for BOTH fields here, and at the time that was right: the 2nd-request
// field had no separate meaning, so writing it alongside was the only way to populate it.
// TK-11409 (Steve, 2026-09-10) gave it a real meaning — "a 2nd request was sent" — and this
diff --git a/test/sent-stamp-retry.test.js b/test/sent-stamp-retry.test.js
index c61226d..5b64849 100644
--- a/test/sent-stamp-retry.test.js
+++ b/test/sent-stamp-retry.test.js
@@ -82,9 +82,21 @@ const { pathToFileURL } = require('node:url');
await core.runPass({ msgs: [msgs[2], msgs[1], msgs[0]], ledger: {}, now: NOW, dry: true, retrySecs: 900, minAgeDays: 10, maxAgeDays: 60,
deps: { ...deps, stamp: async (v, body) => { order.push(body); return { stamped: [] }; } } });
assert.deepEqual(order, ['letter mvah', 'letter m1838', 'letter mkra'], 'must process oldest send first');
- // every letter item already stamped (covered) is finished; stamped:0 with nothing covered is not
- assert.equal(core.isTerminal({ vids: ['X'], stamped: 0, covered: 1, failed: 0 }), true);
+ // covered-only is NOT finished (a sibling letter item may not be in FM yet)
+ assert.equal(core.isTerminal({ vids: ['X'], stamped: 0, covered: 1, failed: 0 }), false);
assert.equal(core.isTerminal({ vids: ['X'], stamped: 0, covered: 0, failed: 0 }), false);
+ // an older send in backoff is pulled forward when a newer send to the same recipient is due,
+ // so the older letter still owns the chase date
+ {
+ const older = { id: 'old', to: 'a@v.com', internalDate: String(SENT_VAH) };
+ const newer = { id: 'new', to: 'A <a@v.com>', internalDate: String(SENT_KRA) };
+ const ev = core.buildEmailVid([{ slug: 'v', vid: 'V' }], { v: { sample_email: 'a@v.com' } });
+ const seenOrder = [];
+ const led = { old: { vids: ['V'], stamped: 0, last_try: new Date(NOW - 60e3).toISOString() } }; // in backoff
+ await core.runPass({ msgs: [newer, older], ledger: led, now: NOW, dry: false, retrySecs: 900, minAgeDays: 10, maxAgeDays: 60,
+ deps: { emailVid: ev, getBody: async id => id, stamp: async (v, body, w, sentOn) => { seenOrder.push(`${body}@${sentOn}`); return { stamped: [] }; } } });
+ assert.deepEqual(seenOrder, ['old@09/13/2026', 'new@09/24/2026'], 'older backoff send must run before the newer one');
+ }
// failed FM commit is NOT terminal even with stamped>=1
assert.equal(core.isTerminal({ stamped: 2, failed: 1 }), false);
// send-date fallbacks
← 523a32c watch-sent-stamp: refuse unknown args instead of running a l
·
back to Sample Followup Sweep
·
5x sweep 1: npm test runs .cjs suites and fails on any faili 32af305 →