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 +51 -7
- package/mcp/dist/repo.js +63 -0
- package/mcp/dist/telemetry.js +127 -1
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/engage_github.py +24 -14
- package/scripts/engage_reddit.py +48 -20
- package/scripts/post_github.py +17 -7
- package/scripts/sentry_init.py +24 -0
- package/scripts/twitter_post_plan.py +43 -0
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
|
-
//
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
|
|
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:
|
|
1217
|
-
|
|
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) => {
|
package/mcp/dist/telemetry.js
CHANGED
|
@@ -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
|
+
}
|
package/mcp/dist/version.json
CHANGED
package/mcp/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"dxt_version": "0.1",
|
|
3
3
|
"name": "social-autoposter",
|
|
4
4
|
"display_name": "S4L",
|
|
5
|
-
"version": "1.6.
|
|
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.
|
|
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
package/scripts/engage_github.py
CHANGED
|
@@ -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([
|
|
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([
|
|
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([
|
|
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([
|
|
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 = [
|
|
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([
|
|
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
|
-
|
|
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([
|
|
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([
|
|
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([
|
|
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 = [
|
|
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
|
-
[
|
|
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([
|
|
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([
|
|
636
|
+
subprocess.run([PYTHON, REPLY_DB, "status"])
|
|
627
637
|
|
|
628
638
|
|
|
629
639
|
if __name__ == "__main__":
|
package/scripts/engage_reddit.py
CHANGED
|
@@ -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
|
-
[
|
|
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
|
-
[
|
|
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
|
-
[
|
|
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
|
-
[
|
|
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([
|
|
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([
|
|
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
|
-
[
|
|
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 = [
|
|
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([
|
|
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([
|
|
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([
|
|
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
|
-
[
|
|
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
|
-
[
|
|
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
|
-
[
|
|
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 = [
|
|
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 = [
|
|
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
|
-
[
|
|
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
|
-
[
|
|
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([
|
|
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([
|
|
1231
|
+
subprocess.run([PYTHON, REPLY_DB, "status", args.platform])
|
|
1204
1232
|
|
|
1205
1233
|
|
|
1206
1234
|
if __name__ == "__main__":
|
package/scripts/post_github.py
CHANGED
|
@@ -88,7 +88,7 @@ def _emit_run_summary_oneshot():
|
|
|
88
88
|
try:
|
|
89
89
|
subprocess.run(
|
|
90
90
|
[
|
|
91
|
-
|
|
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
|
-
[
|
|
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
|
-
[
|
|
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
|
-
[
|
|
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 = [
|
|
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
|
-
|
|
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
|
-
|
|
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)),
|
package/scripts/sentry_init.py
CHANGED
|
@@ -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
|