From 74cb31e90ec2c39a10b3875a6d9e252de3cc05c2 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 14 Oct 2026 07:07:02 +0000 Subject: [PATCH] fix(lx): count guest posts by author so a site cannot get stuck on guest slots A guest post is stored under the partner's site_id with the writer in author_site_id. isGuestPostSlot and the partner cooldown both filtered by site_id, so neither ever saw the guest posts a site wrote: - the cadence count never moved once it sat at 9 mod 10, so every slot became a guest slot for ever. pairux.com has published nothing to its own blog since 2026-08-19. - the 30-day cooldown found nothing, so pairux wrote the same "screen sharing software development" post for one partner 17 times. Count the site's own posts plus the guest posts it authored, and look up the cooldown by author_site_id. The test stub now records its filters so the column choice is pinned. Co-Authored-By: Claude Opus 5.5 --- lib/lx/autoGuestPost.ts | 14 ++++++- tests/contract/auto-guest-post.test.ts | 54 +++++++++++++++++++------- 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/lib/lx/autoGuestPost.ts b/lib/lx/autoGuestPost.ts index d8c1058c..68312a57 100644 --- a/lib/lx/autoGuestPost.ts +++ b/lib/lx/autoGuestPost.ts @@ -68,10 +68,19 @@ export async function isGuestPostSlot( supabase: SupabaseClient, authorSiteId: string, ): Promise { + // A guest post is stored under the PARTNER's site_id, with the author in + // author_site_id. Counting by site_id alone missed every guest post this site + // wrote, so once its own count sat one short of a multiple of ten the slot + // stayed a guest slot for ever: pairux.com published nothing to its own blog + // from 2026-08-19 while writing the same guest post daily. Count what this + // site authored — its own posts plus the guest posts it wrote elsewhere — + // and not the guest posts partners wrote onto it. const { count, error } = await supabase .from("lx_article") .select("id", { count: "exact", head: true }) - .eq("site_id", authorSiteId); + .or( + `and(site_id.eq.${authorSiteId},is_guest_post.eq.false),author_site_id.eq.${authorSiteId}`, + ); // A failed count must not silently turn every slot into a guest post, nor // silently turn none of them into one. Falling back to "no" keeps the @@ -114,10 +123,11 @@ export async function planGuestPost( // Who we have written for lately. One query for the whole cooldown rather // than one per candidate: the list is short and the cron is walking every // active site. + // By author_site_id, not site_id: the article lives under the partner. const { data: recent } = await supabase .from("lx_article") .select("target_site_id") - .eq("site_id", authorSiteId) + .eq("author_site_id", authorSiteId) .eq("is_guest_post", true) .gte("created_at", since); diff --git a/tests/contract/auto-guest-post.test.ts b/tests/contract/auto-guest-post.test.ts index 39c1cb7a..0a0cfac3 100644 --- a/tests/contract/auto-guest-post.test.ts +++ b/tests/contract/auto-guest-post.test.ts @@ -22,12 +22,15 @@ const matcher = vi.mocked(findGuestPostOpportunities); * getting one of those wrong is precisely the kind of mistake that returns * `undefined` and silently disables the feature rather than failing. */ -function stubSupabase(opts: { - articleCount?: number; - countError?: string; - recentGuestTargets?: string[]; - existingRequests?: Array<{ target_site_id: string; topic: string }>; -}) { +function stubSupabase( + opts: { + articleCount?: number; + countError?: string; + recentGuestTargets?: string[]; + existingRequests?: Array<{ target_site_id: string; topic: string }>; + }, + filters: string[] = [], +) { return { from(table: string) { if (table === "lx_article") { @@ -35,23 +38,27 @@ function stubSupabase(opts: { select(_cols: string, o?: { count?: string; head?: boolean }) { if (o?.head) { return { - eq: () => - Promise.resolve( + or: (f: string) => { + filters.push(`or:${f}`); + return Promise.resolve( opts.countError ? { count: null, error: { message: opts.countError } } : { count: opts.articleCount ?? 0, error: null }, - ), + ); + }, }; } return { - eq: () => ({ - eq: () => ({ - gte: () => - Promise.resolve({ + eq: (col: string, val: unknown) => ({ + eq: (col2: string, val2: unknown) => ({ + gte: () => { + filters.push(`eq:${col}=${String(val)}`, `eq:${col2}=${String(val2)}`); + return Promise.resolve({ data: (opts.recentGuestTargets ?? []).map((id) => ({ target_site_id: id, })), - }), + }); + }, }), }), }; @@ -107,6 +114,16 @@ describe("guest post cadence", () => { ); expect(slot).toBe(false); }); + + it("counts the guest posts this site wrote, which live under the partner", async () => { + // Counting by site_id alone never saw them, so a site one short of a + // multiple of ten was handed a guest slot on every tick, for ever. + const filters: string[] = []; + await isGuestPostSlot(stubSupabase({}, filters), "author"); + expect(filters).toEqual([ + "or:and(site_id.eq.author,is_guest_post.eq.false),author_site_id.eq.author", + ]); + }); }); describe("choosing a partner", () => { @@ -142,6 +159,15 @@ describe("choosing a partner", () => { expect(plan?.targetSiteId).toBe("fresh"); }); + it("looks up the cooldown by author, not by the site the post lives on", async () => { + // The same mistake as the cadence count: by site_id the cooldown found + // nothing, and one partner got the same post seventeen days running. + matcher.mockResolvedValue([opportunity("partner", ["topic a"])]); + const filters: string[] = []; + await planGuestPost(stubSupabase({}, filters), "author"); + expect(filters).toEqual(["eq:author_site_id=author", "eq:is_guest_post=true"]); + }); + it("does not commission a topic a human already requested", async () => { // The manual path and the cron share one ledger so they cannot // independently order the same article.