social-autoposter 1.6.82 → 1.6.83
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 +16 -2
- package/mcp/dist/runtime.js +32 -0
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_menubar.py +61 -4
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/post_reddit.py +38 -11
- package/scripts/seed_search_queries.py +18 -1
- package/scripts/sentry_init.py +37 -0
- package/scripts/twitter_post_plan.py +43 -5
- package/skill/run-twitter-cycle.sh +1 -1
package/mcp/dist/index.js
CHANGED
|
@@ -2369,8 +2369,22 @@ async function main() {
|
|
|
2369
2369
|
// and cheap when already present, so existing installs pick it up on the next
|
|
2370
2370
|
// Claude restart without re-provisioning. Best-effort: never blocks boot.
|
|
2371
2371
|
void ensureMenubar()
|
|
2372
|
-
.then((r) =>
|
|
2373
|
-
|
|
2372
|
+
.then((r) => {
|
|
2373
|
+
console.error(`[social-autoposter-mcp] menubar: ${r.skipped ? "skip" : r.ok ? "ok" : "fail"} (${r.detail})`);
|
|
2374
|
+
// A non-skipped failure here is the boot-time "menu bar didn't come up"
|
|
2375
|
+
// path (e.g. uv missing, rumps reinstall failed on an existing install).
|
|
2376
|
+
// Report it; a skip (non-macOS / runtime not ready) is expected, not an error.
|
|
2377
|
+
if (!r.ok && !r.skipped) {
|
|
2378
|
+
captureError(new Error(`menubar ensure failed: ${r.detail}`), {
|
|
2379
|
+
component: "menubar",
|
|
2380
|
+
phase: "ensure",
|
|
2381
|
+
});
|
|
2382
|
+
}
|
|
2383
|
+
})
|
|
2384
|
+
.catch((e) => {
|
|
2385
|
+
console.error("[social-autoposter-mcp] menubar ensure failed:", e?.message || e);
|
|
2386
|
+
captureError(e, { component: "menubar", phase: "ensure" });
|
|
2387
|
+
});
|
|
2374
2388
|
// Phone home so this .mcpb install is visible in the install-lane digest
|
|
2375
2389
|
// (parity with the npx launchd heartbeat). Once on startup, then every 15m
|
|
2376
2390
|
// while the desktop app keeps the server alive. unref() so it never holds the
|
package/mcp/dist/runtime.js
CHANGED
|
@@ -22,6 +22,7 @@ import fs from "node:fs";
|
|
|
22
22
|
import os from "node:os";
|
|
23
23
|
import path from "node:path";
|
|
24
24
|
import { fileURLToPath } from "node:url";
|
|
25
|
+
import { captureError } from "./telemetry.js";
|
|
25
26
|
// Pin the standalone CPython series the venv is built from. Bump deliberately.
|
|
26
27
|
const PYTHON_VERSION = "3.12";
|
|
27
28
|
// The CDP scan engine the twitter cycle shells out to (~/.local/bin/browser-harness).
|
|
@@ -378,6 +379,15 @@ async function provision(progress) {
|
|
|
378
379
|
progress.ok = false;
|
|
379
380
|
progress.error = msg;
|
|
380
381
|
writeProgress(progress);
|
|
382
|
+
// Every fatal install-step failure (repo unpack, uv, python, venv, deps,
|
|
383
|
+
// chromium, harness, chrome) was previously only written to the local
|
|
384
|
+
// install-progress.json, invisible to us. Report it so a failed runtime
|
|
385
|
+
// install becomes a real Sentry event, tagged with the step that failed.
|
|
386
|
+
const failedStep = progress.steps.find((s) => s.status === "running");
|
|
387
|
+
captureError(new Error(msg), {
|
|
388
|
+
component: "install",
|
|
389
|
+
...(failedStep ? { step: failedStep.id } : {}),
|
|
390
|
+
});
|
|
381
391
|
return progress;
|
|
382
392
|
};
|
|
383
393
|
fs.mkdirSync(RUNTIME_DIR, { recursive: true });
|
|
@@ -499,6 +509,19 @@ async function provision(progress) {
|
|
|
499
509
|
if (r.code !== 0) {
|
|
500
510
|
return fail(`playwright install chromium failed (exit ${r.code}). ${r.out.slice(-400)}`);
|
|
501
511
|
}
|
|
512
|
+
// Smoke-test the EXACT gate the pipeline's post path runs at use time
|
|
513
|
+
// (twitter_post_plan.py preflight): the owned interpreter must import
|
|
514
|
+
// playwright. The reply step is the only Playwright importer, so a deps
|
|
515
|
+
// sync that left it unimportable was invisible until the first real post
|
|
516
|
+
// died with no_reply_json in production (Karol, 2026-06-22). Fail the
|
|
517
|
+
// install LOUDLY here instead.
|
|
518
|
+
const smoke = await sh(VENV_PYTHON, ["-c", "import playwright"], {
|
|
519
|
+
timeoutMs: 60000,
|
|
520
|
+
});
|
|
521
|
+
if (smoke.code !== 0) {
|
|
522
|
+
return fail(`runtime smoke test failed: ${VENV_PYTHON} cannot import playwright ` +
|
|
523
|
+
`(exit ${smoke.code}). ${smoke.out.slice(-400)}`);
|
|
524
|
+
}
|
|
502
525
|
}
|
|
503
526
|
setStep("chromium", "done");
|
|
504
527
|
// --- Step 6: browser-harness CLI -----------------------------------------
|
|
@@ -607,6 +630,15 @@ async function provision(progress) {
|
|
|
607
630
|
if (process.platform === "darwin") {
|
|
608
631
|
const mb = await installMenubar(uv, uvEnv, VENV_PYTHON);
|
|
609
632
|
setStep("menubar", mb.ok ? "done" : "error", mb.detail);
|
|
633
|
+
// Non-fatal step, so the only prior signal of a menu bar install failure was
|
|
634
|
+
// a local install-progress.json entry (invisible to us). Report it so "menu
|
|
635
|
+
// bar didn't start" becomes a real Sentry event with the failing detail.
|
|
636
|
+
if (!mb.ok) {
|
|
637
|
+
captureError(new Error(`menubar install failed: ${mb.detail}`), {
|
|
638
|
+
component: "menubar",
|
|
639
|
+
phase: "install",
|
|
640
|
+
});
|
|
641
|
+
}
|
|
610
642
|
}
|
|
611
643
|
else {
|
|
612
644
|
setStep("menubar", "done", "skipped (macOS only)");
|
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.6.
|
|
5
|
+
"version": "1.6.83",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts. Thin desktop client over the S4L pipeline.",
|
|
7
7
|
"long_description": "A guided assistant that drafts, reviews, and autopilots X/Twitter posts.\nTo get started:\n1. Click **Configure** and set every tool permission to **Always Allow**.\n2. Copy this prompt: **Set me up on S4L end to end**.\n3. Quit fully with CMD+Q, restart Claude, and paste the prompt into a new chat.",
|
|
8
8
|
"author": {
|
|
@@ -24,9 +24,58 @@ import tempfile
|
|
|
24
24
|
import threading
|
|
25
25
|
import time
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
# --- Sentry bootstrap --------------------------------------------------------
|
|
28
|
+
# The menu bar runs as a standalone KeepAlive LaunchAgent off the owned venv,
|
|
29
|
+
# a separate process from the MCP server, so it was a Sentry blind spot: a crash
|
|
30
|
+
# (most often rumps missing/broken in the venv -> "menu bar didn't start") only
|
|
31
|
+
# ever landed in the local menubar.err.log. Wire it in BEFORE importing rumps so
|
|
32
|
+
# even an import-time failure of the menu bar's heaviest dependency is reported.
|
|
33
|
+
# sentry_init lives in the pipeline's scripts/ dir (SAPS_REPO_DIR is exported by
|
|
34
|
+
# the launchd plist) and sentry-sdk is in the owned venv (requirements.txt). All
|
|
35
|
+
# best-effort: a missing repo path or SDK degrades to a silent no-op.
|
|
36
|
+
_sentry = None
|
|
37
|
+
try:
|
|
38
|
+
_repo = os.environ.get("SAPS_REPO_DIR")
|
|
39
|
+
if _repo:
|
|
40
|
+
_scripts = os.path.join(_repo, "scripts")
|
|
41
|
+
if _scripts not in sys.path:
|
|
42
|
+
sys.path.insert(0, _scripts)
|
|
43
|
+
import sentry_init as _sentry # noqa: E402
|
|
44
|
+
|
|
45
|
+
_sentry.init()
|
|
46
|
+
except Exception:
|
|
47
|
+
_sentry = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _capture(err, **tags):
|
|
51
|
+
"""Report a handled menu-bar error to Sentry (component=menubar) without ever
|
|
52
|
+
raising into the caller. No-op if the Sentry bootstrap above failed."""
|
|
53
|
+
try:
|
|
54
|
+
if _sentry is not None:
|
|
55
|
+
tags.setdefault("component", "menubar")
|
|
56
|
+
_sentry.capture_exception(err, tags=tags)
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _flush():
|
|
62
|
+
try:
|
|
63
|
+
if _sentry is not None:
|
|
64
|
+
_sentry.flush()
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
import rumps # noqa: E402
|
|
71
|
+
except Exception as _import_err:
|
|
72
|
+
# rumps missing/broken in the owned venv is THE "menu bar didn't start" case.
|
|
73
|
+
# Report it explicitly, flush, then re-raise so launchd records the crash too.
|
|
74
|
+
_capture(_import_err, phase="import_rumps")
|
|
75
|
+
_flush()
|
|
76
|
+
raise
|
|
77
|
+
|
|
78
|
+
import s4l_state as st # noqa: E402
|
|
30
79
|
|
|
31
80
|
CLAUDE_APP = "Claude"
|
|
32
81
|
POLL_SECONDS = 5
|
|
@@ -432,6 +481,7 @@ class S4LMenuBar(rumps.App):
|
|
|
432
481
|
self._review_active = False
|
|
433
482
|
sys.stderr.write(f"[s4l-menubar] review cards failed: {e}\n")
|
|
434
483
|
sys.stderr.flush()
|
|
484
|
+
_capture(e, phase="review_cards")
|
|
435
485
|
|
|
436
486
|
def _on_review_done(self, batch, decisions):
|
|
437
487
|
# Runs on the main thread (from the card controller). Translate decisions
|
|
@@ -598,4 +648,11 @@ class S4LMenuBar(rumps.App):
|
|
|
598
648
|
|
|
599
649
|
|
|
600
650
|
if __name__ == "__main__":
|
|
601
|
-
|
|
651
|
+
try:
|
|
652
|
+
S4LMenuBar().run()
|
|
653
|
+
except Exception as _run_err:
|
|
654
|
+
# The run loop dying is the other "menu bar didn't start / vanished" case.
|
|
655
|
+
# Report + flush before the KeepAlive relaunch so it isn't lost on teardown.
|
|
656
|
+
_capture(_run_err, phase="run")
|
|
657
|
+
_flush()
|
|
658
|
+
raise
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/social-autoposter-mcp",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.83",
|
|
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
package/scripts/post_reddit.py
CHANGED
|
@@ -36,6 +36,17 @@ CONFIG_PATH = os.path.join(REPO_DIR, "config.json")
|
|
|
36
36
|
REDDIT_BROWSER = os.path.join(REPO_DIR, "scripts", "reddit_browser.py")
|
|
37
37
|
REDDIT_BROWSER_LOCK = os.path.join(REPO_DIR, "scripts", "reddit_browser_lock.py")
|
|
38
38
|
REDDIT_TOOLS = os.path.join(REPO_DIR, "scripts", "reddit_tools.py")
|
|
39
|
+
|
|
40
|
+
# Interpreter every child subprocess must run under. A bare PYTHON resolved
|
|
41
|
+
# to the user's system python, which lacks the pipeline deps (Playwright and
|
|
42
|
+
# friends) that live only in the owned uv runtime — so on a fresh box every
|
|
43
|
+
# reddit_browser.py reply died (the same class as the Karol/Twitter bug,
|
|
44
|
+
# 2026-06-22). Honor the authoritative SAPS_PYTHON pin (set by the launchd
|
|
45
|
+
# plist), else sys.executable (the owned interpreter the MCP launches us under).
|
|
46
|
+
# Never the literal PYTHON: that re-rolls the PATH dice. Re-exported so
|
|
47
|
+
# grandchildren inherit it.
|
|
48
|
+
PYTHON = os.environ.get("SAPS_PYTHON") or sys.executable
|
|
49
|
+
os.environ["SAPS_PYTHON"] = PYTHON
|
|
39
50
|
RATELIMIT_FILE = "/tmp/reddit_ratelimit.json"
|
|
40
51
|
PREFLIGHT_WAIT_BUDGET_SECONDS = 180
|
|
41
52
|
|
|
@@ -619,7 +630,7 @@ def load_config():
|
|
|
619
630
|
|
|
620
631
|
def pick_project(platform="reddit", exclude=None):
|
|
621
632
|
try:
|
|
622
|
-
cmd = [
|
|
633
|
+
cmd = [PYTHON, os.path.join(REPO_DIR, "scripts", "pick_project.py"),
|
|
623
634
|
"--platform", platform, "--json"]
|
|
624
635
|
if exclude:
|
|
625
636
|
cmd.extend(["--exclude", ",".join(exclude)])
|
|
@@ -641,7 +652,7 @@ def get_top_performers(project_name, platform="reddit", style=None):
|
|
|
641
652
|
callers that have not flipped to the picker yet).
|
|
642
653
|
"""
|
|
643
654
|
try:
|
|
644
|
-
cmd = [
|
|
655
|
+
cmd = [PYTHON, os.path.join(REPO_DIR, "scripts", "top_performers.py"),
|
|
645
656
|
"--platform", platform, "--project", project_name]
|
|
646
657
|
if style:
|
|
647
658
|
cmd.extend(["--style", style])
|
|
@@ -660,7 +671,7 @@ def get_top_search_topics(project_name, platform="reddit", limit=8, window_days=
|
|
|
660
671
|
project on this platform, or '' if no data yet. See top_search_topics.py."""
|
|
661
672
|
try:
|
|
662
673
|
result = subprocess.run(
|
|
663
|
-
[
|
|
674
|
+
[PYTHON, os.path.join(REPO_DIR, "scripts", "top_search_topics.py"),
|
|
664
675
|
"--project", project_name, "--platform", platform,
|
|
665
676
|
"--window-days", str(window_days), "--limit", str(limit)],
|
|
666
677
|
capture_output=True, text=True, timeout=15,
|
|
@@ -684,7 +695,7 @@ def get_omitted_reddit_topics(project_name, limit=10, window_hours=168, min_omit
|
|
|
684
695
|
"""
|
|
685
696
|
try:
|
|
686
697
|
result = subprocess.run(
|
|
687
|
-
[
|
|
698
|
+
[PYTHON, os.path.join(REPO_DIR, "scripts", "top_omitted_reddit_topics.py"),
|
|
688
699
|
"--project", project_name,
|
|
689
700
|
"--window-hours", str(window_hours),
|
|
690
701
|
"--limit", str(limit),
|
|
@@ -709,7 +720,7 @@ def get_dud_reddit_queries(project_name, limit=15, window_hours=168):
|
|
|
709
720
|
"""
|
|
710
721
|
try:
|
|
711
722
|
result = subprocess.run(
|
|
712
|
-
[
|
|
723
|
+
[PYTHON, os.path.join(REPO_DIR, "scripts", "top_dud_reddit_queries.py"),
|
|
713
724
|
"--project", project_name,
|
|
714
725
|
"--window-hours", str(window_hours),
|
|
715
726
|
"--limit", str(limit)],
|
|
@@ -1451,7 +1462,7 @@ def run_claude(prompt, timeout=600):
|
|
|
1451
1462
|
text_output = "\n".join(all_text_parts) if all_text_parts else "".join(collected)
|
|
1452
1463
|
stderr_out = proc.stderr.read() if proc.stderr else ""
|
|
1453
1464
|
try:
|
|
1454
|
-
log_args = [
|
|
1465
|
+
log_args = [PYTHON, os.path.join(REPO_DIR, "scripts", "log_claude_session.py"),
|
|
1455
1466
|
"--session-id", session_id, "--script", "post_reddit"]
|
|
1456
1467
|
orch_cost = usage.get("cost_usd")
|
|
1457
1468
|
if isinstance(orch_cost, (int, float)) and orch_cost > 0:
|
|
@@ -1487,7 +1498,7 @@ def _acquire_browser_lease(timeout: int = 600, ttl: int = 90):
|
|
|
1487
1498
|
"""
|
|
1488
1499
|
try:
|
|
1489
1500
|
r = subprocess.run(
|
|
1490
|
-
[
|
|
1501
|
+
[PYTHON, REDDIT_BROWSER_LOCK, "acquire",
|
|
1491
1502
|
"--timeout", str(timeout), "--ttl", str(ttl)],
|
|
1492
1503
|
capture_output=True, text=True, timeout=timeout + 30,
|
|
1493
1504
|
)
|
|
@@ -1512,7 +1523,7 @@ def _release_browser_lease() -> None:
|
|
|
1512
1523
|
"""
|
|
1513
1524
|
try:
|
|
1514
1525
|
subprocess.run(
|
|
1515
|
-
[
|
|
1526
|
+
[PYTHON, REDDIT_BROWSER_LOCK, "release"],
|
|
1516
1527
|
capture_output=True, text=True, timeout=10,
|
|
1517
1528
|
)
|
|
1518
1529
|
except Exception:
|
|
@@ -1529,7 +1540,7 @@ def post_via_cdp(thread_url, reply_to_url, text):
|
|
|
1529
1540
|
for attempt in range(MAX_ATTEMPTS):
|
|
1530
1541
|
try:
|
|
1531
1542
|
target = reply_to_url or thread_url
|
|
1532
|
-
cmd = [
|
|
1543
|
+
cmd = [PYTHON, REDDIT_BROWSER, "reply" if reply_to_url else "post-comment", target, text]
|
|
1533
1544
|
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
|
1534
1545
|
cdp_out = proc.stdout.strip()
|
|
1535
1546
|
if not cdp_out:
|
|
@@ -1585,7 +1596,7 @@ def log_post(thread_url, permalink, text, project_name, thread_author, thread_ti
|
|
|
1585
1596
|
(if any) Claude baked into the reply text.
|
|
1586
1597
|
"""
|
|
1587
1598
|
try:
|
|
1588
|
-
cmd = [
|
|
1599
|
+
cmd = [PYTHON, REDDIT_TOOLS, "log-post",
|
|
1589
1600
|
thread_url, permalink or "", text, project_name,
|
|
1590
1601
|
thread_author, thread_title,
|
|
1591
1602
|
"--account", reddit_username]
|
|
@@ -1616,7 +1627,7 @@ def bump_campaigns(table, row_id, campaign_ids):
|
|
|
1616
1627
|
for cid in campaign_ids:
|
|
1617
1628
|
try:
|
|
1618
1629
|
subprocess.run(
|
|
1619
|
-
[
|
|
1630
|
+
[PYTHON, bump,
|
|
1620
1631
|
"--table", table, "--id", str(row_id), "--campaign-id", str(cid)],
|
|
1621
1632
|
capture_output=True, text=True, timeout=15,
|
|
1622
1633
|
)
|
|
@@ -2623,6 +2634,22 @@ def main():
|
|
|
2623
2634
|
sys.exit(2)
|
|
2624
2635
|
with open(args.in_path) as f:
|
|
2625
2636
|
plan = json.load(f)
|
|
2637
|
+
# Hard preflight: _post_iteration shells to reddit_browser.py, the only
|
|
2638
|
+
# Playwright importer on this rail. If the resolved interpreter can't
|
|
2639
|
+
# import it the owned runtime is missing/half-provisioned and every post
|
|
2640
|
+
# would die with CDP_ERROR. Fail LOUD with a distinct signal instead.
|
|
2641
|
+
# Gated on real decisions so an empty plan still exits clean.
|
|
2642
|
+
if plan.get("decisions"):
|
|
2643
|
+
_chk = subprocess.run(
|
|
2644
|
+
[PYTHON, "-c", "import playwright"],
|
|
2645
|
+
capture_output=True, text=True,
|
|
2646
|
+
)
|
|
2647
|
+
if _chk.returncode != 0:
|
|
2648
|
+
print(f"[post_reddit] FATAL runtime_incomplete: interpreter {PYTHON!r} "
|
|
2649
|
+
f"cannot import playwright — the owned Python runtime is missing or "
|
|
2650
|
+
f"unprovisioned. Run the `runtime` install (action:'install') before "
|
|
2651
|
+
f"posting. stderr: {(_chk.stderr or '').strip()[:300]}", file=sys.stderr)
|
|
2652
|
+
sys.exit(3)
|
|
2626
2653
|
try:
|
|
2627
2654
|
posted, failed = _post_iteration(plan, reddit_username)
|
|
2628
2655
|
print(f"[post_reddit] phase=post project={plan.get('project_name')} posted={posted} failed={failed}")
|
|
@@ -433,4 +433,21 @@ def main() -> int:
|
|
|
433
433
|
|
|
434
434
|
|
|
435
435
|
if __name__ == "__main__":
|
|
436
|
-
|
|
436
|
+
try:
|
|
437
|
+
_rc = main()
|
|
438
|
+
except BrokenPipeError:
|
|
439
|
+
# The MCP setup hook (our parent) closes stdout once it has read the
|
|
440
|
+
# sentinel ===QUERIES_JSON=== block; the trailing summary prints then hit
|
|
441
|
+
# a dead pipe and raise BrokenPipeError. All persistence already happened
|
|
442
|
+
# earlier in main(), so this is BENIGN. Previously it propagated as an
|
|
443
|
+
# uncaught exception and Sentry logged it as a "seeding failed" event
|
|
444
|
+
# (Karol, 2026-06-22) — a false positive that buried the real signal.
|
|
445
|
+
# Redirect stdout to devnull so interpreter shutdown doesn't re-raise on
|
|
446
|
+
# the final flush, then exit clean.
|
|
447
|
+
try:
|
|
448
|
+
_devnull = os.open(os.devnull, os.O_WRONLY)
|
|
449
|
+
os.dup2(_devnull, sys.stdout.fileno())
|
|
450
|
+
except Exception:
|
|
451
|
+
pass
|
|
452
|
+
_rc = 0
|
|
453
|
+
raise SystemExit(_rc)
|
package/scripts/sentry_init.py
CHANGED
|
@@ -65,3 +65,40 @@ def _tag_install(sentry_sdk) -> None:
|
|
|
65
65
|
sentry_sdk.set_tag("hostname", str(host))
|
|
66
66
|
except Exception:
|
|
67
67
|
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def capture_exception(err, tags=None) -> None:
|
|
71
|
+
"""Explicitly report an exception to Sentry with optional tags. Safe to call
|
|
72
|
+
even if init() was never run or sentry-sdk is missing (silent no-op). Use for
|
|
73
|
+
swallowed/handled errors that would otherwise never reach Sentry (the global
|
|
74
|
+
excepthook only catches UNHANDLED ones)."""
|
|
75
|
+
if not _initialized:
|
|
76
|
+
return
|
|
77
|
+
try:
|
|
78
|
+
import sentry_sdk
|
|
79
|
+
except Exception:
|
|
80
|
+
return
|
|
81
|
+
try:
|
|
82
|
+
if tags:
|
|
83
|
+
with sentry_sdk.push_scope() as scope:
|
|
84
|
+
for k, v in tags.items():
|
|
85
|
+
scope.set_tag(str(k), str(v))
|
|
86
|
+
sentry_sdk.capture_exception(err)
|
|
87
|
+
else:
|
|
88
|
+
sentry_sdk.capture_exception(err)
|
|
89
|
+
except Exception:
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def flush(timeout: float = 2.0) -> None:
|
|
94
|
+
"""Block until queued events are sent (best-effort). Call before a short-lived
|
|
95
|
+
or about-to-crash process exits so a just-captured event isn't dropped on
|
|
96
|
+
teardown."""
|
|
97
|
+
if not _initialized:
|
|
98
|
+
return
|
|
99
|
+
try:
|
|
100
|
+
import sentry_sdk
|
|
101
|
+
|
|
102
|
+
sentry_sdk.flush(timeout)
|
|
103
|
+
except Exception:
|
|
104
|
+
return
|
|
@@ -55,6 +55,18 @@ LOG_POST = os.path.join(REPO_DIR, "scripts", "log_post.py")
|
|
|
55
55
|
CAMPAIGN_BUMP = os.path.join(REPO_DIR, "scripts", "campaign_bump.py")
|
|
56
56
|
LINK_TAIL = os.path.join(REPO_DIR, "scripts", "link_tail.py")
|
|
57
57
|
|
|
58
|
+
# Interpreter every child subprocess (twitter_browser.py reply, log_post.py,
|
|
59
|
+
# campaign_bump.py, link_tail.py) must run under. The reply path is the only
|
|
60
|
+
# Playwright importer in the pipeline, so a bare "python3" here silently
|
|
61
|
+
# resolved to the user's system python (no Playwright) and every post died
|
|
62
|
+
# with no_reply_json (Karol, 2026-06-22). Honor the authoritative pin the rest
|
|
63
|
+
# of the runtime uses — SAPS_PYTHON (set by the launchd plist) — then fall back
|
|
64
|
+
# to sys.executable (the interpreter THIS process already runs under, which the
|
|
65
|
+
# MCP's runPython resolves to the owned uv runtime). Never the literal
|
|
66
|
+
# "python3": that re-rolls the PATH dice. Re-exported so grandchildren inherit.
|
|
67
|
+
PYTHON = os.environ.get("SAPS_PYTHON") or sys.executable
|
|
68
|
+
os.environ["SAPS_PYTHON"] = PYTHON
|
|
69
|
+
|
|
58
70
|
# DATABASE_URL was previously used to issue ad-hoc `psql -c "..."` calls for
|
|
59
71
|
# the pre-post dedup probe and the candidate status updates. As of the
|
|
60
72
|
# 2026-05-18 routes migration both lanes go through the s4l.ai HTTP API
|
|
@@ -527,7 +539,7 @@ def post_one(c: dict, picker_assignment: dict | None = None) -> tuple[str, str]:
|
|
|
527
539
|
link_tail_outcome = "skipped_no_link"
|
|
528
540
|
if _add_tail_link:
|
|
529
541
|
rc, out, err = run_subprocess(
|
|
530
|
-
[
|
|
542
|
+
[PYTHON, LINK_TAIL,
|
|
531
543
|
"--reply-text", reply_text,
|
|
532
544
|
"--link-url", link_url,
|
|
533
545
|
"--thread-text", thread_text or "",
|
|
@@ -591,7 +603,7 @@ def post_one(c: dict, picker_assignment: dict | None = None) -> tuple[str, str]:
|
|
|
591
603
|
|
|
592
604
|
print(f"[post] candidate {cid} -> posting (link={link_url!r})", flush=True)
|
|
593
605
|
rc, out, err = run_subprocess(
|
|
594
|
-
[
|
|
606
|
+
[PYTHON, TWITTER_BROWSER, "reply", candidate_url, full_text],
|
|
595
607
|
timeout_sec=600,
|
|
596
608
|
)
|
|
597
609
|
if err:
|
|
@@ -668,7 +680,7 @@ def post_one(c: dict, picker_assignment: dict | None = None) -> tuple[str, str]:
|
|
|
668
680
|
from twitter_account import resolve_handle as _resolve_twitter_handle
|
|
669
681
|
|
|
670
682
|
log_args = [
|
|
671
|
-
|
|
683
|
+
PYTHON, LOG_POST,
|
|
672
684
|
"--platform", "twitter",
|
|
673
685
|
"--thread-url", candidate_url,
|
|
674
686
|
"--our-url", reply_url,
|
|
@@ -766,7 +778,7 @@ def post_one(c: dict, picker_assignment: dict | None = None) -> tuple[str, str]:
|
|
|
766
778
|
# Campaign attribution.
|
|
767
779
|
for ccid in applied_campaigns:
|
|
768
780
|
rc, out, err = run_subprocess(
|
|
769
|
-
[
|
|
781
|
+
[PYTHON, CAMPAIGN_BUMP, "--table", "posts",
|
|
770
782
|
"--id", str(post_id), "--campaign-id", str(ccid)],
|
|
771
783
|
timeout_sec=30,
|
|
772
784
|
)
|
|
@@ -778,7 +790,7 @@ def post_one(c: dict, picker_assignment: dict | None = None) -> tuple[str, str]:
|
|
|
778
790
|
# Mark link_edited_at: link is embedded in primary reply, no self-reply
|
|
779
791
|
# will follow. Prevents link-edit-twitter sweep from re-attempting.
|
|
780
792
|
rc, out, err = run_subprocess(
|
|
781
|
-
[
|
|
793
|
+
[PYTHON, LOG_POST,
|
|
782
794
|
"--mark-self-reply",
|
|
783
795
|
"--post-id", str(post_id),
|
|
784
796
|
"--self-reply-url", reply_url,
|
|
@@ -958,6 +970,32 @@ def main() -> int:
|
|
|
958
970
|
f"approved in plan (pass --post-unapproved to override)", flush=True)
|
|
959
971
|
candidates = _kept
|
|
960
972
|
|
|
973
|
+
# Hard preflight: the reply path (twitter_browser.py) imports Playwright,
|
|
974
|
+
# the only such importer in the pipeline. If the resolved interpreter can't
|
|
975
|
+
# import it, EVERY post dies with no_reply_json because the owned runtime is
|
|
976
|
+
# missing or half-provisioned (Karol, 2026-06-22). Fail LOUD here with a
|
|
977
|
+
# distinct signal instead of attempting posts that silently no-op. Gated on
|
|
978
|
+
# there being real work, so a no-op / all-skipped plan still exits clean.
|
|
979
|
+
if candidates:
|
|
980
|
+
_chk = subprocess.run(
|
|
981
|
+
[PYTHON, "-c", "import playwright"],
|
|
982
|
+
capture_output=True, text=True,
|
|
983
|
+
)
|
|
984
|
+
if _chk.returncode != 0:
|
|
985
|
+
print(f"[post] FATAL runtime_incomplete: interpreter {PYTHON!r} cannot "
|
|
986
|
+
f"import playwright — the owned Python runtime is missing or "
|
|
987
|
+
f"unprovisioned. Run the `runtime` install (action:'install') "
|
|
988
|
+
f"before posting. stderr: {(_chk.stderr or '').strip()[:300]}",
|
|
989
|
+
file=sys.stderr, flush=True)
|
|
990
|
+
print(json.dumps({
|
|
991
|
+
"posted": 0,
|
|
992
|
+
"skipped": 0,
|
|
993
|
+
"failed": len(candidates),
|
|
994
|
+
"failure_reasons": "runtime_incomplete",
|
|
995
|
+
"skip_reasons": "",
|
|
996
|
+
}), flush=True)
|
|
997
|
+
return 3
|
|
998
|
+
|
|
961
999
|
_total = len(candidates)
|
|
962
1000
|
try:
|
|
963
1001
|
for _idx, c in enumerate(candidates, start=1):
|
|
@@ -2070,7 +2070,7 @@ log "twitter-browser lock held (pid=$$) Phase 2b-post"
|
|
|
2070
2070
|
ensure_twitter_browser_for_backend 2>&1 | tee -a "$LOG_FILE"
|
|
2071
2071
|
|
|
2072
2072
|
log "Phase 2b-post: posting $PLAN_COUNT candidate(s)..."
|
|
2073
|
-
POST_OUTPUT=$(python3 "$REPO_DIR/scripts/twitter_post_plan.py" --plan "$PLAN_FILE" 2>&1)
|
|
2073
|
+
POST_OUTPUT=$("${SAPS_PYTHON:-python3}" "$REPO_DIR/scripts/twitter_post_plan.py" --plan "$PLAN_FILE" 2>&1)
|
|
2074
2074
|
echo "$POST_OUTPUT" >> "$LOG_FILE"
|
|
2075
2075
|
|
|
2076
2076
|
# The post helper prints a JSON summary on its last stdout line.
|