social-autoposter 1.6.63 → 1.6.64

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.
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.63",
3
- "installedAt": "2026-06-05T03:14:19.604Z"
2
+ "version": "1.6.64",
3
+ "installedAt": "2026-06-05T16:30:35.428Z"
4
4
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.6.63",
3
+ "version": "1.6.64",
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"
@@ -517,12 +517,29 @@ def _fetch_drafts(limit: int = 40) -> list:
517
517
 
518
518
  # --- cycle-log -> friendly status -------------------------------------------
519
519
 
520
+ def _safe_mtime(p: str) -> float:
521
+ """getmtime that tolerates the file vanishing mid-scan (log rotation race).
522
+
523
+ The watch loop runs forever while cycles rotate/delete logs underneath it.
524
+ A bare os.path.getmtime on a path that disappeared between the glob and the
525
+ stat raises FileNotFoundError and (previously) killed the whole watcher,
526
+ dropping the overlay until something restarted it. Treat a gone file as
527
+ infinitely old so it just loses the max() race instead of crashing.
528
+ """
529
+ try:
530
+ return os.path.getmtime(p)
531
+ except OSError:
532
+ return 0.0
533
+
534
+
520
535
  def _latest_cycle_log() -> Path | None:
521
536
  files = glob.glob(str(LOG_DIR / "twitter-cycle-*.log"))
522
537
  if not files:
523
538
  return None
524
- newest = max(files, key=lambda f: os.path.getmtime(f))
525
- return Path(newest)
539
+ newest = max(files, key=_safe_mtime)
540
+ # The winner could STILL have been deleted between selection and use; the
541
+ # caller (_current_status) stats it again, so hand back None if it's gone.
542
+ return Path(newest) if os.path.exists(newest) else None
526
543
 
527
544
 
528
545
  _RE_SCAN = re.compile(r"project='([^']+)'\s+q=(['\"])(.*?)\2\s+kept=(\d+)")
@@ -575,7 +592,7 @@ def _current_status() -> str:
575
592
  log = _latest_cycle_log()
576
593
  if not log:
577
594
  return "Idle \u2014 waiting for the next cycle\u2026"
578
- age = time.time() - os.path.getmtime(log)
595
+ age = time.time() - _safe_mtime(str(log))
579
596
  if age > IDLE_AFTER_SEC:
580
597
  return "Idle \u2014 waiting for the next cycle\u2026"
581
598
  return _tail_last_meaningful(log) or "Working\u2026"
@@ -638,7 +655,12 @@ def cmd_watch(interval: float = 2.0) -> int:
638
655
  last_sb_sig = None
639
656
  try:
640
657
  while True:
641
- status = _current_status()
658
+ # Never let status computation (log globbing/stat, all racing against
659
+ # live log rotation) kill the watcher; fall back to a neutral status.
660
+ try:
661
+ status = _current_status()
662
+ except Exception:
663
+ status = "Working\u2026"
642
664
  try:
643
665
  if h is None:
644
666
  h = Harness().__enter__()
@@ -192,24 +192,57 @@ _acquire_linkedin_pipeline_lock() {
192
192
  return 0
193
193
  fi
194
194
  local _who="${SAPS_PIPELINE_NAME:-$(basename "${0:-linkedin-pipeline}")}"
195
+ # WAIT, don't bail. Aligned 2026-06-04 with Twitter's skill/lock.sh::acquire_lock
196
+ # so an overlapping LinkedIn fire (e.g. stats-linkedin's :23 slot landing on
197
+ # top of run-linkedin's :14 post still finishing) queues for the 9556 Chrome
198
+ # instead of `exit 0`-skipping. Previously the live-holder branch bailed, which
199
+ # systematically starved stats-linkedin every time its slot collided with the
200
+ # ~9min post pipeline, so comment-stats never refreshed. Bounded by a timeout
201
+ # (default 1h) and a 3h lock-age safety net, matching lock.sh.
202
+ local _waited=0
203
+ local _timeout="${SAPS_LINKEDIN_PIPELINE_LOCK_TIMEOUT:-3600}"
204
+ local _logged_wait=false
195
205
  while : ; do
196
206
  if mkdir "$_LI_PIPELINE_LOCK_DIR" 2>/dev/null; then
197
207
  echo "$$" > "$_LI_PIPELINE_LOCK_DIR/pid"
198
208
  echo "$_who" > "$_LI_PIPELINE_LOCK_DIR/holder"
199
209
  export _LI_PIPELINE_LOCK_HELD=1
200
- echo "[$(date +%H:%M:%S)] linkedin-pipeline lock ACQUIRED by $_who (pid $$)" >&2
210
+ echo "[$(date +%H:%M:%S)] linkedin-pipeline lock ACQUIRED by $_who (pid $$) waited=${_waited}s" >&2
201
211
  return 0
202
212
  fi
203
213
  local _h_pid _h_who
204
214
  _h_pid="$(cat "$_LI_PIPELINE_LOCK_DIR/pid" 2>/dev/null || echo "")"
205
215
  _h_who="$(cat "$_LI_PIPELINE_LOCK_DIR/holder" 2>/dev/null || echo "?")"
216
+ # Reclaim a stale lock left by a dead holder (no release trap by design).
206
217
  if [ -z "$_h_pid" ] || ! kill -0 "$_h_pid" 2>/dev/null; then
207
218
  echo "[$(date +%H:%M:%S)] linkedin-pipeline lock: reclaiming stale lock (dead holder ${_h_who} pid ${_h_pid:-unknown})" >&2
208
219
  rm -rf "$_LI_PIPELINE_LOCK_DIR"
209
220
  continue
210
221
  fi
211
- echo "[$(date +%H:%M:%S)] linkedin-pipeline lock: held by ${_h_who} (pid ${_h_pid}); ${_who} exiting this fire to avoid two drivers on the 9556 Chrome" >&2
212
- exit 0
222
+ # Safety net: reclaim any lock older than 3h regardless of holder liveness.
223
+ # watchdog_hung_runs.py SIGTERMs a hung holder long before this fires;
224
+ # mirrors the 10800s ceiling in lock.sh::acquire_lock.
225
+ if [ -d "$_LI_PIPELINE_LOCK_DIR" ]; then
226
+ local _lock_age
227
+ _lock_age=$(( $(date +%s) - $(stat -f %m "$_LI_PIPELINE_LOCK_DIR" 2>/dev/null || stat -c %Y "$_LI_PIPELINE_LOCK_DIR" 2>/dev/null || date +%s) ))
228
+ if [ "$_lock_age" -gt 10800 ]; then
229
+ echo "[$(date +%H:%M:%S)] linkedin-pipeline lock: removing lock older than 3h (age ${_lock_age}s, holder ${_h_who})" >&2
230
+ rm -rf "$_LI_PIPELINE_LOCK_DIR"
231
+ continue
232
+ fi
233
+ fi
234
+ # Timed out waiting -> skip this fire (launchd will re-fire next slot).
235
+ if [ "$_waited" -ge "$_timeout" ]; then
236
+ echo "[$(date +%H:%M:%S)] linkedin-pipeline lock: still held by ${_h_who} (pid ${_h_pid}) after $((_timeout/60))min; ${_who} skipping this fire" >&2
237
+ exit 0
238
+ fi
239
+ # Surface the holder once when we first start waiting.
240
+ if [ "$_logged_wait" = "false" ]; then
241
+ echo "[$(date +%H:%M:%S)] linkedin-pipeline lock: held by ${_h_who} (pid ${_h_pid}); ${_who} waiting (timeout $((_timeout/60))min)..." >&2
242
+ _logged_wait=true
243
+ fi
244
+ sleep 2
245
+ _waited=$((_waited + 2))
213
246
  done
214
247
  }
215
248