@m13v/s4l 1.7.4-rc.4 → 1.7.4
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/README.md +134 -101
- package/bin/cli.js +23 -0
- package/mcp/dist/index.js +24 -5
- package/mcp/dist/runtime.js +110 -0
- package/mcp/dist/version.js +163 -54
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +55 -19
- package/mcp/menubar/s4l_menubar.py +179 -56
- package/mcp/menubar/s4l_state.py +41 -1
- package/mcp/package.json +1 -1
- package/package.json +4 -2
- package/scripts/active_experiments.py +30 -6
- package/scripts/autopilot_stall_watch.py +185 -63
- package/scripts/cdp_ready_check.py +94 -0
- package/scripts/engage_twitter_helper.py +83 -17
- package/scripts/engagement_styles.py +221 -26
- package/scripts/enrich_reply_parents.py +68 -22
- package/scripts/feedback_digest.py +24 -1
- package/scripts/generate_daily_human_style.py +31 -7
- package/scripts/invent_styles.py +103 -26
- package/scripts/linkedin_cadence.py +234 -0
- package/scripts/linkedin_killswitch.py +43 -0
- package/scripts/memory_snapshot.py +57 -0
- package/scripts/patches/browser-harness-loopback-cdp-proxy.patch +118 -0
- package/scripts/recent_self_posts.py +6 -0
- package/scripts/reddit_browser.py +57 -3
- package/scripts/s4l_box_update.sh +20 -1
- package/scripts/s4l_mode.py +3 -4
- package/scripts/scan_x_profile.py +25 -7
- package/scripts/schedule_state.py +23 -0
- package/scripts/scheduled_task_selfheal.py +213 -16
- package/scripts/setup_twitter_auth.py +6 -1
- package/scripts/snapshot.py +179 -61
- package/scripts/stall_guard.py +70 -0
- package/scripts/test_scheduled_task_selfheal.py +121 -0
- package/scripts/top_performers.py +34 -0
- package/scripts/twitter_browser.py +146 -8
- package/scripts/twitter_post_plan.py +10 -1
- package/scripts/twitter_scan.py +26 -0
- package/scripts/watchdog_hung_runs.py +117 -3
- package/skill/amplitude-24h-signups.sh +1 -1
- package/skill/audit-dm-staleness.sh +1 -1
- package/skill/audit-linkedin.sh +1 -1
- package/skill/audit-reddit-resurrect.sh +1 -1
- package/skill/audit.sh +5 -5
- package/skill/check-web-chats.sh +1 -1
- package/skill/dm-outreach-linkedin.sh +4 -4
- package/skill/dm-outreach-reddit.sh +1 -1
- package/skill/dm-outreach-twitter.sh +1 -1
- package/skill/engage-dm-replies.sh +16 -3
- package/skill/engage-linkedin.sh +2 -2
- package/skill/engage-moltbook.sh +1 -1
- package/skill/engage-twitter.sh +4 -4
- package/skill/github-engage.sh +1 -1
- package/skill/lib/browser-launch.sh +54 -0
- package/skill/lib/linkedin-backend.sh +22 -15
- package/skill/lib/reddit-backend.sh +17 -11
- package/skill/lib/twitter-backend.sh +137 -16
- package/skill/link-edit-github.sh +1 -1
- package/skill/link-edit-moltbook.sh +1 -1
- package/skill/link-edit-reddit.sh +1 -1
- package/skill/linkedin-cadence.sh +20 -0
- package/skill/linkedin-presence.sh +1 -1
- package/skill/lock.sh +30 -6
- package/skill/precompute-stats.sh +1 -1
- package/skill/refresh-instagram-tokens.sh +1 -1
- package/skill/refresh-twitter-following.sh +1 -1
- package/skill/run-generate-daily-style.sh +1 -1
- package/skill/run-instagram-daily.sh +1 -1
- package/skill/run-instagram-render.sh +1 -1
- package/skill/run-linkedin-launchd.sh +1 -1
- package/skill/run-linkedin-unipile.sh +1 -1
- package/skill/run-linkedin.sh +1 -1
- package/skill/run-reddit-search.sh +2 -2
- package/skill/run-reddit-threads.sh +1 -1
- package/skill/run-twitter-cycle.sh +190 -55
- package/skill/scan-instagram-replies.sh +1 -1
- package/skill/sentry-digest.sh +1 -1
- package/skill/stats-instagram.sh +1 -1
- package/skill/stats-linkedin.sh +2 -2
- package/skill/stats.sh +1 -1
- package/skill/sweep-link-clicks.sh +1 -1
- package/scripts/_lock_preempt_test.py +0 -60
- package/scripts/saps_activity.py +0 -18
- package/scripts/saps_mode.py +0 -18
package/scripts/snapshot.py
CHANGED
|
@@ -70,7 +70,7 @@ PERSONA_REQUIRED_FIELDS = ["name", "description", "voice", "search_topics"]
|
|
|
70
70
|
CURRENT_WORKER_TASK_IDS = ("s4l-worker", "saps-worker")
|
|
71
71
|
LEGACY_WORKER_TASK_IDS = ("saps-phase1-query", "saps-phase2b-draft")
|
|
72
72
|
UPDATER_LABEL = "com.m13v.social-autoposter-update"
|
|
73
|
-
AUTOPILOT_STALL_MS =
|
|
73
|
+
AUTOPILOT_STALL_MS = 1_200_000
|
|
74
74
|
|
|
75
75
|
# Milestones overlaid with LIVE state for display (the rest keep their ledger
|
|
76
76
|
# value). Mirrors the overlay in buildSnapshot().
|
|
@@ -325,6 +325,81 @@ def _x_status():
|
|
|
325
325
|
_ver_cache = {"at": 0.0, "latest": None, "tag": None, "channel": None, "checked": False}
|
|
326
326
|
_VER_TTL = 55.0
|
|
327
327
|
|
|
328
|
+
# SHARED CROSS-PROCESS CACHE (2026-07-13): <state dir>/latest-release.json.
|
|
329
|
+
# Every surface that resolves the newest release (this module, mcp/src/
|
|
330
|
+
# version.ts, scripts/s4l_box_update.sh) reads and writes THIS one file, so a
|
|
331
|
+
# box makes at most one real GitHub probe per _SHARED_TTL no matter how many
|
|
332
|
+
# short-lived processes spin up (MCP servers respawn per s4l-worker session,
|
|
333
|
+
# buildSnapshot shells this module as a fresh subprocess, the menu bar ticks).
|
|
334
|
+
# The persisted ETag makes even those probes quota-free between releases (304s
|
|
335
|
+
# do not count against the anonymous 60/h-per-IP quota, and before this cache
|
|
336
|
+
# each process held its OWN in-process ETag that died with the process, so
|
|
337
|
+
# every respawn paid a full 200). Added after the box's aggregate probing
|
|
338
|
+
# (~80-100 req/h across processes) burned the quota on 2026-07-13 and silenced
|
|
339
|
+
# the update banner. Failures (version=None) are cached too, same rationale as
|
|
340
|
+
# _ver_cache. Keep the file shape in lockstep with version.ts::SharedCache:
|
|
341
|
+
# {"at": <epoch s>, "channel": ..., "version": ..., "tag": ..., "etag": ...}
|
|
342
|
+
_SHARED_TTL = 600.0 # banner latency ceiling: a new release surfaces within 10 min
|
|
343
|
+
_PROBE_LOCK_STALE = 30.0
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _shared_cache_path():
|
|
347
|
+
return os.path.join(_state_dir(), "latest-release.json")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _read_shared_cache(channel):
|
|
351
|
+
d = _read_json(_shared_cache_path())
|
|
352
|
+
if not isinstance(d, dict) or d.get("channel") != channel:
|
|
353
|
+
return None
|
|
354
|
+
if not isinstance(d.get("at"), (int, float)):
|
|
355
|
+
return None
|
|
356
|
+
return {
|
|
357
|
+
"at": float(d["at"]),
|
|
358
|
+
"channel": channel,
|
|
359
|
+
"version": d.get("version") if isinstance(d.get("version"), str) else None,
|
|
360
|
+
"tag": d.get("tag") if isinstance(d.get("tag"), str) else None,
|
|
361
|
+
"etag": d.get("etag") if isinstance(d.get("etag"), str) else None,
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _write_shared_cache(channel, version, tag, etag):
|
|
366
|
+
try:
|
|
367
|
+
os.makedirs(_state_dir(), exist_ok=True)
|
|
368
|
+
p = _shared_cache_path()
|
|
369
|
+
tmp = "%s.tmp.%d" % (p, os.getpid())
|
|
370
|
+
with open(tmp, "w") as f:
|
|
371
|
+
json.dump({"at": time.time(), "channel": channel,
|
|
372
|
+
"version": version, "tag": tag, "etag": etag}, f)
|
|
373
|
+
os.replace(tmp, p)
|
|
374
|
+
except Exception:
|
|
375
|
+
pass # best effort; worst case another process re-probes
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _try_probe_lock():
|
|
379
|
+
"""Single-flight guard so N concurrent processes with an expired shared
|
|
380
|
+
cache don't all probe at once. Returns (acquired, lock_path). acquired is
|
|
381
|
+
False ONLY when another probe holds a FRESH lock (< _PROBE_LOCK_STALE s);
|
|
382
|
+
any other failure returns (True, None) so we probe anyway rather than go
|
|
383
|
+
blind on a broken state dir."""
|
|
384
|
+
lock = os.path.join(_state_dir(), "latest-release.lock")
|
|
385
|
+
try:
|
|
386
|
+
os.makedirs(_state_dir(), exist_ok=True)
|
|
387
|
+
fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
388
|
+
os.close(fd)
|
|
389
|
+
return True, lock
|
|
390
|
+
except FileExistsError:
|
|
391
|
+
try:
|
|
392
|
+
if time.time() - os.path.getmtime(lock) > _PROBE_LOCK_STALE:
|
|
393
|
+
os.unlink(lock) # stale: holder died mid-probe
|
|
394
|
+
fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
395
|
+
os.close(fd)
|
|
396
|
+
return True, lock
|
|
397
|
+
except Exception:
|
|
398
|
+
pass
|
|
399
|
+
return False, None
|
|
400
|
+
except Exception:
|
|
401
|
+
return True, None
|
|
402
|
+
|
|
328
403
|
_RELEASES_LATEST_URL = "https://github.com/m13v/s4l/releases/latest"
|
|
329
404
|
_RELEASES_LATEST_API = "https://api.github.com/repos/m13v/s4l/releases/latest"
|
|
330
405
|
# Staging resolves the newest release OVERALL from the releases LIST, since
|
|
@@ -367,56 +442,64 @@ def _latest_from_github_redirect():
|
|
|
367
442
|
return None
|
|
368
443
|
|
|
369
444
|
|
|
370
|
-
#
|
|
371
|
-
#
|
|
372
|
-
# 200 per process
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
445
|
+
# Conditional-request state lives in the SHARED cache file (latest-release.json)
|
|
446
|
+
# so the ETag survives process boundaries: short-lived shell-outs used to pay a
|
|
447
|
+
# full 200 per process; now every probe sends If-None-Match and gets a free 304
|
|
448
|
+
# between releases.
|
|
449
|
+
def _curl_conditional(url, etag):
|
|
450
|
+
"""GET url with optional If-None-Match. Returns (status, new_etag, body)."""
|
|
451
|
+
args = ["/usr/bin/curl", "-sS", "-m", "10",
|
|
452
|
+
"-H", "Accept: application/vnd.github+json"]
|
|
453
|
+
if etag:
|
|
454
|
+
args += ["-H", "If-None-Match: %s" % etag]
|
|
455
|
+
args += ["-w", "\n__CURL_STATUS__:%{http_code}\n__CURL_ETAG__:%header{etag}", url]
|
|
456
|
+
res = subprocess.run(args, capture_output=True, text=True, timeout=12)
|
|
457
|
+
status, new_etag, body = 0, None, []
|
|
458
|
+
for line in (res.stdout or "").splitlines():
|
|
459
|
+
if line.startswith("__CURL_STATUS__:"):
|
|
460
|
+
status = int(line.split(":", 1)[1].strip() or 0)
|
|
461
|
+
elif line.startswith("__CURL_ETAG__:"):
|
|
462
|
+
new_etag = line.split(":", 1)[1].strip() or None
|
|
463
|
+
else:
|
|
464
|
+
body.append(line)
|
|
465
|
+
return status, new_etag, "\n".join(body)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _latest_from_github_api(etag=None, cached=None):
|
|
469
|
+
"""Stable probe (releases/latest). Returns (version, etag). On 304 serves
|
|
470
|
+
the caller-supplied cached version with the same etag; If-None-Match is only
|
|
471
|
+
sent when there IS a cached value to serve."""
|
|
377
472
|
try:
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
if _api_state["etag"]:
|
|
381
|
-
args += ["-H", "If-None-Match: %s" % _api_state["etag"]]
|
|
382
|
-
args += ["-w", "\n__CURL_STATUS__:%{http_code}\n__CURL_ETAG__:%header{etag}",
|
|
383
|
-
_RELEASES_LATEST_API]
|
|
384
|
-
res = subprocess.run(args, capture_output=True, text=True, timeout=12)
|
|
385
|
-
status, etag, body = 0, None, []
|
|
386
|
-
for line in (res.stdout or "").splitlines():
|
|
387
|
-
if line.startswith("__CURL_STATUS__:"):
|
|
388
|
-
status = int(line.split(":", 1)[1].strip() or 0)
|
|
389
|
-
elif line.startswith("__CURL_ETAG__:"):
|
|
390
|
-
etag = line.split(":", 1)[1].strip() or None
|
|
391
|
-
else:
|
|
392
|
-
body.append(line)
|
|
473
|
+
status, new_etag, body = _curl_conditional(
|
|
474
|
+
_RELEASES_LATEST_API, etag if cached else None)
|
|
393
475
|
if status == 304:
|
|
394
|
-
return
|
|
476
|
+
return cached, etag
|
|
395
477
|
if status != 200:
|
|
396
|
-
return None
|
|
397
|
-
tag = (json.loads(
|
|
478
|
+
return None, None
|
|
479
|
+
tag = (json.loads(body) or {}).get("tag_name")
|
|
398
480
|
v = _parse_semverish(tag.lstrip("v").strip()) if isinstance(tag, str) else None
|
|
399
|
-
if v
|
|
400
|
-
_api_state.update(etag=etag, latest=v)
|
|
401
|
-
return v
|
|
481
|
+
return (v, new_etag) if v else (None, None)
|
|
402
482
|
except Exception:
|
|
403
|
-
return None
|
|
483
|
+
return None, None
|
|
404
484
|
|
|
405
485
|
|
|
406
|
-
def _latest_from_github_list_staging():
|
|
486
|
+
def _latest_from_github_list_staging(etag=None, cached=None):
|
|
407
487
|
"""Staging channel: newest release OVERALL (prereleases included) from the
|
|
408
|
-
releases LIST endpoint. Returns (version, tag) or (None, None).
|
|
409
|
-
skipped. 'Newest' is by the rc-aware key so 1.6.193 outranks
|
|
410
|
-
rc.2 outranks rc.1.
|
|
488
|
+
releases LIST endpoint. Returns (version, tag, etag) or (None, None, None).
|
|
489
|
+
Drafts are skipped. 'Newest' is by the rc-aware key so 1.6.193 outranks
|
|
490
|
+
1.6.193-rc.N and rc.2 outranks rc.1. On 304 serves the caller-supplied
|
|
491
|
+
cached (version, tag) with the same etag."""
|
|
411
492
|
try:
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
493
|
+
cached_v, cached_tag = cached if isinstance(cached, tuple) else (None, None)
|
|
494
|
+
status, new_etag, body = _curl_conditional(
|
|
495
|
+
_RELEASES_LIST_API, etag if (cached_v and cached_tag) else None)
|
|
496
|
+
if status == 304:
|
|
497
|
+
return cached_v, cached_tag, etag
|
|
498
|
+
if status != 200:
|
|
499
|
+
return None, None, None
|
|
500
|
+
rels = json.loads(body or "[]")
|
|
418
501
|
if not isinstance(rels, list):
|
|
419
|
-
return None, None
|
|
502
|
+
return None, None, None
|
|
420
503
|
best_v, best_tag, best_key = None, None, None
|
|
421
504
|
for r in rels:
|
|
422
505
|
if not isinstance(r, dict) or r.get("draft"):
|
|
@@ -430,9 +513,11 @@ def _latest_from_github_list_staging():
|
|
|
430
513
|
k = _ver_key(v)
|
|
431
514
|
if best_key is None or k > best_key:
|
|
432
515
|
best_v, best_tag, best_key = v, tag, k
|
|
433
|
-
|
|
516
|
+
if best_v is None:
|
|
517
|
+
return None, None, None
|
|
518
|
+
return best_v, best_tag, new_etag
|
|
434
519
|
except Exception:
|
|
435
|
-
return None, None
|
|
520
|
+
return None, None, None
|
|
436
521
|
|
|
437
522
|
|
|
438
523
|
def _latest_from_npm():
|
|
@@ -460,31 +545,64 @@ def _resolve_version() -> str:
|
|
|
460
545
|
def _latest_published(channel=None):
|
|
461
546
|
"""(version, tag) for the newest release on this box's channel. The tag is
|
|
462
547
|
what the staging download URL is built from; stable callers can ignore it and
|
|
463
|
-
use releases/latest/download.
|
|
464
|
-
|
|
548
|
+
use releases/latest/download. Two cache layers, both keyed by channel so a
|
|
549
|
+
mid-process flip re-probes instead of serving the other channel's value:
|
|
550
|
+
1. in-process _ver_cache (55s) — spares the file read on tight tick loops;
|
|
551
|
+
2. shared latest-release.json (_SHARED_TTL) — spares the NETWORK probe
|
|
552
|
+
across ALL processes on the box.
|
|
553
|
+
Failures (latest=None) are cached in both, like version.ts: a tick loop
|
|
554
|
+
re-probing an unreachable/rate-limited GitHub every few seconds would burn
|
|
555
|
+
the unauthenticated API quota (60/h per IP) and lock itself out for good."""
|
|
465
556
|
if channel is None:
|
|
466
557
|
channel = _channel()
|
|
467
558
|
now = time.time()
|
|
468
|
-
# Cache failures (latest=None) too, like version.ts: a menu-bar tick loop
|
|
469
|
-
# re-probing an unreachable/rate-limited GitHub every few seconds would burn
|
|
470
|
-
# the unauthenticated API quota (60/h per IP) and lock itself out for good.
|
|
471
559
|
if (_ver_cache["checked"] and _ver_cache["channel"] == channel
|
|
472
560
|
and now - _ver_cache["at"] < _VER_TTL):
|
|
473
561
|
return _ver_cache["latest"], _ver_cache["tag"]
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
562
|
+
shared = _read_shared_cache(channel)
|
|
563
|
+
if shared and now - shared["at"] < _SHARED_TTL:
|
|
564
|
+
_ver_cache.update(at=now, latest=shared["version"], tag=shared["tag"],
|
|
565
|
+
channel=channel, checked=True)
|
|
566
|
+
return shared["version"], shared["tag"]
|
|
567
|
+
acquired, lock = _try_probe_lock()
|
|
568
|
+
if not acquired:
|
|
569
|
+
# Another process is probing right now; serve the stale shared value
|
|
570
|
+
# (or None) instead of doubling the request. Short in-process TTL means
|
|
571
|
+
# we pick up its fresh result within a minute.
|
|
572
|
+
latest = shared["version"] if shared else None
|
|
573
|
+
tag = shared["tag"] if shared else None
|
|
574
|
+
_ver_cache.update(at=now, latest=latest, tag=tag, channel=channel, checked=True)
|
|
575
|
+
return latest, tag
|
|
576
|
+
etag = shared["etag"] if shared else None
|
|
577
|
+
try:
|
|
578
|
+
if channel == "staging":
|
|
579
|
+
latest, tag, new_etag = _latest_from_github_list_staging(
|
|
580
|
+
etag, (shared["version"], shared["tag"]) if shared else None)
|
|
581
|
+
# Fallback to the stable probes if the list endpoint fails, so a
|
|
582
|
+
# staging box degrades to "at least track stable" than going blind.
|
|
583
|
+
# new_etag stays None: a releases/latest etag must never be replayed
|
|
584
|
+
# against the LIST endpoint on the next staging probe.
|
|
585
|
+
if latest is None:
|
|
586
|
+
latest, _ = _latest_from_github_api()
|
|
587
|
+
new_etag = None
|
|
588
|
+
if latest is None:
|
|
589
|
+
latest = _latest_from_github_redirect()
|
|
590
|
+
tag = ("v" + latest) if latest else None
|
|
591
|
+
else:
|
|
592
|
+
latest, new_etag = _latest_from_github_api(
|
|
593
|
+
etag, shared["version"] if shared else None)
|
|
594
|
+
if latest is None:
|
|
595
|
+
latest, new_etag = _latest_from_github_redirect(), None
|
|
596
|
+
if latest is None:
|
|
597
|
+
latest = _latest_from_npm()
|
|
480
598
|
tag = ("v" + latest) if latest else None
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
if
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
599
|
+
_write_shared_cache(channel, latest, tag, new_etag)
|
|
600
|
+
finally:
|
|
601
|
+
if lock:
|
|
602
|
+
try:
|
|
603
|
+
os.unlink(lock)
|
|
604
|
+
except Exception:
|
|
605
|
+
pass
|
|
488
606
|
_ver_cache.update(at=now, latest=latest, tag=tag, channel=channel, checked=True)
|
|
489
607
|
return latest, tag
|
|
490
608
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared zero-progress deadline for browser-touching pipeline steps.
|
|
3
|
+
|
|
4
|
+
Any step that relays CDP to the harness Chrome (scan queries, thread media
|
|
5
|
+
capture, ...) can hang FOREVER against a half-wedged browser or a dead
|
|
6
|
+
renderer: the call never returns, the step holds the browser lock, and the
|
|
7
|
+
cycle-entry health checks that could heal Chrome can't run because the next
|
|
8
|
+
cycle can't start. 2026-07-13: a scan hung 23 min at zero queries; the same
|
|
9
|
+
night media capture hung 34+ min at candidate 24/92 and only the watchdog's
|
|
10
|
+
90-min lock-liveness net was ever going to reap it.
|
|
11
|
+
|
|
12
|
+
One implementation, used by every step (twitter_scan.py Phase-1 queries,
|
|
13
|
+
twitter_browser.py media capture). Usage:
|
|
14
|
+
|
|
15
|
+
from stall_guard import stall_guard
|
|
16
|
+
with stall_guard("thread_media", url): # default deadline 240s
|
|
17
|
+
<one bounded unit of browser work>
|
|
18
|
+
|
|
19
|
+
If the guarded block does not finish inside the deadline the guard:
|
|
20
|
+
1. prints a loud machine-greppable marker to stderr
|
|
21
|
+
([stall_guard] STALL_ABORT step=<step> ...)
|
|
22
|
+
2. stamps the port's wedge strike file, so the next cycle's two-strike
|
|
23
|
+
gate in skill/lib/twitter-backend.sh converges to a kill+relaunch if
|
|
24
|
+
Chrome is genuinely sick
|
|
25
|
+
3. hard-exits the process with EX_TEMPFAIL (75) so the cycle fails fast
|
|
26
|
+
and releases the browser lock + cycle slot.
|
|
27
|
+
|
|
28
|
+
A completed block cancels its timer; a healthy pipeline never notices the
|
|
29
|
+
guard exists. Deadline override: S4L_BROWSER_OP_DEADLINE_S (seconds).
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import contextlib
|
|
34
|
+
import os
|
|
35
|
+
import pathlib
|
|
36
|
+
import sys
|
|
37
|
+
import threading
|
|
38
|
+
|
|
39
|
+
DEFAULT_DEADLINE_S = float(os.environ.get("S4L_BROWSER_OP_DEADLINE_S", "240"))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _strike_file(port: int) -> str:
|
|
43
|
+
# Must match the two-strike gate in skill/lib/twitter-backend.sh.
|
|
44
|
+
return f"/tmp/s4l_cdp_wedge_strike_{port}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@contextlib.contextmanager
|
|
48
|
+
def stall_guard(step: str, detail: str = "", deadline_s: float | None = None, port: int = 9555):
|
|
49
|
+
limit = DEFAULT_DEADLINE_S if deadline_s is None else float(deadline_s)
|
|
50
|
+
|
|
51
|
+
def _fire():
|
|
52
|
+
sys.stderr.write(
|
|
53
|
+
f"[stall_guard] STALL_ABORT step={step} detail={detail!r} "
|
|
54
|
+
f"deadline={limit:.0f}s — browser call hung (wedged Chrome or dead "
|
|
55
|
+
"renderer); stamping wedge strike and aborting so the lock frees\n"
|
|
56
|
+
)
|
|
57
|
+
sys.stderr.flush()
|
|
58
|
+
try:
|
|
59
|
+
pathlib.Path(_strike_file(port)).touch()
|
|
60
|
+
except OSError:
|
|
61
|
+
pass
|
|
62
|
+
os._exit(75) # EX_TEMPFAIL
|
|
63
|
+
|
|
64
|
+
t = threading.Timer(limit, _fire)
|
|
65
|
+
t.daemon = True
|
|
66
|
+
t.start()
|
|
67
|
+
try:
|
|
68
|
+
yield
|
|
69
|
+
finally:
|
|
70
|
+
t.cancel()
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Focused tests for fresh-account scheduled-task registry creation."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sqlite3
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import unittest
|
|
12
|
+
from unittest import mock
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, os.path.dirname(__file__))
|
|
15
|
+
import scheduled_task_selfheal as selfheal # noqa: E402
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
ACCOUNT_UUID = "f598e7b5-55be-46e8-815e-e8ddb394fc73"
|
|
19
|
+
ORG_UUID = "d03d2d69-12d0-45bf-9f67-ae085d6848e5"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _write_cookie_db(root: str, *, value: str = "", encrypted: bytes = b"v10fixture"):
|
|
23
|
+
db = os.path.join(root, "Cookies")
|
|
24
|
+
conn = sqlite3.connect(db)
|
|
25
|
+
try:
|
|
26
|
+
conn.execute(
|
|
27
|
+
"""
|
|
28
|
+
CREATE TABLE cookies (
|
|
29
|
+
host_key TEXT,
|
|
30
|
+
name TEXT,
|
|
31
|
+
value TEXT,
|
|
32
|
+
encrypted_value BLOB,
|
|
33
|
+
last_access_utc INTEGER
|
|
34
|
+
)
|
|
35
|
+
"""
|
|
36
|
+
)
|
|
37
|
+
conn.execute(
|
|
38
|
+
"INSERT INTO cookies VALUES (?, 'lastActiveOrg', ?, ?, 1)",
|
|
39
|
+
(selfheal.CLAUDE_COOKIE_HOST, value, encrypted),
|
|
40
|
+
)
|
|
41
|
+
conn.commit()
|
|
42
|
+
finally:
|
|
43
|
+
conn.close()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ScheduledTaskSelfhealTest(unittest.TestCase):
|
|
47
|
+
def test_decrypted_cookie_strips_chromium_host_digest(self):
|
|
48
|
+
with tempfile.TemporaryDirectory() as root:
|
|
49
|
+
_write_cookie_db(root)
|
|
50
|
+
clear = (
|
|
51
|
+
hashlib.sha256(selfheal.CLAUDE_COOKIE_HOST.encode()).digest()
|
|
52
|
+
+ ORG_UUID.encode()
|
|
53
|
+
)
|
|
54
|
+
with (
|
|
55
|
+
mock.patch.object(
|
|
56
|
+
selfheal, "_claude_safe_storage_password", return_value=b"fixture"
|
|
57
|
+
),
|
|
58
|
+
mock.patch.object(selfheal, "_aes_128_cbc_decrypt", return_value=clear),
|
|
59
|
+
):
|
|
60
|
+
self.assertEqual(selfheal._active_org_uuid_from_cookie(root), ORG_UUID)
|
|
61
|
+
|
|
62
|
+
def test_capability_probe_does_not_read_keychain(self):
|
|
63
|
+
with tempfile.TemporaryDirectory() as root:
|
|
64
|
+
os.makedirs(root, exist_ok=True)
|
|
65
|
+
config = os.path.join(root, "config.json")
|
|
66
|
+
with open(config, "w") as fh:
|
|
67
|
+
json.dump({"lastKnownAccountUuid": ACCOUNT_UUID}, fh)
|
|
68
|
+
_write_cookie_db(root)
|
|
69
|
+
with (
|
|
70
|
+
mock.patch.object(
|
|
71
|
+
selfheal.schedule_state, "_config_json_paths", return_value=[config]
|
|
72
|
+
),
|
|
73
|
+
mock.patch.object(
|
|
74
|
+
selfheal,
|
|
75
|
+
"_claude_safe_storage_password",
|
|
76
|
+
side_effect=AssertionError("passive probe touched Keychain"),
|
|
77
|
+
),
|
|
78
|
+
):
|
|
79
|
+
self.assertTrue(selfheal.can_create_for_active_account())
|
|
80
|
+
|
|
81
|
+
def test_heal_creates_exact_account_org_registry_without_session_dir(self):
|
|
82
|
+
with tempfile.TemporaryDirectory() as root:
|
|
83
|
+
config = os.path.join(root, "config.json")
|
|
84
|
+
with open(config, "w") as fh:
|
|
85
|
+
json.dump({"lastKnownAccountUuid": ACCOUNT_UUID}, fh)
|
|
86
|
+
worker_cwd = os.path.join(root, "worker")
|
|
87
|
+
registry_glob = os.path.join(
|
|
88
|
+
root, "claude-code-sessions", "*", "*", "scheduled-tasks.json"
|
|
89
|
+
)
|
|
90
|
+
expected = os.path.join(
|
|
91
|
+
root,
|
|
92
|
+
"claude-code-sessions",
|
|
93
|
+
ACCOUNT_UUID,
|
|
94
|
+
ORG_UUID,
|
|
95
|
+
"scheduled-tasks.json",
|
|
96
|
+
)
|
|
97
|
+
with (
|
|
98
|
+
mock.patch.object(
|
|
99
|
+
selfheal.schedule_state, "_config_json_paths", return_value=[config]
|
|
100
|
+
),
|
|
101
|
+
mock.patch.object(
|
|
102
|
+
selfheal, "_active_org_uuid_from_cookie", return_value=ORG_UUID
|
|
103
|
+
),
|
|
104
|
+
mock.patch.object(selfheal, "_ensure_worker_skill_md", return_value=True),
|
|
105
|
+
mock.patch.object(selfheal, "WORKER_CWD", worker_cwd),
|
|
106
|
+
mock.patch.object(selfheal, "SCHED_REGISTRY_GLOB", registry_glob),
|
|
107
|
+
mock.patch.object(selfheal, "DEPRECATED_TASK_IDS", ()),
|
|
108
|
+
mock.patch.object(selfheal, "LEGACY_WORKER_TASK_IDS", ()),
|
|
109
|
+
):
|
|
110
|
+
summary = selfheal.heal()
|
|
111
|
+
|
|
112
|
+
self.assertEqual(summary["created"], [expected])
|
|
113
|
+
with open(expected) as fh:
|
|
114
|
+
registry = json.load(fh)
|
|
115
|
+
self.assertEqual(registry["recordedSkips"], [])
|
|
116
|
+
self.assertEqual(registry["scheduledTasks"][0]["id"], selfheal.WORKER_TASK_ID)
|
|
117
|
+
self.assertEqual(registry["scheduledTasks"][0]["cwd"], worker_cwd)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__":
|
|
121
|
+
unittest.main()
|
|
@@ -567,9 +567,43 @@ def main():
|
|
|
567
567
|
"summary, no bottom posts). This is the lean "
|
|
568
568
|
"on-demand shape the drafting session calls "
|
|
569
569
|
"after routing a candidate to a project."))
|
|
570
|
+
parser.add_argument("--invoked-by", default=None,
|
|
571
|
+
help=("Caller tag recorded in the on-demand invocation "
|
|
572
|
+
"ledger (the drafting prompt passes the cycle's "
|
|
573
|
+
"batch_id). Tracking only; no behavior change."))
|
|
570
574
|
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
571
575
|
args = parser.parse_args()
|
|
572
576
|
|
|
577
|
+
# On-demand invocation ledger (2026-07-10). Any --project call is the
|
|
578
|
+
# on-demand per-project winners lookup the draft prompt offers; record it
|
|
579
|
+
# at the TOOL level (model self-reports are unreliable) so we can measure
|
|
580
|
+
# whether drafting sessions actually use the query. One JSON line per
|
|
581
|
+
# call in $S4L_STATE_DIR/top-performers-invocations.jsonl; the cycle
|
|
582
|
+
# counts lines for its batch_id after prep and logs a
|
|
583
|
+
# [project_top_performers] marker. Best-effort: never block the report.
|
|
584
|
+
if args.project:
|
|
585
|
+
try:
|
|
586
|
+
import datetime
|
|
587
|
+
state_dir = os.environ.get(
|
|
588
|
+
"S4L_STATE_DIR",
|
|
589
|
+
os.path.expanduser("~/.social-autoposter-mcp"))
|
|
590
|
+
os.makedirs(state_dir, exist_ok=True)
|
|
591
|
+
with open(os.path.join(state_dir,
|
|
592
|
+
"top-performers-invocations.jsonl"),
|
|
593
|
+
"a") as fh:
|
|
594
|
+
fh.write(json.dumps({
|
|
595
|
+
"ts": datetime.datetime.now(
|
|
596
|
+
datetime.timezone.utc).isoformat(),
|
|
597
|
+
"project": args.project,
|
|
598
|
+
"platform": args.platform,
|
|
599
|
+
"brief": bool(args.brief),
|
|
600
|
+
"top": args.top,
|
|
601
|
+
"invoked_by": args.invoked_by,
|
|
602
|
+
}) + "\n")
|
|
603
|
+
except Exception as exc:
|
|
604
|
+
print(f"[top_performers] invocation ledger write failed: {exc!r}",
|
|
605
|
+
file=sys.stderr)
|
|
606
|
+
|
|
573
607
|
(summary, style_perf, top, bottom, fallback_top,
|
|
574
608
|
top_by_group, top_by_style) = _fetch_report_via_api(
|
|
575
609
|
platform=args.platform, project=args.project, top=args.top, bottom=args.bottom,
|