@m13v/s4l 1.7.6-rc.5 → 1.7.6-rc.7

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
@@ -19,7 +19,7 @@ import { screencast, bringBrowserToFront } from "./screencast.js";
19
19
  import os from "node:os";
20
20
  import path from "node:path";
21
21
  import fs from "node:fs";
22
- import { repoDir, runPython, run, readPlan, writePlan, planPath, } from "./repo.js";
22
+ import { repoDir, runPython, run, readPlan, writePlan, planPath, TMP_DIR, } from "./repo.js";
23
23
  import { applySetup, resolveProject, hasReadyProject, personaReady, listManagedProjectStatus, listProjectSettings, ensureShortLinksDefault, ensurePersonaProject, findPersonaProject, REQUIRED_FIELDS, RECOMMENDED_FIELDS, configPath, ensureConfigInStateDir, normalizeStringList, recordRedditAccount, } from "./setup.js";
24
24
  import { xStatus, xConnect, xDetectSources, xScanProfile, summarizeXAuth } from "./twitterAuth.js";
25
25
  import { redditStatus, redditConnect, redditDetectSources, summarizeRedditAuth, } from "./redditAuth.js";
@@ -909,6 +909,11 @@ async function ensureTwitterBrowserForPost() {
909
909
  // thread still exists. Without this override, a card approved while (or just
910
910
  // before) the sync stamped it is refused as already-decided and the approval
911
911
  // silently no-ops (2 of 3 approvals lost on 2026-07-10).
912
+ // Give-up bound for approved cards whose post attempts keep failing
913
+ // transiently (browser lock contention, timeouts). 5 attempts spans several
914
+ // drain cycles — plenty for genuine transients to clear — while guaranteeing
915
+ // no card can retry forever (2026-07-17 zombie-card incident).
916
+ const MAX_POST_ATTEMPTS = 5;
912
917
  function expiredStampOverridable(c) {
913
918
  return (c.terminal === true &&
914
919
  c.posted !== true &&
@@ -924,7 +929,37 @@ function isSandboxCandidate(c) {
924
929
  const id = Number(c.candidate_id);
925
930
  return Number.isFinite(id) && id >= 900_000_000;
926
931
  }
927
- function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
932
+ // Write field patches into the review-queue store UNDER ITS LOCK by shelling
933
+ // to scripts/store_patch.py, which takes the same fcntl.flock the menubar's
934
+ // _store_update and merge_review_queue.py hold around their read-modify-write.
935
+ // Node has no native flock, and this process writing the store directly was
936
+ // the last unlocked writer (the race that erased posted stamps on 2026-07-17).
937
+ // Returns false on any failure so callers can fall back to the legacy write.
938
+ async function patchReviewStore(patches) {
939
+ if (!patches.length)
940
+ return true;
941
+ const tmp = path.join(TMP_DIR, `s4l-store-patches-${process.pid}-${Date.now()}.json`);
942
+ try {
943
+ fs.writeFileSync(tmp, JSON.stringify({ patches }), "utf-8");
944
+ const res = await runPython("scripts/store_patch.py", [tmp], {
945
+ timeoutMs: 30_000,
946
+ env: { S4L_REPO_DIR: repoDir(), PATH: pipelinePath() },
947
+ });
948
+ return res.code === 0;
949
+ }
950
+ catch {
951
+ return false;
952
+ }
953
+ finally {
954
+ try {
955
+ fs.unlinkSync(tmp);
956
+ }
957
+ catch {
958
+ /* best effort */
959
+ }
960
+ }
961
+ }
962
+ async function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
928
963
  // Merge posted/terminal stamps into a FRESH read of the store instead of
929
964
  // rewriting the whole plan from the copy taken minutes ago. The old
930
965
  // whole-file write was last-writer-wins: while a batch posted, the menubar
@@ -936,6 +971,46 @@ function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
936
971
  // overwrites a fresh `posted=true`. Fallback: candidates without a
937
972
  // candidate_id can't be matched into the fresh copy, so keep the legacy
938
973
  // whole-plan write for those older plans.
974
+ //
975
+ // Review-queue store: go through the LOCKED patch path (store_patch.py)
976
+ // first. The fresh-read merge below closes most of the race window but not
977
+ // all of it — a menubar decision landing between our readPlan and writePlan
978
+ // still gets erased. The locked path holds the store's flock for the whole
979
+ // read-mutate-replace, applies to every sibling row sharing a candidate_id,
980
+ // and enforces the same posted-sticky rules. Legacy path stays as the
981
+ // fallback and for per-batch /tmp plans (single writer, no lock needed).
982
+ try {
983
+ const mergeableForPatch = stamped.every((c) => c.candidate_id !== undefined && c.candidate_id !== null);
984
+ if (batchId === REVIEW_QUEUE_ID && mergeableForPatch) {
985
+ const patches = stamped.map((c) => {
986
+ const set = {};
987
+ const unset = [];
988
+ if (c.posted === true) {
989
+ set.posted = true;
990
+ set.terminal = false;
991
+ if (c.our_url)
992
+ set.our_url = c.our_url;
993
+ unset.push("discard_reason");
994
+ }
995
+ else if (c.terminal === true) {
996
+ set.terminal = true;
997
+ set.terminal_reason = c.terminal_reason ?? null;
998
+ // See the zombie-card note in the legacy branch below: a terminal
999
+ // from a real post attempt must clear the overridable expiry stamp.
1000
+ unset.push("discard_reason");
1001
+ }
1002
+ if (typeof c.post_attempts === "number")
1003
+ set.post_attempts = c.post_attempts;
1004
+ return { candidate_id: c.candidate_id, set, unset };
1005
+ });
1006
+ if (await patchReviewStore(patches))
1007
+ return;
1008
+ console.error("[post] store_patch.py failed; falling back to unlocked stamp merge");
1009
+ }
1010
+ }
1011
+ catch {
1012
+ /* fall through to the legacy write */
1013
+ }
939
1014
  try {
940
1015
  const mergeable = stamped.every((c) => c.candidate_id !== undefined && c.candidate_id !== null);
941
1016
  const fresh = mergeable ? readPlan(batchId) : null;
@@ -964,11 +1039,29 @@ function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
964
1039
  f.terminal = false;
965
1040
  if (c.our_url)
966
1041
  f.our_url = c.our_url;
1042
+ // A post outcome closes the card's history: the pre-approval
1043
+ // freshness stamp must not survive it.
1044
+ delete f.discard_reason;
967
1045
  }
968
1046
  else if (c.terminal === true && f.posted !== true) {
969
1047
  f.terminal = true;
970
1048
  f.terminal_reason = c.terminal_reason;
1049
+ // CRITICAL (2026-07-17 Nhat zombie-card incident, 438 retries over
1050
+ // 5 days): a stale discard_reason="backend_status_expired" left on
1051
+ // the fresh copy makes expiredStampOverridable() treat THIS
1052
+ // post-outcome terminal as overridable, so every later drain
1053
+ // resurrects the card, re-posts, dedup-skips, and re-stamps —
1054
+ // forever. A terminal that came from an actual post attempt is
1055
+ // final; delete the expiry stamp so the override can never fire
1056
+ // on it again. (The drain's own `delete c.discard_reason` happens
1057
+ // on an in-memory copy that is never the write source; this line
1058
+ // is the one that persists.)
1059
+ delete f.discard_reason;
971
1060
  }
1061
+ // Carry the retry counter so the give-up bound survives across
1062
+ // drains (each drain reads the store fresh).
1063
+ if (typeof c.post_attempts === "number" && c.post_attempts > (f.post_attempts || 0))
1064
+ f.post_attempts = c.post_attempts;
972
1065
  }
973
1066
  }
974
1067
  writePlan(batchId, fresh);
@@ -1267,7 +1360,7 @@ async function postApproved(batchId, plan) {
1267
1360
  };
1268
1361
  }
1269
1362
  if (approvedReddit.length)
1270
- mergeApprovedStampsIntoStore(batchId, plan, approvedReddit);
1363
+ await mergeApprovedStampsIntoStore(batchId, plan, approvedReddit);
1271
1364
  return {
1272
1365
  attempted: approvedReddit.length,
1273
1366
  posted: redditPosted,
@@ -1508,6 +1601,21 @@ async function postApproved(batchId, plan) {
1508
1601
  // here (found 2026-07-16) silently and permanently discarded 7 real
1509
1602
  // approved drafts on nothing worse than lock contention — Reddit's
1510
1603
  // equivalent transient failures self-healed on the very next drain.
1604
+ //
1605
+ // BOUNDED (2026-07-17): sticky is right, sticky-forever is not — an
1606
+ // unbounded retry is a zombie generator (one card retried 438 times
1607
+ // over 5 days on the Nhat install). Count the transient failures and
1608
+ // give up loudly after MAX_POST_ATTEMPTS; the terminal_reason keeps
1609
+ // the last failure visible so the give-up is diagnosable, and the
1610
+ // on-disk post-events trail records it for forensics.
1611
+ const attempts = (typeof c.post_attempts === "number" ? c.post_attempts : 0) + 1;
1612
+ c.post_attempts = attempts;
1613
+ if (attempts >= MAX_POST_ATTEMPTS) {
1614
+ c.terminal = true;
1615
+ c.terminal_reason = `gave_up_after_${attempts}_failed_attempts:${r.reason || "failed"}`;
1616
+ console.error(`[post] giving up on candidate ${r.candidate_id} after ${attempts} failed attempts (last: ${r.reason || "failed"})`);
1617
+ logPostEvent(`retry_budget_exhausted candidate=${r.candidate_id} attempts=${attempts} last=${r.reason || "failed"}`);
1618
+ }
1511
1619
  touchedPlan = true;
1512
1620
  }
1513
1621
  });
@@ -1522,7 +1630,7 @@ async function postApproved(batchId, plan) {
1522
1630
  // Reddit stamps (set in the reddit drain above) merge alongside the twitter
1523
1631
  // ones: `approved` here spans both platforms.
1524
1632
  if (touchedPlan || redditPosted || redditFailed) {
1525
- mergeApprovedStampsIntoStore(batchId, plan, approved);
1633
+ await mergeApprovedStampsIntoStore(batchId, plan, approved);
1526
1634
  }
1527
1635
  // Post failures are HANDLED in the pipeline (it returns a count, never throws),
1528
1636
  // so they never reach Sentry on their own. Capture an explicit event whenever
@@ -2767,6 +2875,16 @@ tool("post_drafts", {
2767
2875
  const total = candidates.length;
2768
2876
  const warnings = [];
2769
2877
  const inRange = (n) => n >= 1 && n <= total;
2878
+ // Review-queue store: snapshot every row now so the write below can be a
2879
+ // field-level DIFF applied under the store lock (store_patch.py) instead
2880
+ // of an unlocked whole-file replace. This function holds its in-memory
2881
+ // plan across user think-time; a whole-file write here erased any menubar
2882
+ // decision or merge that landed in between (the 2026-07-17 truth-loss
2883
+ // family). Non-store batches keep the plain write: single writer.
2884
+ const isStoreBatch = batch_id === REVIEW_QUEUE_ID;
2885
+ const rowsBefore = isStoreBatch
2886
+ ? candidates.map((c) => JSON.stringify(c))
2887
+ : [];
2770
2888
  // ---- Rejections: durable + final --------------------------------------
2771
2889
  // A rejected draft is marked terminal so it NEVER re-appears for review and is
2772
2890
  // never posted. A reject overrides any earlier approve on the same card.
@@ -2903,7 +3021,40 @@ tool("post_drafts", {
2903
3021
  if (c)
2904
3022
  c.approved = true;
2905
3023
  });
2906
- writePlan(batch_id, plan);
3024
+ // Persist the decision mutations. Store batch: diff each row against its
3025
+ // snapshot and apply only the changed fields under the store lock, so a
3026
+ // concurrent menubar decision or merge is never erased. Anything else
3027
+ // (per-batch /tmp plans): plain write, single writer.
3028
+ let storeWriteDone = false;
3029
+ if (isStoreBatch) {
3030
+ const patches = [];
3031
+ candidates.forEach((c, i) => {
3032
+ const beforeRaw = rowsBefore[i];
3033
+ const afterRaw = JSON.stringify(c);
3034
+ if (beforeRaw === afterRaw)
3035
+ return;
3036
+ const before = JSON.parse(beforeRaw ?? "{}");
3037
+ const after = JSON.parse(afterRaw);
3038
+ const set = {};
3039
+ const unset = [];
3040
+ for (const k of new Set([...Object.keys(before), ...Object.keys(after)])) {
3041
+ if (!(k in after) || after[k] === undefined) {
3042
+ if (k in before)
3043
+ unset.push(k);
3044
+ }
3045
+ else if (JSON.stringify(before[k]) !== JSON.stringify(after[k])) {
3046
+ set[k] = after[k];
3047
+ }
3048
+ }
3049
+ if (Object.keys(set).length || unset.length)
3050
+ patches.push({ candidate_id: c.candidate_id ?? null, n: i + 1, set, unset });
3051
+ });
3052
+ storeWriteDone = await patchReviewStore(patches);
3053
+ if (!storeWriteDone)
3054
+ console.error("[post_drafts] store_patch.py failed; falling back to unlocked plan write");
3055
+ }
3056
+ if (!storeWriteDone)
3057
+ writePlan(batch_id, plan);
2907
3058
  if (approve.size === 0) {
2908
3059
  return jsonContent({
2909
3060
  batch_id,
@@ -5717,33 +5868,18 @@ registerAppResource(server, "S4L product link", PRODUCT_LINK_URI, { mimeType: RE
5717
5868
  },
5718
5869
  ],
5719
5870
  }));
5720
- // Post any cards the user APPROVED that never landed — e.g. a restart killed the
5721
- // batch mid-way. "Proceed to post the already-approved items." postApproved is
5722
- // idempotent (it filters posted/terminal), so this only drains the genuine
5723
- // backlog and never double-posts. Best-effort; never throws.
5724
- async function drainApprovedBacklog() {
5725
- try {
5726
- const plan = readPlan(REVIEW_QUEUE_ID);
5727
- const cands = plan?.candidates || [];
5728
- const backlog = cands.filter((c) => c.approved === true &&
5729
- c.posted !== true &&
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));
5734
- if (!backlog.length)
5735
- return;
5736
- console.error(`[post] draining ${backlog.length} approved-but-unposted card(s) left from before`);
5737
- await postApproved(REVIEW_QUEUE_ID, plan);
5738
- }
5739
- catch (e) {
5740
- console.error("[post] drainApprovedBacklog error:", e?.message || e);
5741
- // Same reasoning as the other postApproved call site: don't let an
5742
- // escaped exception leave the cross-instance posting flag stuck true.
5743
- postingActive = false;
5744
- stopPostingFlagHeartbeat();
5745
- }
5746
- }
5871
+ // REMOVED (2026-07-17): drainApprovedBacklog. It ran 30s after EVERY MCP boot,
5872
+ // which was sane when boots meant "user launched Claude Desktop" — but each
5873
+ // queue-worker session boots its own MCP server, so the drain had silently
5874
+ // become a ~5-minute cron running across up to 4 concurrent MCP instances.
5875
+ // Combined with universal posting preemption, every drain wakeup SIGKILLed
5876
+ // whatever held the twitter-browser lock (profile scans included), and any
5877
+ // stamp bug turned into an infinite retry loop (438 retries over 5 days on
5878
+ // one Nhat card). Backlog recovery is now owned by ONE long-lived process:
5879
+ // the menubar's _resume_approved_queue, which runs on loopback-reachable and
5880
+ // periodically thereafter (mcp/menubar/s4l_menubar.py). Do NOT re-add a
5881
+ // boot-time drain here; if the menubar is dead, ensureMenubar() below revives
5882
+ // it and its resume covers the backlog.
5747
5883
  async function main() {
5748
5884
  initSentry();
5749
5885
  // Detect a self-update (old_version -> new_version) as the very first thing
@@ -5919,13 +6055,9 @@ async function main() {
5919
6055
  void startLocalPanel()
5920
6056
  .then((url) => console.error(`[social-autoposter-mcp] panel loopback ready at ${url}`))
5921
6057
  .catch((e) => console.error("[social-autoposter-mcp] panel loopback start failed:", e?.message || e));
5922
- // Resume posting any approved-but-unposted cards a prior run/restart left behind.
5923
- // Delayed so the runtime + harness Chrome have settled; never blocks boot.
5924
- {
5925
- const t = setTimeout(() => void drainApprovedBacklog(), 30_000);
5926
- if (typeof t.unref === "function")
5927
- t.unref();
5928
- }
6058
+ // NOTE (2026-07-17): the boot-time drainApprovedBacklog() call that lived
6059
+ // here is gone — backlog recovery is owned by the menubar's periodic
6060
+ // _resume_approved_queue (single drainer; see the removal note above).
5929
6061
  // Ensure the macOS menu bar mini-dashboard is installed + running. Idempotent
5930
6062
  // and cheap when already present, so existing installs pick it up on the next
5931
6063
  // Claude restart without re-provisioning. Best-effort: never blocks boot.
@@ -301,7 +301,8 @@ function collectStateSnapshot() {
301
301
  ["install_progress", "install-progress.json", 64_000],
302
302
  ["onboarding_progress", "onboarding-progress.json", 256_000],
303
303
  ["review_queue", "review-queue.json", 256_000],
304
- ["approved_queue", "approved-queue.json", 256_000],
304
+ // approved-queue.json removed 2026-07-17: the ledger is gone; the review
305
+ // store is the only local decision record.
305
306
  ];
306
307
  for (const [key, file, cap] of stateFiles) {
307
308
  const val = readJsonCapped(path.join(stateDir, file), cap);
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.6-rc.5",
3
- "installedAt": "2026-07-17T17:09:56.264Z"
2
+ "version": "1.7.6-rc.7",
3
+ "installedAt": "2026-07-17T17:41:44.516Z"
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.5",
5
+ "version": "1.7.6-rc.7",
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": {
@@ -579,6 +579,12 @@ class S4LMenuBar(rumps.App):
579
579
  # a hitch every 5s tick.
580
580
  self._resumed = False
581
581
  self._loopback_check_due_at = 0.0
582
+ # Periodic approved-backlog drain cadence (2026-07-17): the menubar is
583
+ # the single drainer now that the MCP boot-time drain is gone. 10 min
584
+ # keeps a transiently-failed approval's retry latency reasonable while
585
+ # staying far below the old effective 5-min/multi-instance churn.
586
+ self.RESUME_PERIOD_S = 600.0
587
+ self._resume_due_at = 0.0
582
588
  # Reliable self-check of our own Accessibility (TCC) grant — this is the
583
589
  # faithful reading (our launchd process identity, not a parent's). Logged
584
590
  # so menubar.err.log records whether keystroke posting will work.
@@ -2301,16 +2307,14 @@ class S4LMenuBar(rumps.App):
2301
2307
  never confirmed posted (the in-memory _post_q died with the old process).
2302
2308
  Skip any the plan already shows as posted, so a card that landed on X just
2303
2309
  before the kill — but whose status update was lost — isn't posted twice."""
2304
- # Store lane (canonical): approved-but-unposted rows in the review store.
2305
- # Legacy lane: pre-store approved-queue.json entries (read-only now; the
2306
- # ledger is no longer written). Union, store first, dedup by (batch, n).
2310
+ # Store lane ONLY (2026-07-17): approved-but-unposted rows in the review
2311
+ # store, the single source of truth. The legacy approved-queue.json lane
2312
+ # (a read-only third truth source that the ledger stopped writing long
2313
+ # ago) was removed — its entries could never settle, so it re-enqueued
2314
+ # the same dead approvals on every resume forever. If a genuinely
2315
+ # pre-store approval ever resurfaces, it is visible in the store lane
2316
+ # or it is gone; do not re-add the union.
2307
2317
  pending = list(st.store_pending_posts())
2308
- seen = {(it.get("batch"), it.get("n")) for it in pending}
2309
- legacy = [
2310
- it for it in st.approved_queue_pending()
2311
- if (it.get("batch"), it.get("n")) not in seen
2312
- ]
2313
- pending.extend(legacy)
2314
2318
  if not pending:
2315
2319
  return
2316
2320
  posted_ns = set()
@@ -2327,7 +2331,6 @@ class S4LMenuBar(rumps.App):
2327
2331
  for it in pending:
2328
2332
  batch, n = it.get("batch"), it.get("n")
2329
2333
  if n in posted_ns:
2330
- st.approved_queue_set_status(batch, n, "posted") # reconcile lost update
2331
2334
  continue
2332
2335
  decision = {
2333
2336
  "n": n,
@@ -2462,8 +2465,19 @@ class S4LMenuBar(rumps.App):
2462
2465
  if now >= self._loopback_check_due_at:
2463
2466
  self._loopback_check_due_at = now + 60.0
2464
2467
  if st.loopback_reachable():
2465
- if not self._resumed:
2468
+ # Periodic backlog drain (2026-07-17): the menubar is now the
2469
+ # SINGLE owner of approved-backlog recovery. The MCP server's
2470
+ # boot-time drainApprovedBacklog was removed (each queue-worker
2471
+ # session boots an MCP, so "boot-time" had become a 5-minute
2472
+ # cron across concurrent instances). Here it runs on the one
2473
+ # long-lived process instead: on loopback recovery and every
2474
+ # RESUME_PERIOD_S thereafter, but only while nothing is
2475
+ # in-flight (avoids enqueueing a card twice while an earlier
2476
+ # drain still holds it in _post_q). _resume_approved_queue is
2477
+ # idempotent against the store (skips posted/terminal rows).
2478
+ if (not self._resumed or now >= self._resume_due_at) and self._posts_outstanding == 0:
2466
2479
  self._resumed = True
2480
+ self._resume_due_at = now + self.RESUME_PERIOD_S
2467
2481
  try:
2468
2482
  self._resume_approved_queue()
2469
2483
  except Exception as e:
@@ -3566,7 +3580,6 @@ class S4LMenuBar(rumps.App):
3566
3580
  # No "Posting draft N…" banner: the menu-bar spinner already shows
3567
3581
  # live posting progress, so a Notification Center toast per approved
3568
3582
  # card is pure noise. Only failures (below) raise a notification.
3569
- st.approved_queue_set_status(batch, n, "posting")
3570
3583
  with self._review_lock:
3571
3584
  activity_label = self._posting_activity_label_locked()
3572
3585
  cl = [n] if decision.get("drop_link") else None
@@ -3598,32 +3611,25 @@ class S4LMenuBar(rumps.App):
3598
3611
  # one registered). Unlike a real posting failure, nothing
3599
3612
  # was ever attempted here — post_drafts never reached a
3600
3613
  # server — so there's no double-post risk in retrying.
3601
- # Deliberately do NOT call store_mark_post_failed / set
3602
- # status "failed": both would exclude this card from
3603
- # store_pending_posts()/approved_queue_pending(), which is
3614
+ # Deliberately do NOT call store_mark_post_failed: it would
3615
+ # exclude this card from store_pending_posts(), which is
3604
3616
  # exactly what _resume_approved_queue() reads on the next
3605
- # unreachable->reachable edge. Leaving it "queued" lets
3606
- # that existing restart-recovery path retry it
3607
- # automatically once a server re-registers, instead of
3608
- # stranding it for manual dashboard retry.
3609
- st.approved_queue_set_status(batch, n, "queued", error="loopback_unreachable")
3617
+ # unreachable->reachable edge. Leaving the store row
3618
+ # approved-unposted lets that existing restart-recovery
3619
+ # path retry it automatically once a server re-registers,
3620
+ # instead of stranding it for manual dashboard retry.
3610
3621
  self._notify(
3611
3622
  "S4L", "Server unreachable — will retry automatically once Claude Desktop reconnects."
3612
3623
  )
3613
3624
  else:
3614
3625
  posted = res.get("posted") if isinstance(res, dict) else None
3615
3626
  if posted == 0:
3616
- st.approved_queue_set_status(batch, n, "failed", error="posted_0")
3617
3627
  st.store_mark_post_failed(batch, n, decision.get("candidate_id"), "posted_0")
3618
3628
  self._notify("S4L", f"Draft {n} didn't post — see the dashboard for why.")
3619
- else:
3620
- # Success is silent: the spinner + dashboard already reflect
3621
- # it. No per-card "Posted draft N." banner. The server
3622
- # stamps posted/our_url into the store itself; the legacy
3623
- # set_status only settles pre-store ledger entries.
3624
- st.approved_queue_set_status(batch, n, "posted")
3629
+ # Success is silent: the spinner + dashboard already reflect
3630
+ # it. No per-card "Posted draft N." banner. The server
3631
+ # stamps posted/our_url into the store itself.
3625
3632
  except Exception as e:
3626
- st.approved_queue_set_status(batch, n, "failed", error=str(e)[:200])
3627
3633
  st.store_mark_post_failed(batch, n, decision.get("candidate_id"), str(e)[:200])
3628
3634
  sys.stderr.write(f"[s4l-menubar] post draft {n} failed: {e}\n")
3629
3635
  sys.stderr.flush()
@@ -43,10 +43,6 @@ try:
43
43
  except Exception:
44
44
  pass
45
45
 
46
- # Serializes read-modify-write on approved-queue.json. The menu bar's main thread
47
- # (approve click / restart resume) and the post-worker thread (status updates)
48
- # both mutate it; without this a concurrent interleave would drop an approval.
49
- _approved_lock = threading.Lock()
50
46
 
51
47
  # Mirrors shared/onboarding-ledger.cjs MILESTONES (same order). The ledger's
52
48
  # OPTIONAL milestones (reddit_connected / reddit_verified) are deliberately NOT
@@ -982,89 +978,23 @@ def review_queue_posted_count():
982
978
  return sum(1 for c in cands if candidate_state(c) == "posted")
983
979
 
984
980
 
985
- def _plan_generation(batch):
986
- """created_at of the CURRENT review plan for this batch (stamped by
987
- merge_review_queue.py when it starts a fresh plan), or None for plans
988
- written before generation stamping existed.
989
-
990
- Why this matters: the plan lives in /tmp and dies on every reboot or tmp
991
- sweep, and its candidate numbering restarts at 1, while approved-queue.json
992
- lives in the state dir forever. Without a generation marker, ledger entries
993
- from a dead plan match the new plan's low indices and every fresh draft is
994
- treated as already-decided: the 2026-07-03 "unapproved cards never show up"
995
- bug (30 stale entries silently swallowed a whole day of drafts)."""
996
- try:
997
- p = Path(store_path())
998
- if not p.exists():
999
- base = os.environ.get("S4L_TMP_DIR") or "/tmp"
1000
- p = Path(base) / f"twitter_cycle_plan_{batch}.json"
1001
- return json.loads(p.read_text()).get("created_at") or None
1002
- except Exception:
1003
- return None
1004
-
1005
-
1006
- def _ts_before(a, b):
1007
- """True when iso timestamp a is strictly before b. Tolerates the two stamp
1008
- shapes in play ('...Z' from merge_review_queue, '+00:00' from time_iso)."""
1009
- try:
1010
- import datetime
1011
-
1012
- def parse(s):
1013
- return datetime.datetime.fromisoformat(str(s).replace("Z", "+00:00"))
1014
-
1015
- return parse(a) < parse(b)
1016
- except Exception:
1017
- return False
1018
-
1019
-
1020
- def _stale_for_plan(it, gen):
1021
- """A ledger item that predates the current plan generation refers to a DEAD
1022
- plan's numbering; its `n` must never match against the live plan."""
1023
- return bool(gen) and bool(it.get("ts")) and _ts_before(it.get("ts"), gen)
1024
-
1025
-
1026
- def _ledger_items_for_plan(batch, gen):
1027
- """Decided ledger items that belong to the CURRENT plan generation."""
1028
- return [
1029
- it
1030
- for it in read_approved_queue()["items"]
1031
- if it.get("batch") == batch
1032
- and it.get("status") in ("queued", "posting", "posted", "failed", "rejected")
1033
- and not _stale_for_plan(it, gen)
1034
- ]
1035
-
1036
-
1037
981
  def review_drafts(plan, batch="review-queue"):
1038
982
  """Flatten a plan into the card model: only UNDECIDED candidates. A card that's
1039
983
  posted, terminal (rejected/dead), or already approved is a settled decision and
1040
984
  must never be re-presented for review (approved ones proceed to post).
1041
985
 
1042
- Also excludes cards with ANY durable decision (approved, edited, rejected, or a
1043
- decided-but-failed post) via _ledger_items_for_plan() below. approve/reject/edit
1044
- each write a durable local record via store_stamp_decision() the INSTANT the
1045
- user clicks (see s4l_menubar.py::_on_card_decision), so a
1046
- decided card never re-presents even if the loopback (Claude Desktop) is down
1047
- when the decision's plan-flag write is attempted. The main plan's
1048
- `approved`/`terminal`/`posted` flags are only stamped once the loopback write
1049
- lands, so without this a card the user just decided would re-present (the exact
1050
- "I already decided these" bug)."""
1051
- gen = (plan or {}).get("created_at") or _plan_generation(batch)
1052
- items = _ledger_items_for_plan(batch, gen)
1053
- settled_ids = {
1054
- it.get("candidate_id") for it in items if it.get("candidate_id") is not None
1055
- }
1056
- settled_ns = {it.get("n") for it in items if it.get("candidate_id") is None}
986
+ Decision durability lives in the store itself: approve/reject/edit each
987
+ write approved/terminal + the decision payload via store_stamp_decision()
988
+ the INSTANT the user clicks (see s4l_menubar.py::_on_card_decision), under
989
+ the store lock, so candidate_state() alone decides what re-presents. The
990
+ legacy approved-queue.json ledger cross-check that used to live here was
991
+ removed 2026-07-17 along with the rest of that third state store."""
1057
992
  out = []
1058
993
  for i, c in enumerate(((plan or {}).get("candidates") or [])):
1059
994
  # Only awaiting_review rows become cards. This also skips post_failed
1060
995
  # rows (decided-but-failed is settled per the docstring above).
1061
996
  if candidate_state(c) != "awaiting_review":
1062
997
  continue
1063
- cid = c.get("candidate_id")
1064
- if cid is not None and cid in settled_ids:
1065
- continue
1066
- if (i + 1) in settled_ns:
1067
- continue
1068
998
  out.append(
1069
999
  {
1070
1000
  "n": i + 1, # 1-based, matches post_drafts numbering
@@ -1132,35 +1062,13 @@ def review_drafts(plan, batch="review-queue"):
1132
1062
  return out
1133
1063
 
1134
1064
 
1135
- # ---- durable approved-card queue ------------------------------------------
1136
- # Card approvals MUST survive a menu bar / Claude restart. The in-memory post
1137
- # queue does not: a restart strands every approved-but-unposted card, which then
1138
- # re-presents for approval (the system had no record the user already approved
1139
- # it). This file is the durable record, owned SOLELY by the menu bar — persisting
1140
- # the approval in the main plan instead would race with the autopilot, which
1141
- # rewrites that plan continuously and would silently drop the flag. Status flow:
1142
- # queued -> posting -> posted | failed. review_drafts() excludes queued/posting
1143
- # so an approved card is never re-shown while it drains; a restart re-enqueues
1144
- # queued/posting items instead of re-presenting them.
1145
- APPROVED_QUEUE = "approved-queue.json"
1146
-
1147
-
1148
- def read_approved_queue():
1149
- d = read_json(APPROVED_QUEUE)
1150
- if not isinstance(d, dict) or not isinstance(d.get("items"), list):
1151
- return {"items": []}
1152
- return d
1153
-
1154
-
1155
- def _write_approved_queue(d):
1156
- try:
1157
- p = Path(state_dir()) / APPROVED_QUEUE
1158
- p.parent.mkdir(parents=True, exist_ok=True)
1159
- tmp = p.with_suffix(".json.tmp")
1160
- tmp.write_text(json.dumps(d))
1161
- os.replace(str(tmp), str(p)) # atomic: a crash never leaves a half file
1162
- except Exception:
1163
- pass
1065
+ # ---- durable approved-card queue: REMOVED (2026-07-17) ---------------------
1066
+ # approved-queue.json was a second local ledger from before the review store
1067
+ # stamped decisions durably itself (store_stamp_decision, under the store
1068
+ # lock). Once decisions lived in the store, nothing appended to the ledger,
1069
+ # every status write was a no-op over a frozen item list, and its read lane
1070
+ # re-enqueued dead approvals forever. The review store is the ONLY local
1071
+ # decision record now; do not add a second one back.
1164
1072
 
1165
1073
 
1166
1074
  # ---- Engagement mode (2026-06-26, dual-flag 2026-06-29) -------------------
@@ -1422,23 +1330,10 @@ def toggle_lane(lane):
1422
1330
  return write_flags(f["personal_brand"], f["promotion"])
1423
1331
 
1424
1332
 
1425
- def approved_queue_set_status(batch, n, status, error=None):
1426
- with _approved_lock:
1427
- d = read_approved_queue()
1428
- changed = False
1429
- for it in d["items"]:
1430
- if it.get("batch") == batch and it.get("n") == n:
1431
- it.update(status=status, error=error, ts=time_iso())
1432
- changed = True
1433
- if changed:
1434
- _write_approved_queue(d)
1435
-
1436
-
1437
- def approved_queue_pending():
1438
- """Approvals not yet confirmed posted (queued or posting). Re-enqueued by the
1439
- menu bar on startup so a restart RESUMES the drain instead of re-presenting."""
1440
- return [it for it in read_approved_queue()["items"]
1441
- if it.get("status") in ("queued", "posting")]
1333
+ # approved_queue_set_status() / approved_queue_pending() REMOVED (2026-07-17):
1334
+ # the legacy approved-queue.json ledger is gone entirely — see the removal note
1335
+ # above read-approved-queue's old location. The store lane (store_stamp_decision
1336
+ # + store_pending_posts) is the only decision record and resume source now.
1442
1337
 
1443
1338
 
1444
1339
  def post_drafts(batch_id, post=None, edits=None, reject=None, clear_link=None, timeout=900, activity_label=None):
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.6-rc.5",
3
+ "version": "1.7.6-rc.7",
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.5",
3
+ "version": "1.7.6-rc.7",
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",
@@ -383,137 +383,156 @@ def main() -> int:
383
383
  # cycle process and only that process knows them.
384
384
 
385
385
  dst = store_path()
386
- existing = []
387
- plan_created_at = None
388
- if os.path.exists(dst):
389
- try:
390
- with open(dst) as f:
391
- prev = json.load(f)
392
- existing = prev.get("candidates") or []
393
- plan_created_at = prev.get("created_at")
394
- except Exception:
395
- existing = []
396
- plan_created_at = None
397
- # Absorb a REAL file at the legacy /tmp location (pre-upgrade store, or one
398
- # written by old code after a reboot) so no pending draft or decision flag
399
- # is lost, then ensure_store_symlink() below replaces it with the link.
400
- legacy = plan_path(REVIEW_QUEUE_ID)
401
- if os.path.exists(legacy) and not os.path.islink(legacy):
386
+ # ---- store lock ---------------------------------------------------
387
+ # Hold the SAME fcntl.flock the menubar's _store_update and the MCP's
388
+ # store_patch.py take, for the whole read->merge->write span. Without
389
+ # it this function was a last-writer-wins whole-file rewrite: a card
390
+ # decision stamped between our read (below) and _atomic_write was
391
+ # silently erased (the 2026-07-17 truth-loss family, including the
392
+ # resurrected sandbox approval that posted for real). The lock covers
393
+ # one bulk API call (_sync_with_backend); worst case a menubar decision
394
+ # write blocks for a few seconds once per cycle, which beats losing it.
395
+ import fcntl
396
+ _lk = open(dst + ".lock", "w")
397
+ fcntl.flock(_lk, fcntl.LOCK_EX)
398
+ try:
399
+ existing = []
400
+ plan_created_at = None
401
+ if os.path.exists(dst):
402
+ try:
403
+ with open(dst) as f:
404
+ prev = json.load(f)
405
+ existing = prev.get("candidates") or []
406
+ plan_created_at = prev.get("created_at")
407
+ except Exception:
408
+ existing = []
409
+ plan_created_at = None
410
+ # Absorb a REAL file at the legacy /tmp location (pre-upgrade store, or one
411
+ # written by old code after a reboot) so no pending draft or decision flag
412
+ # is lost, then ensure_store_symlink() below replaces it with the link.
413
+ legacy = plan_path(REVIEW_QUEUE_ID)
414
+ if os.path.exists(legacy) and not os.path.islink(legacy):
415
+ try:
416
+ with open(legacy) as f:
417
+ lp = json.load(f)
418
+ lc = lp.get("candidates") or []
419
+ have = {_dedup_key(c) for c in existing}
420
+ absorbed = [c for c in lc if _dedup_key(c) not in have]
421
+ existing.extend(absorbed)
422
+ plan_created_at = plan_created_at or lp.get("created_at")
423
+ if absorbed:
424
+ print(
425
+ f"[merge_review_queue] absorbed {len(absorbed)} candidate(s) "
426
+ "from legacy /tmp plan into the durable store",
427
+ file=sys.stderr,
428
+ )
429
+ except Exception as e:
430
+ print(f"[merge_review_queue] legacy plan absorb failed: {e}", file=sys.stderr)
431
+ # Generation stamp: set ONLY when starting a fresh plan (the /tmp plan dies
432
+ # on reboot/tmp-sweep and numbering restarts at 1). The menu bar's durable
433
+ # approved-queue ledger uses this to ignore decisions that belong to a dead
434
+ # plan generation; without it, stale (batch, n) entries silently swallow
435
+ # every new draft after a reset. An existing unstamped plan is left
436
+ # unstamped: back-stamping it "now" would invalidate live decisions.
437
+ if not existing and not plan_created_at:
438
+ plan_created_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
439
+
440
+ seen = {_dedup_key(c) for c in existing}
441
+ added = 0
442
+ merged = list(existing)
443
+ for c in new_cands:
444
+ k = _dedup_key(c)
445
+ if k in seen:
446
+ continue
447
+ seen.add(k)
448
+ merged.append(c)
449
+ added += 1
450
+
451
+ stamped, pruned = _sync_with_backend(merged)
452
+ if stamped:
453
+ print(f"[merge_review_queue] stamped stats on {stamped} candidate(s)", file=sys.stderr)
454
+ if pruned:
455
+ print(
456
+ f"[merge_review_queue] pruned {pruned} candidate(s) already retired by the "
457
+ "backend (expired/etc.) before they were reviewed",
458
+ file=sys.stderr,
459
+ )
460
+
461
+ plan_obj = {"candidates": merged}
462
+ if plan_created_at:
463
+ plan_obj["created_at"] = plan_created_at
464
+
465
+ # This is the actual delivery: if anything below throws, the cycle's drafts
466
+ # were computed but never reached the store the menu bar reads, and the
467
+ # wrapper (run-draft-and-publish.sh) captures this process's whole stdout+
468
+ # stderr with `|| true`, so a crash here previously vanished into a local
469
+ # log nothing central reads — the exact blind spot that cost the 2026-07-08
470
+ # Karol investigation its root cause. Report it like any other handled
471
+ # pipeline failure (see twitter_post_plan.py's post-failure capture).
402
472
  try:
403
- with open(legacy) as f:
404
- lp = json.load(f)
405
- lc = lp.get("candidates") or []
406
- have = {_dedup_key(c) for c in existing}
407
- absorbed = [c for c in lc if _dedup_key(c) not in have]
408
- existing.extend(absorbed)
409
- plan_created_at = plan_created_at or lp.get("created_at")
410
- if absorbed:
411
- print(
412
- f"[merge_review_queue] absorbed {len(absorbed)} candidate(s) "
413
- "from legacy /tmp plan into the durable store",
414
- file=sys.stderr,
415
- )
473
+ _atomic_write(dst, plan_obj)
474
+ ensure_store_symlink()
475
+
476
+ # Refresh the review-request marker the menu bar polls. count = cards
477
+ # actually awaiting review (mirrors s4l_state.candidate_state()'s
478
+ # awaiting_review bucket): approved-unposted and post_failed rows are
479
+ # settled decisions, counting them inflated the badge and misled every
480
+ # human/agent reading the marker.
481
+ pending_count = len(
482
+ [
483
+ c
484
+ for c in merged
485
+ if not c.get("posted")
486
+ and not c.get("terminal")
487
+ and not c.get("post_failed")
488
+ and not c.get("approved")
489
+ ]
490
+ )
491
+ project = ns.project or batch.get("project") or (new_cands[0].get("matched_project") if new_cands else None)
492
+ _atomic_write(
493
+ review_request_path(),
494
+ {
495
+ "batch_id": REVIEW_QUEUE_ID,
496
+ "project": project,
497
+ "count": pending_count,
498
+ "plan_path": dst,
499
+ "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
500
+ },
501
+ )
416
502
  except Exception as e:
417
- print(f"[merge_review_queue] legacy plan absorb failed: {e}", file=sys.stderr)
418
- # Generation stamp: set ONLY when starting a fresh plan (the /tmp plan dies
419
- # on reboot/tmp-sweep and numbering restarts at 1). The menu bar's durable
420
- # approved-queue ledger uses this to ignore decisions that belong to a dead
421
- # plan generation; without it, stale (batch, n) entries silently swallow
422
- # every new draft after a reset. An existing unstamped plan is left
423
- # unstamped: back-stamping it "now" would invalidate live decisions.
424
- if not existing and not plan_created_at:
425
- plan_created_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
426
-
427
- seen = {_dedup_key(c) for c in existing}
428
- added = 0
429
- merged = list(existing)
430
- for c in new_cands:
431
- k = _dedup_key(c)
432
- if k in seen:
433
- continue
434
- seen.add(k)
435
- merged.append(c)
436
- added += 1
437
-
438
- stamped, pruned = _sync_with_backend(merged)
439
- if stamped:
440
- print(f"[merge_review_queue] stamped stats on {stamped} candidate(s)", file=sys.stderr)
441
- if pruned:
503
+ print(f"[merge_review_queue] delivery failed (drafts NOT merged into cards): {e}", file=sys.stderr)
504
+ try:
505
+ import sentry_init
506
+
507
+ sentry_init.init()
508
+ sentry_init.capture_message(
509
+ f"merge_review_queue delivery failed: {e}",
510
+ level="error",
511
+ tags={"component": "merge_review_queue", "added": str(added)},
512
+ extra={"plan_src": src},
513
+ )
514
+ sentry_init.flush(2.0)
515
+ except Exception:
516
+ pass
517
+ return 1
518
+
442
519
  print(
443
- f"[merge_review_queue] pruned {pruned} candidate(s) already retired by the "
444
- "backend (expired/etc.) before they were reviewed",
520
+ f"[merge_review_queue] merged {added} new draft(s) into {REVIEW_QUEUE_ID} "
521
+ f"({pending_count} pending total) from {os.path.basename(src)}",
445
522
  file=sys.stderr,
446
523
  )
447
-
448
- plan_obj = {"candidates": merged}
449
- if plan_created_at:
450
- plan_obj["created_at"] = plan_created_at
451
-
452
- # This is the actual delivery: if anything below throws, the cycle's drafts
453
- # were computed but never reached the store the menu bar reads, and the
454
- # wrapper (run-draft-and-publish.sh) captures this process's whole stdout+
455
- # stderr with `|| true`, so a crash here previously vanished into a local
456
- # log nothing central reads — the exact blind spot that cost the 2026-07-08
457
- # Karol investigation its root cause. Report it like any other handled
458
- # pipeline failure (see twitter_post_plan.py's post-failure capture).
459
- try:
460
- _atomic_write(dst, plan_obj)
461
- ensure_store_symlink()
462
-
463
- # Refresh the review-request marker the menu bar polls. count = cards
464
- # actually awaiting review (mirrors s4l_state.candidate_state()'s
465
- # awaiting_review bucket): approved-unposted and post_failed rows are
466
- # settled decisions, counting them inflated the badge and misled every
467
- # human/agent reading the marker.
468
- pending_count = len(
469
- [
470
- c
471
- for c in merged
472
- if not c.get("posted")
473
- and not c.get("terminal")
474
- and not c.get("post_failed")
475
- and not c.get("approved")
476
- ]
477
- )
478
- project = ns.project or batch.get("project") or (new_cands[0].get("matched_project") if new_cands else None)
479
- _atomic_write(
480
- review_request_path(),
481
- {
482
- "batch_id": REVIEW_QUEUE_ID,
483
- "project": project,
484
- "count": pending_count,
485
- "plan_path": dst,
486
- "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
487
- },
488
- )
489
- except Exception as e:
490
- print(f"[merge_review_queue] delivery failed (drafts NOT merged into cards): {e}", file=sys.stderr)
524
+ # Clean up the consumed batch plan so /tmp doesn't fill with orphans.
491
525
  try:
492
- import sentry_init
493
-
494
- sentry_init.init()
495
- sentry_init.capture_message(
496
- f"merge_review_queue delivery failed: {e}",
497
- level="error",
498
- tags={"component": "merge_review_queue", "added": str(added)},
499
- extra={"plan_src": src},
500
- )
501
- sentry_init.flush(2.0)
526
+ os.remove(src)
502
527
  except Exception:
503
528
  pass
504
- return 1
505
-
506
- print(
507
- f"[merge_review_queue] merged {added} new draft(s) into {REVIEW_QUEUE_ID} "
508
- f"({pending_count} pending total) from {os.path.basename(src)}",
509
- file=sys.stderr,
510
- )
511
- # Clean up the consumed batch plan so /tmp doesn't fill with orphans.
512
- try:
513
- os.remove(src)
514
- except Exception:
515
- pass
516
- return 0
529
+ return 0
530
+ finally:
531
+ try:
532
+ fcntl.flock(_lk, fcntl.LOCK_UN)
533
+ except Exception:
534
+ pass
535
+ _lk.close()
517
536
 
518
537
 
519
538
  if __name__ == "__main__":
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env python3
2
+ """Locked field-patcher for the review-queue store.
3
+
4
+ The review store (review-queue.json) has three writers: the menubar
5
+ (s4l_state._store_update), merge_review_queue.py, and the MCP server. The
6
+ first two hold an fcntl.flock on `<store>.lock` around their read-modify-write;
7
+ the MCP server is Node, which has no native flock, so it routes its store
8
+ writes through THIS script instead of writing the file itself. That closes the
9
+ last unlocked writer (the last-writer-wins race that erased posted stamps on
10
+ 2026-07-17 — six live replies rendered as duplicate_thread_pre_post kills).
11
+
12
+ Protocol-compatible with s4l_state._store_update: same lock file, LOCK_EX for
13
+ the whole read-mutate-replace span, atomic os.replace. fcntl (not an O_EXCL
14
+ lockfile) on purpose: the kernel drops the lock when the holder dies, so there
15
+ is no stale-lock stealing logic to get wrong.
16
+
17
+ Usage: store_patch.py <patches.json> (or '-' to read the JSON from stdin)
18
+
19
+ Input shape:
20
+ {"patches": [
21
+ {"candidate_id": 123, # match ALL rows with this id (ids are NOT
22
+ # unique: sandbox reruns/variant drafts
23
+ # append sibling rows; a stamp that hits
24
+ # only one sibling leaves a drainable twin)
25
+ "n": 4, # 1-based index fallback when candidate_id
26
+ # is absent/None (legacy rows)
27
+ "set": {"approved": true}, # fields to assign
28
+ "unset": ["discard_reason"]} # fields to delete
29
+ ]}
30
+
31
+ Merge rules (mirror mergeApprovedStampsIntoStore):
32
+ - posted is sticky: a patch may set posted=true, but `set.terminal=true` is
33
+ ignored on a row whose posted is already true, and posted is never unset.
34
+ Prints {"ok": true, "patched": N} on stdout. Exit 0 even when N=0 (a patch
35
+ matching nothing is not an error; the row may have been absorbed elsewhere).
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import fcntl
41
+ import json
42
+ import os
43
+ import sys
44
+
45
+
46
+ def store_path() -> str:
47
+ state_dir = os.environ.get("S4L_STATE_DIR") or os.path.join(
48
+ os.path.expanduser("~"), ".social-autoposter-mcp"
49
+ )
50
+ return os.path.join(state_dir, "review-queue.json")
51
+
52
+
53
+ def _match_rows(cands: list, patch: dict) -> list:
54
+ cid = patch.get("candidate_id")
55
+ if cid is not None:
56
+ rows = [c for c in cands if c.get("candidate_id") == cid]
57
+ if rows:
58
+ return rows
59
+ n = patch.get("n")
60
+ if isinstance(n, int) and 1 <= n <= len(cands):
61
+ return [cands[n - 1]]
62
+ return []
63
+
64
+
65
+ def apply_patches(data: dict, patches: list) -> int:
66
+ cands = data.get("candidates") or []
67
+ patched = 0
68
+ for p in patches:
69
+ for c in _match_rows(cands, p):
70
+ sets = dict(p.get("set") or {})
71
+ if c.get("posted") is True:
72
+ sets.pop("terminal", None)
73
+ sets.pop("terminal_reason", None)
74
+ for k, v in sets.items():
75
+ if k == "posted" and v is not True and c.get("posted") is True:
76
+ continue # posted is sticky
77
+ c[k] = v
78
+ for k in p.get("unset") or []:
79
+ if k == "posted":
80
+ continue
81
+ c.pop(k, None)
82
+ patched += 1
83
+ return patched
84
+
85
+
86
+ def main() -> int:
87
+ src = sys.argv[1] if len(sys.argv) > 1 else "-"
88
+ raw = sys.stdin.read() if src == "-" else open(src).read()
89
+ patches = (json.loads(raw) or {}).get("patches") or []
90
+ sp = store_path()
91
+ with open(sp + ".lock", "w") as lk:
92
+ fcntl.flock(lk, fcntl.LOCK_EX)
93
+ try:
94
+ try:
95
+ with open(sp) as f:
96
+ data = json.load(f)
97
+ except Exception:
98
+ data = {"candidates": []}
99
+ patched = apply_patches(data, patches)
100
+ tmp = f"{sp}.tmp.{os.getpid()}"
101
+ with open(tmp, "w") as f:
102
+ json.dump(data, f, indent=2)
103
+ os.replace(tmp, sp)
104
+ finally:
105
+ fcntl.flock(lk, fcntl.LOCK_UN)
106
+ print(json.dumps({"ok": True, "patched": patched}))
107
+ return 0
108
+
109
+
110
+ if __name__ == "__main__":
111
+ sys.exit(main())
@@ -33,6 +33,30 @@ log "=== archive-old-logs starting (DAYS=$DAYS) ==="
33
33
  find "$LOG_DIR" -maxdepth 1 -type f -mtime +"$DAYS" ! -name "$(basename "$RUN_LOG")" -print0 \
34
34
  | xargs -0 -I{} mv {} "$ARCHIVE_DIR/" 2>&1 | tee -a "$RUN_LOG" >/dev/null || true
35
35
 
36
+ # --- Size-based rotation for live launchd-*.log files (2026-07-17) -----------
37
+ # launchd holds these fds open and appends forever, so the mtime sweep above
38
+ # never touches them: their mtime is always fresh. Found in the wild at 1.9GB
39
+ # (launchd-linkedin-stdout.log) and 694MB (launchd-engage-twitter-stdout.log),
40
+ # which made "grep the logs" surface months-stale content as if it were
41
+ # current (the 2026-07-17 Neon-hostname false alarm).
42
+ # Copy-truncate: gzip the current content into logs-archive with a timestamp,
43
+ # then truncate IN PLACE (same inode) so the launchd fd keeps appending.
44
+ # Lines appended during the gzip window are lost on truncate; acceptable for
45
+ # logs, and the window is seconds. Threshold tunable via ROTATE_MAX_MB.
46
+ ROTATE_MAX_MB="${ROTATE_MAX_MB:-10}"
47
+ find "$LOG_DIR" -maxdepth 1 -type f -name "launchd-*.log" -size +"${ROTATE_MAX_MB}M" -print0 \
48
+ | while IFS= read -r -d '' f; do
49
+ base=$(basename "$f" .log)
50
+ dest="$ARCHIVE_DIR/${base}-$(date +%Y%m%d-%H%M%S).log.gz"
51
+ if gzip -c "$f" > "$dest" 2>>"$RUN_LOG"; then
52
+ : > "$f"
53
+ log "rotated $(basename "$f") -> $(basename "$dest") ($(du -h "$dest" | cut -f1) compressed)"
54
+ else
55
+ rm -f "$dest"
56
+ log "WARN: rotate failed for $f (left untouched)"
57
+ fi
58
+ done
59
+
36
60
  remaining=$(find "$LOG_DIR" -maxdepth 1 -type f | wc -l | tr -d ' ')
37
61
  archived=$(find "$ARCHIVE_DIR" -maxdepth 1 -type f | wc -l | tr -d ' ')
38
62