@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.
- package/mcp/dist/index.js +61 -9
- package/mcp/dist/screencast.js +27 -0
- package/mcp/dist/setup.js +8 -2
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_browser_foreground.py +188 -0
- package/mcp/menubar/s4l_card.py +312 -29
- package/mcp/menubar/s4l_log_relay.py +32 -0
- package/mcp/menubar/s4l_menubar.py +278 -62
- package/mcp/menubar/s4l_state.py +37 -4
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/_process_li_notifs.py +91 -0
- package/scripts/feedback_digest.py +1 -1
- package/scripts/merge_review_queue.py +87 -21
- package/scripts/pick_project.py +12 -3
- package/scripts/release-mcpb.sh +15 -1
- package/scripts/s4l_mode.py +9 -8
- package/scripts/schedule_state.py +53 -0
- package/scripts/twitter_post_plan.py +67 -0
- package/skill/run-draft-and-publish.sh +4 -3
|
@@ -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
|
|
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
|
|
133
|
-
"""
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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 =
|
|
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
|
|
278
|
-
|
|
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":
|
|
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"({
|
|
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.
|
package/scripts/pick_project.py
CHANGED
|
@@ -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,
|
|
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
|
|
126
|
-
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
|
|
package/scripts/release-mcpb.sh
CHANGED
|
@@ -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
|
-
&& (
|
|
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
|
package/scripts/s4l_mode.py
CHANGED
|
@@ -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
|
|
62
|
-
While ON (the normal state), every
|
|
63
|
-
become review cards. When an
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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
|