Skip to content

Database as primary conversations#19

Open
malanjary-tsri wants to merge 11 commits into
mainfrom
db-primary-conversations
Open

Database as primary conversations#19
malanjary-tsri wants to merge 11 commits into
mainfrom
db-primary-conversations

Conversation

@malanjary-tsri

Copy link
Copy Markdown
Collaborator

Disintegration of Slack as a dependency but maintain Slack as redundant storage / interface that can update the local database (now the ground truth for agent conversations).

Tested full disintegration with 5 successful proposals created (Slack totally disabled in .env)
Slack functions as normal when enabled, conversations are working, private-channels are able to be created. All functionality seems unaffected

*Note: Retroactive conversations, e.g. Slack_enabled=False, are not propagated when Slack is re-enabled. Only live conversations and interactions while enabled (due to heavy changes needed to align conversation ID's in this situation). This is only a view-only restriction as the store of truth/conversations now lives in the local DB

Slack to DB working (PI interaction updates the database)
DB to Slack operational

see issue: #18

Mohammad Alanjary and others added 11 commits July 20, 2026 15:50
…+ rebuild)

Historically Slack was the primary durable store of conversation content: the
in-memory MessageLog was rebuilt from Slack history on every start, and
agent_messages held only metadata (message_length, no body). With Slack off the
system could not reconstruct any conversation and a restart lost all history.

This lands the foundation from specs/local-db-conversations.md so the local DB
is the single source of truth and the simulation can run with Slack fully off.

Stage 1 — content persistence + DB rebuild:
- Migration 0019 extends agent_messages with content, sender_name, is_bot,
  posted_at and nullable slack_ts/slack_channel_id/slack_thread_ts mirror
  columns; relaxes agent_id to nullable (NULL = human/PI, mirroring
  LogEntry.sender_agent_id); adds UNIQUE(simulation_run_id, message_ts) plus
  posted_at / channel / partial slack_ts indexes.
- MessageLog.append is now idempotent (skips duplicate ts) and fires a persist
  callback; load_entry() appends restored rows without re-persisting.
- SimulationEngine buffers every appended entry (bot, peer, and human) and
  batch-upserts them into agent_messages in _flush_persisted (ON CONFLICT on
  run+message_ts), drained each main-loop tick and on stop(). The per-post
  _log_message path is retired.
- _rebuild_state_from_db() hydrates the log from agent_messages; the per-agent
  state reconstruction is extracted to _rebuild_agent_state() so it runs with
  Slack on or off; _rebuild_state_from_slack is demoted to a Slack-only
  reconcile that only adds messages missing from the DB.

Stage 2 — local id minting:
- mint_ts() yields monotonic, unique, ts-shaped ids seeded from the rebuild's
  max(posted_at); replaces the str(time.time()) fallbacks and removes the
  "mock_ts" constant (a latent collision that idempotent append would have
  turned into message loss).
- Slack-off channels use stable local:<name> ids (can't collide with Slack
  C…/G…); seeded channels are persisted to agent_channels.

Verification: full suite 308 passed (new tests cover idempotent append, the
persist callback, load_entry bypass, and mint_ts monotonicity/seeding), plus a
real-Postgres continuity check (post -> persist -> fresh rebuild with thread
intact, duplicate dropped, human row stored with NULL agent_id).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Formalizes running with Slack fully off and adds the Slack-independent inbound
path for PI messages.

- New src/agent/transport.py: a Transport Protocol matching the exact surface
  the engine uses on AgentSlackClient (which conforms structurally, no change),
  plus NullTransport — a no-op used when Slack is disabled. NullTransport
  reports is_connected=False (so the engine's existing guards take the no-op
  path), returns None from outbound posts (engine mints a local id), and
  returns local: ids for channel creates and [] from all inbound polls.
- config.slack_enabled (bool | None): None auto-detects (Slack on iff any agent
  has a usable token); SLACK_ENABLED=false forces DB-only mode. main.py resolves
  it (--mock forces off), builds AgentSlackClients when on and NullTransports
  when off, and passes the flag to the engine.
- SimulationEngine.slack_enabled gates the roster hot-add: when off, newly-active
  agents are admitted with a NullTransport instead of being dropped for a missing
  token / failed connect.
- New _poll_pi_inbox_from_db(): ingests human/PI rows the web app writes to
  agent_messages (is_bot=false), appends any unseen ones to the MessageLog, and
  routes them through PI handling (proposal-review clear, thread reopen,
  pi_context, @bot tags) derived from the thread's own participants rather than a
  Slack user→agent map. Runs every tick regardless of Slack, and is the
  convergence point for the future Slack mirror's inbound side. Cursor seeded
  past restored history in _rebuild_state_from_db.

Verification: full suite 314 passed (new tests: NullTransport behavior + Protocol
conformance for both NullTransport and AgentSlackClient), plus a real-Postgres
smoke test (a web-written PI reply on a proposal thread is ingested and clears
the pending-proposal block; re-poll is a no-op).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… poller

The public→collab_private migration (PI reopen flow) now works with Slack off,
and a running sim ingests migration handover posts through the DB.

- private_channels.py: _slack_enabled_for_migration() resolves the mode
  (explicit SLACK_ENABLED wins; else auto-detect from whether both bots have
  usable tokens). When off, _migrate_offline() creates the collab_private
  AgentChannel with a local: id and the same PrivateChannelMember rows as the
  Slack path, but makes no Slack calls: the handover posts (authored by the
  creator bot) and the neutral ⏸️ origin-thread close marker are written
  straight to agent_messages (visibility=collab_private for the handover) and
  refined_in_channel is set. The other PI is not DM'd (no transport).
- Generalized the engine's DB inbound poller (renamed _poll_pi_inbox_from_db ->
  _poll_inbound_from_db): it now ingests ANY unseen message for the run, not
  just human rows — so bot-authored handover posts written by the web process
  reach the live MessageLog. Human/PI rows still additionally go through PI
  handling; the sim's own messages are already in the log and are skipped.

Verification: full suite 314 passed, plus a real-Postgres smoke test with
SLACK_ENABLED=false (offline migration creates a local: private channel with 3
members, writes 3 handover posts + origin close marker + refined_in_channel,
and a running sim ingests all 3 handover posts from the DB). Also confirmed the
auto-detect path still uses Slack when tokens are present.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gives PIs (and delegates) a Slack-independent way to see recent activity and
inject a message into their agent's workspace.

- src/services/pi_inbox.py: get_latest_run_id() and record_pi_message(), which
  insert a human/PI row (is_bot=false, agent_id=null) into agent_messages with a
  minted ts, resolving channel_id/visibility from agent_channels. The running
  sim ingests it via _poll_inbound_from_db and routes it through PI handling.
- agent_page.py: GET /{agent_id}/conversations (a read view of recent messages —
  content now lives in the DB — plus a post form) and POST /{agent_id}/message
  (writes the inbound row; an optional "address my bot" toggle prepends the
  @botName tag the engine already understands). Both gated by
  get_agent_with_access (owner or delegate).
- templates/agent/conversations.html + a dashboard card linking to it.

Identity uses AgentRegistry.user_id / access checks rather than a Slack user id,
so this works with Slack fully off. DM-style directives depend on DM persistence
and are wired in Stage 7.

Verification: full suite 314 passed; a real-Postgres smoke test (record_pi_message
-> _poll_inbound_from_db ingests the PI row and the @bot tag sets has_pi_directive
on the target agent); app restarted cleanly and the new routes resolve.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Makes the non-engine Slack writers degrade to the DB when Slack is off, and
records the Slack↔DB id mapping so mirroring and reconcile dedup are correct.

Secondary posters (gated by slack_tokens.slack_globally_enabled — explicit
SLACK_ENABLED wins, else auto-detect from token presence):
- grantbot.py: when Slack is off, funding opportunities are written to
  agent_messages (authored by a "grantbot" identity, 💰 top-level post)
  via _post_funding_to_db instead of released — so funding threads still exist
  and agents scan them.
- email_inbound.py + agent_page.py reopen: the legacy "post guidance to the
  origin thread" fallback now writes to the DB inbox via record_pi_message when
  Slack is off (the primary reopen path already routes through the Slack-aware
  migration). invite.py's users_lookupByEmail was already token-guarded.

Slack-mirror mapping:
- LogEntry gains slack_ts/slack_channel_id; _post_message records them when a
  connected client posted (in pure Slack-on mode slack_ts == message_ts), and
  _flush_persisted upserts slack_ts/slack_channel_id/slack_thread_ts.
- _rebuild_state_from_db seeds _known_slack_ts; the Slack reconcile skips any
  message already represented in the DB by its slack_ts (dedup for a DB-origin
  message later mirrored to Slack) and stamps reconciled entries with their
  slack mapping.

Verification: full suite 314 passed, plus a real-Postgres smoke test (a
connected post persists message_ts == slack_ts + slack_channel_id, and a fresh
rebuild seeds _known_slack_ts for reconcile dedup).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ackfill

Completes the DB-primary refactor: PI<->bot DMs are durable and Slack-optional,
and there's a one-time importer for existing Slack history.

DM persistence:
- Migration 0020 + PiDmMessage model: pi_dm_messages (run, agent_id, pi_user_id,
  direction inbound/outbound, content, ts, slack_ts, posted_at). pi_user_id is a
  Slack user id (Slack-on) or local:<users.id> (web).
- pi_inbox.record_pi_dm() / web_pi_user_id(); PIHandler._send_dm now records every
  outbound DM (durable + visible in the web UI even with Slack off) and takes
  simulation_run_id.
- DM processing is unified through the DB: _poll_pi_dms (Slack) now only RECORDS
  inbound DMs as rows; the new _poll_pi_dms_from_db is the single processor that
  runs each inbound row through PIHandler.handle_dm and flips has_pi_directive.
  This handles Slack and web DMs identically with no double-processing.
  _seed_pi_dm_cursor starts it past existing history on boot.

PI web interface (DMs): POST /agent/{id}/dm writes an inbound DM row; the
conversations page shows the recent DM thread and a send box.

Ops:
- --fresh now also wipes pi_dm_messages.
- scripts/backfill_slack_history_to_db.py: one-time importer that reuses the
  engine's setup + Slack reconcile + flush to pull channel/thread history
  (content + slack_ts) into agent_messages for a run, without running any turns.

Verification: full suite 314 passed; a real-Postgres smoke test (inbound web DM
recorded → processed exactly once → directive flag set → re-poll no-op; outbound
DM persisted); app/worker/grantbot restarted cleanly; backfill script imports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…els)

_persist_seeded_channels referenced AgentChannel but it wasn't in the module
import, raising NameError ("Failed to persist seeded channels") on startup so
seeded channels were never recorded in agent_channels. Surfaced by the first
Slack-off agent run. Add AgentChannel to the src.models import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The channel poller and proposal-thread PI-reply poller recorded human Slack
messages with message_ts set to the Slack ts but left the slack_ts /
slack_channel_id mirror-mapping columns null, so "origin = Slack" reporting
(and the reconcile's slack_ts dedup set) didn't see them. Content and dedup were
unaffected (dedup keys on message_ts, which already equals the Slack ts here),
but the mapping is now recorded for consistency with agent posts and the bulk
reconcile. Web-origin reopen guidance stays null (it is DB-origin, not Slack).

Verification: full suite 314 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Merged main (the test-suite-characterization overhaul: tests/unit|integration|
characterization|contract split, testcontainers harness, fakes/factories, CI
gate). No conflicts with the refactor's src/ changes. Alignment fixups:

- Move tests/test_transport.py -> tests/unit/ to match the new layout (still
  collected before, but keeps the convention). My edits to the three relocated
  files (test_message_log, test_simulation_logic, test_private_channel_migration)
  came across cleanly via git's rename-follow.
- test_simulation_logic: add strict=False to the mint_ts zip() (ruff B905; the
  test lint gate is enforced by scripts/ci.sh).
- test_harness_smoke: bump the pinned alembic head 0018 -> 0020 (this branch adds
  migrations 0019 + 0020).

Verified: scripts/ci.sh passes — 460 tests (unit+integration+characterization+
contract), 13 golden-master snapshots unchanged, coverage 35.62% >= 35% floor,
ruff clean; migrations 0019/0020 apply via the real alembic chain in the
integration harness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds tests/integration/test_pi_inbox.py (real migrated Postgres) covering
src/services/pi_inbox.py — the Slack-independent path by which PI web messages
and DMs enter the simulation:
- get_latest_run_id picks the most recent run
- record_pi_message writes a human row (is_bot=false, agent_id NULL), resolves
  channel_id/visibility from agent_channels, falls back to local: + public, and
  sets phase new_post vs thread_reply
- record_pi_dm writes inbound/outbound rows; web_pi_user_id formatting

Lifts src coverage 35.62% -> 35.86% (pi_inbox was 0%). Full gate: 464 passed,
13 golden-master snapshots unchanged, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ahueb

ahueb commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

PR #19 "Database as primary conversations" — Review

What this is: an independent verification of PR #19, run against an ephemeral Postgres migrated to head 0020, driving the real SimulationEngine (live LLM for the functional part; deterministic no-LLM repros for the bug confirmations). Every claim below was reproduced by execution, not just code reading.

Bottom line: the feature works as described — Slack-off conversations persist, restart continuity holds, PI web interaction drives the agents, and Slack-on parity is intact. Recommended: mergeable after addressing 3 data-loss bugs (H1, H2, M1a) that can silently drop or overwrite committed conversation content in the store this PR makes authoritative. All three reproduce on demand.


1. What works (functional verification, live LLM)

Claim Result Evidence
DB is the primary store; content persists (Slack off) A real threaded 2-lab dialogue persisted with local: channel ids, null slack_ts, unique ids, correct threading/phases, recomputed run totals.
Runs identically with Slack fully disabled Full 5-phase loop ran on NullTransport, no Slack calls.
Restart / resume rebuilds from the DB, no duplicates 2nd engine on the same run hydrated the log with content, continued the timeline, 0 duplicate ids.
PI interaction updates the DB (web message + DM) A web @tag and a web DM drove a live round-trip: DM classified → private profile rewritten → confirmation recorded as an outbound DM row — all with Slack off.
Slack-on mirror parity (message_ts == slack_ts) Mirrored row had message_ts exactly equal to slack_ts.
Migrations apply over existing rows 0018→0019→0020 clean on a populated DB; legacy rows survive on server defaults; --fresh also wipes pi_dm_messages.

23 of 24 live checks passed; the one miss was a message-count threshold in a short budget window, not a correctness failure. Cost of the live run: ~$0.70.

Design is sound and better than the old Slack-primary model: cursor-bounded pollers backed by the new indexes, an idempotent ON CONFLICT upsert, a clean transport seam, a DB-agnostic MessageLog (persistence injected via callback), and a well-separated pi_inbox service. The four items below are the exceptions.


2. Please fix before production (all reproduced at runtime)

🔴 H1 — A failed flush silently drops conversation content

simulation.py _flush_persisted (~:2762, :2831). The buffer is detached (entries = self._pending_persist; self._pending_persist = []) before the try, and the except Exception only logs. On any transient DB error the batch is gone — and because the DB is now the source of truth, a restart cannot recover it.
Reproduced: forced a commit error → buffer emptied to 0 and 0 rows persisted; the two messages vanished.
Fix: re-queue the batch on failure (prepend back to _pending_persist) instead of dropping it.

🔴 H2 — Inbox cursor can permanently skip a PI message

simulation.py _poll_inbound_from_db (~:2098:2108; same pattern for DMs at :2317). The poller filters posted_at > _pi_inbox_cursor and advances the cursor to the max posted_at seen — but posted_at is stamped at row creation time (pi_inbox.py:62, time.time()), not commit time. If the engine flushes its own later message and advances the cursor in the window before a web PI row commits, that PI row lands below the high-water mark and is never ingested — silently, at debug log level.
Reproduced: a row with posted_at=150 against cursor=200 was never ingested or routed, even on a second poll; a control at cursor=0 ingested the identical row (so it's the cursor logic, not the harness).
Fix: reconcile by "message_ts not already in the log" or order by a DB-assigned sequence, rather than a wall-clock high-water cursor.

🟠 M1a/M1b — Canonical-id collision between processes (clobber + 500)

The id format f"{time.time():.6f}" is minted in 4 places but only mint_ts (simulation.py:2463) is monotonic/unique; the web (pi_inbox.py:62,93) and grantbot (grantbot.py:173) writers use raw time.time(). mint_ts's guarantee is per-process only, so the engine and the web app can mint the same message_ts for the same run.

  • M1a (silent): the engine's ON CONFLICT DO UPDATE set includes content/agent_id/is_bot, so on collision it overwrites a human PI row with a bot message. Reproduced: the human row became is_bot=True, agent_id=<bot>, content=<bot text>.
  • M1b (visible): the same collision through POST /message raises an uncaught IntegrityError (no guard in post_agent_message) → HTTP 500. Reproduced with pinned time.
    Fix: share one mint_local_ts() helper across all writers so they inherit mint_ts's guarantee; and either ON CONFLICT DO NOTHING for genuinely distinct births or add an IntegrityError guard on the web route.

🟠 M2 — Transport Protocol under-declares its real contract

transport.py declares the Transport Protocol, but the engine mutates client._channel_name_to_id (simulation.py:1355, :2597), a private attribute not in the Protocol (NullTransport carries it "for parity" — the tell). A new backend that satisfies the declared Protocol will crash.
Reproduced: a stub with isinstance(t, Transport) == True crashed with AttributeError: '_channel_name_to_id' in _ensure_seeded_channels.
Fix: declare _channel_name_to_id (or a public accessor) on the Protocol. Minor: set_visibility_lookup on NullTransport has 0 callers in src/ — it's dead code, safe to drop.


2b. How likely is each to bite in production?

Calibrated to the actual topology: one web worker + one long-lived sim process sharing Postgres, turn_delay_seconds=0 (the sim flushes every tick), no pool_pre_ping, and PI web writes that are human-paced. Ranked most- to least-likely to actually surface.

Bug Likelihood in prod Conditioned on Impact when it hits
H1 flush-loss Moderate — expect it any DB error at flush time (Postgres restart, connection reset, stale pooled conn — worsened by no pool_pre_ping) small, silent, recurring history gaps (only the current tick's buffer; visible after next restart)
B1/B2 scaling Gradual, ~certain message count → 10k–100k+ rising restart time + RAM (rebuild loads all rows) and continuous per-tick overhead
H2 cursor race Rare a PI web write committed in a ~ms window while the sim is actively posting (cursor pinned to ~now); only-input path when Slack is off that single PI message silently, permanently ignored
M1a/M1b id collision Very rare Slack-off only + two processes minting the same microsecond; ↑ sharply on coarse VM/container clocks M1a silently overwrites a PI's row; M1b a visible, retryable 500
M2 protocol gap Not today only when a 3rd transport backend is added crash for the next maintainer, not a running deployment

Reading of it:

  • H1 is the one to actually plan for — a long-running process will hit transient DB errors periodically (especially around Postgres restarts), and each one silently drops that flush's messages from the store this PR makes authoritative. Low per-incident, but cumulative and invisible. Adding pool_pre_ping/pool_recycle reduces the trigger; re-queueing the batch removes the loss.
  • B1/B2 are a slow tax, not an incident — fine at pilot scale, worth addressing before history grows past ~10k messages.
  • H2 is rare but sharp — it undermines the feature's own promise (PI input via web) exactly when the sim is busy, and it's invisible at default log level.
  • M1 and M2 are the least likely to surface in the current Slack-capable, single-worker deployment — M1 needs a microsecond cross-process coincidence in Slack-off mode (watch it only on VMs with coarse clock resolution); M2 is a future-maintenance trap, not a runtime risk today.

Net: none are high-frequency. Priority order for real-world exposure is H1 → B1/B2 → H2 → M1 → M2, which is close to (but not the same as) the severity order — H1 combines real severity and real likelihood, so it's the clear first fix.


3. Lower-priority notes (not blocking)

  • Scaling (simulation.py): _flush_persisted runs a full COUNT(*) + SimulationRun update every tick with pending rows (cosmetic counter, index-backed but continuous); _rebuild_state_from_db loads all of a run's rows with content into memory at startup, no cap/window (measured: 2000 rows → all 2000 loaded). Both bite only at large message counts. Good: the two pollers are correctly index-backed (confirmed via EXPLAIN), and each flush is a single INSERT…ON CONFLICT round-trip.
  • Modularity: simulation.py grew 3423 → 3820 lines; the new persistence/rebuild/poller/minting concerns landed on an already-large class. Consider extracting a MessagePersister/DbInboxPoller collaborator later.
  • DRY / clean code: the id format (×4) and 9 near-identical LogEntry(...) builders could be centralized; the PR adds 10 broad except Exception handlers, 3 of them at debug level on the only Slack-off PI input path — a persistently failing poll would be invisible at default log level. Consider logger.warning for those three.

4. Not covered here (worth a manual pass)

  • Live Slack workspace round-trip — parity was verified with a fake transport, not the real Slack Web API. You reported testing this manually ("5 successful proposals, Slack functions as normal"); a reviewer should confirm an inbound PI Slack message lands in agent_messages with slack_ts set.
  • scripts/backfill_slack_history_to_db.py — not executed.
  • The documented retroactive-history limitation (Slack-off history not back-propagated when Slack is re-enabled) is understood to be by design.

Verification used an isolated ephemeral database; the dev stack and repository were untouched (no commits, no source/test changes). Confirmation scripts and raw evidence are available on request.

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