🐛 fix(moderation): recover nodes stranded in pending#92
Merged
Conversation
Nodes could end up stuck `pending` with no auto-moderation, forcing a manual admin "Re-moderate" (seen in prod for issue #68 and case studies #65 / #78). Two independent causes, two fixes. 1. Trigger boundary has no safety net. `triggerModeration` fires the moderation Workflow best-effort and swallows failures; if the run is never created (transient error / missing binding) or dies before a verdict, the node sits `pending` forever with nothing to recover it. Add `review:reconcile-pending`, an hourly scheduled task that finds nodes pending > 30 min with no terminal verdict since their last edit and re-submits them to the durable Workflow. The decision-action + grace-window guards skip nodes that are deliberately pending (flagged uncertain / awaiting author info) or still in flight. 2. Case-study approval was coupled to enrichment. `curate` + `finalize` ran together under one Promise.all, so a curate/location failure threw out of the run *before* the approval committed, reverting the case study to `pending` (what stranded #65). Split it like the issue pipeline: `finalizeCaseStudy` commits the approval first, then a best-effort `enrichCaseStudy` pass (curate ∥ location, each guarded) runs after. Curation now lands as its own `curate` audit log. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🧹 Code quality
updated for |
The two functions introduced by the previous commit tripped `fallow audit`'s new-only CRAP gate. Refactor both under threshold (no suppressions): - applyCaseStudyCurate: extract the field-cleaning into `compact` / `cleanRows` null-strippers and the embed text into `curatedCaseStudyText`, leaving the function a flat patch-builder. `Metric`/`Source`/`LinkRow` are a required key plus optionals, so stripping nulls is equivalent to the old explicit per-field reconstruction. - review:reconcile-pending: fold the two stuck-node queries into one UNION helper (`findStuckPending`) so the task callback is a short fetch → fire → log. verdict: pass · 0 introduced complexity/duplication/dead-code findings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`vize fmt` (the CI format:check gate) reflowed the extracted `compact` helper signature. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
Why
Nodes were ending up stuck in
pendingwith no auto-moderation, forcing a manual admin Re-moderate. Confirmed in production: issue #68 (edited at 09:29, sat pending ~7h until a manual remod at 16:27) and case studies #65 / #78 (never auto-approved at creation). Two independent root causes.What
1. Safety net for failed/never-fired triggers —
review:reconcile-pendingtriggerModerationfires the moderation Workflow best-effort and swallows every failure (missing binding, transientcreate()error). Cloudflare Workflows give durability once a run exists — but if the run is never created or dies before recording a verdict, the node is strandedpendingwith nothing to recover it. There was no reconciliation job.New hourly scheduled task (
0 * * * *) that finds nodespendingfor > 30 min with no terminal moderation verdict since their last edit and re-submits them to the durable Workflow.updated_atmoves only on a real edit (the Workflow's own approve/curate/relocate writes don't touch it), so a node edited after a prior verdict correctly re-qualifies as stuck if its re-trigger failed.flag_uncertain/request_info/ approve / reject / …) keeps nodes that are deliberately pending out of the candidate set; the 30-min grace keeps in-flight reviews out.2. Case-study approval no longer coupled to enrichment
curate+finalizeran together under onePromise.all, so a curate/location failure threw out of the run before the approval committed, reverting the case study topending(what stranded #65). Split to mirror the issue pipeline:finalizeCaseStudycommits the approval first (status + embedding from original text + approve audit), takingprep.enrichCaseStudypass runs after (curate ∥ location, each.catch-guarded). Curation now lands as its owncurateaudit log, exactly like issues.Verification
tscclean on the moderation worker;vue-tscclean on the app.moderation-stepssnapshot tests pass (prompt YAML untouched — no drift).Notes
triggerModerationintentionally stays best-effort on the hot write path (a moderation hiccup never fails a user's create/edit); the hourly reconciler is the backstop.instance.idper node, drive the reconciler offMODERATION_WORKFLOW.get(id).status()/restart()) is possible but needs a schema migration and is deferred — the heuristic reconciler recovers every case observed here.🤖 Generated with Claude Code