@m13v/s4l 1.7.2-rc.9 → 1.7.3

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.
@@ -0,0 +1,91 @@
1
+ import json, re, urllib.parse
2
+
3
+ NOTIFS = "/tmp/li_notifs.json"
4
+ CID = "/Users/matthewdi/.claude/projects/-/f0a4ef0d-11f2-4618-85e2-bf62ad39cea2/tool-results/brzcccx3h.txt"
5
+ PAIRS = "/Users/matthewdi/.claude/projects/-/f0a4ef0d-11f2-4618-85e2-bf62ad39cea2/tool-results/bu0y0bphg.txt"
6
+ POSTS = "/Users/matthewdi/.claude/projects/-/f0a4ef0d-11f2-4618-85e2-bf62ad39cea2/tool-results/bnb5ginhc.txt"
7
+
8
+ def load_lines(p):
9
+ out=[]
10
+ for ln in open(p, encoding="utf-8", errors="replace"):
11
+ ln=ln.rstrip("\n")
12
+ # skip persisted-output wrapper lines
13
+ if not ln or ln.startswith("<") or ln.startswith("Output too large") or ln.startswith("Preview") or ln.startswith("..."):
14
+ continue
15
+ out.append(ln)
16
+ return out
17
+
18
+ comment_ids = set(load_lines(CID))
19
+ pairs = set(load_lines(PAIRS))
20
+ posts = load_lines(POSTS)
21
+
22
+ # activity number -> post id
23
+ post_by_act = {}
24
+ for ln in posts:
25
+ if "|" not in ln: continue
26
+ pid, url = ln.split("|",1)
27
+ for m in re.findall(r"urn:li:(?:activity|share|ugcPost):(\d+)", url):
28
+ post_by_act[m]=pid
29
+
30
+ # config projects search_topics
31
+ cfg = json.load(open("/Users/matthewdi/social-autoposter/config.json"))
32
+ projtopics = []
33
+ for pr in cfg.get("projects",[]):
34
+ projtopics.append((pr["name"], [t.lower() for t in pr.get("search_topics",[])]))
35
+
36
+ EXCLUDED = {"louis030195","louis3195"}
37
+ OWN = {"matthew diakonov","m13v"}
38
+
39
+ def match_project(snippet):
40
+ s = snippet.lower()
41
+ best=None; bestscore=0
42
+ for name, topics in projtopics:
43
+ score=0
44
+ for t in topics:
45
+ if t and t in s:
46
+ score+=1
47
+ if score>bestscore:
48
+ bestscore=score; best=name
49
+ return best or "S4L"
50
+
51
+ data = json.load(open(NOTIFS))
52
+ decisions=[]
53
+ for d in data:
54
+ href = d["href"]
55
+ dec = urllib.parse.unquote(href)
56
+ m = re.search(r"urn:li:activity:(\d+)", dec)
57
+ activity_id = m.group(1) if m else None
58
+ comment_urn = d["comment_urn"]
59
+ author = d["author"]
60
+ a_low = author.lower().strip()
61
+ rec = dict(author=author, activity_id=activity_id, comment_urn=comment_urn,
62
+ snippet=d["snippet"], href=href, type=d["type"])
63
+ if not comment_urn or not activity_id:
64
+ rec["decision"]="no_comment_urn"; decisions.append(rec); continue
65
+ if a_low in OWN:
66
+ rec["decision"]="own_account"; decisions.append(rec); continue
67
+ if a_low in EXCLUDED or any(x in a_low for x in EXCLUDED):
68
+ rec["decision"]="excluded_author"; decisions.append(rec); continue
69
+ if comment_urn in comment_ids:
70
+ rec["decision"]="already_tracked"; decisions.append(rec); continue
71
+ our_url = "https://www.linkedin.com/feed/update/urn:li:activity:%s/" % activity_id
72
+ apk = author + "|||" + our_url
73
+ if apk in pairs:
74
+ rec["decision"]="author_already_engaged"; decisions.append(rec); continue
75
+ # match post
76
+ pid = post_by_act.get(activity_id)
77
+ rec["post_id"]=pid
78
+ rec["project"]=match_project(d["snippet"])
79
+ rec["decision"]="INSERT"
80
+ decisions.append(rec)
81
+
82
+ json.dump(decisions, open("/tmp/li_decisions.json","w"))
83
+ # summary
84
+ from collections import Counter
85
+ c=Counter(r["decision"] for r in decisions)
86
+ print("TOTAL", len(decisions))
87
+ for k,v in c.items(): print(k,v)
88
+ print("---INSERTS---")
89
+ for i,r in enumerate(decisions):
90
+ if r["decision"]=="INSERT":
91
+ print(i, r["author"], "| act", r["activity_id"], "| post", r.get("post_id"), "| proj", r["project"])
@@ -203,7 +203,7 @@ CURRENT content_guardrails.do_not: {json.dumps(guard_do_not)}
203
203
  NEW REVIEW EVENTS since the last digest ({len(rejected)} rejected, {len(no_reason)} of the rejects without a stated reason, {len(approved)} approved, {len(loved)} of the approvals loved):
204
204
  {ev_lines}{overall_block}
205
205
 
206
- Categories: wrong_author = the thread's author/audience was a bad fit; off_topic = the thread itself was a bad fit; bad_draft = thread was fine but the written reply was off; other = see the note. "no_reason_given" means the user rejected without picking a category or typing a note: the rejection itself is real, but WHY is your inference from the author/thread/draft context alone, so treat it as weak evidence. It can corroborate a pattern that reasoned events already show, but a no_reason_given reject never justifies a new entry or an author block on its own, and 2+ of them agreeing still only justify an entry when the shared pattern in their context is unmistakable. "edited_before_approving" with an ORIGINAL/REWROTE pair means the user hand-corrected our draft before posting: the rewrite is a direct statement of the voice they want. Diff the pair; when 2+ edits show the same correction (a phrase type removed, a structure replaced, tone shifted, length cut), distill that recurring pattern into draft_style_notes. Ignore edit content that is lead-specific or cosmetic (typo fixes, one-off facts); learn only what generalizes. "user_checked=profile_click" means the user opened the author's profile before deciding (a strong author-quality signal even without a note). "[approved+loved]" means the user picked a stronger emoji in the approve row ("this was a really good one"; approve_level_N in interactions carries the strength, 3 = best of the best): strong positive evidence for audience_prefer and thread selection, worth roughly two plain approvals.
206
+ Categories: wrong_author = the thread's author/audience was a bad fit; off_topic = the thread itself was a bad fit; bad_draft = thread was fine but the written reply was off; other = see the note. "no_reason_given" means the user rejected without picking a category or typing a note: the rejection itself is real, but WHY is your inference from the author/thread/draft context alone, so treat it as weak evidence. It can corroborate a pattern that reasoned events already show, but a no_reason_given reject never justifies a new entry or an author block on its own, and 2+ of them agreeing still only justify an entry when the shared pattern in their context is unmistakable. "edited_before_approving" with an ORIGINAL/REWROTE pair means the user hand-corrected our draft before posting: the rewrite is a direct statement of the voice they want. Diff the pair; when 2+ edits show the same correction (a phrase type removed, a structure replaced, tone shifted, length cut), distill that recurring pattern into draft_style_notes. Ignore edit content that is lead-specific or cosmetic (typo fixes, one-off facts); learn only what generalizes. "user_checked=profile_click" means the user opened the author's profile before deciding (a strong author-quality signal even without a note). "[approved+loved]" means the user picked the heart in the approve row ("this was a really good one"; approve_level_N in interactions carries the strength, 2 = best of the best): strong positive evidence for audience_prefer and thread selection, worth roughly two plain approvals.
207
207
 
208
208
  You can also block SPECIFIC authors via the plan's block_authors list. A block is a permanent hard exclusion of that one handle from all future thread selection, so it is YOUR judgment call, never automatic. Block when the evidence is strong: a wrong_author reject IS a direct human statement about that author (especially with profile_click), and the author context (author_followers, their post, found_via_topic) or the user's note confirms the account itself was the problem rather than the topic. Do NOT block when the reject looks topic-driven (off_topic/bad_draft on a reasonable account) or when you are unsure; the generalizable TYPE entry in audience_avoid is the softer tool for that.
209
209
 
@@ -19,6 +19,15 @@ Usage:
19
19
 
20
20
  State dir (for review-request.json) honors $S4L_STATE_DIR; the review-queue plan
21
21
  lives in $S4L_TMP_DIR or /tmp (matching the MCP's planPath()).
22
+
23
+ LEDGER SEMANTICS (read this before counting anything in review-queue.json):
24
+ the queue is APPEND-FOREVER — handled candidates are never removed, they are
25
+ flag-stamped in place (posted / terminal+terminal_reason / post_failed /
26
+ approved). "Not posted" is therefore NOT "awaiting review"; in an old queue
27
+ most rows are retired. The canonical classifier is
28
+ mcp/menubar/s4l_state.py::candidate_state(); the honest pending-cards count is
29
+ its awaiting_review bucket, mirrored into review-request.json's .count here at
30
+ merge time.
22
31
  """
23
32
 
24
33
  from __future__ import annotations
@@ -129,16 +138,47 @@ STATS_KEYS = (
129
138
  )
130
139
 
131
140
 
132
- def _enrich_with_stats(cands: list) -> int:
133
- """Stamp a `stats` sidecar onto plan candidates that lack one, from the
134
- twitter_candidates rows the discovery pipeline already wrote. ONE listing
135
- call (/api/v1/twitter-candidates?tweet_urls=...) covers the whole queue.
136
- Best-effort: any failure (offline box, missing identity, API error) leaves
137
- candidates unstamped and NEVER blocks card delivery. Returns count stamped."""
138
- want = [c for c in cands if not c.get("stats") and not c.get("posted") and _thread_url(c)]
139
- if not want:
140
- return 0
141
- urls = sorted({_thread_url(c) for c in want})[:500]
141
+ def _sync_with_backend(cands: list) -> tuple[int, int]:
142
+ """One bulk /api/v1/twitter-candidates lookup for every still-open candidate
143
+ (not posted, not terminal), used for two things:
144
+
145
+ - stamp the discovery-time `stats` sidecar the card renders (candidates
146
+ that already have one are left alone), same as the old
147
+ _enrich_with_stats this replaces.
148
+ - notice when the backend has ALREADY retired a candidate this plan
149
+ still thinks is 'pending' (most commonly the Phase 0 freshness gate
150
+ flipping status='expired' after FRESHNESS_HOURS — see
151
+ skill/run-twitter-cycle.sh) and mark it terminal here too, same as a
152
+ human "discard all pending" would. Without this, a card can sit in
153
+ the review queue as an approvable draft long after the backend has
154
+ moved on; approving it later silently no-ops (post_drafts returns
155
+ posted:0, no browser ever launches, no post-*.log — see the
156
+ 2026-07-09 "approved 3 cards, nothing posted" investigation).
157
+
158
+ Runs on EVERY merge (every cycle), not just once per candidate, so status
159
+ drift after the initial stamp is still caught while a card is still
160
+ pending. Best-effort: any API failure leaves every candidate untouched
161
+ (fail open, same as before). Returns (stamped_count, pruned_count).
162
+
163
+ APPROVED cards are exempt from the prune: an approval is a settled human
164
+ decision awaiting its serialized post, and stamping terminal on it makes
165
+ post_drafts refuse it as already-decided (posted:0). That exact race ate
166
+ 2 of 3 approvals on 2026-07-10 (approved 04:30:02Z, merge stamped
167
+ backend_status_expired 04:32:33Z, poster refused both). The freshness
168
+ gate exists to stop stale UNDECIDED drafts from burning review attention;
169
+ once a human has said "post it", the only honest gate left is the
170
+ poster's own at-post-time tweet_unavailable check."""
171
+ pending = [
172
+ c
173
+ for c in cands
174
+ if not c.get("posted")
175
+ and not c.get("terminal")
176
+ and not c.get("approved")
177
+ and _thread_url(c)
178
+ ]
179
+ if not pending:
180
+ return 0, 0
181
+ urls = sorted({_thread_url(c) for c in pending})[:500]
142
182
  try:
143
183
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
144
184
  from http_api import api_get
@@ -149,17 +189,24 @@ def _enrich_with_stats(cands: list) -> int:
149
189
  )
150
190
  rows = (resp.get("data") or {}).get("candidates") or []
151
191
  except BaseException as e: # http_api raises SystemExit on terminal failure
152
- print(f"[merge_review_queue] stats enrichment skipped: {e}", file=sys.stderr)
153
- return 0
192
+ print(f"[merge_review_queue] backend sync skipped: {e}", file=sys.stderr)
193
+ return 0, 0
154
194
  by_url = {str(r.get("tweet_url")): r for r in rows if r.get("tweet_url")}
155
195
  stamped = 0
156
- for c in want:
196
+ pruned = 0
197
+ for c in pending:
157
198
  row = by_url.get(_thread_url(c))
158
199
  if not row:
159
200
  continue
160
- c["stats"] = {k: row.get(k) for k in STATS_KEYS}
161
- stamped += 1
162
- return stamped
201
+ if not c.get("stats"):
202
+ c["stats"] = {k: row.get(k) for k in STATS_KEYS}
203
+ stamped += 1
204
+ status = row.get("status")
205
+ if status and status != "pending":
206
+ c["terminal"] = True
207
+ c["discard_reason"] = f"backend_status_{status}"
208
+ pruned += 1
209
+ return stamped, pruned
163
210
 
164
211
 
165
212
  def main() -> int:
@@ -255,9 +302,15 @@ def main() -> int:
255
302
  merged.append(c)
256
303
  added += 1
257
304
 
258
- stamped = _enrich_with_stats(merged)
305
+ stamped, pruned = _sync_with_backend(merged)
259
306
  if stamped:
260
307
  print(f"[merge_review_queue] stamped stats on {stamped} candidate(s)", file=sys.stderr)
308
+ if pruned:
309
+ print(
310
+ f"[merge_review_queue] pruned {pruned} candidate(s) already retired by the "
311
+ "backend (expired/etc.) before they were reviewed",
312
+ file=sys.stderr,
313
+ )
261
314
 
262
315
  plan_obj = {"candidates": merged}
263
316
  if plan_created_at:
@@ -274,15 +327,28 @@ def main() -> int:
274
327
  _atomic_write(dst, plan_obj)
275
328
  ensure_store_symlink()
276
329
 
277
- # Refresh the review-request marker the menu bar polls (count = pending, not posted).
278
- pending = len([c for c in merged if not c.get("posted")])
330
+ # Refresh the review-request marker the menu bar polls. count = cards
331
+ # actually awaiting review (mirrors s4l_state.candidate_state()'s
332
+ # awaiting_review bucket): approved-unposted and post_failed rows are
333
+ # settled decisions, counting them inflated the badge and misled every
334
+ # human/agent reading the marker.
335
+ pending_count = len(
336
+ [
337
+ c
338
+ for c in merged
339
+ if not c.get("posted")
340
+ and not c.get("terminal")
341
+ and not c.get("post_failed")
342
+ and not c.get("approved")
343
+ ]
344
+ )
279
345
  project = ns.project or batch.get("project") or (new_cands[0].get("matched_project") if new_cands else None)
280
346
  _atomic_write(
281
347
  review_request_path(),
282
348
  {
283
349
  "batch_id": REVIEW_QUEUE_ID,
284
350
  "project": project,
285
- "count": pending,
351
+ "count": pending_count,
286
352
  "plan_path": dst,
287
353
  "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
288
354
  },
@@ -306,7 +372,7 @@ def main() -> int:
306
372
 
307
373
  print(
308
374
  f"[merge_review_queue] merged {added} new draft(s) into {REVIEW_QUEUE_ID} "
309
- f"({pending} pending total) from {os.path.basename(src)}",
375
+ f"({pending_count} pending total) from {os.path.basename(src)}",
310
376
  file=sys.stderr,
311
377
  )
312
378
  # Clean up the consumed batch plan so /tmp doesn't fill with orphans.
@@ -74,10 +74,17 @@ def recent_posts_by_project(platform=None, days=RECENT_WINDOW_DAYS):
74
74
 
75
75
 
76
76
  def _eligible_pool(config, platform=None, exclude=None):
77
- """Projects eligible for selection: enabled, weight>0, platform-compatible."""
77
+ """Projects eligible for selection: enabled, weight>0, non-persona,
78
+ platform-compatible.
79
+
80
+ Persona entries (`persona: true`) are structurally excluded: they run ONLY
81
+ via the personal_brand lane's force-inject (s4l_mode.persona_name), never
82
+ via this promotion lottery, regardless of their `enabled` flag.
83
+ """
78
84
  pool = [
79
85
  p for p in config.get("projects", [])
80
86
  if p.get("enabled", True) and p.get("weight", 0) > 0
87
+ and p.get("persona") is not True
81
88
  ]
82
89
  if exclude:
83
90
  excluded = {n.lower() for n in exclude}
@@ -122,8 +129,10 @@ def pick_project(config, platform=None, exclude=None):
122
129
  return picks[0]
123
130
  if exclude:
124
131
  return None
125
- # No eligible project at all: legacy fallback to any project in config.
126
- projects = config.get("projects", [])
132
+ # No eligible project at all: legacy fallback to any non-persona project.
133
+ projects = [
134
+ p for p in config.get("projects", []) if p.get("persona") is not True
135
+ ]
127
136
  return random.choice(projects) if projects else None
128
137
 
129
138
 
@@ -460,7 +460,21 @@ if [[ "$DO_NPM" == "1" ]]; then
460
460
  fs.writeFileSync(p, JSON.stringify(j,null,2)+'\n');
461
461
  console.log(' temp copy renamed to $S4L_ALIAS_PKG@'+j.version+' (repo package.json untouched)');
462
462
  " \
463
- && ( cd "$S4L_PUB_DIR/package" && npm publish --access=public ${NPM_TAG_ARGS[@]+"${NPM_TAG_ARGS[@]}"} ); then
463
+ && (
464
+ # The registry's large-upload path flakes (TLS bad record mac / EPIPE)
465
+ # often enough that one attempt let the alias lane silently drift for
466
+ # three rc's (rc.16/21/22, caught 2026-07-10). Retry, and between
467
+ # attempts check whether the upload actually landed despite the
468
+ # client-side error (EPIPE can be a false negative).
469
+ cd "$S4L_PUB_DIR/package" || exit 1
470
+ for _try in 1 2 3; do
471
+ npm publish --access=public ${NPM_TAG_ARGS[@]+"${NPM_TAG_ARGS[@]}"} && exit 0
472
+ sleep 3
473
+ [[ "$(curl -s -o /dev/null -w '%{http_code}' "$S4L_ALIAS_URL")" == "200" ]] && exit 0
474
+ echo " dual-publish attempt $_try/3 failed" >&2
475
+ done
476
+ exit 1
477
+ ); then
464
478
  for _ in 1 2 3 4 5; do
465
479
  sleep 2
466
480
  [[ "$(curl -s -o /dev/null -w '%{http_code}' "$S4L_ALIAS_URL")" == "200" ]] && break
@@ -58,14 +58,15 @@ Usage:
58
58
  s4l_mode.py split # print the personal-brand share (0.0-1.0)
59
59
  s4l_mode.py split <value> # set it; accepts 70, 70%, or 0.7
60
60
 
61
- Draft-only (2026-07-06): a third, independent flag in mode.json, DEFAULT ON.
62
- While ON (the normal state), every cycle stops before posting and its drafts
63
- become review cards. When an OPERATOR turns it off, run-draft-and-publish.sh
64
- runs promotion-lane cycles with DRAFT_ONLY=0 so they POST autonomously (the
65
- rolling virality bar is the quality gate). Persona-lane cycles ALWAYS stay
66
- draft-only (review cards) regardless. Deliberately NOT exposed on any user
67
- surface (no MCP tool param, no menubar toggle): this CLI and mode.json are the
68
- only way to flip it.
61
+ Draft-only (2026-07-06, single global switch as of 2026-07-08): a third,
62
+ independent flag in mode.json, DEFAULT ON. While ON (the normal state), every
63
+ cycle stops before posting and its drafts become review cards. When an
64
+ OPERATOR turns it off, run-draft-and-publish.sh runs EVERY lane (promotion AND
65
+ personal_brand) with DRAFT_ONLY=0 so they POST autonomously (promotion
66
+ additionally runs behind the rolling virality bar). This applies uniformly
67
+ across lanes; persona-lane cycles do NOT stay draft-only when the flag is off.
68
+ Deliberately NOT exposed on any user surface (no MCP tool param, no menubar
69
+ toggle): this CLI and mode.json are the only way to flip it.
69
70
  `env` additionally exports S4L_CYCLE_LANE=<lane> for both lanes so the wrapper
70
71
  knows which lane this cycle is without re-deriving it.
71
72
 
@@ -38,6 +38,12 @@ States:
38
38
  now correctly resolves to 'missing' (below), since the account
39
39
  scoping means an orphaned OTHER account's registry is never
40
40
  consulted for the active account's health.
41
+ DIAGNOSTIC-ONLY for the menu bar ⚠ since 2026-07-09: under the
42
+ wedge the tick can skip 70-90% of fires while the queue still
43
+ drains every job (jobs arrive ~every 8 min vs 60 fires/hr), so
44
+ a point-in-time lastRunAt check flip-flops against a healthy
45
+ pipeline. The menu bar alarms on queue latency instead and
46
+ renders 'stalled' as a plain detail line via tick_stats().
41
47
  'missing' — the ACTIVE account has no registry with a complete worker set
42
48
  (deleted / never scheduled / orphaned by an account switch)
43
49
  -> the dashboard offers "Set up draft schedule". Consumers
@@ -270,6 +276,53 @@ def _detail(glob_pattern: str | None = None) -> dict:
270
276
  return {"glob": patterns, "registries": regs}
271
277
 
272
278
 
279
+ def tick_stats(window_s: int = 3600, glob_pattern: str | None = None) -> dict:
280
+ """Diagnostic tick-level stats for the active account's worker set: how many
281
+ per-minute fires the host SKIPPED inside the last window_s (the registry's
282
+ recordedSkips ledger — under the Desktop warm-session wedge these carry
283
+ reason per_task_limit) plus the age of the newest successful run. Powers the
284
+ menu bar's non-alarm "scheduler degraded" detail line when compute() returns
285
+ 'stalled'. NEVER a health verdict on its own: a box skipping 80% of ticks
286
+ can still drain every job (jobs arrive ~every 8 min vs 60 fires/hr), which
287
+ is exactly why tick staleness stopped driving the attention ⚠ (2026-07-09).
288
+ Same account scoping as compute(); pass glob_pattern only for tests."""
289
+ patterns = [glob_pattern] if glob_pattern is not None else _active_registry_glob_patterns()
290
+ now = time.time()
291
+ skips = 0
292
+ last_skip_epoch = None
293
+ newest_epoch = None
294
+ for pattern in patterns:
295
+ for f in glob.glob(pattern):
296
+ try:
297
+ with open(f) as fh:
298
+ d = json.load(fh)
299
+ except Exception:
300
+ continue
301
+ by_id = {t.get("id"): t for t in (d.get("scheduledTasks") or [])}
302
+ recs = _registry_worker_recs(by_id)
303
+ if recs is None:
304
+ continue
305
+ epochs = [_iso_to_epoch(r.get("lastRunAt")) for r in recs]
306
+ e = max([x for x in epochs if x is not None], default=None)
307
+ if e is not None and (newest_epoch is None or e > newest_epoch):
308
+ newest_epoch = e
309
+ recorded = d.get("recordedSkips") or {}
310
+ for tid in WORKER_TASK_IDS:
311
+ for s in recorded.get(tid) or []:
312
+ at = _ms_to_epoch((s or {}).get("at"))
313
+ if at is None or (now - at) > window_s:
314
+ continue
315
+ skips += 1
316
+ if last_skip_epoch is None or at > last_skip_epoch:
317
+ last_skip_epoch = at
318
+ return {
319
+ "skips_in_window": skips,
320
+ "window_s": window_s,
321
+ "last_run_age_s": int(now - newest_epoch) if newest_epoch is not None else None,
322
+ "last_skip_age_s": int(now - last_skip_epoch) if last_skip_epoch is not None else None,
323
+ }
324
+
325
+
273
326
  def main() -> int:
274
327
  out = {}
275
328
  try:
@@ -43,12 +43,59 @@ import json
43
43
  import os
44
44
  import random
45
45
  import re
46
+ import signal
46
47
  import subprocess
47
48
  import sys
48
49
  import time
49
50
  from datetime import datetime, timezone
50
51
  from pathlib import Path
51
52
 
53
+
54
+ def _neuter_stream(stream) -> None:
55
+ """Point a dead pipe's fd at /dev/null so later writes (including the
56
+ interpreter-shutdown flush) can't raise BrokenPipeError again."""
57
+ try:
58
+ devnull = os.open(os.devnull, os.O_WRONLY)
59
+ os.dup2(devnull, stream.fileno())
60
+ os.close(devnull)
61
+ except Exception:
62
+ pass
63
+
64
+
65
+ _builtin_print = print
66
+
67
+
68
+ def print(*args, **kwargs): # noqa: A001 -- deliberate builtins.print shadow
69
+ # When the parent (cycle shell tree or MCP server) is killed mid-batch, our
70
+ # stdout/stderr pipes close and the next print raises BrokenPipeError. The
71
+ # old behavior unwound through excepthook (Sentry S4L-8), killing the batch
72
+ # BETWEEN posting a reply and recording it — which is how live tweets ended
73
+ # up unlogged and candidates re-feedable. Output to a dead parent is
74
+ # worthless; the bookkeeping (log_post, update_candidate, audit JSONL) is
75
+ # not. Swallow the error, neuter the fds, keep going.
76
+ try:
77
+ _builtin_print(*args, **kwargs)
78
+ except BrokenPipeError:
79
+ _neuter_stream(sys.stdout)
80
+ _neuter_stream(sys.stderr)
81
+
82
+
83
+ # Graceful SIGTERM: the MCP post_drafts timeout (and anything else that asks
84
+ # nicely before SIGKILL) sends SIGTERM. Dying instantly reopens the same
85
+ # posted-but-unrecorded window as the broken pipe, so instead flag the loop to
86
+ # stop at the next candidate boundary: current candidate finishes its
87
+ # bookkeeping, the summary and audit line still get written, exit stays 0.
88
+ _terminate_requested = False
89
+
90
+
91
+ def _on_sigterm(signum, frame):
92
+ global _terminate_requested
93
+ _terminate_requested = True
94
+ print("[post] SIGTERM received; stopping after current candidate", flush=True)
95
+
96
+
97
+ signal.signal(signal.SIGTERM, _on_sigterm)
98
+
52
99
  # This pipeline ONLY posts (never scans), so mark every twitter_browser.py reply
53
100
  # subprocess it spawns as the high-priority "post" lock role. run_subprocess
54
101
  # inherits this process env, so the child twitter_browser.py reads S4L_LOCK_ROLE
@@ -1150,6 +1197,10 @@ def main() -> int:
1150
1197
 
1151
1198
  try:
1152
1199
  for _idx, c in enumerate(candidates, start=1):
1200
+ if _terminate_requested:
1201
+ print(f"[post] stopping early on SIGTERM: {_idx - 1}/{_total} "
1202
+ f"candidates processed, posted={posted}", flush=True)
1203
+ break
1153
1204
  # Live per-post status for the S4L menu bar. LEAD with `posted` (the
1154
1205
  # REAL count of replies that actually landed), not `_idx` (the loop
1155
1206
  # position). _idx races through already-posted / deleted cards as instant
@@ -1224,6 +1275,22 @@ def main() -> int:
1224
1275
  "reason": reason or "",
1225
1276
  "our_url": c.get("our_url") or "",
1226
1277
  })
1278
+ # Per-candidate durable record: the run-level audit at the bottom of
1279
+ # main() is lost if this process is SIGKILLed mid-batch (browser-lock
1280
+ # hijack), leaving no local trace of what already posted. Separate
1281
+ # file from post-results.jsonl on purpose: that one is run-level and
1282
+ # the menu bar/dashboard parse it; don't mix schemas.
1283
+ try:
1284
+ _prog_path = os.path.join(
1285
+ REPO_DIR, "skill", "logs", "post-candidates.jsonl")
1286
+ with open(_prog_path, "a", encoding="utf-8") as _pf:
1287
+ _pf.write(json.dumps({
1288
+ "at": datetime.now(timezone.utc).isoformat(),
1289
+ "plan": plan_path.name,
1290
+ **candidate_results[-1],
1291
+ }) + "\n")
1292
+ except Exception:
1293
+ pass
1227
1294
  finally:
1228
1295
  _clear_activity()
1229
1296
  # Release the batch hold so the next scan/post can take the browser
@@ -11,9 +11,10 @@
11
11
  # review-queue cards the menu bar shows. Without this merge the cycle's
12
12
  # plan would sit in a /tmp batch file nobody reads.
13
13
  # - draft-only OFF (operator opt-out via `s4l_mode.py draft-only off`,
14
- # promotion-lane cycles only): DRAFT_ONLY=0 — the cycle posts its top-1
15
- # pick autonomously, gated by the rolling virality bar. Persona cycles
16
- # keep making cards either way.
14
+ # single global switch across EVERY lane as of 2026-07-08): DRAFT_ONLY=0
15
+ # — the cycle posts its top-1 pick autonomously; promotion additionally
16
+ # gated by the rolling virality bar. Persona cycles also post
17
+ # autonomously when the flag is off; they no longer stay draft-only.
17
18
  set -uo pipefail
18
19
 
19
20
  # SAPS_->S4L_ env mirror (brand rename 2026-07-03): old plists/tasks still