@m13v/s4l 1.7.1-rc.26 → 1.7.1-rc.28

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 CHANGED
@@ -885,11 +885,24 @@ async function postApproved(batchId, plan) {
885
885
  {
886
886
  const gateDeadline = Date.now() + 8 * 60_000;
887
887
  let waited = false;
888
+ let waitStartedAt = 0;
888
889
  while ((postingActive || isPeerDrainActive()) && Date.now() < gateDeadline) {
890
+ if (!waited) {
891
+ waitStartedAt = Date.now();
892
+ // This loop was previously silent end-to-end (2026-07-08 forensics: a
893
+ // ~1:45 gap in a user's approval batch had zero trace anywhere). Log
894
+ // once on entry (not per 5s tick, to avoid spamming the file) so a
895
+ // later "why did card N take so long" question has a starting point.
896
+ logPostEvent(`postApproved_wait_start batch=${batchId} blocked_by=${describePostingBlocker()}`);
897
+ }
889
898
  waited = true;
890
899
  await sleepMs(5000);
891
900
  }
892
- if (postingActive || isPeerDrainActive()) {
901
+ const timedOut = postingActive || isPeerDrainActive();
902
+ if (waited) {
903
+ logPostEvent(`postApproved_wait_end batch=${batchId} waited_ms=${Date.now() - waitStartedAt} timed_out=${timedOut}`);
904
+ }
905
+ if (timedOut) {
893
906
  return {
894
907
  attempted: 0,
895
908
  exit_code: 0,
@@ -3858,8 +3871,8 @@ async function ensureUpdaterInstalled() {
3858
3871
  // 'disabled' — present but a worker task is disabled.
3859
3872
  // 'missing' — not firing anywhere (orphaned / not registered for the live
3860
3873
  // account) -> dashboard offers "Set up draft schedule".
3861
- // The algorithm (live-account detection by freshest lastRunAt, firing window,
3862
- // etc.) lives in ONE place: scripts/schedule_state.py. The Python menu bar imports
3874
+ // The algorithm (live-account detection via config.json's lastKnownAccountUuid,
3875
+ // firing window, etc.) lives in ONE place: scripts/schedule_state.py. The Python menu bar imports
3863
3876
  // that module in-process; we shell out to it here. Keeping a single implementation
3864
3877
  // is the whole point — the two surfaces can no longer drift. The script is
3865
3878
  // stdlib-only and resolvePython() falls back to system python3, so this works even
@@ -4155,8 +4168,12 @@ function writePanelUrl(url) {
4155
4168
  // and fully usable via `url` for anything that already has it (e.g. this
4156
4169
  // process's own Code side-panel proxy); we just don't publish ourselves
4157
4170
  // as THE shared menu-bar target.
4171
+ logPanelEvent(`panel_cede existing_pid=${existing.pid}`);
4158
4172
  return;
4159
4173
  }
4174
+ logPanelEvent(existing?.pid
4175
+ ? `panel_takeover dead_pid=${existing.pid}`
4176
+ : `panel_claim previous=none`);
4160
4177
  fs.writeFileSync(panelEndpointPath(), JSON.stringify({ url, pid: process.pid, version: VERSION, started_at: new Date().toISOString() }, null, 2) + "\n", "utf-8");
4161
4178
  }
4162
4179
  catch (e) {
@@ -4307,6 +4324,25 @@ function isPeerDrainActive() {
4307
4324
  return false;
4308
4325
  }
4309
4326
  }
4327
+ // Human-readable reason for the postApproved wait gate, used only for the
4328
+ // wait_start log line — distinguishes "our own drain hasn't hit its grace
4329
+ // release yet" from "a peer MCP instance (different pid, e.g. a separate
4330
+ // Claude session/queue-worker) is mid-drain," and names the peer pid + how
4331
+ // much longer its flag has left so a log reader doesn't have to guess.
4332
+ function describePostingBlocker() {
4333
+ if (postingActive)
4334
+ return "own_in_process_flag";
4335
+ try {
4336
+ const j = JSON.parse(fs.readFileSync(postingFlagPath(), "utf-8"));
4337
+ if (typeof j?.expires_at === "number" && j.expires_at > Date.now() && typeof j?.pid === "number") {
4338
+ return `peer_pid=${j.pid} expires_in_ms=${j.expires_at - Date.now()}`;
4339
+ }
4340
+ }
4341
+ catch {
4342
+ /* best effort */
4343
+ }
4344
+ return "unknown";
4345
+ }
4310
4346
  // activity.json: a tiny "what's running right now" signal the menu bar reads to
4311
4347
  // show a loading spinner + label (scanning / drafting / posting / …). Written by
4312
4348
  // long-running tools, cleared when they finish. Best-effort; absence == idle.
@@ -4491,6 +4527,25 @@ function logPostEvent(msg) {
4491
4527
  /* best effort */
4492
4528
  }
4493
4529
  }
4530
+ // Timestamped on-disk trail of panel-endpoint.json ownership handoffs. Mirrors
4531
+ // logPostEvent's rationale (per-process console.error is hard to even locate
4532
+ // across ephemeral, per-host-session MCP instances), for the panel-election
4533
+ // side instead of the posting side. writePanelUrl() runs on every process's
4534
+ // eager startup, so this also captures the mundane "claimed with nothing
4535
+ // registered" and "ceded to an already-alive owner" cases, not just handoffs
4536
+ // — that's what makes it possible to tell "a fresh instance took over from a
4537
+ // dead registrant" apart from "an approve click found nothing registered at
4538
+ // all" after the fact, instead of inferring it from timing alone.
4539
+ function logPanelEvent(msg) {
4540
+ try {
4541
+ const dir = path.join(repoDir(), "skill", "logs");
4542
+ fs.mkdirSync(dir, { recursive: true });
4543
+ fs.appendFileSync(path.join(dir, "panel-events.log"), `${new Date().toISOString()} pid=${process.pid} ${msg}\n`);
4544
+ }
4545
+ catch {
4546
+ /* best effort */
4547
+ }
4548
+ }
4494
4549
  function sigkillScanTree(pid) {
4495
4550
  logPostEvent(`sigkill_scan_tree target=${pid}`);
4496
4551
  try {
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.1-rc.26",
3
- "installedAt": "2026-07-08T21:06:17.945Z"
2
+ "version": "1.7.1-rc.28",
3
+ "installedAt": "2026-07-08T23:19:54.168Z"
4
4
  }
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.1-rc.26",
5
+ "version": "1.7.1-rc.28",
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": {
@@ -28,6 +28,50 @@ import tempfile
28
28
  import threading
29
29
  import time
30
30
 
31
+ # --- stderr timestamp wrapper -------------------------------------------------
32
+ # menubar.err.log (this process's stderr, redirected by the launchd plist) has
33
+ # never carried timestamps, so correlating a line like "[s4l-card] approved at
34
+ # level 1" with the UTC-stamped logs elsewhere (post-*.log, post-preempt-
35
+ # events.log, panel-events.log) meant guessing from file position alone
36
+ # (2026-07-08 forensics). Wrap sys.stderr once, here, before any other module
37
+ # gets a chance to print, so every line written to stderr for the rest of the
38
+ # process gets a leading UTC timestamp in the same format those other logs
39
+ # use — zero changes needed at any individual print()/write() call site
40
+ # (s4l_card._log, the many sys.stderr.write(...) calls in this file, rumps'/
41
+ # PyObjC's own warnings, …).
42
+ class _TimestampedStderr:
43
+ def __init__(self, stream):
44
+ self._stream = stream
45
+ self._newline_pending = True # True at the start of a fresh line
46
+
47
+ def write(self, data):
48
+ if not data:
49
+ return 0
50
+ ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
51
+ lines = data.split("\n")
52
+ out = []
53
+ for i, line in enumerate(lines):
54
+ if i > 0:
55
+ out.append("\n")
56
+ starts_new_line = i > 0 or self._newline_pending
57
+ if starts_new_line and line:
58
+ out.append(f"[{ts}] ")
59
+ out.append(line)
60
+ self._newline_pending = data.endswith("\n")
61
+ return self._stream.write("".join(out))
62
+
63
+ def flush(self):
64
+ self._stream.flush()
65
+
66
+ def isatty(self):
67
+ try:
68
+ return self._stream.isatty()
69
+ except Exception:
70
+ return False
71
+
72
+
73
+ sys.stderr = _TimestampedStderr(sys.stderr)
74
+
31
75
  # --- Sentry bootstrap --------------------------------------------------------
32
76
  # The menu bar runs as a standalone KeepAlive LaunchAgent off the owned venv,
33
77
  # a separate process from the MCP server, so it was a Sentry blind spot: a crash
@@ -2269,10 +2313,14 @@ class S4LMenuBar(rumps.App):
2269
2313
  # never nag the user mid-onboarding — only the value is now always honest.
2270
2314
  schedule_state = self._schedule_state()
2271
2315
  self._schedule_state_cache = schedule_state
2272
- # 'stalled' (task present + enabled, host scheduler stopped launching it:
2273
- # the Desktop warm-session wedge or an account-switch orphan) needs
2274
- # attention just like missing/disabled, but its primary fix is a Claude
2275
- # Desktop restart, not re-arm (Karol, 2026-07-06).
2316
+ # 'stalled' (task present + enabled FOR THE ACTIVE ACCOUNT, host
2317
+ # scheduler stopped launching it: the Desktop warm-session wedge,
2318
+ # Karol 2026-07-06) needs attention just like missing/disabled.
2319
+ # schedule_state.py is account-scoped (2026-07-08): an account switch
2320
+ # now correctly resolves to 'missing' (the active account genuinely
2321
+ # has no registration), not 'stalled' — a stale OTHER account's
2322
+ # registry is never consulted for the active account's health, so
2323
+ # 'stalled' can no longer be misdiagnosed here.
2276
2324
  attention = setup_complete and schedule_state in ("missing", "disabled", "stalled")
2277
2325
  # Routines-lane rate limit (429): the draft tasks ARE registered and firing
2278
2326
  # for this account, but every run dies on a Claude rate limit, so nothing
@@ -2304,13 +2352,12 @@ class S4LMenuBar(rumps.App):
2304
2352
  # draft_stuck shadowed the missing/disabled branch in _build_menu — the user
2305
2353
  # saw "worker keeps getting killed" with NO Re-arm button instead of "Draft
2306
2354
  # tasks aren't scheduled on this account" + the one-click fix (Karol,
2307
- # 2026-07-06). "stalled" is deliberately included (2026-07-08): that branch's
2308
- # only offered fix is "Restart Claude Desktop", but a job that has sat this
2309
- # long — claimed-and-hung, OR never claimed at all (see the ⧖ prefix check
2310
- # below) — means a restart was either already tried and didn't help, or
2311
- # isn't the right first move; Diagnose is the more useful single action once
2312
- # we have DRAFT_STUCK_SECONDS of direct evidence from the queue itself,
2313
- # which is a more reliable signal than the host's lastRunAt staleness.
2355
+ # 2026-07-06). "stalled" is deliberately included (2026-07-08): that
2356
+ # branch's fix (Set up draft schedule / re-arm) works for it too, but a
2357
+ # job that has sat this long — claimed-and-hung, OR never claimed at all
2358
+ # (see the ⧖ prefix check below) — is more specific, direct evidence
2359
+ # from the queue itself than the host's lastRunAt staleness, so it's
2360
+ # worth surfacing as its own reason even under "stalled".
2314
2361
  if (
2315
2362
  setup_complete
2316
2363
  and schedule_state in ("ok", "stalled")
@@ -2965,16 +3012,20 @@ class S4LMenuBar(rumps.App):
2965
3012
  items.append(self._label("⚠ Draft tasks are scheduled but disabled"))
2966
3013
  items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
2967
3014
  elif schedule_state == "stalled":
2968
- # Task registered + enabled but the host stopped launching it: the
2969
- # Claude Desktop warm-session wedge (finished worker sessions never
2970
- # exit; the overlap guard skips every fire) or an account-switch
2971
- # orphan. Re-arm goes through the same real create_scheduled_task
3015
+ # Task registered + enabled FOR THE ACTIVE ACCOUNT (schedule_state.py
3016
+ # is account-scoped, 2026-07-08 — see its module docstring) but the
3017
+ # host stopped launching it: the Claude Desktop warm-session wedge
3018
+ # (finished worker sessions never exit; the overlap guard skips
3019
+ # every fire). Re-arm goes through the same real create_scheduled_task
2972
3020
  # path as onboarding — a verified fix the user has seen work —
2973
3021
  # rather than a silent Claude Desktop quit/relaunch with no visible
2974
3022
  # feedback, which field evidence (2026-07-08) showed doesn't
2975
- # reliably clear the underlying stall. Distinct label from the
2976
- # "aren't scheduled" case below since the task DOES exist here;
2977
- # same one-click remedy either way.
3023
+ # reliably clear the underlying stall (that field incident turned
3024
+ # out to be a DIFFERENT bug: schedule_state.py was reading a stale,
3025
+ # no-longer-active account's registry — now fixed, so "stalled"
3026
+ # here means what it says: same account, task present, just not
3027
+ # firing). Distinct label from the "aren't scheduled" case below
3028
+ # since the task DOES exist here; same one-click remedy either way.
2978
3029
  items.append(self._label("⚠ Drafts stopped — Claude’s scheduler is stuck"))
2979
3030
  items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
2980
3031
  else:
@@ -1317,8 +1317,18 @@ def review_event_add(event):
1317
1317
  p.parent.mkdir(parents=True, exist_ok=True)
1318
1318
  with open(p, "a") as f:
1319
1319
  f.write(json.dumps(ev) + "\n")
1320
- except Exception:
1321
- pass
1320
+ except Exception as err:
1321
+ # Was a silent `pass` — a card's approve/reject decision could vanish
1322
+ # with zero trace anywhere (2026-07-08: two approved-and-posted cards
1323
+ # had no review_events row at all, and this was one of the candidate
1324
+ # causes with nothing to confirm or rule it out). Never never write to
1325
+ # the outbox again for THIS event, so it's worth saying loudly.
1326
+ sys.stderr.write(
1327
+ f"[s4l-state] review_event_add: failed to append to outbox ({type(err).__name__}: {err}); "
1328
+ f"event DROPPED event_uuid={ev.get('event_uuid')} decision={ev.get('decision')} "
1329
+ f"candidate_id={ev.get('candidate_id')}\n"
1330
+ )
1331
+ sys.stderr.flush()
1322
1332
  flush_review_events_async()
1323
1333
 
1324
1334
 
@@ -1339,16 +1349,29 @@ def flush_review_events():
1339
1349
  if not p.exists():
1340
1350
  return
1341
1351
  lines = [ln for ln in p.read_text().splitlines() if ln.strip()]
1342
- except Exception:
1352
+ except Exception as err:
1353
+ sys.stderr.write(f"[s4l-state] flush_review_events: failed to read outbox: {type(err).__name__}: {err}\n")
1354
+ sys.stderr.flush()
1343
1355
  return
1344
1356
  events = []
1345
1357
  for ln in lines:
1346
1358
  try:
1347
1359
  ev = json.loads(ln)
1348
- if isinstance(ev, dict) and ev.get("event_uuid"):
1349
- events.append(ev)
1350
- except Exception:
1360
+ except Exception as err:
1361
+ sys.stderr.write(
1362
+ f"[s4l-state] flush_review_events: dropping unparseable outbox line "
1363
+ f"({type(err).__name__}: {err}): {ln[:200]!r}\n"
1364
+ )
1365
+ sys.stderr.flush()
1351
1366
  continue # corrupt line: dropped on the next rewrite
1367
+ if isinstance(ev, dict) and ev.get("event_uuid"):
1368
+ events.append(ev)
1369
+ else:
1370
+ sys.stderr.write(
1371
+ f"[s4l-state] flush_review_events: dropping malformed outbox line "
1372
+ f"(not a dict, or missing event_uuid): {ln[:200]!r}\n"
1373
+ )
1374
+ sys.stderr.flush()
1352
1375
  if not events:
1353
1376
  if lines: # only corrupt lines left — clear the file
1354
1377
  _outbox_remove(set())
@@ -1357,7 +1380,12 @@ def flush_review_events():
1357
1380
  # import lazily so a missing pipeline repo degrades to buffer-only.
1358
1381
  try:
1359
1382
  from http_api import api_post
1360
- except Exception:
1383
+ except Exception as err:
1384
+ sys.stderr.write(
1385
+ f"[s4l-state] flush_review_events: http_api unavailable ({type(err).__name__}: {err}); "
1386
+ f"{len(events)} event(s) staying buffered in the outbox\n"
1387
+ )
1388
+ sys.stderr.flush()
1361
1389
  return
1362
1390
  shipped = set()
1363
1391
  for i in range(0, len(events), 100):
@@ -1365,7 +1393,12 @@ def flush_review_events():
1365
1393
  try:
1366
1394
  api_post("/api/v1/review-events", {"events": batch})
1367
1395
  shipped.update(e["event_uuid"] for e in batch)
1368
- except Exception:
1396
+ except Exception as err:
1397
+ sys.stderr.write(
1398
+ f"[s4l-state] flush_review_events: POST failed ({type(err).__name__}: {err}); "
1399
+ f"{len(batch)} event(s) in this batch (and any after it) staying buffered for the next kick\n"
1400
+ )
1401
+ sys.stderr.flush()
1369
1402
  break # network/API down: keep the rest for the next kick
1370
1403
  _outbox_remove(shipped, keep_only_valid=True)
1371
1404
  finally:
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.1-rc.26",
3
+ "version": "1.7.1-rc.28",
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.1-rc.26",
3
+ "version": "1.7.1-rc.28",
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",
@@ -104,16 +104,66 @@ CREATED_GRACE = 900
104
104
  # "Claude*" (not "Claude"): the host app can run with a custom --user-data-dir
105
105
  # (per-account dirs like "Claude-mediar" on multi-account machines), and the
106
106
  # registry lands under THAT dir while plain "Claude/" has no claude-code-sessions
107
- # at all. The freshest-lastRunAt selection in compute() already picks the live
108
- # registry among however many dirs match, so widening the glob is safe; the
109
- # plain-"Claude" glob read a hard "missing" forever on such machines even while
110
- # both worker tasks fired every minute (found 2026-07-02 during onboarding).
107
+ # at all. UNSCOPED across every account under each root — deliberately, this is
108
+ # also imported by callers that need to see EVERY account's registries (e.g. the
109
+ # menu bar's relocation/legacy-consolidation sweep, which must find and clean up
110
+ # old accounts' leftover tasks, not just the live one). compute()/_detail() do
111
+ # NOT use this directly by default any more — see _active_registry_glob_patterns.
111
112
  SCHED_REGISTRY_GLOB = os.path.join(
112
113
  os.path.expanduser("~"), "Library", "Application Support", "Claude*",
113
114
  "claude-code-sessions", "*", "*", "scheduled-tasks.json",
114
115
  )
115
116
 
116
117
 
118
+ def _config_json_paths():
119
+ """One config.json per Claude* root (multi-account machines can run
120
+ several distinct --user-data-dir roots, e.g. Claude-mediar, each a
121
+ separate host app instance with its own logged-in account)."""
122
+ return glob.glob(os.path.join(
123
+ os.path.expanduser("~"), "Library", "Application Support", "Claude*",
124
+ "config.json",
125
+ ))
126
+
127
+
128
+ def _active_account_uuid(config_path):
129
+ """The account currently logged into the Claude* root config_path lives
130
+ under, or None if config.json is missing/unreadable/predates this field."""
131
+ try:
132
+ with open(config_path) as fh:
133
+ d = json.load(fh)
134
+ u = d.get("lastKnownAccountUuid")
135
+ return u if isinstance(u, str) and u else None
136
+ except Exception:
137
+ return None
138
+
139
+
140
+ def _active_registry_glob_patterns():
141
+ """One glob pattern per Claude* root, scoped to ONLY the account that root
142
+ is currently logged into (verified 2026-07-08: claude-code-sessions/<uuid>/
143
+ is keyed by account — lastKnownAccountUuid in that root's config.json
144
+ matches the top-level directory local-agent-mode-sessions/<uuid>/ actually
145
+ running under, and a different, no-longer-active account's own <uuid>/
146
+ directory sits untouched alongside it). Falls back to the OLD
147
+ every-account-under-this-root pattern for a root whose config.json doesn't
148
+ resolve an account, so an unusual/older install never regresses to a blind
149
+ 'missing' just because this field happens to be absent."""
150
+ patterns = []
151
+ for cfg in _config_json_paths():
152
+ root = os.path.dirname(cfg)
153
+ uuid = _active_account_uuid(cfg)
154
+ if uuid:
155
+ patterns.append(os.path.join(
156
+ root, "claude-code-sessions", uuid, "*", "scheduled-tasks.json"
157
+ ))
158
+ else:
159
+ patterns.append(os.path.join(
160
+ root, "claude-code-sessions", "*", "*", "scheduled-tasks.json"
161
+ ))
162
+ # No Claude* root found at all (host app never launched here) -> fall back
163
+ # to the unscoped glob so callers still see SOMETHING rather than nothing.
164
+ return patterns or [SCHED_REGISTRY_GLOB]
165
+
166
+
117
167
  def _iso_to_epoch(s):
118
168
  if not s:
119
169
  return None
@@ -134,37 +184,45 @@ def _ms_to_epoch(ms):
134
184
  return None
135
185
 
136
186
 
137
- def compute(glob_pattern: str = SCHED_REGISTRY_GLOB) -> str:
138
- """Return 'ok' | 'disabled' | 'stalled' | 'missing' for the draft schedule."""
187
+ def compute(glob_pattern: str | None = None) -> str:
188
+ """Return 'ok' | 'disabled' | 'stalled' | 'missing' for the draft schedule.
189
+
190
+ With no argument (every real caller), scans ONLY the active account's
191
+ registries via _active_registry_glob_patterns() — see its docstring and
192
+ the module docstring for why. Pass an explicit glob_pattern to scan a
193
+ literal pattern instead (e.g. a test fixture dir); this bypasses account
194
+ scoping entirely, matching the old unconditional behavior."""
195
+ patterns = [glob_pattern] if glob_pattern is not None else _active_registry_glob_patterns()
139
196
  newest_epoch, newest_enabled = None, False
140
197
  # Track the freshest just-created, enabled, never-yet-fired task so a schedule
141
198
  # the user only moments ago set up doesn't read as "missing" before its first
142
199
  # fire lands (see CREATED_GRACE).
143
200
  newest_fresh_created = None
144
201
  any_present, any_enabled = False, False
145
- for f in glob.glob(glob_pattern):
146
- try:
147
- with open(f) as fh:
148
- d = json.load(fh)
149
- except Exception:
150
- continue
151
- by_id = {t.get("id"): t for t in (d.get("scheduledTasks") or [])}
152
- recs = _registry_worker_recs(by_id)
153
- if recs is None:
154
- continue
155
- any_present = True
156
- enabled = all(r.get("enabled") for r in recs)
157
- any_enabled = any_enabled or enabled
158
- epochs = [_iso_to_epoch(r.get("lastRunAt")) for r in recs]
159
- e = max([x for x in epochs if x is not None], default=None)
160
- if e is not None and (newest_epoch is None or e > newest_epoch):
161
- newest_epoch, newest_enabled = e, enabled
162
- # Freshly scheduled + enabled + not yet fired anywhere in this registry.
163
- if enabled and e is None:
164
- created = [_ms_to_epoch(r.get("createdAt")) for r in recs]
165
- c = min([x for x in created if x is not None], default=None)
166
- if c is not None and (newest_fresh_created is None or c > newest_fresh_created):
167
- newest_fresh_created = c
202
+ for pattern in patterns:
203
+ for f in glob.glob(pattern):
204
+ try:
205
+ with open(f) as fh:
206
+ d = json.load(fh)
207
+ except Exception:
208
+ continue
209
+ by_id = {t.get("id"): t for t in (d.get("scheduledTasks") or [])}
210
+ recs = _registry_worker_recs(by_id)
211
+ if recs is None:
212
+ continue
213
+ any_present = True
214
+ enabled = all(r.get("enabled") for r in recs)
215
+ any_enabled = any_enabled or enabled
216
+ epochs = [_iso_to_epoch(r.get("lastRunAt")) for r in recs]
217
+ e = max([x for x in epochs if x is not None], default=None)
218
+ if e is not None and (newest_epoch is None or e > newest_epoch):
219
+ newest_epoch, newest_enabled = e, enabled
220
+ # Freshly scheduled + enabled + not yet fired anywhere in this registry.
221
+ if enabled and e is None:
222
+ created = [_ms_to_epoch(r.get("createdAt")) for r in recs]
223
+ c = min([x for x in created if x is not None], default=None)
224
+ if c is not None and (newest_fresh_created is None or c > newest_fresh_created):
225
+ newest_fresh_created = c
168
226
  # Firing recently => the live account's schedule is active and healthy.
169
227
  if newest_epoch is not None and (time.time() - newest_epoch) <= FIRING_WINDOW:
170
228
  return "ok" if newest_enabled else "disabled"
@@ -172,9 +230,11 @@ def compute(glob_pattern: str = SCHED_REGISTRY_GLOB) -> str:
172
230
  # Suppresses the false ⚠ right after the user sets up the draft schedule.
173
231
  if newest_fresh_created is not None and (time.time() - newest_fresh_created) <= CREATED_GRACE:
174
232
  return "ok"
175
- # Not firing anywhere. Registered-but-disabled => disabled; registered and
176
- # enabled but the host stopped launching it => stalled (Desktop scheduler
177
- # wedge or account-switch orphan); absent everywhere => missing.
233
+ # Not firing anywhere for the active account. Registered-but-disabled =>
234
+ # disabled; registered and enabled but the host stopped launching it =>
235
+ # stalled (Desktop scheduler wedge, SAME account); absent for the active
236
+ # account entirely (never scheduled, or orphaned by an account switch) =>
237
+ # missing.
178
238
  if any_present and not any_enabled:
179
239
  return "disabled"
180
240
  if any_present and any_enabled:
@@ -182,29 +242,32 @@ def compute(glob_pattern: str = SCHED_REGISTRY_GLOB) -> str:
182
242
  return "missing"
183
243
 
184
244
 
185
- def _detail(glob_pattern: str = SCHED_REGISTRY_GLOB) -> dict:
186
- """Cheap diagnostics for the JSON output: which registries the glob saw and
187
- which contain both worker tasks, with each one's freshest lastRunAt age.
188
- This is what makes a 'missing' verdict debuggable from a log line instead of
189
- requiring filesystem forensics (the 2026-07-02 rotated-dir bug hid here)."""
245
+ def _detail(glob_pattern: str | None = None) -> dict:
246
+ """Cheap diagnostics for the JSON output: which registries the glob(s) saw
247
+ and which contain both worker tasks, with each one's freshest lastRunAt
248
+ age. This is what makes a 'missing' verdict debuggable from a log line
249
+ instead of requiring filesystem forensics (the 2026-07-02 rotated-dir bug,
250
+ and the 2026-07-08 wrong-account bug, both hid here)."""
251
+ patterns = [glob_pattern] if glob_pattern is not None else _active_registry_glob_patterns()
190
252
  regs = []
191
- for f in glob.glob(glob_pattern):
192
- entry = {"path": f, "has_workers": False, "last_run_age_s": None}
193
- try:
194
- with open(f) as fh:
195
- d = json.load(fh)
196
- by_id = {t.get("id"): t for t in (d.get("scheduledTasks") or [])}
197
- recs = _registry_worker_recs(by_id)
198
- if recs is not None:
199
- entry["has_workers"] = True
200
- epochs = [_iso_to_epoch(r.get("lastRunAt")) for r in recs]
201
- e = max([x for x in epochs if x is not None], default=None)
202
- if e is not None:
203
- entry["last_run_age_s"] = int(time.time() - e)
204
- except Exception as exc:
205
- entry["error"] = str(exc)
206
- regs.append(entry)
207
- return {"glob": glob_pattern, "registries": regs}
253
+ for pattern in patterns:
254
+ for f in glob.glob(pattern):
255
+ entry = {"path": f, "has_workers": False, "last_run_age_s": None}
256
+ try:
257
+ with open(f) as fh:
258
+ d = json.load(fh)
259
+ by_id = {t.get("id"): t for t in (d.get("scheduledTasks") or [])}
260
+ recs = _registry_worker_recs(by_id)
261
+ if recs is not None:
262
+ entry["has_workers"] = True
263
+ epochs = [_iso_to_epoch(r.get("lastRunAt")) for r in recs]
264
+ e = max([x for x in epochs if x is not None], default=None)
265
+ if e is not None:
266
+ entry["last_run_age_s"] = int(time.time() - e)
267
+ except Exception as exc:
268
+ entry["error"] = str(exc)
269
+ regs.append(entry)
270
+ return {"glob": patterns, "registries": regs}
208
271
 
209
272
 
210
273
  def main() -> int: