social-autoposter 1.6.84 → 1.6.85
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/package.json +1 -1
- package/scripts/_lock_preempt_test.py +60 -0
- package/scripts/harness_overlay.py +5 -1
- package/scripts/link_tail.py +109 -4
- package/scripts/log_post.py +26 -16
- package/scripts/twitter_browser.py +164 -16
- package/scripts/twitter_post_plan.py +66 -0
package/package.json
CHANGED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import json, os, sys, time, subprocess, signal
|
|
2
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
|
|
3
|
+
|
|
4
|
+
LOCK = os.path.expanduser("~/.claude/twitter-browser-lock.json")
|
|
5
|
+
os.makedirs(os.path.dirname(LOCK), exist_ok=True)
|
|
6
|
+
|
|
7
|
+
def write_lock(pid, role):
|
|
8
|
+
with open(LOCK, "w") as f:
|
|
9
|
+
json.dump({"session_id": f"python:{pid}", "timestamp": int(time.time()), "role": role}, f)
|
|
10
|
+
|
|
11
|
+
def clean():
|
|
12
|
+
try: os.remove(LOCK)
|
|
13
|
+
except OSError: pass
|
|
14
|
+
|
|
15
|
+
# ---- TEST 1: poster preempts a LIVE scan holder ----
|
|
16
|
+
clean()
|
|
17
|
+
victim = subprocess.Popen(["sleep", "120"])
|
|
18
|
+
write_lock(victim.pid, "scan")
|
|
19
|
+
os.environ["SAPS_LOCK_ROLE"] = "post"
|
|
20
|
+
import importlib, twitter_browser
|
|
21
|
+
importlib.reload(twitter_browser)
|
|
22
|
+
t0 = time.time()
|
|
23
|
+
twitter_browser._acquire_browser_lock()
|
|
24
|
+
dt = time.time() - t0
|
|
25
|
+
held = json.load(open(LOCK))
|
|
26
|
+
victim_alive = victim.poll() is None
|
|
27
|
+
print(f"TEST1 poster-preempts-scan: took={dt:.1f}s holder_now={held['session_id']} role={held.get('role')} victim_alive={victim_alive}")
|
|
28
|
+
assert held["session_id"] == f"python:{os.getpid()}", "poster should hold lock"
|
|
29
|
+
assert held.get("role") == "post"
|
|
30
|
+
assert not victim_alive, "scan victim should be killed"
|
|
31
|
+
assert dt < 10, "should preempt fast, not wait 45s"
|
|
32
|
+
if victim.poll() is None: victim.kill()
|
|
33
|
+
print("TEST1 PASS")
|
|
34
|
+
|
|
35
|
+
# ---- TEST 2: scanner does NOT preempt a live POST holder (waits then gives up) ----
|
|
36
|
+
clean()
|
|
37
|
+
poster = subprocess.Popen(["sleep", "120"])
|
|
38
|
+
write_lock(poster.pid, "post")
|
|
39
|
+
os.environ["SAPS_LOCK_ROLE"] = "scan"
|
|
40
|
+
twitter_browser.LOCK_WAIT_MAX = 4 # shorten the give-up window for the test
|
|
41
|
+
twitter_browser.LOCK_ROLE = "scan"
|
|
42
|
+
twitter_browser._LOCK_SESSION_ID = f"python:{os.getpid()}"
|
|
43
|
+
twitter_browser._LOCK_INHERITED = False
|
|
44
|
+
import io, contextlib
|
|
45
|
+
t0 = time.time()
|
|
46
|
+
rc = None
|
|
47
|
+
try:
|
|
48
|
+
twitter_browser._acquire_browser_lock()
|
|
49
|
+
rc = "acquired"
|
|
50
|
+
except SystemExit as e:
|
|
51
|
+
rc = f"exit:{e.code}"
|
|
52
|
+
dt = time.time() - t0
|
|
53
|
+
poster_alive = poster.poll() is None
|
|
54
|
+
print(f"TEST2 scanner-vs-post: result={rc} took={dt:.1f}s poster_alive={poster_alive}")
|
|
55
|
+
assert rc == "exit:1", "scanner must NOT take a live post holder; should give up"
|
|
56
|
+
assert poster_alive, "scanner must NOT kill the poster"
|
|
57
|
+
if poster.poll() is None: poster.kill()
|
|
58
|
+
print("TEST2 PASS")
|
|
59
|
+
clean()
|
|
60
|
+
print("ALL PASS")
|
|
@@ -173,7 +173,11 @@ window.__sapsPaint = function(payload){
|
|
|
173
173
|
// coordinates (Input.dispatchMouseEvent) and by Playwright hit-testing,
|
|
174
174
|
// both of which an opaque clickable card sitting over a target would eat.
|
|
175
175
|
s.position="fixed"; s.top="50%"; s.left="50%"; s.transform="translate(-50%,-50%)";
|
|
176
|
-
|
|
176
|
+
// Sit one below the announce modal (2147483647) so the one-time "S4L is
|
|
177
|
+
// running" notice + its OK button always stack ON TOP of this always-on
|
|
178
|
+
// status box. They're both screen-centered, so equal z-index would let
|
|
179
|
+
// whichever was appended last (this overlay) cover the OK button.
|
|
180
|
+
s.zIndex="2147483646"; s.pointerEvents="none"; s.maxWidth="460px";
|
|
177
181
|
s.boxSizing="border-box"; s.padding="10px 14px"; s.borderRadius="12px";
|
|
178
182
|
s.background="rgba(15,15,17,0.92)"; s.color="#fff";
|
|
179
183
|
s.font="13px/1.35 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif";
|
package/scripts/link_tail.py
CHANGED
|
@@ -42,6 +42,8 @@ Exit codes:
|
|
|
42
42
|
2 — argparse / IO failure before we could write any JSON
|
|
43
43
|
"""
|
|
44
44
|
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
45
47
|
import argparse
|
|
46
48
|
import json
|
|
47
49
|
import os
|
|
@@ -56,6 +58,62 @@ REPO_DIR = os.path.expanduser("~/social-autoposter")
|
|
|
56
58
|
RUN_CLAUDE_SH = os.path.join(REPO_DIR, "scripts", "run_claude.sh")
|
|
57
59
|
CONFIG_PATH = os.path.join(REPO_DIR, "config.json")
|
|
58
60
|
|
|
61
|
+
# --- X/Twitter length budget -------------------------------------------------
|
|
62
|
+
# X charges a FLAT 23 characters for any http/https URL (t.co wrapping),
|
|
63
|
+
# regardless of the link's real length. So the budget is fixed: text + 23 <= 280
|
|
64
|
+
# => at most 257 characters of text before the link. We only enforce this for
|
|
65
|
+
# twitter; reddit/linkedin have far larger ceilings and need no tail trim.
|
|
66
|
+
TWEET_LIMIT = 280
|
|
67
|
+
URL_WEIGHT = 23
|
|
68
|
+
TWITTER_TEXT_BUDGET = TWEET_LIMIT - URL_WEIGHT # 257 chars for everything but the URL
|
|
69
|
+
_URL_RE = re.compile(r"https?://\S+")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def x_weighted_len(text: str) -> int:
|
|
73
|
+
"""Character count the way X computes it: every URL counts as 23."""
|
|
74
|
+
if not text:
|
|
75
|
+
return 0
|
|
76
|
+
return len(_URL_RE.sub("x" * URL_WEIGHT, text))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _trim_to_chars(s: str, max_chars: int) -> str:
|
|
80
|
+
"""Trim `s` to at most `max_chars`, backing off to a word boundary so we
|
|
81
|
+
never chop mid-word, then stripping trailing punctuation/space."""
|
|
82
|
+
s = s.strip()
|
|
83
|
+
if max_chars <= 0:
|
|
84
|
+
return ""
|
|
85
|
+
if len(s) <= max_chars:
|
|
86
|
+
return s
|
|
87
|
+
cut = s[:max_chars].rstrip()
|
|
88
|
+
sp = cut.rfind(" ")
|
|
89
|
+
# only back off to the word boundary when it doesn't gut more than half
|
|
90
|
+
if sp > max_chars * 0.5:
|
|
91
|
+
cut = cut[:sp]
|
|
92
|
+
return cut.rstrip(" ,;:-")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def enforce_budget(text: str, link_url: str,
|
|
96
|
+
limit: int = TWEET_LIMIT) -> tuple[str, bool]:
|
|
97
|
+
"""Guarantee x_weighted_len(text) <= limit by trimming the BODY, never the
|
|
98
|
+
link. The link is the most important part of the reply and always stays at
|
|
99
|
+
the end. Returns (text, was_trimmed)."""
|
|
100
|
+
if x_weighted_len(text) <= limit:
|
|
101
|
+
return text, False
|
|
102
|
+
if link_url and link_url in text:
|
|
103
|
+
head, _, tail = text.rpartition(link_url)
|
|
104
|
+
head = head.rstrip()
|
|
105
|
+
tail = tail.strip() # normally empty; the URL ends the reply
|
|
106
|
+
# Budget left for the body after reserving the URL (23) + a joining
|
|
107
|
+
# space + any (rare) trailing chars after the URL.
|
|
108
|
+
max_head = limit - URL_WEIGHT - 1 - len(tail)
|
|
109
|
+
trimmed_head = _trim_to_chars(head, max_head)
|
|
110
|
+
joined = (trimmed_head + " " + link_url).strip()
|
|
111
|
+
if tail:
|
|
112
|
+
joined = (joined + " " + tail).strip()
|
|
113
|
+
return joined, True
|
|
114
|
+
# No link present (shouldn't happen on the twitter path): hard word-trim.
|
|
115
|
+
return _trim_to_chars(text, limit), True
|
|
116
|
+
|
|
59
117
|
|
|
60
118
|
def resolve_voice_relationship(project: str) -> str:
|
|
61
119
|
"""Look up the matched project's `voice_relationship` field in config.json.
|
|
@@ -151,6 +209,26 @@ def build_prompt(*, reply_text: str, link_url: str, thread_text: str,
|
|
|
151
209
|
f" - \"we ship the same recall-on-revisit pattern in {project}, scores against a 4-axis rubric, {link_url}\""
|
|
152
210
|
)
|
|
153
211
|
|
|
212
|
+
# X-specific hard length budget. Pass the agent the current body length AND
|
|
213
|
+
# the budget so it self-fits by compressing the body, keeping the link at
|
|
214
|
+
# the end. Other platforms (reddit/linkedin) have far larger ceilings, so we
|
|
215
|
+
# add no tight cap there.
|
|
216
|
+
length_rule = ""
|
|
217
|
+
if platform == "twitter":
|
|
218
|
+
body_len = len(reply_text or "")
|
|
219
|
+
length_rule = (
|
|
220
|
+
f"HARD LENGTH LIMIT (X counts EVERY link as exactly 23 characters, "
|
|
221
|
+
f"no matter how long it looks):\n"
|
|
222
|
+
f"- The entire final reply must be \u2264 {TWEET_LIMIT} characters with "
|
|
223
|
+
f"the URL counted as {URL_WEIGHT}. That means everything EXCEPT the "
|
|
224
|
+
f"URL must total \u2264 {TWITTER_TEXT_BUDGET} characters.\n"
|
|
225
|
+
f"- The drafted body is currently {body_len} characters. If body + "
|
|
226
|
+
f"bridge would exceed {TWITTER_TEXT_BUDGET} chars of text, COMPRESS "
|
|
227
|
+
f"the body to make room: tighten wording, drop the weakest clause, "
|
|
228
|
+
f"but keep the single strongest claim and keep the URL at the very "
|
|
229
|
+
f"end. Never drop or move the link.\n"
|
|
230
|
+
)
|
|
231
|
+
|
|
154
232
|
return f"""You are writing the FINAL bridge sentence that folds a product link into a social media reply we already drafted. This is a one-shot task. Output ONLY the bridge sentence (no preamble, no explanation, no quotes).
|
|
155
233
|
|
|
156
234
|
PLATFORM: {platform}
|
|
@@ -159,6 +237,8 @@ LANDING PAGE URL: {link_url}
|
|
|
159
237
|
|
|
160
238
|
{voice_rule}
|
|
161
239
|
|
|
240
|
+
{length_rule}
|
|
241
|
+
|
|
162
242
|
ORIGINAL THREAD WE ARE REPLYING TO:
|
|
163
243
|
{thread_text}
|
|
164
244
|
|
|
@@ -289,7 +369,8 @@ THIRD_PARTY_VOICE_VIOLATIONS = (
|
|
|
289
369
|
|
|
290
370
|
|
|
291
371
|
def passes_quality_gate(final_text: str, link_url: str,
|
|
292
|
-
voice_relationship: str = "first_party"
|
|
372
|
+
voice_relationship: str = "first_party",
|
|
373
|
+
limit: int | None = None
|
|
293
374
|
) -> tuple[bool, str]:
|
|
294
375
|
"""Return (passes, reason_if_not).
|
|
295
376
|
|
|
@@ -327,6 +408,12 @@ def passes_quality_gate(final_text: str, link_url: str,
|
|
|
327
408
|
# Length sanity: model returning a 5-word stub is a fail.
|
|
328
409
|
if len(final_text.split()) < 8:
|
|
329
410
|
return (False, "too_short")
|
|
411
|
+
# Upper-length backstop (twitter): X-weighted length must fit the cap. The
|
|
412
|
+
# caller trims the body to fit BEFORE the gate, so this should only trip on
|
|
413
|
+
# a degenerate trim; on trip we fall back to the (also budget-enforced)
|
|
414
|
+
# mechanical concat.
|
|
415
|
+
if limit is not None and x_weighted_len(final_text) > limit:
|
|
416
|
+
return (False, f"too_long:{x_weighted_len(final_text)}>{limit}")
|
|
330
417
|
return (True, "")
|
|
331
418
|
|
|
332
419
|
|
|
@@ -375,6 +462,8 @@ def main() -> int:
|
|
|
375
462
|
return 0
|
|
376
463
|
|
|
377
464
|
voice_relationship = args.voice_relationship or resolve_voice_relationship(args.project)
|
|
465
|
+
# Length cap is X-specific; reddit/linkedin pass None (no tail trim).
|
|
466
|
+
limit = TWEET_LIMIT if args.platform == "twitter" else None
|
|
378
467
|
prompt = build_prompt(
|
|
379
468
|
reply_text=reply_text, link_url=link_url,
|
|
380
469
|
thread_text=(args.thread_text or "").strip()[:2000],
|
|
@@ -388,12 +477,16 @@ def main() -> int:
|
|
|
388
477
|
elapsed = round(time.time() - started, 2)
|
|
389
478
|
|
|
390
479
|
if not ok:
|
|
480
|
+
fb_text, fb_trim = enforce_budget(
|
|
481
|
+
mechanical_fallback(reply_text, link_url), link_url,
|
|
482
|
+
limit if limit is not None else TWEET_LIMIT * 100)
|
|
391
483
|
out = {
|
|
392
484
|
"ok": True,
|
|
393
|
-
"text":
|
|
485
|
+
"text": fb_text,
|
|
394
486
|
"tail": link_url,
|
|
395
487
|
"model_call_ok": False,
|
|
396
488
|
"fallback_used": True,
|
|
489
|
+
"budget_trimmed": fb_trim,
|
|
397
490
|
"error": err or "model_call_failed",
|
|
398
491
|
"elapsed_sec": elapsed,
|
|
399
492
|
}
|
|
@@ -401,15 +494,26 @@ def main() -> int:
|
|
|
401
494
|
return 0
|
|
402
495
|
|
|
403
496
|
cleaned = clean_output(raw)
|
|
497
|
+
# Trim the body to fit BEFORE the gate so a good model bridge is preserved
|
|
498
|
+
# (we trim the body, never the link) instead of being thrown away for being
|
|
499
|
+
# a few chars over. No-op on non-twitter (limit is None).
|
|
500
|
+
budget_trimmed = False
|
|
501
|
+
if limit is not None:
|
|
502
|
+
cleaned, budget_trimmed = enforce_budget(cleaned, link_url, limit)
|
|
404
503
|
passes, reason = passes_quality_gate(cleaned, link_url,
|
|
405
|
-
voice_relationship=voice_relationship
|
|
504
|
+
voice_relationship=voice_relationship,
|
|
505
|
+
limit=limit)
|
|
406
506
|
if not passes:
|
|
507
|
+
fb_text, fb_trim = enforce_budget(
|
|
508
|
+
mechanical_fallback(reply_text, link_url), link_url,
|
|
509
|
+
limit if limit is not None else TWEET_LIMIT * 100)
|
|
407
510
|
out = {
|
|
408
511
|
"ok": True,
|
|
409
|
-
"text":
|
|
512
|
+
"text": fb_text,
|
|
410
513
|
"tail": link_url,
|
|
411
514
|
"model_call_ok": True,
|
|
412
515
|
"fallback_used": True,
|
|
516
|
+
"budget_trimmed": fb_trim,
|
|
413
517
|
"error": f"quality_gate_failed:{reason}",
|
|
414
518
|
"raw_model_output": raw[:500],
|
|
415
519
|
"elapsed_sec": elapsed,
|
|
@@ -436,6 +540,7 @@ def main() -> int:
|
|
|
436
540
|
"tail": tail,
|
|
437
541
|
"model_call_ok": True,
|
|
438
542
|
"fallback_used": False,
|
|
543
|
+
"budget_trimmed": budget_trimmed,
|
|
439
544
|
"elapsed_sec": elapsed,
|
|
440
545
|
}
|
|
441
546
|
print(json.dumps(out), flush=True)
|
package/scripts/log_post.py
CHANGED
|
@@ -122,30 +122,40 @@ def parse_urn_ids(*sources):
|
|
|
122
122
|
|
|
123
123
|
VALID_PLATFORMS = ("reddit", "twitter", "linkedin", "github_issues", "moltbook")
|
|
124
124
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
"
|
|
129
|
-
"
|
|
125
|
+
# Maps log_post's platform strings to account_resolver's platform keys.
|
|
126
|
+
# (log_post uses "github_issues"; account_resolver uses "github".)
|
|
127
|
+
_RESOLVER_PLATFORM = {
|
|
128
|
+
"twitter": "twitter",
|
|
129
|
+
"reddit": "reddit",
|
|
130
|
+
"linkedin": "linkedin",
|
|
131
|
+
"github_issues": "github",
|
|
132
|
+
"moltbook": "moltbook",
|
|
130
133
|
}
|
|
131
134
|
|
|
132
135
|
|
|
133
136
|
def _resolve_default_account(platform: str) -> str:
|
|
134
|
-
"""Return the
|
|
137
|
+
"""Return the configured account handle for `platform` on this machine.
|
|
135
138
|
|
|
136
|
-
|
|
137
|
-
`
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
139
|
+
Resolved ONLY from env (`AUTOPOSTER_<PLATFORM>_*`) or config.json
|
|
140
|
+
(`accounts.<platform>.*`) via account_resolver. There are NO hardcoded
|
|
141
|
+
handle fallbacks: a misconfigured install must never silently post under
|
|
142
|
+
another person's identity. The old per-platform defaults
|
|
143
|
+
(m13v_/Deep_Ad1959/Matthew Diakonov/m13v/matthew-autoposter) did exactly
|
|
144
|
+
that, stamping every unconfigured install's rows with the repo owner's
|
|
145
|
+
handle and polluting the shared DB across accounts.
|
|
141
146
|
|
|
142
|
-
Returns
|
|
147
|
+
Returns "" when nothing is configured; the caller's
|
|
143
148
|
`args.account or _resolve_default_account(...)` chain still lets an
|
|
144
|
-
explicit `--account` flag win
|
|
149
|
+
explicit `--account` flag win, and an empty value surfaces the misconfig
|
|
150
|
+
instead of impersonating someone.
|
|
145
151
|
"""
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
152
|
+
try:
|
|
153
|
+
import account_resolver
|
|
154
|
+
return account_resolver.resolve(
|
|
155
|
+
_RESOLVER_PLATFORM.get(platform, platform)
|
|
156
|
+
) or ""
|
|
157
|
+
except Exception:
|
|
158
|
+
return ""
|
|
149
159
|
|
|
150
160
|
|
|
151
161
|
def coerce_engagement_style(args):
|
|
@@ -33,6 +33,7 @@ import json
|
|
|
33
33
|
import os
|
|
34
34
|
import random
|
|
35
35
|
import re
|
|
36
|
+
import signal
|
|
36
37
|
import subprocess
|
|
37
38
|
import sys
|
|
38
39
|
import time
|
|
@@ -40,27 +41,51 @@ import time
|
|
|
40
41
|
|
|
41
42
|
LOCK_FILE = os.path.expanduser("~/.claude/twitter-browser-lock.json")
|
|
42
43
|
LOCK_EXPIRY = 300 # process-level mutex TTL; refreshed during long ops
|
|
44
|
+
# Posting-specific silence ceiling, DECOUPLED from the fleet-wide LOCK_EXPIRY.
|
|
45
|
+
# A role:"post" holder (an approved batch, or a single reply) is reclaimed by a
|
|
46
|
+
# peer once its lock has gone unrefreshed this long; a role:"scan" holder keeps
|
|
47
|
+
# the 300s LOCK_EXPIRY untouched. Posting refreshes the lock at every candidate
|
|
48
|
+
# boundary (twitter_post_plan holds it across the whole batch), so a healthy
|
|
49
|
+
# poster never goes silent this long -- only a genuinely hung poster (e.g.
|
|
50
|
+
# link_tail's `claude -p` wedged) trips it. Kept as its own knob so tuning the
|
|
51
|
+
# scan TTL never moves the poster's hang ceiling and vice-versa. Must exceed the
|
|
52
|
+
# worst-case single candidate step (one reply + the link_tail AI call), and stay
|
|
53
|
+
# well under any value that would let a hung poster block the browser for long.
|
|
54
|
+
POST_LOCK_EXPIRY = 180 # seconds; applies ONLY to a role:"post" holder
|
|
43
55
|
LOCK_WAIT_MAX = 45 # seconds to wait for lock to free before giving up
|
|
44
56
|
LOCK_POLL_INTERVAL = 2
|
|
57
|
+
PREEMPT_KILL_WAIT = 5 # secs to wait for a preempted scan holder to die before SIGKILL
|
|
58
|
+
|
|
59
|
+
# Lock role priority. A "post" holder is user-initiated (an approved reply) and
|
|
60
|
+
# outranks any "scan" holder (the scan/draft cycle, autopilot or plugin). When a
|
|
61
|
+
# poster finds a LIVE lower-priority holder it PREEMPTS it (SIGTERM + reclaim)
|
|
62
|
+
# instead of waiting LOCK_WAIT_MAX and giving up. This is what makes "posting
|
|
63
|
+
# takes priority over scanning" hold CROSS-PROCESS: the old in-process
|
|
64
|
+
# preemptScanForPost only killed the plugin's own scan, never a scan spawned by a
|
|
65
|
+
# separate autopilot agent / launchd cron, so an approved post kept losing the
|
|
66
|
+
# 45s race to a live scan that held the browser. Default "scan" so any unmarked
|
|
67
|
+
# browser op is preemptable; only the poster path sets SAPS_LOCK_ROLE=post.
|
|
68
|
+
LOCK_ROLE = (os.environ.get("SAPS_LOCK_ROLE") or "scan").strip() or "scan"
|
|
45
69
|
VIEWPORT = {"width": 911, "height": 1016}
|
|
46
70
|
|
|
47
71
|
# Posting handle. Resolved at call time from AUTOPOSTER_TWITTER_HANDLE env
|
|
48
72
|
# var (set by per-account launchd/systemd units) or config.json
|
|
49
|
-
# accounts.twitter.handle.
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
|
|
53
|
-
|
|
73
|
+
# accounts.twitter.handle. Returns None when neither source is set.
|
|
74
|
+
#
|
|
75
|
+
# There is intentionally NO hardcoded fallback handle. The old "m13v_"
|
|
76
|
+
# default meant any install with an unset handle silently posted under the
|
|
77
|
+
# repo owner's identity: it stamped posts.our_account = m13v_ and built reply
|
|
78
|
+
# permalinks as x.com/m13v_/status/<id> for tweets that actually belonged to a
|
|
79
|
+
# different account, corrupting attribution in the shared DB. Callers that
|
|
80
|
+
# build a URL or post under this identity MUST treat None as "account not
|
|
81
|
+
# configured" and refuse, rather than impersonate someone.
|
|
54
82
|
def our_handle():
|
|
55
83
|
try:
|
|
56
84
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
57
85
|
import account_resolver
|
|
58
|
-
|
|
59
|
-
if h:
|
|
60
|
-
return h
|
|
86
|
+
return account_resolver.resolve("twitter")
|
|
61
87
|
except Exception:
|
|
62
|
-
|
|
63
|
-
return _DEFAULT_HANDLE
|
|
88
|
+
return None
|
|
64
89
|
|
|
65
90
|
# DM encryption passcode from .env
|
|
66
91
|
DM_PASSCODE = os.environ.get("TWITTER_DM_PASSCODE", "")
|
|
@@ -297,13 +322,45 @@ def _try_take_lock() -> bool:
|
|
|
297
322
|
return False
|
|
298
323
|
try:
|
|
299
324
|
os.write(fd, json.dumps(
|
|
300
|
-
{"session_id": _LOCK_SESSION_ID, "timestamp": int(time.time())}
|
|
325
|
+
{"session_id": _LOCK_SESSION_ID, "timestamp": int(time.time()), "role": LOCK_ROLE}
|
|
301
326
|
).encode())
|
|
302
327
|
finally:
|
|
303
328
|
os.close(fd)
|
|
304
329
|
return True
|
|
305
330
|
|
|
306
331
|
|
|
332
|
+
def _preempt_holder(pid: int) -> bool:
|
|
333
|
+
"""Preempt a live lock holder we outrank (a poster taking the browser from a
|
|
334
|
+
scan). SIGTERM it, wait PREEMPT_KILL_WAIT for it to die so its pid frees the
|
|
335
|
+
lock, then escalate to SIGKILL once. Returns True once the holder is gone
|
|
336
|
+
(or was already gone). Best-effort; never raises. The caller then removes the
|
|
337
|
+
stale lockfile and claims it via O_EXCL.
|
|
338
|
+
"""
|
|
339
|
+
try:
|
|
340
|
+
os.kill(pid, signal.SIGTERM)
|
|
341
|
+
except ProcessLookupError:
|
|
342
|
+
return True # already gone
|
|
343
|
+
except OSError:
|
|
344
|
+
return False # not ours to signal / ambiguous -> don't claim
|
|
345
|
+
deadline = time.time() + PREEMPT_KILL_WAIT
|
|
346
|
+
while time.time() < deadline:
|
|
347
|
+
try:
|
|
348
|
+
os.kill(pid, 0)
|
|
349
|
+
except OSError:
|
|
350
|
+
return True # ProcessLookupError or perm change -> dead enough
|
|
351
|
+
time.sleep(0.2)
|
|
352
|
+
# Still alive after the SIGTERM grace window -> escalate once.
|
|
353
|
+
try:
|
|
354
|
+
os.kill(pid, signal.SIGKILL)
|
|
355
|
+
except OSError:
|
|
356
|
+
pass
|
|
357
|
+
try:
|
|
358
|
+
os.kill(pid, 0)
|
|
359
|
+
except OSError:
|
|
360
|
+
return True
|
|
361
|
+
return False
|
|
362
|
+
|
|
363
|
+
|
|
307
364
|
def _acquire_browser_lock():
|
|
308
365
|
"""Acquire the Twitter browser session mutex (~/.claude/twitter-browser-lock.json).
|
|
309
366
|
|
|
@@ -377,6 +434,7 @@ def _acquire_browser_lock():
|
|
|
377
434
|
continue
|
|
378
435
|
age = time.time() - lock.get("timestamp", 0)
|
|
379
436
|
holder = lock.get("session_id", "")
|
|
437
|
+
holder_role = lock.get("role", "scan") # legacy locks (no role) = preemptable
|
|
380
438
|
|
|
381
439
|
# 1. Re-entrant: the lock is already ours (same process, or a stale lock
|
|
382
440
|
# left by a previous process whose PID we have since reused). Refresh the
|
|
@@ -385,6 +443,29 @@ def _acquire_browser_lock():
|
|
|
385
443
|
_refresh_browser_lock()
|
|
386
444
|
break
|
|
387
445
|
|
|
446
|
+
# 1b. Batch-owner inherit (posting). The poster (twitter_post_plan.py)
|
|
447
|
+
# acquires this lock ONCE and holds it across the WHOLE approved batch,
|
|
448
|
+
# exporting its own session id as SAPS_LOCK_OWNER for the child
|
|
449
|
+
# twitter_browser.py reply subprocesses it spawns. Each child INHERITS the
|
|
450
|
+
# parent's hold instead of contending for it -- two role:"post" peers would
|
|
451
|
+
# otherwise both fall to the case-6 peer-wait and give up after
|
|
452
|
+
# LOCK_WAIT_MAX, breaking the post. The child refreshes the timestamp
|
|
453
|
+
# (proof of progress at this candidate boundary, so the POST_LOCK_EXPIRY
|
|
454
|
+
# failsafe only ever fires on a real hang) and, being _LOCK_INHERITED,
|
|
455
|
+
# leaves the lock in place for the PARENT to release at batch end. A DEAD
|
|
456
|
+
# owner is never inherited: the alive-probe fails here and we fall through
|
|
457
|
+
# to the dead_python reclaim below, so a crashed batch can't wedge the
|
|
458
|
+
# browser. This is what closes the inter-candidate gap (the link_tail
|
|
459
|
+
# claude -p call, ~5-20s) the every-60s autopilot scan used to slip into.
|
|
460
|
+
_batch_owner = os.environ.get("SAPS_LOCK_OWNER") or ""
|
|
461
|
+
if holder and holder == _batch_owner and _is_python_holder_alive(holder):
|
|
462
|
+
_LOCK_SESSION_ID = holder
|
|
463
|
+
_LOCK_INHERITED = True
|
|
464
|
+
_refresh_browser_lock()
|
|
465
|
+
print(f"[browser_lock] inherited batch owner={holder} "
|
|
466
|
+
f"role={holder_role} -> pid={os.getpid()}", file=sys.stderr)
|
|
467
|
+
break
|
|
468
|
+
|
|
388
469
|
# 2-4. Reclaim a holder we can prove is dead/expired. Remove-then-take so
|
|
389
470
|
# the O_EXCL claim wins; if a peer reclaims at the same instant exactly
|
|
390
471
|
# one of us creates the file and the other re-loops (never both).
|
|
@@ -393,7 +474,10 @@ def _acquire_browser_lock():
|
|
|
393
474
|
reclaim_reason = "dead_uuid"
|
|
394
475
|
elif holder.startswith("python:") and not _is_python_holder_alive(holder):
|
|
395
476
|
reclaim_reason = "dead_python"
|
|
396
|
-
elif age >= LOCK_EXPIRY:
|
|
477
|
+
elif age >= (POST_LOCK_EXPIRY if holder_role == "post" else LOCK_EXPIRY):
|
|
478
|
+
# Role-aware failsafe: a hung poster self-clears on the posting-only
|
|
479
|
+
# POST_LOCK_EXPIRY, a scan on the fleet-wide LOCK_EXPIRY. Scan
|
|
480
|
+
# behaviour is unchanged; only the post ceiling is decoupled.
|
|
397
481
|
reclaim_reason = "expired"
|
|
398
482
|
if reclaim_reason:
|
|
399
483
|
try:
|
|
@@ -415,6 +499,43 @@ def _acquire_browser_lock():
|
|
|
415
499
|
_LOCK_INHERITED = True
|
|
416
500
|
break
|
|
417
501
|
|
|
502
|
+
# 5b. POSTING PRIORITY (cross-process). A LIVE python:PID peer running a
|
|
503
|
+
# lower-priority op (role != "post": the scan/draft cycle, whether the
|
|
504
|
+
# plugin's own, a separate autopilot agent's, or the launchd cron's) must
|
|
505
|
+
# YIELD to an approved post. Preempt it by signal and reclaim, so the post
|
|
506
|
+
# takes the browser at once instead of waiting LOCK_WAIT_MAX and giving up
|
|
507
|
+
# while the scan holds it. The aborted scan just re-runs next cron tick;
|
|
508
|
+
# posting is the scarce, user-initiated action. Only a poster
|
|
509
|
+
# (LOCK_ROLE == "post") ever preempts, and only a non-post holder -- two
|
|
510
|
+
# posters fall through to the normal peer-wait below so neither kills the
|
|
511
|
+
# other. UUID holders are handled above (we inherit, never kill those).
|
|
512
|
+
if (
|
|
513
|
+
LOCK_ROLE == "post"
|
|
514
|
+
and holder.startswith("python:")
|
|
515
|
+
and holder_role != "post"
|
|
516
|
+
and _is_python_holder_alive(holder)
|
|
517
|
+
):
|
|
518
|
+
try:
|
|
519
|
+
victim_pid = int(holder.split(":", 1)[1])
|
|
520
|
+
except (ValueError, IndexError):
|
|
521
|
+
victim_pid = 0
|
|
522
|
+
if victim_pid and _preempt_holder(victim_pid):
|
|
523
|
+
try:
|
|
524
|
+
os.remove(LOCK_FILE)
|
|
525
|
+
except OSError:
|
|
526
|
+
pass
|
|
527
|
+
if _try_take_lock():
|
|
528
|
+
print(
|
|
529
|
+
f"[browser_lock] post preempted holder={holder} "
|
|
530
|
+
f"role={holder_role} age={int(age)}s -> pid={os.getpid()}",
|
|
531
|
+
file=sys.stderr,
|
|
532
|
+
)
|
|
533
|
+
break
|
|
534
|
+
# Preempt didn't land (couldn't kill, or a peer reclaimed first) ->
|
|
535
|
+
# re-loop and re-evaluate rather than busy-spin.
|
|
536
|
+
time.sleep(LOCK_POLL_INTERVAL)
|
|
537
|
+
continue
|
|
538
|
+
|
|
418
539
|
# 6. Live python:PID peer. Wait, then give up. Reaching the deadline now
|
|
419
540
|
# means the holder is a genuinely LIVE peer (dead ones were reclaimed
|
|
420
541
|
# above), i.e. real contention -- NOT the defect-a starvation. The
|
|
@@ -433,7 +554,7 @@ def _refresh_browser_lock():
|
|
|
433
554
|
"""Refresh the lock timestamp to prevent expiry during long operations."""
|
|
434
555
|
try:
|
|
435
556
|
with open(LOCK_FILE, "w") as f:
|
|
436
|
-
json.dump({"session_id": _LOCK_SESSION_ID, "timestamp": int(time.time())}, f)
|
|
557
|
+
json.dump({"session_id": _LOCK_SESSION_ID, "timestamp": int(time.time()), "role": LOCK_ROLE}, f)
|
|
437
558
|
except OSError:
|
|
438
559
|
pass
|
|
439
560
|
|
|
@@ -820,6 +941,18 @@ def reply_to_tweet(tweet_url, text, apply_campaigns=True):
|
|
|
820
941
|
"""
|
|
821
942
|
print(f"[twitter_browser] reply_to_tweet called: {tweet_url}", file=sys.stderr)
|
|
822
943
|
|
|
944
|
+
# Identity gate: refuse to post when no account is configured. Without a
|
|
945
|
+
# resolved handle we cannot attribute the post or build a correct reply
|
|
946
|
+
# permalink, and the old behaviour silently impersonated the repo owner
|
|
947
|
+
# (handle "m13v_"). Fail fast and loud so the misconfiguration surfaces
|
|
948
|
+
# instead of polluting the shared DB under someone else's identity.
|
|
949
|
+
_handle = our_handle()
|
|
950
|
+
if not _handle:
|
|
951
|
+
print("[twitter_browser] no twitter account configured "
|
|
952
|
+
"(set AUTOPOSTER_TWITTER_HANDLE or accounts.twitter.handle in "
|
|
953
|
+
"config.json); refusing to post.", file=sys.stderr)
|
|
954
|
+
return {"ok": False, "error": "no_account_configured"}
|
|
955
|
+
|
|
823
956
|
applied_campaigns = []
|
|
824
957
|
if apply_campaigns:
|
|
825
958
|
for cid, suffix, sample_rate in _load_active_twitter_campaigns():
|
|
@@ -946,7 +1079,17 @@ def reply_to_tweet(tweet_url, text, apply_campaigns=True):
|
|
|
946
1079
|
page.goto(tweet_url, wait_until="domcontentloaded", timeout=60000)
|
|
947
1080
|
except Exception:
|
|
948
1081
|
pass
|
|
949
|
-
|
|
1082
|
+
# Was a blind 15s/8s settle here -> pure dead latency. SPA
|
|
1083
|
+
# readiness is ALREADY gated actively below by
|
|
1084
|
+
# wait_for_selector("main") (up to 20s) and
|
|
1085
|
+
# _wait_for_reply_textbox (polls every 500ms up to 45s); both
|
|
1086
|
+
# return the instant the composer mounts, so the blind sleep
|
|
1087
|
+
# only delayed the start of that polling. Keep a short floor so
|
|
1088
|
+
# the initial JS kicks off (and the deleted-tweet text check
|
|
1089
|
+
# below has content to read), then let the active gates do the
|
|
1090
|
+
# real waiting. Cuts ~12s off every happy-path reply.
|
|
1091
|
+
# (optimized 2026-06-22: 15000/8000 -> 2500)
|
|
1092
|
+
page.wait_for_timeout(2500)
|
|
950
1093
|
|
|
951
1094
|
# `wait_until="load"` fires before Twitter's SPA mounts the
|
|
952
1095
|
# <main> app shell, so "loaded" != "rendered". Explicitly gate
|
|
@@ -1021,7 +1164,12 @@ def reply_to_tweet(tweet_url, text, apply_campaigns=True):
|
|
|
1021
1164
|
except Exception:
|
|
1022
1165
|
page.keyboard.press("Meta+Enter")
|
|
1023
1166
|
|
|
1024
|
-
|
|
1167
|
+
# Post-submit settle: lets the CDP network response (which carries
|
|
1168
|
+
# the new tweet id -> reply_url, captured below) and the success
|
|
1169
|
+
# interstitial arrive. Trimmed from 4000ms 2026-06-22; the DOM-diff
|
|
1170
|
+
# fallback (3x2s, below) still covers a slow CDP response, so the
|
|
1171
|
+
# reply_url is not lost if 2000ms is short on a given run.
|
|
1172
|
+
page.wait_for_timeout(2000)
|
|
1025
1173
|
|
|
1026
1174
|
# Verify: check if the reply box is empty (cleared after posting)
|
|
1027
1175
|
try:
|
|
@@ -1050,7 +1198,7 @@ def reply_to_tweet(tweet_url, text, apply_campaigns=True):
|
|
|
1050
1198
|
|
|
1051
1199
|
# Method 1: CDP network interception (most reliable)
|
|
1052
1200
|
if _created_tweet_ids:
|
|
1053
|
-
reply_url = f"https://x.com/{
|
|
1201
|
+
reply_url = f"https://x.com/{_handle}/status/{_created_tweet_ids[-1]}"
|
|
1054
1202
|
print(f"[reply_url] captured via CDP+response-listener: {reply_url}", file=sys.stderr)
|
|
1055
1203
|
|
|
1056
1204
|
# Method 2: DOM diff (check if new reply links appeared)
|
|
@@ -49,6 +49,14 @@ import time
|
|
|
49
49
|
from datetime import datetime, timezone
|
|
50
50
|
from pathlib import Path
|
|
51
51
|
|
|
52
|
+
# This pipeline ONLY posts (never scans), so mark every twitter_browser.py reply
|
|
53
|
+
# subprocess it spawns as the high-priority "post" lock role. run_subprocess
|
|
54
|
+
# inherits this process env, so the child twitter_browser.py reads SAPS_LOCK_ROLE
|
|
55
|
+
# at import and will PREEMPT a live scan holding the browser lock instead of
|
|
56
|
+
# losing the 45s wait. Covers BOTH the MCP approve path and the cron post path,
|
|
57
|
+
# since both shell out to this script. Set before any child is spawned.
|
|
58
|
+
os.environ["SAPS_LOCK_ROLE"] = "post"
|
|
59
|
+
|
|
52
60
|
REPO_DIR = os.path.expanduser("~/social-autoposter")
|
|
53
61
|
TWITTER_BROWSER = os.path.join(REPO_DIR, "scripts", "twitter_browser.py")
|
|
54
62
|
LOG_POST = os.path.join(REPO_DIR, "scripts", "log_post.py")
|
|
@@ -1009,11 +1017,58 @@ def main() -> int:
|
|
|
1009
1017
|
return 3
|
|
1010
1018
|
|
|
1011
1019
|
_total = len(candidates)
|
|
1020
|
+
|
|
1021
|
+
# ---- Batch-level browser-lock hold (cross-process posting priority) --------
|
|
1022
|
+
# Hold the Twitter browser lock for the WHOLE approved batch instead of
|
|
1023
|
+
# re-acquiring it per candidate. Per-candidate acquisition freed the lock in
|
|
1024
|
+
# the gap between replies (dominated by link_tail's `claude -p` call, ~5-20s),
|
|
1025
|
+
# and the autopilot scan fires every 60s, so a scan kept slipping into that gap
|
|
1026
|
+
# and seizing the browser mid-batch -- the exact "posting gets cut off" symptom
|
|
1027
|
+
# on the remote box. Acquiring ONCE here PREEMPTS any live scan (role:"post"
|
|
1028
|
+
# priority) and, by exporting our session id as SAPS_LOCK_OWNER, makes every
|
|
1029
|
+
# child twitter_browser.py reply INHERIT this hold rather than contend for it,
|
|
1030
|
+
# closing the gap. The hold is bounded by the posting-specific POST_LOCK_EXPIRY
|
|
1031
|
+
# failsafe in twitter_browser (a hung poster self-clears in <=180s; a crashed
|
|
1032
|
+
# one frees instantly via dead-pid reclaim), so it can never wedge the browser
|
|
1033
|
+
# indefinitely. Best-effort: if the import or acquire fails we simply fall back
|
|
1034
|
+
# to the legacy per-candidate acquisition (children still preempt scans one by
|
|
1035
|
+
# one), so posting degrades gracefully and is never blocked by this addition.
|
|
1036
|
+
_tb = None
|
|
1037
|
+
_batch_lock_held = False
|
|
1038
|
+
if candidates:
|
|
1039
|
+
try:
|
|
1040
|
+
import twitter_browser as _tb # SAPS_LOCK_ROLE=post already set above
|
|
1041
|
+
_tb._acquire_browser_lock() # preempts a live scan; sys.exit(1) if contended
|
|
1042
|
+
os.environ["SAPS_LOCK_OWNER"] = _tb._LOCK_SESSION_ID
|
|
1043
|
+
_batch_lock_held = True
|
|
1044
|
+
print(f"[post] batch lock held by {_tb._LOCK_SESSION_ID} (role=post); "
|
|
1045
|
+
f"{_total} candidate(s) inherit it", flush=True)
|
|
1046
|
+
except SystemExit:
|
|
1047
|
+
# _acquire_browser_lock exits when a LIVE non-preemptable peer (another
|
|
1048
|
+
# poster) holds the lock past LOCK_WAIT_MAX. Don't abort the whole run:
|
|
1049
|
+
# drop to per-candidate acquisition (each child still preempts scans).
|
|
1050
|
+
print("[post] batch lock contended; per-candidate acquisition in effect",
|
|
1051
|
+
flush=True)
|
|
1052
|
+
except Exception as _e:
|
|
1053
|
+
print(f"[post] batch lock setup skipped ({_e}); per-candidate "
|
|
1054
|
+
"acquisition in effect", flush=True)
|
|
1055
|
+
|
|
1012
1056
|
try:
|
|
1013
1057
|
for _idx, c in enumerate(candidates, start=1):
|
|
1014
1058
|
# Live per-post status for the S4L menu bar: "posting 3/10" while this
|
|
1015
1059
|
# one is in flight, then "posted 3/10 ✓" once it lands. Cosmetic only.
|
|
1016
1060
|
_write_activity(f"posting {_idx}/{_total}")
|
|
1061
|
+
# Re-stamp the batch hold at each candidate boundary so the
|
|
1062
|
+
# POST_LOCK_EXPIRY failsafe measures silence from the LAST real
|
|
1063
|
+
# progress, not from batch start. Insurance on top of the child's own
|
|
1064
|
+
# inherit-refresh: keeps the hold fresh even across a candidate that
|
|
1065
|
+
# skips before ever spawning a reply subprocess (empty_reply_text,
|
|
1066
|
+
# pre-post dedup). Cheap; never raises.
|
|
1067
|
+
if _batch_lock_held and _tb is not None:
|
|
1068
|
+
try:
|
|
1069
|
+
_tb._refresh_browser_lock()
|
|
1070
|
+
except Exception:
|
|
1071
|
+
pass
|
|
1017
1072
|
try:
|
|
1018
1073
|
outcome, reason = post_one(c, picker_assignment=picker_assignment)
|
|
1019
1074
|
except Exception as e:
|
|
@@ -1039,6 +1094,17 @@ def main() -> int:
|
|
|
1039
1094
|
fail_reasons[reason] = fail_reasons.get(reason, 0) + 1
|
|
1040
1095
|
finally:
|
|
1041
1096
|
_clear_activity()
|
|
1097
|
+
# Release the batch hold so the next scan/post can take the browser
|
|
1098
|
+
# immediately (don't make peers wait out POST_LOCK_EXPIRY). _tb's atexit
|
|
1099
|
+
# is a backstop if we somehow skip this; clearing SAPS_LOCK_OWNER stops a
|
|
1100
|
+
# late child from re-inheriting a lock we just dropped.
|
|
1101
|
+
if _batch_lock_held and _tb is not None:
|
|
1102
|
+
try:
|
|
1103
|
+
_tb._release_browser_lock()
|
|
1104
|
+
except Exception:
|
|
1105
|
+
pass
|
|
1106
|
+
os.environ.pop("SAPS_LOCK_OWNER", None)
|
|
1107
|
+
print("[post] batch lock released", flush=True)
|
|
1042
1108
|
|
|
1043
1109
|
summary = {
|
|
1044
1110
|
"posted": posted,
|