Skip to content

Database as primary conversations#19

Merged
malanjary-tsri merged 22 commits into
mainfrom
db-primary-conversations
Jul 27, 2026
Merged

Database as primary conversations#19
malanjary-tsri merged 22 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

malanjary-tsri 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.

andrewsu and others added 7 commits July 24, 2026 00:30
_flush_persisted detached the pending buffer before the try and only
logged on failure, so any transient DB error at flush time silently
dropped that batch. Because the DB is now the primary conversation
store, a restart rebuilds from the DB and the lost messages are gone
for good.

Re-queue the batch on failure (prepend ahead of any entries enqueued
during the failed commit, preserving chronological order) so the next
tick retries. The no-DB path still clears the buffer to avoid unbounded
growth.

Adds regression tests covering re-queue, ordering vs. newly-arrived
entries, and the no-DB clear path.

Addresses H1 from the PR #19 review (issue #18).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mint_ts precision: f"{time.time():.6f}" lost monotonicity at the current
epoch magnitude — float64 ULP (~2.4e-7s) is coarser than the 1e-6s step,
so consecutive ids could format to the same string (breaking uniqueness)
or fail to increase once parsed back to float (breaking posted_at order).
Replace with a shared TsMinter (src/agent/ids.py) that carries a monotonic
integer-microsecond high-water mark and formats to a string only at the
end, never round-tripping the fraction through a float.

M1 (canonical-id collision across processes):
- Share one mint_local_ts() helper across all writers (PI web inbox,
  GrantBot) so they inherit the engine minter's per-process monotonic,
  unique guarantee instead of raw time.time() (also DRYs the id format).
- M1a: add a WHERE guard to the engine flush's ON CONFLICT DO UPDATE so a
  bot message can never overwrite an existing human (PI) row on a
  cross-process ts collision; legitimate bot re-flush / human re-flush /
  slack-mirror updates still apply.
- M1b: guard the web post route against the IntegrityError a collision
  raises — roll back and retry once with a fresh id, else return 409
  instead of a raw 500.

Tests: TsMinter/mint_local_ts unit tests (monotonic, unique, float-ordered,
ts-shaped, seed_floor); rewrite the mint_ts tests as precision regression
guards; integration tests for the M1a upsert guard (block bot-over-human,
allow bot re-flush, allow human re-flush) run against real Postgres.
conftest gains an optional TEST_DATABASE_URL to point the suite at an
existing Postgres when a Docker socket for testcontainers isn't available.

Addresses M1 and the mint-precision issue from the PR #19 review (issue #18).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both DB inbox pollers filtered posted_at > cursor and advanced the cursor
to the max posted_at seen. But posted_at is stamped at row *creation*
(mint time), not commit — so a row written by another process (a PI web
message) can become visible only after this process has already advanced
its cursor past that timestamp. That row lands below the high-water mark
and is never ingested: silently, permanently, at debug log level. With
Slack off this is the only PI input path, so the message is simply lost.

Fix: query a lookback window behind the cursor and dedup by identity, so
a late-committing row is re-queried within the window and ingested exactly
once. The channel poller already deduped via the message log; the DM
poller gains a seen-set (ts -> posted_at), pruned to the lookback window
each poll, and _seed_pi_dm_cursor seeds it so a restart's first re-scan
doesn't replay history through handle_dm. Polls are LLM-paced, so the
re-scan is cheap. Also raise the three Slack-off poll-failure logs from
debug to warning so a persistently failing poll isn't invisible.

Tests (real Postgres): channel + DM pollers ingest a row committed below
the cursor; the DM lookback re-scan dedups (processed once); the re-scan
is bounded (a row older than the window is not re-queried); restart seed
prevents DM replay.

Addresses H2 from the PR #19 review (issue #18).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
B1 — _flush_persisted ran a full COUNT(*) over the run's agent_messages on
every flush (~every tick) just to refresh the cosmetic total_messages /
total_api_calls shown in the admin UI. Throttle that refresh to at most
once per RUN_STATS_UPDATE_INTERVAL (30s), forced on shutdown, while still
upserting the message rows every flush. total_messages may lag by a few
seconds between refreshes — fine for a display counter.

B2 — _rebuild_state_from_db loaded *every* row of a run (with content) into
memory at startup, so restart time and RAM grew with all-time history.
Bound the load to a window: messages from the last REBUILD_WINDOW_S (14d)
plus the full history of any thread with no ThreadDecision (still
undecided). Active-thread reconstruction only needs undecided threads, and
all other restored state (proposals, prior threads, api counts) comes from
DB tables, not the log — so old closed-thread bodies are safe to leave in
the DB. Add _hydrate_thread_from_db to pull a specific thread back on
demand, and call it in the three reopen paths (PI web reply, PI DM/Slack
reopen via _reopen_thread, and rating=0 proposal reopen) so reopening an
old closed thread still resolves participants and the reply-budget offset.

Tests (real Postgres): stats count is throttled and force-refreshes on
shutdown; rebuild loads recent + undecided threads and windows out old
closed ones; on-demand hydration restores a windowed-out thread and is
idempotent.

Addresses B1/B2 from the PR #19 review (issue #18).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The engine seeded the channel name->id lookup by poking each client's
private `_channel_name_to_id` attribute directly (simulation.py). That
attribute isn't part of the Transport Protocol, so a new backend that
satisfies the *declared* contract would crash with AttributeError in
_ensure_seeded_channels — NullTransport only carried the attribute "for
parity", which was the tell.

Add a public `cache_channel_ids(mapping)` to the Transport Protocol,
implement it on NullTransport and AgentSlackClient, and switch the engine's
two seeding sites to call it. The write path is now part of the contract.

Also drop `set_visibility_lookup`, which had zero callers across the repo
(the visibility lookup is set via the AgentSlackClient constructor): removed
from NullTransport, AgentSlackClient, and the test fake (plus the fake's
unused _visibility_lookup attribute).

Tests: cache_channel_ids seeds the lookup; it's declared on the Protocol;
a Protocol-only backend without _channel_name_to_id can be seeded; the dead
setter is gone.

Addresses M2 from the PR #19 review (issue #18).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes the three residual findings in .notes/db-conversations-residual-2026-07-24.md
— the failure modes left after H1/H2/M1a, all at boundaries the Slack-primary
system got for free (a globally unique authoritative ts, durability the instant a
message was posted, one clock).

R2 — messages lost on a hard kill
Posts are buffered in MessageLog and written to Postgres once per turn, so the
documented `docker rm -f agent-run` (SIGKILL) discarded the in-flight turn
permanently: the DB, not Slack, is the durable store. Three parts, because the
doc change alone was not enough:
  * restart recipe is now `docker stop -t 30 agent-run && docker rm agent-run`
    (CLAUDE.md, README.md, provision_slack_bots.py), and the agent service
    carries stop_grace_period: 30s so the container's StopTimeout matches;
  * the graceful path was itself racy — the handler did
    `asyncio.ensure_future(sim_engine.stop())`, so the main loop could return
    first and asyncio.run cancel the orphan flush mid-await. The handler is now
    the sync request_stop() (flag only) and `await sim_engine.stop()` runs in the
    entry point's finally-block, on every exit path;
  * SIGTERM could not be noticed in time — the idle backoff sleeps up to 30s,
    longer than the old 10s grace. Those sleeps now go through _sleep(), which
    races the sleep against a stop event.
An in-flight LLM call is still not interruptible, hence -t 30 over the default.

R1 — cross-process canonical-id collision silently dropped a message
TsMinter guaranteed uniqueness per process, but three processes mint into the
same run (engine, web app, GrantBot). Two minting in the same microsecond
produced the identical id; the uq_agent_messages_run_ts conflict handler then
resolved it by keeping one row and dropping the other. Each minter now owns a
residue class: ids are quantized to a 100us slot with the writer id in the low
microsecond digits, so a cross-writer collision is structurally impossible. Ids
stay ts-shaped, float-parseable and strictly ordered; the cost is resolution
(one id per writer per 100us), far above the real posting rate. Writer ids are
claimed at process entry via set_default_writer_id(); the engine process claims
two, since its DM writes use the module-default minter.
Chosen over re-mint-on-conflict, which for a thread root would have to rewrite
MessageLog._by_ts, every child's thread_ts, PostRef.post_id,
ThreadState.thread_id, ProposalRef.thread_id and the ThreadDecision rows; and
over a DB sequence, which mint_ts() cannot await. The M1a guard and the web
route's M1b retry stay as backstops.

R3 — inbound delivery depended on the writers' clocks agreeing
Both DB inbox pollers paged over posted_at, which is float(the writing process's
minted ts). A PI message stamped more than the 300s lookback below the engine's
cursor was skipped silently and forever — safe on one host, not across hosts.
They now page over created_at (server_default=now(), the single Postgres
server's clock), so the window depends on one clock. posted_at remains the
ordering key for conversation content; it is just no longer the delivery cursor.
Cursors are datetimes, seeded from MAX(created_at) over the whole run rather
than the windowed rebuild's loaded rows. An id-based cursor was rejected: the PK
is a random uuid4, so it would have needed a new sequence column and a backfill.
The lookback stays — it covers commit visibility, not skew (H2).
Migration 0021 adds ix_agent_messages_run_created and
ix_pi_dm_run_direction_created to back the new access path.

Tests: writer-slot disjointness at a frozen clock (unit) and against the real
unique constraint (integration); graceful-shutdown flag/sleep/flush behaviour;
delivery of a row from a writer three years of skew behind. The H2 tests now
derive their cursor from each row's own created_at, so they no longer depend on
the test process's clock either. Full suite green: 500 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two remaining "minor observations" from
.notes/db-conversations-residual-2026-07-24.md.

Thread history was not posted_at-ordered
MessageLog.get_thread_history returned root + replies in log-insertion order.
Single-process appends are roughly ordered, but the DB inbound poller and the
Slack reconcile append entries whose posted_at predates messages already in the
log, so the LLM could get a subtly scrambled thread — unlike the Slack-primary
rebuild, which fetched in ts order. Replies are now sorted by posted_at; the
sort is stable, so entries sharing a timestamp keep their insertion order. The
root stays pinned first: it is the thread's parent by definition even when a
writer running behind stamps a reply below it. This also re-orders the input to
get_thread_allowed_agents' first-two-participants rule, which is the intended
correction.

Enabling Slack mid-conversation corrupted the mirror
Slack threads on the *root's* ts, which equals the canonical thread_ts only when
the root was born on Slack. A thread started with Slack off has a minted root id
that Slack has never seen. The finding named the recorded slack_thread_ts
column, but the defect was wider: _post_message passed the canonical thread_ts
straight to client.post_message, so the Slack API call itself was wrong and the
reply would detach or error. Fixing only the column would have left that intact.

  * new _slack_parent_ts() translates canonical -> Slack via the root entry's
    slack_ts, falling back to the canonical id when the root is not in the log
    (windowed out by the B2 rebuild bound), which preserves pure-Slack-on
    behaviour where the two are the same value;
  * a reply whose root has no Slack presence is kept DB-only with a warning
    rather than posted against an unknown id — the message still drives the
    simulation, only the mirror skips it. Mid-life toggling stays unsupported
    by design, but now degrades safely instead of corrupting the thread;
  * LogEntry gains slack_thread_ts, set at every Slack-origin construction site
    and written by _flush_persisted in place of the canonical thread_ts.

Prerequisite, found while fixing the above: the rebuild and hydrate paths were
dropping slack_ts from restored entries entirely, so after any restart every
root looked DB-origin. Left as-is, the new check would have been a regression
that stopped mirroring *all* replies on the first restart — and a restart with
Slack newly enabled is precisely the mid-life-toggle scenario. Both paths now
restore the mirror mapping, inferring it for pre-Stage-6 rows: a real Slack
channel_id with a NULL slack_ts means the canonical id IS the Slack ts.

No migration — slack_thread_ts already exists on agent_messages; only what gets
written into it changed.

Left alone: MessageLog.latest_timestamp has the same insertion-order flaw but
has no callers in src/ or tests/, so it is noted rather than changed. Fix or
delete it before anything uses it as a cursor.

Full suite green: 512 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@malanjary-tsri

Copy link
Copy Markdown
Collaborator Author

DB-primary conversations — residual failure modes (post H1/H2/M1a fixes)

What this is: a deep inspection of the db-primary-conversations branch (HEAD
9548c08) hunting for cases where local-DB-backed conversations fail compared to
the original Slack-primary system
. Complements .notes/PR19-review.md, whose three
data-loss bugs (H1 flush re-queue, H2 inbox cursor skip, M1a human-row clobber) are
now fixed on this branch. The findings below are what remains after those fixes.

Bottom line: the all-off steady state is functionally solid — a live resume ran 34+
turns with agents doing Phase 4 replies and Phase 5 posts, no errors, all agents
unblocked. The remaining gaps are all at the cross-process and durability
boundaries that Slack used to cover for free (unique authoritative ts, always-durable
on post, global ordering). Two are worth fixing; one is a latent fragility; the rest are
minor. R1 and R2 were reproduced by execution against the live modules/DB (rolled back).

Status 2026-07-25: R1, R2 and R3 are fixed (see the per-finding notes below).
The two "minor observations" are still open. Full suite green: 500 passed.


R1 — Cross-process canonical-id collision silently drops a message (confirmed)

Severity: medium · Probability: low (grows with run length + PI activity)

TsMinter guarantees monotonic/unique ids only per process (src/agent/ids.py:29-36,
docstring says so explicitly). Three processes mint canonical ids into the same run:
the engine (self._ts_minter), the web app / PI inbox (mint_local_ts() → module
_default, src/agent/ids.py:63-68), and GrantBot. Two of them minting in the same
microsecond
emit the identical id. The DB uq_agent_messages_run_ts constraint catches
it, and the M1a ON CONFLICT … WHERE guard (src/agent/simulation.py:2958-2961) resolves
it by keeping the human row and dropping the bot message (or a later human upsert
overwrites a bot row). Slack never had this — Slack guarantees a unique ts. The
resolution is lossy (drop, no re-mint), and the DB is now the only durable store.

Reproduced (independent minters at a fixed instant; then the real on-conflict statement,
rolled back):

[collision] engine=1800000.000000  web=1800000.000000  SAME=True
[conflict]  rows_at_ts=1  surviving is_bot=False  content='PI: please pivot to aging biology'
[conflict]  -> bot message LOST = True

Fix options: add a per-writer discriminator to the minted id; or re-mint + retry on
conflict instead of DO UPDATE; or mint canonical ids from a DB sequence. The M1a guard
(protect PI data) is correct and should stay; the gap is that the losing row is
discarded rather than re-minted.

FIXED 2026-07-25 — per-writer discriminator (option 1). TsMinter(writer_id) now
quantizes ids to a 100 µs slot and stamps the writer id into the low microsecond digits,
so each minter owns a residue class and a cross-writer collision is structurally
impossible. Writer ids (WRITER_ENGINE / _WEB / _GRANTBOT / _ENGINE_AUX) are
claimed at process entry via set_default_writer_id() in src/main.py,
src/agent/main.py and src/agent/grantbot.py. Ids stay ts-shaped, float-parseable and
strictly ordered; the cost is resolution (1 id per writer per 100 µs), far above the real
posting rate. Chosen over re-mint-on-conflict because re-minting a thread root would
have to rewrite MessageLog._by_ts, every child's thread_ts, PostRef.post_id,
ThreadState.thread_id, ProposalRef.thread_id and the ThreadDecision rows; and over a
DB sequence because mint_ts() is called from sync code. The M1a guard and the web
route's M1b retry stay as backstops. Covered by tests/unit/test_ids.py::TestWriterSlots
and an integration test that drives both writers at a frozen clock against the real
unique constraint.

R2 — Un-flushed messages lost on hard kill, unlike Slack (confirmed by code path)

Severity: medium · Probability: medium

Posts are appended to the in-memory MessageLog and only written to Postgres by
_flush_persisted, which runs once per turn (src/agent/simulation.py:471) and on
graceful shutdown via the SIGTERM/SIGINT handler → stop() (src/agent/main.py:225-230,
simulation.py:481). The documented restart step docker rm -f agent-run sends
SIGKILL
, bypassing the graceful flush. Any message posted during the in-flight turn is
lost, and since Slack is off it is unrecoverable (restart rebuilds from the DB, where
those rows never landed). In the Slack-primary system those posts were durable on Slack
the instant they were sent. The H1 fix only re-queues on flush exception, not on
process death.

Fix / mitigation (easy): use docker stop agent-run (SIGTERM, 10 s grace — the
handler already flushes) before docker rm; update the CLAUDE.md restart recipe
accordingly. Stronger: flush after each post rather than each turn, or persist
synchronously inside _post_message.

FIXED 2026-07-25. Three parts, because the doc change alone was not enough:

  1. Restart recipe is now docker stop -t 30 agent-run && docker rm agent-run in
    CLAUDE.md, README.md and scripts/provision_slack_bots.py; the agent service
    also carries stop_grace_period: 30s, which is baked into the container's
    StopTimeout so a bare docker stop gets it too.
  2. The graceful path was itself racy (not in the original finding): the handler did
    asyncio.ensure_future(sim_engine.stop()), so the main loop could return first and
    asyncio.run would cancel the orphan flush mid-await. The handler is now the sync
    request_stop() (flag only), and await sim_engine.stop() runs in the entry point's
    finally-block — on every exit path, signal or not.
  3. SIGTERM could not be noticed in time: the idle backoff sleeps up to 30 s, longer
    than the old 10 s grace. Those sleeps go through _sleep(), which races the sleep
    against a stop event, so shutdown starts immediately.

An in-flight LLM call is still not interruptible — hence -t 30 rather than the default.

R3 — Inbox delivery depends on wall-clock agreement + a fixed 300 s lookback

Severity: low · Probability: low on a single host, real cross-host

Both DB pollers select new rows with posted_at > cursor - 300s
(src/agent/simulation.py:2138 and :2376; PI_INBOX_LOOKBACK_S = 300,
:120). The cursor is the max posted_at seen across all rows, including the
engine's own bot posts, so it tracks ≈ now during active runs. A PI/web message whose
posted_at lands more than 300 s below the engine's cursor is silently skipped
forever
. This assumes the writing process's clock and the engine's clock agree to
within 300 s. Safe on one host (shared clock); Slack's authoritative ordering made it a
non-issue. Cross-host deployment or large clock skew breaks inbound PI delivery with no
error. Consider an ordering guard (e.g. a monotonic DB sequence / id-based cursor)
rather than a pure wall-clock window.

FIXED 2026-07-25 — both pollers now page over created_at instead of posted_at.
agent_messages.created_at / pi_dm_messages.created_at are server_default=now(), i.e.
stamped by the single Postgres server, so the window depends on one clock rather than on
every writer's clock agreeing with the engine's. posted_at remains the ordering key for
conversation content; it is just no longer the delivery cursor. An id-based cursor was
rejected: the PK is a random uuid4, so it would have meant a new sequence column plus a
backfill, where created_at already exists on every row. The lookback stays (it covers
commit visibility, not skew — H2). Cursors are datetime now, seeded from
MAX(created_at) over the whole run (_seed_pi_inbox_cursor) rather than from the
windowed rebuild's loaded rows. Migration 0021 adds
ix_agent_messages_run_created and ix_pi_dm_run_direction_created to back the new
access path. Covered by
test_inbound_poller_delivers_a_row_from_a_skewed_writer_clock.


Minor observations

  • Thread history is not posted_at-ordered. MessageLog.get_thread_history
    (src/agent/message_log.py:123-131) returns root + replies in log-insertion order,
    not posted_at order. Single-process appends are ~ordered, but reconcile / DB-poll
    appends arrive later and can subtly reorder the LLM's thread context vs. the
    Slack-primary rebuild (which fetched in ts order).

    FIXED 2026-07-25 — replies are sorted by posted_at (stable, so ties keep
    insertion order); the root stays pinned first, since it is the parent by definition
    even if a skewed writer stamps a reply below it. Note this also re-orders the input to
    get_thread_allowed_agents' first-two-participants rule, which is the intended
    correction and is covered by tests.

  • Mid-life Slack toggling breaks threading. A reply's slack_thread_ts is set to the
    canonical (possibly minted) thread_ts (src/agent/simulation.py:2923). If a
    conversation started Slack-off and Slack is later enabled, that minted id is not a valid
    Slack ts, so the mirrored reply detaches or errors. Design targets all-on or all-off;
    toggling mid-conversation is unsupported.

    FIXED 2026-07-25 — it now degrades safely rather than corrupting the mirror. The
    bug was wider than the DB column: _post_message passed the canonical thread_ts
    straight to client.post_message, so the Slack API call itself was wrong, not just
    the recorded mapping. New _slack_parent_ts() resolves canonical → Slack via the root
    entry's slack_ts; a reply whose root has no Slack presence is kept DB-only with a
    warning instead of being posted against an unknown id. LogEntry gained
    slack_thread_ts, set at every Slack-origin site and used by the flush.
    Prerequisite found while fixing: the rebuild/hydrate paths were dropping slack_ts
    from restored entries entirely, so after any restart every root looked DB-origin — that
    would have turned the fix into a regression that stopped mirroring all replies. They
    now restore the mapping, inferring it for pre-Stage-6 rows (real Slack channel_id +
    NULL slack_ts ⇒ canonical id is the Slack ts).

malanjary-tsri and others added 4 commits July 25, 2026 19:25
Two defects in private_channels.py, both cases of the DB not actually being
the primary store yet.

R4 — the Slack-off migration minted its canonical ids by hand as
f"{time.time():.6f}", the one site the R1 writer-slot fix missed. Those ids
carry no writer slot, so they can coincide with an id minted by the engine or
GrantBot; because these rows go in through the ORM rather than the ON CONFLICT
upsert, the collision aborts the whole migration transaction (channel,
members, handover, close marker) rather than dropping one message. The format
also round-trips microseconds through a float, which cannot hold them at
current epoch magnitudes, and it ignored the minter's high-water mark.

R5 — the Slack-on migration posted the handover and the origin-thread close
marker to Slack and never wrote them to agent_messages. Nothing was lost while
Slack stayed on (the reconcile pass re-reads Slack history), but a Slack-off
restart rebuilt the refinement channel with no handover in it: two bots in a
channel whose purpose is stated only in messages the DB never saw. The marker
was also posted with thread_ts=thread_decision.thread_id — the *canonical* id,
which is not a valid Slack ts once a thread has started Slack-off. That is the
same bug a93d136 fixed in the engine, still live here because the web process
has no MessageLog to resolve the root against.

Both paths now write through one helper, _add_handover_message: canonical id =
Slack ts when the mirror post landed, else minted; slack_* columns only when it
landed. _slack_parent_ts_from_db is the DB-side twin of the engine's resolver,
with the same pre-Stage-6 inference, and the marker stays DB-only (with a
warning) when the root has no Slack presence.

Two incidentals in the lines being touched: _latest_simulation_run_id moved
above the Slack side-effects, so a run-less DB fails before creating an orphan
Slack channel instead of after; and ThreadNotFound on the marker post no longer
aborts a PI-initiated migration.

Tests: three integration tests, each verified to fail against the pre-fix code
(IntegrityError on the duplicate message_ts; no rows written; a minted id handed
to Slack as thread_ts). FakeSlackClient gains _resolve_channel_id.
Full gate green: 515 passed, coverage 38.63%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The log is append-only in insertion order, which is not time order: the DB
inbound poller and the Slack reconcile append entries whose posted_at predates
messages already stored. a93d136 fixed get_thread_history; three sibling reads
were still keying on list position.

- latest_timestamp returned _entries[-1].posted_at, so a cursor taken from it
  could move backwards. It has no callers, which is why it was left open — it
  now reads an O(1) high-water mark maintained by a new _record(), the single
  add path behind append/load_entry.

- get_agent_top_level_posts sliced posts[-limit:] in insertion order. Callers:
  the Phase 5 dedup context (limit=10) and the daily post cap (limit=100). A
  late append of older history pushes a genuinely recent post out of the slice,
  so the LLM loses its most relevant dedup context and the cap under-counts.
  Now sorted by posted_at (stable) before slicing.

- get_last_bot_sender_in_channel scanned reversed(_entries). Callers: the
  collab_private turn-taking gate (three sites). A late-appended *older* message
  answered as "the last poster", handing the turn to the wrong bot — letting one
  post back-to-back, or blocking the one whose turn it was. Now a max-scan on
  posted_at, ties resolved to the later insertion so behaviour is unchanged when
  timestamps collide.

Tests: TestOrderingIsByPostedAtNotInsertion, 7 cases. The three behavioural ones
fail against the pre-fix code (backwards cursor; newest post dropped from the
slice; 'wiseman' != 'su' for the last poster); the other four pin invariants the
fix must not break. Full gate green: 522 passed, coverage 38.71%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The channel poller's bot branch built its LogEntry without slack_ts,
slack_channel_id or slack_thread_ts, while the human branch 20 lines below sets
all three from the same conversations.history response.

The missing column is not the problem; _slack_parent_ts is. An entry with no
slack_ts looks DB-origin, so any thread rooted at a polled bot post resolved to
"no Slack root" and _post_message kept every reply DB-only. The roots this
branch ingests are another workspace bot's posts — i.e. GrantBot's funding
posts, whose threads are open to all agents — so the effect was that replies to
a funding thread silently stopped being mirrored to Slack.

Two things masked it: a restart repairs the mapping (_restored_slack_ts infers
it from a real Slack channel_id), and it only bites for a root polled in during
the current process's lifetime, so the window is "replies to today's funding
post, before the next restart". Found by inspecting live rows after a restart —
60 of 625 rows in the current run carry slack_ts NULL, and the newest are
GrantBot posts sitting in real Slack channels.

Slack-origin means the canonical id IS the Slack ts, so the thread parent needs
no translation, same as the reconcile and proposal-thread pollers already do.

Forward-only: existing NULL rows still rely on the rebuild's inference, which
handles them correctly; nothing backfills the column.

Tests: test_polled_bot_message_keeps_its_slack_mapping drives the real poller
with a canned GrantBot history entry and checks the in-memory mapping,
_slack_parent_ts resolution, and that the mapping survives the flush. Fails
against the pre-fix code (slack_ts is None). Full gate green: 523 passed,
coverage 39.14%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_restored_slack_ts treated "a row in a real Slack channel with slack_ts NULL" as
proof the message was born on Slack, and promoted its canonical id to a Slack ts.
That covers pre-Stage-6 history, but it is unsound: a DB-origin message can carry
a real Slack channel_id by two independent routes — a PI message from the web
inbox (_resolve_channel returns the agent_channels row's id, Slack's when Slack
is on) and an agent post whose mirror failed (_flush_persisted uses
_channel_id_map, likewise Slack's id). Both mint a *local* id, so the inference
fabricates a timestamp Slack never issued. _slack_parent_ts then hands it to
chat.postMessage as a thread_ts: Slack drops the unknown parent, creates an
orphan top-level post, post_message raises ThreadNotFound and the thread gets
evicted.

Nothing on the row separates the two cases — is_bot doesn't, since the
failed-mirror case is a bot row — so the guess is removed rather than narrowed.
slack_ts is now the only evidence of Slack presence; NULL means not on Slack.

Legacy data is repaired instead of guessed at. scripts/backfill_slack_ts.py asks
Slack via conversations.history whether each candidate ts actually exists and
writes slack_ts only for confirmed ones; a failed lookup is reported as
unverified rather than treated as absent, so an expired token cannot mark real
Slack history as DB-origin. Dry-run by default, idempotent.

Verified on the dev DB before this change landed: of 11 candidate rows Slack
confirmed 10 (9 GrantBot posts, 1 polled human message) and denied 1 — a
web-written PI row whose id the old inference would have promoted. The 10 are
backfilled; the 1 correctly keeps NULL.

_slack_parent_ts_from_db (added in 2a2e98c, which copied the inference) is fixed
the same way. test_rebuild_infers_the_slack_ts_of_a_pre_stage6_row pinned the bug
and is replaced by test_rebuild_never_infers_a_slack_ts_from_the_channel_id,
built from the row shape that actually broke.

DEPLOY ORDER: on a workspace with pre-Stage-6 history, run the backfill BEFORE
deploying this — otherwise replies stop being mirrored into legacy Slack threads.

Full gate green: 523 passed, coverage 39.08%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@malanjary-tsri

Copy link
Copy Markdown
Collaborator Author

Additional minor fixes (see commit descriptions) added and testing on su08 show local DB conversations are healthy

@malanjary-tsri
malanjary-tsri merged commit b7edcbc into main Jul 27, 2026
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.

3 participants