@m13v/s4l 1.7.5-rc.2 → 1.7.5-rc.21
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 +571 -79
- package/mcp/dist/panel.html +2 -3
- package/mcp/dist/redditAuth.js +115 -0
- package/mcp/dist/runtime.js +83 -2
- package/mcp/dist/setup.js +142 -5
- package/mcp/dist/telemetry.js +13 -3
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +32 -6
- package/mcp/menubar/s4l_menubar.py +42 -111
- package/mcp/menubar/s4l_state.py +54 -187
- package/mcp/package.json +1 -1
- package/mcp/shared/onboarding-ledger.cjs +63 -4
- package/package.json +1 -1
- package/scripts/autopilot_stall_watch.py +3 -6
- package/scripts/browser_mutex.py +316 -0
- package/scripts/claude_job.py +21 -0
- package/scripts/config.py +118 -0
- package/scripts/counterparty_history.py +44 -6
- package/scripts/engage_github.py +7 -3
- package/scripts/engage_reddit.py +9 -3
- package/scripts/engagement_signal_feedback.py +256 -0
- package/scripts/learned_preferences.py +37 -14
- package/scripts/memory_snapshot.py +45 -0
- package/scripts/merge_review_queue.py +67 -3
- package/scripts/post_reddit.py +289 -24
- package/scripts/reddit_browser.py +62 -63
- package/scripts/reddit_browser_fetch.py +32 -0
- package/scripts/release-mcpb.sh +17 -0
- package/scripts/reset-test-machine.sh +13 -7
- package/scripts/scan_twitter_thread_followups.py +7 -1
- package/scripts/schedule_state.py +1 -1
- package/scripts/setup_reddit_auth.py +925 -0
- package/scripts/snapshot.py +96 -4
- package/scripts/stats.py +37 -13
- package/scripts/test_no_silent_fallbacks.py +281 -0
- package/scripts/trigger_promo_lane_test.sh +91 -0
- package/scripts/twitter_browser.py +183 -342
- package/scripts/twitter_scan.py +75 -10
- package/scripts/watchdog_hung_runs.py +15 -9
- package/skill/lib/harness-common.sh +344 -0
- package/skill/lib/linkedin-backend.sh +61 -161
- package/skill/lib/reddit-backend.sh +39 -125
- package/skill/lib/twitter-backend.sh +51 -273
- package/skill/lock.sh +57 -0
- package/skill/run-reddit-search-launchd.sh +4 -1
- package/skill/run-reddit-search.sh +51 -2
- package/skill/run-twitter-cycle.sh +44 -21
package/scripts/post_reddit.py
CHANGED
|
@@ -31,11 +31,45 @@ from http_api import api_get, api_post, api_patch
|
|
|
31
31
|
from author_history_block import render as _render_author_history
|
|
32
32
|
from project_topics import topics_for_project
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
# Honor S4L_REPO_DIR so managed-package installs resolve helper scripts inside
|
|
35
|
+
# the package instead of a nonexistent ~/social-autoposter (the same $HOME
|
|
36
|
+
# hardcode class that silently no-op'd session restore on customer boxes).
|
|
37
|
+
REPO_DIR = os.path.expanduser(os.environ.get("S4L_REPO_DIR") or "~/social-autoposter")
|
|
35
38
|
CONFIG_PATH = os.path.join(REPO_DIR, "config.json")
|
|
36
39
|
REDDIT_BROWSER = os.path.join(REPO_DIR, "scripts", "reddit_browser.py")
|
|
37
40
|
REDDIT_BROWSER_LOCK = os.path.join(REPO_DIR, "scripts", "reddit_browser_lock.py")
|
|
38
41
|
REDDIT_TOOLS = os.path.join(REPO_DIR, "scripts", "reddit_tools.py")
|
|
42
|
+
RUN_CLAUDE_SH = os.path.join(REPO_DIR, "scripts", "run_claude.sh")
|
|
43
|
+
|
|
44
|
+
# JSON schema for the queue-routed draft turn (tag post-reddit-draft ->
|
|
45
|
+
# claude_job.py type reddit-draft). One object, two arrays; posts[] entries
|
|
46
|
+
# mirror the legacy JSONL "action":"post" line fields, rejects[] entries feed
|
|
47
|
+
# _propose_excludes_from_rejects (thread_url + reason + proposed_excludes).
|
|
48
|
+
REDDIT_DRAFT_SCHEMA = json.dumps({
|
|
49
|
+
"type": "object",
|
|
50
|
+
"properties": {
|
|
51
|
+
"posts": {"type": "array", "items": {"type": "object", "properties": {
|
|
52
|
+
"thread_url": {"type": "string"},
|
|
53
|
+
"reply_to_url": {"type": ["string", "null"]},
|
|
54
|
+
"text": {"type": "string"},
|
|
55
|
+
"thread_author": {"type": "string"},
|
|
56
|
+
"thread_title": {"type": "string"},
|
|
57
|
+
"engagement_style": {"type": "string"},
|
|
58
|
+
"search_topic": {"type": "string"},
|
|
59
|
+
"new_style": {"type": ["object", "null"], "properties": {
|
|
60
|
+
"description": {"type": "string"},
|
|
61
|
+
"example": {"type": "string"},
|
|
62
|
+
}},
|
|
63
|
+
}, "required": ["thread_url", "text", "thread_author",
|
|
64
|
+
"thread_title", "engagement_style"]}},
|
|
65
|
+
"rejects": {"type": "array", "items": {"type": "object", "properties": {
|
|
66
|
+
"thread_url": {"type": "string"},
|
|
67
|
+
"reason": {"type": "string"},
|
|
68
|
+
"proposed_excludes": {"type": "array", "items": {"type": "string"}},
|
|
69
|
+
}, "required": ["thread_url", "reason"]}},
|
|
70
|
+
},
|
|
71
|
+
"required": ["posts", "rejects"],
|
|
72
|
+
})
|
|
39
73
|
|
|
40
74
|
# Interpreter every child subprocess must run under. A bare PYTHON resolved
|
|
41
75
|
# to the user's system python, which lacks the pipeline deps (Playwright and
|
|
@@ -101,8 +135,44 @@ _TRANSIENT_CDP_ERRORS = {
|
|
|
101
135
|
"all_attempts_failed",
|
|
102
136
|
"comment_box_not_found",
|
|
103
137
|
"not_logged_in",
|
|
138
|
+
# A concurrent reddit pipeline navigated the shared harness tab out from
|
|
139
|
+
# under the poster (2026-07-14: 5/5 approvals misclassified as
|
|
140
|
+
# account_blocked_in_sub this way). Always retryable.
|
|
141
|
+
"tab_contention",
|
|
104
142
|
}
|
|
105
143
|
|
|
144
|
+
# ---- posting-active flag (2026-07-14, mirrors twitter's posting-active) ----
|
|
145
|
+
# Stamped for the whole --phase post run and heartbeated per row. Readers:
|
|
146
|
+
# run-reddit-search.sh (skips a cycle fire while fresh) and reddit-backend.sh's
|
|
147
|
+
# pre-launch defer hook (skips every other reddit pipeline's fire), so scans
|
|
148
|
+
# and engagement never grab the ONE shared harness tab mid-post. A stale file
|
|
149
|
+
# (heartbeat older than ~120s) never blocks anyone: a killed poster must not
|
|
150
|
+
# wedge the fleet.
|
|
151
|
+
_S4L_STATE_DIR = os.path.expanduser(os.environ.get("S4L_STATE_DIR") or "~/.social-autoposter-mcp")
|
|
152
|
+
POSTING_ACTIVE_FILE = os.path.join(_S4L_STATE_DIR, "reddit-posting-active.json")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _stamp_posting_active():
|
|
156
|
+
try:
|
|
157
|
+
os.makedirs(_S4L_STATE_DIR, exist_ok=True)
|
|
158
|
+
with open(POSTING_ACTIVE_FILE, "w") as f:
|
|
159
|
+
json.dump({"pid": os.getpid(), "hb": time.time()}, f)
|
|
160
|
+
except Exception:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _clear_posting_active():
|
|
165
|
+
try:
|
|
166
|
+
with open(POSTING_ACTIVE_FILE) as f:
|
|
167
|
+
if json.load(f).get("pid") != os.getpid():
|
|
168
|
+
return # a peer poster owns the flag now; not ours to clear
|
|
169
|
+
except Exception:
|
|
170
|
+
pass
|
|
171
|
+
try:
|
|
172
|
+
os.remove(POSTING_ACTIVE_FILE)
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
|
175
|
+
|
|
106
176
|
from engagement_styles import (
|
|
107
177
|
VALID_STYLES, get_styles_prompt, get_content_rules, validate_or_register,
|
|
108
178
|
pick_style_for_post, get_voice_relationship_rule,
|
|
@@ -1029,13 +1099,87 @@ no candidate lines, no commentary about thread content (you don't see any).
|
|
|
1029
1099
|
"""
|
|
1030
1100
|
|
|
1031
1101
|
|
|
1102
|
+
def _truncate(s, n):
|
|
1103
|
+
s = s or ""
|
|
1104
|
+
return s if len(s) <= n else s[:n] + "..."
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def _prefetch_thread_digests(candidates, reddit_username=None):
|
|
1108
|
+
"""Pre-fetch thread content for the draft prompt (2026-07-14).
|
|
1109
|
+
|
|
1110
|
+
The draft turn used to be agentic: Claude ran `reddit_tools.py fetch` via
|
|
1111
|
+
the Bash tool per candidate. Queue routing (claude_job.py, tag
|
|
1112
|
+
post-reddit-draft) only carries pure text->JSON turns, so the fetches now
|
|
1113
|
+
happen HERE, in Python, and the digests are inlined into the prompt —
|
|
1114
|
+
the same tool-free conversion the twitter Phase 2b prep got on 2026-06-26.
|
|
1115
|
+
Same access pattern as before (reddit_tools fetch rides the harness
|
|
1116
|
+
Chrome's CDP, which is multi-client safe), just a different caller.
|
|
1117
|
+
|
|
1118
|
+
Returns (digests, dropped): digests maps thread_url -> digest text for
|
|
1119
|
+
candidates worth drafting; dropped is a list of (candidate, reason) for
|
|
1120
|
+
threads that can no longer be posted to (archived/locked/blocked) so the
|
|
1121
|
+
caller marks them permanently failed. Transient fetch failures land in
|
|
1122
|
+
NEITHER (candidate stays pending; Phase 0 salvage retries next cycle).
|
|
1123
|
+
"""
|
|
1124
|
+
digests = {}
|
|
1125
|
+
dropped = []
|
|
1126
|
+
our_name = (reddit_username or "").lower()
|
|
1127
|
+
for c in candidates:
|
|
1128
|
+
url = c.get("thread_url") or ""
|
|
1129
|
+
if not url:
|
|
1130
|
+
continue
|
|
1131
|
+
try:
|
|
1132
|
+
proc = subprocess.run(
|
|
1133
|
+
[PYTHON, REDDIT_TOOLS, "fetch", url],
|
|
1134
|
+
capture_output=True, text=True, timeout=90,
|
|
1135
|
+
)
|
|
1136
|
+
data = json.loads((proc.stdout or "").strip() or "{}")
|
|
1137
|
+
except Exception as e:
|
|
1138
|
+
print(f"[post_reddit] prefetch FAILED (transient) {url}: {e}",
|
|
1139
|
+
file=sys.stderr, flush=True)
|
|
1140
|
+
continue
|
|
1141
|
+
err = data.get("error")
|
|
1142
|
+
if err:
|
|
1143
|
+
if err in ("thread_archived", "thread_locked", "subreddit_blocked"):
|
|
1144
|
+
dropped.append((c, err))
|
|
1145
|
+
else:
|
|
1146
|
+
print(f"[post_reddit] prefetch error (transient) {url}: {err}",
|
|
1147
|
+
file=sys.stderr, flush=True)
|
|
1148
|
+
continue
|
|
1149
|
+
thread = data.get("thread") or {}
|
|
1150
|
+
comments = data.get("comments") or []
|
|
1151
|
+
lines = [
|
|
1152
|
+
f"subreddit: {thread.get('subreddit', '')} score: {thread.get('score', 0)}"
|
|
1153
|
+
f" comments: {thread.get('num_comments', 0)}",
|
|
1154
|
+
f"OP u/{thread.get('author', '')}: {thread.get('title', '')}",
|
|
1155
|
+
]
|
|
1156
|
+
selftext = (thread.get("selftext") or "").strip()
|
|
1157
|
+
if selftext:
|
|
1158
|
+
lines.append(_truncate(selftext, 1500))
|
|
1159
|
+
already = False
|
|
1160
|
+
if comments:
|
|
1161
|
+
lines.append("top comments:")
|
|
1162
|
+
for cm in comments[:12]:
|
|
1163
|
+
author = cm.get("author") or ""
|
|
1164
|
+
if our_name and author.lower() == our_name:
|
|
1165
|
+
already = True
|
|
1166
|
+
body = _truncate((cm.get("body") or "").strip().replace("\n", " "), 280)
|
|
1167
|
+
lines.append(f" u/{author} ({cm.get('score', 0)}): {body}")
|
|
1168
|
+
if already:
|
|
1169
|
+
lines.append("NOTE: one of OUR accounts already commented in this"
|
|
1170
|
+
" thread (astroturf OMIT rule applies).")
|
|
1171
|
+
digests[url] = "\n".join(lines)
|
|
1172
|
+
return digests, dropped
|
|
1173
|
+
|
|
1174
|
+
|
|
1032
1175
|
def build_draft_prompt(project, config, candidates, top_report, recent_comments,
|
|
1033
|
-
style_assignment=None):
|
|
1176
|
+
style_assignment=None, digests=None):
|
|
1034
1177
|
"""DRAFT phase: write comments only for ripen-survivors.
|
|
1035
1178
|
|
|
1036
1179
|
`candidates` is the list of decisions that passed the delta gate, each
|
|
1037
|
-
annotated with ripen data (delta_up, delta_comments, composite).
|
|
1038
|
-
|
|
1180
|
+
annotated with ripen data (delta_up, delta_comments, composite). Thread
|
|
1181
|
+
content is pre-fetched by _prefetch_thread_digests and inlined per
|
|
1182
|
+
candidate (tool-free turn since 2026-07-14; the model must not fetch).
|
|
1039
1183
|
|
|
1040
1184
|
2026-05-19: `style_assignment` is the pick_style_for_post() result the
|
|
1041
1185
|
discover phase already wrote into the plan JSON. Forwarding it here so
|
|
@@ -1078,11 +1222,15 @@ def build_draft_prompt(project, config, candidates, top_report, recent_comments,
|
|
|
1078
1222
|
history_line = "\n " + _hb.replace("\n", "\n ")
|
|
1079
1223
|
except Exception:
|
|
1080
1224
|
pass
|
|
1225
|
+
digest_block = ""
|
|
1226
|
+
if digests and c.get("thread_url") in digests:
|
|
1227
|
+
_d = digests[c["thread_url"]]
|
|
1228
|
+
digest_block = "\n THREAD CONTENT:\n " + _d.replace("\n", "\n ")
|
|
1081
1229
|
candidate_lines.append(
|
|
1082
1230
|
f" - {c['thread_url']}{delta_info}\n"
|
|
1083
1231
|
f" title: {c.get('thread_title', '')}\n"
|
|
1084
1232
|
f" suggested style: {c.get('engagement_style', '')}"
|
|
1085
|
-
f"{history_line}"
|
|
1233
|
+
f"{history_line}{digest_block}"
|
|
1086
1234
|
)
|
|
1087
1235
|
candidates_block = "\n".join(candidate_lines)
|
|
1088
1236
|
|
|
@@ -1120,13 +1268,8 @@ OMIT THESE (clear no-bridge cases only):
|
|
|
1120
1268
|
- Project: study tool. Thread where you've ALREADY commented under any of our accounts (`already_posted=true` or our usernames in the comment list): obvious astroturfing. OMIT.
|
|
1121
1269
|
- Any thread where you'd be embarrassed to have your comment shown next to a {project.get('name', 'this project')} link in the same Reddit thread.
|
|
1122
1270
|
|
|
1123
|
-
##
|
|
1124
|
-
|
|
1125
|
-
- Do NOT run any searches. The threads are already chosen.
|
|
1126
|
-
|
|
1127
|
-
## CRITICAL Bash rules
|
|
1128
|
-
- NEVER use run_in_background=true. All commands foreground.
|
|
1129
|
-
- Fetch each thread once to read OP and top comments for context. Use the fetched content to apply the SELECTION GATE.
|
|
1271
|
+
## THREAD CONTENT (pre-fetched)
|
|
1272
|
+
Each candidate above carries a THREAD CONTENT block (OP + top comments), already fetched for you. You have NO tools this session: do NOT fetch, search, or browse anything. Apply the SELECTION GATE using the inlined content only. If a candidate is somehow missing its THREAD CONTENT block, OMIT it.
|
|
1130
1273
|
|
|
1131
1274
|
## CRITICAL CONTENT RULES (apply only to threads that pass the gate)
|
|
1132
1275
|
- Go BIMODAL on length: 1 punchy sentence (<100 chars) OR 4-5 sentences of real substance. Avoid 2-3 sentence middle-ground.
|
|
@@ -1148,15 +1291,19 @@ OMIT THESE (clear no-bridge cases only):
|
|
|
1148
1291
|
{get_voice_relationship_rule()}
|
|
1149
1292
|
|
|
1150
1293
|
## OUTPUT FORMAT
|
|
1151
|
-
|
|
1152
|
-
|
|
1294
|
+
Return ONE JSON object with two arrays (a JSON schema is enforced on this session):
|
|
1295
|
+
|
|
1296
|
+
{{"posts": [...], "rejects": [...]}}
|
|
1297
|
+
|
|
1298
|
+
Each posts[] entry is one thread that PASSES the SELECTION GATE:
|
|
1299
|
+
{{"thread_url": "SAME_URL_AS_GIVEN", "reply_to_url": null, "text": "your comment here", "thread_author": "username", "thread_title": "thread title", "engagement_style": "{(style_assignment or {}).get('style') or 'style_name'}", "search_topic": "the seed concept", "new_style": null}}
|
|
1153
1300
|
|
|
1154
|
-
For threads that FAIL the gate, simply
|
|
1301
|
+
For threads that FAIL the gate, simply leave them out of posts. The shell handles unhandled candidates correctly (Phase 0 salvage on the next cycle re-checks them, and one-strike ripen failure has already pruned dead threads). When nothing passes, return {{"posts": [], "rejects": []}}.
|
|
1155
1302
|
|
|
1156
|
-
## OPTIONAL:
|
|
1157
|
-
When you OMIT a thread because of a recurring CLASS of false-positive (the SUB itself surfaces wrong-audience threads, not just this one thread), you MAY
|
|
1303
|
+
## OPTIONAL: rejects[] (self-improving denylist)
|
|
1304
|
+
When you OMIT a thread because of a recurring CLASS of false-positive (the SUB itself surfaces wrong-audience threads, not just this one thread), you MAY add a rejects[] entry for that thread:
|
|
1158
1305
|
|
|
1159
|
-
{{"
|
|
1306
|
+
{{"thread_url": "SAME_URL_AS_GIVEN", "reason": "short reason", "proposed_excludes": ["subreddit:bestofredditorupdates"]}}
|
|
1160
1307
|
|
|
1161
1308
|
Rules:
|
|
1162
1309
|
- proposed_excludes entries MUST use the typed form `subreddit:<slug>` (lowercase, no `r/` prefix). Future shape: `keyword:<word>` is accepted but unused today.
|
|
@@ -1176,7 +1323,7 @@ Examples of WRONG proposals (do not emit):
|
|
|
1176
1323
|
- Reject a specific r/nursing thread because OP is venting → DO NOT exclude r/nursing (it's our target audience; just omit this thread)
|
|
1177
1324
|
- Reject one r/anki thread that's off-topic → DO NOT exclude r/anki (core ICP)
|
|
1178
1325
|
|
|
1179
|
-
|
|
1326
|
+
Do NOT narrate. Gate, draft-or-reject, return the single JSON object.
|
|
1180
1327
|
"""
|
|
1181
1328
|
|
|
1182
1329
|
|
|
@@ -1366,9 +1513,77 @@ Do NOT describe what you are doing. Do NOT narrate. Just search, draft, output J
|
|
|
1366
1513
|
"""
|
|
1367
1514
|
|
|
1368
1515
|
|
|
1516
|
+
def run_claude_structured(prompt, timeout=1200):
|
|
1517
|
+
"""Run the draft turn through scripts/run_claude.sh, tag post-reddit-draft.
|
|
1518
|
+
|
|
1519
|
+
The tag is mapped in claude_job.py TAG_TO_TYPE, so run_claude.sh routes it
|
|
1520
|
+
through the file job queue and the s4l-worker scheduled task performs the
|
|
1521
|
+
LLM turn (mapped tags deliberately have NO claude -p fallback: on a box
|
|
1522
|
+
with no live worker the provider times out with exit 79 and this phase
|
|
1523
|
+
skips, exactly like the twitter draft lane when Claude Desktop is closed).
|
|
1524
|
+
run_claude.sh also owns session accounting (claude_sessions cost rows); it
|
|
1525
|
+
honors our pre-set CLAUDE_SESSION_ID so posts keep their attribution.
|
|
1526
|
+
|
|
1527
|
+
Returns (ok, result, usage): result is the parsed structured_output dict
|
|
1528
|
+
(posts/rejects per REDDIT_DRAFT_SCHEMA), or raw text when the envelope
|
|
1529
|
+
carried an unparseable result (caller falls back to the legacy JSONL
|
|
1530
|
+
regex parsers), or an error string when ok is False.
|
|
1531
|
+
"""
|
|
1532
|
+
usage = {"input_tokens": 0, "output_tokens": 0, "cache_read": 0,
|
|
1533
|
+
"cache_create": 0, "cost_usd": 0.0}
|
|
1534
|
+
session_id = str(uuid.uuid4())
|
|
1535
|
+
usage["session_id"] = session_id
|
|
1536
|
+
# Inherited by run_claude.sh (which honors a caller-set session id) and by
|
|
1537
|
+
# the log_post -> reddit_tools.py chain for posts.claude_session_id.
|
|
1538
|
+
os.environ["CLAUDE_SESSION_ID"] = session_id
|
|
1539
|
+
env = os.environ.copy()
|
|
1540
|
+
env.pop("ANTHROPIC_API_KEY", None) # claude uses OAuth, not API key
|
|
1541
|
+
cmd = ["bash", RUN_CLAUDE_SH, "post-reddit-draft", "-p",
|
|
1542
|
+
"--output-format", "json", "--json-schema", REDDIT_DRAFT_SCHEMA]
|
|
1543
|
+
try:
|
|
1544
|
+
proc = subprocess.run(cmd, input=prompt, env=env, text=True,
|
|
1545
|
+
capture_output=True, timeout=timeout)
|
|
1546
|
+
except subprocess.TimeoutExpired:
|
|
1547
|
+
return False, "TIMEOUT", usage
|
|
1548
|
+
# run_claude.sh narrates on stderr (queue enqueue, quota stamps); forward
|
|
1549
|
+
# into the run log so cycle debugging keeps its trail.
|
|
1550
|
+
for ln in (proc.stderr or "").splitlines():
|
|
1551
|
+
print(f"[post_reddit] {ln[:300]}", file=sys.stderr, flush=True)
|
|
1552
|
+
text = (proc.stdout or "").strip()
|
|
1553
|
+
if proc.returncode != 0:
|
|
1554
|
+
# exit 79 = queue provider timeout (no live worker) — the standard
|
|
1555
|
+
# claude-blocked skip code; the caller records draft_error and the
|
|
1556
|
+
# candidates stay pending for the next cycle.
|
|
1557
|
+
return False, (text[:2000] or f"exit {proc.returncode}"), usage
|
|
1558
|
+
try:
|
|
1559
|
+
envlp, _ = json.JSONDecoder().raw_decode(text)
|
|
1560
|
+
except Exception as e:
|
|
1561
|
+
return False, f"envelope parse error: {e}: {text[:500]}", usage
|
|
1562
|
+
try:
|
|
1563
|
+
usage["cost_usd"] = float(envlp.get("total_cost_usd") or 0.0)
|
|
1564
|
+
except (TypeError, ValueError):
|
|
1565
|
+
pass
|
|
1566
|
+
so = envlp.get("structured_output")
|
|
1567
|
+
if so is None:
|
|
1568
|
+
so = envlp.get("result")
|
|
1569
|
+
if isinstance(so, str):
|
|
1570
|
+
try:
|
|
1571
|
+
so = json.loads(so)
|
|
1572
|
+
except (json.JSONDecodeError, TypeError):
|
|
1573
|
+
pass # caller regex-parses the raw text
|
|
1574
|
+
return True, so, usage
|
|
1575
|
+
|
|
1576
|
+
|
|
1369
1577
|
def run_claude(prompt, timeout=600):
|
|
1370
1578
|
"""Run claude -p in bare mode with Bash tool only (no MCP needed).
|
|
1371
1579
|
|
|
1580
|
+
DEPRECATED 2026-07-14: the draft phase (the only caller) moved to
|
|
1581
|
+
run_claude_structured, which routes through run_claude.sh -> the
|
|
1582
|
+
claude_job.py queue (tag post-reddit-draft) so the s4l-worker scheduled
|
|
1583
|
+
task performs the turn. Kept for reference and emergency manual use only;
|
|
1584
|
+
do NOT wire new callers to this (it bypasses session cost accounting and
|
|
1585
|
+
the queue seam).
|
|
1586
|
+
|
|
1372
1587
|
Streams output in real time to stderr (picked up by tee in the shell wrapper)
|
|
1373
1588
|
while collecting the full output for JSON parsing.
|
|
1374
1589
|
"""
|
|
@@ -2210,8 +2425,29 @@ def _draft_iteration(plan, config, reddit_username):
|
|
|
2210
2425
|
# the draft phase (the discover phase loads it for the search step).
|
|
2211
2426
|
# Pass empty string; the trace audit still captures top_performers
|
|
2212
2427
|
# and recent_comments, which is the bulk of the few-shot context.
|
|
2428
|
+
# Tool-free turn (2026-07-14): fetch every thread HERE and inline the
|
|
2429
|
+
# digests, so the draft prompt is pure text->JSON and queue-routable.
|
|
2430
|
+
# Dead threads (archived/locked/blocked) are marked permanently failed
|
|
2431
|
+
# now instead of at post time; transient fetch failures stay pending
|
|
2432
|
+
# (dropped from THIS cycle only; Phase 0 salvage retries them).
|
|
2433
|
+
_before_prefetch = len(candidates)
|
|
2434
|
+
digests, dropped = _prefetch_thread_digests(candidates, reddit_username)
|
|
2435
|
+
for _c, _why in dropped:
|
|
2436
|
+
_url = _c.get("thread_url", "")
|
|
2437
|
+
print(f"[post_reddit] prefetch drop {_url}: {_why}")
|
|
2438
|
+
_db_mark_candidate_attempt(_url, reason=f"prefetch_{_why}", permanent=True)
|
|
2439
|
+
candidates = [c for c in candidates if c.get("thread_url") in digests]
|
|
2440
|
+
print(f"[post_reddit] prefetch: {len(candidates)} thread(s) inlined, "
|
|
2441
|
+
f"{len(dropped)} dropped dead, "
|
|
2442
|
+
f"{_before_prefetch - len(candidates) - len(dropped)} transient-skipped")
|
|
2443
|
+
if not candidates:
|
|
2444
|
+
plan = dict(plan)
|
|
2445
|
+
plan["decisions"] = []
|
|
2446
|
+
plan["phase"] = "draft"
|
|
2447
|
+
return plan
|
|
2448
|
+
|
|
2213
2449
|
prompt = build_draft_prompt(project, config, candidates, top_report, recent_comments,
|
|
2214
|
-
style_assignment=style_assignment)
|
|
2450
|
+
style_assignment=style_assignment, digests=digests)
|
|
2215
2451
|
|
|
2216
2452
|
# Build the generation_trace audit blob: what Claude is about to see.
|
|
2217
2453
|
# Captured BEFORE the Claude call so we never end up with a post row
|
|
@@ -2243,17 +2479,31 @@ def _draft_iteration(plan, config, reddit_username):
|
|
|
2243
2479
|
|
|
2244
2480
|
print(f"[post_reddit] Starting draft session for {len(candidates)} thread(s)...")
|
|
2245
2481
|
start = time.time()
|
|
2246
|
-
ok, output, usage =
|
|
2482
|
+
ok, output, usage = run_claude_structured(prompt, timeout=1200)
|
|
2247
2483
|
elapsed = time.time() - start
|
|
2248
2484
|
print(f"[post_reddit] Draft finished in {elapsed:.0f}s (${usage['cost_usd']:.4f})")
|
|
2249
2485
|
|
|
2250
2486
|
if not ok:
|
|
2251
|
-
print(f"[post_reddit] Draft FAILED: {output[:300]}")
|
|
2487
|
+
print(f"[post_reddit] Draft FAILED: {str(output)[:300]}")
|
|
2252
2488
|
plan["draft_error"] = "claude_failed"
|
|
2253
2489
|
plan["draft_cost"] = usage["cost_usd"]
|
|
2254
2490
|
return plan
|
|
2255
2491
|
|
|
2256
|
-
|
|
2492
|
+
if isinstance(output, dict):
|
|
2493
|
+
# Queue/schema path: posts[] mirrors the legacy JSONL post-line shape.
|
|
2494
|
+
drafted = []
|
|
2495
|
+
_seen_urls = set()
|
|
2496
|
+
for _d in output.get("posts") or []:
|
|
2497
|
+
if not isinstance(_d, dict):
|
|
2498
|
+
continue
|
|
2499
|
+
_url = _d.get("thread_url", "")
|
|
2500
|
+
if _d.get("text") and _url and _url not in _seen_urls:
|
|
2501
|
+
_d.setdefault("action", "post")
|
|
2502
|
+
drafted.append(_d)
|
|
2503
|
+
_seen_urls.add(_url)
|
|
2504
|
+
else:
|
|
2505
|
+
# Legacy fallback: the envelope carried free text; regex-parse it.
|
|
2506
|
+
drafted = parse_post_decisions(output or "")
|
|
2257
2507
|
print(f"[post_reddit] Draft produced {len(drafted)} post(s)")
|
|
2258
2508
|
|
|
2259
2509
|
# 2026-05-11: parse optional action=reject lines and forward any
|
|
@@ -2263,7 +2513,10 @@ def _draft_iteration(plan, config, reddit_username):
|
|
|
2263
2513
|
# here MUST NOT kill the draft phase; the post pipeline is the critical
|
|
2264
2514
|
# path. See parse_reject_decisions / _propose_excludes_from_rejects.
|
|
2265
2515
|
try:
|
|
2266
|
-
|
|
2516
|
+
if isinstance(output, dict):
|
|
2517
|
+
rejects = [r for r in (output.get("rejects") or []) if isinstance(r, dict)]
|
|
2518
|
+
else:
|
|
2519
|
+
rejects = parse_reject_decisions(output or "")
|
|
2267
2520
|
if rejects:
|
|
2268
2521
|
cand_by_url = {c.get("thread_url"): c for c in candidates if c.get("thread_url")}
|
|
2269
2522
|
counters = _propose_excludes_from_rejects(
|
|
@@ -2315,6 +2568,12 @@ def _draft_iteration(plan, config, reddit_username):
|
|
|
2315
2568
|
plan["decisions"] = merged
|
|
2316
2569
|
plan["draft_cost"] = usage["cost_usd"]
|
|
2317
2570
|
plan["draft_session_id"] = usage.get("session_id")
|
|
2571
|
+
# Also stamp the key _post_iteration actually reads: in two-phase mode
|
|
2572
|
+
# (draft in process A, post in process B) it re-exports
|
|
2573
|
+
# plan["session_id"] as CLAUDE_SESSION_ID, and this was never set after
|
|
2574
|
+
# discover went programmatic (session attribution silently dropped on
|
|
2575
|
+
# the two-phase path until 2026-07-14).
|
|
2576
|
+
plan["session_id"] = usage.get("session_id")
|
|
2318
2577
|
plan["phase"] = "draft"
|
|
2319
2578
|
# Stash the picker assignment so _post_iteration (which runs in a
|
|
2320
2579
|
# separate process via JSON-serialized plan) can pass it to
|
|
@@ -2362,6 +2621,10 @@ def _post_iteration(plan, reddit_username):
|
|
|
2362
2621
|
failed = 0
|
|
2363
2622
|
|
|
2364
2623
|
for i, decision in enumerate(decisions):
|
|
2624
|
+
# Heartbeat the posting-active flag per row so readers see a fresh
|
|
2625
|
+
# stamp for the whole drain (rows are ~45s + 180s inter-post sleeps;
|
|
2626
|
+
# the reader freshness window is 120s, so re-stamp before EACH row).
|
|
2627
|
+
_stamp_posting_active()
|
|
2365
2628
|
thread_url = decision["thread_url"]
|
|
2366
2629
|
reply_to_url = decision.get("reply_to_url")
|
|
2367
2630
|
text = decision["text"]
|
|
@@ -2662,9 +2925,11 @@ def main():
|
|
|
2662
2925
|
f"posting. stderr: {(_chk.stderr or '').strip()[:300]}", file=sys.stderr)
|
|
2663
2926
|
sys.exit(3)
|
|
2664
2927
|
try:
|
|
2928
|
+
_stamp_posting_active()
|
|
2665
2929
|
posted, failed = _post_iteration(plan, reddit_username)
|
|
2666
2930
|
print(f"[post_reddit] phase=post project={plan.get('project_name')} posted={posted} failed={failed}")
|
|
2667
2931
|
finally:
|
|
2932
|
+
_clear_posting_active()
|
|
2668
2933
|
# Clean up the generation_trace temp file. By this point every
|
|
2669
2934
|
# post that landed has the trace JSONB persisted to its row,
|
|
2670
2935
|
# so the on-disk file is redundant. Best-effort delete.
|
|
@@ -243,8 +243,6 @@ def find_reddit_cdp_port():
|
|
|
243
243
|
return None
|
|
244
244
|
|
|
245
245
|
|
|
246
|
-
_LOCK_SESSION_ID = f"python:{os.getpid()}"
|
|
247
|
-
|
|
248
246
|
# Path to the bash-lock lease helper. Bumping this lease from inside reddit_browser.py
|
|
249
247
|
# is what shields Python-CDP pipelines (run-reddit-search, audit-reddit-resurrect,
|
|
250
248
|
# stats.sh reddit phase, engage-reddit) from the watchdog's 60-90s reclaim. Those
|
|
@@ -279,72 +277,41 @@ def _heartbeat_bash_lease():
|
|
|
279
277
|
pass # Best-effort. Never fail a CDP op because of lease bookkeeping.
|
|
280
278
|
|
|
281
279
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
280
|
+
# ---- browser-session mutex (2026-07-14: shared implementation) -------------
|
|
281
|
+
# The old inline lock here still had the check-then-write claim race AND the
|
|
282
|
+
# dead-holder starvation (a SIGKILLed peer blocked everyone for the full
|
|
283
|
+
# 300s LOCK_EXPIRY) that twitter_browser.py fixed on 2026-06-16. Both drivers
|
|
284
|
+
# now share scripts/browser_mutex.py (twitter's proven mutex, parameterized):
|
|
285
|
+
# reddit gains the O_EXCL atomic claim, dead-PID reclaim, batch inherit via
|
|
286
|
+
# S4L_LOCK_OWNER, and role-aware posting priority (only active when a caller
|
|
287
|
+
# sets S4L_LOCK_ROLE=post; default scan keeps today's no-preemption behavior).
|
|
288
|
+
# The bash-lease bump rides the mutex's on_touch hook, so every acquire AND
|
|
289
|
+
# refresh keeps the watchdog fed exactly as before.
|
|
290
|
+
from browser_mutex import BrowserMutex
|
|
291
|
+
|
|
292
|
+
_MUTEX = BrowserMutex(
|
|
293
|
+
lock_file=LOCK_FILE,
|
|
294
|
+
label="Reddit browser",
|
|
295
|
+
lock_expiry=LOCK_EXPIRY,
|
|
296
|
+
wait_max=LOCK_WAIT_MAX,
|
|
297
|
+
poll_interval=LOCK_POLL_INTERVAL,
|
|
298
|
+
on_touch=_heartbeat_bash_lease,
|
|
299
|
+
)
|
|
292
300
|
|
|
293
301
|
|
|
294
|
-
|
|
302
|
+
def _acquire_browser_lock():
|
|
303
|
+
_MUTEX.acquire()
|
|
295
304
|
|
|
296
305
|
|
|
297
|
-
def
|
|
298
|
-
|
|
306
|
+
def _refresh_browser_lock():
|
|
307
|
+
_MUTEX.refresh()
|
|
299
308
|
|
|
300
|
-
Polls every LOCK_POLL_INTERVAL seconds for up to LOCK_WAIT_MAX seconds.
|
|
301
|
-
Exits 1 only after exhausting the wait budget; the launchd caller will
|
|
302
|
-
retry on the next 5-min tick.
|
|
303
|
-
"""
|
|
304
|
-
deadline = time.time() + LOCK_WAIT_MAX
|
|
305
|
-
while True:
|
|
306
|
-
if os.path.exists(LOCK_FILE):
|
|
307
|
-
try:
|
|
308
|
-
with open(LOCK_FILE) as f:
|
|
309
|
-
lock = json.load(f)
|
|
310
|
-
age = time.time() - lock.get("timestamp", 0)
|
|
311
|
-
if age >= LOCK_EXPIRY:
|
|
312
|
-
break
|
|
313
|
-
holder = lock.get("session_id", "unknown")
|
|
314
|
-
if time.time() >= deadline:
|
|
315
|
-
print(json.dumps({
|
|
316
|
-
"success": False,
|
|
317
|
-
"error": f"Reddit browser locked by session {holder} ({int(age)}s); waited {LOCK_WAIT_MAX}s, giving up."
|
|
318
|
-
}))
|
|
319
|
-
sys.exit(1)
|
|
320
|
-
time.sleep(LOCK_POLL_INTERVAL)
|
|
321
|
-
continue
|
|
322
|
-
except (json.JSONDecodeError, OSError):
|
|
323
|
-
pass
|
|
324
|
-
break
|
|
325
|
-
with open(LOCK_FILE, "w") as f:
|
|
326
|
-
json.dump({"session_id": _LOCK_SESSION_ID, "timestamp": int(time.time())}, f)
|
|
327
|
-
# Also bump the bash-lock lease so the watchdog respects this CDP burst.
|
|
328
|
-
# See _heartbeat_bash_lease() for why: Python-CDP pipelines never trigger
|
|
329
|
-
# the MCP PreToolUse heartbeat hook, so without this call the watchdog
|
|
330
|
-
# would steal the bash lock at age >= 60s during legitimate CDP work.
|
|
331
|
-
_heartbeat_bash_lease()
|
|
332
309
|
|
|
310
|
+
def _release_browser_lock():
|
|
311
|
+
_MUTEX.release()
|
|
333
312
|
|
|
334
|
-
def _refresh_browser_lock():
|
|
335
|
-
"""Refresh the lock timestamp to prevent expiry during long operations.
|
|
336
313
|
|
|
337
|
-
|
|
338
|
-
multi-step CDP ops. Call this from inside long CDP flows (scroll loops,
|
|
339
|
-
multi-second waits) to keep both the python lock file mtime AND the bash
|
|
340
|
-
lease fresh.
|
|
341
|
-
"""
|
|
342
|
-
try:
|
|
343
|
-
with open(LOCK_FILE, "w") as f:
|
|
344
|
-
json.dump({"session_id": _LOCK_SESSION_ID, "timestamp": int(time.time())}, f)
|
|
345
|
-
except OSError:
|
|
346
|
-
pass
|
|
347
|
-
_heartbeat_bash_lease()
|
|
314
|
+
atexit.register(_release_browser_lock)
|
|
348
315
|
|
|
349
316
|
|
|
350
317
|
# --- L1/L2 browser-hang guards (2026-07-13, mirrors twitter_browser.py) ------
|
|
@@ -589,6 +556,28 @@ def post_comment(thread_url, text):
|
|
|
589
556
|
if "login" in page.url.lower():
|
|
590
557
|
return {"ok": False, "error": "not_logged_in"}
|
|
591
558
|
|
|
559
|
+
# Tab-collision guard (2026-07-14): every reddit pipeline shares
|
|
560
|
+
# the ONE harness tab, and a concurrent scan/fetch/engage session
|
|
561
|
+
# can navigate it between our goto and the checks below. The
|
|
562
|
+
# missing comment form then misclassified as account_blocked_in_sub
|
|
563
|
+
# (a PERMANENT verdict) — 5/5 approved cards false-positived this
|
|
564
|
+
# way on 2026-07-14. Verify the tab still shows OUR thread; re-goto
|
|
565
|
+
# once if hijacked, and classify a persistent hijack as the
|
|
566
|
+
# TRANSIENT tab_contention so the row is retried, never buried.
|
|
567
|
+
import re as _re
|
|
568
|
+
_tid_m = _re.search(r"/comments/([a-z0-9]+)", old_url)
|
|
569
|
+
_tid = _tid_m.group(1) if _tid_m else None
|
|
570
|
+
|
|
571
|
+
def _tab_is_ours():
|
|
572
|
+
return bool(_tid) and f"/comments/{_tid}" in (page.url or "")
|
|
573
|
+
|
|
574
|
+
if not _tab_is_ours():
|
|
575
|
+
page.goto(old_url, wait_until="domcontentloaded")
|
|
576
|
+
page.wait_for_timeout(3000)
|
|
577
|
+
_ensure_old_reddit(page)
|
|
578
|
+
if not _tab_is_ours():
|
|
579
|
+
return {"ok": False, "error": "tab_contention"}
|
|
580
|
+
|
|
592
581
|
# Check if the top-level comment form exists at all.
|
|
593
582
|
# When the sub gates top-level commenting on this account (CrowdControl,
|
|
594
583
|
# AutoMod karma/age threshold, mod-approved-only, shadowban), old reddit
|
|
@@ -596,10 +585,20 @@ def post_comment(thread_url, text):
|
|
|
596
585
|
# page. The sub itself may be public; the gate is account-level. There
|
|
597
586
|
# is no error banner and no API field that exposes this, so the only
|
|
598
587
|
# signal is the missing form on a logged-in page load.
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
588
|
+
_form_sel = ".commentarea .usertext.cloneable, .commentarea > form.usertext"
|
|
589
|
+
has_comment_form = page.locator(_form_sel).count() > 0
|
|
590
|
+
if not has_comment_form:
|
|
591
|
+
# Slow-render tolerance: give the form a short explicit wait
|
|
592
|
+
# before concluding the sub gates this account (an instant
|
|
593
|
+
# count() on a slow load is another false-positive source).
|
|
594
|
+
try:
|
|
595
|
+
page.locator(_form_sel).first.wait_for(state="attached", timeout=6000)
|
|
596
|
+
has_comment_form = True
|
|
597
|
+
except Exception:
|
|
598
|
+
pass
|
|
602
599
|
if not has_comment_form:
|
|
600
|
+
if not _tab_is_ours():
|
|
601
|
+
return {"ok": False, "error": "tab_contention"}
|
|
603
602
|
return {"ok": False, "error": "account_blocked_in_sub"}
|
|
604
603
|
|
|
605
604
|
# Some subs render the form but show a gate notice instead of a usable
|
|
@@ -39,12 +39,44 @@ def _default_cdp_url():
|
|
|
39
39
|
or "http://127.0.0.1:9557"
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
_POSTING_FLAG = os.path.join(
|
|
43
|
+
os.path.expanduser(os.environ.get("S4L_STATE_DIR") or "~/.social-autoposter-mcp"),
|
|
44
|
+
"reddit-posting-active.json",
|
|
45
|
+
)
|
|
46
|
+
_POSTING_FRESH_S = 120
|
|
47
|
+
_POSTING_YIELD_MAX_S = 300
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _yield_to_poster():
|
|
51
|
+
"""Posting owns the ONE shared harness tab (2026-07-14). Every reader
|
|
52
|
+
fetch navigates the tab to the reddit host root, which is exactly what
|
|
53
|
+
yanked the tab out from under mid-post drains (the tab_contention /
|
|
54
|
+
false account_blocked_in_sub family). A fresh reddit-posting-active.json
|
|
55
|
+
(heartbeated per row by post_reddit.py) means a poster is mid-drain:
|
|
56
|
+
WAIT for it instead of navigating, bounded so a stale flag can never
|
|
57
|
+
starve discovery/stats (they just run a few minutes later)."""
|
|
58
|
+
deadline = time.time() + _POSTING_YIELD_MAX_S
|
|
59
|
+
waited = False
|
|
60
|
+
while time.time() < deadline:
|
|
61
|
+
try:
|
|
62
|
+
age = time.time() - os.path.getmtime(_POSTING_FLAG)
|
|
63
|
+
except OSError:
|
|
64
|
+
break # no flag -> no poster
|
|
65
|
+
if age >= _POSTING_FRESH_S:
|
|
66
|
+
break # stale flag -> dead poster; never blocks readers
|
|
67
|
+
waited = True
|
|
68
|
+
time.sleep(5)
|
|
69
|
+
if waited:
|
|
70
|
+
sys.stderr.write("[reddit_browser_fetch] yielded to active poster\n")
|
|
71
|
+
|
|
72
|
+
|
|
42
73
|
def browser_get_json(url, cdp_url=None, timeout_ms=25000):
|
|
43
74
|
"""Fetch a Reddit JSON URL through the logged-in harness Chrome.
|
|
44
75
|
|
|
45
76
|
Returns (body_str_or_None, http_status_int). On any transport/connect
|
|
46
77
|
failure returns (None, 0) so the caller can fall back to urllib.
|
|
47
78
|
"""
|
|
79
|
+
_yield_to_poster()
|
|
48
80
|
cdp_url = (cdp_url or _default_cdp_url())
|
|
49
81
|
try:
|
|
50
82
|
from playwright.sync_api import sync_playwright
|