← 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