@m13v/s4l 1.6.203-rc.21 → 1.6.203-rc.22
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/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +15 -1
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/active_experiments.py +39 -0
- package/scripts/claude_job.py +15 -2
- package/scripts/relay_provider_log.py +189 -0
- package/scripts/salvage_orphaned_prep_results.py +178 -0
- package/skill/run-draft-and-publish.sh +8 -0
- package/skill/run-twitter-cycle.sh +19 -19
package/mcp/dist/version.json
CHANGED
package/mcp/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"dxt_version": "0.1",
|
|
3
3
|
"name": "social-autoposter",
|
|
4
4
|
"display_name": "S4L",
|
|
5
|
-
"version": "1.6.203-rc.
|
|
5
|
+
"version": "1.6.203-rc.22",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts.",
|
|
7
7
|
"long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
|
|
8
8
|
"author": {
|
package/mcp/menubar/s4l_card.py
CHANGED
|
@@ -303,13 +303,27 @@ def _details_lines(d):
|
|
|
303
303
|
# merge_review_queue.py from scripts/active_experiments.py. Rendered
|
|
304
304
|
# generically so every future experiment surfaces here with no card change.
|
|
305
305
|
# 'lane' reads as a scenario, not an A/B arm, so it gets its own label.
|
|
306
|
+
# Each arm carries its meaning from the DESCRIPTIONS registry in
|
|
307
|
+
# active_experiments.py (scripts/ is on sys.path via s4l_state's
|
|
308
|
+
# S4L_REPO_DIR insertion); the reviewer needs what the variant DOES, not
|
|
309
|
+
# just its name. Unknown arms fall back to the bare name.
|
|
306
310
|
exps = d.get("experiments") or {}
|
|
311
|
+
if exps:
|
|
312
|
+
try:
|
|
313
|
+
from active_experiments import describe as _exp_describe
|
|
314
|
+
except Exception:
|
|
315
|
+
def _exp_describe(_name, _variant):
|
|
316
|
+
return None
|
|
307
317
|
for name in sorted(exps):
|
|
308
318
|
label = (
|
|
309
319
|
"Lane" if name == "lane"
|
|
310
320
|
else f"Experiment {str(name).replace('_', ' ')}"
|
|
311
321
|
)
|
|
312
|
-
|
|
322
|
+
line = f"{label}: {exps[name]}"
|
|
323
|
+
exp_desc = _exp_describe(name, exps[name])
|
|
324
|
+
if exp_desc:
|
|
325
|
+
line += f" ({exp_desc})"
|
|
326
|
+
lines.append(line)
|
|
313
327
|
topic = (d.get("search_topic") or "").strip()
|
|
314
328
|
if topic:
|
|
315
329
|
lines.append(f"Found via search: {topic}")
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.6.203-rc.
|
|
3
|
+
"version": "1.6.203-rc.22",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
|
|
6
6
|
"license": "MIT",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l",
|
|
3
|
-
"version": "1.6.203-rc.
|
|
3
|
+
"version": "1.6.203-rc.22",
|
|
4
4
|
"description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"social-autoposter": "bin/cli.js",
|
|
@@ -32,6 +32,45 @@ import sys
|
|
|
32
32
|
|
|
33
33
|
ENV_PREFIX = "S4L_EXP_"
|
|
34
34
|
|
|
35
|
+
# What each arm MEANS, for humans reviewing cards: the details popover shows
|
|
36
|
+
# this next to the bare variant name (s4l_card._details_lines calls
|
|
37
|
+
# describe()). Keyed experiment name -> variant -> short description; one
|
|
38
|
+
# sentence, the popover wraps at ~300px. A missing entry degrades to the bare
|
|
39
|
+
# variant name, so unknown or future arms need no code change to render.
|
|
40
|
+
# Keep draft_prompt entries in sync with bin/server.js
|
|
41
|
+
# DRAFT_PROMPT_VARIANT_DEFS and the arm strings in run-twitter-cycle.sh.
|
|
42
|
+
DESCRIPTIONS = {
|
|
43
|
+
"draft_prompt": {
|
|
44
|
+
"treatment_v2": (
|
|
45
|
+
"skeleton ban: forbids the concede-then-reverse "
|
|
46
|
+
'"easy X / hard Y" structure and forces varied entry points'
|
|
47
|
+
),
|
|
48
|
+
"control_v2": (
|
|
49
|
+
"current draft directive (style + product pivot), no structure ban"
|
|
50
|
+
),
|
|
51
|
+
# v1 arms (decoupled-product-pivot) retired 2026-07-06; kept so any
|
|
52
|
+
# straggler card from an old plan still explains itself.
|
|
53
|
+
"treatment": "v1, retired: product pivot decoupled from the reply",
|
|
54
|
+
"control": "v1, retired: original draft directive",
|
|
55
|
+
},
|
|
56
|
+
"lane": {
|
|
57
|
+
"personal_brand": (
|
|
58
|
+
"organic persona lane: first-hand voice, no product, no link, no CTA"
|
|
59
|
+
),
|
|
60
|
+
"promotion": (
|
|
61
|
+
"product lane: routes to the matched project and may carry a link"
|
|
62
|
+
),
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def describe(name, variant):
|
|
68
|
+
"""Human-readable meaning of an arm, or None when unknown."""
|
|
69
|
+
try:
|
|
70
|
+
return DESCRIPTIONS.get(str(name), {}).get(str(variant))
|
|
71
|
+
except Exception:
|
|
72
|
+
return None
|
|
73
|
+
|
|
35
74
|
# Experiments/scenarios that predate the S4L_EXP_ convention, keyed by env
|
|
36
75
|
# var. Order matters where two vars map to one name: later entries win, so
|
|
37
76
|
# S4L_CYCLE_LANE (the wrapper's authoritative per-cycle lane tag) overrides
|
package/scripts/claude_job.py
CHANGED
|
@@ -637,6 +637,9 @@ def cmd_provider(ns) -> int:
|
|
|
637
637
|
|
|
638
638
|
job_id = uuid.uuid4().hex
|
|
639
639
|
created = time.time()
|
|
640
|
+
# Cycle batch id (run-twitter-cycle.sh exports BATCH_ID) so every provider.log
|
|
641
|
+
# line correlates to the cycle's own twitter-cycle-<batch>.log. '-' when off-cycle.
|
|
642
|
+
batch = (os.environ.get("BATCH_ID") or os.environ.get("SA_CYCLE_ID") or "-").strip() or "-"
|
|
640
643
|
_ensure_dirs(qtype)
|
|
641
644
|
_sweep_stale() # clear leftovers from prior timed-out producers before enqueuing
|
|
642
645
|
job = {
|
|
@@ -652,7 +655,7 @@ def cmd_provider(ns) -> int:
|
|
|
652
655
|
pending_path = os.path.join(pending_dir(qtype), fname)
|
|
653
656
|
running_path = os.path.join(running_dir(), fname)
|
|
654
657
|
_atomic_write(pending_path, job)
|
|
655
|
-
_plog(f"enqueued {qtype} job {job_id}; waiting for a scheduled task (timeout {ns.timeout}s)")
|
|
658
|
+
_plog(f"enqueued {qtype} job {job_id} batch={batch}; waiting for a scheduled task (timeout {ns.timeout}s)")
|
|
656
659
|
# Narrate the (multi-minute) block to the menu bar. The launchd draft lane has
|
|
657
660
|
# no other activity writer, so without this the box looks idle while it works.
|
|
658
661
|
# Cleared by run-draft-and-publish.sh's exit trap at cycle end (and by the
|
|
@@ -712,6 +715,16 @@ def cmd_provider(ns) -> int:
|
|
|
712
715
|
# A worker drained this job -> the autopilot is alive; clear any latched
|
|
713
716
|
# stall so the menu bar / dashboard / Sentry watcher recover.
|
|
714
717
|
_mark_drain_success()
|
|
718
|
+
# Success-consume event: the SILENT gap that made an orphaned result
|
|
719
|
+
# (worker wrote it, producer died before consuming) indistinguishable
|
|
720
|
+
# from a healthy one. A result file only survives in result/ if it was
|
|
721
|
+
# NEVER consumed (we os.remove above), so "consumed" here + a surviving
|
|
722
|
+
# file = the orphan signature the salvage reconciler keys on.
|
|
723
|
+
try:
|
|
724
|
+
_ncand = len(obj.get("candidates")) if isinstance(obj, dict) and isinstance(obj.get("candidates"), list) else "?"
|
|
725
|
+
except Exception:
|
|
726
|
+
_ncand = "?"
|
|
727
|
+
_plog(f"consumed result for job {job_id} batch={batch} ({qtype}); {_ncand} candidates -> producer assembles the plan")
|
|
715
728
|
return 0
|
|
716
729
|
time.sleep(POLL_INTERVAL_S)
|
|
717
730
|
|
|
@@ -731,7 +744,7 @@ def cmd_provider(ns) -> int:
|
|
|
731
744
|
# (no pending file exists between cycles, so an instantaneous queue check would
|
|
732
745
|
# flicker the ⚠ off). Cleared only when a draft actually drains.
|
|
733
746
|
_bump_drain_timeout()
|
|
734
|
-
_plog(f"timed out after {ns.timeout}s waiting for job {job_id} ({qtype}); removed the job")
|
|
747
|
+
_plog(f"timed out after {ns.timeout}s waiting for job {job_id} batch={batch} ({qtype}); removed the job")
|
|
735
748
|
return 79 # mirror run_claude.sh's "blocked, skip cleanly" exit code
|
|
736
749
|
|
|
737
750
|
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""relay_provider_log.py — ship the queue producer/consumer log to Cloud Logging.
|
|
3
|
+
|
|
4
|
+
claude_job.py writes the queue handoff lifecycle (enqueue / claim / consumed /
|
|
5
|
+
timed out, plus [salvage] orphan events) to <state>/claude-queue/provider.log — a
|
|
6
|
+
LOCAL file that is invisible remotely. This tails it incrementally (byte offset in
|
|
7
|
+
provider-relay-state.json, flock single-flight) and POSTs new lines through the
|
|
8
|
+
SAME relay lane the pipeline logs use (bin/server.js /api/v1/installations/logs),
|
|
9
|
+
tagged context="queue-provider", so a stranded/orphaned batch on any box is
|
|
10
|
+
queryable in Cloud Logging instead of only on the box.
|
|
11
|
+
|
|
12
|
+
Query:
|
|
13
|
+
gcloud logging read 'jsonPayload.install_id="<uuid>" AND jsonPayload.context="queue-provider"' --project=s4l-app-prod
|
|
14
|
+
|
|
15
|
+
Forward-only baseline: the first run records the current EOF and ships NOTHING
|
|
16
|
+
(no backfill flood); `--from-start` overrides. Best-effort everywhere: an
|
|
17
|
+
unreadable file or a failed POST just retries next run.
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
python3 scripts/relay_provider_log.py [--max-lines 500] [--from-start] [--dry-run]
|
|
21
|
+
"""
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import time
|
|
28
|
+
import urllib.request
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
|
|
31
|
+
STATE_DIR = os.path.expanduser(os.environ.get("S4L_STATE_DIR", "~/.social-autoposter-mcp"))
|
|
32
|
+
PROVIDER_LOG = os.path.join(STATE_DIR, "claude-queue", "provider.log")
|
|
33
|
+
STATE_PATH = os.path.join(STATE_DIR, "provider-relay-state.json")
|
|
34
|
+
LOCK_PATH = os.path.join(STATE_DIR, "provider-relay.lock")
|
|
35
|
+
|
|
36
|
+
LOG_BASE = (os.environ.get("AUTOPOSTER_LOG_BASE") or "https://app.s4l.ai").rstrip("/")
|
|
37
|
+
POST_BATCH = 200 # relay accepts 1-200 lines per POST
|
|
38
|
+
MAX_LINE = 7500 # relay caps at 8192; leave headroom for the envelope
|
|
39
|
+
CONTEXT = "queue-provider"
|
|
40
|
+
LOG_TYPE = "client-pipeline"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _install_header():
|
|
44
|
+
ident = os.path.join(os.path.dirname(os.path.abspath(__file__)), "identity.py")
|
|
45
|
+
if not os.path.exists(ident):
|
|
46
|
+
return None
|
|
47
|
+
try:
|
|
48
|
+
out = subprocess.run([sys.executable, ident, "header"],
|
|
49
|
+
capture_output=True, text=True, timeout=15)
|
|
50
|
+
h = (out.stdout or "").strip()
|
|
51
|
+
return h if out.returncode == 0 and h else None
|
|
52
|
+
except Exception:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _post(lines, header):
|
|
57
|
+
body = json.dumps({"lines": lines}).encode("utf-8")
|
|
58
|
+
req = urllib.request.Request(
|
|
59
|
+
f"{LOG_BASE}/api/v1/installations/logs",
|
|
60
|
+
data=body,
|
|
61
|
+
headers={"X-Installation": header, "Content-Type": "application/json"},
|
|
62
|
+
method="POST",
|
|
63
|
+
)
|
|
64
|
+
try:
|
|
65
|
+
with urllib.request.urlopen(req, timeout=20) as resp:
|
|
66
|
+
return 200 <= resp.status < 300
|
|
67
|
+
except Exception as e:
|
|
68
|
+
print(f"[provider-relay] POST failed: {e}", file=sys.stderr)
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _load_state():
|
|
73
|
+
try:
|
|
74
|
+
with open(STATE_PATH) as f:
|
|
75
|
+
return json.load(f)
|
|
76
|
+
except Exception:
|
|
77
|
+
return {}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _save_state(state):
|
|
81
|
+
try:
|
|
82
|
+
os.makedirs(STATE_DIR, exist_ok=True)
|
|
83
|
+
tmp = STATE_PATH + ".tmp"
|
|
84
|
+
with open(tmp, "w") as f:
|
|
85
|
+
json.dump(state, f)
|
|
86
|
+
os.replace(tmp, STATE_PATH)
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main():
|
|
92
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
93
|
+
ap.add_argument("--max-lines", type=int, default=500,
|
|
94
|
+
help="cap on lines shipped this run (remainder ships next run)")
|
|
95
|
+
ap.add_argument("--from-start", action="store_true",
|
|
96
|
+
help="ship from the start of the file, overriding the forward-only baseline")
|
|
97
|
+
ap.add_argument("--dry-run", action="store_true")
|
|
98
|
+
ns = ap.parse_args()
|
|
99
|
+
|
|
100
|
+
if not os.path.exists(PROVIDER_LOG):
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
# Single-flight: skip silently if another run holds the lock.
|
|
104
|
+
lock_fd = None
|
|
105
|
+
if not ns.dry_run:
|
|
106
|
+
try:
|
|
107
|
+
import fcntl
|
|
108
|
+
os.makedirs(STATE_DIR, exist_ok=True)
|
|
109
|
+
lock_fd = open(LOCK_PATH, "w")
|
|
110
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
111
|
+
except Exception:
|
|
112
|
+
return 0 # another instance is running
|
|
113
|
+
|
|
114
|
+
state = _load_state()
|
|
115
|
+
try:
|
|
116
|
+
size = os.path.getsize(PROVIDER_LOG)
|
|
117
|
+
except OSError:
|
|
118
|
+
return 0
|
|
119
|
+
offset = state.get("offset")
|
|
120
|
+
inode = state.get("inode")
|
|
121
|
+
try:
|
|
122
|
+
cur_inode = os.stat(PROVIDER_LOG).st_ino
|
|
123
|
+
except OSError:
|
|
124
|
+
cur_inode = None
|
|
125
|
+
|
|
126
|
+
# First run (or rotated/truncated file): baseline forward-only unless --from-start.
|
|
127
|
+
if ns.from_start:
|
|
128
|
+
offset = 0
|
|
129
|
+
elif offset is None or inode != cur_inode or offset > size:
|
|
130
|
+
_save_state({"offset": size, "inode": cur_inode})
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
with open(PROVIDER_LOG, "r", errors="replace") as f:
|
|
135
|
+
f.seek(offset)
|
|
136
|
+
chunk = f.read(2_000_000) # bounded read
|
|
137
|
+
new_offset = f.tell()
|
|
138
|
+
except Exception:
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
raw_lines = [ln for ln in chunk.split("\n") if ln.strip()]
|
|
142
|
+
if not raw_lines:
|
|
143
|
+
_save_state({"offset": new_offset, "inode": cur_inode})
|
|
144
|
+
return 0
|
|
145
|
+
|
|
146
|
+
raw_lines = raw_lines[: ns.max_lines]
|
|
147
|
+
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
|
148
|
+
records = [{
|
|
149
|
+
"logType": LOG_TYPE,
|
|
150
|
+
"context": CONTEXT,
|
|
151
|
+
"stream": "stdout",
|
|
152
|
+
"client_ts": now_iso,
|
|
153
|
+
"message": ln[:MAX_LINE],
|
|
154
|
+
} for ln in raw_lines]
|
|
155
|
+
|
|
156
|
+
if ns.dry_run:
|
|
157
|
+
for r in records[:20]:
|
|
158
|
+
print(f"[provider-relay] (dry-run) {r['message'][:140]}")
|
|
159
|
+
print(f"[provider-relay] (dry-run) would ship {len(records)} line(s)")
|
|
160
|
+
return 0
|
|
161
|
+
|
|
162
|
+
header = _install_header()
|
|
163
|
+
if not header:
|
|
164
|
+
print("[provider-relay] no X-Installation header; skipping", file=sys.stderr)
|
|
165
|
+
return 0
|
|
166
|
+
|
|
167
|
+
shipped = 0
|
|
168
|
+
for i in range(0, len(records), POST_BATCH):
|
|
169
|
+
batch = records[i:i + POST_BATCH]
|
|
170
|
+
if _post(batch, header):
|
|
171
|
+
shipped += len(batch)
|
|
172
|
+
else:
|
|
173
|
+
break # stop on failure; retry from the saved offset next run
|
|
174
|
+
|
|
175
|
+
# Only advance the offset by what we actually shipped, so a mid-run POST failure
|
|
176
|
+
# re-ships the un-acked tail next run instead of dropping it.
|
|
177
|
+
if shipped >= len(raw_lines):
|
|
178
|
+
# Shipped everything we read (and possibly capped by max_lines == read count).
|
|
179
|
+
_save_state({"offset": new_offset, "inode": cur_inode})
|
|
180
|
+
elif shipped > 0:
|
|
181
|
+
# Shipped only a prefix; advance past exactly those lines (+ their newlines).
|
|
182
|
+
partial = "\n".join(raw_lines[:shipped]) + "\n"
|
|
183
|
+
_save_state({"offset": offset + len(partial.encode("utf-8")), "inode": cur_inode})
|
|
184
|
+
print(f"[provider-relay] shipped {shipped}/{len(records)} provider.log line(s)")
|
|
185
|
+
return 0
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
if __name__ == "__main__":
|
|
189
|
+
sys.exit(main())
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""salvage_orphaned_prep_results.py — recover twitter-prep drafts that were
|
|
3
|
+
stranded when the PRODUCER cycle died after the worker wrote its result but before
|
|
4
|
+
consuming it.
|
|
5
|
+
|
|
6
|
+
Mechanism: a producer (claude_job.py) that consumes a result os.remove()s it, so
|
|
7
|
+
any file that SURVIVES in claude-queue/result/ past the producer's max wait is
|
|
8
|
+
orphaned — the worker's drafts exist but were never turned into a plan / merged
|
|
9
|
+
into review cards. This scans for such files and merges their candidates into the
|
|
10
|
+
review queue via merge_review_queue.py, then renames them .salvaged so they are
|
|
11
|
+
not re-processed.
|
|
12
|
+
|
|
13
|
+
Safe by construction:
|
|
14
|
+
- Only touches results OLDER than the queue timeout (default + buffer), so no
|
|
15
|
+
live producer can still be polling for that job_id.
|
|
16
|
+
- merge_review_queue.py dedupes by (thread_url), so a re-run or an overlap with a
|
|
17
|
+
late-arriving producer cannot create duplicate cards.
|
|
18
|
+
- Best-effort: any single failure is logged and skipped; never raises.
|
|
19
|
+
|
|
20
|
+
Degradation vs a normal cycle: salvaged candidates skip the cycle's post-provider
|
|
21
|
+
top-N selection (so MORE cards, which is fine) and lack the tail-link / experiments
|
|
22
|
+
arm stamp that run-twitter-cycle.sh's plan writer adds after the provider returns.
|
|
23
|
+
The reply text itself is complete. A salvaged card is strictly better than a lost
|
|
24
|
+
draft.
|
|
25
|
+
|
|
26
|
+
Usage:
|
|
27
|
+
python3 scripts/salvage_orphaned_prep_results.py # automated (safe age gate)
|
|
28
|
+
python3 scripts/salvage_orphaned_prep_results.py --age-min 5 --dry-run
|
|
29
|
+
"""
|
|
30
|
+
import argparse
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
import time
|
|
36
|
+
from datetime import datetime, timezone
|
|
37
|
+
|
|
38
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
39
|
+
sys.path.insert(0, HERE)
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
from claude_job import result_dir, DEFAULT_TIMEOUT_S, _plog # reuse exact dir + provider.log writer
|
|
43
|
+
except Exception: # standalone fallbacks
|
|
44
|
+
DEFAULT_TIMEOUT_S = int(os.environ.get("S4L_CLAUDE_QUEUE_TIMEOUT", "1800"))
|
|
45
|
+
|
|
46
|
+
def _state_dir():
|
|
47
|
+
return os.environ.get("S4L_STATE_DIR") or os.path.join(os.path.expanduser("~"), ".social-autoposter-mcp")
|
|
48
|
+
|
|
49
|
+
def result_dir():
|
|
50
|
+
return os.path.join(_state_dir(), "claude-queue", "result")
|
|
51
|
+
|
|
52
|
+
def _plog(msg):
|
|
53
|
+
try:
|
|
54
|
+
p = os.path.join(_state_dir(), "claude-queue", "provider.log")
|
|
55
|
+
with open(p, "a") as f:
|
|
56
|
+
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
57
|
+
f.write(f"{ts} pid={os.getpid()} {msg}\n")
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _is_prep_result(obj):
|
|
63
|
+
"""True iff obj looks like a twitter-prep result (candidates with reply_text)."""
|
|
64
|
+
if not isinstance(obj, dict):
|
|
65
|
+
return False
|
|
66
|
+
cands = obj.get("candidates")
|
|
67
|
+
if not isinstance(cands, list) or not cands:
|
|
68
|
+
return False
|
|
69
|
+
c0 = cands[0]
|
|
70
|
+
return isinstance(c0, dict) and "reply_text" in c0 and ("candidate_url" in c0 or "candidate_id" in c0)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def main():
|
|
74
|
+
ap = argparse.ArgumentParser(description="Merge orphaned twitter-prep results into the review queue.")
|
|
75
|
+
ap.add_argument("--age-min", type=float, default=(DEFAULT_TIMEOUT_S / 60.0 + 5),
|
|
76
|
+
help="only salvage results older than this many minutes "
|
|
77
|
+
"(default = queue timeout + 5, so no live producer is still polling)")
|
|
78
|
+
ap.add_argument("--max-age-hours", type=float,
|
|
79
|
+
default=float(os.environ.get("S4L_SALVAGE_MAX_AGE_HOURS", "6")),
|
|
80
|
+
help="do NOT salvage results older than this (stale threads not worth carding); "
|
|
81
|
+
"they are renamed .stale so they stop being rescanned")
|
|
82
|
+
ap.add_argument("--repo-dir", default=os.environ.get("S4L_REPO_DIR") or os.path.dirname(HERE))
|
|
83
|
+
ap.add_argument("--dry-run", action="store_true")
|
|
84
|
+
ns = ap.parse_args()
|
|
85
|
+
|
|
86
|
+
rdir = result_dir()
|
|
87
|
+
if not os.path.isdir(rdir):
|
|
88
|
+
return 0
|
|
89
|
+
merge = os.path.join(ns.repo_dir, "scripts", "merge_review_queue.py")
|
|
90
|
+
now = time.time()
|
|
91
|
+
young_cutoff = now - ns.age_min * 60.0
|
|
92
|
+
stale_cutoff = now - ns.max_age_hours * 3600.0
|
|
93
|
+
scanned = salvaged = skipped = stale = 0
|
|
94
|
+
|
|
95
|
+
for name in sorted(os.listdir(rdir)):
|
|
96
|
+
if not name.endswith(".json"):
|
|
97
|
+
continue # skip already-handled .salvaged / .skipped / .stale
|
|
98
|
+
path = os.path.join(rdir, name)
|
|
99
|
+
try:
|
|
100
|
+
st = os.stat(path)
|
|
101
|
+
except OSError:
|
|
102
|
+
continue
|
|
103
|
+
if st.st_mtime > young_cutoff:
|
|
104
|
+
continue # too fresh: a producer may still be polling for this job
|
|
105
|
+
if st.st_mtime < stale_cutoff:
|
|
106
|
+
# too old: the threads have moved on; retire it so we stop rescanning.
|
|
107
|
+
stale += 1
|
|
108
|
+
if not ns.dry_run:
|
|
109
|
+
try:
|
|
110
|
+
os.rename(path, path + ".stale")
|
|
111
|
+
except OSError:
|
|
112
|
+
pass
|
|
113
|
+
continue
|
|
114
|
+
scanned += 1
|
|
115
|
+
job_id = name[:-5]
|
|
116
|
+
try:
|
|
117
|
+
with open(path) as f:
|
|
118
|
+
d = json.load(f)
|
|
119
|
+
except Exception:
|
|
120
|
+
continue
|
|
121
|
+
obj = d.get("result") if isinstance(d, dict) else None
|
|
122
|
+
if obj is None and isinstance(d, dict) and "candidates" in d:
|
|
123
|
+
obj = d
|
|
124
|
+
if not _is_prep_result(obj):
|
|
125
|
+
skipped += 1
|
|
126
|
+
if not ns.dry_run:
|
|
127
|
+
try:
|
|
128
|
+
os.rename(path, path + ".skipped")
|
|
129
|
+
except OSError:
|
|
130
|
+
pass
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
n = len(obj["candidates"])
|
|
134
|
+
age_min = (now - st.st_mtime) / 60.0
|
|
135
|
+
_plog(f"[salvage] ORPHAN prep result job {job_id}: producer never consumed it "
|
|
136
|
+
f"({n} drafts, {age_min:.0f}m old) -> merging into review queue")
|
|
137
|
+
if ns.dry_run:
|
|
138
|
+
print(f"[salvage] (dry-run) would merge {n} drafts from job {job_id} ({age_min:.0f}m old)")
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
plan_path = os.path.join("/tmp", f"salvage_plan_{job_id}.json")
|
|
142
|
+
plan = {
|
|
143
|
+
"candidates": obj["candidates"],
|
|
144
|
+
"created_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
145
|
+
"salvaged_from_job": job_id,
|
|
146
|
+
}
|
|
147
|
+
try:
|
|
148
|
+
with open(plan_path, "w") as f:
|
|
149
|
+
json.dump(plan, f)
|
|
150
|
+
except Exception as e:
|
|
151
|
+
_plog(f"[salvage] job {job_id}: could not write plan: {e}")
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
r = subprocess.run([sys.executable, merge, "--plan", plan_path],
|
|
156
|
+
capture_output=True, text=True, timeout=180)
|
|
157
|
+
except Exception as e:
|
|
158
|
+
_plog(f"[salvage] job {job_id}: merge subprocess errored: {e}")
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
if r.returncode == 0:
|
|
162
|
+
salvaged += 1
|
|
163
|
+
try:
|
|
164
|
+
os.rename(path, path + ".salvaged")
|
|
165
|
+
except OSError:
|
|
166
|
+
pass
|
|
167
|
+
_plog(f"[salvage] job {job_id}: merged {n} orphaned drafts into review queue")
|
|
168
|
+
print(f"[salvage] recovered {n} orphaned drafts from job {job_id}")
|
|
169
|
+
else:
|
|
170
|
+
_plog(f"[salvage] job {job_id}: merge failed rc={r.returncode}: {(r.stderr or '')[:200]}")
|
|
171
|
+
|
|
172
|
+
if scanned or stale:
|
|
173
|
+
print(f"[salvage] scanned={scanned} salvaged={salvaged} skipped_nonprep={skipped} retired_stale={stale}")
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
sys.exit(main())
|
|
@@ -26,6 +26,14 @@ EOF_ENV
|
|
|
26
26
|
REPO_DIR="${S4L_REPO_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
|
|
27
27
|
PY="${S4L_PYTHON:-python3}"
|
|
28
28
|
|
|
29
|
+
# Salvage twitter-prep drafts orphaned when a producer cycle died after its worker
|
|
30
|
+
# wrote the result but before consuming it (a consumed result is deleted, so a
|
|
31
|
+
# surviving one in claude-queue/result/ = never consumed). Best-effort, dedup-safe
|
|
32
|
+
# merge into the review queue; the default age gate only touches results past the
|
|
33
|
+
# queue timeout, so no live producer is racing. Runs every tick so a dead
|
|
34
|
+
# producer's drafts land within a cycle instead of being lost. Never blocks.
|
|
35
|
+
"$PY" "$REPO_DIR/scripts/salvage_orphaned_prep_results.py" >/dev/null 2>&1 || true
|
|
36
|
+
|
|
29
37
|
# Posting-defer gate (2026-07-06). Posting and scanning share ONE harness Chrome,
|
|
30
38
|
# and the MCP poster SIGKILLs any scan holding the twitter-browser lock (it cannot
|
|
31
39
|
# wait: an approved card must land while the user watches, and scans trap SIGTERM).
|
|
@@ -108,18 +108,18 @@ TW_ENGINE_PREFIX=""
|
|
|
108
108
|
# ceiling drops that carry runway so aged-out junk expires instead of riding
|
|
109
109
|
# ~80 cycles. Discovery is already capped at 1h (FRESHNESS_HOURS_DISCOVER).
|
|
110
110
|
#
|
|
111
|
-
# 2026-06
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
if [ "${DRAFT_ONLY:-0}" = "1" ]; then
|
|
122
|
-
FRESHNESS_HOURS="$
|
|
111
|
+
# 2026-07-06 (per user request): the wide draft window is SETUP-ONLY. The
|
|
112
|
+
# original 2026-06-17 change made DRAFT_ONLY=1 default to 24h permanently,
|
|
113
|
+
# so every steady-state draft-autopilot cycle kept surfacing day-old tweets.
|
|
114
|
+
# Now the ONLY way draft mode widens is the explicit S4L_DRAFT_FRESHNESS_HOURS
|
|
115
|
+
# env override, which run-draft-and-publish.sh exports solely while the
|
|
116
|
+
# first-run-boost.json onboarding marker is live (consumed after the first
|
|
117
|
+
# delivered batch, hard-expired after 24h). Once the marker is gone the env
|
|
118
|
+
# is unset and draft cycles run the same experiment-concluded variant D
|
|
119
|
+
# windows as autopilot: 2h expire ceiling + 1h discovery.
|
|
120
|
+
# Autopilot (DRAFT_ONLY=0) ignores the override entirely, as before.
|
|
121
|
+
if [ "${DRAFT_ONLY:-0}" = "1" ] && [ -n "${S4L_DRAFT_FRESHNESS_HOURS:-}" ]; then
|
|
122
|
+
FRESHNESS_HOURS="$S4L_DRAFT_FRESHNESS_HOURS"
|
|
123
123
|
else
|
|
124
124
|
FRESHNESS_HOURS=2
|
|
125
125
|
fi
|
|
@@ -139,13 +139,13 @@ fi
|
|
|
139
139
|
# other's still-pending rows. FRESHNESS_HOURS_DISCOVER (Phase 1 prompt +
|
|
140
140
|
# since-rewrite hook) stays tightened to 1h, the winning D setting.
|
|
141
141
|
TWITTER_CYCLE_VARIANT=D
|
|
142
|
-
#
|
|
143
|
-
#
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
#
|
|
147
|
-
if [ "${DRAFT_ONLY:-0}" = "1" ]; then
|
|
148
|
-
FRESHNESS_HOURS_DISCOVER="$
|
|
142
|
+
# Discovery widens ONLY under the first-run onboarding boost (see the
|
|
143
|
+
# FRESHNESS_HOURS branch above): S4L_DRAFT_FRESHNESS_HOURS set + DRAFT_ONLY=1.
|
|
144
|
+
# Steady-state draft cycles keep the winning D setting of 1h, same as
|
|
145
|
+
# autopilot. The lean Phase 1 CDP scraper reads FRESHNESS_HOURS_DISCOVER
|
|
146
|
+
# directly and honors any value.
|
|
147
|
+
if [ "${DRAFT_ONLY:-0}" = "1" ] && [ -n "${S4L_DRAFT_FRESHNESS_HOURS:-}" ]; then
|
|
148
|
+
FRESHNESS_HOURS_DISCOVER="$S4L_DRAFT_FRESHNESS_HOURS"
|
|
149
149
|
else
|
|
150
150
|
FRESHNESS_HOURS_DISCOVER=1
|
|
151
151
|
fi
|