social-autoposter 1.6.83 → 1.6.84

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
@@ -22,7 +22,7 @@ import { xStatus, xConnect, xDetectSources, xScanProfile, summarizeXAuth } from
22
22
  import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, resolveChrome, ensureMenubar, ensurePipelineCurrent, } from "./runtime.js";
23
23
  import { blockOnboardingMilestone, completeOnboardingMilestone, ensureDoctorPhase, onboardingLedger, onboardingSnapshot, recordOnboardingAttempt, runDoctorPhase, } from "./onboarding.js";
24
24
  import { VERSION, versionStatus, latestPublishedVersion } from "./version.js";
25
- import { initSentry, sendHeartbeat, captureError, flushSentry } from "./telemetry.js";
25
+ import { initSentry, sendHeartbeat, captureError, flushSentry, startLogStreaming, flushLogs } from "./telemetry.js";
26
26
  import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, getUiCapability, } from "@modelcontextprotocol/ext-apps/server";
27
27
  import { fileURLToPath } from "node:url";
28
28
  import http from "node:http";
@@ -576,10 +576,20 @@ async function postApproved(batchId, plan) {
576
576
  catch {
577
577
  /* keep raw */
578
578
  }
579
- // On a successful run, mark the posted candidates in the ORIGINAL plan so the
580
- // other review surface (chat vs menu-bar pop-ups) skips them cross-surface
581
- // de-dup. Best-effort; the pipeline's own already-posted check is the backstop.
582
- if (res.code === 0) {
579
+ // Real posted count from the pipeline summary NOT the approved count. A run
580
+ // can exit 0 yet post nothing (every reply hit reply_box_not_found, etc.), so
581
+ // trusting approved.length here reported phantom successes ("posted: 1" when 0
582
+ // landed). Fall back to approved.length only when the summary is unparseable
583
+ // AND the process exited clean.
584
+ const summObj = (summary && typeof summary === "object") ? summary : null;
585
+ const realPosted = summObj && typeof summObj.posted === "number"
586
+ ? summObj.posted
587
+ : res.code === 0 && !summObj
588
+ ? approved.length
589
+ : 0;
590
+ // Mark only candidates that ACTUALLY posted (cross-surface de-dup). When the
591
+ // summary is missing but the run exited clean, fall back to marking all.
592
+ if (realPosted > 0 || (res.code === 0 && !summObj)) {
583
593
  for (const c of approved)
584
594
  c.posted = true;
585
595
  try {
@@ -589,8 +599,27 @@ async function postApproved(batchId, plan) {
589
599
  /* best effort */
590
600
  }
591
601
  }
602
+ // Post failures are HANDLED in the pipeline (it returns a count, never throws),
603
+ // so they never reach Sentry on their own. Capture an explicit event whenever
604
+ // the run exited non-zero OR fewer drafts posted than were approved. This is
605
+ // the only telemetry channel that reaches a customer .mcpb install (their cycle
606
+ // log lives on their machine). install_id/hostname are auto-tagged.
607
+ if (res.code !== 0 || realPosted < approved.length) {
608
+ captureError(new Error(`post_drafts: ${realPosted}/${approved.length} posted (exit=${res.code})`), {
609
+ component: "post",
610
+ exit_code: String(res.code),
611
+ attempted: String(approved.length),
612
+ posted: String(realPosted),
613
+ failure_reasons: String(summObj?.failure_reasons || ""),
614
+ skip_reasons: String(summObj?.skip_reasons || ""),
615
+ stderr_tail: res.stderr.split("\n").slice(-5).join(" | ").slice(0, 500),
616
+ });
617
+ void flushSentry(2000);
618
+ }
619
+ void flushLogs();
592
620
  return {
593
621
  attempted: approved.length,
622
+ posted: realPosted,
594
623
  exit_code: res.code,
595
624
  summary,
596
625
  stderr_tail: res.stderr.split("\n").slice(-8).join("\n"),
@@ -1210,11 +1239,20 @@ tool("post_drafts", {
1210
1239
  });
1211
1240
  }
1212
1241
  const result = await postApproved(batch_id, plan);
1242
+ // Report the REAL posted count from the pipeline, not the approved count.
1243
+ // A run can approve N yet land 0 (browser/session failure); reporting
1244
+ // approve.size here told the agent "posted: N" on a total failure.
1245
+ const actuallyPosted = typeof result.posted === "number" ? result.posted : approve.size;
1246
+ if (actuallyPosted < approve.size) {
1247
+ warnings.push(`only ${actuallyPosted}/${approve.size} actually posted (exit=${result.exit_code}); ` +
1248
+ `see result.summary / result.stderr_tail for the reason`);
1249
+ }
1213
1250
  return jsonContent({
1214
1251
  batch_id,
1215
1252
  drafted: total,
1216
- posted: approve.size,
1217
- skipped: total - approve.size,
1253
+ posted: actuallyPosted,
1254
+ approved: approve.size,
1255
+ skipped: total - actuallyPosted,
1218
1256
  edited: editedCount,
1219
1257
  result,
1220
1258
  warnings,
@@ -2346,6 +2384,11 @@ registerAppResource(server, "S4L product link", PRODUCT_LINK_URI, { mimeType: RE
2346
2384
  }));
2347
2385
  async function main() {
2348
2386
  initSentry();
2387
+ // Tee the verbatim stdout/stderr of every pipeline subprocess to the s4l
2388
+ // backend so we can troubleshoot/rescue any user scenario (silent stalls,
2389
+ // partial onboarding) without asking them to ship a log file. Best-effort;
2390
+ // disabled with SAPS_LOG_STREAM=0.
2391
+ startLogStreaming();
2349
2392
  // A plugin UPDATE refreshes this server (dist/) but not the materialized
2350
2393
  // pipeline. Re-extract the bundled pipeline.tgz when it's newer than what's on
2351
2394
  // disk, BEFORE serving, so the very first scan uses the shipped pipeline (not
@@ -2396,6 +2439,7 @@ async function main() {
2396
2439
  main().catch(async (err) => {
2397
2440
  console.error("[social-autoposter-mcp] fatal:", err);
2398
2441
  captureError(err, { component: "main" });
2442
+ await flushLogs();
2399
2443
  await flushSentry();
2400
2444
  process.exit(1);
2401
2445
  });
package/mcp/dist/repo.js CHANGED
@@ -30,6 +30,27 @@ export function repoDir() {
30
30
  // "No drafts in batch ...". Default to /tmp to match the script; allow an explicit
31
31
  // override for non-standard installs.
32
32
  export const TMP_DIR = process.env.SAPS_TMP_DIR || "/tmp";
33
+ let lineSink = null;
34
+ export function setLineSink(fn) {
35
+ lineSink = fn;
36
+ }
37
+ // Derive a short, stable context label for a spawned command so log lines can
38
+ // be grouped by which script produced them (e.g. "scripts/seed_search_topics.py",
39
+ // "skill/run-twitter-cycle.sh"). Best-effort; never throws.
40
+ function deriveContext(cmd, args) {
41
+ try {
42
+ const base = cmd.split("/").pop() || cmd;
43
+ if (/python/i.test(base) || base === "bash" || base === "sh" || base === "node") {
44
+ const first = args.find((a) => !a.startsWith("-"));
45
+ if (first)
46
+ return first;
47
+ }
48
+ return [base, args[0] ?? ""].join(" ").trim();
49
+ }
50
+ catch {
51
+ return cmd;
52
+ }
53
+ }
33
54
  // Spawn a process inside the repo, inheriting the repo env (API base + keys
34
55
  // come from the install's environment / .env loaded by the scripts themselves).
35
56
  //
@@ -73,6 +94,29 @@ export function run(cmd, args, opts = {}) {
73
94
  }
74
95
  return buf;
75
96
  };
97
+ // Parallel whole-line splitter that tees to the telemetry sink (if any),
98
+ // kept separate from the onLine pump so neither path can affect the other.
99
+ const logCtx = opts.logContext || deriveContext(cmd, args);
100
+ let sinkOutBuf = "";
101
+ let sinkErrBuf = "";
102
+ const sinkPump = (chunk, which, buf) => {
103
+ const sink = lineSink;
104
+ if (!sink)
105
+ return buf;
106
+ buf += chunk;
107
+ let nl;
108
+ while ((nl = buf.indexOf("\n")) !== -1) {
109
+ const line = buf.slice(0, nl);
110
+ buf = buf.slice(nl + 1);
111
+ try {
112
+ sink(line, which, logCtx);
113
+ }
114
+ catch {
115
+ /* the telemetry sink must never break the wrapped command */
116
+ }
117
+ }
118
+ return buf;
119
+ };
76
120
  let timer;
77
121
  if (opts.timeoutMs) {
78
122
  timer = setTimeout(() => {
@@ -83,11 +127,13 @@ export function run(cmd, args, opts = {}) {
83
127
  const s = d.toString();
84
128
  stdout += s;
85
129
  outBuf = pump(s, "stdout", outBuf);
130
+ sinkOutBuf = sinkPump(s, "stdout", sinkOutBuf);
86
131
  });
87
132
  child.stderr.on("data", (d) => {
88
133
  const s = d.toString();
89
134
  stderr += s;
90
135
  errBuf = pump(s, "stderr", errBuf);
136
+ sinkErrBuf = sinkPump(s, "stderr", sinkErrBuf);
91
137
  });
92
138
  child.on("close", (code) => {
93
139
  if (timer)
@@ -109,6 +155,23 @@ export function run(cmd, args, opts = {}) {
109
155
  /* ignore */
110
156
  }
111
157
  }
158
+ const sink = lineSink;
159
+ if (sink) {
160
+ if (sinkOutBuf)
161
+ try {
162
+ sink(sinkOutBuf, "stdout", logCtx);
163
+ }
164
+ catch {
165
+ /* ignore */
166
+ }
167
+ if (sinkErrBuf)
168
+ try {
169
+ sink(sinkErrBuf, "stderr", logCtx);
170
+ }
171
+ catch {
172
+ /* ignore */
173
+ }
174
+ }
112
175
  resolve({ code: code ?? -1, stdout, stderr });
113
176
  });
114
177
  child.on("error", (err) => {
@@ -9,7 +9,7 @@
9
9
  import * as Sentry from "@sentry/node";
10
10
  import path from "node:path";
11
11
  import fs from "node:fs";
12
- import { repoDir, runPython } from "./repo.js";
12
+ import { repoDir, runPython, setLineSink } from "./repo.js";
13
13
  import { VERSION } from "./version.js";
14
14
  // Sentry DSN is a client-side identifier (safe to embed, same posture as Fazm's
15
15
  // hardcoded Swift DSN). Overridable via env for dev. Empty -> Sentry disabled.
@@ -102,3 +102,129 @@ export async function sendHeartbeat(reason) {
102
102
  console.error("[social-autoposter-mcp] heartbeat failed:", err?.message || err);
103
103
  }
104
104
  }
105
+ // ---- Raw subprocess log streaming ------------------------------------------
106
+ // Tees the verbatim stdout/stderr of every pipeline subprocess (via the
107
+ // repo.ts run() boundary) to the s4l backend, so we can troubleshoot and
108
+ // rescue any user scenario without asking them to ship a log file. Lines are
109
+ // buffered in memory and flushed in small batches under the same X-Installation
110
+ // identity the heartbeat uses. Best-effort: this NEVER throws into the server,
111
+ // never blocks the child's I/O, and drops on overflow rather than growing
112
+ // unbounded. Disable with SAPS_LOG_STREAM=0.
113
+ const LOG_STREAM_ENABLED = process.env.SAPS_LOG_STREAM !== "0";
114
+ const LOG_MAX_LINE_LEN = 8192; // mirror the backend cap
115
+ const LOG_MAX_BUFFER = 1000; // drop oldest beyond this (overflow protection)
116
+ const LOG_FLUSH_BATCH = 100; // flush eagerly once we have this many lines
117
+ const LOG_MAX_PER_POST = 200; // backend accepts 1-200 per request
118
+ const LOG_FLUSH_MS = 3000; // otherwise flush on this cadence
119
+ const logBuffer = [];
120
+ let logDropped = 0; // count of lines dropped on overflow (surfaced periodically)
121
+ let logFlushing = false;
122
+ let logTimer;
123
+ let cachedInstallHeader = null;
124
+ let logStreamingStarted = false;
125
+ async function installHeader() {
126
+ if (cachedInstallHeader)
127
+ return cachedInstallHeader;
128
+ try {
129
+ const idScript = path.join(repoDir(), "scripts", "identity.py");
130
+ if (!fs.existsSync(idScript))
131
+ return null;
132
+ const res = await runPython("scripts/identity.py", ["header"], { timeoutMs: 10_000 });
133
+ const header = (res.stdout || "").trim();
134
+ if (res.code === 0 && header) {
135
+ cachedInstallHeader = header;
136
+ return header;
137
+ }
138
+ }
139
+ catch {
140
+ /* best-effort */
141
+ }
142
+ return null;
143
+ }
144
+ // Buffer one raw line. Called from the repo.ts line sink, so it must be cheap
145
+ // and total non-throwing.
146
+ export function logLine(stream, line, context) {
147
+ if (!LOG_STREAM_ENABLED)
148
+ return;
149
+ try {
150
+ logBuffer.push({
151
+ ts: new Date().toISOString(),
152
+ stream,
153
+ line: line.length > LOG_MAX_LINE_LEN ? line.slice(0, LOG_MAX_LINE_LEN) : line,
154
+ context: context || "",
155
+ });
156
+ if (logBuffer.length > LOG_MAX_BUFFER) {
157
+ // Drop oldest to bound memory; the newest lines are the most useful.
158
+ logDropped += logBuffer.length - LOG_MAX_BUFFER;
159
+ logBuffer.splice(0, logBuffer.length - LOG_MAX_BUFFER);
160
+ }
161
+ if (logBuffer.length >= LOG_FLUSH_BATCH)
162
+ void flushLogs();
163
+ }
164
+ catch {
165
+ /* never throw into the run() boundary */
166
+ }
167
+ }
168
+ export async function flushLogs() {
169
+ if (!LOG_STREAM_ENABLED)
170
+ return;
171
+ if (logFlushing || logBuffer.length === 0)
172
+ return;
173
+ logFlushing = true;
174
+ try {
175
+ const header = await installHeader();
176
+ if (!header)
177
+ return; // runtime not unpacked yet; keep buffering
178
+ const base = (process.env.AUTOPOSTER_API_BASE || "https://s4l.ai").replace(/\/+$/, "");
179
+ // Drain in <=200-line POSTs until the buffer empties (or a POST fails).
180
+ while (logBuffer.length > 0) {
181
+ const batch = logBuffer.splice(0, LOG_MAX_PER_POST);
182
+ const lines = batch.map((b) => ({
183
+ ts: b.ts,
184
+ stream: b.stream,
185
+ line: b.line,
186
+ context: b.context || undefined,
187
+ }));
188
+ try {
189
+ const resp = await fetch(`${base}/api/v1/installations/logs`, {
190
+ method: "POST",
191
+ headers: { "X-Installation": header, "content-type": "application/json" },
192
+ body: JSON.stringify({ lines }),
193
+ signal: AbortSignal.timeout(15_000),
194
+ });
195
+ if (!resp.ok) {
196
+ // Drop this batch (don't re-buffer): a persistent 4xx/5xx would grow
197
+ // the buffer unbounded. The raw stream is best-effort.
198
+ console.error(`[social-autoposter-mcp] log flush http ${resp.status}`);
199
+ break;
200
+ }
201
+ }
202
+ catch (err) {
203
+ // Network blip: drop this batch, stop draining, try again next tick.
204
+ console.error("[social-autoposter-mcp] log flush failed:", err?.message || err);
205
+ break;
206
+ }
207
+ }
208
+ if (logDropped > 0) {
209
+ console.error(`[social-autoposter-mcp] log stream dropped ${logDropped} line(s) on overflow`);
210
+ logDropped = 0;
211
+ }
212
+ }
213
+ finally {
214
+ logFlushing = false;
215
+ }
216
+ }
217
+ // Register the repo.ts line sink and start the periodic flush. Idempotent.
218
+ export function startLogStreaming() {
219
+ if (!LOG_STREAM_ENABLED || logStreamingStarted)
220
+ return;
221
+ logStreamingStarted = true;
222
+ try {
223
+ setLineSink((line, stream, context) => logLine(stream, line, context));
224
+ logTimer = setInterval(() => void flushLogs(), LOG_FLUSH_MS);
225
+ logTimer.unref();
226
+ }
227
+ catch (err) {
228
+ console.error("[social-autoposter-mcp] log streaming start failed:", err?.message || err);
229
+ }
230
+ }
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.83",
3
- "installedAt": "2026-06-23T02:17:06.705Z"
2
+ "version": "1.6.84",
3
+ "installedAt": "2026-06-23T02:53:37.786Z"
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.6.83",
5
+ "version": "1.6.84",
6
6
  "description": "Draft, review, approve, and autopilot X/Twitter posts. Thin desktop client over the S4L pipeline.",
7
7
  "long_description": "A guided assistant that drafts, reviews, and autopilots X/Twitter posts.\nTo get started:\n1. Click **Configure** and set every tool permission to **Always Allow**.\n2. Copy this prompt: **Set me up on S4L end to end**.\n3. Quit fully with CMD+Q, restart Claude, and paste the prompt into a new chat.",
8
8
  "author": {
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/social-autoposter-mcp",
3
- "version": "1.6.83",
3
+ "version": "1.6.84",
4
4
  "private": true,
5
5
  "description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
6
6
  "license": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.6.83",
3
+ "version": "1.6.84",
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"
@@ -36,6 +36,16 @@ CONFIG_PATH = os.path.join(REPO_DIR, "config.json")
36
36
  REPLY_DB = os.path.join(REPO_DIR, "scripts", "reply_db.py")
37
37
  SKILL_FILE = os.path.join(REPO_DIR, "SKILL.md")
38
38
 
39
+ # Interpreter every child subprocess must run under. A bare PYTHON resolved
40
+ # to the user's system python, which lacks the pipeline deps that live only in
41
+ # the owned uv runtime — the same fresh-box failure class that broke the Twitter
42
+ # poster (Karol, 2026-06-22). The GitHub rail posts via the REST API (no browser,
43
+ # so no Playwright dep), but its util/DB children still need the owned venv, so
44
+ # pin the interpreter here too. Honor SAPS_PYTHON (set by the launchd plist),
45
+ # else sys.executable; never the literal PYTHON.
46
+ PYTHON = os.environ.get("SAPS_PYTHON") or sys.executable
47
+ os.environ["SAPS_PYTHON"] = PYTHON
48
+
39
49
  # Cap the thread JSON we pass to Claude. Long issues with 100+ comments would
40
50
  # otherwise blow the prompt budget. 12k chars is ~3k tokens, enough for most
41
51
  # threads while leaving headroom for the rules and output.
@@ -444,7 +454,7 @@ def main():
444
454
 
445
455
  # Exclusion: author
446
456
  if (reply["their_author"] or "").lower() in excluded_authors:
447
- subprocess.run(["python3", REPLY_DB, "skipped", str(reply["id"]), "excluded_author"])
457
+ subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]), "excluded_author"])
448
458
  print(f"[engage_github] #{reply['id']} skipped (excluded_author: {reply['their_author']})")
449
459
  skipped += 1
450
460
  processed += 1
@@ -453,7 +463,7 @@ def main():
453
463
  # Parse owner/repo/number from thread_url
454
464
  owner, repo, number = parse_issue_url(reply["thread_url"] or "")
455
465
  if not owner:
456
- subprocess.run(["python3", REPLY_DB, "skipped", str(reply["id"]), "bad_thread_url"])
466
+ subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]), "bad_thread_url"])
457
467
  print(f"[engage_github] #{reply['id']} skipped (bad_thread_url: {reply['thread_url']})")
458
468
  skipped += 1
459
469
  processed += 1
@@ -462,7 +472,7 @@ def main():
462
472
  # Exclusion: repo
463
473
  repo_key = f"{owner}/{repo}".lower()
464
474
  if repo_key in excluded_repos or owner.lower() in excluded_repos:
465
- subprocess.run(["python3", REPLY_DB, "skipped", str(reply["id"]), "excluded_repo"])
475
+ subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]), "excluded_repo"])
466
476
  print(f"[engage_github] #{reply['id']} skipped (excluded_repo: {repo_key})")
467
477
  skipped += 1
468
478
  processed += 1
@@ -472,7 +482,7 @@ def main():
472
482
  print(f"[engage_github] Fetching thread for {owner}/{repo}#{number}")
473
483
  thread = fetch_thread(owner, repo, number)
474
484
  if "_error" in thread:
475
- subprocess.run(["python3", REPLY_DB, "skipped", str(reply["id"]),
485
+ subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]),
476
486
  f"fetch_error: {thread['_error']}"])
477
487
  print(f"[engage_github] #{reply['id']} skipped (fetch_error: {thread['_error'][:100]})")
478
488
  skipped += 1
@@ -499,7 +509,7 @@ def main():
499
509
  ok, output, usage = run_claude(prompt, timeout=args.per_reply_timeout, session_id=session_id)
500
510
  reply_elapsed = time.time() - reply_start
501
511
  session_ended_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
502
- log_args = ["python3", os.path.join(REPO_DIR, "scripts", "log_claude_session.py"),
512
+ log_args = [PYTHON, os.path.join(REPO_DIR, "scripts", "log_claude_session.py"),
503
513
  "--session-id", session_id, "--script", "engage_github",
504
514
  "--started-at", session_started_at, "--ended-at", session_ended_at]
505
515
  orch_cost = usage.get("cost_usd")
@@ -514,7 +524,7 @@ def main():
514
524
  failed += 1
515
525
  consecutive_failures += 1
516
526
  # Mark as skipped so the loop advances to the next pending reply
517
- subprocess.run(["python3", REPLY_DB, "skipped", str(reply["id"]), "claude_error"],
527
+ subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]), "claude_error"],
518
528
  capture_output=True)
519
529
  print(f"[engage_github] #{reply['id']} CLAUDE FAILED ({reply_elapsed:.0f}s): {output[:200]}")
520
530
  else:
@@ -540,7 +550,7 @@ def main():
540
550
  classification = parts[2].strip() if len(parts) > 2 and parts[2].strip() in ("bot", "engagement_loop") else "bot"
541
551
  if handle:
542
552
  bl_cmd = [
543
- "python3", REPLY_DB, "blocklist", "add",
553
+ PYTHON, REPLY_DB, "blocklist", "add",
544
554
  "github_issues", handle,
545
555
  "--reason", f"engage_llm judgment: {reason}",
546
556
  "--classification", classification,
@@ -552,7 +562,7 @@ def main():
552
562
  print(f"[engage_github] blocklist add github_issues/{handle} cls={classification}")
553
563
  else:
554
564
  print(f"[engage_github] blocklist add FAILED for {handle}: {bl_res.stderr[:200]}")
555
- subprocess.run(["python3", REPLY_DB, "skipped", str(reply["id"]), reason])
565
+ subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]), reason])
556
566
  skipped += 1
557
567
  print(f"[engage_github] #{reply['id']} SKIPPED: {reason} ({reply_elapsed:.0f}s) "
558
568
  f"[${usage['cost_usd']:.4f}]")
@@ -560,14 +570,14 @@ def main():
560
570
  reply_text = (decision.get("text") or "").strip()
561
571
  project = decision.get("project")
562
572
  if not reply_text:
563
- subprocess.run(["python3", REPLY_DB, "skipped", str(reply["id"]), "empty_reply_text"])
573
+ subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]), "empty_reply_text"])
564
574
  failed += 1
565
575
  print(f"[engage_github] #{reply['id']} empty reply text, marked skipped")
566
576
  else:
567
- subprocess.run(["python3", REPLY_DB, "processing", str(reply["id"])])
577
+ subprocess.run([PYTHON, REPLY_DB, "processing", str(reply["id"])])
568
578
  ok_post, url_or_err = post_comment(owner, repo, number, reply_text)
569
579
  if ok_post:
570
- cmd_args = ["python3", REPLY_DB, "replied", str(reply["id"]), reply_text]
580
+ cmd_args = [PYTHON, REPLY_DB, "replied", str(reply["id"]), reply_text]
571
581
  if url_or_err:
572
582
  cmd_args.append(url_or_err)
573
583
  style = decision.get("engagement_style", "")
@@ -578,14 +588,14 @@ def main():
578
588
  subprocess.run(cmd_args)
579
589
  if project:
580
590
  subprocess.run(
581
- ["python3", REPLY_DB, "set_project", str(reply["id"]), project],
591
+ [PYTHON, REPLY_DB, "set_project", str(reply["id"]), project],
582
592
  capture_output=True,
583
593
  )
584
594
  succeeded += 1
585
595
  print(f"[engage_github] #{reply['id']} POSTED ({reply_elapsed:.0f}s) "
586
596
  f"[${usage['cost_usd']:.4f}] -> {url_or_err or '(no url)'}")
587
597
  else:
588
- subprocess.run(["python3", REPLY_DB, "skipped", str(reply["id"]),
598
+ subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]),
589
599
  f"post_error: {url_or_err}"])
590
600
  failed += 1
591
601
  print(f"[engage_github] #{reply['id']} POST FAILED: {url_or_err}")
@@ -623,7 +633,7 @@ def main():
623
633
  f" elapsed={int(total_elapsed)}"
624
634
  )
625
635
 
626
- subprocess.run(["python3", REPLY_DB, "status"])
636
+ subprocess.run([PYTHON, REPLY_DB, "status"])
627
637
 
628
638
 
629
639
  if __name__ == "__main__":
@@ -33,6 +33,17 @@ CAMPAIGN_BUMP = os.path.join(REPO_DIR, "scripts", "campaign_bump.py")
33
33
  REDDIT_MCP_CONFIG = os.path.expanduser("~/.claude/browser-agent-configs/reddit-agent-mcp.json")
34
34
  REDDIT_BROWSER_LOCK = os.path.join(REPO_DIR, "scripts", "reddit_browser_lock.py")
35
35
 
36
+ # Interpreter every child subprocess must run under. A bare PYTHON resolved
37
+ # to the user's system python, which lacks the pipeline deps (Playwright and
38
+ # friends) that live only in the owned uv runtime — so on a fresh box every
39
+ # reddit_browser.py reply died (the same class as the Karol/Twitter bug,
40
+ # 2026-06-22). Honor the authoritative SAPS_PYTHON pin (set by the launchd
41
+ # plist), else sys.executable (the owned interpreter the MCP launches us under).
42
+ # Never the literal PYTHON: that re-rolls the PATH dice. Re-exported so
43
+ # grandchildren inherit it.
44
+ PYTHON = os.environ.get("SAPS_PYTHON") or sys.executable
45
+ os.environ["SAPS_PYTHON"] = PYTHON
46
+
36
47
  from engagement_styles import (
37
48
  REPLY_STYLES as VALID_STYLES,
38
49
  get_styles_prompt,
@@ -65,7 +76,7 @@ def _acquire_browser_lease(timeout: int = 600, ttl: int = 90):
65
76
  """
66
77
  try:
67
78
  r = subprocess.run(
68
- ["python3", REDDIT_BROWSER_LOCK, "acquire",
79
+ [PYTHON, REDDIT_BROWSER_LOCK, "acquire",
69
80
  "--timeout", str(timeout), "--ttl", str(ttl)],
70
81
  capture_output=True, text=True, timeout=timeout + 30,
71
82
  )
@@ -84,7 +95,7 @@ def _release_browser_lease() -> None:
84
95
  """Release the reddit-browser lease. Idempotent (NOT_HELD is fine)."""
85
96
  try:
86
97
  subprocess.run(
87
- ["python3", REDDIT_BROWSER_LOCK, "release"],
98
+ [PYTHON, REDDIT_BROWSER_LOCK, "release"],
88
99
  capture_output=True, text=True, timeout=10,
89
100
  )
90
101
  except Exception:
@@ -162,7 +173,7 @@ def bump_campaigns(table, row_id, campaign_ids):
162
173
  for cid in campaign_ids:
163
174
  try:
164
175
  subprocess.run(
165
- ["python3", CAMPAIGN_BUMP,
176
+ [PYTHON, CAMPAIGN_BUMP,
166
177
  "--table", table, "--id", str(row_id), "--campaign-id", str(cid)],
167
178
  capture_output=True, text=True, timeout=15,
168
179
  )
@@ -649,11 +660,28 @@ def main():
649
660
  config = load_config()
650
661
  excluded_authors = config.get("exclusions", {}).get("authors", [])
651
662
 
663
+ # Hard preflight: the reddit rail posts replies via reddit_browser.py, the
664
+ # only Playwright importer here (Moltbook uses its own poster). If the
665
+ # resolved interpreter can't import Playwright the owned runtime is missing
666
+ # or half-provisioned and every reply would die with CDP_ERROR. Fail LOUD
667
+ # with a distinct signal instead. Moltbook is exempt (no browser path).
668
+ if args.platform == "reddit":
669
+ _chk = subprocess.run(
670
+ [PYTHON, "-c", "import playwright"],
671
+ capture_output=True, text=True,
672
+ )
673
+ if _chk.returncode != 0:
674
+ print(f"[engage_reddit] FATAL runtime_incomplete: interpreter {PYTHON!r} "
675
+ f"cannot import playwright — the owned Python runtime is missing or "
676
+ f"unprovisioned. Run the `runtime` install (action:'install') before "
677
+ f"engaging. stderr: {(_chk.stderr or '').strip()[:300]}", file=sys.stderr)
678
+ sys.exit(3)
679
+
652
680
  reset_stuck_processing(args.platform)
653
681
 
654
682
  try:
655
683
  top_report = subprocess.check_output(
656
- ["python3", os.path.join(REPO_DIR, "scripts", "top_performers.py"), "--platform", args.platform],
684
+ [PYTHON, os.path.join(REPO_DIR, "scripts", "top_performers.py"), "--platform", args.platform],
657
685
  text=True, stderr=subprocess.DEVNULL, timeout=30,
658
686
  )
659
687
  except Exception:
@@ -690,7 +718,7 @@ def main():
690
718
 
691
719
  # Check exclusion before spawning Claude
692
720
  if reply["their_author"].lower() in {a.lower() for a in excluded_authors}:
693
- subprocess.run(["python3", REPLY_DB, "skipped", str(reply["id"]), "excluded_author"])
721
+ subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]), "excluded_author"])
694
722
  print(f"[engage_reddit] #{reply['id']} skipped (excluded_author: {reply['their_author']})")
695
723
  skipped += 1
696
724
  skip_reasons["excluded_author"] += 1
@@ -711,7 +739,7 @@ def main():
711
739
  f":interest={same_post_disengage['interest_level']}"
712
740
  f":status={same_post_disengage['conversation_status']}"
713
741
  )
714
- subprocess.run(["python3", REPLY_DB, "skipped", str(reply["id"]), reason])
742
+ subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]), reason])
715
743
  print(f"[engage_reddit] #{reply['id']} skipped ({reason})")
716
744
  skipped += 1
717
745
  skip_reasons["cross_pipeline_disengage"] += 1
@@ -775,7 +803,7 @@ def main():
775
803
  # reset_stuck_processing's 2h cap brings it back to pending.
776
804
  try:
777
805
  subprocess.run(
778
- ["python3", REPLY_DB, "processing", str(reply["id"])],
806
+ [PYTHON, REPLY_DB, "processing", str(reply["id"])],
779
807
  capture_output=True, timeout=10,
780
808
  )
781
809
  except Exception:
@@ -796,7 +824,7 @@ def main():
796
824
  ok, output, usage = run_claude(prompt, timeout=args.per_reply_timeout, session_id=session_id)
797
825
  reply_elapsed = time.time() - reply_start
798
826
  session_ended_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
799
- log_args = ["python3", os.path.join(REPO_DIR, "scripts", "log_claude_session.py"),
827
+ log_args = [PYTHON, os.path.join(REPO_DIR, "scripts", "log_claude_session.py"),
800
828
  "--session-id", session_id, "--script", "engage_reddit",
801
829
  "--started-at", session_started_at, "--ended-at", session_ended_at]
802
830
  orch_cost = usage.get("cost_usd")
@@ -853,7 +881,7 @@ def main():
853
881
  reason_key = "timeout" if output == "TIMEOUT" else "claude_failed"
854
882
  skip_reasons[reason_key] += 1
855
883
  try:
856
- subprocess.run(["python3", REPLY_DB, "processing", str(reply["id"])],
884
+ subprocess.run([PYTHON, REPLY_DB, "processing", str(reply["id"])],
857
885
  capture_output=True, timeout=10)
858
886
  except Exception:
859
887
  pass
@@ -877,14 +905,14 @@ def main():
877
905
  # Same loop-prevention as the not-ok branch: mark processing
878
906
  # so the next iteration moves to a different pending row.
879
907
  try:
880
- subprocess.run(["python3", REPLY_DB, "processing", str(reply["id"])],
908
+ subprocess.run([PYTHON, REPLY_DB, "processing", str(reply["id"])],
881
909
  capture_output=True, timeout=10)
882
910
  except Exception:
883
911
  pass
884
912
  print(f"[engage_reddit] #{reply['id']} BAD OUTPUT ({reply_elapsed:.0f}s): {output[:200]}")
885
913
  elif decision.get("action") == "skip":
886
914
  reason = decision.get("reason", "unknown")
887
- subprocess.run(["python3", REPLY_DB, "skipped", str(reply["id"]), reason])
915
+ subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]), reason])
888
916
  skipped += 1
889
917
  skip_reasons[f"llm:{reason[:48]}"] += 1
890
918
  print(f"[engage_reddit] #{reply['id']} skipped: {reason} ({reply_elapsed:.0f}s) "
@@ -924,7 +952,7 @@ def main():
924
952
  # entire run on any non-zero exit so the row stays untouched and
925
953
  # no platform side-effect occurs.
926
954
  proc_result = subprocess.run(
927
- ["python3", REPLY_DB, "processing", str(reply["id"])],
955
+ [PYTHON, REPLY_DB, "processing", str(reply["id"])],
928
956
  capture_output=True,
929
957
  )
930
958
  if proc_result.returncode != 0:
@@ -1012,7 +1040,7 @@ def main():
1012
1040
  for attempt in range(3):
1013
1041
  try:
1014
1042
  out = subprocess.check_output(
1015
- ["python3", os.path.join(REPO_DIR, "scripts", "moltbook_post.py"),
1043
+ [PYTHON, os.path.join(REPO_DIR, "scripts", "moltbook_post.py"),
1016
1044
  "comment",
1017
1045
  "--post-id", post_uuid,
1018
1046
  "--parent-id", parent_id,
@@ -1034,7 +1062,7 @@ def main():
1034
1062
  for attempt in range(3):
1035
1063
  try:
1036
1064
  cdp_out = subprocess.check_output(
1037
- ["python3", os.path.join(REPO_DIR, "scripts", "reddit_browser.py"),
1065
+ [PYTHON, os.path.join(REPO_DIR, "scripts", "reddit_browser.py"),
1038
1066
  "reply", reply["their_comment_url"], reply_text],
1039
1067
  text=True, timeout=120, stderr=subprocess.DEVNULL,
1040
1068
  )
@@ -1051,7 +1079,7 @@ def main():
1051
1079
  if post_result.get("already_replied"):
1052
1080
  existing = post_result.get("existing_text", "")
1053
1081
  existing_url = post_result.get("existing_url", "")
1054
- cmd_args = ["python3", REPLY_DB, "replied", str(reply["id"]), existing]
1082
+ cmd_args = [PYTHON, REPLY_DB, "replied", str(reply["id"]), existing]
1055
1083
  if existing_url:
1056
1084
  cmd_args.append(existing_url)
1057
1085
  patch_replied_with_retry(cmd_args, reply["id"])
@@ -1070,7 +1098,7 @@ def main():
1070
1098
  # 'processing' (which 2h reset_stuck_processing would flip
1071
1099
  # back to 'pending' and cause a duplicate post).
1072
1100
  reply_url = post_result.get("url", "")
1073
- cmd_args = ["python3", REPLY_DB, "replied", str(reply["id"]), reply_text, reply_url]
1101
+ cmd_args = [PYTHON, REPLY_DB, "replied", str(reply["id"]), reply_text, reply_url]
1074
1102
  if engagement_style:
1075
1103
  cmd_args.append(engagement_style)
1076
1104
  patch_replied_with_retry(cmd_args, reply["id"])
@@ -1097,7 +1125,7 @@ def main():
1097
1125
  if reply["platform"] == "reddit":
1098
1126
  try:
1099
1127
  subprocess.run(
1100
- ["python3",
1128
+ [PYTHON,
1101
1129
  os.path.join(REPO_DIR, "scripts", "dm_conversation.py"),
1102
1130
  "ensure-dm",
1103
1131
  "--platform", "reddit",
@@ -1114,7 +1142,7 @@ def main():
1114
1142
  # mutations.
1115
1143
  if project:
1116
1144
  subprocess.run(
1117
- ["python3", REPLY_DB, "set_project",
1145
+ [PYTHON, REPLY_DB, "set_project",
1118
1146
  str(reply["id"]), project],
1119
1147
  capture_output=True,
1120
1148
  )
@@ -1123,7 +1151,7 @@ def main():
1123
1151
  f"[${usage['cost_usd']:.4f}]")
1124
1152
  else:
1125
1153
  err = post_result.get("error", "unknown") if post_result else "no_response"
1126
- subprocess.run(["python3", REPLY_DB, "skipped", str(reply["id"]), f"CDP_ERROR: {err}"])
1154
+ subprocess.run([PYTHON, REPLY_DB, "skipped", str(reply["id"]), f"CDP_ERROR: {err}"])
1127
1155
  skip_reasons[f"cdp_error:{(err or 'unknown')[:32]}"] += 1
1128
1156
  failed += 1
1129
1157
  print(f"[engage_reddit] #{reply['id']} CDP FAILED: {err} ({reply_elapsed:.0f}s)")
@@ -1200,7 +1228,7 @@ def main():
1200
1228
 
1201
1229
  # Print final status (per-platform counts) via reply_db.py status helper,
1202
1230
  # which now reads /api/v1/replies/counts under the hood.
1203
- subprocess.run(["python3", REPLY_DB, "status", args.platform])
1231
+ subprocess.run([PYTHON, REPLY_DB, "status", args.platform])
1204
1232
 
1205
1233
 
1206
1234
  if __name__ == "__main__":
@@ -88,7 +88,7 @@ def _emit_run_summary_oneshot():
88
88
  try:
89
89
  subprocess.run(
90
90
  [
91
- "python3", os.path.join(os.path.dirname(os.path.abspath(__file__)), "log_run.py"),
91
+ PYTHON, os.path.join(os.path.dirname(os.path.abspath(__file__)), "log_run.py"),
92
92
  "--script", "post_github",
93
93
  "--posted", str(_RUN_STATE["posted"]),
94
94
  "--skipped", str(_RUN_STATE["skipped"]),
@@ -143,6 +143,16 @@ SKILL_FILE = os.path.join(REPO_DIR, "SKILL.md")
143
143
  GITHUB_TOOLS = os.path.join(SCRIPTS, "github_tools.py")
144
144
  RUN_CLAUDE = os.path.join(SCRIPTS, "run_claude.sh")
145
145
 
146
+ # Interpreter every child subprocess must run under. A bare PYTHON resolved
147
+ # to the user's system python, which lacks the pipeline deps that live only in
148
+ # the owned uv runtime — the same fresh-box failure class that broke the Twitter
149
+ # poster (Karol, 2026-06-22). The GitHub rail posts via the REST API (no browser,
150
+ # so no Playwright dep), but its util/DB children still need the owned venv, so
151
+ # pin the interpreter here too. Honor SAPS_PYTHON (set by the launchd plist),
152
+ # else sys.executable; never the literal PYTHON.
153
+ PYTHON = os.environ.get("SAPS_PYTHON") or sys.executable
154
+ os.environ["SAPS_PYTHON"] = PYTHON
155
+
146
156
  # Momentum tunables. Edit here, not at call sites.
147
157
  DELTA_THRESHOLD = 1.0
148
158
  HIGH_DELTA_BUMP = 3
@@ -178,7 +188,7 @@ def load_config():
178
188
  def get_top_performers(project_name, platform="github"):
179
189
  try:
180
190
  result = subprocess.run(
181
- ["python3", os.path.join(SCRIPTS, "top_performers.py"),
191
+ [PYTHON, os.path.join(SCRIPTS, "top_performers.py"),
182
192
  "--platform", platform, "--project", project_name],
183
193
  capture_output=True, text=True, timeout=15,
184
194
  )
@@ -194,7 +204,7 @@ def get_top_search_topics(project_name, platform="github", limit=8, window_days=
194
204
  Empty string if no data yet. Mirrors post_reddit.get_top_search_topics."""
195
205
  try:
196
206
  result = subprocess.run(
197
- ["python3", os.path.join(SCRIPTS, "top_search_topics.py"),
207
+ [PYTHON, os.path.join(SCRIPTS, "top_search_topics.py"),
198
208
  "--project", project_name, "--platform", platform,
199
209
  "--window-days", str(window_days), "--limit", str(limit)],
200
210
  capture_output=True, text=True, timeout=15,
@@ -287,7 +297,7 @@ def build_content_angle(project, config):
287
297
  def gh_search(query, limit=SEARCH_PER_TOPIC):
288
298
  try:
289
299
  out = subprocess.check_output(
290
- ["python3", GITHUB_TOOLS, "search", query, "--limit", str(limit)],
300
+ [PYTHON, GITHUB_TOOLS, "search", query, "--limit", str(limit)],
291
301
  text=True, timeout=45,
292
302
  )
293
303
  items = json.loads(out)
@@ -727,7 +737,7 @@ def log_post(thread_url, our_url, text, project_name, thread_author, thread_titl
727
737
  curated landing-page hits from generic homepage links.
728
738
  """
729
739
  try:
730
- cmd = ["python3", GITHUB_TOOLS, "log-post",
740
+ cmd = [PYTHON, GITHUB_TOOLS, "log-post",
731
741
  thread_url, our_url or "", text, project_name,
732
742
  thread_author or "unknown", thread_title or "",
733
743
  "--account", github_username]
@@ -1018,7 +1028,7 @@ def main():
1018
1028
  log(f"Claude FAILED: {output[:300]}")
1019
1029
  _RUN_STATE["failed"] = 1
1020
1030
  subprocess.run([
1021
- "python3", os.path.join(SCRIPTS, "log_run.py"),
1031
+ PYTHON, os.path.join(SCRIPTS, "log_run.py"),
1022
1032
  "--script", "post_github",
1023
1033
  "--posted", "0", "--skipped", "0", "--failed", "1",
1024
1034
  "--cost", f"{usage['cost_usd']:.4f}",
@@ -1286,7 +1296,7 @@ def main():
1286
1296
  _RUN_STATE["skipped"] = len(skipped)
1287
1297
  _RUN_STATE["cost"] = usage["cost_usd"]
1288
1298
  subprocess.run([
1289
- "python3", os.path.join(SCRIPTS, "log_run.py"),
1299
+ PYTHON, os.path.join(SCRIPTS, "log_run.py"),
1290
1300
  "--script", "post_github",
1291
1301
  "--posted", str(posted),
1292
1302
  "--skipped", str(len(skipped)),
@@ -90,6 +90,30 @@ def capture_exception(err, tags=None) -> None:
90
90
  return
91
91
 
92
92
 
93
+ def capture_message(message, level="error", tags=None) -> None:
94
+ """Report a handled, non-exception condition to Sentry (e.g. a post that
95
+ failed gracefully and returned a reason instead of raising). The global
96
+ excepthook only catches UNHANDLED exceptions, so operational failures that
97
+ are caught + reported as a result count would otherwise never reach Sentry.
98
+ Safe to call even if init() never ran or sentry-sdk is missing (no-op)."""
99
+ if not _initialized:
100
+ return
101
+ try:
102
+ import sentry_sdk
103
+ except Exception:
104
+ return
105
+ try:
106
+ if tags:
107
+ with sentry_sdk.push_scope() as scope:
108
+ for k, v in tags.items():
109
+ scope.set_tag(str(k), str(v))
110
+ sentry_sdk.capture_message(str(message), level=level)
111
+ else:
112
+ sentry_sdk.capture_message(str(message), level=level)
113
+ except Exception:
114
+ return
115
+
116
+
93
117
  def flush(timeout: float = 2.0) -> None:
94
118
  """Block until queued events are sent (best-effort). Call before a short-lived
95
119
  or about-to-crash process exits so a just-captured event isn't dropped on
@@ -97,6 +97,18 @@ try:
97
97
  except Exception:
98
98
  validate_or_register = None # type: ignore[assignment]
99
99
 
100
+ # Reasons that signal an OPERATIONAL post failure (browser/session/API broke),
101
+ # as opposed to a content-judgment skip ("off-topic ..."). Some of these are
102
+ # bucketed as `skipped` in the run summary (e.g. reply_box_not_found) so they do
103
+ # not pollute the dashboard "failed" pill, but for remote observability they ARE
104
+ # the signal that a user "approved but couldn't post" — so we capture them to
105
+ # Sentry regardless of which summary bucket they land in.
106
+ MACHINE_FAIL_REASONS = {
107
+ "no_reply_json", "reply_failed", "timeout", "unknown", "exception",
108
+ "log_post_no_id", "reply_box_not_found", "rate_limited", "tweet_not_found",
109
+ "no_reply_url_captured", "empty_reply_text", "session_invalid",
110
+ }
111
+
100
112
  REPLY_URL_RE = re.compile(r"^https?://(?:x\.com|twitter\.com)/[^/]+/status/\d+")
101
113
  TOP_LEVEL_OBJ_RE = re.compile(r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}", re.DOTALL)
102
114
 
@@ -1035,6 +1047,37 @@ def main() -> int:
1035
1047
  "failure_reasons": ",".join(f"{k}:{v}" for k, v in fail_reasons.items()),
1036
1048
  "skip_reasons": ",".join(f"{k}:{v}" for k, v in skip_reasons.items()),
1037
1049
  }
1050
+ # Remote observability: a handled post failure returns a reason instead of
1051
+ # raising, so the global Sentry excepthook never sees it. On customer .mcpb
1052
+ # installs the cycle log lives only on their machine, so an explicit capture
1053
+ # here is the ONLY channel that surfaces "approved but didn't post" to us.
1054
+ # Fires only on operational/machine reasons (content-judgment skips like
1055
+ # "off-topic ..." are excluded), so it never alerts on a healthy dedup cycle.
1056
+ try:
1057
+ machine = dict(fail_reasons)
1058
+ for _k, _v in skip_reasons.items():
1059
+ if _k in MACHINE_FAIL_REASONS:
1060
+ machine[_k] = machine.get(_k, 0) + _v
1061
+ if machine:
1062
+ import sentry_init
1063
+ _top = max(machine, key=machine.get)
1064
+ sentry_init.capture_message(
1065
+ "twitter post pipeline issues: "
1066
+ f"posted={posted} failed={failed} attempted={len(candidates)} "
1067
+ f"reasons={','.join(f'{k}:{v}' for k, v in machine.items())}",
1068
+ level=("error" if (failed > 0 or posted == 0) else "warning"),
1069
+ tags={
1070
+ "component": "twitter_post",
1071
+ "posted": str(posted),
1072
+ "failed": str(failed),
1073
+ "attempted": str(len(candidates)),
1074
+ "top_reason": _top,
1075
+ },
1076
+ )
1077
+ sentry_init.flush(2.0)
1078
+ except Exception:
1079
+ pass
1080
+
1038
1081
  # Persist a durable audit line so "did it post, and how many — and where?" is
1039
1082
  # answerable after the fact. The shell harvests the json on stdout, but when
1040
1083
  # the menu bar launches this directly it captures-then-discards stdout, leaving