@m13v/s4l 1.7.1 → 1.7.2-rc.1
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 +10 -0
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +15 -1
- package/mcp/menubar/s4l_menubar.py +103 -6
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/claude_job.py +30 -0
- package/scripts/producer_deathwatch.py +210 -0
- package/scripts/run_claude.sh +27 -0
- package/scripts/scheduled_task_selfheal.py +26 -0
- package/skill/run-twitter-cycle.sh +28 -30
package/mcp/dist/index.js
CHANGED
|
@@ -2837,6 +2837,16 @@ async function autopilotLoaded() {
|
|
|
2837
2837
|
// Claude turn, writes the result back, and stops.
|
|
2838
2838
|
// ===========================================================================
|
|
2839
2839
|
const QUEUE_WORKER_PROMPT_VERSION = 8; // v8: worker polls internally (claude_job.py next --wait-seconds) instead of single-shot check-then-die. Empirically verified (2026-07-06) that a single long-running Bash call survives well past the host's ~90s between-tool-call inactivity kill — that timer only fires on MODEL silence, not on one in-flight tool call — so one Bash call can safely poll for QUEUE_WORKER_POLL_SECONDS before giving up. This cuts the every-minute spin-up-empty-then-die husk cycle down to roughly one session per poll window instead of one per cron tick. v7: universal type-blind worker. ONE task claims `--type any`; per-type execution notes (e.g. the v6 incremental-draft pacing for twitter-prep) moved into claude_job.py TYPE_TO_WORKER_NOTES and ride the prompt sidecar, so the worker prompt never mentions job types. Legacy per-type tasks get this same body on refresh and become interchangeable universal workers.
|
|
2840
|
+
// v9 (PLANNED, NOT IMPLEMENTED): delegate the actual drafting to a fresh
|
|
2841
|
+
// sub-agent per claimed job (claim -> delegate -> wait -> claim next, looped
|
|
2842
|
+
// within one continuous worker session) instead of drafting inline. Validated
|
|
2843
|
+
// via throwaway probe tasks 2026-07-07/08 (10 loop iterations, ~210s of real
|
|
2844
|
+
// delegated work, survives); the one hard constraint proven: the delegated
|
|
2845
|
+
// sub-agent must never fully idle-wait (e.g. background + wait on a Monitor
|
|
2846
|
+
// notification) or the host kills the whole parent+child chain in 1-3 min.
|
|
2847
|
+
// Never live-fire tested against a real production job. Full design, what's
|
|
2848
|
+
// validated vs not, and the implementation steps: docs/queue-worker-delegation-plan.md
|
|
2849
|
+
// Bump this constant to 9 only once that plan is actually implemented.
|
|
2840
2850
|
const QUEUE_WORKER_PROMPT_MARKER = "s4l_queue_worker_prompt_version";
|
|
2841
2851
|
// How long ONE `next --wait-seconds` call polls before giving up and exiting.
|
|
2842
2852
|
// 240s (4 min): comfortably inside the 900s single-Bash-call survival verified
|
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.1",
|
|
5
|
+
"version": "1.7.2-rc.1",
|
|
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/menubar/s4l_card.py
CHANGED
|
@@ -418,7 +418,21 @@ def _details_lines(d):
|
|
|
418
418
|
lines.append(
|
|
419
419
|
f"Original thread ({lang.lower()}): {_truncate(d.get('thread_text'), 280)}"
|
|
420
420
|
)
|
|
421
|
-
|
|
421
|
+
# Two-draft cards: reply_text_en only ever mirrors Draft A (the
|
|
422
|
+
# canonical single-draft field), so Draft B's translation would be
|
|
423
|
+
# silently dropped from the popover unless we read each slot's own
|
|
424
|
+
# text_en directly off the drafts array.
|
|
425
|
+
dual_drafts = d.get("drafts")
|
|
426
|
+
if isinstance(dual_drafts, list) and len(dual_drafts) == 2:
|
|
427
|
+
slot_labels = {"a": "Draft A", "b": "Draft B"}
|
|
428
|
+
for draft in dual_drafts:
|
|
429
|
+
text_en = (draft.get("text_en") or "").strip()
|
|
430
|
+
if text_en:
|
|
431
|
+
label = slot_labels.get(
|
|
432
|
+
(draft.get("variant") or "").strip().lower(), "Draft"
|
|
433
|
+
)
|
|
434
|
+
lines.append(f"{label} in English: {_truncate(text_en, 280)}")
|
|
435
|
+
elif (d.get("reply_text_en") or "").strip():
|
|
422
436
|
lines.append(
|
|
423
437
|
f"Draft in English: {_truncate(d.get('reply_text_en'), 280)}"
|
|
424
438
|
)
|
|
@@ -775,6 +775,74 @@ class S4LMenuBar(rumps.App):
|
|
|
775
775
|
"that schedules the draft tasks for this account",
|
|
776
776
|
)
|
|
777
777
|
|
|
778
|
+
def _finish_schedule_setup(self, _=None):
|
|
779
|
+
"""One-click fix for schedule_state == 'missing' when
|
|
780
|
+
scheduled_task_selfheal.can_create_for_active_account() confirms a
|
|
781
|
+
session directory already exists for the active account (2026-07-08):
|
|
782
|
+
quit Claude, create the registration via a direct file write (the
|
|
783
|
+
same heal() the update flow uses — see its module docstring for why
|
|
784
|
+
this is equivalent to what create_scheduled_task would produce), then
|
|
785
|
+
relaunch. Primary action for this case now instead of re-arm: no
|
|
786
|
+
clipboard paste, no chat turn required. Re-arm remains the fallback
|
|
787
|
+
for the rarer case where no session directory exists yet for the
|
|
788
|
+
active account (see _build_menu) — fix 5 never fabricates one."""
|
|
789
|
+
_activate_front()
|
|
790
|
+
choice = _show_alert(
|
|
791
|
+
title="Finish setting up drafts?",
|
|
792
|
+
message=(
|
|
793
|
+
"Claude needs to restart once to finish registering your draft "
|
|
794
|
+
"schedule for this account. Its window will close and reopen "
|
|
795
|
+
"in a moment — drafting starts within a few minutes after."
|
|
796
|
+
),
|
|
797
|
+
ok="Restart & Finish Setup", cancel="Cancel",
|
|
798
|
+
)
|
|
799
|
+
if choice != 1:
|
|
800
|
+
return
|
|
801
|
+
_capture_msg(
|
|
802
|
+
"S4L finish-schedule-setup clicked",
|
|
803
|
+
phase="draft_schedule",
|
|
804
|
+
reason="missing",
|
|
805
|
+
_extra={"scheduled_tasks": _registry_summary_for_capture()},
|
|
806
|
+
)
|
|
807
|
+
self._notify("S4L", "Restarting Claude Desktop… setting up your draft schedule.")
|
|
808
|
+
threading.Thread(target=self._finish_schedule_setup_work, daemon=True).start()
|
|
809
|
+
|
|
810
|
+
def _finish_schedule_setup_work(self):
|
|
811
|
+
try:
|
|
812
|
+
user_data_dirs = self._claude_user_data_dirs()
|
|
813
|
+
self._quit_claude_and_wait()
|
|
814
|
+
# Claude is down: the same safe window _rewrite_scheduled_task_cwd
|
|
815
|
+
# always required. In-process is fine here (unlike the update
|
|
816
|
+
# flow's _fresh variant) — no new bundle was just downloaded, so
|
|
817
|
+
# the currently-running code IS the current code; nothing to go
|
|
818
|
+
# stale against.
|
|
819
|
+
self._rewrite_scheduled_task_cwd()
|
|
820
|
+
self._relaunch_claude(user_data_dirs)
|
|
821
|
+
self._sig = None
|
|
822
|
+
# Verify rather than claim success unconditionally — the earlier,
|
|
823
|
+
# now-deleted silent restart-fix drew a direct complaint for
|
|
824
|
+
# giving zero feedback either way (2026-07-08). CREATED_GRACE in
|
|
825
|
+
# schedule_state.py means a freshly-created, never-yet-fired task
|
|
826
|
+
# already reads "ok", so this doesn't need to wait for an actual
|
|
827
|
+
# first fire — just for Claude to be back up and the file to have
|
|
828
|
+
# landed.
|
|
829
|
+
time.sleep(12)
|
|
830
|
+
state = self._schedule_state()
|
|
831
|
+
if state == "ok":
|
|
832
|
+
self._notify(
|
|
833
|
+
"S4L drafts set up",
|
|
834
|
+
"Your draft schedule is registered — drafting starts within a few minutes.",
|
|
835
|
+
)
|
|
836
|
+
else:
|
|
837
|
+
self._notify(
|
|
838
|
+
"S4L couldn't finish setup",
|
|
839
|
+
"Restarting didn't register the schedule. Open the S4L menu → "
|
|
840
|
+
"“Set up draft schedule” to finish it manually.",
|
|
841
|
+
)
|
|
842
|
+
except Exception as e:
|
|
843
|
+
self._notify("S4L setup restart failed", str(e)[:140])
|
|
844
|
+
_capture(e, phase="finish_schedule_setup")
|
|
845
|
+
|
|
778
846
|
# ---- schedule-state detection ----------------------------------------
|
|
779
847
|
def _schedule_state(self):
|
|
780
848
|
"""Is the draft schedule registered AND running for the live account?
|
|
@@ -2400,11 +2468,24 @@ class S4LMenuBar(rumps.App):
|
|
|
2400
2468
|
"schedule” to re-register it.",
|
|
2401
2469
|
)
|
|
2402
2470
|
else:
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2471
|
+
can_selfheal = False
|
|
2472
|
+
try:
|
|
2473
|
+
import scheduled_task_selfheal
|
|
2474
|
+
can_selfheal = scheduled_task_selfheal.can_create_for_active_account()
|
|
2475
|
+
except Exception:
|
|
2476
|
+
pass
|
|
2477
|
+
if can_selfheal:
|
|
2478
|
+
self._notify(
|
|
2479
|
+
"S4L draft autopilot not scheduled",
|
|
2480
|
+
"No draft tasks are running on this Claude account (switching "
|
|
2481
|
+
"accounts clears them). Open the S4L menu → “Finish setting up drafts”.",
|
|
2482
|
+
)
|
|
2483
|
+
else:
|
|
2484
|
+
self._notify(
|
|
2485
|
+
"S4L draft autopilot not scheduled",
|
|
2486
|
+
"No draft tasks are running on this Claude account (switching "
|
|
2487
|
+
"accounts clears them). Open the S4L menu → “Set up draft schedule”.",
|
|
2488
|
+
)
|
|
2408
2489
|
self._stall_notified = True
|
|
2409
2490
|
elif not attention and self._stall_notified:
|
|
2410
2491
|
if self._attention_clear_since is None:
|
|
@@ -2988,7 +3069,23 @@ class S4LMenuBar(rumps.App):
|
|
|
2988
3069
|
items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
|
|
2989
3070
|
else:
|
|
2990
3071
|
items.append(self._label("⚠ Draft tasks aren’t scheduled on this account"))
|
|
2991
|
-
|
|
3072
|
+
# Prefer the automatic fix (2026-07-08): if the active account
|
|
3073
|
+
# already has a session directory, _finish_schedule_setup can
|
|
3074
|
+
# create the registration directly (heal() fix 5) with no
|
|
3075
|
+
# clipboard paste needed. Fall back to re-arm only when fix 5
|
|
3076
|
+
# has nowhere to write (no session dir yet for this account —
|
|
3077
|
+
# it never fabricates one), since that's the one case where
|
|
3078
|
+
# only the live create_scheduled_task host tool can help.
|
|
3079
|
+
can_selfheal = False
|
|
3080
|
+
try:
|
|
3081
|
+
import scheduled_task_selfheal
|
|
3082
|
+
can_selfheal = scheduled_task_selfheal.can_create_for_active_account()
|
|
3083
|
+
except Exception:
|
|
3084
|
+
pass
|
|
3085
|
+
if can_selfheal:
|
|
3086
|
+
items.append(rumps.MenuItem("Finish setting up drafts", callback=self._finish_schedule_setup))
|
|
3087
|
+
else:
|
|
3088
|
+
items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
|
|
2992
3089
|
items.append(rumps.separator)
|
|
2993
3090
|
|
|
2994
3091
|
if not runtime_ready:
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.1",
|
|
3
|
+
"version": "1.7.2-rc.1",
|
|
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
package/scripts/claude_job.py
CHANGED
|
@@ -335,6 +335,32 @@ def heartbeat_path() -> str:
|
|
|
335
335
|
return os.path.join(queue_root(), "worker-heartbeat.json")
|
|
336
336
|
|
|
337
337
|
|
|
338
|
+
def _arm_deathwatch(job_id: str, qtype: str, batch: str) -> None:
|
|
339
|
+
"""Best-effort dead-man's-switch (2026-07-08): arm scripts/producer_deathwatch.py
|
|
340
|
+
to flag an UNEXPECTED death (SIGKILL/OOM/hard crash) of THIS process while
|
|
341
|
+
it's blocked in the cmd_provider() poll loop below — the exact gap that
|
|
342
|
+
made orphaned salvage results ("worker drafted, no card") unexplainable.
|
|
343
|
+
Every normal return path in cmd_provider() calls _disarm_deathwatch()
|
|
344
|
+
first, so a clean exit never produces a report. Shared with
|
|
345
|
+
run_claude.sh's direct-exec path (every non-queue platform), which calls
|
|
346
|
+
producer_deathwatch.py's `arm`/`disarm` CLI directly instead of through
|
|
347
|
+
this Python wrapper — see that file for the single implementation both
|
|
348
|
+
callers share."""
|
|
349
|
+
try:
|
|
350
|
+
import producer_deathwatch as pdw
|
|
351
|
+
pdw.arm(os.getpid(), job_id, qtype, batch, call_path="queue")
|
|
352
|
+
except Exception:
|
|
353
|
+
pass
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _disarm_deathwatch(job_id: str) -> None:
|
|
357
|
+
try:
|
|
358
|
+
import producer_deathwatch as pdw
|
|
359
|
+
pdw.disarm(job_id)
|
|
360
|
+
except Exception:
|
|
361
|
+
pass
|
|
362
|
+
|
|
363
|
+
|
|
338
364
|
def _stamp_heartbeat(event: str, qtype: str | None = None) -> None:
|
|
339
365
|
"""Best-effort: never let a heartbeat write failure break the queue."""
|
|
340
366
|
try:
|
|
@@ -690,6 +716,7 @@ def cmd_provider(ns) -> int:
|
|
|
690
716
|
running_path = os.path.join(running_dir(), fname)
|
|
691
717
|
_atomic_write(pending_path, job)
|
|
692
718
|
_plog(f"enqueued {qtype} job {job_id} batch={batch}; waiting for a scheduled task (timeout {ns.timeout}s)")
|
|
719
|
+
_arm_deathwatch(job_id, qtype, batch)
|
|
693
720
|
# Narrate the (multi-minute) block to the menu bar. The launchd draft lane has
|
|
694
721
|
# no other activity writer, so without this the box looks idle while it works.
|
|
695
722
|
# Cleared by run-draft-and-publish.sh's exit trap at cycle end (and by the
|
|
@@ -732,6 +759,7 @@ def cmd_provider(ns) -> int:
|
|
|
732
759
|
os.remove(res_path)
|
|
733
760
|
if res.get("status") == "error":
|
|
734
761
|
_plog(f"job {job_id} returned error: {res.get('error', 'unknown')}")
|
|
762
|
+
_disarm_deathwatch(job_id)
|
|
735
763
|
return 1
|
|
736
764
|
obj = res.get("result")
|
|
737
765
|
# Emit a claude `--output-format json` shaped envelope so the
|
|
@@ -759,6 +787,7 @@ def cmd_provider(ns) -> int:
|
|
|
759
787
|
except Exception:
|
|
760
788
|
_ncand = "?"
|
|
761
789
|
_plog(f"consumed result for job {job_id} batch={batch} ({qtype}); {_ncand} candidates -> producer assembles the plan")
|
|
790
|
+
_disarm_deathwatch(job_id)
|
|
762
791
|
return 0
|
|
763
792
|
time.sleep(POLL_INTERVAL_S)
|
|
764
793
|
|
|
@@ -779,6 +808,7 @@ def cmd_provider(ns) -> int:
|
|
|
779
808
|
# flicker the ⚠ off). Cleared only when a draft actually drains.
|
|
780
809
|
_bump_drain_timeout()
|
|
781
810
|
_plog(f"timed out after {ns.timeout}s waiting for job {job_id} batch={batch} ({qtype}); removed the job")
|
|
811
|
+
_disarm_deathwatch(job_id)
|
|
782
812
|
return 79 # mirror run_claude.sh's "blocked, skip cleanly" exit code
|
|
783
813
|
|
|
784
814
|
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""producer_deathwatch.py — dead-man's-switch for every Claude-calling
|
|
3
|
+
producer process in the pipeline.
|
|
4
|
+
|
|
5
|
+
Watches a single PID: either scripts/claude_job.py's blocking provider wait
|
|
6
|
+
(cmd_provider, the queue path used by twitter-prep/feedback-digest/etc.), or
|
|
7
|
+
scripts/run_claude.sh's direct `claude -p` exec (every other platform:
|
|
8
|
+
reddit, linkedin, github, moltbook, instagram, dm-outreach-*, ...). If that
|
|
9
|
+
PID disappears while its arm marker still exists, something killed it
|
|
10
|
+
(SIGKILL / OOM / hard crash) rather than a normal return — a clean return
|
|
11
|
+
always disarms first. This is the exact gap that made orphaned salvage
|
|
12
|
+
results ("worker drafted, no card") unexplainable: the dying process can
|
|
13
|
+
never log its own death, and salvage only sees the aftermath up to
|
|
14
|
+
--max-age-hours later.
|
|
15
|
+
|
|
16
|
+
On an unexpected death this:
|
|
17
|
+
1. Snapshots memory pressure + related processes.
|
|
18
|
+
2. Appends a structured JSON line to producer-deathwatch.jsonl (local,
|
|
19
|
+
box-only, for offline/box-local debugging).
|
|
20
|
+
3. POSTs the same event to /api/v1/producer-death-events so it's queryable
|
|
21
|
+
across every install from Postgres, not just grep-able on one box (see
|
|
22
|
+
migrations/2026-07-09-producer-death-events.sql).
|
|
23
|
+
4. Emits a one-line summary via claude_job._plog() into provider.log,
|
|
24
|
+
which scripts/relay_provider_log.py already ships to Cloud Logging.
|
|
25
|
+
|
|
26
|
+
Three subcommands, so both Python (claude_job.py) and bash (run_claude.sh)
|
|
27
|
+
callers share one implementation:
|
|
28
|
+
arm — write the marker + spawn `watch` detached (start_new_session=True,
|
|
29
|
+
so it survives being in the same process group as the watched
|
|
30
|
+
pid if that group gets signaled). Called right before a caller
|
|
31
|
+
starts blocking on the watched pid.
|
|
32
|
+
disarm — remove the marker. Called on every normal return path AND from
|
|
33
|
+
a signal-trap cleanup (e.g. run_claude.sh's _sa_cleanup, which
|
|
34
|
+
itself SIGKILLs the watched process group as part of ordinary
|
|
35
|
+
TERM/INT/HUP handling — that must disarm too, or a normal
|
|
36
|
+
watchdog-triggered shutdown would misreport as an unexpected
|
|
37
|
+
death).
|
|
38
|
+
watch — the actual poll loop (internal; `arm` spawns this, nothing else
|
|
39
|
+
should call it directly).
|
|
40
|
+
|
|
41
|
+
Best-effort throughout: this is diagnostics only, never allowed to affect
|
|
42
|
+
the real job either way.
|
|
43
|
+
"""
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import argparse
|
|
47
|
+
import json
|
|
48
|
+
import os
|
|
49
|
+
import subprocess
|
|
50
|
+
import sys
|
|
51
|
+
import time
|
|
52
|
+
|
|
53
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
54
|
+
sys.path.insert(0, HERE)
|
|
55
|
+
from claude_job import queue_root, _plog # noqa: E402
|
|
56
|
+
|
|
57
|
+
POLL_S = 5.0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def arm_path(job_id: str) -> str:
|
|
61
|
+
return os.path.join(queue_root(), f"deathwatch-armed-{job_id}.marker")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def arm(watch_pid: int, job_id: str, qtype: str, batch: str, call_path: str) -> None:
|
|
65
|
+
"""Write the marker and spawn a detached `watch` subprocess. Best-effort:
|
|
66
|
+
any failure here must never block the real caller."""
|
|
67
|
+
try:
|
|
68
|
+
marker = arm_path(job_id)
|
|
69
|
+
os.makedirs(queue_root(), exist_ok=True)
|
|
70
|
+
with open(marker, "w") as f:
|
|
71
|
+
f.write(str(watch_pid))
|
|
72
|
+
subprocess.Popen(
|
|
73
|
+
[sys.executable, os.path.abspath(__file__), "watch",
|
|
74
|
+
"--watch-pid", str(watch_pid), "--job-id", job_id,
|
|
75
|
+
"--qtype", qtype, "--batch", batch, "--call-path", call_path],
|
|
76
|
+
start_new_session=True,
|
|
77
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
78
|
+
)
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def disarm(job_id: str) -> None:
|
|
84
|
+
try:
|
|
85
|
+
os.remove(arm_path(job_id))
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _snapshot() -> tuple[str, str]:
|
|
91
|
+
try:
|
|
92
|
+
vm = subprocess.run(["vm_stat"], capture_output=True, text=True, timeout=5).stdout.strip()
|
|
93
|
+
except Exception as e:
|
|
94
|
+
vm = f"(vm_stat failed: {e})"
|
|
95
|
+
try:
|
|
96
|
+
procs = subprocess.run(
|
|
97
|
+
["/bin/ps", "-axo", "pid=,ppid=,%mem=,%cpu=,command="],
|
|
98
|
+
capture_output=True, text=True, timeout=5,
|
|
99
|
+
).stdout
|
|
100
|
+
related = "\n".join(
|
|
101
|
+
ln for ln in procs.splitlines()
|
|
102
|
+
if "claude" in ln or "run-twitter-cycle" in ln or "run_claude" in ln
|
|
103
|
+
) or "(none matching claude/run-twitter-cycle/run_claude)"
|
|
104
|
+
except Exception as e:
|
|
105
|
+
related = f"(ps failed: {e})"
|
|
106
|
+
# Cap length: this rides into a Postgres text column and a JSON line;
|
|
107
|
+
# keep it bounded so a busy box's ps dump can't balloon either.
|
|
108
|
+
return vm[:4000], related[:4000]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _report_to_db(event: dict) -> None:
|
|
112
|
+
"""Best-effort POST to /api/v1/producer-death-events. Catches
|
|
113
|
+
BaseException, not just Exception: http_api._request raises SystemExit
|
|
114
|
+
on a terminal 4xx/5xx, which must never be allowed to break the
|
|
115
|
+
diagnostic path (mirrors autopilot_stall_watch.py's same guard)."""
|
|
116
|
+
try:
|
|
117
|
+
import http_api # noqa: E402 (sibling module, HERE already on sys.path)
|
|
118
|
+
http_api.api_post("/api/v1/producer-death-events", {
|
|
119
|
+
"watch_pid": event["watch_pid"],
|
|
120
|
+
"job_id": event["job_id"],
|
|
121
|
+
"batch_id": event["batch"] if event["batch"] != "-" else None,
|
|
122
|
+
"qtype": event["qtype"],
|
|
123
|
+
"call_path": event["call_path"],
|
|
124
|
+
"vm_stat_summary": event["vm_stat"],
|
|
125
|
+
"related_processes": event["related_processes"],
|
|
126
|
+
})
|
|
127
|
+
except BaseException:
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def watch(watch_pid: int, job_id: str, qtype: str, batch: str, call_path: str) -> int:
|
|
132
|
+
marker = arm_path(job_id)
|
|
133
|
+
while True:
|
|
134
|
+
if not os.path.exists(marker):
|
|
135
|
+
return 0 # disarmed: the caller returned cleanly, nothing to report
|
|
136
|
+
try:
|
|
137
|
+
os.kill(watch_pid, 0)
|
|
138
|
+
except ProcessLookupError:
|
|
139
|
+
break # pid gone but still armed -> unexpected death
|
|
140
|
+
except PermissionError:
|
|
141
|
+
pass # exists, just not signalable from here; keep watching
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
time.sleep(POLL_S)
|
|
145
|
+
|
|
146
|
+
ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
147
|
+
vm, related = _snapshot()
|
|
148
|
+
event = {
|
|
149
|
+
"ts": ts,
|
|
150
|
+
"event": "unexpected_death",
|
|
151
|
+
"watch_pid": watch_pid,
|
|
152
|
+
"job_id": job_id,
|
|
153
|
+
"qtype": qtype,
|
|
154
|
+
"batch": batch,
|
|
155
|
+
"call_path": call_path,
|
|
156
|
+
"vm_stat": vm,
|
|
157
|
+
"related_processes": related,
|
|
158
|
+
}
|
|
159
|
+
try:
|
|
160
|
+
os.makedirs(queue_root(), exist_ok=True)
|
|
161
|
+
with open(os.path.join(queue_root(), "producer-deathwatch.jsonl"), "a") as f:
|
|
162
|
+
f.write(json.dumps(event) + "\n")
|
|
163
|
+
except Exception:
|
|
164
|
+
pass
|
|
165
|
+
_report_to_db(event)
|
|
166
|
+
try:
|
|
167
|
+
_plog(f"[deathwatch] UNEXPECTED DEATH watch_pid={watch_pid} job={job_id} "
|
|
168
|
+
f"type={qtype} batch={batch} path={call_path} -> see producer-deathwatch.jsonl")
|
|
169
|
+
except Exception:
|
|
170
|
+
pass
|
|
171
|
+
try:
|
|
172
|
+
os.remove(marker)
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
|
175
|
+
return 0
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def main() -> int:
|
|
179
|
+
ap = argparse.ArgumentParser()
|
|
180
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
181
|
+
|
|
182
|
+
pa = sub.add_parser("arm")
|
|
183
|
+
pa.add_argument("--watch-pid", type=int, required=True)
|
|
184
|
+
pa.add_argument("--job-id", required=True)
|
|
185
|
+
pa.add_argument("--qtype", default="?")
|
|
186
|
+
pa.add_argument("--batch", default="-")
|
|
187
|
+
pa.add_argument("--call-path", default="queue", choices=["queue", "direct"])
|
|
188
|
+
|
|
189
|
+
pd = sub.add_parser("disarm")
|
|
190
|
+
pd.add_argument("--job-id", required=True)
|
|
191
|
+
|
|
192
|
+
pw = sub.add_parser("watch")
|
|
193
|
+
pw.add_argument("--watch-pid", type=int, required=True)
|
|
194
|
+
pw.add_argument("--job-id", required=True)
|
|
195
|
+
pw.add_argument("--qtype", default="?")
|
|
196
|
+
pw.add_argument("--batch", default="-")
|
|
197
|
+
pw.add_argument("--call-path", default="queue", choices=["queue", "direct"])
|
|
198
|
+
|
|
199
|
+
ns = ap.parse_args()
|
|
200
|
+
if ns.cmd == "arm":
|
|
201
|
+
arm(ns.watch_pid, ns.job_id, ns.qtype, ns.batch, ns.call_path)
|
|
202
|
+
return 0
|
|
203
|
+
if ns.cmd == "disarm":
|
|
204
|
+
disarm(ns.job_id)
|
|
205
|
+
return 0
|
|
206
|
+
return watch(ns.watch_pid, ns.job_id, ns.qtype, ns.batch, ns.call_path)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
if __name__ == "__main__":
|
|
210
|
+
sys.exit(main())
|
package/scripts/run_claude.sh
CHANGED
|
@@ -200,6 +200,16 @@ _sa_cleanup() {
|
|
|
200
200
|
rm -f "$SIDE_LOG"
|
|
201
201
|
rm -f "$ACTIVE_FILE"
|
|
202
202
|
|
|
203
|
+
# Disarm the deathwatch (see the arm call in the retry loop below). This
|
|
204
|
+
# path SIGKILLs $CLAUDE_PG's process group a few lines down as ordinary
|
|
205
|
+
# TERM/INT/HUP handling (e.g. the watchdog killing a genuinely hung run),
|
|
206
|
+
# so it must disarm too — otherwise a normal forced shutdown would
|
|
207
|
+
# misreport as an unexpected death once that kill lands.
|
|
208
|
+
if [ -n "${_SA_DW_JOB:-}" ]; then
|
|
209
|
+
python3 "$REPO_DIR/scripts/producer_deathwatch.py" disarm --job-id "$_SA_DW_JOB" \
|
|
210
|
+
>/dev/null 2>&1 || true
|
|
211
|
+
fi
|
|
212
|
+
|
|
203
213
|
# Sweep orphan claude descendants. Process groups survive the parent's
|
|
204
214
|
# death (kids reparented to launchd keep their PGID), so killing
|
|
205
215
|
# `kill -- -PGID` reaches every grandchild, including ones reparented
|
|
@@ -308,8 +318,25 @@ EOF
|
|
|
308
318
|
{ claude --session-id "$SESSION_ID" ${MODEL_ARGS[@]+"${MODEL_ARGS[@]}"} "$@" | tee -a "$SIDE_LOG"; exit "${PIPESTATUS[0]}"; } &
|
|
309
319
|
CLAUDE_PG=$!
|
|
310
320
|
set +m
|
|
321
|
+
# Dead-man's-switch (2026-07-09): every non-queue-routed tag (reddit,
|
|
322
|
+
# linkedin, github, moltbook, instagram, dm-outreach-*, ...) blocks
|
|
323
|
+
# here exactly like claude_job.py's queue provider does, with the
|
|
324
|
+
# same silent-death risk (SIGKILL/OOM/hard crash while waiting). Arm
|
|
325
|
+
# per-attempt (job id includes $CLAUDE_PG so a retry never collides
|
|
326
|
+
# with a still-unwinding prior attempt's watcher); disarm right after
|
|
327
|
+
# `wait` returns AND from _sa_cleanup's trap (that path SIGKILLs
|
|
328
|
+
# $CLAUDE_PG itself as ordinary TERM/INT/HUP handling, which must
|
|
329
|
+
# disarm too or a normal watchdog-triggered shutdown would misreport
|
|
330
|
+
# as an unexpected death). See scripts/producer_deathwatch.py.
|
|
331
|
+
_SA_DW_JOB="${SESSION_ID}-${CLAUDE_PG}"
|
|
332
|
+
python3 "$REPO_DIR/scripts/producer_deathwatch.py" arm \
|
|
333
|
+
--watch-pid "$CLAUDE_PG" --job-id "$_SA_DW_JOB" --qtype "$SCRIPT_TAG" \
|
|
334
|
+
--batch "${BATCH_ID:-${SA_CYCLE_ID:--}}" --call-path direct \
|
|
335
|
+
>/dev/null 2>&1 || true
|
|
311
336
|
wait "$CLAUDE_PG"
|
|
312
337
|
RC=$?
|
|
338
|
+
python3 "$REPO_DIR/scripts/producer_deathwatch.py" disarm --job-id "$_SA_DW_JOB" \
|
|
339
|
+
>/dev/null 2>&1 || true
|
|
313
340
|
if [ "$RC" -ne 127 ]; then
|
|
314
341
|
break
|
|
315
342
|
fi
|
|
@@ -266,6 +266,32 @@ def heal() -> dict:
|
|
|
266
266
|
return summary
|
|
267
267
|
|
|
268
268
|
|
|
269
|
+
def can_create_for_active_account() -> bool:
|
|
270
|
+
"""Read-only: would fix 5 (see heal()) actually be able to create a fresh
|
|
271
|
+
registration right now? True only if the active account (resolved the same
|
|
272
|
+
way heal() does, via schedule_state's config.json lookup) has at least one
|
|
273
|
+
EXISTING session directory to write into — fix 5 never fabricates one.
|
|
274
|
+
Used by callers (the menu bar) to decide whether to offer an automatic
|
|
275
|
+
"restart to finish setup" action or fall back to the manual re-arm prompt,
|
|
276
|
+
BEFORE committing to a restart that would turn out to fix nothing."""
|
|
277
|
+
try:
|
|
278
|
+
for cfg in schedule_state._config_json_paths():
|
|
279
|
+
root = os.path.dirname(cfg)
|
|
280
|
+
uuid = schedule_state._active_account_uuid(cfg)
|
|
281
|
+
if not uuid:
|
|
282
|
+
continue
|
|
283
|
+
account_dir = os.path.join(root, "claude-code-sessions", uuid)
|
|
284
|
+
session_dirs = [
|
|
285
|
+
p for p in glob.glob(os.path.join(account_dir, "*"))
|
|
286
|
+
if os.path.isdir(p)
|
|
287
|
+
]
|
|
288
|
+
if session_dirs:
|
|
289
|
+
return True
|
|
290
|
+
except Exception:
|
|
291
|
+
pass
|
|
292
|
+
return False
|
|
293
|
+
|
|
294
|
+
|
|
269
295
|
def main() -> int:
|
|
270
296
|
out = heal()
|
|
271
297
|
print(json.dumps(out))
|
|
@@ -2058,6 +2058,24 @@ else
|
|
|
2058
2058
|
fi
|
|
2059
2059
|
rm -f "$MEDIA_URLS_FILE" 2>/dev/null || true
|
|
2060
2060
|
|
|
2061
|
+
# Release the twitter-browser lock now. Thread-media capture above was the
|
|
2062
|
+
# ONLY browser-touching step in Phase 2b-prep; the Claude drafting call below
|
|
2063
|
+
# is architecturally browser-free (--strict-mcp-config omits the
|
|
2064
|
+
# twitter-harness MCP, so the model can never reach the CDP Chrome even if it
|
|
2065
|
+
# tried) and, since the 2026-06-23 queue migration, "run-twitter-cycle-prep"
|
|
2066
|
+
# routes through claude_job.py to an independent worker process that can
|
|
2067
|
+
# block for up to S4L_CLAUDE_QUEUE_TIMEOUT (1800s default). Holding the
|
|
2068
|
+
# browser lock across that wait made this process a preemption target for
|
|
2069
|
+
# any post that needed the browser: the post-vs-scan hijack fix SIGKILLs
|
|
2070
|
+
# whoever holds the lock (see docs/twitter_browser_lock.md), which killed
|
|
2071
|
+
# this process mid-wait while the worker kept drafting in the background,
|
|
2072
|
+
# stranding the finished result until the salvage reconciler's 35-min window
|
|
2073
|
+
# (scripts/salvage_orphaned_prep_results.py) picked it up. Releasing here
|
|
2074
|
+
# removes this whole phase from being a SIGKILL target for the rest of the
|
|
2075
|
+
# drafting wait. Phase 2b-post re-acquires unconditionally below.
|
|
2076
|
+
log "Releasing twitter-browser lock before Claude drafting (drafting never touches the browser)..."
|
|
2077
|
+
release_lock "twitter-browser" 2>>"$LOG_FILE"
|
|
2078
|
+
|
|
2061
2079
|
# --- PERSONA CORPUS injection (personal_brand lane only) --------------------
|
|
2062
2080
|
# build_persona.py apply writes a raw first-hand corpus sidecar next to
|
|
2063
2081
|
# config.json. In the personal_brand lane we inline it so the drafter grounds
|
|
@@ -2432,30 +2450,12 @@ if [ "$PREP_PARSE_EXIT" -eq 0 ] && [ -f "$PLAN_FILE" ]; then
|
|
|
2432
2450
|
fi
|
|
2433
2451
|
log "Phase 2b-prep complete. plan_count=$PLAN_COUNT"
|
|
2434
2452
|
|
|
2435
|
-
#
|
|
2436
|
-
#
|
|
2437
|
-
#
|
|
2438
|
-
#
|
|
2439
|
-
#
|
|
2440
|
-
#
|
|
2441
|
-
# dance entirely.
|
|
2442
|
-
GEN_RATE_RAW="${TWITTER_PAGE_GEN_RATE:-0.0}"
|
|
2443
|
-
GEN_IS_NOOP=false
|
|
2444
|
-
case "$GEN_RATE_RAW" in
|
|
2445
|
-
0|0.0|0.00|0.000|"") GEN_IS_NOOP=true ;;
|
|
2446
|
-
esac
|
|
2447
|
-
|
|
2448
|
-
# Release the lock unless (a) plan is non-empty AND (b) gen is a no-op. The
|
|
2449
|
-
# empty-plan early-exit below still needs the release for a clean handoff, so
|
|
2450
|
-
# we cannot just skip when GEN_IS_NOOP=true unconditionally.
|
|
2451
|
-
if [ "${PLAN_COUNT:-0}" = "0" ] || ! $GEN_IS_NOOP; then
|
|
2452
|
-
log "Releasing twitter-browser lock (gen step is lock-free)..."
|
|
2453
|
-
release_lock "twitter-browser" 2>>"$LOG_FILE"
|
|
2454
|
-
# (2026-06-16) session-lock rm removed (defect b); dead holders self-reclaim
|
|
2455
|
-
# in twitter_browser.py now. Do NOT re-add. See Phase 1 note + docs/twitter_browser_lock.md.
|
|
2456
|
-
else
|
|
2457
|
-
log "Keeping twitter-browser lock through Phase 2b-gen (TWITTER_PAGE_GEN_RATE=$GEN_RATE_RAW, gen is a no-op; skipping release/re-acquire dance)"
|
|
2458
|
-
fi
|
|
2453
|
+
# twitter-browser lock was already released right after thread-media capture
|
|
2454
|
+
# (before the Claude drafting call above), since nothing from there through
|
|
2455
|
+
# Phase 2b-gen touches the browser. Phase 2b-post re-acquires unconditionally
|
|
2456
|
+
# below. (2026-06-16) session-lock rm removed (defect b); dead holders
|
|
2457
|
+
# self-reclaim in twitter_browser.py now. Do NOT re-add. See Phase 1 note +
|
|
2458
|
+
# docs/twitter_browser_lock.md.
|
|
2459
2459
|
|
|
2460
2460
|
if [ "${PLAN_COUNT:-0}" = "0" ]; then
|
|
2461
2461
|
log "Empty plan from prep step. Exiting cycle without posting (pending rows salvaged next cycle)."
|
|
@@ -2575,12 +2575,10 @@ fi
|
|
|
2575
2575
|
# 2b-gen's potentially long run, peer cycles' 20-min phase2a fallback would
|
|
2576
2576
|
# already be tripping if we left the row at phase2a.
|
|
2577
2577
|
python3 "$REPO_DIR/scripts/twitter_batch_phase.py" advance "$BATCH_ID" --phase phase2b-post 2>&1 | tee -a "$LOG_FILE" || true
|
|
2578
|
-
#
|
|
2579
|
-
#
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
acquire_lock "twitter-browser" 3600 2>>"$LOG_FILE"
|
|
2583
|
-
fi
|
|
2578
|
+
# Always re-acquire: the lock was released right after thread-media capture
|
|
2579
|
+
# (before Claude drafting), well before 2b-gen, so it is never still held here.
|
|
2580
|
+
log "Re-acquiring twitter-browser lock for Phase 2b-post..."
|
|
2581
|
+
acquire_lock "twitter-browser" 3600 2>>"$LOG_FILE"
|
|
2584
2582
|
log "twitter-browser lock held (pid=$$) Phase 2b-post"
|
|
2585
2583
|
# Drop stale singleton locks (see clean_stale_singleton.sh, also called in Phase 1 / 2b-prep).
|
|
2586
2584
|
ensure_twitter_browser_for_backend 2>&1 | tee -a "$LOG_FILE"
|