social-autoposter 1.6.136 → 1.6.138
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 +39 -2
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_menubar.py +34 -0
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/_check_block.py +37 -0
- package/scripts/autopilot_stall_watch.py +54 -7
- package/scripts/claude_job.py +50 -3
- package/scripts/memory_snapshot.py +63 -4
package/mcp/dist/index.js
CHANGED
|
@@ -2039,8 +2039,14 @@ async function autopilotLoaded() {
|
|
|
2039
2039
|
// fires every minute, claims ONE job, runs the pipeline's own prompt as its
|
|
2040
2040
|
// Claude turn, writes the result back, and stops.
|
|
2041
2041
|
// ===========================================================================
|
|
2042
|
-
const QUEUE_WORKER_PROMPT_VERSION = 2
|
|
2042
|
+
const QUEUE_WORKER_PROMPT_VERSION = 4; // v4: tighten per-run draft cap to 2 (v3's 3 still overran the ~100-134s kill window on heavy cycles, ~20% timeouts)
|
|
2043
2043
|
const QUEUE_WORKER_PROMPT_MARKER = "saps_queue_worker_prompt_version";
|
|
2044
|
+
// Max replies the Phase-2b draft worker may draft in ONE scheduled run. The host
|
|
2045
|
+
// SIGTERMs scheduled sessions after ~90-110s; drafting more than a handful never
|
|
2046
|
+
// submits in time. Start conservative so a run always finishes inside the window;
|
|
2047
|
+
// undrafted candidates stay "pending" and return next cycle (lossless). Can be
|
|
2048
|
+
// raised once drains are confirmed reliably under the kill budget.
|
|
2049
|
+
const DRAFT_BUDGET_CANDIDATES = 2;
|
|
2044
2050
|
// One spec per worker task. queueType MUST match scripts/claude_job.py TAG_TO_TYPE.
|
|
2045
2051
|
const QUEUE_WORKERS = [
|
|
2046
2052
|
{ taskId: PHASE1_TASK_ID, queueType: "twitter-query", human: "Phase 1 X search-query drafting" },
|
|
@@ -2142,6 +2148,17 @@ function queueWorkerBody(spec) {
|
|
|
2142
2148
|
const job = path.join(repoDir(), "scripts", "claude_job.py");
|
|
2143
2149
|
const sd = sapsStateDir();
|
|
2144
2150
|
const outDir = queueDir();
|
|
2151
|
+
// The Phase-2b "twitter-prep" worker drafts replies inside the scheduled
|
|
2152
|
+
// session, which the host hard-kills (SIGTERM) after ~90-110s of wall time.
|
|
2153
|
+
// Drafting all of one cycle's candidates blows past that every time, so the
|
|
2154
|
+
// job is never submitted and times out (see provider.log drain timeouts).
|
|
2155
|
+
// Cap how many it drafts per run so it always finishes and submits in time;
|
|
2156
|
+
// the inner prompt already treats undrafted candidates as "pending" timing
|
|
2157
|
+
// deferrals, so they come back next cycle (lossless, self-healing).
|
|
2158
|
+
const isDraft = spec.queueType === "twitter-prep";
|
|
2159
|
+
const draftBudgetNote = isDraft
|
|
2160
|
+
? ` Per the TIME BUDGET above, draft AT MOST the first ${DRAFT_BUDGET_CANDIDATES} candidates and OMIT the rest; do not attempt them all.`
|
|
2161
|
+
: "";
|
|
2145
2162
|
return [
|
|
2146
2163
|
`You are the S4L "${spec.human}" queue worker. Run ONE iteration, then STOP.`,
|
|
2147
2164
|
``,
|
|
@@ -2151,6 +2168,26 @@ function queueWorkerBody(spec) {
|
|
|
2151
2168
|
`You do this with Bash and Write, and NOTHING else. This run is unattended — ` +
|
|
2152
2169
|
`reaching for any other tool, or trying to "investigate", STALLS it forever.`,
|
|
2153
2170
|
``,
|
|
2171
|
+
...(isDraft
|
|
2172
|
+
? [
|
|
2173
|
+
`TIME BUDGET (read before step 2; this OVERRIDES anything the inner prompt ` +
|
|
2174
|
+
`says about "no cap" or drafting "every" candidate): this unattended ` +
|
|
2175
|
+
`session is HARD-KILLED after about 90 seconds. You physically cannot ` +
|
|
2176
|
+
`draft more than a few replies before that kill, and if you are killed ` +
|
|
2177
|
+
`mid-draft the pipeline receives NOTHING and the whole cycle is wasted. ` +
|
|
2178
|
+
`So work to this rule: draft replies for AT MOST the first ` +
|
|
2179
|
+
`${DRAFT_BUDGET_CANDIDATES} candidates in the prompt (they are pre-sorted ` +
|
|
2180
|
+
`strongest-first), then IMMEDIATELY do step 3 (write your result and ` +
|
|
2181
|
+
`submit). Every candidate you do not draft is a TIMING DEFERRAL, NOT a ` +
|
|
2182
|
+
`rejection: OMIT it from BOTH the "candidates" and "rejected" arrays. The ` +
|
|
2183
|
+
`inner prompt explicitly allows this (omitted candidates stay "pending" ` +
|
|
2184
|
+
`and a later run re-judges them, so nothing is lost). A valid result with ` +
|
|
2185
|
+
`${DRAFT_BUDGET_CANDIDATES} or FEWER solid drafts that you submit IN TIME ` +
|
|
2186
|
+
`is a SUCCESS; being killed with nothing submitted is the only failure. ` +
|
|
2187
|
+
`When unsure, draft fewer and submit sooner.`,
|
|
2188
|
+
``,
|
|
2189
|
+
]
|
|
2190
|
+
: []),
|
|
2154
2191
|
`Steps:`,
|
|
2155
2192
|
`1. Claim the next job. Run this EXACT Bash command:`,
|
|
2156
2193
|
` ${py} ${job} next --type ${spec.queueType} --prompt-file --state-dir ${sd}`,
|
|
@@ -2162,7 +2199,7 @@ function queueWorkerBody(spec) {
|
|
|
2162
2199
|
`or truncated, keep reading the same file with offsets until EOF. If schema_file ` +
|
|
2163
2200
|
`is not null, read it too. Follow the prompt EXACTLY and produce the SINGLE JSON ` +
|
|
2164
2201
|
`object it asks for. If a schema is present, your JSON MUST satisfy it. Output ` +
|
|
2165
|
-
`ONLY that JSON object — no prose, no markdown, no code fences
|
|
2202
|
+
`ONLY that JSON object — no prose, no markdown, no code fences.${draftBudgetNote}`,
|
|
2166
2203
|
`3. Submit it. Write your JSON object to ${outDir}/out-<job_id>.json using the ` +
|
|
2167
2204
|
`Write tool (substitute the real job_id), then run this EXACT Bash command:`,
|
|
2168
2205
|
` ${py} ${job} result --job <job_id> --result-file ${outDir}/out-<job_id>.json --state-dir ${sd}`,
|
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.138",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts.",
|
|
7
7
|
"long_description": "The disclaimer above is generic Claude boilerplate. S4L 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 end to end**\n\n2\\. Quit fully with CMD+Q, restart Claude, and paste the prompt into a new chat.",
|
|
8
8
|
"author": {
|
|
@@ -133,6 +133,12 @@ REARM_PROMPT = (
|
|
|
133
133
|
# scheduler's per-minute cadence + a slow claim.
|
|
134
134
|
AUTOPILOT_STALL_SECONDS = 180
|
|
135
135
|
|
|
136
|
+
# A job CLAIMED but never finished (sits in running/ this long) means a worker
|
|
137
|
+
# picked it up and then wedged mid-run (the claude -p drafting child died / never
|
|
138
|
+
# spawned). Generous enough that the longest real drafting turn never trips it.
|
|
139
|
+
# Keep in sync with RUNNING_STALL_SECONDS (scripts/autopilot_stall_watch.py).
|
|
140
|
+
AUTOPILOT_RUNNING_STALL_SECONDS = 900
|
|
141
|
+
|
|
136
142
|
# A worker task whose lastRunAt is within this many seconds is "firing" — the host
|
|
137
143
|
# scheduler runs them every minute, so a fresh stamp means the live account's
|
|
138
144
|
# schedule is active. 7 min tolerates host throttling + a restart gap without
|
|
@@ -452,6 +458,18 @@ class S4LMenuBar(rumps.App):
|
|
|
452
458
|
(2) FAST: a draft job has sat unclaimed in pending/ past
|
|
453
459
|
AUTOPILOT_STALL_SECONDS -> catches a fresh stall ~3 min in, before
|
|
454
460
|
the first full producer timeout has even latched (1).
|
|
461
|
+
(3) IN-FLIGHT: a draft job was claimed (moved to running/) but never
|
|
462
|
+
finished within AUTOPILOT_RUNNING_STALL_SECONDS -> the worker picked
|
|
463
|
+
it up and then wedged mid-run. Self-clearing (the file is removed on
|
|
464
|
+
result or swept next cycle), so unlike the abandoned drain latch it
|
|
465
|
+
does NOT stay stale after recovery.
|
|
466
|
+
|
|
467
|
+
NOTE: kept in sync with scripts/autopilot_stall_watch.py (the fleet Sentry
|
|
468
|
+
backstop). The menu-bar ⚠ itself is driven by _schedule_state, NOT this
|
|
469
|
+
method — the attention/⚠ path keys off schedule_state so a firing-but-
|
|
470
|
+
momentarily-empty queue stays green (an earlier drain-latch ⚠ stayed stale
|
|
471
|
+
after recovery and was deliberately removed). This method exists for the
|
|
472
|
+
watcher-parity contract and _stall_reason.
|
|
455
473
|
"""
|
|
456
474
|
qroot = os.path.join(st.state_dir(), "claude-queue")
|
|
457
475
|
# (1) latched producer drain-status
|
|
@@ -478,6 +496,22 @@ class S4LMenuBar(rumps.App):
|
|
|
478
496
|
return True
|
|
479
497
|
except Exception:
|
|
480
498
|
pass
|
|
499
|
+
# (3) in-flight running-age (claimed then wedged). running/ is flat.
|
|
500
|
+
try:
|
|
501
|
+
oldest = None
|
|
502
|
+
for jf in glob.glob(os.path.join(qroot, "running", "*.json")):
|
|
503
|
+
if jf.endswith(".tmp"):
|
|
504
|
+
continue
|
|
505
|
+
try:
|
|
506
|
+
m = os.path.getmtime(jf)
|
|
507
|
+
except OSError:
|
|
508
|
+
continue
|
|
509
|
+
if oldest is None or m < oldest:
|
|
510
|
+
oldest = m
|
|
511
|
+
if oldest is not None and (time.time() - oldest) > AUTOPILOT_RUNNING_STALL_SECONDS:
|
|
512
|
+
return True
|
|
513
|
+
except Exception:
|
|
514
|
+
pass
|
|
481
515
|
return False
|
|
482
516
|
|
|
483
517
|
def _recent_worker_outcome(self, window=600):
|
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.138",
|
|
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,37 @@
|
|
|
1
|
+
import asyncio, sys
|
|
2
|
+
from playwright.async_api import async_playwright
|
|
3
|
+
|
|
4
|
+
PROFILE = "https://x.com/Web3Mohsin"
|
|
5
|
+
STATUS = "https://x.com/Web3Mohsin/status/2071449607092183151"
|
|
6
|
+
|
|
7
|
+
async def grab(page, url, shot):
|
|
8
|
+
await page.goto(url, wait_until="domcontentloaded")
|
|
9
|
+
await page.wait_for_timeout(6000)
|
|
10
|
+
try:
|
|
11
|
+
txt = await page.inner_text("body")
|
|
12
|
+
except Exception as e:
|
|
13
|
+
txt = f"<no body text: {e}>"
|
|
14
|
+
await page.screenshot(path=shot, full_page=False)
|
|
15
|
+
return txt
|
|
16
|
+
|
|
17
|
+
async def main():
|
|
18
|
+
async with async_playwright() as p:
|
|
19
|
+
b = await p.chromium.connect_over_cdp("http://127.0.0.1:9555")
|
|
20
|
+
ctx = b.contexts[0]
|
|
21
|
+
page = await ctx.new_page()
|
|
22
|
+
who = ""
|
|
23
|
+
try:
|
|
24
|
+
await page.goto("https://x.com/home", wait_until="domcontentloaded")
|
|
25
|
+
await page.wait_for_timeout(3000)
|
|
26
|
+
except Exception as e:
|
|
27
|
+
who = f"<home err {e}>"
|
|
28
|
+
prof = await grab(page, PROFILE, "/tmp/blk_profile.png")
|
|
29
|
+
stat = await grab(page, STATUS, "/tmp/blk_status.png")
|
|
30
|
+
print("==== PROFILE URL:", page.url)
|
|
31
|
+
print("==== PROFILE TEXT (first 1500) ====")
|
|
32
|
+
print(prof[:1500])
|
|
33
|
+
print("\n==== STATUS TEXT (first 1500) ====")
|
|
34
|
+
print(stat[:1500])
|
|
35
|
+
await page.close()
|
|
36
|
+
|
|
37
|
+
asyncio.run(main())
|
|
@@ -40,6 +40,12 @@ import time
|
|
|
40
40
|
|
|
41
41
|
# Keep in sync with AUTOPILOT_STALL_SECONDS (menubar) / AUTOPILOT_STALL_MS (index.ts).
|
|
42
42
|
STALL_SECONDS = 180
|
|
43
|
+
# A job CLAIMED but never finished (sits in running/ this long) means a worker
|
|
44
|
+
# picked it up and then died mid-run — the claude -p drafting child never came up
|
|
45
|
+
# or crashed. Must be generous enough to clear the longest real drafting turn so a
|
|
46
|
+
# healthy run never trips it. Keep in sync with AUTOPILOT_RUNNING_STALL_SECONDS
|
|
47
|
+
# (menubar). See _oldest_running_age.
|
|
48
|
+
RUNNING_STALL_SECONDS = 900
|
|
43
49
|
# Require the stall to persist this many consecutive checks before paging, so a
|
|
44
50
|
# transient slow claim (e.g. right after a Claude restart) doesn't false-alarm.
|
|
45
51
|
# At StartInterval 120 that is ~6 min of continuous stall.
|
|
@@ -136,6 +142,30 @@ def _oldest_pending_age() -> float | None:
|
|
|
136
142
|
return time.time() - oldest
|
|
137
143
|
|
|
138
144
|
|
|
145
|
+
def _oldest_running_age() -> float | None:
|
|
146
|
+
"""Seconds since the oldest CLAIMED-but-unfinished job was written, or None if
|
|
147
|
+
nothing is in flight. A worker claims by moving a job pending/ -> running/ and
|
|
148
|
+
only removes it on result, so a job lingering in running/ far past any real
|
|
149
|
+
drafting turn means the worker claimed it and then wedged mid-run (dead/never-
|
|
150
|
+
spawned claude -p child). This is the ONLY signal for that case: pending-age is
|
|
151
|
+
silent (the job left pending/) and the producer's drain latch hasn't fired yet
|
|
152
|
+
(it's still inside its own timeout). running/ is flat (see claude_job.py)."""
|
|
153
|
+
run_root = os.path.join(_queue_root(), "running")
|
|
154
|
+
oldest = None
|
|
155
|
+
for jf in glob.glob(os.path.join(run_root, "*.json")):
|
|
156
|
+
if jf.endswith(".tmp"):
|
|
157
|
+
continue
|
|
158
|
+
try:
|
|
159
|
+
m = os.path.getmtime(jf)
|
|
160
|
+
except OSError:
|
|
161
|
+
continue
|
|
162
|
+
if oldest is None or m < oldest:
|
|
163
|
+
oldest = m
|
|
164
|
+
if oldest is None:
|
|
165
|
+
return None
|
|
166
|
+
return time.time() - oldest
|
|
167
|
+
|
|
168
|
+
|
|
139
169
|
def _read_state() -> dict:
|
|
140
170
|
try:
|
|
141
171
|
with open(_watch_state_path()) as f:
|
|
@@ -169,11 +199,16 @@ def _sentry():
|
|
|
169
199
|
|
|
170
200
|
def main() -> int:
|
|
171
201
|
age = _oldest_pending_age()
|
|
202
|
+
run_age = _oldest_running_age()
|
|
172
203
|
timeouts = _consecutive_timeouts()
|
|
173
|
-
#
|
|
174
|
-
#
|
|
204
|
+
# Three complementary signals, OR'd; all gated on the autopilot actually being
|
|
205
|
+
# configured here. (1) durable producer drain latch, (2) fast pending-age (job
|
|
206
|
+
# never claimed), (3) running-age (job claimed then wedged mid-run). (3) is the
|
|
207
|
+
# only one that catches a worker dying after it picked up the job.
|
|
175
208
|
stalled = _autopilot_configured() and (
|
|
176
|
-
timeouts >= 1
|
|
209
|
+
timeouts >= 1
|
|
210
|
+
or (age is not None and age > STALL_SECONDS)
|
|
211
|
+
or (run_age is not None and run_age > RUNNING_STALL_SECONDS)
|
|
177
212
|
)
|
|
178
213
|
# A rate-limit stall is expected and self-heals at the quota reset — never page
|
|
179
214
|
# for it (and re-arm can't fix it). Treat it as "not an actionable stall" so the
|
|
@@ -193,21 +228,33 @@ def main() -> int:
|
|
|
193
228
|
|
|
194
229
|
consecutive += 1
|
|
195
230
|
age_str = f"{int(age)}s" if age is not None else "n/a (between cycles)"
|
|
231
|
+
run_age_str = f"{int(run_age)}s" if run_age is not None else "n/a (none in flight)"
|
|
232
|
+
# Distinguish the two shapes so the alert points at the right cause: a claimed-
|
|
233
|
+
# but-wedged job (running-age) is a mid-run worker death, not an orphaned routine.
|
|
234
|
+
wedged_inflight = run_age is not None and run_age > RUNNING_STALL_SECONDS
|
|
196
235
|
if consecutive >= ALERT_AFTER and not alerted:
|
|
197
236
|
try:
|
|
198
237
|
sentry = _sentry()
|
|
199
238
|
sentry.init()
|
|
239
|
+
cause = (
|
|
240
|
+
"a worker claimed a draft job and then died mid-run (claude -p child "
|
|
241
|
+
"never came up / crashed)"
|
|
242
|
+
if wedged_inflight
|
|
243
|
+
else "scheduled-task routines likely orphaned — Claude Desktop account change?"
|
|
244
|
+
)
|
|
200
245
|
sentry.capture_message(
|
|
201
246
|
"social-autoposter autopilot stalled: draft jobs are not being "
|
|
202
|
-
"drained (
|
|
203
|
-
f"
|
|
204
|
-
f"
|
|
247
|
+
f"drained ({cause}). producer consecutive timeouts={timeouts}, "
|
|
248
|
+
f"oldest pending job age={age_str}, oldest in-flight (running) job "
|
|
249
|
+
f"age={run_age_str}, sustained {consecutive} checks.",
|
|
205
250
|
level="error",
|
|
206
251
|
tags={
|
|
207
252
|
"component": "autopilot",
|
|
208
253
|
"issue": "stall",
|
|
254
|
+
"stall_shape": "inflight_wedged" if wedged_inflight else "not_draining",
|
|
209
255
|
"consecutive_timeouts": str(timeouts),
|
|
210
256
|
"oldest_pending_age_s": str(int(age)) if age is not None else "",
|
|
257
|
+
"oldest_running_age_s": str(int(run_age)) if run_age is not None else "",
|
|
211
258
|
},
|
|
212
259
|
)
|
|
213
260
|
sentry.flush()
|
|
@@ -215,7 +262,7 @@ def main() -> int:
|
|
|
215
262
|
# No Sentry (helper/SDK missing) -> at least leave a local breadcrumb.
|
|
216
263
|
sys.stderr.write(
|
|
217
264
|
f"[stall-watch] autopilot stalled (timeouts={timeouts}, "
|
|
218
|
-
f"
|
|
265
|
+
f"pending_age={age_str}, running_age={run_age_str}) but Sentry report failed\n"
|
|
219
266
|
)
|
|
220
267
|
alerted = True
|
|
221
268
|
|
package/scripts/claude_job.py
CHANGED
|
@@ -86,6 +86,43 @@ def _act_clear() -> None:
|
|
|
86
86
|
except Exception:
|
|
87
87
|
pass
|
|
88
88
|
|
|
89
|
+
|
|
90
|
+
def _fmt_dur(secs: float) -> str:
|
|
91
|
+
"""Compact human duration for the menu-bar label: '45s', '12m'."""
|
|
92
|
+
s = int(max(0, secs))
|
|
93
|
+
return f"{s}s" if s < 60 else f"{s // 60}m"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _act_write_progress(
|
|
97
|
+
qtype: str, created: float, claimed_at: float | None, now: float
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Granular in-flight menu-bar label, so a wedged cycle reads as the TRUTH
|
|
100
|
+
instead of a static 'drafting replies' that lingers for the whole producer
|
|
101
|
+
timeout (the failure mode where the worker never claims the job, or claims it
|
|
102
|
+
and dies mid-run, looked identical to healthy drafting before this).
|
|
103
|
+
|
|
104
|
+
- job still in pending/ (no worker has claimed it) -> '<base> (queued <dur>)'
|
|
105
|
+
counting from enqueue. A growing 'queued 18m' is the unmistakable tell that
|
|
106
|
+
a scheduled-task worker is orphaned and nothing is draining.
|
|
107
|
+
- job claimed (pending file gone -> moved to running/) -> '<base> (<dur>)'
|
|
108
|
+
counting from the claim, i.e. real drafting elapsed.
|
|
109
|
+
|
|
110
|
+
Purely cosmetic and best-effort: a write failure must never affect the queue."""
|
|
111
|
+
if _activity is None:
|
|
112
|
+
return
|
|
113
|
+
sl = TYPE_TO_ACTIVITY.get(qtype)
|
|
114
|
+
if not sl:
|
|
115
|
+
return
|
|
116
|
+
state, base = sl
|
|
117
|
+
if claimed_at is None:
|
|
118
|
+
label = f"{base} (queued {_fmt_dur(now - created)})"
|
|
119
|
+
else:
|
|
120
|
+
label = f"{base} ({_fmt_dur(now - claimed_at)})"
|
|
121
|
+
try:
|
|
122
|
+
_activity.write(state, label)
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
|
|
89
126
|
# claude flags that consume the following argv token as their value, so the
|
|
90
127
|
# value is never mistaken for the positional prompt.
|
|
91
128
|
VALUE_FLAGS = {
|
|
@@ -499,17 +536,27 @@ def cmd_provider(ns) -> int:
|
|
|
499
536
|
res_path = os.path.join(result_dir(), f"{job_id}.json")
|
|
500
537
|
deadline = created + ns.timeout
|
|
501
538
|
last_hb = created # last menu-bar heartbeat (see below)
|
|
539
|
+
claimed_at = None # set the moment a worker moves the job pending/ -> running/
|
|
502
540
|
while time.time() < deadline:
|
|
541
|
+
now = time.time()
|
|
542
|
+
# A worker claims a job by atomically renaming pending/ -> running/, so the
|
|
543
|
+
# pending file vanishing is our signal that drafting actually STARTED (vs.
|
|
544
|
+
# the job still sitting unclaimed). Latch the claim time once so the label
|
|
545
|
+
# can distinguish "waiting for a worker" from "worker is drafting" and show
|
|
546
|
+
# the right elapsed for each.
|
|
547
|
+
if claimed_at is None and not os.path.exists(pending_path):
|
|
548
|
+
claimed_at = now
|
|
503
549
|
# Heartbeat the menu-bar label so its `since` stays fresh for the whole
|
|
504
550
|
# multi-minute block. The consumer (s4l_state.read_activity) ages a label
|
|
505
551
|
# out after a TTL, so without this refresh a long drafting turn would look
|
|
506
552
|
# stale and the spinner would wrongly blink to idle. Refreshing here means
|
|
507
553
|
# the label is fresh EXACTLY while real work is happening, and stops the
|
|
508
554
|
# instant we return or die — so the consumer's TTL can then expire it
|
|
509
|
-
# instead of it freezing forever. Throttled to ~10s; best-effort only.
|
|
510
|
-
now
|
|
555
|
+
# instead of it freezing forever. Throttled to ~10s; best-effort only. The
|
|
556
|
+
# label now carries claim-state + elapsed so a stuck cycle reads honestly
|
|
557
|
+
# ("queued 18m") instead of a reassuring static "drafting replies".
|
|
511
558
|
if now - last_hb >= 10:
|
|
512
|
-
|
|
559
|
+
_act_write_progress(qtype, created, claimed_at, now)
|
|
513
560
|
last_hb = now
|
|
514
561
|
if os.path.exists(res_path):
|
|
515
562
|
try:
|
|
@@ -169,6 +169,7 @@ def parse_vm_stat() -> dict[str, Any]:
|
|
|
169
169
|
"inactive_mb": pages_mb("pages_inactive"),
|
|
170
170
|
"speculative_mb": pages_mb("pages_speculative"),
|
|
171
171
|
"wired_mb": pages_mb("pages_wired_down"),
|
|
172
|
+
"purgeable_mb": pages_mb("pages_purgeable"),
|
|
172
173
|
"compressed_mb": pages_mb("pages_occupied_by_compressor"),
|
|
173
174
|
"swapins": pages.get("swapins"),
|
|
174
175
|
"swapouts": pages.get("swapouts"),
|
|
@@ -176,6 +177,30 @@ def parse_vm_stat() -> dict[str, Any]:
|
|
|
176
177
|
}
|
|
177
178
|
|
|
178
179
|
|
|
180
|
+
def memory_pressure_pct_free() -> float | None:
|
|
181
|
+
"""macOS-authoritative availability signal.
|
|
182
|
+
|
|
183
|
+
`memory_pressure` prints "System-wide memory free percentage: N%". THIS is the
|
|
184
|
+
number to trust for "is the box starved?" — NOT vm_stat "pages free", which sits
|
|
185
|
+
near-zero by design (macOS hoards RAM as cached/inactive/compressed pages, so a
|
|
186
|
+
tiny "free" is the normal healthy state, not starvation). Best-effort; returns
|
|
187
|
+
None if the tool is unavailable so the caller can fall back to a pages estimate.
|
|
188
|
+
"""
|
|
189
|
+
out = run(["/usr/bin/memory_pressure"], timeout=6.0)
|
|
190
|
+
if not out:
|
|
191
|
+
return None
|
|
192
|
+
m = re.search(r"free percentage:\s*([\d.]+)", out)
|
|
193
|
+
return round(float(m.group(1)), 1) if m else None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def swap_used_mb() -> float | None:
|
|
197
|
+
"""Active swap in MB from vm.swapusage (a real-pressure corroborator)."""
|
|
198
|
+
sysctl_bin = "/usr/sbin/sysctl" if Path("/usr/sbin/sysctl").exists() else "sysctl"
|
|
199
|
+
out = run([sysctl_bin, "-n", "vm.swapusage"], timeout=2.0)
|
|
200
|
+
m = re.search(r"used\s*=\s*([\d.]+)M", out or "")
|
|
201
|
+
return round(float(m.group(1)), 1) if m else None
|
|
202
|
+
|
|
203
|
+
|
|
179
204
|
def launchd_jobs(by_pid: dict[int, dict[str, Any]], children: dict[int, list[int]]) -> list[dict[str, Any]]:
|
|
180
205
|
out = run(["launchctl", "list"], timeout=5.0)
|
|
181
206
|
jobs: list[dict[str, Any]] = []
|
|
@@ -601,12 +626,42 @@ def build_summary() -> dict[str, Any]:
|
|
|
601
626
|
rows, by_pid, children = parse_ps()
|
|
602
627
|
mem = parse_vm_stat()
|
|
603
628
|
total = mem.get("total_mb")
|
|
604
|
-
|
|
629
|
+
# macOS memory accounting: "available" headroom is what can be handed to a
|
|
630
|
+
# process WITHOUT paging — free + inactive + speculative + purgeable, all of
|
|
631
|
+
# which the OS reclaims on demand. The real footprint is total - available.
|
|
632
|
+
# Do NOT use vm_stat "pages free" as the headline: it is near-zero by design
|
|
633
|
+
# (macOS keeps RAM full of reclaimable cache), so total-minus-free reads ~99%
|
|
634
|
+
# and falsely looks like starvation. That trap caused a wrong OOM call once.
|
|
635
|
+
avail_parts = [mem.get(k) for k in ("free_mb", "inactive_mb", "speculative_mb", "purgeable_mb")]
|
|
636
|
+
available = (
|
|
637
|
+
round(sum(p for p in avail_parts if isinstance(p, (int, float))), 1) if mem else None
|
|
638
|
+
)
|
|
605
639
|
used = (
|
|
606
|
-
round(float(total) - float(
|
|
607
|
-
if isinstance(total, (int, float)) and isinstance(
|
|
640
|
+
round(float(total) - float(available), 1)
|
|
641
|
+
if isinstance(total, (int, float)) and isinstance(available, (int, float))
|
|
642
|
+
else None
|
|
643
|
+
)
|
|
644
|
+
# pct_free is kept CONSISTENT with the MB figures above (available / total) so a
|
|
645
|
+
# reader never sees two contradictory percentages. `pressure_pct` is the separate
|
|
646
|
+
# OS pressure gauge from `memory_pressure` (counts evictable file cache as free, so
|
|
647
|
+
# it reads higher) — it is the most robust starvation detector, so `health` is
|
|
648
|
+
# derived from it, falling back to pct_free only when the tool is unavailable.
|
|
649
|
+
pct_free = (
|
|
650
|
+
round(available / total * 100, 1)
|
|
651
|
+
if isinstance(total, (int, float)) and isinstance(available, (int, float)) and total
|
|
608
652
|
else None
|
|
609
653
|
)
|
|
654
|
+
pressure_pct = memory_pressure_pct_free()
|
|
655
|
+
basis = pressure_pct if pressure_pct is not None else pct_free
|
|
656
|
+
if basis is None:
|
|
657
|
+
health = "unknown"
|
|
658
|
+
elif basis < 10:
|
|
659
|
+
health = "critical"
|
|
660
|
+
elif basis < 20:
|
|
661
|
+
health = "warn"
|
|
662
|
+
else:
|
|
663
|
+
health = "ok"
|
|
664
|
+
swap_used = swap_used_mb()
|
|
610
665
|
slim_groups = {
|
|
611
666
|
name: {"count": g["count"], "rss_mb": g["rss_mb"]}
|
|
612
667
|
for name, g in group_summaries(rows).items()
|
|
@@ -634,9 +689,13 @@ def build_summary() -> dict[str, Any]:
|
|
|
634
689
|
"mem": {
|
|
635
690
|
"total_mb": total,
|
|
636
691
|
"used_mb": used,
|
|
637
|
-
"
|
|
692
|
+
"available_mb": available,
|
|
693
|
+
"pct_free": pct_free,
|
|
694
|
+
"pressure_pct": pressure_pct,
|
|
695
|
+
"health": health,
|
|
638
696
|
"wired_mb": mem.get("wired_mb"),
|
|
639
697
|
"compressed_mb": mem.get("compressed_mb"),
|
|
698
|
+
"swap_used_mb": swap_used,
|
|
640
699
|
"swapouts": mem.get("swapouts"),
|
|
641
700
|
},
|
|
642
701
|
"groups": slim_groups,
|