diff --git a/CHANGELOG.md b/CHANGELOG.md index a60d8a1bf6..62b6dbaf1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,13 @@ This section accumulates work toward the **2.3.0** milestone ### Fixed +- **gateway:** Posting feedback twice for the same run now returns a 409 + instead of a 500. `POST /api/threads/{id}/runs/{run_id}/feedback` allows one + feedback row per user and run; a retry, or feedback on another message of the + same run, hit that unique key and the Gateway failed with an unhandled + database error. The 409 points callers at `PUT` on the same route, which + updates the existing feedback. ([#8064]) + - **sandbox:** AIO commands from independent subagents now run concurrently when they carry per-call environment variables, including Lark CLI commands. Commands within one subagent remain ordered and client cleanup drains them. @@ -16224,3 +16231,5 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#8006]: https://github.com/bytedance/deer-flow/pull/8006 [#8043]: https://github.com/bytedance/deer-flow/pull/8043 [#8057]: https://github.com/bytedance/deer-flow/pull/8057 +[#8064]: https://github.com/bytedance/deer-flow/pull/8064 + diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index f365016a81..b6c0a688da 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -26,6 +26,11 @@ ### 修复 +- **网关:** 同一用户对同一次运行重复提交反馈时,现在返回 409 而不是 500。 + `POST /api/threads/{id}/runs/{run_id}/feedback` 每个用户每次运行只允许一条反馈;此前重试, + 或对同一次运行的另一条消息提交反馈,会触发该唯一约束,Gateway 因未处理的数据库错误而失败。 + 409 会提示调用方改用同一路由的 `PUT` 更新已有反馈。([#8064]) + - **网关:** 删除线程时不再在对话仍可读取的情况下返回成功。此前 `DELETE /api/threads/{id}` 在检查点存储失败时只记录 DEBUG 日志,仍删除线程元数据行并 返回成功;之后任何已登录用户都能通过 `GET /threads/{id}`、`/state`、`/history` 和 @@ -12848,3 +12853,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#7993]: https://github.com/bytedance/deer-flow/pull/7993 [#8006]: https://github.com/bytedance/deer-flow/pull/8006 [#8043]: https://github.com/bytedance/deer-flow/pull/8043 +[#8064]: https://github.com/bytedance/deer-flow/pull/8064 diff --git a/backend/app/gateway/AGENTS.md b/backend/app/gateway/AGENTS.md index e5e02f76ea..624077b6e3 100644 --- a/backend/app/gateway/AGENTS.md +++ b/backend/app/gateway/AGENTS.md @@ -149,7 +149,7 @@ owner-scoped assistant version selection remains enabled. | **Suggestions** (`/api/suggestions`) | `GET /config` - returns global suggestions config boolean; `POST /threads/{id}/suggestions` - generate follow-up questions; rich list/block model content is normalized and inline reasoning (`...`, including unclosed/truncated blocks from reasoning models like MiniMax-M3) is stripped before JSON parsing | | **Input Polish** (`/api/input-polish`) | `POST /` - rewrite a composer draft before it is sent. This is a short authenticated `runs:create` LLM request using `input_polish` config; it does not create a LangGraph run, persist a message, or modify thread state. Shares the non-graph one-shot LLM path (`deerflow.utils.oneshot_llm.run_oneshot_llm`) with the suggestions route so model build + Langfuse metadata + invoke stay in one place; validates the same stripped view of the draft it sends to the model, and preserves literal `` substrings in the rewrite (`strip_think_blocks(truncate_unclosed=False)`) | | **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block. Before the first journaled run, seed an empty feed from a checkpoint so legacy checkpoint-only history keeps its order and visibility; skip absent checkpoints or populated feeds. `POST /regenerate/prepare` - prepare clean input + checkpoint metadata for regenerating the latest completed or interrupted assistant answer, treating a protocol-valid hidden `human_input_response` as confirmed replay input while rejecting other hidden/control humans, preserving that response's hidden/correlation metadata, and carrying the latest non-empty thread title in graph input so resuming an older checkpoint cannot roll back a later manual rename (#4457); `POST /edit-regenerate/prepare` - prepare a checkpoint replay from the latest editable human turn with a replacement user message and edit replay metadata; it carries the current thread title the same way, but only when the replay base already has one — an untitled base belongs to a thread the title middleware has not named yet, so pinning the current title there would keep a name generated from the prompt the edit just replaced; `GET /` - newest 100 runs as an array; `GET /page` - keyset history page `{data, has_more, next_before_created_at, next_before_run_id}`; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `GET /{rid}/stream` hides action/wait; GET action 405 pre-owner; POST needs `runs:cancel`; `GET /{rid}/messages` - paginated per-run messages `{data, has_more}`; `GET /{rid}/events` - full event stream; `GET /{rid}/workspace-changes` - workspace/output file change summary and optional diffs; `GET/POST /{rid}/artifacts/archive` - receipt manifest / bounded ZIP; `GET /../messages` - legacy thread message array; `GET /../messages/page` - backward thread-global `seq` history page with middleware/subagent-AI/successful-regenerate/edit-replay filtering and page-run-scoped feedback enrichment; subagent AI callbacks remain available through run events while parent `task` ToolMessages stay visible for card restoration; `GET /../token-usage` - aggregate tokens plus an optional `context_usage` percentage. Context usage approximately counts messages from the latest materialized thread state through `build_thread_checkpoint_state_accessor`, so full and delta checkpoint modes expose the same input. The percentage uses the latest run's model and its `context_window`. | -| **Feedback** (`/api/threads/{id}/runs/{rid}/feedback`) | `PUT /` - upsert feedback; `DELETE /` - delete user feedback; `POST /` - create feedback; `GET /` - list feedback; `GET /stats` - aggregate stats; `DELETE /{fid}` - delete specific | +| **Feedback** (`/api/threads/{id}/runs/{rid}/feedback`) | `PUT /` - upsert feedback; `DELETE /` - delete user feedback; `POST /` - create feedback (409 if the user already rated this run; use `PUT` to change it); `GET /` - list feedback; `GET /stats` - aggregate stats; `DELETE /{fid}` - delete specific | | **Runs** (`/api/runs`) | `POST /stream`, `/wait` - stateless runs requiring `runs:create`; optional body `thread_id` is owner-checked. Scheduled-task create/update/resume/trigger also require `threads:write` plus `runs:create`. `GET /{rid}/messages`, `/feedback` - run messages/feedback | | **GitHub Webhooks** (`/api/webhooks/github`) | `POST /` - receive GitHub App / repo webhook deliveries. Verifies `X-Hub-Signature-256` against `GITHUB_WEBHOOK_SECRET`; exempt from auth + CSRF because authenticity is enforced by HMAC. The route is fail-closed: mounted only when `GITHUB_WEBHOOK_SECRET` is set, or when explicit dev opt-in `DEER_FLOW_ALLOW_UNVERIFIED_GITHUB_WEBHOOKS=1` is set. Recognized events include `ping`, `issues`, `issue_comment`, `pull_request`, `pull_request_review`, and `pull_request_review_comment`; unknown events return 200 with `handled=false`. Fan-out runtime failures return 503, keeping the delivery recorded as failed for manual/API/scripted redelivery (GitHub does not automatically retry any failed delivery, 5xx included); permanent/non-retryable conditions such as `channels.github.enabled: false`, unknown events, malformed payloads, or unavailable channel service return 200 with a skipped/handled response. | | **GitHub Event-Driven Agents** | Custom agents can declare a `github:` block in their `config.yaml` to bind to repos and event triggers. Webhook fan-out publishes one `InboundMessage` per matching binding to the channel bus; `GitHubChannel` routes those messages through `ChannelManager`. The response `dispatch` summarizes matched/fired/skipped agents. | diff --git a/backend/app/gateway/routers/feedback.py b/backend/app/gateway/routers/feedback.py index c86a9c32c9..b6d6e241c6 100644 --- a/backend/app/gateway/routers/feedback.py +++ b/backend/app/gateway/routers/feedback.py @@ -14,6 +14,7 @@ from pydantic import BaseModel, Field from app.gateway.authz import require_permission from app.gateway.deps import get_current_user, get_feedback_repo, get_run_store +from deerflow.persistence.feedback import FeedbackAlreadyExistsError from deerflow.utils.thread_id import ThreadId logger = logging.getLogger(__name__) @@ -133,14 +134,20 @@ async def create_feedback( raise HTTPException(status_code=404, detail=f"Run {run_id} not found in thread {thread_id}") feedback_repo = get_feedback_repo(request) - return await feedback_repo.create( - run_id=run_id, - thread_id=thread_id, - rating=body.rating, - user_id=user_id, - message_id=body.message_id, - comment=body.comment, - ) + try: + return await feedback_repo.create( + run_id=run_id, + thread_id=thread_id, + rating=body.rating, + user_id=user_id, + message_id=body.message_id, + comment=body.comment, + ) + except FeedbackAlreadyExistsError: + raise HTTPException( + status_code=409, + detail="Feedback already exists for this run; use PUT to update it", + ) from None @router.get("/{thread_id}/runs/{run_id}/feedback", response_model=list[FeedbackResponse]) diff --git a/backend/packages/harness/deerflow/persistence/feedback/__init__.py b/backend/packages/harness/deerflow/persistence/feedback/__init__.py index ee958b0275..ef880a5edf 100644 --- a/backend/packages/harness/deerflow/persistence/feedback/__init__.py +++ b/backend/packages/harness/deerflow/persistence/feedback/__init__.py @@ -1,6 +1,6 @@ """Feedback persistence — ORM and SQL repository.""" from deerflow.persistence.feedback.model import FeedbackRow -from deerflow.persistence.feedback.sql import FeedbackRepository +from deerflow.persistence.feedback.sql import FeedbackAlreadyExistsError, FeedbackRepository -__all__ = ["FeedbackRepository", "FeedbackRow"] +__all__ = ["FeedbackAlreadyExistsError", "FeedbackRepository", "FeedbackRow"] diff --git a/backend/packages/harness/deerflow/persistence/feedback/sql.py b/backend/packages/harness/deerflow/persistence/feedback/sql.py index dec5514c6a..2264f1f1d6 100644 --- a/backend/packages/harness/deerflow/persistence/feedback/sql.py +++ b/backend/packages/harness/deerflow/persistence/feedback/sql.py @@ -19,6 +19,10 @@ from deerflow.utils.time import coerce_iso _UPSERT_MAX_ATTEMPTS = 3 +class FeedbackAlreadyExistsError(Exception): + """The user already has feedback for this run (``uq_feedback_thread_run_user``).""" + + class FeedbackRepository: def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: self._sf = session_factory @@ -42,7 +46,11 @@ class FeedbackRepository: message_id: str | None = None, comment: str | None = None, ) -> dict: - """Create a feedback record. rating must be +1 or -1.""" + """Create a feedback record. rating must be +1 or -1. + + Raises ``FeedbackAlreadyExistsError`` when the user already has + feedback for this run; use ``upsert`` to replace it. + """ if rating not in (1, -1): raise ValueError(f"rating must be +1 or -1, got {rating}") resolved_user_id = resolve_user_id(user_id, method_name="FeedbackRepository.create") @@ -58,7 +66,27 @@ class FeedbackRepository: ) async with self._sf() as session: session.add(row) - await session.commit() + try: + await session.commit() + except IntegrityError as exc: + await session.rollback() + # Only a committed row for this (thread, run, user) key means + # "already exists"; any other violation propagates unchanged. + # A NULL user_id never conflicts on that key. + if resolved_user_id is None: + raise + existing = await session.scalar( + select(FeedbackRow.feedback_id) + .where( + FeedbackRow.thread_id == thread_id, + FeedbackRow.run_id == run_id, + FeedbackRow.user_id == resolved_user_id, + ) + .limit(1) + ) + if existing is None: + raise + raise FeedbackAlreadyExistsError(f"Feedback already exists for run {run_id}") from exc await session.refresh(row) return self._row_to_dict(row) diff --git a/backend/tests/test_feedback.py b/backend/tests/test_feedback.py index 9d4ef4f802..af3a9d7b97 100644 --- a/backend/tests/test_feedback.py +++ b/backend/tests/test_feedback.py @@ -7,7 +7,7 @@ import pytest from _repository_race_helpers import miss_select_factory from sqlalchemy.exc import IntegrityError -from deerflow.persistence.feedback import FeedbackRepository +from deerflow.persistence.feedback import FeedbackAlreadyExistsError, FeedbackRepository from deerflow.persistence.feedback.sql import _UPSERT_MAX_ATTEMPTS @@ -67,6 +67,41 @@ class TestFeedbackRepository: assert record["user_id"] == "user-1" await _cleanup() + @pytest.mark.anyio + async def test_create_twice_for_same_run_and_user_raises_already_exists(self, tmp_path): + """A second create for one (thread, run, user) raises a domain error, not a raw IntegrityError.""" + repo = await _make_feedback_repo(tmp_path) + try: + first = await repo.create(run_id="r1", thread_id="t1", rating=1, user_id="u1", message_id="msg-a") + with pytest.raises(FeedbackAlreadyExistsError): + await repo.create(run_id="r1", thread_id="t1", rating=-1, user_id="u1", message_id="msg-b") + + rows = await repo.list_by_run("t1", "r1", user_id="u1") + assert [(row["feedback_id"], row["rating"]) for row in rows] == [(first["feedback_id"], 1)] + # The session was rolled back, so the repository keeps working. + other = await repo.create(run_id="r1", thread_id="t1", rating=-1, user_id="u2") + assert other["user_id"] == "u2" + finally: + await _cleanup() + + @pytest.mark.anyio + async def test_create_reraises_integrity_errors_other_than_the_run_user_key(self, tmp_path, monkeypatch): + """A violation that is not the (thread, run, user) key, e.g. a feedback_id collision, is not reported as 'already exists'.""" + import uuid + from types import SimpleNamespace + + from deerflow.persistence.feedback import sql as feedback_sql + + fixed_id = uuid.UUID("00000000-0000-4000-8000-000000000001") + monkeypatch.setattr(feedback_sql, "uuid", SimpleNamespace(uuid4=lambda: fixed_id)) + repo = await _make_feedback_repo(tmp_path) + try: + await repo.create(run_id="r1", thread_id="t1", rating=1, user_id="u1") + with pytest.raises(IntegrityError): + await repo.create(run_id="r2", thread_id="t1", rating=1, user_id="u2") + finally: + await _cleanup() + @pytest.mark.anyio async def test_create_invalid_rating_zero(self, tmp_path): repo = await _make_feedback_repo(tmp_path) diff --git a/backend/tests/test_feedback_router.py b/backend/tests/test_feedback_router.py index 4288d9080d..990766d74e 100644 --- a/backend/tests/test_feedback_router.py +++ b/backend/tests/test_feedback_router.py @@ -112,3 +112,88 @@ async def test_concurrent_first_feedback_puts_both_succeed_with_one_row(feedback assert len(rows) == 1 # The later commit wins, as with sequential submissions. assert rows[0]["rating"] in (1, -1) + + +class _CommitBarrierSession: + """Hold each session at commit until every writer has staged its INSERT. + + Both POSTs then commit at once and the database's unique key decides the + winner, which is the interleaving two concurrent first submissions produce. + """ + + def __init__(self, session, barrier: asyncio.Barrier): + self._session = session + self._barrier = barrier + self._waited = False + + def __getattr__(self, name): + return getattr(self._session, name) + + async def commit(self): + if not self._waited: + self._waited = True + async with asyncio.timeout(5): + await self._barrier.wait() + return await self._session.commit() + + async def __aenter__(self): + await self._session.__aenter__() + return self + + async def __aexit__(self, *args): + return await self._session.__aexit__(*args) + + +def _feedback_post_app(feedback_repo): + app = make_authed_test_app(user_factory=_user) + + @app.middleware("http") + async def _session_auth_source(request, call_next): + request.state.auth_source = AUTH_SOURCE_SESSION + return await call_next(request) + + run_store = MagicMock() + run_store.get = AsyncMock(return_value={"run_id": RUN_ID, "thread_id": THREAD_ID}) + app.state.run_store = run_store + app.state.feedback_repo = feedback_repo + app.include_router(feedback.router) + return app + + +def _client(app) -> httpx.AsyncClient: + return httpx.AsyncClient(transport=httpx.ASGITransport(app=app, raise_app_exceptions=False), base_url="http://test") + + +FEEDBACK_URL = f"/api/threads/{THREAD_ID}/runs/{RUN_ID}/feedback" + + +@pytest.mark.anyio +async def test_second_feedback_post_for_same_run_returns_409_not_500(feedback_repo): + async with _client(_feedback_post_app(feedback_repo)) as client: + first = await client.post(FEEDBACK_URL, json={"rating": 1, "message_id": "msg-a"}) + second = await client.post(FEEDBACK_URL, json={"rating": -1, "message_id": "msg-b"}) + + assert first.status_code == 200, first.text + assert second.status_code == 409, second.text + assert "PUT" in second.json()["detail"] + rows = await feedback_repo.list_by_run(THREAD_ID, RUN_ID, user_id=str(_user().id)) + assert [(row["feedback_id"], row["rating"]) for row in rows] == [(first.json()["feedback_id"], 1)] + + +@pytest.mark.anyio +async def test_concurrent_first_feedback_posts_return_one_200_and_one_409(feedback_repo): + barrier = asyncio.Barrier(2) + real_factory = feedback_repo._sf + feedback_repo._sf = lambda: _CommitBarrierSession(real_factory(), barrier) + + async with _client(_feedback_post_app(feedback_repo)) as client: + up, down = await asyncio.gather( + client.post(FEEDBACK_URL, json={"rating": 1}), + client.post(FEEDBACK_URL, json={"rating": -1}), + ) + + assert sorted([up.status_code, down.status_code]) == [200, 409], (up.text, down.text) + winner = up if up.status_code == 200 else down + feedback_repo._sf = real_factory + rows = await feedback_repo.list_by_run(THREAD_ID, RUN_ID, user_id=str(_user().id)) + assert [(row["feedback_id"], row["rating"]) for row in rows] == [(winner.json()["feedback_id"], winner.json()["rating"])]