@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.
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.203-rc.21",
3
- "installedAt": "2026-07-06T21:53:47.458Z"
2
+ "version": "1.6.203-rc.22",
3
+ "installedAt": "2026-07-06T22:16:22.489Z"
4
4
  }
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.21",
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": {
@@ -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
- lines.append(f"{label}: {exps[name]}")
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.21",
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.21",
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
@@ -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-17 (per user request): DRAFT mode (DRAFT_ONLY=1, the MCP draft_cycle
112
- # tool) widens both freshness knobs to 24h so human review surfaces more (and
113
- # older) candidates. Autopilot is untouched: it keeps the experiment-concluded
114
- # 2h expire ceiling + 1h discovery window (variant D). The branch is on
115
- # DRAFT_ONLY, an external env var set by the draft_cycle tool, available here.
116
- # 2026-07-02 (first-run onboarding boost, per user request): the draft-mode
117
- # value accepts an env override, S4L_DRAFT_FRESHNESS_HOURS, so the kicker
118
- # wrapper (run-draft-and-publish.sh) can widen a brand-new user's FIRST draft
119
- # cycle to 48h and surface multiple review cards. Unset = the standard 24h
120
- # draft window. Autopilot (DRAFT_ONLY=0) ignores the override entirely.
121
- if [ "${DRAFT_ONLY:-0}" = "1" ]; then
122
- FRESHNESS_HOURS="${S4L_DRAFT_FRESHNESS_HOURS:-24}"
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
- # DRAFT mode widens discovery to 24h by default; autopilot keeps the winning D
143
- # setting of 1h. S4L_DRAFT_FRESHNESS_HOURS (first-run onboarding boost, see the
144
- # FRESHNESS_HOURS branch above) can widen the draft-mode value further (48h on a
145
- # brand-new install's first cycle). The lean Phase 1 CDP scraper reads
146
- # FRESHNESS_HOURS_DISCOVER directly and honors any value.
147
- if [ "${DRAFT_ONLY:-0}" = "1" ]; then
148
- FRESHNESS_HOURS_DISCOVER="${S4L_DRAFT_FRESHNESS_HOURS:-24}"
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