@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.
- package/mcp/dist/runtime.js +33 -10
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_menubar.py +79 -16
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/reddit_browser_fetch.py +119 -64
- package/skill/run-twitter-cycle.sh +16 -3
package/mcp/dist/runtime.js
CHANGED
|
@@ -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
|
|
641
|
-
// runtime `status` handler on each poll
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
-
//
|
|
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
|
-
|
|
658
|
-
|
|
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;
|
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.5-rc.
|
|
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
|
-
|
|
2831
|
+
_, mod = self._review_mod()
|
|
2798
2832
|
|
|
2799
|
-
if
|
|
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
|
-
|
|
2803
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2866
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
2986
|
+
_, mod = self._review_mod()
|
|
2937
2987
|
|
|
2938
|
-
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 =
|
|
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
|
-
|
|
3286
|
+
_, mod = self._review_mod()
|
|
3233
3287
|
|
|
3234
|
-
|
|
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.
|
|
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
|
@@ -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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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..."
|