@m13v/s4l 1.7.6-rc.39 → 1.7.6-rc.40
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 +68 -83
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_state.py +30 -10
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/claude_job.py +70 -10
- package/scripts/preflight.sh +23 -3
- package/skill/run-draft-and-publish.sh +7 -0
- package/skill/run-reddit-search-launchd.sh +1 -0
package/mcp/dist/index.js
CHANGED
|
@@ -4715,15 +4715,15 @@ async function ensureMemorySnapshotInstalled() {
|
|
|
4715
4715
|
}
|
|
4716
4716
|
// ---- pause/resume: stop drafting/posting without touching Claude Desktop ---
|
|
4717
4717
|
// Unlike Quit (which kills + relaunches Claude Desktop and deletes the draft
|
|
4718
|
-
// schedule), Pause only unloads the launchd jobs that actually DO work —
|
|
4719
|
-
// kicker
|
|
4720
|
-
//
|
|
4721
|
-
//
|
|
4722
|
-
//
|
|
4723
|
-
//
|
|
4718
|
+
// schedule), Pause only unloads the launchd jobs that actually DO work — every
|
|
4719
|
+
// pipeline kicker (X cycle, Reddit discovery) plus the reaper, stall-watch, and
|
|
4720
|
+
// memory-snapshot support daemons — while leaving Claude Desktop, the tray, and
|
|
4721
|
+
// the Claude-native s4l-worker scheduled task alone. The scheduled task still
|
|
4722
|
+
// fires on its own cadence; claude_job.py's `next` checks the same flag file
|
|
4723
|
+
// (at entry AND on every poll iteration) and reports "no work" instead of
|
|
4724
4724
|
// draining the queue, so nothing drafts or posts while paused even if a
|
|
4725
4725
|
// worker session wakes up mid-pause. A flag file (nothing is deleted) is what
|
|
4726
|
-
// makes Resume a plain reinstall of the same
|
|
4726
|
+
// makes Resume a plain reinstall of the same daemons — see resumeS4L.
|
|
4727
4727
|
function pauseFlagPath() {
|
|
4728
4728
|
return path.join(s4lStateDir(), "paused.flag");
|
|
4729
4729
|
}
|
|
@@ -4735,15 +4735,38 @@ function isPaused() {
|
|
|
4735
4735
|
return false;
|
|
4736
4736
|
}
|
|
4737
4737
|
}
|
|
4738
|
+
// THE single registry of pausable pipeline daemons. Pause unloads exactly this
|
|
4739
|
+
// set, Resume reinstalls exactly this set (each ensure re-applies its own
|
|
4740
|
+
// install gates: draftable lane, reddit connected, etc.), boot skips exactly
|
|
4741
|
+
// this set while paused, and the label list is mirrored to
|
|
4742
|
+
// <state>/pause-targets.json so the menubar's Python pause button
|
|
4743
|
+
// (mcp/menubar/s4l_state.py) acts on the same set without a second hardcoded
|
|
4744
|
+
// list. Add a new pipeline daemon HERE and every pause surface picks it up.
|
|
4745
|
+
// Feedback-digest is deliberately absent: it only distills past review
|
|
4746
|
+
// decisions, not drafting/posting, so it keeps running while paused.
|
|
4738
4747
|
const PAUSE_TARGETS = [
|
|
4739
|
-
{ label: TWITTER_AUTOPILOT_LABEL, plist: TWITTER_AUTOPILOT_PLIST },
|
|
4740
|
-
{ label:
|
|
4741
|
-
{ label:
|
|
4742
|
-
{ label:
|
|
4748
|
+
{ tag: "queue-worker", label: TWITTER_AUTOPILOT_LABEL, plist: TWITTER_AUTOPILOT_PLIST, ensure: ensureQueueKickerInstalled },
|
|
4749
|
+
{ tag: "reddit-kicker", label: REDDIT_SEARCH_LABEL, plist: REDDIT_SEARCH_PLIST, ensure: ensureRedditKickerInstalled },
|
|
4750
|
+
{ tag: "claude-reaper", label: REAPER_LABEL, plist: REAPER_PLIST, ensure: ensureClaudeReaperInstalled },
|
|
4751
|
+
{ tag: "stall-watch", label: STALL_WATCH_LABEL, plist: STALL_WATCH_PLIST, ensure: ensureStallWatchInstalled },
|
|
4752
|
+
{ tag: "memory-snapshot", label: MEMORY_SNAPSHOT_LABEL, plist: MEMORY_SNAPSHOT_PLIST, ensure: ensureMemorySnapshotInstalled },
|
|
4743
4753
|
];
|
|
4754
|
+
// Mirror the pausable label set into the state dir for the menubar's Python
|
|
4755
|
+
// pause/resume (which can't import this module). Written at boot and on every
|
|
4756
|
+
// pause/resume so the two surfaces can't drift.
|
|
4757
|
+
function writePauseTargetsManifest() {
|
|
4758
|
+
try {
|
|
4759
|
+
fs.mkdirSync(s4lStateDir(), { recursive: true });
|
|
4760
|
+
fs.writeFileSync(path.join(s4lStateDir(), "pause-targets.json"), JSON.stringify({ labels: PAUSE_TARGETS.map((t) => t.label) }) + "\n", "utf-8");
|
|
4761
|
+
}
|
|
4762
|
+
catch {
|
|
4763
|
+
/* best-effort */
|
|
4764
|
+
}
|
|
4765
|
+
}
|
|
4744
4766
|
async function pauseS4L() {
|
|
4745
4767
|
if (isPaused())
|
|
4746
4768
|
return { ok: true, detail: "already paused" };
|
|
4769
|
+
writePauseTargetsManifest();
|
|
4747
4770
|
try {
|
|
4748
4771
|
fs.mkdirSync(path.dirname(pauseFlagPath()), { recursive: true });
|
|
4749
4772
|
fs.writeFileSync(pauseFlagPath(), `paused at ${new Date().toISOString()}\n`, "utf-8");
|
|
@@ -4771,17 +4794,18 @@ async function resumeS4L() {
|
|
|
4771
4794
|
catch {
|
|
4772
4795
|
/* best-effort */
|
|
4773
4796
|
}
|
|
4774
|
-
|
|
4775
|
-
const
|
|
4776
|
-
const
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
|
|
4780
|
-
|
|
4781
|
-
|
|
4782
|
-
|
|
4783
|
-
|
|
4784
|
-
|
|
4797
|
+
writePauseTargetsManifest();
|
|
4798
|
+
const results = [];
|
|
4799
|
+
for (const t of PAUSE_TARGETS) {
|
|
4800
|
+
try {
|
|
4801
|
+
const r = await t.ensure();
|
|
4802
|
+
results.push(`${t.tag}: ${r.ok ? "ok" : "skip"} (${r.detail})`);
|
|
4803
|
+
}
|
|
4804
|
+
catch (e) {
|
|
4805
|
+
results.push(`${t.tag}: ${e?.message || e}`);
|
|
4806
|
+
}
|
|
4807
|
+
}
|
|
4808
|
+
return { ok: true, detail: results.join("; ") };
|
|
4785
4809
|
}
|
|
4786
4810
|
// Install/refresh the on-screen overlay watcher launchd job. Promotes the
|
|
4787
4811
|
// harness status overlay from a best-effort, fired-from-other-tools nicety to a
|
|
@@ -5822,7 +5846,7 @@ appTool("dashboard", {
|
|
|
5822
5846
|
(snap.update_available && snap.latest_version ? ` (update to ${snap.latest_version})` : "") +
|
|
5823
5847
|
` — projects ${snap.projects_ready}/${snap.projects_total} ready, ` +
|
|
5824
5848
|
`X ${snap.x_connected ? "connected" : "not connected"}, ` +
|
|
5825
|
-
`autopilot ${snap.autopilot_on ? "on" : "off"}.`;
|
|
5849
|
+
`autopilot ${snap.paused ? "PAUSED" : snap.autopilot_on ? "on" : "off"}.`;
|
|
5826
5850
|
const base = {
|
|
5827
5851
|
content: [{ type: "text", text: human }],
|
|
5828
5852
|
structuredContent: { snapshot: JSON.stringify(snap) },
|
|
@@ -6074,73 +6098,34 @@ async function main() {
|
|
|
6074
6098
|
catch (e) {
|
|
6075
6099
|
console.error(`[queue-worker] could not create worker folder: ${e?.message || e}`);
|
|
6076
6100
|
}
|
|
6077
|
-
//
|
|
6078
|
-
//
|
|
6101
|
+
// Pausable pipeline daemons — one loop over the SAME registry pause/resume
|
|
6102
|
+
// use (PAUSE_TARGETS: X kicker, reddit kicker, reaper, stall-watch,
|
|
6103
|
+
// memory-snapshot), so boot can never install a daemon Pause doesn't cover.
|
|
6104
|
+
// Each is skipped while paused.flag is present, so a Claude Desktop restart
|
|
6079
6105
|
// during a Pause doesn't silently un-pause the pipeline — see pauseS4L/
|
|
6080
|
-
// resumeS4L.
|
|
6081
|
-
//
|
|
6082
|
-
|
|
6083
|
-
|
|
6084
|
-
|
|
6085
|
-
|
|
6086
|
-
|
|
6087
|
-
|
|
6088
|
-
|
|
6089
|
-
|
|
6090
|
-
|
|
6091
|
-
|
|
6092
|
-
|
|
6093
|
-
|
|
6094
|
-
// product-only gate: once the gate passes, the plist lands on the next boot
|
|
6095
|
-
// (worker sessions boot ~1/min). Paused-gated like the X kicker (produces drafts).
|
|
6096
|
-
if (!isPaused()) {
|
|
6097
|
-
void ensureRedditKickerInstalled()
|
|
6098
|
-
.then((r) => console.error(`[reddit-kicker] launchd kicker: ${r.ok ? "ok" : "skip"} (${r.detail})`))
|
|
6099
|
-
.catch((e) => console.error("[reddit-kicker] kicker install failed:", e?.message || e));
|
|
6100
|
-
}
|
|
6101
|
-
else {
|
|
6102
|
-
console.error("[reddit-kicker] launchd kicker: skip (paused)");
|
|
6103
|
-
}
|
|
6104
|
-
// Self-healing reaper for the agent-mode session leak the queue autopilot
|
|
6105
|
-
// produces (finished `claude` worker sessions Desktop never tears down). A
|
|
6106
|
-
// standalone guardrail; install unconditionally so it caps memory even on a
|
|
6107
|
-
// box whose project isn't ready yet. Best-effort; must never block boot.
|
|
6108
|
-
if (!isPaused()) {
|
|
6109
|
-
void ensureClaudeReaperInstalled()
|
|
6110
|
-
.then((r) => console.error(`[claude-reaper] launchd reaper: ${r.ok ? "ok" : "skip"} (${r.detail})`))
|
|
6111
|
-
.catch((e) => console.error("[claude-reaper] reaper install failed:", e?.message || e));
|
|
6112
|
-
}
|
|
6113
|
-
else {
|
|
6114
|
-
console.error("[claude-reaper] launchd reaper: skip (paused)");
|
|
6106
|
+
// resumeS4L. Each ensure keeps its own install gates (draftable lane, reddit
|
|
6107
|
+
// connected, etc.); a box that never connected reddit is a cheap no-op skip.
|
|
6108
|
+
// The boot-time call also SELF-HEALS installs broken by older gates: once a
|
|
6109
|
+
// gate passes, the plist lands on the next boot. All best-effort; never block
|
|
6110
|
+
// boot.
|
|
6111
|
+
writePauseTargetsManifest();
|
|
6112
|
+
for (const t of PAUSE_TARGETS) {
|
|
6113
|
+
if (isPaused()) {
|
|
6114
|
+
console.error(`[${t.tag}] launchd ${t.label}: skip (paused)`);
|
|
6115
|
+
continue;
|
|
6116
|
+
}
|
|
6117
|
+
void t.ensure()
|
|
6118
|
+
.then((r) => console.error(`[${t.tag}] launchd: ${r.ok ? "ok" : "skip"} (${r.detail})`))
|
|
6119
|
+
.catch((e) => console.error(`[${t.tag}] launchd install failed:`, e?.message || e));
|
|
6115
6120
|
}
|
|
6116
6121
|
// Feedback digest: hourly distillation of the user's card approve/reject
|
|
6117
6122
|
// decisions into learned_preferences (see scripts/feedback_digest.py).
|
|
6118
|
-
// Best-effort; a box with no review events runs a no-op.
|
|
6123
|
+
// Best-effort; a box with no review events runs a no-op. NOT pause-gated (and
|
|
6124
|
+
// not in PAUSE_TARGETS): it only distills past review decisions, not
|
|
6125
|
+
// drafting/posting, so it's harmless to keep running.
|
|
6119
6126
|
void ensureFeedbackDigestInstalled()
|
|
6120
6127
|
.then((r) => console.error(`[feedback-digest] launchd digest: ${r.ok ? "ok" : "skip"} (${r.detail})`))
|
|
6121
6128
|
.catch((e) => console.error("[feedback-digest] digest install failed:", e?.message || e));
|
|
6122
|
-
// Autopilot stall watchdog: fleet-side Sentry alert when the draft routines stop
|
|
6123
|
-
// draining (most often an account switch orphaning them). The menu bar shows the
|
|
6124
|
-
// user the Re-arm action; this is the part we see. Best-effort; never blocks boot.
|
|
6125
|
-
if (!isPaused()) {
|
|
6126
|
-
void ensureStallWatchInstalled()
|
|
6127
|
-
.then((r) => console.error(`[stall-watch] launchd watchdog: ${r.ok ? "ok" : "skip"} (${r.detail})`))
|
|
6128
|
-
.catch((e) => console.error("[stall-watch] watchdog install failed:", e?.message || e));
|
|
6129
|
-
}
|
|
6130
|
-
else {
|
|
6131
|
-
console.error("[stall-watch] launchd watchdog: skip (paused)");
|
|
6132
|
-
}
|
|
6133
|
-
// Periodic host-resource sampler (memory/process snapshot -> local JSONL). Gives
|
|
6134
|
-
// us per-box resource history to diagnose RAM blowups (e.g. the agent-mode
|
|
6135
|
-
// session leak). Best-effort; never blocks boot. Disable with S4L_MEMORY_SNAPSHOT=0.
|
|
6136
|
-
if (!isPaused()) {
|
|
6137
|
-
void ensureMemorySnapshotInstalled()
|
|
6138
|
-
.then((r) => console.error(`[memory-snapshot] launchd sampler: ${r.ok ? "ok" : "skip"} (${r.detail})`))
|
|
6139
|
-
.catch((e) => console.error("[memory-snapshot] sampler install failed:", e?.message || e));
|
|
6140
|
-
}
|
|
6141
|
-
else {
|
|
6142
|
-
console.error("[memory-snapshot] launchd sampler: skip (paused)");
|
|
6143
|
-
}
|
|
6144
6129
|
// On-screen overlay watcher supervisor. The harness status overlay only renders
|
|
6145
6130
|
// while the watcher process is alive, and that watcher had no supervisor — when
|
|
6146
6131
|
// it died nothing respawned it and the overlay silently vanished. Install it as
|
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.6-rc.
|
|
5
|
+
"version": "1.7.6-rc.40",
|
|
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_state.py
CHANGED
|
@@ -90,22 +90,42 @@ def state_dir() -> str:
|
|
|
90
90
|
# to the MCP: this needs to work from BOTH the menu bar (s4l_menubar.py) and
|
|
91
91
|
# the Claude-independent dashboard_server.py, which share this process but
|
|
92
92
|
# are a different process from the MCP server, so there's no way to delegate
|
|
93
|
-
# to mcp/src/index.ts's pauseS4L/resumeS4L here.
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
93
|
+
# to mcp/src/index.ts's pauseS4L/resumeS4L here. The label SET is not
|
|
94
|
+
# duplicated though: index.ts's PAUSE_TARGETS registry is the single source of
|
|
95
|
+
# truth, mirrored to <state>/pause-targets.json at every MCP boot and
|
|
96
|
+
# pause/resume; pause_target_labels() reads that manifest, so a pipeline daemon
|
|
97
|
+
# added to the registry reaches this button without touching this file.
|
|
98
|
+
# PAUSE_TARGET_LABELS below is only the fallback for a box whose MCP has never
|
|
99
|
+
# written the manifest. What this resume deliberately does NOT do:
|
|
100
|
+
# mcp/src/index.ts's resumeS4L also re-runs the readiness-gated install logic
|
|
101
|
+
# (persona/project/X-verified checks, content-aware plist rewrite) via
|
|
102
|
+
# ensure*Installed() — that only matters for a box that was never fully set up
|
|
103
|
+
# before Pause, an edge case the next Claude boot / project_config call
|
|
104
|
+
# naturally heals once unpaused. This resume just reloads the exact plists
|
|
105
|
+
# Pause unloaded (Pause never deletes anything).
|
|
101
106
|
PAUSE_TARGET_LABELS = (
|
|
102
107
|
AUTOPILOT_LABEL,
|
|
108
|
+
"com.m13v.social-reddit-search",
|
|
103
109
|
"com.m13v.social-claude-reaper",
|
|
104
110
|
"com.m13v.social-autopilot-stall-watch",
|
|
105
111
|
"com.m13v.social-memory-snapshot",
|
|
106
112
|
)
|
|
107
113
|
|
|
108
114
|
|
|
115
|
+
def pause_target_labels() -> tuple:
|
|
116
|
+
try:
|
|
117
|
+
data = json.loads((Path(state_dir()) / "pause-targets.json").read_text())
|
|
118
|
+
labels = tuple(
|
|
119
|
+
l for l in data.get("labels", [])
|
|
120
|
+
if isinstance(l, str) and l.startswith("com.m13v.")
|
|
121
|
+
)
|
|
122
|
+
if labels:
|
|
123
|
+
return labels
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
return PAUSE_TARGET_LABELS
|
|
127
|
+
|
|
128
|
+
|
|
109
129
|
def pause_flag_path() -> str:
|
|
110
130
|
return os.path.join(state_dir(), "paused.flag")
|
|
111
131
|
|
|
@@ -125,7 +145,7 @@ def pause_s4l() -> dict:
|
|
|
125
145
|
return {"ok": False, "paused": is_paused(), "detail": f"could not write pause flag: {e}"}
|
|
126
146
|
uid = os.getuid()
|
|
127
147
|
results = []
|
|
128
|
-
for label in
|
|
148
|
+
for label in pause_target_labels():
|
|
129
149
|
try:
|
|
130
150
|
subprocess.run(
|
|
131
151
|
["launchctl", "bootout", f"gui/{uid}/{label}"],
|
|
@@ -147,7 +167,7 @@ def resume_s4l() -> dict:
|
|
|
147
167
|
uid = os.getuid()
|
|
148
168
|
la_dir = str(Path.home() / "Library" / "LaunchAgents")
|
|
149
169
|
results = []
|
|
150
|
-
for label in
|
|
170
|
+
for label in pause_target_labels():
|
|
151
171
|
plist = os.path.join(la_dir, f"{label}.plist")
|
|
152
172
|
if not os.path.exists(plist):
|
|
153
173
|
results.append(f"{label}: no plist, skip")
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.6-rc.
|
|
3
|
+
"version": "1.7.6-rc.40",
|
|
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
|
@@ -608,14 +608,26 @@ def _atomic_write_text(path: str, text: str) -> None:
|
|
|
608
608
|
|
|
609
609
|
|
|
610
610
|
def _sweep_stale() -> int:
|
|
611
|
-
"""
|
|
611
|
+
"""GC job files orphaned by dead producers. Returns count removed.
|
|
612
|
+
|
|
613
|
+
Two different TTLs on purpose:
|
|
614
|
+
- pending/: a job unclaimed past the producer's whole wait window
|
|
615
|
+
(DEFAULT_TIMEOUT_S + margin) has no live producer left to consume its
|
|
616
|
+
result — drafting it later only burns a worker turn on a stale prompt.
|
|
617
|
+
This is the leak signature of a producer that was SIGKILLed by its
|
|
618
|
+
external wrapper before its own timeout-cleanup ran (2026-07-18: three
|
|
619
|
+
orphaned reddit-draft jobs piled up while paused).
|
|
620
|
+
- running/: a claimed job may legitimately still be drafting, so it gets
|
|
621
|
+
the longer STALE_TTL_S before we conclude the worker died too.
|
|
622
|
+
"""
|
|
612
623
|
removed = 0
|
|
613
624
|
now = time.time()
|
|
614
|
-
|
|
625
|
+
pending_ttl = DEFAULT_TIMEOUT_S + 120
|
|
626
|
+
roots = [(running_dir(), STALE_TTL_S)]
|
|
615
627
|
pend = os.path.join(queue_root(), "pending")
|
|
616
628
|
if os.path.isdir(pend):
|
|
617
|
-
roots += [os.path.join(pend, d) for d in os.listdir(pend)]
|
|
618
|
-
for d in roots:
|
|
629
|
+
roots += [(os.path.join(pend, d), pending_ttl) for d in os.listdir(pend)]
|
|
630
|
+
for d, ttl in roots:
|
|
619
631
|
if not os.path.isdir(d):
|
|
620
632
|
continue
|
|
621
633
|
for name in os.listdir(d):
|
|
@@ -625,7 +637,7 @@ def _sweep_stale() -> int:
|
|
|
625
637
|
try:
|
|
626
638
|
with open(fp) as f:
|
|
627
639
|
created = json.load(f).get("created_at", 0)
|
|
628
|
-
if now - float(created) >
|
|
640
|
+
if now - float(created) > ttl:
|
|
629
641
|
os.remove(fp)
|
|
630
642
|
removed += 1
|
|
631
643
|
except Exception:
|
|
@@ -719,6 +731,13 @@ def cmd_provider(ns) -> int:
|
|
|
719
731
|
% (_before_len - len(prompt))
|
|
720
732
|
)
|
|
721
733
|
|
|
734
|
+
# S4L paused: refuse to enqueue at all. Workers won't claim while paused, so
|
|
735
|
+
# an enqueued job would just sit in pending/ while this producer burns its
|
|
736
|
+
# whole wait window "drafting" and then dies to its external timeout.
|
|
737
|
+
if _is_paused():
|
|
738
|
+
_plog(f"S4L paused; refusing to enqueue {qtype} job")
|
|
739
|
+
return 1
|
|
740
|
+
|
|
722
741
|
job_id = uuid.uuid4().hex
|
|
723
742
|
created = time.time()
|
|
724
743
|
# Cycle batch id (run-twitter-cycle.sh exports BATCH_ID) so every provider.log
|
|
@@ -749,11 +768,31 @@ def cmd_provider(ns) -> int:
|
|
|
749
768
|
_act_write(qtype)
|
|
750
769
|
|
|
751
770
|
res_path = os.path.join(result_dir(), f"{job_id}.json")
|
|
752
|
-
|
|
771
|
+
# Give up before ns.timeout, not at it: external wrappers (post_reddit's
|
|
772
|
+
# subprocess timeout, gtimeout in shell lanes) start their clocks at
|
|
773
|
+
# process start — before stdin read + enqueue — so an internal deadline of
|
|
774
|
+
# exactly created + timeout LOSES that race and the producer gets SIGKILLed
|
|
775
|
+
# before the cleanup below ever runs (2026-07-18 deathwatch "unexpected
|
|
776
|
+
# death" at enqueue+1800s, leaking pending files and a stuck "drafting"
|
|
777
|
+
# label). The 60s margin guarantees the graceful path wins.
|
|
778
|
+
deadline = created + max(60, ns.timeout - 60)
|
|
753
779
|
last_hb = created # last menu-bar heartbeat (see below)
|
|
754
780
|
claimed_at = None # set the moment a worker moves the job pending/ -> running/
|
|
755
781
|
while time.time() < deadline:
|
|
756
782
|
now = time.time()
|
|
783
|
+
# Pause landed mid-wait: abandon immediately with the same cleanup the
|
|
784
|
+
# timeout path does, instead of blocking the full wait window against a
|
|
785
|
+
# queue no worker will drain while paused.
|
|
786
|
+
if _is_paused():
|
|
787
|
+
_plog(f"job {job_id} abandoned: S4L paused mid-wait; cleaning up")
|
|
788
|
+
for p in (pending_path, running_path):
|
|
789
|
+
try:
|
|
790
|
+
os.remove(p)
|
|
791
|
+
except OSError:
|
|
792
|
+
pass
|
|
793
|
+
_act_clear()
|
|
794
|
+
_disarm_deathwatch(job_id)
|
|
795
|
+
return 1
|
|
757
796
|
# A worker claims a job by atomically renaming pending/ -> running/, so the
|
|
758
797
|
# pending file vanishing is our signal that drafting actually STARTED (vs.
|
|
759
798
|
# the job still sitting unclaimed). Latch the claim time once so the label
|
|
@@ -972,12 +1011,26 @@ def _attempt_claim(ns, qtype: str) -> bool:
|
|
|
972
1011
|
return False
|
|
973
1012
|
|
|
974
1013
|
|
|
1014
|
+
def _is_paused() -> bool:
|
|
1015
|
+
# S4L paused (mcp/src/index.ts pauseS4L / the menubar pause button): the
|
|
1016
|
+
# one flag file every pipeline surface honors.
|
|
1017
|
+
return os.path.exists(os.path.join(state_dir(), "paused.flag"))
|
|
1018
|
+
|
|
1019
|
+
|
|
975
1020
|
def cmd_next(ns) -> int:
|
|
976
1021
|
_apply_state_dir_override(ns)
|
|
977
|
-
#
|
|
978
|
-
#
|
|
979
|
-
#
|
|
980
|
-
|
|
1022
|
+
# GC jobs orphaned by dead/killed producers BEFORE claiming (and before the
|
|
1023
|
+
# pause check, so orphans left behind by a pause don't sit until the next
|
|
1024
|
+
# enqueue-side sweep — while paused nothing enqueues, so this is the only
|
|
1025
|
+
# sweeper running). Without this, a resume would hand workers stale jobs
|
|
1026
|
+
# whose producer died long ago.
|
|
1027
|
+
swept = _sweep_stale()
|
|
1028
|
+
if swept:
|
|
1029
|
+
_plog(f"swept {swept} stale queue job file(s) before claim pass")
|
|
1030
|
+
# S4L paused: report no work instead of claiming a job, so an
|
|
1031
|
+
# already-queued draft can't drain/post while paused even though the
|
|
1032
|
+
# scheduled task worker still fires on its own cadence.
|
|
1033
|
+
if _is_paused():
|
|
981
1034
|
print(json.dumps({}))
|
|
982
1035
|
return 0
|
|
983
1036
|
qtype = ns.type
|
|
@@ -997,6 +1050,13 @@ def cmd_next(ns) -> int:
|
|
|
997
1050
|
start = time.time()
|
|
998
1051
|
attempt = 0
|
|
999
1052
|
while True:
|
|
1053
|
+
# Re-check pause on every iteration, not just at entry: a worker
|
|
1054
|
+
# already inside its wait window when Pause is clicked must stop
|
|
1055
|
+
# claiming immediately (observed 2026-07-18: pause at 12:24, a
|
|
1056
|
+
# pre-pause poller claimed a reddit-draft job at 12:29 anyway).
|
|
1057
|
+
if _is_paused():
|
|
1058
|
+
_plog(f"polling {qtype}: paused.flag appeared mid-wait; exiting without claiming")
|
|
1059
|
+
break
|
|
1000
1060
|
if _attempt_claim(ns, qtype):
|
|
1001
1061
|
return 0
|
|
1002
1062
|
attempt += 1
|
package/scripts/preflight.sh
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# preflight.sh — sourced helper for launchd-fired run-*.sh wrappers.
|
|
3
3
|
#
|
|
4
|
-
#
|
|
4
|
+
# Four checks, each emits a `[skipped: <reason>]` stderr line and exits 0
|
|
5
5
|
# (so launchd treats the slot as cleanly consumed and fires the next one
|
|
6
6
|
# on schedule, rather than thinking the job is broken):
|
|
7
7
|
#
|
|
@@ -28,7 +28,16 @@
|
|
|
28
28
|
# still recovering automatically within 10 min of the underlying
|
|
29
29
|
# cap being lifted.
|
|
30
30
|
#
|
|
31
|
-
# 3.
|
|
31
|
+
# 3. preflight_skip_if_paused
|
|
32
|
+
# Reads <state>/paused.flag (S4L Pause: written by mcp/src/index.ts
|
|
33
|
+
# pauseS4L and the menubar's pause button). Pause boots the pipeline
|
|
34
|
+
# launchd jobs out, but that only covers jobs loaded at pause time —
|
|
35
|
+
# this guard is the shared runtime backstop every pipeline wrapper
|
|
36
|
+
# calls, so a job that was mid-flight, re-loaded by a Claude boot,
|
|
37
|
+
# or added later can never draft/post while paused. One guard, all
|
|
38
|
+
# pipelines: add a new run-*.sh wrapper and call this, done.
|
|
39
|
+
#
|
|
40
|
+
# 4. preflight_acquire_slot_or_skip <pool_name> [max_slots=4]
|
|
32
41
|
# Slot-pool admission control via mkdir on
|
|
33
42
|
# /tmp/sa-${pool_name}-slot-{1..max_slots}.lock. If all slots are
|
|
34
43
|
# held by live PIDs, skips. Stale slots (PID dead) are GC'd before
|
|
@@ -146,7 +155,18 @@ PY
|
|
|
146
155
|
}
|
|
147
156
|
|
|
148
157
|
# ---------------------------------------------------------------------------
|
|
149
|
-
# 3.
|
|
158
|
+
# 3. S4L Pause preflight
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
preflight_skip_if_paused() {
|
|
161
|
+
local flag="${S4L_STATE_DIR:-$HOME/.social-autoposter-mcp}/paused.flag"
|
|
162
|
+
[ -e "$flag" ] || return 0
|
|
163
|
+
local script_tag="${SA_PREFLIGHT_SCRIPT:-${SCRIPT_TAG:-$(basename "$0")}}"
|
|
164
|
+
echo "[skipped: paused flag=$flag script=$script_tag] $(date)" >&2
|
|
165
|
+
exit 0
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# 4. Slot-pool admission (parallel-cycle cap)
|
|
150
170
|
# ---------------------------------------------------------------------------
|
|
151
171
|
preflight_acquire_slot_or_skip() {
|
|
152
172
|
local pool_name="$1"
|
|
@@ -28,6 +28,13 @@ EOF_ENV
|
|
|
28
28
|
REPO_DIR="${S4L_REPO_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
|
|
29
29
|
PY="${S4L_PYTHON:-python3}"
|
|
30
30
|
|
|
31
|
+
# Shared pause guard (scripts/preflight.sh): every pipeline kicker honors the
|
|
32
|
+
# same paused.flag, so Pause stops the X lane the same way it stops Reddit —
|
|
33
|
+
# even if launchd re-fires this wrapper while the flag is up.
|
|
34
|
+
SA_PREFLIGHT_SCRIPT="run-draft-and-publish"
|
|
35
|
+
source "$REPO_DIR/scripts/preflight.sh"
|
|
36
|
+
preflight_skip_if_paused
|
|
37
|
+
|
|
31
38
|
# Salvage twitter-prep drafts orphaned when a producer cycle died after its worker
|
|
32
39
|
# wrote the result but before consuming it (a consumed result is deleted, so a
|
|
33
40
|
# surviving one in claude-queue/result/ = never consumed). Best-effort, dedup-safe
|
|
@@ -40,6 +40,7 @@ ERR="$LOG_DIR/launchd-reddit-search-stderr.log"
|
|
|
40
40
|
# See scripts/preflight.sh for full design.
|
|
41
41
|
SA_PREFLIGHT_SCRIPT="run-reddit-search"
|
|
42
42
|
source "$REPO_DIR/scripts/preflight.sh"
|
|
43
|
+
preflight_skip_if_paused
|
|
43
44
|
preflight_skip_if_claude_blocked
|
|
44
45
|
preflight_skip_if_jetsam_pressure
|
|
45
46
|
|