@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 +171 -39
- package/mcp/dist/telemetry.js +2 -1
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_menubar.py +34 -28
- package/mcp/menubar/s4l_state.py +17 -122
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/merge_review_queue.py +142 -123
- package/scripts/store_patch.py +111 -0
- package/skill/archive-old-logs.sh +24 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
5721
|
-
//
|
|
5722
|
-
//
|
|
5723
|
-
//
|
|
5724
|
-
|
|
5725
|
-
|
|
5726
|
-
|
|
5727
|
-
|
|
5728
|
-
|
|
5729
|
-
|
|
5730
|
-
|
|
5731
|
-
|
|
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
|
-
//
|
|
5923
|
-
//
|
|
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.
|
package/mcp/dist/telemetry.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
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.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 (
|
|
2305
|
-
#
|
|
2306
|
-
#
|
|
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
|
-
|
|
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
|
|
3602
|
-
#
|
|
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
|
|
3606
|
-
# that existing restart-recovery
|
|
3607
|
-
# automatically once a server re-registers,
|
|
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
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
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()
|
package/mcp/menubar/s4l_state.py
CHANGED
|
@@ -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
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
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
|
-
#
|
|
1137
|
-
#
|
|
1138
|
-
#
|
|
1139
|
-
#
|
|
1140
|
-
#
|
|
1141
|
-
#
|
|
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
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
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.
|
|
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
|
@@ -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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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]
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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]
|
|
444
|
-
"
|
|
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
|
-
|
|
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
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
|