@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 +53 -34
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_menubar.py +25 -10
- package/mcp/menubar/s4l_state.py +4 -5
- package/mcp/package.json +1 -1
- package/package.json +1 -1
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
|
-
//
|
|
5721
|
-
//
|
|
5722
|
-
//
|
|
5723
|
-
//
|
|
5724
|
-
|
|
5725
|
-
|
|
5726
|
-
|
|
5727
|
-
|
|
5728
|
-
|
|
5729
|
-
|
|
5730
|
-
|
|
5731
|
-
|
|
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
|
-
//
|
|
5923
|
-
//
|
|
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.
|
package/mcp/dist/version.json
CHANGED
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
|
+
"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 (
|
|
2305
|
-
#
|
|
2306
|
-
#
|
|
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
|
-
|
|
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:
|
package/mcp/menubar/s4l_state.py
CHANGED
|
@@ -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
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
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.
|
|
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