@m13v/s4l 1.6.197-rc.10
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 +143 -0
- package/SKILL.md +342 -0
- package/bin/cli.js +980 -0
- package/bin/cookie-helper.js +315 -0
- package/bin/platform.js +59 -0
- package/bin/scheduler/index.js +12 -0
- package/bin/scheduler/launchd.js +518 -0
- package/browser-agent-configs/all-agents-mcp.json +68 -0
- package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
- package/browser-agent-configs/linkedin-agent.json +17 -0
- package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
- package/browser-agent-configs/reddit-agent-mcp.json +16 -0
- package/browser-agent-configs/reddit-agent.json +17 -0
- package/browser-agent-configs/twitter-harness-mcp.json +18 -0
- package/config.example.json +45 -0
- package/mcp/dist/index.js +4212 -0
- package/mcp/dist/onboarding.js +200 -0
- package/mcp/dist/panel.html +176 -0
- package/mcp/dist/product-link.html +102 -0
- package/mcp/dist/repo.js +222 -0
- package/mcp/dist/runtime.js +1079 -0
- package/mcp/dist/screencast.js +323 -0
- package/mcp/dist/setup.js +545 -0
- package/mcp/dist/telemetry.js +306 -0
- package/mcp/dist/twitterAuth.js +138 -0
- package/mcp/dist/version.js +271 -0
- package/mcp/dist/version.json +4 -0
- package/mcp/install-runtime.mjs +70 -0
- package/mcp/install.mjs +169 -0
- package/mcp/manifest.json +80 -0
- package/mcp/menubar/dashboard_server.py +213 -0
- package/mcp/menubar/s4l_card.py +1336 -0
- package/mcp/menubar/s4l_log_relay.py +179 -0
- package/mcp/menubar/s4l_menubar.py +2439 -0
- package/mcp/menubar/s4l_state.py +891 -0
- package/mcp/package.json +34 -0
- package/mcp/shared/doctor.cjs +437 -0
- package/mcp/shared/onboarding-ledger.cjs +324 -0
- package/mcp-servers/browser-harness/server.py +968 -0
- package/package.json +160 -0
- package/requirements.txt +20 -0
- package/scripts/_compute_allowlist.py +58 -0
- package/scripts/_db_update.py +20 -0
- package/scripts/_filt.py +9 -0
- package/scripts/_li_notif_match.py +76 -0
- package/scripts/_li_notif_orchestrate.py +126 -0
- package/scripts/_lock_preempt_test.py +60 -0
- package/scripts/_run_icp_precheck.py +57 -0
- package/scripts/a16z_pearx_calendar_reminders.py +99 -0
- package/scripts/account_resolver.py +141 -0
- package/scripts/active_campaigns.py +114 -0
- package/scripts/active_users.py +190 -0
- package/scripts/amplitude_24h_signups.py +468 -0
- package/scripts/amplitude_signups.py +177 -0
- package/scripts/apply_onboarding_selections.py +131 -0
- package/scripts/audience_pages.py +243 -0
- package/scripts/audit_helper.py +120 -0
- package/scripts/author_history_block.py +353 -0
- package/scripts/autopilot_stall_watch.py +284 -0
- package/scripts/backfill_twitter_attempts_topic.py +81 -0
- package/scripts/backfill_twitter_log_post_no_id.py +322 -0
- package/scripts/bench_dashboard.sh +138 -0
- package/scripts/bh_send.py +39 -0
- package/scripts/build_persona.py +409 -0
- package/scripts/bulk_icp.py +18 -0
- package/scripts/campaign_bump.py +51 -0
- package/scripts/capture_thread_media.py +288 -0
- package/scripts/check_browser_lock_health.sh +81 -0
- package/scripts/check_external_pool_depth.py +253 -0
- package/scripts/check_unread_web_chats.py +28 -0
- package/scripts/claim_web_chat.py +47 -0
- package/scripts/classify_run_error.py +158 -0
- package/scripts/claude_job.py +988 -0
- package/scripts/clean_stale_singleton.sh +56 -0
- package/scripts/cleanup_harness_tabs.py +68 -0
- package/scripts/copy_browser_cookies.py +454 -0
- package/scripts/counterparty_history.py +350 -0
- package/scripts/db.py +57 -0
- package/scripts/discover_claude_profiles.py +120 -0
- package/scripts/discover_linkedin_candidates.py +984 -0
- package/scripts/dm_conversation.py +682 -0
- package/scripts/dm_db_update.py +69 -0
- package/scripts/dm_engage_helper.py +161 -0
- package/scripts/dm_outreach_helper.py +147 -0
- package/scripts/dm_outreach_twitter_helper.py +129 -0
- package/scripts/dm_send_log.py +106 -0
- package/scripts/dm_short_links.py +1084 -0
- package/scripts/dump_web_chat_history.py +47 -0
- package/scripts/engage_github.py +640 -0
- package/scripts/engage_reddit.py +1235 -0
- package/scripts/engage_twitter_helper.py +301 -0
- package/scripts/engagement_styles.py +1787 -0
- package/scripts/enrich_twitter_candidates.py +82 -0
- package/scripts/feedback_digest.py +448 -0
- package/scripts/fetch_prospect_profile.py +312 -0
- package/scripts/fetch_twitter_t1.py +134 -0
- package/scripts/find_threads.py +530 -0
- package/scripts/follow_gate_log.py +59 -0
- package/scripts/funnel_per_day.py +194 -0
- package/scripts/generate_daily_human_style.py +494 -0
- package/scripts/generation_trace.py +173 -0
- package/scripts/get_run_cost.py +107 -0
- package/scripts/github_engage_helper.py +93 -0
- package/scripts/github_tools.py +509 -0
- package/scripts/harness_overlay.py +556 -0
- package/scripts/harvest_twitter_following.py +243 -0
- package/scripts/heartbeat.sh +70 -0
- package/scripts/history_context.py +284 -0
- package/scripts/http_api.py +206 -0
- package/scripts/human_dm_replies_helper.py +169 -0
- package/scripts/identity.py +302 -0
- package/scripts/ig_batch_creator.sh +93 -0
- package/scripts/ig_post_type_picker.py +243 -0
- package/scripts/ig_scrape_transcribe.sh +91 -0
- package/scripts/ingest_human_dm_replies.py +271 -0
- package/scripts/ingest_web_chat_replies.py +229 -0
- package/scripts/install_fleet.py +187 -0
- package/scripts/invent_mcp_server.py +350 -0
- package/scripts/invent_topics.py +1462 -0
- package/scripts/learned_preferences.py +263 -0
- package/scripts/li_discovery.py +161 -0
- package/scripts/link_edit_helper.py +142 -0
- package/scripts/link_tail.py +592 -0
- package/scripts/linkedin_api.py +561 -0
- package/scripts/linkedin_browser.py +730 -0
- package/scripts/linkedin_cooldown.py +128 -0
- package/scripts/linkedin_exclusions.py +234 -0
- package/scripts/linkedin_killswitch.py +1333 -0
- package/scripts/linkedin_search_topic_schema.py +49 -0
- package/scripts/linkedin_unipile.py +658 -0
- package/scripts/linkedin_url.py +228 -0
- package/scripts/log_claude_session.py +636 -0
- package/scripts/log_draft.py +143 -0
- package/scripts/log_linkedin_search_attempts.py +126 -0
- package/scripts/log_post.py +651 -0
- package/scripts/log_run.py +364 -0
- package/scripts/log_thread_media.py +108 -0
- package/scripts/log_twitter_search_attempts.py +150 -0
- package/scripts/log_twitter_skips.py +211 -0
- package/scripts/lookup_post.py +78 -0
- package/scripts/mark_web_chat_processed.py +32 -0
- package/scripts/mcp_lock_proxy.py +370 -0
- package/scripts/memory_snapshot.py +972 -0
- package/scripts/merge_review_queue.py +215 -0
- package/scripts/mint_external_pool.py +182 -0
- package/scripts/mint_kent_pool.py +249 -0
- package/scripts/moltbook_post.py +320 -0
- package/scripts/moltbook_tools.py +159 -0
- package/scripts/pending_threads.py +188 -0
- package/scripts/pick_ig_account.py +177 -0
- package/scripts/pick_project.py +208 -0
- package/scripts/pick_search_topic.py +771 -0
- package/scripts/pick_thread_target.py +279 -0
- package/scripts/pick_twitter_thread_target.py +202 -0
- package/scripts/podlog_fetch_batch.sh +32 -0
- package/scripts/post_github.py +1311 -0
- package/scripts/post_reddit.py +2668 -0
- package/scripts/precompute_dashboard_stats.py +204 -0
- package/scripts/preflight.sh +297 -0
- package/scripts/progress.py +88 -0
- package/scripts/project_excludes.py +353 -0
- package/scripts/project_slugs.py +91 -0
- package/scripts/project_stats.py +241 -0
- package/scripts/project_stats_json.py +1563 -0
- package/scripts/project_topics.py +192 -0
- package/scripts/qualified_query_bank.py +436 -0
- package/scripts/reap_stale_claude_sessions.py +867 -0
- package/scripts/reddit_browser.py +2549 -0
- package/scripts/reddit_browser_fetch.py +141 -0
- package/scripts/reddit_browser_lock.py +593 -0
- package/scripts/reddit_chat_sync.py +710 -0
- package/scripts/reddit_query_bank.py +200 -0
- package/scripts/reddit_threads_helper.py +151 -0
- package/scripts/reddit_tools.py +956 -0
- package/scripts/refresh_instagram_tokens.py +280 -0
- package/scripts/release-mcpb.sh +513 -0
- package/scripts/reply_db.py +334 -0
- package/scripts/reply_insert.py +98 -0
- package/scripts/reply_risk_digest.py +761 -0
- package/scripts/reset-test-machine.sh +602 -0
- package/scripts/restore_twitter_session.py +177 -0
- package/scripts/ripen_reddit_plan.py +478 -0
- package/scripts/run_claude.sh +433 -0
- package/scripts/run_moltbook_cycle.py +555 -0
- package/scripts/s4l_box_update.sh +226 -0
- package/scripts/s4l_channel.py +103 -0
- package/scripts/s4l_ctl.sh +75 -0
- package/scripts/s4l_env.py +47 -0
- package/scripts/saps_activity.py +126 -0
- package/scripts/saps_mode.py +328 -0
- package/scripts/scan_dm_candidates.py +580 -0
- package/scripts/scan_github_replies.py +168 -0
- package/scripts/scan_instagram_comments.py +481 -0
- package/scripts/scan_moltbook_replies.py +252 -0
- package/scripts/scan_pii.py +190 -0
- package/scripts/scan_reddit_replies.py +377 -0
- package/scripts/scan_twitter_mentions_browser.py +327 -0
- package/scripts/scan_twitter_thread_followups.py +299 -0
- package/scripts/scan_x_profile.py +384 -0
- package/scripts/schedule_state.py +202 -0
- package/scripts/scheduled_tasks_snapshot.py +123 -0
- package/scripts/score_linkedin_candidates.py +419 -0
- package/scripts/score_twitter_candidates.py +718 -0
- package/scripts/scrape_linkedin_comment_stats.py +1755 -0
- package/scripts/scrape_linkedin_stats_browser.py +52 -0
- package/scripts/scrape_reddit_views.py +365 -0
- package/scripts/seed_search_queries.py +453 -0
- package/scripts/seed_search_topics.py +127 -0
- package/scripts/send_web_chat_reply.py +130 -0
- package/scripts/sentry_init.py +128 -0
- package/scripts/setup_twitter_auth.py +1320 -0
- package/scripts/snapshot.py +583 -0
- package/scripts/stats.py +2702 -0
- package/scripts/stats_helper.py +52 -0
- package/scripts/strike_alert.py +783 -0
- package/scripts/sweep_post_link_clicks.py +107 -0
- package/scripts/sync_ig_to_posts.py +147 -0
- package/scripts/test_browser_lock.py +189 -0
- package/scripts/test_installation_api.sh +52 -0
- package/scripts/test_percard_posting.py +142 -0
- package/scripts/top_dud_linkedin_queries.py +71 -0
- package/scripts/top_dud_reddit_queries.py +67 -0
- package/scripts/top_dud_twitter_queries.py +71 -0
- package/scripts/top_dud_twitter_topics.py +102 -0
- package/scripts/top_linkedin_queries.py +55 -0
- package/scripts/top_omitted_reddit_topics.py +91 -0
- package/scripts/top_performers.py +588 -0
- package/scripts/top_search_topics.py +180 -0
- package/scripts/top_twitter_queries.py +190 -0
- package/scripts/twitter_access_check.py +382 -0
- package/scripts/twitter_account.py +41 -0
- package/scripts/twitter_batch_phase.py +126 -0
- package/scripts/twitter_browser.py +2804 -0
- package/scripts/twitter_cookie_mirror.py +130 -0
- package/scripts/twitter_cycle_helper.py +310 -0
- package/scripts/twitter_gen_links.py +287 -0
- package/scripts/twitter_post_plan.py +1188 -0
- package/scripts/twitter_scan.py +324 -0
- package/scripts/twitter_supply_signal.py +57 -0
- package/scripts/twitter_threads_helper.py +152 -0
- package/scripts/unclaim_web_chat.py +29 -0
- package/scripts/update_instagram_stats.py +261 -0
- package/scripts/update_linkedin_stats_from_feed.py +328 -0
- package/scripts/version.py +72 -0
- package/scripts/watchdog_hung_runs.py +343 -0
- package/scripts/write_generation_trace.py +73 -0
- package/setup/SKILL.md +277 -0
- package/skill/amplitude-24h-signups.sh +38 -0
- package/skill/archive-old-logs.sh +40 -0
- package/skill/audit-dm-staleness.sh +42 -0
- package/skill/audit-linkedin.sh +14 -0
- package/skill/audit-moltbook.sh +4 -0
- package/skill/audit-reddit-resurrect.sh +67 -0
- package/skill/audit-reddit.sh +4 -0
- package/skill/audit-twitter.sh +4 -0
- package/skill/audit.sh +287 -0
- package/skill/backfill-twitter-attempts-topic.sh +19 -0
- package/skill/backfill-twitter-ghost-posts.sh +24 -0
- package/skill/check-external-pool-depth.sh +7 -0
- package/skill/check-web-chats.sh +203 -0
- package/skill/dm-outreach-linkedin.sh +250 -0
- package/skill/dm-outreach-reddit.sh +274 -0
- package/skill/dm-outreach-twitter.sh +265 -0
- package/skill/engage-dm-replies-linkedin.sh +4 -0
- package/skill/engage-dm-replies-reddit.sh +4 -0
- package/skill/engage-dm-replies-twitter.sh +4 -0
- package/skill/engage-dm-replies.sh +1597 -0
- package/skill/engage-linkedin.sh +581 -0
- package/skill/engage-moltbook.sh +36 -0
- package/skill/engage-reddit.sh +146 -0
- package/skill/engage-twitter.sh +467 -0
- package/skill/github-engage.sh +176 -0
- package/skill/ingest-web-chat-replies.sh +38 -0
- package/skill/invent-supply-test.sh +100 -0
- package/skill/invent-topics.sh +50 -0
- package/skill/lib/linkedin-backend.sh +364 -0
- package/skill/lib/platform.sh +48 -0
- package/skill/lib/reddit-backend.sh +234 -0
- package/skill/lib/twitter-backend.sh +314 -0
- package/skill/link-edit-github.sh +136 -0
- package/skill/link-edit-moltbook.sh +117 -0
- package/skill/link-edit-reddit.sh +201 -0
- package/skill/linkedin-presence.sh +182 -0
- package/skill/linkedin-recovery.sh +282 -0
- package/skill/lock.sh +647 -0
- package/skill/memory-snapshot.sh +39 -0
- package/skill/precompute-stats.sh +35 -0
- package/skill/prewarm-funnel.sh +104 -0
- package/skill/refresh-instagram-tokens.sh +57 -0
- package/skill/refresh-twitter-following.sh +52 -0
- package/skill/reply-risk-digest.sh +31 -0
- package/skill/run-cycle-update-guard.sh +44 -0
- package/skill/run-draft-and-publish.sh +123 -0
- package/skill/run-generate-daily-style.sh +50 -0
- package/skill/run-github-launchd.sh +62 -0
- package/skill/run-github.sh +102 -0
- package/skill/run-instagram-daily.sh +149 -0
- package/skill/run-instagram-render.sh +875 -0
- package/skill/run-linkedin-launchd.sh +81 -0
- package/skill/run-linkedin-unipile.sh +130 -0
- package/skill/run-linkedin.sh +1593 -0
- package/skill/run-moltbook-launchd.sh +61 -0
- package/skill/run-moltbook.sh +38 -0
- package/skill/run-overlay-watch.sh +100 -0
- package/skill/run-reddit-search-launchd.sh +64 -0
- package/skill/run-reddit-search.sh +505 -0
- package/skill/run-reddit-threads-double.sh +32 -0
- package/skill/run-reddit-threads.sh +847 -0
- package/skill/run-scan-moltbook-replies.sh +57 -0
- package/skill/run-twitter-cycle-launchd.sh +63 -0
- package/skill/run-twitter-cycle-singleton.sh +62 -0
- package/skill/run-twitter-cycle.sh +2408 -0
- package/skill/run-twitter-threads.sh +592 -0
- package/skill/scan-instagram-replies.sh +61 -0
- package/skill/scan-twitter-followups.sh +57 -0
- package/skill/social-autoposter-update.sh +66 -0
- package/skill/stats-instagram.sh +72 -0
- package/skill/stats-linkedin.sh +271 -0
- package/skill/stats-moltbook.sh +4 -0
- package/skill/stats-reddit.sh +4 -0
- package/skill/stats-twitter.sh +4 -0
- package/skill/stats.sh +521 -0
- package/skill/strike-alert.sh +18 -0
- package/skill/styles.sh +87 -0
- package/skill/sweep-link-clicks.sh +40 -0
- package/skill/topics.sh +51 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""scripts/author_history_block.py — cross-platform prior-interaction context.
|
|
3
|
+
|
|
4
|
+
Given a target author + platform, prints a soft-context block summarizing
|
|
5
|
+
our recent comments on that author's threads (last N days, capped at K
|
|
6
|
+
most-recent). Empty output when no history. Designed to be injected into
|
|
7
|
+
the per-candidate section of draft prompts so the model can vary angle and
|
|
8
|
+
not repeat itself.
|
|
9
|
+
|
|
10
|
+
Wired into (one callsite each):
|
|
11
|
+
- skill/run-twitter-cycle.sh (Phase 2b-prep CANDIDATE_BLOCK loop)
|
|
12
|
+
- scripts/engage_reddit.py (reply draft prompt builder)
|
|
13
|
+
- scripts/post_reddit.py (build_draft_prompt)
|
|
14
|
+
- scripts/post_github.py (build_prompt)
|
|
15
|
+
- skill/run-linkedin.sh (Phase B prompt template)
|
|
16
|
+
|
|
17
|
+
CLI:
|
|
18
|
+
python3 scripts/author_history_block.py --platform twitter --author tom_doerr
|
|
19
|
+
python3 scripts/author_history_block.py --platform reddit --author lazycodewiz \\
|
|
20
|
+
--days 60 --limit 8
|
|
21
|
+
|
|
22
|
+
Stdout is prose, ready to paste into a prompt. Empty stdout when no rows.
|
|
23
|
+
Stderr only on argparse errors and DB failures; never raises mid-cycle.
|
|
24
|
+
|
|
25
|
+
LinkedIn caveat: thread_author_handle is the display name (not a unique
|
|
26
|
+
vanity slug), so two distinct LinkedIn users with the same display name
|
|
27
|
+
will collide. We document this in the block header rather than guard,
|
|
28
|
+
because the cost of a collision is "show one more harmless prior comment"
|
|
29
|
+
not anything dangerous.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import os
|
|
34
|
+
import sys
|
|
35
|
+
from datetime import datetime
|
|
36
|
+
|
|
37
|
+
REPO_DIR = os.path.expanduser("~/social-autoposter")
|
|
38
|
+
sys.path.insert(0, os.path.join(REPO_DIR, "scripts"))
|
|
39
|
+
from http_api import api_get # noqa: E402
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
PLATFORM_ALIAS = {
|
|
43
|
+
"x": "twitter",
|
|
44
|
+
"twitter": "twitter",
|
|
45
|
+
"reddit": "reddit",
|
|
46
|
+
"linkedin": "linkedin",
|
|
47
|
+
"github": "github",
|
|
48
|
+
"github_issues": "github",
|
|
49
|
+
"moltbook": "moltbook",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Per-process cache for active campaign suffixes; populated lazily on first
|
|
54
|
+
# format_block() call. None = not loaded yet; [] = loaded but empty.
|
|
55
|
+
_ACTIVE_CAMPAIGN_SUFFIXES_CACHE = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _load_active_campaign_suffixes():
|
|
59
|
+
"""Best-effort: return a list of currently-active campaign suffix literals.
|
|
60
|
+
|
|
61
|
+
Mirrors the helper of the same name in scripts/top_performers.py. We
|
|
62
|
+
duplicate (rather than import) to keep this module's failure mode
|
|
63
|
+
independent of top_performers' larger dependency surface.
|
|
64
|
+
|
|
65
|
+
Used to strip the suffix from `our_content` before injecting prior
|
|
66
|
+
interactions into the draft prompt, so the LLM never learns to echo
|
|
67
|
+
the suffix in its drafts (which would then double-fire when the
|
|
68
|
+
tool-layer injection at twitter_browser.reply_to_tweet / reddit_browser
|
|
69
|
+
appends a second copy). See feedback_suffix_injection_gating.md for the
|
|
70
|
+
history; this closes the 4th leak path that the 2026-05-19 sweep missed.
|
|
71
|
+
|
|
72
|
+
On any failure returns []: missing strip is preferable to crashing the
|
|
73
|
+
prompt assembly path.
|
|
74
|
+
"""
|
|
75
|
+
global _ACTIVE_CAMPAIGN_SUFFIXES_CACHE
|
|
76
|
+
if _ACTIVE_CAMPAIGN_SUFFIXES_CACHE is not None:
|
|
77
|
+
return _ACTIVE_CAMPAIGN_SUFFIXES_CACHE
|
|
78
|
+
suffixes = []
|
|
79
|
+
try:
|
|
80
|
+
from http_api import api_get # noqa: E402
|
|
81
|
+
resp = api_get(
|
|
82
|
+
"/api/v1/campaigns",
|
|
83
|
+
query={"status": "active", "has_suffix": "true", "limit": 500},
|
|
84
|
+
)
|
|
85
|
+
rows = ((resp or {}).get("data") or {}).get("campaigns") or []
|
|
86
|
+
for r in rows:
|
|
87
|
+
s = (r.get("suffix") or "").strip()
|
|
88
|
+
if s and s not in suffixes:
|
|
89
|
+
suffixes.append(s)
|
|
90
|
+
except Exception as e:
|
|
91
|
+
print(
|
|
92
|
+
f"[author_history_block] _load_active_campaign_suffixes failed: {e!r}",
|
|
93
|
+
file=sys.stderr,
|
|
94
|
+
)
|
|
95
|
+
_ACTIVE_CAMPAIGN_SUFFIXES_CACHE = suffixes
|
|
96
|
+
return suffixes
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _strip_active_campaign_suffixes(text, suffixes):
|
|
100
|
+
"""Trailing-only, idempotent strip of any active-campaign suffix.
|
|
101
|
+
|
|
102
|
+
Identical contract to top_performers._strip_active_campaign_suffixes.
|
|
103
|
+
Idempotent loop also collapses an already-doubled historical suffix
|
|
104
|
+
(e.g. "... written with s4lai written with s4lai") to clean text.
|
|
105
|
+
Trailing-only so we never touch the body of the comment.
|
|
106
|
+
"""
|
|
107
|
+
if not text or not suffixes:
|
|
108
|
+
return text
|
|
109
|
+
cleaned = text.rstrip()
|
|
110
|
+
changed = True
|
|
111
|
+
while changed:
|
|
112
|
+
changed = False
|
|
113
|
+
for sfx in suffixes:
|
|
114
|
+
if sfx and cleaned.endswith(sfx):
|
|
115
|
+
cleaned = cleaned[: -len(sfx)].rstrip()
|
|
116
|
+
changed = True
|
|
117
|
+
return cleaned
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _normalize(handle):
|
|
121
|
+
"""Lowercase + strip @, u/, / prefixes. Empty/'unknown' → empty string."""
|
|
122
|
+
if not handle:
|
|
123
|
+
return ""
|
|
124
|
+
h = str(handle).strip().lower()
|
|
125
|
+
while h and h[0] in "@/":
|
|
126
|
+
h = h[1:]
|
|
127
|
+
if h.startswith("u/"):
|
|
128
|
+
h = h[2:]
|
|
129
|
+
h = h.strip()
|
|
130
|
+
if h in ("", "unknown", "[deleted]", "deleted"):
|
|
131
|
+
return ""
|
|
132
|
+
return h
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# Column order returned by GET /api/v1/posts/author-history (which is the
|
|
136
|
+
# single source of truth for the SQL; the route comment notes "keep the column
|
|
137
|
+
# list + filters in sync"). format_block below indexes the tuple positionally,
|
|
138
|
+
# so this order is load-bearing.
|
|
139
|
+
def _parse_dt(s):
|
|
140
|
+
"""Parse the API's ISO posted_at into a datetime; None on any failure.
|
|
141
|
+
|
|
142
|
+
format_block / fetch's summary call `.date()` on this, so a real datetime
|
|
143
|
+
is required where present. Falls back to the leading YYYY-MM-DD so older
|
|
144
|
+
Pythons (pre-3.11 fromisoformat can't take 'Z' or variable fractional
|
|
145
|
+
digits) still yield a usable date instead of crashing the prompt path.
|
|
146
|
+
"""
|
|
147
|
+
if not s:
|
|
148
|
+
return None
|
|
149
|
+
try:
|
|
150
|
+
return datetime.fromisoformat(str(s).strip().replace("Z", "+00:00"))
|
|
151
|
+
except Exception:
|
|
152
|
+
try:
|
|
153
|
+
return datetime.fromisoformat(str(s)[:10])
|
|
154
|
+
except Exception:
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _row_to_tuple(r):
|
|
159
|
+
return (
|
|
160
|
+
r.get("id"),
|
|
161
|
+
_parse_dt(r.get("posted_at")),
|
|
162
|
+
r.get("project_name"),
|
|
163
|
+
r.get("our_content"),
|
|
164
|
+
r.get("thread_title"),
|
|
165
|
+
r.get("upvotes") or 0,
|
|
166
|
+
r.get("replies_count") or 0,
|
|
167
|
+
r.get("views") or 0,
|
|
168
|
+
r.get("their_first_reply"),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def fetch(platform, author, days=30, limit=5):
|
|
173
|
+
"""Return list of tuples matching SQL columns. Empty on bad input/no rows.
|
|
174
|
+
|
|
175
|
+
Always emits one stderr line per call so pipeline logs show injection
|
|
176
|
+
activity. Status token (INJECTED / EMPTY / SKIPPED / ERROR) is the
|
|
177
|
+
leading word after the tag for fast grep.
|
|
178
|
+
|
|
179
|
+
Grep recipes (see latest log via `ls -t skill/logs/<platform>*.log | head -1`):
|
|
180
|
+
grep '\\[author_history_block\\] INJECTED' <log> # confirmed wins
|
|
181
|
+
grep '\\[author_history_block\\] EMPTY' <log> # author has no prior history
|
|
182
|
+
grep '\\[author_history_block\\] SKIPPED' <log> # blank/unknown author field
|
|
183
|
+
grep '\\[author_history_block\\] ERROR' <log> # DB or query failure
|
|
184
|
+
"""
|
|
185
|
+
plat = PLATFORM_ALIAS.get(str(platform).lower(), str(platform).lower())
|
|
186
|
+
norm = _normalize(author)
|
|
187
|
+
if not norm:
|
|
188
|
+
print(
|
|
189
|
+
f"[author_history_block] SKIPPED platform={plat} "
|
|
190
|
+
f"author_input={author!r} reason=empty_or_unknown_handle",
|
|
191
|
+
file=sys.stderr,
|
|
192
|
+
)
|
|
193
|
+
return []
|
|
194
|
+
try:
|
|
195
|
+
resp = api_get(
|
|
196
|
+
"/api/v1/posts/author-history",
|
|
197
|
+
query={
|
|
198
|
+
"platform": plat,
|
|
199
|
+
"author": norm,
|
|
200
|
+
"days": int(days),
|
|
201
|
+
"limit": int(limit),
|
|
202
|
+
},
|
|
203
|
+
)
|
|
204
|
+
json_rows = ((resp or {}).get("data") or {}).get("rows") or []
|
|
205
|
+
rows = [_row_to_tuple(r) for r in json_rows]
|
|
206
|
+
if not rows:
|
|
207
|
+
print(
|
|
208
|
+
f"[author_history_block] EMPTY platform={plat} "
|
|
209
|
+
f"author={norm} days={days} limit={limit}",
|
|
210
|
+
file=sys.stderr,
|
|
211
|
+
)
|
|
212
|
+
return rows
|
|
213
|
+
# Compute compact summary: latest + oldest date + project, total likes
|
|
214
|
+
# received on prior comments, count of prior threads that got a reply.
|
|
215
|
+
# These give a one-line "what got injected" preview without dumping
|
|
216
|
+
# the full block to the log.
|
|
217
|
+
latest = rows[0]
|
|
218
|
+
oldest = rows[-1]
|
|
219
|
+
latest_date = latest[1].date().isoformat() if latest[1] else "?"
|
|
220
|
+
oldest_date = oldest[1].date().isoformat() if oldest[1] else "?"
|
|
221
|
+
latest_proj = latest[2] or "?"
|
|
222
|
+
total_likes = sum((r[5] or 0) for r in rows)
|
|
223
|
+
n_with_their_reply = sum(1 for r in rows if r[8])
|
|
224
|
+
print(
|
|
225
|
+
f"[author_history_block] INJECTED platform={plat} "
|
|
226
|
+
f"author={norm} rows={len(rows)} days={days} "
|
|
227
|
+
f"latest={latest_date}({latest_proj}) oldest={oldest_date} "
|
|
228
|
+
f"likes_total={total_likes} they_replied={n_with_their_reply}",
|
|
229
|
+
file=sys.stderr,
|
|
230
|
+
)
|
|
231
|
+
return rows
|
|
232
|
+
except Exception as e:
|
|
233
|
+
print(
|
|
234
|
+
f"[author_history_block] ERROR platform={plat} author={norm} "
|
|
235
|
+
f"error={e!r}",
|
|
236
|
+
file=sys.stderr,
|
|
237
|
+
)
|
|
238
|
+
return []
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _truncate(s, n):
|
|
242
|
+
s = (s or "").replace("\n", " ").replace("\r", " ").strip()
|
|
243
|
+
if len(s) <= n:
|
|
244
|
+
return s
|
|
245
|
+
return s[: n - 3].rstrip() + "..."
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def format_block(rows, author, platform, days):
|
|
249
|
+
"""Render the per-candidate prompt block. Returns '' when rows is empty."""
|
|
250
|
+
if not rows:
|
|
251
|
+
return ""
|
|
252
|
+
plat = PLATFORM_ALIAS.get(str(platform).lower(), str(platform).lower())
|
|
253
|
+
norm = _normalize(author)
|
|
254
|
+
# Display: keep author's natural form on platforms where the handle IS the
|
|
255
|
+
# name (linkedin/moltbook). Use the canonical @/u/ prefix on platforms
|
|
256
|
+
# where users recognize it that way.
|
|
257
|
+
if plat == "twitter":
|
|
258
|
+
handle_disp = "@" + norm
|
|
259
|
+
elif plat == "reddit":
|
|
260
|
+
handle_disp = "u/" + norm
|
|
261
|
+
elif plat == "linkedin":
|
|
262
|
+
handle_disp = str(author).strip()
|
|
263
|
+
else:
|
|
264
|
+
handle_disp = norm
|
|
265
|
+
|
|
266
|
+
header = (
|
|
267
|
+
f"PRIOR INTERACTIONS WITH {handle_disp} "
|
|
268
|
+
f"(our last {len(rows)} comments to this author, "
|
|
269
|
+
f"window={days}d, latest first):"
|
|
270
|
+
)
|
|
271
|
+
lines = [header]
|
|
272
|
+
# Load active campaign suffixes ONCE per format_block call so we strip
|
|
273
|
+
# them off `our_content` BEFORE truncation. Short Twitter replies
|
|
274
|
+
# (≤140 chars total) would otherwise show the suffix verbatim in the
|
|
275
|
+
# exemplar, training the LLM to echo it; the tool layer then appends
|
|
276
|
+
# a second copy. See feedback_suffix_injection_gating.md.
|
|
277
|
+
suffix_strip_list = _load_active_campaign_suffixes()
|
|
278
|
+
for row in rows:
|
|
279
|
+
(
|
|
280
|
+
_id,
|
|
281
|
+
posted_at,
|
|
282
|
+
project,
|
|
283
|
+
our_content,
|
|
284
|
+
_thread_title,
|
|
285
|
+
upvotes,
|
|
286
|
+
replies_count,
|
|
287
|
+
views,
|
|
288
|
+
their_first_reply,
|
|
289
|
+
) = row
|
|
290
|
+
date = posted_at.date().isoformat() if posted_at else "?"
|
|
291
|
+
proj = project or "?"
|
|
292
|
+
our_content_clean = _strip_active_campaign_suffixes(
|
|
293
|
+
our_content, suffix_strip_list
|
|
294
|
+
)
|
|
295
|
+
ours = _truncate(our_content_clean, 140)
|
|
296
|
+
eng_bits = []
|
|
297
|
+
if upvotes:
|
|
298
|
+
eng_bits.append(f"likes={upvotes}")
|
|
299
|
+
if replies_count:
|
|
300
|
+
eng_bits.append(f"replies={replies_count}")
|
|
301
|
+
if views:
|
|
302
|
+
eng_bits.append(f"views={views}")
|
|
303
|
+
eng_str = (" [" + ", ".join(eng_bits) + "]") if eng_bits else " [no engagement]"
|
|
304
|
+
lines.append(f"- {date} ({proj}): \"{ours}\"{eng_str}")
|
|
305
|
+
if their_first_reply:
|
|
306
|
+
tr = _truncate(their_first_reply, 110)
|
|
307
|
+
lines.append(f" -> they replied: \"{tr}\"")
|
|
308
|
+
lines.append(
|
|
309
|
+
"Use as SOFT CONTEXT only: vary angle, avoid repeating phrasing or "
|
|
310
|
+
"anecdotes. Do NOT over-reference (never write 'as I said before'). "
|
|
311
|
+
"If our prior take got pushback, soften; if it landed well, keep the voice."
|
|
312
|
+
)
|
|
313
|
+
if plat == "linkedin":
|
|
314
|
+
lines.append(
|
|
315
|
+
"(LinkedIn caveat: matched on display name. If the candidate's "
|
|
316
|
+
"account looks unrelated to the prior posts, ignore this block.)"
|
|
317
|
+
)
|
|
318
|
+
return "\n".join(lines)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def render(platform, author, days=30, limit=5):
|
|
322
|
+
"""Convenience for Python callers: returns the block string (possibly empty)."""
|
|
323
|
+
rows = fetch(platform, author, days=days, limit=limit)
|
|
324
|
+
return format_block(rows, author, platform, days)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def main():
|
|
328
|
+
p = argparse.ArgumentParser(
|
|
329
|
+
description="Print prior-interaction context for a target author."
|
|
330
|
+
)
|
|
331
|
+
p.add_argument(
|
|
332
|
+
"--platform",
|
|
333
|
+
required=True,
|
|
334
|
+
help="twitter | reddit | linkedin | github | moltbook (aliases: x, github_issues)",
|
|
335
|
+
)
|
|
336
|
+
p.add_argument(
|
|
337
|
+
"--author",
|
|
338
|
+
required=True,
|
|
339
|
+
help="Target author's handle (any case, leading @ or u/ tolerated)",
|
|
340
|
+
)
|
|
341
|
+
p.add_argument("--days", type=int, default=30, help="Look-back window (default 30)")
|
|
342
|
+
p.add_argument(
|
|
343
|
+
"--limit", type=int, default=5, help="Max interactions to include (default 5)"
|
|
344
|
+
)
|
|
345
|
+
args = p.parse_args()
|
|
346
|
+
|
|
347
|
+
block = render(args.platform, args.author, days=args.days, limit=args.limit)
|
|
348
|
+
if block:
|
|
349
|
+
print(block)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
if __name__ == "__main__":
|
|
353
|
+
main()
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Box-side autopilot stall watchdog (fleet backstop).
|
|
3
|
+
|
|
4
|
+
Fires a Sentry event when the draft autopilot's scheduled-task routines stop
|
|
5
|
+
draining the local job queue. The most common cause is the user logging Claude
|
|
6
|
+
Desktop into a DIFFERENT account, which leaves the two queue-worker routines
|
|
7
|
+
(saps-phase1-query / saps-phase2b-draft) registered only under the OLD account's
|
|
8
|
+
session, so nothing claims the jobs the pipeline enqueues. The routines' SKILL.md
|
|
9
|
+
files live in a GLOBAL dir and survive the switch, so the old "is the SKILL.md on
|
|
10
|
+
disk?" check stayed falsely green while drafting silently died for hours.
|
|
11
|
+
|
|
12
|
+
The menu bar already surfaces this to the user (title -> "S4L ⚠" + a "Re-arm
|
|
13
|
+
autopilot" item). This watcher is the part the user can't see: a fleet-side alert
|
|
14
|
+
so a sustained stall pages us even when nobody is looking at the menu bar.
|
|
15
|
+
|
|
16
|
+
Design mirrors the stall signal in mcp/menubar/s4l_menubar.py (_autopilot_stalled)
|
|
17
|
+
and mcp/src/index.ts (autopilotStalled) — keep the threshold in sync:
|
|
18
|
+
stalled = the autopilot is configured (both worker SKILL.md files present)
|
|
19
|
+
AND a draft job has sat unclaimed in pending/ past STALL_SECONDS.
|
|
20
|
+
False-positive free: an idle queue (no candidates) has no pending job at all, so
|
|
21
|
+
a quiet pipeline never trips this.
|
|
22
|
+
|
|
23
|
+
Idempotency: only ONE Sentry event per stall episode, and only after the stall
|
|
24
|
+
has persisted ALERT_AFTER consecutive checks (so a single slow claim during a
|
|
25
|
+
restart doesn't page). State lives in <queue>/stall-watch.json; reset when the
|
|
26
|
+
stall clears.
|
|
27
|
+
|
|
28
|
+
Runs as launchd com.m13v.social-autopilot-stall-watch (StartInterval 120) off the
|
|
29
|
+
owned venv (needs sentry-sdk + scripts/ on sys.path via S4L_REPO_DIR). Stdlib
|
|
30
|
+
otherwise. Best-effort: never raises into launchd.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import glob
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
import sys
|
|
39
|
+
import time
|
|
40
|
+
|
|
41
|
+
# SAPS_->S4L_ env mirror (brand rename 2026-07-03): old launchd plists and
|
|
42
|
+
# scheduled-task prompts still export SAPS_*; this process reads S4L_*.
|
|
43
|
+
import s4l_env # noqa: E402 (lives next to this file in scripts/)
|
|
44
|
+
|
|
45
|
+
s4l_env.mirror()
|
|
46
|
+
|
|
47
|
+
# Keep in sync with AUTOPILOT_STALL_SECONDS (menubar) / AUTOPILOT_STALL_MS (index.ts).
|
|
48
|
+
STALL_SECONDS = 180
|
|
49
|
+
# A job CLAIMED but never finished (sits in running/ this long) means a worker
|
|
50
|
+
# picked it up and then died mid-run — the claude -p drafting child never came up
|
|
51
|
+
# or crashed. Must be generous enough to clear the longest real drafting turn so a
|
|
52
|
+
# healthy run never trips it. Keep in sync with AUTOPILOT_RUNNING_STALL_SECONDS
|
|
53
|
+
# (menubar). See _oldest_running_age.
|
|
54
|
+
RUNNING_STALL_SECONDS = 900
|
|
55
|
+
# Require the stall to persist this many consecutive checks before paging, so a
|
|
56
|
+
# transient slow claim (e.g. right after a Claude restart) doesn't false-alarm.
|
|
57
|
+
# At StartInterval 120 that is ~6 min of continuous stall.
|
|
58
|
+
ALERT_AFTER = 3
|
|
59
|
+
|
|
60
|
+
WORKER_TASK_IDS = ("saps-phase1-query", "saps-phase2b-draft")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _state_dir() -> str:
|
|
64
|
+
return os.environ.get("S4L_STATE_DIR") or os.path.join(
|
|
65
|
+
os.path.expanduser("~"), ".social-autoposter-mcp"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _queue_root() -> str:
|
|
70
|
+
return os.path.join(_state_dir(), "claude-queue")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _watch_state_path() -> str:
|
|
74
|
+
return os.path.join(_queue_root(), "stall-watch.json")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _claude_config_dir() -> str:
|
|
78
|
+
return os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join(
|
|
79
|
+
os.path.expanduser("~"), ".claude"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _autopilot_configured() -> bool:
|
|
84
|
+
"""Both worker routines have their SKILL.md on disk = the autopilot was set up
|
|
85
|
+
here (so 'no drafts draining' is a real stall, not just unfinished setup)."""
|
|
86
|
+
base = os.path.join(_claude_config_dir(), "scheduled-tasks")
|
|
87
|
+
return all(
|
|
88
|
+
os.path.exists(os.path.join(base, tid, "SKILL.md")) for tid in WORKER_TASK_IDS
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _consecutive_timeouts() -> int:
|
|
93
|
+
"""The producer's LATCHED stall count: consecutive enqueue->timeout cycles with
|
|
94
|
+
no drain since. Persists across the between-cycle gap, so it's the durable
|
|
95
|
+
signal (the pending file is gone between cycles). Cleared on any successful
|
|
96
|
+
drain. See claude_job.py::drain_status_path."""
|
|
97
|
+
try:
|
|
98
|
+
with open(os.path.join(_queue_root(), "drain-status.json")) as f:
|
|
99
|
+
return int((json.load(f) or {}).get("consecutive_timeouts", 0) or 0)
|
|
100
|
+
except Exception:
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _recent_rate_limit(window: int = 1200) -> bool:
|
|
105
|
+
"""True if a worker run in the last `window` seconds hit the Claude weekly/usage
|
|
106
|
+
limit. That stall is EXPECTED and auto-resets, so it must NOT page Sentry —
|
|
107
|
+
paging would be pure noise. Reads the ~/.s4l-worker transcript bucket."""
|
|
108
|
+
try:
|
|
109
|
+
now = time.time()
|
|
110
|
+
files = glob.glob(
|
|
111
|
+
os.path.expanduser("~/.claude/projects/*s4l-worker*/*.jsonl")
|
|
112
|
+
)
|
|
113
|
+
recent = sorted(
|
|
114
|
+
(f for f in files if (now - os.path.getmtime(f)) <= window),
|
|
115
|
+
key=os.path.getmtime,
|
|
116
|
+
reverse=True,
|
|
117
|
+
)[:5]
|
|
118
|
+
for f in recent:
|
|
119
|
+
try:
|
|
120
|
+
low = open(f).read().lower()
|
|
121
|
+
except Exception:
|
|
122
|
+
continue
|
|
123
|
+
if "weekly limit" in low or "usage limit" in low or "hit your limit" in low:
|
|
124
|
+
return True
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _oldest_pending_age() -> float | None:
|
|
131
|
+
"""Seconds since the oldest unclaimed pending draft job was written, or None
|
|
132
|
+
if nothing is pending (idle queue). The FAST signal: catches a fresh stall
|
|
133
|
+
before the first full producer timeout has latched."""
|
|
134
|
+
pend_root = os.path.join(_queue_root(), "pending")
|
|
135
|
+
oldest = None
|
|
136
|
+
for sub in glob.glob(os.path.join(pend_root, "*")):
|
|
137
|
+
for jf in glob.glob(os.path.join(sub, "*.json")):
|
|
138
|
+
if jf.endswith(".tmp"):
|
|
139
|
+
continue
|
|
140
|
+
try:
|
|
141
|
+
m = os.path.getmtime(jf)
|
|
142
|
+
except OSError:
|
|
143
|
+
continue
|
|
144
|
+
if oldest is None or m < oldest:
|
|
145
|
+
oldest = m
|
|
146
|
+
if oldest is None:
|
|
147
|
+
return None
|
|
148
|
+
return time.time() - oldest
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _oldest_running_age() -> float | None:
|
|
152
|
+
"""Seconds since the oldest CLAIMED-but-unfinished job was written, or None if
|
|
153
|
+
nothing is in flight. A worker claims by moving a job pending/ -> running/ and
|
|
154
|
+
only removes it on result, so a job lingering in running/ far past any real
|
|
155
|
+
drafting turn means the worker claimed it and then wedged mid-run (dead/never-
|
|
156
|
+
spawned claude -p child). This is the ONLY signal for that case: pending-age is
|
|
157
|
+
silent (the job left pending/) and the producer's drain latch hasn't fired yet
|
|
158
|
+
(it's still inside its own timeout). running/ is flat (see claude_job.py)."""
|
|
159
|
+
run_root = os.path.join(_queue_root(), "running")
|
|
160
|
+
oldest = None
|
|
161
|
+
for jf in glob.glob(os.path.join(run_root, "*.json")):
|
|
162
|
+
if jf.endswith(".tmp"):
|
|
163
|
+
continue
|
|
164
|
+
try:
|
|
165
|
+
m = os.path.getmtime(jf)
|
|
166
|
+
except OSError:
|
|
167
|
+
continue
|
|
168
|
+
if oldest is None or m < oldest:
|
|
169
|
+
oldest = m
|
|
170
|
+
if oldest is None:
|
|
171
|
+
return None
|
|
172
|
+
return time.time() - oldest
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _read_state() -> dict:
|
|
176
|
+
try:
|
|
177
|
+
with open(_watch_state_path()) as f:
|
|
178
|
+
return json.load(f)
|
|
179
|
+
except Exception:
|
|
180
|
+
return {}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _write_state(obj: dict) -> None:
|
|
184
|
+
try:
|
|
185
|
+
os.makedirs(_queue_root(), exist_ok=True)
|
|
186
|
+
tmp = f"{_watch_state_path()}.tmp.{os.getpid()}"
|
|
187
|
+
with open(tmp, "w") as f:
|
|
188
|
+
json.dump(obj, f)
|
|
189
|
+
os.replace(tmp, _watch_state_path())
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _sentry():
|
|
195
|
+
"""Import the pipeline's Sentry helper (S4L_REPO_DIR/scripts on path)."""
|
|
196
|
+
repo = os.environ.get("S4L_REPO_DIR")
|
|
197
|
+
if repo:
|
|
198
|
+
scripts = os.path.join(repo, "scripts")
|
|
199
|
+
if scripts not in sys.path:
|
|
200
|
+
sys.path.insert(0, scripts)
|
|
201
|
+
import sentry_init # noqa: E402
|
|
202
|
+
|
|
203
|
+
return sentry_init
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def main() -> int:
|
|
207
|
+
age = _oldest_pending_age()
|
|
208
|
+
run_age = _oldest_running_age()
|
|
209
|
+
timeouts = _consecutive_timeouts()
|
|
210
|
+
# Three complementary signals, OR'd; all gated on the autopilot actually being
|
|
211
|
+
# configured here. (1) durable producer drain latch, (2) fast pending-age (job
|
|
212
|
+
# never claimed), (3) running-age (job claimed then wedged mid-run). (3) is the
|
|
213
|
+
# only one that catches a worker dying after it picked up the job.
|
|
214
|
+
stalled = _autopilot_configured() and (
|
|
215
|
+
timeouts >= 1
|
|
216
|
+
or (age is not None and age > STALL_SECONDS)
|
|
217
|
+
or (run_age is not None and run_age > RUNNING_STALL_SECONDS)
|
|
218
|
+
)
|
|
219
|
+
# A rate-limit stall is expected and self-heals at the quota reset — never page
|
|
220
|
+
# for it (and re-arm can't fix it). Treat it as "not an actionable stall" so the
|
|
221
|
+
# episode resets and a LATER real stall (orphaned routines) still alerts.
|
|
222
|
+
if stalled and _recent_rate_limit():
|
|
223
|
+
stalled = False
|
|
224
|
+
|
|
225
|
+
st = _read_state()
|
|
226
|
+
consecutive = int(st.get("consecutive", 0))
|
|
227
|
+
alerted = bool(st.get("alerted", False))
|
|
228
|
+
|
|
229
|
+
if not stalled:
|
|
230
|
+
# Recovered (or never stalled) -> reset the episode so the next stall pages.
|
|
231
|
+
if consecutive or alerted:
|
|
232
|
+
_write_state({"consecutive": 0, "alerted": False})
|
|
233
|
+
return 0
|
|
234
|
+
|
|
235
|
+
consecutive += 1
|
|
236
|
+
age_str = f"{int(age)}s" if age is not None else "n/a (between cycles)"
|
|
237
|
+
run_age_str = f"{int(run_age)}s" if run_age is not None else "n/a (none in flight)"
|
|
238
|
+
# Distinguish the two shapes so the alert points at the right cause: a claimed-
|
|
239
|
+
# but-wedged job (running-age) is a mid-run worker death, not an orphaned routine.
|
|
240
|
+
wedged_inflight = run_age is not None and run_age > RUNNING_STALL_SECONDS
|
|
241
|
+
if consecutive >= ALERT_AFTER and not alerted:
|
|
242
|
+
try:
|
|
243
|
+
sentry = _sentry()
|
|
244
|
+
sentry.init()
|
|
245
|
+
cause = (
|
|
246
|
+
"a worker claimed a draft job and then died mid-run (claude -p child "
|
|
247
|
+
"never came up / crashed)"
|
|
248
|
+
if wedged_inflight
|
|
249
|
+
else "scheduled-task routines likely orphaned — Claude Desktop account change?"
|
|
250
|
+
)
|
|
251
|
+
sentry.capture_message(
|
|
252
|
+
"social-autoposter autopilot stalled: draft jobs are not being "
|
|
253
|
+
f"drained ({cause}). producer consecutive timeouts={timeouts}, "
|
|
254
|
+
f"oldest pending job age={age_str}, oldest in-flight (running) job "
|
|
255
|
+
f"age={run_age_str}, sustained {consecutive} checks.",
|
|
256
|
+
level="error",
|
|
257
|
+
tags={
|
|
258
|
+
"component": "autopilot",
|
|
259
|
+
"issue": "stall",
|
|
260
|
+
"stall_shape": "inflight_wedged" if wedged_inflight else "not_draining",
|
|
261
|
+
"consecutive_timeouts": str(timeouts),
|
|
262
|
+
"oldest_pending_age_s": str(int(age)) if age is not None else "",
|
|
263
|
+
"oldest_running_age_s": str(int(run_age)) if run_age is not None else "",
|
|
264
|
+
},
|
|
265
|
+
)
|
|
266
|
+
sentry.flush()
|
|
267
|
+
except Exception:
|
|
268
|
+
# No Sentry (helper/SDK missing) -> at least leave a local breadcrumb.
|
|
269
|
+
sys.stderr.write(
|
|
270
|
+
f"[stall-watch] autopilot stalled (timeouts={timeouts}, "
|
|
271
|
+
f"pending_age={age_str}, running_age={run_age_str}) but Sentry report failed\n"
|
|
272
|
+
)
|
|
273
|
+
alerted = True
|
|
274
|
+
|
|
275
|
+
_write_state({"consecutive": consecutive, "alerted": alerted, "at": time.time()})
|
|
276
|
+
return 0
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
if __name__ == "__main__":
|
|
280
|
+
try:
|
|
281
|
+
sys.exit(main())
|
|
282
|
+
except Exception as e: # never let launchd see a non-zero/crash loop
|
|
283
|
+
sys.stderr.write(f"[stall-watch] unexpected error: {e}\n")
|
|
284
|
+
sys.exit(0)
|