← back to Dw Marketing Reels
scripts/retry-publish.mjs
116 lines
#!/usr/bin/env node
/**
* retry-publish.mjs — TK-10395 delayed re-queue.
*
* The nightly 07:10 run can fail on Meta-side error 2207076 (media PROCESSING flakiness,
* NOT credentials — token verified healthy 2026-08-11). A few fast in-run retries don't
* reliably beat an intermittent Meta processing failure; the robust fix is to re-attempt the
* SAME reel a few hours LATER, when Meta's processing has recovered.
*
* This runs on a delayed schedule (a few slots after 07:10). Each run:
* - newest reel already posted/simulated -> nothing to do (exit 0)
* - not armed -> nothing to do (exit 0)
* - armed + not-landed + under the cap -> re-run publish-social.mjs (which re-attempts an
* 'error' reel — the dup-guard only skips a genuine
* success, so this is double-post-safe), bump counter
* - armed + not-landed + AT the cap -> fire ONE "gave up after N delayed retries" alert
*
* Reuses: publish-social.mjs (re-attempt + Norma's own 3x in-run retry), the dup-guard
* (double-post-safe), and the same CNCP + George alert path as cron-run.sh.
*
* The delayed-retry counter lives at reels[0].publish.instagram_retry (a SIBLING key that
* publish-social.mjs never overwrites — it only writes .instagram / .tiktok).
*
* Env: SOCIAL_LIVE_ARMED (arm gate, same as publisher), REELS_RETRY_MAX (default 4),
* REELS_ALERT_TO, CNCP_URL. $0 — no paid APIs.
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const MAN = join(ROOT, 'data', 'reels.json');
const ARMED = process.env.SOCIAL_LIVE_ARMED === '1';
const MAX = Number(process.env.REELS_RETRY_MAX || 4);
const now = () => new Date().toISOString();
const log = (m) => console.log(`[retry-publish] ${m}`);
const LANDED = new Set(['posted', 'already-posted', 'simulated', 'held-claims-review']);
function load() { return JSON.parse(readFileSync(MAN, 'utf8')); }
function list(r) { return Array.isArray(r) ? r : (r.reels || r.items || []); }
function fireAlert(msg) {
// Same channel as cron-run.sh: CNCP parking-lot card + George email to steve-office.
// msg is passed via ALERT_MSG env var — no shell-escaping of untrusted content needed.
const cncp = process.env.CNCP_URL || 'http://localhost:3333';
const to = process.env.REELS_ALERT_TO || 'steve@designerwallcoverings.com';
const sh = `
curl -sS --max-time 10 "${cncp}/api/parking-lot" -H 'Content-Type: application/json' \
-d "$(node -e 'console.log(JSON.stringify({url:"http://marketing.designerwallcoverings.com/",note:process.env.ALERT_MSG}))')" \
>/dev/null 2>&1 && echo " CNCP card posted" || echo " CNCP post failed (non-fatal)";
if [ -f "$HOME/.claude/skills/_shared/george-send.sh" ]; then
. "$HOME/.claude/skills/_shared/george-send.sh";
george_send steve-office "${to}" "DW nightly reel -- gave up after delayed retries ($(date +%Y-%m-%d))" "<p>$ALERT_MSG</p>" >/dev/null 2>&1 \
&& echo " George alert sent" || echo " George send failed (non-fatal)";
fi`;
spawnSync('bash', ['-c', sh], { stdio: 'inherit', env: { ...process.env, ALERT_MSG: msg } });
}
let reels = load();
let arr = list(reels);
if (!arr.length) { log('no reels — nothing to retry'); process.exit(0); }
let reel = arr[0];
const ig = (reel.publish && reel.publish.instagram) || {};
if (LANDED.has(ig.status)) { log(`newest reel already ${ig.status} — nothing to retry`); process.exit(0); }
if (ig.status === 'posted-unverified') { log('newest reel posted-unverified (outcome unknown) — leaving for manual verify, not re-firing'); process.exit(0); }
if (!ARMED) { log(`not armed — nothing to retry (status=${ig.status || 'none'})`); process.exit(0); }
// Not landed + armed. Manage the delayed-retry counter.
reel.publish = reel.publish || {};
const rq = reel.publish.instagram_retry || { delayed_attempts: 0, gave_up: false, history: [] };
if (rq.delayed_attempts >= MAX) {
if (!rq.gave_up) {
rq.gave_up = true; rq.gave_up_at = now();
reel.publish.instagram_retry = rq;
writeFileSync(MAN, JSON.stringify(reels, null, 2));
const msg = `TK-10395: ${reel.file} still not posted after ${MAX} delayed re-queue attempts (Meta 2207076 persisted all day). Manual attention needed — creds are healthy, this is Meta-side processing. Last: ${ig.status}${ig.note ? ' — ' + ig.note : ''}.`;
log(`cap reached (${MAX}) — firing give-up alert`);
fireAlert(msg);
} else {
log(`cap reached (${MAX}) and already alerted — silent`);
}
process.exit(0);
}
// Under the cap → re-attempt via the real publisher (double-post-safe: dup-guard skips only a
// genuine success; an 'error'/failed reel is re-attempted, and Norma runs its own 3x in-run retry).
rq.delayed_attempts += 1;
rq.last_attempt_at = now();
reel.publish.instagram_retry = rq;
writeFileSync(MAN, JSON.stringify(reels, null, 2));
log(`delayed re-attempt ${rq.delayed_attempts}/${MAX} for ${reel.file} (prev status=${ig.status})`);
const res = spawnSync('node', ['scripts/publish-social.mjs'], {
cwd: ROOT, stdio: 'inherit',
env: { ...process.env, SOCIAL_AUTOPOST: '1' },
});
// Re-read outcome.
reels = load(); arr = list(reels); reel = arr[0];
const ig2 = (reel.publish && reel.publish.instagram) || {};
// Preserve the counter (publish-social rewrote .instagram but not our sibling key).
reel.publish.instagram_retry = { ...rq };
if (LANDED.has(ig2.status)) {
reel.publish.instagram_retry.recovered_at = now();
writeFileSync(MAN, JSON.stringify(reels, null, 2));
log(`recovered on delayed attempt ${rq.delayed_attempts}/${MAX} — status=${ig2.status}`);
process.exit(0);
}
writeFileSync(MAN, JSON.stringify(reels, null, 2));
log(`still not landed after delayed attempt ${rq.delayed_attempts}/${MAX} — status=${ig2.status}. Will re-try next slot (or give up at cap).`);
process.exit(0); // silent between attempts; the give-up alert fires only at the cap