@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
|
@@ -186,22 +186,32 @@ def find_twitter_cdp_port():
|
|
|
186
186
|
returns the first port whose /json index lists at least one x.com or
|
|
187
187
|
twitter.com tab (preferring logged-in tabs over login pages). Used only
|
|
188
188
|
as a fallback when TWITTER_CDP_URL isn't exported by the caller.
|
|
189
|
+
|
|
190
|
+
Since the park-on-exit mitigation (2026-07-14) the harness tab sits on
|
|
191
|
+
x.com/robots.txt between runs, which still matches the x.com scan above.
|
|
192
|
+
A responding port whose cmdline carries the browser-harness profile dir
|
|
193
|
+
is kept as a last-resort fallback for the tab-somehow-closed case (never
|
|
194
|
+
a different platform's harness Chrome).
|
|
189
195
|
"""
|
|
190
196
|
try:
|
|
191
197
|
ps_out = subprocess.check_output(
|
|
192
198
|
["ps", "aux"], text=True, stderr=subprocess.DEVNULL
|
|
193
199
|
)
|
|
194
200
|
ports = set()
|
|
201
|
+
harness_ports = set()
|
|
195
202
|
for line in ps_out.splitlines():
|
|
196
203
|
if "chromium" not in line.lower() and "chrome" not in line.lower():
|
|
197
204
|
continue
|
|
198
205
|
m = re.search(r"remote-debugging-port=(\d+)", line)
|
|
199
206
|
if m:
|
|
200
207
|
ports.add(int(m.group(1)))
|
|
208
|
+
if "browser-profiles/browser-harness" in line:
|
|
209
|
+
harness_ports.add(int(m.group(1)))
|
|
201
210
|
|
|
202
211
|
import urllib.request
|
|
203
212
|
|
|
204
213
|
best_port = None
|
|
214
|
+
harness_fallback = None
|
|
205
215
|
for port in sorted(ports):
|
|
206
216
|
try:
|
|
207
217
|
resp = urllib.request.urlopen(
|
|
@@ -214,6 +224,11 @@ def find_twitter_cdp_port():
|
|
|
214
224
|
if "x.com" in p.get("url", "") or "twitter.com" in p.get("url", "")
|
|
215
225
|
]
|
|
216
226
|
if not twitter_urls:
|
|
227
|
+
# Responding harness Chrome with its tab parked on
|
|
228
|
+
# about:blank: usable, but only if nothing shows a live
|
|
229
|
+
# x.com tab.
|
|
230
|
+
if port in harness_ports and harness_fallback is None:
|
|
231
|
+
harness_fallback = port
|
|
217
232
|
continue
|
|
218
233
|
# Prefer ports with logged-in pages (home, chat, notifications)
|
|
219
234
|
logged_in = any(
|
|
@@ -227,360 +242,46 @@ def find_twitter_cdp_port():
|
|
|
227
242
|
best_port = port
|
|
228
243
|
except Exception:
|
|
229
244
|
continue
|
|
230
|
-
return best_port
|
|
245
|
+
return best_port if best_port is not None else harness_fallback
|
|
231
246
|
except Exception:
|
|
232
247
|
pass
|
|
233
248
|
return None
|
|
234
249
|
|
|
235
250
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
If we inherited the lock from a Claude session (UUID holder), leave it for
|
|
246
|
-
the hook/session-end handler to release — don't clobber the parent's lock.
|
|
247
|
-
"""
|
|
248
|
-
if _LOCK_INHERITED:
|
|
249
|
-
return
|
|
250
|
-
try:
|
|
251
|
-
if os.path.exists(LOCK_FILE):
|
|
252
|
-
with open(LOCK_FILE) as f:
|
|
253
|
-
lock = json.load(f)
|
|
254
|
-
if lock.get("session_id") == _LOCK_SESSION_ID:
|
|
255
|
-
os.remove(LOCK_FILE)
|
|
256
|
-
# Quantify hold time: long holds are what starve peers into the
|
|
257
|
-
# "locked by session ... waited 45s, giving up" family (S4L-B),
|
|
258
|
-
# and the 2026-07-09/11 wedge showed a hung CDP connect holding
|
|
259
|
-
# this lock for 3 minutes per attempt. >=5s filter keeps the
|
|
260
|
-
# healthy-path noise out of the logs.
|
|
261
|
-
if _LOCK_ACQUIRED_AT:
|
|
262
|
-
_held = time.time() - _LOCK_ACQUIRED_AT
|
|
263
|
-
if _held >= 5:
|
|
264
|
-
print(
|
|
265
|
-
f"[browser-lock] held {_held:.0f}s "
|
|
266
|
-
f"(role={LOCK_ROLE}, pid={os.getpid()})",
|
|
267
|
-
file=sys.stderr,
|
|
268
|
-
)
|
|
269
|
-
except (json.JSONDecodeError, OSError):
|
|
270
|
-
pass
|
|
251
|
+
# ---- browser-session mutex (2026-07-14: shared implementation) -------------
|
|
252
|
+
# The ~250-line battle-hardened mutex that lived here (O_EXCL claim, dead-PID
|
|
253
|
+
# reclaim, role-aware post-preempts-scan, batch inherit via S4L_LOCK_OWNER,
|
|
254
|
+
# UUID-session inherit, POST_LOCK_EXPIRY decoupling) moved VERBATIM to
|
|
255
|
+
# scripts/browser_mutex.py so reddit_browser.py stops running a weaker copy.
|
|
256
|
+
# Same lockfile, same JSON shape, same timers, same log/error strings —
|
|
257
|
+
# behavior is unchanged. Incident history: docs/twitter_browser_lock.md.
|
|
258
|
+
from browser_mutex import BrowserMutex
|
|
271
259
|
|
|
260
|
+
_MUTEX = BrowserMutex(
|
|
261
|
+
lock_file=LOCK_FILE,
|
|
262
|
+
label="Twitter browser",
|
|
263
|
+
lock_expiry=LOCK_EXPIRY,
|
|
264
|
+
post_lock_expiry=POST_LOCK_EXPIRY,
|
|
265
|
+
wait_max=LOCK_WAIT_MAX,
|
|
266
|
+
poll_interval=LOCK_POLL_INTERVAL,
|
|
267
|
+
preempt_kill_wait=PREEMPT_KILL_WAIT,
|
|
268
|
+
role=LOCK_ROLE,
|
|
269
|
+
)
|
|
272
270
|
|
|
273
|
-
atexit.register(_release_browser_lock)
|
|
274
271
|
|
|
272
|
+
def _acquire_browser_lock():
|
|
273
|
+
_MUTEX.acquire()
|
|
275
274
|
|
|
276
|
-
def _is_holder_alive(holder: str) -> bool:
|
|
277
|
-
"""Check whether a Claude session UUID lock holder is still running.
|
|
278
275
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
holder is dead and the lock is stale, even if its JSONL transcript
|
|
282
|
-
is still tail-flushing. Legacy semantics from the retired
|
|
283
|
-
twitter-agent-lock.sh PreToolUse hook; only python:PID holders are
|
|
284
|
-
written to the lock file today, so this code path is dormant unless
|
|
285
|
-
a Claude session still inherits an in-flight UUID lock.
|
|
286
|
-
"""
|
|
287
|
-
if not holder:
|
|
288
|
-
return False
|
|
289
|
-
try:
|
|
290
|
-
return (
|
|
291
|
-
subprocess.run(
|
|
292
|
-
["pgrep", "-f", f"claude.*--session-id {holder}"],
|
|
293
|
-
stdout=subprocess.DEVNULL,
|
|
294
|
-
stderr=subprocess.DEVNULL,
|
|
295
|
-
timeout=2,
|
|
296
|
-
).returncode
|
|
297
|
-
== 0
|
|
298
|
-
)
|
|
299
|
-
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
300
|
-
return True # err on the side of NOT stealing
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
def _is_python_holder_alive(holder: str) -> bool:
|
|
304
|
-
"""Liveness probe for a `python:PID` lock holder.
|
|
305
|
-
|
|
306
|
-
Holders written today are `python:<pid>` (see _LOCK_SESSION_ID). Before this
|
|
307
|
-
check existed (defect a, 2026-06-16), a holder whose process died WITHOUT
|
|
308
|
-
running its atexit _release_browser_lock (SIGKILL, OOM, watchdog SIGTERM,
|
|
309
|
-
hard hang) left the lockfile behind, and _acquire_browser_lock had no way to
|
|
310
|
-
tell it was dead -- so every peer waited the full LOCK_WAIT_MAX and gave up,
|
|
311
|
-
and the lock only cleared after LOCK_EXPIRY (300s). os.kill(pid, 0) sends no
|
|
312
|
-
signal; it just probes existence. Returns True (treat as held, do NOT steal)
|
|
313
|
-
for anything we cannot prove dead, so the worst case degrades to the old
|
|
314
|
-
LOCK_EXPIRY failsafe rather than stealing a live peer's lock.
|
|
315
|
-
"""
|
|
316
|
-
if not holder.startswith("python:"):
|
|
317
|
-
return True # not a python holder; this probe makes no claim
|
|
318
|
-
try:
|
|
319
|
-
pid = int(holder.split(":", 1)[1])
|
|
320
|
-
except (ValueError, IndexError):
|
|
321
|
-
return True # unparseable holder -> don't steal on this basis
|
|
322
|
-
try:
|
|
323
|
-
os.kill(pid, 0)
|
|
324
|
-
return True # process exists -> alive
|
|
325
|
-
except ProcessLookupError:
|
|
326
|
-
return False # no such process -> dead, reclaimable
|
|
327
|
-
except PermissionError:
|
|
328
|
-
return True # exists but another owner -> alive
|
|
329
|
-
except OSError:
|
|
330
|
-
return True # ambiguous -> err toward NOT stealing
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
def _try_take_lock() -> bool:
|
|
334
|
-
"""Atomically claim LOCK_FILE for this process. Returns True iff we created
|
|
335
|
-
it. O_CREAT|O_EXCL makes "is it free? then take it" a single syscall, so two
|
|
336
|
-
cold-start acquirers can't both win the way the old os.path.exists +
|
|
337
|
-
open(w) check-then-act allowed (defect c, 2026-06-16). A False return means a
|
|
338
|
-
peer beat us to it; the caller re-loops and re-evaluates the holder.
|
|
339
|
-
"""
|
|
340
|
-
try:
|
|
341
|
-
fd = os.open(LOCK_FILE, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
|
342
|
-
except FileExistsError:
|
|
343
|
-
return False
|
|
344
|
-
except OSError:
|
|
345
|
-
return False
|
|
346
|
-
try:
|
|
347
|
-
os.write(fd, json.dumps(
|
|
348
|
-
{"session_id": _LOCK_SESSION_ID, "timestamp": int(time.time()), "role": LOCK_ROLE}
|
|
349
|
-
).encode())
|
|
350
|
-
finally:
|
|
351
|
-
os.close(fd)
|
|
352
|
-
return True
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
def _preempt_holder(pid: int) -> bool:
|
|
356
|
-
"""Preempt a live lock holder we outrank (a poster taking the browser from a
|
|
357
|
-
scan). SIGTERM it, wait PREEMPT_KILL_WAIT for it to die so its pid frees the
|
|
358
|
-
lock, then escalate to SIGKILL once. Returns True once the holder is gone
|
|
359
|
-
(or was already gone). Best-effort; never raises. The caller then removes the
|
|
360
|
-
stale lockfile and claims it via O_EXCL.
|
|
361
|
-
"""
|
|
362
|
-
try:
|
|
363
|
-
os.kill(pid, signal.SIGTERM)
|
|
364
|
-
except ProcessLookupError:
|
|
365
|
-
return True # already gone
|
|
366
|
-
except OSError:
|
|
367
|
-
return False # not ours to signal / ambiguous -> don't claim
|
|
368
|
-
deadline = time.time() + PREEMPT_KILL_WAIT
|
|
369
|
-
while time.time() < deadline:
|
|
370
|
-
try:
|
|
371
|
-
os.kill(pid, 0)
|
|
372
|
-
except OSError:
|
|
373
|
-
return True # ProcessLookupError or perm change -> dead enough
|
|
374
|
-
time.sleep(0.2)
|
|
375
|
-
# Still alive after the SIGTERM grace window -> escalate once.
|
|
376
|
-
try:
|
|
377
|
-
os.kill(pid, signal.SIGKILL)
|
|
378
|
-
except OSError:
|
|
379
|
-
pass
|
|
380
|
-
try:
|
|
381
|
-
os.kill(pid, 0)
|
|
382
|
-
except OSError:
|
|
383
|
-
return True
|
|
384
|
-
return False
|
|
276
|
+
def _refresh_browser_lock():
|
|
277
|
+
_MUTEX.refresh()
|
|
385
278
|
|
|
386
279
|
|
|
387
|
-
def
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
This file-mutex is the UNIVERSAL serializer for every twitter_browser.py
|
|
391
|
-
browser op (all of them route through get_browser_and_page below). The shell
|
|
392
|
-
FIFO lock in skill/lock.sh only serializes the pipelines that bother to take
|
|
393
|
-
it; this one catches everything, including cross-pipeline handoff races and
|
|
394
|
-
MCP-driven posts.
|
|
395
|
-
|
|
396
|
-
Holders today are python:PID. UUID-style holders are a legacy artifact of the
|
|
397
|
-
retired PreToolUse hook (twitter-agent-lock.sh); a live UUID holder is a
|
|
398
|
-
parent Claude session still in flight, so we INHERIT rather than fight it.
|
|
399
|
-
|
|
400
|
-
Reclaim priority (a holder we can PROVE is dead is taken immediately, so a
|
|
401
|
-
crashed peer can never starve the fleet for LOCK_WAIT_MAX/LOCK_EXPIRY):
|
|
402
|
-
1. holder == us -> re-entrant; we already hold it.
|
|
403
|
-
2. UUID holder, pid gone -> stale legacy lock, reclaim.
|
|
404
|
-
3. python:PID, pid gone -> dead peer (defect a fix), reclaim.
|
|
405
|
-
4. age >= LOCK_EXPIRY -> failsafe for holders we cannot probe.
|
|
406
|
-
5. live UUID holder -> inherit (parent session).
|
|
407
|
-
6. live python:PID holder -> real peer; wait, then give up after
|
|
408
|
-
LOCK_WAIT_MAX with a structured error.
|
|
409
|
-
|
|
410
|
-
Acquisition itself is atomic (_try_take_lock / O_EXCL), so the moment we
|
|
411
|
-
decide the lock is free, no concurrent acquirer can also claim it.
|
|
412
|
-
|
|
413
|
-
NOTE for future maintainers: do NOT "simplify" this by having the shell
|
|
414
|
-
pipelines `rm -f` the lockfile around release_lock. That blind rm deleted
|
|
415
|
-
LIVE peers' locks (defect b) and was removed 2026-06-16. Dead holders are
|
|
416
|
-
reclaimed here instead. See docs/twitter_browser_lock.md.
|
|
417
|
-
"""
|
|
418
|
-
global _LOCK_SESSION_ID, _LOCK_INHERITED, _LOCK_ACQUIRED_AT
|
|
419
|
-
deadline = time.time() + LOCK_WAIT_MAX
|
|
420
|
-
# Guarantee the lock dir exists so _try_take_lock's O_EXCL create can't fail
|
|
421
|
-
# for a missing-parent reason (which would otherwise spin the no-file path).
|
|
422
|
-
try:
|
|
423
|
-
os.makedirs(os.path.dirname(LOCK_FILE), exist_ok=True)
|
|
424
|
-
except OSError:
|
|
425
|
-
pass
|
|
426
|
-
while True:
|
|
427
|
-
if not os.path.exists(LOCK_FILE):
|
|
428
|
-
if _try_take_lock():
|
|
429
|
-
break
|
|
430
|
-
# Lost the create race to a peer (or a persistent create failure).
|
|
431
|
-
# Bound by `deadline` so this path can never spin forever.
|
|
432
|
-
if time.time() >= deadline:
|
|
433
|
-
print(json.dumps({
|
|
434
|
-
"success": False,
|
|
435
|
-
"error": f"Twitter browser lock contended on create; waited {LOCK_WAIT_MAX}s, giving up."
|
|
436
|
-
}))
|
|
437
|
-
sys.exit(1)
|
|
438
|
-
time.sleep(LOCK_POLL_INTERVAL)
|
|
439
|
-
continue
|
|
440
|
-
try:
|
|
441
|
-
with open(LOCK_FILE) as f:
|
|
442
|
-
lock = json.load(f)
|
|
443
|
-
except (json.JSONDecodeError, OSError):
|
|
444
|
-
# Corrupt / half-written / vanished between exists() and open().
|
|
445
|
-
# Try to claim atomically; if a peer holds a valid lock our O_EXCL
|
|
446
|
-
# create fails and we re-loop. Bounded by `deadline` so a persistently
|
|
447
|
-
# unreadable lockfile gives up instead of hanging the pipeline.
|
|
448
|
-
if _try_take_lock():
|
|
449
|
-
break
|
|
450
|
-
if time.time() >= deadline:
|
|
451
|
-
print(json.dumps({
|
|
452
|
-
"success": False,
|
|
453
|
-
"error": f"Twitter browser lock unreadable; waited {LOCK_WAIT_MAX}s, giving up."
|
|
454
|
-
}))
|
|
455
|
-
sys.exit(1)
|
|
456
|
-
time.sleep(LOCK_POLL_INTERVAL)
|
|
457
|
-
continue
|
|
458
|
-
age = time.time() - lock.get("timestamp", 0)
|
|
459
|
-
holder = lock.get("session_id", "")
|
|
460
|
-
holder_role = lock.get("role", "scan") # legacy locks (no role) = preemptable
|
|
461
|
-
|
|
462
|
-
# 1. Re-entrant: the lock is already ours (same process, or a stale lock
|
|
463
|
-
# left by a previous process whose PID we have since reused). Refresh the
|
|
464
|
-
# timestamp so a peer's LOCK_EXPIRY failsafe can't reclaim it under us.
|
|
465
|
-
if holder == _LOCK_SESSION_ID and not _LOCK_INHERITED:
|
|
466
|
-
_refresh_browser_lock()
|
|
467
|
-
break
|
|
468
|
-
|
|
469
|
-
# 1b. Batch-owner inherit (posting). The poster (twitter_post_plan.py)
|
|
470
|
-
# acquires this lock ONCE and holds it across the WHOLE approved batch,
|
|
471
|
-
# exporting its own session id as S4L_LOCK_OWNER for the child
|
|
472
|
-
# twitter_browser.py reply subprocesses it spawns. Each child INHERITS the
|
|
473
|
-
# parent's hold instead of contending for it -- two role:"post" peers would
|
|
474
|
-
# otherwise both fall to the case-6 peer-wait and give up after
|
|
475
|
-
# LOCK_WAIT_MAX, breaking the post. The child refreshes the timestamp
|
|
476
|
-
# (proof of progress at this candidate boundary, so the POST_LOCK_EXPIRY
|
|
477
|
-
# failsafe only ever fires on a real hang) and, being _LOCK_INHERITED,
|
|
478
|
-
# leaves the lock in place for the PARENT to release at batch end. A DEAD
|
|
479
|
-
# owner is never inherited: the alive-probe fails here and we fall through
|
|
480
|
-
# to the dead_python reclaim below, so a crashed batch can't wedge the
|
|
481
|
-
# browser. This is what closes the inter-candidate gap (the link_tail
|
|
482
|
-
# claude -p call, ~5-20s) the every-60s autopilot scan used to slip into.
|
|
483
|
-
_batch_owner = os.environ.get("S4L_LOCK_OWNER") or ""
|
|
484
|
-
if holder and holder == _batch_owner and _is_python_holder_alive(holder):
|
|
485
|
-
_LOCK_SESSION_ID = holder
|
|
486
|
-
_LOCK_INHERITED = True
|
|
487
|
-
_refresh_browser_lock()
|
|
488
|
-
print(f"[browser_lock] inherited batch owner={holder} "
|
|
489
|
-
f"role={holder_role} -> pid={os.getpid()}", file=sys.stderr)
|
|
490
|
-
break
|
|
491
|
-
|
|
492
|
-
# 2-4. Reclaim a holder we can prove is dead/expired. Remove-then-take so
|
|
493
|
-
# the O_EXCL claim wins; if a peer reclaims at the same instant exactly
|
|
494
|
-
# one of us creates the file and the other re-loops (never both).
|
|
495
|
-
reclaim_reason = ""
|
|
496
|
-
if _UUID_RE.match(holder or "") and not _is_holder_alive(holder):
|
|
497
|
-
reclaim_reason = "dead_uuid"
|
|
498
|
-
elif holder.startswith("python:") and not _is_python_holder_alive(holder):
|
|
499
|
-
reclaim_reason = "dead_python"
|
|
500
|
-
elif age >= (POST_LOCK_EXPIRY if holder_role == "post" else LOCK_EXPIRY):
|
|
501
|
-
# Role-aware failsafe: a hung poster self-clears on the posting-only
|
|
502
|
-
# POST_LOCK_EXPIRY, a scan on the fleet-wide LOCK_EXPIRY. Scan
|
|
503
|
-
# behaviour is unchanged; only the post ceiling is decoupled.
|
|
504
|
-
reclaim_reason = "expired"
|
|
505
|
-
if reclaim_reason:
|
|
506
|
-
try:
|
|
507
|
-
os.remove(LOCK_FILE)
|
|
508
|
-
except OSError:
|
|
509
|
-
pass
|
|
510
|
-
if _try_take_lock():
|
|
511
|
-
# Verifiable signal that defect-a starvation was prevented.
|
|
512
|
-
print(f"[browser_lock] reclaimed holder={holder or '<none>'} "
|
|
513
|
-
f"reason={reclaim_reason} age={int(age)}s -> pid={os.getpid()}",
|
|
514
|
-
file=sys.stderr)
|
|
515
|
-
break
|
|
516
|
-
time.sleep(LOCK_POLL_INTERVAL)
|
|
517
|
-
continue
|
|
518
|
-
|
|
519
|
-
# 5. Live UUID holder = parent Claude session still in flight -> inherit.
|
|
520
|
-
if _UUID_RE.match(holder or ""):
|
|
521
|
-
_LOCK_SESSION_ID = holder
|
|
522
|
-
_LOCK_INHERITED = True
|
|
523
|
-
break
|
|
524
|
-
|
|
525
|
-
# 5b. POSTING PRIORITY (cross-process). A LIVE python:PID peer running a
|
|
526
|
-
# lower-priority op (role != "post": the scan/draft cycle, whether the
|
|
527
|
-
# plugin's own, a separate autopilot agent's, or the launchd cron's) must
|
|
528
|
-
# YIELD to an approved post. Preempt it by signal and reclaim, so the post
|
|
529
|
-
# takes the browser at once instead of waiting LOCK_WAIT_MAX and giving up
|
|
530
|
-
# while the scan holds it. The aborted scan just re-runs next cron tick;
|
|
531
|
-
# posting is the scarce, user-initiated action. Only a poster
|
|
532
|
-
# (LOCK_ROLE == "post") ever preempts, and only a non-post holder -- two
|
|
533
|
-
# posters fall through to the normal peer-wait below so neither kills the
|
|
534
|
-
# other. UUID holders are handled above (we inherit, never kill those).
|
|
535
|
-
if (
|
|
536
|
-
LOCK_ROLE == "post"
|
|
537
|
-
and holder.startswith("python:")
|
|
538
|
-
and holder_role != "post"
|
|
539
|
-
and _is_python_holder_alive(holder)
|
|
540
|
-
):
|
|
541
|
-
try:
|
|
542
|
-
victim_pid = int(holder.split(":", 1)[1])
|
|
543
|
-
except (ValueError, IndexError):
|
|
544
|
-
victim_pid = 0
|
|
545
|
-
if victim_pid and _preempt_holder(victim_pid):
|
|
546
|
-
try:
|
|
547
|
-
os.remove(LOCK_FILE)
|
|
548
|
-
except OSError:
|
|
549
|
-
pass
|
|
550
|
-
if _try_take_lock():
|
|
551
|
-
print(
|
|
552
|
-
f"[browser_lock] post preempted holder={holder} "
|
|
553
|
-
f"role={holder_role} age={int(age)}s -> pid={os.getpid()}",
|
|
554
|
-
file=sys.stderr,
|
|
555
|
-
)
|
|
556
|
-
break
|
|
557
|
-
# Preempt didn't land (couldn't kill, or a peer reclaimed first) ->
|
|
558
|
-
# re-loop and re-evaluate rather than busy-spin.
|
|
559
|
-
time.sleep(LOCK_POLL_INTERVAL)
|
|
560
|
-
continue
|
|
561
|
-
|
|
562
|
-
# 6. Live python:PID peer. Wait, then give up. Reaching the deadline now
|
|
563
|
-
# means the holder is a genuinely LIVE peer (dead ones were reclaimed
|
|
564
|
-
# above), i.e. real contention -- NOT the defect-a starvation. The
|
|
565
|
-
# "locked by session" substring is preserved for downstream parsers.
|
|
566
|
-
if time.time() >= deadline:
|
|
567
|
-
print(json.dumps({
|
|
568
|
-
"success": False,
|
|
569
|
-
"error": f"Twitter browser locked by session {holder} ({int(age)}s, peer alive); waited {LOCK_WAIT_MAX}s, giving up."
|
|
570
|
-
}))
|
|
571
|
-
sys.exit(1)
|
|
572
|
-
time.sleep(LOCK_POLL_INTERVAL)
|
|
573
|
-
continue
|
|
574
|
-
_LOCK_ACQUIRED_AT = time.time()
|
|
280
|
+
def _release_browser_lock():
|
|
281
|
+
_MUTEX.release()
|
|
575
282
|
|
|
576
283
|
|
|
577
|
-
|
|
578
|
-
"""Refresh the lock timestamp to prevent expiry during long operations."""
|
|
579
|
-
try:
|
|
580
|
-
with open(LOCK_FILE, "w") as f:
|
|
581
|
-
json.dump({"session_id": _LOCK_SESSION_ID, "timestamp": int(time.time()), "role": LOCK_ROLE}, f)
|
|
582
|
-
except OSError:
|
|
583
|
-
pass
|
|
284
|
+
atexit.register(_release_browser_lock)
|
|
584
285
|
|
|
585
286
|
|
|
586
287
|
def _cdp_diagnostics(cdp_url):
|
|
@@ -645,11 +346,149 @@ def _touch_browser_heartbeat(*_args):
|
|
|
645
346
|
pass
|
|
646
347
|
|
|
647
348
|
|
|
349
|
+
# L3: CHOKE-POINT self-liveness watchdog (2026-07-14). The per-call-site
|
|
350
|
+
# stall_guard wrappers proved to be coverage-by-enumeration: the batch media
|
|
351
|
+
# path (scrape_many_thread_media) was missed and hung 31+ min on a crashed
|
|
352
|
+
# renderer with only the external watchdog's 90-min net under it. This layer
|
|
353
|
+
# is enforcement at the ONE entry every Playwright browser op flows through:
|
|
354
|
+
# if THIS process has produced zero browser activity (no heartbeat touches —
|
|
355
|
+
# no attach, no page network traffic) for the deadline while holding the
|
|
356
|
+
# browser, it is hung; print a marker, stamp the wedge strike, and self-abort
|
|
357
|
+
# so the lock and cycle slot free up. Applies to every current and FUTURE
|
|
358
|
+
# call site automatically. Write path excluded by ROLE (S4L_LOCK_ROLE=post):
|
|
359
|
+
# aborting mid-post risks a posted-but-unrecorded reply (double-post on
|
|
360
|
+
# retry), so posting keeps its own timeouts + the external nets.
|
|
361
|
+
_SELF_WATCHDOG_ARMED = False
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _arm_self_liveness_watchdog():
|
|
365
|
+
global _SELF_WATCHDOG_ARMED
|
|
366
|
+
if _SELF_WATCHDOG_ARMED or LOCK_ROLE == "post":
|
|
367
|
+
return
|
|
368
|
+
_SELF_WATCHDOG_ARMED = True
|
|
369
|
+
import threading
|
|
370
|
+
|
|
371
|
+
deadline = float(os.environ.get("S4L_BROWSER_OP_DEADLINE_S", "240")) + 60.0
|
|
372
|
+
interval = max(5.0, min(20.0, deadline / 4.0))
|
|
373
|
+
|
|
374
|
+
def _loop():
|
|
375
|
+
while True:
|
|
376
|
+
time.sleep(interval)
|
|
377
|
+
if not _hb_last_touch:
|
|
378
|
+
continue
|
|
379
|
+
idle = time.time() - _hb_last_touch
|
|
380
|
+
if idle > deadline:
|
|
381
|
+
print(
|
|
382
|
+
f"[twitter_browser] SELF_LIVENESS_ABORT role={LOCK_ROLE} "
|
|
383
|
+
f"idle={idle:.0f}s deadline={deadline:.0f}s — browser held "
|
|
384
|
+
"with zero activity (hung op); stamping wedge strike and "
|
|
385
|
+
"aborting so the lock frees",
|
|
386
|
+
file=sys.stderr,
|
|
387
|
+
)
|
|
388
|
+
try:
|
|
389
|
+
# Matches the two-strike gate in skill/lib/twitter-backend.sh.
|
|
390
|
+
with open("/tmp/s4l_cdp_wedge_strike_9555", "w") as f:
|
|
391
|
+
f.write(str(int(time.time())))
|
|
392
|
+
except OSError:
|
|
393
|
+
pass
|
|
394
|
+
os._exit(75) # EX_TEMPFAIL
|
|
395
|
+
|
|
396
|
+
threading.Thread(target=_loop, daemon=True).start()
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# PARK-ON-EXIT (2026-07-14, Chrome 150 crash-storm mitigation). Root cause of
|
|
400
|
+
# the 07-12..07-14 stalls: Chrome 150 (live at the 07-12 reboot) crashes the
|
|
401
|
+
# x.com renderer with a deliberate internal CHECK abort roughly hourly while
|
|
402
|
+
# the tab sits unthrottled on x.com between pipeline runs, and a crashed
|
|
403
|
+
# renderer precedes most whole-browser CDP wedges by minutes (see Crashpad
|
|
404
|
+
# dump ↔ cdp_wedge correlation, memory
|
|
405
|
+
# insights_chrome150_renderer_crash_wedge_2026_07_14). Parking every
|
|
406
|
+
# x.com/twitter.com tab when the last browser-using python process exits
|
|
407
|
+
# removes the heavy SPA from the idle window entirely. Raw CDP over
|
|
408
|
+
# HTTP+websocket with hard 3s timeouts, NEVER Playwright: at atexit the
|
|
409
|
+
# Playwright loop may be gone, and against an already-wedged browser this
|
|
410
|
+
# must fail fast, not hang the exit.
|
|
411
|
+
#
|
|
412
|
+
# PARK TARGET IS x.com/robots.txt, NOT about:blank (user rule 2026-07-14:
|
|
413
|
+
# always REUSE the tab; creating tabs risks focus steal). The bh harness's
|
|
414
|
+
# list_tabs() treats "about:" as INTERNAL and hides it, so an about:blank
|
|
415
|
+
# park made twitter_scan._navigate see zero real tabs and open a NEW tab
|
|
416
|
+
# every cycle. robots.txt is a static ~100-byte text document (no SPA, no
|
|
417
|
+
# JS, nothing to leak or crash) whose URL still matches every reuse
|
|
418
|
+
# heuristic: harness list_tabs (plain https), the x.com-tab preference in
|
|
419
|
+
# _get_browser_and_page_raw, and find_twitter_cdp_port discovery. The
|
|
420
|
+
# harness-profile discovery fallback stays as a belt for a closed tab.
|
|
421
|
+
_PARK_URL = "https://x.com/robots.txt"
|
|
422
|
+
_PARK_REGISTERED = False
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _park_twitter_tabs():
|
|
426
|
+
try:
|
|
427
|
+
import urllib.request
|
|
428
|
+
|
|
429
|
+
base = (
|
|
430
|
+
os.environ.get("TWITTER_CDP_URL", "").strip()
|
|
431
|
+
or "http://127.0.0.1:9555"
|
|
432
|
+
).rstrip("/")
|
|
433
|
+
resp = urllib.request.urlopen(base + "/json/list", timeout=3)
|
|
434
|
+
targets = json.loads(resp.read())
|
|
435
|
+
for t in targets:
|
|
436
|
+
url = t.get("url", "")
|
|
437
|
+
if t.get("type") != "page":
|
|
438
|
+
continue
|
|
439
|
+
if "x.com" not in url and "twitter.com" not in url:
|
|
440
|
+
continue
|
|
441
|
+
if url.split("?", 1)[0].rstrip("/").endswith("/robots.txt"):
|
|
442
|
+
continue # already parked
|
|
443
|
+
ws_url = t.get("webSocketDebuggerUrl")
|
|
444
|
+
if not ws_url:
|
|
445
|
+
continue
|
|
446
|
+
try:
|
|
447
|
+
import websocket
|
|
448
|
+
|
|
449
|
+
# suppress_origin: Chrome 111+ rejects CDP websocket clients
|
|
450
|
+
# that send an Origin header not in --remote-allow-origins
|
|
451
|
+
# (same pattern as restore_twitter_session.py).
|
|
452
|
+
ws = websocket.create_connection(
|
|
453
|
+
ws_url, timeout=3, suppress_origin=True
|
|
454
|
+
)
|
|
455
|
+
try:
|
|
456
|
+
ws.send(json.dumps({
|
|
457
|
+
"id": 1,
|
|
458
|
+
"method": "Page.navigate",
|
|
459
|
+
"params": {"url": _PARK_URL},
|
|
460
|
+
}))
|
|
461
|
+
ws.recv()
|
|
462
|
+
finally:
|
|
463
|
+
ws.close()
|
|
464
|
+
print(
|
|
465
|
+
f"[twitter_browser] parked tab on {_PARK_URL} (was {url[:80]})",
|
|
466
|
+
file=sys.stderr,
|
|
467
|
+
)
|
|
468
|
+
except Exception:
|
|
469
|
+
continue
|
|
470
|
+
except Exception:
|
|
471
|
+
pass
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _register_park_on_exit():
|
|
475
|
+
"""Arm parking once per process, only for processes that actually used the
|
|
476
|
+
browser. Registered AFTER the module-level _release_browser_lock atexit,
|
|
477
|
+
so it runs BEFORE it (LIFO): park while still holding the python lock,
|
|
478
|
+
then release. S4L_NO_TAB_PARK=1 is the escape hatch."""
|
|
479
|
+
global _PARK_REGISTERED
|
|
480
|
+
if _PARK_REGISTERED or os.environ.get("S4L_NO_TAB_PARK"):
|
|
481
|
+
return
|
|
482
|
+
_PARK_REGISTERED = True
|
|
483
|
+
atexit.register(_park_twitter_tabs)
|
|
484
|
+
|
|
485
|
+
|
|
648
486
|
def get_browser_and_page(playwright):
|
|
649
487
|
"""Instrumented entry point: attach via _get_browser_and_page_raw, then
|
|
650
488
|
(L1) set default per-op/navigation timeouts so no page call can hang
|
|
651
|
-
forever,
|
|
652
|
-
|
|
489
|
+
forever, (L2) wire the browser-lock liveness heartbeat, and (L3) arm the
|
|
490
|
+
self-liveness watchdog so a hung op in ANY caller self-aborts. All new
|
|
491
|
+
code should use THIS, never the raw variant."""
|
|
653
492
|
browser, page, is_cdp = _get_browser_and_page_raw(playwright)
|
|
654
493
|
try:
|
|
655
494
|
page.set_default_timeout(BROWSER_OP_TIMEOUT_MS)
|
|
@@ -661,6 +500,8 @@ def get_browser_and_page(playwright):
|
|
|
661
500
|
page.on("request", _touch_browser_heartbeat)
|
|
662
501
|
except Exception:
|
|
663
502
|
pass
|
|
503
|
+
_arm_self_liveness_watchdog()
|
|
504
|
+
_register_park_on_exit()
|
|
664
505
|
return browser, page, is_cdp
|
|
665
506
|
|
|
666
507
|
|