@m13v/s4l 1.6.197-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +143 -0
- package/SKILL.md +342 -0
- package/bin/cli.js +980 -0
- package/bin/cookie-helper.js +315 -0
- package/bin/platform.js +59 -0
- package/bin/scheduler/index.js +12 -0
- package/bin/scheduler/launchd.js +518 -0
- package/browser-agent-configs/all-agents-mcp.json +68 -0
- package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
- package/browser-agent-configs/linkedin-agent.json +17 -0
- package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
- package/browser-agent-configs/reddit-agent-mcp.json +16 -0
- package/browser-agent-configs/reddit-agent.json +17 -0
- package/browser-agent-configs/twitter-harness-mcp.json +18 -0
- package/config.example.json +45 -0
- package/mcp/dist/index.js +4212 -0
- package/mcp/dist/onboarding.js +200 -0
- package/mcp/dist/panel.html +176 -0
- package/mcp/dist/product-link.html +102 -0
- package/mcp/dist/repo.js +222 -0
- package/mcp/dist/runtime.js +1079 -0
- package/mcp/dist/screencast.js +323 -0
- package/mcp/dist/setup.js +545 -0
- package/mcp/dist/telemetry.js +306 -0
- package/mcp/dist/twitterAuth.js +138 -0
- package/mcp/dist/version.js +271 -0
- package/mcp/dist/version.json +4 -0
- package/mcp/install-runtime.mjs +70 -0
- package/mcp/install.mjs +169 -0
- package/mcp/manifest.json +80 -0
- package/mcp/menubar/dashboard_server.py +213 -0
- package/mcp/menubar/s4l_card.py +1336 -0
- package/mcp/menubar/s4l_log_relay.py +179 -0
- package/mcp/menubar/s4l_menubar.py +2439 -0
- package/mcp/menubar/s4l_state.py +891 -0
- package/mcp/package.json +34 -0
- package/mcp/shared/doctor.cjs +437 -0
- package/mcp/shared/onboarding-ledger.cjs +324 -0
- package/mcp-servers/browser-harness/server.py +968 -0
- package/package.json +160 -0
- package/requirements.txt +20 -0
- package/scripts/_compute_allowlist.py +58 -0
- package/scripts/_db_update.py +20 -0
- package/scripts/_filt.py +9 -0
- package/scripts/_li_notif_match.py +76 -0
- package/scripts/_li_notif_orchestrate.py +126 -0
- package/scripts/_lock_preempt_test.py +60 -0
- package/scripts/_run_icp_precheck.py +57 -0
- package/scripts/a16z_pearx_calendar_reminders.py +99 -0
- package/scripts/account_resolver.py +141 -0
- package/scripts/active_campaigns.py +114 -0
- package/scripts/active_users.py +190 -0
- package/scripts/amplitude_24h_signups.py +468 -0
- package/scripts/amplitude_signups.py +177 -0
- package/scripts/apply_onboarding_selections.py +131 -0
- package/scripts/audience_pages.py +243 -0
- package/scripts/audit_helper.py +120 -0
- package/scripts/author_history_block.py +353 -0
- package/scripts/autopilot_stall_watch.py +284 -0
- package/scripts/backfill_twitter_attempts_topic.py +81 -0
- package/scripts/backfill_twitter_log_post_no_id.py +322 -0
- package/scripts/bench_dashboard.sh +138 -0
- package/scripts/bh_send.py +39 -0
- package/scripts/build_persona.py +409 -0
- package/scripts/bulk_icp.py +18 -0
- package/scripts/campaign_bump.py +51 -0
- package/scripts/capture_thread_media.py +288 -0
- package/scripts/check_browser_lock_health.sh +81 -0
- package/scripts/check_external_pool_depth.py +253 -0
- package/scripts/check_unread_web_chats.py +28 -0
- package/scripts/claim_web_chat.py +47 -0
- package/scripts/classify_run_error.py +158 -0
- package/scripts/claude_job.py +988 -0
- package/scripts/clean_stale_singleton.sh +56 -0
- package/scripts/cleanup_harness_tabs.py +68 -0
- package/scripts/copy_browser_cookies.py +454 -0
- package/scripts/counterparty_history.py +350 -0
- package/scripts/db.py +57 -0
- package/scripts/discover_claude_profiles.py +120 -0
- package/scripts/discover_linkedin_candidates.py +984 -0
- package/scripts/dm_conversation.py +682 -0
- package/scripts/dm_db_update.py +69 -0
- package/scripts/dm_engage_helper.py +161 -0
- package/scripts/dm_outreach_helper.py +147 -0
- package/scripts/dm_outreach_twitter_helper.py +129 -0
- package/scripts/dm_send_log.py +106 -0
- package/scripts/dm_short_links.py +1084 -0
- package/scripts/dump_web_chat_history.py +47 -0
- package/scripts/engage_github.py +640 -0
- package/scripts/engage_reddit.py +1235 -0
- package/scripts/engage_twitter_helper.py +301 -0
- package/scripts/engagement_styles.py +1787 -0
- package/scripts/enrich_twitter_candidates.py +82 -0
- package/scripts/feedback_digest.py +448 -0
- package/scripts/fetch_prospect_profile.py +312 -0
- package/scripts/fetch_twitter_t1.py +134 -0
- package/scripts/find_threads.py +530 -0
- package/scripts/follow_gate_log.py +59 -0
- package/scripts/funnel_per_day.py +194 -0
- package/scripts/generate_daily_human_style.py +494 -0
- package/scripts/generation_trace.py +173 -0
- package/scripts/get_run_cost.py +107 -0
- package/scripts/github_engage_helper.py +93 -0
- package/scripts/github_tools.py +509 -0
- package/scripts/harness_overlay.py +556 -0
- package/scripts/harvest_twitter_following.py +243 -0
- package/scripts/heartbeat.sh +70 -0
- package/scripts/history_context.py +284 -0
- package/scripts/http_api.py +206 -0
- package/scripts/human_dm_replies_helper.py +169 -0
- package/scripts/identity.py +302 -0
- package/scripts/ig_batch_creator.sh +93 -0
- package/scripts/ig_post_type_picker.py +243 -0
- package/scripts/ig_scrape_transcribe.sh +91 -0
- package/scripts/ingest_human_dm_replies.py +271 -0
- package/scripts/ingest_web_chat_replies.py +229 -0
- package/scripts/install_fleet.py +187 -0
- package/scripts/invent_mcp_server.py +350 -0
- package/scripts/invent_topics.py +1462 -0
- package/scripts/learned_preferences.py +263 -0
- package/scripts/li_discovery.py +161 -0
- package/scripts/link_edit_helper.py +142 -0
- package/scripts/link_tail.py +592 -0
- package/scripts/linkedin_api.py +561 -0
- package/scripts/linkedin_browser.py +730 -0
- package/scripts/linkedin_cooldown.py +128 -0
- package/scripts/linkedin_exclusions.py +234 -0
- package/scripts/linkedin_killswitch.py +1333 -0
- package/scripts/linkedin_search_topic_schema.py +49 -0
- package/scripts/linkedin_unipile.py +658 -0
- package/scripts/linkedin_url.py +228 -0
- package/scripts/log_claude_session.py +636 -0
- package/scripts/log_draft.py +143 -0
- package/scripts/log_linkedin_search_attempts.py +126 -0
- package/scripts/log_post.py +651 -0
- package/scripts/log_run.py +364 -0
- package/scripts/log_thread_media.py +108 -0
- package/scripts/log_twitter_search_attempts.py +150 -0
- package/scripts/log_twitter_skips.py +211 -0
- package/scripts/lookup_post.py +78 -0
- package/scripts/mark_web_chat_processed.py +32 -0
- package/scripts/mcp_lock_proxy.py +370 -0
- package/scripts/memory_snapshot.py +972 -0
- package/scripts/merge_review_queue.py +215 -0
- package/scripts/mint_external_pool.py +182 -0
- package/scripts/mint_kent_pool.py +249 -0
- package/scripts/moltbook_post.py +320 -0
- package/scripts/moltbook_tools.py +159 -0
- package/scripts/pending_threads.py +188 -0
- package/scripts/pick_ig_account.py +177 -0
- package/scripts/pick_project.py +208 -0
- package/scripts/pick_search_topic.py +771 -0
- package/scripts/pick_thread_target.py +279 -0
- package/scripts/pick_twitter_thread_target.py +202 -0
- package/scripts/podlog_fetch_batch.sh +32 -0
- package/scripts/post_github.py +1311 -0
- package/scripts/post_reddit.py +2668 -0
- package/scripts/precompute_dashboard_stats.py +204 -0
- package/scripts/preflight.sh +297 -0
- package/scripts/progress.py +88 -0
- package/scripts/project_excludes.py +353 -0
- package/scripts/project_slugs.py +91 -0
- package/scripts/project_stats.py +241 -0
- package/scripts/project_stats_json.py +1563 -0
- package/scripts/project_topics.py +192 -0
- package/scripts/qualified_query_bank.py +436 -0
- package/scripts/reap_stale_claude_sessions.py +867 -0
- package/scripts/reddit_browser.py +2549 -0
- package/scripts/reddit_browser_fetch.py +141 -0
- package/scripts/reddit_browser_lock.py +593 -0
- package/scripts/reddit_chat_sync.py +710 -0
- package/scripts/reddit_query_bank.py +200 -0
- package/scripts/reddit_threads_helper.py +151 -0
- package/scripts/reddit_tools.py +956 -0
- package/scripts/refresh_instagram_tokens.py +280 -0
- package/scripts/release-mcpb.sh +513 -0
- package/scripts/reply_db.py +334 -0
- package/scripts/reply_insert.py +98 -0
- package/scripts/reply_risk_digest.py +761 -0
- package/scripts/reset-test-machine.sh +602 -0
- package/scripts/restore_twitter_session.py +177 -0
- package/scripts/ripen_reddit_plan.py +478 -0
- package/scripts/run_claude.sh +433 -0
- package/scripts/run_moltbook_cycle.py +555 -0
- package/scripts/s4l_box_update.sh +226 -0
- package/scripts/s4l_channel.py +103 -0
- package/scripts/s4l_ctl.sh +75 -0
- package/scripts/s4l_env.py +47 -0
- package/scripts/saps_activity.py +126 -0
- package/scripts/saps_mode.py +328 -0
- package/scripts/scan_dm_candidates.py +580 -0
- package/scripts/scan_github_replies.py +168 -0
- package/scripts/scan_instagram_comments.py +481 -0
- package/scripts/scan_moltbook_replies.py +252 -0
- package/scripts/scan_pii.py +190 -0
- package/scripts/scan_reddit_replies.py +377 -0
- package/scripts/scan_twitter_mentions_browser.py +327 -0
- package/scripts/scan_twitter_thread_followups.py +299 -0
- package/scripts/scan_x_profile.py +384 -0
- package/scripts/schedule_state.py +202 -0
- package/scripts/scheduled_tasks_snapshot.py +123 -0
- package/scripts/score_linkedin_candidates.py +419 -0
- package/scripts/score_twitter_candidates.py +718 -0
- package/scripts/scrape_linkedin_comment_stats.py +1755 -0
- package/scripts/scrape_linkedin_stats_browser.py +52 -0
- package/scripts/scrape_reddit_views.py +365 -0
- package/scripts/seed_search_queries.py +453 -0
- package/scripts/seed_search_topics.py +127 -0
- package/scripts/send_web_chat_reply.py +130 -0
- package/scripts/sentry_init.py +128 -0
- package/scripts/setup_twitter_auth.py +1320 -0
- package/scripts/snapshot.py +583 -0
- package/scripts/stats.py +2702 -0
- package/scripts/stats_helper.py +52 -0
- package/scripts/strike_alert.py +783 -0
- package/scripts/sweep_post_link_clicks.py +107 -0
- package/scripts/sync_ig_to_posts.py +147 -0
- package/scripts/test_browser_lock.py +189 -0
- package/scripts/test_installation_api.sh +52 -0
- package/scripts/test_percard_posting.py +142 -0
- package/scripts/top_dud_linkedin_queries.py +71 -0
- package/scripts/top_dud_reddit_queries.py +67 -0
- package/scripts/top_dud_twitter_queries.py +71 -0
- package/scripts/top_dud_twitter_topics.py +102 -0
- package/scripts/top_linkedin_queries.py +55 -0
- package/scripts/top_omitted_reddit_topics.py +91 -0
- package/scripts/top_performers.py +588 -0
- package/scripts/top_search_topics.py +180 -0
- package/scripts/top_twitter_queries.py +190 -0
- package/scripts/twitter_access_check.py +382 -0
- package/scripts/twitter_account.py +41 -0
- package/scripts/twitter_batch_phase.py +126 -0
- package/scripts/twitter_browser.py +2804 -0
- package/scripts/twitter_cookie_mirror.py +130 -0
- package/scripts/twitter_cycle_helper.py +310 -0
- package/scripts/twitter_gen_links.py +287 -0
- package/scripts/twitter_post_plan.py +1188 -0
- package/scripts/twitter_scan.py +324 -0
- package/scripts/twitter_supply_signal.py +57 -0
- package/scripts/twitter_threads_helper.py +152 -0
- package/scripts/unclaim_web_chat.py +29 -0
- package/scripts/update_instagram_stats.py +261 -0
- package/scripts/update_linkedin_stats_from_feed.py +328 -0
- package/scripts/version.py +72 -0
- package/scripts/watchdog_hung_runs.py +343 -0
- package/scripts/write_generation_trace.py +73 -0
- package/setup/SKILL.md +277 -0
- package/skill/amplitude-24h-signups.sh +38 -0
- package/skill/archive-old-logs.sh +40 -0
- package/skill/audit-dm-staleness.sh +42 -0
- package/skill/audit-linkedin.sh +14 -0
- package/skill/audit-moltbook.sh +4 -0
- package/skill/audit-reddit-resurrect.sh +67 -0
- package/skill/audit-reddit.sh +4 -0
- package/skill/audit-twitter.sh +4 -0
- package/skill/audit.sh +287 -0
- package/skill/backfill-twitter-attempts-topic.sh +19 -0
- package/skill/backfill-twitter-ghost-posts.sh +24 -0
- package/skill/check-external-pool-depth.sh +7 -0
- package/skill/check-web-chats.sh +203 -0
- package/skill/dm-outreach-linkedin.sh +250 -0
- package/skill/dm-outreach-reddit.sh +274 -0
- package/skill/dm-outreach-twitter.sh +265 -0
- package/skill/engage-dm-replies-linkedin.sh +4 -0
- package/skill/engage-dm-replies-reddit.sh +4 -0
- package/skill/engage-dm-replies-twitter.sh +4 -0
- package/skill/engage-dm-replies.sh +1597 -0
- package/skill/engage-linkedin.sh +581 -0
- package/skill/engage-moltbook.sh +36 -0
- package/skill/engage-reddit.sh +146 -0
- package/skill/engage-twitter.sh +467 -0
- package/skill/github-engage.sh +176 -0
- package/skill/ingest-web-chat-replies.sh +38 -0
- package/skill/invent-supply-test.sh +100 -0
- package/skill/invent-topics.sh +50 -0
- package/skill/lib/linkedin-backend.sh +364 -0
- package/skill/lib/platform.sh +48 -0
- package/skill/lib/reddit-backend.sh +234 -0
- package/skill/lib/twitter-backend.sh +314 -0
- package/skill/link-edit-github.sh +136 -0
- package/skill/link-edit-moltbook.sh +117 -0
- package/skill/link-edit-reddit.sh +201 -0
- package/skill/linkedin-presence.sh +182 -0
- package/skill/linkedin-recovery.sh +282 -0
- package/skill/lock.sh +647 -0
- package/skill/memory-snapshot.sh +39 -0
- package/skill/precompute-stats.sh +35 -0
- package/skill/prewarm-funnel.sh +104 -0
- package/skill/refresh-instagram-tokens.sh +57 -0
- package/skill/refresh-twitter-following.sh +52 -0
- package/skill/reply-risk-digest.sh +31 -0
- package/skill/run-cycle-update-guard.sh +44 -0
- package/skill/run-draft-and-publish.sh +123 -0
- package/skill/run-generate-daily-style.sh +50 -0
- package/skill/run-github-launchd.sh +62 -0
- package/skill/run-github.sh +102 -0
- package/skill/run-instagram-daily.sh +149 -0
- package/skill/run-instagram-render.sh +875 -0
- package/skill/run-linkedin-launchd.sh +81 -0
- package/skill/run-linkedin-unipile.sh +130 -0
- package/skill/run-linkedin.sh +1593 -0
- package/skill/run-moltbook-launchd.sh +61 -0
- package/skill/run-moltbook.sh +38 -0
- package/skill/run-overlay-watch.sh +100 -0
- package/skill/run-reddit-search-launchd.sh +64 -0
- package/skill/run-reddit-search.sh +505 -0
- package/skill/run-reddit-threads-double.sh +32 -0
- package/skill/run-reddit-threads.sh +847 -0
- package/skill/run-scan-moltbook-replies.sh +57 -0
- package/skill/run-twitter-cycle-launchd.sh +63 -0
- package/skill/run-twitter-cycle-singleton.sh +62 -0
- package/skill/run-twitter-cycle.sh +2408 -0
- package/skill/run-twitter-threads.sh +592 -0
- package/skill/scan-instagram-replies.sh +61 -0
- package/skill/scan-twitter-followups.sh +57 -0
- package/skill/social-autoposter-update.sh +66 -0
- package/skill/stats-instagram.sh +72 -0
- package/skill/stats-linkedin.sh +271 -0
- package/skill/stats-moltbook.sh +4 -0
- package/skill/stats-reddit.sh +4 -0
- package/skill/stats-twitter.sh +4 -0
- package/skill/stats.sh +521 -0
- package/skill/strike-alert.sh +18 -0
- package/skill/styles.sh +87 -0
- package/skill/sweep-link-clicks.sh +40 -0
- package/skill/topics.sh +51 -0
|
@@ -0,0 +1,988 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
claude_job.py — queue-backed substitute for `claude -p` on boxes without the
|
|
4
|
+
Claude CLI (customer .mcpb installs).
|
|
5
|
+
|
|
6
|
+
The deterministic pipeline never calls `claude` directly; every invocation goes
|
|
7
|
+
through scripts/run_claude.sh. When S4L_CLAUDE_PROVIDER=queue is set (only on
|
|
8
|
+
customer boxes — your own machines leave it unset and keep calling claude -p
|
|
9
|
+
directly), run_claude.sh delegates here instead of exec'ing the `claude` binary.
|
|
10
|
+
The pipeline is otherwise untouched: it enqueues the same prompt + json-schema it
|
|
11
|
+
would have passed to claude, blocks until a result appears, and gets back bytes
|
|
12
|
+
shaped exactly like claude's `--output-format json` envelope, so the existing
|
|
13
|
+
parsers don't change.
|
|
14
|
+
|
|
15
|
+
Three roles:
|
|
16
|
+
provider — (producer side, called by run_claude.sh) extract the prompt (stdin
|
|
17
|
+
or trailing arg) + --json-schema, enqueue a typed job, BLOCK until a
|
|
18
|
+
result lands, then print a claude-json-shaped envelope to stdout.
|
|
19
|
+
next — (consumer side, called by a Claude Desktop scheduled task) atomically
|
|
20
|
+
claim the oldest pending job of a given type and print it as JSON.
|
|
21
|
+
result — (consumer side) store the JSON the task produced (validated) and
|
|
22
|
+
unblock the waiting provider.
|
|
23
|
+
|
|
24
|
+
Queue = plain files under <state_dir>/claude-queue/. No DB, no network.
|
|
25
|
+
state_dir = $S4L_STATE_DIR or ~/.social-autoposter-mcp
|
|
26
|
+
|
|
27
|
+
Job-type mapping is by run_claude.sh script_tag. Only the PURE text->JSON calls
|
|
28
|
+
are queue-eligible; anything else exits non-zero so the caller's own fallback
|
|
29
|
+
runs (e.g. link_tail's mechanical concat). twitter-link-tail is intentionally
|
|
30
|
+
NOT mapped: the customer flow skips it for now.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import argparse
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
import re
|
|
39
|
+
import signal
|
|
40
|
+
import subprocess
|
|
41
|
+
import sys
|
|
42
|
+
import time
|
|
43
|
+
import uuid
|
|
44
|
+
|
|
45
|
+
# SAPS_->S4L_ env mirror (brand rename 2026-07-03): old launchd plists and
|
|
46
|
+
# scheduled-task prompts still export SAPS_*; this process reads S4L_*.
|
|
47
|
+
import s4l_env # noqa: E402 (lives next to this file in scripts/)
|
|
48
|
+
|
|
49
|
+
s4l_env.mirror()
|
|
50
|
+
|
|
51
|
+
# Best-effort menu-bar activity narration. Importable because this script's own
|
|
52
|
+
# directory (scripts/) is on sys.path[0] when run as `python3 .../claude_job.py`.
|
|
53
|
+
# A failure to import (or to write) must NEVER affect the queue's real work.
|
|
54
|
+
try:
|
|
55
|
+
import saps_activity as _activity # type: ignore
|
|
56
|
+
except Exception: # pragma: no cover - cosmetic only
|
|
57
|
+
_activity = None
|
|
58
|
+
|
|
59
|
+
# script_tag -> queue type. ONLY pure text->JSON claude calls belong here.
|
|
60
|
+
TAG_TO_TYPE = {
|
|
61
|
+
"run-twitter-cycle-queries": "twitter-query",
|
|
62
|
+
"run-twitter-cycle-prep": "twitter-prep",
|
|
63
|
+
"feedback-digest": "feedback-digest",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# queue type -> (activity state, label) the menu bar shows while the job is in
|
|
67
|
+
# flight. Phase-1 queries drive the X search ("finding threads"); Phase-2b prep is
|
|
68
|
+
# the reply drafting. Both the launchd provider (which blocks for minutes) and the
|
|
69
|
+
# scheduled-task worker (which does the LLM turn) narrate from this one map.
|
|
70
|
+
TYPE_TO_ACTIVITY = {
|
|
71
|
+
"twitter-query": ("scanning", "queries"),
|
|
72
|
+
"twitter-prep": ("drafting", "draft"),
|
|
73
|
+
"feedback-digest": ("learning", "feedback"),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# queue type -> execution notes PREPENDED to the prompt sidecar at claim time.
|
|
77
|
+
# This keeps the scheduled-task worker fully type-blind: its SKILL.md is one
|
|
78
|
+
# generic claim -> follow -> submit loop, and anything a specific job type
|
|
79
|
+
# needs the executor to know (pacing, persist cadence) travels WITH the job.
|
|
80
|
+
# The twitter-prep note exists because the host kills an unattended session
|
|
81
|
+
# ~90s after its LAST tool call; drafting a whole batch in one silent turn
|
|
82
|
+
# starves that clock (the v6 worker-prompt lesson, moved under the hood).
|
|
83
|
+
TYPE_TO_WORKER_NOTES = {
|
|
84
|
+
"twitter-prep": (
|
|
85
|
+
"WORKER EXECUTION NOTES (queue metadata; follow while executing the "
|
|
86
|
+
"prompt below): this unattended session is terminated ~90 seconds after "
|
|
87
|
+
"your LAST tool call. The prompt asks you to draft replies for SEVERAL "
|
|
88
|
+
"candidates. Do NOT draft them all silently in one turn. Work ONE "
|
|
89
|
+
"candidate at a time: draft its reply, then IMMEDIATELY run that "
|
|
90
|
+
"candidate's log_draft.py persist command exactly as the prompt's "
|
|
91
|
+
"persist step specifies (a quick Bash call), THEN move to the next. "
|
|
92
|
+
"Those per-candidate Bash calls keep the session alive. Begin the first "
|
|
93
|
+
"candidate promptly. Only after EVERY candidate is drafted and logged "
|
|
94
|
+
"do you assemble and submit the single result JSON."
|
|
95
|
+
),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _act_write(qtype: str) -> None:
|
|
100
|
+
if _activity is None:
|
|
101
|
+
return
|
|
102
|
+
sl = TYPE_TO_ACTIVITY.get(qtype)
|
|
103
|
+
if not sl:
|
|
104
|
+
return
|
|
105
|
+
try:
|
|
106
|
+
_activity.write(sl[0], f"{sl[1]}: starting")
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _act_clear() -> None:
|
|
112
|
+
if _activity is None:
|
|
113
|
+
return
|
|
114
|
+
try:
|
|
115
|
+
_activity.clear()
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _fmt_dur(secs: float) -> str:
|
|
121
|
+
"""Compact human duration for the menu-bar label: '45s', '12m'."""
|
|
122
|
+
s = int(max(0, secs))
|
|
123
|
+
return f"{s}s" if s < 60 else f"{s // 60}m"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _act_write_progress(
|
|
127
|
+
qtype: str, created: float, claimed_at: float | None, now: float
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Granular in-flight menu-bar label, so a wedged cycle reads as the TRUTH
|
|
130
|
+
instead of a static 'drafting replies' that lingers for the whole producer
|
|
131
|
+
timeout (the failure mode where the worker never claims the job, or claims it
|
|
132
|
+
and dies mid-run, looked identical to healthy drafting before this).
|
|
133
|
+
|
|
134
|
+
- job still in pending/ (no worker has claimed it) -> '<base> (queued <dur>)'
|
|
135
|
+
counting from enqueue. A growing 'queued 18m' is the unmistakable tell that
|
|
136
|
+
a scheduled-task worker is orphaned and nothing is draining.
|
|
137
|
+
- job claimed (pending file gone -> moved to running/) -> '<base> (<dur>)'
|
|
138
|
+
counting from the claim, i.e. real drafting elapsed.
|
|
139
|
+
|
|
140
|
+
Purely cosmetic and best-effort: a write failure must never affect the queue."""
|
|
141
|
+
if _activity is None:
|
|
142
|
+
return
|
|
143
|
+
sl = TYPE_TO_ACTIVITY.get(qtype)
|
|
144
|
+
if not sl:
|
|
145
|
+
return
|
|
146
|
+
state, base = sl
|
|
147
|
+
if claimed_at is None:
|
|
148
|
+
label = f"{base}: queued {_fmt_dur(now - created)}"
|
|
149
|
+
else:
|
|
150
|
+
label = f"{base}: {_fmt_dur(now - claimed_at)}"
|
|
151
|
+
try:
|
|
152
|
+
_activity.write(state, label)
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
# claude flags that consume the following argv token as their value, so the
|
|
157
|
+
# value is never mistaken for the positional prompt.
|
|
158
|
+
VALUE_FLAGS = {
|
|
159
|
+
"--mcp-config",
|
|
160
|
+
"--json-schema",
|
|
161
|
+
"--output-format",
|
|
162
|
+
"--input-format",
|
|
163
|
+
"--model",
|
|
164
|
+
"--fallback-model",
|
|
165
|
+
"--system-prompt",
|
|
166
|
+
"--append-system-prompt",
|
|
167
|
+
"--permission-mode",
|
|
168
|
+
"--allowedTools",
|
|
169
|
+
"--disallowedTools",
|
|
170
|
+
"--add-dir",
|
|
171
|
+
"--session-id",
|
|
172
|
+
"--settings",
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
POLL_INTERVAL_S = 2.0
|
|
176
|
+
# Per-call budget the producer waits for ONE claude job (a query or a draft-prep
|
|
177
|
+
# reasoning turn). Was 600s, which sat right at the edge of the draft call's real
|
|
178
|
+
# ~9-10 min need: on the QA box ~41% of twitter-prep jobs breached 600s and got
|
|
179
|
+
# dropped (each drop = a lost draft AND an orphaned over-running worker that
|
|
180
|
+
# becomes a leaked agent-mode session). The DIRECT launchd `claude -p` lane has no
|
|
181
|
+
# such per-call cap — its draft call just runs inside the 180-min cycle watchdog —
|
|
182
|
+
# so 600s here made the queue lane diverge and silently fail where the direct lane
|
|
183
|
+
# would not. 1800s (30 min) = 3x the real draft need, matching the sibling Twitter
|
|
184
|
+
# engagement claude cap (engage-twitter Phase B gtimeout 1800), which removes the
|
|
185
|
+
# drops while staying a bounded per-call value (not the whole-cycle budget).
|
|
186
|
+
# COUPLING: reap_stale_claude_sessions.py reaps leaked workers at THIS value plus a
|
|
187
|
+
# fixed margin (S4L_REAPER_AGE_MARGIN_SEC, default 300s) and MUST stay > it (a lower
|
|
188
|
+
# reaper would SIGKILL a draft the producer is still waiting on). Both read
|
|
189
|
+
# S4L_CLAUDE_QUEUE_TIMEOUT and both default to 1800; keep them in lockstep if you
|
|
190
|
+
# change the base.
|
|
191
|
+
DEFAULT_TIMEOUT_S = int(os.environ.get("S4L_CLAUDE_QUEUE_TIMEOUT", "1800"))
|
|
192
|
+
# Jobs older than this (pending or running) are swept — a job nobody drained in
|
|
193
|
+
# this long is a leftover from a timed-out producer or a dead worker, and keeping
|
|
194
|
+
# it would feed a stale prompt to a worker much later. Default 2x the timeout.
|
|
195
|
+
STALE_TTL_S = int(os.environ.get("S4L_CLAUDE_QUEUE_STALE_TTL", str(DEFAULT_TIMEOUT_S * 2)))
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
# EXPERIMENT (2026-06-29, per user): hide the "Top Posts by Project" few-shot
|
|
199
|
+
# block from the Phase-2b drafting prompt.
|
|
200
|
+
#
|
|
201
|
+
# WHY: that block is ~42% of the prompt — top_performers.py emits up to 5 curated
|
|
202
|
+
# example posts for EVERY project (~20 projects = ~74 examples, ~16k tokens), and
|
|
203
|
+
# it is NOT scoped to the projects in this cycle's candidates. On a `.mcpb` box the
|
|
204
|
+
# drafting runs inside a Claude Desktop scheduled-task session that the app
|
|
205
|
+
# SIGTERMs after ~120s; the 38k-token prompt pushed the worker past that window
|
|
206
|
+
# before it could submit, so jobs never drained. Dropping this block shrinks the
|
|
207
|
+
# prompt to ~22k tokens (one Read, faster turns) while KEEPING the "Best Example
|
|
208
|
+
# Per Style" block (which is style-scoped and tiny).
|
|
209
|
+
#
|
|
210
|
+
# This is a DELIVERY-LAYER trim only: the generator (top_performers.py, locked) is
|
|
211
|
+
# untouched — we strip the section from the prompt text right before it is queued.
|
|
212
|
+
# A loud marker is left in its place and a provider.log line is emitted, so it is
|
|
213
|
+
# obvious the section was intentionally hidden, not lost.
|
|
214
|
+
#
|
|
215
|
+
# DEFAULT: ON (hidden). Set S4L_HIDE_TOP_BY_PROJECT=0 (or false/no) to restore the
|
|
216
|
+
# full per-project example block.
|
|
217
|
+
HIDE_TOP_BY_PROJECT = (
|
|
218
|
+
os.environ.get("S4L_HIDE_TOP_BY_PROJECT", "1").strip().lower()
|
|
219
|
+
not in ("0", "false", "no", "off", "")
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _strip_top_by_project(prompt: str) -> str:
|
|
224
|
+
"""Remove the '### Top Posts by Project' block from a drafting prompt.
|
|
225
|
+
|
|
226
|
+
Returns the prompt with that one section replaced by a clearly-labelled
|
|
227
|
+
HIDDEN marker (so anyone reading the prompt sees it was intentionally hidden
|
|
228
|
+
behind S4L_HIDE_TOP_BY_PROJECT, not silently dropped). No-op if the block is
|
|
229
|
+
absent (e.g. query prompts, or a report that produced no per-project posts).
|
|
230
|
+
The section runs from its '### Top Posts by Project' header to the next '##'/
|
|
231
|
+
'###' header (normally '### Bottom N Posts').
|
|
232
|
+
"""
|
|
233
|
+
start = "### Top Posts by Project"
|
|
234
|
+
i = prompt.find(start)
|
|
235
|
+
if i < 0:
|
|
236
|
+
return prompt
|
|
237
|
+
m = re.search(r"\n#{2,3} ", prompt[i + len(start):])
|
|
238
|
+
j = (i + len(start) + m.start() + 1) if m else len(prompt)
|
|
239
|
+
marker = (
|
|
240
|
+
"### Top Posts by Project — HIDDEN\n"
|
|
241
|
+
"[This per-project few-shot block (~16k tokens) was hidden at the delivery "
|
|
242
|
+
"layer by claude_job.py via S4L_HIDE_TOP_BY_PROJECT (default ON, added "
|
|
243
|
+
"2026-06-29) so the drafting worker fits Claude Desktop's ~120s "
|
|
244
|
+
"scheduled-session window. Set S4L_HIDE_TOP_BY_PROJECT=0 to restore it. "
|
|
245
|
+
"The 'Best Example Per Style' block above is kept.]\n\n"
|
|
246
|
+
)
|
|
247
|
+
return prompt[:i] + marker + prompt[j:]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# --------------------------------------------------------------------------- #
|
|
252
|
+
# Queue layout #
|
|
253
|
+
# --------------------------------------------------------------------------- #
|
|
254
|
+
def _apply_state_dir_override(ns) -> None:
|
|
255
|
+
"""`--state-dir` wins over $S4L_STATE_DIR. The scheduled-task worker passes
|
|
256
|
+
it explicitly so it always reads the SAME queue the launchd kicker writes to,
|
|
257
|
+
regardless of what env the task session inherits."""
|
|
258
|
+
sd = getattr(ns, "state_dir", None)
|
|
259
|
+
if sd:
|
|
260
|
+
os.environ["S4L_STATE_DIR"] = sd
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def state_dir() -> str:
|
|
264
|
+
return os.environ.get("S4L_STATE_DIR") or os.path.join(
|
|
265
|
+
os.path.expanduser("~"), ".social-autoposter-mcp"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def queue_root() -> str:
|
|
270
|
+
return os.path.join(state_dir(), "claude-queue")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def pending_dir(qtype: str) -> str:
|
|
274
|
+
return os.path.join(queue_root(), "pending", qtype)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def running_dir() -> str:
|
|
278
|
+
return os.path.join(queue_root(), "running")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def result_dir() -> str:
|
|
282
|
+
return os.path.join(queue_root(), "result")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def heartbeat_path() -> str:
|
|
286
|
+
"""Single file the worker stamps each time it claims or completes a job. Its
|
|
287
|
+
mtime/contents prove the scheduled-task worker is actually draining the queue
|
|
288
|
+
(vs. the SKILL.md merely existing on disk, which survives a Claude account
|
|
289
|
+
switch and gave a false-green). Read by the MCP's autopilot liveness check and
|
|
290
|
+
the stall detector. Empty-queue ("no jobs") fires deliberately do NOT stamp it
|
|
291
|
+
— we want "is a job getting DRAINED", not "did a worker tick"."""
|
|
292
|
+
return os.path.join(queue_root(), "worker-heartbeat.json")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _stamp_heartbeat(event: str, qtype: str | None = None) -> None:
|
|
296
|
+
"""Best-effort: never let a heartbeat write failure break the queue."""
|
|
297
|
+
try:
|
|
298
|
+
os.makedirs(queue_root(), exist_ok=True)
|
|
299
|
+
_atomic_write(
|
|
300
|
+
heartbeat_path(),
|
|
301
|
+
{"at": time.time(), "event": event, "type": qtype or ""},
|
|
302
|
+
)
|
|
303
|
+
except Exception:
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def drain_status_path() -> str:
|
|
308
|
+
"""LATCHED autopilot-liveness marker the producer maintains: how many times in
|
|
309
|
+
a row it has enqueued a job and timed out with NO worker draining it. Unlike a
|
|
310
|
+
pending-job age check, this persists across the gaps between cycles (the
|
|
311
|
+
producer removes the job on timeout, so there's no pending file to look at
|
|
312
|
+
between cycles) — so the menu bar / dashboard / Sentry watcher can show a
|
|
313
|
+
CONTINUOUS stall instead of one that flickers off every time a job is removed.
|
|
314
|
+
Cleared (consecutive_timeouts=0) the moment a draft actually drains."""
|
|
315
|
+
return os.path.join(queue_root(), "drain-status.json")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _read_drain_status() -> dict:
|
|
319
|
+
try:
|
|
320
|
+
with open(drain_status_path()) as f:
|
|
321
|
+
return json.load(f)
|
|
322
|
+
except Exception:
|
|
323
|
+
return {}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _mark_drain_success() -> None:
|
|
327
|
+
"""A job drained successfully -> clear the latched stall."""
|
|
328
|
+
try:
|
|
329
|
+
os.makedirs(queue_root(), exist_ok=True)
|
|
330
|
+
_atomic_write(
|
|
331
|
+
drain_status_path(),
|
|
332
|
+
{"consecutive_timeouts": 0, "last_success_at": time.time()},
|
|
333
|
+
)
|
|
334
|
+
except Exception:
|
|
335
|
+
pass
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _bump_drain_timeout() -> None:
|
|
339
|
+
"""The producer gave up waiting -> latch/escalate the stall."""
|
|
340
|
+
try:
|
|
341
|
+
os.makedirs(queue_root(), exist_ok=True)
|
|
342
|
+
cur = _read_drain_status()
|
|
343
|
+
prev = int(cur.get("consecutive_timeouts", 0) or 0)
|
|
344
|
+
cur["consecutive_timeouts"] = prev + 1
|
|
345
|
+
cur["last_timeout_at"] = time.time()
|
|
346
|
+
_atomic_write(drain_status_path(), cur)
|
|
347
|
+
except Exception:
|
|
348
|
+
pass
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# --------------------------------------------------------------------------- #
|
|
352
|
+
# Opt-in worker self-reap (2026-06-27) #
|
|
353
|
+
# --------------------------------------------------------------------------- #
|
|
354
|
+
# A scheduled-task worker turn finishes its one queue iteration but Claude
|
|
355
|
+
# Desktop keeps the agent-mode `claude` process warm (`--input-format
|
|
356
|
+
# stream-json`), so finished workers pile up and leak RAM. The launchd reaper
|
|
357
|
+
# (reap_stale_claude_sessions.py) is the GUARANTEE that bounds this. This opt-in
|
|
358
|
+
# path is a faster, source-side trim: once THIS worker is provably done (no work
|
|
359
|
+
# to do, or its result is already on disk), terminate OUR OWN session so it never
|
|
360
|
+
# becomes part of the standing pool. Strictly off unless S4L_WORKER_SELF_REAP is
|
|
361
|
+
# set, so it ships dormant and cannot destabilize the default behaviour.
|
|
362
|
+
#
|
|
363
|
+
# Safety properties:
|
|
364
|
+
# * No-op unless the env flag is set.
|
|
365
|
+
# * Only ever targets a process in OUR OWN ancestry that matches the reaper's
|
|
366
|
+
# worker signature (claude-code agent-mode session). The producer side
|
|
367
|
+
# (run-twitter-cycle.sh -> python) has no such ancestor, so a misplaced call
|
|
368
|
+
# there finds nothing and does nothing.
|
|
369
|
+
# * Detached + delayed: a double-forked grandchild waits a few seconds (so the
|
|
370
|
+
# current turn returns and prints its final line normally) before signalling.
|
|
371
|
+
# * Re-verifies the target's cmdline right before SIGTERM, so a recycled PID is
|
|
372
|
+
# never signalled.
|
|
373
|
+
# * Best-effort throughout: never raises into the caller, never changes the
|
|
374
|
+
# worker's exit code, never touches the result already written to disk.
|
|
375
|
+
_SELF_REAP_SIG = (
|
|
376
|
+
"claude-code/",
|
|
377
|
+
"/Contents/MacOS/claude ",
|
|
378
|
+
"--input-format stream-json",
|
|
379
|
+
"local-agent-mode-sessions",
|
|
380
|
+
)
|
|
381
|
+
_SELF_REAP_UUID_RE = re.compile(r"local-agent-mode-sessions/[0-9a-fA-F-]{36}")
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _self_reap_enabled() -> bool:
|
|
385
|
+
return os.environ.get("S4L_WORKER_SELF_REAP", "").strip().lower() in (
|
|
386
|
+
"1",
|
|
387
|
+
"true",
|
|
388
|
+
"yes",
|
|
389
|
+
"on",
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _ps_pid_map() -> dict:
|
|
394
|
+
"""pid -> (ppid, command) for every process. Empty dict on any failure."""
|
|
395
|
+
out: dict = {}
|
|
396
|
+
try:
|
|
397
|
+
res = subprocess.run(
|
|
398
|
+
["/bin/ps", "-axo", "pid=,ppid=,command="],
|
|
399
|
+
capture_output=True,
|
|
400
|
+
text=True,
|
|
401
|
+
timeout=10,
|
|
402
|
+
)
|
|
403
|
+
except Exception:
|
|
404
|
+
return out
|
|
405
|
+
for line in res.stdout.splitlines():
|
|
406
|
+
parts = line.strip().split(None, 2)
|
|
407
|
+
if len(parts) < 3:
|
|
408
|
+
continue
|
|
409
|
+
try:
|
|
410
|
+
pid, ppid = int(parts[0]), int(parts[1])
|
|
411
|
+
except ValueError:
|
|
412
|
+
continue
|
|
413
|
+
out[pid] = (ppid, parts[2])
|
|
414
|
+
return out
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _find_own_session(psmap: dict):
|
|
418
|
+
"""Walk OUR ancestry to the nearest claude agent-mode worker session that
|
|
419
|
+
matches the reaper signature. Returns (pid, uuid_token) or None."""
|
|
420
|
+
pid = os.getpid()
|
|
421
|
+
seen: set = set()
|
|
422
|
+
for _ in range(40): # bounded climb up the process tree
|
|
423
|
+
if pid in seen:
|
|
424
|
+
break
|
|
425
|
+
seen.add(pid)
|
|
426
|
+
ent = psmap.get(pid)
|
|
427
|
+
if not ent:
|
|
428
|
+
break
|
|
429
|
+
ppid, cmd = ent
|
|
430
|
+
if all(t in cmd for t in _SELF_REAP_SIG) and "Helpers/disclaimer" not in cmd:
|
|
431
|
+
m = _SELF_REAP_UUID_RE.search(cmd)
|
|
432
|
+
if m:
|
|
433
|
+
return pid, m.group(0)
|
|
434
|
+
pid = ppid
|
|
435
|
+
if pid <= 1:
|
|
436
|
+
break
|
|
437
|
+
return None
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _maybe_self_reap(delay: float = 6.0) -> None:
|
|
441
|
+
"""Opt-in: terminate our own finished worker session. See block comment above."""
|
|
442
|
+
if not _self_reap_enabled():
|
|
443
|
+
return
|
|
444
|
+
try:
|
|
445
|
+
found = _find_own_session(_ps_pid_map())
|
|
446
|
+
if not found:
|
|
447
|
+
return
|
|
448
|
+
target_pid, token = found
|
|
449
|
+
if os.fork() != 0:
|
|
450
|
+
return # the worker continues + exits normally
|
|
451
|
+
except Exception:
|
|
452
|
+
return
|
|
453
|
+
# child -> detach into its own session, then exit, orphaning the grandchild
|
|
454
|
+
try:
|
|
455
|
+
os.setsid()
|
|
456
|
+
if os.fork() != 0:
|
|
457
|
+
os._exit(0)
|
|
458
|
+
except Exception:
|
|
459
|
+
os._exit(0)
|
|
460
|
+
# grandchild (fully detached): wait, re-verify, signal
|
|
461
|
+
try:
|
|
462
|
+
time.sleep(delay)
|
|
463
|
+
cur = _ps_pid_map().get(target_pid)
|
|
464
|
+
if cur and token in cur[1]: # same session, not a recycled PID
|
|
465
|
+
try:
|
|
466
|
+
os.kill(target_pid, signal.SIGTERM)
|
|
467
|
+
except OSError:
|
|
468
|
+
pass
|
|
469
|
+
except Exception:
|
|
470
|
+
pass
|
|
471
|
+
os._exit(0)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _ensure_dirs(qtype: str | None = None) -> None:
|
|
475
|
+
for d in (running_dir(), result_dir()):
|
|
476
|
+
os.makedirs(d, exist_ok=True)
|
|
477
|
+
if qtype:
|
|
478
|
+
os.makedirs(pending_dir(qtype), exist_ok=True)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _atomic_write(path: str, obj) -> None:
|
|
482
|
+
tmp = f"{path}.tmp.{os.getpid()}"
|
|
483
|
+
with open(tmp, "w") as f:
|
|
484
|
+
json.dump(obj, f)
|
|
485
|
+
os.replace(tmp, path)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _atomic_write_text(path: str, text: str) -> None:
|
|
489
|
+
tmp = f"{path}.tmp.{os.getpid()}"
|
|
490
|
+
with open(tmp, "w") as f:
|
|
491
|
+
f.write(text)
|
|
492
|
+
os.replace(tmp, path)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _sweep_stale() -> int:
|
|
496
|
+
"""Remove pending/running job files older than STALE_TTL_S. Returns count."""
|
|
497
|
+
removed = 0
|
|
498
|
+
now = time.time()
|
|
499
|
+
roots = [running_dir()]
|
|
500
|
+
pend = os.path.join(queue_root(), "pending")
|
|
501
|
+
if os.path.isdir(pend):
|
|
502
|
+
roots += [os.path.join(pend, d) for d in os.listdir(pend)]
|
|
503
|
+
for d in roots:
|
|
504
|
+
if not os.path.isdir(d):
|
|
505
|
+
continue
|
|
506
|
+
for name in os.listdir(d):
|
|
507
|
+
if not name.endswith(".json"):
|
|
508
|
+
continue
|
|
509
|
+
fp = os.path.join(d, name)
|
|
510
|
+
try:
|
|
511
|
+
with open(fp) as f:
|
|
512
|
+
created = json.load(f).get("created_at", 0)
|
|
513
|
+
if now - float(created) > STALE_TTL_S:
|
|
514
|
+
os.remove(fp)
|
|
515
|
+
removed += 1
|
|
516
|
+
except Exception:
|
|
517
|
+
continue
|
|
518
|
+
return removed
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
# --------------------------------------------------------------------------- #
|
|
522
|
+
# provider (producer side, run by run_claude.sh) #
|
|
523
|
+
# --------------------------------------------------------------------------- #
|
|
524
|
+
def _parse_claude_args(args: list[str]) -> tuple[str | None, str | None]:
|
|
525
|
+
"""Return (trailing_prompt, schema_path) from the verbatim claude argv."""
|
|
526
|
+
schema_path = None
|
|
527
|
+
positionals: list[str] = []
|
|
528
|
+
i = 0
|
|
529
|
+
while i < len(args):
|
|
530
|
+
a = args[i]
|
|
531
|
+
if a == "--json-schema":
|
|
532
|
+
schema_path = args[i + 1] if i + 1 < len(args) else None
|
|
533
|
+
i += 2
|
|
534
|
+
continue
|
|
535
|
+
if a in VALUE_FLAGS:
|
|
536
|
+
i += 2
|
|
537
|
+
continue
|
|
538
|
+
if a.startswith("-"):
|
|
539
|
+
i += 1 # boolean flag (-p, --strict-mcp-config, --verbose, ...)
|
|
540
|
+
continue
|
|
541
|
+
positionals.append(a)
|
|
542
|
+
i += 1
|
|
543
|
+
prompt = positionals[-1] if positionals else None
|
|
544
|
+
return prompt, schema_path
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _plog(msg: str) -> None:
|
|
548
|
+
"""Provider diagnostics go to a log file, NEVER stderr.
|
|
549
|
+
|
|
550
|
+
The pipeline captures this wrapper's output with `2>&1` and parses the FIRST
|
|
551
|
+
JSON value (raw_decode). Anything we print to stderr BEFORE the envelope (e.g.
|
|
552
|
+
an "enqueued, waiting" line) lands ahead of the JSON and breaks the parse with
|
|
553
|
+
"Expecting value: line 1 column 2". So stdout carries ONLY the final envelope
|
|
554
|
+
and stderr stays silent; humans read provider.log instead. (fix 2026-06-24)
|
|
555
|
+
"""
|
|
556
|
+
try:
|
|
557
|
+
os.makedirs(queue_root(), exist_ok=True)
|
|
558
|
+
with open(os.path.join(queue_root(), "provider.log"), "a") as f:
|
|
559
|
+
f.write(f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} pid={os.getpid()} {msg}\n")
|
|
560
|
+
except Exception:
|
|
561
|
+
pass
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def cmd_provider(ns) -> int:
|
|
565
|
+
_apply_state_dir_override(ns)
|
|
566
|
+
qtype = TAG_TO_TYPE.get(ns.tag)
|
|
567
|
+
if not qtype:
|
|
568
|
+
# Not a queue-eligible call. Exit non-zero so run_claude.sh's caller
|
|
569
|
+
# treats it as a claude failure and runs its own fallback path.
|
|
570
|
+
_plog(f"tag '{ns.tag}' is not queue-eligible; no provider")
|
|
571
|
+
return 1
|
|
572
|
+
|
|
573
|
+
stdin_text = ""
|
|
574
|
+
if not sys.stdin.isatty():
|
|
575
|
+
try:
|
|
576
|
+
stdin_text = sys.stdin.read()
|
|
577
|
+
except Exception:
|
|
578
|
+
stdin_text = ""
|
|
579
|
+
|
|
580
|
+
trailing_prompt, schema_path = _parse_claude_args(ns.claude_args)
|
|
581
|
+
prompt = stdin_text if stdin_text.strip() else (trailing_prompt or "")
|
|
582
|
+
if not prompt.strip():
|
|
583
|
+
_plog("empty prompt; nothing to enqueue")
|
|
584
|
+
return 1
|
|
585
|
+
|
|
586
|
+
schema_text = None
|
|
587
|
+
if schema_path and os.path.exists(schema_path):
|
|
588
|
+
try:
|
|
589
|
+
with open(schema_path) as f:
|
|
590
|
+
schema_text = f.read()
|
|
591
|
+
except Exception:
|
|
592
|
+
schema_text = None
|
|
593
|
+
|
|
594
|
+
# Delivery-layer trim: hide the "Top Posts by Project" block from drafting
|
|
595
|
+
# prompts so the worker fits the ~120s scheduled-session window. See
|
|
596
|
+
# HIDE_TOP_BY_PROJECT / _strip_top_by_project above. Drafting jobs only.
|
|
597
|
+
if qtype == "twitter-prep" and HIDE_TOP_BY_PROJECT:
|
|
598
|
+
_before_len = len(prompt)
|
|
599
|
+
prompt = _strip_top_by_project(prompt)
|
|
600
|
+
if len(prompt) != _before_len:
|
|
601
|
+
_plog(
|
|
602
|
+
"hid 'Top Posts by Project' block: -%d chars "
|
|
603
|
+
"(S4L_HIDE_TOP_BY_PROJECT on; set =0 to restore)"
|
|
604
|
+
% (_before_len - len(prompt))
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
job_id = uuid.uuid4().hex
|
|
608
|
+
created = time.time()
|
|
609
|
+
_ensure_dirs(qtype)
|
|
610
|
+
_sweep_stale() # clear leftovers from prior timed-out producers before enqueuing
|
|
611
|
+
job = {
|
|
612
|
+
"job_id": job_id,
|
|
613
|
+
"type": qtype,
|
|
614
|
+
"tag": ns.tag,
|
|
615
|
+
"prompt": prompt,
|
|
616
|
+
"schema": schema_text,
|
|
617
|
+
"created_at": created,
|
|
618
|
+
}
|
|
619
|
+
# Filename is <created_ns>_<job_id>.json so a plain sorted() listing is FIFO.
|
|
620
|
+
fname = f"{int(created * 1e9):020d}_{job_id}.json"
|
|
621
|
+
pending_path = os.path.join(pending_dir(qtype), fname)
|
|
622
|
+
running_path = os.path.join(running_dir(), fname)
|
|
623
|
+
_atomic_write(pending_path, job)
|
|
624
|
+
_plog(f"enqueued {qtype} job {job_id}; waiting for a scheduled task (timeout {ns.timeout}s)")
|
|
625
|
+
# Narrate the (multi-minute) block to the menu bar. The launchd draft lane has
|
|
626
|
+
# no other activity writer, so without this the box looks idle while it works.
|
|
627
|
+
# Cleared by run-draft-and-publish.sh's exit trap at cycle end (and by the
|
|
628
|
+
# worker's cmd_result), so we deliberately do NOT clear on the success path
|
|
629
|
+
# here — that would flicker the indicator off between the cycle's claude calls.
|
|
630
|
+
_act_write(qtype)
|
|
631
|
+
|
|
632
|
+
res_path = os.path.join(result_dir(), f"{job_id}.json")
|
|
633
|
+
deadline = created + ns.timeout
|
|
634
|
+
last_hb = created # last menu-bar heartbeat (see below)
|
|
635
|
+
claimed_at = None # set the moment a worker moves the job pending/ -> running/
|
|
636
|
+
while time.time() < deadline:
|
|
637
|
+
now = time.time()
|
|
638
|
+
# A worker claims a job by atomically renaming pending/ -> running/, so the
|
|
639
|
+
# pending file vanishing is our signal that drafting actually STARTED (vs.
|
|
640
|
+
# the job still sitting unclaimed). Latch the claim time once so the label
|
|
641
|
+
# can distinguish "waiting for a worker" from "worker is drafting" and show
|
|
642
|
+
# the right elapsed for each.
|
|
643
|
+
if claimed_at is None and not os.path.exists(pending_path):
|
|
644
|
+
claimed_at = now
|
|
645
|
+
# Heartbeat the menu-bar label so its `since` stays fresh for the whole
|
|
646
|
+
# multi-minute block. The consumer (s4l_state.read_activity) ages a label
|
|
647
|
+
# out after a TTL, so without this refresh a long drafting turn would look
|
|
648
|
+
# stale and the spinner would wrongly blink to idle. Refreshing here means
|
|
649
|
+
# the label is fresh EXACTLY while real work is happening, and stops the
|
|
650
|
+
# instant we return or die — so the consumer's TTL can then expire it
|
|
651
|
+
# instead of it freezing forever. Throttled to ~10s; best-effort only. The
|
|
652
|
+
# label now carries claim-state + elapsed so a stuck cycle reads honestly
|
|
653
|
+
# ("queued 18m") instead of a reassuring static "drafting replies".
|
|
654
|
+
if now - last_hb >= 10:
|
|
655
|
+
_act_write_progress(qtype, created, claimed_at, now)
|
|
656
|
+
last_hb = now
|
|
657
|
+
if os.path.exists(res_path):
|
|
658
|
+
try:
|
|
659
|
+
with open(res_path) as f:
|
|
660
|
+
res = json.load(f)
|
|
661
|
+
except Exception:
|
|
662
|
+
time.sleep(POLL_INTERVAL_S)
|
|
663
|
+
continue
|
|
664
|
+
os.remove(res_path)
|
|
665
|
+
if res.get("status") == "error":
|
|
666
|
+
_plog(f"job {job_id} returned error: {res.get('error', 'unknown')}")
|
|
667
|
+
return 1
|
|
668
|
+
obj = res.get("result")
|
|
669
|
+
# Emit a claude `--output-format json` shaped envelope so the
|
|
670
|
+
# pipeline's existing raw_decode + structured_output/result parser
|
|
671
|
+
# is byte-compatible.
|
|
672
|
+
envelope = {
|
|
673
|
+
"type": "result",
|
|
674
|
+
"subtype": "success",
|
|
675
|
+
"is_error": False,
|
|
676
|
+
"structured_output": obj,
|
|
677
|
+
"result": json.dumps(obj) if not isinstance(obj, str) else obj,
|
|
678
|
+
}
|
|
679
|
+
sys.stdout.write(json.dumps(envelope))
|
|
680
|
+
sys.stdout.flush()
|
|
681
|
+
# A worker drained this job -> the autopilot is alive; clear any latched
|
|
682
|
+
# stall so the menu bar / dashboard / Sentry watcher recover.
|
|
683
|
+
_mark_drain_success()
|
|
684
|
+
return 0
|
|
685
|
+
time.sleep(POLL_INTERVAL_S)
|
|
686
|
+
|
|
687
|
+
# Don't leak the job: remove it from pending/running so it can't be drafted
|
|
688
|
+
# later with a stale prompt (and so /tmp doesn't accumulate stuck jobs).
|
|
689
|
+
for p in (pending_path, running_path):
|
|
690
|
+
try:
|
|
691
|
+
os.remove(p)
|
|
692
|
+
except OSError:
|
|
693
|
+
pass
|
|
694
|
+
# We gave up waiting for a worker — drop the "drafting" menu-bar label we kept
|
|
695
|
+
# re-asserting while blocked. Otherwise it lingers and the menu bar shows
|
|
696
|
+
# "drafting replies" forever (masking the autopilot-stalled ⚠) even though no
|
|
697
|
+
# routine ever claimed the job.
|
|
698
|
+
_act_clear()
|
|
699
|
+
# Latch the stall so it persists across the gap until the next cycle enqueues
|
|
700
|
+
# (no pending file exists between cycles, so an instantaneous queue check would
|
|
701
|
+
# flicker the ⚠ off). Cleared only when a draft actually drains.
|
|
702
|
+
_bump_drain_timeout()
|
|
703
|
+
_plog(f"timed out after {ns.timeout}s waiting for job {job_id} ({qtype}); removed the job")
|
|
704
|
+
return 79 # mirror run_claude.sh's "blocked, skip cleanly" exit code
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
# --------------------------------------------------------------------------- #
|
|
708
|
+
# next (consumer side, run by a scheduled task) #
|
|
709
|
+
# --------------------------------------------------------------------------- #
|
|
710
|
+
def _agent_session_pid():
|
|
711
|
+
"""Best-effort: the Claude agent-mode SESSION pid running THIS worker — the
|
|
712
|
+
exact process the stale-session reaper (reap_stale_claude_sessions.py) would
|
|
713
|
+
target. We climb our own process tree to the ancestor whose cmd carries the
|
|
714
|
+
reaper's worker signature ('claude-code/' + 'local-agent-mode-sessions') and
|
|
715
|
+
return its pid, so the claim can be stamped with it and the reaper can SPARE
|
|
716
|
+
that session for the whole drafting turn (instead of SIGTERMing it at the short
|
|
717
|
+
grace window — the 2026-06-29 draft-kill regression). None if not identifiable;
|
|
718
|
+
the reaper then falls back to its newest-spare heuristic.
|
|
719
|
+
"""
|
|
720
|
+
try:
|
|
721
|
+
out = subprocess.run(
|
|
722
|
+
["/bin/ps", "-axo", "pid=,ppid=,command="],
|
|
723
|
+
capture_output=True, text=True, timeout=10,
|
|
724
|
+
).stdout
|
|
725
|
+
info = {}
|
|
726
|
+
for line in out.splitlines():
|
|
727
|
+
m = re.match(r"\s*(\d+)\s+(\d+)\s+(.*)$", line)
|
|
728
|
+
if m:
|
|
729
|
+
info[int(m.group(1))] = (int(m.group(2)), m.group(3))
|
|
730
|
+
pid = os.getpid()
|
|
731
|
+
for _ in range(16): # bounded climb up the tree
|
|
732
|
+
ent = info.get(pid)
|
|
733
|
+
if not ent or ent[0] <= 1:
|
|
734
|
+
break
|
|
735
|
+
ppid = ent[0]
|
|
736
|
+
pcmd = info.get(ppid, (0, ""))[1]
|
|
737
|
+
if ("claude-code/" in pcmd) and ("local-agent-mode-sessions" in pcmd):
|
|
738
|
+
return ppid
|
|
739
|
+
pid = ppid
|
|
740
|
+
except Exception:
|
|
741
|
+
return None
|
|
742
|
+
return None
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def cmd_next(ns) -> int:
|
|
746
|
+
_apply_state_dir_override(ns)
|
|
747
|
+
qtype = ns.type
|
|
748
|
+
# "any" (the universal type-blind worker) scans EVERY pending type dir;
|
|
749
|
+
# a comma list scans those types; a single type keeps legacy behavior.
|
|
750
|
+
# Job filenames start with a zero-padded nanosecond timestamp, so one
|
|
751
|
+
# global lexicographic sort is oldest-first across types.
|
|
752
|
+
if qtype == "any":
|
|
753
|
+
_ensure_dirs()
|
|
754
|
+
root = os.path.join(queue_root(), "pending")
|
|
755
|
+
try:
|
|
756
|
+
scan_types = sorted(
|
|
757
|
+
d for d in os.listdir(root) if os.path.isdir(os.path.join(root, d))
|
|
758
|
+
)
|
|
759
|
+
except FileNotFoundError:
|
|
760
|
+
scan_types = []
|
|
761
|
+
else:
|
|
762
|
+
scan_types = [t.strip() for t in qtype.split(",") if t.strip()]
|
|
763
|
+
for t in scan_types:
|
|
764
|
+
_ensure_dirs(t)
|
|
765
|
+
entries = []
|
|
766
|
+
for t in scan_types:
|
|
767
|
+
pend = pending_dir(t)
|
|
768
|
+
try:
|
|
769
|
+
for name in os.listdir(pend):
|
|
770
|
+
entries.append((name, pend))
|
|
771
|
+
except FileNotFoundError:
|
|
772
|
+
continue
|
|
773
|
+
entries.sort(key=lambda e: e[0])
|
|
774
|
+
for name, pend in entries:
|
|
775
|
+
if not name.endswith(".json") or name.endswith(".tmp"):
|
|
776
|
+
continue
|
|
777
|
+
src = os.path.join(pend, name)
|
|
778
|
+
dst = os.path.join(running_dir(), name)
|
|
779
|
+
try:
|
|
780
|
+
os.rename(src, dst) # atomic claim; loser of a race gets FileNotFound
|
|
781
|
+
except FileNotFoundError:
|
|
782
|
+
continue
|
|
783
|
+
try:
|
|
784
|
+
with open(dst) as f:
|
|
785
|
+
job = json.load(f)
|
|
786
|
+
except Exception:
|
|
787
|
+
continue
|
|
788
|
+
# Stamp the agent-session pid that holds THIS claim so the reaper spares it
|
|
789
|
+
# for the whole drafting turn (see _agent_session_pid above).
|
|
790
|
+
agent_pid = _agent_session_pid()
|
|
791
|
+
if agent_pid:
|
|
792
|
+
job["claim_pid"] = agent_pid
|
|
793
|
+
job["claimed_at"] = time.time()
|
|
794
|
+
prompt_file = None
|
|
795
|
+
schema_file = None
|
|
796
|
+
if ns.prompt_file:
|
|
797
|
+
prompt_file = os.path.join(queue_root(), f"prompt-{job['job_id']}.md")
|
|
798
|
+
prompt_body = job.get("prompt") or ""
|
|
799
|
+
# Per-type execution notes ride in the sidecar, not the worker
|
|
800
|
+
# prompt, so the worker stays type-blind (see TYPE_TO_WORKER_NOTES).
|
|
801
|
+
notes = TYPE_TO_WORKER_NOTES.get(job.get("type") or "")
|
|
802
|
+
if notes:
|
|
803
|
+
prompt_body = f"{notes}\n\n---\n\n{prompt_body}"
|
|
804
|
+
_atomic_write_text(prompt_file, prompt_body)
|
|
805
|
+
job["prompt_file"] = prompt_file
|
|
806
|
+
schema = job.get("schema")
|
|
807
|
+
if schema:
|
|
808
|
+
schema_file = os.path.join(queue_root(), f"schema-{job['job_id']}.json")
|
|
809
|
+
_atomic_write_text(schema_file, schema)
|
|
810
|
+
job["schema_file"] = schema_file
|
|
811
|
+
# ALWAYS persist the claim back (claim_pid + any prompt/schema sidecars) so
|
|
812
|
+
# the reaper can read claim_pid; previously this only happened on the
|
|
813
|
+
# --prompt-file lane, leaving claim_pid unstamped for inline callers.
|
|
814
|
+
_atomic_write(dst, job)
|
|
815
|
+
_plog(
|
|
816
|
+
f"claimed {job.get('type') or qtype} job {job['job_id']}; "
|
|
817
|
+
+ (f"agent-session pid={agent_pid} stamped (reaper will spare it)"
|
|
818
|
+
if agent_pid else
|
|
819
|
+
"agent-session pid NOT found (reaper falls back to newest-spare)")
|
|
820
|
+
)
|
|
821
|
+
# Narrate the scheduled-task worker's drafting turn to the menu bar. This
|
|
822
|
+
# is the lane that actually runs the LLM; it persists until cmd_result
|
|
823
|
+
# clears it (or the kicker's exit trap does). Covers the box's autopilot.
|
|
824
|
+
_act_write(job.get("type") or qtype)
|
|
825
|
+
# Liveness pulse: a routine actually claimed a job. Proves the worker is
|
|
826
|
+
# firing, not just that its SKILL.md exists (see heartbeat_path()).
|
|
827
|
+
_stamp_heartbeat("claim", job.get("type") or qtype)
|
|
828
|
+
# Hand the consumer exactly what it needs to do the work and report back.
|
|
829
|
+
payload = {"job_id": job["job_id"], "type": job["type"]}
|
|
830
|
+
if ns.prompt_file:
|
|
831
|
+
payload["prompt_file"] = prompt_file
|
|
832
|
+
payload["schema_file"] = schema_file
|
|
833
|
+
else:
|
|
834
|
+
payload["prompt"] = job["prompt"]
|
|
835
|
+
payload["schema"] = job.get("schema")
|
|
836
|
+
print(json.dumps(payload))
|
|
837
|
+
return 0
|
|
838
|
+
print(json.dumps({})) # no work
|
|
839
|
+
_maybe_self_reap() # idle turn, no job claimed — safe to retire this session
|
|
840
|
+
return 0
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
# --------------------------------------------------------------------------- #
|
|
844
|
+
# result (consumer side, run by a scheduled task) #
|
|
845
|
+
# --------------------------------------------------------------------------- #
|
|
846
|
+
def _validate_against_schema(obj, schema_text: str | None) -> str | None:
|
|
847
|
+
"""Lenient validation. Returns an error string or None if acceptable.
|
|
848
|
+
|
|
849
|
+
We deliberately avoid a jsonschema dependency (not guaranteed on the box).
|
|
850
|
+
Enforce only what matters: the result is a JSON object and carries the
|
|
851
|
+
schema's top-level required keys. The prompt itself describes the full shape.
|
|
852
|
+
"""
|
|
853
|
+
if schema_text:
|
|
854
|
+
try:
|
|
855
|
+
schema = json.loads(schema_text)
|
|
856
|
+
except Exception:
|
|
857
|
+
schema = None
|
|
858
|
+
if isinstance(schema, dict):
|
|
859
|
+
if schema.get("type") == "object" and not isinstance(obj, dict):
|
|
860
|
+
return "result must be a JSON object"
|
|
861
|
+
required = schema.get("required")
|
|
862
|
+
if isinstance(required, list) and isinstance(obj, dict):
|
|
863
|
+
missing = [k for k in required if k not in obj]
|
|
864
|
+
if missing:
|
|
865
|
+
return f"result missing required keys: {missing}"
|
|
866
|
+
return None
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def cmd_result(ns) -> int:
|
|
870
|
+
_apply_state_dir_override(ns)
|
|
871
|
+
_ensure_dirs()
|
|
872
|
+
# The worker's drafting turn ends here (success or failure); drop the menu-bar
|
|
873
|
+
# label so nothing lingers. The provider's next enqueue re-asserts the right
|
|
874
|
+
# label for the cycle's following claude call, if any.
|
|
875
|
+
_act_clear()
|
|
876
|
+
# Liveness pulse: a routine completed a drain. Keeps the heartbeat fresh
|
|
877
|
+
# across the whole claim->result span the worker was alive.
|
|
878
|
+
_stamp_heartbeat("result")
|
|
879
|
+
job_id = ns.job
|
|
880
|
+
# Read the produced result (JSON object) from a file or stdin.
|
|
881
|
+
if ns.result_file and ns.result_file != "-":
|
|
882
|
+
with open(ns.result_file) as f:
|
|
883
|
+
raw = f.read()
|
|
884
|
+
else:
|
|
885
|
+
raw = sys.stdin.read()
|
|
886
|
+
raw = raw.strip()
|
|
887
|
+
|
|
888
|
+
running = None
|
|
889
|
+
# Locate the claimed job to recover its schema (filename carries job_id).
|
|
890
|
+
schema_text = None
|
|
891
|
+
cleanup_files: list[str] = []
|
|
892
|
+
try:
|
|
893
|
+
for name in os.listdir(running_dir()):
|
|
894
|
+
if name.endswith(f"_{job_id}.json"):
|
|
895
|
+
with open(os.path.join(running_dir(), name)) as f:
|
|
896
|
+
job = json.load(f)
|
|
897
|
+
schema_text = job.get("schema")
|
|
898
|
+
cleanup_files = [
|
|
899
|
+
p for p in (job.get("prompt_file"), job.get("schema_file")) if p
|
|
900
|
+
]
|
|
901
|
+
running = os.path.join(running_dir(), name)
|
|
902
|
+
break
|
|
903
|
+
except FileNotFoundError:
|
|
904
|
+
running = None
|
|
905
|
+
|
|
906
|
+
if ns.error:
|
|
907
|
+
_atomic_write(
|
|
908
|
+
os.path.join(result_dir(), f"{job_id}.json"),
|
|
909
|
+
{"status": "error", "error": raw or "unspecified"},
|
|
910
|
+
)
|
|
911
|
+
if running and os.path.exists(running):
|
|
912
|
+
os.remove(running)
|
|
913
|
+
for p in cleanup_files:
|
|
914
|
+
try:
|
|
915
|
+
os.remove(p)
|
|
916
|
+
except OSError:
|
|
917
|
+
pass
|
|
918
|
+
print(f"[claude_job] recorded error for job {job_id}", file=sys.stderr)
|
|
919
|
+
_maybe_self_reap() # error recorded, turn done — safe to retire this session
|
|
920
|
+
return 0
|
|
921
|
+
|
|
922
|
+
try:
|
|
923
|
+
obj = json.loads(raw)
|
|
924
|
+
except Exception as e:
|
|
925
|
+
print(
|
|
926
|
+
f"[claude_job] result for job {job_id} is not valid JSON: {e}",
|
|
927
|
+
file=sys.stderr,
|
|
928
|
+
)
|
|
929
|
+
return 2
|
|
930
|
+
|
|
931
|
+
err = _validate_against_schema(obj, schema_text)
|
|
932
|
+
if err:
|
|
933
|
+
print(f"[claude_job] result for job {job_id} rejected: {err}", file=sys.stderr)
|
|
934
|
+
return 3
|
|
935
|
+
|
|
936
|
+
_atomic_write(
|
|
937
|
+
os.path.join(result_dir(), f"{job_id}.json"),
|
|
938
|
+
{"status": "done", "result": obj},
|
|
939
|
+
)
|
|
940
|
+
if running and os.path.exists(running):
|
|
941
|
+
os.remove(running)
|
|
942
|
+
for p in cleanup_files:
|
|
943
|
+
try:
|
|
944
|
+
os.remove(p)
|
|
945
|
+
except OSError:
|
|
946
|
+
pass
|
|
947
|
+
print(f"[claude_job] stored result for job {job_id}", file=sys.stderr)
|
|
948
|
+
_maybe_self_reap() # result delivered to disk — safe to retire this session
|
|
949
|
+
return 0
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def main() -> int:
|
|
953
|
+
p = argparse.ArgumentParser(description="claude -p queue shim")
|
|
954
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
955
|
+
|
|
956
|
+
pp = sub.add_parser("provider", help="enqueue + block-poll (run by run_claude.sh)")
|
|
957
|
+
pp.add_argument("--tag", required=True)
|
|
958
|
+
pp.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT_S)
|
|
959
|
+
pp.add_argument("--state-dir", default=None, help="override $S4L_STATE_DIR")
|
|
960
|
+
pp.add_argument("claude_args", nargs=argparse.REMAINDER)
|
|
961
|
+
pp.set_defaults(func=cmd_provider)
|
|
962
|
+
|
|
963
|
+
pn = sub.add_parser("next", help="claim oldest pending job of a type")
|
|
964
|
+
pn.add_argument("--type", required=True)
|
|
965
|
+
pn.add_argument("--state-dir", default=None, help="override $S4L_STATE_DIR")
|
|
966
|
+
pn.add_argument(
|
|
967
|
+
"--prompt-file",
|
|
968
|
+
action="store_true",
|
|
969
|
+
help="write the prompt/schema to sidecar files and print their paths",
|
|
970
|
+
)
|
|
971
|
+
pn.set_defaults(func=cmd_next)
|
|
972
|
+
|
|
973
|
+
pr = sub.add_parser("result", help="store a job's result")
|
|
974
|
+
pr.add_argument("--job", required=True)
|
|
975
|
+
pr.add_argument("--result-file", default="-", help="path to JSON, or - for stdin")
|
|
976
|
+
pr.add_argument("--error", action="store_true", help="record a failure")
|
|
977
|
+
pr.add_argument("--state-dir", default=None, help="override $S4L_STATE_DIR")
|
|
978
|
+
pr.set_defaults(func=cmd_result)
|
|
979
|
+
|
|
980
|
+
ns = p.parse_args()
|
|
981
|
+
# argparse.REMAINDER keeps a leading "--"; drop it.
|
|
982
|
+
if getattr(ns, "claude_args", None) and ns.claude_args and ns.claude_args[0] == "--":
|
|
983
|
+
ns.claude_args = ns.claude_args[1:]
|
|
984
|
+
return ns.func(ns)
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
if __name__ == "__main__":
|
|
988
|
+
sys.exit(main())
|