social-autoposter 1.6.84 → 1.6.86
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 +1 -8
- package/mcp/dist/repo.js +0 -63
- package/mcp/dist/telemetry.js +1 -127
- 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/harness_overlay.py +5 -1
- package/scripts/link_tail.py +109 -4
- package/scripts/log_post.py +26 -16
- package/scripts/release-mcpb.sh +109 -20
- package/scripts/twitter_browser.py +164 -16
- package/scripts/twitter_post_plan.py +66 -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
|
|
25
|
+
import { initSentry, sendHeartbeat, captureError, flushSentry } 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";
|
|
@@ -616,7 +616,6 @@ async function postApproved(batchId, plan) {
|
|
|
616
616
|
});
|
|
617
617
|
void flushSentry(2000);
|
|
618
618
|
}
|
|
619
|
-
void flushLogs();
|
|
620
619
|
return {
|
|
621
620
|
attempted: approved.length,
|
|
622
621
|
posted: realPosted,
|
|
@@ -2384,11 +2383,6 @@ registerAppResource(server, "S4L product link", PRODUCT_LINK_URI, { mimeType: RE
|
|
|
2384
2383
|
}));
|
|
2385
2384
|
async function main() {
|
|
2386
2385
|
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();
|
|
2392
2386
|
// A plugin UPDATE refreshes this server (dist/) but not the materialized
|
|
2393
2387
|
// pipeline. Re-extract the bundled pipeline.tgz when it's newer than what's on
|
|
2394
2388
|
// disk, BEFORE serving, so the very first scan uses the shipped pipeline (not
|
|
@@ -2439,7 +2433,6 @@ async function main() {
|
|
|
2439
2433
|
main().catch(async (err) => {
|
|
2440
2434
|
console.error("[social-autoposter-mcp] fatal:", err);
|
|
2441
2435
|
captureError(err, { component: "main" });
|
|
2442
|
-
await flushLogs();
|
|
2443
2436
|
await flushSentry();
|
|
2444
2437
|
process.exit(1);
|
|
2445
2438
|
});
|
package/mcp/dist/repo.js
CHANGED
|
@@ -30,27 +30,6 @@ 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
|
-
}
|
|
54
33
|
// Spawn a process inside the repo, inheriting the repo env (API base + keys
|
|
55
34
|
// come from the install's environment / .env loaded by the scripts themselves).
|
|
56
35
|
//
|
|
@@ -94,29 +73,6 @@ export function run(cmd, args, opts = {}) {
|
|
|
94
73
|
}
|
|
95
74
|
return buf;
|
|
96
75
|
};
|
|
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
|
-
};
|
|
120
76
|
let timer;
|
|
121
77
|
if (opts.timeoutMs) {
|
|
122
78
|
timer = setTimeout(() => {
|
|
@@ -127,13 +83,11 @@ export function run(cmd, args, opts = {}) {
|
|
|
127
83
|
const s = d.toString();
|
|
128
84
|
stdout += s;
|
|
129
85
|
outBuf = pump(s, "stdout", outBuf);
|
|
130
|
-
sinkOutBuf = sinkPump(s, "stdout", sinkOutBuf);
|
|
131
86
|
});
|
|
132
87
|
child.stderr.on("data", (d) => {
|
|
133
88
|
const s = d.toString();
|
|
134
89
|
stderr += s;
|
|
135
90
|
errBuf = pump(s, "stderr", errBuf);
|
|
136
|
-
sinkErrBuf = sinkPump(s, "stderr", sinkErrBuf);
|
|
137
91
|
});
|
|
138
92
|
child.on("close", (code) => {
|
|
139
93
|
if (timer)
|
|
@@ -155,23 +109,6 @@ export function run(cmd, args, opts = {}) {
|
|
|
155
109
|
/* ignore */
|
|
156
110
|
}
|
|
157
111
|
}
|
|
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
|
-
}
|
|
175
112
|
resolve({ code: code ?? -1, stdout, stderr });
|
|
176
113
|
});
|
|
177
114
|
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
|
|
12
|
+
import { repoDir, runPython } 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,129 +102,3 @@ 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.86",
|
|
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.86",
|
|
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")
|
|
@@ -173,7 +173,11 @@ window.__sapsPaint = function(payload){
|
|
|
173
173
|
// coordinates (Input.dispatchMouseEvent) and by Playwright hit-testing,
|
|
174
174
|
// both of which an opaque clickable card sitting over a target would eat.
|
|
175
175
|
s.position="fixed"; s.top="50%"; s.left="50%"; s.transform="translate(-50%,-50%)";
|
|
176
|
-
|
|
176
|
+
// Sit one below the announce modal (2147483647) so the one-time "S4L is
|
|
177
|
+
// running" notice + its OK button always stack ON TOP of this always-on
|
|
178
|
+
// status box. They're both screen-centered, so equal z-index would let
|
|
179
|
+
// whichever was appended last (this overlay) cover the OK button.
|
|
180
|
+
s.zIndex="2147483646"; s.pointerEvents="none"; s.maxWidth="460px";
|
|
177
181
|
s.boxSizing="border-box"; s.padding="10px 14px"; s.borderRadius="12px";
|
|
178
182
|
s.background="rgba(15,15,17,0.92)"; s.color="#fff";
|
|
179
183
|
s.font="13px/1.35 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif";
|
package/scripts/link_tail.py
CHANGED
|
@@ -42,6 +42,8 @@ Exit codes:
|
|
|
42
42
|
2 — argparse / IO failure before we could write any JSON
|
|
43
43
|
"""
|
|
44
44
|
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
45
47
|
import argparse
|
|
46
48
|
import json
|
|
47
49
|
import os
|
|
@@ -56,6 +58,62 @@ REPO_DIR = os.path.expanduser("~/social-autoposter")
|
|
|
56
58
|
RUN_CLAUDE_SH = os.path.join(REPO_DIR, "scripts", "run_claude.sh")
|
|
57
59
|
CONFIG_PATH = os.path.join(REPO_DIR, "config.json")
|
|
58
60
|
|
|
61
|
+
# --- X/Twitter length budget -------------------------------------------------
|
|
62
|
+
# X charges a FLAT 23 characters for any http/https URL (t.co wrapping),
|
|
63
|
+
# regardless of the link's real length. So the budget is fixed: text + 23 <= 280
|
|
64
|
+
# => at most 257 characters of text before the link. We only enforce this for
|
|
65
|
+
# twitter; reddit/linkedin have far larger ceilings and need no tail trim.
|
|
66
|
+
TWEET_LIMIT = 280
|
|
67
|
+
URL_WEIGHT = 23
|
|
68
|
+
TWITTER_TEXT_BUDGET = TWEET_LIMIT - URL_WEIGHT # 257 chars for everything but the URL
|
|
69
|
+
_URL_RE = re.compile(r"https?://\S+")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def x_weighted_len(text: str) -> int:
|
|
73
|
+
"""Character count the way X computes it: every URL counts as 23."""
|
|
74
|
+
if not text:
|
|
75
|
+
return 0
|
|
76
|
+
return len(_URL_RE.sub("x" * URL_WEIGHT, text))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _trim_to_chars(s: str, max_chars: int) -> str:
|
|
80
|
+
"""Trim `s` to at most `max_chars`, backing off to a word boundary so we
|
|
81
|
+
never chop mid-word, then stripping trailing punctuation/space."""
|
|
82
|
+
s = s.strip()
|
|
83
|
+
if max_chars <= 0:
|
|
84
|
+
return ""
|
|
85
|
+
if len(s) <= max_chars:
|
|
86
|
+
return s
|
|
87
|
+
cut = s[:max_chars].rstrip()
|
|
88
|
+
sp = cut.rfind(" ")
|
|
89
|
+
# only back off to the word boundary when it doesn't gut more than half
|
|
90
|
+
if sp > max_chars * 0.5:
|
|
91
|
+
cut = cut[:sp]
|
|
92
|
+
return cut.rstrip(" ,;:-")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def enforce_budget(text: str, link_url: str,
|
|
96
|
+
limit: int = TWEET_LIMIT) -> tuple[str, bool]:
|
|
97
|
+
"""Guarantee x_weighted_len(text) <= limit by trimming the BODY, never the
|
|
98
|
+
link. The link is the most important part of the reply and always stays at
|
|
99
|
+
the end. Returns (text, was_trimmed)."""
|
|
100
|
+
if x_weighted_len(text) <= limit:
|
|
101
|
+
return text, False
|
|
102
|
+
if link_url and link_url in text:
|
|
103
|
+
head, _, tail = text.rpartition(link_url)
|
|
104
|
+
head = head.rstrip()
|
|
105
|
+
tail = tail.strip() # normally empty; the URL ends the reply
|
|
106
|
+
# Budget left for the body after reserving the URL (23) + a joining
|
|
107
|
+
# space + any (rare) trailing chars after the URL.
|
|
108
|
+
max_head = limit - URL_WEIGHT - 1 - len(tail)
|
|
109
|
+
trimmed_head = _trim_to_chars(head, max_head)
|
|
110
|
+
joined = (trimmed_head + " " + link_url).strip()
|
|
111
|
+
if tail:
|
|
112
|
+
joined = (joined + " " + tail).strip()
|
|
113
|
+
return joined, True
|
|
114
|
+
# No link present (shouldn't happen on the twitter path): hard word-trim.
|
|
115
|
+
return _trim_to_chars(text, limit), True
|
|
116
|
+
|
|
59
117
|
|
|
60
118
|
def resolve_voice_relationship(project: str) -> str:
|
|
61
119
|
"""Look up the matched project's `voice_relationship` field in config.json.
|
|
@@ -151,6 +209,26 @@ def build_prompt(*, reply_text: str, link_url: str, thread_text: str,
|
|
|
151
209
|
f" - \"we ship the same recall-on-revisit pattern in {project}, scores against a 4-axis rubric, {link_url}\""
|
|
152
210
|
)
|
|
153
211
|
|
|
212
|
+
# X-specific hard length budget. Pass the agent the current body length AND
|
|
213
|
+
# the budget so it self-fits by compressing the body, keeping the link at
|
|
214
|
+
# the end. Other platforms (reddit/linkedin) have far larger ceilings, so we
|
|
215
|
+
# add no tight cap there.
|
|
216
|
+
length_rule = ""
|
|
217
|
+
if platform == "twitter":
|
|
218
|
+
body_len = len(reply_text or "")
|
|
219
|
+
length_rule = (
|
|
220
|
+
f"HARD LENGTH LIMIT (X counts EVERY link as exactly 23 characters, "
|
|
221
|
+
f"no matter how long it looks):\n"
|
|
222
|
+
f"- The entire final reply must be \u2264 {TWEET_LIMIT} characters with "
|
|
223
|
+
f"the URL counted as {URL_WEIGHT}. That means everything EXCEPT the "
|
|
224
|
+
f"URL must total \u2264 {TWITTER_TEXT_BUDGET} characters.\n"
|
|
225
|
+
f"- The drafted body is currently {body_len} characters. If body + "
|
|
226
|
+
f"bridge would exceed {TWITTER_TEXT_BUDGET} chars of text, COMPRESS "
|
|
227
|
+
f"the body to make room: tighten wording, drop the weakest clause, "
|
|
228
|
+
f"but keep the single strongest claim and keep the URL at the very "
|
|
229
|
+
f"end. Never drop or move the link.\n"
|
|
230
|
+
)
|
|
231
|
+
|
|
154
232
|
return f"""You are writing the FINAL bridge sentence that folds a product link into a social media reply we already drafted. This is a one-shot task. Output ONLY the bridge sentence (no preamble, no explanation, no quotes).
|
|
155
233
|
|
|
156
234
|
PLATFORM: {platform}
|
|
@@ -159,6 +237,8 @@ LANDING PAGE URL: {link_url}
|
|
|
159
237
|
|
|
160
238
|
{voice_rule}
|
|
161
239
|
|
|
240
|
+
{length_rule}
|
|
241
|
+
|
|
162
242
|
ORIGINAL THREAD WE ARE REPLYING TO:
|
|
163
243
|
{thread_text}
|
|
164
244
|
|
|
@@ -289,7 +369,8 @@ THIRD_PARTY_VOICE_VIOLATIONS = (
|
|
|
289
369
|
|
|
290
370
|
|
|
291
371
|
def passes_quality_gate(final_text: str, link_url: str,
|
|
292
|
-
voice_relationship: str = "first_party"
|
|
372
|
+
voice_relationship: str = "first_party",
|
|
373
|
+
limit: int | None = None
|
|
293
374
|
) -> tuple[bool, str]:
|
|
294
375
|
"""Return (passes, reason_if_not).
|
|
295
376
|
|
|
@@ -327,6 +408,12 @@ def passes_quality_gate(final_text: str, link_url: str,
|
|
|
327
408
|
# Length sanity: model returning a 5-word stub is a fail.
|
|
328
409
|
if len(final_text.split()) < 8:
|
|
329
410
|
return (False, "too_short")
|
|
411
|
+
# Upper-length backstop (twitter): X-weighted length must fit the cap. The
|
|
412
|
+
# caller trims the body to fit BEFORE the gate, so this should only trip on
|
|
413
|
+
# a degenerate trim; on trip we fall back to the (also budget-enforced)
|
|
414
|
+
# mechanical concat.
|
|
415
|
+
if limit is not None and x_weighted_len(final_text) > limit:
|
|
416
|
+
return (False, f"too_long:{x_weighted_len(final_text)}>{limit}")
|
|
330
417
|
return (True, "")
|
|
331
418
|
|
|
332
419
|
|
|
@@ -375,6 +462,8 @@ def main() -> int:
|
|
|
375
462
|
return 0
|
|
376
463
|
|
|
377
464
|
voice_relationship = args.voice_relationship or resolve_voice_relationship(args.project)
|
|
465
|
+
# Length cap is X-specific; reddit/linkedin pass None (no tail trim).
|
|
466
|
+
limit = TWEET_LIMIT if args.platform == "twitter" else None
|
|
378
467
|
prompt = build_prompt(
|
|
379
468
|
reply_text=reply_text, link_url=link_url,
|
|
380
469
|
thread_text=(args.thread_text or "").strip()[:2000],
|
|
@@ -388,12 +477,16 @@ def main() -> int:
|
|
|
388
477
|
elapsed = round(time.time() - started, 2)
|
|
389
478
|
|
|
390
479
|
if not ok:
|
|
480
|
+
fb_text, fb_trim = enforce_budget(
|
|
481
|
+
mechanical_fallback(reply_text, link_url), link_url,
|
|
482
|
+
limit if limit is not None else TWEET_LIMIT * 100)
|
|
391
483
|
out = {
|
|
392
484
|
"ok": True,
|
|
393
|
-
"text":
|
|
485
|
+
"text": fb_text,
|
|
394
486
|
"tail": link_url,
|
|
395
487
|
"model_call_ok": False,
|
|
396
488
|
"fallback_used": True,
|
|
489
|
+
"budget_trimmed": fb_trim,
|
|
397
490
|
"error": err or "model_call_failed",
|
|
398
491
|
"elapsed_sec": elapsed,
|
|
399
492
|
}
|
|
@@ -401,15 +494,26 @@ def main() -> int:
|
|
|
401
494
|
return 0
|
|
402
495
|
|
|
403
496
|
cleaned = clean_output(raw)
|
|
497
|
+
# Trim the body to fit BEFORE the gate so a good model bridge is preserved
|
|
498
|
+
# (we trim the body, never the link) instead of being thrown away for being
|
|
499
|
+
# a few chars over. No-op on non-twitter (limit is None).
|
|
500
|
+
budget_trimmed = False
|
|
501
|
+
if limit is not None:
|
|
502
|
+
cleaned, budget_trimmed = enforce_budget(cleaned, link_url, limit)
|
|
404
503
|
passes, reason = passes_quality_gate(cleaned, link_url,
|
|
405
|
-
voice_relationship=voice_relationship
|
|
504
|
+
voice_relationship=voice_relationship,
|
|
505
|
+
limit=limit)
|
|
406
506
|
if not passes:
|
|
507
|
+
fb_text, fb_trim = enforce_budget(
|
|
508
|
+
mechanical_fallback(reply_text, link_url), link_url,
|
|
509
|
+
limit if limit is not None else TWEET_LIMIT * 100)
|
|
407
510
|
out = {
|
|
408
511
|
"ok": True,
|
|
409
|
-
"text":
|
|
512
|
+
"text": fb_text,
|
|
410
513
|
"tail": link_url,
|
|
411
514
|
"model_call_ok": True,
|
|
412
515
|
"fallback_used": True,
|
|
516
|
+
"budget_trimmed": fb_trim,
|
|
413
517
|
"error": f"quality_gate_failed:{reason}",
|
|
414
518
|
"raw_model_output": raw[:500],
|
|
415
519
|
"elapsed_sec": elapsed,
|
|
@@ -436,6 +540,7 @@ def main() -> int:
|
|
|
436
540
|
"tail": tail,
|
|
437
541
|
"model_call_ok": True,
|
|
438
542
|
"fallback_used": False,
|
|
543
|
+
"budget_trimmed": budget_trimmed,
|
|
439
544
|
"elapsed_sec": elapsed,
|
|
440
545
|
}
|
|
441
546
|
print(json.dumps(out), flush=True)
|
package/scripts/log_post.py
CHANGED
|
@@ -122,30 +122,40 @@ def parse_urn_ids(*sources):
|
|
|
122
122
|
|
|
123
123
|
VALID_PLATFORMS = ("reddit", "twitter", "linkedin", "github_issues", "moltbook")
|
|
124
124
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
"
|
|
129
|
-
"
|
|
125
|
+
# Maps log_post's platform strings to account_resolver's platform keys.
|
|
126
|
+
# (log_post uses "github_issues"; account_resolver uses "github".)
|
|
127
|
+
_RESOLVER_PLATFORM = {
|
|
128
|
+
"twitter": "twitter",
|
|
129
|
+
"reddit": "reddit",
|
|
130
|
+
"linkedin": "linkedin",
|
|
131
|
+
"github_issues": "github",
|
|
132
|
+
"moltbook": "moltbook",
|
|
130
133
|
}
|
|
131
134
|
|
|
132
135
|
|
|
133
136
|
def _resolve_default_account(platform: str) -> str:
|
|
134
|
-
"""Return the
|
|
137
|
+
"""Return the configured account handle for `platform` on this machine.
|
|
135
138
|
|
|
136
|
-
|
|
137
|
-
`
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
139
|
+
Resolved ONLY from env (`AUTOPOSTER_<PLATFORM>_*`) or config.json
|
|
140
|
+
(`accounts.<platform>.*`) via account_resolver. There are NO hardcoded
|
|
141
|
+
handle fallbacks: a misconfigured install must never silently post under
|
|
142
|
+
another person's identity. The old per-platform defaults
|
|
143
|
+
(m13v_/Deep_Ad1959/Matthew Diakonov/m13v/matthew-autoposter) did exactly
|
|
144
|
+
that, stamping every unconfigured install's rows with the repo owner's
|
|
145
|
+
handle and polluting the shared DB across accounts.
|
|
141
146
|
|
|
142
|
-
Returns
|
|
147
|
+
Returns "" when nothing is configured; the caller's
|
|
143
148
|
`args.account or _resolve_default_account(...)` chain still lets an
|
|
144
|
-
explicit `--account` flag win
|
|
149
|
+
explicit `--account` flag win, and an empty value surfaces the misconfig
|
|
150
|
+
instead of impersonating someone.
|
|
145
151
|
"""
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
152
|
+
try:
|
|
153
|
+
import account_resolver
|
|
154
|
+
return account_resolver.resolve(
|
|
155
|
+
_RESOLVER_PLATFORM.get(platform, platform)
|
|
156
|
+
) or ""
|
|
157
|
+
except Exception:
|
|
158
|
+
return ""
|
|
149
159
|
|
|
150
160
|
|
|
151
161
|
def coerce_engagement_style(args):
|
package/scripts/release-mcpb.sh
CHANGED
|
@@ -1,21 +1,35 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
#
|
|
2
|
+
# THE single release flow for social-autoposter. One command does EVERYTHING and
|
|
3
|
+
# keeps the npm path (Story A) and the .mcpb double-click path (Story B) on one
|
|
4
|
+
# version, because both derive from one bumped repo-root package.json.
|
|
3
5
|
#
|
|
4
6
|
# What it does, end to end:
|
|
5
|
-
# 1.
|
|
6
|
-
#
|
|
7
|
-
#
|
|
7
|
+
# 1. Bump the repo-root package.json (the SINGLE source of truth) and lockfile.
|
|
8
|
+
# Default: patch bump. --bump minor|major, or pin with --version / --tag,
|
|
9
|
+
# or --no-bump to re-release the current version as-is.
|
|
10
|
+
# 2. Build the MCP bundle: vite (panel) + tsc (server) + bundle-pipeline. The
|
|
11
|
+
# embedded pipeline.tgz is `npm pack` of the (now-bumped) repo, so the shell
|
|
12
|
+
# and the bundled Python pipeline CANNOT diverge.
|
|
13
|
+
# 3. Stamp dist/version.json + manifest.json + mcp/package.json(+lock) to match.
|
|
8
14
|
# 4. Pack mcp/ into mcp/social-autoposter.mcpb via the mcpb CLI.
|
|
9
|
-
# 5. Verify
|
|
10
|
-
#
|
|
15
|
+
# 5. Verify: size cap, embedded pipeline.tgz present, version.json + manifest +
|
|
16
|
+
# the pipeline.tgz's OWN internal version all == VERSION (the guard that was
|
|
17
|
+
# missing when 1.6.84 shipped a 1.6.83 pipeline), install tools present.
|
|
18
|
+
# 6. npm publish social-autoposter@VERSION (idempotent; skipped if already live).
|
|
19
|
+
# 7. Create/update GitHub release vX.Y.Z and upload the .mcpb (--clobber).
|
|
11
20
|
#
|
|
12
|
-
#
|
|
21
|
+
# Boxes self-update from the GitHub release: the menu-bar updater polls
|
|
22
|
+
# releases/latest, pulls the new .mcpb, and on next server boot
|
|
23
|
+
# ensurePipelineCurrent() re-extracts pipeline.tgz. No manual box step needed.
|
|
13
24
|
#
|
|
14
25
|
# Usage:
|
|
15
|
-
# bash scripts/release-mcpb.sh
|
|
16
|
-
# bash scripts/release-mcpb.sh --
|
|
17
|
-
# bash scripts/release-mcpb.sh --
|
|
18
|
-
# bash scripts/release-mcpb.sh --
|
|
26
|
+
# bash scripts/release-mcpb.sh # patch bump, npm + .mcpb + GitHub
|
|
27
|
+
# bash scripts/release-mcpb.sh --bump minor
|
|
28
|
+
# bash scripts/release-mcpb.sh --version 1.7.0 # pin an exact version
|
|
29
|
+
# bash scripts/release-mcpb.sh --no-bump # re-release current package.json version
|
|
30
|
+
# bash scripts/release-mcpb.sh --no-npm # skip npm publish (only .mcpb + GitHub)
|
|
31
|
+
# bash scripts/release-mcpb.sh --no-release # build + pack + verify only (no npm, no GitHub)
|
|
32
|
+
# bash scripts/release-mcpb.sh --draft # GitHub release as a draft
|
|
19
33
|
|
|
20
34
|
set -euo pipefail
|
|
21
35
|
|
|
@@ -29,38 +43,82 @@ GH_REPO="m13v/social-autoposter"
|
|
|
29
43
|
SIZE_CAP_MB=180
|
|
30
44
|
|
|
31
45
|
TAG_OVERRIDE=""
|
|
46
|
+
VERSION_OVERRIDE=""
|
|
32
47
|
DO_RELEASE=1
|
|
48
|
+
DO_NPM=1
|
|
49
|
+
DO_BUMP=1
|
|
50
|
+
BUMP_LEVEL="patch"
|
|
33
51
|
DRAFT_FLAG=""
|
|
34
52
|
|
|
35
53
|
while [[ $# -gt 0 ]]; do
|
|
36
54
|
case "$1" in
|
|
37
55
|
--tag) TAG_OVERRIDE="$2"; shift 2 ;;
|
|
38
56
|
--tag=*) TAG_OVERRIDE="${1#*=}"; shift ;;
|
|
57
|
+
--version) VERSION_OVERRIDE="$2"; shift 2 ;;
|
|
58
|
+
--version=*) VERSION_OVERRIDE="${1#*=}"; shift ;;
|
|
59
|
+
--bump) BUMP_LEVEL="$2"; shift 2 ;;
|
|
60
|
+
--bump=*) BUMP_LEVEL="${1#*=}"; shift ;;
|
|
61
|
+
--no-bump) DO_BUMP=0; shift ;;
|
|
62
|
+
--no-npm) DO_NPM=0; shift ;;
|
|
39
63
|
--no-release) DO_RELEASE=0; shift ;;
|
|
40
64
|
--draft) DRAFT_FLAG="--draft"; shift ;;
|
|
41
|
-
-h|--help) sed -n '2,
|
|
65
|
+
-h|--help) sed -n '2,38p' "$0"; exit 0 ;;
|
|
42
66
|
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
|
43
67
|
esac
|
|
44
68
|
done
|
|
45
69
|
|
|
70
|
+
case "$BUMP_LEVEL" in
|
|
71
|
+
patch|minor|major) ;;
|
|
72
|
+
*) echo "invalid --bump level: $BUMP_LEVEL (want patch|minor|major)" >&2; exit 2 ;;
|
|
73
|
+
esac
|
|
74
|
+
|
|
46
75
|
say() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
|
|
47
76
|
die() { printf '\033[1mERROR:\033[0m %s\n' "$*" >&2; exit 1; }
|
|
48
77
|
|
|
49
78
|
command -v node >/dev/null || die "node not found on PATH"
|
|
50
79
|
command -v mcpb >/dev/null || die "mcpb CLI not found (npm i -g @anthropic-ai/mcpb)"
|
|
51
80
|
|
|
52
|
-
# ---- 1. Resolve version
|
|
81
|
+
# ---- 1. Resolve + WRITE version into the repo-root package.json -------------
|
|
82
|
+
# The repo-root package.json is the SINGLE source of truth: `npm pack` reads it
|
|
83
|
+
# to build the embedded pipeline.tgz, so the bundle shell and the bundled
|
|
84
|
+
# pipeline cannot diverge as long as we bump it BEFORE building (step 2). This
|
|
85
|
+
# closes the 1.6.84-class bug where the shell said X but pipeline.tgz carried
|
|
86
|
+
# the prior version because only the satellites were stamped.
|
|
53
87
|
PKG_VERSION="$(node -p "require('$REPO_ROOT/package.json').version")"
|
|
54
|
-
if [[ -n "$
|
|
55
|
-
|
|
56
|
-
|
|
88
|
+
if [[ -n "$VERSION_OVERRIDE" ]]; then
|
|
89
|
+
VERSION="${VERSION_OVERRIDE#v}"
|
|
90
|
+
elif [[ -n "$TAG_OVERRIDE" ]]; then
|
|
91
|
+
VERSION="${TAG_OVERRIDE#v}"
|
|
92
|
+
elif [[ "$DO_BUMP" == "1" ]]; then
|
|
93
|
+
# Compute the next version without writing a git tag; we own the write below.
|
|
94
|
+
VERSION="$(node -e "
|
|
95
|
+
let [a,b,c]='$PKG_VERSION'.split('.').map(Number);
|
|
96
|
+
const lvl='$BUMP_LEVEL';
|
|
97
|
+
if(lvl==='major'){a++;b=0;c=0;} else if(lvl==='minor'){b++;c=0;} else {c++;}
|
|
98
|
+
console.log(a+'.'+b+'.'+c);
|
|
99
|
+
")"
|
|
57
100
|
else
|
|
58
101
|
VERSION="$PKG_VERSION"
|
|
59
|
-
TAG="v$VERSION"
|
|
60
102
|
fi
|
|
61
|
-
|
|
103
|
+
TAG="v$VERSION"
|
|
104
|
+
|
|
105
|
+
# Write the resolved version into the repo-root package.json + lockfile so the
|
|
106
|
+
# pipeline tarball, npm publish, and all satellites share one number.
|
|
62
107
|
if [[ "$VERSION" != "$PKG_VERSION" ]]; then
|
|
63
|
-
|
|
108
|
+
say "Bumping repo-root package.json $PKG_VERSION -> $VERSION"
|
|
109
|
+
node -e "
|
|
110
|
+
const fs=require('fs');
|
|
111
|
+
for (const p of ['$REPO_ROOT/package.json','$REPO_ROOT/package-lock.json']) {
|
|
112
|
+
if (!fs.existsSync(p)) continue;
|
|
113
|
+
const j=JSON.parse(fs.readFileSync(p,'utf8'));
|
|
114
|
+
j.version='$VERSION';
|
|
115
|
+
if (j.packages && j.packages['']) j.packages[''].version='$VERSION';
|
|
116
|
+
fs.writeFileSync(p, JSON.stringify(j,null,2)+'\n');
|
|
117
|
+
console.log(' '+p.replace('$REPO_ROOT/','')+' -> '+j.version);
|
|
118
|
+
}
|
|
119
|
+
"
|
|
120
|
+
else
|
|
121
|
+
say "Releasing $TAG (repo-root package.json already $VERSION)"
|
|
64
122
|
fi
|
|
65
123
|
|
|
66
124
|
# ---- 2. Build the bundle ----------------------------------------------------
|
|
@@ -128,6 +186,14 @@ MANIFEST_VER=$(unzip -p "$BUNDLE" manifest.json 2>/dev/null | node -p "JSON.pars
|
|
|
128
186
|
[[ "$MANIFEST_VER" == "$VERSION" ]] || die "bundle manifest.json=$MANIFEST_VER != $VERSION (Desktop Details panel would show the wrong version)"
|
|
129
187
|
echo " manifest.json: $MANIFEST_VER ok"
|
|
130
188
|
|
|
189
|
+
# THE guard that was missing when 1.6.84 shipped a 1.6.83 pipeline: assert the
|
|
190
|
+
# version INSIDE the embedded pipeline.tgz matches the bundle. ensurePipelineCurrent()
|
|
191
|
+
# on the box trusts version.json to decide whether to re-extract; if the tarball's
|
|
192
|
+
# own package.json lags, the box materializes stale Python and never knows.
|
|
193
|
+
PIPELINE_VER=$(unzip -p "$BUNDLE" dist/pipeline.tgz 2>/dev/null | tar -xzO package/package.json 2>/dev/null | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version" 2>/dev/null || echo "?")
|
|
194
|
+
[[ "$PIPELINE_VER" == "$VERSION" ]] || die "embedded pipeline.tgz version=$PIPELINE_VER != $VERSION (box would materialize a STALE pipeline; bump repo-root package.json BEFORE build)"
|
|
195
|
+
echo " pipeline.tgz internal version: $PIPELINE_VER ok"
|
|
196
|
+
|
|
131
197
|
for f in "dist/index.js" "dist/runtime.js" "manifest.json"; do
|
|
132
198
|
# grep -c reads all input (no SIGPIPE); anchor on the time column + 3-space
|
|
133
199
|
# gutter so node_modules/.../dist/index.js does not false-match the top-level.
|
|
@@ -141,7 +207,30 @@ if [[ "$DO_RELEASE" == "0" ]]; then
|
|
|
141
207
|
exit 0
|
|
142
208
|
fi
|
|
143
209
|
|
|
144
|
-
# ---- 6.
|
|
210
|
+
# ---- 6. npm publish (Story A: `npx social-autoposter@<v> init`) -------------
|
|
211
|
+
# Same VERSION as the bundle, from the SAME bumped repo-root package.json, so the
|
|
212
|
+
# npm install path and the .mcpb path can never disagree. Idempotent: a version
|
|
213
|
+
# already on the registry is skipped, not failed.
|
|
214
|
+
if [[ "$DO_NPM" == "1" ]]; then
|
|
215
|
+
command -v npm >/dev/null || die "npm not found on PATH"
|
|
216
|
+
NPM_HTTP=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/social-autoposter/$VERSION" || echo "000")
|
|
217
|
+
if [[ "$NPM_HTTP" == "200" ]]; then
|
|
218
|
+
say "npm: social-autoposter@$VERSION already published — skipping"
|
|
219
|
+
else
|
|
220
|
+
say "Publishing social-autoposter@$VERSION to npm"
|
|
221
|
+
( cd "$REPO_ROOT" && npm publish ) || die "npm publish failed"
|
|
222
|
+
# Confirm it actually landed (granular-token whoami lies; a version fetch doesn't).
|
|
223
|
+
for _ in 1 2 3 4 5; do
|
|
224
|
+
sleep 2
|
|
225
|
+
[[ "$(curl -s -o /dev/null -w '%{http_code}' "https://registry.npmjs.org/social-autoposter/$VERSION")" == "200" ]] && break
|
|
226
|
+
done
|
|
227
|
+
echo " npm: social-autoposter@$VERSION live"
|
|
228
|
+
fi
|
|
229
|
+
else
|
|
230
|
+
say "npm publish skipped (--no-npm)"
|
|
231
|
+
fi
|
|
232
|
+
|
|
233
|
+
# ---- 7. GitHub release ------------------------------------------------------
|
|
145
234
|
command -v gh >/dev/null || die "gh CLI not found"
|
|
146
235
|
gh auth status >/dev/null 2>&1 || die "gh not authenticated (run: gh auth login)"
|
|
147
236
|
|
|
@@ -33,6 +33,7 @@ import json
|
|
|
33
33
|
import os
|
|
34
34
|
import random
|
|
35
35
|
import re
|
|
36
|
+
import signal
|
|
36
37
|
import subprocess
|
|
37
38
|
import sys
|
|
38
39
|
import time
|
|
@@ -40,27 +41,51 @@ import time
|
|
|
40
41
|
|
|
41
42
|
LOCK_FILE = os.path.expanduser("~/.claude/twitter-browser-lock.json")
|
|
42
43
|
LOCK_EXPIRY = 300 # process-level mutex TTL; refreshed during long ops
|
|
44
|
+
# Posting-specific silence ceiling, DECOUPLED from the fleet-wide LOCK_EXPIRY.
|
|
45
|
+
# A role:"post" holder (an approved batch, or a single reply) is reclaimed by a
|
|
46
|
+
# peer once its lock has gone unrefreshed this long; a role:"scan" holder keeps
|
|
47
|
+
# the 300s LOCK_EXPIRY untouched. Posting refreshes the lock at every candidate
|
|
48
|
+
# boundary (twitter_post_plan holds it across the whole batch), so a healthy
|
|
49
|
+
# poster never goes silent this long -- only a genuinely hung poster (e.g.
|
|
50
|
+
# link_tail's `claude -p` wedged) trips it. Kept as its own knob so tuning the
|
|
51
|
+
# scan TTL never moves the poster's hang ceiling and vice-versa. Must exceed the
|
|
52
|
+
# worst-case single candidate step (one reply + the link_tail AI call), and stay
|
|
53
|
+
# well under any value that would let a hung poster block the browser for long.
|
|
54
|
+
POST_LOCK_EXPIRY = 180 # seconds; applies ONLY to a role:"post" holder
|
|
43
55
|
LOCK_WAIT_MAX = 45 # seconds to wait for lock to free before giving up
|
|
44
56
|
LOCK_POLL_INTERVAL = 2
|
|
57
|
+
PREEMPT_KILL_WAIT = 5 # secs to wait for a preempted scan holder to die before SIGKILL
|
|
58
|
+
|
|
59
|
+
# Lock role priority. A "post" holder is user-initiated (an approved reply) and
|
|
60
|
+
# outranks any "scan" holder (the scan/draft cycle, autopilot or plugin). When a
|
|
61
|
+
# poster finds a LIVE lower-priority holder it PREEMPTS it (SIGTERM + reclaim)
|
|
62
|
+
# instead of waiting LOCK_WAIT_MAX and giving up. This is what makes "posting
|
|
63
|
+
# takes priority over scanning" hold CROSS-PROCESS: the old in-process
|
|
64
|
+
# preemptScanForPost only killed the plugin's own scan, never a scan spawned by a
|
|
65
|
+
# separate autopilot agent / launchd cron, so an approved post kept losing the
|
|
66
|
+
# 45s race to a live scan that held the browser. Default "scan" so any unmarked
|
|
67
|
+
# browser op is preemptable; only the poster path sets SAPS_LOCK_ROLE=post.
|
|
68
|
+
LOCK_ROLE = (os.environ.get("SAPS_LOCK_ROLE") or "scan").strip() or "scan"
|
|
45
69
|
VIEWPORT = {"width": 911, "height": 1016}
|
|
46
70
|
|
|
47
71
|
# Posting handle. Resolved at call time from AUTOPOSTER_TWITTER_HANDLE env
|
|
48
72
|
# var (set by per-account launchd/systemd units) or config.json
|
|
49
|
-
# accounts.twitter.handle.
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
|
|
53
|
-
|
|
73
|
+
# accounts.twitter.handle. Returns None when neither source is set.
|
|
74
|
+
#
|
|
75
|
+
# There is intentionally NO hardcoded fallback handle. The old "m13v_"
|
|
76
|
+
# default meant any install with an unset handle silently posted under the
|
|
77
|
+
# repo owner's identity: it stamped posts.our_account = m13v_ and built reply
|
|
78
|
+
# permalinks as x.com/m13v_/status/<id> for tweets that actually belonged to a
|
|
79
|
+
# different account, corrupting attribution in the shared DB. Callers that
|
|
80
|
+
# build a URL or post under this identity MUST treat None as "account not
|
|
81
|
+
# configured" and refuse, rather than impersonate someone.
|
|
54
82
|
def our_handle():
|
|
55
83
|
try:
|
|
56
84
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
57
85
|
import account_resolver
|
|
58
|
-
|
|
59
|
-
if h:
|
|
60
|
-
return h
|
|
86
|
+
return account_resolver.resolve("twitter")
|
|
61
87
|
except Exception:
|
|
62
|
-
|
|
63
|
-
return _DEFAULT_HANDLE
|
|
88
|
+
return None
|
|
64
89
|
|
|
65
90
|
# DM encryption passcode from .env
|
|
66
91
|
DM_PASSCODE = os.environ.get("TWITTER_DM_PASSCODE", "")
|
|
@@ -297,13 +322,45 @@ def _try_take_lock() -> bool:
|
|
|
297
322
|
return False
|
|
298
323
|
try:
|
|
299
324
|
os.write(fd, json.dumps(
|
|
300
|
-
{"session_id": _LOCK_SESSION_ID, "timestamp": int(time.time())}
|
|
325
|
+
{"session_id": _LOCK_SESSION_ID, "timestamp": int(time.time()), "role": LOCK_ROLE}
|
|
301
326
|
).encode())
|
|
302
327
|
finally:
|
|
303
328
|
os.close(fd)
|
|
304
329
|
return True
|
|
305
330
|
|
|
306
331
|
|
|
332
|
+
def _preempt_holder(pid: int) -> bool:
|
|
333
|
+
"""Preempt a live lock holder we outrank (a poster taking the browser from a
|
|
334
|
+
scan). SIGTERM it, wait PREEMPT_KILL_WAIT for it to die so its pid frees the
|
|
335
|
+
lock, then escalate to SIGKILL once. Returns True once the holder is gone
|
|
336
|
+
(or was already gone). Best-effort; never raises. The caller then removes the
|
|
337
|
+
stale lockfile and claims it via O_EXCL.
|
|
338
|
+
"""
|
|
339
|
+
try:
|
|
340
|
+
os.kill(pid, signal.SIGTERM)
|
|
341
|
+
except ProcessLookupError:
|
|
342
|
+
return True # already gone
|
|
343
|
+
except OSError:
|
|
344
|
+
return False # not ours to signal / ambiguous -> don't claim
|
|
345
|
+
deadline = time.time() + PREEMPT_KILL_WAIT
|
|
346
|
+
while time.time() < deadline:
|
|
347
|
+
try:
|
|
348
|
+
os.kill(pid, 0)
|
|
349
|
+
except OSError:
|
|
350
|
+
return True # ProcessLookupError or perm change -> dead enough
|
|
351
|
+
time.sleep(0.2)
|
|
352
|
+
# Still alive after the SIGTERM grace window -> escalate once.
|
|
353
|
+
try:
|
|
354
|
+
os.kill(pid, signal.SIGKILL)
|
|
355
|
+
except OSError:
|
|
356
|
+
pass
|
|
357
|
+
try:
|
|
358
|
+
os.kill(pid, 0)
|
|
359
|
+
except OSError:
|
|
360
|
+
return True
|
|
361
|
+
return False
|
|
362
|
+
|
|
363
|
+
|
|
307
364
|
def _acquire_browser_lock():
|
|
308
365
|
"""Acquire the Twitter browser session mutex (~/.claude/twitter-browser-lock.json).
|
|
309
366
|
|
|
@@ -377,6 +434,7 @@ def _acquire_browser_lock():
|
|
|
377
434
|
continue
|
|
378
435
|
age = time.time() - lock.get("timestamp", 0)
|
|
379
436
|
holder = lock.get("session_id", "")
|
|
437
|
+
holder_role = lock.get("role", "scan") # legacy locks (no role) = preemptable
|
|
380
438
|
|
|
381
439
|
# 1. Re-entrant: the lock is already ours (same process, or a stale lock
|
|
382
440
|
# left by a previous process whose PID we have since reused). Refresh the
|
|
@@ -385,6 +443,29 @@ def _acquire_browser_lock():
|
|
|
385
443
|
_refresh_browser_lock()
|
|
386
444
|
break
|
|
387
445
|
|
|
446
|
+
# 1b. Batch-owner inherit (posting). The poster (twitter_post_plan.py)
|
|
447
|
+
# acquires this lock ONCE and holds it across the WHOLE approved batch,
|
|
448
|
+
# exporting its own session id as SAPS_LOCK_OWNER for the child
|
|
449
|
+
# twitter_browser.py reply subprocesses it spawns. Each child INHERITS the
|
|
450
|
+
# parent's hold instead of contending for it -- two role:"post" peers would
|
|
451
|
+
# otherwise both fall to the case-6 peer-wait and give up after
|
|
452
|
+
# LOCK_WAIT_MAX, breaking the post. The child refreshes the timestamp
|
|
453
|
+
# (proof of progress at this candidate boundary, so the POST_LOCK_EXPIRY
|
|
454
|
+
# failsafe only ever fires on a real hang) and, being _LOCK_INHERITED,
|
|
455
|
+
# leaves the lock in place for the PARENT to release at batch end. A DEAD
|
|
456
|
+
# owner is never inherited: the alive-probe fails here and we fall through
|
|
457
|
+
# to the dead_python reclaim below, so a crashed batch can't wedge the
|
|
458
|
+
# browser. This is what closes the inter-candidate gap (the link_tail
|
|
459
|
+
# claude -p call, ~5-20s) the every-60s autopilot scan used to slip into.
|
|
460
|
+
_batch_owner = os.environ.get("SAPS_LOCK_OWNER") or ""
|
|
461
|
+
if holder and holder == _batch_owner and _is_python_holder_alive(holder):
|
|
462
|
+
_LOCK_SESSION_ID = holder
|
|
463
|
+
_LOCK_INHERITED = True
|
|
464
|
+
_refresh_browser_lock()
|
|
465
|
+
print(f"[browser_lock] inherited batch owner={holder} "
|
|
466
|
+
f"role={holder_role} -> pid={os.getpid()}", file=sys.stderr)
|
|
467
|
+
break
|
|
468
|
+
|
|
388
469
|
# 2-4. Reclaim a holder we can prove is dead/expired. Remove-then-take so
|
|
389
470
|
# the O_EXCL claim wins; if a peer reclaims at the same instant exactly
|
|
390
471
|
# one of us creates the file and the other re-loops (never both).
|
|
@@ -393,7 +474,10 @@ def _acquire_browser_lock():
|
|
|
393
474
|
reclaim_reason = "dead_uuid"
|
|
394
475
|
elif holder.startswith("python:") and not _is_python_holder_alive(holder):
|
|
395
476
|
reclaim_reason = "dead_python"
|
|
396
|
-
elif age >= LOCK_EXPIRY:
|
|
477
|
+
elif age >= (POST_LOCK_EXPIRY if holder_role == "post" else LOCK_EXPIRY):
|
|
478
|
+
# Role-aware failsafe: a hung poster self-clears on the posting-only
|
|
479
|
+
# POST_LOCK_EXPIRY, a scan on the fleet-wide LOCK_EXPIRY. Scan
|
|
480
|
+
# behaviour is unchanged; only the post ceiling is decoupled.
|
|
397
481
|
reclaim_reason = "expired"
|
|
398
482
|
if reclaim_reason:
|
|
399
483
|
try:
|
|
@@ -415,6 +499,43 @@ def _acquire_browser_lock():
|
|
|
415
499
|
_LOCK_INHERITED = True
|
|
416
500
|
break
|
|
417
501
|
|
|
502
|
+
# 5b. POSTING PRIORITY (cross-process). A LIVE python:PID peer running a
|
|
503
|
+
# lower-priority op (role != "post": the scan/draft cycle, whether the
|
|
504
|
+
# plugin's own, a separate autopilot agent's, or the launchd cron's) must
|
|
505
|
+
# YIELD to an approved post. Preempt it by signal and reclaim, so the post
|
|
506
|
+
# takes the browser at once instead of waiting LOCK_WAIT_MAX and giving up
|
|
507
|
+
# while the scan holds it. The aborted scan just re-runs next cron tick;
|
|
508
|
+
# posting is the scarce, user-initiated action. Only a poster
|
|
509
|
+
# (LOCK_ROLE == "post") ever preempts, and only a non-post holder -- two
|
|
510
|
+
# posters fall through to the normal peer-wait below so neither kills the
|
|
511
|
+
# other. UUID holders are handled above (we inherit, never kill those).
|
|
512
|
+
if (
|
|
513
|
+
LOCK_ROLE == "post"
|
|
514
|
+
and holder.startswith("python:")
|
|
515
|
+
and holder_role != "post"
|
|
516
|
+
and _is_python_holder_alive(holder)
|
|
517
|
+
):
|
|
518
|
+
try:
|
|
519
|
+
victim_pid = int(holder.split(":", 1)[1])
|
|
520
|
+
except (ValueError, IndexError):
|
|
521
|
+
victim_pid = 0
|
|
522
|
+
if victim_pid and _preempt_holder(victim_pid):
|
|
523
|
+
try:
|
|
524
|
+
os.remove(LOCK_FILE)
|
|
525
|
+
except OSError:
|
|
526
|
+
pass
|
|
527
|
+
if _try_take_lock():
|
|
528
|
+
print(
|
|
529
|
+
f"[browser_lock] post preempted holder={holder} "
|
|
530
|
+
f"role={holder_role} age={int(age)}s -> pid={os.getpid()}",
|
|
531
|
+
file=sys.stderr,
|
|
532
|
+
)
|
|
533
|
+
break
|
|
534
|
+
# Preempt didn't land (couldn't kill, or a peer reclaimed first) ->
|
|
535
|
+
# re-loop and re-evaluate rather than busy-spin.
|
|
536
|
+
time.sleep(LOCK_POLL_INTERVAL)
|
|
537
|
+
continue
|
|
538
|
+
|
|
418
539
|
# 6. Live python:PID peer. Wait, then give up. Reaching the deadline now
|
|
419
540
|
# means the holder is a genuinely LIVE peer (dead ones were reclaimed
|
|
420
541
|
# above), i.e. real contention -- NOT the defect-a starvation. The
|
|
@@ -433,7 +554,7 @@ def _refresh_browser_lock():
|
|
|
433
554
|
"""Refresh the lock timestamp to prevent expiry during long operations."""
|
|
434
555
|
try:
|
|
435
556
|
with open(LOCK_FILE, "w") as f:
|
|
436
|
-
json.dump({"session_id": _LOCK_SESSION_ID, "timestamp": int(time.time())}, f)
|
|
557
|
+
json.dump({"session_id": _LOCK_SESSION_ID, "timestamp": int(time.time()), "role": LOCK_ROLE}, f)
|
|
437
558
|
except OSError:
|
|
438
559
|
pass
|
|
439
560
|
|
|
@@ -820,6 +941,18 @@ def reply_to_tweet(tweet_url, text, apply_campaigns=True):
|
|
|
820
941
|
"""
|
|
821
942
|
print(f"[twitter_browser] reply_to_tweet called: {tweet_url}", file=sys.stderr)
|
|
822
943
|
|
|
944
|
+
# Identity gate: refuse to post when no account is configured. Without a
|
|
945
|
+
# resolved handle we cannot attribute the post or build a correct reply
|
|
946
|
+
# permalink, and the old behaviour silently impersonated the repo owner
|
|
947
|
+
# (handle "m13v_"). Fail fast and loud so the misconfiguration surfaces
|
|
948
|
+
# instead of polluting the shared DB under someone else's identity.
|
|
949
|
+
_handle = our_handle()
|
|
950
|
+
if not _handle:
|
|
951
|
+
print("[twitter_browser] no twitter account configured "
|
|
952
|
+
"(set AUTOPOSTER_TWITTER_HANDLE or accounts.twitter.handle in "
|
|
953
|
+
"config.json); refusing to post.", file=sys.stderr)
|
|
954
|
+
return {"ok": False, "error": "no_account_configured"}
|
|
955
|
+
|
|
823
956
|
applied_campaigns = []
|
|
824
957
|
if apply_campaigns:
|
|
825
958
|
for cid, suffix, sample_rate in _load_active_twitter_campaigns():
|
|
@@ -946,7 +1079,17 @@ def reply_to_tweet(tweet_url, text, apply_campaigns=True):
|
|
|
946
1079
|
page.goto(tweet_url, wait_until="domcontentloaded", timeout=60000)
|
|
947
1080
|
except Exception:
|
|
948
1081
|
pass
|
|
949
|
-
|
|
1082
|
+
# Was a blind 15s/8s settle here -> pure dead latency. SPA
|
|
1083
|
+
# readiness is ALREADY gated actively below by
|
|
1084
|
+
# wait_for_selector("main") (up to 20s) and
|
|
1085
|
+
# _wait_for_reply_textbox (polls every 500ms up to 45s); both
|
|
1086
|
+
# return the instant the composer mounts, so the blind sleep
|
|
1087
|
+
# only delayed the start of that polling. Keep a short floor so
|
|
1088
|
+
# the initial JS kicks off (and the deleted-tweet text check
|
|
1089
|
+
# below has content to read), then let the active gates do the
|
|
1090
|
+
# real waiting. Cuts ~12s off every happy-path reply.
|
|
1091
|
+
# (optimized 2026-06-22: 15000/8000 -> 2500)
|
|
1092
|
+
page.wait_for_timeout(2500)
|
|
950
1093
|
|
|
951
1094
|
# `wait_until="load"` fires before Twitter's SPA mounts the
|
|
952
1095
|
# <main> app shell, so "loaded" != "rendered". Explicitly gate
|
|
@@ -1021,7 +1164,12 @@ def reply_to_tweet(tweet_url, text, apply_campaigns=True):
|
|
|
1021
1164
|
except Exception:
|
|
1022
1165
|
page.keyboard.press("Meta+Enter")
|
|
1023
1166
|
|
|
1024
|
-
|
|
1167
|
+
# Post-submit settle: lets the CDP network response (which carries
|
|
1168
|
+
# the new tweet id -> reply_url, captured below) and the success
|
|
1169
|
+
# interstitial arrive. Trimmed from 4000ms 2026-06-22; the DOM-diff
|
|
1170
|
+
# fallback (3x2s, below) still covers a slow CDP response, so the
|
|
1171
|
+
# reply_url is not lost if 2000ms is short on a given run.
|
|
1172
|
+
page.wait_for_timeout(2000)
|
|
1025
1173
|
|
|
1026
1174
|
# Verify: check if the reply box is empty (cleared after posting)
|
|
1027
1175
|
try:
|
|
@@ -1050,7 +1198,7 @@ def reply_to_tweet(tweet_url, text, apply_campaigns=True):
|
|
|
1050
1198
|
|
|
1051
1199
|
# Method 1: CDP network interception (most reliable)
|
|
1052
1200
|
if _created_tweet_ids:
|
|
1053
|
-
reply_url = f"https://x.com/{
|
|
1201
|
+
reply_url = f"https://x.com/{_handle}/status/{_created_tweet_ids[-1]}"
|
|
1054
1202
|
print(f"[reply_url] captured via CDP+response-listener: {reply_url}", file=sys.stderr)
|
|
1055
1203
|
|
|
1056
1204
|
# Method 2: DOM diff (check if new reply links appeared)
|
|
@@ -49,6 +49,14 @@ import time
|
|
|
49
49
|
from datetime import datetime, timezone
|
|
50
50
|
from pathlib import Path
|
|
51
51
|
|
|
52
|
+
# This pipeline ONLY posts (never scans), so mark every twitter_browser.py reply
|
|
53
|
+
# subprocess it spawns as the high-priority "post" lock role. run_subprocess
|
|
54
|
+
# inherits this process env, so the child twitter_browser.py reads SAPS_LOCK_ROLE
|
|
55
|
+
# at import and will PREEMPT a live scan holding the browser lock instead of
|
|
56
|
+
# losing the 45s wait. Covers BOTH the MCP approve path and the cron post path,
|
|
57
|
+
# since both shell out to this script. Set before any child is spawned.
|
|
58
|
+
os.environ["SAPS_LOCK_ROLE"] = "post"
|
|
59
|
+
|
|
52
60
|
REPO_DIR = os.path.expanduser("~/social-autoposter")
|
|
53
61
|
TWITTER_BROWSER = os.path.join(REPO_DIR, "scripts", "twitter_browser.py")
|
|
54
62
|
LOG_POST = os.path.join(REPO_DIR, "scripts", "log_post.py")
|
|
@@ -1009,11 +1017,58 @@ def main() -> int:
|
|
|
1009
1017
|
return 3
|
|
1010
1018
|
|
|
1011
1019
|
_total = len(candidates)
|
|
1020
|
+
|
|
1021
|
+
# ---- Batch-level browser-lock hold (cross-process posting priority) --------
|
|
1022
|
+
# Hold the Twitter browser lock for the WHOLE approved batch instead of
|
|
1023
|
+
# re-acquiring it per candidate. Per-candidate acquisition freed the lock in
|
|
1024
|
+
# the gap between replies (dominated by link_tail's `claude -p` call, ~5-20s),
|
|
1025
|
+
# and the autopilot scan fires every 60s, so a scan kept slipping into that gap
|
|
1026
|
+
# and seizing the browser mid-batch -- the exact "posting gets cut off" symptom
|
|
1027
|
+
# on the remote box. Acquiring ONCE here PREEMPTS any live scan (role:"post"
|
|
1028
|
+
# priority) and, by exporting our session id as SAPS_LOCK_OWNER, makes every
|
|
1029
|
+
# child twitter_browser.py reply INHERIT this hold rather than contend for it,
|
|
1030
|
+
# closing the gap. The hold is bounded by the posting-specific POST_LOCK_EXPIRY
|
|
1031
|
+
# failsafe in twitter_browser (a hung poster self-clears in <=180s; a crashed
|
|
1032
|
+
# one frees instantly via dead-pid reclaim), so it can never wedge the browser
|
|
1033
|
+
# indefinitely. Best-effort: if the import or acquire fails we simply fall back
|
|
1034
|
+
# to the legacy per-candidate acquisition (children still preempt scans one by
|
|
1035
|
+
# one), so posting degrades gracefully and is never blocked by this addition.
|
|
1036
|
+
_tb = None
|
|
1037
|
+
_batch_lock_held = False
|
|
1038
|
+
if candidates:
|
|
1039
|
+
try:
|
|
1040
|
+
import twitter_browser as _tb # SAPS_LOCK_ROLE=post already set above
|
|
1041
|
+
_tb._acquire_browser_lock() # preempts a live scan; sys.exit(1) if contended
|
|
1042
|
+
os.environ["SAPS_LOCK_OWNER"] = _tb._LOCK_SESSION_ID
|
|
1043
|
+
_batch_lock_held = True
|
|
1044
|
+
print(f"[post] batch lock held by {_tb._LOCK_SESSION_ID} (role=post); "
|
|
1045
|
+
f"{_total} candidate(s) inherit it", flush=True)
|
|
1046
|
+
except SystemExit:
|
|
1047
|
+
# _acquire_browser_lock exits when a LIVE non-preemptable peer (another
|
|
1048
|
+
# poster) holds the lock past LOCK_WAIT_MAX. Don't abort the whole run:
|
|
1049
|
+
# drop to per-candidate acquisition (each child still preempts scans).
|
|
1050
|
+
print("[post] batch lock contended; per-candidate acquisition in effect",
|
|
1051
|
+
flush=True)
|
|
1052
|
+
except Exception as _e:
|
|
1053
|
+
print(f"[post] batch lock setup skipped ({_e}); per-candidate "
|
|
1054
|
+
"acquisition in effect", flush=True)
|
|
1055
|
+
|
|
1012
1056
|
try:
|
|
1013
1057
|
for _idx, c in enumerate(candidates, start=1):
|
|
1014
1058
|
# Live per-post status for the S4L menu bar: "posting 3/10" while this
|
|
1015
1059
|
# one is in flight, then "posted 3/10 ✓" once it lands. Cosmetic only.
|
|
1016
1060
|
_write_activity(f"posting {_idx}/{_total}")
|
|
1061
|
+
# Re-stamp the batch hold at each candidate boundary so the
|
|
1062
|
+
# POST_LOCK_EXPIRY failsafe measures silence from the LAST real
|
|
1063
|
+
# progress, not from batch start. Insurance on top of the child's own
|
|
1064
|
+
# inherit-refresh: keeps the hold fresh even across a candidate that
|
|
1065
|
+
# skips before ever spawning a reply subprocess (empty_reply_text,
|
|
1066
|
+
# pre-post dedup). Cheap; never raises.
|
|
1067
|
+
if _batch_lock_held and _tb is not None:
|
|
1068
|
+
try:
|
|
1069
|
+
_tb._refresh_browser_lock()
|
|
1070
|
+
except Exception:
|
|
1071
|
+
pass
|
|
1017
1072
|
try:
|
|
1018
1073
|
outcome, reason = post_one(c, picker_assignment=picker_assignment)
|
|
1019
1074
|
except Exception as e:
|
|
@@ -1039,6 +1094,17 @@ def main() -> int:
|
|
|
1039
1094
|
fail_reasons[reason] = fail_reasons.get(reason, 0) + 1
|
|
1040
1095
|
finally:
|
|
1041
1096
|
_clear_activity()
|
|
1097
|
+
# Release the batch hold so the next scan/post can take the browser
|
|
1098
|
+
# immediately (don't make peers wait out POST_LOCK_EXPIRY). _tb's atexit
|
|
1099
|
+
# is a backstop if we somehow skip this; clearing SAPS_LOCK_OWNER stops a
|
|
1100
|
+
# late child from re-inheriting a lock we just dropped.
|
|
1101
|
+
if _batch_lock_held and _tb is not None:
|
|
1102
|
+
try:
|
|
1103
|
+
_tb._release_browser_lock()
|
|
1104
|
+
except Exception:
|
|
1105
|
+
pass
|
|
1106
|
+
os.environ.pop("SAPS_LOCK_OWNER", None)
|
|
1107
|
+
print("[post] batch lock released", flush=True)
|
|
1042
1108
|
|
|
1043
1109
|
summary = {
|
|
1044
1110
|
"posted": posted,
|