social-autoposter 1.7.2-rc.9 → 1.7.2
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 +36 -3
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +211 -29
- package/mcp/menubar/s4l_menubar.py +264 -61
- 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 +67 -21
- package/scripts/schedule_state.py +53 -0
package/mcp/dist/index.js
CHANGED
|
@@ -870,6 +870,18 @@ async function ensureTwitterBrowserForPost() {
|
|
|
870
870
|
},
|
|
871
871
|
});
|
|
872
872
|
}
|
|
873
|
+
// A terminal stamp written by merge_review_queue.py's backend sync for a row the
|
|
874
|
+
// freshness gate merely EXPIRED (not posted, not skipped) records "nobody decided
|
|
875
|
+
// this in time" — an explicit human approval outranks it. The poster's own
|
|
876
|
+
// at-post-time tweet_unavailable check remains the real gate on whether the
|
|
877
|
+
// thread still exists. Without this override, a card approved while (or just
|
|
878
|
+
// before) the sync stamped it is refused as already-decided and the approval
|
|
879
|
+
// silently no-ops (2 of 3 approvals lost on 2026-07-10).
|
|
880
|
+
function expiredStampOverridable(c) {
|
|
881
|
+
return (c.terminal === true &&
|
|
882
|
+
c.posted !== true &&
|
|
883
|
+
c.discard_reason === "backend_status_expired");
|
|
884
|
+
}
|
|
873
885
|
async function postApproved(batchId, plan) {
|
|
874
886
|
// Drain serialization (2026-07-06 incident). Every call drains the WHOLE
|
|
875
887
|
// approved backlog, so overlapping drains are pure waste and actively harmful:
|
|
@@ -922,7 +934,18 @@ async function postApproved(batchId, plan) {
|
|
|
922
934
|
// drains the not-yet-posted approved backlog (e.g. a card a restart interrupted),
|
|
923
935
|
// never re-posts a done one. This is what lets the startup backlog-drain and the
|
|
924
936
|
// per-card menu-bar calls share one code path safely.
|
|
925
|
-
|
|
937
|
+
// An approved card whose only blocker is an overridable backend-expiry stamp is
|
|
938
|
+
// included: approval outranks the freshness gate (see expiredStampOverridable),
|
|
939
|
+
// and the stamp is cleared so every downstream terminal check agrees it's live.
|
|
940
|
+
const approved = (plan.candidates || []).filter((c) => c.approved === true &&
|
|
941
|
+
c.posted !== true &&
|
|
942
|
+
(c.terminal !== true || expiredStampOverridable(c)));
|
|
943
|
+
for (const c of approved) {
|
|
944
|
+
if (c.terminal === true) {
|
|
945
|
+
c.terminal = false;
|
|
946
|
+
delete c.discard_reason;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
926
949
|
if (approved.length === 0)
|
|
927
950
|
return { attempted: 0, exit_code: 0, summary: "nothing approved" };
|
|
928
951
|
// PREFLIGHT: posting needs a configured @handle, or twitter_browser.py refuses
|
|
@@ -2316,9 +2339,17 @@ tool("post_drafts", {
|
|
|
2316
2339
|
});
|
|
2317
2340
|
// Cross-surface de-dup: chat and the menu-bar pop-ups can both approve, so
|
|
2318
2341
|
// never re-post a candidate the other surface already posted OR ruled out.
|
|
2342
|
+
// Exception: an overridable backend-expiry stamp yields to this explicit
|
|
2343
|
+
// approval (see expiredStampOverridable) — clear it and post.
|
|
2319
2344
|
const alreadyDone = [];
|
|
2320
2345
|
for (const n of Array.from(approve)) {
|
|
2321
|
-
|
|
2346
|
+
const c = candidates[n - 1];
|
|
2347
|
+
if (c && expiredStampOverridable(c)) {
|
|
2348
|
+
c.terminal = false;
|
|
2349
|
+
delete c.discard_reason;
|
|
2350
|
+
continue;
|
|
2351
|
+
}
|
|
2352
|
+
if (c?.posted === true || c?.terminal === true) {
|
|
2322
2353
|
approve.delete(n);
|
|
2323
2354
|
alreadyDone.push(n);
|
|
2324
2355
|
}
|
|
@@ -4963,7 +4994,9 @@ async function drainApprovedBacklog() {
|
|
|
4963
4994
|
try {
|
|
4964
4995
|
const plan = readPlan(REVIEW_QUEUE_ID);
|
|
4965
4996
|
const cands = plan?.candidates || [];
|
|
4966
|
-
const backlog = cands.filter((c) => c.approved === true &&
|
|
4997
|
+
const backlog = cands.filter((c) => c.approved === true &&
|
|
4998
|
+
c.posted !== true &&
|
|
4999
|
+
(c.terminal !== true || expiredStampOverridable(c)));
|
|
4967
5000
|
if (!backlog.length)
|
|
4968
5001
|
return;
|
|
4969
5002
|
console.error(`[post] draining ${backlog.length} approved-but-unposted card(s) left from before`);
|
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.7.2
|
|
5
|
+
"version": "1.7.2",
|
|
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
|
@@ -8,6 +8,15 @@ fires once the last card is decided or the window is closed. The whole AppKit
|
|
|
8
8
|
surface is isolated behind that one function so the menu bar wiring doesn't
|
|
9
9
|
depend on the windowing details.
|
|
10
10
|
|
|
11
|
+
The panel is NONACTIVATING and auto-presented cards never take keyboard focus
|
|
12
|
+
(2026-07-09 customer complaint: cards stole the caret mid-typing); the reply
|
|
13
|
+
field becomes editable on first click, and only a user-initiated open
|
|
14
|
+
(present_review(focus=True), the menu bar's "Review N pending drafts")
|
|
15
|
+
activates the app and seats the caret immediately. A title-bar "Snooze 1h"
|
|
16
|
+
button sits opposite the close cross; it and the cross both just close the
|
|
17
|
+
panel, and the menu bar interprets any close with undecided drafts as a
|
|
18
|
+
snooze (drafts stay pending, re-present after REVIEW_SNOOZE_SECONDS).
|
|
19
|
+
|
|
11
20
|
Decision shape: {"n": int, "approved": bool, "loved": bool, "text": str,
|
|
12
21
|
"edited": bool, "drop_link": bool, "reject_category": str|None,
|
|
13
22
|
"reject_note": str|None, "interactions": [{"type": str, "ts": str}],
|
|
@@ -49,6 +58,7 @@ run loop, so that holds).
|
|
|
49
58
|
|
|
50
59
|
import datetime
|
|
51
60
|
import json
|
|
61
|
+
import os
|
|
52
62
|
import re
|
|
53
63
|
import time
|
|
54
64
|
|
|
@@ -116,6 +126,26 @@ try:
|
|
|
116
126
|
from AppKit import NSVisualEffectView
|
|
117
127
|
except Exception:
|
|
118
128
|
NSVisualEffectView = None
|
|
129
|
+
# Nonactivating panel: an auto-presented card must never yank keyboard focus
|
|
130
|
+
# from whatever the user is typing into (customer complaint 2026-07-09); the
|
|
131
|
+
# panel becomes key only when the user clicks into one of its text fields.
|
|
132
|
+
# Bit value 1 << 7 per AppKit; 0 degrades to the old always-activating panel.
|
|
133
|
+
try:
|
|
134
|
+
from AppKit import NSWindowStyleMaskNonactivatingPanel
|
|
135
|
+
except Exception:
|
|
136
|
+
NSWindowStyleMaskNonactivatingPanel = 0
|
|
137
|
+
# Title-bar "Snooze 1h" button (sits opposite the close cross). Missing on
|
|
138
|
+
# very old AppKit; the card then degrades to cross-only (closing still
|
|
139
|
+
# snoozes, the label is just absent).
|
|
140
|
+
try:
|
|
141
|
+
from AppKit import NSTitlebarAccessoryViewController, NSLayoutAttributeRight
|
|
142
|
+
except Exception:
|
|
143
|
+
NSTitlebarAccessoryViewController = None
|
|
144
|
+
NSLayoutAttributeRight = None
|
|
145
|
+
try:
|
|
146
|
+
from AppKit import NSBezelStyleInline
|
|
147
|
+
except Exception:
|
|
148
|
+
NSBezelStyleInline = None
|
|
119
149
|
try:
|
|
120
150
|
# Registers the CGColor bridge type; without it every NSColor.CGColor()
|
|
121
151
|
# call in _round_rect logs an ObjCPointerWarning to menubar.err.log.
|
|
@@ -149,14 +179,28 @@ REJECT_REASONS = (
|
|
|
149
179
|
# Client-side cap on tracked interactions per card (server clips at 50 too).
|
|
150
180
|
MAX_INTERACTIONS = 50
|
|
151
181
|
|
|
152
|
-
|
|
153
|
-
|
|
182
|
+
|
|
183
|
+
def _snooze_secs():
|
|
184
|
+
"""Snooze duration, for the title-bar button LABEL only. The menu bar owns
|
|
185
|
+
the actual timer (its REVIEW_SNOOZE_SECONDS reads the same env var)."""
|
|
186
|
+
try:
|
|
187
|
+
return max(60, int(float(os.environ.get("S4L_REVIEW_SNOOZE_S", "3600"))))
|
|
188
|
+
except Exception:
|
|
189
|
+
return 3600
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _snooze_title():
|
|
193
|
+
s = _snooze_secs()
|
|
194
|
+
return f"Snooze {s // 3600}h" if s % 3600 == 0 else f"Snooze {s // 60}m"
|
|
195
|
+
|
|
196
|
+
# Inline approve row: glyph + tooltip per approval level (button tag =
|
|
197
|
+
# level). Level 1 = plain approve; 2 = loved=True on the decision, with the
|
|
154
198
|
# exact level shipped as an approve_level_N interaction for the feedback
|
|
155
|
-
# digest.
|
|
199
|
+
# digest. Monochrome text-presentation glyphs (U+FE0E) so the buttons stay
|
|
200
|
+
# black-and-white in both appearances instead of rendering as color emoji.
|
|
156
201
|
APPROVE_EMOJIS = (
|
|
157
|
-
("
|
|
158
|
-
("
|
|
159
|
-
("❤️🔥", "Approve, best of the best"),
|
|
202
|
+
("✓︎", "Approve"),
|
|
203
|
+
("♥︎", "Approve, best of the best"),
|
|
160
204
|
)
|
|
161
205
|
|
|
162
206
|
# Review-surface state mirrored to the state dir for out-of-process observers
|
|
@@ -626,13 +670,21 @@ def _editable_scroll(frame, text=""):
|
|
|
626
670
|
|
|
627
671
|
|
|
628
672
|
class _ReviewController(NSObject):
|
|
629
|
-
def
|
|
673
|
+
def initWithDrafts_onDecision_onComplete_focus_(
|
|
674
|
+
self, drafts, on_decision, on_complete, focus
|
|
675
|
+
):
|
|
630
676
|
self = objc.super(_ReviewController, self).init()
|
|
631
677
|
if self is None:
|
|
632
678
|
return None
|
|
633
679
|
self._drafts = list(drafts)
|
|
634
680
|
self._on_decision = on_decision
|
|
635
681
|
self._on_complete = on_complete
|
|
682
|
+
# focus=True only for a USER-initiated open (the menu bar's "Review N
|
|
683
|
+
# pending drafts"): activate the app and seat the caret in the reply
|
|
684
|
+
# field. Auto-presented cards (the timer tick) must appear without
|
|
685
|
+
# taking keyboard focus from whatever the user is doing.
|
|
686
|
+
self._focus = bool(focus)
|
|
687
|
+
self._close_reason = None # "snooze" when the title-bar button closed us
|
|
636
688
|
self._idx = 0
|
|
637
689
|
self._decisions = []
|
|
638
690
|
self._panel = None
|
|
@@ -776,26 +828,35 @@ class _ReviewController(NSObject):
|
|
|
776
828
|
NSWindowStyleMaskTitled
|
|
777
829
|
| NSWindowStyleMaskClosable
|
|
778
830
|
| NSWindowStyleMaskUtilityWindow
|
|
831
|
+
| NSWindowStyleMaskNonactivatingPanel
|
|
779
832
|
)
|
|
780
833
|
panel = _ReviewPanel.alloc().initWithContentRect_styleMask_backing_defer_(
|
|
781
834
|
frame, style, NSBackingStoreBuffered, False
|
|
782
835
|
)
|
|
783
836
|
panel.setLevel_(NSFloatingWindowLevel)
|
|
784
837
|
panel.setFloatingPanel_(True)
|
|
785
|
-
|
|
838
|
+
# Key only when a text field is clicked: buttons (Approve/Reject) work
|
|
839
|
+
# without ever taking keyboard focus, and clicking into the reply field
|
|
840
|
+
# makes this nonactivating panel key without activating the app, so
|
|
841
|
+
# the user's frontmost app keeps its state. The old False + activate-
|
|
842
|
+
# on-present combination stole the keyboard mid-typing (2026-07-09
|
|
843
|
+
# customer complaint).
|
|
844
|
+
panel.setBecomesKeyOnlyIfNeeded_(True)
|
|
786
845
|
panel.setHidesOnDeactivate_(False)
|
|
787
846
|
panel.setReleasedWhenClosed_(False)
|
|
788
847
|
panel.setDelegate_(self)
|
|
848
|
+
self._add_snooze_accessory(panel)
|
|
789
849
|
self._panel = panel
|
|
790
850
|
self._render()
|
|
791
|
-
panel.makeKeyAndOrderFront_(None)
|
|
792
851
|
panel.orderFrontRegardless()
|
|
793
852
|
self._log_surface("presented")
|
|
794
|
-
self.
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
853
|
+
if self._focus:
|
|
854
|
+
# User asked for the cards (menu item): behave like a normal open,
|
|
855
|
+
# activate and put the caret in the reply field. Deferred second
|
|
856
|
+
# pass because activation/key promotion lands on the run loop.
|
|
857
|
+
panel.makeKeyAndOrderFront_(None)
|
|
858
|
+
self.focusReply_(None)
|
|
859
|
+
self.performSelector_withObject_afterDelay_("focusReply:", None, 0.05)
|
|
799
860
|
|
|
800
861
|
def focusReply_(self, sender):
|
|
801
862
|
panel = self._panel
|
|
@@ -826,6 +887,52 @@ class _ReviewController(NSObject):
|
|
|
826
887
|
except Exception:
|
|
827
888
|
pass
|
|
828
889
|
|
|
890
|
+
@objc.python_method
|
|
891
|
+
def _add_snooze_accessory(self, panel):
|
|
892
|
+
""""Snooze 1h" in the title bar, opposite the close cross: the explicit
|
|
893
|
+
"not now" affordance. It just closes the panel; the menu bar treats any
|
|
894
|
+
close with undecided drafts as a snooze, so the cross and this button
|
|
895
|
+
are the same action, this one merely says so out loud."""
|
|
896
|
+
if NSTitlebarAccessoryViewController is None or NSLayoutAttributeRight is None:
|
|
897
|
+
return
|
|
898
|
+
try:
|
|
899
|
+
btn = NSButton.alloc().initWithFrame_(NSMakeRect(0, 0, 80, 17))
|
|
900
|
+
btn.setTitle_(_snooze_title())
|
|
901
|
+
if NSBezelStyleInline is not None:
|
|
902
|
+
btn.setBezelStyle_(NSBezelStyleInline)
|
|
903
|
+
else:
|
|
904
|
+
btn.setBezelStyle_(NSBezelStyleRounded)
|
|
905
|
+
btn.setFont_(NSFont.systemFontOfSize_(10.0))
|
|
906
|
+
btn.setToolTip_(
|
|
907
|
+
"Put these drafts away for now. They stay pending and the "
|
|
908
|
+
"card comes back later (or sooner from the S4L menu)."
|
|
909
|
+
)
|
|
910
|
+
btn.setTarget_(self)
|
|
911
|
+
btn.setAction_("snoozeClicked:")
|
|
912
|
+
btn.sizeToFit()
|
|
913
|
+
bf = btn.frame()
|
|
914
|
+
holder = NSView.alloc().initWithFrame_(
|
|
915
|
+
NSMakeRect(0, 0, bf.size.width + 8, max(19, bf.size.height + 2))
|
|
916
|
+
)
|
|
917
|
+
btn.setFrameOrigin_(
|
|
918
|
+
(4, max(0, (holder.frame().size.height - bf.size.height) / 2.0))
|
|
919
|
+
)
|
|
920
|
+
holder.addSubview_(btn)
|
|
921
|
+
acc = NSTitlebarAccessoryViewController.alloc().init()
|
|
922
|
+
acc.setView_(holder)
|
|
923
|
+
acc.setLayoutAttribute_(NSLayoutAttributeRight)
|
|
924
|
+
panel.addTitlebarAccessoryViewController_(acc)
|
|
925
|
+
except Exception as e:
|
|
926
|
+
_log(f"snooze accessory unavailable: {e}")
|
|
927
|
+
|
|
928
|
+
def snoozeClicked_(self, sender):
|
|
929
|
+
self._track("snooze")
|
|
930
|
+
self._close_reason = "snooze"
|
|
931
|
+
try:
|
|
932
|
+
self._panel.performClose_(None)
|
|
933
|
+
except Exception:
|
|
934
|
+
self._finish()
|
|
935
|
+
|
|
829
936
|
@objc.python_method
|
|
830
937
|
def _eye_button(self, frame, kind):
|
|
831
938
|
"""Borderless SF-Symbol eye whose hover/click opens a popover. A plain
|
|
@@ -884,12 +991,13 @@ class _ReviewController(NSObject):
|
|
|
884
991
|
# Bezeled, not borderless: bare emoji read as decoration and
|
|
885
992
|
# users doubted the click registered (2026-07-03/04 feedback,
|
|
886
993
|
# twice now — the outline is what says "button").
|
|
887
|
-
btn = NSButton.alloc().initWithFrame_(NSMakeRect(x, H - 42,
|
|
994
|
+
btn = NSButton.alloc().initWithFrame_(NSMakeRect(x, H - 42, 38, 30))
|
|
888
995
|
btn.setTitle_(emoji)
|
|
889
996
|
btn.setBezelStyle_(NSBezelStyleRounded)
|
|
890
|
-
#
|
|
891
|
-
#
|
|
892
|
-
|
|
997
|
+
# 16pt fits the rounded bezel's ~22px content lane for these
|
|
998
|
+
# text-presentation glyphs (the old color emoji clipped at 16pt,
|
|
999
|
+
# which is why this used to be 13pt).
|
|
1000
|
+
btn.setFont_(NSFont.systemFontOfSize_(16))
|
|
893
1001
|
btn.setTag_(i + 1) # tag = approval level
|
|
894
1002
|
btn.setTarget_(self)
|
|
895
1003
|
btn.setAction_("approve:")
|
|
@@ -898,7 +1006,7 @@ class _ReviewController(NSObject):
|
|
|
898
1006
|
except Exception:
|
|
899
1007
|
pass
|
|
900
1008
|
content.addSubview_(btn)
|
|
901
|
-
x +=
|
|
1009
|
+
x += 42
|
|
902
1010
|
|
|
903
1011
|
reject = NSButton.alloc().initWithFrame_(NSMakeRect(W - M - 66, H - 42, 66, 30))
|
|
904
1012
|
reject.setTitle_("Reject")
|
|
@@ -1239,9 +1347,15 @@ class _ReviewController(NSObject):
|
|
|
1239
1347
|
)
|
|
1240
1348
|
# setContentView_ rebuilds the view tree, so the caret would otherwise
|
|
1241
1349
|
# default to the Approve button. Re-seat it in the reply field for every
|
|
1242
|
-
# card
|
|
1243
|
-
|
|
1244
|
-
|
|
1350
|
+
# card the user is ACTIVELY reviewing (they opened the stack from the
|
|
1351
|
+
# menu, or already decided/touched something), so each one is
|
|
1352
|
+
# immediately editable. NOT on the initial auto-presented render:
|
|
1353
|
+
# focusReply_ activates the app, and yanking the caret out of whatever
|
|
1354
|
+
# the user was typing was the 2026-07-09 "too distracting" complaint.
|
|
1355
|
+
# An untouched card's field becomes editable on first click instead.
|
|
1356
|
+
if self._focus or self._decisions or self._last_interaction_at is not None:
|
|
1357
|
+
self._panel.makeFirstResponder_(tv)
|
|
1358
|
+
self.performSelector_withObject_afterDelay_("focusReply:", None, 0.05)
|
|
1245
1359
|
|
|
1246
1360
|
@objc.python_method
|
|
1247
1361
|
def _close_stats_popover(self):
|
|
@@ -1600,6 +1714,37 @@ class _ReviewController(NSObject):
|
|
|
1600
1714
|
self._log_surface(f"extended +{len(added)}")
|
|
1601
1715
|
return len(added)
|
|
1602
1716
|
|
|
1717
|
+
@objc.python_method
|
|
1718
|
+
def prune_drafts(self, ns):
|
|
1719
|
+
"""Drop not-yet-reached drafts (plan index `n` in `ns`) from the stack,
|
|
1720
|
+
e.g. a card the backend already retired (expired freshness gate, etc.)
|
|
1721
|
+
while it was still waiting to be shown. Only ever removes entries AFTER
|
|
1722
|
+
the current index, so the card on screen right now and everything
|
|
1723
|
+
already decided are untouched -- nothing visible disappears out from
|
|
1724
|
+
under the user. Refreshes the title-bar counter live. Returns the
|
|
1725
|
+
count actually removed."""
|
|
1726
|
+
if self._panel is None or not ns:
|
|
1727
|
+
return 0
|
|
1728
|
+
ns = set(ns)
|
|
1729
|
+
kept = []
|
|
1730
|
+
removed = 0
|
|
1731
|
+
for i, d in enumerate(self._drafts):
|
|
1732
|
+
if i > self._idx and d.get("n") in ns:
|
|
1733
|
+
removed += 1
|
|
1734
|
+
continue
|
|
1735
|
+
kept.append(d)
|
|
1736
|
+
if not removed:
|
|
1737
|
+
return 0
|
|
1738
|
+
self._drafts = kept
|
|
1739
|
+
try:
|
|
1740
|
+
self._panel.setTitle_(
|
|
1741
|
+
f"s4l · Review draft {self._idx + 1} of {len(self._drafts)}"
|
|
1742
|
+
)
|
|
1743
|
+
except Exception:
|
|
1744
|
+
pass
|
|
1745
|
+
self._log_surface(f"pruned {removed} expired")
|
|
1746
|
+
return removed
|
|
1747
|
+
|
|
1603
1748
|
@objc.python_method
|
|
1604
1749
|
def _fire_decision(self):
|
|
1605
1750
|
# Fire the per-card callback the instant a decision is made, so an
|
|
@@ -1761,7 +1906,10 @@ class _ReviewController(NSObject):
|
|
|
1761
1906
|
|
|
1762
1907
|
def windowShouldClose_(self, sender):
|
|
1763
1908
|
# Closing the window stops review; remaining cards are left undecided
|
|
1764
|
-
# (not posted). Finish with whatever was decided so far.
|
|
1909
|
+
# (not posted). Finish with whatever was decided so far. The menu bar
|
|
1910
|
+
# treats a close with undecided drafts as SNOOZE (they re-present after
|
|
1911
|
+
# REVIEW_SNOOZE_SECONDS, or sooner via the menu), whether it came from
|
|
1912
|
+
# the cross or the title-bar Snooze button.
|
|
1765
1913
|
self._finish()
|
|
1766
1914
|
return True
|
|
1767
1915
|
|
|
@@ -1783,11 +1931,12 @@ class _ReviewController(NSObject):
|
|
|
1783
1931
|
except Exception:
|
|
1784
1932
|
pass
|
|
1785
1933
|
_active = None
|
|
1786
|
-
|
|
1787
|
-
|
|
1934
|
+
reason = self._close_reason or "closed"
|
|
1935
|
+
_log(f"closed: {len(self._decisions)} decided of {len(self._drafts)} ({reason})")
|
|
1936
|
+
_write_review_state(last_event="snoozed" if reason == "snooze" else "closed")
|
|
1788
1937
|
|
|
1789
1938
|
|
|
1790
|
-
def present_review(drafts, on_decision=None, on_complete=None):
|
|
1939
|
+
def present_review(drafts, on_decision=None, on_complete=None, focus=False):
|
|
1791
1940
|
"""Show the review cards (main thread only). drafts: list of
|
|
1792
1941
|
{n, thread_author, thread_text, reply_text, link_url, thread_url?, stats?}
|
|
1793
1942
|
where stats is the discovery-time candidate snapshot
|
|
@@ -1809,14 +1958,17 @@ def present_review(drafts, on_decision=None, on_complete=None):
|
|
|
1809
1958
|
active experiment/scenario arm.
|
|
1810
1959
|
on_decision(decision) fires the instant each card is approved/rejected (so an
|
|
1811
1960
|
approved draft posts right away); on_complete(decisions) fires when the user
|
|
1812
|
-
finishes the last card or closes the window. Both run on the main thread.
|
|
1961
|
+
finishes the last card or closes the window. Both run on the main thread.
|
|
1962
|
+
focus=True (user-initiated open, e.g. the menu bar's "Review N pending
|
|
1963
|
+
drafts") activates the app and seats the caret in the reply field; the
|
|
1964
|
+
default False shows the card without taking keyboard focus."""
|
|
1813
1965
|
global _active
|
|
1814
1966
|
if not drafts:
|
|
1815
1967
|
if on_complete is not None:
|
|
1816
1968
|
on_complete([])
|
|
1817
1969
|
return
|
|
1818
|
-
_active = _ReviewController.alloc().
|
|
1819
|
-
drafts, on_decision, on_complete
|
|
1970
|
+
_active = _ReviewController.alloc().initWithDrafts_onDecision_onComplete_focus_(
|
|
1971
|
+
drafts, on_decision, on_complete, focus
|
|
1820
1972
|
)
|
|
1821
1973
|
|
|
1822
1974
|
|
|
@@ -1832,6 +1984,22 @@ def extend_active(drafts):
|
|
|
1832
1984
|
return 0
|
|
1833
1985
|
|
|
1834
1986
|
|
|
1987
|
+
def prune_active(ns):
|
|
1988
|
+
"""Remove not-yet-reached drafts (by plan index `n`) from the open review
|
|
1989
|
+
card, if one is up -- e.g. a card that expired on the backend mid-review
|
|
1990
|
+
(see merge_review_queue.py's backend sync). Never touches the card
|
|
1991
|
+
currently on screen or any already-decided one, so nothing visible is
|
|
1992
|
+
yanked out from under the user. Returns the count actually removed (0 if
|
|
1993
|
+
no card is open or none of `ns` are still ahead in the stack). Main thread
|
|
1994
|
+
only (called from the menu bar's rumps timer)."""
|
|
1995
|
+
if _active is None or not ns:
|
|
1996
|
+
return 0
|
|
1997
|
+
try:
|
|
1998
|
+
return _active.prune_drafts(ns)
|
|
1999
|
+
except Exception:
|
|
2000
|
+
return 0
|
|
2001
|
+
|
|
2002
|
+
|
|
1835
2003
|
def active_status():
|
|
1836
2004
|
"""Live review-surface snapshot for the menu bar's unattended-review
|
|
1837
2005
|
watchdog, or None when no card is open. Main thread only."""
|
|
@@ -1913,6 +2081,20 @@ def heal_active():
|
|
|
1913
2081
|
return False
|
|
1914
2082
|
|
|
1915
2083
|
|
|
2084
|
+
def focus_active():
|
|
2085
|
+
"""Bring the open card to the user deliberately (menu 'Review pending
|
|
2086
|
+
drafts' while a card is already up): activate + caret in the reply field.
|
|
2087
|
+
Main thread only. Returns True if a card was focused."""
|
|
2088
|
+
c = _active
|
|
2089
|
+
if c is None or c._panel is None:
|
|
2090
|
+
return False
|
|
2091
|
+
try:
|
|
2092
|
+
c.focusReply_(None)
|
|
2093
|
+
return True
|
|
2094
|
+
except Exception:
|
|
2095
|
+
return False
|
|
2096
|
+
|
|
2097
|
+
|
|
1916
2098
|
# ---- overall-feedback composer ----------------------------------------------
|
|
1917
2099
|
# One small floating panel with a free-text field, for guidance that is about
|
|
1918
2100
|
# the PIPELINE rather than any single draft ("less shilling", "more dev
|
|
@@ -439,6 +439,17 @@ REVIEW_HEAL_EVERY_SECONDS = float(
|
|
|
439
439
|
)
|
|
440
440
|
REVIEW_UNATTENDED_SENTRY_SECONDS = 3600.0
|
|
441
441
|
|
|
442
|
+
# Review snooze (2026-07-09 customer feedback: cards interrupt focused work
|
|
443
|
+
# with no way to defer them). Closing the card with undecided drafts, via the
|
|
444
|
+
# cross or the title-bar "Snooze 1h" button, parks the WHOLE pending backlog
|
|
445
|
+
# for REVIEW_SNOOZE_SECONDS: no auto-present, no watchdog heal (nothing is on
|
|
446
|
+
# screen). Drafts stay in the store untouched; the menu's "Review N pending
|
|
447
|
+
# drafts" clears the snooze early. Persisted so a menubar restart mid-snooze
|
|
448
|
+
# doesn't re-pop the card. s4l_card._snooze_secs reads the same env var for
|
|
449
|
+
# the button label; keep them in sync.
|
|
450
|
+
REVIEW_SNOOZE_SECONDS = float(os.environ.get("S4L_REVIEW_SNOOZE_S", "3600"))
|
|
451
|
+
SNOOZE_FILE = os.path.join(st.state_dir(), "review-snooze.json")
|
|
452
|
+
|
|
442
453
|
|
|
443
454
|
def _label_elapsed_secs(label):
|
|
444
455
|
"""Parse the trailing duration the producer encodes in a drafting activity
|
|
@@ -519,6 +530,10 @@ class S4LMenuBar(rumps.App):
|
|
|
519
530
|
self._post_worker = None
|
|
520
531
|
self._review_lock = threading.Lock()
|
|
521
532
|
self._panel_open = False
|
|
533
|
+
# Review snooze: epoch until which draft cards must not auto-present
|
|
534
|
+
# (0 = not snoozed). Loaded from disk so a menubar restart mid-snooze
|
|
535
|
+
# doesn't re-pop the card the user just put away.
|
|
536
|
+
self._review_snooze_until = self._read_snooze_until()
|
|
522
537
|
# Unattended-review watchdog state (_maybe_heal_review).
|
|
523
538
|
self._review_heal_at = 0.0
|
|
524
539
|
self._review_unattended_notified = False
|
|
@@ -645,6 +660,11 @@ class S4LMenuBar(rumps.App):
|
|
|
645
660
|
# Cached schedule state for the current account: 'missing'/'disabled'/'ok'/
|
|
646
661
|
# 'unknown'. PRIMARY driver of the menu's attention section.
|
|
647
662
|
self._schedule_state_cache = "ok"
|
|
663
|
+
# Tick-freshness diagnostics (schedule_state.tick_stats()) shown as the
|
|
664
|
+
# non-alarm "scheduler degraded" menu line while state == 'stalled'.
|
|
665
|
+
# None outside that state; refreshed at most once a minute (_tick).
|
|
666
|
+
self._tick_stats = None
|
|
667
|
+
self._tick_stats_at = 0.0
|
|
648
668
|
self._reloc_timer = rumps.Timer(self._maybe_relocate_tasks, 90)
|
|
649
669
|
self._reloc_timer.start()
|
|
650
670
|
self._tick(None)
|
|
@@ -874,11 +894,15 @@ class S4LMenuBar(rumps.App):
|
|
|
874
894
|
does NOT stay stale after recovery.
|
|
875
895
|
|
|
876
896
|
NOTE: kept in sync with scripts/autopilot_stall_watch.py (the fleet Sentry
|
|
877
|
-
backstop).
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
897
|
+
backstop). Since 2026-07-09 this method IS an attention/⚠ driver: _tick
|
|
898
|
+
ORs it in (with the reason from _stall_reason) alongside the structural
|
|
899
|
+
schedule states (missing/disabled) and the activity-label draft_stuck
|
|
900
|
+
check. A firing-but-momentarily-empty queue still stays green: an idle
|
|
901
|
+
queue has no pending job, running/ is empty, and the drain latch zeroes
|
|
902
|
+
on every successful consume (claude_job._mark_drain_success), so none of
|
|
903
|
+
the three signals can hold a stale True after recovery. Tick-freshness
|
|
904
|
+
'stalled' from schedule_state no longer flips the ⚠ at all — it renders
|
|
905
|
+
as a plain diagnostic line (see _build_menu).
|
|
882
906
|
"""
|
|
883
907
|
qroot = os.path.join(st.state_dir(), "claude-queue")
|
|
884
908
|
# (1) latched producer drain-status
|
|
@@ -2320,34 +2344,38 @@ class S4LMenuBar(rumps.App):
|
|
|
2320
2344
|
blocker = (ob or {}).get("current_blocker")
|
|
2321
2345
|
blocker_code = (blocker or {}).get("code")
|
|
2322
2346
|
# --- Autopilot health (only meaningful once setup is complete) --------
|
|
2323
|
-
#
|
|
2324
|
-
#
|
|
2325
|
-
#
|
|
2326
|
-
#
|
|
2327
|
-
#
|
|
2328
|
-
#
|
|
2329
|
-
#
|
|
2347
|
+
# TWO layers (2026-07-09 redesign):
|
|
2348
|
+
# STRUCTURAL (schedule_state): 'missing'/'disabled' = the schedule is
|
|
2349
|
+
# genuinely not registered/enabled for this account -> ⚠ + re-arm.
|
|
2350
|
+
# JOB LATENCY (_autopilot_stalled + the draft_stuck label check below):
|
|
2351
|
+
# a draft job sat unclaimed, wedged mid-run, or the producer's drain
|
|
2352
|
+
# latch tripped -> ⚠, because THAT is user-visible harm.
|
|
2353
|
+
# Tick freshness ('stalled': task present + enabled but lastRunAt stale,
|
|
2354
|
+
# the Desktop warm-session wedge) is DIAGNOSTIC ONLY, never the ⚠: under
|
|
2355
|
+
# the wedge the per-minute tick can skip 70-90% of fires while the queue
|
|
2356
|
+
# still drains every job (jobs arrive ~every 8 min vs 60 fires/hr), so
|
|
2357
|
+
# lastRunAt staleness flip-flopped the icon against a healthy pipeline
|
|
2358
|
+
# (observed on the operator box 2026-07-09: 83% ticks skipped, ~14
|
|
2359
|
+
# posts/hr flowing, icon oscillating). _build_menu renders 'stalled' as
|
|
2360
|
+
# a plain "scheduler degraded" detail line with tick_stats() numbers.
|
|
2361
|
+
# The drain latch IS safe to alarm on now: claude_job._mark_drain_success
|
|
2362
|
+
# zeroes it on every successful consume, so it self-clears (the old
|
|
2363
|
+
# stayed-stale-after-recovery latch this comment used to warn about
|
|
2364
|
+
# predates that reset).
|
|
2330
2365
|
# Always read the REAL schedule state (no setup-gated "ok" fallback that
|
|
2331
2366
|
# lied). The re-arm WARNING still only fires once setup is complete, so we
|
|
2332
2367
|
# never nag the user mid-onboarding — only the value is now always honest.
|
|
2333
2368
|
schedule_state = self._schedule_state()
|
|
2334
2369
|
self._schedule_state_cache = schedule_state
|
|
2335
|
-
|
|
2336
|
-
# scheduler stopped launching it: the Desktop warm-session wedge,
|
|
2337
|
-
# Karol 2026-07-06) needs attention just like missing/disabled.
|
|
2338
|
-
# schedule_state.py is account-scoped (2026-07-08): an account switch
|
|
2339
|
-
# now correctly resolves to 'missing' (the active account genuinely
|
|
2340
|
-
# has no registration), not 'stalled' — a stale OTHER account's
|
|
2341
|
-
# registry is never consulted for the active account's health, so
|
|
2342
|
-
# 'stalled' can no longer be misdiagnosed here.
|
|
2343
|
-
attention = setup_complete and schedule_state in ("missing", "disabled", "stalled")
|
|
2370
|
+
attention = setup_complete and schedule_state in ("missing", "disabled")
|
|
2344
2371
|
# Routines-lane rate limit (429): the draft tasks ARE registered and firing
|
|
2345
2372
|
# for this account, but every run dies on a Claude rate limit, so nothing
|
|
2346
2373
|
# drafts. Re-arm can't fix that — surface it as its own ⚠ attention state
|
|
2347
|
-
# with a "rate-limited" reason.
|
|
2348
|
-
# ('ok'); the missing/disabled case
|
|
2349
|
-
#
|
|
2350
|
-
|
|
2374
|
+
# with a "rate-limited" reason. Meaningful whenever the schedule exists
|
|
2375
|
+
# ('ok' or the degraded-but-firing 'stalled'); the missing/disabled case
|
|
2376
|
+
# already owns the ⚠. Throttled (~30s): scanning the worker-transcript
|
|
2377
|
+
# bucket is glob-heavy and changes slowly.
|
|
2378
|
+
if setup_complete and schedule_state in ("ok", "stalled"):
|
|
2351
2379
|
now_rl = time.time()
|
|
2352
2380
|
if now_rl - getattr(self, "_rl_checked_at", 0.0) >= 30:
|
|
2353
2381
|
self._rl_checked_at = now_rl
|
|
@@ -2371,12 +2399,10 @@ class S4LMenuBar(rumps.App):
|
|
|
2371
2399
|
# draft_stuck shadowed the missing/disabled branch in _build_menu — the user
|
|
2372
2400
|
# saw "worker keeps getting killed" with NO Re-arm button instead of "Draft
|
|
2373
2401
|
# tasks aren't scheduled on this account" + the one-click fix (Karol,
|
|
2374
|
-
# 2026-07-06). "stalled" is deliberately included (2026-07-08): that
|
|
2375
|
-
#
|
|
2376
|
-
#
|
|
2377
|
-
#
|
|
2378
|
-
# from the queue itself than the host's lastRunAt staleness, so it's
|
|
2379
|
-
# worth surfacing as its own reason even under "stalled".
|
|
2402
|
+
# 2026-07-06). "stalled" is deliberately included (2026-07-08): a job that
|
|
2403
|
+
# has sat this long — claimed-and-hung, OR never claimed at all (see the ⧖
|
|
2404
|
+
# prefix check below) — is direct evidence from the queue itself, exactly
|
|
2405
|
+
# the layer the ⚠ keys off now that tick staleness alone is diagnostic.
|
|
2380
2406
|
if (
|
|
2381
2407
|
setup_complete
|
|
2382
2408
|
and schedule_state in ("ok", "stalled")
|
|
@@ -2390,6 +2416,44 @@ class S4LMenuBar(rumps.App):
|
|
|
2390
2416
|
):
|
|
2391
2417
|
attention = True
|
|
2392
2418
|
self._stall_reason_info = ("draft_stuck", _act.get("label") or "")
|
|
2419
|
+
# Queue-latency stall (the layer that measures actual user harm): a draft
|
|
2420
|
+
# job sat unclaimed past AUTOPILOT_STALL_SECONDS, wedged in running/ past
|
|
2421
|
+
# AUTOPILOT_RUNNING_STALL_SECONDS, or the producer's drain latch tripped
|
|
2422
|
+
# (consecutive timeouts, zeroed on every successful consume). The activity-
|
|
2423
|
+
# label draft_stuck check above needs a live producer narrating "drafting";
|
|
2424
|
+
# this one works between cycles too (the latch persists the episode), so
|
|
2425
|
+
# together they cover producer-alive and producer-gone stalls. Reason
|
|
2426
|
+
# refines the one-click fix: 'orphaned' (no routine executed recently,
|
|
2427
|
+
# re-arm helps) vs 'failing' (routines run but drafts die, Diagnose).
|
|
2428
|
+
if (
|
|
2429
|
+
setup_complete
|
|
2430
|
+
and schedule_state in ("ok", "stalled")
|
|
2431
|
+
and self._stall_reason_info[0] not in ("rate_limited", "draft_stuck")
|
|
2432
|
+
):
|
|
2433
|
+
if self._autopilot_stalled():
|
|
2434
|
+
_qreason, _qmsg = self._stall_reason()
|
|
2435
|
+
attention = True
|
|
2436
|
+
self._stall_reason_info = (_qreason, _qmsg)
|
|
2437
|
+
elif self._stall_reason_info[0] in ("orphaned", "failing"):
|
|
2438
|
+
# Queue recovered since the last tick: clear the stale reason
|
|
2439
|
+
# now instead of waiting for the 30s rate-limit rescan to
|
|
2440
|
+
# overwrite it (a lingering reason skews the diagnose prompt
|
|
2441
|
+
# and churns the menu signature for nothing).
|
|
2442
|
+
self._stall_reason_info = ("", "")
|
|
2443
|
+
# Tick-freshness diagnostics for the non-alarm "scheduler degraded" menu
|
|
2444
|
+
# line. Only computed while 'stalled' (it reads + parses the registry
|
|
2445
|
+
# JSON, which can be hundreds of KB) and throttled to once a minute.
|
|
2446
|
+
if schedule_state == "stalled":
|
|
2447
|
+
_now_ts = time.time()
|
|
2448
|
+
if _now_ts - getattr(self, "_tick_stats_at", 0.0) >= 60:
|
|
2449
|
+
self._tick_stats_at = _now_ts
|
|
2450
|
+
try:
|
|
2451
|
+
import schedule_state as _ss
|
|
2452
|
+
self._tick_stats = _ss.tick_stats()
|
|
2453
|
+
except Exception:
|
|
2454
|
+
self._tick_stats = None
|
|
2455
|
+
else:
|
|
2456
|
+
self._tick_stats = None
|
|
2393
2457
|
# Drop the stale "drafting" spinner while we need attention so the ⚠ shows.
|
|
2394
2458
|
self._stalled = attention
|
|
2395
2459
|
|
|
@@ -2415,7 +2479,7 @@ class S4LMenuBar(rumps.App):
|
|
|
2415
2479
|
# Once per episode (gated by _stall_notified), so it never spams.
|
|
2416
2480
|
_reason = (
|
|
2417
2481
|
self._stall_reason_info[0]
|
|
2418
|
-
or (
|
|
2482
|
+
or ("disabled" if schedule_state == "disabled" else "missing")
|
|
2419
2483
|
)
|
|
2420
2484
|
_capture_msg(
|
|
2421
2485
|
f"S4L draft autopilot needs attention: {_reason}",
|
|
@@ -2447,19 +2511,26 @@ class S4LMenuBar(rumps.App):
|
|
|
2447
2511
|
"A worker claimed a draft job and never finished it. ")
|
|
2448
2512
|
+ "Open the S4L menu → “Diagnose & fix in Claude…”.",
|
|
2449
2513
|
)
|
|
2514
|
+
elif self._stall_reason_info[0] == "orphaned":
|
|
2515
|
+
self._notify(
|
|
2516
|
+
"S4L drafts not draining",
|
|
2517
|
+
"Draft jobs are stuck in the queue and no routine has picked "
|
|
2518
|
+
"them up. Open the S4L menu, then click "
|
|
2519
|
+
"“Set up draft schedule”.",
|
|
2520
|
+
)
|
|
2521
|
+
elif self._stall_reason_info[0] == "failing":
|
|
2522
|
+
self._notify(
|
|
2523
|
+
"S4L drafts not draining",
|
|
2524
|
+
"Draft jobs are stuck in the queue; runs start but never "
|
|
2525
|
+
"finish. Open the S4L menu, then click "
|
|
2526
|
+
"“Diagnose & fix in Claude…”.",
|
|
2527
|
+
)
|
|
2450
2528
|
elif schedule_state == "disabled":
|
|
2451
2529
|
self._notify(
|
|
2452
2530
|
"S4L draft tasks disabled",
|
|
2453
2531
|
"The draft tasks are scheduled but disabled. Open the S4L menu → "
|
|
2454
2532
|
"“Set up draft schedule” to re-enable.",
|
|
2455
2533
|
)
|
|
2456
|
-
elif schedule_state == "stalled":
|
|
2457
|
-
self._notify(
|
|
2458
|
-
"S4L drafts stopped",
|
|
2459
|
-
"Claude’s scheduler stopped running the draft tasks (a known "
|
|
2460
|
-
"Claude Desktop glitch). Open the S4L menu → “Set up draft "
|
|
2461
|
-
"schedule” to re-register it.",
|
|
2462
|
-
)
|
|
2463
2534
|
else:
|
|
2464
2535
|
can_selfheal = False
|
|
2465
2536
|
try:
|
|
@@ -2527,8 +2598,20 @@ class S4LMenuBar(rumps.App):
|
|
|
2527
2598
|
attention,
|
|
2528
2599
|
schedule_state,
|
|
2529
2600
|
self._stall_reason_info,
|
|
2601
|
+
# Degraded-scheduler skip count, bucketed by 5 so the diagnostic
|
|
2602
|
+
# line refreshes every few minutes at most (not every poll — an
|
|
2603
|
+
# open menu shouldn't be torn down under the cursor for a +1).
|
|
2604
|
+
((self._tick_stats or {}).get("skips_in_window", 0) // 5)
|
|
2605
|
+
if self._tick_stats
|
|
2606
|
+
else None,
|
|
2530
2607
|
os.path.exists(PAUSE_FLAG),
|
|
2531
2608
|
pending_count,
|
|
2609
|
+
# Snooze end (0 = not snoozed): rebuilds the menu when a snooze is
|
|
2610
|
+
# set, cleared, or lapses, so the "Snoozed until HH:MM" label and
|
|
2611
|
+
# the review item stay honest.
|
|
2612
|
+
int(self._review_snooze_until)
|
|
2613
|
+
if time.time() < self._review_snooze_until
|
|
2614
|
+
else 0,
|
|
2532
2615
|
)
|
|
2533
2616
|
if sig != self._sig:
|
|
2534
2617
|
self._sig = sig
|
|
@@ -2602,7 +2685,51 @@ class S4LMenuBar(rumps.App):
|
|
|
2602
2685
|
except Exception:
|
|
2603
2686
|
return None, []
|
|
2604
2687
|
|
|
2605
|
-
def
|
|
2688
|
+
def _read_snooze_until(self):
|
|
2689
|
+
try:
|
|
2690
|
+
with open(SNOOZE_FILE) as f:
|
|
2691
|
+
return float((json.load(f) or {}).get("until") or 0.0)
|
|
2692
|
+
except Exception:
|
|
2693
|
+
return 0.0
|
|
2694
|
+
|
|
2695
|
+
def _set_snooze(self, until, pending=0):
|
|
2696
|
+
"""Set (or clear, until<=now) the review snooze, mirrored to disk."""
|
|
2697
|
+
self._review_snooze_until = until
|
|
2698
|
+
try:
|
|
2699
|
+
if until <= time.time():
|
|
2700
|
+
try:
|
|
2701
|
+
os.remove(SNOOZE_FILE)
|
|
2702
|
+
except FileNotFoundError:
|
|
2703
|
+
pass
|
|
2704
|
+
else:
|
|
2705
|
+
with open(SNOOZE_FILE, "w") as f:
|
|
2706
|
+
json.dump(
|
|
2707
|
+
{"until": until, "set_at": time.time(), "pending": pending},
|
|
2708
|
+
f,
|
|
2709
|
+
)
|
|
2710
|
+
except Exception:
|
|
2711
|
+
pass
|
|
2712
|
+
|
|
2713
|
+
def _review_now(self, _=None):
|
|
2714
|
+
"""Menu: bring the pending draft cards up right now. Clears any snooze;
|
|
2715
|
+
this is the one presentation path that may take keyboard focus (the
|
|
2716
|
+
user explicitly asked for the cards)."""
|
|
2717
|
+
self._set_snooze(0.0)
|
|
2718
|
+
try:
|
|
2719
|
+
import s4l_card
|
|
2720
|
+
|
|
2721
|
+
if s4l_card.active_status():
|
|
2722
|
+
# A card is already on screen (user clicked while one is up,
|
|
2723
|
+
# perhaps parked on another display): bring it to them instead.
|
|
2724
|
+
s4l_card.heal_active()
|
|
2725
|
+
s4l_card.focus_active()
|
|
2726
|
+
return
|
|
2727
|
+
except Exception:
|
|
2728
|
+
pass
|
|
2729
|
+
self._last_review_sig = None
|
|
2730
|
+
self._maybe_start_review(focus=True)
|
|
2731
|
+
|
|
2732
|
+
def _maybe_start_review(self, focus=False):
|
|
2606
2733
|
req = st.read_review_request()
|
|
2607
2734
|
if not req:
|
|
2608
2735
|
return
|
|
@@ -2618,6 +2745,12 @@ class S4LMenuBar(rumps.App):
|
|
|
2618
2745
|
self._last_review_sig = None
|
|
2619
2746
|
st.clear_review_request()
|
|
2620
2747
|
return
|
|
2748
|
+
# Snoozed (the user closed the card with drafts still undecided): leave
|
|
2749
|
+
# the backlog in the store untouched and present nothing until the
|
|
2750
|
+
# snooze lapses. New drafts keep accumulating silently; the menu's
|
|
2751
|
+
# "Review N pending drafts" clears this early.
|
|
2752
|
+
if time.time() < self._review_snooze_until:
|
|
2753
|
+
return
|
|
2621
2754
|
# De-dup on the CONTENT of the pending set (each draft's plan index + reply
|
|
2622
2755
|
# text), not the constant batch_id. This means: re-present whenever NEW
|
|
2623
2756
|
# drafts arrive (the signature changes), but don't re-pop the identical
|
|
@@ -2632,6 +2765,13 @@ class S4LMenuBar(rumps.App):
|
|
|
2632
2765
|
# live. This is the fix for the "card froze at 1 of 4 while 137 piled
|
|
2633
2766
|
# up" bug — drafts that arrived after the card opened used to be
|
|
2634
2767
|
# stranded because this method returned early on _review_active.
|
|
2768
|
+
# Also prune the other direction: any `n` this same stack used to
|
|
2769
|
+
# carry but that dropped out of the fresh `drafts` list (merge_review_
|
|
2770
|
+
# queue.py's backend sync just marked it terminal, most commonly the
|
|
2771
|
+
# freshness gate expiring it) is removed from the not-yet-reached part
|
|
2772
|
+
# of the stack, so an old card can't sit there waiting to be approved
|
|
2773
|
+
# into a silent no-op (see the 2026-07-09 "approved 3, nothing
|
|
2774
|
+
# posted" investigation).
|
|
2635
2775
|
# - Posting is DRAINING with no panel up (_review_active but not
|
|
2636
2776
|
# _panel_open): leave the signature untouched so the full pending set
|
|
2637
2777
|
# is presented fresh once the drain completes (don't pop a card mid-post).
|
|
@@ -2640,6 +2780,11 @@ class S4LMenuBar(rumps.App):
|
|
|
2640
2780
|
try:
|
|
2641
2781
|
import s4l_card
|
|
2642
2782
|
|
|
2783
|
+
prev_ns = {n for n, _ in (self._last_review_sig or ())}
|
|
2784
|
+
cur_ns = {d.get("n") for d in drafts}
|
|
2785
|
+
vanished = prev_ns - cur_ns
|
|
2786
|
+
if vanished:
|
|
2787
|
+
s4l_card.prune_active(vanished)
|
|
2643
2788
|
s4l_card.extend_active(drafts)
|
|
2644
2789
|
except Exception as e:
|
|
2645
2790
|
sys.stderr.write(f"[s4l-menubar] extend cards failed: {e}\n")
|
|
@@ -2661,6 +2806,7 @@ class S4LMenuBar(rumps.App):
|
|
|
2661
2806
|
drafts,
|
|
2662
2807
|
on_decision=lambda d: self._on_card_decision(batch, d),
|
|
2663
2808
|
on_complete=lambda decisions: self._on_review_closed(batch, decisions),
|
|
2809
|
+
focus=focus,
|
|
2664
2810
|
)
|
|
2665
2811
|
# Record as shown only AFTER the cards are actually up, so a transient
|
|
2666
2812
|
# card-UI failure never permanently suppresses this pending set.
|
|
@@ -2886,6 +3032,20 @@ class S4LMenuBar(rumps.App):
|
|
|
2886
3032
|
remaining = 0
|
|
2887
3033
|
if remaining <= 0:
|
|
2888
3034
|
st.clear_review_request()
|
|
3035
|
+
else:
|
|
3036
|
+
# Undecided drafts left = the user closed the card early (cross or
|
|
3037
|
+
# the title-bar Snooze button): park the backlog instead of
|
|
3038
|
+
# re-popping it on the next tick, which made the card impossible
|
|
3039
|
+
# to put away (2026-07-09 customer feedback). The signature drop
|
|
3040
|
+
# below still runs, so after the snooze lapses the leftover
|
|
3041
|
+
# presents fresh rather than being suppressed as "already shown".
|
|
3042
|
+
until = time.time() + REVIEW_SNOOZE_SECONDS
|
|
3043
|
+
self._set_snooze(until, remaining)
|
|
3044
|
+
sys.stderr.write(
|
|
3045
|
+
f"[s4l-menubar] review snoozed: {remaining} pending until "
|
|
3046
|
+
f"{time.strftime('%H:%M', time.localtime(until))}\n"
|
|
3047
|
+
)
|
|
3048
|
+
sys.stderr.flush()
|
|
2889
3049
|
# Drop the dedup signature so whatever is left is presented fresh (not
|
|
2890
3050
|
# suppressed as "already shown") once posting finishes draining.
|
|
2891
3051
|
self._last_review_sig = None
|
|
@@ -2895,7 +3055,15 @@ class S4LMenuBar(rumps.App):
|
|
|
2895
3055
|
st.flush_review_events_async()
|
|
2896
3056
|
except Exception:
|
|
2897
3057
|
pass
|
|
2898
|
-
if
|
|
3058
|
+
if remaining > 0:
|
|
3059
|
+
plural = "s" if remaining != 1 else ""
|
|
3060
|
+
self._notify(
|
|
3061
|
+
"S4L",
|
|
3062
|
+
f"Snoozed {remaining} pending draft{plural} until "
|
|
3063
|
+
f"{time.strftime('%H:%M', time.localtime(self._review_snooze_until))}. "
|
|
3064
|
+
"Review sooner from the S4L menu.",
|
|
3065
|
+
)
|
|
3066
|
+
elif not any(d.get("approved") for d in decisions):
|
|
2899
3067
|
self._notify("S4L", "No drafts approved — nothing posted.")
|
|
2900
3068
|
|
|
2901
3069
|
def _discard_all_pending(self, _=None):
|
|
@@ -3123,26 +3291,21 @@ class S4LMenuBar(rumps.App):
|
|
|
3123
3291
|
" " + (self._stall_reason_info[1] or "drafting") + " — no result yet"
|
|
3124
3292
|
))
|
|
3125
3293
|
items.append(rumps.MenuItem("Diagnose & fix in Claude…", callback=self._diagnose_fix))
|
|
3294
|
+
elif self._stall_reason_info[0] == "orphaned":
|
|
3295
|
+
# Queue-latency stall, no routine executing (no recent worker
|
|
3296
|
+
# transcript): draft jobs sit unclaimed. Re-arm re-registers the
|
|
3297
|
+
# schedule through the real create_scheduled_task path, the one
|
|
3298
|
+
# mechanical fix that addresses "nothing is picking jobs up".
|
|
3299
|
+
items.append(self._label("⚠ Draft jobs stuck, no routine picking them up"))
|
|
3300
|
+
items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
|
|
3301
|
+
elif self._stall_reason_info[0] == "failing":
|
|
3302
|
+
# Queue-latency stall but routines DO execute: runs start and
|
|
3303
|
+
# die before draining. Re-arm can't fix that; Diagnose can.
|
|
3304
|
+
items.append(self._label("⚠ Draft jobs stuck, runs start but don't finish"))
|
|
3305
|
+
items.append(rumps.MenuItem("Diagnose & fix in Claude…", callback=self._diagnose_fix))
|
|
3126
3306
|
elif schedule_state == "disabled":
|
|
3127
3307
|
items.append(self._label("⚠ Draft tasks are scheduled but disabled"))
|
|
3128
3308
|
items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
|
|
3129
|
-
elif schedule_state == "stalled":
|
|
3130
|
-
# Task registered + enabled FOR THE ACTIVE ACCOUNT (schedule_state.py
|
|
3131
|
-
# is account-scoped, 2026-07-08 — see its module docstring) but the
|
|
3132
|
-
# host stopped launching it: the Claude Desktop warm-session wedge
|
|
3133
|
-
# (finished worker sessions never exit; the overlap guard skips
|
|
3134
|
-
# every fire). Re-arm goes through the same real create_scheduled_task
|
|
3135
|
-
# path as onboarding — a verified fix the user has seen work —
|
|
3136
|
-
# rather than a silent Claude Desktop quit/relaunch with no visible
|
|
3137
|
-
# feedback, which field evidence (2026-07-08) showed doesn't
|
|
3138
|
-
# reliably clear the underlying stall (that field incident turned
|
|
3139
|
-
# out to be a DIFFERENT bug: schedule_state.py was reading a stale,
|
|
3140
|
-
# no-longer-active account's registry — now fixed, so "stalled"
|
|
3141
|
-
# here means what it says: same account, task present, just not
|
|
3142
|
-
# firing). Distinct label from the "aren't scheduled" case below
|
|
3143
|
-
# since the task DOES exist here; same one-click remedy either way.
|
|
3144
|
-
items.append(self._label("⚠ Drafts stopped — Claude’s scheduler is stuck"))
|
|
3145
|
-
items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
|
|
3146
3309
|
else:
|
|
3147
3310
|
items.append(self._label("⚠ Draft tasks aren’t scheduled on this account"))
|
|
3148
3311
|
# Prefer the automatic fix (2026-07-08): if the active account
|
|
@@ -3172,6 +3335,29 @@ class S4LMenuBar(rumps.App):
|
|
|
3172
3335
|
else:
|
|
3173
3336
|
items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
|
|
3174
3337
|
items.append(rumps.separator)
|
|
3338
|
+
elif setup_complete and schedule_state == "stalled":
|
|
3339
|
+
# NON-ALARM diagnostic (2026-07-09): the task is registered + enabled
|
|
3340
|
+
# for the active account but the host's per-minute tick is stale or
|
|
3341
|
+
# mostly skipped (Desktop warm-session wedge). The queue checks above
|
|
3342
|
+
# would have flipped ⚠ if jobs were actually stuck, so reaching here
|
|
3343
|
+
# means drafts still drain; say so with numbers instead of alarming.
|
|
3344
|
+
# tick_stats is cached by _tick (refreshed ≤ once/min, None outside
|
|
3345
|
+
# 'stalled').
|
|
3346
|
+
_ts = self._tick_stats or {}
|
|
3347
|
+
_skips = _ts.get("skips_in_window")
|
|
3348
|
+
_age = _ts.get("last_run_age_s")
|
|
3349
|
+
_bits = []
|
|
3350
|
+
if _skips is not None:
|
|
3351
|
+
_bits.append(f"{_skips} of ~60 ticks skipped last hour")
|
|
3352
|
+
if _age is not None:
|
|
3353
|
+
_bits.append(f"last run {max(0, int(_age)) // 60}m ago")
|
|
3354
|
+
items.append(self._label("Scheduler degraded (drafts still running)"))
|
|
3355
|
+
if _bits:
|
|
3356
|
+
items.append(self._label(" " + ", ".join(_bits)))
|
|
3357
|
+
# Keep the one-click remedy reachable without dressing it as urgent:
|
|
3358
|
+
# re-registering through create_scheduled_task un-wedges the host.
|
|
3359
|
+
items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
|
|
3360
|
+
items.append(rumps.separator)
|
|
3175
3361
|
|
|
3176
3362
|
if not runtime_ready:
|
|
3177
3363
|
items += self._state_a()
|
|
@@ -3329,12 +3515,29 @@ class S4LMenuBar(rumps.App):
|
|
|
3329
3515
|
def _state_c(self, snap, pending_count=0):
|
|
3330
3516
|
if pending_count <= 0:
|
|
3331
3517
|
return []
|
|
3332
|
-
|
|
3518
|
+
plural = "s" if pending_count != 1 else ""
|
|
3519
|
+
items = [
|
|
3333
3520
|
rumps.MenuItem(
|
|
3334
|
-
f"
|
|
3335
|
-
callback=self.
|
|
3521
|
+
f"Review {pending_count} pending draft{plural}",
|
|
3522
|
+
callback=self._review_now,
|
|
3336
3523
|
)
|
|
3337
3524
|
]
|
|
3525
|
+
if time.time() < self._review_snooze_until:
|
|
3526
|
+
items.append(
|
|
3527
|
+
self._label(
|
|
3528
|
+
"Snoozed until "
|
|
3529
|
+
+ time.strftime(
|
|
3530
|
+
"%H:%M", time.localtime(self._review_snooze_until)
|
|
3531
|
+
)
|
|
3532
|
+
)
|
|
3533
|
+
)
|
|
3534
|
+
items.append(
|
|
3535
|
+
rumps.MenuItem(
|
|
3536
|
+
f"Discard {pending_count} pending draft{plural}…",
|
|
3537
|
+
callback=self._discard_all_pending,
|
|
3538
|
+
)
|
|
3539
|
+
)
|
|
3540
|
+
return items
|
|
3338
3541
|
|
|
3339
3542
|
|
|
3340
3543
|
if __name__ == "__main__":
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.2
|
|
3
|
+
"version": "1.7.2",
|
|
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": "social-autoposter",
|
|
3
|
-
"version": "1.7.2
|
|
3
|
+
"version": "1.7.2",
|
|
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",
|
|
@@ -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
|
|
|
@@ -129,16 +129,47 @@ STATS_KEYS = (
|
|
|
129
129
|
)
|
|
130
130
|
|
|
131
131
|
|
|
132
|
-
def
|
|
133
|
-
"""
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
132
|
+
def _sync_with_backend(cands: list) -> tuple[int, int]:
|
|
133
|
+
"""One bulk /api/v1/twitter-candidates lookup for every still-open candidate
|
|
134
|
+
(not posted, not terminal), used for two things:
|
|
135
|
+
|
|
136
|
+
- stamp the discovery-time `stats` sidecar the card renders (candidates
|
|
137
|
+
that already have one are left alone), same as the old
|
|
138
|
+
_enrich_with_stats this replaces.
|
|
139
|
+
- notice when the backend has ALREADY retired a candidate this plan
|
|
140
|
+
still thinks is 'pending' (most commonly the Phase 0 freshness gate
|
|
141
|
+
flipping status='expired' after FRESHNESS_HOURS — see
|
|
142
|
+
skill/run-twitter-cycle.sh) and mark it terminal here too, same as a
|
|
143
|
+
human "discard all pending" would. Without this, a card can sit in
|
|
144
|
+
the review queue as an approvable draft long after the backend has
|
|
145
|
+
moved on; approving it later silently no-ops (post_drafts returns
|
|
146
|
+
posted:0, no browser ever launches, no post-*.log — see the
|
|
147
|
+
2026-07-09 "approved 3 cards, nothing posted" investigation).
|
|
148
|
+
|
|
149
|
+
Runs on EVERY merge (every cycle), not just once per candidate, so status
|
|
150
|
+
drift after the initial stamp is still caught while a card is still
|
|
151
|
+
pending. Best-effort: any API failure leaves every candidate untouched
|
|
152
|
+
(fail open, same as before). Returns (stamped_count, pruned_count).
|
|
153
|
+
|
|
154
|
+
APPROVED cards are exempt from the prune: an approval is a settled human
|
|
155
|
+
decision awaiting its serialized post, and stamping terminal on it makes
|
|
156
|
+
post_drafts refuse it as already-decided (posted:0). That exact race ate
|
|
157
|
+
2 of 3 approvals on 2026-07-10 (approved 04:30:02Z, merge stamped
|
|
158
|
+
backend_status_expired 04:32:33Z, poster refused both). The freshness
|
|
159
|
+
gate exists to stop stale UNDECIDED drafts from burning review attention;
|
|
160
|
+
once a human has said "post it", the only honest gate left is the
|
|
161
|
+
poster's own at-post-time tweet_unavailable check."""
|
|
162
|
+
pending = [
|
|
163
|
+
c
|
|
164
|
+
for c in cands
|
|
165
|
+
if not c.get("posted")
|
|
166
|
+
and not c.get("terminal")
|
|
167
|
+
and not c.get("approved")
|
|
168
|
+
and _thread_url(c)
|
|
169
|
+
]
|
|
170
|
+
if not pending:
|
|
171
|
+
return 0, 0
|
|
172
|
+
urls = sorted({_thread_url(c) for c in pending})[:500]
|
|
142
173
|
try:
|
|
143
174
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
144
175
|
from http_api import api_get
|
|
@@ -149,17 +180,24 @@ def _enrich_with_stats(cands: list) -> int:
|
|
|
149
180
|
)
|
|
150
181
|
rows = (resp.get("data") or {}).get("candidates") or []
|
|
151
182
|
except BaseException as e: # http_api raises SystemExit on terminal failure
|
|
152
|
-
print(f"[merge_review_queue]
|
|
153
|
-
return 0
|
|
183
|
+
print(f"[merge_review_queue] backend sync skipped: {e}", file=sys.stderr)
|
|
184
|
+
return 0, 0
|
|
154
185
|
by_url = {str(r.get("tweet_url")): r for r in rows if r.get("tweet_url")}
|
|
155
186
|
stamped = 0
|
|
156
|
-
|
|
187
|
+
pruned = 0
|
|
188
|
+
for c in pending:
|
|
157
189
|
row = by_url.get(_thread_url(c))
|
|
158
190
|
if not row:
|
|
159
191
|
continue
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
192
|
+
if not c.get("stats"):
|
|
193
|
+
c["stats"] = {k: row.get(k) for k in STATS_KEYS}
|
|
194
|
+
stamped += 1
|
|
195
|
+
status = row.get("status")
|
|
196
|
+
if status and status != "pending":
|
|
197
|
+
c["terminal"] = True
|
|
198
|
+
c["discard_reason"] = f"backend_status_{status}"
|
|
199
|
+
pruned += 1
|
|
200
|
+
return stamped, pruned
|
|
163
201
|
|
|
164
202
|
|
|
165
203
|
def main() -> int:
|
|
@@ -255,9 +293,15 @@ def main() -> int:
|
|
|
255
293
|
merged.append(c)
|
|
256
294
|
added += 1
|
|
257
295
|
|
|
258
|
-
stamped =
|
|
296
|
+
stamped, pruned = _sync_with_backend(merged)
|
|
259
297
|
if stamped:
|
|
260
298
|
print(f"[merge_review_queue] stamped stats on {stamped} candidate(s)", file=sys.stderr)
|
|
299
|
+
if pruned:
|
|
300
|
+
print(
|
|
301
|
+
f"[merge_review_queue] pruned {pruned} candidate(s) already retired by the "
|
|
302
|
+
"backend (expired/etc.) before they were reviewed",
|
|
303
|
+
file=sys.stderr,
|
|
304
|
+
)
|
|
261
305
|
|
|
262
306
|
plan_obj = {"candidates": merged}
|
|
263
307
|
if plan_created_at:
|
|
@@ -274,15 +318,17 @@ def main() -> int:
|
|
|
274
318
|
_atomic_write(dst, plan_obj)
|
|
275
319
|
ensure_store_symlink()
|
|
276
320
|
|
|
277
|
-
# Refresh the review-request marker the menu bar polls (count = pending,
|
|
278
|
-
|
|
321
|
+
# Refresh the review-request marker the menu bar polls (count = pending,
|
|
322
|
+
# not posted, not terminal -- a just-pruned expired card must not still
|
|
323
|
+
# inflate the badge).
|
|
324
|
+
pending_count = len([c for c in merged if not c.get("posted") and not c.get("terminal")])
|
|
279
325
|
project = ns.project or batch.get("project") or (new_cands[0].get("matched_project") if new_cands else None)
|
|
280
326
|
_atomic_write(
|
|
281
327
|
review_request_path(),
|
|
282
328
|
{
|
|
283
329
|
"batch_id": REVIEW_QUEUE_ID,
|
|
284
330
|
"project": project,
|
|
285
|
-
"count":
|
|
331
|
+
"count": pending_count,
|
|
286
332
|
"plan_path": dst,
|
|
287
333
|
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
288
334
|
},
|
|
@@ -306,7 +352,7 @@ def main() -> int:
|
|
|
306
352
|
|
|
307
353
|
print(
|
|
308
354
|
f"[merge_review_queue] merged {added} new draft(s) into {REVIEW_QUEUE_ID} "
|
|
309
|
-
f"({
|
|
355
|
+
f"({pending_count} pending total) from {os.path.basename(src)}",
|
|
310
356
|
file=sys.stderr,
|
|
311
357
|
)
|
|
312
358
|
# Clean up the consumed batch plan so /tmp doesn't fill with orphans.
|
|
@@ -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:
|