social-autoposter 1.6.129 → 1.6.131

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mcp/dist/index.js CHANGED
@@ -2045,7 +2045,11 @@ async function autopilotLoaded() {
2045
2045
  }
2046
2046
  let auto_update_on = false;
2047
2047
  try {
2048
- const res = await run("launchctl", ["list"], { timeoutMs: 10_000 });
2048
+ // noTee: this status probe dumps the entire launchd job table (hundreds of
2049
+ // lines) and fires on every dashboard/status poll — teeing it flooded Cloud
2050
+ // Logging (~98% of an install's log volume). We only need the substring
2051
+ // check, so keep the output in-memory and out of the relay. (2026-06-28)
2052
+ const res = await run("launchctl", ["list"], { timeoutMs: 10_000, noTee: true });
2049
2053
  auto_update_on = res.stdout.split("\n").some((l) => l.includes(UPDATER_LABEL));
2050
2054
  }
2051
2055
  catch {
package/mcp/dist/repo.js CHANGED
@@ -100,7 +100,7 @@ export function run(cmd, args, opts = {}) {
100
100
  let sinkOutBuf = "";
101
101
  let sinkErrBuf = "";
102
102
  const sinkPump = (chunk, which, buf) => {
103
- const sink = lineSink;
103
+ const sink = opts.noTee ? null : lineSink;
104
104
  if (!sink)
105
105
  return buf;
106
106
  buf += chunk;
@@ -155,7 +155,7 @@ export function run(cmd, args, opts = {}) {
155
155
  /* ignore */
156
156
  }
157
157
  }
158
- const sink = lineSink;
158
+ const sink = opts.noTee ? null : lineSink;
159
159
  if (sink) {
160
160
  if (sinkOutBuf)
161
161
  try {
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.129",
3
- "installedAt": "2026-06-28T04:38:52.828Z"
2
+ "version": "1.6.131",
3
+ "installedAt": "2026-06-28T23:09:36.990Z"
4
4
  }
package/mcp/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "dxt_version": "0.1",
3
3
  "name": "social-autoposter",
4
4
  "display_name": "S4L",
5
- "version": "1.6.129",
5
+ "version": "1.6.131",
6
6
  "description": "Draft, review, approve, and autopilot X/Twitter posts.",
7
7
  "long_description": "The disclaimer above is generic Claude boilerplate. S4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L end to end**\n\n2\\. Quit fully with CMD+Q, restart Claude, and paste the prompt into a new chat.",
8
8
  "author": {
@@ -112,21 +112,12 @@ SETUP_PROMPT = (
112
112
  )
113
113
  DRAFT_PROMPT = "Run a social-autoposter draft cycle and show me the drafts to review."
114
114
  UPDATE_PROMPT = "Update social-autoposter to the latest version."
115
- # Re-arm after the draft autopilot's scheduled-task routines got orphaned most
116
- # often because the user logged Claude Desktop into a different account, which
117
- # leaves the routines registered only under the OLD account's session so nothing
118
- # drains the draft queue (the SKILL.md files persist globally, so the old presence
119
- # check stayed falsely "green"). The host's create_scheduled_task only exists
120
- # inside an interactive Desktop session, so recovery routes through the composer
121
- # like Setup/Draft do. Targeted: it does NOT redo X/login/project setup.
122
- REARM_PROMPT = (
123
- "The social-autoposter draft autopilot stopped running (its scheduled-task "
124
- "routines are missing under this Claude account). Re-arm it now: call "
125
- "queue_setup, then for EACH returned task call the host tool "
126
- "create_scheduled_task with its taskId, cronExpression, and prompt verbatim, "
127
- "then call run_draft_cycle once to verify a draft card appears. Do not redo my "
128
- "X connection or project setup."
129
- )
115
+ # NOTE: re-arm does NOT drive the Claude UI. Pasting a prompt to make the agent
116
+ # call create_scheduled_task proved unreliable (keystroke automation into Electron
117
+ # races focus/timing even with Accessibility granted). Instead, re-arm writes the
118
+ # two worker routines straight into the host's scheduled-tasks.json registry during
119
+ # a Claude restart (the same down-window mechanism the cwd-rewrite already uses) —
120
+ # deterministic, no Accessibility, works headless. See _rearm_work().
130
121
 
131
122
  # A pending draft job older than this (seconds) with nothing claiming it means no
132
123
  # routine is draining the queue — the worker would claim within a minute if it
@@ -255,6 +246,10 @@ class S4LMenuBar(rumps.App):
255
246
  # Cached stall flag (set each _tick) so the 1s activity poll can suppress a
256
247
  # stale "drafting" spinner that would otherwise mask the ⚠ in the title.
257
248
  self._stalled = False
249
+ # Cached (kind, detail) explaining a stall so the menu offers the right
250
+ # action: 'orphaned' -> Re-arm, 'rate_limited' -> wait/switch (no Re-arm),
251
+ # 'failing' -> generic. Recomputed each tick while stalled.
252
+ self._stall_reason_info = ("", "")
258
253
  self._reloc_timer = rumps.Timer(self._maybe_relocate_tasks, 90)
259
254
  self._reloc_timer.start()
260
255
  self._tick(None)
@@ -339,21 +334,161 @@ class S4LMenuBar(rumps.App):
339
334
  self._send_to_claude(DRAFT_PROMPT)
340
335
 
341
336
  def _rearm(self, _=None):
342
- self._send_to_claude(REARM_PROMPT)
343
-
344
- def _rearm_copy(self, _=None):
345
- """Always-works fallback: copy the re-arm prompt to the clipboard and open
346
- Claude, no Accessibility/automation needed. The user pastes it (⌘V) and
347
- presses Enter. This is the escape hatch for when the keystroke-paste path
348
- is blocked by a stale TCC grant."""
349
- copied = self._copy_to_clipboard(REARM_PROMPT)
350
- self._open_claude()
351
- self._notify(
352
- "S4L · re-arm prompt copied" if copied else "S4L",
353
- "Paste it into Claude (⌘V) and press Enter to re-arm the autopilot."
354
- if copied
355
- else "Open Claude and ask it to re-arm the draft autopilot.",
337
+ """One-click recovery: re-create the draft autopilot's scheduled-task
338
+ routines under the CURRENT Claude account, deterministically — no GUI
339
+ keystrokes, no Accessibility. The host caches its registry in memory and
340
+ clobbers live edits, so we must edit while Claude is DOWN; this therefore
341
+ restarts Claude. Heavy work + the restart run off the main thread."""
342
+ self._notify("S4L", "Re-arming autopilot… Claude will restart briefly.")
343
+ threading.Thread(target=self._rearm_work, daemon=True).start()
344
+
345
+ def _rearm_work(self):
346
+ try:
347
+ # Bring Claude down so registry edits stick (it reloads the file fresh
348
+ # at launch; a live edit is clobbered on the next scheduler fire).
349
+ subprocess.run(["osascript", "-e", 'tell application "Claude" to quit'],
350
+ capture_output=True, timeout=20)
351
+ time.sleep(4)
352
+ subprocess.run(["killall", "Claude"], capture_output=True)
353
+ time.sleep(2)
354
+ subprocess.run(["killall", "-9", "Claude"], capture_output=True)
355
+ time.sleep(1)
356
+ n = self._ensure_routines_registered()
357
+ # Keep cwd correct + drop the deprecated task while we're down.
358
+ self._rewrite_scheduled_task_cwd()
359
+ subprocess.run(["open", "-a", CLAUDE_APP], capture_output=True, timeout=20)
360
+ self._sig = None
361
+ if n:
362
+ self._notify(
363
+ "S4L re-armed",
364
+ "Draft autopilot routines restored. Drafts resume within a few minutes.",
365
+ )
366
+ else:
367
+ self._notify(
368
+ "S4L",
369
+ "Couldn't find where to register routines. Try again, or sign back "
370
+ "into the original Claude account.",
371
+ )
372
+ except Exception as e:
373
+ self._notify("S4L re-arm failed", str(e)[:140])
374
+ _capture(e, phase="rearm")
375
+
376
+ # ---- re-arm: write routines straight into the host registry -----------
377
+ def _worker_record_templates(self):
378
+ """Clone existing host-authored worker records (so the shape is EXACTLY what
379
+ the scheduler accepts). Returns {taskId: record_dict}. Empty if none found
380
+ (e.g. a box that never had them)."""
381
+ out = {}
382
+ try:
383
+ for f in glob.glob(SCHED_REGISTRY_GLOB):
384
+ try:
385
+ with open(f) as fh:
386
+ d = json.load(fh)
387
+ except Exception:
388
+ continue
389
+ for t in d.get("scheduledTasks", []) or []:
390
+ tid = t.get("id")
391
+ if tid in WORKER_TASK_IDS and tid not in out:
392
+ out[tid] = dict(t)
393
+ except Exception:
394
+ pass
395
+ return out
396
+
397
+ def _make_worker_record(self, tid, template):
398
+ """Build a routine record. Prefer cloning a host-authored template (exact
399
+ accepted shape); override the fields that must be right for this account."""
400
+ rec = dict(template) if template else {}
401
+ rec["id"] = tid
402
+ rec["cronExpression"] = "* * * * *"
403
+ rec["enabled"] = True
404
+ rec["filePath"] = os.path.join(
405
+ os.path.expanduser("~"), ".claude", "scheduled-tasks", tid, "SKILL.md"
356
406
  )
407
+ rec["cwd"] = WORKER_CWD
408
+ rec.setdefault("createdAt", int(time.time() * 1000))
409
+ # Let the host recompute run-history for this account.
410
+ for k in ("lastRunAt", "lastScheduledFor", "fireAt"):
411
+ rec.pop(k, None)
412
+ return rec
413
+
414
+ def _session_registry_targets(self):
415
+ """Where to write routines: every existing scheduled-tasks.json (fix in
416
+ place) PLUS the most-recently-active session dir (the live account, which
417
+ may have no registry yet). Covers whichever account is logged in without
418
+ having to know which it is."""
419
+ targets = set(glob.glob(SCHED_REGISTRY_GLOB))
420
+ base = os.path.join(
421
+ os.path.expanduser("~"), "Library", "Application Support", "Claude",
422
+ "claude-code-sessions",
423
+ )
424
+ best, best_m = None, -1.0
425
+ try:
426
+ for ws in os.listdir(base):
427
+ wsp = os.path.join(base, ws)
428
+ if not os.path.isdir(wsp):
429
+ continue
430
+ for inner in os.listdir(wsp):
431
+ ip = os.path.join(wsp, inner)
432
+ if not os.path.isdir(ip):
433
+ continue
434
+ try:
435
+ m = os.path.getmtime(ip)
436
+ except OSError:
437
+ continue
438
+ if m > best_m:
439
+ best_m, best = m, ip
440
+ except Exception:
441
+ pass
442
+ if best:
443
+ targets.add(os.path.join(best, "scheduled-tasks.json"))
444
+ return sorted(targets)
445
+
446
+ def _ensure_routines_registered(self):
447
+ """Create the two queue-worker routines (cwd=~/.s4l-worker, enabled) in the
448
+ live account's registry — recovering an account-switch orphan WITHOUT the
449
+ Claude UI. Caller MUST run this while Claude is DOWN. Best-effort; never
450
+ raises. Returns the number of registry files written."""
451
+ templates = self._worker_record_templates()
452
+ try:
453
+ os.makedirs(WORKER_CWD, exist_ok=True)
454
+ except Exception:
455
+ pass
456
+ written = 0
457
+ for f in self._session_registry_targets():
458
+ try:
459
+ if os.path.exists(f):
460
+ with open(f) as fh:
461
+ d = json.load(fh)
462
+ else:
463
+ d = {"scheduledTasks": [], "recordedSkips": {}}
464
+ except Exception:
465
+ d = {"scheduledTasks": [], "recordedSkips": {}}
466
+ tasks = d.get("scheduledTasks") or []
467
+ by_id = {t.get("id"): t for t in tasks}
468
+ dirty = False
469
+ for tid in WORKER_TASK_IDS:
470
+ if tid in by_id:
471
+ t = by_id[tid]
472
+ if not t.get("enabled") or t.get("cwd") != WORKER_CWD:
473
+ t["enabled"] = True
474
+ t["cwd"] = WORKER_CWD
475
+ dirty = True
476
+ else:
477
+ tasks.append(self._make_worker_record(tid, templates.get(tid)))
478
+ dirty = True
479
+ if not dirty:
480
+ continue
481
+ d["scheduledTasks"] = tasks
482
+ try:
483
+ os.makedirs(os.path.dirname(f), exist_ok=True)
484
+ fd, tmp = tempfile.mkstemp(dir=os.path.dirname(f))
485
+ with os.fdopen(fd, "w") as fh:
486
+ json.dump(d, fh, indent=2)
487
+ os.replace(tmp, f)
488
+ written += 1
489
+ except Exception:
490
+ pass
491
+ return written
357
492
 
358
493
  # ---- autopilot liveness (the false-green fix) -------------------------
359
494
  def _autopilot_stalled(self):
@@ -398,6 +533,57 @@ class S4LMenuBar(rumps.App):
398
533
  pass
399
534
  return False
400
535
 
536
+ def _recent_worker_outcome(self, window=600):
537
+ """Inspect worker transcripts written in the last `window` seconds (the
538
+ ~/.s4l-worker bucket). Returns (ran, rate_limit_msg):
539
+ ran — a routine actually EXECUTED recently (a worker that runs
540
+ leaves a transcript; an orphaned/not-firing account leaves
541
+ none). This is what tells "routines fire but fail" apart
542
+ from "routines gone".
543
+ rate_limit_msg— set when a recent run hit the Claude weekly/usage limit
544
+ (re-arm cannot fix that); carries a short 'resets …' string.
545
+ Account-agnostic on purpose: it keys off actual execution, not a per-account
546
+ lastRunAt that freezes (and lies) after an account switch."""
547
+ ran = False
548
+ limit_msg = None
549
+ try:
550
+ now = time.time()
551
+ files = glob.glob(
552
+ os.path.expanduser("~/.claude/projects/*s4l-worker*/*.jsonl")
553
+ )
554
+ recent = [f for f in files if (now - os.path.getmtime(f)) <= window]
555
+ recent.sort(key=os.path.getmtime, reverse=True)
556
+ if recent:
557
+ ran = True
558
+ for f in recent[:5]:
559
+ try:
560
+ txt = open(f).read()
561
+ except Exception:
562
+ continue
563
+ low = txt.lower()
564
+ if "weekly limit" in low or "usage limit" in low or "hit your limit" in low:
565
+ import re
566
+ m = re.search(r"resets [^\"\\]{0,40}", txt)
567
+ limit_msg = m.group(0).strip().rstrip(".") if m else "Claude usage limit reached"
568
+ break
569
+ except Exception:
570
+ pass
571
+ return ran, limit_msg
572
+
573
+ def _stall_reason(self):
574
+ """Why drafts aren't draining, so the menu offers the RIGHT action:
575
+ ('orphaned', '') routines aren't firing -> Re-arm fixes it.
576
+ ('rate_limited', msg) routines fire but the account hit its Claude
577
+ limit -> Re-arm is useless; wait/switch account.
578
+ ('failing', '') routines fire but drafts fail for another reason.
579
+ Only meaningful when _autopilot_stalled() is True."""
580
+ ran, limit_msg = self._recent_worker_outcome()
581
+ if limit_msg:
582
+ return ("rate_limited", limit_msg)
583
+ if not ran:
584
+ return ("orphaned", "")
585
+ return ("failing", "")
586
+
401
587
  def _toggle_mode(self, _=None):
402
588
  """Flip personal-brand <-> promotion. Pure local state write (no model,
403
589
  no network): the cycle reads mode.json on its next run. Rebuild the menu
@@ -887,6 +1073,11 @@ class S4LMenuBar(rumps.App):
887
1073
  # which would otherwise own the title and hide the ⚠. _poll_activity reads
888
1074
  # this to drop that stale spinner.
889
1075
  self._stalled = stalled
1076
+ # When stalled, work out WHY so the menu (and this notification) offer the
1077
+ # right action — re-arm only helps when routines aren't firing, not when
1078
+ # they fire but hit the Claude usage limit. Only computed while stalled
1079
+ # (it reads worker transcripts), and cached for _build_menu.
1080
+ self._stall_reason_info = self._stall_reason() if stalled else ("", "")
890
1081
 
891
1082
  # Spinner owns the title while busy; _spin already keeps the ⬆ visible there.
892
1083
  if not busy:
@@ -899,14 +1090,27 @@ class S4LMenuBar(rumps.App):
899
1090
  blocker.get("message", "Setup is blocked"),
900
1091
  )
901
1092
  self._last_blocker_code = blocker_code
902
- # Notify once on the transition into a stall, with the one action that fixes
903
- # it. Re-arms are user-driven (composer), so we don't spam every tick.
1093
+ # Notify once on the transition into a stall, with the cause-specific action.
904
1094
  if stalled and not self._stall_notified:
905
- self._notify(
906
- "S4L autopilot not running",
907
- "No drafts are being generated (routines missing — did Claude's "
908
- "account change?). Open the S4L menu and click “Re-arm autopilot”.",
909
- )
1095
+ kind, detail = self._stall_reason_info
1096
+ if kind == "rate_limited":
1097
+ self._notify(
1098
+ "S4L autopilot paused",
1099
+ "Claude usage limit reached" + (f" ({detail})" if detail else "")
1100
+ + ". Drafting resumes when the limit resets, or sign into a "
1101
+ "Claude account with quota.",
1102
+ )
1103
+ elif kind == "orphaned":
1104
+ self._notify(
1105
+ "S4L autopilot not running",
1106
+ "The draft routines aren't running (did Claude's account "
1107
+ "change?). Open the S4L menu and click “Re-arm autopilot”.",
1108
+ )
1109
+ else:
1110
+ self._notify(
1111
+ "S4L autopilot stalled",
1112
+ "Drafts aren't being produced. Open the S4L menu for options.",
1113
+ )
910
1114
  self._stall_notified = True
911
1115
  elif not stalled:
912
1116
  self._stall_notified = False
@@ -935,6 +1139,7 @@ class S4LMenuBar(rumps.App):
935
1139
  snap.get("projects_total"),
936
1140
  st.read_mode(),
937
1141
  stalled,
1142
+ self._stall_reason_info,
938
1143
  )
939
1144
  if sig != self._sig:
940
1145
  self._sig = sig
@@ -1194,13 +1399,21 @@ class S4LMenuBar(rumps.App):
1194
1399
  # account switch): surface the one-click recovery FIRST, above the normal
1195
1400
  # state items, so it's the obvious next action.
1196
1401
  if stalled:
1197
- items.append(self._label("⚠ Autopilot not running — no drafts"))
1198
- items.append(rumps.MenuItem("Re-arm autopilot", callback=self._rearm))
1199
- # Always-works fallback when the keystroke-paste path is blocked by a
1200
- # stale Accessibility grant: copy the prompt so the user can paste it.
1201
- items.append(
1202
- rumps.MenuItem("Re-arm: copy prompt (paste in Claude)", callback=self._rearm_copy)
1203
- )
1402
+ kind, detail = self._stall_reason_info
1403
+ if kind == "rate_limited":
1404
+ # Routines ARE firing; the account hit its Claude limit. Re-arm
1405
+ # cannot fix that, so DON'T offer it show the cause instead.
1406
+ items.append(self._label("⚠ Autopilot paused — Claude usage limit"))
1407
+ if detail:
1408
+ items.append(self._label(f" {detail}"))
1409
+ items.append(self._label(" Resumes at reset, or switch Claude account"))
1410
+ elif kind == "orphaned":
1411
+ # Routines aren't firing -> re-arm re-creates them.
1412
+ items.append(self._label("⚠ Autopilot not running — no drafts"))
1413
+ items.append(rumps.MenuItem("Re-arm autopilot", callback=self._rearm))
1414
+ else: # 'failing' — routines fire but drafts fail for another reason
1415
+ items.append(self._label("⚠ Autopilot stalled — drafts failing"))
1416
+ items.append(rumps.MenuItem("Re-arm autopilot", callback=self._rearm))
1204
1417
  items.append(rumps.separator)
1205
1418
 
1206
1419
  if not runtime_ready:
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/social-autoposter-mcp",
3
- "version": "1.6.129",
3
+ "version": "1.6.131",
4
4
  "private": true,
5
5
  "description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
6
6
  "license": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.6.129",
3
+ "version": "1.6.131",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js"
@@ -89,6 +89,32 @@ def _consecutive_timeouts() -> int:
89
89
  return 0
90
90
 
91
91
 
92
+ def _recent_rate_limit(window: int = 1200) -> bool:
93
+ """True if a worker run in the last `window` seconds hit the Claude weekly/usage
94
+ limit. That stall is EXPECTED and auto-resets, so it must NOT page Sentry —
95
+ paging would be pure noise. Reads the ~/.s4l-worker transcript bucket."""
96
+ try:
97
+ now = time.time()
98
+ files = glob.glob(
99
+ os.path.expanduser("~/.claude/projects/*s4l-worker*/*.jsonl")
100
+ )
101
+ recent = sorted(
102
+ (f for f in files if (now - os.path.getmtime(f)) <= window),
103
+ key=os.path.getmtime,
104
+ reverse=True,
105
+ )[:5]
106
+ for f in recent:
107
+ try:
108
+ low = open(f).read().lower()
109
+ except Exception:
110
+ continue
111
+ if "weekly limit" in low or "usage limit" in low or "hit your limit" in low:
112
+ return True
113
+ except Exception:
114
+ pass
115
+ return False
116
+
117
+
92
118
  def _oldest_pending_age() -> float | None:
93
119
  """Seconds since the oldest unclaimed pending draft job was written, or None
94
120
  if nothing is pending (idle queue). The FAST signal: catches a fresh stall
@@ -149,6 +175,11 @@ def main() -> int:
149
175
  stalled = _autopilot_configured() and (
150
176
  timeouts >= 1 or (age is not None and age > STALL_SECONDS)
151
177
  )
178
+ # A rate-limit stall is expected and self-heals at the quota reset — never page
179
+ # for it (and re-arm can't fix it). Treat it as "not an actionable stall" so the
180
+ # episode resets and a LATER real stall (orphaned routines) still alerts.
181
+ if stalled and _recent_rate_limit():
182
+ stalled = False
152
183
 
153
184
  st = _read_state()
154
185
  consecutive = int(st.get("consecutive", 0))
@@ -424,6 +424,21 @@ def claude_queue_summary() -> dict[str, Any]:
424
424
  }
425
425
  except OSError:
426
426
  pass
427
+ # The producer's drain latch: consecutive_timeouts>=1 means the scheduled-task
428
+ # worker stopped draining (the definitive phase2b-stall signal). Surfacing it
429
+ # here lets the heartbeat carry it server-side, so a stall is visible centrally
430
+ # without SSHing the box. See claude_job.py _bump_drain_timeout / _clear_drain.
431
+ drain_path = root / "drain-status.json"
432
+ if drain_path.exists():
433
+ try:
434
+ ds = json.loads(drain_path.read_text())
435
+ summary["drain_status"] = {
436
+ "consecutive_timeouts": int(ds.get("consecutive_timeouts", 0) or 0),
437
+ "last_success_at": ds.get("last_success_at"),
438
+ "last_timeout_at": ds.get("last_timeout_at"),
439
+ }
440
+ except (OSError, ValueError, TypeError):
441
+ pass
427
442
  return summary
428
443
 
429
444
 
@@ -603,6 +618,14 @@ def build_summary() -> dict[str, Any]:
603
618
  else None
604
619
  )
605
620
  cq = claude_queue_summary()
621
+ ds = cq.get("drain_status") or {}
622
+ oldest = cq.get("oldest_age_sec")
623
+ consec = int(ds.get("consecutive_timeouts", 0) or 0)
624
+ # Mirror the MCP's autopilotStalled(): a latched producer timeout, OR a draft
625
+ # job that has sat unclaimed past 180s, means no scheduled-task worker is
626
+ # draining the queue. Carrying this on the heartbeat makes a phase2b stall
627
+ # visible in installation_resource_samples without SSHing the box.
628
+ stalled = bool(consec >= 1 or (isinstance(oldest, (int, float)) and oldest > 180))
606
629
  return {
607
630
  "ts": dt.datetime.now(dt.timezone.utc).astimezone().isoformat(timespec="seconds"),
608
631
  "hostname": socket.gethostname(),
@@ -621,6 +644,10 @@ def build_summary() -> dict[str, Any]:
621
644
  "claude_queue": {
622
645
  "pending": cq.get("pending_total", 0),
623
646
  "running": cq.get("running_total", 0),
647
+ "oldest_age_sec": oldest,
648
+ "stalled": stalled,
649
+ "consecutive_timeouts": consec,
650
+ "last_success_at": ds.get("last_success_at"),
624
651
  },
625
652
  }
626
653