← 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) => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
@@ -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 →