@m13v/s4l 1.7.6-rc.5 → 1.7.6-rc.6

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
@@ -909,6 +909,11 @@ async function ensureTwitterBrowserForPost() {
909
909
  // thread still exists. Without this override, a card approved while (or just
910
910
  // before) the sync stamped it is refused as already-decided and the approval
911
911
  // silently no-ops (2 of 3 approvals lost on 2026-07-10).
912
+ // Give-up bound for approved cards whose post attempts keep failing
913
+ // transiently (browser lock contention, timeouts). 5 attempts spans several
914
+ // drain cycles — plenty for genuine transients to clear — while guaranteeing
915
+ // no card can retry forever (2026-07-17 zombie-card incident).
916
+ const MAX_POST_ATTEMPTS = 5;
912
917
  function expiredStampOverridable(c) {
913
918
  return (c.terminal === true &&
914
919
  c.posted !== true &&
@@ -964,11 +969,29 @@ function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
964
969
  f.terminal = false;
965
970
  if (c.our_url)
966
971
  f.our_url = c.our_url;
972
+ // A post outcome closes the card's history: the pre-approval
973
+ // freshness stamp must not survive it.
974
+ delete f.discard_reason;
967
975
  }
968
976
  else if (c.terminal === true && f.posted !== true) {
969
977
  f.terminal = true;
970
978
  f.terminal_reason = c.terminal_reason;
979
+ // CRITICAL (2026-07-17 Nhat zombie-card incident, 438 retries over
980
+ // 5 days): a stale discard_reason="backend_status_expired" left on
981
+ // the fresh copy makes expiredStampOverridable() treat THIS
982
+ // post-outcome terminal as overridable, so every later drain
983
+ // resurrects the card, re-posts, dedup-skips, and re-stamps —
984
+ // forever. A terminal that came from an actual post attempt is
985
+ // final; delete the expiry stamp so the override can never fire
986
+ // on it again. (The drain's own `delete c.discard_reason` happens
987
+ // on an in-memory copy that is never the write source; this line
988
+ // is the one that persists.)
989
+ delete f.discard_reason;
971
990
  }
991
+ // Carry the retry counter so the give-up bound survives across
992
+ // drains (each drain reads the store fresh).
993
+ if (typeof c.post_attempts === "number" && c.post_attempts > (f.post_attempts || 0))
994
+ f.post_attempts = c.post_attempts;
972
995
  }
973
996
  }
974
997
  writePlan(batchId, fresh);
@@ -1508,6 +1531,21 @@ async function postApproved(batchId, plan) {
1508
1531
  // here (found 2026-07-16) silently and permanently discarded 7 real
1509
1532
  // approved drafts on nothing worse than lock contention — Reddit's
1510
1533
  // equivalent transient failures self-healed on the very next drain.
1534
+ //
1535
+ // BOUNDED (2026-07-17): sticky is right, sticky-forever is not — an
1536
+ // unbounded retry is a zombie generator (one card retried 438 times
1537
+ // over 5 days on the Nhat install). Count the transient failures and
1538
+ // give up loudly after MAX_POST_ATTEMPTS; the terminal_reason keeps
1539
+ // the last failure visible so the give-up is diagnosable, and the
1540
+ // on-disk post-events trail records it for forensics.
1541
+ const attempts = (typeof c.post_attempts === "number" ? c.post_attempts : 0) + 1;
1542
+ c.post_attempts = attempts;
1543
+ if (attempts >= MAX_POST_ATTEMPTS) {
1544
+ c.terminal = true;
1545
+ c.terminal_reason = `gave_up_after_${attempts}_failed_attempts:${r.reason || "failed"}`;
1546
+ console.error(`[post] giving up on candidate ${r.candidate_id} after ${attempts} failed attempts (last: ${r.reason || "failed"})`);
1547
+ logPostEvent(`retry_budget_exhausted candidate=${r.candidate_id} attempts=${attempts} last=${r.reason || "failed"}`);
1548
+ }
1511
1549
  touchedPlan = true;
1512
1550
  }
1513
1551
  });
@@ -5717,33 +5755,18 @@ registerAppResource(server, "S4L product link", PRODUCT_LINK_URI, { mimeType: RE
5717
5755
  },
5718
5756
  ],
5719
5757
  }));
5720
- // Post any cards the user APPROVED that never landed — e.g. a restart killed the
5721
- // batch mid-way. "Proceed to post the already-approved items." postApproved is
5722
- // idempotent (it filters posted/terminal), so this only drains the genuine
5723
- // backlog and never double-posts. Best-effort; never throws.
5724
- async function drainApprovedBacklog() {
5725
- try {
5726
- const plan = readPlan(REVIEW_QUEUE_ID);
5727
- const cands = plan?.candidates || [];
5728
- const backlog = cands.filter((c) => c.approved === true &&
5729
- c.posted !== true &&
5730
- (c.terminal !== true || expiredStampOverridable(c)) &&
5731
- // Same sandbox exclusion as postApproved's own filter: a sandbox
5732
- // replay can never post, so it must never count as backlog.
5733
- !isSandboxCandidate(c));
5734
- if (!backlog.length)
5735
- return;
5736
- console.error(`[post] draining ${backlog.length} approved-but-unposted card(s) left from before`);
5737
- await postApproved(REVIEW_QUEUE_ID, plan);
5738
- }
5739
- catch (e) {
5740
- console.error("[post] drainApprovedBacklog error:", e?.message || e);
5741
- // Same reasoning as the other postApproved call site: don't let an
5742
- // escaped exception leave the cross-instance posting flag stuck true.
5743
- postingActive = false;
5744
- stopPostingFlagHeartbeat();
5745
- }
5746
- }
5758
+ // REMOVED (2026-07-17): drainApprovedBacklog. It ran 30s after EVERY MCP boot,
5759
+ // which was sane when boots meant "user launched Claude Desktop" — but each
5760
+ // queue-worker session boots its own MCP server, so the drain had silently
5761
+ // become a ~5-minute cron running across up to 4 concurrent MCP instances.
5762
+ // Combined with universal posting preemption, every drain wakeup SIGKILLed
5763
+ // whatever held the twitter-browser lock (profile scans included), and any
5764
+ // stamp bug turned into an infinite retry loop (438 retries over 5 days on
5765
+ // one Nhat card). Backlog recovery is now owned by ONE long-lived process:
5766
+ // the menubar's _resume_approved_queue, which runs on loopback-reachable and
5767
+ // periodically thereafter (mcp/menubar/s4l_menubar.py). Do NOT re-add a
5768
+ // boot-time drain here; if the menubar is dead, ensureMenubar() below revives
5769
+ // it and its resume covers the backlog.
5747
5770
  async function main() {
5748
5771
  initSentry();
5749
5772
  // Detect a self-update (old_version -> new_version) as the very first thing
@@ -5919,13 +5942,9 @@ async function main() {
5919
5942
  void startLocalPanel()
5920
5943
  .then((url) => console.error(`[social-autoposter-mcp] panel loopback ready at ${url}`))
5921
5944
  .catch((e) => console.error("[social-autoposter-mcp] panel loopback start failed:", e?.message || e));
5922
- // Resume posting any approved-but-unposted cards a prior run/restart left behind.
5923
- // Delayed so the runtime + harness Chrome have settled; never blocks boot.
5924
- {
5925
- const t = setTimeout(() => void drainApprovedBacklog(), 30_000);
5926
- if (typeof t.unref === "function")
5927
- t.unref();
5928
- }
5945
+ // NOTE (2026-07-17): the boot-time drainApprovedBacklog() call that lived
5946
+ // here is gone — backlog recovery is owned by the menubar's periodic
5947
+ // _resume_approved_queue (single drainer; see the removal note above).
5929
5948
  // Ensure the macOS menu bar mini-dashboard is installed + running. Idempotent
5930
5949
  // and cheap when already present, so existing installs pick it up on the next
5931
5950
  // Claude restart without re-provisioning. Best-effort: never blocks boot.
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.6-rc.5",
3
- "installedAt": "2026-07-17T17:09:56.264Z"
2
+ "version": "1.7.6-rc.6",
3
+ "installedAt": "2026-07-17T17:26:23.362Z"
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.7.6-rc.5",
5
+ "version": "1.7.6-rc.6",
6
6
  "description": "Draft, review, approve, and autopilot X/Twitter posts.",
7
7
  "long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L 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 plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
8
8
  "author": {
@@ -579,6 +579,12 @@ class S4LMenuBar(rumps.App):
579
579
  # a hitch every 5s tick.
580
580
  self._resumed = False
581
581
  self._loopback_check_due_at = 0.0
582
+ # Periodic approved-backlog drain cadence (2026-07-17): the menubar is
583
+ # the single drainer now that the MCP boot-time drain is gone. 10 min
584
+ # keeps a transiently-failed approval's retry latency reasonable while
585
+ # staying far below the old effective 5-min/multi-instance churn.
586
+ self.RESUME_PERIOD_S = 600.0
587
+ self._resume_due_at = 0.0
582
588
  # Reliable self-check of our own Accessibility (TCC) grant — this is the
583
589
  # faithful reading (our launchd process identity, not a parent's). Logged
584
590
  # so menubar.err.log records whether keystroke posting will work.
@@ -2301,16 +2307,14 @@ class S4LMenuBar(rumps.App):
2301
2307
  never confirmed posted (the in-memory _post_q died with the old process).
2302
2308
  Skip any the plan already shows as posted, so a card that landed on X just
2303
2309
  before the kill — but whose status update was lost — isn't posted twice."""
2304
- # Store lane (canonical): approved-but-unposted rows in the review store.
2305
- # Legacy lane: pre-store approved-queue.json entries (read-only now; the
2306
- # ledger is no longer written). Union, store first, dedup by (batch, n).
2310
+ # Store lane ONLY (2026-07-17): approved-but-unposted rows in the review
2311
+ # store, the single source of truth. The legacy approved-queue.json lane
2312
+ # (a read-only third truth source that the ledger stopped writing long
2313
+ # ago) was removed — its entries could never settle, so it re-enqueued
2314
+ # the same dead approvals on every resume forever. If a genuinely
2315
+ # pre-store approval ever resurfaces, it is visible in the store lane
2316
+ # or it is gone; do not re-add the union.
2307
2317
  pending = list(st.store_pending_posts())
2308
- seen = {(it.get("batch"), it.get("n")) for it in pending}
2309
- legacy = [
2310
- it for it in st.approved_queue_pending()
2311
- if (it.get("batch"), it.get("n")) not in seen
2312
- ]
2313
- pending.extend(legacy)
2314
2318
  if not pending:
2315
2319
  return
2316
2320
  posted_ns = set()
@@ -2462,8 +2466,19 @@ class S4LMenuBar(rumps.App):
2462
2466
  if now >= self._loopback_check_due_at:
2463
2467
  self._loopback_check_due_at = now + 60.0
2464
2468
  if st.loopback_reachable():
2465
- if not self._resumed:
2469
+ # Periodic backlog drain (2026-07-17): the menubar is now the
2470
+ # SINGLE owner of approved-backlog recovery. The MCP server's
2471
+ # boot-time drainApprovedBacklog was removed (each queue-worker
2472
+ # session boots an MCP, so "boot-time" had become a 5-minute
2473
+ # cron across concurrent instances). Here it runs on the one
2474
+ # long-lived process instead: on loopback recovery and every
2475
+ # RESUME_PERIOD_S thereafter, but only while nothing is
2476
+ # in-flight (avoids enqueueing a card twice while an earlier
2477
+ # drain still holds it in _post_q). _resume_approved_queue is
2478
+ # idempotent against the store (skips posted/terminal rows).
2479
+ if (not self._resumed or now >= self._resume_due_at) and self._posts_outstanding == 0:
2466
2480
  self._resumed = True
2481
+ self._resume_due_at = now + self.RESUME_PERIOD_S
2467
2482
  try:
2468
2483
  self._resume_approved_queue()
2469
2484
  except Exception as e:
@@ -1434,11 +1434,10 @@ def approved_queue_set_status(batch, n, status, error=None):
1434
1434
  _write_approved_queue(d)
1435
1435
 
1436
1436
 
1437
- def approved_queue_pending():
1438
- """Approvals not yet confirmed posted (queued or posting). Re-enqueued by the
1439
- menu bar on startup so a restart RESUMES the drain instead of re-presenting."""
1440
- return [it for it in read_approved_queue()["items"]
1441
- if it.get("status") in ("queued", "posting")]
1437
+ # approved_queue_pending() REMOVED (2026-07-17): the legacy approved-queue.json
1438
+ # resume lane was a third source of truth whose entries could never settle (the
1439
+ # ledger stopped being written), so it re-enqueued dead approvals forever. The
1440
+ # store lane (store_pending_posts) is the only resume source now.
1442
1441
 
1443
1442
 
1444
1443
  def post_drafts(batch_id, post=None, edits=None, reject=None, clear_link=None, timeout=900, activity_label=None):
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.6-rc.5",
3
+ "version": "1.7.6-rc.6",
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": "@m13v/s4l",
3
- "version": "1.7.6-rc.5",
3
+ "version": "1.7.6-rc.6",
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",