[object Object]

← back to Agent Cabinet

activity /api/schedule: read real launchd state (loaded+disabled+lastExit), flag jobs that won't fire (disabled/unloaded) + nonzero-exit — stops the stdout-age false-stale crying wolf in both directions

5b3ec473ffa0ca7f56f8b04068c10e4abf585df0 · 2026-06-16 19:16:41 -0700 · Steve Abrams

Files touched

Diff

commit 5b3ec473ffa0ca7f56f8b04068c10e4abf585df0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jun 16 19:16:41 2026 -0700

    activity /api/schedule: read real launchd state (loaded+disabled+lastExit), flag jobs that won't fire (disabled/unloaded) + nonzero-exit — stops the stdout-age false-stale crying wolf in both directions
---
 activity.html | 11 ++++++++---
 server.js     | 37 +++++++++++++++++++++++++++++++++----
 2 files changed, 41 insertions(+), 7 deletions(-)

diff --git a/activity.html b/activity.html
index 1dafd4d..d2f8665 100644
--- a/activity.html
+++ b/activity.html
@@ -166,10 +166,15 @@ function renderSchedule(){
   let up=SCHED.upcoming, cont=SCHED.continuous;
   if(QUERY){ up=up.filter(e=>((e.label||'')+' '+(e.cadence||'')).toLowerCase().includes(QUERY)); cont=cont.filter(c=>((c.label||'')+' '+(c.cadence||'')).toLowerCase().includes(QUERY)); }
   const n=SHOW_ALL_UP?up.length:14;
-  let h='<div class="up-head">⏭ Upcoming <span class="s">— '+up.length+' scheduled jobs · next fire</span></div>';
-  h+=up.slice(0,n).map(e=>`<div class="up-row" data-label="${escA(e.label)}"><span class="rel">${rel(Date.parse(e.next_ts))}</span><span class="abs">${fmtAbs(e.next_ts)}</span><span class="lab">${esc(e.label)}</span><span class="cad">${esc(e.cadence)}</span></div>`).join('');
+  const wf=up.filter(e=>e.wontFire).length;
+  let h='<div class="up-head">⏭ Upcoming <span class="s">— '+(up.length-wf)+' will fire'+(wf?` · <span style="color:#fb7185">${wf} won't (disabled/unloaded)</span>`:'')+'</span></div>';
+  h+=up.slice(0,n).map(e=>{
+    const flag = e.wontFire ? `<span class="rel" style="color:#fb7185" title="${esc(e.reason||'')}">⚠ won't fire</span>`
+      : (e.lastExit && e.lastExit!==0 ? `<span class="rel" style="color:#fbbf24" title="last exit ${e.lastExit}">${rel(Date.parse(e.next_ts))} ⚠</span>` : `<span class="rel">${rel(Date.parse(e.next_ts))}</span>`);
+    return `<div class="up-row" data-label="${escA(e.label)}" style="${e.wontFire?'opacity:.5':''}">${flag}<span class="abs">${e.wontFire?esc(e.reason||''):fmtAbs(e.next_ts)}</span><span class="lab">${esc(e.label)}</span><span class="cad">${esc(e.cadence)}</span></div>`;
+  }).join('');
   if(up.length>14) h+=`<div class="up-more" onclick="SHOW_ALL_UP=!SHOW_ALL_UP;loadSchedule()">${SHOW_ALL_UP?'▲ show less':'▼ show all '+up.length}</div>`;
-  if(cont.length) h+='<div class="up-cont"><b style="color:#8a94a6">Continuous</b>'+cont.map(c=>`<span class="cc" data-label="${escA(c.label)}">${esc(c.label)} · ${esc(c.cadence)}</span>`).join('')+'</div>';
+  if(cont.length) h+='<div class="up-cont"><b style="color:#8a94a6">Continuous</b>'+cont.map(c=>`<span class="cc" data-label="${escA(c.label)}" style="${c.wontFire?'opacity:.5;border-color:#fb7185':''}" title="${c.wontFire?esc(c.reason||''):''}">${c.wontFire?'⚠ ':''}${esc(c.label)} · ${esc(c.cadence)}</span>`).join('')+'</div>';
   document.getElementById('upcoming').innerHTML=h;
 }
 // ── click any process / event → detail drawer ──
diff --git a/server.js b/server.js
index 51beba6..3176484 100644
--- a/server.js
+++ b/server.js
@@ -168,6 +168,26 @@ function calCadence(s) {
 }
 function humanInt(sec) { return sec % 3600 === 0 ? 'every ' + sec / 3600 + 'h' : sec % 60 === 0 ? 'every ' + sec / 60 + 'm' : 'every ' + sec + 's'; }
 
+// Real launchd state in 2 calls (NOT plist contents) — a job present in the plist
+// dir but absent from `launchctl list` is UNLOADED and will NOT fire, even though
+// its StartCalendarInterval still "computes" a next-fire. This is what kept the
+// schedule lane crying wolf in both directions (dead jobs shown as upcoming fires).
+function launchState() {
+  const loaded = new Map(); // label(no prefix) -> { pid, exit }
+  const disabled = new Set();
+  try {
+    execSync('launchctl list', { encoding: 'utf8' }).split('\n').forEach(l => {
+      const m = l.match(/^(-|\d+)\s+(-?\d+)\s+com\.steve\.(\S+)/);
+      if (m) loaded.set(m[3], { pid: m[1] === '-' ? null : +m[1], exit: +m[2] });
+    });
+  } catch (e) {}
+  try {
+    execSync('launchctl print-disabled gui/' + process.getuid() + ' 2>/dev/null', { encoding: 'utf8' })
+      .split('\n').forEach(l => { const m = l.match(/"com\.steve\.(\S+?)"\s*=>\s*disabled/); if (m) disabled.add(m[1]); });
+  } catch (e) {}
+  return { loaded, disabled };
+}
+
 function parseYaml(text) {
   // Tiny YAML subset parser — good enough for cabinet.yaml's shape.
   // Produces {president, cabinet:[{vp, domain, triggers:[], directors:[{skill?,subagent?,owns?}]}]}
@@ -770,11 +790,19 @@ const server = http.createServer((req, res) => {
     const now = Date.now(), upcoming = [], continuous = [];
     let files = [];
     try { files = fs.readdirSync(path.join(HOME, 'Library', 'LaunchAgents')).filter(f => /^com\.steve\..*\.plist$/.test(f)); } catch (e) {}
+    const { loaded, disabled } = launchState(); // real launchd state, not plist contents
     files.forEach(f => {
       let d; try { d = JSON.parse(execSync('plutil -convert json -o - "' + path.join(HOME, 'Library', 'LaunchAgents', f) + '"', { encoding: 'utf8' })); } catch (e) { return; }
       const label = (d.Label || f).replace(/^com\.steve\./, '');
+      // A job will only actually fire if it's loaded AND not disabled. Flag the rest
+      // so the dashboard shows "won't fire" instead of a phantom upcoming run.
+      const st = loaded.get(label);
+      const isDisabled = disabled.has(label);
+      const wontFire = !st || isDisabled;
+      const health = { loaded: !!st, disabled: isDisabled, lastExit: st ? st.exit : null,
+        wontFire, reason: isDisabled ? 'disabled' : (!st ? 'unloaded' : (st.exit && st.exit !== 0 ? 'last exit ' + st.exit : null)) };
       if (d.StartInterval) {
-        if (d.StartInterval >= 600) continuous.push({ label, cadence: humanInt(d.StartInterval), every_s: d.StartInterval });
+        if (d.StartInterval >= 600) continuous.push({ label, cadence: humanInt(d.StartInterval), every_s: d.StartInterval, ...health });
         return;
       }
       const sc = d.StartCalendarInterval;
@@ -782,10 +810,11 @@ const server = http.createServer((req, res) => {
       const specs = Array.isArray(sc) ? sc : [sc];
       let best = Infinity, spec = null;
       specs.forEach(s => { const t = nextCal(s, now); if (t < best) { best = t; spec = s; } });
-      if (spec) upcoming.push({ label, next_ts: new Date(best).toISOString(), cadence: Array.isArray(sc) ? (specs.length + '×/day') : calCadence(spec) });
+      if (spec) upcoming.push({ label, next_ts: new Date(best).toISOString(), cadence: Array.isArray(sc) ? (specs.length + '×/day') : calCadence(spec), ...health });
     });
-    upcoming.sort((a, b) => Date.parse(a.next_ts) - Date.parse(b.next_ts));
-    continuous.sort((a, b) => a.every_s - b.every_s);
+    // sort: firing jobs by next fire; won't-fire jobs sink to the bottom of the lane
+    upcoming.sort((a, b) => (a.wontFire - b.wontFire) || (Date.parse(a.next_ts) - Date.parse(b.next_ts)));
+    continuous.sort((a, b) => (a.wontFire - b.wontFire) || (a.every_s - b.every_s));
     return J({ ok: true, generated_at: new Date().toISOString(), upcoming, continuous });
   }
   if (u.pathname === '/api/job') {

← d170113 Activity viewer: FIX day-grouping used UTC date while times  ·  back to Agent Cabinet  ·  cabinet: approve cross-officer skill dw-legal-compliance und 7656ca5 →