[object Object]

← back to Butlr

live page: hide + reap stale zombie calls (age-based filter + 60s reaper → failed)

7b0d62cda01ccc7b5e8fb06258ef9ac051eec082 · 2026-05-26 10:12:26 -0700 · SteveStudio2

Files touched

Diff

commit 7b0d62cda01ccc7b5e8fb06258ef9ac051eec082
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Tue May 26 10:12:26 2026 -0700

    live page: hide + reap stale zombie calls (age-based filter + 60s reaper → failed)
---
 routes/live.js | 44 +++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 43 insertions(+), 1 deletion(-)

diff --git a/routes/live.js b/routes/live.js
index ef43e30..80917a0 100644
--- a/routes/live.js
+++ b/routes/live.js
@@ -10,6 +10,23 @@ const router = express.Router();
 const LIVE_STATUSES = new Set(['queued', 'dialing', 'on_hold', 'connected', 'ringing']);
 const LIVE_HOST = 'live.agentabrams.com';
 
+// Max plausible age (since last activity) for each non-terminal status. A call
+// that's been "dialing" for an hour is a zombie — its terminal Twilio callback
+// (busy/no-answer/completed) never arrived, so the row never left the live set.
+// Pre-pickup states resolve in well under a minute; an active call gets partial
+// callbacks every few seconds, so a long-stale updated_at means it's dead.
+const STALE_MS = {
+  queued: 3 * 60_000, dialing: 3 * 60_000, ringing: 3 * 60_000,
+  on_hold: 20 * 60_000, connected: 20 * 60_000,
+};
+function isStale(c) {
+  const ms = STALE_MS[c.status];
+  if (!ms) return false;
+  const last = new Date(c.updated_at || c.created_at).getTime();
+  if (!isFinite(last)) return false;
+  return (Date.now() - last) > ms;
+}
+
 function escapeHtml(s) {
   return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({
     '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
@@ -17,10 +34,35 @@ function escapeHtml(s) {
 }
 
 function pickLive(calls) {
-  return (calls || []).filter((c) => LIVE_STATUSES.has(c.status))
+  return (calls || []).filter((c) => LIVE_STATUSES.has(c.status) && !isStale(c))
     .sort((a, b) => new Date(b.updated_at || b.created_at) - new Date(a.updated_at || a.created_at));
 }
 
+// ── Stale-call reaper ─────────────────────────────────────────────────
+// Belt to the display filter's suspenders: actually mark zombie rows
+// terminal so they leave the dataset (and the cost ledger isn't waiting on
+// a callback that will never come). Runs at module load + every 60s.
+function reapStaleCalls() {
+  try {
+    const all = data.listCallsUnscoped();
+    let n = 0;
+    for (const c of all) {
+      if (LIVE_STATUSES.has(c.status) && isStale(c)) {
+        data.patchRow(c.id, {
+          status: 'failed',
+          notes: ((c.notes || '') + ' [stale-swept: stuck in ' + c.status + ', no terminal Twilio callback]').trim(),
+          stale_swept_at: new Date().toISOString(),
+        });
+        n++;
+      }
+    }
+    if (n) console.log('[live-reaper] swept ' + n + ' stale call(s) → failed');
+  } catch (e) { console.error('[live-reaper]', e.message); }
+}
+reapStaleCalls();
+const _reaperTimer = setInterval(reapStaleCalls, 60_000);
+if (_reaperTimer.unref) _reaperTimer.unref();
+
 function fmtAgo(ts) {
   try {
     const ms = Date.now() - new Date(ts).getTime();

← 40322db butlr: add voicemail-IVR language guard to auto-hangup when  ·  back to Butlr  ·  live page: fix completed-calls flash-disappear (scope live-g eeda3f9 →