Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
176 commits
Select commit Hold shift + click to select a range
b00b0e6
Add cohort agent isolation + reactive-priority scheduler
malanjary-tsri Jul 15, 2026
a7659b4
Stage 1+2: make DB the primary conversation store (Slack-off content …
malanjary-tsri Jul 20, 2026
131104f
Stage 3: transport abstraction + slack_enabled flag + DB inbox poller
malanjary-tsri Jul 20, 2026
5b0c953
Stage 4: Slack-less private-channel migration + generalize DB inbound…
malanjary-tsri Jul 20, 2026
cd072b9
Stage 5: PI web interface for messaging agents without Slack
malanjary-tsri Jul 20, 2026
c8500cd
Stage 6: guard secondary Slack posters + record Slack-mirror mapping
malanjary-tsri Jul 20, 2026
33d31cb
Stage 7: DM persistence (migration 0020) + web DMs + one-time Slack b…
malanjary-tsri Jul 20, 2026
771b655
Fix: import AgentChannel in simulation (Stage 2 _persist_seeded_chann…
malanjary-tsri Jul 21, 2026
09041a1
Populate slack_ts on inbound-polled human messages
malanjary-tsri Jul 21, 2026
c840642
Merge remote-tracking branch 'origin/main' into db-primary-conversations
malanjary-tsri Jul 21, 2026
311deba
Align db-primary branch to the new test-suite layout
malanjary-tsri Jul 21, 2026
1bfb170
test: integration coverage for the DB-native PI inbox
malanjary-tsri Jul 21, 2026
79390d0
H1: re-queue failed message-log flush instead of dropping it
andrewsu Jul 24, 2026
b4a0e14
mint precision + M1: unique ts-shaped ids and human-row-safe upsert
andrewsu Jul 24, 2026
0728ce7
H2: stop the inbox cursor from permanently skipping a PI message
andrewsu Jul 24, 2026
3053fba
B1/B2: bound the per-tick COUNT and the startup state rebuild
andrewsu Jul 24, 2026
9548c08
M2: make the channel-cache a Transport contract; drop dead code
andrewsu Jul 24, 2026
33659a8
R1/R2/R3: close the durability + cross-process gaps Slack used to cover
malanjary-tsri Jul 25, 2026
a93d136
Order thread history by posted_at; never hand a canonical id to Slack
malanjary-tsri Jul 25, 2026
2a2e98c
R4/R5: persist the private-channel handover in both migration paths
malanjary-tsri Jul 26, 2026
baa5583
Order every "most recent" MessageLog read by posted_at, not by insertion
malanjary-tsri Jul 26, 2026
7d8b177
Record the Slack mirror mapping on polled bot messages
malanjary-tsri Jul 26, 2026
10c240c
Stop inferring slack_ts from the channel id; repair legacy rows instead
malanjary-tsri Jul 26, 2026
b7edcbc
Merge pull request #19 from SuLab/db-primary-conversations
malanjary-tsri Jul 27, 2026
8e7536e
Merge cohort-agent-isolation into the DB-primary conversation store
ahueb Jul 30, 2026
8489a31
Implement cohort system v2 on the DB-primary conversation store
ahueb Jul 30, 2026
a5fffb4
Add live end-to-end cohort tests; verify the suite by mutation
ahueb Jul 30, 2026
ac6dfd2
Browser, concurrency, 20-agent and real-API testing; long-run plan
ahueb Jul 30, 2026
d679ae6
Real-API cohort tests: fix two vacuous designs the API exposed
ahueb Jul 30, 2026
ad9edbb
Fix two gate defects a real multi-turn run surfaced
ahueb Jul 30, 2026
f0fb218
Ignore .playwright-mcp/ browser artifacts
ahueb Jul 30, 2026
6a00188
Tasks 1-2: exhaustive §5.1 and §5.3 tables, each with a polarity control
ahueb Jul 30, 2026
103a030
Task 3: pilot-scale gate recompute and mid-run roster churn
ahueb Jul 30, 2026
2d4ea8f
Task 4: pin forward-only cursor semantics (§6.3)
ahueb Jul 30, 2026
439f9b3
Task 5: §7 exemption through all three write paths
ahueb Jul 30, 2026
6dbef70
Task 6: §8 both halves in one test; §13.1 snapshot through start()
ahueb Jul 30, 2026
fc3c7ac
Task 7: parametrised strip table with survival controls; valve at 20 …
ahueb Jul 30, 2026
7280194
Task 8: §11 settings-cached vs membership-live, and matrix save safety
ahueb Jul 30, 2026
71d4111
Task 9: pin the alembic CI gate; add an opt-in migration round trip
ahueb Jul 30, 2026
c9db0f6
Task 10: real-API open-policy and non-transitivity, each controlled
ahueb Jul 30, 2026
3fccf35
Tasks 11-12: real multi-turn scenario harness and five scenarios
ahueb Jul 30, 2026
3ecb934
Task 13: mutation check for the cohort gate — 9/9 killed
ahueb Jul 30, 2026
033ddf3
Slack T1: token validity, source precedence, slack_enabled tri-state
ahueb Jul 31, 2026
9dbc9e0
Slack T2: client wire contract + provisioning; two production bugs fixed
ahueb Jul 31, 2026
cad3683
Slack T0/T3/T4: live tier wired up; 20 tests against the real workspace
ahueb Jul 31, 2026
147e458
Slack T5: the DB<->Slack mirror, live — all four recent fixes confirmed
ahueb Jul 31, 2026
e97f989
Slack T9: the cohort gate x the Slack mirror, live
ahueb Jul 31, 2026
e5979cf
Slack T6/T10/T11: restart, off<->on transitions, failure modes
ahueb Jul 31, 2026
abb1f89
Slack T7: PI interaction over real Slack with the real classifier
ahueb Jul 31, 2026
56507cb
Slack T8: private-channel migration against the real workspace
ahueb Jul 31, 2026
b17a8cc
Slack T12: teardown, mirror mutation check, and flake fixes
ahueb Jul 31, 2026
b4a450b
Remove a real-token prefix from a test fixture
ahueb Jul 31, 2026
acadc96
Full-system T0/T1: live_api tier and ORCID, with fixture-drift detection
ahueb Jul 31, 2026
58f181f
Full-system T2/T3: PubMed and grants.gov live; real drift found
ahueb Jul 31, 2026
5285b81
Full-system T6: the CLI, 7 commands, 15 passed / 2 xfailed
ahueb Jul 31, 2026
ed85a21
Full-system T11: the proposal review loop, 17 passed
ahueb Jul 31, 2026
895709f
Full-system T9: public routes — no private leak, window edges pinned
ahueb Jul 31, 2026
d732804
Full-system T5: the worker, 15 passed — and a silent job-loss path
ahueb Jul 31, 2026
c6e39dd
Full-system T8: agent page, 84 passed / 3 xfailed — a privacy hole
ahueb Jul 31, 2026
cf16665
Full-system T7: onboarding/profile/settings, 73 passed — no auth holes
ahueb Jul 31, 2026
f6842cd
Full-system T4: profile pipeline live — grounding proved empirically
ahueb Jul 31, 2026
c3b01cc
Full-system T10: GrantBot live — every funding post is a title inference
ahueb Jul 31, 2026
6cecb65
T14: independent mutation check — 11/13 killed, two real survivors
ahueb Jul 31, 2026
d0f4ed4
Live Slack tier: deterministic — the flakiness was a production bug
ahueb Jul 31, 2026
c652fcc
T12: browser flows driven; provisioning verified end to end from Slack
ahueb Jul 31, 2026
351676a
T13: the whole system running — Slack silently splits >4000-char mess…
ahueb Jul 31, 2026
d311170
WIP: four fixes implemented offline-green; live Slack tier UNVERIFIED
ahueb Jul 31, 2026
ac5b354
Fix a missing `import re` the pre-push gate caught
ahueb Jul 31, 2026
35ff4bf
Verify fixes 1-3: all three have teeth; my baseline figure was wrong
ahueb Jul 31, 2026
5d8571a
Drop an unused `json` import Fix 2 added
ahueb Jul 31, 2026
8515f65
Fix 4 verified: chokepoint closes all four defects; it also had three…
ahueb Jul 31, 2026
16136a1
docs: ship the cohort spec the code actually cites
ahueb Aug 4, 2026
22d2606
feat: add slack_web, the Slack boundary for the web layer
ahueb Aug 4, 2026
d5f8574
test: pin the _MAX_POST_CHARS invariant that holds defect 2 closed
ahueb Aug 4, 2026
02f5749
fix: make _rebuild_agent_state idempotent, and characterise the live …
ahueb Aug 4, 2026
da405cb
fix: CLI exits nonzero on unknown ORCID, and the backfill is idempotent
ahueb Aug 4, 2026
c3a0e1a
test: cover the cohort admin routes' remaining branches
ahueb Aug 4, 2026
ae12344
fix: split GrantBot's posts, and route it through the Slack boundary
ahueb Aug 4, 2026
5ec66d2
feat: post_message takes thread_ts, so threaded callers can use the b…
ahueb Aug 4, 2026
02143de
fix: close the Slack boundary, the privacy hole, and two identity def…
ahueb Aug 4, 2026
d1005b1
fix: delete the dead onboarding path and the broken profile link
ahueb Aug 4, 2026
bd68fae
chore: clear the lint debt this branch added, and fix the coverage tr…
ahueb Aug 4, 2026
cc8490f
chore: widen the local gate to src lint and the alembic round trip
ahueb Aug 4, 2026
5aeb558
chore: one mutation isolation strategy, and headers that match measur…
ahueb Aug 4, 2026
85a7fea
chore: ignore docs/superpowers/, the plan/scratch working directory
ahueb Aug 4, 2026
fa143a6
fix: keep the Slack boundary off the event loop
ahueb Aug 4, 2026
afe7733
fix: make the grants.gov contract match what grants.gov actually sends
ahueb Aug 4, 2026
46e66fe
test: a partial probe-bot set skips the live Slack tier, it does not …
ahueb Aug 4, 2026
7ac0224
fix: unblock probe-bot provisioning — omittable scopes, and correct c…
ahueb Aug 4, 2026
ae18cdb
fix: a refresh token alone is enough to provision, and say so when it…
ahueb Aug 4, 2026
f017572
fix: the live Slack tier is green — Slack escapes <, >, & and the mir…
ahueb Aug 4, 2026
56c7d48
test: make the no-token assertions hermetic against a provisioned .env
ahueb Aug 4, 2026
356454f
fix: answer a missing cohort the same way everywhere, and drop a dead…
ahueb Aug 5, 2026
cd2bd66
test: run the e2e tier for the first time, and give the mirror harnes…
ahueb Aug 5, 2026
cb4378b
fix: the slack_ts backfill mis-verified every thread reply
ahueb Aug 5, 2026
52de34a
chore: widen the migration round trip to 0018, where the downgrades h…
ahueb Aug 5, 2026
fa2c442
fix(db): bound the migration's lock wait instead of letting it stall …
ahueb Aug 5, 2026
69fc607
fix(web): give the message and DM listings a total ordering
ahueb Aug 5, 2026
28d53b7
feat(migrate): guided, verified migration path to 0023 from 0018 or 0019
ahueb Aug 5, 2026
5fa6219
fix(migrate): accept 0020 and 0021, which is where a deployment track…
ahueb Aug 5, 2026
3411664
docs(spec): org1 parity — the generic blackbird work, minus the product
ahueb Aug 10, 2026
9d11ec0
docs(spec): correct the test_roles.py mechanism — nothing is trimmed
ahueb Aug 10, 2026
17085f5
docs(spec): the Scripps guard needs no edit — 0e1ac52 is what breaks it
ahueb Aug 10, 2026
d57b70a
docs(plan): implementation plan for the org1 parity port
ahueb Aug 10, 2026
5f720aa
docs(plan,spec): fold in the adversarial-audit amendments
ahueb Aug 10, 2026
74abdc3
Merge remote-tracking branch 'origin/copi-prod' into org1-parity
ahueb Aug 10, 2026
d2abb1a
chore: clear unused imports and pin three settings-dependent tests
ahueb Aug 10, 2026
1d55515
feat(roles): prompt-path resolution with per-role fallback
Aug 5, 2026
c56113e
feat(roles): role.toml manifest with tool allow-list and safe fallbacks
Aug 5, 2026
255d429
refactor(agent): role-aware prompt loading; collapse 3 builders into …
Aug 5, 2026
599d71a
feat(db): add agents.role column (migration 0024)
Aug 5, 2026
c70b48b
fix(migrate): advance migration tooling's target from 0023 to 0024
Aug 5, 2026
3250aa6
feat(roster): thread role through roster reads; pick up role changes …
Aug 5, 2026
74ecbd2
feat(tools): per-role tool allow-list, enforced in Phase 4 and the ex…
Aug 5, 2026
a02a781
fix(cohort): scope lab directory to the cohort gate (runbook A3)
Aug 5, 2026
d8341c4
feat(admin): view and set agent role; show role on topology page
Aug 5, 2026
24823c8
test(admin): give the role-set test a tmp roles dir — org1 ships no p…
ahueb Aug 10, 2026
7ed24bc
fix(agent): phase 5 must honour role prompt overrides like every othe…
ahueb Aug 10, 2026
2db3dbb
fix(migrate): plan and verify 0024's column, and pin the head to it
ahueb Aug 10, 2026
5f7ec0e
chore: drop three unused imports the role refactor left behind
ahueb Aug 10, 2026
f9d9685
fix(ci): drop test_roles.py's unused pathlib import — it fails the te…
ahueb Aug 10, 2026
11eeadf
docs(plan): the Path-import fix must precede CHECKPOINT 1, not ride T…
ahueb Aug 10, 2026
e6aa237
docs: design for cohort-scoped conversations feed, threads, topology …
Aug 5, 2026
91469fe
docs: implementation plan for cohort-scoped feed, threads, topology p…
Aug 5, 2026
743374b
fix(admin): topology matrix payload 3,360 fields -> 116
Aug 5, 2026
cb1cd0e
fix(admin): bound the topology cross product by table size, not paylo…
Aug 5, 2026
02443ca
feat(feed): gate_clause — the cohort gate as a SQL predicate
Aug 5, 2026
39ccbe3
feat(feed): resolve_agent_gate via the engine's compute_gates
Aug 5, 2026
2403b1a
docs(feed): clarify resolve_agent_gate docstrings post-review
Aug 5, 2026
0c2e792
fix(feed): cohort-scope the conversations page and select thread roots
Aug 6, 2026
7637651
fix(feed): own-post carve-out, gate the reply count, and pin the regr…
Aug 6, 2026
c22f3f1
feat(feed): thread expand endpoint returning a gated replies partial
Aug 6, 2026
87c3f64
fix(feed): prove replies are actually gated; dedupe channel-set compu…
Aug 6, 2026
ddd9a40
feat(feed): render roots with a reply badge and expand-on-click
Aug 6, 2026
edb5a7a
fix(feed): guard the thread-expand link against a double-click mid-fetch
Aug 6, 2026
1e43699
fix(feed): cover the plural badge and href correctness gaps from review
Aug 6, 2026
354b937
docs(cohort): correct spec/docstrings now that the PI feed is gated (…
Aug 6, 2026
16b0113
fix(feed): scope reply queries to the root's channel; log preflight f…
Aug 6, 2026
f842aa7
test(admin): pin the topology marker/cell cross-product invariant (F5)
Aug 6, 2026
d31e754
docs(cohort): fix the amendment pointer to specs/, not .notes/
Aug 6, 2026
6d8731a
docs(spec): load-proportional budget and scheduling for star topologies
Aug 6, 2026
c4f4753
docs(plan): implementation plan for load-proportional budget/scheduling
Aug 6, 2026
4315607
docs(plan): adversarial audit against HEAD 7f6b304 — fix 4 defects
Aug 6, 2026
2646248
feat(sched): _agent_load — the shared load signal
Aug 6, 2026
e64095c
feat(config): rate-limiter settings + optional per-role allowance
Aug 6, 2026
f2efbd9
feat(sched): call ledger — record_api_call maintains both counters
Aug 6, 2026
d1c440e
fix(roster): adopt a Slack client when a live agent gains a token
Aug 6, 2026
05e867d
feat(sched): sliding-window rate limiter replaces the cumulative cap
Aug 6, 2026
ec03683
feat(sched): rebuild call_times from llm_call_logs within the window
Aug 6, 2026
24b4b02
fix(sched): make step 4b idempotent and DB-test the window query
Aug 6, 2026
18c6f8c
feat(sched): load-proportional selection weight and reactive tiebreak
Aug 6, 2026
9ededfe
feat(cli): deprecate --budget, default it off, document the replacement
Aug 6, 2026
1146586
test(sched): production regression for the run-4f1e8395 hub bench
Aug 6, 2026
3e7f104
fix(ci): make settings-dependent tests hermetic; clear lint debt
Aug 6, 2026
1754415
fix(sched): a throttled roster must back off, not end the run (F1-F5,…
Aug 6, 2026
0e473f5
fix(agent): phases 2 and 4 must honour role prompt overrides like eve…
Aug 6, 2026
38d8952
fix(agent): phase2-prune must also honour role prompt overrides
Aug 6, 2026
10daf36
feat(scout_hub): drive the interview off the screening rubric, not th…
Aug 6, 2026
f5fad2c
fix(llm): detect and log a still-truncated retry; let callers count it
Aug 6, 2026
6af8207
fix(sched): suppress a post that strips to nothing, and tell the caller
ahueb Aug 10, 2026
f2a9e4f
fix(admin): a Slack post with no mappable sender must not 500 /admin/…
ahueb Aug 7, 2026
c145abe
fix(admin,public): close the null-agent_id 500 class, an unauthentica…
ahueb Aug 7, 2026
35e9bf9
fix(llm): finish the truncation fix — generate_with_tools had the sam…
ahueb Aug 10, 2026
2d02879
fix(agent): a suppressed post must not count as a turn
ahueb Aug 10, 2026
4cf7bd5
fix(phase5): a response with no `action` is unparseable, not a new post
ahueb Aug 10, 2026
16064fe
fix(cohort): build the lab directory after the gate, not before
ahueb Aug 7, 2026
5f12ea7
feat(post_types): the canonical vocabulary and the role+topology filter
ahueb Aug 7, 2026
824b1fe
feat(post_types): add legacy idea->idea_crosslab alias resolution
ahueb Aug 7, 2026
1d89e2e
feat(roles): parse a post_types allow-list from role.toml
ahueb Aug 7, 2026
58b58d2
feat(agent): substitute {post_type_menu} in the phase-5 prompt
ahueb Aug 10, 2026
8f4f95b
fix(post_types): dedupe duplicate entries; keep the directory on a ga…
ahueb Aug 10, 2026
036f286
docs(spec): land the post-type gating design, without the draft promp…
ahueb Aug 10, 2026
e6a5c40
docs(plan): Task 19's measured counts — 76 commits, 11 Ported-from tr…
ahueb Aug 10, 2026
fa47e30
Merge prod's live copi-prod state (3e3c379) — the unpushed logging ov…
ahueb Aug 10, 2026
d4ee439
feat(llm): upgrade the agents to Claude Opus 5
ahueb Aug 10, 2026
aaba04b
fix(llm): keep the high-volume agent paths on the Sonnet tier
ahueb Aug 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ data/agent_roster.json
# Local scratch notes
.notes/

# Superpowers working files (implementation plans, per-session scratch). Local
# process artifacts, not project documentation — docs/agent-flow.md and specs/
# are where anything durable belongs.
docs/superpowers/

# Slack provisioning state — holds app client_secrets during a bulk provisioning
# run (scripts/provision_slack_bots.py). Must never be committed.
.provision_state.json
Expand All @@ -75,6 +80,12 @@ data/agent_roster.json
.mutmut-cache
mutants/

# Playwright MCP browser artifacts (console logs, page snapshots)
.playwright-mcp/

# Production migration dumps (scripts/migrate/run_migration.sh). Never commit these:
# they are full database copies containing message bodies and tokens.
backups/
# Local Claude Code state
.claude/

Expand Down
35 changes: 28 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,26 @@

## Testing

Run `python -m pytest tests/ -v` before committing. All tests must pass.
Tests run inside Docker: `docker compose exec app python -m pytest tests/ -v`
(may need `pip install pytest pytest-asyncio` first if the container was rebuilt).
Run `./scripts/ci.sh` before committing — alembic sanity (single head, no
duplicate revision ids), `ruff check` on the test suite, then the full pytest run
with a branch-coverage floor. This is exactly what the `pre-push` hook runs, and
it is the whole gate: there is no server-side CI.

To run pytest alone **inside the container**, `TEST_DATABASE_URL` is required.
Without it `tests/conftest.py` falls back to spinning an ephemeral Postgres via
testcontainers, and the `app` container has no Docker socket — so every test that
needs a database errors out (469 of them, measured 2026-08-04):

```bash
docker compose exec -T -e TEST_DATABASE_URL=postgresql+asyncpg://copi:copi@postgres:5432/copi_a3 \
app python -m pytest tests/ -v
```

The named database must already exist — the suite migrates it, it does not create
it. Add a fresh scratch DB with
`docker compose exec -T postgres createdb -U copi copi_xN`, and give concurrent
suites distinct names so they do not migrate each other's schema mid-run. Never
point `TEST_DATABASE_URL` at `copi`, the dev database.

## Compose file set (read this before any `docker compose` command)

Expand Down Expand Up @@ -68,8 +85,12 @@ C="-f docker-compose.prod.yml -f docker-compose.override.yml"
docker logs agent-run > logs/run_$(date +%s).log 2>&1
ls -t logs/run_*.log | tail -n +11 | xargs rm -f

# 2. Stop the old container
docker rm -f agent-run
# 2. Stop the old container — GRACEFULLY. `docker rm -f` sends SIGKILL, which
# skips the shutdown flush and permanently loses the in-flight turn's
# messages (the DB, not Slack, is the durable store). `docker stop` sends
# SIGTERM; -t 30 leaves room for an in-flight LLM call to finish.
docker stop -t 30 agent-run
docker rm agent-run

# 3. Rebuild app + worker (picks up code changes)
docker compose $C up -d --build app worker
Expand Down Expand Up @@ -135,14 +156,14 @@ rotating pair is persisted in the `app_settings` KV table) and a public `base_ur
roster from the container, then run the script on the host:

```bash
docker exec copi-python-app-1 python scripts/export_agent_roster.py # writes data/agent_roster.json
docker compose exec app python scripts/export_agent_roster.py # writes data/agent_roster.json
python3 scripts/provision_slack_bots.py # host: creates apps, prints OAuth URLs
```

The host script writes tokens to `.env`; import them into the DB column with:

```bash
docker exec copi-python-app-1 python scripts/backfill_agent_tokens.py
docker compose exec app python scripts/backfill_agent_tokens.py
```

(`.env` + `config.py get_slack_tokens()` remain a read fallback, but the DB column is
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,14 @@ Cross-cutting:
```bash
cp .env.example .env # fill in Anthropic, Slack, ORCID, SMTP credentials
docker compose up -d --build app worker postgres

# Migrate. Check for a single head FIRST: two migrations sharing a revision id
# (a stale branch renumbered late) makes `upgrade head` fail on multiple heads,
# and makes a targeted `upgrade <rev>` silently skip one of them while stamping
# the DB as fully migrated. `alembic heads` needs no database.
docker compose exec app alembic heads # must print exactly one line
docker compose exec app alembic upgrade head
docker compose exec app alembic current # confirm it advanced
```

Web UI: <http://localhost:8001>.
Expand Down Expand Up @@ -71,7 +78,8 @@ Before restarting, save logs and rebuild:
```bash
docker logs agent-run > logs/run_$(date +%s).log 2>&1
ls -t logs/run_*.log | tail -n +11 | xargs rm -f
docker rm -f agent-run
docker stop -t 30 agent-run # SIGTERM: lets the engine flush before exit
docker rm agent-run
docker compose up -d --build app worker
docker compose --profile agent run -d --name agent-run agent \
python -m src.agent.main --budget 0
Expand Down
47 changes: 42 additions & 5 deletions alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,39 @@ def run_migrations_offline() -> None:
context.run_migrations()


#: How long a migration will WAIT for a lock before giving up, in milliseconds.
#: 0 disables the bound (Postgres' own default, and what this file did before).
#:
#: Why this exists. `context.configure()` below is deliberately NOT passed
#: `transaction_per_migration`, so the entire upgrade chain runs in ONE
#: transaction. That is good — a killed migration cannot leave a half-applied
#: schema, verified by terminating the backend mid-chain. But it means every lock
#: the chain takes is held until the final commit, and migration 0019 takes
#: ACCESS EXCLUSIVE on `agent_messages` to add three indexes and a unique
#: constraint (which brings a fourth index of its own).
#:
#: With no lock_timeout, `alembic upgrade` parked behind a single open
#: `BEGIN; SELECT …` waits forever — and because a pending ACCESS EXCLUSIVE
#: request queues ahead of new readers, every subsequent query on that table
#: blocks behind it. One forgotten transaction plus a migration is a total stall
#: on the hot table, with no timeout to end it. Failing fast and retrying in a
#: quieter moment is strictly better than an unbounded outage: the transaction
#: rolls back cleanly, so a timeout costs nothing but the attempt.
#:
#: This bounds only the WAIT for a lock. It is not `statement_timeout`, which
#: would cancel a legitimately long index build partway through.
LOCK_TIMEOUT_MS = os.environ.get("ALEMBIC_LOCK_TIMEOUT_MS", "10000")


#: NOTE ON HOW THIS IS APPLIED. It is set as an asyncpg *connect* setting, not by
#: executing `SET lock_timeout = …` on the connection inside do_run_migrations().
#: The obvious version of that is quietly catastrophic: `connection.exec_driver_sql`
#: before `context.begin_transaction()` opens its own transaction, alembic's
#: transaction then nests inside it, and the outer `async with connect()` exits
#: without committing — so every migration LOGS "Running upgrade" and the whole
#: chain SILENTLY ROLLS BACK, leaving no `alembic_version` table at all. Observed
#: while writing this: 18 migrations "applied", nothing persisted. Counting the log
#: lines is not a verification; always re-read `alembic_version` afterwards.
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
Expand All @@ -50,11 +83,15 @@ def do_run_migrations(connection: Connection) -> None:

async def run_async_migrations() -> None:
"""In this scenario we need to create an Engine and associate a connection with the context."""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
cfg = config.get_section(config.config_ini_section, {})
kwargs: dict = {"prefix": "sqlalchemy.", "poolclass": pool.NullPool}
if LOCK_TIMEOUT_MS and LOCK_TIMEOUT_MS != "0":
# asyncpg takes libpq-style GUCs via server_settings, applied at connect
# time — outside any transaction, so it cannot disturb alembic's.
kwargs["connect_args"] = {
"server_settings": {"lock_timeout": str(int(LOCK_TIMEOUT_MS))}
}
connectable = async_engine_from_config(cfg, **kwargs)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
Expand Down
85 changes: 85 additions & 0 deletions alembic/versions/0019_agent_message_content.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""Add conversation-content columns to agent_messages (DB becomes primary store)

Revision ID: 0019
Revises: 0018
Create Date: 2026-07-20 00:00:00.000000

Makes the local DB the primary store for agent conversations: agent_messages now
carries the message body and sender metadata (previously only in Slack + the
in-memory MessageLog), plus nullable Slack-mirror mapping columns. See
specs/local-db-conversations.md.
"""

from typing import Sequence, Union

import sqlalchemy as sa

from alembic import op

revision: str = "0019"
down_revision: Union[str, None] = "0018"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# Content columns — DB is now the durable conversation store.
op.add_column(
"agent_messages",
sa.Column("content", sa.Text(), nullable=False, server_default=""),
)
op.add_column(
"agent_messages",
sa.Column("sender_name", sa.String(100), nullable=False, server_default=""),
)
op.add_column(
"agent_messages",
sa.Column("is_bot", sa.Boolean(), nullable=False, server_default=sa.text("true")),
)
op.add_column(
"agent_messages",
sa.Column("posted_at", sa.Float(), nullable=False, server_default="0"),
)
# Slack-mirror mapping (NULL when Slack is off / message is DB-origin).
op.add_column("agent_messages", sa.Column("slack_ts", sa.String(50), nullable=True))
op.add_column("agent_messages", sa.Column("slack_channel_id", sa.String(100), nullable=True))
op.add_column("agent_messages", sa.Column("slack_thread_ts", sa.String(50), nullable=True))

# agent_id becomes the sender_agent_id: NULL for human/PI messages.
op.alter_column("agent_messages", "agent_id", existing_type=sa.String(50), nullable=True)

# Idempotency + rebuild/mirror indexes.
op.create_unique_constraint(
"uq_agent_messages_run_ts", "agent_messages", ["simulation_run_id", "message_ts"]
)
op.create_index(
"ix_agent_messages_run_posted",
"agent_messages",
["simulation_run_id", "posted_at"],
)
op.create_index(
"ix_agent_messages_run_channel_posted",
"agent_messages",
["simulation_run_id", "channel_name", "posted_at"],
)
op.create_index(
"ix_agent_messages_run_slack_ts",
"agent_messages",
["simulation_run_id", "slack_ts"],
postgresql_where=sa.text("slack_ts IS NOT NULL"),
)


def downgrade() -> None:
op.drop_index("ix_agent_messages_run_slack_ts", table_name="agent_messages")
op.drop_index("ix_agent_messages_run_channel_posted", table_name="agent_messages")
op.drop_index("ix_agent_messages_run_posted", table_name="agent_messages")
op.drop_constraint("uq_agent_messages_run_ts", "agent_messages", type_="unique")
op.alter_column("agent_messages", "agent_id", existing_type=sa.String(50), nullable=False)
op.drop_column("agent_messages", "slack_thread_ts")
op.drop_column("agent_messages", "slack_channel_id")
op.drop_column("agent_messages", "slack_ts")
op.drop_column("agent_messages", "posted_at")
op.drop_column("agent_messages", "is_bot")
op.drop_column("agent_messages", "sender_name")
op.drop_column("agent_messages", "content")
66 changes: 66 additions & 0 deletions alembic/versions/0020_pi_dm_messages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Add pi_dm_messages table (durable PI<->bot direct messages)

Revision ID: 0020
Revises: 0019
Create Date: 2026-07-20 00:00:00.000000

DMs never entered the shared message log, so they had no durable home. This
table stores them so a PI can DM their bot (standing instructions, questions)
with Slack fully off. See specs/local-db-conversations.md.
"""

from typing import Sequence, Union

import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

from alembic import op

revision: str = "0020"
down_revision: Union[str, None] = "0019"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
"pi_dm_messages",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"simulation_run_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("simulation_runs.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("agent_id", sa.String(50), nullable=False),
sa.Column("pi_user_id", sa.String(50), nullable=False),
sa.Column(
"direction",
sa.Enum("inbound", "outbound", name="pi_dm_direction_enum"),
nullable=False,
),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("sender_name", sa.String(100), nullable=False, server_default=""),
sa.Column("ts", sa.String(50), nullable=False),
sa.Column("slack_ts", sa.String(50), nullable=True),
sa.Column("posted_at", sa.Float(), nullable=False, server_default="0"),
sa.Column(
"created_at", sa.DateTime(timezone=True),
server_default=sa.func.now(), nullable=False,
),
)
op.create_index(
"ix_pi_dm_run_agent_posted", "pi_dm_messages",
["simulation_run_id", "agent_id", "posted_at"],
)
op.create_index(
"ix_pi_dm_run_direction_posted", "pi_dm_messages",
["simulation_run_id", "direction", "posted_at"],
)


def downgrade() -> None:
op.drop_index("ix_pi_dm_run_direction_posted", table_name="pi_dm_messages")
op.drop_index("ix_pi_dm_run_agent_posted", table_name="pi_dm_messages")
op.drop_table("pi_dm_messages")
sa.Enum(name="pi_dm_direction_enum").drop(op.get_bind(), checkfirst=True)
39 changes: 39 additions & 0 deletions alembic/versions/0021_inbox_cursor_created_at_indexes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Index the DB inbox pollers' created_at cursor

Revision ID: 0021
Revises: 0020
Create Date: 2026-07-25 00:00:00.000000

Both DB inbox pollers used to page over ``posted_at``, which is derived from the
*writing process's* clock (float of its minted ts). That made inbound PI delivery
depend on every writer's clock agreeing with the engine's to within the lookback
window — fine on one host, silently lossy across hosts. They now page over
``created_at`` (``server_default=now()``, i.e. the single Postgres server's
clock), so these indexes back the new access path. See
.notes/db-conversations-residual-2026-07-24.md (R3).
"""

from typing import Sequence, Union

from alembic import op

revision: str = "0021"
down_revision: Union[str, None] = "0020"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_index(
"ix_agent_messages_run_created", "agent_messages",
["simulation_run_id", "created_at"],
)
op.create_index(
"ix_pi_dm_run_direction_created", "pi_dm_messages",
["simulation_run_id", "direction", "created_at"],
)


def downgrade() -> None:
op.drop_index("ix_pi_dm_run_direction_created", table_name="pi_dm_messages")
op.drop_index("ix_agent_messages_run_created", table_name="agent_messages")
Loading