Repository navigation
feat(marketing): voice lint, [BRIAN:] markers, and missed-slot hold - #1422
Merged
Merged
Conversation
Ideas adapted from Hashbrown's marketing service, reimplemented here. - lint warns on machine-sounding words (leverage, seamless, delve...), the "not X but Y" cadence, and more than one emoji on social; curly apostrophes are normalized before matching. - A [BRIAN: ...] marker is a blocking brian_marker flag that confirm refuses, so a draft waiting on Brian's input can never be approved. - An X or LinkedIn row that reaches a tick more than 6 hours after its slot goes back to draft unsent with a missed_slot alert, so resuming after a pause or outage doesn't post a stale backlog. dev.to and blog rows still go out late. list shows held drafts. - /week skill, voice guide, and runbook document the new flags and the held-post recovery; the skill also forbids invented askers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
blove
added a commit
to liveloveapp/hashbrown
that referenced
this pull request
Nov 2, 2026
This brings over lessons from Threadplane's production rollout of the same queue design (cacheplane/threadplane#1422 went the other way). ## Fixes - **Double-post on a 2xx without an id (X, dev.to).** `postTweet` read `response.data.id` and `postDevTo` read `response.id`/`url` without checking them. An empty or partial 2xx body threw a plain `TypeError`, and the dispatcher retried it with `markRetry`, so a post that was already live could go out again. Both adapters now throw `OutcomeUnknownError`, which fails the row with `post_failed` and never retries. On an X thread, a first tweet with no id stops the thread there. - **Rotated X token lost on an image-upload timeout.** `prepareMedia` races the request against a 20s deadline. If the deadline fired during `auth.refresh()`, the adapter threw, the tick could finish, and the function could freeze before the rotated refresh token was saved, which forces `x-auth` again. The upload now waits for any in-flight refresh to settle before it fails. - **Approve accepted drafts that dispatch rejects.** `check`, `import`, `edit` and `approve` all used the same zod shapes, but the adapter's `validateDraft` length limits only ran at dispatch, so an over-length post would fail three times. `approve` now runs `assertApprovable`: the shape, then the adapter's own rules. A `length` voice issue can no longer be accepted, and the skill says so. ## Runbook - Vercel runs crons only on production, so the dry-run week runs against production and preview isn't exercised. - The preview growth store can lag on migrations. Check `growth_schema_migrations` before trusting it. - You can create a deploy hook with `vercel api`. - www rejects any alert kind or result key it doesn't know, with a 503 on every tick. Update `marketing-client.ts` in the same PR. - Which failures are retried: only ones that happened before anything was sent. ## Checked and already fine - An X thread that fails after part 1 is already `OutcomeUnknownError`. The queue's X draft schema also rejects `threadParts`, so queued threads can't happen today. - Creates never retry on 5xx. A 4xx is a definite rejection, and the `on401` refresh comes before the resend. - LinkedIn: image upload failures happen before the post is created, a missing `x-restli-id` is outcome-unknown, and a failed first comment is a warning. - A row still `publishing` after its lease expires is failed with "publish outcome unknown" and never re-posted. The lease (5 min) is longer than `maxDuration` (60s). - X persists the rotated pair before the new access token is used. No abort signal reaches `refresh()` apart from the timeout fixed above. - The four alert codes match between `state.ts` and www's `OPERATOR_ALERTS`, and tests pin both. ## Verification - `npx nx test marketing`: 336 passed. The 11 new specs fail against the old source. - `npx nx lint marketing`: passed. `tsc --noEmit` on `tools/marketing/tsconfig.json` and `tsconfig.spec.json`: clean. - Not run: integration specs (they need a database) and the workspace `tsc:typecheck` chain. The `core` typecheck fails locally because of an older `@b4run/sdk` in the shared `node_modules`, which this change doesn't touch. Nothing was posted and no production config was changed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
blove
enabled auto-merge (squash)
November 2, 2026 06:18
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Three pieces adapted from Hashbrown's marketing automation, reimplemented in Threadplane's queue (no shared code):
lintnow also warns on machine-sounding words (leverage,seamless,delve, "here's the thing"…), the "not X but Y" cadence, and more than one emoji on X/LinkedIn. Curly apostrophes are normalized first. All warnings, no new blockers.[BRIAN: ...]markers. The/weekdrafter writes these for facts only Brian has. Lint raises a blockingbrian_markerflag thatconfirmrefuses, so the row can't be approved until the marker is filled in withedit.publishAtgoes back todraftunsent, with amissed_slotalert.listshows it asHELD: missed its slot by N hours, and you send it withreschedule+approve --id. This covers resuming afterMARKETING_POSTING_ENABLEDwas off or lifecycle was down. dev.to and blog rows are evergreen and still go out late./weekskill (new flags, marker usage, held-post handling, a no-invented-askers rule),docs/gtm/voice.md, and the runbook's rollback section.Deliberately not ported: Hashbrown's required SHA-pinned images (Threadplane uploads media to Blob at
addtime, so the bytes are already fixed), and its 3x retry (Threadplane never retries afterpost(), which avoids double posts).Verification
npx nx run-many -t test,lint,build -p marketing-queue: 129 tests pass.npx nx test lifecyclepasses. It wraps the dispatcher.lintCLI flags a sample draft asbrian_marker(blocking),banned_phrase,cadenceandemoji. Yesterday's live X post still lints clean.🤖 Generated with Claude Code