@m13v/s4l 1.6.197-rc.7

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 +1314 -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 +497 -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,1188 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ twitter_post_plan.py — Phase 2b-post helper for run-twitter-cycle.sh.
4
+
5
+ Reads the candidate plan JSON file (already enriched with link_url by
6
+ twitter_gen_links.py), and for each candidate:
7
+
8
+ 1. Calls scripts/twitter_browser.py reply <candidate_url> "<reply_text> <link_url>"
9
+ 2. Logs the post via scripts/log_post.py (INSERT mode), captures post_id
10
+ 3. Bumps every campaign in applied_campaigns via scripts/campaign_bump.py
11
+ 4. Marks link_edited_at via scripts/log_post.py --mark-self-reply
12
+ (the link is embedded in the primary reply; no self-reply will follow)
13
+ 5. UPDATE twitter_candidates SET status='posted', posted_at=NOW(), post_id=...
14
+
15
+ Browser lock IS expected to be held by the caller (run-twitter-cycle.sh
16
+ re-acquires twitter-browser before invoking this script). twitter_browser.py
17
+ attaches to the twitter-harness Chrome via CDP on the browser-harness
18
+ profile, so the exclusive lock matters.
19
+
20
+ The script exits 0 unless it can't even load the plan; per-candidate failures
21
+ are recorded in twitter_candidates.status (skipped|failed) and a JSON summary
22
+ is written to stdout for the caller to read counts back.
23
+
24
+ Stdout summary (one JSON object on the last line):
25
+ {"posted": N, "skipped": N, "failed": N,
26
+ "failure_reasons": "timeout:1,log_post_no_id:1,...",
27
+ "skip_reasons": "duplicate_thread_pre_post:3,empty_reply_text:1,..."}
28
+
29
+ `failure_reasons` is real failures only (the dashboard renders it as a
30
+ "failed: <reason>" pill, so dedup skips do NOT belong here). `skip_reasons`
31
+ captures the per-skip breakdown (duplicate_thread_pre_post,
32
+ empty_reply_text, rate_limited, tweet_not_found, reply_box_not_found,
33
+ no_reply_url_captured) without misclassifying them as failures.
34
+
35
+ Usage:
36
+ python3 twitter_post_plan.py --plan /tmp/twitter_cycle_plan_<batch>.json
37
+ """
38
+
39
+ from __future__ import annotations # PEP 604 unions (int | None) for Python 3.9 launchd
40
+
41
+ import argparse
42
+ import json
43
+ import os
44
+ import random
45
+ import re
46
+ import subprocess
47
+ import sys
48
+ import time
49
+ from datetime import datetime, timezone
50
+ from pathlib import Path
51
+
52
+ # This pipeline ONLY posts (never scans), so mark every twitter_browser.py reply
53
+ # subprocess it spawns as the high-priority "post" lock role. run_subprocess
54
+ # inherits this process env, so the child twitter_browser.py reads S4L_LOCK_ROLE
55
+ # at import and will PREEMPT a live scan holding the browser lock instead of
56
+ # losing the 45s wait. Covers BOTH the MCP approve path and the cron post path,
57
+ # since both shell out to this script. Set before any child is spawned.
58
+ os.environ["S4L_LOCK_ROLE"] = "post"
59
+
60
+ REPO_DIR = os.path.expanduser("~/social-autoposter")
61
+ TWITTER_BROWSER = os.path.join(REPO_DIR, "scripts", "twitter_browser.py")
62
+ LOG_POST = os.path.join(REPO_DIR, "scripts", "log_post.py")
63
+ CAMPAIGN_BUMP = os.path.join(REPO_DIR, "scripts", "campaign_bump.py")
64
+ LINK_TAIL = os.path.join(REPO_DIR, "scripts", "link_tail.py")
65
+
66
+ # Interpreter every child subprocess (twitter_browser.py reply, log_post.py,
67
+ # campaign_bump.py, link_tail.py) must run under. The reply path is the only
68
+ # Playwright importer in the pipeline, so a bare "python3" here silently
69
+ # resolved to the user's system python (no Playwright) and every post died
70
+ # with no_reply_json (Karol, 2026-06-22). Honor the authoritative pin the rest
71
+ # of the runtime uses — S4L_PYTHON (set by the launchd plist) — then fall back
72
+ # to sys.executable (the interpreter THIS process already runs under, which the
73
+ # MCP's runPython resolves to the owned uv runtime). Never the literal
74
+ # "python3": that re-rolls the PATH dice. Re-exported so grandchildren inherit.
75
+ PYTHON = os.environ.get("S4L_PYTHON") or sys.executable
76
+ os.environ["S4L_PYTHON"] = PYTHON
77
+
78
+ # DATABASE_URL was previously used to issue ad-hoc `psql -c "..."` calls for
79
+ # the pre-post dedup probe and the candidate status updates. As of the
80
+ # 2026-05-18 routes migration both lanes go through the s4l.ai HTTP API
81
+ # (/api/v1/posts/lookup + /api/v1/twitter-candidates/by-id) via http_api, so
82
+ # we no longer need the raw connection string at this layer. Kept around as
83
+ # a no-op constant in case downstream tooling reads it from the environment.
84
+ sys.path.insert(0, os.path.join(REPO_DIR, "scripts"))
85
+ from http_api import api_get, api_patch, api_post # noqa: E402
86
+ try:
87
+ from account_resolver import resolve as _resolve_account # noqa: E402
88
+ except Exception:
89
+ def _resolve_account(_platform): # type: ignore[unused-arg]
90
+ return None
91
+
92
+ # Engagement-style enforcement (2026-05-22 cutover): the Twitter post path
93
+ # now calls validate_or_register exactly like Reddit/GitHub/Moltbook so
94
+ # (a) USE-mode drift gets coerced back to the picker's assigned style and
95
+ # (b) INVENT-mode inventions land in engagement_styles_registry via the
96
+ # /api/v1/engagement-styles/registry POST. The picker assignment is read
97
+ # from the plan envelope (run-twitter-cycle.sh writes assigned_style +
98
+ # assigned_mode into the same JSON file that already carries session_id).
99
+ # The model's optional new_style block per candidate is read from the
100
+ # candidate dict itself. Soft import so the post path still runs if the
101
+ # module is unavailable for some reason (we fall back to the raw
102
+ # engagement_style string from the model).
103
+ try:
104
+ from engagement_styles import validate_or_register # noqa: E402
105
+ except Exception:
106
+ validate_or_register = None # type: ignore[assignment]
107
+
108
+ # Reasons that signal an OPERATIONAL post failure (browser/session/API broke),
109
+ # as opposed to a content-judgment skip ("off-topic ..."). Some of these are
110
+ # bucketed as `skipped` in the run summary (e.g. reply_box_not_found) so they do
111
+ # not pollute the dashboard "failed" pill, but for remote observability they ARE
112
+ # the signal that a user "approved but couldn't post" — so we capture them to
113
+ # Sentry regardless of which summary bucket they land in.
114
+ MACHINE_FAIL_REASONS = {
115
+ "no_reply_json", "reply_failed", "timeout", "unknown", "exception",
116
+ "log_post_no_id", "reply_box_not_found", "rate_limited", "tweet_not_found",
117
+ "no_reply_url_captured", "empty_reply_text", "session_invalid",
118
+ }
119
+
120
+ REPLY_URL_RE = re.compile(r"^https?://(?:x\.com|twitter\.com)/[^/]+/status/\d+")
121
+ TOP_LEVEL_OBJ_RE = re.compile(r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}", re.DOTALL)
122
+
123
+ def parse_last_json_object(text): # -> dict | None; bare hint kept off the signature for Python 3.9 compatibility (PEP 604 union requires 3.10+)
124
+ """Extract the last balanced top-level JSON object from a string.
125
+
126
+ twitter_browser.py prints log lines to stderr and one JSON object to
127
+ stdout via json.dumps(indent=2); but capture_output=True merges nothing
128
+ by default. We still scan defensively for the last `{...}` block in case
129
+ the caller passes combined output.
130
+ """
131
+ text = text.strip()
132
+ if not text:
133
+ return None
134
+ # Fast path: single object.
135
+ if text.startswith("{") and text.endswith("}"):
136
+ try:
137
+ return json.loads(text)
138
+ except Exception:
139
+ pass
140
+ # Fallback: find all top-level balanced objects.
141
+ matches = []
142
+ depth = 0
143
+ start = None
144
+ for i, ch in enumerate(text):
145
+ if ch == "{":
146
+ if depth == 0:
147
+ start = i
148
+ depth += 1
149
+ elif ch == "}":
150
+ if depth > 0:
151
+ depth -= 1
152
+ if depth == 0 and start is not None:
153
+ matches.append(text[start:i + 1])
154
+ start = None
155
+ for cand in reversed(matches):
156
+ try:
157
+ return json.loads(cand)
158
+ except Exception:
159
+ continue
160
+ return None
161
+
162
+
163
+ def run_subprocess(cmd: list[str], timeout_sec: int = 600) -> tuple[int, str, str]:
164
+ """Run a subprocess; return (returncode, stdout, stderr)."""
165
+ try:
166
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_sec)
167
+ return (r.returncode, r.stdout or "", r.stderr or "")
168
+ except subprocess.TimeoutExpired as e:
169
+ return (-1, e.stdout or "", f"TIMEOUT after {timeout_sec}s")
170
+
171
+
172
+ def update_candidate(cid: int, status: str, reason: str | None = None) -> None:
173
+ """Flip candidate status (skipped/posted/expired) via the HTTP API.
174
+
175
+ Server-side WHERE: `status != 'posted'` so we never stomp the posted
176
+ state — mirrors the old psql guard exactly. The route returns 404 when
177
+ the row IS already posted (or absent); we treat that as success here
178
+ since the caller's intent ("don't retry this row") is already met.
179
+
180
+ IMPORTANT: the DB CHECK constraint twitter_candidates_status_check only
181
+ allows pending/posted/skipped/expired. There is NO 'failed' status — a
182
+ reply that fails (timeout, exception, missing reply_url, lost log row)
183
+ is recorded as 'skipped' with a descriptive skip_reason so the row is
184
+ not retried (re-trying a landed reply double-posts on x.com). The
185
+ run-summary 'failed' count is derived from each post_one() return value,
186
+ NOT from the DB status, so the dashboard signal is unaffected. Writing
187
+ 'failed' here used to 500 against the check constraint on every failure.
188
+
189
+ When status='skipped' and a reason is given, route through the
190
+ mark_skipped action so skip_reason + skipped_at are stamped; otherwise
191
+ use the generic set_status override.
192
+ """
193
+ if status == "posted":
194
+ # Caller will set post_id separately on success path; here we just
195
+ # mark intermediate states.
196
+ return
197
+ try:
198
+ if status == "skipped" and reason:
199
+ payload = {
200
+ "id": int(cid),
201
+ "action": "mark_skipped",
202
+ "reason": str(reason)[:500],
203
+ }
204
+ else:
205
+ payload = {"id": int(cid), "action": "set_status", "status": status}
206
+ resp = api_patch(
207
+ "/api/v1/twitter-candidates/by-id",
208
+ payload,
209
+ ok_on_404=True,
210
+ )
211
+ if resp.get("_not_found"):
212
+ # Either row was already posted (allow_overwrite_posted=false
213
+ # default blocks it / mark_skipped only touches pending) or it
214
+ # doesn't exist. Either way, no further action is needed.
215
+ return
216
+ except SystemExit as e:
217
+ print(f"[post] candidate {cid} status update failed: {e}", flush=True)
218
+
219
+
220
+ def already_posted_to_thread(thread_url: str) -> tuple[bool, int | None]:
221
+ """Pre-post dedup race guard.
222
+
223
+ Returns (True, post_id) if posts already has a row for
224
+ (platform='twitter', thread_url=<thread_url>), else (False, None).
225
+
226
+ Why this exists: cycles overlap. Phase 0 of cycle B can salvage a
227
+ candidate while cycle A is still in its T1 wait window — cycle A
228
+ hasn't INSERTed into posts yet, so salvage's
229
+ `tweet_url NOT IN (SELECT thread_url FROM posts)` guard lets the
230
+ same row through. Both cycles then call reply_to_tweet, the second
231
+ one gets DUPLICATE_THREAD from log_post.py only AFTER the second
232
+ reply is already on X. Real double-post observed 2026-05-01:
233
+ posts #22317 (cycle 14:23, our_url ...4034) AND a second reply
234
+ ...8891 (cycle 14:38, never logged).
235
+
236
+ This SELECT runs ~26s after the peer cycle's INSERT in the observed
237
+ race, so it would have caught the duplicate. It does not eliminate
238
+ the race entirely — two cycles SELECTing in the same ms would both
239
+ pass — but advisory-lock-grade atomicity is overkill for an event
240
+ that fires once per cycle. log_post.py's post-INSERT dedup is still
241
+ the final backstop.
242
+ """
243
+ # Scope MUST match the server-side insert dedup, which is keyed on
244
+ # (platform, thread_url) ONLY -- NOT our_account (see social-autoposter-
245
+ # website /api/v1/posts route: "Enforces dedup on (platform, thread_url)").
246
+ # The old per-account scoping here made the probe NARROWER than the server:
247
+ # it passed when a post existed under a different/placeholder our_account,
248
+ # so the cycle posted a SECOND reply to a thread the server then rejected
249
+ # with duplicate_thread -- after the reply was already live on X. Querying
250
+ # thread-only makes the pre-post guard catch exactly what the insert would
251
+ # reject, so we never burn that wasted second reply. (2026-06-02)
252
+ dedupe_q = {"platform": "twitter", "thread_url": thread_url}
253
+ try:
254
+ resp = api_get(
255
+ "/api/v1/posts/lookup",
256
+ query=dedupe_q,
257
+ ok_on_404=True,
258
+ )
259
+ except SystemExit as e:
260
+ print(f"[post] dedup pre-check API call failed: {e}", flush=True)
261
+ return (False, None)
262
+ if resp.get("_not_found"):
263
+ return (False, None)
264
+ data = resp.get("data") or {}
265
+ post = data.get("post") or {}
266
+ pid = post.get("id")
267
+ if pid is None:
268
+ return (False, None)
269
+ try:
270
+ return (True, int(pid))
271
+ except (TypeError, ValueError):
272
+ return (True, None)
273
+
274
+
275
+ def fetch_thread_engagement_snapshot(cid: int) -> str | None:
276
+ """Fetch the T0 engagement snapshot the discovery pipeline recorded for
277
+ this candidate, serialised as a compact JSON string ready for the
278
+ posts.thread_engagement TEXT column.
279
+
280
+ Reads from /api/v1/twitter-candidates/by-id?id=<cid>, which returns the
281
+ *_t0 columns score_twitter_candidates.py stamps at scrape time. No live
282
+ refresh, no fxtwitter call: this is the snapshot Twitter showed when the
283
+ candidate was first discovered.
284
+
285
+ Returns:
286
+ - JSON string like '{"likes":42,"retweets":3,"replies":12,"views":8100,"bookmarks":1,"source":"discovery_t0"}'
287
+ when at least one engagement field was present on the candidate row.
288
+ - None when the row is missing or every engagement field is NULL (no
289
+ signal worth storing; column stays NULL on posts).
290
+
291
+ Failure mode: any error logs a warning and returns None. We never block
292
+ the post on this; missing one row of snapshot data is preferable to
293
+ losing the post.
294
+ """
295
+ try:
296
+ resp = api_get(
297
+ "/api/v1/twitter-candidates/by-id",
298
+ query={"id": int(cid)},
299
+ ok_on_404=True,
300
+ )
301
+ except SystemExit as e:
302
+ print(f"[post] candidate {cid} thread_engagement fetch failed: {e}", flush=True)
303
+ return None
304
+ if resp.get("_not_found"):
305
+ return None
306
+ data = resp.get("data") or {}
307
+ cand = data.get("candidate") or {}
308
+ if not cand:
309
+ return None
310
+
311
+ def _pick(t0_key: str, live_key: str):
312
+ # Prefer the T0 snapshot (captured at discovery, the user's explicit
313
+ # requirement: scrape-time engagement, not live). Fall back to the
314
+ # live column only when T0 is missing AND live is present, which
315
+ # happens on very old candidate rows that pre-date the T0 backfill.
316
+ v0 = cand.get(t0_key)
317
+ if v0 is not None:
318
+ return v0
319
+ return cand.get(live_key)
320
+
321
+ snap = {
322
+ "likes": _pick("likes_t0", "likes"),
323
+ "retweets": _pick("retweets_t0", "retweets"),
324
+ "replies": _pick("replies_t0", "replies"),
325
+ "views": _pick("views_t0", "views"),
326
+ "bookmarks": _pick("bookmarks_t0", "bookmarks"),
327
+ }
328
+ # Skip when every field is NULL/missing — nothing worth recording.
329
+ if not any(v is not None for v in snap.values()):
330
+ return None
331
+ snap["source"] = "discovery_t0"
332
+ discovered = cand.get("discovered_at")
333
+ if discovered:
334
+ snap["snapshot_at"] = str(discovered)
335
+ return json.dumps(snap, separators=(",", ":"))
336
+
337
+
338
+ def fetch_thread_media_snapshot(cid: int) -> str | None:
339
+ """Fetch the candidate's thread_media (captured in Phase 2b-prep by
340
+ capture_thread_media.py) as a compact JSON-array string, ready for the
341
+ posts.thread_media JSONB column (2026-06-03 thread-media feature).
342
+
343
+ Reads from /api/v1/twitter-candidates/by-id?id=<cid>, the same endpoint the
344
+ engagement snapshot uses. No browser, no live refresh: whatever media the
345
+ prep step persisted onto the candidate row is what gets frozen onto the
346
+ post as an immutable audit record of what the thread visually showed.
347
+
348
+ Returns:
349
+ - A JSON array string (e.g. '[{"url":"...","alt":"...","type":"image"}]')
350
+ when the candidate has a non-empty thread_media array.
351
+ - '[]' when media was captured but the thread had none (captured-none is
352
+ meaningful and worth recording, distinct from never-captured).
353
+ - None when the row is missing, thread_media is NULL (never captured), or
354
+ any error occurs. We never block the post on this.
355
+ """
356
+ try:
357
+ resp = api_get(
358
+ "/api/v1/twitter-candidates/by-id",
359
+ query={"id": int(cid)},
360
+ ok_on_404=True,
361
+ )
362
+ except SystemExit as e:
363
+ print(f"[post] candidate {cid} thread_media fetch failed: {e}", flush=True)
364
+ return None
365
+ if resp.get("_not_found"):
366
+ return None
367
+ cand = (resp.get("data") or {}).get("candidate") or {}
368
+ media = cand.get("thread_media")
369
+ # NULL on the row = never captured (capture disabled or pre-feature row):
370
+ # leave posts.thread_media NULL too. An empty list = captured-none: record it.
371
+ if media is None:
372
+ return None
373
+ if not isinstance(media, list):
374
+ return None
375
+ return json.dumps(media, separators=(",", ":"))
376
+
377
+
378
+ def update_candidate_posted(cid: int, post_id: int,
379
+ matched_project=None, search_topic=None) -> None:
380
+ """Mark the candidate posted via /api/v1/twitter-candidates/by-id.
381
+
382
+ Re-stamps batch_id to the executing cycle's BATCH_ID alongside the
383
+ status='posted' flip. Belt-and-suspenders against peer-cycle Phase 0
384
+ salvage races: salvage can rewrite our candidate's batch_id while we are
385
+ mid-Phase-2b (observed 2026-05-15 with twcycle-20260515-171505's 6 posts
386
+ mis-attributed to twcycle-20260515-180005 after the latter salvaged them
387
+ while 171505 was queued behind 173005's 42-min Phase 1 lock-hold).
388
+ When BATCH_ID env is unset (manual replays, ad-hoc runs), fall back to
389
+ leaving batch_id alone so we never NULL-out a live attribution.
390
+
391
+ Cross-route writeback (2026-05-29): the Phase 2b prep step can re-route a
392
+ candidate to a better-fitting project than the Phase 1 query that surfaced
393
+ it. matched_project carries the project the post actually landed on; it is
394
+ sent on EVERY post (not just re-routes) so twitter_candidates.matched_project
395
+ always equals posts.project_name. search_topic is the plan's topic, which is
396
+ "" on a re-route (the by-id route clears "" to NULL, because the origin
397
+ query's topic does not belong to the routed project). Both are honoured by
398
+ the by-id route as of 2026-05-29; older deploys ignore unknown body fields
399
+ harmlessly, so this is safe to ship ahead of the route.
400
+ """
401
+ body = {
402
+ "id": int(cid),
403
+ "action": "mark_posted",
404
+ "post_id": int(post_id),
405
+ }
406
+ batch_id = (os.environ.get("BATCH_ID") or "").strip()
407
+ if batch_id:
408
+ body["batch_id"] = batch_id
409
+ if matched_project:
410
+ body["matched_project"] = matched_project
411
+ # Send even when empty: "" tells the route to CLEAR search_topic to NULL on
412
+ # a re-route. Only omit when the caller passed nothing at all (None).
413
+ if search_topic is not None:
414
+ body["search_topic"] = search_topic
415
+ try:
416
+ api_patch("/api/v1/twitter-candidates/by-id", body)
417
+ except SystemExit as e:
418
+ print(f"[post] candidate {cid} -> posted update failed: {e}", flush=True)
419
+
420
+
421
+ def post_one(c: dict, picker_assignment: dict | None = None) -> tuple[str, str]:
422
+ """Post a single candidate. Returns (outcome, reason).
423
+
424
+ outcome: 'posted' | 'skipped' | 'failed'
425
+ reason: short failure key when outcome != 'posted', else ''.
426
+
427
+ picker_assignment: optional {assigned_style, assigned_mode} dict
428
+ sourced from the plan envelope. When present, drives the
429
+ validate_or_register call below so USE-mode drift coerces back
430
+ and INVENT-mode new_style blocks land in
431
+ engagement_styles_registry. None means legacy behaviour
432
+ (uncoerced; whatever the model said is what gets logged).
433
+ """
434
+ cid = int(c["candidate_id"])
435
+ candidate_url = c["candidate_url"]
436
+ reply_text = (c.get("reply_text") or "").strip()
437
+ link_url = (c.get("link_url") or "").strip()
438
+ project = c["matched_project"]
439
+ thread_author = c.get("thread_author") or ""
440
+ thread_text = c.get("thread_text") or ""
441
+ # Engagement-style enforcement (2026-05-22 cutover). Twitter is now
442
+ # symmetric with Reddit/GitHub/Moltbook: the draft phase pre-picks an
443
+ # assignment via saps_pick_style; the post phase calls
444
+ # validate_or_register(decision, assigned_style=..., assigned_mode=...)
445
+ # which coerces USE drift back to the assigned name OR registers
446
+ # INVENT inventions into engagement_styles_registry via
447
+ # POST /api/v1/engagement-styles/registry. The picker assignment flows
448
+ # in via the plan envelope (picker_assignment param); the model's
449
+ # optional new_style block flows in via the candidate dict itself.
450
+ raw_style = (c.get("engagement_style") or "").strip()
451
+ new_style_block = c.get("new_style") if isinstance(c.get("new_style"), dict) else None
452
+ if validate_or_register is not None and raw_style:
453
+ assigned_style = (picker_assignment or {}).get("assigned_style") or None
454
+ assigned_mode = (picker_assignment or {}).get("assigned_mode") or None
455
+ decision = {
456
+ "engagement_style": raw_style,
457
+ # Only attach new_style when the model actually shipped one;
458
+ # validate_or_register treats None as "no new_style block"
459
+ # and never registers anything in that case.
460
+ **({"new_style": new_style_block} if new_style_block else {}),
461
+ }
462
+ try:
463
+ coerced_style, action = validate_or_register(
464
+ decision,
465
+ source_post={
466
+ "platform": "twitter",
467
+ "post_url": candidate_url,
468
+ "post_id": None,
469
+ "model": None,
470
+ },
471
+ assigned_style=assigned_style,
472
+ assigned_mode=assigned_mode,
473
+ )
474
+ except Exception as e:
475
+ # Never let a registry/API hiccup block posting. Fall back to
476
+ # the raw model output; the post still lands, just without
477
+ # picker coercion for this one row.
478
+ print(f"[post] candidate {cid}: validate_or_register raised {e!r}; "
479
+ f"falling back to raw style={raw_style!r}", flush=True)
480
+ coerced_style, action = raw_style, "rejected"
481
+ if action == "coerced" and coerced_style != raw_style:
482
+ print(f"[post] candidate {cid}: engagement_style coerced "
483
+ f"{raw_style!r} -> {coerced_style!r} (assigned={assigned_style!r})",
484
+ flush=True)
485
+ elif action == "registered":
486
+ print(f"[post] candidate {cid}: registered new engagement_style "
487
+ f"{coerced_style!r} into engagement_styles_registry",
488
+ flush=True)
489
+ style = (coerced_style or raw_style or "").strip()
490
+ else:
491
+ style = raw_style
492
+ # target_chars SNAPSHOT: freeze the assigned style's target comment length
493
+ # onto this post so style_length_report can compare realized-vs-target
494
+ # without being fooled by later registry drift (the human_derived
495
+ # synthesizer retunes targets daily). Resolve from the FINAL coerced style
496
+ # name via the registry; fall back to DEFAULT_TARGET_CHARS, then to None
497
+ # (column is nullable; the report falls back to the live target for NULL).
498
+ target_chars = None
499
+ if style:
500
+ try:
501
+ from engagement_styles import get_all_styles, DEFAULT_TARGET_CHARS
502
+ meta = get_all_styles().get(style) or {}
503
+ target_chars = meta.get("target_chars") or DEFAULT_TARGET_CHARS
504
+ except Exception as e:
505
+ print(f"[post] candidate {cid}: target_chars lookup failed ({e}); "
506
+ f"leaving NULL", flush=True)
507
+ target_chars = None
508
+ language = (c.get("language") or "").strip()
509
+ link_source = (c.get("link_source") or "").strip()
510
+ # search_topic flows from twitter_candidates -> Phase 2b prompt
511
+ # ("Search query: <topic>") -> prep envelope -> here. Stamped on
512
+ # posts.search_topic so top_search_topics.py can aggregate per-topic
513
+ # conversion (clicks / likes / views) and feed the next cycle's Phase 1
514
+ # which topics to favour or drop. Reddit/GitHub already populate this;
515
+ # Twitter was a coverage gap (0/3,280 rows) until the 2026-05-25 wiring.
516
+ search_topic = (c.get("search_topic") or "").strip()
517
+
518
+ if not reply_text:
519
+ print(f"[post] candidate {cid}: empty reply_text; skipping", flush=True)
520
+ update_candidate(cid, "skipped")
521
+ return ("skipped", "empty_reply_text")
522
+
523
+ # Pre-post dedup race guard. See already_posted_to_thread() docstring
524
+ # for the full failure mode this closes (overlapping cycles double-
525
+ # posting because Phase 0 salvage runs before the peer cycle has
526
+ # INSERTed into posts). Skip without calling reply_to_tweet so we
527
+ # don't burn a second reply tweet on a thread we've already engaged.
528
+ pre_dup, pre_dup_pid = already_posted_to_thread(candidate_url)
529
+ if pre_dup:
530
+ print(
531
+ f"[post] candidate {cid}: pre-post dedup hit "
532
+ f"(existing post_id={pre_dup_pid}, thread={candidate_url}); "
533
+ f"skipping reply call",
534
+ flush=True,
535
+ )
536
+ update_candidate(cid, "skipped")
537
+ return ("skipped", "duplicate_thread_pre_post")
538
+
539
+ # CTA bridge generation: instead of bolting `link_url` onto `reply_text`
540
+ # with a space (the old `f"{reply_text} {link_url}"`), call link_tail.py
541
+ # which spawns one Claude call (default smart model, NOT Haiku) to write
542
+ # a 1-sentence bridge that names a concrete benefit and ends in the URL.
543
+ # On any failure (timeout, model error, output fails sanity gate) the
544
+ # script returns the mechanical concat as a fallback, so this code path
545
+ # is always tolerant of model failure.
546
+ #
547
+ # AB TEST — tail link on/off:
548
+ # TWITTER_TAIL_LINK_RATE (float 0..1, default 0.5) controls the fraction
549
+ # of posts that receive a tail link. Setting it to 1.0 restores old
550
+ # behavior (always add link). Setting it to 0.0 disables links entirely.
551
+ # tail_link_variant is logged to posts.tail_link_variant so the dashboard
552
+ # can compare engagement across arms.
553
+ _tail_link_rate = float(os.environ.get("TWITTER_TAIL_LINK_RATE", "0.5"))
554
+ _add_tail_link = link_url and (random.random() < _tail_link_rate)
555
+ tail_link_variant: str | None = None
556
+ if link_url:
557
+ tail_link_variant = "link" if _add_tail_link else "no_link"
558
+ full_text = reply_text
559
+ link_tail_outcome = "skipped_no_link"
560
+ if _add_tail_link:
561
+ rc, out, err = run_subprocess(
562
+ [PYTHON, LINK_TAIL,
563
+ "--reply-text", reply_text,
564
+ "--link-url", link_url,
565
+ "--thread-text", thread_text or "",
566
+ "--project", project,
567
+ "--platform", "twitter",
568
+ "--timeout", "120"],
569
+ timeout_sec=180,
570
+ )
571
+ tail_obj = parse_last_json_object(out) or {}
572
+ if tail_obj.get("text"):
573
+ full_text = tail_obj["text"]
574
+ if tail_obj.get("model_call_ok") and not tail_obj.get("fallback_used"):
575
+ link_tail_outcome = "bridge_generated"
576
+ else:
577
+ link_tail_outcome = f"fallback:{tail_obj.get('error', 'unknown')[:60]}"
578
+ else:
579
+ # link_tail.py is supposed to ALWAYS return JSON; if we got
580
+ # nothing, hard-fall-back to the mechanical concat to preserve
581
+ # prior behavior (post still ships, link still on the wire).
582
+ full_text = f"{reply_text} {link_url}".strip()
583
+ link_tail_outcome = f"hard_fallback_no_json:rc={rc}"
584
+ print(f"[post] candidate {cid} link_tail: {link_tail_outcome} "
585
+ f"(elapsed={tail_obj.get('elapsed_sec')}s)", flush=True)
586
+ elif link_url and not _add_tail_link:
587
+ # No-link arm of the AB test: post the reply text as-is (no CTA bridge,
588
+ # no URL). Log the outcome so the dashboard can tally the arm.
589
+ link_tail_outcome = "ab_no_link"
590
+ print(f"[post] candidate {cid} link_tail: {link_tail_outcome} "
591
+ f"(tail_link_variant=no_link, rate={_tail_link_rate})", flush=True)
592
+
593
+ # URL-wrap the text BEFORE handing it to twitter_browser. The browser
594
+ # script appends the campaign suffix internally; suffixes are plain
595
+ # text in practice, so URLs in the suffix won't be wrapped (documented
596
+ # caveat). All URLs in reply_text + link_url get minted into post_links
597
+ # with NULL post_id; we backfill with post_id below after log_post.py
598
+ # returns.
599
+ minted_session = None
600
+ try:
601
+ from dm_short_links import wrap_text_for_post, utm_only_text
602
+ wrap_res = wrap_text_for_post(text=full_text, platform="twitter",
603
+ project_name=project)
604
+ if wrap_res.get("ok"):
605
+ full_text = wrap_res["text"]
606
+ minted_session = wrap_res.get("minted_session")
607
+ if wrap_res.get("codes"):
608
+ print(f"[post] candidate {cid} wrapped {len(wrap_res['codes'])} URL(s): "
609
+ f"{wrap_res['codes']}", flush=True)
610
+ else:
611
+ print(f"[post] candidate {cid} WARNING: URL wrap failed "
612
+ f"({wrap_res.get('error')}); falling back to UTM-only", flush=True)
613
+ full_text = utm_only_text(text=full_text, platform="twitter", project_name=project)
614
+ except Exception as e:
615
+ print(f"[post] candidate {cid} WARNING: URL wrap raised ({e}); "
616
+ f"falling back to UTM-only", flush=True)
617
+ try:
618
+ from dm_short_links import utm_only_text
619
+ full_text = utm_only_text(text=full_text, platform="twitter", project_name=project)
620
+ except Exception as ee:
621
+ print(f"[post] candidate {cid} WARNING: UTM-only fallback also failed ({ee}); "
622
+ f"posting unwrapped", flush=True)
623
+
624
+ print(f"[post] candidate {cid} -> posting (link={link_url!r})", flush=True)
625
+ rc, out, err = run_subprocess(
626
+ [PYTHON, TWITTER_BROWSER, "reply", candidate_url, full_text],
627
+ timeout_sec=600,
628
+ )
629
+ if err:
630
+ # Surface stderr verbatim for the cycle log; reply_to_tweet logs to
631
+ # stderr extensively so this is intentional debugging context.
632
+ print(f"[post][reply.stderr]\n{err}", flush=True)
633
+ if out:
634
+ print(f"[post][reply.stdout]\n{out}", flush=True)
635
+
636
+ parsed = parse_last_json_object(out) or {}
637
+ if not parsed.get("ok"):
638
+ reason = parsed.get("error") or "no_reply_json"
639
+ print(f"[post] candidate {cid} reply failed: {reason}", flush=True)
640
+ if reason in ("rate_limited", "tweet_not_found", "reply_box_not_found",
641
+ "reply_restricted", "tweet_unavailable"):
642
+ # reply_restricted / tweet_unavailable are PERMANENT, thread-intrinsic
643
+ # conditions (the author limits who can reply, or the tweet is gone):
644
+ # record the specific skip_reason so discovery can suppress the thread
645
+ # (and, for restrictions, the author) and never burn another draft
646
+ # re-attempting it.
647
+ update_candidate(cid, "skipped", reason)
648
+ return ("skipped", reason)
649
+ # everything else (incl. timeout, parse errors): the reply did NOT
650
+ # land, so mark skipped (NOT a DB 'failed' status — that violates the
651
+ # check constraint) with the reason, but report 'failed' to the run
652
+ # summary so the dashboard reflects the real failure.
653
+ update_candidate(cid, "skipped", reason if reason else "reply_failed")
654
+ return ("failed", reason if reason else "unknown")
655
+
656
+ reply_url = parsed.get("reply_url") or ""
657
+ final_text = parsed.get("final_text") or full_text
658
+ applied_campaigns = parsed.get("applied_campaigns") or []
659
+ # Snapshot the top human replies on the thread at post-success time.
660
+ # twitter_browser.reply_to_tweet scrapes them while the page is still on
661
+ # the candidate URL with replies visible. List is already filtered (self
662
+ # + thread author removed), sorted by likes DESC, capped at 3.
663
+ top_replies = parsed.get("top_replies") or []
664
+
665
+ # Auto-like outcome (reply_to_tweet likes the parent tweet after the reply
666
+ # lands). Log pass/fail to the cycle log so we have a record on our end.
667
+ # A like failure is non-fatal: the reply already landed.
668
+ like_result = parsed.get("like_result") or {}
669
+ if parsed.get("liked"):
670
+ print(
671
+ f"[like] candidate {cid} parent tweet liked "
672
+ f"(already_liked={like_result.get('already_liked', False)})",
673
+ flush=True,
674
+ )
675
+ else:
676
+ err = str(like_result.get("error", "unknown")).splitlines()[0]
677
+ print(f"[like] candidate {cid} parent tweet not liked (non-fatal): {err}", flush=True)
678
+
679
+ if not reply_url or not REPLY_URL_RE.match(reply_url):
680
+ # Reply was likely sent (browser action returned ok=True with verified)
681
+ # but the URL capture in twitter_browser.py couldn't pin it down — CDP
682
+ # network interception missed the CreateTweet response and the DOM diff
683
+ # found no new /m13v_/status link. Method 3 (profile-page scrape) was
684
+ # removed 2026-05-01 because it cross-contaminated under parallel
685
+ # cycles. Mark SKIPPED, not FAILED, so the candidate is NOT re-tried
686
+ # next cycle — re-trying when the prior reply already landed creates
687
+ # a duplicate on Twitter. Salvage's posts.thread_url guard would catch
688
+ # it eventually but only after the candidate sat through one more
689
+ # cycle of wasted Claude work.
690
+ print(f"[post] candidate {cid} reply succeeded but reply_url invalid: {reply_url!r}",
691
+ flush=True)
692
+ update_candidate(cid, "skipped", "no_reply_url_captured")
693
+ return ("skipped", "no_reply_url_captured")
694
+
695
+ # Insert the post row.
696
+ # Pass --account explicitly so log_post.py stamps posts.our_account with
697
+ # this machine's configured Twitter handle (e.g. `m13v_` on the local
698
+ # cron, `matt_diak` on the VM). Without this, log_post.py falls back
699
+ # through twitter_account.resolve_handle() to the same value, but
700
+ # forwarding it here makes the per-machine identity visible in the
701
+ # subprocess argv (useful for grep'ing run logs to confirm scoping).
702
+ sys.path.insert(0, os.path.join(REPO_DIR, "scripts"))
703
+ from twitter_account import resolve_handle as _resolve_twitter_handle
704
+
705
+ log_args = [
706
+ PYTHON, LOG_POST,
707
+ "--platform", "twitter",
708
+ "--thread-url", candidate_url,
709
+ "--our-url", reply_url,
710
+ "--our-content", final_text,
711
+ "--project", project,
712
+ "--thread-author", thread_author,
713
+ "--thread-title", thread_text,
714
+ ]
715
+ twitter_handle = _resolve_twitter_handle()
716
+ if twitter_handle:
717
+ log_args += ["--account", twitter_handle]
718
+ if style:
719
+ log_args += ["--engagement-style", style]
720
+ if target_chars:
721
+ log_args += ["--target-chars", str(target_chars)]
722
+ if language:
723
+ log_args += ["--language", language]
724
+ if link_source:
725
+ log_args += ["--link-source", link_source]
726
+ if search_topic:
727
+ log_args += ["--search-topic", search_topic]
728
+ if tail_link_variant:
729
+ log_args += ["--tail-link-variant", tail_link_variant]
730
+ # Draft-prompt A/B arm: assigned ONCE per cycle in run-twitter-cycle.sh and
731
+ # exported as S4L_DRAFT_PROMPT_VARIANT, so every post this cycle inherits the
732
+ # same arm (the whole prep batch shared one draft directive). Stamp it onto
733
+ # posts.draft_prompt_variant, mirroring tail_link_variant.
734
+ draft_prompt_variant = os.environ.get("S4L_DRAFT_PROMPT_VARIANT") or None
735
+ if draft_prompt_variant:
736
+ log_args += ["--draft-prompt-variant", draft_prompt_variant]
737
+ # LENGTH A/B concluded 2026-06-04; future production posts are no longer
738
+ # stamped into posts.length_arm so the archived experiment readout stays
739
+ # frozen to the actual test window.
740
+ # Generation trace: run-twitter-cycle.sh writes a snapshot of the
741
+ # cycle's few-shot context (top_performers, top_queries, supply
742
+ # signal, dud queries) to a tempfile and exports the path via
743
+ # S4L_TWITTER_GEN_TRACE_PATH. Forward to log_post.py so every
744
+ # post landed this cycle gets posts.generation_trace JSONB pointing
745
+ # to the same snapshot. Same trace for every post in this run
746
+ # because they all saw the same Phase 2b-prep context. The env var
747
+ # is missing/empty when run-twitter-cycle.sh's trace step failed —
748
+ # in that case we just skip the flag and the row gets NULL trace.
749
+ trace_path = os.environ.get("S4L_TWITTER_GEN_TRACE_PATH") or ""
750
+ if trace_path and os.path.isfile(trace_path):
751
+ log_args += ["--generation-trace", trace_path]
752
+
753
+ # T0 engagement of the original thread (captured at discovery, NOT live).
754
+ # Read from twitter_candidates via the by-id GET endpoint. No fxtwitter
755
+ # call, no extra page-load: whatever score_twitter_candidates.py stamped
756
+ # into *_t0 at scrape time is what we record. Stored as a JSON string
757
+ # in posts.thread_engagement (TEXT). Silently skip on any failure;
758
+ # losing one snapshot row is preferable to losing the post.
759
+ thread_engagement_json = fetch_thread_engagement_snapshot(cid)
760
+ if thread_engagement_json:
761
+ log_args += ["--thread-engagement", thread_engagement_json]
762
+ print(f"[post] candidate {cid} thread_engagement snapshot: "
763
+ f"{thread_engagement_json}", flush=True)
764
+ else:
765
+ print(f"[post] candidate {cid} thread_engagement snapshot: none "
766
+ f"(no T0 data on candidate row)", flush=True)
767
+
768
+ # Thread media snapshot (2026-06-03): freeze the candidate's captured media
769
+ # onto posts.thread_media. Reads thread_media off the candidate row (set in
770
+ # Phase 2b-prep by capture_thread_media.py). None when capture was disabled
771
+ # or the row pre-dates the feature; '[]' when the thread genuinely had none.
772
+ thread_media_json = fetch_thread_media_snapshot(cid)
773
+ if thread_media_json is not None:
774
+ log_args += ["--thread-media", thread_media_json]
775
+ print(f"[post] candidate {cid} thread_media snapshot: "
776
+ f"{thread_media_json}", flush=True)
777
+
778
+ rc, out, err = run_subprocess(log_args, timeout_sec=60)
779
+ if err:
780
+ print(f"[post][log_post.stderr]\n{err}", flush=True)
781
+ if out:
782
+ print(f"[post][log_post.stdout]\n{out}", flush=True)
783
+ log_obj = parse_last_json_object(out) or {}
784
+ post_id = log_obj.get("post_id")
785
+ if not post_id:
786
+ print(f"[post] candidate {cid} log_post.py did not return post_id; raw={out!r}",
787
+ flush=True)
788
+ # The reply IS posted; the data layer just lost the row. We MUST keep
789
+ # the candidate's DB status as 'skipped' so it isn't retried (which
790
+ # would double-post on x.com). But the run-summary outcome should be
791
+ # 'failed' so the dashboard reflects reality: posted=0, failed=N.
792
+ # Previously this returned 'skipped', which silently hid backend
793
+ # logging outages (e.g. the /api/v1/posts 5000/24h rate-limit cap)
794
+ # behind a benign-looking metric.
795
+ update_candidate(cid, "skipped", "log_post_no_id")
796
+ return ("failed", "log_post_no_id")
797
+
798
+ # Stamp post_links.post_id for the URLs minted at wrap time. Idempotent;
799
+ # no-op when minted_session is None (no URLs in the original text).
800
+ if minted_session:
801
+ try:
802
+ from dm_short_links import backfill_post_id
803
+ backfill_post_id(minted_session=minted_session, post_id=post_id)
804
+ except Exception as e:
805
+ print(f"[post] candidate {cid} WARNING: backfill_post_id failed ({e})",
806
+ flush=True)
807
+
808
+ # Campaign attribution.
809
+ for ccid in applied_campaigns:
810
+ rc, out, err = run_subprocess(
811
+ [PYTHON, CAMPAIGN_BUMP, "--table", "posts",
812
+ "--id", str(post_id), "--campaign-id", str(ccid)],
813
+ timeout_sec=30,
814
+ )
815
+ if err:
816
+ print(f"[post][campaign_bump.stderr] cid={ccid} {err}", flush=True)
817
+ if out:
818
+ print(f"[post][campaign_bump.stdout] cid={ccid} {out}", flush=True)
819
+
820
+ # Mark link_edited_at: link is embedded in primary reply, no self-reply
821
+ # will follow. Prevents link-edit-twitter sweep from re-attempting.
822
+ rc, out, err = run_subprocess(
823
+ [PYTHON, LOG_POST,
824
+ "--mark-self-reply",
825
+ "--post-id", str(post_id),
826
+ "--self-reply-url", reply_url,
827
+ "--self-reply-content", final_text],
828
+ timeout_sec=30,
829
+ )
830
+ if err:
831
+ print(f"[post][mark-self-reply.stderr] {err}", flush=True)
832
+ if out:
833
+ print(f"[post][mark-self-reply.stdout] {out}", flush=True)
834
+
835
+ update_candidate_posted(cid, post_id,
836
+ matched_project=project, search_topic=search_topic)
837
+ print(f"[post] candidate {cid} posted as {reply_url} (post_id={post_id})",
838
+ flush=True)
839
+ # Stash the live URL on the candidate so main() can include it in the durable
840
+ # post-results audit log (so the menu bar/dashboard can surface "posted N + links").
841
+ c["our_url"] = reply_url
842
+
843
+ # Persist the human-top-replies snapshot via the s4l.ai routes. We POST
844
+ # even when top_replies is empty so posts.top_replies_captured_at is
845
+ # stamped and the "did we attempt capture?" gate doesn't keep retrying
846
+ # threads that had genuinely zero competitor replies. Failure here is
847
+ # non-fatal: the reply IS posted and logged; missing snapshot only loses
848
+ # one row of benchmark data, not the run.
849
+ try:
850
+ ttr_payload = {
851
+ "post_id": post_id,
852
+ "platform": "twitter",
853
+ "thread_url": candidate_url,
854
+ "replies": [
855
+ {
856
+ "rank": rank,
857
+ "reply_url": r.get("reply_url"),
858
+ "reply_tweet_id": r.get("reply_tweet_id"),
859
+ "reply_author": r.get("reply_author"),
860
+ "reply_author_handle": r.get("reply_author_handle"),
861
+ "reply_content": r.get("reply_content"),
862
+ "likes": r.get("likes"),
863
+ "replies": r.get("replies"),
864
+ "retweets": r.get("retweets"),
865
+ "views": r.get("views"),
866
+ # Link metadata (2026-05-22). reply_link_url is the t.co
867
+ # shortlink twitter wraps every external URL with;
868
+ # reply_link_display is what the user sees in the tweet
869
+ # (e.g. "deno.com/blog/agents"). Either may be null when
870
+ # the reply contains no outbound link (the typical case
871
+ # for rank=1; the typical NON-null case for rank=2).
872
+ "reply_link_url": r.get("reply_link_url"),
873
+ "reply_link_display": r.get("reply_link_display"),
874
+ }
875
+ for rank, r in enumerate(top_replies, start=1)
876
+ if r.get("reply_url")
877
+ ],
878
+ }
879
+ ttr_res = api_post("/api/v1/thread-top-replies", ttr_payload)
880
+ print(f"[post] candidate {cid} thread_top_replies "
881
+ f"inserted={ttr_res.get('inserted_count')} "
882
+ f"requested={ttr_res.get('requested_count')}",
883
+ flush=True)
884
+ except Exception as e:
885
+ print(f"[post] candidate {cid} WARNING: thread_top_replies POST failed ({e})",
886
+ flush=True)
887
+
888
+ return ("posted", "")
889
+
890
+
891
+ def _saps_state_dir() -> str:
892
+ return os.environ.get("S4L_STATE_DIR") or os.path.join(
893
+ os.path.expanduser("~"), ".social-autoposter-mcp")
894
+
895
+
896
+ def _write_activity(label: str) -> None:
897
+ """Best-effort live status for the S4L menu bar, which polls
898
+ <state_dir>/activity.json. Mirrors the Node server's writeActivity shape so
899
+ the menu-bar spinner renders our per-post progress ("posting 3/10", then
900
+ "posted 3/10 ✓"). Purely cosmetic: a failure here never affects posting."""
901
+ try:
902
+ sd = _saps_state_dir()
903
+ os.makedirs(sd, exist_ok=True)
904
+ payload = {"state": "working", "label": label,
905
+ "since": datetime.now(timezone.utc).isoformat()}
906
+ with open(os.path.join(sd, "activity.json"), "w", encoding="utf-8") as f:
907
+ f.write(json.dumps(payload) + "\n")
908
+ except Exception:
909
+ pass
910
+
911
+
912
+ def _clear_activity() -> None:
913
+ """Remove our status so neither an early exit nor the cron path (which does
914
+ NOT go through the MCP runTool's clear) leaves a stale 'posting/posted' stuck
915
+ in the menu bar. Double-clearing with runTool is harmless."""
916
+ try:
917
+ p = os.path.join(_saps_state_dir(), "activity.json")
918
+ if os.path.exists(p):
919
+ os.remove(p)
920
+ except Exception:
921
+ pass
922
+
923
+
924
+ def main() -> int:
925
+ ap = argparse.ArgumentParser()
926
+ ap.add_argument("--plan", required=True,
927
+ help="Path to the plan JSON file (read-only here)")
928
+ ap.add_argument("--post-unapproved", action="store_true",
929
+ help="Post candidates even when the plan marks them "
930
+ "approved=false. The MCP review path already filters to "
931
+ "approved-only, and autopilot/legacy plans omit the key; "
932
+ "this is the explicit override for an intentional direct run.")
933
+ args = ap.parse_args()
934
+
935
+ plan_path = Path(args.plan)
936
+ if not plan_path.exists():
937
+ print(f"[post] plan file not found: {plan_path}", file=sys.stderr)
938
+ return 2
939
+ try:
940
+ plan = json.loads(plan_path.read_text(encoding="utf-8"))
941
+ except Exception as e:
942
+ print(f"[post] plan file unreadable: {e}", file=sys.stderr)
943
+ return 2
944
+
945
+ candidates = plan.get("candidates") or []
946
+
947
+ # Re-export the prep session id into env so log_post.py stamps
948
+ # posts.claude_session_id and the dashboard activity feed can join to
949
+ # claude_sessions for cost. The parent shell pre-assigns this in Phase
950
+ # 2b-prep and writes it into the plan JSON; the env var doesn't survive
951
+ # the prep command-substitution subshell, so we restore it here.
952
+ plan_session_id = plan.get("session_id")
953
+ if plan_session_id:
954
+ os.environ["CLAUDE_SESSION_ID"] = plan_session_id
955
+
956
+ # Pull the picker assignment from the plan envelope (written by
957
+ # run-twitter-cycle.sh after saps_pick_style). Shared across every
958
+ # candidate in the batch because the picker fires once per cycle.
959
+ # Falls back to None on legacy plans (pre-2026-05-22 envelopes that
960
+ # don't carry these keys); post_one then runs the legacy uncoerced
961
+ # path. Empty assigned_style + assigned_mode='invent' means the
962
+ # picker rolled INVENT this cycle; validate_or_register treats that
963
+ # as "register if the model produced a well-formed new_style block".
964
+ picker_assignment = {
965
+ "assigned_style": plan.get("assigned_style") or None,
966
+ "assigned_mode": plan.get("assigned_mode") or None,
967
+ }
968
+ if picker_assignment["assigned_mode"]:
969
+ print(f"[post] picker assignment for batch: "
970
+ f"mode={picker_assignment['assigned_mode']} "
971
+ f"style={picker_assignment['assigned_style'] or '(invent)'}",
972
+ flush=True)
973
+
974
+ posted = skipped = failed = 0
975
+ # Split skip vs fail reasons. The dashboard renders `failure_reasons` as
976
+ # a "failed: <reason>" pill, so intentional skips (duplicate_thread_pre_post,
977
+ # empty_reply_text, rate_limited, tweet_not_found, reply_box_not_found,
978
+ # no_reply_url_captured) MUST NOT land in this bucket; otherwise a clean
979
+ # dedup-only cycle (posted=2, failed=0) misrenders as
980
+ # "failed: duplicate_thread_pre_post 3" which is exactly the wrong signal.
981
+ fail_reasons: dict[str, int] = {}
982
+ skip_reasons: dict[str, int] = {}
983
+
984
+ # Approval gate. A plan that went through the MCP review carries an
985
+ # `approved` flag per candidate (set in mcp/dist/index.js). Honor it here so
986
+ # a DIRECT `--plan` run — bypassing the elicitation form — can't publish
987
+ # drafts the user never ticked. Plans that never had review (autopilot,
988
+ # legacy) omit the key entirely and pass through untouched. Override with
989
+ # --post-unapproved.
990
+ if not args.post_unapproved:
991
+ _kept = []
992
+ for c in candidates:
993
+ if "approved" in c and not c.get("approved"):
994
+ skipped += 1
995
+ skip_reasons["not_approved"] = skip_reasons.get("not_approved", 0) + 1
996
+ else:
997
+ _kept.append(c)
998
+ if skip_reasons.get("not_approved"):
999
+ print(f"[post] {skip_reasons['not_approved']} candidate(s) skipped: not "
1000
+ f"approved in plan (pass --post-unapproved to override)", flush=True)
1001
+ candidates = _kept
1002
+
1003
+ # Hard preflight: the reply path (twitter_browser.py) imports Playwright,
1004
+ # the only such importer in the pipeline. If the resolved interpreter can't
1005
+ # import it, EVERY post dies with no_reply_json because the owned runtime is
1006
+ # missing or half-provisioned (Karol, 2026-06-22). Fail LOUD here with a
1007
+ # distinct signal instead of attempting posts that silently no-op. Gated on
1008
+ # there being real work, so a no-op / all-skipped plan still exits clean.
1009
+ if candidates:
1010
+ _chk = subprocess.run(
1011
+ [PYTHON, "-c", "import playwright"],
1012
+ capture_output=True, text=True,
1013
+ )
1014
+ if _chk.returncode != 0:
1015
+ print(f"[post] FATAL runtime_incomplete: interpreter {PYTHON!r} cannot "
1016
+ f"import playwright — the owned Python runtime is missing or "
1017
+ f"unprovisioned. Run the `runtime` install (action:'install') "
1018
+ f"before posting. stderr: {(_chk.stderr or '').strip()[:300]}",
1019
+ file=sys.stderr, flush=True)
1020
+ print(json.dumps({
1021
+ "posted": 0,
1022
+ "skipped": 0,
1023
+ "failed": len(candidates),
1024
+ "failure_reasons": "runtime_incomplete",
1025
+ "skip_reasons": "",
1026
+ }), flush=True)
1027
+ return 3
1028
+
1029
+ _total = len(candidates)
1030
+
1031
+ # ---- Batch-level browser-lock hold (cross-process posting priority) --------
1032
+ # Hold the Twitter browser lock for the WHOLE approved batch instead of
1033
+ # re-acquiring it per candidate. Per-candidate acquisition freed the lock in
1034
+ # the gap between replies (dominated by link_tail's `claude -p` call, ~5-20s),
1035
+ # and the autopilot scan fires every 60s, so a scan kept slipping into that gap
1036
+ # and seizing the browser mid-batch -- the exact "posting gets cut off" symptom
1037
+ # on the remote box. Acquiring ONCE here PREEMPTS any live scan (role:"post"
1038
+ # priority) and, by exporting our session id as S4L_LOCK_OWNER, makes every
1039
+ # child twitter_browser.py reply INHERIT this hold rather than contend for it,
1040
+ # closing the gap. The hold is bounded by the posting-specific POST_LOCK_EXPIRY
1041
+ # failsafe in twitter_browser (a hung poster self-clears in <=180s; a crashed
1042
+ # one frees instantly via dead-pid reclaim), so it can never wedge the browser
1043
+ # indefinitely. Best-effort: if the import or acquire fails we simply fall back
1044
+ # to the legacy per-candidate acquisition (children still preempt scans one by
1045
+ # one), so posting degrades gracefully and is never blocked by this addition.
1046
+ _tb = None
1047
+ _batch_lock_held = False
1048
+ if candidates:
1049
+ try:
1050
+ import twitter_browser as _tb # S4L_LOCK_ROLE=post already set above
1051
+ _tb._acquire_browser_lock() # preempts a live scan; sys.exit(1) if contended
1052
+ os.environ["S4L_LOCK_OWNER"] = _tb._LOCK_SESSION_ID
1053
+ _batch_lock_held = True
1054
+ print(f"[post] batch lock held by {_tb._LOCK_SESSION_ID} (role=post); "
1055
+ f"{_total} candidate(s) inherit it", flush=True)
1056
+ except SystemExit:
1057
+ # _acquire_browser_lock exits when a LIVE non-preemptable peer (another
1058
+ # poster) holds the lock past LOCK_WAIT_MAX. Don't abort the whole run:
1059
+ # drop to per-candidate acquisition (each child still preempts scans).
1060
+ print("[post] batch lock contended; per-candidate acquisition in effect",
1061
+ flush=True)
1062
+ except Exception as _e:
1063
+ print(f"[post] batch lock setup skipped ({_e}); per-candidate "
1064
+ "acquisition in effect", flush=True)
1065
+
1066
+ try:
1067
+ for _idx, c in enumerate(candidates, start=1):
1068
+ # Live per-post status for the S4L menu bar. LEAD with `posted` (the
1069
+ # REAL count of replies that actually landed), not `_idx` (the loop
1070
+ # position). _idx races through already-posted / deleted cards as instant
1071
+ # dedup-skips, so a bare "posting 88/139" looked like 88 were sent when
1072
+ # 0 were — misleading on every restart. "{posted} sent · {_idx}/{_total}"
1073
+ # keeps the honest number in front; the position is secondary context.
1074
+ _write_activity(f"posting {posted} sent · {_idx}/{_total}")
1075
+ # Re-stamp the batch hold at each candidate boundary so the
1076
+ # POST_LOCK_EXPIRY failsafe measures silence from the LAST real
1077
+ # progress, not from batch start. Insurance on top of the child's own
1078
+ # inherit-refresh: keeps the hold fresh even across a candidate that
1079
+ # skips before ever spawning a reply subprocess (empty_reply_text,
1080
+ # pre-post dedup). Cheap; never raises.
1081
+ if _batch_lock_held and _tb is not None:
1082
+ try:
1083
+ _tb._refresh_browser_lock()
1084
+ except Exception:
1085
+ pass
1086
+ try:
1087
+ outcome, reason = post_one(c, picker_assignment=picker_assignment)
1088
+ except Exception as e:
1089
+ print(f"[post] candidate {c.get('candidate_id')} crashed: {e}",
1090
+ flush=True)
1091
+ outcome, reason = ("failed", "exception")
1092
+ cid = c.get("candidate_id")
1093
+ if isinstance(cid, int):
1094
+ update_candidate(cid, "skipped", "exception")
1095
+ if outcome == "posted":
1096
+ posted += 1
1097
+ # Flash the confirmation with a short dwell so the menu bar shows
1098
+ # it before the next iteration's "posting" overwrites the label.
1099
+ # `posted` was just incremented, so it reflects the reply that landed.
1100
+ _write_activity(f"posting {posted} sent ✓ · {_idx}/{_total}")
1101
+ time.sleep(0.6)
1102
+ elif outcome == "skipped":
1103
+ skipped += 1
1104
+ if reason:
1105
+ skip_reasons[reason] = skip_reasons.get(reason, 0) + 1
1106
+ else:
1107
+ failed += 1
1108
+ if reason:
1109
+ fail_reasons[reason] = fail_reasons.get(reason, 0) + 1
1110
+ finally:
1111
+ _clear_activity()
1112
+ # Release the batch hold so the next scan/post can take the browser
1113
+ # immediately (don't make peers wait out POST_LOCK_EXPIRY). _tb's atexit
1114
+ # is a backstop if we somehow skip this; clearing S4L_LOCK_OWNER stops a
1115
+ # late child from re-inheriting a lock we just dropped.
1116
+ if _batch_lock_held and _tb is not None:
1117
+ try:
1118
+ _tb._release_browser_lock()
1119
+ except Exception:
1120
+ pass
1121
+ os.environ.pop("S4L_LOCK_OWNER", None)
1122
+ print("[post] batch lock released", flush=True)
1123
+
1124
+ summary = {
1125
+ "posted": posted,
1126
+ "skipped": skipped,
1127
+ "failed": failed,
1128
+ "failure_reasons": ",".join(f"{k}:{v}" for k, v in fail_reasons.items()),
1129
+ "skip_reasons": ",".join(f"{k}:{v}" for k, v in skip_reasons.items()),
1130
+ }
1131
+ # Remote observability: a handled post failure returns a reason instead of
1132
+ # raising, so the global Sentry excepthook never sees it. On customer .mcpb
1133
+ # installs the cycle log lives only on their machine, so an explicit capture
1134
+ # here is the ONLY channel that surfaces "approved but didn't post" to us.
1135
+ # Fires only on operational/machine reasons (content-judgment skips like
1136
+ # "off-topic ..." are excluded), so it never alerts on a healthy dedup cycle.
1137
+ try:
1138
+ machine = dict(fail_reasons)
1139
+ for _k, _v in skip_reasons.items():
1140
+ if _k in MACHINE_FAIL_REASONS:
1141
+ machine[_k] = machine.get(_k, 0) + _v
1142
+ if machine:
1143
+ import sentry_init
1144
+ _top = max(machine, key=machine.get)
1145
+ sentry_init.capture_message(
1146
+ "twitter post pipeline issues: "
1147
+ f"posted={posted} failed={failed} attempted={len(candidates)} "
1148
+ f"reasons={','.join(f'{k}:{v}' for k, v in machine.items())}",
1149
+ level=("error" if (failed > 0 or posted == 0) else "warning"),
1150
+ tags={
1151
+ "component": "twitter_post",
1152
+ "posted": str(posted),
1153
+ "failed": str(failed),
1154
+ "attempted": str(len(candidates)),
1155
+ "top_reason": _top,
1156
+ },
1157
+ )
1158
+ sentry_init.flush(2.0)
1159
+ except Exception:
1160
+ pass
1161
+
1162
+ # Persist a durable audit line so "did it post, and how many — and where?" is
1163
+ # answerable after the fact. The shell harvests the json on stdout, but when
1164
+ # the menu bar launches this directly it captures-then-discards stdout, leaving
1165
+ # no record of what posted. Append a timestamped JSONL row (with the live URLs)
1166
+ # the menu bar / dashboard can read. Best-effort: never affects the post outcome.
1167
+ try:
1168
+ posted_urls = [c.get("our_url") for c in candidates if c.get("our_url")]
1169
+ audit = dict(summary)
1170
+ audit["plan"] = plan_path.name
1171
+ audit["at"] = datetime.now(timezone.utc).isoformat()
1172
+ audit["urls"] = posted_urls
1173
+ audit_path = os.path.join(REPO_DIR, "skill", "logs", "post-results.jsonl")
1174
+ os.makedirs(os.path.dirname(audit_path), exist_ok=True)
1175
+ with open(audit_path, "a", encoding="utf-8") as f:
1176
+ f.write(json.dumps(audit) + "\n")
1177
+ print(f"[post] result: posted={posted} skipped={skipped} failed={failed}"
1178
+ f"{' urls=' + ','.join(posted_urls) if posted_urls else ''} "
1179
+ f"(audit: {audit_path})", flush=True)
1180
+ except Exception as e:
1181
+ print(f"[post] audit-log write failed (non-fatal): {e}", flush=True)
1182
+ # The shell harvests this as the last json line in our stdout.
1183
+ print(json.dumps(summary), flush=True)
1184
+ return 0
1185
+
1186
+
1187
+ if __name__ == "__main__":
1188
+ sys.exit(main())