Database as primary conversations#19
Conversation
…+ 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>
PR #19 "Database as primary conversations" — ReviewWhat this is: an independent verification of PR #19, run against an ephemeral Postgres migrated to head 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)
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 2. Please fix before production (all reproduced at runtime)🔴 H1 — A failed flush silently drops conversation content
🔴 H2 — Inbox cursor can permanently skip a PI message
🟠 M1a/M1b — Canonical-id collision between processes (clobber + 500)The id format
🟠 M2 —
|
| 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_recyclereduces 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_persistedruns a fullCOUNT(*)+SimulationRunupdate every tick with pending rows (cosmetic counter, index-backed but continuous);_rebuild_state_from_dbloads 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 viaEXPLAIN), and each flush is a singleINSERT…ON CONFLICTround-trip. - Modularity:
simulation.pygrew 3423 → 3820 lines; the new persistence/rebuild/poller/minting concerns landed on an already-large class. Consider extracting aMessagePersister/DbInboxPollercollaborator later. - DRY / clean code: the id format (×4) and 9 near-identical
LogEntry(...)builders could be centralized; the PR adds 10 broadexcept Exceptionhandlers, 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. Considerlogger.warningfor 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_messageswithslack_tsset. 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.
_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>
DB-primary conversations — residual failure modes (post H1/H2/M1a fixes)What this is: a deep inspection of the Bottom line: the all-off steady state is functionally solid — a live resume ran 34+
R1 — Cross-process canonical-id collision silently drops a message (confirmed)Severity: medium · Probability: low (grows with run length + PI activity)
Reproduced (independent minters at a fixed instant; then the real on-conflict statement, Fix options: add a per-writer discriminator to the minted id; or re-mint + retry on FIXED 2026-07-25 — per-writer discriminator (option 1). 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 Fix / mitigation (easy): use FIXED 2026-07-25. Three parts, because the doc change alone was not enough:
An in-flight LLM call is still not interruptible — hence R3 — Inbox delivery depends on wall-clock agreement + a fixed 300 s lookbackSeverity: low · Probability: low on a single host, real cross-host Both DB pollers select new rows with FIXED 2026-07-25 — both pollers now page over Minor observations
|
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>
|
Additional minor fixes (see commit descriptions) added and testing on su08 show local DB conversations are healthy |
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