@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.
Files changed (48) hide show
  1. package/mcp/dist/index.js +571 -79
  2. package/mcp/dist/panel.html +2 -3
  3. package/mcp/dist/redditAuth.js +115 -0
  4. package/mcp/dist/runtime.js +83 -2
  5. package/mcp/dist/setup.js +142 -5
  6. package/mcp/dist/telemetry.js +13 -3
  7. package/mcp/dist/version.json +2 -2
  8. package/mcp/manifest.json +1 -1
  9. package/mcp/menubar/s4l_card.py +32 -6
  10. package/mcp/menubar/s4l_menubar.py +42 -111
  11. package/mcp/menubar/s4l_state.py +54 -187
  12. package/mcp/package.json +1 -1
  13. package/mcp/shared/onboarding-ledger.cjs +63 -4
  14. package/package.json +1 -1
  15. package/scripts/autopilot_stall_watch.py +3 -6
  16. package/scripts/browser_mutex.py +316 -0
  17. package/scripts/claude_job.py +21 -0
  18. package/scripts/config.py +118 -0
  19. package/scripts/counterparty_history.py +44 -6
  20. package/scripts/engage_github.py +7 -3
  21. package/scripts/engage_reddit.py +9 -3
  22. package/scripts/engagement_signal_feedback.py +256 -0
  23. package/scripts/learned_preferences.py +37 -14
  24. package/scripts/memory_snapshot.py +45 -0
  25. package/scripts/merge_review_queue.py +67 -3
  26. package/scripts/post_reddit.py +289 -24
  27. package/scripts/reddit_browser.py +62 -63
  28. package/scripts/reddit_browser_fetch.py +32 -0
  29. package/scripts/release-mcpb.sh +17 -0
  30. package/scripts/reset-test-machine.sh +13 -7
  31. package/scripts/scan_twitter_thread_followups.py +7 -1
  32. package/scripts/schedule_state.py +1 -1
  33. package/scripts/setup_reddit_auth.py +925 -0
  34. package/scripts/snapshot.py +96 -4
  35. package/scripts/stats.py +37 -13
  36. package/scripts/test_no_silent_fallbacks.py +281 -0
  37. package/scripts/trigger_promo_lane_test.sh +91 -0
  38. package/scripts/twitter_browser.py +183 -342
  39. package/scripts/twitter_scan.py +75 -10
  40. package/scripts/watchdog_hung_runs.py +15 -9
  41. package/skill/lib/harness-common.sh +344 -0
  42. package/skill/lib/linkedin-backend.sh +61 -161
  43. package/skill/lib/reddit-backend.sh +39 -125
  44. package/skill/lib/twitter-backend.sh +51 -273
  45. package/skill/lock.sh +57 -0
  46. package/skill/run-reddit-search-launchd.sh +4 -1
  47. package/skill/run-reddit-search.sh +51 -2
  48. 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
- _LOCK_SESSION_ID = f"python:{os.getpid()}"
237
- _LOCK_INHERITED = False
238
- _LOCK_ACQUIRED_AT = 0.0
239
- _UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
240
-
241
-
242
- def _release_browser_lock():
243
- """Release the lock if we hold it.
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
- A live Claude session puts its UUID on the cmdline as
280
- `claude --session-id <UUID>`. pgrep matches it; absence means the
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 _acquire_browser_lock():
388
- """Acquire the Twitter browser session mutex (~/.claude/twitter-browser-lock.json).
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
- def _refresh_browser_lock():
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, and (L2) wire the browser-lock liveness heartbeat. All new code
652
- should use THIS, never the raw variant."""
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