@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.
- package/README.md +143 -0
- package/SKILL.md +342 -0
- package/bin/cli.js +980 -0
- package/bin/cookie-helper.js +315 -0
- package/bin/platform.js +59 -0
- package/bin/scheduler/index.js +12 -0
- package/bin/scheduler/launchd.js +518 -0
- package/browser-agent-configs/all-agents-mcp.json +68 -0
- package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
- package/browser-agent-configs/linkedin-agent.json +17 -0
- package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
- package/browser-agent-configs/reddit-agent-mcp.json +16 -0
- package/browser-agent-configs/reddit-agent.json +17 -0
- package/browser-agent-configs/twitter-harness-mcp.json +18 -0
- package/config.example.json +45 -0
- package/mcp/dist/index.js +4212 -0
- package/mcp/dist/onboarding.js +200 -0
- package/mcp/dist/panel.html +176 -0
- package/mcp/dist/product-link.html +102 -0
- package/mcp/dist/repo.js +222 -0
- package/mcp/dist/runtime.js +1079 -0
- package/mcp/dist/screencast.js +323 -0
- package/mcp/dist/setup.js +545 -0
- package/mcp/dist/telemetry.js +306 -0
- package/mcp/dist/twitterAuth.js +138 -0
- package/mcp/dist/version.js +271 -0
- package/mcp/dist/version.json +4 -0
- package/mcp/install-runtime.mjs +70 -0
- package/mcp/install.mjs +169 -0
- package/mcp/manifest.json +80 -0
- package/mcp/menubar/dashboard_server.py +213 -0
- package/mcp/menubar/s4l_card.py +1314 -0
- package/mcp/menubar/s4l_log_relay.py +179 -0
- package/mcp/menubar/s4l_menubar.py +2439 -0
- package/mcp/menubar/s4l_state.py +891 -0
- package/mcp/package.json +34 -0
- package/mcp/shared/doctor.cjs +437 -0
- package/mcp/shared/onboarding-ledger.cjs +324 -0
- package/mcp-servers/browser-harness/server.py +968 -0
- package/package.json +160 -0
- package/requirements.txt +20 -0
- package/scripts/_compute_allowlist.py +58 -0
- package/scripts/_db_update.py +20 -0
- package/scripts/_filt.py +9 -0
- package/scripts/_li_notif_match.py +76 -0
- package/scripts/_li_notif_orchestrate.py +126 -0
- package/scripts/_lock_preempt_test.py +60 -0
- package/scripts/_run_icp_precheck.py +57 -0
- package/scripts/a16z_pearx_calendar_reminders.py +99 -0
- package/scripts/account_resolver.py +141 -0
- package/scripts/active_campaigns.py +114 -0
- package/scripts/active_users.py +190 -0
- package/scripts/amplitude_24h_signups.py +468 -0
- package/scripts/amplitude_signups.py +177 -0
- package/scripts/apply_onboarding_selections.py +131 -0
- package/scripts/audience_pages.py +243 -0
- package/scripts/audit_helper.py +120 -0
- package/scripts/author_history_block.py +353 -0
- package/scripts/autopilot_stall_watch.py +284 -0
- package/scripts/backfill_twitter_attempts_topic.py +81 -0
- package/scripts/backfill_twitter_log_post_no_id.py +322 -0
- package/scripts/bench_dashboard.sh +138 -0
- package/scripts/bh_send.py +39 -0
- package/scripts/build_persona.py +409 -0
- package/scripts/bulk_icp.py +18 -0
- package/scripts/campaign_bump.py +51 -0
- package/scripts/capture_thread_media.py +288 -0
- package/scripts/check_browser_lock_health.sh +81 -0
- package/scripts/check_external_pool_depth.py +253 -0
- package/scripts/check_unread_web_chats.py +28 -0
- package/scripts/claim_web_chat.py +47 -0
- package/scripts/classify_run_error.py +158 -0
- package/scripts/claude_job.py +988 -0
- package/scripts/clean_stale_singleton.sh +56 -0
- package/scripts/cleanup_harness_tabs.py +68 -0
- package/scripts/copy_browser_cookies.py +454 -0
- package/scripts/counterparty_history.py +350 -0
- package/scripts/db.py +57 -0
- package/scripts/discover_claude_profiles.py +120 -0
- package/scripts/discover_linkedin_candidates.py +984 -0
- package/scripts/dm_conversation.py +682 -0
- package/scripts/dm_db_update.py +69 -0
- package/scripts/dm_engage_helper.py +161 -0
- package/scripts/dm_outreach_helper.py +147 -0
- package/scripts/dm_outreach_twitter_helper.py +129 -0
- package/scripts/dm_send_log.py +106 -0
- package/scripts/dm_short_links.py +1084 -0
- package/scripts/dump_web_chat_history.py +47 -0
- package/scripts/engage_github.py +640 -0
- package/scripts/engage_reddit.py +1235 -0
- package/scripts/engage_twitter_helper.py +301 -0
- package/scripts/engagement_styles.py +1787 -0
- package/scripts/enrich_twitter_candidates.py +82 -0
- package/scripts/feedback_digest.py +448 -0
- package/scripts/fetch_prospect_profile.py +312 -0
- package/scripts/fetch_twitter_t1.py +134 -0
- package/scripts/find_threads.py +530 -0
- package/scripts/follow_gate_log.py +59 -0
- package/scripts/funnel_per_day.py +194 -0
- package/scripts/generate_daily_human_style.py +494 -0
- package/scripts/generation_trace.py +173 -0
- package/scripts/get_run_cost.py +107 -0
- package/scripts/github_engage_helper.py +93 -0
- package/scripts/github_tools.py +509 -0
- package/scripts/harness_overlay.py +556 -0
- package/scripts/harvest_twitter_following.py +243 -0
- package/scripts/heartbeat.sh +70 -0
- package/scripts/history_context.py +284 -0
- package/scripts/http_api.py +206 -0
- package/scripts/human_dm_replies_helper.py +169 -0
- package/scripts/identity.py +302 -0
- package/scripts/ig_batch_creator.sh +93 -0
- package/scripts/ig_post_type_picker.py +243 -0
- package/scripts/ig_scrape_transcribe.sh +91 -0
- package/scripts/ingest_human_dm_replies.py +271 -0
- package/scripts/ingest_web_chat_replies.py +229 -0
- package/scripts/install_fleet.py +187 -0
- package/scripts/invent_mcp_server.py +350 -0
- package/scripts/invent_topics.py +1462 -0
- package/scripts/learned_preferences.py +263 -0
- package/scripts/li_discovery.py +161 -0
- package/scripts/link_edit_helper.py +142 -0
- package/scripts/link_tail.py +592 -0
- package/scripts/linkedin_api.py +561 -0
- package/scripts/linkedin_browser.py +730 -0
- package/scripts/linkedin_cooldown.py +128 -0
- package/scripts/linkedin_exclusions.py +234 -0
- package/scripts/linkedin_killswitch.py +1333 -0
- package/scripts/linkedin_search_topic_schema.py +49 -0
- package/scripts/linkedin_unipile.py +658 -0
- package/scripts/linkedin_url.py +228 -0
- package/scripts/log_claude_session.py +636 -0
- package/scripts/log_draft.py +143 -0
- package/scripts/log_linkedin_search_attempts.py +126 -0
- package/scripts/log_post.py +651 -0
- package/scripts/log_run.py +364 -0
- package/scripts/log_thread_media.py +108 -0
- package/scripts/log_twitter_search_attempts.py +150 -0
- package/scripts/log_twitter_skips.py +211 -0
- package/scripts/lookup_post.py +78 -0
- package/scripts/mark_web_chat_processed.py +32 -0
- package/scripts/mcp_lock_proxy.py +370 -0
- package/scripts/memory_snapshot.py +972 -0
- package/scripts/merge_review_queue.py +215 -0
- package/scripts/mint_external_pool.py +182 -0
- package/scripts/mint_kent_pool.py +249 -0
- package/scripts/moltbook_post.py +320 -0
- package/scripts/moltbook_tools.py +159 -0
- package/scripts/pending_threads.py +188 -0
- package/scripts/pick_ig_account.py +177 -0
- package/scripts/pick_project.py +208 -0
- package/scripts/pick_search_topic.py +771 -0
- package/scripts/pick_thread_target.py +279 -0
- package/scripts/pick_twitter_thread_target.py +202 -0
- package/scripts/podlog_fetch_batch.sh +32 -0
- package/scripts/post_github.py +1311 -0
- package/scripts/post_reddit.py +2668 -0
- package/scripts/precompute_dashboard_stats.py +204 -0
- package/scripts/preflight.sh +297 -0
- package/scripts/progress.py +88 -0
- package/scripts/project_excludes.py +353 -0
- package/scripts/project_slugs.py +91 -0
- package/scripts/project_stats.py +241 -0
- package/scripts/project_stats_json.py +1563 -0
- package/scripts/project_topics.py +192 -0
- package/scripts/qualified_query_bank.py +436 -0
- package/scripts/reap_stale_claude_sessions.py +867 -0
- package/scripts/reddit_browser.py +2549 -0
- package/scripts/reddit_browser_fetch.py +141 -0
- package/scripts/reddit_browser_lock.py +593 -0
- package/scripts/reddit_chat_sync.py +710 -0
- package/scripts/reddit_query_bank.py +200 -0
- package/scripts/reddit_threads_helper.py +151 -0
- package/scripts/reddit_tools.py +956 -0
- package/scripts/refresh_instagram_tokens.py +280 -0
- package/scripts/release-mcpb.sh +497 -0
- package/scripts/reply_db.py +334 -0
- package/scripts/reply_insert.py +98 -0
- package/scripts/reply_risk_digest.py +761 -0
- package/scripts/reset-test-machine.sh +602 -0
- package/scripts/restore_twitter_session.py +177 -0
- package/scripts/ripen_reddit_plan.py +478 -0
- package/scripts/run_claude.sh +433 -0
- package/scripts/run_moltbook_cycle.py +555 -0
- package/scripts/s4l_box_update.sh +226 -0
- package/scripts/s4l_channel.py +103 -0
- package/scripts/s4l_ctl.sh +75 -0
- package/scripts/s4l_env.py +47 -0
- package/scripts/saps_activity.py +126 -0
- package/scripts/saps_mode.py +328 -0
- package/scripts/scan_dm_candidates.py +580 -0
- package/scripts/scan_github_replies.py +168 -0
- package/scripts/scan_instagram_comments.py +481 -0
- package/scripts/scan_moltbook_replies.py +252 -0
- package/scripts/scan_pii.py +190 -0
- package/scripts/scan_reddit_replies.py +377 -0
- package/scripts/scan_twitter_mentions_browser.py +327 -0
- package/scripts/scan_twitter_thread_followups.py +299 -0
- package/scripts/scan_x_profile.py +384 -0
- package/scripts/schedule_state.py +202 -0
- package/scripts/scheduled_tasks_snapshot.py +123 -0
- package/scripts/score_linkedin_candidates.py +419 -0
- package/scripts/score_twitter_candidates.py +718 -0
- package/scripts/scrape_linkedin_comment_stats.py +1755 -0
- package/scripts/scrape_linkedin_stats_browser.py +52 -0
- package/scripts/scrape_reddit_views.py +365 -0
- package/scripts/seed_search_queries.py +453 -0
- package/scripts/seed_search_topics.py +127 -0
- package/scripts/send_web_chat_reply.py +130 -0
- package/scripts/sentry_init.py +128 -0
- package/scripts/setup_twitter_auth.py +1320 -0
- package/scripts/snapshot.py +583 -0
- package/scripts/stats.py +2702 -0
- package/scripts/stats_helper.py +52 -0
- package/scripts/strike_alert.py +783 -0
- package/scripts/sweep_post_link_clicks.py +107 -0
- package/scripts/sync_ig_to_posts.py +147 -0
- package/scripts/test_browser_lock.py +189 -0
- package/scripts/test_installation_api.sh +52 -0
- package/scripts/test_percard_posting.py +142 -0
- package/scripts/top_dud_linkedin_queries.py +71 -0
- package/scripts/top_dud_reddit_queries.py +67 -0
- package/scripts/top_dud_twitter_queries.py +71 -0
- package/scripts/top_dud_twitter_topics.py +102 -0
- package/scripts/top_linkedin_queries.py +55 -0
- package/scripts/top_omitted_reddit_topics.py +91 -0
- package/scripts/top_performers.py +588 -0
- package/scripts/top_search_topics.py +180 -0
- package/scripts/top_twitter_queries.py +190 -0
- package/scripts/twitter_access_check.py +382 -0
- package/scripts/twitter_account.py +41 -0
- package/scripts/twitter_batch_phase.py +126 -0
- package/scripts/twitter_browser.py +2804 -0
- package/scripts/twitter_cookie_mirror.py +130 -0
- package/scripts/twitter_cycle_helper.py +310 -0
- package/scripts/twitter_gen_links.py +287 -0
- package/scripts/twitter_post_plan.py +1188 -0
- package/scripts/twitter_scan.py +324 -0
- package/scripts/twitter_supply_signal.py +57 -0
- package/scripts/twitter_threads_helper.py +152 -0
- package/scripts/unclaim_web_chat.py +29 -0
- package/scripts/update_instagram_stats.py +261 -0
- package/scripts/update_linkedin_stats_from_feed.py +328 -0
- package/scripts/version.py +72 -0
- package/scripts/watchdog_hung_runs.py +343 -0
- package/scripts/write_generation_trace.py +73 -0
- package/setup/SKILL.md +277 -0
- package/skill/amplitude-24h-signups.sh +38 -0
- package/skill/archive-old-logs.sh +40 -0
- package/skill/audit-dm-staleness.sh +42 -0
- package/skill/audit-linkedin.sh +14 -0
- package/skill/audit-moltbook.sh +4 -0
- package/skill/audit-reddit-resurrect.sh +67 -0
- package/skill/audit-reddit.sh +4 -0
- package/skill/audit-twitter.sh +4 -0
- package/skill/audit.sh +287 -0
- package/skill/backfill-twitter-attempts-topic.sh +19 -0
- package/skill/backfill-twitter-ghost-posts.sh +24 -0
- package/skill/check-external-pool-depth.sh +7 -0
- package/skill/check-web-chats.sh +203 -0
- package/skill/dm-outreach-linkedin.sh +250 -0
- package/skill/dm-outreach-reddit.sh +274 -0
- package/skill/dm-outreach-twitter.sh +265 -0
- package/skill/engage-dm-replies-linkedin.sh +4 -0
- package/skill/engage-dm-replies-reddit.sh +4 -0
- package/skill/engage-dm-replies-twitter.sh +4 -0
- package/skill/engage-dm-replies.sh +1597 -0
- package/skill/engage-linkedin.sh +581 -0
- package/skill/engage-moltbook.sh +36 -0
- package/skill/engage-reddit.sh +146 -0
- package/skill/engage-twitter.sh +467 -0
- package/skill/github-engage.sh +176 -0
- package/skill/ingest-web-chat-replies.sh +38 -0
- package/skill/invent-supply-test.sh +100 -0
- package/skill/invent-topics.sh +50 -0
- package/skill/lib/linkedin-backend.sh +364 -0
- package/skill/lib/platform.sh +48 -0
- package/skill/lib/reddit-backend.sh +234 -0
- package/skill/lib/twitter-backend.sh +314 -0
- package/skill/link-edit-github.sh +136 -0
- package/skill/link-edit-moltbook.sh +117 -0
- package/skill/link-edit-reddit.sh +201 -0
- package/skill/linkedin-presence.sh +182 -0
- package/skill/linkedin-recovery.sh +282 -0
- package/skill/lock.sh +647 -0
- package/skill/memory-snapshot.sh +39 -0
- package/skill/precompute-stats.sh +35 -0
- package/skill/prewarm-funnel.sh +104 -0
- package/skill/refresh-instagram-tokens.sh +57 -0
- package/skill/refresh-twitter-following.sh +52 -0
- package/skill/reply-risk-digest.sh +31 -0
- package/skill/run-cycle-update-guard.sh +44 -0
- package/skill/run-draft-and-publish.sh +123 -0
- package/skill/run-generate-daily-style.sh +50 -0
- package/skill/run-github-launchd.sh +62 -0
- package/skill/run-github.sh +102 -0
- package/skill/run-instagram-daily.sh +149 -0
- package/skill/run-instagram-render.sh +875 -0
- package/skill/run-linkedin-launchd.sh +81 -0
- package/skill/run-linkedin-unipile.sh +130 -0
- package/skill/run-linkedin.sh +1593 -0
- package/skill/run-moltbook-launchd.sh +61 -0
- package/skill/run-moltbook.sh +38 -0
- package/skill/run-overlay-watch.sh +100 -0
- package/skill/run-reddit-search-launchd.sh +64 -0
- package/skill/run-reddit-search.sh +505 -0
- package/skill/run-reddit-threads-double.sh +32 -0
- package/skill/run-reddit-threads.sh +847 -0
- package/skill/run-scan-moltbook-replies.sh +57 -0
- package/skill/run-twitter-cycle-launchd.sh +63 -0
- package/skill/run-twitter-cycle-singleton.sh +62 -0
- package/skill/run-twitter-cycle.sh +2408 -0
- package/skill/run-twitter-threads.sh +592 -0
- package/skill/scan-instagram-replies.sh +61 -0
- package/skill/scan-twitter-followups.sh +57 -0
- package/skill/social-autoposter-update.sh +66 -0
- package/skill/stats-instagram.sh +72 -0
- package/skill/stats-linkedin.sh +271 -0
- package/skill/stats-moltbook.sh +4 -0
- package/skill/stats-reddit.sh +4 -0
- package/skill/stats-twitter.sh +4 -0
- package/skill/stats.sh +521 -0
- package/skill/strike-alert.sh +18 -0
- package/skill/styles.sh +87 -0
- package/skill/sweep-link-clicks.sh +40 -0
- package/skill/topics.sh +51 -0
|
@@ -0,0 +1,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())
|