@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
- freshById.set(String(c.candidate_id), c);
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 = freshById.get(String(c.candidate_id));
940
- if (!f)
941
- continue;
942
- if (c.posted === true) {
943
- f.posted = true;
944
- f.terminal = false;
945
- if (c.our_url)
946
- f.our_url = c.our_url;
947
- }
948
- else if (c.terminal === true && f.posted !== true) {
949
- f.terminal = true;
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 = 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.
3472
- // v9 (PLANNED, NOT IMPLEMENTED): delegate the actual drafting to a fresh
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 9 only once that plan is actually implemented.
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
- // 240s (4 min): comfortably inside the 900s single-Bash-call survival verified
3485
- // live on 2026-07-06, and covers a meaningful chunk of the ~8min average
3486
- // real job inter-arrival gap measured on the box, while still keeping each
3487
- // worker session bounded. The cron's `* * * * *` cadence remains the outer
3488
- // safety net for whatever the poll window doesn't catch.
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
- const QUEUE_WORKER_POLL_SECONDS = 240;
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`);
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.6-rc.4",
3
- "installedAt": "2026-07-17T16:50:15.589Z"
2
+ "version": "1.7.6-rc.5",
3
+ "installedAt": "2026-07-17T17:09:56.264Z"
4
4
  }
package/mcp/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "dxt_version": "0.1",
3
3
  "name": "social-autoposter",
4
4
  "display_name": "S4L",
5
- "version": "1.7.6-rc.4",
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.4",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l",
3
- "version": "1.7.6-rc.4",
3
+ "version": "1.7.6-rc.5",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js",
@@ -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
- if not pending:
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
- resp = api_get(
276
- "/api/v1/twitter-candidates",
277
- query={"tweet_urls": ",".join(urls), "limit": 500},
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 300s (2026-07-06): QUEUE_WORKER_POLL_SECONDS (240s) + ~60s margin for
824
- # cold agent-mode boot (skill load + MCP init) and this reaper's own ps-scan
825
- # latency. COUPLING: must stay >= QUEUE_WORKER_POLL_SECONDS + margin — if that
826
- # constant grows, bump this default (or S4L_REAPER_CLAIM_GRACE_SEC) to match, or
827
- # the reaper will SIGTERM a legitimately-polling worker before it ever claims.
828
- claim_grace = _env_int("S4L_REAPER_CLAIM_GRACE_SEC", 300)
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