@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.
Files changed (326) hide show
  1. package/README.md +143 -0
  2. package/SKILL.md +342 -0
  3. package/bin/cli.js +980 -0
  4. package/bin/cookie-helper.js +315 -0
  5. package/bin/platform.js +59 -0
  6. package/bin/scheduler/index.js +12 -0
  7. package/bin/scheduler/launchd.js +518 -0
  8. package/browser-agent-configs/all-agents-mcp.json +68 -0
  9. package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
  10. package/browser-agent-configs/linkedin-agent.json +17 -0
  11. package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
  12. package/browser-agent-configs/reddit-agent-mcp.json +16 -0
  13. package/browser-agent-configs/reddit-agent.json +17 -0
  14. package/browser-agent-configs/twitter-harness-mcp.json +18 -0
  15. package/config.example.json +45 -0
  16. package/mcp/dist/index.js +4212 -0
  17. package/mcp/dist/onboarding.js +200 -0
  18. package/mcp/dist/panel.html +176 -0
  19. package/mcp/dist/product-link.html +102 -0
  20. package/mcp/dist/repo.js +222 -0
  21. package/mcp/dist/runtime.js +1079 -0
  22. package/mcp/dist/screencast.js +323 -0
  23. package/mcp/dist/setup.js +545 -0
  24. package/mcp/dist/telemetry.js +306 -0
  25. package/mcp/dist/twitterAuth.js +138 -0
  26. package/mcp/dist/version.js +271 -0
  27. package/mcp/dist/version.json +4 -0
  28. package/mcp/install-runtime.mjs +70 -0
  29. package/mcp/install.mjs +169 -0
  30. package/mcp/manifest.json +80 -0
  31. package/mcp/menubar/dashboard_server.py +213 -0
  32. package/mcp/menubar/s4l_card.py +1336 -0
  33. package/mcp/menubar/s4l_log_relay.py +179 -0
  34. package/mcp/menubar/s4l_menubar.py +2439 -0
  35. package/mcp/menubar/s4l_state.py +891 -0
  36. package/mcp/package.json +34 -0
  37. package/mcp/shared/doctor.cjs +437 -0
  38. package/mcp/shared/onboarding-ledger.cjs +324 -0
  39. package/mcp-servers/browser-harness/server.py +968 -0
  40. package/package.json +160 -0
  41. package/requirements.txt +20 -0
  42. package/scripts/_compute_allowlist.py +58 -0
  43. package/scripts/_db_update.py +20 -0
  44. package/scripts/_filt.py +9 -0
  45. package/scripts/_li_notif_match.py +76 -0
  46. package/scripts/_li_notif_orchestrate.py +126 -0
  47. package/scripts/_lock_preempt_test.py +60 -0
  48. package/scripts/_run_icp_precheck.py +57 -0
  49. package/scripts/a16z_pearx_calendar_reminders.py +99 -0
  50. package/scripts/account_resolver.py +141 -0
  51. package/scripts/active_campaigns.py +114 -0
  52. package/scripts/active_users.py +190 -0
  53. package/scripts/amplitude_24h_signups.py +468 -0
  54. package/scripts/amplitude_signups.py +177 -0
  55. package/scripts/apply_onboarding_selections.py +131 -0
  56. package/scripts/audience_pages.py +243 -0
  57. package/scripts/audit_helper.py +120 -0
  58. package/scripts/author_history_block.py +353 -0
  59. package/scripts/autopilot_stall_watch.py +284 -0
  60. package/scripts/backfill_twitter_attempts_topic.py +81 -0
  61. package/scripts/backfill_twitter_log_post_no_id.py +322 -0
  62. package/scripts/bench_dashboard.sh +138 -0
  63. package/scripts/bh_send.py +39 -0
  64. package/scripts/build_persona.py +409 -0
  65. package/scripts/bulk_icp.py +18 -0
  66. package/scripts/campaign_bump.py +51 -0
  67. package/scripts/capture_thread_media.py +288 -0
  68. package/scripts/check_browser_lock_health.sh +81 -0
  69. package/scripts/check_external_pool_depth.py +253 -0
  70. package/scripts/check_unread_web_chats.py +28 -0
  71. package/scripts/claim_web_chat.py +47 -0
  72. package/scripts/classify_run_error.py +158 -0
  73. package/scripts/claude_job.py +988 -0
  74. package/scripts/clean_stale_singleton.sh +56 -0
  75. package/scripts/cleanup_harness_tabs.py +68 -0
  76. package/scripts/copy_browser_cookies.py +454 -0
  77. package/scripts/counterparty_history.py +350 -0
  78. package/scripts/db.py +57 -0
  79. package/scripts/discover_claude_profiles.py +120 -0
  80. package/scripts/discover_linkedin_candidates.py +984 -0
  81. package/scripts/dm_conversation.py +682 -0
  82. package/scripts/dm_db_update.py +69 -0
  83. package/scripts/dm_engage_helper.py +161 -0
  84. package/scripts/dm_outreach_helper.py +147 -0
  85. package/scripts/dm_outreach_twitter_helper.py +129 -0
  86. package/scripts/dm_send_log.py +106 -0
  87. package/scripts/dm_short_links.py +1084 -0
  88. package/scripts/dump_web_chat_history.py +47 -0
  89. package/scripts/engage_github.py +640 -0
  90. package/scripts/engage_reddit.py +1235 -0
  91. package/scripts/engage_twitter_helper.py +301 -0
  92. package/scripts/engagement_styles.py +1787 -0
  93. package/scripts/enrich_twitter_candidates.py +82 -0
  94. package/scripts/feedback_digest.py +448 -0
  95. package/scripts/fetch_prospect_profile.py +312 -0
  96. package/scripts/fetch_twitter_t1.py +134 -0
  97. package/scripts/find_threads.py +530 -0
  98. package/scripts/follow_gate_log.py +59 -0
  99. package/scripts/funnel_per_day.py +194 -0
  100. package/scripts/generate_daily_human_style.py +494 -0
  101. package/scripts/generation_trace.py +173 -0
  102. package/scripts/get_run_cost.py +107 -0
  103. package/scripts/github_engage_helper.py +93 -0
  104. package/scripts/github_tools.py +509 -0
  105. package/scripts/harness_overlay.py +556 -0
  106. package/scripts/harvest_twitter_following.py +243 -0
  107. package/scripts/heartbeat.sh +70 -0
  108. package/scripts/history_context.py +284 -0
  109. package/scripts/http_api.py +206 -0
  110. package/scripts/human_dm_replies_helper.py +169 -0
  111. package/scripts/identity.py +302 -0
  112. package/scripts/ig_batch_creator.sh +93 -0
  113. package/scripts/ig_post_type_picker.py +243 -0
  114. package/scripts/ig_scrape_transcribe.sh +91 -0
  115. package/scripts/ingest_human_dm_replies.py +271 -0
  116. package/scripts/ingest_web_chat_replies.py +229 -0
  117. package/scripts/install_fleet.py +187 -0
  118. package/scripts/invent_mcp_server.py +350 -0
  119. package/scripts/invent_topics.py +1462 -0
  120. package/scripts/learned_preferences.py +263 -0
  121. package/scripts/li_discovery.py +161 -0
  122. package/scripts/link_edit_helper.py +142 -0
  123. package/scripts/link_tail.py +592 -0
  124. package/scripts/linkedin_api.py +561 -0
  125. package/scripts/linkedin_browser.py +730 -0
  126. package/scripts/linkedin_cooldown.py +128 -0
  127. package/scripts/linkedin_exclusions.py +234 -0
  128. package/scripts/linkedin_killswitch.py +1333 -0
  129. package/scripts/linkedin_search_topic_schema.py +49 -0
  130. package/scripts/linkedin_unipile.py +658 -0
  131. package/scripts/linkedin_url.py +228 -0
  132. package/scripts/log_claude_session.py +636 -0
  133. package/scripts/log_draft.py +143 -0
  134. package/scripts/log_linkedin_search_attempts.py +126 -0
  135. package/scripts/log_post.py +651 -0
  136. package/scripts/log_run.py +364 -0
  137. package/scripts/log_thread_media.py +108 -0
  138. package/scripts/log_twitter_search_attempts.py +150 -0
  139. package/scripts/log_twitter_skips.py +211 -0
  140. package/scripts/lookup_post.py +78 -0
  141. package/scripts/mark_web_chat_processed.py +32 -0
  142. package/scripts/mcp_lock_proxy.py +370 -0
  143. package/scripts/memory_snapshot.py +972 -0
  144. package/scripts/merge_review_queue.py +215 -0
  145. package/scripts/mint_external_pool.py +182 -0
  146. package/scripts/mint_kent_pool.py +249 -0
  147. package/scripts/moltbook_post.py +320 -0
  148. package/scripts/moltbook_tools.py +159 -0
  149. package/scripts/pending_threads.py +188 -0
  150. package/scripts/pick_ig_account.py +177 -0
  151. package/scripts/pick_project.py +208 -0
  152. package/scripts/pick_search_topic.py +771 -0
  153. package/scripts/pick_thread_target.py +279 -0
  154. package/scripts/pick_twitter_thread_target.py +202 -0
  155. package/scripts/podlog_fetch_batch.sh +32 -0
  156. package/scripts/post_github.py +1311 -0
  157. package/scripts/post_reddit.py +2668 -0
  158. package/scripts/precompute_dashboard_stats.py +204 -0
  159. package/scripts/preflight.sh +297 -0
  160. package/scripts/progress.py +88 -0
  161. package/scripts/project_excludes.py +353 -0
  162. package/scripts/project_slugs.py +91 -0
  163. package/scripts/project_stats.py +241 -0
  164. package/scripts/project_stats_json.py +1563 -0
  165. package/scripts/project_topics.py +192 -0
  166. package/scripts/qualified_query_bank.py +436 -0
  167. package/scripts/reap_stale_claude_sessions.py +867 -0
  168. package/scripts/reddit_browser.py +2549 -0
  169. package/scripts/reddit_browser_fetch.py +141 -0
  170. package/scripts/reddit_browser_lock.py +593 -0
  171. package/scripts/reddit_chat_sync.py +710 -0
  172. package/scripts/reddit_query_bank.py +200 -0
  173. package/scripts/reddit_threads_helper.py +151 -0
  174. package/scripts/reddit_tools.py +956 -0
  175. package/scripts/refresh_instagram_tokens.py +280 -0
  176. package/scripts/release-mcpb.sh +513 -0
  177. package/scripts/reply_db.py +334 -0
  178. package/scripts/reply_insert.py +98 -0
  179. package/scripts/reply_risk_digest.py +761 -0
  180. package/scripts/reset-test-machine.sh +602 -0
  181. package/scripts/restore_twitter_session.py +177 -0
  182. package/scripts/ripen_reddit_plan.py +478 -0
  183. package/scripts/run_claude.sh +433 -0
  184. package/scripts/run_moltbook_cycle.py +555 -0
  185. package/scripts/s4l_box_update.sh +226 -0
  186. package/scripts/s4l_channel.py +103 -0
  187. package/scripts/s4l_ctl.sh +75 -0
  188. package/scripts/s4l_env.py +47 -0
  189. package/scripts/saps_activity.py +126 -0
  190. package/scripts/saps_mode.py +328 -0
  191. package/scripts/scan_dm_candidates.py +580 -0
  192. package/scripts/scan_github_replies.py +168 -0
  193. package/scripts/scan_instagram_comments.py +481 -0
  194. package/scripts/scan_moltbook_replies.py +252 -0
  195. package/scripts/scan_pii.py +190 -0
  196. package/scripts/scan_reddit_replies.py +377 -0
  197. package/scripts/scan_twitter_mentions_browser.py +327 -0
  198. package/scripts/scan_twitter_thread_followups.py +299 -0
  199. package/scripts/scan_x_profile.py +384 -0
  200. package/scripts/schedule_state.py +202 -0
  201. package/scripts/scheduled_tasks_snapshot.py +123 -0
  202. package/scripts/score_linkedin_candidates.py +419 -0
  203. package/scripts/score_twitter_candidates.py +718 -0
  204. package/scripts/scrape_linkedin_comment_stats.py +1755 -0
  205. package/scripts/scrape_linkedin_stats_browser.py +52 -0
  206. package/scripts/scrape_reddit_views.py +365 -0
  207. package/scripts/seed_search_queries.py +453 -0
  208. package/scripts/seed_search_topics.py +127 -0
  209. package/scripts/send_web_chat_reply.py +130 -0
  210. package/scripts/sentry_init.py +128 -0
  211. package/scripts/setup_twitter_auth.py +1320 -0
  212. package/scripts/snapshot.py +583 -0
  213. package/scripts/stats.py +2702 -0
  214. package/scripts/stats_helper.py +52 -0
  215. package/scripts/strike_alert.py +783 -0
  216. package/scripts/sweep_post_link_clicks.py +107 -0
  217. package/scripts/sync_ig_to_posts.py +147 -0
  218. package/scripts/test_browser_lock.py +189 -0
  219. package/scripts/test_installation_api.sh +52 -0
  220. package/scripts/test_percard_posting.py +142 -0
  221. package/scripts/top_dud_linkedin_queries.py +71 -0
  222. package/scripts/top_dud_reddit_queries.py +67 -0
  223. package/scripts/top_dud_twitter_queries.py +71 -0
  224. package/scripts/top_dud_twitter_topics.py +102 -0
  225. package/scripts/top_linkedin_queries.py +55 -0
  226. package/scripts/top_omitted_reddit_topics.py +91 -0
  227. package/scripts/top_performers.py +588 -0
  228. package/scripts/top_search_topics.py +180 -0
  229. package/scripts/top_twitter_queries.py +190 -0
  230. package/scripts/twitter_access_check.py +382 -0
  231. package/scripts/twitter_account.py +41 -0
  232. package/scripts/twitter_batch_phase.py +126 -0
  233. package/scripts/twitter_browser.py +2804 -0
  234. package/scripts/twitter_cookie_mirror.py +130 -0
  235. package/scripts/twitter_cycle_helper.py +310 -0
  236. package/scripts/twitter_gen_links.py +287 -0
  237. package/scripts/twitter_post_plan.py +1188 -0
  238. package/scripts/twitter_scan.py +324 -0
  239. package/scripts/twitter_supply_signal.py +57 -0
  240. package/scripts/twitter_threads_helper.py +152 -0
  241. package/scripts/unclaim_web_chat.py +29 -0
  242. package/scripts/update_instagram_stats.py +261 -0
  243. package/scripts/update_linkedin_stats_from_feed.py +328 -0
  244. package/scripts/version.py +72 -0
  245. package/scripts/watchdog_hung_runs.py +343 -0
  246. package/scripts/write_generation_trace.py +73 -0
  247. package/setup/SKILL.md +277 -0
  248. package/skill/amplitude-24h-signups.sh +38 -0
  249. package/skill/archive-old-logs.sh +40 -0
  250. package/skill/audit-dm-staleness.sh +42 -0
  251. package/skill/audit-linkedin.sh +14 -0
  252. package/skill/audit-moltbook.sh +4 -0
  253. package/skill/audit-reddit-resurrect.sh +67 -0
  254. package/skill/audit-reddit.sh +4 -0
  255. package/skill/audit-twitter.sh +4 -0
  256. package/skill/audit.sh +287 -0
  257. package/skill/backfill-twitter-attempts-topic.sh +19 -0
  258. package/skill/backfill-twitter-ghost-posts.sh +24 -0
  259. package/skill/check-external-pool-depth.sh +7 -0
  260. package/skill/check-web-chats.sh +203 -0
  261. package/skill/dm-outreach-linkedin.sh +250 -0
  262. package/skill/dm-outreach-reddit.sh +274 -0
  263. package/skill/dm-outreach-twitter.sh +265 -0
  264. package/skill/engage-dm-replies-linkedin.sh +4 -0
  265. package/skill/engage-dm-replies-reddit.sh +4 -0
  266. package/skill/engage-dm-replies-twitter.sh +4 -0
  267. package/skill/engage-dm-replies.sh +1597 -0
  268. package/skill/engage-linkedin.sh +581 -0
  269. package/skill/engage-moltbook.sh +36 -0
  270. package/skill/engage-reddit.sh +146 -0
  271. package/skill/engage-twitter.sh +467 -0
  272. package/skill/github-engage.sh +176 -0
  273. package/skill/ingest-web-chat-replies.sh +38 -0
  274. package/skill/invent-supply-test.sh +100 -0
  275. package/skill/invent-topics.sh +50 -0
  276. package/skill/lib/linkedin-backend.sh +364 -0
  277. package/skill/lib/platform.sh +48 -0
  278. package/skill/lib/reddit-backend.sh +234 -0
  279. package/skill/lib/twitter-backend.sh +314 -0
  280. package/skill/link-edit-github.sh +136 -0
  281. package/skill/link-edit-moltbook.sh +117 -0
  282. package/skill/link-edit-reddit.sh +201 -0
  283. package/skill/linkedin-presence.sh +182 -0
  284. package/skill/linkedin-recovery.sh +282 -0
  285. package/skill/lock.sh +647 -0
  286. package/skill/memory-snapshot.sh +39 -0
  287. package/skill/precompute-stats.sh +35 -0
  288. package/skill/prewarm-funnel.sh +104 -0
  289. package/skill/refresh-instagram-tokens.sh +57 -0
  290. package/skill/refresh-twitter-following.sh +52 -0
  291. package/skill/reply-risk-digest.sh +31 -0
  292. package/skill/run-cycle-update-guard.sh +44 -0
  293. package/skill/run-draft-and-publish.sh +123 -0
  294. package/skill/run-generate-daily-style.sh +50 -0
  295. package/skill/run-github-launchd.sh +62 -0
  296. package/skill/run-github.sh +102 -0
  297. package/skill/run-instagram-daily.sh +149 -0
  298. package/skill/run-instagram-render.sh +875 -0
  299. package/skill/run-linkedin-launchd.sh +81 -0
  300. package/skill/run-linkedin-unipile.sh +130 -0
  301. package/skill/run-linkedin.sh +1593 -0
  302. package/skill/run-moltbook-launchd.sh +61 -0
  303. package/skill/run-moltbook.sh +38 -0
  304. package/skill/run-overlay-watch.sh +100 -0
  305. package/skill/run-reddit-search-launchd.sh +64 -0
  306. package/skill/run-reddit-search.sh +505 -0
  307. package/skill/run-reddit-threads-double.sh +32 -0
  308. package/skill/run-reddit-threads.sh +847 -0
  309. package/skill/run-scan-moltbook-replies.sh +57 -0
  310. package/skill/run-twitter-cycle-launchd.sh +63 -0
  311. package/skill/run-twitter-cycle-singleton.sh +62 -0
  312. package/skill/run-twitter-cycle.sh +2408 -0
  313. package/skill/run-twitter-threads.sh +592 -0
  314. package/skill/scan-instagram-replies.sh +61 -0
  315. package/skill/scan-twitter-followups.sh +57 -0
  316. package/skill/social-autoposter-update.sh +66 -0
  317. package/skill/stats-instagram.sh +72 -0
  318. package/skill/stats-linkedin.sh +271 -0
  319. package/skill/stats-moltbook.sh +4 -0
  320. package/skill/stats-reddit.sh +4 -0
  321. package/skill/stats-twitter.sh +4 -0
  322. package/skill/stats.sh +521 -0
  323. package/skill/strike-alert.sh +18 -0
  324. package/skill/styles.sh +87 -0
  325. package/skill/sweep-link-clicks.sh +40 -0
  326. 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"