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 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) => console.error(`[social-autoposter-mcp] menubar: ${r.skipped ? "skip" : r.ok ? "ok" : "fail"} (${r.detail})`))
2373
- .catch((e) => console.error("[social-autoposter-mcp] menubar ensure failed:", e?.message || e));
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
@@ -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)");
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.82",
3
- "installedAt": "2026-06-22T21:04:08.350Z"
2
+ "version": "1.6.83",
3
+ "installedAt": "2026-06-23T02:17:06.705Z"
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.82",
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
- import rumps
28
-
29
- import s4l_state as st
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
- S4LMenuBar().run()
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.82",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.6.82",
3
+ "version": "1.6.83",
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"
@@ -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 = ["python3", os.path.join(REPO_DIR, "scripts", "pick_project.py"),
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 = ["python3", os.path.join(REPO_DIR, "scripts", "top_performers.py"),
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
- ["python3", os.path.join(REPO_DIR, "scripts", "top_search_topics.py"),
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
- ["python3", os.path.join(REPO_DIR, "scripts", "top_omitted_reddit_topics.py"),
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
- ["python3", os.path.join(REPO_DIR, "scripts", "top_dud_reddit_queries.py"),
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 = ["python3", os.path.join(REPO_DIR, "scripts", "log_claude_session.py"),
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
- ["python3", REDDIT_BROWSER_LOCK, "acquire",
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
- ["python3", REDDIT_BROWSER_LOCK, "release"],
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 = ["python3", REDDIT_BROWSER, "reply" if reply_to_url else "post-comment", target, text]
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 = ["python3", REDDIT_TOOLS, "log-post",
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
- ["python3", bump,
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
- raise SystemExit(main())
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)
@@ -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
- ["python3", LINK_TAIL,
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
- ["python3", TWITTER_BROWSER, "reply", candidate_url, full_text],
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
- "python3", LOG_POST,
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
- ["python3", CAMPAIGN_BUMP, "--table", "posts",
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
- ["python3", LOG_POST,
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.