Skip to content

feat(threads): thread bundles — root + chained replies, instant or scheduled - #252

Merged
ajianaz merged 8 commits into
developfrom
feat/thread-bundles
Sep 8, 2026
Merged

ajianaz merged 8 commits into
developfrom
feat/thread-bundles

Conversation

@ajianaz

@ajianaz ajianaz commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

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...").
  • Instant or scheduled: without scheduled_at everything publishes right away (root on the next tick, replies chain after it); with scheduled_at the 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.
  • Migration 017: schedules gains bundle_id, bundle_seq, bundle_total (reuses all existing machinery — HITL, bookkeeping, calendar, rate tracking).
  • Scheduler: due bundle roots are promoted (bundle_waiting → pending); each successful publish promotes the next member; a failed item fails the remaining members (no stranded chains). Bundle reply markers bundle:<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:titen uteke room). Scheduling N replies as N independent schedules was inefficient and error-prone; a bundle is atomic to approve and ordered by construction.

Testing

  • 3 new integration tests: bundle creation (pending root + waiting replies + GET round-trip), forward-index reply rejection, instant-mode item states
  • cargo test --workspace — 229 passed / 0 failed
  • cargo clippy --workspace --all-targets — clean
  • cargo fmt — clean

…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.
Comment thread crates/titen-api/src/routes/threads_bundle.rs Fixed
Comment thread crates/titen-core/src/scheduler.rs Fixed
@github-actions

github-actions Bot commented Sep 8, 2026 •

Copy link
Copy Markdown

🔍 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.
Comment thread crates/titen-api/src/routes/threads_bundle.rs Fixed
Comment thread crates/titen-api/src/routes/threads_bundle.rs Fixed
Comment thread crates/titen-core/src/scheduler.rs Fixed
Comment thread crates/titen-core/src/scheduler.rs Fixed
- 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.
Comment thread crates/titen-api/src/routes/schedules.rs Fixed
Comment thread crates/titen-api/src/routes/threads_bundle.rs Fixed
Comment thread crates/titen-core/src/scheduler.rs Fixed
- 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(
Comment thread crates/titen-core/src/models.rs Outdated
/// clients directly).
#[serde(default)]
pub bundle_id: Option<String>,
#[serde(default)]
Comment thread crates/titen-core/src/scheduler.rs Dismissed
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.
Comment thread crates/titen-api/src/routes/threads_bundle.rs Fixed
Comment thread crates/titen-core/src/models.rs Fixed
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.
Comment thread crates/titen-core/src/scheduler.rs Fixed
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).
@ajianaz
ajianaz merged commit bfdb6f5 into develop Sep 8, 2026
12 checks passed
@ajianaz
ajianaz deleted the feat/thread-bundles branch September 8, 2026 06:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants