Skip to content

The three deploys

The agent runs as three containers off the same image, each with a different CMD and a different lifecycle. This page is the ops-side counterpart to the architecture overview.

The three CMDs

prod-accounting-bot       python -m accounting_agent.discord_bot
prod-accounting-webhooks  python -m accounting_agent.webhooks
prod-accounting-booker    python -m accounting_agent.booker_daemon

All three:

  • Run on the prod_network Docker network (talks to prod-postgres, prod-redis, prod-falkordb, the shared LGTM stack, etc.).
  • Run on the cf_network so Traefik can route to them where ingress is needed (only the webhooks container has an HTTP surface).
  • Pull secrets at startup via infisical run wrapping the actual command — Infisical project + path + env are injected as three vars; everything else comes through the wrapper.

Bot container

Surface: outbound Discord gateway (WebSocket, kept open 24/7).

What it does:

  • Receives Discord messages + interactions
  • Runs the ingest pipeline when a PDF is forwarded
  • Runs the chat agent for /ask
  • Runs the BookerHitlPoller — every 300s scans for booked rows needing prompt + pending client_create_approval checkpoints needing posting
  • Hosts every slash command (/pending, /book, /rules, /setkind, etc.)

Restart cost: ~5 sec to reconnect to Discord gateway.

Webhooks container

Surface: inbound HTTPS (FastAPI, port 8000) behind Traefik.

What it does:

  • Stripe webhook handler (signature-verified)
  • Enable Banking OAuth callback handler
  • A background asyncio task running the EB poll loop every ~6 hours (free-tier AIS quota math: 4 polls/day × 30 days = 120 calls/month, comfortably under 100/account limit)

Restart cost: ~2 sec to start FastAPI; ~6 sec until the first EB poll fires (if run_immediately=True).

Booker daemon container

Surface: none. Pure outbound (talks to Turso + R2 + FalkorDB + e-Fin).

What it does:

  • Wakes every 900s (15 min)
  • Calls book_all_pending — scans booked rows where efin_entry_id IS NULL and processes up to 50 per tick
  • Each row goes through: find_by_name (client lookup) → vat_decision (rate) → _build_create_payload → create → update_totals → attach PDF → register
  • Stamps efin_entry_id on the recon row when done

Restart cost: ~3 sec. Daemon catches SIGTERM and finishes the in-flight row before exiting (no half-booked entries).

Why split this way

The original design had everything in the bot container. Two problems surfaced:

  1. Discord gateway latency. A book_one call blocks for 3-5 seconds on the e-Fin round-trip; doing that on the bot's event loop made the bot stop responding to other Discord events during books.
  2. Cron coupling. Putting the booker on a cron in the bot container meant cron failures (image restart mid-tick) corrupted the Discord connection state.

The booker as its own container fixes both. Separation also lets us scale the booker independently (more frequent ticks, higher batch size) without touching the bot.

Shared state

All three containers share Turso (the same libsql://... URL + auth token in Infisical). The reconciliation table is the lingua franca:

  • Bot writes proposed rows (when matching) and booked rows (when operator approves via HITL).
  • Webhooks writes new unmatched rows (when EB poller sees a new transaction).
  • Booker reads booked AND efin_entry_id IS NULL and writes booked AND efin_entry_id=<id>.

The state machine prevents collisions: each transition has a single owner. See data-model for the full machine.

Resilience contract

Each container is restartable at any time:

  • Mid-OCR? The Turso doc row stays at parsed status; next bot start picks it up.
  • Mid-EB-poll? The high-water mark only advances on success; next webhooks start re-polls the same window. UNIQUE constraint on (source_kind, source_reference) handles duplicates.
  • Mid-book? The notes-marker dedup (source_kind:source_reference in the e-Fin entry's notes field) lets the next booker start find the half-created draft and resume from update_totals.

No state lives in container memory that isn't recoverable from Turso + R2.