@m13v/s4l 1.7.5-rc.13 → 1.7.5-rc.14
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 +127 -73
- 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/browser_mutex.py +316 -0
- package/scripts/counterparty_history.py +44 -6
- package/scripts/engagement_signal_feedback.py +256 -0
- package/scripts/reddit_browser.py +27 -60
- package/scripts/release-mcpb.sh +17 -0
- package/scripts/scan_twitter_thread_followups.py +7 -1
- package/scripts/twitter_browser.py +26 -340
- package/skill/lib/linkedin-backend.sh +18 -0
- package/skill/lock.sh +57 -0
package/mcp/dist/index.js
CHANGED
|
@@ -1014,95 +1014,149 @@ async function postApproved(batchId, plan) {
|
|
|
1014
1014
|
const approvedTwitter = approved.filter((c) => c.platform !== "reddit");
|
|
1015
1015
|
let redditPosted = 0;
|
|
1016
1016
|
let redditFailed = 0;
|
|
1017
|
+
let redditSkippedPeerDrain = false;
|
|
1017
1018
|
if (approvedReddit.length) {
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
: dec.text,
|
|
1036
|
-
};
|
|
1037
|
-
if (cc.engagement_style)
|
|
1038
|
-
decPost.engagement_style = cc.engagement_style;
|
|
1039
|
-
const miniPlan = {
|
|
1040
|
-
project_name: meta.project_name || cc.matched_project,
|
|
1041
|
-
batch_id: cc.reddit_batch_id || meta.batch_id || "reddit-mcp-approval",
|
|
1042
|
-
phase: "draft",
|
|
1043
|
-
decisions: [decPost],
|
|
1044
|
-
style_assignment: meta.style_assignment || {},
|
|
1045
|
-
generation_trace_path: meta.generation_trace_path,
|
|
1046
|
-
session_id: meta.session_id || meta.draft_session_id,
|
|
1047
|
-
};
|
|
1048
|
-
const tmpPlan = path.join(process.env.S4L_TMP_DIR || "/tmp", `reddit_mcp_post_${Date.now()}_${Math.floor(Math.random() * 1e6)}.json`);
|
|
1049
|
-
let r;
|
|
1019
|
+
// Cross-instance drain gate (2026-07-14 drain storm): every one-shot MCP
|
|
1020
|
+
// boot (the queue worker fires ~every minute; each boot runs the startup
|
|
1021
|
+
// backlog drain) drained the SAME sticky approvals concurrently — 26
|
|
1022
|
+
// drain plans in ~30 minutes, all fighting over the one reddit tab and
|
|
1023
|
+
// false-positiving each other's posts. reddit-posting-active.json is the
|
|
1024
|
+
// arbiter: post_reddit.py heartbeats it per row, and we stamp it around
|
|
1025
|
+
// the whole drain here. A fresh flag (<120s) means a peer is mid-drain:
|
|
1026
|
+
// leave the cards for it — approvals are sticky, nothing is lost.
|
|
1027
|
+
const redditFlagPath = path.join(s4lStateDir(), "reddit-posting-active.json");
|
|
1028
|
+
let redditPeerFresh = false;
|
|
1029
|
+
try {
|
|
1030
|
+
redditPeerFresh = Date.now() - fs.statSync(redditFlagPath).mtimeMs < 120_000;
|
|
1031
|
+
}
|
|
1032
|
+
catch {
|
|
1033
|
+
redditPeerFresh = false;
|
|
1034
|
+
}
|
|
1035
|
+
const stampRedditFlag = () => {
|
|
1050
1036
|
try {
|
|
1051
|
-
fs.writeFileSync(
|
|
1052
|
-
r = await runPython("scripts/post_reddit.py", ["--phase", "post", "--in", tmpPlan], {
|
|
1053
|
-
timeoutMs: 900_000,
|
|
1054
|
-
env: {
|
|
1055
|
-
REDDIT_CDP_URL: process.env.REDDIT_CDP_URL || "http://127.0.0.1:9557",
|
|
1056
|
-
// Reviewed posts never get the active-campaign suffix (same rule
|
|
1057
|
-
// as the twitter manual-approval path below).
|
|
1058
|
-
S4L_SKIP_CAMPAIGN_SUFFIX: "1",
|
|
1059
|
-
},
|
|
1060
|
-
onLine: (line) => {
|
|
1061
|
-
const t = line.replace(/\s+$/, "");
|
|
1062
|
-
if (t.trim())
|
|
1063
|
-
console.error(`[post-reddit] ${t}`);
|
|
1064
|
-
},
|
|
1065
|
-
});
|
|
1037
|
+
fs.writeFileSync(redditFlagPath, JSON.stringify({ pid: process.pid, hb: Math.floor(Date.now() / 1000) }));
|
|
1066
1038
|
}
|
|
1067
|
-
catch
|
|
1068
|
-
|
|
1039
|
+
catch {
|
|
1040
|
+
/* best effort */
|
|
1041
|
+
}
|
|
1042
|
+
};
|
|
1043
|
+
if (redditPeerFresh) {
|
|
1044
|
+
redditSkippedPeerDrain = true;
|
|
1045
|
+
logPostEvent(`postApproved_reddit_skip batch=${batchId} reason=peer_drain_active cards=${approvedReddit.length}`);
|
|
1046
|
+
}
|
|
1047
|
+
else {
|
|
1048
|
+
stampRedditFlag();
|
|
1049
|
+
try {
|
|
1050
|
+
for (const c of approvedReddit) {
|
|
1051
|
+
stampRedditFlag();
|
|
1052
|
+
const cc = c;
|
|
1053
|
+
const dec = cc.reddit_decision;
|
|
1054
|
+
const meta = (cc.reddit_plan_meta || {});
|
|
1055
|
+
if (!dec) {
|
|
1056
|
+
c.terminal = true;
|
|
1057
|
+
c.terminal_reason = "reddit_decision_missing";
|
|
1058
|
+
redditFailed++;
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
// The card's reply_text is the CANONICAL text (post_drafts edits write
|
|
1062
|
+
// there); the embedded decision still carries the draft-time original.
|
|
1063
|
+
// Posting dec.text verbatim would silently discard a human edit.
|
|
1064
|
+
const decPost = {
|
|
1065
|
+
...dec,
|
|
1066
|
+
text: (typeof cc.reply_text === "string" && cc.reply_text.trim())
|
|
1067
|
+
? cc.reply_text
|
|
1068
|
+
: dec.text,
|
|
1069
|
+
};
|
|
1070
|
+
if (cc.engagement_style)
|
|
1071
|
+
decPost.engagement_style = cc.engagement_style;
|
|
1072
|
+
const miniPlan = {
|
|
1073
|
+
project_name: meta.project_name || cc.matched_project,
|
|
1074
|
+
batch_id: cc.reddit_batch_id || meta.batch_id || "reddit-mcp-approval",
|
|
1075
|
+
phase: "draft",
|
|
1076
|
+
decisions: [decPost],
|
|
1077
|
+
style_assignment: meta.style_assignment || {},
|
|
1078
|
+
generation_trace_path: meta.generation_trace_path,
|
|
1079
|
+
session_id: meta.session_id || meta.draft_session_id,
|
|
1080
|
+
};
|
|
1081
|
+
const tmpPlan = path.join(process.env.S4L_TMP_DIR || "/tmp", `reddit_mcp_post_${Date.now()}_${Math.floor(Math.random() * 1e6)}.json`);
|
|
1082
|
+
let r;
|
|
1083
|
+
try {
|
|
1084
|
+
fs.writeFileSync(tmpPlan, JSON.stringify(miniPlan));
|
|
1085
|
+
r = await runPython("scripts/post_reddit.py", ["--phase", "post", "--in", tmpPlan], {
|
|
1086
|
+
timeoutMs: 900_000,
|
|
1087
|
+
env: {
|
|
1088
|
+
REDDIT_CDP_URL: process.env.REDDIT_CDP_URL || "http://127.0.0.1:9557",
|
|
1089
|
+
// Reviewed posts never get the active-campaign suffix (same rule
|
|
1090
|
+
// as the twitter manual-approval path below).
|
|
1091
|
+
S4L_SKIP_CAMPAIGN_SUFFIX: "1",
|
|
1092
|
+
},
|
|
1093
|
+
onLine: (line) => {
|
|
1094
|
+
const t = line.replace(/\s+$/, "");
|
|
1095
|
+
if (t.trim())
|
|
1096
|
+
console.error(`[post-reddit] ${t}`);
|
|
1097
|
+
},
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
catch (err) {
|
|
1101
|
+
r = { code: -1, stdout: "", stderr: String(err) };
|
|
1102
|
+
}
|
|
1103
|
+
finally {
|
|
1104
|
+
try {
|
|
1105
|
+
fs.unlinkSync(tmpPlan);
|
|
1106
|
+
}
|
|
1107
|
+
catch {
|
|
1108
|
+
/* best effort */
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
// post_reddit.py's summary marker: `[post_reddit] phase=post ... posted=N failed=M`
|
|
1112
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
1113
|
+
const postedN = Number((/posted=(\d+)/.exec(out) || [])[1] || 0);
|
|
1114
|
+
if (r.code === 0 && postedN > 0) {
|
|
1115
|
+
c.posted = true;
|
|
1116
|
+
c.terminal = false;
|
|
1117
|
+
const urlMatch = /(https:\/\/(?:old\.|www\.)?reddit\.com\/r\/\S+)/.exec((/\[post_reddit\][^\n]*posted[^\n]*/i.exec(out) || [""])[0]);
|
|
1118
|
+
if (urlMatch)
|
|
1119
|
+
c.our_url = urlMatch[1];
|
|
1120
|
+
redditPosted++;
|
|
1121
|
+
}
|
|
1122
|
+
else {
|
|
1123
|
+
// Leave the approval sticky (approved && !posted && !terminal) so the
|
|
1124
|
+
// next post_drafts call retries, mirroring twitter's failed-drain
|
|
1125
|
+
// semantics; only stamp terminal on a conclusive CDP refusal.
|
|
1126
|
+
const cdpReason = (/\[post_reddit\] CDP FAILED: ([a-z_]+)/.exec(out) || [])[1];
|
|
1127
|
+
if (cdpReason && ["thread_locked", "thread_archived", "thread_not_found", "blocked_by_author"].includes(cdpReason)) {
|
|
1128
|
+
c.terminal = true;
|
|
1129
|
+
c.terminal_reason = `reddit_${cdpReason}`;
|
|
1130
|
+
}
|
|
1131
|
+
redditFailed++;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1069
1134
|
}
|
|
1070
1135
|
finally {
|
|
1136
|
+
// Clear only OUR stamp; a peer that took over mid-drain keeps its own.
|
|
1071
1137
|
try {
|
|
1072
|
-
fs.
|
|
1138
|
+
const cur = JSON.parse(fs.readFileSync(redditFlagPath, "utf-8"));
|
|
1139
|
+
if (cur && cur.pid === process.pid)
|
|
1140
|
+
fs.unlinkSync(redditFlagPath);
|
|
1073
1141
|
}
|
|
1074
1142
|
catch {
|
|
1075
1143
|
/* best effort */
|
|
1076
1144
|
}
|
|
1077
1145
|
}
|
|
1078
|
-
|
|
1079
|
-
const out = `${r.stdout}\n${r.stderr}`;
|
|
1080
|
-
const postedN = Number((/posted=(\d+)/.exec(out) || [])[1] || 0);
|
|
1081
|
-
if (r.code === 0 && postedN > 0) {
|
|
1082
|
-
c.posted = true;
|
|
1083
|
-
c.terminal = false;
|
|
1084
|
-
const urlMatch = /(https:\/\/(?:old\.|www\.)?reddit\.com\/r\/\S+)/.exec((/\[post_reddit\][^\n]*posted[^\n]*/i.exec(out) || [""])[0]);
|
|
1085
|
-
if (urlMatch)
|
|
1086
|
-
c.our_url = urlMatch[1];
|
|
1087
|
-
redditPosted++;
|
|
1088
|
-
}
|
|
1089
|
-
else {
|
|
1090
|
-
// Leave the approval sticky (approved && !posted && !terminal) so the
|
|
1091
|
-
// next post_drafts call retries, mirroring twitter's failed-drain
|
|
1092
|
-
// semantics; only stamp terminal on a conclusive CDP refusal.
|
|
1093
|
-
const cdpReason = (/\[post_reddit\] CDP FAILED: ([a-z_]+)/.exec(out) || [])[1];
|
|
1094
|
-
if (cdpReason && ["thread_locked", "thread_archived", "thread_not_found", "blocked_by_author"].includes(cdpReason)) {
|
|
1095
|
-
c.terminal = true;
|
|
1096
|
-
c.terminal_reason = `reddit_${cdpReason}`;
|
|
1097
|
-
}
|
|
1098
|
-
redditFailed++;
|
|
1099
|
-
}
|
|
1146
|
+
logPostEvent(`postApproved_reddit batch=${batchId} attempted=${approvedReddit.length} posted=${redditPosted} failed=${redditFailed}`);
|
|
1100
1147
|
}
|
|
1101
|
-
logPostEvent(`postApproved_reddit batch=${batchId} attempted=${approvedReddit.length} posted=${redditPosted} failed=${redditFailed}`);
|
|
1102
1148
|
}
|
|
1103
1149
|
if (approvedTwitter.length === 0) {
|
|
1104
1150
|
// All-reddit batch: persist the stamps and return without touching the
|
|
1105
1151
|
// twitter preflight/lock path.
|
|
1152
|
+
if (redditSkippedPeerDrain) {
|
|
1153
|
+
return {
|
|
1154
|
+
attempted: 0,
|
|
1155
|
+
posted: 0,
|
|
1156
|
+
exit_code: 0,
|
|
1157
|
+
summary: "reddit: skipped (peer drain active); cards stay approved for the next drain",
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1106
1160
|
if (approvedReddit.length)
|
|
1107
1161
|
mergeApprovedStampsIntoStore(batchId, plan, approvedReddit);
|
|
1108
1162
|
return {
|
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.7.5-rc.
|
|
5
|
+
"version": "1.7.5-rc.14",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts.",
|
|
7
7
|
"long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
|
|
8
8
|
"author": {
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.5-rc.
|
|
3
|
+
"version": "1.7.5-rc.14",
|
|
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,316 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""browser_mutex.py — THE per-platform browser-session mutex (2026-07-14).
|
|
3
|
+
|
|
4
|
+
A faithful, parameterized extraction of scripts/twitter_browser.py's
|
|
5
|
+
battle-hardened file mutex, so every platform driver shares ONE implementation
|
|
6
|
+
instead of three divergent ones (reddit_browser.py's old copy still had the
|
|
7
|
+
check-then-write claim race AND the dead-holder starvation twitter fixed on
|
|
8
|
+
2026-06-16). The twitter semantics are preserved bit-for-bit: same lockfile
|
|
9
|
+
JSON shape ({session_id, timestamp, role}), same reclaim ladder, same
|
|
10
|
+
"locked by session" give-up string downstream parsers grep for.
|
|
11
|
+
|
|
12
|
+
This mutex serializes the PYTHON drivers of one harness Chrome. It deliberately
|
|
13
|
+
does NOT share a path with the shell pipeline lock (skill/lock.sh's
|
|
14
|
+
/tmp/social-autoposter-<name>.lock dirs): pipelines hold that lock around whole
|
|
15
|
+
phases while their child python ops take THIS one per op — merging the two onto
|
|
16
|
+
one path would deadlock that nesting (or need ancestor-walk inheritance in
|
|
17
|
+
every acquire). Unification here means one LIBRARY, not one lock.
|
|
18
|
+
|
|
19
|
+
Reclaim ladder (a holder we can PROVE dead is taken immediately, so a crashed
|
|
20
|
+
peer can never starve the fleet):
|
|
21
|
+
1. holder == us -> re-entrant; refresh and proceed.
|
|
22
|
+
1b. holder == $S4L_LOCK_OWNER (live) -> batch inherit: a poster parent holds
|
|
23
|
+
the lock across a whole approved batch; its child reply subprocesses
|
|
24
|
+
refresh instead of contending, and leave release to the parent.
|
|
25
|
+
2. UUID holder, pid gone -> stale legacy (Claude session) lock, reclaim.
|
|
26
|
+
3. python:PID, pid gone -> dead peer, reclaim.
|
|
27
|
+
4. age >= expiry -> failsafe (role "post" holders get the shorter
|
|
28
|
+
post_lock_expiry so a hung poster self-clears).
|
|
29
|
+
5. live UUID holder -> inherit (parent Claude session).
|
|
30
|
+
5b. we are role "post", holder is a LIVE lower-priority python peer ->
|
|
31
|
+
PREEMPT it (SIGTERM, grace, SIGKILL) and claim: posting is the scarce
|
|
32
|
+
user-initiated action; the aborted scan re-runs next tick.
|
|
33
|
+
6. live python peer -> wait, then give up after wait_max with the
|
|
34
|
+
structured "locked by session" error.
|
|
35
|
+
|
|
36
|
+
Do NOT "simplify" by letting shell pipelines rm -f the lockfile: that blind rm
|
|
37
|
+
deleted LIVE peers' locks (defect b, removed 2026-06-16). See
|
|
38
|
+
docs/twitter_browser_lock.md for the incident history behind every branch.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import json
|
|
44
|
+
import os
|
|
45
|
+
import re
|
|
46
|
+
import signal
|
|
47
|
+
import subprocess
|
|
48
|
+
import sys
|
|
49
|
+
import time
|
|
50
|
+
|
|
51
|
+
_UUID_RE = re.compile(
|
|
52
|
+
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class BrowserMutex:
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
lock_file: str,
|
|
60
|
+
label: str,
|
|
61
|
+
lock_expiry: int = 300,
|
|
62
|
+
post_lock_expiry: int = 180,
|
|
63
|
+
wait_max: int = 45,
|
|
64
|
+
poll_interval: int = 2,
|
|
65
|
+
preempt_kill_wait: int = 5,
|
|
66
|
+
role: str | None = None,
|
|
67
|
+
on_touch=None,
|
|
68
|
+
):
|
|
69
|
+
"""label is the human prefix in error strings ("Twitter browser",
|
|
70
|
+
"Reddit browser") — downstream parsers grep "locked by session", keep
|
|
71
|
+
the shape. on_touch (optional callable) runs after every successful
|
|
72
|
+
acquire/refresh (reddit bumps its bash lease there); it must never
|
|
73
|
+
raise consequences: exceptions are swallowed."""
|
|
74
|
+
self.lock_file = os.path.expanduser(lock_file)
|
|
75
|
+
self.label = label
|
|
76
|
+
self.lock_expiry = lock_expiry
|
|
77
|
+
self.post_lock_expiry = post_lock_expiry
|
|
78
|
+
self.wait_max = wait_max
|
|
79
|
+
self.poll_interval = poll_interval
|
|
80
|
+
self.preempt_kill_wait = preempt_kill_wait
|
|
81
|
+
self.role = (role or os.environ.get("S4L_LOCK_ROLE") or "scan").strip() or "scan"
|
|
82
|
+
self.on_touch = on_touch
|
|
83
|
+
self.session_id = f"python:{os.getpid()}"
|
|
84
|
+
self.inherited = False
|
|
85
|
+
self.acquired_at: float | None = None
|
|
86
|
+
|
|
87
|
+
# ---- liveness probes ----------------------------------------------------
|
|
88
|
+
@staticmethod
|
|
89
|
+
def _is_uuid_holder_alive(holder: str) -> bool:
|
|
90
|
+
if not holder:
|
|
91
|
+
return False
|
|
92
|
+
try:
|
|
93
|
+
return (
|
|
94
|
+
subprocess.run(
|
|
95
|
+
["pgrep", "-f", f"claude.*--session-id {holder}"],
|
|
96
|
+
stdout=subprocess.DEVNULL,
|
|
97
|
+
stderr=subprocess.DEVNULL,
|
|
98
|
+
timeout=2,
|
|
99
|
+
).returncode
|
|
100
|
+
== 0
|
|
101
|
+
)
|
|
102
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
103
|
+
return True # err on the side of NOT stealing
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _is_python_holder_alive(holder: str) -> bool:
|
|
107
|
+
if not holder.startswith("python:"):
|
|
108
|
+
return True # not a python holder; this probe makes no claim
|
|
109
|
+
try:
|
|
110
|
+
pid = int(holder.split(":", 1)[1])
|
|
111
|
+
except (ValueError, IndexError):
|
|
112
|
+
return True
|
|
113
|
+
try:
|
|
114
|
+
os.kill(pid, 0)
|
|
115
|
+
return True
|
|
116
|
+
except ProcessLookupError:
|
|
117
|
+
return False
|
|
118
|
+
except PermissionError:
|
|
119
|
+
return True
|
|
120
|
+
except OSError:
|
|
121
|
+
return True
|
|
122
|
+
|
|
123
|
+
# ---- claim / preempt ----------------------------------------------------
|
|
124
|
+
def _try_take(self) -> bool:
|
|
125
|
+
"""O_CREAT|O_EXCL makes "is it free? then take it" one syscall, so two
|
|
126
|
+
cold-start acquirers can't both win (defect c)."""
|
|
127
|
+
try:
|
|
128
|
+
fd = os.open(self.lock_file, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
|
129
|
+
except FileExistsError:
|
|
130
|
+
return False
|
|
131
|
+
except OSError:
|
|
132
|
+
return False
|
|
133
|
+
try:
|
|
134
|
+
os.write(fd, json.dumps(
|
|
135
|
+
{"session_id": self.session_id, "timestamp": int(time.time()), "role": self.role}
|
|
136
|
+
).encode())
|
|
137
|
+
finally:
|
|
138
|
+
os.close(fd)
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
def _preempt(self, pid: int) -> bool:
|
|
142
|
+
try:
|
|
143
|
+
os.kill(pid, signal.SIGTERM)
|
|
144
|
+
except ProcessLookupError:
|
|
145
|
+
return True
|
|
146
|
+
except OSError:
|
|
147
|
+
return False
|
|
148
|
+
deadline = time.time() + self.preempt_kill_wait
|
|
149
|
+
while time.time() < deadline:
|
|
150
|
+
try:
|
|
151
|
+
os.kill(pid, 0)
|
|
152
|
+
except OSError:
|
|
153
|
+
return True
|
|
154
|
+
time.sleep(0.2)
|
|
155
|
+
try:
|
|
156
|
+
os.kill(pid, signal.SIGKILL)
|
|
157
|
+
except OSError:
|
|
158
|
+
pass
|
|
159
|
+
try:
|
|
160
|
+
os.kill(pid, 0)
|
|
161
|
+
except OSError:
|
|
162
|
+
return True
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
def _touch(self):
|
|
166
|
+
if self.on_touch:
|
|
167
|
+
try:
|
|
168
|
+
self.on_touch()
|
|
169
|
+
except Exception:
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
# ---- public API -----------------------------------------------------------
|
|
173
|
+
def acquire(self):
|
|
174
|
+
deadline = time.time() + self.wait_max
|
|
175
|
+
try:
|
|
176
|
+
os.makedirs(os.path.dirname(self.lock_file), exist_ok=True)
|
|
177
|
+
except OSError:
|
|
178
|
+
pass
|
|
179
|
+
while True:
|
|
180
|
+
if not os.path.exists(self.lock_file):
|
|
181
|
+
if self._try_take():
|
|
182
|
+
break
|
|
183
|
+
if time.time() >= deadline:
|
|
184
|
+
print(json.dumps({
|
|
185
|
+
"success": False,
|
|
186
|
+
"error": f"{self.label} lock contended on create; waited {self.wait_max}s, giving up."
|
|
187
|
+
}))
|
|
188
|
+
sys.exit(1)
|
|
189
|
+
time.sleep(self.poll_interval)
|
|
190
|
+
continue
|
|
191
|
+
try:
|
|
192
|
+
with open(self.lock_file) as f:
|
|
193
|
+
lock = json.load(f)
|
|
194
|
+
except (json.JSONDecodeError, OSError):
|
|
195
|
+
if self._try_take():
|
|
196
|
+
break
|
|
197
|
+
if time.time() >= deadline:
|
|
198
|
+
print(json.dumps({
|
|
199
|
+
"success": False,
|
|
200
|
+
"error": f"{self.label} lock unreadable; waited {self.wait_max}s, giving up."
|
|
201
|
+
}))
|
|
202
|
+
sys.exit(1)
|
|
203
|
+
time.sleep(self.poll_interval)
|
|
204
|
+
continue
|
|
205
|
+
age = time.time() - lock.get("timestamp", 0)
|
|
206
|
+
holder = lock.get("session_id", "")
|
|
207
|
+
holder_role = lock.get("role", "scan") # legacy locks (no role) = preemptable
|
|
208
|
+
|
|
209
|
+
# 1. Re-entrant.
|
|
210
|
+
if holder == self.session_id and not self.inherited:
|
|
211
|
+
self.refresh()
|
|
212
|
+
break
|
|
213
|
+
|
|
214
|
+
# 1b. Batch-owner inherit (posting).
|
|
215
|
+
batch_owner = os.environ.get("S4L_LOCK_OWNER") or ""
|
|
216
|
+
if holder and holder == batch_owner and self._is_python_holder_alive(holder):
|
|
217
|
+
self.session_id = holder
|
|
218
|
+
self.inherited = True
|
|
219
|
+
self.refresh()
|
|
220
|
+
print(f"[browser_lock] inherited batch owner={holder} "
|
|
221
|
+
f"role={holder_role} -> pid={os.getpid()}", file=sys.stderr)
|
|
222
|
+
break
|
|
223
|
+
|
|
224
|
+
# 2-4. Reclaim provably dead/expired holders.
|
|
225
|
+
reclaim_reason = ""
|
|
226
|
+
if _UUID_RE.match(holder or "") and not self._is_uuid_holder_alive(holder):
|
|
227
|
+
reclaim_reason = "dead_uuid"
|
|
228
|
+
elif holder.startswith("python:") and not self._is_python_holder_alive(holder):
|
|
229
|
+
reclaim_reason = "dead_python"
|
|
230
|
+
elif age >= (self.post_lock_expiry if holder_role == "post" else self.lock_expiry):
|
|
231
|
+
reclaim_reason = "expired"
|
|
232
|
+
if reclaim_reason:
|
|
233
|
+
try:
|
|
234
|
+
os.remove(self.lock_file)
|
|
235
|
+
except OSError:
|
|
236
|
+
pass
|
|
237
|
+
if self._try_take():
|
|
238
|
+
print(f"[browser_lock] reclaimed holder={holder or '<none>'} "
|
|
239
|
+
f"reason={reclaim_reason} age={int(age)}s -> pid={os.getpid()}",
|
|
240
|
+
file=sys.stderr)
|
|
241
|
+
break
|
|
242
|
+
time.sleep(self.poll_interval)
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
# 5. Live UUID holder = parent Claude session -> inherit.
|
|
246
|
+
if _UUID_RE.match(holder or ""):
|
|
247
|
+
self.session_id = holder
|
|
248
|
+
self.inherited = True
|
|
249
|
+
break
|
|
250
|
+
|
|
251
|
+
# 5b. POSTING PRIORITY: preempt a live lower-priority python peer.
|
|
252
|
+
if (
|
|
253
|
+
self.role == "post"
|
|
254
|
+
and holder.startswith("python:")
|
|
255
|
+
and holder_role != "post"
|
|
256
|
+
and self._is_python_holder_alive(holder)
|
|
257
|
+
):
|
|
258
|
+
try:
|
|
259
|
+
victim_pid = int(holder.split(":", 1)[1])
|
|
260
|
+
except (ValueError, IndexError):
|
|
261
|
+
victim_pid = 0
|
|
262
|
+
if victim_pid and self._preempt(victim_pid):
|
|
263
|
+
try:
|
|
264
|
+
os.remove(self.lock_file)
|
|
265
|
+
except OSError:
|
|
266
|
+
pass
|
|
267
|
+
if self._try_take():
|
|
268
|
+
print(
|
|
269
|
+
f"[browser_lock] post preempted holder={holder} "
|
|
270
|
+
f"role={holder_role} age={int(age)}s -> pid={os.getpid()}",
|
|
271
|
+
file=sys.stderr,
|
|
272
|
+
)
|
|
273
|
+
break
|
|
274
|
+
time.sleep(self.poll_interval)
|
|
275
|
+
continue
|
|
276
|
+
|
|
277
|
+
# 6. Live python peer: wait, then give up (real contention).
|
|
278
|
+
if time.time() >= deadline:
|
|
279
|
+
print(json.dumps({
|
|
280
|
+
"success": False,
|
|
281
|
+
"error": f"{self.label} locked by session {holder} ({int(age)}s, peer alive); waited {self.wait_max}s, giving up."
|
|
282
|
+
}))
|
|
283
|
+
sys.exit(1)
|
|
284
|
+
time.sleep(self.poll_interval)
|
|
285
|
+
continue
|
|
286
|
+
self.acquired_at = time.time()
|
|
287
|
+
self._touch()
|
|
288
|
+
|
|
289
|
+
def refresh(self):
|
|
290
|
+
try:
|
|
291
|
+
with open(self.lock_file, "w") as f:
|
|
292
|
+
json.dump({"session_id": self.session_id, "timestamp": int(time.time()), "role": self.role}, f)
|
|
293
|
+
except OSError:
|
|
294
|
+
pass
|
|
295
|
+
self._touch()
|
|
296
|
+
|
|
297
|
+
def release(self):
|
|
298
|
+
"""Inherited locks are the PARENT'S to release; never clobber them."""
|
|
299
|
+
if self.inherited:
|
|
300
|
+
return
|
|
301
|
+
try:
|
|
302
|
+
if os.path.exists(self.lock_file):
|
|
303
|
+
with open(self.lock_file) as f:
|
|
304
|
+
lock = json.load(f)
|
|
305
|
+
if lock.get("session_id") == self.session_id:
|
|
306
|
+
os.remove(self.lock_file)
|
|
307
|
+
if self.acquired_at:
|
|
308
|
+
held = time.time() - self.acquired_at
|
|
309
|
+
if held >= 5:
|
|
310
|
+
print(
|
|
311
|
+
f"[browser-lock] held {held:.0f}s "
|
|
312
|
+
f"(role={self.role}, pid={os.getpid()})",
|
|
313
|
+
file=sys.stderr,
|
|
314
|
+
)
|
|
315
|
+
except (json.JSONDecodeError, OSError):
|
|
316
|
+
pass
|