social-autoposter 1.6.83 → 1.6.85
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/_lock_preempt_test.py +60 -0
- package/scripts/engage_github.py +24 -14
- package/scripts/engage_reddit.py +48 -20
- package/scripts/harness_overlay.py +5 -1
- package/scripts/link_tail.py +109 -4
- package/scripts/log_post.py +26 -16
- package/scripts/post_github.py +17 -7
- package/scripts/sentry_init.py +24 -0
- package/scripts/twitter_browser.py +164 -16
- package/scripts/twitter_post_plan.py +109 -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
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import json, os, sys, time, subprocess, signal
|
|
2
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
|
|
3
|
+
|
|
4
|
+
LOCK = os.path.expanduser("~/.claude/twitter-browser-lock.json")
|
|
5
|
+
os.makedirs(os.path.dirname(LOCK), exist_ok=True)
|
|
6
|
+
|
|
7
|
+
def write_lock(pid, role):
|
|
8
|
+
with open(LOCK, "w") as f:
|
|
9
|
+
json.dump({"session_id": f"python:{pid}", "timestamp": int(time.time()), "role": role}, f)
|
|
10
|
+
|
|
11
|
+
def clean():
|
|
12
|
+
try: os.remove(LOCK)
|
|
13
|
+
except OSError: pass
|
|
14
|
+
|
|
15
|
+
# ---- TEST 1: poster preempts a LIVE scan holder ----
|
|
16
|
+
clean()
|
|
17
|
+
victim = subprocess.Popen(["sleep", "120"])
|
|
18
|
+
write_lock(victim.pid, "scan")
|
|
19
|
+
os.environ["SAPS_LOCK_ROLE"] = "post"
|
|
20
|
+
import importlib, twitter_browser
|
|
21
|
+
importlib.reload(twitter_browser)
|
|
22
|
+
t0 = time.time()
|
|
23
|
+
twitter_browser._acquire_browser_lock()
|
|
24
|
+
dt = time.time() - t0
|
|
25
|
+
held = json.load(open(LOCK))
|
|
26
|
+
victim_alive = victim.poll() is None
|
|
27
|
+
print(f"TEST1 poster-preempts-scan: took={dt:.1f}s holder_now={held['session_id']} role={held.get('role')} victim_alive={victim_alive}")
|
|
28
|
+
assert held["session_id"] == f"python:{os.getpid()}", "poster should hold lock"
|
|
29
|
+
assert held.get("role") == "post"
|
|
30
|
+
assert not victim_alive, "scan victim should be killed"
|
|
31
|
+
assert dt < 10, "should preempt fast, not wait 45s"
|
|
32
|
+
if victim.poll() is None: victim.kill()
|
|
33
|
+
print("TEST1 PASS")
|
|
34
|
+
|
|
35
|
+
# ---- TEST 2: scanner does NOT preempt a live POST holder (waits then gives up) ----
|
|
36
|
+
clean()
|
|
37
|
+
poster = subprocess.Popen(["sleep", "120"])
|
|
38
|
+
write_lock(poster.pid, "post")
|
|
39
|
+
os.environ["SAPS_LOCK_ROLE"] = "scan"
|
|
40
|
+
twitter_browser.LOCK_WAIT_MAX = 4 # shorten the give-up window for the test
|
|
41
|
+
twitter_browser.LOCK_ROLE = "scan"
|
|
42
|
+
twitter_browser._LOCK_SESSION_ID = f"python:{os.getpid()}"
|
|
43
|
+
twitter_browser._LOCK_INHERITED = False
|
|
44
|
+
import io, contextlib
|
|
45
|
+
t0 = time.time()
|
|
46
|
+
rc = None
|
|
47
|
+
try:
|
|
48
|
+
twitter_browser._acquire_browser_lock()
|
|
49
|
+
rc = "acquired"
|
|
50
|
+
except SystemExit as e:
|
|
51
|
+
rc = f"exit:{e.code}"
|
|
52
|
+
dt = time.time() - t0
|
|
53
|
+
poster_alive = poster.poll() is None
|
|
54
|
+
print(f"TEST2 scanner-vs-post: result={rc} took={dt:.1f}s poster_alive={poster_alive}")
|
|
55
|
+
assert rc == "exit:1", "scanner must NOT take a live post holder; should give up"
|
|
56
|
+
assert poster_alive, "scanner must NOT kill the poster"
|
|
57
|
+
if poster.poll() is None: poster.kill()
|
|
58
|
+
print("TEST2 PASS")
|
|
59
|
+
clean()
|
|
60
|
+
print("ALL PASS")
|
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__":
|