@m13v/s4l 1.7.6-rc.4 → 1.7.6-rc.5
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
|
@@ -914,6 +914,16 @@ function expiredStampOverridable(c) {
|
|
|
914
914
|
c.posted !== true &&
|
|
915
915
|
c.discard_reason === "backend_status_expired");
|
|
916
916
|
}
|
|
917
|
+
// Prompt-sandbox replay cards (run-twitter-cycle.sh S4L_SANDBOX_CANDIDATES_FILE)
|
|
918
|
+
// carry experiments.sandbox=true; older sandbox rows predate that stamp but all
|
|
919
|
+
// use the synthetic >=900,000,000 id range twitter_prompt_sandbox.py assigns.
|
|
920
|
+
function isSandboxCandidate(c) {
|
|
921
|
+
const exps = c.experiments;
|
|
922
|
+
if (exps && typeof exps === "object" && exps.sandbox)
|
|
923
|
+
return true;
|
|
924
|
+
const id = Number(c.candidate_id);
|
|
925
|
+
return Number.isFinite(id) && id >= 900_000_000;
|
|
926
|
+
}
|
|
917
927
|
function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
|
|
918
928
|
// Merge posted/terminal stamps into a FRESH read of the store instead of
|
|
919
929
|
// rewriting the whole plan from the copy taken minutes ago. The old
|
|
@@ -930,24 +940,35 @@ function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
|
|
|
930
940
|
const mergeable = stamped.every((c) => c.candidate_id !== undefined && c.candidate_id !== null);
|
|
931
941
|
const fresh = mergeable ? readPlan(batchId) : null;
|
|
932
942
|
if (fresh && Array.isArray(fresh.candidates)) {
|
|
943
|
+
// candidate_id is NOT unique in the review-queue store: sandbox reruns
|
|
944
|
+
// and re-merged drafts append sibling rows with the same id. Stamping
|
|
945
|
+
// only one sibling (the old Map single-slot) left the others matching
|
|
946
|
+
// the drain's approved && !posted && !terminal filter, so the backlog
|
|
947
|
+
// re-drained the same candidate every heartbeat forever (2026-07-17
|
|
948
|
+
// incident: 23-card loop at 60s cadence). Stamp EVERY row with the id.
|
|
933
949
|
const freshById = new Map();
|
|
934
950
|
fresh.candidates.forEach((c) => {
|
|
935
|
-
if (c.candidate_id !== undefined && c.candidate_id !== null)
|
|
936
|
-
|
|
951
|
+
if (c.candidate_id !== undefined && c.candidate_id !== null) {
|
|
952
|
+
const key = String(c.candidate_id);
|
|
953
|
+
const list = freshById.get(key);
|
|
954
|
+
if (list)
|
|
955
|
+
list.push(c);
|
|
956
|
+
else
|
|
957
|
+
freshById.set(key, [c]);
|
|
958
|
+
}
|
|
937
959
|
});
|
|
938
960
|
for (const c of stamped) {
|
|
939
|
-
const f
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
f.terminal_reason = c.terminal_reason;
|
|
961
|
+
for (const f of freshById.get(String(c.candidate_id)) ?? []) {
|
|
962
|
+
if (c.posted === true) {
|
|
963
|
+
f.posted = true;
|
|
964
|
+
f.terminal = false;
|
|
965
|
+
if (c.our_url)
|
|
966
|
+
f.our_url = c.our_url;
|
|
967
|
+
}
|
|
968
|
+
else if (c.terminal === true && f.posted !== true) {
|
|
969
|
+
f.terminal = true;
|
|
970
|
+
f.terminal_reason = c.terminal_reason;
|
|
971
|
+
}
|
|
951
972
|
}
|
|
952
973
|
}
|
|
953
974
|
writePlan(batchId, fresh);
|
|
@@ -1043,7 +1064,12 @@ async function postApproved(batchId, plan) {
|
|
|
1043
1064
|
// and the stamp is cleared so every downstream terminal check agrees it's live.
|
|
1044
1065
|
const approved = (plan.candidates || []).filter((c) => c.approved === true &&
|
|
1045
1066
|
c.posted !== true &&
|
|
1046
|
-
(c.terminal !== true || expiredStampOverridable(c))
|
|
1067
|
+
(c.terminal !== true || expiredStampOverridable(c)) &&
|
|
1068
|
+
// Prompt-sandbox replays can never post (twitter_post_plan.py post_one()
|
|
1069
|
+
// hard-refuses them), so draining one is pure churn: it burns a browser
|
|
1070
|
+
// lock turn and, if its terminal stamp later loses a store-write race,
|
|
1071
|
+
// loops forever. Exclude them here regardless of stamp state.
|
|
1072
|
+
!isSandboxCandidate(c));
|
|
1047
1073
|
for (const c of approved) {
|
|
1048
1074
|
if (c.terminal === true) {
|
|
1049
1075
|
c.terminal = false;
|
|
@@ -3468,8 +3494,8 @@ async function autopilotLoaded() {
|
|
|
3468
3494
|
// fires every minute, claims ONE job, runs the pipeline's own prompt as its
|
|
3469
3495
|
// Claude turn, writes the result back, and stops.
|
|
3470
3496
|
// ===========================================================================
|
|
3471
|
-
const QUEUE_WORKER_PROMPT_VERSION =
|
|
3472
|
-
//
|
|
3497
|
+
const QUEUE_WORKER_PROMPT_VERSION = 9; // v9 (2026-07-17): poll window widened 240s -> 900s (see QUEUE_WORKER_POLL_SECONDS); version bump forces the prompt refresh that carries the new --wait-seconds onto existing installs. 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.
|
|
3498
|
+
// v10 (PLANNED, NOT IMPLEMENTED): delegate the actual drafting to a fresh
|
|
3473
3499
|
// sub-agent per claimed job (claim -> delegate -> wait -> claim next, looped
|
|
3474
3500
|
// within one continuous worker session) instead of drafting inline. Validated
|
|
3475
3501
|
// via throwaway probe tasks 2026-07-07/08 (10 loop iterations, ~210s of real
|
|
@@ -3478,19 +3504,26 @@ const QUEUE_WORKER_PROMPT_VERSION = 8; // v8: worker polls internally (claude_jo
|
|
|
3478
3504
|
// notification) or the host kills the whole parent+child chain in 1-3 min.
|
|
3479
3505
|
// Never live-fire tested against a real production job. Full design, what's
|
|
3480
3506
|
// validated vs not, and the implementation steps: docs/queue-worker-delegation-plan.md
|
|
3481
|
-
// Bump this constant to
|
|
3507
|
+
// Bump this constant to 10 only once that plan is actually implemented.
|
|
3482
3508
|
const QUEUE_WORKER_PROMPT_MARKER = "s4l_queue_worker_prompt_version";
|
|
3483
3509
|
// How long ONE `next --wait-seconds` call polls before giving up and exiting.
|
|
3484
|
-
//
|
|
3485
|
-
// live on 2026-07-06
|
|
3486
|
-
//
|
|
3487
|
-
//
|
|
3488
|
-
//
|
|
3510
|
+
// 900s (15 min, per Matthew 2026-07-17, up from 240s): sits AT the single-
|
|
3511
|
+
// Bash-call survival ceiling verified live on 2026-07-06 (the host's ~90s
|
|
3512
|
+
// inactivity kill fires only on model silence, and one in-flight tool call
|
|
3513
|
+
// survived a full 900s probe). This covers the ~8min average real job
|
|
3514
|
+
// inter-arrival gap outright, so most jobs are claimed by an already-polling
|
|
3515
|
+
// session instead of paying a fresh spin-up, and MCP boot side effects
|
|
3516
|
+
// (backfill checks, backlog drains) run 1/15min instead of 1/5min. Watch
|
|
3517
|
+
// point: 900s has zero margin below the verified ceiling — if workers start
|
|
3518
|
+
// dying mid-poll with no reaper kill recorded, the host clipped the call;
|
|
3519
|
+
// back off to 600s. The cron's `* * * * *` cadence remains the outer safety
|
|
3520
|
+
// net for whatever the poll window doesn't catch.
|
|
3489
3521
|
// COUPLING: scripts/reap_stale_claude_sessions.py's S4L_REAPER_CLAIM_GRACE_SEC
|
|
3490
3522
|
// default MUST stay >= this value + margin — a claimless session inside this
|
|
3491
3523
|
// poll window is legitimately still working, not a husk, and a too-tight
|
|
3492
3524
|
// claim_grace would SIGTERM it mid-poll before it ever gets to claim.
|
|
3493
|
-
|
|
3525
|
+
// (Bumped to 1020s alongside this change.)
|
|
3526
|
+
const QUEUE_WORKER_POLL_SECONDS = 900;
|
|
3494
3527
|
// One spec per worker task. queueType MUST match scripts/claude_job.py TAG_TO_TYPE.
|
|
3495
3528
|
const QUEUE_WORKERS = [
|
|
3496
3529
|
{ taskId: WORKER_TASK_ID, queueType: "any", human: "universal queue" },
|
|
@@ -5694,7 +5727,10 @@ async function drainApprovedBacklog() {
|
|
|
5694
5727
|
const cands = plan?.candidates || [];
|
|
5695
5728
|
const backlog = cands.filter((c) => c.approved === true &&
|
|
5696
5729
|
c.posted !== true &&
|
|
5697
|
-
(c.terminal !== true || expiredStampOverridable(c))
|
|
5730
|
+
(c.terminal !== true || expiredStampOverridable(c)) &&
|
|
5731
|
+
// Same sandbox exclusion as postApproved's own filter: a sandbox
|
|
5732
|
+
// replay can never post, so it must never count as backlog.
|
|
5733
|
+
!isSandboxCandidate(c));
|
|
5698
5734
|
if (!backlog.length)
|
|
5699
5735
|
return;
|
|
5700
5736
|
console.error(`[post] draining ${backlog.length} approved-but-unposted card(s) left from before`);
|
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.5",
|
|
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.6-rc.
|
|
3
|
+
"version": "1.7.6-rc.5",
|
|
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
|
@@ -265,17 +265,45 @@ def _sync_with_backend(cands: list) -> tuple[int, int]:
|
|
|
265
265
|
and not (c.get("experiments") or {}).get("sandbox")
|
|
266
266
|
and _thread_url(c)
|
|
267
267
|
]
|
|
268
|
-
|
|
268
|
+
# Approved-but-unposted cards get the INVERSE sync: never pruned (the
|
|
269
|
+
# approval is a settled human decision, see docstring above), but if the
|
|
270
|
+
# backend row says the reply already LANDED (status='posted'), stamp
|
|
271
|
+
# posted=True here so the store agrees with the database. Without this
|
|
272
|
+
# there is no path that ever corrects a lost posted stamp: the drain's
|
|
273
|
+
# in-process stamp can lose a whole-file write race against the menubar
|
|
274
|
+
# or a peer drain, after which the card shows failed/terminal forever
|
|
275
|
+
# while the reply is live on X (2026-07-17 incident: 6 posted replies
|
|
276
|
+
# rendered as duplicate_thread_pre_post kills).
|
|
277
|
+
approved_open = [
|
|
278
|
+
c
|
|
279
|
+
for c in cands
|
|
280
|
+
if c.get("approved")
|
|
281
|
+
and not c.get("posted")
|
|
282
|
+
and c.get("platform") != "reddit"
|
|
283
|
+
and not (c.get("experiments") or {}).get("sandbox")
|
|
284
|
+
and _thread_url(c)
|
|
285
|
+
]
|
|
286
|
+
if not pending and not approved_open:
|
|
269
287
|
return 0, 0
|
|
270
|
-
urls = sorted({_thread_url(c) for c in pending})[:500]
|
|
288
|
+
urls = sorted({_thread_url(c) for c in pending} | {_thread_url(c) for c in approved_open})[:500]
|
|
271
289
|
try:
|
|
272
290
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
273
291
|
from http_api import api_get
|
|
274
292
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
293
|
+
# Scope to OUR candidate rows: the bulk GET has no tenant isolation,
|
|
294
|
+
# so without install_id another install's row for the same tweet_url
|
|
295
|
+
# (e.g. two customers discovering one viral thread) could be read
|
|
296
|
+
# back as "our" status and mis-stamp posted/terminal on our card.
|
|
297
|
+
query = {"tweet_urls": ",".join(urls), "limit": 500}
|
|
298
|
+
try:
|
|
299
|
+
from identity import get_identity
|
|
300
|
+
|
|
301
|
+
own_install = (get_identity() or {}).get("install_id")
|
|
302
|
+
if own_install:
|
|
303
|
+
query["install_id"] = own_install
|
|
304
|
+
except Exception:
|
|
305
|
+
pass # fail open: unscoped lookup, same behavior as before
|
|
306
|
+
resp = api_get("/api/v1/twitter-candidates", query=query)
|
|
279
307
|
rows = (resp.get("data") or {}).get("candidates") or []
|
|
280
308
|
except BaseException as e: # http_api raises SystemExit on terminal failure
|
|
281
309
|
print(f"[merge_review_queue] backend sync skipped: {e}", file=sys.stderr)
|
|
@@ -295,6 +323,14 @@ def _sync_with_backend(cands: list) -> tuple[int, int]:
|
|
|
295
323
|
c["terminal"] = True
|
|
296
324
|
c["discard_reason"] = f"backend_status_{status}"
|
|
297
325
|
pruned += 1
|
|
326
|
+
for c in approved_open:
|
|
327
|
+
row = by_url.get(_thread_url(c))
|
|
328
|
+
if row and row.get("status") == "posted":
|
|
329
|
+
c["posted"] = True
|
|
330
|
+
c["terminal"] = False
|
|
331
|
+
c.pop("post_failed", None)
|
|
332
|
+
c.pop("post_error", None)
|
|
333
|
+
stamped += 1
|
|
298
334
|
return stamped, pruned
|
|
299
335
|
|
|
300
336
|
|
|
@@ -820,12 +820,13 @@ def main() -> int:
|
|
|
820
820
|
# (it stays in pending), never a lost draft — a DRAFTING session is protected by
|
|
821
821
|
# claim_pids, not by grace, so no grace value can kill a real draft.
|
|
822
822
|
#
|
|
823
|
-
# Default
|
|
824
|
-
# cold agent-mode boot (skill load + MCP init) and this reaper's own
|
|
825
|
-
# latency. COUPLING: must stay >= QUEUE_WORKER_POLL_SECONDS + margin
|
|
826
|
-
# constant grows, bump this default (or S4L_REAPER_CLAIM_GRACE_SEC) to
|
|
827
|
-
# the reaper will SIGTERM a legitimately-polling worker before it
|
|
828
|
-
|
|
823
|
+
# Default 1020s (2026-07-17): QUEUE_WORKER_POLL_SECONDS (900s) + ~120s margin
|
|
824
|
+
# for cold agent-mode boot (skill load + MCP init) and this reaper's own
|
|
825
|
+
# ps-scan latency. COUPLING: must stay >= QUEUE_WORKER_POLL_SECONDS + margin;
|
|
826
|
+
# if that constant grows, bump this default (or S4L_REAPER_CLAIM_GRACE_SEC) to
|
|
827
|
+
# match, or the reaper will SIGTERM a legitimately-polling worker before it
|
|
828
|
+
# ever claims. (Was 300s while the poll window was 240s.)
|
|
829
|
+
claim_grace = _env_int("S4L_REAPER_CLAIM_GRACE_SEC", 1020)
|
|
829
830
|
|
|
830
831
|
inflight = count_running_jobs() # None => queue unreadable => age-gate fallback
|
|
831
832
|
claim_pids = running_claim_pids() # agent-session pids actively holding a claim
|