Repository navigation
feat(threads): thread bundles — root + chained replies, instant or scheduled - #252
Merged
Merged
Conversation
…nstant or scheduled
POST /api/threads accepts a bundle of 1-20 items. Item 0 is the root
post; later items reply to an earlier item by index (reply_to: 0) or to
any existing Threads post id (reply_to: id-string). Without scheduled_at
everything publishes immediately (root on the next tick, replies chain
after it); with scheduled_at the bundle occupies one calendar slot.
Storage reuses the schedules table with three new columns (bundle_id,
bundle_seq, bundle_total — migration 017) so HITL approval, bookkeeping,
calendar listing, and rate tracking all keep working unchanged.
Scheduler: a pre-pass promotes due bundle roots (bundle_waiting ->
pending); on each successful publish the next member is promoted, and a
failed chain item fails the remaining members so nothing strands.
Bundle reply markers (bundle:<id>:<seq>) resolve to the referenced
member's real post id at publish time.
GET /api/threads/{bundle_id} reports aggregate status (queued/partial/
published/failed) with per-item detail.
229/229 tests, clippy clean.
🔍 Cora AI Code Review✅ No issues found. Code looks good! Review powered by cora-code · BYOK · MIT |
…TL reject CodeCora review findings on PR #252: - The post-loop bulk status flip (set_bundle_status draft/pending -> bundle_waiting + unconditional root restore) raced a concurrent scheduler tick in instant mode: the tick could claim or publish the root before the restore ran, and the restore would then reset processing/published back to pending, re-publishing the post. Non-root rows are now fixed up individually right after insert, scoped to the new row id. - fail_remaining_bundle was only invoked in the publish-Err arm. Roots that failed earlier (account missing / inactive) left the remaining members bundle_waiting forever. Both branches now cascade via a shared cascade_fail_bundle helper. - Rejecting a bundle member via HITL now also fails remaining waiting members so rejected bundles resolve cleanly. 229/229 tests, clippy clean.
- GET /api/threads/{id}: empty result now 404 (was misread as
published); DB errors now 500 (was 404).
- Bundle creation aborts on the first failed insert and fails all
waiting members — a missing seq would strand everything after it
(promotion is strictly seq-1 -> seq). Same for a failed
bundle_waiting fixup: the row is marked failed, not left pending.
- Bundle reply markers now fail the item cleanly (plus cascade) when
the referenced member is failed or not published, instead of sending
the literal bundle:<id>:<seq> marker to the Threads API.
- reconcile_bundle_promotions() runs each tick: promotes any waiting
member whose predecessor published — self-heals lost/failed promote
hooks and crashes between publish and promote.
229/229 tests, clippy clean.
- Reject cascade now runs only after reject_schedule succeeds (Ok arm), so invalid/failed rejects can no longer wipe waiting bundle members. - bundle_waiting fixup failure now BREAKS creation instead of continuing (later rows would be stranded behind a failed seq). - Bundle reply marker lookup failure (bad seq / store error / missing member) now fails the item cleanly, same as an unavailable target — the literal marker is never sent to Threads. 229/229 tests, clippy clean.
| // fail the waiting members created so far. | ||
| let _ = state | ||
| .store | ||
| .fail_remaining_bundle( |
| /// clients directly). | ||
| #[serde(default)] | ||
| pub bundle_id: Option<String>, | ||
| #[serde(default)] |
CodeCora review round 4: - Bundle creation is now a single SQLite transaction (Store::begin + create_bundle_item_tx): either all rows exist or none — eliminates orphaned drafts, seq gaps, and stranded waiting rows from mid-loop failures or crashes. - Bundle membership fields (bundle_id/seq/total) removed from the serde-exposed CreateSchedule: clients could previously inject them via POST /api/schedules and hijack bundle promote/cascade logic. Bundle rows are created through an internal CreateBundleItem struct that no request body deserializes into. 229/229 tests, clippy clean.
CodeCora review round 5: - Bundle items accepted alt_text but the schedule path discarded it silently — publishes without the accessibility text the caller set. Bundle items with alt_text now get an explicit 400 (ALT_TEXT_UNSUPPORTED) until alt text is plumbed through. - Approving a bundle root via HITL now approves the whole bundle: members 1..n (draft) flip to bundle_waiting and run after the root publishes. Previously they were never reviewed at all, contradicting the auto_approve contract. set_bundle_status (previously unused) is the mechanism. 229/229 tests, clippy clean.
CodeCora review round 4 final finding: resolve_bundle_marker mapped a transient DB error to None, and the caller treated every None as a permanent failure — one SQLite hiccup would permanently kill the schedule and cascade-fail the rest of the bundle. resolve_bundle_marker now returns Result: malformed markers and failed targets still fail the item permanently, but store errors reset the claimed schedule to pending so the next tick retries the lookup. Bundle creation is also fully transactional now (Store::begin + create_bundle_item_tx in one commit) — a mid-loop failure rolls back every row instead of leaving orphaned drafts or seq gaps. alt_text on bundle items is rejected explicitly (ALT_TEXT_UNSUPPORTED) until plumbed through, and bundle-level HITL approve flips all draft members to bundle_waiting via set_bundle_status. 229/229 tests, clippy clean.
| let msg = format!("bundle reply target unavailable: {marker}"); | ||
| let _ = store | ||
| .update_schedule_status(&schedule.id, "failed", None, Some(&msg)) | ||
| .await; |
…etry CodeCora review round 5: resolve_bundle_marker returned Err(InvalidRequest) for permanent conditions (malformed marker, unparseable seq, missing member), which the caller treated as transient and reset to pending — a user-supplied reply_to string starting with 'bundle:' would retry forever, spamming logs every tick. The marker parser now returns Ok(None) for permanent conditions (item fails immediately) and Err only for transient store errors (item resets to pending for retry next tick).
This was referenced Sep 8, 2026
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.
What
POST /api/threads— thread bundles: 1–20 items in one object. Item 0 is the root post; later items reply to an earlier item by index (reply_to: 0) or to any existing Threads post id (reply_to: "1789...").scheduled_ateverything publishes right away (root on the next tick, replies chain after it); withscheduled_atthe whole bundle occupies one calendar slot — no more scheduling-per-reply.GET /api/threads/{bundle_id}— aggregate status (queued / partial / published / failed) plus per-item detail.schedulesgainsbundle_id,bundle_seq,bundle_total(reuses all existing machinery — HITL, bookkeeping, calendar, rate tracking).bundle_waiting→pending); each successful publish promotes the next member; a failed item fails the remaining members (no stranded chains). Bundle reply markersbundle:<id>:<seq>resolve to the referenced member's real post id at publish time.Why
Phase 2 of the thread-bundle design (approved in #engineering, design notes in the
project:titenuteke room). Scheduling N replies as N independent schedules was inefficient and error-prone; a bundle is atomic to approve and ordered by construction.Testing
cargo test --workspace— 229 passed / 0 failedcargo clippy --workspace --all-targets— cleancargo fmt— clean