@m13v/s4l 1.7.2-rc.14 → 1.7.2-rc.18
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 +152 -18
- package/mcp/menubar/s4l_menubar.py +252 -61
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/merge_review_queue.py +18 -2
- 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-rc.
|
|
5
|
+
"version": "1.7.2-rc.18",
|
|
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,6 +179,20 @@ REJECT_REASONS = (
|
|
|
149
179
|
# Client-side cap on tracked interactions per card (server clips at 50 too).
|
|
150
180
|
MAX_INTERACTIONS = 50
|
|
151
181
|
|
|
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
|
+
|
|
152
196
|
# Inline approve row: glyph + tooltip per approval level (button tag =
|
|
153
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
|
|
@@ -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
|
|
@@ -1240,9 +1347,15 @@ class _ReviewController(NSObject):
|
|
|
1240
1347
|
)
|
|
1241
1348
|
# setContentView_ rebuilds the view tree, so the caret would otherwise
|
|
1242
1349
|
# default to the Approve button. Re-seat it in the reply field for every
|
|
1243
|
-
# card
|
|
1244
|
-
|
|
1245
|
-
|
|
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)
|
|
1246
1359
|
|
|
1247
1360
|
@objc.python_method
|
|
1248
1361
|
def _close_stats_popover(self):
|
|
@@ -1793,7 +1906,10 @@ class _ReviewController(NSObject):
|
|
|
1793
1906
|
|
|
1794
1907
|
def windowShouldClose_(self, sender):
|
|
1795
1908
|
# Closing the window stops review; remaining cards are left undecided
|
|
1796
|
-
# (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.
|
|
1797
1913
|
self._finish()
|
|
1798
1914
|
return True
|
|
1799
1915
|
|
|
@@ -1815,11 +1931,12 @@ class _ReviewController(NSObject):
|
|
|
1815
1931
|
except Exception:
|
|
1816
1932
|
pass
|
|
1817
1933
|
_active = None
|
|
1818
|
-
|
|
1819
|
-
|
|
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")
|
|
1820
1937
|
|
|
1821
1938
|
|
|
1822
|
-
def present_review(drafts, on_decision=None, on_complete=None):
|
|
1939
|
+
def present_review(drafts, on_decision=None, on_complete=None, focus=False):
|
|
1823
1940
|
"""Show the review cards (main thread only). drafts: list of
|
|
1824
1941
|
{n, thread_author, thread_text, reply_text, link_url, thread_url?, stats?}
|
|
1825
1942
|
where stats is the discovery-time candidate snapshot
|
|
@@ -1841,14 +1958,17 @@ def present_review(drafts, on_decision=None, on_complete=None):
|
|
|
1841
1958
|
active experiment/scenario arm.
|
|
1842
1959
|
on_decision(decision) fires the instant each card is approved/rejected (so an
|
|
1843
1960
|
approved draft posts right away); on_complete(decisions) fires when the user
|
|
1844
|
-
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."""
|
|
1845
1965
|
global _active
|
|
1846
1966
|
if not drafts:
|
|
1847
1967
|
if on_complete is not None:
|
|
1848
1968
|
on_complete([])
|
|
1849
1969
|
return
|
|
1850
|
-
_active = _ReviewController.alloc().
|
|
1851
|
-
drafts, on_decision, on_complete
|
|
1970
|
+
_active = _ReviewController.alloc().initWithDrafts_onDecision_onComplete_focus_(
|
|
1971
|
+
drafts, on_decision, on_complete, focus
|
|
1852
1972
|
)
|
|
1853
1973
|
|
|
1854
1974
|
|
|
@@ -1961,6 +2081,20 @@ def heal_active():
|
|
|
1961
2081
|
return False
|
|
1962
2082
|
|
|
1963
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
|
+
|
|
1964
2098
|
# ---- overall-feedback composer ----------------------------------------------
|
|
1965
2099
|
# One small floating panel with a free-text field, for guidance that is about
|
|
1966
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
|
|
@@ -2673,6 +2806,7 @@ class S4LMenuBar(rumps.App):
|
|
|
2673
2806
|
drafts,
|
|
2674
2807
|
on_decision=lambda d: self._on_card_decision(batch, d),
|
|
2675
2808
|
on_complete=lambda decisions: self._on_review_closed(batch, decisions),
|
|
2809
|
+
focus=focus,
|
|
2676
2810
|
)
|
|
2677
2811
|
# Record as shown only AFTER the cards are actually up, so a transient
|
|
2678
2812
|
# card-UI failure never permanently suppresses this pending set.
|
|
@@ -2898,6 +3032,20 @@ class S4LMenuBar(rumps.App):
|
|
|
2898
3032
|
remaining = 0
|
|
2899
3033
|
if remaining <= 0:
|
|
2900
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()
|
|
2901
3049
|
# Drop the dedup signature so whatever is left is presented fresh (not
|
|
2902
3050
|
# suppressed as "already shown") once posting finishes draining.
|
|
2903
3051
|
self._last_review_sig = None
|
|
@@ -2907,7 +3055,15 @@ class S4LMenuBar(rumps.App):
|
|
|
2907
3055
|
st.flush_review_events_async()
|
|
2908
3056
|
except Exception:
|
|
2909
3057
|
pass
|
|
2910
|
-
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):
|
|
2911
3067
|
self._notify("S4L", "No drafts approved — nothing posted.")
|
|
2912
3068
|
|
|
2913
3069
|
def _discard_all_pending(self, _=None):
|
|
@@ -3135,26 +3291,21 @@ class S4LMenuBar(rumps.App):
|
|
|
3135
3291
|
" " + (self._stall_reason_info[1] or "drafting") + " — no result yet"
|
|
3136
3292
|
))
|
|
3137
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))
|
|
3138
3306
|
elif schedule_state == "disabled":
|
|
3139
3307
|
items.append(self._label("⚠ Draft tasks are scheduled but disabled"))
|
|
3140
3308
|
items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
|
|
3141
|
-
elif schedule_state == "stalled":
|
|
3142
|
-
# Task registered + enabled FOR THE ACTIVE ACCOUNT (schedule_state.py
|
|
3143
|
-
# is account-scoped, 2026-07-08 — see its module docstring) but the
|
|
3144
|
-
# host stopped launching it: the Claude Desktop warm-session wedge
|
|
3145
|
-
# (finished worker sessions never exit; the overlap guard skips
|
|
3146
|
-
# every fire). Re-arm goes through the same real create_scheduled_task
|
|
3147
|
-
# path as onboarding — a verified fix the user has seen work —
|
|
3148
|
-
# rather than a silent Claude Desktop quit/relaunch with no visible
|
|
3149
|
-
# feedback, which field evidence (2026-07-08) showed doesn't
|
|
3150
|
-
# reliably clear the underlying stall (that field incident turned
|
|
3151
|
-
# out to be a DIFFERENT bug: schedule_state.py was reading a stale,
|
|
3152
|
-
# no-longer-active account's registry — now fixed, so "stalled"
|
|
3153
|
-
# here means what it says: same account, task present, just not
|
|
3154
|
-
# firing). Distinct label from the "aren't scheduled" case below
|
|
3155
|
-
# since the task DOES exist here; same one-click remedy either way.
|
|
3156
|
-
items.append(self._label("⚠ Drafts stopped — Claude’s scheduler is stuck"))
|
|
3157
|
-
items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
|
|
3158
3309
|
else:
|
|
3159
3310
|
items.append(self._label("⚠ Draft tasks aren’t scheduled on this account"))
|
|
3160
3311
|
# Prefer the automatic fix (2026-07-08): if the active account
|
|
@@ -3184,6 +3335,29 @@ class S4LMenuBar(rumps.App):
|
|
|
3184
3335
|
else:
|
|
3185
3336
|
items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
|
|
3186
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)
|
|
3187
3361
|
|
|
3188
3362
|
if not runtime_ready:
|
|
3189
3363
|
items += self._state_a()
|
|
@@ -3341,12 +3515,29 @@ class S4LMenuBar(rumps.App):
|
|
|
3341
3515
|
def _state_c(self, snap, pending_count=0):
|
|
3342
3516
|
if pending_count <= 0:
|
|
3343
3517
|
return []
|
|
3344
|
-
|
|
3518
|
+
plural = "s" if pending_count != 1 else ""
|
|
3519
|
+
items = [
|
|
3345
3520
|
rumps.MenuItem(
|
|
3346
|
-
f"
|
|
3347
|
-
callback=self.
|
|
3521
|
+
f"Review {pending_count} pending draft{plural}",
|
|
3522
|
+
callback=self._review_now,
|
|
3348
3523
|
)
|
|
3349
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
|
|
3350
3541
|
|
|
3351
3542
|
|
|
3352
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-rc.
|
|
3
|
+
"version": "1.7.2-rc.18",
|
|
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
|
@@ -149,8 +149,24 @@ def _sync_with_backend(cands: list) -> tuple[int, int]:
|
|
|
149
149
|
Runs on EVERY merge (every cycle), not just once per candidate, so status
|
|
150
150
|
drift after the initial stamp is still caught while a card is still
|
|
151
151
|
pending. Best-effort: any API failure leaves every candidate untouched
|
|
152
|
-
(fail open, same as before). Returns (stamped_count, pruned_count).
|
|
153
|
-
|
|
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
|
+
]
|
|
154
170
|
if not pending:
|
|
155
171
|
return 0, 0
|
|
156
172
|
urls = sorted({_thread_url(c) for c in pending})[:500]
|
|
@@ -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:
|