Publish Service
File:src/cofounder_agent/poindexter/services/publish_service.py
Tested by: src/cofounder_agent/tests/unit/services/test_publish_service.py
Last reviewed: 2026-04-30
What it does
publish_post_from_task() is the ONE place where a completed
pipeline_tasks row becomes a row in posts. It has three exits
(see reference_publish_post_from_task_three_exits in project
memory): the stage_only short-circuit (the default operator flow —
approve stages a post at status='approved' and returns early), the
_promote_or_skip_existing short-circuit (a later go-live call
promotes the staged row in place), and the immediate-publish tail
(reached only by a direct/immediate publish, rare since ~2026-06-24).
Each exit handles everything that should happen exactly once when a
post goes live: parse merged result+metadata, extract title from
content, slugify, resolve author + category + tags, insert (or
promote) the posts row, update the task status, and — on the
promote and tail exits — fan out a list of fire-and-forget side
effects.
Side effects (each gated by feature toggles or local-mode checks):
embed into pgvector, cross-post to Dev.to, ISR
revalidation on Vercel, static JSON export to R2, IndexNow + Google
sitemap pings, newsletter blast, operator notification. Podcast/video
generation, delivery to R2, and RSS feed rebuilds are NOT done here —
they’re owned by the Stage-2/3 render pipeline and
jobs/podcast_distribute.py / jobs/media_distribute.py
(task-keyed, Gate-2-approval-driven). A prior fire-and-forget R2-upload
hook on the immediate-publish tail (_upload_media_to_r2_bg, “phase
11e”) was retired 2026-09-25: it operated on a post-keyed file
convention nothing in the pipeline had produced since the task-keyed
delivery cutovers, and — being on the tail — was unreachable from the
default flow besides.
A cloud-DB push (sync_service.SyncService.push_post, “phase 8” beside
the embed) was retired 2026-09-28 (Glad-Labs/poindexter#1112). No hosted
copy has existed since the Railway target was decommissioned in April
2026, and on a stock stack CLOUD_DATABASE_URL is unset and falls back
to DATABASE_URL, so both of its pools opened on the one local database
and it upserted each post over itself. Readers get a published post
through the static JSON export to R2, not a second database. A future
hosted target belongs behind the declarative publishing_adapters
surface, not a hard-wired call from here.
The pacing scheduler (_calculate_scheduled_publish_time) is opt-in
via honor_pacing=True. Default is immediate publish because the
human reviewer is already the throttle.
Public API
await publish_post_from_task(db_service, task, task_id, *, publisher="operator", trigger_revalidation=True, queue_social=True, draft_mode=False, honor_pacing=False, background_tasks=None) -> PublishResult— the single canonical entry point.PublishResult(success, post_id, post_slug, published_url, post_title, revalidation_success, error)— return value withto_dict()for HTTP responses.
_calculate_scheduled_publish_time(db_service)— returnsNone(publish now) or a future UTCdatetime. Readsmax_posts_per_daypublish_spacing_hoursfromapp_settings.
Configuration
All fromapp_settings via site_config:
site_url(REQUIRED, no default —site_config.require()raises if missing) — used for IndexNow + sitemap pings + YouTube descriptions.indexnow_key(default"") — IndexNow ping API key. Empty key still sends the ping; settingindexnow_ping_url=""disables.indexnow_ping_url(defaulthttps://api.indexnow.org/indexnow) — set to""to skip IndexNow entirely.google_sitemap_ping_url(defaulthttps://www.google.com/ping) — set to""to skip Google sitemap ping.max_posts_per_day(default3, only whenhonor_pacing=True).publish_spacing_hours(default4, only whenhonor_pacing=True).
DEPLOYMENT_MODE—"worker"flips on the local-mode side effects (pgvector embed, Dev.to cross-post, newsletter);"coordinator"(the default) skips them all. Read directly from the environment rather than viasite_config, deliberately, so the check is consistent with howmain.pydecides which mode to start in. See_should_run_post_publish_hooks(). (ReadLOCAL_DATABASE_URLuntil 2026-05-08 — a stale signal no container actually set, which silently disabled every hook for 8 days.)
Dependencies
- Reads from:
pipeline_tasks(the task arg, plus the existing-slug guard)tagstable (resolved tag rows forpost_tagsjunction)services.category_resolver.select_category_for_topicservices.default_author.get_or_create_default_authorsite_config(from AppContainer or DI) for IndexNow + URL settingsutils.text_utils.extract_title_from_content(LLM# Titlelift)
- Writes to:
posts(the central INSERT)tags(upserts each new term —ON CONFLICT (slug) DO UPDATE)post_tags(viadb_service.create_post’stag_idshandling)pipeline_tasks(status →published, result JSON updated)webhook_eventsindirectly viaemit_webhook_event("post.published", ...)(the helper’s actual target — earlier docs miscalled itpipeline_events; that unrelated table was dropped 2026-05-04 in poindexter#366)audit_logindirectly via the[content_published]log line
- External APIs (all fire-and-forget, errors swallowed):
- Vercel ISR (
trigger_nextjs_revalidation) - IndexNow + Google sitemap ping
- Cloudflare R2 / S3 (
upload_to_r2, viastatic_export_servicefor the post’s JSON — NOT for podcast/video, which this file no longer touches; see “What it does” above) - Dev.to (
DevToCrossPostService) - Media distribution — podcast delivery via
services/jobs/podcast_distribute.py, video/YouTube/Postiz via thepublishing_adapterssurface (services/jobs/media_distribute.py) — neither is called from this file; they run on their own schedule against themedia_assets/media_approvalsrows this file’s upstream pipeline produced - Newsletter delivery (
send_post_newsletter) - Telegram/Discord via
notify_operator
- Vercel ISR (
Failure modes
- Missing content or topic — short-circuits with
PublishResult(success=False, error="Missing content or topic — cannot create post"). - Duplicate task already published — slug-suffix idempotency guard
finds an existing post with
slug LIKE '%' || task_id[:8]. ReturnsPublishResult(success=True, ...)pointing at the original. Does not insert a duplicate. Visible in logs asPost already exists for task ... — skipping duplicate. db_service.create_postraises — returnsPublishResult(success=False, error="Failed to create post: ..."). No fire-and-forget side effects fire.- Side-effect failure — every fire-and-forget block is wrapped in
try/except Exceptionand logged at debug or warning. Publish succeeds even if every side effect fails. This is intentional; losing a sitemap ping must not block a post going live. site_urlnot set —site_config.require("site_url")raisesRuntimeErrorBEFORE the search-engine pings would run. The post is already in the DB at that point, but the function will bubble the error and revalidation/notification step won’t complete. Setsite_urlinapp_settingsbefore publishing.- ISR revalidation failure — non-fatal;
revalidation_success=Falsecomes back on thePublishResultand is logged as a warning.
Common ops
- Force a duplicate publish — change the
task_id(the idempotency guard keys ontask_id[:8]in the slug suffix). - Disable social fan-out for one publish: pass
queue_social=False. - Publish as draft (skip live distribution side effects): pass
draft_mode=True. The post lands asstatus='draft',distributed_atstays NULL. - Schedule pacing for a backlog: pass
honor_pacing=Trueand tunemax_posts_per_day+publish_spacing_hours. Otherwise the human reviewer is the throttle. - Re-trigger ISR for a stuck cache:
await trigger_nextjs_revalidation(["/posts/<slug>"], ["post:<slug>"])via the FastAPI shell or a one-off script. - Audit recent publishes:
SELECT id, slug, published_at, status FROM posts ORDER BY published_at DESC LIMIT 20;
See also
docs/architecture/services/content_router_service.md— upstream pipeline that produced the task.docs/operations/disaster-recovery.md— cleanup steps when a publish goes wrong.feedback_no_bulk_publish(operator design note) — Matt’s rule that bulk publishes never bypass per-post approval.