@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,505 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Social Autoposter - Reddit comment posting via search API + CDP browser
|
|
3
|
+
#
|
|
4
|
+
# Two-lane single-pass cycle (post 2026-05-07 refactor):
|
|
5
|
+
#
|
|
6
|
+
# SALVAGE LANE (already-vetted retries, skip ripen):
|
|
7
|
+
# Phase 0 → Salvage pull → Salvage draft → Salvage post
|
|
8
|
+
#
|
|
9
|
+
# DISCOVER LANE (fresh threads, full ripen gate):
|
|
10
|
+
# Discover → Ripen (30-min delta gate, floor>=1) → Discover draft → Discover post
|
|
11
|
+
#
|
|
12
|
+
# Both lanes run every cycle. Salvage rows skip ripen because they were
|
|
13
|
+
# already ripened in a prior cycle (either CDP-failed mid-post or already
|
|
14
|
+
# delta-validated); re-ripening burns 10 min of wall-clock for no signal.
|
|
15
|
+
# Salvage posts FIRST so the browser lock releases before the 10-min ripen
|
|
16
|
+
# sleep, letting peer agents use the browser during the wait.
|
|
17
|
+
#
|
|
18
|
+
# Browser lock is held PER ROW inside post_reddit.py's `_post_iteration`
|
|
19
|
+
# (acquire just before `post_via_cdp`, release in finally right after). The
|
|
20
|
+
# pre-flight at the top of this script does a one-shot ensure_browser_healthy
|
|
21
|
+
# (orphan-Chrome sweep, Singleton-lock clear) under a brief 30s lease so the
|
|
22
|
+
# rest of the cycle can rely on a clean profile. Migrated 2026-05-13 from the
|
|
23
|
+
# previous design that held the lease around the whole `--phase post` call —
|
|
24
|
+
# that monopolised the browser for the full batch (~30 min for 10 rows) while
|
|
25
|
+
# peer reddit pipelines sat blocked through every 3-min between-post sleep.
|
|
26
|
+
#
|
|
27
|
+
# Called by launchd every 15 minutes via run-reddit-search-launchd.sh.
|
|
28
|
+
|
|
29
|
+
set -euo pipefail
|
|
30
|
+
|
|
31
|
+
# SAPS_->S4L_ env mirror (brand rename 2026-07-03): old plists/tasks still
|
|
32
|
+
# export SAPS_*; new code reads S4L_*. Copy names, never values via eval.
|
|
33
|
+
while IFS='=' read -r _k _; do
|
|
34
|
+
case "$_k" in SAPS_*) _n="S4L_${_k#SAPS_}"; eval "[ -n \"\${$_n+x}\" ] || export $_n=\"\${$_k}\"";; esac
|
|
35
|
+
done <<EOF_ENV
|
|
36
|
+
$(env | grep '^SAPS_' | cut -d= -f1 | sed 's/$/=/')
|
|
37
|
+
EOF_ENV
|
|
38
|
+
|
|
39
|
+
[ -f "$HOME/social-autoposter/.env" ] && source "$HOME/social-autoposter/.env"
|
|
40
|
+
|
|
41
|
+
REPO_DIR="$HOME/social-autoposter"
|
|
42
|
+
LOG_DIR="$REPO_DIR/skill/logs"
|
|
43
|
+
mkdir -p "$LOG_DIR"
|
|
44
|
+
LOG_FILE="$LOG_DIR/run-reddit-search-$(date +%Y-%m-%d_%H%M%S).log"
|
|
45
|
+
|
|
46
|
+
log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
|
|
47
|
+
|
|
48
|
+
log "=== Reddit Search Post Run: $(date) ==="
|
|
49
|
+
|
|
50
|
+
source "$REPO_DIR/skill/lock.sh"
|
|
51
|
+
|
|
52
|
+
# Reddit-harness backend (2026-05-29 migration): Reddit started 403ing urllib/
|
|
53
|
+
# curl on *.json from residential IPs, so the whole Reddit pipeline now rides a
|
|
54
|
+
# dedicated browser-harness Chrome on port 9557 (profile reddit-harness),
|
|
55
|
+
# mirroring twitter-harness. Sourcing this exports REDDIT_CDP_URL so every child
|
|
56
|
+
# Python proc (reddit_tools.py discovery fetch, reddit_browser.py posting) attaches
|
|
57
|
+
# directly to the harness instead of ps-scanning for the reddit-agent MCP Chrome.
|
|
58
|
+
source "$REPO_DIR/skill/lib/reddit-backend.sh"
|
|
59
|
+
|
|
60
|
+
LIMIT=10
|
|
61
|
+
EXCLUDE=""
|
|
62
|
+
TOTAL_POSTED=0
|
|
63
|
+
TOTAL_FAILED=0
|
|
64
|
+
TOTAL_SKIPPED=0
|
|
65
|
+
TOTAL_SALVAGED=0 # actual salvaged decisions (rows pulled + drafted/posted) this cycle
|
|
66
|
+
TOTAL_CANDIDATES=0 # total reddit_candidates rows touched (discovered + salvaged)
|
|
67
|
+
RUN_START=$(date +%s)
|
|
68
|
+
FAILURE_REASONS=""
|
|
69
|
+
|
|
70
|
+
# Helper: parse `posted=N failed=M` from post-phase stdout. Returns "posted failed"
|
|
71
|
+
# on stdout. Caller MUST do the TOTAL_* accumulation in the parent shell;
|
|
72
|
+
# previously this function tried to mutate TOTAL_POSTED/TOTAL_FAILED itself,
|
|
73
|
+
# but bash's $() captures spawn a subshell where mutations to the parent's
|
|
74
|
+
# variables are silently lost. The 21:01 salvage lane really posted 4 to DB
|
|
75
|
+
# but run_monitor.log showed posted=0 because of this bug. (Fixed 2026-05-07.)
|
|
76
|
+
_parse_post_results() {
|
|
77
|
+
local out="$1"
|
|
78
|
+
local rc="$2"
|
|
79
|
+
if [ "$rc" = "0" ]; then
|
|
80
|
+
local posted failed
|
|
81
|
+
posted=$(echo "$out" | grep -oE 'posted=[0-9]+' | tail -1 | cut -d= -f2 || echo 0)
|
|
82
|
+
failed=$(echo "$out" | grep -oE 'failed=[0-9]+' | tail -1 | cut -d= -f2 || echo 0)
|
|
83
|
+
echo "${posted:-0} ${failed:-0}"
|
|
84
|
+
else
|
|
85
|
+
# CRITICAL: write directly to LOG_FILE, NEVER to stdout. This function's
|
|
86
|
+
# stdout is captured by $(...) in the caller's `read SALVAGE_POSTED ...`,
|
|
87
|
+
# so any stray timestamp here corrupts arithmetic at TOTAL_POSTED += $X.
|
|
88
|
+
# Bug observed 2026-05-08: a leaked "[14:57:04] Post phase: ..." line
|
|
89
|
+
# produced "TOTAL_POSTED + [14:57:04]: syntax error" and `set -e` aborted
|
|
90
|
+
# the script BEFORE the discover lane ran.
|
|
91
|
+
echo "[$(date +%H:%M:%S)] Post phase: exit code $rc; counting as failed." >> "$LOG_FILE"
|
|
92
|
+
echo "0 1"
|
|
93
|
+
fi
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# Helper: parse CDP failure reasons from post-phase stdout and accumulate
|
|
97
|
+
# into FAILURE_REASONS (Twitter pipeline schema). Mirrored across both lanes.
|
|
98
|
+
_accumulate_cdp_reasons() {
|
|
99
|
+
local out="$1"
|
|
100
|
+
while IFS= read -r line; do
|
|
101
|
+
local cdp_key
|
|
102
|
+
cdp_key=$(echo "$line" | grep -oE '\[post_reddit\] CDP FAILED: [a-z_]+' | awk '{print $NF}')
|
|
103
|
+
case "$cdp_key" in
|
|
104
|
+
thread_locked) add_reason reddit_locked ;;
|
|
105
|
+
thread_archived) add_reason reddit_archived ;;
|
|
106
|
+
thread_not_found) add_reason reddit_deleted ;;
|
|
107
|
+
account_blocked_in_sub) add_reason account_blocked ;;
|
|
108
|
+
not_logged_in) add_reason reddit_logged_out ;;
|
|
109
|
+
all_attempts_failed) add_reason cdp_no_response ;;
|
|
110
|
+
comment_box_not_found) add_reason comment_box_missing ;;
|
|
111
|
+
"") : ;;
|
|
112
|
+
*) add_reason "cdp_${cdp_key}" ;;
|
|
113
|
+
esac
|
|
114
|
+
done <<< "$out"
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# Idempotent run_monitor.log emitter wired to EXIT/INT/TERM/HUP. Without this,
|
|
118
|
+
# a SIGTERM landing between the post phase (where post_reddit.py has already
|
|
119
|
+
# committed to the `posts` table via log_post) and the historical inline
|
|
120
|
+
# log_run.py call at the bottom of the script silently drops the run from
|
|
121
|
+
# run_monitor.log. The dashboard reads run_monitor.log, so the operator-
|
|
122
|
+
# visible "last post_reddit cycle" stays stuck on a stale entry while real
|
|
123
|
+
# posts continue landing in the DB unrecorded. Concretely: in one observed
|
|
124
|
+
# 4-cycle window, three of four 15-min cycles SIGTERMed mid-post and the
|
|
125
|
+
# dashboard surfaced none of the two posts (r/aiToolForBusiness,
|
|
126
|
+
# r/SideProject) that DID land in `posts`.
|
|
127
|
+
#
|
|
128
|
+
# Mechanism:
|
|
129
|
+
# - The function reads the cycle's accumulator globals (TOTAL_*,
|
|
130
|
+
# FAILURE_REASONS, RUN_START) and shells out to scripts/log_run.py with
|
|
131
|
+
# the same arg shape the historical inline call used.
|
|
132
|
+
# - _SA_RUN_SUMMARY_EMITTED guards against double-write: the happy path
|
|
133
|
+
# calls the function explicitly once at the bottom (so cost can be
|
|
134
|
+
# computed without the trap's 10s timeout), and the trap fires on EXIT
|
|
135
|
+
# to catch SIGTERM/error paths. The flag makes either order a no-op
|
|
136
|
+
# after first emission.
|
|
137
|
+
# - On SIGTERM the get_run_cost.py call is wrapped in `timeout 10` so a
|
|
138
|
+
# hung Postgres query doesn't wedge the trap; cost falls back to 0.0000.
|
|
139
|
+
#
|
|
140
|
+
# Trap chaining: lock.sh sourced above already installed `_sa_release_locks`
|
|
141
|
+
# on EXIT INT TERM HUP. Bash trap REPLACES, not appends, so we re-set with
|
|
142
|
+
# both handlers explicitly. Order matters: emit summary first (it shells
|
|
143
|
+
# out, harmless if locks are still held), then release locks. _sa_release_locks
|
|
144
|
+
# is defined by lock.sh and stays in scope after sourcing.
|
|
145
|
+
_SA_RUN_SUMMARY_EMITTED=0
|
|
146
|
+
_SA_PRECOMPUTED_COST=""
|
|
147
|
+
_sa_emit_run_summary_oneshot() {
|
|
148
|
+
[ "${_SA_RUN_SUMMARY_EMITTED:-0}" = "1" ] && return 0
|
|
149
|
+
_SA_RUN_SUMMARY_EMITTED=1
|
|
150
|
+
local elapsed cost
|
|
151
|
+
elapsed=$(( $(date +%s) - ${RUN_START:-$(date +%s)} ))
|
|
152
|
+
if [ -n "${_SA_PRECOMPUTED_COST:-}" ]; then
|
|
153
|
+
cost="$_SA_PRECOMPUTED_COST"
|
|
154
|
+
else
|
|
155
|
+
# Prefer cycle_id when BATCH_ID is set (after Phase 0). Falls back to
|
|
156
|
+
# the legacy since+scripts query if the trap fires before BATCH_ID was
|
|
157
|
+
# initialised (very early SIGTERM, e.g. from a stale .env source).
|
|
158
|
+
if [ -n "${BATCH_ID:-}" ]; then
|
|
159
|
+
cost=$(timeout 10 python3 "$REPO_DIR/scripts/get_run_cost.py" \
|
|
160
|
+
--cycle-id "$BATCH_ID" 2>/dev/null || echo "0.0000")
|
|
161
|
+
else
|
|
162
|
+
cost=$(timeout 10 python3 "$REPO_DIR/scripts/get_run_cost.py" \
|
|
163
|
+
--since "${RUN_START:-0}" \
|
|
164
|
+
--scripts "post_reddit" 2>/dev/null || echo "0.0000")
|
|
165
|
+
fi
|
|
166
|
+
fi
|
|
167
|
+
# Rescue Anthropic-side failures the per-phase add_reason cascade didn't
|
|
168
|
+
# catch (stream_idle_timeout, monthly_limit, api_overloaded,
|
|
169
|
+
# context_overflow, credit_balance, generic api_error). Scans the cycle
|
|
170
|
+
# log only when TOTAL_FAILED>0 AND FAILURE_REASONS is still empty — so
|
|
171
|
+
# the historical per-phase keys (reddit_locked, account_blocked, etc.)
|
|
172
|
+
# stay authoritative when they fired, and the classifier fills in the
|
|
173
|
+
# gap when Claude died before any phase emitted a reason.
|
|
174
|
+
if [ "${TOTAL_FAILED:-0}" -gt 0 ] && [ -z "${FAILURE_REASONS:-}" ] \
|
|
175
|
+
&& [ -n "${LOG_FILE:-}" ] && [ -f "${LOG_FILE:-}" ]; then
|
|
176
|
+
local api_reason
|
|
177
|
+
api_reason=$(python3 "$REPO_DIR/scripts/classify_run_error.py" "$LOG_FILE" 2>/dev/null)
|
|
178
|
+
[ -n "$api_reason" ] && FAILURE_REASONS="${api_reason}:1"
|
|
179
|
+
fi
|
|
180
|
+
local args
|
|
181
|
+
args=(--script "post_reddit" \
|
|
182
|
+
--posted "${TOTAL_POSTED:-0}" \
|
|
183
|
+
--skipped "${TOTAL_SKIPPED:-0}" \
|
|
184
|
+
--failed "${TOTAL_FAILED:-0}" \
|
|
185
|
+
--cost "$cost" \
|
|
186
|
+
--elapsed "$elapsed")
|
|
187
|
+
[ "${TOTAL_SALVAGED:-0}" -gt 0 ] && args+=(--salvaged "$TOTAL_SALVAGED")
|
|
188
|
+
[ "${TOTAL_CANDIDATES:-0}" -gt 0 ] && args+=(--candidates "$TOTAL_CANDIDATES")
|
|
189
|
+
[ -n "${FAILURE_REASONS:-}" ] && args+=(--failure-reasons "$FAILURE_REASONS")
|
|
190
|
+
python3 "$REPO_DIR/scripts/log_run.py" "${args[@]}" 2>/dev/null || true
|
|
191
|
+
}
|
|
192
|
+
_sa_release_lease_oneshot() {
|
|
193
|
+
# Belt-and-suspenders for SIGTERM/crash paths: free the reddit-browser
|
|
194
|
+
# lease in case post_reddit.py died mid-post and didn't get to the explicit
|
|
195
|
+
# release. Idempotent (NOT_HELD is fine). Safe to call even if we never
|
|
196
|
+
# acquired the lease this run. Bounded 3s so a hung helper can't stall the
|
|
197
|
+
# trap. Without this, a SIGTERM mid-post would leave the lease alive for
|
|
198
|
+
# ~90s before peers could steal it; with this, peers proceed within seconds.
|
|
199
|
+
timeout 3 python3 "$REPO_DIR/scripts/reddit_browser_lock.py" release 2>/dev/null || true
|
|
200
|
+
}
|
|
201
|
+
trap '_sa_emit_run_summary_oneshot; _sa_release_lease_oneshot; _sa_release_locks' EXIT INT TERM HUP
|
|
202
|
+
|
|
203
|
+
# Cycle-level batch_id, mirrors the Twitter cycle's twcycle-* convention.
|
|
204
|
+
# Used by --phase phase0 / --phase salvage / --phase discover to attribute
|
|
205
|
+
# rows in reddit_candidates and to drive the persistent retry queue.
|
|
206
|
+
BATCH_ID="rdcycle-$(date +%Y%m%d-%H%M%S)"
|
|
207
|
+
log "Cycle batch_id=$BATCH_ID"
|
|
208
|
+
|
|
209
|
+
# Export the same id as SA_CYCLE_ID so every Claude session spawned downstream
|
|
210
|
+
# (post_reddit.py → run_claude(), run_claude.sh → claude -p, log_claude_session.py)
|
|
211
|
+
# stamps claude_sessions.cycle_id with this cycle. Without this, concurrent
|
|
212
|
+
# overlapping cycles (double-fork wrapper added 2026-04-30 lets cycles stack)
|
|
213
|
+
# all share the same script tag 'post_reddit' and get_run_cost.py was summing
|
|
214
|
+
# costs across every cycle in the time window, producing absurd $150+ per-cycle
|
|
215
|
+
# reports (observed 2026-05-10: 11:00 cycle reported $166 when its own work
|
|
216
|
+
# was ~$32; the rest belonged to the 11:15/11:30/11:45 cycles that started
|
|
217
|
+
# during the same window). cycle_id makes per-cycle cost attribution accurate.
|
|
218
|
+
export SA_CYCLE_ID="$BATCH_ID"
|
|
219
|
+
|
|
220
|
+
# --- Pre-flight: orphan-Chrome sweep + singleton-lock clear, ONCE per cycle ---
|
|
221
|
+
# Lock strategy (migrated 2026-05-13): per-post acquire/release happens INSIDE
|
|
222
|
+
# post_reddit.py's `_post_iteration` for loop (around each `post_via_cdp` call),
|
|
223
|
+
# not here. Holding the lease around the whole `--phase post` invocation meant
|
|
224
|
+
# a 10-row salvage batch monopolised the browser for ~30 min (10 × ~45s post +
|
|
225
|
+
# 9 × 180s between-post sleep) while peer reddit pipelines (link-edit-reddit,
|
|
226
|
+
# dm-outreach-reddit, engage-reddit, engage-dm-replies-reddit) sat blocked.
|
|
227
|
+
# Mirrors the link-edit-reddit.sh / dm-outreach-reddit.sh pattern shipped
|
|
228
|
+
# 2026-05-08 → 2026-05-10.
|
|
229
|
+
#
|
|
230
|
+
# The brief acquire+ensure_browser_healthy+release below runs ONCE so
|
|
231
|
+
# ensure_browser_healthy's CDP probe / wait-for-orphan-exit / Singleton-lock
|
|
232
|
+
# clear happens before the cycle starts. ensure_browser_healthy is bash so we
|
|
233
|
+
# can't easily call it from Python; orphan-Chrome sweep ALSO runs inside the
|
|
234
|
+
# Python lock helper's acquire path (sweep_orphan_browser_processes), so the
|
|
235
|
+
# per-post lease still gets that protection. Pre-flight is best-effort: if
|
|
236
|
+
# acquire is BUSY (peer pipeline mid-post), warn and proceed; Python per-row
|
|
237
|
+
# acquire will retry inside the for loop.
|
|
238
|
+
log "Pre-flight: brief reddit-browser acquire + harness bootstrap + release..."
|
|
239
|
+
python3 "$REPO_DIR/scripts/reddit_browser_lock.py" acquire --timeout 60 --ttl 30 2>&1 | tee -a "$LOG_FILE" || \
|
|
240
|
+
log "WARNING: pre-flight acquire BUSY; harness bootstrap will run anyway; per-row acquires inside post_reddit.py will retry."
|
|
241
|
+
# reddit-harness bootstrap: probe + launch the dedicated harness Chrome on port
|
|
242
|
+
# 9557 (profile reddit-harness) if down, then clean leftover tabs. Replaces the
|
|
243
|
+
# old ensure_browser_healthy "reddit" ps-scan-for-MCP-Chrome path; the harness is
|
|
244
|
+
# the single browser the whole Reddit pipeline now rides (REDDIT_CDP_URL points
|
|
245
|
+
# every child Python proc at it). Falls back to ensure_browser_healthy on failure.
|
|
246
|
+
if ! ensure_reddit_browser_for_backend 2>&1 | tee -a "$LOG_FILE"; then
|
|
247
|
+
log "WARNING: reddit-harness bootstrap failed; falling back to ensure_browser_healthy reddit"
|
|
248
|
+
ensure_browser_healthy "reddit"
|
|
249
|
+
fi
|
|
250
|
+
python3 "$REPO_DIR/scripts/reddit_browser_lock.py" release 2>/dev/null || true
|
|
251
|
+
|
|
252
|
+
# --- Phase 0: hard-expire stale pending rows + salvage truly-orphaned rows ---
|
|
253
|
+
# Pending rows from prior cycles fall into two buckets:
|
|
254
|
+
# - discovered_at older than FRESHNESS_HOURS (24h) -> hard-expire
|
|
255
|
+
# - still-fresh AND attempt_count < MAX_ATTEMPTS (3) AND last_attempt_at
|
|
256
|
+
# older than RETRY_BACKOFF (30m) -> re-assign to this batch so the loop
|
|
257
|
+
# below can pull them via --phase salvage.
|
|
258
|
+
#
|
|
259
|
+
# Mirrors run-twitter-cycle.sh's Phase 0 in shape, but with Reddit-tuned
|
|
260
|
+
# windows (24h FRESHNESS vs Twitter 6h, since Reddit threads stay actionable
|
|
261
|
+
# longer). All the SQL lives in post_reddit.py:_db_phase0_salvage() under a
|
|
262
|
+
# pg_advisory_xact_lock so two concurrent Reddit cycles can't double-salvage.
|
|
263
|
+
#
|
|
264
|
+
# Output is `expired=N salvaged=M` on a single line; we parse it inline.
|
|
265
|
+
PHASE0_OUT=$(python3 "$REPO_DIR/scripts/post_reddit.py" --phase phase0 --batch-id "$BATCH_ID" 2>&1 | tee -a "$LOG_FILE" | tail -1)
|
|
266
|
+
PHASE0_EXPIRED=$(echo "$PHASE0_OUT" | grep -oE 'expired=[0-9]+' | cut -d= -f2 || echo 0)
|
|
267
|
+
PHASE0_SALVAGED=$(echo "$PHASE0_OUT" | grep -oE 'salvaged=[0-9]+' | cut -d= -f2 || echo 0)
|
|
268
|
+
[ "${PHASE0_EXPIRED:-0}" -gt 0 ] && log "Phase 0: hard-expired $PHASE0_EXPIRED pending rows older than 24h"
|
|
269
|
+
[ "${PHASE0_SALVAGED:-0}" -gt 0 ] && log "Phase 0: salvaged $PHASE0_SALVAGED orphaned pending rows into $BATCH_ID"
|
|
270
|
+
|
|
271
|
+
# Add a reason:count pair to FAILURE_REASONS (same schema as Twitter pipeline).
|
|
272
|
+
# Accumulates counts for duplicate keys (e.g. two thread_locked failures).
|
|
273
|
+
add_reason() {
|
|
274
|
+
local key="$1" count="${2:-1}"
|
|
275
|
+
# Extract existing count for this key and add to it
|
|
276
|
+
local existing
|
|
277
|
+
existing=$(echo "$FAILURE_REASONS" | tr ',' '\n' | grep "^${key}:" | cut -d: -f2 | head -1)
|
|
278
|
+
if [ -n "$existing" ]; then
|
|
279
|
+
local new_count=$(( existing + count ))
|
|
280
|
+
FAILURE_REASONS=$(echo "$FAILURE_REASONS" | tr ',' '\n' | grep -v "^${key}:" | tr '\n' ',' | sed 's/,$//;s/^,//')
|
|
281
|
+
FAILURE_REASONS="${FAILURE_REASONS:+$FAILURE_REASONS,}${key}:${new_count}"
|
|
282
|
+
else
|
|
283
|
+
FAILURE_REASONS="${FAILURE_REASONS:+$FAILURE_REASONS,}${key}:${count}"
|
|
284
|
+
fi
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
# =============================================================================
|
|
288
|
+
# SALVAGE LANE — already-vetted retries, skip ripen, post early
|
|
289
|
+
# =============================================================================
|
|
290
|
+
# Salvage rows were ripened (and survived, or CDP-failed mid-post) in a prior
|
|
291
|
+
# cycle. Re-ripening them now would burn 10 min of wall-clock for stale signal.
|
|
292
|
+
# Pull up to LIMIT rows from one project, draft any that lack a fresh persisted
|
|
293
|
+
# draft, then post. Lock is held briefly here so peers can use the browser
|
|
294
|
+
# during the discover lane's 10-min ripen sleep below.
|
|
295
|
+
SALVAGE_FILE=$(mktemp -t post_reddit_salvage.XXXXXX.json)
|
|
296
|
+
SALVAGE_DRAFT_FILE=$(mktemp -t post_reddit_salvage_draft.XXXXXX.json)
|
|
297
|
+
HAS_SALVAGE=0
|
|
298
|
+
SALVAGE_COUNT=0
|
|
299
|
+
|
|
300
|
+
set +e
|
|
301
|
+
python3 "$REPO_DIR/scripts/post_reddit.py" \
|
|
302
|
+
--phase salvage \
|
|
303
|
+
--batch-id "$BATCH_ID" \
|
|
304
|
+
--limit "$LIMIT" \
|
|
305
|
+
--out "$SALVAGE_FILE" 2>&1 | tee -a "$LOG_FILE"
|
|
306
|
+
SALVAGE_RC=${PIPESTATUS[0]}
|
|
307
|
+
set -e
|
|
308
|
+
|
|
309
|
+
if [ "$SALVAGE_RC" = "0" ]; then
|
|
310
|
+
SALVAGE_COUNT=$(python3 -c "import json;print(len(json.load(open('$SALVAGE_FILE')).get('decisions',[])))" 2>/dev/null || echo 1)
|
|
311
|
+
HAS_SALVAGE=1
|
|
312
|
+
TOTAL_SALVAGED=$((TOTAL_SALVAGED + ${SALVAGE_COUNT:-1}))
|
|
313
|
+
TOTAL_CANDIDATES=$((TOTAL_CANDIDATES + ${SALVAGE_COUNT:-1}))
|
|
314
|
+
log "Salvage lane: pulled $SALVAGE_COUNT candidate(s); skipping ripen."
|
|
315
|
+
else
|
|
316
|
+
log "Salvage lane: nothing to salvage this cycle (rc=$SALVAGE_RC)."
|
|
317
|
+
fi
|
|
318
|
+
|
|
319
|
+
# --- Salvage draft (no browser; skips rows with fresh persisted draft_text) ---
|
|
320
|
+
if [ "$HAS_SALVAGE" = "1" ]; then
|
|
321
|
+
log "Salvage lane: drafting $SALVAGE_COUNT candidate(s)..."
|
|
322
|
+
set +e
|
|
323
|
+
python3 "$REPO_DIR/scripts/post_reddit.py" \
|
|
324
|
+
--phase draft \
|
|
325
|
+
--in "$SALVAGE_FILE" \
|
|
326
|
+
--out "$SALVAGE_DRAFT_FILE" 2>&1 | tee -a "$LOG_FILE"
|
|
327
|
+
SALVAGE_DRAFT_RC=${PIPESTATUS[0]}
|
|
328
|
+
set -e
|
|
329
|
+
|
|
330
|
+
case "$SALVAGE_DRAFT_RC" in
|
|
331
|
+
0) : ;;
|
|
332
|
+
5) log "Salvage draft: Claude failed; skipping salvage post."; HAS_SALVAGE=0; TOTAL_FAILED=$((TOTAL_FAILED + ${SALVAGE_COUNT:-1})) ;;
|
|
333
|
+
6) log "Salvage draft: no drafted decisions; skipping salvage post."; HAS_SALVAGE=0; TOTAL_SKIPPED=$((TOTAL_SKIPPED + ${SALVAGE_COUNT:-1})) ;;
|
|
334
|
+
*) log "Salvage draft: rc=$SALVAGE_DRAFT_RC; skipping salvage post."; HAS_SALVAGE=0; TOTAL_FAILED=$((TOTAL_FAILED + ${SALVAGE_COUNT:-1})) ;;
|
|
335
|
+
esac
|
|
336
|
+
fi
|
|
337
|
+
|
|
338
|
+
# --- Salvage post (per-row lease handled inside post_reddit.py) ---
|
|
339
|
+
# Lock strategy (migrated 2026-05-13): the reddit-browser lease is now
|
|
340
|
+
# acquired/released PER ROW inside post_reddit.py's `_post_iteration` for
|
|
341
|
+
# loop, around each `post_via_cdp` call. We no longer hold the lease around
|
|
342
|
+
# the whole `--phase post` invocation — that monopolised the browser for the
|
|
343
|
+
# entire batch (~30 min for 10 rows) while peers sat blocked. The pre-flight
|
|
344
|
+
# at the top of this script already did the one-shot ensure_browser_healthy
|
|
345
|
+
# work; per-row acquires inside Python handle the rest.
|
|
346
|
+
if [ "$HAS_SALVAGE" = "1" ]; then
|
|
347
|
+
log "Salvage lane: posting $SALVAGE_COUNT candidate(s) (per-row reddit-browser lease)..."
|
|
348
|
+
|
|
349
|
+
# set +e covers the entire post + cleanup block. Discover lane MUST run
|
|
350
|
+
# every cycle (per design comment at line 263), so any failure in salvage
|
|
351
|
+
# cleanup (_parse_post_results contamination, arithmetic over malformed
|
|
352
|
+
# reads, _accumulate_cdp_reasons) must NOT abort the script.
|
|
353
|
+
# 2026-05-08 bug: cycle 16:38 finished salvage (posted=2) then died on a
|
|
354
|
+
# set -e trap mid-cleanup; discover lane never ran for the rest of the day.
|
|
355
|
+
set +e
|
|
356
|
+
SALVAGE_POST_OUT=$(python3 "$REPO_DIR/scripts/post_reddit.py" --phase post --in "$SALVAGE_DRAFT_FILE" 2>&1 | tee -a "$LOG_FILE")
|
|
357
|
+
SALVAGE_POST_RC=${PIPESTATUS[0]}
|
|
358
|
+
|
|
359
|
+
# Parse + accumulate in parent shell. $() spawns a subshell, so we must
|
|
360
|
+
# do the TOTAL_* increments AFTER capturing the helper's stdout.
|
|
361
|
+
read -r SALVAGE_POSTED SALVAGE_FAILED <<< "$(_parse_post_results "$SALVAGE_POST_OUT" "$SALVAGE_POST_RC")"
|
|
362
|
+
TOTAL_POSTED=$((TOTAL_POSTED + ${SALVAGE_POSTED:-0}))
|
|
363
|
+
TOTAL_FAILED=$((TOTAL_FAILED + ${SALVAGE_FAILED:-0}))
|
|
364
|
+
log "Salvage lane: posted=$SALVAGE_POSTED failed=$SALVAGE_FAILED"
|
|
365
|
+
_accumulate_cdp_reasons "$SALVAGE_POST_OUT"
|
|
366
|
+
set -e
|
|
367
|
+
fi
|
|
368
|
+
|
|
369
|
+
# =============================================================================
|
|
370
|
+
# DISCOVER LANE — fresh threads, full ripen gate
|
|
371
|
+
# =============================================================================
|
|
372
|
+
# Discover always runs every cycle (independent of salvage). Picks one project
|
|
373
|
+
# via select_project.py, fans out search topics, and emits all matching threads
|
|
374
|
+
# as candidates for ripen. The 10-min ripen sleep happens here; salvage
|
|
375
|
+
# already finished posting above so this sleep doesn't block any output.
|
|
376
|
+
#
|
|
377
|
+
# Project-scoped subreddit excludes (added 2026-05-11): post_reddit.py's
|
|
378
|
+
# discover phase logs `[project_excludes] platform=reddit project=...
|
|
379
|
+
# active_subs=N active_keywords=N subs=[...] keywords=[...]` for visibility.
|
|
380
|
+
# Enforcement happens server-side inside reddit_tools._load_comment_blocked_
|
|
381
|
+
# subs via the S4L_REDDIT_PROJECT env var that post_reddit.py exports below.
|
|
382
|
+
# Claude's draft prompt can propose new subreddit:<slug> excludes when it
|
|
383
|
+
# rejects a thread; they accumulate in project_search_excludes and go live
|
|
384
|
+
# after >=2 distinct batch_ids propose them (activation gate). See
|
|
385
|
+
# scripts/project_excludes.py for the full spec.
|
|
386
|
+
DISCOVER_FILE=$(mktemp -t post_reddit_discover.XXXXXX.json)
|
|
387
|
+
RIPEN_FILE=$(mktemp -t post_reddit_ripened.XXXXXX.json)
|
|
388
|
+
DISCOVER_DRAFT_FILE=$(mktemp -t post_reddit_discover_draft.XXXXXX.json)
|
|
389
|
+
HAS_DISCOVER=0
|
|
390
|
+
|
|
391
|
+
set +e
|
|
392
|
+
python3 "$REPO_DIR/scripts/post_reddit.py" \
|
|
393
|
+
--phase discover \
|
|
394
|
+
--batch-id "$BATCH_ID" \
|
|
395
|
+
--out "$DISCOVER_FILE" \
|
|
396
|
+
--exclude "$EXCLUDE" \
|
|
397
|
+
--limit "$LIMIT" 2>&1 | tee -a "$LOG_FILE"
|
|
398
|
+
DISCOVER_RC=${PIPESTATUS[0]}
|
|
399
|
+
set -e
|
|
400
|
+
|
|
401
|
+
case "$DISCOVER_RC" in
|
|
402
|
+
0)
|
|
403
|
+
DISCOVER_COUNT=$(python3 -c "import json;print(len(json.load(open('$DISCOVER_FILE')).get('decisions',[])))" 2>/dev/null || echo 0)
|
|
404
|
+
TOTAL_CANDIDATES=$((TOTAL_CANDIDATES + DISCOVER_COUNT))
|
|
405
|
+
HAS_DISCOVER=1
|
|
406
|
+
log "Discover lane: found $DISCOVER_COUNT candidate(s)."
|
|
407
|
+
;;
|
|
408
|
+
3) log "Discover lane: rate-limited; skipping discover this cycle." ;;
|
|
409
|
+
4) log "Discover lane: no eligible project; skipping discover this cycle." ;;
|
|
410
|
+
5) log "Discover lane: Claude failed; counting as failed."; TOTAL_FAILED=$((TOTAL_FAILED + 1)) ;;
|
|
411
|
+
6) log "Discover lane: no candidates found; counting as skipped."; TOTAL_SKIPPED=$((TOTAL_SKIPPED + 1)) ;;
|
|
412
|
+
*) log "Discover lane: unexpected rc=$DISCOVER_RC; counting as failed."; TOTAL_FAILED=$((TOTAL_FAILED + 1)) ;;
|
|
413
|
+
esac
|
|
414
|
+
|
|
415
|
+
# --- Rank + cap discover output (ripen stage RETIRED 2026-06-01) ---
|
|
416
|
+
# The 30-min ripen momentum gate (ripen_reddit_plan.py: T0 capture → sleep 1800s
|
|
417
|
+
# → T1 repoll → composite Δup+4·Δcomments floor) was removed to align with the
|
|
418
|
+
# Twitter pipeline, which dropped its own inter-phase momentum sleep on
|
|
419
|
+
# 2026-05-31 (variant D won: no wait, just expire→discover+score→draft→post).
|
|
420
|
+
# Two failure modes the ripen stage caused, both fixed by removing it:
|
|
421
|
+
# 1. repoll() had a hard 120s subprocess timeout on T1 re-fetch of the WHOLE
|
|
422
|
+
# candidate set; at ~75+ candidates it timed out → returned {} → every
|
|
423
|
+
# candidate dropped → zero posts (S4L 08:15, mk0r 08:30 on 2026-06-01).
|
|
424
|
+
# 2. mature long-tail threads that are genuinely on-topic but not gaining
|
|
425
|
+
# fresh upvotes in a 30-min window were momentum-starved and dropped
|
|
426
|
+
# (Podlog 08:00), even though they were the RIGHT threads to comment on.
|
|
427
|
+
# Ranking + capping now happens inside post_reddit.py --phase discover
|
|
428
|
+
# (_discover_iteration): candidates are scored by topical overlap (query vs.
|
|
429
|
+
# thread title+selftext) to fight the sort=relevance leak, then capped to the
|
|
430
|
+
# top S4L_REDDIT_DISCOVER_CAP (default 25). The final post cap is still
|
|
431
|
+
# enforced by _post_iteration (S4L_REDDIT_MAX_POSTS_PER_CYCLE, default 10).
|
|
432
|
+
# RIPEN_FILE is kept as a passthrough alias so the draft/cleanup paths below
|
|
433
|
+
# stay unchanged.
|
|
434
|
+
if [ "$HAS_DISCOVER" = "1" ]; then
|
|
435
|
+
cp "$DISCOVER_FILE" "$RIPEN_FILE"
|
|
436
|
+
SURVIVORS=$(python3 -c "import json;print(len(json.load(open('$RIPEN_FILE')).get('decisions',[])))" 2>/dev/null || echo 0)
|
|
437
|
+
if [ "$SURVIVORS" = "0" ]; then
|
|
438
|
+
log "Discover lane: 0 candidates after rank+cap; skipping discover draft+post."
|
|
439
|
+
TOTAL_SKIPPED=$((TOTAL_SKIPPED + 1))
|
|
440
|
+
HAS_DISCOVER=0
|
|
441
|
+
else
|
|
442
|
+
log "Discover lane: $SURVIVORS candidate(s) ranked + capped (no ripen wait)."
|
|
443
|
+
fi
|
|
444
|
+
fi
|
|
445
|
+
|
|
446
|
+
# --- Discover draft ---
|
|
447
|
+
if [ "$HAS_DISCOVER" = "1" ]; then
|
|
448
|
+
log "Discover lane: drafting $SURVIVORS candidate(s)..."
|
|
449
|
+
set +e
|
|
450
|
+
python3 "$REPO_DIR/scripts/post_reddit.py" \
|
|
451
|
+
--phase draft \
|
|
452
|
+
--in "$RIPEN_FILE" \
|
|
453
|
+
--out "$DISCOVER_DRAFT_FILE" 2>&1 | tee -a "$LOG_FILE"
|
|
454
|
+
DRAFT_RC=${PIPESTATUS[0]}
|
|
455
|
+
set -e
|
|
456
|
+
|
|
457
|
+
case "$DRAFT_RC" in
|
|
458
|
+
0) : ;;
|
|
459
|
+
5) log "Discover draft: Claude failed."; TOTAL_FAILED=$((TOTAL_FAILED + 1)); HAS_DISCOVER=0 ;;
|
|
460
|
+
6) log "Discover draft: no drafted decisions."; TOTAL_SKIPPED=$((TOTAL_SKIPPED + 1)); HAS_DISCOVER=0 ;;
|
|
461
|
+
*) log "Discover draft: rc=$DRAFT_RC."; TOTAL_FAILED=$((TOTAL_FAILED + 1)); HAS_DISCOVER=0 ;;
|
|
462
|
+
esac
|
|
463
|
+
fi
|
|
464
|
+
|
|
465
|
+
# --- Discover post (per-row lease handled inside post_reddit.py) ---
|
|
466
|
+
# Same per-row lease pattern as the salvage block above (see comment there for
|
|
467
|
+
# rationale). The lease is acquired/released around each post_via_cdp call
|
|
468
|
+
# inside `_post_iteration`, NOT around the whole --phase post invocation.
|
|
469
|
+
if [ "$HAS_DISCOVER" = "1" ]; then
|
|
470
|
+
log "Discover lane: posting $SURVIVORS survivor(s) (per-row reddit-browser lease)..."
|
|
471
|
+
|
|
472
|
+
# set +e covers the entire post + cleanup block. The script must reach
|
|
473
|
+
# the trap-installed cost emitter at the bottom even if discover cleanup
|
|
474
|
+
# errors (mirrors the salvage block above).
|
|
475
|
+
set +e
|
|
476
|
+
DISCOVER_POST_OUT=$(python3 "$REPO_DIR/scripts/post_reddit.py" --phase post --in "$DISCOVER_DRAFT_FILE" 2>&1 | tee -a "$LOG_FILE")
|
|
477
|
+
DISCOVER_POST_RC=${PIPESTATUS[0]}
|
|
478
|
+
|
|
479
|
+
read -r DISCOVER_POSTED DISCOVER_FAILED <<< "$(_parse_post_results "$DISCOVER_POST_OUT" "$DISCOVER_POST_RC")"
|
|
480
|
+
TOTAL_POSTED=$((TOTAL_POSTED + ${DISCOVER_POSTED:-0}))
|
|
481
|
+
TOTAL_FAILED=$((TOTAL_FAILED + ${DISCOVER_FAILED:-0}))
|
|
482
|
+
log "Discover lane: posted=$DISCOVER_POSTED failed=$DISCOVER_FAILED"
|
|
483
|
+
_accumulate_cdp_reasons "$DISCOVER_POST_OUT"
|
|
484
|
+
set -e
|
|
485
|
+
fi
|
|
486
|
+
|
|
487
|
+
rm -f "$SALVAGE_FILE" "$SALVAGE_DRAFT_FILE" "$DISCOVER_FILE" "$RIPEN_FILE" "$DISCOVER_DRAFT_FILE"
|
|
488
|
+
|
|
489
|
+
ELAPSED=$(( $(date +%s) - RUN_START ))
|
|
490
|
+
# Sum claude_sessions.total_cost_usd for every post_reddit session started
|
|
491
|
+
# during this cycle. Mirrors run-twitter-cycle.sh / run-linkedin.sh; the
|
|
492
|
+
# script value here is the same tag passed to log_claude_session.py inside
|
|
493
|
+
# scripts/post_reddit.py (~line 1141). Falls back to 0.0000 if the DB is
|
|
494
|
+
# unreachable so the dashboard never shows blank.
|
|
495
|
+
_COST=$(python3 "$REPO_DIR/scripts/get_run_cost.py" --cycle-id "$BATCH_ID" 2>/dev/null || echo "0.0000")
|
|
496
|
+
log "=== Run summary: posted=$TOTAL_POSTED failed=$TOTAL_FAILED skipped=$TOTAL_SKIPPED salvaged=$TOTAL_SALVAGED candidates=$TOTAL_CANDIDATES projects=[$EXCLUDE] cost=\$$_COST elapsed=${ELAPSED}s ==="
|
|
497
|
+
|
|
498
|
+
# Hand the precomputed cost to the trap-installed emitter so the happy path
|
|
499
|
+
# pays the (slow) Postgres query once, without the 10s clamp the SIGTERM path
|
|
500
|
+
# uses. _sa_emit_run_summary_oneshot is idempotent; the EXIT trap will
|
|
501
|
+
# no-op after this call.
|
|
502
|
+
_SA_PRECOMPUTED_COST="$_COST"
|
|
503
|
+
_sa_emit_run_summary_oneshot
|
|
504
|
+
|
|
505
|
+
log "=== Done: $(date) ==="
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Posts two original Reddit threads per launchd fire (separated by 30 min).
|
|
3
|
+
#
|
|
4
|
+
# Wrapper around run-reddit-threads.sh so we double daily Reddit thread
|
|
5
|
+
# volume (3 fires x 2 posts = 6 posts/day) without touching the main script
|
|
6
|
+
# or adding more launchd slots.
|
|
7
|
+
#
|
|
8
|
+
# Each invocation acquires its own pipeline lock, picks its own target via
|
|
9
|
+
# pick_thread_target.py (per-sub floors apply naturally), and exits.
|
|
10
|
+
# We use ';' (not '&&') so a NO_ELIGIBLE_TARGET on the first run still
|
|
11
|
+
# lets the second run try.
|
|
12
|
+
|
|
13
|
+
set -u
|
|
14
|
+
|
|
15
|
+
REPO_DIR="$HOME/social-autoposter"
|
|
16
|
+
SCRIPT="$REPO_DIR/skill/run-reddit-threads.sh"
|
|
17
|
+
LOG_DIR="$REPO_DIR/skill/logs"
|
|
18
|
+
mkdir -p "$LOG_DIR"
|
|
19
|
+
WRAPPER_LOG="$LOG_DIR/run-reddit-threads-double-$(date +%Y-%m-%d_%H%M%S).log"
|
|
20
|
+
|
|
21
|
+
echo "=== Reddit Threads DOUBLE wrapper start: $(date) ===" | tee "$WRAPPER_LOG"
|
|
22
|
+
|
|
23
|
+
echo "--- iteration 1 ---" | tee -a "$WRAPPER_LOG"
|
|
24
|
+
"$SCRIPT" || echo "iter1 exit=$?" | tee -a "$WRAPPER_LOG"
|
|
25
|
+
|
|
26
|
+
echo "--- sleeping 1800s before iteration 2 ---" | tee -a "$WRAPPER_LOG"
|
|
27
|
+
sleep 1800
|
|
28
|
+
|
|
29
|
+
echo "--- iteration 2 ---" | tee -a "$WRAPPER_LOG"
|
|
30
|
+
"$SCRIPT" || echo "iter2 exit=$?" | tee -a "$WRAPPER_LOG"
|
|
31
|
+
|
|
32
|
+
echo "=== Reddit Threads DOUBLE wrapper done: $(date) ===" | tee -a "$WRAPPER_LOG"
|