@m13v/s4l 1.7.2-rc.9 → 1.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -117,6 +117,19 @@ try:
117
117
  except Exception:
118
118
  pass
119
119
 
120
+ # Ground-truth browser-foreground telemetry: whenever a MANAGED harness Chrome
121
+ # becomes the frontmost app (fresh-launch activation, screencast bringToFront,
122
+ # macOS clamping the off-screen window back on-screen, anything), ship one
123
+ # structured line with context "browser-foreground" through the relay above.
124
+ # Cause-agnostic OS-level observation; the causing sites (screencast.ts) log
125
+ # their own attribution lines under the same context. Best-effort.
126
+ try:
127
+ import s4l_browser_foreground # noqa: E402
128
+
129
+ s4l_browser_foreground.install()
130
+ except Exception:
131
+ pass
132
+
120
133
 
121
134
  def _capture(err, **tags):
122
135
  """Report a handled menu-bar error to Sentry (component=menubar) without ever
@@ -439,6 +452,17 @@ REVIEW_HEAL_EVERY_SECONDS = float(
439
452
  )
440
453
  REVIEW_UNATTENDED_SENTRY_SECONDS = 3600.0
441
454
 
455
+ # Review snooze (2026-07-09 customer feedback: cards interrupt focused work
456
+ # with no way to defer them). Closing the card with undecided drafts, via the
457
+ # cross or the title-bar "Snooze 1h" button, parks the WHOLE pending backlog
458
+ # for REVIEW_SNOOZE_SECONDS: no auto-present, no watchdog heal (nothing is on
459
+ # screen). Drafts stay in the store untouched; the menu's "Review N pending
460
+ # drafts" clears the snooze early. Persisted so a menubar restart mid-snooze
461
+ # doesn't re-pop the card. s4l_card._snooze_secs reads the same env var for
462
+ # the button label; keep them in sync.
463
+ REVIEW_SNOOZE_SECONDS = float(os.environ.get("S4L_REVIEW_SNOOZE_S", "3600"))
464
+ SNOOZE_FILE = os.path.join(st.state_dir(), "review-snooze.json")
465
+
442
466
 
443
467
  def _label_elapsed_secs(label):
444
468
  """Parse the trailing duration the producer encodes in a drafting activity
@@ -519,6 +543,10 @@ class S4LMenuBar(rumps.App):
519
543
  self._post_worker = None
520
544
  self._review_lock = threading.Lock()
521
545
  self._panel_open = False
546
+ # Review snooze: epoch until which draft cards must not auto-present
547
+ # (0 = not snoozed). Loaded from disk so a menubar restart mid-snooze
548
+ # doesn't re-pop the card the user just put away.
549
+ self._review_snooze_until = self._read_snooze_until()
522
550
  # Unattended-review watchdog state (_maybe_heal_review).
523
551
  self._review_heal_at = 0.0
524
552
  self._review_unattended_notified = False
@@ -645,6 +673,11 @@ class S4LMenuBar(rumps.App):
645
673
  # Cached schedule state for the current account: 'missing'/'disabled'/'ok'/
646
674
  # 'unknown'. PRIMARY driver of the menu's attention section.
647
675
  self._schedule_state_cache = "ok"
676
+ # Tick-freshness diagnostics (schedule_state.tick_stats()) shown as the
677
+ # non-alarm "scheduler degraded" menu line while state == 'stalled'.
678
+ # None outside that state; refreshed at most once a minute (_tick).
679
+ self._tick_stats = None
680
+ self._tick_stats_at = 0.0
648
681
  self._reloc_timer = rumps.Timer(self._maybe_relocate_tasks, 90)
649
682
  self._reloc_timer.start()
650
683
  self._tick(None)
@@ -874,11 +907,15 @@ class S4LMenuBar(rumps.App):
874
907
  does NOT stay stale after recovery.
875
908
 
876
909
  NOTE: kept in sync with scripts/autopilot_stall_watch.py (the fleet Sentry
877
- backstop). The menu-bar ⚠ itself is driven by _schedule_state, NOT this
878
- method — the attention/⚠ path keys off schedule_state so a firing-but-
879
- momentarily-empty queue stays green (an earlier drain-latch ⚠ stayed stale
880
- after recovery and was deliberately removed). This method exists for the
881
- watcher-parity contract and _stall_reason.
910
+ backstop). Since 2026-07-09 this method IS an attention/⚠ driver: _tick
911
+ ORs it in (with the reason from _stall_reason) alongside the structural
912
+ schedule states (missing/disabled) and the activity-label draft_stuck
913
+ check. A firing-but-momentarily-empty queue still stays green: an idle
914
+ queue has no pending job, running/ is empty, and the drain latch zeroes
915
+ on every successful consume (claude_job._mark_drain_success), so none of
916
+ the three signals can hold a stale True after recovery. Tick-freshness
917
+ 'stalled' from schedule_state no longer flips the ⚠ at all — it renders
918
+ as a plain diagnostic line (see _build_menu).
882
919
  """
883
920
  qroot = os.path.join(st.state_dir(), "claude-queue")
884
921
  # (1) latched producer drain-status
@@ -2320,34 +2357,38 @@ class S4LMenuBar(rumps.App):
2320
2357
  blocker = (ob or {}).get("current_blocker")
2321
2358
  blocker_code = (blocker or {}).get("code")
2322
2359
  # --- Autopilot health (only meaningful once setup is complete) --------
2323
- # SINGLE signal: is the draft schedule registered AND firing for the live
2324
- # account (schedule_state)? 'ok' = the host is running the tasks -> healthy,
2325
- # NO warning (even if no draft has drained yet — that's just an empty queue
2326
- # between cycles, not a setup problem). 'missing'/'disabled' = not running
2327
- # for this account -> show re-arm. We deliberately do NOT drive the menu off
2328
- # the drain-status latch anymore: it stayed stale after recovery and made a
2329
- # firing, healthy autopilot look "not set up".
2360
+ # TWO layers (2026-07-09 redesign):
2361
+ # STRUCTURAL (schedule_state): 'missing'/'disabled' = the schedule is
2362
+ # genuinely not registered/enabled for this account -> ⚠ + re-arm.
2363
+ # JOB LATENCY (_autopilot_stalled + the draft_stuck label check below):
2364
+ # a draft job sat unclaimed, wedged mid-run, or the producer's drain
2365
+ # latch tripped -> ⚠, because THAT is user-visible harm.
2366
+ # Tick freshness ('stalled': task present + enabled but lastRunAt stale,
2367
+ # the Desktop warm-session wedge) is DIAGNOSTIC ONLY, never the ⚠: under
2368
+ # the wedge the per-minute tick can skip 70-90% of fires while the queue
2369
+ # still drains every job (jobs arrive ~every 8 min vs 60 fires/hr), so
2370
+ # lastRunAt staleness flip-flopped the icon against a healthy pipeline
2371
+ # (observed on the operator box 2026-07-09: 83% ticks skipped, ~14
2372
+ # posts/hr flowing, icon oscillating). _build_menu renders 'stalled' as
2373
+ # a plain "scheduler degraded" detail line with tick_stats() numbers.
2374
+ # The drain latch IS safe to alarm on now: claude_job._mark_drain_success
2375
+ # zeroes it on every successful consume, so it self-clears (the old
2376
+ # stayed-stale-after-recovery latch this comment used to warn about
2377
+ # predates that reset).
2330
2378
  # Always read the REAL schedule state (no setup-gated "ok" fallback that
2331
2379
  # lied). The re-arm WARNING still only fires once setup is complete, so we
2332
2380
  # never nag the user mid-onboarding — only the value is now always honest.
2333
2381
  schedule_state = self._schedule_state()
2334
2382
  self._schedule_state_cache = schedule_state
2335
- # 'stalled' (task present + enabled FOR THE ACTIVE ACCOUNT, host
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")
2383
+ attention = setup_complete and schedule_state in ("missing", "disabled")
2344
2384
  # Routines-lane rate limit (429): the draft tasks ARE registered and firing
2345
2385
  # for this account, but every run dies on a Claude rate limit, so nothing
2346
2386
  # drafts. Re-arm can't fix that — surface it as its own ⚠ attention state
2347
- # with a "rate-limited" reason. Only meaningful when the schedule is firing
2348
- # ('ok'); the missing/disabled case already owns the ⚠. Throttled (~30s):
2349
- # scanning the worker-transcript bucket is glob-heavy and changes slowly.
2350
- if setup_complete and schedule_state == "ok":
2387
+ # with a "rate-limited" reason. Meaningful whenever the schedule exists
2388
+ # ('ok' or the degraded-but-firing 'stalled'); the missing/disabled case
2389
+ # already owns the ⚠. Throttled (~30s): scanning the worker-transcript
2390
+ # bucket is glob-heavy and changes slowly.
2391
+ if setup_complete and schedule_state in ("ok", "stalled"):
2351
2392
  now_rl = time.time()
2352
2393
  if now_rl - getattr(self, "_rl_checked_at", 0.0) >= 30:
2353
2394
  self._rl_checked_at = now_rl
@@ -2371,12 +2412,10 @@ class S4LMenuBar(rumps.App):
2371
2412
  # draft_stuck shadowed the missing/disabled branch in _build_menu — the user
2372
2413
  # saw "worker keeps getting killed" with NO Re-arm button instead of "Draft
2373
2414
  # 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
- # branch's fix (Set up draft schedule / re-arm) works for it too, but a
2376
- # job that has sat this long — claimed-and-hung, OR never claimed at all
2377
- # (see the ⧖ prefix check below) — is more specific, direct evidence
2378
- # from the queue itself than the host's lastRunAt staleness, so it's
2379
- # worth surfacing as its own reason even under "stalled".
2415
+ # 2026-07-06). "stalled" is deliberately included (2026-07-08): a job that
2416
+ # has sat this long — claimed-and-hung, OR never claimed at all (see the ⧖
2417
+ # prefix check below) — is direct evidence from the queue itself, exactly
2418
+ # the layer the ⚠ keys off now that tick staleness alone is diagnostic.
2380
2419
  if (
2381
2420
  setup_complete
2382
2421
  and schedule_state in ("ok", "stalled")
@@ -2390,6 +2429,44 @@ class S4LMenuBar(rumps.App):
2390
2429
  ):
2391
2430
  attention = True
2392
2431
  self._stall_reason_info = ("draft_stuck", _act.get("label") or "")
2432
+ # Queue-latency stall (the layer that measures actual user harm): a draft
2433
+ # job sat unclaimed past AUTOPILOT_STALL_SECONDS, wedged in running/ past
2434
+ # AUTOPILOT_RUNNING_STALL_SECONDS, or the producer's drain latch tripped
2435
+ # (consecutive timeouts, zeroed on every successful consume). The activity-
2436
+ # label draft_stuck check above needs a live producer narrating "drafting";
2437
+ # this one works between cycles too (the latch persists the episode), so
2438
+ # together they cover producer-alive and producer-gone stalls. Reason
2439
+ # refines the one-click fix: 'orphaned' (no routine executed recently,
2440
+ # re-arm helps) vs 'failing' (routines run but drafts die, Diagnose).
2441
+ if (
2442
+ setup_complete
2443
+ and schedule_state in ("ok", "stalled")
2444
+ and self._stall_reason_info[0] not in ("rate_limited", "draft_stuck")
2445
+ ):
2446
+ if self._autopilot_stalled():
2447
+ _qreason, _qmsg = self._stall_reason()
2448
+ attention = True
2449
+ self._stall_reason_info = (_qreason, _qmsg)
2450
+ elif self._stall_reason_info[0] in ("orphaned", "failing"):
2451
+ # Queue recovered since the last tick: clear the stale reason
2452
+ # now instead of waiting for the 30s rate-limit rescan to
2453
+ # overwrite it (a lingering reason skews the diagnose prompt
2454
+ # and churns the menu signature for nothing).
2455
+ self._stall_reason_info = ("", "")
2456
+ # Tick-freshness diagnostics for the non-alarm "scheduler degraded" menu
2457
+ # line. Only computed while 'stalled' (it reads + parses the registry
2458
+ # JSON, which can be hundreds of KB) and throttled to once a minute.
2459
+ if schedule_state == "stalled":
2460
+ _now_ts = time.time()
2461
+ if _now_ts - getattr(self, "_tick_stats_at", 0.0) >= 60:
2462
+ self._tick_stats_at = _now_ts
2463
+ try:
2464
+ import schedule_state as _ss
2465
+ self._tick_stats = _ss.tick_stats()
2466
+ except Exception:
2467
+ self._tick_stats = None
2468
+ else:
2469
+ self._tick_stats = None
2393
2470
  # Drop the stale "drafting" spinner while we need attention so the ⚠ shows.
2394
2471
  self._stalled = attention
2395
2472
 
@@ -2415,7 +2492,7 @@ class S4LMenuBar(rumps.App):
2415
2492
  # Once per episode (gated by _stall_notified), so it never spams.
2416
2493
  _reason = (
2417
2494
  self._stall_reason_info[0]
2418
- or (schedule_state if schedule_state in ("disabled", "stalled") else "missing")
2495
+ or ("disabled" if schedule_state == "disabled" else "missing")
2419
2496
  )
2420
2497
  _capture_msg(
2421
2498
  f"S4L draft autopilot needs attention: {_reason}",
@@ -2447,19 +2524,26 @@ class S4LMenuBar(rumps.App):
2447
2524
  "A worker claimed a draft job and never finished it. ")
2448
2525
  + "Open the S4L menu → “Diagnose & fix in Claude…”.",
2449
2526
  )
2527
+ elif self._stall_reason_info[0] == "orphaned":
2528
+ self._notify(
2529
+ "S4L drafts not draining",
2530
+ "Draft jobs are stuck in the queue and no routine has picked "
2531
+ "them up. Open the S4L menu, then click "
2532
+ "“Set up draft schedule”.",
2533
+ )
2534
+ elif self._stall_reason_info[0] == "failing":
2535
+ self._notify(
2536
+ "S4L drafts not draining",
2537
+ "Draft jobs are stuck in the queue; runs start but never "
2538
+ "finish. Open the S4L menu, then click "
2539
+ "“Diagnose & fix in Claude…”.",
2540
+ )
2450
2541
  elif schedule_state == "disabled":
2451
2542
  self._notify(
2452
2543
  "S4L draft tasks disabled",
2453
2544
  "The draft tasks are scheduled but disabled. Open the S4L menu → "
2454
2545
  "“Set up draft schedule” to re-enable.",
2455
2546
  )
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
2547
  else:
2464
2548
  can_selfheal = False
2465
2549
  try:
@@ -2527,8 +2611,20 @@ class S4LMenuBar(rumps.App):
2527
2611
  attention,
2528
2612
  schedule_state,
2529
2613
  self._stall_reason_info,
2614
+ # Degraded-scheduler skip count, bucketed by 5 so the diagnostic
2615
+ # line refreshes every few minutes at most (not every poll — an
2616
+ # open menu shouldn't be torn down under the cursor for a +1).
2617
+ ((self._tick_stats or {}).get("skips_in_window", 0) // 5)
2618
+ if self._tick_stats
2619
+ else None,
2530
2620
  os.path.exists(PAUSE_FLAG),
2531
2621
  pending_count,
2622
+ # Snooze end (0 = not snoozed): rebuilds the menu when a snooze is
2623
+ # set, cleared, or lapses, so the "Snoozed until HH:MM" label and
2624
+ # the review item stay honest.
2625
+ int(self._review_snooze_until)
2626
+ if time.time() < self._review_snooze_until
2627
+ else 0,
2532
2628
  )
2533
2629
  if sig != self._sig:
2534
2630
  self._sig = sig
@@ -2602,7 +2698,51 @@ class S4LMenuBar(rumps.App):
2602
2698
  except Exception:
2603
2699
  return None, []
2604
2700
 
2605
- def _maybe_start_review(self):
2701
+ def _read_snooze_until(self):
2702
+ try:
2703
+ with open(SNOOZE_FILE) as f:
2704
+ return float((json.load(f) or {}).get("until") or 0.0)
2705
+ except Exception:
2706
+ return 0.0
2707
+
2708
+ def _set_snooze(self, until, pending=0):
2709
+ """Set (or clear, until<=now) the review snooze, mirrored to disk."""
2710
+ self._review_snooze_until = until
2711
+ try:
2712
+ if until <= time.time():
2713
+ try:
2714
+ os.remove(SNOOZE_FILE)
2715
+ except FileNotFoundError:
2716
+ pass
2717
+ else:
2718
+ with open(SNOOZE_FILE, "w") as f:
2719
+ json.dump(
2720
+ {"until": until, "set_at": time.time(), "pending": pending},
2721
+ f,
2722
+ )
2723
+ except Exception:
2724
+ pass
2725
+
2726
+ def _review_now(self, _=None):
2727
+ """Menu: bring the pending draft cards up right now. Clears any snooze;
2728
+ this is the one presentation path that may take keyboard focus (the
2729
+ user explicitly asked for the cards)."""
2730
+ self._set_snooze(0.0)
2731
+ try:
2732
+ import s4l_card
2733
+
2734
+ if s4l_card.active_status():
2735
+ # A card is already on screen (user clicked while one is up,
2736
+ # perhaps parked on another display): bring it to them instead.
2737
+ s4l_card.heal_active()
2738
+ s4l_card.focus_active()
2739
+ return
2740
+ except Exception:
2741
+ pass
2742
+ self._last_review_sig = None
2743
+ self._maybe_start_review(focus=True)
2744
+
2745
+ def _maybe_start_review(self, focus=False):
2606
2746
  req = st.read_review_request()
2607
2747
  if not req:
2608
2748
  return
@@ -2618,6 +2758,12 @@ class S4LMenuBar(rumps.App):
2618
2758
  self._last_review_sig = None
2619
2759
  st.clear_review_request()
2620
2760
  return
2761
+ # Snoozed (the user closed the card with drafts still undecided): leave
2762
+ # the backlog in the store untouched and present nothing until the
2763
+ # snooze lapses. New drafts keep accumulating silently; the menu's
2764
+ # "Review N pending drafts" clears this early.
2765
+ if time.time() < self._review_snooze_until:
2766
+ return
2621
2767
  # De-dup on the CONTENT of the pending set (each draft's plan index + reply
2622
2768
  # text), not the constant batch_id. This means: re-present whenever NEW
2623
2769
  # drafts arrive (the signature changes), but don't re-pop the identical
@@ -2632,6 +2778,13 @@ class S4LMenuBar(rumps.App):
2632
2778
  # live. This is the fix for the "card froze at 1 of 4 while 137 piled
2633
2779
  # up" bug — drafts that arrived after the card opened used to be
2634
2780
  # stranded because this method returned early on _review_active.
2781
+ # Also prune the other direction: any `n` this same stack used to
2782
+ # carry but that dropped out of the fresh `drafts` list (merge_review_
2783
+ # queue.py's backend sync just marked it terminal, most commonly the
2784
+ # freshness gate expiring it) is removed from the not-yet-reached part
2785
+ # of the stack, so an old card can't sit there waiting to be approved
2786
+ # into a silent no-op (see the 2026-07-09 "approved 3, nothing
2787
+ # posted" investigation).
2635
2788
  # - Posting is DRAINING with no panel up (_review_active but not
2636
2789
  # _panel_open): leave the signature untouched so the full pending set
2637
2790
  # is presented fresh once the drain completes (don't pop a card mid-post).
@@ -2640,6 +2793,11 @@ class S4LMenuBar(rumps.App):
2640
2793
  try:
2641
2794
  import s4l_card
2642
2795
 
2796
+ prev_ns = {n for n, _ in (self._last_review_sig or ())}
2797
+ cur_ns = {d.get("n") for d in drafts}
2798
+ vanished = prev_ns - cur_ns
2799
+ if vanished:
2800
+ s4l_card.prune_active(vanished)
2643
2801
  s4l_card.extend_active(drafts)
2644
2802
  except Exception as e:
2645
2803
  sys.stderr.write(f"[s4l-menubar] extend cards failed: {e}\n")
@@ -2655,12 +2813,16 @@ class S4LMenuBar(rumps.App):
2655
2813
 
2656
2814
  # present_feedback (the menu bar's feedback item) falls back to
2657
2815
  # the module-level default handler; register ours before any
2658
- # card shows.
2816
+ # card shows. Same pattern for the title bar's "Discard all…"
2817
+ # button (moved out of the dropdown 2026-07-10), which reuses the
2818
+ # bulk-discard handler wholesale, confirmation alert included.
2659
2819
  s4l_card.set_feedback_handler(self._on_feedback_text)
2820
+ s4l_card.set_discard_all_handler(self._discard_all_pending)
2660
2821
  s4l_card.present_review(
2661
2822
  drafts,
2662
2823
  on_decision=lambda d: self._on_card_decision(batch, d),
2663
2824
  on_complete=lambda decisions: self._on_review_closed(batch, decisions),
2825
+ focus=focus,
2664
2826
  )
2665
2827
  # Record as shown only AFTER the cards are actually up, so a transient
2666
2828
  # card-UI failure never permanently suppresses this pending set.
@@ -2886,6 +3048,20 @@ class S4LMenuBar(rumps.App):
2886
3048
  remaining = 0
2887
3049
  if remaining <= 0:
2888
3050
  st.clear_review_request()
3051
+ else:
3052
+ # Undecided drafts left = the user closed the card early (cross or
3053
+ # the title-bar Snooze button): park the backlog instead of
3054
+ # re-popping it on the next tick, which made the card impossible
3055
+ # to put away (2026-07-09 customer feedback). The signature drop
3056
+ # below still runs, so after the snooze lapses the leftover
3057
+ # presents fresh rather than being suppressed as "already shown".
3058
+ until = time.time() + REVIEW_SNOOZE_SECONDS
3059
+ self._set_snooze(until, remaining)
3060
+ sys.stderr.write(
3061
+ f"[s4l-menubar] review snoozed: {remaining} pending until "
3062
+ f"{time.strftime('%H:%M', time.localtime(until))}\n"
3063
+ )
3064
+ sys.stderr.flush()
2889
3065
  # Drop the dedup signature so whatever is left is presented fresh (not
2890
3066
  # suppressed as "already shown") once posting finishes draining.
2891
3067
  self._last_review_sig = None
@@ -2895,7 +3071,15 @@ class S4LMenuBar(rumps.App):
2895
3071
  st.flush_review_events_async()
2896
3072
  except Exception:
2897
3073
  pass
2898
- if not any(d.get("approved") for d in decisions):
3074
+ if remaining > 0:
3075
+ plural = "s" if remaining != 1 else ""
3076
+ self._notify(
3077
+ "S4L",
3078
+ f"Snoozed {remaining} pending draft{plural} until "
3079
+ f"{time.strftime('%H:%M', time.localtime(self._review_snooze_until))}. "
3080
+ "Review sooner from the S4L menu.",
3081
+ )
3082
+ elif not any(d.get("approved") for d in decisions):
2899
3083
  self._notify("S4L", "No drafts approved — nothing posted.")
2900
3084
 
2901
3085
  def _discard_all_pending(self, _=None):
@@ -3123,26 +3307,21 @@ class S4LMenuBar(rumps.App):
3123
3307
  " " + (self._stall_reason_info[1] or "drafting") + " — no result yet"
3124
3308
  ))
3125
3309
  items.append(rumps.MenuItem("Diagnose & fix in Claude…", callback=self._diagnose_fix))
3310
+ elif self._stall_reason_info[0] == "orphaned":
3311
+ # Queue-latency stall, no routine executing (no recent worker
3312
+ # transcript): draft jobs sit unclaimed. Re-arm re-registers the
3313
+ # schedule through the real create_scheduled_task path, the one
3314
+ # mechanical fix that addresses "nothing is picking jobs up".
3315
+ items.append(self._label("⚠ Draft jobs stuck, no routine picking them up"))
3316
+ items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
3317
+ elif self._stall_reason_info[0] == "failing":
3318
+ # Queue-latency stall but routines DO execute: runs start and
3319
+ # die before draining. Re-arm can't fix that; Diagnose can.
3320
+ items.append(self._label("⚠ Draft jobs stuck, runs start but don't finish"))
3321
+ items.append(rumps.MenuItem("Diagnose & fix in Claude…", callback=self._diagnose_fix))
3126
3322
  elif schedule_state == "disabled":
3127
3323
  items.append(self._label("⚠ Draft tasks are scheduled but disabled"))
3128
3324
  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
3325
  else:
3147
3326
  items.append(self._label("⚠ Draft tasks aren’t scheduled on this account"))
3148
3327
  # Prefer the automatic fix (2026-07-08): if the active account
@@ -3172,6 +3351,29 @@ class S4LMenuBar(rumps.App):
3172
3351
  else:
3173
3352
  items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
3174
3353
  items.append(rumps.separator)
3354
+ elif setup_complete and schedule_state == "stalled":
3355
+ # NON-ALARM diagnostic (2026-07-09): the task is registered + enabled
3356
+ # for the active account but the host's per-minute tick is stale or
3357
+ # mostly skipped (Desktop warm-session wedge). The queue checks above
3358
+ # would have flipped ⚠ if jobs were actually stuck, so reaching here
3359
+ # means drafts still drain; say so with numbers instead of alarming.
3360
+ # tick_stats is cached by _tick (refreshed ≤ once/min, None outside
3361
+ # 'stalled').
3362
+ _ts = self._tick_stats or {}
3363
+ _skips = _ts.get("skips_in_window")
3364
+ _age = _ts.get("last_run_age_s")
3365
+ _bits = []
3366
+ if _skips is not None:
3367
+ _bits.append(f"{_skips} of ~60 ticks skipped last hour")
3368
+ if _age is not None:
3369
+ _bits.append(f"last run {max(0, int(_age)) // 60}m ago")
3370
+ items.append(self._label("Scheduler degraded (drafts still running)"))
3371
+ if _bits:
3372
+ items.append(self._label(" " + ", ".join(_bits)))
3373
+ # Keep the one-click remedy reachable without dressing it as urgent:
3374
+ # re-registering through create_scheduled_task un-wedges the host.
3375
+ items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
3376
+ items.append(rumps.separator)
3175
3377
 
3176
3378
  if not runtime_ready:
3177
3379
  items += self._state_a()
@@ -3329,12 +3531,26 @@ class S4LMenuBar(rumps.App):
3329
3531
  def _state_c(self, snap, pending_count=0):
3330
3532
  if pending_count <= 0:
3331
3533
  return []
3332
- return [
3534
+ plural = "s" if pending_count != 1 else ""
3535
+ items = [
3333
3536
  rumps.MenuItem(
3334
- f"Discard {pending_count} pending draft{'s' if pending_count != 1 else ''}…",
3335
- callback=self._discard_all_pending,
3537
+ f"Review {pending_count} pending draft{plural}",
3538
+ callback=self._review_now,
3336
3539
  )
3337
3540
  ]
3541
+ if time.time() < self._review_snooze_until:
3542
+ items.append(
3543
+ self._label(
3544
+ "Snoozed until "
3545
+ + time.strftime(
3546
+ "%H:%M", time.localtime(self._review_snooze_until)
3547
+ )
3548
+ )
3549
+ )
3550
+ # No bulk-discard item here anymore: "Discard all…" lives in the
3551
+ # review card's title bar (2026-07-10). Reaching it with no card open
3552
+ # is "Review N pending drafts" -> Discard all, two clicks.
3553
+ return items
3338
3554
 
3339
3555
 
3340
3556
  if __name__ == "__main__":
@@ -736,6 +736,37 @@ def _match_candidate(data, n, candidate_id):
736
736
  return None
737
737
 
738
738
 
739
+ def candidate_state(c):
740
+ """Canonical lifecycle state of ONE review-queue candidate. Use this instead
741
+ of ad hoc flag checks — "not posted" is NOT "awaiting review".
742
+
743
+ The review queue (review-queue.json) is an APPEND-FOREVER LEDGER: handled
744
+ candidates are never removed, they are flag-stamped in place, so most rows
745
+ in an old queue are retired. States, in precedence order:
746
+
747
+ posted — approved and successfully posted (our_url set).
748
+ terminal — retired without a post; terminal_reason says why
749
+ (rejected, human_rejected, human_discarded_all,
750
+ duplicate_thread_pre_post, ...; None on older rows).
751
+ post_failed — approved but the post attempt errored (post_error says
752
+ why). Settled from the cards' point of view; the resume
753
+ path retries only after a fresh approval clears it.
754
+ approved — human approved, post not yet attempted/confirmed.
755
+ awaiting_review — none of the above. The ONLY state cards present, and
756
+ the only honest "pending" count (review-request.json's
757
+ .count mirrors it at merge time).
758
+ """
759
+ if c.get("posted") is True:
760
+ return "posted"
761
+ if c.get("terminal") is True:
762
+ return "terminal"
763
+ if c.get("post_failed"):
764
+ return "post_failed"
765
+ if c.get("approved") is True:
766
+ return "approved"
767
+ return "awaiting_review"
768
+
769
+
739
770
  def store_stamp_decision(batch, decision):
740
771
  """Write a card decision INTO the store the instant the user clicks. This is
741
772
  the durable record (the old approved-queue.json ledger is no longer
@@ -784,7 +815,7 @@ def discard_all_pending(drafts):
784
815
  done = 0
785
816
  for d in drafts:
786
817
  c = _match_candidate(data, d.get("n"), d.get("candidate_id"))
787
- if c is None or c.get("posted") is True or c.get("terminal") is True:
818
+ if c is None or candidate_state(c) in ("posted", "terminal"):
788
819
  continue
789
820
  c["terminal"] = True
790
821
  c["terminal_reason"] = "human_discarded_all"
@@ -865,7 +896,7 @@ def store_pending_posts(batch="review-queue"):
865
896
  plan = read_plan(store_path())
866
897
  out = []
867
898
  for i, c in enumerate((plan or {}).get("candidates") or []):
868
- if not c.get("approved") or c.get("posted") or c.get("terminal") or c.get("post_failed"):
899
+ if candidate_state(c) != "approved":
869
900
  continue
870
901
  d = c.get("decision") or {}
871
902
  out.append(
@@ -932,7 +963,7 @@ def review_queue_posted_count():
932
963
  cands = (plan or {}).get("candidates")
933
964
  if not cands:
934
965
  return None
935
- return sum(1 for c in cands if c.get("posted") is True)
966
+ return sum(1 for c in cands if candidate_state(c) == "posted")
936
967
 
937
968
 
938
969
  def _plan_generation(batch):
@@ -1008,7 +1039,9 @@ def review_drafts(plan, batch="review-queue"):
1008
1039
  settled_ns = {it.get("n") for it in items if it.get("candidate_id") is None}
1009
1040
  out = []
1010
1041
  for i, c in enumerate(((plan or {}).get("candidates") or [])):
1011
- if c.get("posted") is True or c.get("terminal") is True or c.get("approved") is True:
1042
+ # Only awaiting_review rows become cards. This also skips post_failed
1043
+ # rows (decided-but-failed is settled per the docstring above).
1044
+ if candidate_state(c) != "awaiting_review":
1012
1045
  continue
1013
1046
  cid = c.get("candidate_id")
1014
1047
  if cid is not None and cid in settled_ids:
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.2-rc.9",
3
+ "version": "1.7.3",
4
4
  "private": true,
5
5
  "description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
6
6
  "license": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l",
3
- "version": "1.7.2-rc.9",
3
+ "version": "1.7.3",
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",