@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 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
- for (const c of approvedReddit) {
1019
- const cc = c;
1020
- const dec = cc.reddit_decision;
1021
- const meta = (cc.reddit_plan_meta || {});
1022
- if (!dec) {
1023
- c.terminal = true;
1024
- c.terminal_reason = "reddit_decision_missing";
1025
- redditFailed++;
1026
- continue;
1027
- }
1028
- // The card's reply_text is the CANONICAL text (post_drafts edits write
1029
- // there); the embedded decision still carries the draft-time original.
1030
- // Posting dec.text verbatim would silently discard a human edit.
1031
- const decPost = {
1032
- ...dec,
1033
- text: (typeof cc.reply_text === "string" && cc.reply_text.trim())
1034
- ? cc.reply_text
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(tmpPlan, JSON.stringify(miniPlan));
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 (err) {
1068
- r = { code: -1, stdout: "", stderr: String(err) };
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.unlinkSync(tmpPlan);
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
- // post_reddit.py's summary marker: `[post_reddit] phase=post ... posted=N failed=M`
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 {
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.5-rc.13",
3
- "installedAt": "2026-07-15T01:32:54.676Z"
2
+ "version": "1.7.5-rc.14",
3
+ "installedAt": "2026-07-15T01:50:34.288Z"
4
4
  }
package/mcp/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "dxt_version": "0.1",
3
3
  "name": "social-autoposter",
4
4
  "display_name": "S4L",
5
- "version": "1.7.5-rc.13",
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.13",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l",
3
- "version": "1.7.5-rc.13",
3
+ "version": "1.7.5-rc.14",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js",
@@ -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