From de2d4315714e47ecb136f98932b93709a37356a2 Mon Sep 17 00:00:00 2001 From: daddy Date: Tue, 15 Sep 2026 00:26:48 +0000 Subject: [PATCH 1/3] Generalize rate_limit.py into a reusable named-limit dependency Adds rate_limit(name, max_hits=, window_seconds=) as a factory for FastAPI dependencies, generalizing the auth_rate_limit pattern for future endpoints (chat send, message report) without touching auth_rate_limit's own behavior. Reuses SlidingWindowLimiter for counting and the existing 429 + Retry-After response, and respects RATE_LIMIT_ENABLED the same way auth_rate_limit does. reset_all() now clears every registered limiter (auth's plus any named ones), so the conftest autouse fixture keeps working for sibling tasks automatically. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD --- backend/app/rate_limit.py | 43 +++++++++++++++++++- backend/tests/test_rate_limit.py | 68 ++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/backend/app/rate_limit.py b/backend/app/rate_limit.py index 28f69dc..6d8bd69 100644 --- a/backend/app/rate_limit.py +++ b/backend/app/rate_limit.py @@ -54,6 +54,12 @@ def _client_key(request: Request) -> str: # brute-forcer. _auth_limiter = SlidingWindowLimiter(max_hits=10, window_seconds=60) +# Every limiter in the process -- named limits built via `rate_limit()` +# below register themselves here too, so `reset_all()` (the test hook) and +# any future process-wide accounting stay correct without each caller +# having to remember to wire it up. +_registered_limiters: list[SlidingWindowLimiter] = [_auth_limiter] + async def auth_rate_limit(request: Request) -> None: """FastAPI dependency: rate-limit auth attempts per client IP. @@ -68,6 +74,41 @@ async def auth_rate_limit(request: Request) -> None: _auth_limiter.check(f"auth:{_client_key(request)}") +def rate_limit(name: str, *, max_hits: int, window_seconds: float): + """Build a named, per-client-IP FastAPI rate-limit dependency. + + Generalizes the ``auth_rate_limit`` pattern above for other endpoints + (e.g. chat send, message report) that want their own independent cap: + + report_rate_limit = rate_limit("message_report", max_hits=5, window_seconds=60) + + @router.post("/report", dependencies=[Depends(report_rate_limit)]) + ... + + Each call gets its own ``SlidingWindowLimiter`` -- counters for one + named limit never bleed into another, even for the same client IP -- + and reuses ``SlidingWindowLimiter.check`` for the actual sliding-window + accounting and the 429 + ``Retry-After`` response. Like + ``auth_rate_limit``, the returned dependency is a no-op when + ``settings.RATE_LIMIT_ENABLED`` is false, so the checkpoint smoke suite + (one client IP for the whole run) doesn't trip on it. + + ``name`` only needs to be unique within the process; it namespaces the + per-IP key so it doesn't collide with ``auth_rate_limit`` or any other + named limit. + """ + limiter = SlidingWindowLimiter(max_hits=max_hits, window_seconds=window_seconds) + _registered_limiters.append(limiter) + + async def _dependency(request: Request) -> None: + if not settings.RATE_LIMIT_ENABLED: + return + limiter.check(f"{name}:{_client_key(request)}") + + return _dependency + + def reset_all() -> None: """Test hook.""" - _auth_limiter.reset() + for limiter in _registered_limiters: + limiter.reset() diff --git a/backend/tests/test_rate_limit.py b/backend/tests/test_rate_limit.py index 4080548..9b56c95 100644 --- a/backend/tests/test_rate_limit.py +++ b/backend/tests/test_rate_limit.py @@ -1,6 +1,7 @@ """The auth endpoints are rate-limited per client IP.""" import pytest +from fastapi import Depends, FastAPI from fastapi.testclient import TestClient from app import rate_limit @@ -54,3 +55,70 @@ def test_limiter_window_is_per_key(): with pytest.raises(Exception): limiter.check("a") limiter.check("b") # different key, unaffected + + +def _make_named_limit_app(): + """A tiny standalone app exercising two independent named limits, so + these tests don't depend on any sibling endpoint existing yet.""" + limit_a = rate_limit.rate_limit("test_named_a", max_hits=2, window_seconds=60) + limit_b = rate_limit.rate_limit("test_named_b", max_hits=2, window_seconds=60) + + app = FastAPI() + + @app.post("/a", dependencies=[Depends(limit_a)]) + def _a(): + return {"ok": True} + + @app.post("/b", dependencies=[Depends(limit_b)]) + def _b(): + return {"ok": True} + + return TestClient(app) + + +def test_named_limits_do_not_share_counters(): + client = _make_named_limit_app() + + # Burn out limit "a" entirely. + assert client.post("/a").status_code == 200 + assert client.post("/a").status_code == 200 + r = client.post("/a") + assert r.status_code == 429 + assert "Retry-After" in r.headers + + # Limit "b" is untouched -- same process, same client IP, different name. + assert client.post("/b").status_code == 200 + assert client.post("/b").status_code == 200 + assert client.post("/b").status_code == 429 + + +def test_named_limit_returns_429_with_retry_after_once_exhausted(): + client = _make_named_limit_app() + client.post("/a") + client.post("/a") + r = client.post("/a") + assert r.status_code == 429 + retry_after = r.headers["Retry-After"] + assert int(retry_after) >= 1 + + +def test_named_limit_is_a_noop_when_rate_limiting_disabled(): + prev = settings.RATE_LIMIT_ENABLED + settings.RATE_LIMIT_ENABLED = False + try: + client = _make_named_limit_app() + codes = [client.post("/a").status_code for _ in range(10)] + assert codes == [200] * 10 + finally: + settings.RATE_LIMIT_ENABLED = prev + + +def test_reset_all_clears_named_limiters_too(): + client = _make_named_limit_app() + client.post("/a") + client.post("/a") + assert client.post("/a").status_code == 429 + + rate_limit.reset_all() + + assert client.post("/a").status_code == 200 From 712d21e74605a25c7feaace244d5f9d794900ed1 Mon Sep 17 00:00:00 2001 From: daddy Date: Tue, 15 Sep 2026 00:28:46 +0000 Subject: [PATCH 2/3] Add groups + group_members models and migration docs/planning/social.md US-5 (task 13). Models + Alembic migration only, per the sibling-dependency boundary with the Groups API task. - groups: id, name, description, owner_id (FK users, ON DELETE SET NULL -- a group is a durable multi-member community, so it and its full roster survive the owner deleting their account; the API task handles surfacing an ownerless group for reassignment). - group_members: group_id/user_id (both FK, ON DELETE CASCADE -- pure join-table rows with no meaning without either side), role (owner/admin/member native enum, default member), joined_at, unique constraint on (group_id, user_id). - User gets owned_groups / group_memberships relationships. - tests/conftest.py TEST_TABLES updated for truncation between tests. - New tests/test_group_models.py: role assignment, unique-membership rejection, group-delete cascade, member-delete cascade, and owner-delete SET NULL survival -- all verified against real Postgres. Verified: upgrade/downgrade/upgrade round-trip, \d-inspected schema matches spec (FKs, cascade behavior, unique constraint, indexes), full backend suite 166 passed / 0 regressions. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD --- .../alembic/versions/0009_add_group_tables.py | 80 ++++++ backend/app/models/__init__.py | 2 + backend/app/models/groups.py | 97 +++++++ backend/app/models/user.py | 11 + backend/tests/conftest.py | 4 + backend/tests/test_group_models.py | 258 ++++++++++++++++++ 6 files changed, 452 insertions(+) create mode 100644 backend/alembic/versions/0009_add_group_tables.py create mode 100644 backend/app/models/groups.py create mode 100644 backend/tests/test_group_models.py diff --git a/backend/alembic/versions/0009_add_group_tables.py b/backend/alembic/versions/0009_add_group_tables.py new file mode 100644 index 0000000..567b009 --- /dev/null +++ b/backend/alembic/versions/0009_add_group_tables.py @@ -0,0 +1,80 @@ +"""add group tables (groups, group_members) + +Revision ID: 0009_add_group_tables +Revises: 0008_add_chat_tables +Create Date: 2026-09-15 00:00:00.000000 + +docs/planning/social.md US-5 / app/models/groups.py. Models + migration only +-- create/join/leave, role authorization, and the group page endpoint live +in a sibling task ("Groups API") that depends on this one. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "0009_add_group_tables" +down_revision: Union[str, None] = "0008_add_chat_tables" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + group_role = postgresql.ENUM("owner", "admin", "member", name="group_role") + group_role.create(op.get_bind(), checkfirst=True) + + op.create_table( + "groups", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column( + "owner_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + op.create_index("ix_groups_owner_id", "groups", ["owner_id"]) + + op.create_table( + "group_members", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "group_id", + sa.Integer(), + sa.ForeignKey("groups.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "user_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "role", + postgresql.ENUM("owner", "admin", "member", name="group_role", create_type=False), + nullable=False, + server_default="member", + ), + sa.Column("joined_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("group_id", "user_id", name="uq_group_members_pair"), + ) + op.create_index("ix_group_members_group_id", "group_members", ["group_id"]) + op.create_index("ix_group_members_user_id", "group_members", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_group_members_user_id", table_name="group_members") + op.drop_index("ix_group_members_group_id", table_name="group_members") + op.drop_table("group_members") + + op.drop_index("ix_groups_owner_id", table_name="groups") + op.drop_table("groups") + + postgresql.ENUM(name="group_role").drop(op.get_bind(), checkfirst=True) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 2398a03..8915c50 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -8,10 +8,12 @@ from app.models.social import Friendship, Follow, Block, FriendshipStatus from app.models.chat import ( Conversation, ConversationParticipant, Message, MessageReport, ReportStatus, ) +from app.models.groups import Group, GroupMember, GroupRole __all__ = [ "User", "Session", "World", "Asset", "AvatarLoadout", "WorldRoom", "WorldChunk", "WorldChunkRevision", "Friendship", "Follow", "Block", "FriendshipStatus", "Conversation", "ConversationParticipant", "Message", "MessageReport", "ReportStatus", + "Group", "GroupMember", "GroupRole", ] diff --git a/backend/app/models/groups.py b/backend/app/models/groups.py new file mode 100644 index 0000000..3675141 --- /dev/null +++ b/backend/app/models/groups.py @@ -0,0 +1,97 @@ +"""Groups: named groups with owner/admin/member roles. + +docs/planning/social.md §4/§5 (US-5). This module is models + schema only -- +create/join/leave, role authorization (only the owner may transfer ownership +or promote/demote admins), and the group page aggregate endpoint belong to +the API router task ("Groups API") that depends on this one. + +Delete-behavior choices: + +- ``groups.owner_id`` -> ``SET NULL`` (nullable). A group is a durable, + multi-member community, not content solely attributable to one person like + a ``World``/``Asset``. Cascading the group away (or hard-blocking the + owner's account deletion) would punish every other member for the owner's + choice to leave the platform. Instead the group and its full membership + list survive with ``owner_id`` nulled out; the sibling API task is + responsible for surfacing an ownerless group (e.g. prompting an admin to + claim ownership) the same way ``Message.sender_id`` / ``WorldChunk``'s + actor columns already treat "the actor is gone" as a survivable state + elsewhere in this schema. +- ``group_members.group_id`` -> ``CASCADE``. A membership row is meaningless + without the group it belongs to; deleting a group deletes its roster, + mirroring ``ConversationParticipant.conversation_id`` in chat.py. +- ``group_members.user_id`` -> ``CASCADE``. Deleting a user removes their own + membership rows wherever they appear, matching the join-table convention + used by ``ConversationParticipant.user_id`` and the social-graph tables + (``Friendship``/``Follow``/``Block``) for plain per-user references. +""" +import enum + +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.dialects.postgresql import ENUM +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.database import Base + + +class GroupRole(str, enum.Enum): + OWNER = "owner" + ADMIN = "admin" + MEMBER = "member" + + +class Group(Base): + """A named group. ``owner_id`` is nullable -- see module docstring for + why the group survives its owner's account deletion.""" + + __tablename__ = "groups" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False) + description = Column(Text, nullable=True) + owner_id = Column( + Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True + ) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) + + owner = relationship("User", back_populates="owned_groups") + members = relationship( + "GroupMember", back_populates="group", cascade="all, delete-orphan" + ) + + +class GroupMember(Base): + """A group's roster: one row per (group, user) with a role.""" + + __tablename__ = "group_members" + + id = Column(Integer, primary_key=True, index=True) + group_id = Column( + Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True + ) + user_id = Column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + role = Column( + ENUM( + GroupRole, + name="group_role", + create_type=False, + values_callable=lambda enum_cls: [e.value for e in enum_cls], + ), + nullable=False, + default=GroupRole.MEMBER, + server_default=GroupRole.MEMBER.value, + ) + joined_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + group = relationship("Group", back_populates="members") + user = relationship("User", back_populates="group_memberships") + + __table_args__ = ( + UniqueConstraint("group_id", "user_id", name="uq_group_members_pair"), + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 7559276..22d1b9c 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -73,6 +73,17 @@ class User(Base): cascade="all, delete-orphan", ) + # Groups (docs/planning/social.md US-5; app/models/groups.py) + owned_groups = relationship( + "Group", + back_populates="owner", + ) + group_memberships = relationship( + "GroupMember", + back_populates="user", + cascade="all, delete-orphan", + ) + @property def is_adult(self) -> bool: """Return True if the user is 18 or older based on date_of_birth.""" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 64612e2..eeee4c1 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -50,6 +50,10 @@ TEST_TABLES = ( # reached by cascading a truncate of messages/conversations; list it # explicitly along with the rest for clarity/consistency. "message_reports", "messages", "conversation_participants", "conversations", + # Groups (app/models/groups.py). groups.owner_id is ON DELETE SET NULL + # (not CASCADE, deliberately -- see groups.py), so it wouldn't be reached + # by cascading a truncate of users; list it explicitly for clarity too. + "group_members", "groups", "sessions", "refresh_tokens", "users", ) diff --git a/backend/tests/test_group_models.py b/backend/tests/test_group_models.py new file mode 100644 index 0000000..76d429e --- /dev/null +++ b/backend/tests/test_group_models.py @@ -0,0 +1,258 @@ +"""Model-level tests for groups: groups and group_members. + +app/models/groups.py / alembic/versions/0009_add_group_tables.py +(docs/planning/social.md US-5). This is the models+migration task only -- +there are no API endpoints yet (a sibling task, "Groups API", owns +create/join/leave/role authorization and the group page endpoint), so these +tests exercise the ORM models and the real Postgres constraints directly +rather than going through HTTP. +""" + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import delete, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool + +from app.config import settings +from app.main import app +from app.models.groups import Group, GroupMember, GroupRole +from app.models.user import User + + +def _register(client: TestClient, username: str) -> int: + resp = client.post( + "/api/auth/register", + json={ + "username": username, + "email": f"{username}@example.com", + "password": "secret123", + "date_of_birth": "1990-01-01", + }, + ) + assert resp.status_code == 201, resp.text + headers = {"Authorization": f"Bearer {resp.json()['access_token']}"} + me = client.get("/api/users/me", headers=headers) + assert me.status_code == 200, me.text + return me.json()["id"] + + +def _session_factory(): + engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool) + return engine, async_sessionmaker(engine, expire_on_commit=False) + + +class TestGroupCreationAndRoles: + async def test_create_group_with_members_of_different_roles(self): + client = TestClient(app) + owner = _register(client, "grp_role_owner") + admin = _register(client, "grp_role_admin") + member = _register(client, "grp_role_member") + + engine, factory = _session_factory() + try: + async with factory() as session: + group = Group(name="Explorers", description="We explore worlds", owner_id=owner) + session.add(group) + await session.flush() + session.add_all( + [ + GroupMember(group_id=group.id, user_id=owner, role=GroupRole.OWNER), + GroupMember(group_id=group.id, user_id=admin, role=GroupRole.ADMIN), + GroupMember(group_id=group.id, user_id=member, role=GroupRole.MEMBER), + ] + ) + await session.commit() + group_id = group.id + + async with factory() as session: + fetched = await session.get(Group, group_id) + members = ( + await session.execute( + select(GroupMember).where(GroupMember.group_id == group_id) + ) + ).scalars().all() + by_user = {m.user_id: m.role for m in members} + assert fetched.owner_id == owner + assert len(members) == 3 + assert by_user[owner] == GroupRole.OWNER + assert by_user[admin] == GroupRole.ADMIN + assert by_user[member] == GroupRole.MEMBER + finally: + await engine.dispose() + + async def test_membership_role_defaults_to_member(self): + client = TestClient(app) + owner = _register(client, "grp_default_owner") + joiner = _register(client, "grp_default_joiner") + + engine, factory = _session_factory() + try: + async with factory() as session: + group = Group(name="Defaults", owner_id=owner) + session.add(group) + await session.flush() + session.add(GroupMember(group_id=group.id, user_id=joiner)) + await session.commit() + group_id = group.id + + async with factory() as session: + gm = ( + await session.execute( + select(GroupMember).where( + GroupMember.group_id == group_id, GroupMember.user_id == joiner + ) + ) + ).scalar_one() + assert gm.role == GroupRole.MEMBER + finally: + await engine.dispose() + + +class TestUniqueMembershipConstraint: + async def test_duplicate_membership_rejected(self): + client = TestClient(app) + owner = _register(client, "grp_uq_owner") + member = _register(client, "grp_uq_member") + + engine, factory = _session_factory() + try: + async with factory() as session: + group = Group(name="Uniqueness", owner_id=owner) + session.add(group) + await session.flush() + session.add(GroupMember(group_id=group.id, user_id=member)) + await session.commit() + group_id = group.id + + async with factory() as session: + session.add(GroupMember(group_id=group_id, user_id=member)) + with pytest.raises(IntegrityError): + await session.commit() + finally: + await engine.dispose() + + +class TestCascadeDelete: + async def test_deleting_group_deletes_members(self): + client = TestClient(app) + owner = _register(client, "grp_cd_group_owner") + member = _register(client, "grp_cd_group_member") + + engine, factory = _session_factory() + try: + async with factory() as session: + group = Group(name="To Delete", owner_id=owner) + session.add(group) + await session.flush() + session.add_all( + [ + GroupMember(group_id=group.id, user_id=owner, role=GroupRole.OWNER), + GroupMember(group_id=group.id, user_id=member), + ] + ) + await session.commit() + group_id = group.id + + async with factory() as session: + # Core delete against groups directly, so the real ON DELETE + # CASCADE does the work (not the ORM). + await session.execute(delete(Group).where(Group.id == group_id)) + await session.commit() + + async with factory() as session: + members = ( + await session.execute( + select(GroupMember).where(GroupMember.group_id == group_id) + ) + ).scalars().all() + assert members == [] + finally: + await engine.dispose() + + async def test_deleting_member_user_removes_only_their_membership(self): + client = TestClient(app) + owner = _register(client, "grp_cd_user_owner") + member = _register(client, "grp_cd_user_member") + + engine, factory = _session_factory() + try: + async with factory() as session: + group = Group(name="Membership Cascade", owner_id=owner) + session.add(group) + await session.flush() + session.add_all( + [ + GroupMember(group_id=group.id, user_id=owner, role=GroupRole.OWNER), + GroupMember(group_id=group.id, user_id=member), + ] + ) + await session.commit() + group_id = group.id + + async with factory() as session: + await session.execute(delete(User).where(User.id == member)) + await session.commit() + + async with factory() as session: + remaining = ( + await session.execute( + select(GroupMember).where(GroupMember.group_id == group_id) + ) + ).scalars().all() + # Only the deleted member's row is gone; the group and the + # owner's membership survive. + assert len(remaining) == 1 + assert remaining[0].user_id == owner + + group = await session.get(Group, group_id) + assert group is not None + assert group.owner_id == owner + finally: + await engine.dispose() + + async def test_deleting_owner_survives_with_owner_id_null(self): + """Deleting the owner's account must not delete the group or its + other members -- only ``owner_id`` is nulled out (SET NULL), per the + module docstring in app/models/groups.py.""" + client = TestClient(app) + owner = _register(client, "grp_cd_owner_owner") + member = _register(client, "grp_cd_owner_member") + + engine, factory = _session_factory() + try: + async with factory() as session: + group = Group(name="Survives Owner Deletion", owner_id=owner) + session.add(group) + await session.flush() + session.add_all( + [ + GroupMember(group_id=group.id, user_id=owner, role=GroupRole.OWNER), + GroupMember(group_id=group.id, user_id=member), + ] + ) + await session.commit() + group_id = group.id + + async with factory() as session: + await session.execute(delete(User).where(User.id == owner)) + await session.commit() + + async with factory() as session: + group = await session.get(Group, group_id) + assert group is not None + assert group.owner_id is None + assert group.name == "Survives Owner Deletion" + + # The owner's own membership row is gone (user_id CASCADE), + # but the other member's row survives untouched. + members = ( + await session.execute( + select(GroupMember).where(GroupMember.group_id == group_id) + ) + ).scalars().all() + assert len(members) == 1 + assert members[0].user_id == member + finally: + await engine.dispose() From c256fb2334bf37de6a9ff335f1a0f3bc962a6c42 Mon Sep 17 00:00:00 2001 From: daddy Date: Tue, 15 Sep 2026 00:33:46 +0000 Subject: [PATCH 3/3] tests/conftest.py: derive the hermetic-bootstrap compose project/ports per checkout Hardcoded _COMPOSE_PROJECT="agp-pytest" + fixed ports meant every concurrent checkout running pytest (e.g. multiple Agent-tool subagents, each in their own workspace clone) fought over the same Docker Compose project and host ports -- one run's pytest_unconfigure teardown could yank the ephemeral Postgres out from under a completely unrelated concurrent run. Confirmed live running 3 parallel subagents: one hit "connection was closed in the middle of operation", another independently hit a transient DeadlockDetectedError from concurrent table truncation on the same shared container. Same root cause and fix pattern as the agent_core workspace/Docker-Compose collision documented in project_agp memory -- derive both the project name and the ports from a hash of the checkout directory so concurrent checkouts land on different projects/ports while a single checkout stays stable across repeated runs. --- backend/tests/conftest.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 64612e2..dcceedc 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -16,6 +16,7 @@ out dozens of times. """ import asyncio +import hashlib import os import socket import subprocess @@ -54,9 +55,23 @@ TEST_TABLES = ( ) BACKEND_DIR = Path(__file__).resolve().parents[1] -_COMPOSE_PROJECT = "agp-pytest" -_EPHEMERAL_PG_PORT = 25432 -_EPHEMERAL_REDIS_PORT = 26379 + +# A hardcoded project name/port here would mean every concurrent checkout +# running `pytest` (e.g. several Claude Agent-tool subagents, each in their +# own ~/agp/workspace/task_ clone) fights over the exact same Docker +# Compose project and host ports -- one run's pytest_unconfigure teardown +# can yank the ephemeral Postgres out from under a completely unrelated +# concurrent run, surfacing as "connection was closed in the middle of +# operation" with no obvious cause. Confirmed live 2026-09-15 running 3 +# parallel subagents. Same root cause and fix pattern as the agent_core +# workspace/Docker-Compose collision documented in project_agp memory -- +# derive both the project name and the port from a hash of the checkout +# directory, so different concurrent checkouts land on different projects/ +# ports while the same checkout is stable across repeated runs. +_WORKSPACE_HASH = hashlib.sha256(str(BACKEND_DIR.parent).encode()).hexdigest()[:10] +_COMPOSE_PROJECT = f"agp-pytest-{_WORKSPACE_HASH}" +_EPHEMERAL_PG_PORT = 25400 + (int(_WORKSPACE_HASH, 16) % 100) +_EPHEMERAL_REDIS_PORT = 26300 + (int(_WORKSPACE_HASH, 16) % 100) # Set by pytest_configure() when it starts container(s) it owns; torn down in # pytest_unconfigure.