@m13v/s4l 1.7.5-rc.26 → 1.7.5-rc.28

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.
@@ -637,16 +637,34 @@ export function startProvisioning() {
637
637
  }
638
638
  return readProgress() ?? freshProgress();
639
639
  }
640
- // Bounded auto-retry for a provision that ended in failure. Called from the
641
- // runtime `status` handler on each poll: if the last run failed (done && !ok)
642
- // and nothing is in flight, kick a fresh run so a TRANSIENT failure (a dropped
643
- // Chromium download, a flaky DMG mount) self-heals during normal status polling
644
- // instead of parking until the next server boot. The venv/harness steps clean
645
- // their own partial artifacts, so a retry starts from a clean slate. Capped so a
646
- // genuinely broken environment (no network, no disk) surfaces the error instead
647
- // of looping forever. Returns true if it started a retry.
640
+ // Bounded auto-retry for a provision that ended in failure OR silently died.
641
+ // Called from the runtime `status` handler on each poll. Two distinct dead
642
+ // states, both recoverable the same way:
643
+ // 1. done && !ok — a real failure (fail() ran to completion).
644
+ // 2. running still true, but updated_at hasn't moved in a long time — the
645
+ // HOLDING PROCESS WAS KILLED mid-provision (e.g. Claude Desktop quit
646
+ // mid-install: its onQuitCleanup path closes the MCP server abruptly,
647
+ // never reaching the .finally() that releases the provision lock or
648
+ // calls fail()). Confirmed live on the MacStadium QA box 2026-07-15: a
649
+ // quit ~16s into the Chromium step froze install-progress.json at
650
+ // "running": true forever. The reconnecting process(es) that spawned
651
+ // seconds later saw the lock file as still-fresh (< PROVISION_LOCK_STALE_MS
652
+ // old at that moment) and correctly deferred to it — but nothing ever
653
+ // retried afterward, because case 1's guard doesn't match "running", and
654
+ // ensureRuntimeProvisioned() only fires once, at process boot. Case 2
655
+ // closes that gap: once updated_at is stale enough that the holder is
656
+ // certainly dead, treat it exactly like case 1 — the venv/harness steps
657
+ // already clean their own partial artifacts, and startProvisioning()'s
658
+ // tryAcquireProvisionLock() will find the (by-now very stale) lock file
659
+ // and correctly reclaim it.
660
+ // Capped so a genuinely broken environment (no network, no disk) surfaces the
661
+ // error instead of looping forever. Returns true if it started a retry.
648
662
  let autoRetryCount = 0;
649
663
  const MAX_AUTO_RETRIES = 3;
664
+ // Deliberately well above PROVISION_LOCK_STALE_MS (60s): a heartbeat this old
665
+ // cannot be a merely-slow step (the heartbeat ticks every 15s regardless of
666
+ // what the step itself is doing), only a dead holder.
667
+ const STALLED_RUNNING_MS = 120_000;
650
668
  export function retryProvisionIfStalled() {
651
669
  try {
652
670
  if (runtimeReady())
@@ -654,8 +672,13 @@ export function retryProvisionIfStalled() {
654
672
  if (isProvisioning())
655
673
  return false;
656
674
  const p = readProgress();
657
- if (!(p && p.done && !p.ok))
658
- return false; // only retry a real failure
675
+ const failed = !!(p && p.done && !p.ok);
676
+ const zombieRunning = !!(p &&
677
+ p.running &&
678
+ !p.done &&
679
+ Date.now() - new Date(p.updated_at).getTime() > STALLED_RUNNING_MS);
680
+ if (!(failed || zombieRunning))
681
+ return false;
659
682
  if (autoRetryCount >= MAX_AUTO_RETRIES)
660
683
  return false;
661
684
  autoRetryCount += 1;
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.5-rc.26",
3
- "installedAt": "2026-07-15T23:48:20.436Z"
2
+ "version": "1.7.5-rc.28",
3
+ "installedAt": "2026-07-16T00:10:58.816Z"
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.5-rc.26",
5
+ "version": "1.7.5-rc.28",
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": {
@@ -1070,6 +1070,21 @@ class S4LMenuBar(rumps.App):
1070
1070
  def _toggle_promotion(self, _=None):
1071
1071
  self._toggle_lane(st.MODE_PROMOTION)
1072
1072
 
1073
+ def _toggle_canvas_review(self, _=None):
1074
+ """Menu: flip which review surface presents pending drafts. Persisted
1075
+ (st.write_review_layout), so it survives a restart; an already-open
1076
+ surface keeps driving through whichever module it started with
1077
+ (_review_mod_name) -- this only changes what the NEXT presentation
1078
+ uses. See _review_mod()."""
1079
+ current = st.read_review_layout()
1080
+ st.write_review_layout("cards" if current == "canvas" else "canvas")
1081
+ self._sig = None
1082
+ try:
1083
+ self._tick(None)
1084
+ except Exception as e:
1085
+ sys.stderr.write(f"[s4l-menubar] canvas toggle rebuild failed: {e}\n")
1086
+ sys.stderr.flush()
1087
+
1073
1088
  # Personal-brand share presets for the both-lanes-on state. rumps has no
1074
1089
  # slider, so the "Lane split" submenu offers these fixed points; the
1075
1090
  # dashboard's slider can set anything in between and the checkmark simply
@@ -2788,19 +2803,38 @@ class S4LMenuBar(rumps.App):
2788
2803
  until = self._last_presented_at + cadence
2789
2804
  return until if (cadence > 0 and now < until) else 0.0
2790
2805
 
2806
+ def _review_mod(self):
2807
+ """Which review-surface module drives the review-drafts flow: the
2808
+ module that owns the currently open panel/window when one is open
2809
+ (so a mid-session flip of the "Canvas review" checkbox never talks to
2810
+ the wrong surface for an already-open one), else the persisted
2811
+ st.read_review_layout() preference for a FRESH presentation. Both
2812
+ modules expose the exact same present_review*/extend_active/
2813
+ prune_active/active_status/heal_active/focus_active/dismiss_active
2814
+ surface (see s4l_card_canvas.py's module docstring), so every call
2815
+ site below is agnostic to which one this returns."""
2816
+ name = self._review_mod_name or st.read_review_layout()
2817
+ if name == "canvas":
2818
+ import s4l_card_canvas
2819
+
2820
+ return "canvas", s4l_card_canvas
2821
+ import s4l_card
2822
+
2823
+ return "cards", s4l_card
2824
+
2791
2825
  def _review_now(self, _=None):
2792
2826
  """Menu: bring the pending draft cards up right now. Clears any snooze;
2793
2827
  this is the one presentation path that may take keyboard focus (the
2794
2828
  user explicitly asked for the cards)."""
2795
2829
  self._set_snooze(0.0)
2796
2830
  try:
2797
- import s4l_card
2831
+ _, mod = self._review_mod()
2798
2832
 
2799
- if s4l_card.active_status():
2833
+ if mod.active_status():
2800
2834
  # A card is already on screen (user clicked while one is up,
2801
2835
  # perhaps parked on another display): bring it to them instead.
2802
- s4l_card.heal_active()
2803
- s4l_card.focus_active()
2836
+ mod.heal_active()
2837
+ mod.focus_active()
2804
2838
  return
2805
2839
  except Exception:
2806
2840
  pass
@@ -2856,14 +2890,17 @@ class S4LMenuBar(rumps.App):
2856
2890
  if self._review_active:
2857
2891
  if self._panel_open:
2858
2892
  try:
2859
- import s4l_card
2893
+ # _review_mod_name is already pinned to whichever module
2894
+ # actually opened this panel (set below); _review_mod()
2895
+ # returns that same module, not a fresh preference read.
2896
+ _, mod = self._review_mod()
2860
2897
 
2861
2898
  prev_ns = {n for n, _ in (self._last_review_sig or ())}
2862
2899
  cur_ns = {d.get("n") for d in drafts}
2863
2900
  vanished = prev_ns - cur_ns
2864
2901
  if vanished:
2865
- s4l_card.prune_active(vanished)
2866
- s4l_card.extend_active(drafts)
2902
+ mod.prune_active(vanished)
2903
+ mod.extend_active(drafts)
2867
2904
  except Exception as e:
2868
2905
  sys.stderr.write(f"[s4l-menubar] extend cards failed: {e}\n")
2869
2906
  sys.stderr.flush()
@@ -2889,6 +2926,9 @@ class S4LMenuBar(rumps.App):
2889
2926
  self._review_active = True
2890
2927
  self._panel_open = True
2891
2928
  try:
2929
+ layout_name, mod = self._review_mod()
2930
+ self._review_mod_name = layout_name
2931
+
2892
2932
  import s4l_card
2893
2933
 
2894
2934
  # present_feedback (the menu bar's feedback item) falls back to
@@ -2896,14 +2936,23 @@ class S4LMenuBar(rumps.App):
2896
2936
  # card shows. Same pattern for the title bar's "Discard all…"
2897
2937
  # button (moved out of the dropdown 2026-07-10), which reuses the
2898
2938
  # bulk-discard handler wholesale, confirmation alert included.
2939
+ # Both are s4l_card-only surfaces (the standalone feedback panel
2940
+ # and the corner card's title-bar accessory; the canvas has no
2941
+ # equivalent title-bar button — "Select all" + "Discard
2942
+ # selected" already cover that case natively), so this stays
2943
+ # unconditional on s4l_card regardless of which layout is about
2944
+ # to present, and never goes stale if the layout is switched.
2899
2945
  s4l_card.set_feedback_handler(self._on_feedback_text)
2900
2946
  s4l_card.set_discard_all_handler(self._discard_all_pending)
2901
- s4l_card.present_review(
2902
- drafts,
2947
+ present_kwargs = dict(
2903
2948
  on_decision=lambda d: self._on_card_decision(batch, d),
2904
2949
  on_complete=lambda decisions: self._on_review_closed(batch, decisions),
2905
2950
  focus=focus,
2906
2951
  )
2952
+ if layout_name == "canvas":
2953
+ mod.present_review_canvas(drafts, **present_kwargs)
2954
+ else:
2955
+ mod.present_review(drafts, **present_kwargs)
2907
2956
  # Record as shown only AFTER the cards are actually up, so a transient
2908
2957
  # card-UI failure never permanently suppresses this pending set.
2909
2958
  self._last_review_sig = sig
@@ -2914,11 +2963,12 @@ class S4LMenuBar(rumps.App):
2914
2963
  # notifies once per episode); a stderr line keeps fresh stacks
2915
2964
  # greppable.
2916
2965
  n = len(drafts)
2917
- sys.stderr.write(f"[s4l-menubar] presented {n} draft card(s)\n")
2966
+ sys.stderr.write(f"[s4l-menubar] presented {n} draft{'s' if n != 1 else ''} ({layout_name})\n")
2918
2967
  except Exception as e:
2919
- # Card UI unavailable — don't strand the batch; chat review still works.
2968
+ # Review UI unavailable — don't strand the batch; chat review still works.
2920
2969
  self._review_active = False
2921
2970
  self._panel_open = False
2971
+ self._review_mod_name = None
2922
2972
  sys.stderr.write(f"[s4l-menubar] review cards failed: {e}\n")
2923
2973
  sys.stderr.flush()
2924
2974
  _capture(e, phase="review_cards")
@@ -2933,9 +2983,9 @@ class S4LMenuBar(rumps.App):
2933
2983
  once per episode; after REVIEW_UNATTENDED_SENTRY_SECONDS emit one
2934
2984
  Sentry event so ignored review surfaces are visible fleet-wide."""
2935
2985
  try:
2936
- import s4l_card
2986
+ _, mod = self._review_mod()
2937
2987
 
2938
- status = s4l_card.active_status()
2988
+ status = mod.active_status()
2939
2989
  except Exception:
2940
2990
  return
2941
2991
  if not status or not status.get("pending"):
@@ -2959,7 +3009,7 @@ class S4LMenuBar(rumps.App):
2959
3009
  self._review_heal_at = now
2960
3010
  healed = False
2961
3011
  try:
2962
- healed = s4l_card.heal_active()
3012
+ healed = mod.heal_active()
2963
3013
  except Exception as e:
2964
3014
  sys.stderr.write(f"[s4l-menubar] review heal failed: {e}\n")
2965
3015
  sys.stderr.flush()
@@ -3132,6 +3182,10 @@ class S4LMenuBar(rumps.App):
3132
3182
  # isn't re-presented as a fresh batch.
3133
3183
  with self._review_lock:
3134
3184
  self._panel_open = False
3185
+ # The surface is confirmed gone: release the pin so the NEXT
3186
+ # presentation re-resolves from st.read_review_layout() (picks up
3187
+ # a "Canvas review" toggle flipped while this one was open).
3188
+ self._review_mod_name = None
3135
3189
  if self._posts_outstanding <= 0:
3136
3190
  self._review_active = False
3137
3191
  self._reset_posting_progress_locked()
@@ -3229,13 +3283,14 @@ class S4LMenuBar(rumps.App):
3229
3283
 
3230
3284
  threading.Thread(target=_persist_discard, daemon=True).start()
3231
3285
  try:
3232
- import s4l_card
3286
+ _, mod = self._review_mod()
3233
3287
 
3234
- s4l_card.dismiss_active()
3288
+ mod.dismiss_active()
3235
3289
  except Exception:
3236
3290
  pass
3237
3291
  with self._review_lock:
3238
3292
  self._panel_open = False
3293
+ self._review_mod_name = None
3239
3294
  if self._posts_outstanding <= 0:
3240
3295
  self._review_active = False
3241
3296
  self._reset_posting_progress_locked()
@@ -3514,6 +3569,14 @@ class S4LMenuBar(rumps.App):
3514
3569
  split_menu.add(it)
3515
3570
  items.append(split_menu)
3516
3571
 
3572
+ # Review surface layout: the one-at-a-time corner card (default) vs.
3573
+ # the large centered multi-select canvas (2026-07-15). Always visible
3574
+ # for the same reason as the lane checkmarks above — a standing
3575
+ # preference, not tied to whether anything is pending right now.
3576
+ canvas_item = rumps.MenuItem("Canvas review", callback=self._toggle_canvas_review)
3577
+ canvas_item.state = 1 if st.read_review_layout() == "canvas" else 0
3578
+ items.append(canvas_item)
3579
+
3517
3580
  # Reveal cadence: how often fresh draft cards may pop (the drafting
3518
3581
  # pipeline is unchanged; this only paces the pop-up). Always offered:
3519
3582
  # it is the "don't interrupt me every few minutes" control.
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.5-rc.26",
3
+ "version": "1.7.5-rc.28",
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.5-rc.26",
3
+ "version": "1.7.5-rc.28",
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",
@@ -33,6 +33,9 @@ import sys
33
33
  import time
34
34
  from urllib.parse import urlparse
35
35
 
36
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
37
+ from browser_mutex import BrowserMutex
38
+
36
39
 
37
40
  def _default_cdp_url():
38
41
  return os.environ.get("REDDIT_CDP_URL", "http://127.0.0.1:9557").strip() \
@@ -70,6 +73,50 @@ def _yield_to_poster():
70
73
  sys.stderr.write("[reddit_browser_fetch] yielded to active poster\n")
71
74
 
72
75
 
76
+ # ---- browser-session mutex (2026-07-15) -------------------------------------
77
+ # Overlapping run-reddit-search.sh cycles are BY DESIGN (the "double-fork
78
+ # wrapper" in that script deliberately lets cycles stack when one runs longer
79
+ # than the 15-min launchd interval, which is routine — cycles regularly take
80
+ # 20-40 min). Every one of those cycles' discover-phase fetches calls
81
+ # browser_get_json, and until now nothing serialized them: two concurrent
82
+ # calls both reuse the SAME harness tab (see the tab-reuse comment below), so
83
+ # one call's page.goto() destroys the other's in-flight page.evaluate()
84
+ # execution context ("Execution context was destroyed, most likely because
85
+ # of a navigation" / "TypeError: Failed to fetch"). That failure returns
86
+ # (None, 0), the caller falls back to urllib, and urllib has been 403'd by
87
+ # Reddit's bot wall since 2026-05-28 — so what looks like "Reddit blocked us"
88
+ # is actually two of our own readers racing each other.
89
+ #
90
+ # reddit_browser.py (posting) already fixed this class of bug for itself via
91
+ # scripts/browser_mutex.py (twitter's proven mutex, parameterized) — same
92
+ # lock_file, same JSON shape, so this shares that lock domain rather than
93
+ # adding a fourth divergent one. Role defaults to "scan" (a read), matching
94
+ # every other reader; BrowserMutex's own env read means an actual role="post"
95
+ # caller would still inherit posting priority automatically.
96
+ _LOCK_FILE = os.path.expanduser("~/.claude/reddit-agent-lock.json")
97
+ _MUTEX = BrowserMutex(
98
+ lock_file=_LOCK_FILE,
99
+ label="Reddit browser",
100
+ lock_expiry=300,
101
+ wait_max=45,
102
+ poll_interval=2,
103
+ )
104
+
105
+
106
+ def _acquire_fetch_lock() -> bool:
107
+ """True if the tab is ours. BrowserMutex.acquire() prints a JSON error and
108
+ sys.exit(1)s on contention timeout — correct for a CLI entrypoint like
109
+ reddit_browser.py's own callers, but browser_get_json is a library call
110
+ nested inside reddit_tools.py/post_reddit.py and must degrade to its
111
+ documented (None, 0) contract instead of killing the caller process."""
112
+ try:
113
+ _MUTEX.acquire()
114
+ return True
115
+ except SystemExit:
116
+ sys.stderr.write("[reddit_browser_fetch] tab lock contended; skipping this fetch\n")
117
+ return False
118
+
119
+
73
120
  def browser_get_json(url, cdp_url=None, timeout_ms=25000):
74
121
  """Fetch a Reddit JSON URL through the logged-in harness Chrome.
75
122
 
@@ -88,73 +135,81 @@ def browser_get_json(url, cdp_url=None, timeout_ms=25000):
88
135
  host = parsed.netloc or "www.reddit.com"
89
136
  host_root = f"{parsed.scheme or 'https'}://{host}/"
90
137
 
91
- with sync_playwright() as p:
92
- browser = None
93
- page = None
94
- try:
95
- browser = p.chromium.connect_over_cdp(cdp_url)
96
- if not browser.contexts:
97
- sys.stderr.write("[reddit_browser_fetch] no CDP contexts on harness\n")
98
- return None, 0
99
- ctx = browser.contexts[0]
100
- # Reuse an existing tab instead of new_page() on every fetch. new_page()
101
- # steals OS focus each call (there are many discovery fetches per cycle,
102
- # so this churned the user's focus constantly); navigating a background
103
- # tab does not. Prefer a tab already on reddit.com; else pages[0]; else
104
- # create one. Mirrors reddit_browser / twitter_browser tab reuse. The
105
- # page is left OPEN for the next fetch (cleanup_harness_tabs trims to one
106
- # at cycle start).
138
+ # Tab access is mutex-protected (2026-07-15, see _MUTEX above): everything
139
+ # from tab selection through the evaluate retries must be atomic relative
140
+ # to every other concurrent fetch, since they all share the ONE reused tab.
141
+ if not _acquire_fetch_lock():
142
+ return None, 0
143
+ try:
144
+ with sync_playwright() as p:
145
+ browser = None
107
146
  page = None
108
- for pg in ctx.pages:
109
- if "reddit.com" in (pg.url or "") and "login" not in (pg.url or ""):
110
- page = pg
111
- break
112
- if page is None and ctx.pages:
113
- page = ctx.pages[0]
114
- if page is None:
115
- page = ctx.new_page()
116
- # Load the matching host root so the subsequent fetch() is same-origin
117
- # (no CORS between www/old) and carries the logged-in session.
118
147
  try:
119
- page.goto(host_root, wait_until="load", timeout=timeout_ms)
120
- except Exception:
121
- pass # partial load is fine; we just need an active reddit origin
122
- # Same-origin fetch with a couple retries — reddit.com sometimes does a
123
- # client redirect on first load that destroys the execution context.
124
- js = (
125
- "async (u) => {"
126
- " const r = await fetch(u, {credentials:'include',"
127
- " headers:{'Accept':'application/json'}});"
128
- " const t = await r.text();"
129
- " return {status: r.status, body: t};"
130
- "}"
131
- )
132
- last_err = None
133
- for attempt in range(3):
148
+ browser = p.chromium.connect_over_cdp(cdp_url)
149
+ if not browser.contexts:
150
+ sys.stderr.write("[reddit_browser_fetch] no CDP contexts on harness\n")
151
+ return None, 0
152
+ ctx = browser.contexts[0]
153
+ # Reuse an existing tab instead of new_page() on every fetch. new_page()
154
+ # steals OS focus each call (there are many discovery fetches per cycle,
155
+ # so this churned the user's focus constantly); navigating a background
156
+ # tab does not. Prefer a tab already on reddit.com; else pages[0]; else
157
+ # create one. Mirrors reddit_browser / twitter_browser tab reuse. The
158
+ # page is left OPEN for the next fetch (cleanup_harness_tabs trims to one
159
+ # at cycle start).
160
+ page = None
161
+ for pg in ctx.pages:
162
+ if "reddit.com" in (pg.url or "") and "login" not in (pg.url or ""):
163
+ page = pg
164
+ break
165
+ if page is None and ctx.pages:
166
+ page = ctx.pages[0]
167
+ if page is None:
168
+ page = ctx.new_page()
169
+ # Load the matching host root so the subsequent fetch() is same-origin
170
+ # (no CORS between www/old) and carries the logged-in session.
134
171
  try:
135
- res = page.evaluate(js, url)
136
- status = int(res.get("status", 0))
137
- body = res.get("body") or ""
138
- if status != 200:
139
- return None, status
140
- return body, status
141
- except Exception as e:
142
- last_err = e
143
- time.sleep(2.0) # let a redirect settle, then retry
144
- sys.stderr.write(f"[reddit_browser_fetch] evaluate failed after retries: {last_err}\n")
145
- return None, 0
146
- except Exception as e:
147
- sys.stderr.write(f"[reddit_browser_fetch] error: {e}\n")
148
- return None, 0
149
- finally:
150
- # Do NOT close the page: it is a REUSED tab, and closing it forces the
151
- # next fetch to new_page() which steals OS focus. Leaving it open lets
152
- # the next fetch reuse it (cleanup_harness_tabs trims to one at cycle
153
- # start). Also never close the connect_over_cdp browser/context: that can
154
- # terminate the real harness Chrome (see reddit_browser.py warning). The
155
- # sync_playwright() context exit disconnects the CDP client cleanly
156
- # without killing the remote browser.
157
- pass
172
+ page.goto(host_root, wait_until="load", timeout=timeout_ms)
173
+ except Exception:
174
+ pass # partial load is fine; we just need an active reddit origin
175
+ # Same-origin fetch with a couple retries — reddit.com sometimes does a
176
+ # client redirect on first load that destroys the execution context.
177
+ js = (
178
+ "async (u) => {"
179
+ " const r = await fetch(u, {credentials:'include',"
180
+ " headers:{'Accept':'application/json'}});"
181
+ " const t = await r.text();"
182
+ " return {status: r.status, body: t};"
183
+ "}"
184
+ )
185
+ last_err = None
186
+ for attempt in range(3):
187
+ try:
188
+ res = page.evaluate(js, url)
189
+ status = int(res.get("status", 0))
190
+ body = res.get("body") or ""
191
+ if status != 200:
192
+ return None, status
193
+ return body, status
194
+ except Exception as e:
195
+ last_err = e
196
+ time.sleep(2.0) # let a redirect settle, then retry
197
+ sys.stderr.write(f"[reddit_browser_fetch] evaluate failed after retries: {last_err}\n")
198
+ return None, 0
199
+ except Exception as e:
200
+ sys.stderr.write(f"[reddit_browser_fetch] error: {e}\n")
201
+ return None, 0
202
+ finally:
203
+ # Do NOT close the page: it is a REUSED tab, and closing it forces the
204
+ # next fetch to new_page() which steals OS focus. Leaving it open lets
205
+ # the next fetch reuse it (cleanup_harness_tabs trims to one at cycle
206
+ # start). Also never close the connect_over_cdp browser/context: that can
207
+ # terminate the real harness Chrome (see reddit_browser.py warning). The
208
+ # sync_playwright() context exit disconnects the CDP client cleanly
209
+ # without killing the remote browser.
210
+ pass
211
+ finally:
212
+ _MUTEX.release()
158
213
 
159
214
 
160
215
  def main(argv):
@@ -2154,7 +2154,14 @@ SKIP_FILE="/tmp/twitter_cycle_skips_${BATCH_ID}.json"
2154
2154
  # of stale phase2a (20-min budget). Without this stamp, mid-Phase-2b runs get
2155
2155
  # wrongly salvaged once 20 min elapse past phase2a's start, creating false
2156
2156
  # phase2b_silent run-monitor rows even when posts succeeded.
2157
- python3 "$REPO_DIR/scripts/twitter_batch_phase.py" advance "$BATCH_ID" --phase phase2b-prep 2>&1 | tee -a "$LOG_FILE" || true
2157
+ # Sandbox short-circuit: skip so a sandbox run never auto-creates a
2158
+ # twitter_batches row (advance's own "auto-creates the row if start was
2159
+ # missed" fallback would otherwise silently do it, since sandbox mode never
2160
+ # calls 'start' — found live 2026-07-15, a stray sandbox-* row landed in
2161
+ # production twitter_batches from this exact call).
2162
+ if [ -z "${S4L_SANDBOX_CANDIDATES_FILE:-}" ]; then
2163
+ python3 "$REPO_DIR/scripts/twitter_batch_phase.py" advance "$BATCH_ID" --phase phase2b-prep 2>&1 | tee -a "$LOG_FILE" || true
2164
+ fi
2158
2165
  log "Re-acquiring twitter-browser lock for Phase 2b-prep (read+draft only)..."
2159
2166
  acquire_lock "twitter-browser" 3600 2>>"$LOG_FILE"
2160
2167
  log "twitter-browser lock held (pid=$$) Phase 2b-prep"
@@ -2700,7 +2707,10 @@ fi
2700
2707
  # phase2b-gen has the longest budget (60 min) because the SEO landing-page
2701
2708
  # build can legitimately run 10-40 min. Stamping it here is what protects
2702
2709
  # this cycle from being salvaged out from under itself.
2703
- python3 "$REPO_DIR/scripts/twitter_batch_phase.py" advance "$BATCH_ID" --phase phase2b-gen 2>&1 | tee -a "$LOG_FILE" || true
2710
+ # Same sandbox short-circuit as the phase2b-prep advance above.
2711
+ if [ -z "${S4L_SANDBOX_CANDIDATES_FILE:-}" ]; then
2712
+ python3 "$REPO_DIR/scripts/twitter_batch_phase.py" advance "$BATCH_ID" --phase phase2b-gen 2>&1 | tee -a "$LOG_FILE" || true
2713
+ fi
2704
2714
  log "Phase 2b-gen: generating SEO pages for $PLAN_COUNT candidate(s) without holding the browser lock..."
2705
2715
  python3 "$REPO_DIR/scripts/twitter_gen_links.py" --plan "$PLAN_FILE" 2>&1 | tee -a "$LOG_FILE"
2706
2716
  GEN_EXIT=${PIPESTATUS[0]:-1}
@@ -2781,7 +2791,10 @@ fi
2781
2791
  # Stamp phase2b-post (15-min budget) before the browser-side reply loop. After
2782
2792
  # 2b-gen's potentially long run, peer cycles' 20-min phase2a fallback would
2783
2793
  # already be tripping if we left the row at phase2a.
2784
- python3 "$REPO_DIR/scripts/twitter_batch_phase.py" advance "$BATCH_ID" --phase phase2b-post 2>&1 | tee -a "$LOG_FILE" || true
2794
+ # Same sandbox short-circuit as the phase2b-prep advance above.
2795
+ if [ -z "${S4L_SANDBOX_CANDIDATES_FILE:-}" ]; then
2796
+ python3 "$REPO_DIR/scripts/twitter_batch_phase.py" advance "$BATCH_ID" --phase phase2b-post 2>&1 | tee -a "$LOG_FILE" || true
2797
+ fi
2785
2798
  # Always re-acquire: the lock was released right after thread-media capture
2786
2799
  # (before Claude drafting), well before 2b-gen, so it is never still held here.
2787
2800
  log "Re-acquiring twitter-browser lock for Phase 2b-post..."