Skip to content

feat(api): session attachment resource with protected mount, quotas, and sweep - #5607

Merged
mmabrouk merged 7 commits into
release/v0.107.0from
wp1-api-attachments
Aug 1, 2026
Merged

feat(api): session attachment resource with protected mount, quotas, and sweep#5607
mmabrouk merged 7 commits into
release/v0.107.0from
wp1-api-attachments

Conversation

@mmabrouk

@mmabrouk mmabrouk commented Jul 31, 2026

Copy link
Copy Markdown
Member

A file attached in the agent chat has nowhere to live: the composer collects it, but there is no attachment resource, so the front end inlines base64 into the message and the runner flattens it at the prompt. This PR builds the API half of the fix, work package 1 of 4 of the Stage 1 plan (docs/design/agent-workflows/projects/agent-multi-modality/plans/stage-1-implementation.md, committed in this PR together with protocols/stage-1.md, the full decision trail).

Before: no attachment storage exists; the only file routes are the generic mount routes, which trust the client's media type, buffer unbounded bodies in memory, and let any project member overwrite any mount file.
After: POST /sessions/attachments stores an immutable original in a session-scoped mount through a bounded, idempotent, two-phase create; GET /sessions/attachments/{id}/content serves it back with a session-binding check and the byte-verified media type; POST /sessions/attachments/reference is how a file becomes durable (the runner claims it when accepting a turn). Nothing calls these routes yet; the runner (WP2), the SDK (WP3), and the front end (WP4) follow, stacked on this branch.

The pieces, each traceable to the plan:

  • Two-phase create. Bounded chunked read, magic-bytes classification (puremagic behind an adapter; declared type is never trusted), per-kind caps (10/15/10 MB), a pending → ready row with a client idempotency key, immediate compensation on in-process failures, and per-session quotas (1,000 files, 256 MB, 20 pending).
  • Protected-mount policy. The attachments mount is invisible to every generic mount operation (query, fetch, list, read, download, export, edit, archive, sign, write, delete) via one policy in MountsService, backed by a server-owned purpose column; the wire answer is indistinguishable from a missing mount. Session teardown deliberately still includes it, which is what implements the settled retention rule.
  • Sweep and reaper. An asyncio lifespan task under a Redis lease tombstones eligible rows to a deleting state, deletes objects outside any transaction, and retries on storage errors. Unclaimed uploads die after 24 h; stuck pending rows after 15 min.
  • Gateway. The compose nginx body cap rises to 32m (OSS gh profile only; EE runs no nginx, which is exactly why the route bounds its own read).

The change was adversarially reviewed before this PR opened; the review confirmed the mount policy is closed over every route and found three release blockers (unbounded body spooling, store I/O inside a lock-holding transaction, a wedged idempotency key after failed creates), all fixed in the second commit. The full findings-and-rulings trail is in protocols/stage-1.md.

Implicit decisions and their tradeoffs

Details in protocols/stage-1.md; the ones worth your eyes:

  • Quota enforcement lives in the DAO, under the per-session advisory lock, an accepted layering deviation because the enforcement must sit where the lock sits.
  • Attachment routes use session-domain permissions only (EDIT_SESSIONS/VIEW_SESSIONS/RUN_SESSIONS), deliberately not the dual session+mounts pairs the neighbouring SessionMountsRouter uses: requiring a mounts permission would leak the storage implementation into the authorization model.
  • The media classifier canonicalizes audio containers (Ogg/WebM codec markers, M4A brands) beyond what puremagic reports, because voice recordings arrive in exactly those containers and the audio cap differs from the generic one. Heuristic at the margins, deliberately.
  • A deleting tombstone state exists beyond the designed pending/ready pair so the sweep can be both safe against claims and retryable on storage errors. It never appears on the wire.

Forced routes to double-check

  • Content-Length is the request-size bound (absent → 411, oversized → 413, checked before the form is parsed). A chunked upload without a length header stops working; every client we ship sends one. The alternative was a streaming multipart parser, judged not worth the machinery for a 15 MB-capped route.
  • The public mount-create model no longer exposes purpose even though the core DTO carries it; if a future feature wants client-settable mount purposes, this is the seam to reopen.

QA

ruff clean; 268 unit tests pass. Deployed to the dev stack: the migration applied cleanly on a real database, all nine acceptance tests pass live (zero skips, storage verification forced on), both real-database concurrency tests pass (sweep-versus-claim race, locked quota recheck), and a curl-level wire matrix verified the create/idempotency/conflict/download/claim/411/413/protected-mount behaviors end to end. Deployment QA caught one boot blocker both test suites had missed (a starlette-vs-FastAPI UploadFile annotation broke router import); fixed with an import alias and re-verified live. Full record in protocols/stage-1.md. Remaining: the 15 MB audio upload through the with-nginx profile needs an OSS gh stack (the EE dev stack runs no nginx).

https://claude.ai/code/session_01A1XQVjHPYJgVBHWSNUphtx

@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agenta-documentation Ready Ready Preview Aug 1, 2026 4:36pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Added session attachment uploads, downloads, and referencing.
    • Supports media detection, safe filenames, idempotent uploads, quotas, and size limits.
    • Increased supported request payloads to 32 MB.
  • Bug Fixes
    • Protected attachment storage is hidden from standard mount operations.
    • Improved validation and access controls for missing, foreign, or invalid attachments.
  • Documentation
    • Added implementation and protocol documentation for multimodal session attachments.
  • Maintenance
    • Added automatic cleanup for abandoned and unreferenced attachments.

Walkthrough

This PR adds session attachment storage and APIs. It adds protected attachment mounts, media classification, quotas, idempotent uploads, PostgreSQL lifecycle management, background cleanup, configuration, migrations, and tests.

Changes

Session attachment resource

Layer / File(s) Summary
Attachment contracts and persistence
api/oss/src/core/sessions/attachments/*, api/oss/src/dbs/postgres/sessions/attachments/*, api/oss/databases/postgres/migrations/...
Defines attachment states, media kinds, limits, DAO interfaces, database models, constraints, indexes, and migration changes.
Media classification and upload lifecycle
api/oss/src/core/sessions/attachments/media.py, api/oss/src/core/sessions/attachments/service.py, api/oss/src/core/sessions/attachments/types.py
Classifies content, sanitizes filenames, enforces limits, manages idempotent uploads, handles stale takeover, and performs compensating cleanup.
PostgreSQL lifecycle and concurrency
api/oss/src/dbs/postgres/sessions/attachments/dao.py, api/oss/tests/pytest/unit/sessions/test_attachment_*
Implements reservations, quota accounting, readiness transitions, references, advisory locks, and tombstoned cleanup. Tests cover state transitions, quota races, and cleanup races.

Protected attachment mounts

Layer / File(s) Summary
Protected mount policy and storage
api/oss/src/core/mounts/*, api/oss/src/dbs/postgres/mounts/*, api/oss/src/apis/fastapi/mounts/*
Adds mount purposes, reserved attachment names, protected-mount classification, hidden generic operations, and dedicated original read/write/delete methods.
Protected mount validation
api/oss/tests/pytest/unit/mounts/*, api/oss/tests/pytest/acceptance/mounts/*
Validates protected-mount filtering, generic-operation rejection, lifecycle behavior, reserved names, and dedicated storage access.

Session API and runtime wiring

Layer / File(s) Summary
Session attachment routes
api/oss/src/apis/fastapi/sessions/models.py, api/oss/src/apis/fastapi/sessions/router.py
Adds authenticated upload, content download, and reference endpoints with bounded reads, permission checks, content-length validation, response mapping, and HTTP error translation.
Application wiring and cleanup
api/entrypoints/routers.py, api/oss/src/tasks/asyncio/sessions/attachment_sweep.py, api/oss/src/utils/env.py
Wires the DAO and service, starts and cancels the Redis-locked attachment sweep, and adds environment configuration.
Runtime, infrastructure, and design records
api/pyproject.toml, hosting/docker-compose/oss/nginx/nginx.conf, docs/design/agent-workflows/projects/agent-multi-modality/*
Adds the media classifier dependency, raises the request body limit, and records the implementation plan and protocol decisions.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant SessionAttachmentsRouter
  participant SessionAttachmentsService
  participant SessionAttachmentsDAO
  participant MountsService

  Client->>SessionAttachmentsRouter: Upload multipart attachment
  SessionAttachmentsRouter->>SessionAttachmentsService: Validate and create attachment
  SessionAttachmentsService->>MountsService: Get protected attachment mount
  SessionAttachmentsService->>SessionAttachmentsDAO: Reserve pending record
  SessionAttachmentsService->>MountsService: Write original bytes
  SessionAttachmentsService->>SessionAttachmentsDAO: Mark attachment ready
  SessionAttachmentsRouter-->>Client: Return attachment response
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.21% which is insufficient. The required threshold is 60.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the session attachment resource, protected mounts, quotas, and cleanup sweep added by the changeset.
Description check ✅ Passed The description directly explains the session attachment API, storage lifecycle, quotas, protected mounts, cleanup, and testing reported in the changeset.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch wp1-api-attachments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

*,
session_id: str = Query(...),
) -> SessionAttachmentResponse:
content_length = request.headers.get("content-length")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This Content-Length check is the actual request-size bound, and it must stay ABOVE the request.form() call: declaring the file as a FastAPI File(...) parameter would make the framework spool the entire body to disk before any handler code runs, and EE deployments have no gateway body cap in front of the API. This was the most severe review finding. The chunked _read_bounded below is the second line of defense, not the first.

return f"{_RESERVED_SLUG_PREFIX}session__{uuid5(_MOUNTS_NAMESPACE, session_id)}__{slugify_mount_name(name)}"


def is_protected_mount(mount: Mount) -> bool:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The whole protected-mount policy hangs on this one predicate: every generic mount operation (resolve, query, fetch, export, sign) routes through it, and the review enumerated all 21 routes to confirm none bypasses it. purpose is the durable classification; the slug match is the transitional fallback for rows written before the column existed. The mapped result is a 404 identical to a missing mount, so the wire never reveals the mount exists.

Comment thread api/oss/src/dbs/postgres/sessions/attachments/dao.py
@mmabrouk

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (9)
api/oss/src/core/sessions/attachments/service.py (1)

148-157: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Delete the pending row after the object delete succeeds, not unconditionally.

_delete_original_best_effort swallows failures. If the object delete fails and the row delete then succeeds, the stored object is orphaned with no row and no tombstone. The sweep only finds objects through rows, so nothing retries that delete.

If the row is left in PENDING instead, reap_stale_pending reclaims both the row and the object on a later pass. Make the row delete conditional on a successful object delete.

♻️ Proposed change to keep failed object deletes retryable
         except AttachmentQuotaExceeded:
-            await self._delete_original_best_effort(
+            original_deleted = await self._delete_original_best_effort(
                 project_id=project_id,
                 attachment=reservation.attachment,
             )
-            await self._delete_pending_best_effort(
-                project_id=project_id,
-                attachment_id=reservation.attachment.id,
-            )
+            if original_deleted:
+                await self._delete_pending_best_effort(
+                    project_id=project_id,
+                    attachment_id=reservation.attachment.id,
+                )
             raise

Change _delete_original_best_effort to report the outcome:

     async def _delete_original_best_effort(
         self,
         *,
         project_id: UUID,
         attachment: Attachment,
-    ) -> None:
+    ) -> bool:
         try:
             await self._original_store.delete_attachment_original(
                 project_id=project_id,
                 mount_id=attachment.mount_id,
                 path=attachment.path,
             )
         except Exception:
             log.error(
                 "attachment_create: object compensation failed for %s",
                 attachment.id,
                 exc_info=True,
             )
+            return False
+        return True
api/oss/tests/pytest/unit/sessions/test_attachment_state_machine.py (2)

83-113: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Scope the fake reservation store by project and session.

The real DAO looks up the idempotency key with project_id and session_id in the predicate (api/oss/src/dbs/postgres/sessions/attachments/dao.py lines 350-366). This fake keys self.attachments by idempotency_key alone, so the same key in a different project or session collides and returns CONFLICT instead of creating a new reservation.

fetch_by_idempotency_key in this fake already applies the project and session filters, so the two methods disagree. Key the dictionary by the full tuple to keep the fake faithful to the DAO contract.


549-553: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Import the multipart overhead constant instead of repeating 64 * 1024.

The router computes its cap as max_raw_bytes + _ATTACHMENT_MULTIPART_OVERHEAD_BYTES. This test duplicates that overhead as a literal. If the router constant grows, oversized no longer exceeds the cap and the test stops covering the 413 path.

♻️ Proposed change to bind the test to the router constant
-    oversized = service.limits.max_raw_bytes + (64 * 1024) + 1
+    oversized = (
+        service.limits.max_raw_bytes + _ATTACHMENT_MULTIPART_OVERHEAD_BYTES + 1
+    )

Extend the existing router import:

-from oss.src.apis.fastapi.sessions.router import SessionAttachmentsRouter
+from oss.src.apis.fastapi.sessions.router import (
+    _ATTACHMENT_MULTIPART_OVERHEAD_BYTES,
+    SessionAttachmentsRouter,
+)
api/oss/tests/pytest/unit/sessions/test_attachment_quotas.py (1)

143-171: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The fake dispatches on compiled SQL text, which couples these tests to the entity column list.

The branch at line 152 identifies the row-lock query by the presence of session_attachments.filename in the rendered statement. That fragment appears only because mark_ready selects the whole entity and filename happens to be in the compiled column list. If a column is renamed or removed from SessionAttachmentDBE, this branch stops matching and the test fails at line 171 with Unexpected statement, for a reason unrelated to quota behavior.

Dispatch on a stable property instead, for example the presence of FOR UPDATE together with the absence of an aggregate function. The failure mode today is loud rather than silent, so this is a maintainability improvement, not a correctness defect.

api/oss/src/dbs/postgres/sessions/attachments/dao.py (1)

439-452: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

The per-row referenced_at refresh is redundant under the row lock.

The select at Line 430 already applies WHERE referenced_at IS NULL and FOR UPDATE. The refresh runs in the same transaction, so it reads the same snapshot and the same locked row. A concurrent reference_ready call blocks on that row lock until this transaction commits, so it cannot make referenced_at non-null here. The refresh therefore adds one round trip per candidate row and never changes the outcome. Consider removing it.

♻️ Proposed simplification
             candidates: List[Attachment] = []
             for attachment_dbe in attachment_dbes:
-                if state == AttachmentState.READY:
-                    await session.refresh(
-                        attachment_dbe,
-                        attribute_names=["referenced_at"],
-                    )
-                    if attachment_dbe.referenced_at is not None:
-                        continue
                 candidates.append(
                     map_attachment_dbe_to_dto(attachment_dbe=attachment_dbe)
                 )

If the refresh guards a case that the row lock does not cover, add a comment that names that case.

api/oss/tests/pytest/unit/sessions/test_attachment_dao_integration.py (1)

192-224: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding the reverse race case.

This test proves that a tombstone beats a concurrent claim. The opposite ordering is the other half of the guarantee: if reference_ready commits first, the sweep must skip the row and must not delete the stored object. A test that sets referenced_at before the sweep starts and then asserts that delete_original is never called would cover that path.

api/oss/src/dbs/postgres/mounts/dbas.py (1)

35-39: 🚀 Performance & Scalability | 🔵 Trivial

Consider indexing the purpose predicate.

The purpose column has no index=True, and query_mounts applies MountDBE.purpose.is_distinct_from(...) unconditionally on every call, unlike session_id/agent_id, which are filtered only when the caller supplies a query parameter. Confirm whether the migration adds an index (e.g. a composite (project_id, purpose) index) to keep this predicate cheap as the mounts table grows.

#!/bin/bash
# Description: Check whether the migration adds an index covering `purpose`.
fd 'oss000000020_add_session_attachments.py' | xargs -r cat -n
api/oss/src/core/sessions/attachments/dtos.py (1)

37-37: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Add a non-negative constraint on size.

Attachment.size and AttachmentCreate.size accept any int, including negative values. Downstream quota accounting (AttachmentQuotaUsage.stored_bytes, AttachmentLimits.max_per_session_bytes) assumes non-negative sizes. A negative value would silently corrupt quota math rather than fail immediately.

🛡️ Proposed fix
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
     media_type: str
-    size: int
+    size: int = Field(ge=0)
     kind: AttachmentKind
     state: AttachmentState
     media_type: str
-    size: int
+    size: int = Field(ge=0)
     kind: AttachmentKind
     idempotency_key: str

Also applies to: 52-52

api/oss/tests/pytest/acceptance/sessions/test_session_attachment_teardown.py (1)

56-69: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Avoid duplicating the internal storage-key formula in the test.

_read_original rebuilds the object-store key with f"{prefix}mounts/{project_id}/{mount_id}/{path}", mirroring _storage_key in mounts/service.py. If that internal formula changes, this test keeps testing its own copy of the old formula instead of the real code path, and a genuine bug in key construction could go undetected.

Route this through a shared test helper or the mounts service's own key builder instead of hand-rolling the format here.
[recommended_refactor_effort_placeholder]


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ee4fe5c9-7c8d-4e1a-bcc5-418e8f02ab0e

📥 Commits

Reviewing files that changed from the base of the PR and between ebb22d8 and 3daeb4f.

⛔ Files ignored due to path filters (1)
  • api/uv.lock is excluded by !**/*.lock
📒 Files selected for processing (41)
  • api/entrypoints/routers.py
  • api/oss/databases/postgres/migrations/core_oss/versions/oss000000020_add_session_attachments.py
  • api/oss/src/apis/fastapi/mounts/models.py
  • api/oss/src/apis/fastapi/mounts/router.py
  • api/oss/src/apis/fastapi/sessions/models.py
  • api/oss/src/apis/fastapi/sessions/router.py
  • api/oss/src/core/mounts/dtos.py
  • api/oss/src/core/mounts/service.py
  • api/oss/src/core/mounts/types.py
  • api/oss/src/core/sessions/attachments/__init__.py
  • api/oss/src/core/sessions/attachments/dtos.py
  • api/oss/src/core/sessions/attachments/interfaces.py
  • api/oss/src/core/sessions/attachments/media.py
  • api/oss/src/core/sessions/attachments/service.py
  • api/oss/src/core/sessions/attachments/types.py
  • api/oss/src/dbs/postgres/mounts/dao.py
  • api/oss/src/dbs/postgres/mounts/dbas.py
  • api/oss/src/dbs/postgres/mounts/mappings.py
  • api/oss/src/dbs/postgres/sessions/attachments/__init__.py
  • api/oss/src/dbs/postgres/sessions/attachments/dao.py
  • api/oss/src/dbs/postgres/sessions/attachments/dbas.py
  • api/oss/src/dbs/postgres/sessions/attachments/dbes.py
  • api/oss/src/dbs/postgres/sessions/attachments/mappings.py
  • api/oss/src/tasks/asyncio/sessions/attachment_sweep.py
  • api/oss/src/utils/env.py
  • api/oss/tests/pytest/acceptance/mounts/test_attachments_mount_hidden.py
  • api/oss/tests/pytest/acceptance/sessions/test_session_attachment_teardown.py
  • api/oss/tests/pytest/acceptance/sessions/test_session_attachments.py
  • api/oss/tests/pytest/unit/mounts/test_agent_mounts.py
  • api/oss/tests/pytest/unit/mounts/test_protected_mount_policy.py
  • api/oss/tests/pytest/unit/sessions/test_attachment_dao_integration.py
  • api/oss/tests/pytest/unit/sessions/test_attachment_filename.py
  • api/oss/tests/pytest/unit/sessions/test_attachment_media.py
  • api/oss/tests/pytest/unit/sessions/test_attachment_quotas.py
  • api/oss/tests/pytest/unit/sessions/test_attachment_state_machine.py
  • api/oss/tests/pytest/unit/sessions/test_attachment_sweep.py
  • api/oss/tests/pytest/unit/sessions/test_records_truncation.py
  • api/pyproject.toml
  • docs/design/agent-workflows/projects/agent-multi-modality/plans/stage-1-implementation.md
  • docs/design/agent-workflows/projects/agent-multi-modality/protocols/stage-1.md
  • hosting/docker-compose/oss/nginx/nginx.conf

Comment thread api/oss/src/dbs/postgres/mounts/dao.py
Comment thread api/oss/tests/pytest/unit/sessions/test_attachment_dao_integration.py Outdated
@mmabrouk
mmabrouk force-pushed the wp1-api-attachments branch from 4fad68a to 8905cb8 Compare July 31, 2026 18:36
@mmabrouk
mmabrouk marked this pull request as ready for review July 31, 2026 18:36
@dosubot dosubot Bot added size:XXL This PR changes 1000+ lines, ignoring generated files. Backend Feature Request New feature or request labels Jul 31, 2026
@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Railway Preview Environment

Status Destroyed (PR closed)

Updated at 2026-08-01T18:13:22.767Z

@mmabrouk mmabrouk left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@mmabrouk mmabrouk added the lgtm This PR has been approved by a maintainer label Aug 1, 2026
Comment thread api/oss/src/apis/fastapi/sessions/router.py
@mmabrouk

mmabrouk commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

🤖 The AI agent says:

Review-body items from the CodeRabbit pass: all landed in 56bc0b6 (lockfile in 973ade3).

  • Object delete now precedes the row delete for pending removals, so the sweeper can reclaim failures.
  • The permission check runs before size validation.
  • The mounts-store None guard now covers the new attachment operations.
  • Reference errors name the id that is actually missing.
  • purpose backfills on re-bind.
  • Dedup moved into the DAO.
  • The sweep interval is floored.
  • The storage-key test routes through the real helper instead of duplicating the format.
  • The bounded-read case with an understated Content-Length is now covered.
  • The fake stores are scoped and dispatch on FOR UPDATE.
  • puremagic is capped below 2.

Three items were deliberately skipped.

Computing count from the list would break the uniform response-model convention shared by roughly twenty models in that file, so the explicit field stays. Scoping the nginx body cap and introducing an enum for purpose both stay as-is for now: low value relative to the churn they invite.

@mmabrouk

mmabrouk commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
api/oss/src/core/mounts/types.py (1)

114-118: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add structured context to MountProtected.

MountProtected only stores a free-text message. Sibling exceptions in this file, such as MountSlugReserved, store an identifying attribute (self.slug). Add an identifying attribute, such as mount_id, to MountProtected so callers and logs retain the mount identity even though the router intentionally maps this exception to a generic "Mount not found" response.

As per coding guidelines, "Define domain exceptions in the core layer, include structured context, and use a base exception per domain; do not raise HTTPException from services or DAOs."

♻️ Proposed fix to add structured context
 class MountProtected(MountError):
-    def __init__(self, message: str = "Mount is protected."):
+    def __init__(self, mount_id: str | None = None, message: str = "Mount is protected."):
         super().__init__(message)
+        self.mount_id = mount_id

Source: Coding guidelines

api/oss/tests/pytest/unit/sessions/test_attachment_state_machine.py (1)

98-155: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add test coverage for the DELETING reservation branch.

reserve_pending maps an existing AttachmentState.DELETING row to AttachmentReservationStatus.IN_FLIGHT (Line 124-125). No test in this file exercises this branch. This fake defines the expected reservation contract for the state machine; an untested state transition can hide a mismatch with the real PostgreSQL DAO reservation logic for a duplicate idempotent request that arrives while the original attachment is mid-deletion.

Add a test that creates an existing attachment in AttachmentState.DELETING, retries the same idempotency key, and asserts the resulting behavior (for example, AttachmentUploadInFlight with the expected retry_after_seconds).

api/oss/tests/pytest/unit/sessions/test_attachment_sweep.py (1)

157-307: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

No test asserts successful lock release after a normal sweep pass.

None of the tests check that lock.value becomes None (or equivalent) after run_attachment_sweep completes normally. Add an assertion in at least one happy-path test (for example test_sweep_removes_only_stale_unclaimed_rows) that the lock is released, so a future regression that skips or breaks the finally/_release_lock path is caught.

♻️ Proposed addition
     await _run(dao, store)

     assert {row.id for row in dao.rows} == {claimed_ready.id, fresh_ready.id}
     assert set(store.deleted) == {str(stale_pending.id), str(stale_ready.id)}
+    assert lock.value is None

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1cb636d7-eb17-44c4-8dbf-626ff2b00311

📥 Commits

Reviewing files that changed from the base of the PR and between 3daeb4f and 973ade3.

⛔ Files ignored due to path filters (1)
  • api/uv.lock is excluded by !**/*.lock
📒 Files selected for processing (41)
  • api/entrypoints/routers.py
  • api/oss/databases/postgres/migrations/core_oss/versions/oss000000020_add_session_attachments.py
  • api/oss/src/apis/fastapi/mounts/models.py
  • api/oss/src/apis/fastapi/mounts/router.py
  • api/oss/src/apis/fastapi/sessions/models.py
  • api/oss/src/apis/fastapi/sessions/router.py
  • api/oss/src/core/mounts/dtos.py
  • api/oss/src/core/mounts/service.py
  • api/oss/src/core/mounts/types.py
  • api/oss/src/core/sessions/attachments/__init__.py
  • api/oss/src/core/sessions/attachments/dtos.py
  • api/oss/src/core/sessions/attachments/interfaces.py
  • api/oss/src/core/sessions/attachments/media.py
  • api/oss/src/core/sessions/attachments/service.py
  • api/oss/src/core/sessions/attachments/types.py
  • api/oss/src/dbs/postgres/mounts/dao.py
  • api/oss/src/dbs/postgres/mounts/dbas.py
  • api/oss/src/dbs/postgres/mounts/mappings.py
  • api/oss/src/dbs/postgres/sessions/attachments/__init__.py
  • api/oss/src/dbs/postgres/sessions/attachments/dao.py
  • api/oss/src/dbs/postgres/sessions/attachments/dbas.py
  • api/oss/src/dbs/postgres/sessions/attachments/dbes.py
  • api/oss/src/dbs/postgres/sessions/attachments/mappings.py
  • api/oss/src/tasks/asyncio/sessions/attachment_sweep.py
  • api/oss/src/utils/env.py
  • api/oss/tests/pytest/acceptance/mounts/test_attachments_mount_hidden.py
  • api/oss/tests/pytest/acceptance/sessions/test_session_attachment_teardown.py
  • api/oss/tests/pytest/acceptance/sessions/test_session_attachments.py
  • api/oss/tests/pytest/unit/mounts/test_agent_mounts.py
  • api/oss/tests/pytest/unit/mounts/test_protected_mount_policy.py
  • api/oss/tests/pytest/unit/sessions/test_attachment_dao_integration.py
  • api/oss/tests/pytest/unit/sessions/test_attachment_filename.py
  • api/oss/tests/pytest/unit/sessions/test_attachment_media.py
  • api/oss/tests/pytest/unit/sessions/test_attachment_quotas.py
  • api/oss/tests/pytest/unit/sessions/test_attachment_state_machine.py
  • api/oss/tests/pytest/unit/sessions/test_attachment_sweep.py
  • api/oss/tests/pytest/unit/sessions/test_records_truncation.py
  • api/pyproject.toml
  • docs/design/agent-workflows/projects/agent-multi-modality/plans/stage-1-implementation.md
  • docs/design/agent-workflows/projects/agent-multi-modality/protocols/stage-1.md
  • hosting/docker-compose/oss/nginx/nginx.conf
🚧 Files skipped from review as they are similar to previous changes (33)
  • api/pyproject.toml
  • api/oss/src/core/sessions/attachments/init.py
  • api/oss/src/core/mounts/dtos.py
  • api/oss/src/dbs/postgres/sessions/attachments/init.py
  • api/oss/src/apis/fastapi/mounts/models.py
  • api/oss/src/core/sessions/attachments/media.py
  • api/oss/src/apis/fastapi/sessions/models.py
  • api/oss/src/dbs/postgres/mounts/mappings.py
  • hosting/docker-compose/oss/nginx/nginx.conf
  • api/oss/tests/pytest/unit/sessions/test_attachment_filename.py
  • api/oss/src/dbs/postgres/sessions/attachments/dbes.py
  • api/entrypoints/routers.py
  • api/oss/src/core/sessions/attachments/types.py
  • api/oss/src/dbs/postgres/mounts/dao.py
  • api/oss/tests/pytest/unit/sessions/test_attachment_media.py
  • api/oss/src/dbs/postgres/sessions/attachments/mappings.py
  • api/oss/src/dbs/postgres/sessions/attachments/dbas.py
  • api/oss/tests/pytest/unit/mounts/test_agent_mounts.py
  • api/oss/tests/pytest/unit/sessions/test_attachment_quotas.py
  • api/oss/src/apis/fastapi/mounts/router.py
  • api/oss/src/core/sessions/attachments/interfaces.py
  • api/oss/tests/pytest/acceptance/sessions/test_session_attachments.py
  • api/oss/tests/pytest/acceptance/mounts/test_attachments_mount_hidden.py
  • api/oss/src/dbs/postgres/mounts/dbas.py
  • api/oss/tests/pytest/acceptance/sessions/test_session_attachment_teardown.py
  • api/oss/src/core/sessions/attachments/service.py
  • api/oss/src/utils/env.py
  • api/oss/tests/pytest/unit/sessions/test_records_truncation.py
  • api/oss/src/dbs/postgres/sessions/attachments/dao.py
  • api/oss/databases/postgres/migrations/core_oss/versions/oss000000020_add_session_attachments.py
  • docs/design/agent-workflows/projects/agent-multi-modality/plans/stage-1-implementation.md
  • api/oss/src/apis/fastapi/sessions/router.py
  • api/oss/src/core/mounts/service.py

Comment on lines +141 to +159
@pytest.fixture
def anyio_backend():
return "asyncio"


async def _run(dao, store, lock=None, *, sweep_interval_seconds=60):
await attachment_sweep.run_attachment_sweep(
attachments_dao=dao,
original_store=store,
lock_engine=lock or _FakeLockEngine(),
pending_ttl_seconds=900,
unreferenced_ttl_seconds=86_400,
sweep_interval_seconds=sweep_interval_seconds,
)


@pytest.mark.anyio
async def test_sweep_removes_only_stale_unclaimed_rows(anyio_backend):
assert anyio_backend == "asyncio"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Confirm whether AnyIO's pytest plugin is active for this suite, or whether pytest-asyncio strict mode silently skips these tests.
set -euo pipefail

echo "--- pytest / asyncio_mode config ---"
rg -n 'asyncio_mode|\[tool.pytest' api/pyproject.toml pytest.ini setup.cfg 2>/dev/null || true

echo "--- anyio dependency declaration ---"
rg -n 'anyio' api/pyproject.toml 2>/dev/null || true

echo "--- conftest.py anyio/asyncio references near this test dir ---"
fd conftest.py api/oss/tests | xargs -I{} sh -c 'echo "--- {} ---"; rg -n "anyio|asyncio_mode|pytest_plugins" {}' || true

echo "--- other usages of `@pytest.mark.anyio` vs `@pytest.mark.asyncio` in the suite ---"
rg -n '`@pytest.mark.anyio`' api/oss/tests | wc -l
rg -n '`@pytest.mark.asyncio`' api/oss/tests | wc -l

Repository: Agenta-AI/agenta

Length of output: 739


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "--- dependency and plugin files containing pytest/asyncio/anyio ---"
rg -n 'pytest(-asyncio)?|asyncio_mode|anyio|plugin' pyproject.toml api/pyproject.toml requirements*.txt setup.cfg pytest.ini 2>/dev/null || true

echo "--- test file relevant sections ---"
wc -l api/oss/tests/pytest/unit/sessions/test_attachment_sweep.py
sed -n '1,350p' api/oss/tests/pytest/unit/sessions/test_attachment_sweep.py

echo "--- anyio imports/usages in api/oss/tests/pytest/unit/sessions and nearby pytest tests ---"
rg -n 'import anyio|from anyio|`@pytest`\.mark\.anyio|async def .*anyio|async def _run|test_' api/oss/tests/pytest/unit/sessions test_attachment_sweep.py 2>/dev/null || true

Repository: Agenta-AI/agenta

Length of output: 43073


🌐 Web query:

pytest-asyncio strict mode async function returns coroutine pytest-asyncio v1 default behavior

💡 Result:

In pytest-asyncio, the default mode is strict [1][2][3]. When an async test function is defined but not explicitly marked with @pytest.mark.asyncio, behavior depends on the configured asyncio mode: In strict mode (the default), pytest-asyncio does not treat the async function as a test coroutine [1][4]. If you attempt to run it, pytest may treat it as a standard synchronous test that simply returns a coroutine object [5]. Since the coroutine is never awaited, the test will likely pass (if it executes without error) or behave unexpectedly, as the actual asynchronous logic inside the function is never executed [5][6]. To correctly run an async test function in strict mode, you must explicitly mark it with @pytest.mark.asyncio [1][7]. In auto mode, pytest-asyncio automatically detects async functions and treats them as test coroutines, applying the asyncio marker implicitly, so explicit marking is not required [1][7]. The strict mode is the default behavior to ensure that pytest-asyncio coexists peacefully with other asynchronous testing plugins, as it prevents the plugin from automatically taking ownership of all async functions unless explicitly instructed to do so [1][8].

Citations:


Run these attachment-sweep tests through the active pytest asyncio plugin.

@pytest.mark.anyio only works when AnyIO’s pytest plugin is installed/registered; this dependency list declares pytest-asyncio, not anyio, and no pytest config enables AnyIO. Add AnyIO plugin configuration if you keep these tests as anyio, or change them to @pytest.mark.asyncio; otherwise the async test bodies may never execute under the repo’s pytest setup.

The plan is the adjudicated revision after an independent critical review of the
first draft; its 'Adjudicated decisions' section is the decision log. The
protocol records how it was produced and grows as the work packages land.

Claude-Session: https://claude.ai/code/session_01A1XQVjHPYJgVBHWSNUphtx
…and sweep

A session-scoped attachment original enters through a bounded two-phase create
(pending -> ready, client idempotency key), is served back by a side-effect-free
content route with a session-binding check, and becomes durable only through an
explicit claim route. The attachments mount is invisible to every generic mount
operation via one protected-mount policy backed by a server-owned purpose
column; session teardown still includes it. Per-session quotas bound count,
bytes, and pending uploads. A locked sweep reaps stuck pending rows and
unclaimed originals past their time-to-live. The compose gateway body cap rises
to 32m; the create route bounds its own read because EE deployments have no
gateway cap at all.

Claude-Session: https://claude.ai/code/session_01A1XQVjHPYJgVBHWSNUphtx
The create route verifies Content-Length before the form parse (411 when
absent, 413 over the cap), because declaring the file as a route parameter
spooled the whole body to disk before any check ran and EE deployments have no
gateway body cap. In-process create failures compensate immediately (row and
object removed) so an idempotency key is never wedged behind the pending TTL;
the in-flight 409 carries Retry-After. The sweep tombstones rows to a
'deleting' state under the row lock, deletes objects outside any transaction,
and keeps retryable tombstones on storage errors, so claims fail closed and a
storage outage delays cleanup instead of orphaning objects. Key reuse with
different bytes is a dedicated 409 conflict backed by a content digest; the
protected-mount exclusion moved into the query predicate; filenames are
length-bounded; the public mount-create model no longer exposes the
server-owned purpose field; new real-database integration tests cover the
sweep-versus-claim race and the locked quota recheck.

Claude-Session: https://claude.ai/code/session_01A1XQVjHPYJgVBHWSNUphtx
…as fix

The mount-naming vocabulary moves to core/mounts/types.py so the DAO's
protected-mount predicate derives from the same constants as the service (with
a drift-guard test); the concurrent integration test gets a timeout. Three
CodeRabbit findings were declined on-thread with evidence. Also completes the
FastAPI UploadFile alias on the mount upload route annotation: the earlier
absorb took the import hunk but silently dropped this one, leaving the
committed router unable to boot even though the working tree was correct.

Claude-Session: https://claude.ai/code/session_01A1XQVjHPYJgVBHWSNUphtx
The per-turn count rose to 100, so a session quota of 100 would let one full
turn consume it; the 256 MB byte quota stays the real bound.

Claude-Session: https://claude.ai/code/session_01A1XQVjHPYJgVBHWSNUphtx
@mmabrouk
mmabrouk force-pushed the fix/agent-attach-flag-gate branch from a652f2a to e424506 Compare August 1, 2026 16:35
@mmabrouk
mmabrouk force-pushed the wp1-api-attachments branch from 973ade3 to 97d4367 Compare August 1, 2026 16:35
@mmabrouk
mmabrouk changed the base branch from fix/agent-attach-flag-gate to release/v0.107.0 August 1, 2026 18:12
@mmabrouk
mmabrouk merged commit ac78b00 into release/v0.107.0 Aug 1, 2026
40 of 41 checks passed
@mmabrouk
mmabrouk deleted the wp1-api-attachments branch August 1, 2026 18:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Backend Feature Request New feature or request lgtm This PR has been approved by a maintainer size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant