From 8c19a2eb363a945dc0169950c9831af0efb924f3 Mon Sep 17 00:00:00 2001
From: Vanzeren <53075619+Vanzeren@users.noreply.github.com>
Date: Sat, 25 Jul 2026 21:19:24 +0800
Subject: [PATCH 01/35] perf(checkpoint): linearize message write merging
(#4421)
* perf(checkpoint): linearize message write merging
* test(checkpoint): address message reducer review
---
backend/AGENTS.md | 9 +
.../harness/deerflow/agents/thread_state.py | 99 ++++++++-
backend/tests/test_delta_channel_state.py | 189 +++++++++++++++++-
3 files changed, 290 insertions(+), 7 deletions(-)
diff --git a/backend/AGENTS.md b/backend/AGENTS.md
index 417ebc455..5d3044df0 100644
--- a/backend/AGENTS.md
+++ b/backend/AGENTS.md
@@ -291,6 +291,15 @@ tool graph or subagent executor during state/schema imports.
**ThreadState** (`packages/harness/deerflow/agents/thread_state.py`):
- Extends `AgentState` with: `sandbox`, `thread_data`, `title`, `artifacts`, `todos`, `uploaded_files`, `viewed_images`, `goal`, `promoted`, `delegations`, `skill_context`, `summary_text`
- Uses custom reducers: `merge_artifacts` (deduplicate), `merge_viewed_images` (merge/clear), `merge_goal` (preserve the active goal across ordinary state updates unless the goal writer replaces it), `merge_promoted` (catalog-hash-scoped deferred tool promotions), `merge_delegations` (append task delegation entries, same id latest wins, terminal status never downgraded, capped to the most recent entries), and `merge_skill_context` (dedupe active-skill references by path, keep the most recently read entries; entries store a name/path/description reference, not the SKILL.md body). `summary_text` is a LastValue channel updated by summarization and projected into model requests as durable context data instead of being stored as a `messages` item.
+- Delta-mode `merge_message_writes` normalizes the current message state once,
+ then folds normalized writes in order with message-ID position indexes and
+ deferred tombstone compaction. It preserves public `add_messages` behavior,
+ including duplicate IDs, replacement position, removal errors,
+ `REMOVE_ALL_MESSAGES`, null-write errors, and missing-ID allocation order,
+ without rescanning the accumulated state for every write. Keep this
+ full-parity contract covered by differential tests: LangGraph's private
+ `_messages_delta_reducer` is also linear, but intentionally omits some of
+ those public `add_messages` semantics and cannot be substituted directly.
**Runtime Configuration** (via `config.configurable`):
- `thinking_enabled` - Enable model's extended thinking
diff --git a/backend/packages/harness/deerflow/agents/thread_state.py b/backend/packages/harness/deerflow/agents/thread_state.py
index 385794a67..5784c267d 100644
--- a/backend/packages/harness/deerflow/agents/thread_state.py
+++ b/backend/packages/harness/deerflow/agents/thread_state.py
@@ -1,12 +1,19 @@
import copy
+import uuid
from collections.abc import Mapping, Sequence
from functools import cache
-from typing import Annotated, Any, NotRequired, TypedDict, get_type_hints
+from typing import Annotated, Any, NotRequired, TypedDict, cast, get_type_hints
from langchain.agents import AgentState
-from langchain_core.messages import AnyMessage
+from langchain_core.messages import (
+ AnyMessage,
+ BaseMessageChunk,
+ RemoveMessage,
+ convert_to_messages,
+ message_chunk_to_message,
+)
from langgraph.channels import DeltaChannel
-from langgraph.graph.message import add_messages
+from langgraph.graph.message import REMOVE_ALL_MESSAGES
import deerflow.checkpoint_patches as _checkpoint_patches # noqa: F401 - import-time saver fixes
from deerflow.agents.goal_state import GoalState
@@ -258,11 +265,91 @@ class ThreadState(AgentState):
summary_text: NotRequired[str | None]
+def _normalize_messages(value: Any) -> list[AnyMessage]:
+ values = value if isinstance(value, list) else [value]
+ messages = [message_chunk_to_message(cast(BaseMessageChunk, message)) for message in convert_to_messages(values)]
+ for message in messages:
+ if message.id is None:
+ message.id = str(uuid.uuid4())
+ return messages
+
+
+def _index_messages(
+ messages: list[AnyMessage | None],
+) -> tuple[dict[str, int], dict[str, list[int]]]:
+ latest_position: dict[str, int] = {}
+ positions_by_id: dict[str, list[int]] = {}
+ for position, message in enumerate(messages):
+ if message is None:
+ continue
+ message_id = cast(str, message.id)
+ latest_position[message_id] = position
+ positions_by_id.setdefault(message_id, []).append(position)
+ return latest_position, positions_by_id
+
+
+def _raise_null_write(has_messages: bool) -> None:
+ # ``add_messages(left, None)`` reports only ``left`` when the accumulated
+ # message list is non-empty; with an empty list, it reports only ``right``.
+ received = "left" if has_messages else "right"
+ raise ValueError(f"Must specify non-null arguments for both 'left' and 'right'. Only received: '{received}'.")
+
+
def merge_message_writes(state: list[AnyMessage], writes: Sequence[Any]) -> list[AnyMessage]:
- result = list(state)
+ """Fold DeltaChannel writes with ``add_messages`` semantics in linear time.
+
+ LangGraph's private ``_messages_delta_reducer`` is also linear, but does
+ not preserve the public reducer's full coercion, ID, removal, and
+ ``REMOVE_ALL_MESSAGES`` behavior.
+ """
+ if not writes:
+ return list(state)
+ if writes[0] is None:
+ _raise_null_write(bool(state))
+
+ messages: list[AnyMessage | None] = _normalize_messages(state)
+ latest_position, positions_by_id = _index_messages(messages)
+
for write in writes:
- result = list(add_messages(result, write))
- return result
+ if write is None:
+ _raise_null_write(bool(latest_position))
+ normalized_write = _normalize_messages(write)
+ remove_all_idx = None
+ for position, message in enumerate(normalized_write):
+ if isinstance(message, RemoveMessage) and message.id == REMOVE_ALL_MESSAGES:
+ remove_all_idx = position
+
+ if remove_all_idx is not None:
+ messages = list(normalized_write[remove_all_idx + 1 :])
+ latest_position, positions_by_id = _index_messages(messages)
+ continue
+
+ ids_to_remove: set[str] = set()
+ for message in normalized_write:
+ message_id = cast(str, message.id)
+ existing_position = latest_position.get(message_id)
+ if existing_position is not None:
+ if isinstance(message, RemoveMessage):
+ ids_to_remove.add(message_id)
+ else:
+ ids_to_remove.discard(message_id)
+ messages[existing_position] = message
+ continue
+
+ if isinstance(message, RemoveMessage):
+ raise ValueError(f"Attempting to delete a message with an ID that doesn't exist ('{message_id}')")
+
+ position = len(messages)
+ messages.append(message)
+ latest_position[message_id] = position
+ positions_by_id[message_id] = [position]
+
+ for message_id in ids_to_remove:
+ for position in positions_by_id.pop(message_id):
+ messages[position] = None
+ del latest_position[message_id]
+
+ return [message for message in messages if message is not None]
DELTA_MESSAGES_FIELD = Annotated[
diff --git a/backend/tests/test_delta_channel_state.py b/backend/tests/test_delta_channel_state.py
index d9dcc7bc3..fdcbfa6e1 100644
--- a/backend/tests/test_delta_channel_state.py
+++ b/backend/tests/test_delta_channel_state.py
@@ -1,3 +1,4 @@
+import copy
from typing import get_type_hints
import pytest
@@ -6,7 +7,7 @@ from hypothesis import strategies as st
from langchain.agents import AgentState, create_agent
from langchain.agents.middleware import AgentMiddleware
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
-from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, RemoveMessage
+from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, RemoveMessage, ToolMessageChunk
from langgraph.channels import DeltaChannel
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import StateGraph
@@ -29,6 +30,57 @@ def _fold(state: list, writes: list) -> list:
return result
+def _outcome(call):
+ try:
+ return ("result", call())
+ except Exception as exc:
+ return ("error", type(exc), str(exc))
+
+
+@st.composite
+def _message_merge_cases(draw):
+ message_ids = ["a", "b", "c", "missing"]
+ state_ids = draw(st.lists(st.sampled_from(message_ids[:-1]), max_size=6))
+ state = [
+ {
+ "role": draw(st.sampled_from(["user", "assistant"])),
+ "content": f"state-{index}",
+ "id": message_id,
+ }
+ for index, message_id in enumerate(state_ids)
+ ]
+
+ operation = st.one_of(
+ st.tuples(
+ st.just("message"),
+ st.sampled_from(message_ids),
+ st.sampled_from(["user", "assistant", "ai_chunk", "tool_chunk"]),
+ st.text(max_size=12),
+ ),
+ st.tuples(
+ st.just("remove"),
+ st.sampled_from([*message_ids, REMOVE_ALL_MESSAGES]),
+ st.none(),
+ st.none(),
+ ),
+ )
+ raw_writes = draw(st.lists(st.lists(operation, max_size=6), max_size=8))
+ writes = []
+ for raw_write in raw_writes:
+ write = []
+ for kind, message_id, role, content in raw_write:
+ if kind == "remove":
+ write.append(RemoveMessage(id=message_id))
+ elif role == "ai_chunk":
+ write.append(AIMessageChunk(id=message_id, content=content))
+ elif role == "tool_chunk":
+ write.append(ToolMessageChunk(id=message_id, content=content, tool_call_id=f"call-{message_id}"))
+ else:
+ write.append({"role": role, "content": content, "id": message_id})
+ writes.append(write)
+ return state, writes
+
+
@pytest.mark.parametrize(
"writes",
[
@@ -45,6 +97,16 @@ def test_merge_message_writes_matches_sequential_add_messages(writes: list) -> N
assert merge_message_writes([], writes) == _fold([], writes)
+@given(case=_message_merge_cases())
+def test_merge_message_writes_randomized_differential(case: tuple[list, list]) -> None:
+ state, writes = case
+
+ expected = _outcome(lambda: _fold(copy.deepcopy(state), copy.deepcopy(writes)))
+ actual = _outcome(lambda: merge_message_writes(copy.deepcopy(state), copy.deepcopy(writes)))
+
+ assert actual == expected
+
+
@given(split=st.integers(min_value=0, max_value=3))
def test_merge_message_writes_is_batching_invariant(split: int) -> None:
state = [HumanMessage(id="h0", content="seed")]
@@ -58,6 +120,20 @@ def test_merge_message_writes_is_batching_invariant(split: int) -> None:
assert merge_message_writes(merge_message_writes(state, xs), ys) == merge_message_writes(state, writes)
+@given(case=_message_merge_cases(), data=st.data())
+def test_merge_message_writes_randomized_batching_invariance(case: tuple[list, list], data: st.DataObject) -> None:
+ state, writes = case
+ split = data.draw(st.integers(min_value=0, max_value=len(writes)))
+
+ expected = _outcome(lambda: merge_message_writes(copy.deepcopy(state), copy.deepcopy(writes)))
+
+ def batched():
+ intermediate = merge_message_writes(copy.deepcopy(state), copy.deepcopy(writes[:split]))
+ return merge_message_writes(intermediate, copy.deepcopy(writes[split:]))
+
+ assert _outcome(batched) == expected
+
+
def test_merge_message_writes_matches_unknown_remove_error() -> None:
writes = [[RemoveMessage(id="missing")]]
@@ -69,6 +145,117 @@ def test_merge_message_writes_matches_unknown_remove_error() -> None:
assert str(actual.value) == str(expected.value)
+@pytest.mark.parametrize(
+ ("state", "writes"),
+ [
+ (
+ [HumanMessage(id="duplicate", content="first"), HumanMessage(id="duplicate", content="second")],
+ [[AIMessage(id="duplicate", content="replacement")]],
+ ),
+ (
+ [HumanMessage(id="duplicate", content="first"), HumanMessage(id="duplicate", content="second")],
+ [[RemoveMessage(id="duplicate")]],
+ ),
+ (
+ [HumanMessage(id="same", content="old")],
+ [[RemoveMessage(id="same"), AIMessage(id="same", content="same-write replacement")]],
+ ),
+ (
+ [HumanMessage(id="same", content="old")],
+ [[RemoveMessage(id="same")], [AIMessage(id="same", content="later-write append")]],
+ ),
+ (
+ [HumanMessage(id="seed", content="old")],
+ [
+ [
+ RemoveMessage(id="unknown-but-ignored"),
+ RemoveMessage(id=REMOVE_ALL_MESSAGES),
+ RemoveMessage(id="suffix-is-returned-verbatim"),
+ ]
+ ],
+ ),
+ ],
+ ids=[
+ "duplicate-id-replacement",
+ "duplicate-id-removal",
+ "same-write-remove-then-replace",
+ "cross-write-remove-then-append",
+ "remove-all-short-circuit",
+ ],
+)
+def test_merge_message_writes_preserves_add_messages_edge_semantics(state: list, writes: list) -> None:
+ assert merge_message_writes(copy.deepcopy(state), copy.deepcopy(writes)) == _fold(copy.deepcopy(state), copy.deepcopy(writes))
+
+
+@pytest.mark.parametrize(
+ ("state", "writes"),
+ [
+ ([], [None]),
+ ([HumanMessage(id="seed", content="seed")], [None]),
+ ([], [[HumanMessage(id="added", content="added")], None]),
+ (
+ [HumanMessage(id="removed", content="removed")],
+ [[RemoveMessage(id="removed")], None],
+ ),
+ ],
+)
+def test_merge_message_writes_preserves_null_write_errors(state: list, writes: list) -> None:
+ expected = _outcome(lambda: _fold(copy.deepcopy(state), copy.deepcopy(writes)))
+ actual = _outcome(lambda: merge_message_writes(copy.deepcopy(state), copy.deepcopy(writes)))
+
+ assert actual == expected
+
+
+def test_merge_message_writes_preserves_missing_id_allocation_order(monkeypatch: pytest.MonkeyPatch) -> None:
+ state = [HumanMessage(content="state")]
+ writes = [
+ [AIMessage(content="first"), HumanMessage(content="second")],
+ [AIMessage(content="third")],
+ ]
+
+ expected_ids = iter(["state-id", "first-id", "second-id", "third-id"])
+ monkeypatch.setattr("langgraph.graph.message.uuid.uuid4", lambda: next(expected_ids))
+ expected = _fold(copy.deepcopy(state), copy.deepcopy(writes))
+
+ actual_ids = iter(["state-id", "first-id", "second-id", "third-id"])
+ monkeypatch.setattr("langgraph.graph.message.uuid.uuid4", lambda: next(actual_ids))
+ actual = merge_message_writes(copy.deepcopy(state), copy.deepcopy(writes))
+
+ assert actual == expected
+
+
+def test_merge_message_writes_normalizes_state_and_each_write_once(monkeypatch: pytest.MonkeyPatch) -> None:
+ import deerflow.agents.thread_state as thread_state
+
+ state = [HumanMessage(id="state", content="state")]
+ writes = [
+ [AIMessage(id="first", content="first")],
+ [AIMessage(id="second", content="second")],
+ [AIMessage(id="third", content="third")],
+ ]
+ original = thread_state.convert_to_messages
+ normalized_inputs = []
+
+ def record_conversion(messages):
+ normalized_inputs.append(messages)
+ return original(messages)
+
+ monkeypatch.setattr(thread_state, "convert_to_messages", record_conversion)
+
+ merge_message_writes(state, writes)
+
+ assert normalized_inputs == [state, *writes]
+
+
+def test_merge_message_writes_empty_batch_does_not_assign_state_ids() -> None:
+ state = [HumanMessage(content="unchanged")]
+
+ result = merge_message_writes(state, [])
+
+ assert result == state
+ assert state[0].id is None
+
+
@pytest.mark.parametrize(
"write",
[
From 07d8b9886432baf4d16431ebf6cbdc9f3ddaa76c Mon Sep 17 00:00:00 2001
From: VectorPeak
Date: Sat, 25 Jul 2026 21:43:33 +0800
Subject: [PATCH 02/35] fix(mcp): ignore malformed path-like text (#4456)
Co-authored-by: chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
---
.../packages/harness/deerflow/mcp/tools.py | 5 ++++-
backend/tests/test_mcp_file_migration.py | 20 +++++++++++++++++++
2 files changed, 24 insertions(+), 1 deletion(-)
diff --git a/backend/packages/harness/deerflow/mcp/tools.py b/backend/packages/harness/deerflow/mcp/tools.py
index 9f7d4bc5f..148f82b49 100644
--- a/backend/packages/harness/deerflow/mcp/tools.py
+++ b/backend/packages/harness/deerflow/mcp/tools.py
@@ -70,7 +70,10 @@ def _local_path_from_uri(uri: str, *, base_dir: Path | None = None) -> Path | No
"""
if not uri:
return None
- parsed = urlparse(uri)
+ try:
+ parsed = urlparse(uri)
+ except ValueError:
+ return None
if parsed.scheme == "file":
raw = unquote(parsed.path)
elif parsed.scheme == "":
diff --git a/backend/tests/test_mcp_file_migration.py b/backend/tests/test_mcp_file_migration.py
index 615008b63..235be193e 100644
--- a/backend/tests/test_mcp_file_migration.py
+++ b/backend/tests/test_mcp_file_migration.py
@@ -46,6 +46,9 @@ class TestLocalPathFromUri:
assert mcp_tools._local_path_from_uri("https://example.com/a.png") is None
assert mcp_tools._local_path_from_uri("data:image/png;base64,AAAA") is None
+ def test_malformed_uri_is_ignored(self):
+ assert mcp_tools._local_path_from_uri("//[::1/foo.png") is None
+
def test_relative_path_is_ignored_without_base_dir(self):
assert mcp_tools._local_path_from_uri("relative/path.txt") is None
@@ -192,6 +195,14 @@ class TestRewriteLocalPathsInText:
assert result == text
+ def test_malformed_path_like_text_is_left_untouched(self, paths: Paths):
+ text = "Saved at //[::1/foo.png"
+
+ with _patch_paths(paths):
+ result = mcp_tools._rewrite_local_paths_in_text(text, thread_id="t1", user_id="u1")
+
+ assert result == text
+
def test_playwright_markdown_path_is_rewritten_twice_without_copy(self, paths: Paths):
workspace = paths.sandbox_work_dir("t1", user_id="u1")
_workspace_file(paths, ".playwright-mcp/page.png", content=b"png")
@@ -508,6 +519,15 @@ class TestConvertCallToolResultRewrites:
assert content[0]["type"] == "text"
assert content[0]["text"] == "hello"
+ def test_malformed_path_like_text_result_does_not_raise(self, paths: Paths):
+ result = CallToolResult(content=[TextContent(type="text", text="Saved at //[::1/foo.png")], isError=False)
+
+ with _patch_paths(paths):
+ content, _ = mcp_tools._convert_call_tool_result(result, thread_id="t1", user_id="u1")
+
+ assert content[0]["type"] == "text"
+ assert content[0]["text"] == "Saved at //[::1/foo.png"
+
def test_image_content_passthrough(self, paths: Paths):
from mcp.types import ImageContent
From a65eb531ae28f58299bc14f73365aad5d084b722 Mon Sep 17 00:00:00 2001
From: March-77 <132431415+March-77@users.noreply.github.com>
Date: Sat, 25 Jul 2026 21:55:31 +0800
Subject: [PATCH 03/35] fix(telegram): receive inbound attachments (#4392)
* fix(telegram): receive inbound attachments
* refactor(telegram): tighten inbound attachment handoff
---------
Co-authored-by: Willem Jiang
---
README.md | 1 +
README_zh.md | 1 +
backend/AGENTS.md | 3 +-
backend/app/channels/manager.py | 25 +-
backend/app/channels/message_bus.py | 3 +
backend/app/channels/telegram.py | 306 +++++++++-
.../tests/test_channel_file_attachments.py | 27 +
backend/tests/test_channels.py | 563 +++++++++++++++++-
8 files changed, 886 insertions(+), 43 deletions(-)
diff --git a/README.md b/README.md
index 27e3363c3..732977912 100644
--- a/README.md
+++ b/README.md
@@ -502,6 +502,7 @@ DINGTALK_CLIENT_SECRET=your_client_secret
1. Chat with [@BotFather](https://t.me/BotFather), send `/newbot`, and copy the HTTP API token.
2. Set `TELEGRAM_BOT_TOKEN` in `.env` and enable the channel in `config.yaml`.
+3. The bot accepts inbound text, photos, and documents (with or without captions). Hosted Bot API downloads are limited to 20 MB per attachment.
**Slack Setup**
diff --git a/README_zh.md b/README_zh.md
index af030c5a3..ddd57ca88 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -421,6 +421,7 @@ DINGTALK_CLIENT_SECRET=your_client_secret
1. 打开 [@BotFather](https://t.me/BotFather),发送 `/newbot`,复制生成的 HTTP API token。
2. 在 `.env` 中设置 `TELEGRAM_BOT_TOKEN`,并在 `config.yaml` 里启用该渠道。
+3. 机器人支持接收入站文本、图片和文档(可带说明文字,也可不带);托管版 Bot API 的单个附件下载上限为 20 MB。
**Slack 配置**
diff --git a/backend/AGENTS.md b/backend/AGENTS.md
index 5d3044df0..1bb3a785a 100644
--- a/backend/AGENTS.md
+++ b/backend/AGENTS.md
@@ -666,7 +666,7 @@ Bridges external messaging platforms (Feishu, Slack, Telegram, Discord, DingTalk
A swallowed streaming failure publishes its final outbound before releasing the inbound dedupe key, so a provider redelivery can retry without overtaking the terminal reply.
- `base.py` - Abstract `Channel` base class (start/stop/send lifecycle)
- `service.py` - Manages lifecycle of all configured channels from `config.yaml`
-- `slack.py` / `feishu.py` / `telegram.py` / `discord.py` / `dingtalk.py` - Platform-specific implementations (`feishu.py` tracks the running card `message_id` in memory and patches the same card in place; `telegram.py` registers the "Working on it..." placeholder as the stream target and edits it in place via `editMessageText`; `dingtalk.py` optionally uses AI Card streaming for in-place updates when `card_template_id` is configured)
+- `slack.py` / `feishu.py` / `telegram.py` / `discord.py` / `dingtalk.py` - Platform-specific implementations (`feishu.py` tracks the running card `message_id` in memory and patches the same card in place; `telegram.py` accepts inbound text/photos/documents, preserves media captions, hands token-free attachment bytes to the shared upload pipeline, and edits the "Working on it..." stream target in place via `editMessageText`; `dingtalk.py` optionally uses AI Card streaming for in-place updates when `card_template_id` is configured)
- `github.py` - Webhook-driven GitHub channel. Inbound messages come from `POST /api/webhooks/github`; outbound is log-only because GitHub agents post explicitly with `gh` from their sandbox when they choose to comment or create a PR
- `app/gateway/routers/channel_connections.py` - Browser-facing user connection and disconnect APIs
- `deerflow.persistence.channel_connections` - SQL-backed user-owned connection, optional credential, connect state, and conversation store
@@ -674,6 +674,7 @@ Bridges external messaging platforms (Feishu, Slack, Telegram, Discord, DingTalk
**Message Flow**:
1. External platform -> Channel impl -> `MessageBus.publish_inbound()`
- For GitHub, the webhook router verifies the delivery then calls `fanout_event(bus, ...)`; matching agent bindings publish one `InboundMessage` each instead of a long-polling channel worker.
+ - Telegram photo/document updates use the largest photo size or document metadata, preserve `message.caption`, enforce the hosted Bot API's 20,000,000-byte download ceiling before and after download, and never expose the token-bearing Bot API file URL. Downloaded bytes cross the adapter/manager boundary only through `message_bus.INBOUND_FILE_CONTENT_KEY`; the manager consumes that transient field before persisting safe upload metadata.
2. `ChannelManager._dispatch_loop()` consumes from queue
3. For user-owned channel connections, incoming messages carry `connection_id`, `owner_user_id`, and `workspace_id`; `owner_user_id` becomes the DeerFlow run `user_id`, while the raw platform user id remains `channel_user_id`. The Gateway accepts `channel_user_id` only from an internally authenticated channel caller's top-level `body.context`, clears it from both free-form `body.config` sections, and writes it into runtime context only (never `configurable`, which is checkpointed). `bash_tool` exposes it to sandbox commands as the fixed env var `DEERFLOW_CHANNEL_USER_ID` — via a shell-quoted command-string prefix, NOT the `execute_command(env=...)` channel, which is reserved for request-scoped secrets and would switch `AioSandbox` onto the `bash.exec` path (image >= 1.9.3, fresh session per call). Per-call injection keeps group-chat identity correct (one thread/sandbox, many senders) **without depending on the AIO shell's session semantics**: every IM-channel command carries an explicit `export VAR=; ` (valid id) or `unset VAR; ` (empty / non-str / over the 256-char cap). The AIO no-env path reuses a persistent shell session (the reason for the class lock, #1433), so a bare command could otherwise resolve a stale id an earlier sender exported; the `unset` closes the window the length/type guard would open (a dropped id would inherit the previous sender's value). Non-IM runs (no `channel_user_id` in context) are left untouched. Not injected on the Windows local sandbox (its PowerShell/cmd.exe fallback has no `export`/`unset`). Propagates across `task` delegation: `task_tool` captures the dispatching turn's id and the subagent executor forwards it into the subagent's runtime context, same as the guardrail attribution fields. The runtime-context value is authorization-grade at the Gateway/guardrail boundary, but the exported shell variable remains informational because any bash command can overwrite its own environment; skills must not treat the shell variable itself as authenticated identity. Tests: `tests/test_gateway_services.py`, `tests/test_channel_user_id_env.py`
4. For chat: look up/create thread through Gateway's LangGraph-compatible API
diff --git a/backend/app/channels/manager.py b/backend/app/channels/manager.py
index a50c99c68..30a9a18fe 100644
--- a/backend/app/channels/manager.py
+++ b/backend/app/channels/manager.py
@@ -21,6 +21,7 @@ from langgraph_sdk.errors import ConflictError
from app.channels import feishu_run_policy as _feishu_run_policy # noqa: F401
from app.channels.commands import KNOWN_CHANNEL_COMMANDS
from app.channels.message_bus import (
+ INBOUND_FILE_CONTENT_KEY,
PENDING_CLARIFICATION_METADATA_KEY,
InboundMessage,
InboundMessageType,
@@ -833,15 +834,21 @@ async def _ingest_inbound_files(thread_id: str, msg: InboundMessage, *, user_id:
ftype = f.get("type") if isinstance(f.get("type"), str) else "file"
filename = f.get("filename") if isinstance(f.get("filename"), str) else ""
- try:
- data = await file_reader(f, client)
- except Exception:
- logger.exception(
- "[Manager] failed to read inbound file: channel=%s, file=%s",
- msg.channel_name,
- f.get("url") or filename or idx,
- )
- continue
+ inline_content = f.pop(INBOUND_FILE_CONTENT_KEY, None)
+ if isinstance(inline_content, bytes):
+ data = inline_content
+ elif isinstance(inline_content, (bytearray, memoryview)):
+ data = bytes(inline_content)
+ else:
+ try:
+ data = await file_reader(f, client)
+ except Exception:
+ logger.exception(
+ "[Manager] failed to read inbound file: channel=%s, file=%s",
+ msg.channel_name,
+ f.get("url") or filename or idx,
+ )
+ continue
if data is None:
logger.warning(
diff --git a/backend/app/channels/message_bus.py b/backend/app/channels/message_bus.py
index e04f51006..25fc3f1d8 100644
--- a/backend/app/channels/message_bus.py
+++ b/backend/app/channels/message_bus.py
@@ -15,6 +15,9 @@ logger = logging.getLogger(__name__)
PENDING_CLARIFICATION_METADATA_KEY = "pending_clarification"
RESOLVED_FROM_PENDING_CLARIFICATION_METADATA_KEY = "resolved_from_pending_clarification"
+# Adapter-owned bytes may use this transient key while crossing the channel
+# boundary. ChannelManager consumes and removes it before persisting metadata.
+INBOUND_FILE_CONTENT_KEY = "_content"
# ---------------------------------------------------------------------------
diff --git a/backend/app/channels/telegram.py b/backend/app/channels/telegram.py
index 14ee28a4f..eca123f26 100644
--- a/backend/app/channels/telegram.py
+++ b/backend/app/channels/telegram.py
@@ -6,15 +6,29 @@ import asyncio
import logging
import threading
import time
+from collections.abc import Coroutine
from typing import Any
from app.channels.base import Channel
from app.channels.connection_identity import attach_connection_identity
-from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
+from app.channels.message_bus import (
+ INBOUND_FILE_CONTENT_KEY,
+ InboundMessage,
+ InboundMessageType,
+ MessageBus,
+ OutboundMessage,
+ ResolvedAttachment,
+)
+from deerflow.uploads.manager import is_upload_staging_file, normalize_filename
logger = logging.getLogger(__name__)
TELEGRAM_MAX_MESSAGE_LENGTH = 4096
+# Telegram's hosted Bot API documents this as 20 MB (decimal bytes).
+TELEGRAM_MAX_INBOUND_FILE_BYTES = 20_000_000
+# Keep Telegram cleanup inside the Gateway's five-second shutdown-hook budget.
+TELEGRAM_SHUTDOWN_TIMEOUT_SECONDS = 4.0
+TELEGRAM_BRIDGE_DRAIN_TIMEOUT_SECONDS = 1.0
STREAM_EDIT_MIN_INTERVAL_SECONDS = 1.0
# Groups (negative chat_id) are capped at 20 messages/minute by Telegram,
# so stream edits there must pace well below the private-chat 1 msg/s guideline.
@@ -41,6 +55,9 @@ class TelegramChannel(Channel):
self._thread: threading.Thread | None = None
self._tg_loop: asyncio.AbstractEventLoop | None = None
self._main_loop: asyncio.AbstractEventLoop | None = None
+ # Tasks submitted from the main dispatcher loop back to PTB's loop.
+ # Only the Telegram loop mutates this set.
+ self._tg_bridge_tasks: set[asyncio.Task[Any]] = set()
self._allowed_users: set[int] = set()
for uid in config.get("allowed_users", []):
try:
@@ -96,6 +113,10 @@ class TelegramChannel(Channel):
# General message handler
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self._on_text))
+ # Telegram keeps attachment captions separate from message.text, and
+ # photo/document updates do not match filters.TEXT.
+ app.add_handler(MessageHandler(filters.PHOTO | filters.Document.ALL, self._on_text))
+
self._application = app
# Run polling in a dedicated thread with its own event loop
@@ -106,12 +127,47 @@ class TelegramChannel(Channel):
async def stop(self) -> None:
self._running = False
self.bus.unsubscribe_outbound(self._on_outbound)
- if self._tg_loop and self._tg_loop.is_running():
- self._tg_loop.call_soon_threadsafe(self._tg_loop.stop)
- if self._thread:
- self._thread.join(timeout=10)
- self._thread = None
- self._application = None
+ shutdown_loop = asyncio.get_running_loop()
+ deadline = shutdown_loop.time() + TELEGRAM_SHUTDOWN_TIMEOUT_SECONDS
+ telegram_loop = self._tg_loop
+ worker_thread = self._thread
+
+ try:
+ if telegram_loop and telegram_loop.is_running():
+ drain_future = asyncio.run_coroutine_threadsafe(self._cancel_telegram_bridge_tasks(), telegram_loop)
+ try:
+ remaining = max(0.0, deadline - shutdown_loop.time())
+ await asyncio.wait_for(asyncio.wrap_future(drain_future), timeout=remaining)
+ except asyncio.CancelledError:
+ drain_future.cancel()
+ raise
+ except TimeoutError:
+ drain_future.cancel()
+ logger.warning("[Telegram] timed out cancelling inbound file downloads during shutdown")
+ except Exception as exc:
+ logger.warning("[Telegram] failed to cancel inbound file downloads during shutdown: %s", type(exc).__name__)
+ finally:
+ if telegram_loop and telegram_loop.is_running():
+ try:
+ telegram_loop.call_soon_threadsafe(telegram_loop.stop)
+ except RuntimeError:
+ pass
+ try:
+ if worker_thread is not None and worker_thread.is_alive():
+ remaining = max(0.0, deadline - shutdown_loop.time())
+ if remaining:
+ try:
+ await asyncio.wait_for(
+ asyncio.to_thread(worker_thread.join, remaining),
+ timeout=remaining,
+ )
+ except TimeoutError:
+ logger.warning("[Telegram] polling thread did not stop within the shutdown budget")
+ finally:
+ if worker_thread is not None and worker_thread.is_alive():
+ logger.warning("[Telegram] polling thread is still exiting after bounded shutdown")
+ self._thread = None
+ self._application = None
logger.info("Telegram channel stopped")
async def send(self, msg: OutboundMessage, *, _max_retries: int = 3) -> None:
@@ -293,8 +349,137 @@ class TelegramChannel(Channel):
logger.exception("[Telegram] failed to send file: %s", attachment.filename)
return False
+ async def receive_file(self, msg: InboundMessage, thread_id: str, *, user_id: str | None = None) -> InboundMessage:
+ """Download inbound Telegram attachments for the shared upload pipeline.
+
+ The Bot API download URL contains the bot token, so this adapter never
+ exposes that URL to ``InboundMessage`` or logs. Instead it hands the
+ downloaded bytes to ``ChannelManager`` through a short-lived private
+ field that the manager consumes before persisting safe upload metadata.
+ """
+ # Owner/thread scoping is intentionally applied by ChannelManager when
+ # it persists these bytes through _ingest_inbound_files().
+ del thread_id, user_id
+ if not msg.files:
+ return msg
+
+ bot = self._application.bot if self._application is not None else None
+ materialized: list[dict[str, Any]] = []
+ unavailable: list[str] = []
+
+ for file_info in msg.files:
+ if not isinstance(file_info, dict):
+ continue
+
+ filename = self._safe_inbound_filename(file_info.get("filename"), "attachment")
+ file_id = file_info.get("file_id") if isinstance(file_info.get("file_id"), str) else ""
+ declared_size = self._file_size(file_info.get("size"))
+ if declared_size is not None and declared_size > TELEGRAM_MAX_INBOUND_FILE_BYTES:
+ logger.warning("[Telegram] inbound file exceeds 20 MB download limit, skipping: %s", filename)
+ unavailable.append(f"{filename} (exceeds the 20 MB download limit)")
+ continue
+
+ if bot is None or not file_id:
+ logger.error("[Telegram] cannot download inbound file: %s", filename)
+ unavailable.append(f"{filename} (download unavailable)")
+ continue
+
+ try:
+ resolved_size, content = await self._run_on_telegram_loop(self._download_inbound_file(bot, file_id))
+ except Exception as exc:
+ # Exception strings from HTTP clients can contain request URLs.
+ # Log only the class name so a Bot API token can never leak.
+ logger.error("[Telegram] failed to download inbound file %s: %s", filename, type(exc).__name__)
+ unavailable.append(f"{filename} (download failed)")
+ continue
+
+ if content is None:
+ logger.warning("[Telegram] resolved inbound file exceeds 20 MB download limit, skipping: %s", filename)
+ unavailable.append(f"{filename} (exceeds the 20 MB download limit)")
+ continue
+
+ if len(content) > TELEGRAM_MAX_INBOUND_FILE_BYTES:
+ logger.warning("[Telegram] downloaded inbound file exceeds 20 MB limit, skipping: %s", filename)
+ unavailable.append(f"{filename} (exceeds the 20 MB download limit)")
+ continue
+
+ materialized.append(
+ {
+ "type": "image" if file_info.get("type") == "image" else "file",
+ "filename": filename,
+ "mime_type": file_info.get("mime_type") if isinstance(file_info.get("mime_type"), str) else "application/octet-stream",
+ "size": len(content),
+ INBOUND_FILE_CONTENT_KEY: content,
+ }
+ )
+
+ msg.files = materialized
+ if unavailable:
+ notice = f"[Telegram attachment unavailable: {', '.join(unavailable)}]"
+ msg.text = f"{msg.text}\n\n{notice}" if msg.text else notice
+ return msg
+
# -- helpers -----------------------------------------------------------
+ async def _download_inbound_file(self, bot: Any, file_id: str) -> tuple[int | None, bytearray | None]:
+ """Fetch one file entirely on the event loop that owns PTB's HTTP client."""
+ telegram_file = await bot.get_file(file_id)
+ resolved_size = self._file_size(getattr(telegram_file, "file_size", None))
+ if resolved_size is not None and resolved_size > TELEGRAM_MAX_INBOUND_FILE_BYTES:
+ return resolved_size, None
+ return resolved_size, await telegram_file.download_as_bytearray()
+
+ async def _track_telegram_bridge_task(self, coroutine: Coroutine[Any, Any, Any]) -> Any:
+ """Keep a main-loop submission visible to Telegram-loop shutdown."""
+ task = asyncio.current_task()
+ if task is None:
+ coroutine.close()
+ raise RuntimeError("Telegram bridge task is unavailable")
+ self._tg_bridge_tasks.add(task)
+ try:
+ return await coroutine
+ finally:
+ self._tg_bridge_tasks.discard(task)
+
+ async def _cancel_telegram_bridge_tasks(self) -> None:
+ """Cancel and drain cross-loop PTB work before its event loop exits."""
+ current = asyncio.current_task()
+ pending = [task for task in self._tg_bridge_tasks if task is not current and not task.done()]
+ for task in pending:
+ task.cancel()
+ if pending:
+ done, still_pending = await asyncio.wait(pending, timeout=TELEGRAM_BRIDGE_DRAIN_TIMEOUT_SECONDS)
+ if done:
+ await asyncio.gather(*done, return_exceptions=True)
+ if still_pending:
+ logger.warning("[Telegram] %d inbound file download task(s) did not cancel promptly", len(still_pending))
+
+ async def _run_on_telegram_loop(self, coroutine: Coroutine[Any, Any, Any]) -> Any:
+ """Await a PTB coroutine without using its HTTP client across event loops."""
+ telegram_loop = self._tg_loop
+ current_loop = asyncio.get_running_loop()
+ if telegram_loop is None or telegram_loop is current_loop:
+ return await coroutine
+
+ if not telegram_loop.is_running():
+ coroutine.close()
+ raise RuntimeError("Telegram event loop is not running")
+
+ tracked_coroutine = self._track_telegram_bridge_task(coroutine)
+ try:
+ future = asyncio.run_coroutine_threadsafe(tracked_coroutine, telegram_loop)
+ except BaseException:
+ # A concurrent shutdown can close the loop between the running
+ # check and scheduling. Close the unscheduled coroutine explicitly.
+ tracked_coroutine.close()
+ coroutine.close()
+ raise
+ try:
+ return await asyncio.wrap_future(future)
+ except asyncio.CancelledError:
+ future.cancel()
+ raise
+
@staticmethod
def _stream_key(chat_id: str, thread_ts: str | None) -> str:
return f"{chat_id}:{thread_ts or ''}"
@@ -303,8 +488,83 @@ class TelegramChannel(Channel):
def _parse_message_id(value: str | None) -> int | None:
try:
return int(value) if value else None
+ except (OverflowError, TypeError, ValueError):
+ return None
+
+ @staticmethod
+ def _file_size(value: Any) -> int | None:
+ if isinstance(value, bool):
+ return None
+ try:
+ size = int(value)
except (TypeError, ValueError):
return None
+ return size if size >= 0 else None
+
+ @staticmethod
+ def _safe_inbound_filename(value: Any, fallback: str) -> str:
+ if not isinstance(value, str):
+ return fallback
+ try:
+ candidate = normalize_filename(value.strip())
+ except (UnicodeError, ValueError):
+ return fallback
+ if is_upload_staging_file(candidate) or any(ord(char) < 32 for char in candidate):
+ return fallback
+ return candidate
+
+ @classmethod
+ def _extract_inbound_files(cls, message: Any) -> list[dict[str, Any]]:
+ files: list[dict[str, Any]] = []
+ message_id = str(getattr(message, "message_id", "message"))
+
+ # Materialize the PTB sequence once. Test doubles and partially shaped
+ # update objects can expose a truthy but empty iterable here.
+ photo_sizes = tuple(getattr(message, "photo", None) or ())
+ if photo_sizes:
+ photo = max(
+ photo_sizes,
+ key=lambda item: (
+ (cls._file_size(getattr(item, "width", None)) or 0) * (cls._file_size(getattr(item, "height", None)) or 0),
+ cls._file_size(getattr(item, "file_size", None)) or 0,
+ ),
+ )
+ file_id = getattr(photo, "file_id", None)
+ if isinstance(file_id, str) and file_id:
+ file_unique_id = getattr(photo, "file_unique_id", None)
+ files.append(
+ {
+ "type": "image",
+ "file_id": file_id,
+ "file_unique_id": file_unique_id if isinstance(file_unique_id, str) else None,
+ "filename": f"telegram-photo-{message_id}.jpg",
+ "mime_type": "image/jpeg",
+ "size": cls._file_size(getattr(photo, "file_size", None)),
+ }
+ )
+
+ document = getattr(message, "document", None)
+ document_id = getattr(document, "file_id", None) if document is not None else None
+ if isinstance(document_id, str) and document_id:
+ file_unique_id = getattr(document, "file_unique_id", None)
+ mime_type = getattr(document, "mime_type", None)
+ if not isinstance(mime_type, str) or not mime_type:
+ mime_type = "application/octet-stream"
+ # Avoid the lazy system MIME database lookup on the Telegram event
+ # loop. The original name is preferred; an opaque extension is safe.
+ fallback = f"telegram-document-{message_id}.bin"
+ files.append(
+ {
+ "type": "file",
+ "file_id": document_id,
+ "file_unique_id": file_unique_id if isinstance(file_unique_id, str) else None,
+ "filename": cls._safe_inbound_filename(getattr(document, "file_name", None), fallback),
+ "mime_type": mime_type,
+ "size": cls._file_size(getattr(document, "file_size", None)),
+ }
+ )
+
+ return files
def _register_stream_message(self, key: str, *, message_id: int, last_text: str, last_edit_at: float) -> None:
self._stream_messages.pop(key, None)
@@ -358,15 +618,18 @@ class TelegramChannel(Channel):
def _run_polling(self) -> None:
"""Run telegram polling in a dedicated thread."""
+ application = self._application
+ if application is None:
+ return
self._tg_loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._tg_loop)
try:
# Cannot use run_polling() because it calls add_signal_handler(),
# which only works in the main thread. Instead, manually
# initialize the application and start the updater.
- self._tg_loop.run_until_complete(self._application.initialize())
- self._tg_loop.run_until_complete(self._application.start())
- self._tg_loop.run_until_complete(self._application.updater.start_polling())
+ self._tg_loop.run_until_complete(application.initialize())
+ self._tg_loop.run_until_complete(application.start())
+ self._tg_loop.run_until_complete(application.updater.start_polling())
self._tg_loop.run_forever()
except Exception:
if self._running:
@@ -374,10 +637,11 @@ class TelegramChannel(Channel):
finally:
# Graceful shutdown
try:
- if self._application.updater.running:
- self._tg_loop.run_until_complete(self._application.updater.stop())
- self._tg_loop.run_until_complete(self._application.stop())
- self._tg_loop.run_until_complete(self._application.shutdown())
+ self._tg_loop.run_until_complete(self._cancel_telegram_bridge_tasks())
+ if application.updater.running:
+ self._tg_loop.run_until_complete(application.updater.stop())
+ self._tg_loop.run_until_complete(application.stop())
+ self._tg_loop.run_until_complete(application.shutdown())
except Exception:
logger.exception("Error during Telegram shutdown")
@@ -517,12 +781,19 @@ class TelegramChannel(Channel):
logger.warning("[Telegram] Main loop not running. Cannot publish inbound message.")
async def _on_text(self, update, context) -> None:
- """Handle regular text messages."""
+ """Handle regular text, photo, and document messages."""
if not self._check_user(update.effective_user.id):
return
- text = self._strip_bot_username_from_leading_command(update.message.text.strip(), self._get_bot_username(context))
- if not text:
+ message = update.message
+ message_text = getattr(message, "text", None)
+ caption = getattr(message, "caption", None)
+ raw_text = message_text if isinstance(message_text, str) else caption if isinstance(caption, str) else ""
+ text = raw_text.strip()
+ if text:
+ text = self._strip_bot_username_from_leading_command(text, self._get_bot_username(context))
+ files = self._extract_inbound_files(message)
+ if not text and not files:
return
chat_id = str(update.effective_chat.id)
@@ -549,6 +820,7 @@ class TelegramChannel(Channel):
text=text,
msg_type=InboundMessageType.CHAT,
thread_ts=msg_id,
+ files=files,
metadata={"message_id": msg_id},
)
inbound.topic_id = topic_id
diff --git a/backend/tests/test_channel_file_attachments.py b/backend/tests/test_channel_file_attachments.py
index aa2e9b004..0b8e27496 100644
--- a/backend/tests/test_channel_file_attachments.py
+++ b/backend/tests/test_channel_file_attachments.py
@@ -255,6 +255,33 @@ class TestResolveAttachments:
class TestInboundFileIngestion:
+ def test_consumes_inline_channel_bytes_without_exposing_them_downstream(self, tmp_path):
+ from app.channels import manager
+
+ uploads_dir = tmp_path / "uploads"
+ uploads_dir.mkdir()
+ msg = InboundMessage(
+ channel_name="telegram",
+ chat_id="chat-1",
+ user_id="user-1",
+ text="see attachment",
+ files=[{"type": "file", "filename": "report.pdf", "_content": b"pdf bytes"}],
+ )
+
+ with patch("deerflow.uploads.manager.ensure_uploads_dir", return_value=uploads_dir):
+ result = _run(manager._ingest_inbound_files("thread-1", msg))
+
+ assert result == [
+ {
+ "filename": "report.pdf",
+ "size": len(b"pdf bytes"),
+ "path": "/mnt/user-data/uploads/report.pdf",
+ "is_image": False,
+ }
+ ]
+ assert (uploads_dir / "report.pdf").read_bytes() == b"pdf bytes"
+ assert "_content" not in msg.files[0]
+
def test_rejects_preexisting_symlink_destination(self, tmp_path):
from app.channels import manager
diff --git a/backend/tests/test_channels.py b/backend/tests/test_channels.py
index b3352a370..4058403d2 100644
--- a/backend/tests/test_channels.py
+++ b/backend/tests/test_channels.py
@@ -6,6 +6,7 @@ import asyncio
import json
import logging
import tempfile
+import threading
from concurrent.futures import Future
from pathlib import Path
from types import SimpleNamespace
@@ -7805,6 +7806,9 @@ class TestTelegramSendRetry:
def __and__(self, other):
return FakeFilter(f"{self.expr}&{other.expr}")
+ def __or__(self, other):
+ return FakeFilter(f"{self.expr}|{other.expr}")
+
def __invert__(self):
return FakeFilter(f"~{self.expr}")
@@ -7836,7 +7840,12 @@ class TestTelegramSendRetry:
telegram_ext_mod.ApplicationBuilder = FakeApplicationBuilder
telegram_ext_mod.CommandHandler = fake_command_handler
telegram_ext_mod.MessageHandler = fake_message_handler
- telegram_ext_mod.filters = SimpleNamespace(TEXT=FakeFilter("TEXT"), COMMAND=FakeFilter("COMMAND"))
+ telegram_ext_mod.filters = SimpleNamespace(
+ TEXT=FakeFilter("TEXT"),
+ COMMAND=FakeFilter("COMMAND"),
+ PHOTO=FakeFilter("PHOTO"),
+ Document=SimpleNamespace(ALL=FakeFilter("DOCUMENT")),
+ )
telegram_mod.ext = telegram_ext_mod
monkeypatch.setitem(sys.modules, "telegram", telegram_mod)
monkeypatch.setitem(sys.modules, "telegram.ext", telegram_ext_mod)
@@ -7852,6 +7861,9 @@ class TestTelegramSendRetry:
def join(self, timeout=None):
return None
+ def is_alive(self):
+ return False
+
monkeypatch.setattr("app.channels.telegram.threading.Thread", FakeThread)
async def go():
@@ -7866,6 +7878,7 @@ class TestTelegramSendRetry:
assert "start" in registered_commands
message_filters = {handler.filter_expr.expr for handler in fake_app.handlers if handler.kind == "message"}
assert {"TEXT&COMMAND", "TEXT&~COMMAND"} <= message_filters
+ assert "PHOTO|DOCUMENT" in message_filters
finally:
await ch.stop()
@@ -7958,13 +7971,25 @@ class TestFeishuSendRetry:
# ---------------------------------------------------------------------------
-def _make_telegram_update(chat_type: str, message_id: int, *, reply_to_message_id: int | None = None, text: str = "hello"):
+def _make_telegram_update(
+ chat_type: str,
+ message_id: int,
+ *,
+ reply_to_message_id: int | None = None,
+ text: str | None = "hello",
+ caption: str | None = None,
+ photo: list[SimpleNamespace] | None = None,
+ document: SimpleNamespace | None = None,
+):
"""Build a minimal mock telegram Update for testing _on_text / _cmd_generic."""
update = MagicMock()
update.effective_chat.type = chat_type
update.effective_chat.id = 100
update.effective_user.id = 42
update.message.text = text
+ update.message.caption = caption
+ update.message.photo = photo or []
+ update.message.document = document
update.message.message_id = message_id
if reply_to_message_id is not None:
reply_msg = MagicMock()
@@ -7975,8 +8000,8 @@ def _make_telegram_update(chat_type: str, message_id: int, *, reply_to_message_i
return update
-class TestTelegramPrivateChatThread:
- """Verify that private chats use topic_id=None (single thread per chat)."""
+class TestTelegramInboundMessages:
+ """Verify Telegram inbound normalization and conversation thread context."""
def test_private_chat_no_reply_uses_none_topic(self):
from app.channels.telegram import TelegramChannel
@@ -7984,7 +8009,7 @@ class TestTelegramPrivateChatThread:
async def go():
bus = MessageBus()
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
- ch._main_loop = asyncio.get_event_loop()
+ ch._main_loop = asyncio.get_running_loop()
update = _make_telegram_update("private", message_id=10)
await ch._on_text(update, None)
@@ -7994,13 +8019,519 @@ class TestTelegramPrivateChatThread:
_run(go())
+ def test_photo_caption_uses_largest_size_and_preserves_thread_context(self):
+ from app.channels.telegram import TelegramChannel
+
+ async def go():
+ bus = MessageBus()
+ ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
+ ch._main_loop = asyncio.get_running_loop()
+ small = SimpleNamespace(file_id="photo-small", file_unique_id="unique-small", file_size=10, width=90, height=90)
+ large = SimpleNamespace(file_id="photo-large", file_unique_id="unique-large", file_size=200, width=800, height=600)
+ update = _make_telegram_update(
+ "group",
+ message_id=40,
+ reply_to_message_id=15,
+ text=None,
+ caption=" Describe this image ",
+ # Do not rely on Telegram returning PhotoSize objects in order.
+ photo=[large, small],
+ )
+
+ await ch._on_text(update, None)
+
+ msg = await asyncio.wait_for(bus.get_inbound(), timeout=2)
+ assert msg.text == "Describe this image"
+ assert msg.topic_id == "15"
+ assert msg.thread_ts == "40"
+ assert len(msg.files) == 1
+ assert msg.files[0] == {
+ "type": "image",
+ "file_id": "photo-large",
+ "file_unique_id": "unique-large",
+ "filename": "telegram-photo-40.jpg",
+ "mime_type": "image/jpeg",
+ "size": 200,
+ }
+
+ _run(go())
+
+ def test_document_without_caption_still_publishes_an_inbound_turn(self):
+ from app.channels.telegram import TelegramChannel
+
+ async def go():
+ bus = MessageBus()
+ ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
+ ch._main_loop = asyncio.get_running_loop()
+ document = SimpleNamespace(
+ file_id="document-id",
+ file_unique_id="document-unique",
+ file_name="../report.pdf",
+ mime_type="application/pdf",
+ file_size=1234,
+ )
+ update = _make_telegram_update("private", message_id=41, text=None, document=document)
+
+ await ch._on_text(update, None)
+
+ msg = await asyncio.wait_for(bus.get_inbound(), timeout=2)
+ assert msg.text == ""
+ assert msg.topic_id is None
+ assert msg.files == [
+ {
+ "type": "file",
+ "file_id": "document-id",
+ "file_unique_id": "document-unique",
+ "filename": "report.pdf",
+ "mime_type": "application/pdf",
+ "size": 1234,
+ }
+ ]
+
+ _run(go())
+
+ def test_photo_without_caption_still_publishes_an_inbound_turn(self):
+ from app.channels.telegram import TelegramChannel
+
+ async def go():
+ bus = MessageBus()
+ ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
+ ch._main_loop = asyncio.get_running_loop()
+ photo = SimpleNamespace(file_id="photo-id", file_unique_id="photo-unique", file_size=25)
+ update = _make_telegram_update("private", message_id=42, text=None, photo=[photo])
+
+ await ch._on_text(update, None)
+
+ msg = await asyncio.wait_for(bus.get_inbound(), timeout=2)
+ assert msg.text == ""
+ assert msg.files[0]["filename"] == "telegram-photo-42.jpg"
+ assert msg.files[0]["file_id"] == "photo-id"
+
+ _run(go())
+
+ def test_document_caption_is_preserved_and_missing_filename_gets_safe_fallback(self):
+ from app.channels.telegram import TelegramChannel
+
+ async def go():
+ bus = MessageBus()
+ ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
+ ch._main_loop = asyncio.get_running_loop()
+ document = SimpleNamespace(
+ file_id="document-id",
+ file_unique_id="document-unique",
+ file_name=None,
+ mime_type="text/plain",
+ file_size=12,
+ )
+ update = _make_telegram_update("private", message_id=43, text=None, caption=" Review this ", document=document)
+
+ await ch._on_text(update, None)
+
+ msg = await asyncio.wait_for(bus.get_inbound(), timeout=2)
+ assert msg.text == "Review this"
+ assert msg.files[0]["filename"] == "telegram-document-43.bin"
+
+ _run(go())
+
+ def test_document_staging_filename_gets_visible_safe_fallback(self):
+ from app.channels.telegram import TelegramChannel
+
+ document = SimpleNamespace(
+ file_id="document-id",
+ file_unique_id="document-unique",
+ file_name=".upload-hidden.part",
+ mime_type="application/pdf",
+ file_size=12,
+ )
+ update = _make_telegram_update("private", message_id=44, text=None, document=document)
+
+ files = TelegramChannel._extract_inbound_files(update.message)
+
+ assert files[0]["filename"] == "telegram-document-44.bin"
+
+ def test_receive_file_downloads_bytes_without_retaining_telegram_file_id(self):
+ from app.channels.telegram import TelegramChannel
+
+ async def go():
+ bus = MessageBus()
+ ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
+ downloaded = bytearray(b"data")
+ telegram_file = SimpleNamespace(file_size=4, download_as_bytearray=AsyncMock(return_value=downloaded))
+ bot = SimpleNamespace(get_file=AsyncMock(return_value=telegram_file))
+ ch._application = SimpleNamespace(bot=bot)
+ msg = InboundMessage(
+ channel_name="telegram",
+ chat_id="100",
+ user_id="42",
+ text="caption",
+ files=[
+ {
+ "type": "file",
+ "file_id": "document-id",
+ "file_unique_id": "document-unique",
+ "filename": "report.pdf",
+ "mime_type": "application/pdf",
+ "size": 4,
+ }
+ ],
+ )
+
+ result = await ch.receive_file(msg, "thread-1")
+
+ bot.get_file.assert_awaited_once_with("document-id")
+ telegram_file.download_as_bytearray.assert_awaited_once_with()
+ assert result.text == "caption"
+ assert result.files[0]["_content"] is downloaded
+ assert result.files == [
+ {
+ "type": "file",
+ "filename": "report.pdf",
+ "mime_type": "application/pdf",
+ "size": 4,
+ "_content": b"data",
+ }
+ ]
+
+ _run(go())
+
+ def test_receive_file_marshals_ptb_download_to_telegram_event_loop(self):
+ from app.channels.telegram import TelegramChannel
+
+ async def go():
+ bus = MessageBus()
+ ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
+ telegram_loop = asyncio.new_event_loop()
+ loop_started: Future[None] = Future()
+
+ def run_telegram_loop():
+ asyncio.set_event_loop(telegram_loop)
+ telegram_loop.call_soon(loop_started.set_result, None)
+ telegram_loop.run_forever()
+
+ loop_thread = threading.Thread(target=run_telegram_loop, daemon=True)
+ loop_thread.start()
+ try:
+ loop_started.result(timeout=2)
+
+ class LoopBoundFile:
+ file_size = 4
+
+ async def download_as_bytearray(self):
+ assert asyncio.get_running_loop() is telegram_loop
+ return bytearray(b"data")
+
+ class LoopBoundBot:
+ async def get_file(self, file_id):
+ assert asyncio.get_running_loop() is telegram_loop
+ assert file_id == "document-id"
+ return LoopBoundFile()
+
+ ch._tg_loop = telegram_loop
+ ch._application = SimpleNamespace(bot=LoopBoundBot())
+ msg = InboundMessage(
+ channel_name="telegram",
+ chat_id="100",
+ user_id="42",
+ text="caption",
+ files=[{"type": "file", "file_id": "document-id", "filename": "report.pdf", "size": 4}],
+ )
+ result = await ch.receive_file(msg, "thread-1")
+ finally:
+ telegram_loop.call_soon_threadsafe(telegram_loop.stop)
+ await asyncio.to_thread(loop_thread.join, 2)
+ if loop_thread.is_alive():
+ pytest.fail("Telegram test event loop did not stop")
+ telegram_loop.close()
+
+ assert result.files[0]["_content"] == b"data"
+
+ _run(go())
+
+ def test_stop_cancels_and_drains_an_inflight_receive_download(self):
+ from app.channels.telegram import TelegramChannel
+
+ async def go():
+ bus = MessageBus()
+ ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
+ telegram_loop = asyncio.new_event_loop()
+ loop_started: Future[None] = Future()
+ download_started = threading.Event()
+ download_cancelled = threading.Event()
+
+ def run_telegram_loop():
+ asyncio.set_event_loop(telegram_loop)
+ telegram_loop.call_soon(loop_started.set_result, None)
+ telegram_loop.run_forever()
+
+ class SlowTelegramFile:
+ file_size = 4
+
+ async def download_as_bytearray(self):
+ download_started.set()
+ try:
+ await asyncio.Event().wait()
+ finally:
+ download_cancelled.set()
+
+ class LoopBoundBot:
+ async def get_file(self, file_id):
+ assert asyncio.get_running_loop() is telegram_loop
+ assert file_id == "document-id"
+ return SlowTelegramFile()
+
+ loop_thread = threading.Thread(target=run_telegram_loop, daemon=True)
+ loop_thread.start()
+ try:
+ loop_started.result(timeout=2)
+ ch._tg_loop = telegram_loop
+ ch._thread = loop_thread
+ ch._running = True
+ ch._application = SimpleNamespace(bot=LoopBoundBot())
+ msg = InboundMessage(
+ channel_name="telegram",
+ chat_id="100",
+ user_id="42",
+ text="caption",
+ files=[{"type": "file", "file_id": "document-id", "filename": "report.pdf", "size": 4}],
+ )
+ receive_task = asyncio.create_task(ch.receive_file(msg, "thread-1"))
+ assert await asyncio.to_thread(download_started.wait, 2)
+
+ await ch.stop()
+
+ with pytest.raises(asyncio.CancelledError):
+ await receive_task
+ assert download_cancelled.is_set()
+ assert not ch._tg_bridge_tasks
+ finally:
+ if telegram_loop.is_running():
+ telegram_loop.call_soon_threadsafe(telegram_loop.stop)
+ await asyncio.to_thread(loop_thread.join, 2)
+ if loop_thread.is_alive():
+ pytest.fail("Telegram test event loop did not stop")
+ telegram_loop.close()
+
+ _run(go())
+
+ def test_cancelled_stop_still_stops_thread_and_clears_state(self):
+ from app.channels.telegram import TelegramChannel
+
+ async def go():
+ bus = MessageBus()
+ ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
+ telegram_loop = asyncio.new_event_loop()
+ loop_started: Future[None] = Future()
+ drain_started = threading.Event()
+
+ def run_telegram_loop():
+ asyncio.set_event_loop(telegram_loop)
+ telegram_loop.call_soon(loop_started.set_result, None)
+ telegram_loop.run_forever()
+
+ async def slow_drain():
+ drain_started.set()
+ await asyncio.Event().wait()
+
+ loop_thread = threading.Thread(target=run_telegram_loop, daemon=True)
+ loop_thread.start()
+ try:
+ loop_started.result(timeout=2)
+ ch._tg_loop = telegram_loop
+ ch._thread = loop_thread
+ ch._running = True
+ ch._application = SimpleNamespace(bot=SimpleNamespace())
+ ch._cancel_telegram_bridge_tasks = slow_drain
+
+ stop_task = asyncio.create_task(ch.stop())
+ assert await asyncio.to_thread(drain_started.wait, 2)
+ stop_task.cancel()
+
+ with pytest.raises(asyncio.CancelledError):
+ await stop_task
+
+ assert not loop_thread.is_alive()
+ assert ch._thread is None
+ assert ch._application is None
+ finally:
+ if telegram_loop.is_running():
+ telegram_loop.call_soon_threadsafe(telegram_loop.stop)
+ await asyncio.to_thread(loop_thread.join, 2)
+ if loop_thread.is_alive():
+ pytest.fail("Telegram test event loop did not stop")
+ telegram_loop.close()
+
+ _run(go())
+
+ def test_receive_file_does_not_reuse_ptb_client_after_telegram_loop_stops(self):
+ from app.channels.telegram import TelegramChannel
+
+ async def go():
+ bus = MessageBus()
+ ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
+ stopped_loop = asyncio.new_event_loop()
+ bot = SimpleNamespace(get_file=AsyncMock())
+ ch._tg_loop = stopped_loop
+ ch._application = SimpleNamespace(bot=bot)
+ msg = InboundMessage(
+ channel_name="telegram",
+ chat_id="100",
+ user_id="42",
+ text="caption",
+ files=[{"type": "file", "file_id": "document-id", "filename": "report.pdf", "size": 4}],
+ )
+
+ try:
+ result = await ch.receive_file(msg, "thread-1")
+ finally:
+ stopped_loop.close()
+
+ bot.get_file.assert_not_awaited()
+ assert result.files == []
+ assert result.text.startswith("caption")
+ assert "report.pdf" in result.text
+
+ _run(go())
+
+ def test_receive_file_rejects_declared_oversize_before_download(self):
+ from app.channels.telegram import TELEGRAM_MAX_INBOUND_FILE_BYTES, TelegramChannel
+
+ async def go():
+ bus = MessageBus()
+ ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
+ bot = SimpleNamespace(get_file=AsyncMock())
+ ch._application = SimpleNamespace(bot=bot)
+ msg = InboundMessage(
+ channel_name="telegram",
+ chat_id="100",
+ user_id="42",
+ text="",
+ files=[
+ {
+ "type": "file",
+ "file_id": "too-large",
+ "filename": "archive.zip",
+ "mime_type": "application/zip",
+ "size": TELEGRAM_MAX_INBOUND_FILE_BYTES + 1,
+ }
+ ],
+ )
+
+ result = await ch.receive_file(msg, "thread-1")
+
+ bot.get_file.assert_not_called()
+ assert result.files == []
+ assert "archive.zip" in result.text
+ assert "20 MB" in result.text
+
+ _run(go())
+
+ def test_receive_file_rejects_download_larger_than_reported(self, monkeypatch):
+ from app.channels import telegram
+
+ async def go():
+ monkeypatch.setattr(telegram, "TELEGRAM_MAX_INBOUND_FILE_BYTES", 3)
+ bus = MessageBus()
+ ch = telegram.TelegramChannel(bus=bus, config={"bot_token": "test-token"})
+ telegram_file = SimpleNamespace(file_size=2, download_as_bytearray=AsyncMock(return_value=bytearray(b"four")))
+ ch._application = SimpleNamespace(bot=SimpleNamespace(get_file=AsyncMock(return_value=telegram_file)))
+ msg = InboundMessage(
+ channel_name="telegram",
+ chat_id="100",
+ user_id="42",
+ text="caption",
+ files=[{"type": "image", "file_id": "photo-id", "filename": "photo.jpg", "mime_type": "image/jpeg", "size": 2}],
+ )
+
+ result = await ch.receive_file(msg, "thread-1")
+
+ assert result.files == []
+ assert result.text.startswith("caption")
+ assert "photo.jpg" in result.text
+
+ _run(go())
+
+ def test_receive_file_rejects_resolved_oversize_before_downloading(self, monkeypatch):
+ from app.channels import telegram
+
+ async def go():
+ monkeypatch.setattr(telegram, "TELEGRAM_MAX_INBOUND_FILE_BYTES", 3)
+ bus = MessageBus()
+ ch = telegram.TelegramChannel(bus=bus, config={"bot_token": "test-token"})
+ download = AsyncMock(return_value=bytearray(b"four"))
+ telegram_file = SimpleNamespace(file_size=4, download_as_bytearray=download)
+ ch._application = SimpleNamespace(bot=SimpleNamespace(get_file=AsyncMock(return_value=telegram_file)))
+ msg = InboundMessage(
+ channel_name="telegram",
+ chat_id="100",
+ user_id="42",
+ text="",
+ files=[{"type": "file", "file_id": "file-id", "filename": "large.bin", "size": 2}],
+ )
+
+ result = await ch.receive_file(msg, "thread-1")
+
+ download.assert_not_awaited()
+ assert result.files == []
+ assert "large.bin" in result.text
+
+ _run(go())
+
+ def test_receive_file_accepts_exact_download_limit(self, monkeypatch):
+ from app.channels import telegram
+
+ async def go():
+ monkeypatch.setattr(telegram, "TELEGRAM_MAX_INBOUND_FILE_BYTES", 4)
+ bus = MessageBus()
+ ch = telegram.TelegramChannel(bus=bus, config={"bot_token": "test-token"})
+ telegram_file = SimpleNamespace(file_size=4, download_as_bytearray=AsyncMock(return_value=bytearray(b"four")))
+ ch._application = SimpleNamespace(bot=SimpleNamespace(get_file=AsyncMock(return_value=telegram_file)))
+ msg = InboundMessage(
+ channel_name="telegram",
+ chat_id="100",
+ user_id="42",
+ text="",
+ files=[{"type": "file", "file_id": "file-id", "filename": "exact.bin", "size": 4}],
+ )
+
+ result = await ch.receive_file(msg, "thread-1")
+
+ assert result.files[0]["_content"] == b"four"
+ assert result.files[0]["size"] == 4
+
+ _run(go())
+
+ def test_receive_file_download_failure_keeps_caption_and_drops_attachment(self, caplog):
+ from app.channels.telegram import TelegramChannel
+
+ async def go():
+ bus = MessageBus()
+ ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
+ ch._application = SimpleNamespace(bot=SimpleNamespace(get_file=AsyncMock(side_effect=RuntimeError("GET https://api.telegram.org/bottest-token/getFile failed"))))
+ msg = InboundMessage(
+ channel_name="telegram",
+ chat_id="100",
+ user_id="42",
+ text="Summarize this",
+ files=[{"type": "file", "file_id": "document-id", "filename": "report.pdf", "mime_type": "application/pdf", "size": 10}],
+ )
+
+ with caplog.at_level(logging.ERROR):
+ result = await ch.receive_file(msg, "thread-1")
+
+ assert result.files == []
+ assert result.text.startswith("Summarize this")
+ assert "report.pdf" in result.text
+ assert "test-token" not in caplog.text
+
+ _run(go())
+
def test_private_chat_slash_skill_text_routes_as_chat(self):
from app.channels.telegram import TelegramChannel
async def go():
bus = MessageBus()
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
- ch._main_loop = asyncio.get_event_loop()
+ ch._main_loop = asyncio.get_running_loop()
update = _make_telegram_update("private", message_id=12, text="/data-analysis analyze uploads/foo.csv")
await ch._on_text(update, None)
@@ -8018,7 +8549,7 @@ class TestTelegramPrivateChatThread:
async def go():
bus = MessageBus()
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
- ch._main_loop = asyncio.get_event_loop()
+ ch._main_loop = asyncio.get_running_loop()
update = _make_telegram_update(
"group",
@@ -8041,7 +8572,7 @@ class TestTelegramPrivateChatThread:
async def go():
bus = MessageBus()
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
- ch._main_loop = asyncio.get_event_loop()
+ ch._main_loop = asyncio.get_running_loop()
update = _make_telegram_update("private", message_id=11, reply_to_message_id=5)
await ch._on_text(update, None)
@@ -8057,7 +8588,7 @@ class TestTelegramPrivateChatThread:
async def go():
bus = MessageBus()
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
- ch._main_loop = asyncio.get_event_loop()
+ ch._main_loop = asyncio.get_running_loop()
update = _make_telegram_update("group", message_id=20)
await ch._on_text(update, None)
@@ -8073,7 +8604,7 @@ class TestTelegramPrivateChatThread:
async def go():
bus = MessageBus()
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
- ch._main_loop = asyncio.get_event_loop()
+ ch._main_loop = asyncio.get_running_loop()
update = _make_telegram_update("group", message_id=21, reply_to_message_id=15)
await ch._on_text(update, None)
@@ -8089,7 +8620,7 @@ class TestTelegramPrivateChatThread:
async def go():
bus = MessageBus()
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
- ch._main_loop = asyncio.get_event_loop()
+ ch._main_loop = asyncio.get_running_loop()
update = _make_telegram_update("supergroup", message_id=25)
await ch._on_text(update, None)
@@ -8105,7 +8636,7 @@ class TestTelegramPrivateChatThread:
async def go():
bus = MessageBus()
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
- ch._main_loop = asyncio.get_event_loop()
+ ch._main_loop = asyncio.get_running_loop()
update = _make_telegram_update("private", message_id=30, text="/new")
await ch._cmd_generic(update, None)
@@ -8122,7 +8653,7 @@ class TestTelegramPrivateChatThread:
async def go():
bus = MessageBus()
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
- ch._main_loop = asyncio.get_event_loop()
+ ch._main_loop = asyncio.get_running_loop()
update = _make_telegram_update("group", message_id=31, text="/status")
await ch._cmd_generic(update, None)
@@ -8139,7 +8670,7 @@ class TestTelegramPrivateChatThread:
async def go():
bus = MessageBus()
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
- ch._main_loop = asyncio.get_event_loop()
+ ch._main_loop = asyncio.get_running_loop()
update = _make_telegram_update("group", message_id=32, reply_to_message_id=20, text="/status")
await ch._cmd_generic(update, None)
@@ -8156,7 +8687,7 @@ class TestTelegramPrivateChatThread:
async def go():
bus = MessageBus()
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
- ch._main_loop = asyncio.get_event_loop()
+ ch._main_loop = asyncio.get_running_loop()
update = _make_telegram_update("group", message_id=33, text="/status@DeerFlowBot")
context = SimpleNamespace(bot=SimpleNamespace(username="DeerFlowBot"))
@@ -8180,7 +8711,7 @@ class TestTelegramProcessingOrder:
bus = MessageBus()
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
- ch._main_loop = asyncio.get_event_loop()
+ ch._main_loop = asyncio.get_running_loop()
order = []
From 3c8b82c5941c931ce7b9e3c5dacc34dfd66a81e3 Mon Sep 17 00:00:00 2001
From: Vanzeren <53075619+Vanzeren@users.noreply.github.com>
Date: Sat, 25 Jul 2026 23:18:34 +0800
Subject: [PATCH 04/35] fix(runtime): serialize checkpoint writes with active
runs (#4437)
* fix(runtime): serialize checkpoint writes with active runs
* fix(runtime): address checkpoint reservation reviews
* fix(runtime): address reservation race reviews
* fix(runtime): refine reservation conflict semantics
---
README.md | 4 +-
backend/AGENTS.md | 10 +-
backend/app/gateway/routers/console.py | 15 +-
backend/app/gateway/routers/threads.py | 39 ++--
backend/app/gateway/services.py | 23 ++-
.../versions/0008_thread_operation_kind.py | 36 ++++
.../harness/deerflow/persistence/run/model.py | 1 +
.../harness/deerflow/persistence/run/sql.py | 26 ++-
.../harness/deerflow/runtime/__init__.py | 3 +-
.../harness/deerflow/runtime/runs/__init__.py | 3 +-
.../harness/deerflow/runtime/runs/manager.py | 148 +++++++++++---
.../harness/deerflow/runtime/runs/schemas.py | 7 +
.../deerflow/runtime/runs/store/base.py | 83 +++++++-
.../deerflow/runtime/runs/store/memory.py | 22 ++-
backend/tests/test_console_router.py | 9 +
backend/tests/test_gateway_checkpoint_mode.py | 3 +
...est_migration_0004_run_ownership_dedupe.py | 2 +-
...ration_0007_scheduled_run_active_dedupe.py | 2 +-
.../tests/test_multi_worker_run_ownership.py | 147 +++++++++++---
backend/tests/test_persistence_bootstrap.py | 4 +-
.../test_persistence_bootstrap_concurrency.py | 2 +-
.../test_persistence_bootstrap_regression.py | 4 +-
backend/tests/test_run_manager.py | 185 +++++++++++++++++-
backend/tests/test_run_repository.py | 115 ++++++++++-
backend/tests/test_threads_router.py | 123 +++++++++++-
frontend/AGENTS.md | 2 +-
.../components/workspace/recent-chat-list.tsx | 23 ++-
frontend/src/core/i18n/locales/en-US.ts | 1 +
frontend/src/core/i18n/locales/types.ts | 1 +
frontend/src/core/i18n/locales/zh-CN.ts | 1 +
frontend/tests/unit/core/threads/api.test.ts | 16 ++
31 files changed, 940 insertions(+), 120 deletions(-)
create mode 100644 backend/packages/harness/deerflow/persistence/migrations/versions/0008_thread_operation_kind.py
diff --git a/README.md b/README.md
index 732977912..d77e58444 100644
--- a/README.md
+++ b/README.md
@@ -756,11 +756,11 @@ Supported commands:
After each Gateway-backed run, DeerFlow evaluates the visible conversation against the active goal with a non-thinking evaluator model. The evaluator must return a typed blocker (`missing_evidence`, `needs_user_input`, `run_failed`, `external_wait`, or `goal_not_met_yet`) plus visible evidence. DeerFlow only injects a hidden continuation when the latest assistant turn is durably checkpointed, the blocker is `goal_not_met_yet`, the thread did not change during evaluation, and the no-progress breaker has not fired. The safety cap defaults to 8 hidden continuations, and repeated identical non-progress evaluations stop after 2 attempts. `/goal clear` and any user-authored new input win over queued continuations. When the goal is satisfied, DeerFlow clears it automatically and publishes the updated thread state.
-The Web UI shows the active goal above the composer. The same command is available from the TUI and supported IM channels. In the Web UI and supported IM channels, setting `/goal ` also starts a run with the condition as the task; status and clear commands only manage goal state.
+The Web UI shows the active goal above the composer. The same command is available from the TUI and supported IM channels. In the Web UI and supported IM channels, setting `/goal ` also starts a run with the condition as the task; status and clear commands only manage goal state. Setting or clearing a goal is rejected while that thread has a run in flight, including a run owned by another Gateway worker, so the goal checkpoint cannot branch away from an active run's checkpoint lineage.
### Manual Context Compaction
-Use `/compact` in the Web UI composer to summarize older context for the current thread. DeerFlow keeps the full chat visible, but future model calls use the compacted summary plus recent messages. The command is ignored when there is not enough history to compact, and it is blocked while the thread has a run in flight.
+Use `/compact` in the Web UI composer to summarize older context for the current thread. DeerFlow keeps the full chat visible, but future model calls use the compacted summary plus recent messages. The command is ignored when there is not enough history to compact, and it is blocked while the thread has a run in flight, including when that run is owned by another Gateway worker. If a multi-worker reservation loses its lease, DeerFlow cancels the checkpoint writer before the replacing run proceeds and returns a retryable conflict after cleanup. Thread-title edits are serialized through the same state-write boundary and show a conflict without closing the rename dialog when a run is active.
### Sub-Agents
diff --git a/backend/AGENTS.md b/backend/AGENTS.md
index 1bb3a785a..e0c7e7f2f 100644
--- a/backend/AGENTS.md
+++ b/backend/AGENTS.md
@@ -453,6 +453,8 @@ metadata only.
- `cancel()` returns a :class:`~deerflow.runtime.CancelOutcome` enum: `cancelled` (local cancel), `taken_over` (non-owning worker claimed the run because the owner's lease expired — marks it as `error`), `lease_valid_elsewhere` (owner's lease is still alive — caller should return 409 + `Retry-After`), `not_active_locally` (heartbeat disabled, preserving the old 409 path), `not_cancellable` (terminal state), or `unknown` (not found in memory or store). `create_or_reject(..., multitask_strategy="interrupt"|"rollback")` persists interrupted status through `RunStore.update_status()`, matching normal `set_status()` transitions.
- Store-only hydrated runs are readable history. In multi-worker mode with heartbeat enabled, cancel on a store-only run can take over (mark `error`) when the owner's lease has expired past the grace window; otherwise it fails with 409 + `Retry-After`. In single-worker mode (heartbeat off), store-only runs still return 409.
- Startup/orphan reconciliation must claim stale active rows with `RunStore.claim_for_takeover()`, not a plain `update_status()`. The final claim re-checks `status` and lease expiry atomically, so a heartbeat renewal between the candidate scan and the recovery write keeps the run active.
+- Run admission and independent checkpoint writes are first-class thread operations. `runs.operation_kind` distinguishes user-visible `run` rows from internal `checkpoint_write` reservations, while every active kind shares the existing durable active-thread uniqueness constraint. New operation kinds must go through `RunStore.create_thread_operation_atomic()` and `RunManager.reserve_thread_operation()` rather than adding another lock or metadata marker. Live and lease-less reservations are non-interruptible; an expired leased reservation can be reclaimed immediately by interrupt/rollback admission without waiting for orphan reconciliation. Lease-less rows stay fail-closed because the store cannot distinguish a stale row from a live checkpoint writer in another heartbeat-disabled worker; a rare failed delete therefore requires startup reconciliation, and heartbeat-disabled multi-worker deployment remains unsupported. Reservation bodies are attached to their caller task so loss detected by lease renewal cancels the checkpoint writer before it can continue after takeover; the context manager translates that lease-loss cancellation to `ConflictError` after cleanup so Gateway mutation routes return a retryable 409 instead of dropping the HTTP request. The cleanup scope begins immediately after durable admission, including the await that attaches the caller task, so cancellation cannot strand a locally renewed pending reservation. A failed renewal is revalidated under the manager lock before cancellation; if the reservation completed and unregistered while the store update was in flight, its request task must not be cancelled after the checkpoint write. Reservations are excluded from run history/reporting and from run-only helpers such as `list_by_thread()` and `has_inflight()`, release uses the captured owner rather than ambient user context, and local cleanup still runs when the best-effort store delete fails. `RunStore.create_run_atomic()` remains a deprecated compatibility shim for external stores that only admit normal runs; new stores must implement `create_thread_operation_atomic()` to support internal operation kinds.
+- Gateway checkpoint mutations outside run execution must use `services.reserve_checkpoint_write()`, which composes the process-local thread lock with the durable `checkpoint_write` reservation. Manual compaction, `POST /threads/{id}/state`, and both goal mutation routes (`PUT` / `DELETE /threads/{id}/goal`, including creation of a missing goal checkpoint) use this boundary, so an existing run blocks the write and the reservation blocks new reject/interrupt/rollback runs across workers.
- `POST /wait` (both thread-scoped and `/api/runs/wait`) drains the stream bridge via `wait_for_run_completion()` instead of bare `await record.task`, so it honours the run's `on_disconnect` setting and cancels the background run on real client disconnect rather than returning a stale checkpoint (issue #3265).
- Redis `StreamBridge` keys use a rolling retained-buffer TTL (`stream_bridge.stream_ttl_seconds`, refreshed on `publish()` / `publish_end()`) as a leak safety net, not as a run timeout. Startup and lease-driven periodic orphan recovery share one Gateway stream-terminalization path: after `RunManager` durably marks a run `error` with `stop_reason=orphan_recovered`, Gateway publishes `END_SENTINEL` and schedules stream cleanup. The periodic store scan, per-row status writes, and Gateway callback run as one supervised single-flight task, so a slow pass is skipped at the next interval instead of piling up or pausing the sole lease-renewal loop. Store retries have bounded attempts/backoff; an individual operation still relies on the database driver/pool timeout. `RunManager.shutdown()` gives active user runs priority within its shared deadline, then drains or cancels orphan recovery. Gateway tracks delayed recovered-stream cleanups and converts unfinished delays to immediate deletes before closing the bridge; the Redis TTL remains the outage safety net. Only startup recovery, before the runtime yields to requests, projects the latest affected thread to `error`; periodic recovery deliberately avoids that non-atomic projection because `ThreadMetaStore` has no `latest_run_id` conditional-update contract. Store-only SSE and `/wait` consumers wait for the bridge's real END marker after an ordinary durable terminal status, because status persistence can precede tail events. The explicit `orphan_recovered` signal is the only heartbeat fallback: its publisher is known to be gone, so it supplies the liveness boundary if END publication fails or the retained key expires. Malformed `Last-Event-ID` reconnect values live-tail new Redis events rather than replaying the retained buffer. Keep cross-component recovery orchestration in Gateway through the generic `RunManager.on_orphans_recovered` callback; do not introduce a harness-to-app dependency. Callback failure warnings include every recovered `run_id` so operators can identify rows whose Gateway-side terminalization needs inspection.
- Thread-scoped run creation accepts `checkpoint` / `checkpoint_id`; Gateway validates the checkpoint belongs to the request thread before writing `checkpoint_id` / `checkpoint_ns` into `config.configurable` for LangGraph branching.
@@ -840,6 +842,7 @@ This invokes `alembic revision --autogenerate` against the live ORM models. Revi
- `migrations/versions/0002_runs_token_usage.py` — fixes issue #3682
- `migrations/versions/0004_run_ownership.py` — `runs` multi-worker ownership + the `uq_runs_thread_active` partial unique index, with a `_dedupe_active_runs_per_thread()` pre-step so `CREATE UNIQUE INDEX` cannot fail on a field DB that already has duplicate active rows per thread
- `migrations/versions/0007_scheduled_run_active_index.py` — the `uq_scheduled_task_run_active` partial unique index (at most one queued/running `scheduled_task_runs` row per `task_id`), with a `_dedupe_active_scheduled_runs_per_task()` pre-step (keeps the newest active row per task, supersedes the rest to `interrupted` with an explanatory `error` + `finished_at`) mirroring 0004; chains after `0006_agents`
+- `migrations/versions/0008_thread_operation_kind.py` — adds `runs.operation_kind` for durable non-run thread reservations; chains after `0007_scheduled_run_active_index`
- `persistence/bootstrap.py` — `bootstrap_schema(engine, backend=...)`, the three-branch decision + locking
- Tests: `tests/test_persistence_bootstrap.py` (branches), `tests/test_persistence_bootstrap_concurrency.py` (concurrency), `tests/test_persistence_bootstrap_regression.py` (issue #3682), `tests/test_persistence_migrations_env.py` (filter), `tests/blocking_io/test_persistence_bootstrap.py` (asyncio.to_thread anchor), `tests/test_migration_0004_run_ownership_dedupe.py` + `tests/test_migration_0007_scheduled_run_active_dedupe.py` (dedupe-before-unique-index pre-steps)
@@ -1135,9 +1138,10 @@ Automatic conversation summarization when approaching token limits:
- Manual compaction uses `POST /api/threads/{id}/compact`, reuses the same
`DeerFlowSummarizationMiddleware`, writes a new checkpoint with updated
`messages` and `summary_text`, and bumps only those channel versions.
- The route shares the per-thread serialization gate used by `/goal` writes
- and run admission so compaction cannot race with goal updates or runs that
- read/write checkpoints.
+ The route uses the shared `reserve_checkpoint_write()` boundary (also used by
+ manual state updates). Its short-lived `checkpoint_write` thread operation
+ shares the durable active-thread uniqueness constraint with run admission,
+ preventing either worker-local or cross-worker checkpoint-write races.
See [docs/summarization.md](docs/summarization.md) for details.
diff --git a/backend/app/gateway/routers/console.py b/backend/app/gateway/routers/console.py
index fbdef19f9..a736080cb 100644
--- a/backend/app/gateway/routers/console.py
+++ b/backend/app/gateway/routers/console.py
@@ -272,7 +272,9 @@ async def console_stats(request: Request) -> ConsoleStatsResponse:
"""Return the dashboard's headline counters."""
sf = _session_factory_or_503()
user_id = await get_current_user(request)
- run_where = (RunRow.user_id == user_id,) if user_id else ()
+ run_where = (RunRow.operation_kind == "run",)
+ if user_id:
+ run_where += (RunRow.user_id == user_id,)
thread_where = (ThreadMetaRow.user_id == user_id,) if user_id else ()
pricing = _build_pricing_map()
@@ -347,7 +349,14 @@ async def console_runs(
sf = _session_factory_or_503()
user_id = await get_current_user(request)
- stmt = select(RunRow, ThreadMetaRow.display_name).join(ThreadMetaRow, ThreadMetaRow.thread_id == RunRow.thread_id, isouter=True).order_by(RunRow.created_at.desc(), RunRow.run_id.desc()).limit(limit + 1).offset(offset)
+ stmt = (
+ select(RunRow, ThreadMetaRow.display_name)
+ .join(ThreadMetaRow, ThreadMetaRow.thread_id == RunRow.thread_id, isouter=True)
+ .where(RunRow.operation_kind == "run")
+ .order_by(RunRow.created_at.desc(), RunRow.run_id.desc())
+ .limit(limit + 1)
+ .offset(offset)
+ )
if user_id:
stmt = stmt.where(RunRow.user_id == user_id)
if status:
@@ -415,7 +424,7 @@ async def console_usage(
start_local = today_local - timedelta(days=days - 1)
window_start_utc = datetime.combine(start_local, time.min, tzinfo=UTC) - tz_delta
- stmt = select(RunRow).where(RunRow.created_at >= window_start_utc)
+ stmt = select(RunRow).where(RunRow.operation_kind == "run", RunRow.created_at >= window_start_utc)
if user_id:
stmt = stmt.where(RunRow.user_id == user_id)
diff --git a/backend/app/gateway/routers/threads.py b/backend/app/gateway/routers/threads.py
index 73198f1ae..122f9b65d 100644
--- a/backend/app/gateway/routers/threads.py
+++ b/backend/app/gateway/routers/threads.py
@@ -32,13 +32,14 @@ from app.gateway.checkpoint_lineage import (
find_checkpoint_before_message_chronologically,
is_duration_only_checkpoint,
)
-from app.gateway.deps import get_checkpointer, get_run_event_store, get_run_manager
+from app.gateway.deps import get_checkpointer, get_run_event_store
from app.gateway.internal_auth import get_trusted_internal_owner_user_id
from app.gateway.services import (
build_checkpoint_state_accessor,
build_checkpoint_state_mutation_accessor,
build_thread_checkpoint_state_accessor,
build_thread_checkpoint_state_mutation_accessor,
+ reserve_checkpoint_write,
)
from app.gateway.utils import sanitize_log_param
from deerflow.agents.thread_state import THREAD_STATE_REDUCER_FIELDS
@@ -57,11 +58,11 @@ from deerflow.runtime.goal import (
DEFAULT_MAX_GOAL_CONTINUATIONS,
build_goal_state,
ensure_thread_checkpoint,
- goal_thread_lock,
read_thread_goal,
write_thread_goal,
)
from deerflow.runtime.journal import build_branch_history_seed_events
+from deerflow.runtime.runs.manager import ConflictError
from deerflow.runtime.runs.worker import valid_duration_entry
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.utils.file_io import run_file_io
@@ -1053,13 +1054,17 @@ async def set_thread_goal(thread_id: str, body: ThreadGoalRequest, request: Requ
this endpoint creates the missing thread checkpoint on demand.
"""
checkpointer = get_checkpointer(request)
- await _ensure_thread_for_goal(thread_id, request)
try:
goal = build_goal_state(body.objective, max_continuations=body.max_continuations)
- async with goal_thread_lock(thread_id):
+ async with reserve_checkpoint_write(request, thread_id, user_id=get_effective_user_id()):
+ await _ensure_thread_for_goal(thread_id, request)
await write_thread_goal(checkpointer, thread_id, goal, as_node="goal", create_if_missing=True)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
+ except ConflictError:
+ raise HTTPException(status_code=409, detail="Thread has a run in flight. Set the goal after the run finishes.") from None
+ except HTTPException:
+ raise
except Exception:
logger.exception("Failed to set goal for thread %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to set thread goal") from None
@@ -1072,8 +1077,10 @@ async def clear_thread_goal(thread_id: str, request: Request) -> ThreadGoalRespo
"""Clear the active goal for a thread."""
checkpointer = get_checkpointer(request)
try:
- async with goal_thread_lock(thread_id):
+ async with reserve_checkpoint_write(request, thread_id, user_id=get_effective_user_id()):
await write_thread_goal(checkpointer, thread_id, None, as_node="goal")
+ except ConflictError:
+ raise HTTPException(status_code=409, detail="Thread has a run in flight. Clear the goal after the run finishes.") from None
except LookupError:
return ThreadGoalResponse(goal=None)
except Exception:
@@ -1099,7 +1106,6 @@ def _thread_compact_response(result: ThreadCompactionResult) -> ThreadCompactRes
@require_permission("threads", "write", owner_check=True, require_existing=True)
async def compact_thread(thread_id: str, body: ThreadCompactRequest, request: Request) -> ThreadCompactResponse:
"""Manually summarize old thread context while preserving the visible history."""
- run_manager = get_run_manager(request)
# Compaction writes only base-schema channels (messages + summary_text);
# every other channel — including middleware-contributed ones — is carried
# forward by checkpoint fork inheritance, so the base-schema mutation
@@ -1114,9 +1120,7 @@ async def compact_thread(thread_id: str, body: ThreadCompactRequest, request: Re
raise _checkpoint_mode_http_error(exc, thread_id) from exc
keep = body.keep.to_tuple() if body.keep is not None else None
try:
- async with goal_thread_lock(thread_id):
- if await run_manager.has_inflight(thread_id):
- raise HTTPException(status_code=409, detail="Thread has a run in flight. Compact after the run finishes.")
+ async with reserve_checkpoint_write(request, thread_id, user_id=get_effective_user_id()):
result = await compact_thread_context(
accessor,
thread_id,
@@ -1125,6 +1129,8 @@ async def compact_thread(thread_id: str, body: ThreadCompactRequest, request: Re
user_id=get_effective_user_id(),
agent_name=body.agent_name,
)
+ except ConflictError:
+ raise HTTPException(status_code=409, detail="Thread has a run in flight. Compact after the run finishes.") from None
except _CHECKPOINT_MODE_ERRORS as exc:
raise _checkpoint_mode_http_error(exc, thread_id) from exc
except ContextCompactionDisabled:
@@ -1232,12 +1238,15 @@ async def update_thread_state(thread_id: str, body: ThreadStateUpdateRequest, re
reducer_fields = THREAD_STATE_REDUCER_FIELDS
updates = {key: Overwrite(value) if key in reducer_fields else value for key, value in values.items()}
try:
- updated_config = await accessor.aupdate(
- read_config,
- updates,
- as_node=mutation_node,
- )
- snapshot = await accessor.aget(updated_config)
+ async with reserve_checkpoint_write(request, thread_id, user_id=get_effective_user_id()):
+ updated_config = await accessor.aupdate(
+ read_config,
+ updates,
+ as_node=mutation_node,
+ )
+ snapshot = await accessor.aget(updated_config)
+ except ConflictError:
+ raise HTTPException(status_code=409, detail="Thread has a run in flight. Update state after the run finishes.") from None
except _CHECKPOINT_MODE_ERRORS as exc:
raise _checkpoint_mode_http_error(exc, thread_id) from exc
except Exception:
diff --git a/backend/app/gateway/services.py b/backend/app/gateway/services.py
index e125d4eae..15844c6ec 100644
--- a/backend/app/gateway/services.py
+++ b/backend/app/gateway/services.py
@@ -11,7 +11,8 @@ import asyncio
import json
import logging
import re
-from collections.abc import Mapping
+from collections.abc import AsyncIterator, Mapping
+from contextlib import asynccontextmanager
from types import SimpleNamespace
from typing import Any
@@ -44,6 +45,7 @@ from deerflow.runtime import (
RunRecord,
RunStatus,
StreamBridge,
+ ThreadOperationKind,
UnsupportedStrategyError,
build_state_mutation_graph,
run_agent,
@@ -64,6 +66,25 @@ from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY
logger = logging.getLogger(__name__)
+
+@asynccontextmanager
+async def reserve_checkpoint_write(
+ request: Request,
+ thread_id: str,
+ *,
+ user_id: str | None = None,
+) -> AsyncIterator[None]:
+ """Serialize an out-of-run checkpoint writer against all thread operations."""
+ run_manager = get_run_manager(request)
+ async with goal_thread_lock(thread_id):
+ async with run_manager.reserve_thread_operation(
+ thread_id,
+ kind=ThreadOperationKind.checkpoint_write,
+ user_id=user_id,
+ ):
+ yield
+
+
_TERMINAL_RUN_STATUSES = {
RunStatus.success,
RunStatus.error,
diff --git a/backend/packages/harness/deerflow/persistence/migrations/versions/0008_thread_operation_kind.py b/backend/packages/harness/deerflow/persistence/migrations/versions/0008_thread_operation_kind.py
new file mode 100644
index 000000000..7cff57f06
--- /dev/null
+++ b/backend/packages/harness/deerflow/persistence/migrations/versions/0008_thread_operation_kind.py
@@ -0,0 +1,36 @@
+"""thread operation kind.
+
+Revision ID: 0008_thread_operation_kind
+Revises: 0007_scheduled_run_active_index
+Create Date: 2026-07-24
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0008_thread_operation_kind"
+down_revision: str | Sequence[str] | None = "0007_scheduled_run_active_index"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ from deerflow.persistence.migrations._helpers import safe_add_column
+
+ safe_add_column(
+ "runs",
+ sa.Column(
+ "operation_kind",
+ sa.String(length=32),
+ nullable=False,
+ server_default=sa.text("'run'"),
+ ),
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("runs", "operation_kind")
diff --git a/backend/packages/harness/deerflow/persistence/run/model.py b/backend/packages/harness/deerflow/persistence/run/model.py
index 723a32370..a4277a139 100644
--- a/backend/packages/harness/deerflow/persistence/run/model.py
+++ b/backend/packages/harness/deerflow/persistence/run/model.py
@@ -19,6 +19,7 @@ class RunRow(Base):
user_id: Mapped[str | None] = mapped_column(String(64), index=True)
status: Mapped[str] = mapped_column(String(20), default="pending")
# "pending" | "running" | "success" | "error" | "timeout" | "interrupted"
+ operation_kind: Mapped[str] = mapped_column(String(32), nullable=False, default="run", server_default=text("'run'"))
model_name: Mapped[str | None] = mapped_column(String(128))
multitask_strategy: Mapped[str] = mapped_column(String(20), default="reject")
diff --git a/backend/packages/harness/deerflow/persistence/run/sql.py b/backend/packages/harness/deerflow/persistence/run/sql.py
index 97affff83..d364d7258 100644
--- a/backend/packages/harness/deerflow/persistence/run/sql.py
+++ b/backend/packages/harness/deerflow/persistence/run/sql.py
@@ -92,6 +92,7 @@ class RunRepository(RunStore):
user_id: str | None | _AutoSentinel = AUTO,
model_name: str | None = None,
status="pending",
+ operation_kind: str = "run",
multitask_strategy="reject",
metadata=None,
kwargs=None,
@@ -118,6 +119,7 @@ class RunRepository(RunStore):
"user_id": resolved_user_id,
"model_name": self._normalize_model_name(model_name),
"status": status,
+ "operation_kind": operation_kind,
"multitask_strategy": multitask_strategy,
"metadata_json": self._safe_json(metadata) or {},
"kwargs_json": self._safe_json(kwargs) or {},
@@ -160,7 +162,7 @@ class RunRepository(RunStore):
limit=100,
):
resolved_user_id = resolve_user_id(user_id, method_name="RunRepository.list_by_thread")
- stmt = select(RunRow).where(RunRow.thread_id == thread_id)
+ stmt = select(RunRow).where(RunRow.thread_id == thread_id, RunRow.operation_kind == "run")
if resolved_user_id is not None:
stmt = stmt.where(RunRow.user_id == resolved_user_id)
stmt = stmt.order_by(RunRow.created_at.desc()).limit(limit)
@@ -178,6 +180,7 @@ class RunRepository(RunStore):
source = RunRow.metadata_json["regenerate_from_run_id"].as_string()
stmt = select(source).where(
RunRow.thread_id == thread_id,
+ RunRow.operation_kind == "run",
RunRow.status == "success",
source.is_not(None),
source != "",
@@ -198,7 +201,7 @@ class RunRepository(RunStore):
if not run_ids:
return {}
resolved_user_id = resolve_user_id(user_id, method_name="RunRepository.get_many_by_thread")
- stmt = select(RunRow).where(RunRow.thread_id == thread_id, RunRow.run_id.in_(run_ids))
+ stmt = select(RunRow).where(RunRow.thread_id == thread_id, RunRow.operation_kind == "run", RunRow.run_id.in_(run_ids))
if resolved_user_id is not None:
stmt = stmt.where(RunRow.user_id == resolved_user_id)
async with self._sf() as session:
@@ -242,6 +245,10 @@ class RunRepository(RunStore):
await session.delete(row)
await session.commit()
+ async def delete_thread_operation(self, run_id: str, *, user_id: str | None) -> None:
+ """Release a reservation using its captured owner, not request context."""
+ await self.delete(run_id, user_id=user_id)
+
async def list_pending(self, *, before=None):
if before is None:
before_dt = datetime.now(UTC)
@@ -249,7 +256,7 @@ class RunRepository(RunStore):
before_dt = before
else:
before_dt = datetime.fromisoformat(before)
- stmt = select(RunRow).where(RunRow.status == "pending", RunRow.created_at <= before_dt).order_by(RunRow.created_at.asc())
+ stmt = select(RunRow).where(RunRow.operation_kind == "run", RunRow.status == "pending", RunRow.created_at <= before_dt).order_by(RunRow.created_at.asc())
async with self._sf() as session:
result = await session.execute(stmt)
return [self._row_to_dict(r) for r in result.scalars()]
@@ -378,6 +385,7 @@ class RunRepository(RunStore):
statuses = ("success", "error", "running") if include_active else ("success", "error")
_completed = RunRow.status.in_(statuses)
_thread = RunRow.thread_id == thread_id
+ _run_operation = RunRow.operation_kind == "run"
stmt = select(
RunRow.model_name,
@@ -388,7 +396,7 @@ class RunRepository(RunStore):
RunRow.subagent_tokens,
RunRow.middleware_tokens,
RunRow.token_usage_by_model,
- ).where(_thread, _completed)
+ ).where(_thread, _run_operation, _completed)
async with self._sf() as session:
rows = (await session.execute(stmt)).all()
@@ -510,13 +518,14 @@ class RunRepository(RunStore):
result = await session.execute(stmt)
return [self._row_to_dict(r) for r in result.scalars()]
- async def create_run_atomic(
+ async def create_thread_operation_atomic(
self,
run_id: str,
*,
thread_id: str,
owner_worker_id: str,
lease_expires_at: str | None,
+ operation_kind: str = "run",
multitask_strategy: str = "reject",
assistant_id: str | None = None,
user_id: str | None = None,
@@ -541,7 +550,7 @@ class RunRepository(RunStore):
"""
from deerflow.runtime.runs.manager import ConflictError
- resolved_user_id = resolve_user_id(user_id or AUTO, method_name="RunRepository.create_run_atomic")
+ resolved_user_id = resolve_user_id(user_id or AUTO, method_name="RunRepository.create_thread_operation_atomic")
now = datetime.now(UTC)
created = datetime.fromisoformat(created_at) if created_at else now
lease_dt = datetime.fromisoformat(lease_expires_at) if lease_expires_at else None
@@ -553,6 +562,7 @@ class RunRepository(RunStore):
"user_id": resolved_user_id,
"model_name": self._normalize_model_name(model_name),
"status": "pending",
+ "operation_kind": operation_kind,
"multitask_strategy": multitask_strategy,
"metadata_json": self._safe_json(metadata) or {},
"kwargs_json": self._safe_json(kwargs) or {},
@@ -576,6 +586,7 @@ class RunRepository(RunStore):
)
result = await session.execute(stmt)
for row in result.scalars():
+ lease_expired = False
if row.lease_expires_at is not None:
# SQLite drops tzinfo on read despite
# ``DateTime(timezone=True)`` (see ``_row_to_dict``).
@@ -588,6 +599,7 @@ class RunRepository(RunStore):
row_lease = row.lease_expires_at
if row_lease.tzinfo is None:
row_lease = row_lease.replace(tzinfo=UTC)
+ lease_expired = row_lease < cutoff
if row_lease >= cutoff and row.owner_worker_id != owner_worker_id:
# Live run owned by another worker — we cannot
# interrupt it and the partial unique index would
@@ -595,6 +607,8 @@ class RunRepository(RunStore):
# ConflictError so the caller gets a clean signal
# instead of a retry loop on IntegrityError.
raise ConflictError(f"Thread {thread_id} already has an active run owned by another worker")
+ if row.operation_kind != "run" and not lease_expired:
+ raise ConflictError(f"Thread {thread_id} has an active checkpoint write")
row.status = "interrupted"
row.error = "Cancelled by newer run"
row.owner_worker_id = owner_worker_id
diff --git a/backend/packages/harness/deerflow/runtime/__init__.py b/backend/packages/harness/deerflow/runtime/__init__.py
index c47eca5b1..c3c4dfb6c 100644
--- a/backend/packages/harness/deerflow/runtime/__init__.py
+++ b/backend/packages/harness/deerflow/runtime/__init__.py
@@ -7,7 +7,7 @@ directly from ``deerflow.runtime``.
from .checkpoint_state import CheckpointStateAccessor, build_state_mutation_graph
from .checkpointer import checkpointer_context, get_checkpointer, make_checkpointer, reset_checkpointer
-from .runs import ORPHAN_RECOVERY_STOP_REASON, STARTUP_ORPHAN_RECOVERY_ERROR, CancelOutcome, ConflictError, DisconnectMode, RunContext, RunManager, RunRecord, RunStatus, UnsupportedStrategyError, run_agent
+from .runs import ORPHAN_RECOVERY_STOP_REASON, STARTUP_ORPHAN_RECOVERY_ERROR, CancelOutcome, ConflictError, DisconnectMode, RunContext, RunManager, RunRecord, RunStatus, ThreadOperationKind, UnsupportedStrategyError, run_agent
from .serialization import serialize, serialize_channel_values, serialize_channel_values_for_api, serialize_lc_object, serialize_messages_tuple, strip_data_url_image_blocks
from .store import get_store, make_store, reset_store, store_context
@@ -34,6 +34,7 @@ __all__ = [
"RunManager",
"RunRecord",
"RunStatus",
+ "ThreadOperationKind",
"STARTUP_ORPHAN_RECOVERY_ERROR",
"UnsupportedStrategyError",
"run_agent",
diff --git a/backend/packages/harness/deerflow/runtime/runs/__init__.py b/backend/packages/harness/deerflow/runtime/runs/__init__.py
index f78ae6be0..897ed4af6 100644
--- a/backend/packages/harness/deerflow/runtime/runs/__init__.py
+++ b/backend/packages/harness/deerflow/runtime/runs/__init__.py
@@ -1,7 +1,7 @@
"""Run lifecycle management for LangGraph Platform API compatibility."""
from .manager import ORPHAN_RECOVERY_STOP_REASON, STARTUP_ORPHAN_RECOVERY_ERROR, CancelOutcome, ConflictError, RunManager, RunRecord, UnsupportedStrategyError
-from .schemas import DisconnectMode, RunStatus
+from .schemas import DisconnectMode, RunStatus, ThreadOperationKind
from .worker import RunContext, run_agent
__all__ = [
@@ -13,6 +13,7 @@ __all__ = [
"RunManager",
"RunRecord",
"RunStatus",
+ "ThreadOperationKind",
"STARTUP_ORPHAN_RECOVERY_ERROR",
"UnsupportedStrategyError",
"run_agent",
diff --git a/backend/packages/harness/deerflow/runtime/runs/manager.py b/backend/packages/harness/deerflow/runtime/runs/manager.py
index 04a5d335c..1dfaad500 100644
--- a/backend/packages/harness/deerflow/runtime/runs/manager.py
+++ b/backend/packages/harness/deerflow/runtime/runs/manager.py
@@ -7,7 +7,8 @@ import logging
import socket
import sqlite3
import uuid
-from collections.abc import Awaitable, Callable
+from collections.abc import AsyncIterator, Awaitable, Callable
+from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from enum import StrEnum
@@ -19,7 +20,7 @@ from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
from deerflow.utils.time import is_lease_expired
from deerflow.utils.time import now_iso as _now_iso
-from .schemas import DisconnectMode, RunStatus
+from .schemas import DisconnectMode, RunStatus, ThreadOperationKind
if TYPE_CHECKING:
from deerflow.config.run_ownership_config import RunOwnershipConfig
@@ -158,6 +159,7 @@ class RunRecord:
assistant_id: str | None
status: RunStatus
on_disconnect: DisconnectMode
+ operation_kind: ThreadOperationKind = ThreadOperationKind.run
multitask_strategy: str = "reject"
metadata: dict = field(default_factory=dict)
kwargs: dict = field(default_factory=dict)
@@ -260,6 +262,7 @@ class RunManager:
"thread_id": record.thread_id,
"assistant_id": record.assistant_id,
"status": record.status.value,
+ "operation_kind": record.operation_kind.value,
"multitask_strategy": record.multitask_strategy,
"metadata": record.metadata or {},
"kwargs": record.kwargs or {},
@@ -398,6 +401,7 @@ class RunManager:
assistant_id=row.get("assistant_id"),
status=RunStatus(row.get("status") or RunStatus.pending.value),
on_disconnect=DisconnectMode(row.get("on_disconnect") or DisconnectMode.cancel.value),
+ operation_kind=ThreadOperationKind(row.get("operation_kind") or ThreadOperationKind.run.value),
multitask_strategy=row.get("multitask_strategy") or "reject",
metadata=row.get("metadata") or {},
kwargs=row.get("kwargs") or {},
@@ -493,7 +497,7 @@ class RunManager:
Note: this method assumes no active run exists for the thread. It
persists via ``store.put`` (upsert) rather than the atomic
- ``create_run_atomic`` primitive, so a concurrent insert for the
+ ``create_thread_operation_atomic`` primitive, so a concurrent insert for the
same thread will hit the partial unique index and surface as a
raw ``IntegrityError`` instead of a ``ConflictError``. Production
callers should use :meth:`create_or_reject`.
@@ -586,7 +590,7 @@ class RunManager:
limit: Maximum number of runs to return.
"""
async with self._lock:
- memory_records = self._thread_records_locked(thread_id)
+ memory_records = [record for record in self._thread_records_locked(thread_id) if record.operation_kind == ThreadOperationKind.run]
if self._store is None:
return sorted(memory_records, key=lambda r: r.created_at, reverse=True)[:limit]
records_by_id = {record.run_id: record for record in memory_records}
@@ -626,7 +630,7 @@ class RunManager:
"""
resolved_user_id = resolve_user_id(user_id, method_name="RunManager.list_successful_regenerate_sources")
async with self._lock:
- memory_records = [record for record in self._thread_records_locked(thread_id) if resolved_user_id is None or record.user_id == resolved_user_id]
+ memory_records = [record for record in self._thread_records_locked(thread_id) if record.operation_kind == ThreadOperationKind.run and (resolved_user_id is None or record.user_id == resolved_user_id)]
sources = set(await self._store.list_successful_regenerate_sources(thread_id, user_id=resolved_user_id)) if self._store is not None else set()
# _thread_records_locked preserves the insertion order of the thread
@@ -654,7 +658,9 @@ class RunManager:
return {}
resolved_user_id = resolve_user_id(user_id, method_name="RunManager.get_many_by_thread")
async with self._lock:
- records_by_id = {record.run_id: record for record in self._thread_records_locked(thread_id) if record.run_id in run_ids and (resolved_user_id is None or record.user_id == resolved_user_id)}
+ records_by_id = {
+ record.run_id: record for record in self._thread_records_locked(thread_id) if record.operation_kind == ThreadOperationKind.run and record.run_id in run_ids and (resolved_user_id is None or record.user_id == resolved_user_id)
+ }
if self._store is None:
return records_by_id
@@ -943,6 +949,32 @@ class RunManager:
multitask_strategy: str = "reject",
model_name: str | None = None,
user_id: str | None = None,
+ ) -> RunRecord:
+ """Atomically admit a normal agent run for a thread."""
+ return await self._admit_thread_operation(
+ thread_id,
+ assistant_id,
+ operation_kind=ThreadOperationKind.run,
+ on_disconnect=on_disconnect,
+ metadata=metadata,
+ kwargs=kwargs,
+ multitask_strategy=multitask_strategy,
+ model_name=model_name,
+ user_id=user_id,
+ )
+
+ async def _admit_thread_operation(
+ self,
+ thread_id: str,
+ assistant_id: str | None = None,
+ *,
+ operation_kind: ThreadOperationKind,
+ on_disconnect: DisconnectMode = DisconnectMode.cancel,
+ metadata: dict | None = None,
+ kwargs: dict | None = None,
+ multitask_strategy: str = "reject",
+ model_name: str | None = None,
+ user_id: str | None = None,
) -> RunRecord:
"""Atomically check for inflight runs and create a new one.
@@ -975,6 +1007,7 @@ class RunManager:
assistant_id=assistant_id,
status=RunStatus.pending,
on_disconnect=on_disconnect,
+ operation_kind=operation_kind,
multitask_strategy=multitask_strategy,
metadata=metadata or {},
kwargs=kwargs or {},
@@ -991,6 +1024,9 @@ class RunManager:
# store's partial unique index below).
local_inflight = [r for r in self._thread_records_locked(thread_id) if r.status in (RunStatus.pending, RunStatus.running) or r.finalizing]
+ if multitask_strategy in ("interrupt", "rollback") and any(record.operation_kind != ThreadOperationKind.run for record in local_inflight):
+ raise ConflictError(f"Thread {thread_id} has an active checkpoint write")
+
if multitask_strategy == "reject" and local_inflight:
raise ConflictError(f"Thread {thread_id} already has an active run")
@@ -1008,13 +1044,14 @@ class RunManager:
if multitask_strategy == "reject":
try:
await self._call_store_with_retry(
- "create_run_atomic",
+ "create_thread_operation_atomic",
run_id,
- lambda: self._store.create_run_atomic(
+ lambda: self._store.create_thread_operation_atomic(
run_id=run_id,
thread_id=thread_id,
owner_worker_id=self._worker_id,
lease_expires_at=lease_expires_at,
+ operation_kind=operation_kind.value,
multitask_strategy="reject",
assistant_id=assistant_id,
user_id=user_id,
@@ -1039,13 +1076,14 @@ class RunManager:
for attempt in range(max_retries):
try:
await self._call_store_with_retry(
- "create_run_atomic",
+ "create_thread_operation_atomic",
run_id,
- lambda: self._store.create_run_atomic(
+ lambda: self._store.create_thread_operation_atomic(
run_id=run_id,
thread_id=thread_id,
owner_worker_id=self._worker_id,
lease_expires_at=lease_expires_at,
+ operation_kind=operation_kind.value,
multitask_strategy=multitask_strategy,
assistant_id=assistant_id,
user_id=user_id,
@@ -1068,7 +1106,7 @@ class RunManager:
# worker won the race for this thread.
raise ConflictError(f"Thread {thread_id} already has an active run") from exc
raise
- # ``create_run_atomic`` already marked any claimed store
+ # ``create_thread_operation_atomic`` already marked any claimed store
# rows as interrupted in the same transaction; no extra
# store write is needed for them.
@@ -1100,6 +1138,65 @@ class RunManager:
logger.info("Run created: run_id=%s thread_id=%s", run_id, thread_id)
return record
+ @asynccontextmanager
+ async def reserve_thread_operation(
+ self,
+ thread_id: str,
+ *,
+ kind: ThreadOperationKind,
+ user_id: str | None = None,
+ ) -> AsyncIterator[None]:
+ """Hold exclusive durable admission for a non-run thread operation.
+
+ The reservation is a short-lived pending row, so the same durable
+ uniqueness constraint used by ``create_or_reject`` closes both sides of
+ the race across Gateway workers.
+ """
+ if kind == ThreadOperationKind.run:
+ raise ValueError("Normal runs must be admitted with create_or_reject()")
+ record = await self._admit_thread_operation(
+ thread_id,
+ operation_kind=kind,
+ multitask_strategy="reject",
+ user_id=user_id,
+ )
+ try:
+ reservation_task = asyncio.current_task()
+ if reservation_task is None:
+ raise RuntimeError("Thread operation reservation requires an active asyncio task")
+ lease_lost = True
+ async with self._lock:
+ if self._runs.get(record.run_id) is record:
+ record.task = reservation_task
+ lease_lost = record.abort_event.is_set()
+ if lease_lost:
+ raise asyncio.CancelledError()
+ yield
+ except asyncio.CancelledError:
+ if record.abort_event.is_set():
+ raise ConflictError(f"Thread {thread_id} reservation lease was lost") from None
+ raise
+ finally:
+ try:
+ if self._store is not None:
+ try:
+ await self._call_store_with_retry(
+ "release thread operation",
+ record.run_id,
+ lambda: self._store.delete_thread_operation(record.run_id, user_id=record.user_id),
+ )
+ except Exception:
+ logger.warning(
+ "Failed to release persisted thread operation %s; leaving it for orphan reconciliation",
+ record.run_id,
+ exc_info=True,
+ )
+ finally:
+ async with self._lock:
+ removed = self._runs.pop(record.run_id, None)
+ if removed is not None:
+ self._unindex_run_locked(record.run_id, removed.thread_id)
+
async def reconcile_orphaned_inflight_runs(
self,
*,
@@ -1170,7 +1267,8 @@ class RunManager:
record.error = error
record.stop_reason = stop_reason
record.updated_at = now
- recovered.append(record)
+ if record.operation_kind == ThreadOperationKind.run:
+ recovered.append(record)
if recovered:
logger.warning("Recovered %d orphaned inflight run(s) as error", len(recovered))
@@ -1179,7 +1277,7 @@ class RunManager:
async def has_inflight(self, thread_id: str) -> bool:
"""Return ``True`` if *thread_id* has a pending or running run."""
async with self._lock:
- return any(r.status in (RunStatus.pending, RunStatus.running) or r.finalizing for r in self._thread_records_locked(thread_id))
+ return any(r.operation_kind == ThreadOperationKind.run and (r.status in (RunStatus.pending, RunStatus.running) or r.finalizing) for r in self._thread_records_locked(thread_id))
async def cleanup(self, run_id: str, *, delay: float = 300) -> None:
"""Remove a run record after an optional delay."""
@@ -1304,7 +1402,7 @@ class RunManager:
# Renew any pending/running run owned by this worker unless its
# background task has already completed. A pending run whose task
# has not been spawned yet (``task is None``) is still live from
- # this worker's perspective — between ``create_run_atomic``
+ # this worker's perspective — between ``create_thread_operation_atomic``
# inserting the row and the worker layer spawning the agent task
# there is a brief window. If we drop those records here and the
# window stretches past ``lease_seconds`` (e.g. event-loop
@@ -1338,16 +1436,18 @@ class RunManager:
# or ``owner_worker_id`` changed). Stop the local task so
# we don't waste CPU or overwrite the takeover status on
# finalisation.
- logger.warning(
- "Run %s lease renewal failed (status=%s,owner=%s) – worker likely taken over; aborting local task",
- run_id,
- record.status.value,
- record.owner_worker_id,
- )
- record.abort_event.set()
- task_active = record.task is not None and not record.task.done()
- if task_active:
- record.task.cancel()
+ async with self._lock:
+ still_active = self._runs.get(run_id) is record and record.status in (RunStatus.pending, RunStatus.running) and record.owner_worker_id == self._worker_id and (record.task is None or not record.task.done())
+ if still_active:
+ logger.warning(
+ "Run %s lease renewal failed (status=%s,owner=%s) – worker likely taken over; aborting local task",
+ run_id,
+ record.status.value,
+ record.owner_worker_id,
+ )
+ record.abort_event.set()
+ if record.task is not None:
+ record.task.cancel()
except Exception:
logger.warning("Failed to renew lease for run %s", run_id, exc_info=True)
diff --git a/backend/packages/harness/deerflow/runtime/runs/schemas.py b/backend/packages/harness/deerflow/runtime/runs/schemas.py
index 622d8b70b..028bcb1ad 100644
--- a/backend/packages/harness/deerflow/runtime/runs/schemas.py
+++ b/backend/packages/harness/deerflow/runtime/runs/schemas.py
@@ -3,6 +3,13 @@
from enum import StrEnum
+class ThreadOperationKind(StrEnum):
+ """Kind of operation holding exclusive admission for a thread."""
+
+ run = "run"
+ checkpoint_write = "checkpoint_write"
+
+
class RunStatus(StrEnum):
"""Lifecycle status of a single run."""
diff --git a/backend/packages/harness/deerflow/runtime/runs/store/base.py b/backend/packages/harness/deerflow/runtime/runs/store/base.py
index 017bf374b..677868ffe 100644
--- a/backend/packages/harness/deerflow/runtime/runs/store/base.py
+++ b/backend/packages/harness/deerflow/runtime/runs/store/base.py
@@ -25,6 +25,7 @@ class RunStore(abc.ABC):
user_id: str | None = None,
model_name: str | None = None,
status: str = "pending",
+ operation_kind: str = "run",
multitask_strategy: str = "reject",
metadata: dict[str, Any] | None = None,
kwargs: dict[str, Any] | None = None,
@@ -98,6 +99,15 @@ class RunStore(abc.ABC):
async def delete(self, run_id: str) -> None:
pass
+ async def delete_thread_operation(self, run_id: str, *, user_id: str | None) -> None:
+ """Release an admitted thread operation for its recorded owner.
+
+ The default keeps legacy stores compatible: older implementations only
+ accepted ``run_id``. User-aware stores should override this method so
+ cleanup never depends on ambient request context.
+ """
+ await self.delete(run_id)
+
@abc.abstractmethod
async def update_model_name(
self,
@@ -215,7 +225,53 @@ class RunStore(abc.ABC):
"""Return active runs whose lease has expired (or is NULL for pre-ownership rows)."""
pass
- @abc.abstractmethod
+ async def create_thread_operation_atomic(
+ self,
+ run_id: str,
+ *,
+ thread_id: str,
+ owner_worker_id: str,
+ lease_expires_at: str | None,
+ operation_kind: str = "run",
+ multitask_strategy: str = "reject",
+ assistant_id: str | None = None,
+ user_id: str | None = None,
+ model_name: str | None = None,
+ metadata: dict[str, Any] | None = None,
+ kwargs: dict[str, Any] | None = None,
+ created_at: str | None = None,
+ grace_seconds: int = 10,
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
+ """Atomically create an active thread operation with cross-process uniqueness.
+
+ The default implementation preserves compatibility with stores that
+ still implement the former ``create_run_atomic`` interface. Legacy
+ stores support only normal run rows; internal operation kinds require
+ an implementation of this method.
+
+ Returns ``(new_run_dict, claimed_run_dicts)``.
+ Raises ``IntegrityError`` on conflict for ``reject`` strategy.
+ """
+ legacy_impl = type(self).create_run_atomic
+ if legacy_impl is RunStore.create_run_atomic:
+ raise NotImplementedError("RunStore must implement create_thread_operation_atomic() or create_run_atomic()")
+ if operation_kind != "run":
+ raise NotImplementedError("Legacy RunStore.create_run_atomic() cannot create non-run thread operations")
+ return await self.create_run_atomic(
+ run_id,
+ thread_id=thread_id,
+ owner_worker_id=owner_worker_id,
+ lease_expires_at=lease_expires_at,
+ multitask_strategy=multitask_strategy,
+ assistant_id=assistant_id,
+ user_id=user_id,
+ model_name=model_name,
+ metadata=metadata,
+ kwargs=kwargs,
+ created_at=created_at,
+ grace_seconds=grace_seconds,
+ )
+
async def create_run_atomic(
self,
run_id: str,
@@ -232,9 +288,22 @@ class RunStore(abc.ABC):
created_at: str | None = None,
grace_seconds: int = 10,
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
- """Atomically create a run row with cross-process thread-uniqueness.
-
- Returns ``(new_run_dict, claimed_run_dicts)``.
- Raises ``IntegrityError`` on conflict for ``reject`` strategy.
- """
- pass
+ """Deprecated compatibility alias for normal-run admission."""
+ operation_impl = type(self).create_thread_operation_atomic
+ if operation_impl is RunStore.create_thread_operation_atomic:
+ raise NotImplementedError("RunStore must implement create_thread_operation_atomic() or create_run_atomic()")
+ return await self.create_thread_operation_atomic(
+ run_id,
+ thread_id=thread_id,
+ owner_worker_id=owner_worker_id,
+ lease_expires_at=lease_expires_at,
+ operation_kind="run",
+ multitask_strategy=multitask_strategy,
+ assistant_id=assistant_id,
+ user_id=user_id,
+ model_name=model_name,
+ metadata=metadata,
+ kwargs=kwargs,
+ created_at=created_at,
+ grace_seconds=grace_seconds,
+ )
diff --git a/backend/packages/harness/deerflow/runtime/runs/store/memory.py b/backend/packages/harness/deerflow/runtime/runs/store/memory.py
index 4f7259981..0bacd8f7d 100644
--- a/backend/packages/harness/deerflow/runtime/runs/store/memory.py
+++ b/backend/packages/harness/deerflow/runtime/runs/store/memory.py
@@ -41,6 +41,7 @@ class MemoryRunStore(RunStore):
user_id=None,
model_name=None,
status="pending",
+ operation_kind="run",
multitask_strategy="reject",
metadata=None,
kwargs=None,
@@ -58,6 +59,7 @@ class MemoryRunStore(RunStore):
"user_id": user_id,
"model_name": model_name,
"status": status,
+ "operation_kind": operation_kind,
"multitask_strategy": multitask_strategy,
"metadata": metadata or {},
"kwargs": kwargs or {},
@@ -85,7 +87,7 @@ class MemoryRunStore(RunStore):
run_ids = self._runs_by_thread.get(thread_id)
if not run_ids:
return []
- results = [run for run_id in run_ids if (run := self._runs.get(run_id)) is not None and (user_id is None or run.get("user_id") == user_id)]
+ results = [run for run_id in run_ids if (run := self._runs.get(run_id)) is not None and run.get("operation_kind", "run") == "run" and (user_id is None or run.get("user_id") == user_id)]
results.sort(key=lambda r: r["created_at"], reverse=True)
return results[:limit]
@@ -94,7 +96,7 @@ class MemoryRunStore(RunStore):
sources: set[str] = set()
for run_id in run_ids:
run = self._runs.get(run_id)
- if run is None or run.get("status") != "success":
+ if run is None or run.get("operation_kind", "run") != "run" or run.get("status") != "success":
continue
if user_id is not None and run.get("user_id") != user_id:
continue
@@ -105,7 +107,7 @@ class MemoryRunStore(RunStore):
async def get_many_by_thread(self, thread_id, run_ids, *, user_id=None):
thread_run_ids = self._runs_by_thread.get(thread_id) or ()
- return {run_id: run for run_id in thread_run_ids if run_id in run_ids and (run := self._runs.get(run_id)) is not None and (user_id is None or run.get("user_id") == user_id)}
+ return {run_id: run for run_id in thread_run_ids if run_id in run_ids and (run := self._runs.get(run_id)) is not None and run.get("operation_kind", "run") == "run" and (user_id is None or run.get("user_id") == user_id)}
async def update_status(self, run_id, status, *, error=None, stop_reason=None):
run = self._runs.get(run_id)
@@ -128,7 +130,7 @@ class MemoryRunStore(RunStore):
self._runs[run_id]["model_name"] = model_name
self._runs[run_id]["updated_at"] = datetime.now(UTC).isoformat()
- async def delete(self, run_id):
+ async def delete(self, run_id, *, user_id=None):
run = self._runs.pop(run_id, None)
if run is not None:
self._unindex_run(run_id, run["thread_id"])
@@ -152,7 +154,7 @@ class MemoryRunStore(RunStore):
async def list_pending(self, *, before=None):
now = before or datetime.now(UTC).isoformat()
- results = [r for r in self._runs.values() if r["status"] == "pending" and r["created_at"] <= now]
+ results = [r for r in self._runs.values() if r.get("operation_kind", "run") == "run" and r["status"] == "pending" and r["created_at"] <= now]
results.sort(key=lambda r: r["created_at"])
return results
@@ -167,7 +169,7 @@ class MemoryRunStore(RunStore):
# Use the thread index for an O(runs-in-thread) lookup instead of
# scanning every run in the process (mirrors ``list_by_thread``).
run_ids = self._runs_by_thread.get(thread_id) or ()
- completed = [run for run_id in run_ids if (run := self._runs.get(run_id)) is not None and run.get("status") in statuses]
+ completed = [run for run_id in run_ids if (run := self._runs.get(run_id)) is not None and run.get("operation_kind", "run") == "run" and run.get("status") in statuses]
by_model: dict[str, dict] = {}
for r in completed:
usage_by_model = r.get("token_usage_by_model") or {}
@@ -288,13 +290,14 @@ class MemoryRunStore(RunStore):
results.sort(key=lambda r: r["created_at"])
return results
- async def create_run_atomic(
+ async def create_thread_operation_atomic(
self,
run_id: str,
*,
thread_id: str,
owner_worker_id: str,
lease_expires_at: str | None,
+ operation_kind: str = "run",
multitask_strategy: str = "reject",
assistant_id: str | None = None,
user_id: str | None = None,
@@ -330,6 +333,7 @@ class MemoryRunStore(RunStore):
continue
if r["status"] not in ("pending", "running"):
continue
+ lease_expired = False
existing_lease = r.get("lease_expires_at")
if existing_lease is not None:
try:
@@ -340,6 +344,7 @@ class MemoryRunStore(RunStore):
# raise ``TypeError``.
if lease_dt.tzinfo is None:
lease_dt = lease_dt.replace(tzinfo=UTC)
+ lease_expired = lease_dt < cutoff
if lease_dt >= cutoff and r.get("owner_worker_id") != owner_worker_id:
# Live run owned by another worker — cannot
# interrupt, and the partial unique index would
@@ -349,6 +354,8 @@ class MemoryRunStore(RunStore):
raise ConflictError(f"Thread {thread_id} already has an active run owned by another worker")
except (ValueError, TypeError):
pass
+ if r.get("operation_kind", "run") != "run" and not lease_expired:
+ raise ConflictError(f"Thread {thread_id} has an active checkpoint write")
candidates.append(r)
for r in candidates:
r["status"] = "interrupted"
@@ -364,6 +371,7 @@ class MemoryRunStore(RunStore):
"user_id": user_id,
"model_name": model_name,
"status": "pending",
+ "operation_kind": operation_kind,
"multitask_strategy": multitask_strategy,
"metadata": metadata or {},
"kwargs": kwargs or {},
diff --git a/backend/tests/test_console_router.py b/backend/tests/test_console_router.py
index 95ff1f9d1..dbb98970c 100644
--- a/backend/tests/test_console_router.py
+++ b/backend/tests/test_console_router.py
@@ -126,6 +126,15 @@ def _seed_rows() -> tuple[list[ThreadMetaRow], list[RunRow]]:
created_at=NOW - timedelta(hours=2),
updated_at=NOW - timedelta(hours=2) + timedelta(seconds=8),
),
+ RunRow(
+ run_id="checkpoint-write-1",
+ thread_id="t1",
+ user_id="user-a",
+ operation_kind="checkpoint_write",
+ status="error",
+ created_at=NOW - timedelta(minutes=30),
+ updated_at=NOW - timedelta(minutes=29),
+ ),
]
return threads, runs
diff --git a/backend/tests/test_gateway_checkpoint_mode.py b/backend/tests/test_gateway_checkpoint_mode.py
index 7189432f9..2d78e862f 100644
--- a/backend/tests/test_gateway_checkpoint_mode.py
+++ b/backend/tests/test_gateway_checkpoint_mode.py
@@ -29,7 +29,9 @@ from app.gateway.routers import threads
from deerflow.agents.thread_state import get_thread_state_schema
from deerflow.config.app_config import AppConfig, reset_app_config, set_app_config
from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
+from deerflow.runtime import RunManager
from deerflow.runtime.checkpoint_mode import checkpoint_metadata_uses_delta, inject_checkpoint_mode
+from deerflow.runtime.runs.store.memory import MemoryRunStore
_THREAD_ID = "thread-gateway-parity"
@@ -128,6 +130,7 @@ def test_full_mode_gateway_rejects_delta_thread_with_409(_stub_app_config, monke
app.state.thread_store.get = AsyncMock(return_value=None)
app.state.checkpoint_channel_mode = "full"
app.state.run_event_store = SimpleNamespace()
+ app.state.run_manager = RunManager(store=MemoryRunStore())
app.include_router(threads.router)
full_graph = _build_reply_graph("full", checkpointer)
diff --git a/backend/tests/test_migration_0004_run_ownership_dedupe.py b/backend/tests/test_migration_0004_run_ownership_dedupe.py
index 25a75f539..6819f40e4 100644
--- a/backend/tests/test_migration_0004_run_ownership_dedupe.py
+++ b/backend/tests/test_migration_0004_run_ownership_dedupe.py
@@ -157,7 +157,7 @@ async def test_migration_dedupes_duplicate_active_rows_before_unique_index(tmp_p
with sqlite3.connect(db_path) as raw:
version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone()
# Bootstrap upgrades through the later revisions after 0004.
- assert version_row[0] == "0007_scheduled_run_active_index"
+ assert version_row[0] == "0008_thread_operation_kind"
# Sanity: the invariant the index enforces is now true — at most one
# active row per thread.
diff --git a/backend/tests/test_migration_0007_scheduled_run_active_dedupe.py b/backend/tests/test_migration_0007_scheduled_run_active_dedupe.py
index e737697c2..150ae4464 100644
--- a/backend/tests/test_migration_0007_scheduled_run_active_dedupe.py
+++ b/backend/tests/test_migration_0007_scheduled_run_active_dedupe.py
@@ -169,7 +169,7 @@ async def test_migration_supersedes_duplicate_active_runs_before_unique_index(tm
with sqlite3.connect(db_path) as raw:
version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone()
- assert version_row[0] == "0007_scheduled_run_active_index"
+ assert version_row[0] == "0008_thread_operation_kind"
# Sanity: the invariant the index enforces now holds — at most one
# active row per task_id.
diff --git a/backend/tests/test_multi_worker_run_ownership.py b/backend/tests/test_multi_worker_run_ownership.py
index 8d0939dcf..67f0f6fc2 100644
--- a/backend/tests/test_multi_worker_run_ownership.py
+++ b/backend/tests/test_multi_worker_run_ownership.py
@@ -3,7 +3,7 @@
Coverage:
- create_or_reject with reject strategy blocks duplicate active runs
- create_or_reject with interrupt strategy claims and cancels old runs
-- create_run_atomic refuses to interrupt a run owned by another live worker
+- create_thread_operation_atomic refuses to interrupt a run owned by another live worker
- reconcile_orphaned_inflight_runs uses lease-based detection
- periodic reconciliation notifies Gateway recovery orchestration
- Worker reconciliation skips runs with unexpired leases
@@ -20,7 +20,7 @@ from unittest.mock import AsyncMock
import pytest
from deerflow.config.run_ownership_config import RunOwnershipConfig
-from deerflow.runtime import ORPHAN_RECOVERY_STOP_REASON, RunManager, RunStatus
+from deerflow.runtime import ORPHAN_RECOVERY_STOP_REASON, RunManager, RunStatus, ThreadOperationKind
from deerflow.runtime.runs.manager import CancelOutcome, ConflictError, _generate_worker_id
from deerflow.runtime.runs.store.memory import MemoryRunStore
@@ -74,6 +74,76 @@ async def test_reject_succeeds_when_no_active_run():
assert record.lease_expires_at is not None
+@pytest.mark.anyio
+async def test_checkpoint_write_reservation_rejects_nonowning_worker_while_run_is_active():
+ """A durable run owned by worker A must block worker B's checkpoint writer."""
+ store = MemoryRunStore()
+ owner = _make_manager(store=store, worker_id="worker-a")
+ non_owner = _make_manager(store=store, worker_id="worker-b")
+ active = await owner.create_or_reject("thread-1")
+ await owner.set_status(active.run_id, RunStatus.running)
+
+ with pytest.raises(ConflictError, match="already has an active run"):
+ async with non_owner.reserve_thread_operation("thread-1", kind=ThreadOperationKind.checkpoint_write):
+ pytest.fail("the checkpoint mutation guard must not be acquired")
+
+ stored = await store.get(active.run_id)
+ assert stored is not None
+ assert stored["status"] == "running"
+
+
+@pytest.mark.anyio
+async def test_checkpoint_write_reservation_blocks_new_runs_until_mutation_finishes():
+ """The durable guard closes the check-then-write window in both directions."""
+ store = MemoryRunStore()
+ compaction_worker = _make_manager(store=store, worker_id="worker-a")
+ run_worker = _make_manager(store=store, worker_id="worker-b")
+
+ async with compaction_worker.reserve_thread_operation("thread-1", kind=ThreadOperationKind.checkpoint_write):
+ inflight = await store.list_inflight()
+ assert len(inflight) == 1
+ assert inflight[0]["operation_kind"] == ThreadOperationKind.checkpoint_write
+ assert inflight[0]["metadata"] == {}
+
+ with pytest.raises(ConflictError, match="checkpoint write"):
+ await run_worker.create_or_reject("thread-1", multitask_strategy="interrupt")
+
+ assert await store.list_inflight() == []
+ assert await store.list_by_thread("thread-1") == []
+
+ admitted = await run_worker.create_or_reject("thread-1")
+ assert admitted.status == RunStatus.pending
+
+
+@pytest.mark.anyio
+async def test_interrupt_reclaims_expired_checkpoint_write_reservation():
+ """A dead checkpoint writer must not wait for periodic reconciliation."""
+ store = MemoryRunStore()
+ expired = (datetime.now(UTC) - timedelta(seconds=30)).isoformat()
+ await store.put(
+ "checkpoint-write-1",
+ thread_id="thread-1",
+ status="pending",
+ operation_kind=ThreadOperationKind.checkpoint_write,
+ owner_worker_id="dead-worker",
+ lease_expires_at=expired,
+ created_at=expired,
+ )
+ manager = _make_manager(
+ store=store,
+ worker_id="worker-b",
+ run_ownership_config=_lease_config(grace_seconds=10),
+ )
+
+ admitted = await manager.create_or_reject("thread-1", multitask_strategy="interrupt")
+
+ assert admitted.status == RunStatus.pending
+ stale = await store.get("checkpoint-write-1")
+ assert stale is not None
+ assert stale["status"] == "interrupted"
+ assert stale["owner_worker_id"] == "worker-b"
+
+
@pytest.mark.anyio
async def test_reject_blocks_reentrant_same_thread_locally():
"""reject must also block when a local in-memory active run exists."""
@@ -139,7 +209,7 @@ async def test_interrupt_exhausted_retries_surface_as_conflict_error():
import sqlite3
class _AlwaysUniqueViolationStore(MemoryRunStore):
- """MemoryRunStore whose ``create_run_atomic`` always raises a
+ """MemoryRunStore whose ``create_thread_operation_atomic`` always raises a
real-flavoured unique-violation IntegrityError, simulating a worker
that keeps losing the cross-worker race for the same thread."""
@@ -147,7 +217,7 @@ async def test_interrupt_exhausted_retries_surface_as_conflict_error():
super().__init__()
self.atomic_call_count = 0
- async def create_run_atomic(self, *args, **kwargs):
+ async def create_thread_operation_atomic(self, *args, **kwargs):
self.atomic_call_count += 1
err = sqlite3.IntegrityError("UNIQUE constraint failed: runs.uq_runs_thread_active")
err.sqlite_errorcode = sqlite3.SQLITE_CONSTRAINT_UNIQUE
@@ -184,6 +254,7 @@ async def test_run_record_stores_owner_and_lease():
assert stored is not None
assert stored["owner_worker_id"] == manager.worker_id
assert stored["lease_expires_at"] is not None
+ assert stored["operation_kind"] == ThreadOperationKind.run
@pytest.mark.anyio
@@ -197,6 +268,34 @@ async def test_store_row_roundtrips_ownership_fields():
assert hydrated is not None
assert hydrated.owner_worker_id == manager.worker_id
assert hydrated.lease_expires_at is not None
+ assert hydrated.operation_kind == ThreadOperationKind.run
+
+
+@pytest.mark.anyio
+async def test_reconciliation_releases_expired_internal_operation_without_reporting_run():
+ """Expired internal reservations release admission without becoming failed runs."""
+ store = MemoryRunStore()
+ expired = (datetime.now(UTC) - timedelta(seconds=30)).isoformat()
+ await store.put(
+ "checkpoint-write-1",
+ thread_id="thread-1",
+ status="pending",
+ operation_kind=ThreadOperationKind.checkpoint_write,
+ owner_worker_id="dead-worker",
+ lease_expires_at=expired,
+ created_at=expired,
+ )
+ manager = _make_manager(
+ store=store,
+ run_ownership_config=_lease_config(heartbeat_enabled=True, grace_seconds=10),
+ )
+
+ recovered = await manager.reconcile_orphaned_inflight_runs(error="owner expired")
+
+ assert recovered == []
+ stored = await store.get("checkpoint-write-1")
+ assert stored is not None
+ assert stored["status"] == "error"
# ---------------------------------------------------------------------------
@@ -678,12 +777,12 @@ async def test_heartbeat_renews_active_run_leases():
@pytest.mark.anyio
async def test_heartbeat_renews_pending_run_before_task_is_spawned():
- """A run sitting in ``pending`` between ``create_run_atomic`` and task
+ """A run sitting in ``pending`` between ``create_thread_operation_atomic`` and task
spawn must still have its lease renewed.
Pre-fix the renewal filter required ``record.task is not None``, so a
pending run with no task yet (the brief window after
- ``create_run_atomic`` inserts the row before the worker layer spawns
+ ``create_thread_operation_atomic`` inserts the row before the worker layer spawns
the agent task) was silently skipped. If that window stretched past
``lease_seconds`` — e.g. event-loop saturation, slow checkpoint
hydrate — peer reconciliation reclaimed the run as an orphan and
@@ -859,14 +958,14 @@ def test_two_managers_have_different_default_ids():
@pytest.mark.anyio
-async def test_create_run_atomic_reject_prevents_duplicate():
- """store.create_run_atomic with reject must raise ConflictError on duplicate."""
+async def test_create_thread_operation_atomic_reject_prevents_duplicate():
+ """Atomic thread-operation creation must reject a duplicate."""
store = MemoryRunStore()
config = _lease_config()
- store.create_run_atomic = AsyncMock(wraps=store.create_run_atomic)
+ store.create_thread_operation_atomic = AsyncMock(wraps=store.create_thread_operation_atomic)
- await store.create_run_atomic(
+ await store.create_thread_operation_atomic(
run_id="run-1",
thread_id="thread-1",
owner_worker_id="w1",
@@ -876,7 +975,7 @@ async def test_create_run_atomic_reject_prevents_duplicate():
)
with pytest.raises(ConflictError, match="already has an active run"):
- await store.create_run_atomic(
+ await store.create_thread_operation_atomic(
run_id="run-2",
thread_id="thread-1",
owner_worker_id="w2",
@@ -887,14 +986,14 @@ async def test_create_run_atomic_reject_prevents_duplicate():
@pytest.mark.anyio
-async def test_create_run_atomic_interrupt_claims_and_creates():
- """store.create_run_atomic with interrupt must claim old and create new."""
+async def test_create_thread_operation_atomic_interrupt_claims_and_creates():
+ """Atomic thread-operation creation with interrupt must claim and replace."""
store = MemoryRunStore()
config = _lease_config()
# Create an active run with an expired lease (simulating a crashed worker)
expired_lease = (datetime.now(UTC) - timedelta(seconds=60)).isoformat()
- await store.create_run_atomic(
+ await store.create_thread_operation_atomic(
run_id="run-old",
thread_id="thread-1",
owner_worker_id="w1",
@@ -903,7 +1002,7 @@ async def test_create_run_atomic_interrupt_claims_and_creates():
grace_seconds=config.grace_seconds,
)
- new_row, claimed = await store.create_run_atomic(
+ new_row, claimed = await store.create_thread_operation_atomic(
run_id="run-new",
thread_id="thread-1",
owner_worker_id="w2",
@@ -923,7 +1022,7 @@ async def test_create_run_atomic_interrupt_claims_and_creates():
@pytest.mark.anyio
-async def test_create_run_atomic_interrupt_rejects_other_worker_valid_lease():
+async def test_create_thread_operation_atomic_interrupt_rejects_other_worker_valid_lease():
"""Interrupt must raise ConflictError when a valid-lease run is owned by another worker.
The partial unique index ``uq_runs_thread_active`` would reject the INSERT
@@ -934,7 +1033,7 @@ async def test_create_run_atomic_interrupt_rejects_other_worker_valid_lease():
config = _lease_config(grace_seconds=10)
valid_lease = (datetime.now(UTC) + timedelta(seconds=30)).isoformat()
- await store.create_run_atomic(
+ await store.create_thread_operation_atomic(
run_id="valid-lease-run",
thread_id="thread-1",
owner_worker_id="other-worker",
@@ -944,7 +1043,7 @@ async def test_create_run_atomic_interrupt_rejects_other_worker_valid_lease():
)
with pytest.raises(ConflictError, match="another worker"):
- await store.create_run_atomic(
+ await store.create_thread_operation_atomic(
run_id="run-new",
thread_id="thread-1",
owner_worker_id="w2",
@@ -960,13 +1059,13 @@ async def test_create_run_atomic_interrupt_rejects_other_worker_valid_lease():
@pytest.mark.anyio
-async def test_create_run_atomic_interrupt_allows_self_owned_valid_lease():
+async def test_create_thread_operation_atomic_interrupt_allows_self_owned_valid_lease():
"""Interrupt must succeed when the existing valid-lease run is owned by this worker."""
store = MemoryRunStore()
config = _lease_config(grace_seconds=10)
valid_lease = (datetime.now(UTC) + timedelta(seconds=30)).isoformat()
- await store.create_run_atomic(
+ await store.create_thread_operation_atomic(
run_id="self-run",
thread_id="thread-1",
owner_worker_id="w1",
@@ -975,7 +1074,7 @@ async def test_create_run_atomic_interrupt_allows_self_owned_valid_lease():
grace_seconds=config.grace_seconds,
)
- new_row, claimed = await store.create_run_atomic(
+ new_row, claimed = await store.create_thread_operation_atomic(
run_id="run-new",
thread_id="thread-1",
owner_worker_id="w1", # same worker
@@ -991,7 +1090,7 @@ async def test_create_run_atomic_interrupt_allows_self_owned_valid_lease():
@pytest.mark.anyio
-async def test_create_run_atomic_interrupt_rolls_back_earlier_mutations_on_conflict():
+async def test_create_thread_operation_atomic_interrupt_rolls_back_earlier_mutations_on_conflict():
"""Interrupt must not leave earlier candidates interrupted when a later
candidate raises ConflictError.
@@ -1012,7 +1111,7 @@ async def test_create_run_atomic_interrupt_rolls_back_earlier_mutations_on_confl
expired_lease = (datetime.now(UTC) - timedelta(seconds=60)).isoformat()
valid_lease = (datetime.now(UTC) + timedelta(seconds=30)).isoformat()
- # Seed both active rows directly via ``put`` (bypassing create_run_atomic's
+ # Seed both active rows directly via ``put`` (bypassing atomic operation creation's
# reject check, which would refuse the second row). Insert the
# interruptible run first so dict iteration visits it first — that's the
# ordering that exposes the half-interrupted divergence in a naive
@@ -1033,7 +1132,7 @@ async def test_create_run_atomic_interrupt_rolls_back_earlier_mutations_on_confl
)
with pytest.raises(ConflictError, match="another worker"):
- await store.create_run_atomic(
+ await store.create_thread_operation_atomic(
run_id="run-new",
thread_id="thread-1",
owner_worker_id="w1",
diff --git a/backend/tests/test_persistence_bootstrap.py b/backend/tests/test_persistence_bootstrap.py
index 537c6cf78..993f7b1c1 100644
--- a/backend/tests/test_persistence_bootstrap.py
+++ b/backend/tests/test_persistence_bootstrap.py
@@ -48,7 +48,7 @@ from deerflow.persistence.migrations._helpers import _normalize_default
asyncio_test = pytest.mark.asyncio
-HEAD = "0007_scheduled_run_active_index"
+HEAD = "0008_thread_operation_kind"
BASELINE = "0001_baseline"
@@ -147,6 +147,8 @@ async def test_empty_branch_creates_all_and_stamps_head(tmp_path: Path) -> None:
}:
assert required in tables, f"missing table: {required}"
assert "token_usage_by_model" in await _runs_columns(engine)
+ operation_kind = await _runs_column_meta(engine, "operation_kind")
+ assert operation_kind["nullable"] is False
assert await _alembic_version(engine) == HEAD
# The partial unique index on (thread_id WHERE status IN pending/running)
# must exist on a fresh DB because the empty-branch stamps head without
diff --git a/backend/tests/test_persistence_bootstrap_concurrency.py b/backend/tests/test_persistence_bootstrap_concurrency.py
index aa85acb0d..02bce209f 100644
--- a/backend/tests/test_persistence_bootstrap_concurrency.py
+++ b/backend/tests/test_persistence_bootstrap_concurrency.py
@@ -28,7 +28,7 @@ from deerflow.persistence.bootstrap import bootstrap_schema
pytestmark = pytest.mark.asyncio
-HEAD = "0007_scheduled_run_active_index"
+HEAD = "0008_thread_operation_kind"
def _url(tmp_path: Path) -> str:
diff --git a/backend/tests/test_persistence_bootstrap_regression.py b/backend/tests/test_persistence_bootstrap_regression.py
index edec80822..0fb0c48ba 100644
--- a/backend/tests/test_persistence_bootstrap_regression.py
+++ b/backend/tests/test_persistence_bootstrap_regression.py
@@ -76,7 +76,7 @@ async def test_legacy_database_recovers_token_usage_column(tmp_path: Path) -> No
cols = {row[1] for row in raw.execute("PRAGMA table_info(runs)").fetchall()}
assert "token_usage_by_model" in cols
version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone()
- assert version_row[0] == "0007_scheduled_run_active_index"
+ assert version_row[0] == "0008_thread_operation_kind"
# And the read path that originally 500'd must now succeed.
sf = get_session_factory()
@@ -116,6 +116,6 @@ async def test_legacy_database_with_manual_alter_still_bootstraps(tmp_path: Path
# No duplicate column -- list, not set, to catch dupes.
assert cols.count("token_usage_by_model") == 1
version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone()
- assert version_row[0] == "0007_scheduled_run_active_index"
+ assert version_row[0] == "0008_thread_operation_kind"
finally:
await close_engine()
diff --git a/backend/tests/test_run_manager.py b/backend/tests/test_run_manager.py
index ac9f0e8eb..13ad3d44c 100644
--- a/backend/tests/test_run_manager.py
+++ b/backend/tests/test_run_manager.py
@@ -9,7 +9,8 @@ from typing import Any
import pytest
from sqlalchemy.exc import DatabaseError as SQLAlchemyDatabaseError
-from deerflow.runtime import DisconnectMode, RunManager, RunStatus
+from deerflow.config.run_ownership_config import RunOwnershipConfig
+from deerflow.runtime import DisconnectMode, RunManager, RunStatus, ThreadOperationKind
from deerflow.runtime.runs.manager import CancelOutcome, ConflictError, PersistenceRetryPolicy
from deerflow.runtime.runs.store.memory import MemoryRunStore
@@ -99,6 +100,34 @@ class AlwaysMissingCompletionRunStore(MemoryRunStore):
return False
+class FailingDeleteRunStore(MemoryRunStore):
+ """Run store that cannot release a persisted thread-operation row."""
+
+ async def delete(self, run_id, *, user_id=None):
+ raise RuntimeError("delete failed")
+
+
+class LostLeaseRunStore(MemoryRunStore):
+ """Run store that reports a reservation was taken over."""
+
+ async def update_lease(self, run_id, *, owner_worker_id, lease_expires_at):
+ return False
+
+
+class PausedLostLeaseRunStore(MemoryRunStore):
+ """Run store whose failed renewal can be released after reservation cleanup."""
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.renewal_started = asyncio.Event()
+ self.finish_renewal = asyncio.Event()
+
+ async def update_lease(self, run_id, *, owner_worker_id, lease_expires_at):
+ self.renewal_started.set()
+ await self.finish_renewal.wait()
+ return False
+
+
async def _stored_statuses(store: MemoryRunStore, *run_ids: str) -> dict[str, Any]:
rows = {}
for run_id in run_ids:
@@ -107,6 +136,142 @@ async def _stored_statuses(store: MemoryRunStore, *run_ids: str) -> dict[str, An
return rows
+@pytest.mark.anyio
+async def test_reservation_delete_failure_preserves_body_error_and_clears_local_record(caplog):
+ store = FailingDeleteRunStore()
+ manager = RunManager(
+ store=store,
+ persistence_retry_policy=PersistenceRetryPolicy(max_attempts=1, initial_delay=0),
+ )
+
+ with caplog.at_level(logging.WARNING), pytest.raises(ValueError, match="body failed"):
+ async with manager.reserve_thread_operation(
+ "thread-1",
+ kind=ThreadOperationKind.checkpoint_write,
+ ):
+ raise ValueError("body failed")
+
+ assert not await manager.has_inflight("thread-1")
+ assert manager._runs == {}
+ assert manager._runs_by_thread == {}
+ assert len(await store.list_inflight()) == 1
+ assert "leaving it for orphan reconciliation" in caplog.text
+
+
+@pytest.mark.anyio
+async def test_reservation_lease_loss_surfaces_as_conflict_after_cancelling_body():
+ store = LostLeaseRunStore()
+ manager = RunManager(
+ store=store,
+ run_ownership_config=RunOwnershipConfig(
+ lease_seconds=30,
+ grace_seconds=10,
+ heartbeat_enabled=True,
+ ),
+ )
+ entered = asyncio.Event()
+
+ async def hold_reservation() -> None:
+ async with manager.reserve_thread_operation(
+ "thread-1",
+ kind=ThreadOperationKind.checkpoint_write,
+ ):
+ entered.set()
+ await asyncio.Event().wait()
+
+ task = asyncio.create_task(hold_reservation())
+ await entered.wait()
+
+ await manager._renew_leases()
+
+ with pytest.raises(ConflictError, match="reservation lease was lost"):
+ await task
+ assert not await manager.has_inflight("thread-1")
+ assert await store.list_inflight() == []
+
+
+@pytest.mark.anyio
+async def test_reservation_cancelled_while_attaching_task_is_released(monkeypatch):
+ store = MemoryRunStore()
+ manager = RunManager(store=store)
+ admitted = asyncio.Event()
+ return_from_admission = asyncio.Event()
+ original_admit = manager._admit_thread_operation
+
+ async def pause_after_admission(*args, **kwargs):
+ record = await original_admit(*args, **kwargs)
+ admitted.set()
+ await return_from_admission.wait()
+ return record
+
+ monkeypatch.setattr(manager, "_admit_thread_operation", pause_after_admission)
+
+ async def reserve() -> None:
+ async with manager.reserve_thread_operation(
+ "thread-1",
+ kind=ThreadOperationKind.checkpoint_write,
+ ):
+ raise AssertionError("cancelled reservation must not enter its body")
+
+ task = asyncio.create_task(reserve())
+ await admitted.wait()
+ await manager._lock.acquire()
+ return_from_admission.set()
+ await asyncio.sleep(0)
+ task.cancel()
+ manager._lock.release()
+
+ with pytest.raises(asyncio.CancelledError):
+ await task
+ assert not await manager.has_inflight("thread-1")
+ assert manager._runs == {}
+ assert manager._runs_by_thread == {}
+ assert await store.list_inflight() == []
+
+
+@pytest.mark.anyio
+async def test_late_failed_renewal_does_not_cancel_released_reservation():
+ store = PausedLostLeaseRunStore()
+ manager = RunManager(
+ store=store,
+ run_ownership_config=RunOwnershipConfig(
+ lease_seconds=30,
+ grace_seconds=10,
+ heartbeat_enabled=True,
+ ),
+ )
+ entered = asyncio.Event()
+ leave_body = asyncio.Event()
+ context_exited = asyncio.Event()
+ finish_request = asyncio.Event()
+
+ async def request() -> None:
+ async with manager.reserve_thread_operation(
+ "thread-1",
+ kind=ThreadOperationKind.checkpoint_write,
+ ):
+ entered.set()
+ await leave_body.wait()
+ context_exited.set()
+ await finish_request.wait()
+
+ request_task = asyncio.create_task(request())
+ await entered.wait()
+ renewal_task = asyncio.create_task(manager._renew_leases())
+ await store.renewal_started.wait()
+
+ leave_body.set()
+ await context_exited.wait()
+ assert not await manager.has_inflight("thread-1")
+
+ store.finish_renewal.set()
+ await renewal_task
+ assert not request_task.done()
+
+ finish_request.set()
+ await request_task
+
+
@pytest.mark.anyio
async def test_create_and_get(manager: RunManager):
"""Created run should be retrievable with new fields."""
@@ -367,6 +532,16 @@ async def test_has_inflight(manager: RunManager):
assert await manager.has_inflight("thread-1") is False
+@pytest.mark.anyio
+async def test_has_inflight_ignores_checkpoint_write_reservation(manager: RunManager):
+ """Internal checkpoint writers are not user-visible runs."""
+ async with manager.reserve_thread_operation(
+ "thread-1",
+ kind=ThreadOperationKind.checkpoint_write,
+ ):
+ assert await manager.has_inflight("thread-1") is False
+
+
@pytest.mark.anyio
async def test_cleanup(manager: RunManager):
"""After cleanup, the run should be gone."""
@@ -662,7 +837,7 @@ async def test_create_or_reject_does_not_interrupt_old_run_when_new_run_store_wr
manager = RunManager(store=store)
old = await manager.create("thread-1")
await manager.set_status(old.run_id, RunStatus.running)
- store.create_run_atomic = AsyncMock(side_effect=RuntimeError("db down"))
+ store.create_thread_operation_atomic = AsyncMock(side_effect=RuntimeError("db down"))
with pytest.raises(RuntimeError, match="db down"):
await manager.create_or_reject("thread-1", multitask_strategy="interrupt")
@@ -686,7 +861,7 @@ async def test_create_or_reject_does_not_interrupt_old_run_when_new_run_store_wr
async def cancelled_create(run_id, **kwargs):
raise asyncio.CancelledError
- store.create_run_atomic = cancelled_create
+ store.create_thread_operation_atomic = cancelled_create
with pytest.raises(asyncio.CancelledError):
await manager.create_or_reject("thread-1", multitask_strategy="interrupt")
@@ -900,12 +1075,12 @@ async def test_list_by_thread_falls_back_to_store_with_user_filter():
class _FailingPutRunStore(MemoryRunStore):
- """Memory run store whose every ``put`` and ``create_run_atomic`` fails (non-retryably)."""
+ """Memory run store whose every ``put`` and atomic operation create fails."""
async def put(self, run_id, **kwargs):
raise ValueError("simulated persist failure")
- async def create_run_atomic(self, run_id, **kwargs):
+ async def create_thread_operation_atomic(self, run_id, **kwargs):
raise ValueError("simulated persist failure")
diff --git a/backend/tests/test_run_repository.py b/backend/tests/test_run_repository.py
index 31785e272..82a2b3ef8 100644
--- a/backend/tests/test_run_repository.py
+++ b/backend/tests/test_run_repository.py
@@ -9,7 +9,7 @@ import pytest
from sqlalchemy.dialects import postgresql
from deerflow.persistence.run import RunRepository
-from deerflow.runtime import CancelOutcome, RunManager, RunStatus
+from deerflow.runtime import CancelOutcome, RunManager, RunStatus, ThreadOperationKind
from deerflow.runtime.runs.manager import ConflictError
from deerflow.runtime.runs.store.base import RunStore
@@ -29,6 +29,9 @@ async def _cleanup():
class _CustomRunStoreWithoutProgress(RunStore):
+ def __init__(self):
+ self.legacy_atomic_calls = 0
+
async def put(self, *args, **kwargs):
return None
@@ -66,6 +69,7 @@ class _CustomRunStoreWithoutProgress(RunStore):
return []
async def create_run_atomic(self, *args, **kwargs):
+ self.legacy_atomic_calls += 1
return {}, []
async def claim_for_takeover(self, *args, **kwargs):
@@ -79,6 +83,28 @@ async def test_update_run_progress_defaults_to_noop_for_custom_store():
await store.update_run_progress("r1", total_tokens=1)
+@pytest.mark.anyio
+async def test_legacy_create_run_atomic_store_remains_compatible():
+ store = _CustomRunStoreWithoutProgress()
+
+ await store.create_thread_operation_atomic(
+ "r1",
+ thread_id="t1",
+ owner_worker_id="worker-1",
+ lease_expires_at=None,
+ )
+
+ assert store.legacy_atomic_calls == 1
+ with pytest.raises(NotImplementedError, match="cannot create non-run"):
+ await store.create_thread_operation_atomic(
+ "checkpoint-write-1",
+ thread_id="t1",
+ owner_worker_id="worker-1",
+ lease_expires_at=None,
+ operation_kind=ThreadOperationKind.checkpoint_write,
+ )
+
+
class TestRunRepository:
@pytest.mark.anyio
async def test_put_and_get(self, tmp_path):
@@ -148,6 +174,22 @@ class TestRunRepository:
assert all(r["thread_id"] == "t1" for r in rows)
await _cleanup()
+ @pytest.mark.anyio
+ async def test_run_history_excludes_internal_thread_operations(self, tmp_path):
+ repo = await _make_repo(tmp_path)
+ await repo.put("r1", thread_id="t1", status="success")
+ await repo.put(
+ "checkpoint-write-1",
+ thread_id="t1",
+ status="error",
+ operation_kind=ThreadOperationKind.checkpoint_write,
+ )
+
+ rows = await repo.list_by_thread("t1")
+
+ assert [row["run_id"] for row in rows] == ["r1"]
+ await _cleanup()
+
@pytest.mark.anyio
async def test_list_by_thread_owner_filter(self, tmp_path):
repo = await _make_repo(tmp_path)
@@ -648,7 +690,7 @@ class TestRunRepository:
await _cleanup()
@pytest.mark.anyio
- async def test_create_run_atomic_reject_propagates_conflict_on_unique_violation(self, tmp_path):
+ async def test_create_thread_operation_atomic_rejects_unique_violation(self, tmp_path):
"""reject path against a real SQLite-backed store must surface as ConflictError, not raw IntegrityError.
The partial unique index ``uq_runs_thread_active`` is created by
@@ -678,7 +720,7 @@ class TestRunRepository:
# Pre-insert an active run on thread T directly through the store so
# the partial unique index has something to enforce on the second insert.
lease = (datetime.now(UTC) + timedelta(seconds=30)).isoformat()
- await repo.create_run_atomic(
+ await repo.create_thread_operation_atomic(
"run-A",
thread_id="thread-T",
owner_worker_id="worker-A",
@@ -697,6 +739,65 @@ class TestRunRepository:
await _cleanup()
+ @pytest.mark.anyio
+ async def test_checkpoint_write_reservation_blocks_interrupt_run_on_sql_store(self, tmp_path):
+ """An interrupt-strategy run cannot displace a durable checkpoint writer."""
+ repo = await _make_repo(tmp_path)
+ compaction_worker = RunManager(store=repo, worker_id="worker-a")
+ run_worker = RunManager(store=repo, worker_id="worker-b")
+
+ async with compaction_worker.reserve_thread_operation("thread-T", kind=ThreadOperationKind.checkpoint_write):
+ with pytest.raises(ConflictError, match="checkpoint write"):
+ await run_worker.create_or_reject("thread-T", multitask_strategy="interrupt")
+
+ assert await repo.list_by_thread("thread-T") == []
+ admitted = await run_worker.create_or_reject("thread-T")
+ assert admitted.status == RunStatus.pending
+ await _cleanup()
+
+ @pytest.mark.anyio
+ async def test_reservation_release_uses_record_user_without_ambient_context(self, tmp_path):
+ """Release must not depend on the request ContextVar still being set."""
+ repo = await _make_repo(tmp_path)
+ manager = RunManager(store=repo)
+
+ async with manager.reserve_thread_operation(
+ "thread-T",
+ kind=ThreadOperationKind.checkpoint_write,
+ user_id="reservation-owner",
+ ):
+ inflight = await repo.list_inflight()
+ assert len(inflight) == 1
+ assert inflight[0]["user_id"] == "reservation-owner"
+
+ assert await repo.list_inflight() == []
+ await _cleanup()
+
+ @pytest.mark.anyio
+ async def test_interrupt_reclaims_expired_checkpoint_write_reservation_on_sql_store(self, tmp_path):
+ """An expired durable checkpoint writer is immediately reclaimable."""
+ repo = await _make_repo(tmp_path)
+ expired = (datetime.now(UTC) - timedelta(seconds=30)).isoformat()
+ await repo.put(
+ "checkpoint-write-1",
+ thread_id="thread-T",
+ status="pending",
+ operation_kind=ThreadOperationKind.checkpoint_write,
+ owner_worker_id="dead-worker",
+ lease_expires_at=expired,
+ created_at=expired,
+ )
+ manager = RunManager(store=repo, worker_id="worker-b")
+
+ admitted = await manager.create_or_reject("thread-T", multitask_strategy="interrupt")
+
+ assert admitted.status == RunStatus.pending
+ stale = await repo.get("checkpoint-write-1")
+ assert stale is not None
+ assert stale["status"] == "interrupted"
+ assert stale["owner_worker_id"] == "worker-b"
+ await _cleanup()
+
@pytest.mark.anyio
async def test_is_unique_violation_detects_real_sqlite_integrity_error(self, tmp_path):
"""``_is_unique_violation`` must return True for a real SQLite IntegrityError.
@@ -777,12 +878,12 @@ class TestRunRepository:
assert _is_unique_violation(sa_err) is True
@pytest.mark.anyio
- async def test_create_run_atomic_interrupt_tolerates_tz_naive_lease_on_sqlite(self, tmp_path):
+ async def test_create_thread_operation_atomic_tolerates_tz_naive_lease_on_sqlite(self, tmp_path):
"""Interrupt path must not raise TypeError comparing naive vs aware datetimes.
SQLite drops tzinfo on read despite ``DateTime(timezone=True)`` (see
the comment in ``RunRepository._row_to_dict``). The interrupt branch
- of ``create_run_atomic`` compares ``row.lease_expires_at`` against
+ of ``create_thread_operation_atomic`` compares ``row.lease_expires_at`` against
the aware ``cutoff = datetime.now(UTC) - ...`` in Python. Under
default config (heartbeat disabled) leases are always NULL so the
``is not None`` check short-circuits, but there is no guard against
@@ -801,7 +902,7 @@ class TestRunRepository:
# The lease value is stored as ISO; SQLite reads it back as a tz-naive
# datetime — exactly the shape that triggered the bug.
valid_lease = (datetime.now(UTC) + timedelta(seconds=30)).isoformat()
- await repo.create_run_atomic(
+ await repo.create_thread_operation_atomic(
"valid-lease-run",
thread_id="thread-T",
owner_worker_id="other-worker",
@@ -813,7 +914,7 @@ class TestRunRepository:
# The interrupt path must surface a clean ConflictError, not a
# TypeError from the naive-vs-aware comparison.
with pytest.raises(ConflictError, match="another worker"):
- await repo.create_run_atomic(
+ await repo.create_thread_operation_atomic(
"run-new",
thread_id="thread-T",
owner_worker_id="w1",
diff --git a/backend/tests/test_threads_router.py b/backend/tests/test_threads_router.py
index 73f1b20a9..df2c702f9 100644
--- a/backend/tests/test_threads_router.py
+++ b/backend/tests/test_threads_router.py
@@ -1,5 +1,6 @@
import asyncio
import re
+from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
@@ -52,6 +53,15 @@ class _PermissiveThreadMetaStore(MemoryThreadMetaStore):
return await super().search(metadata=metadata, status=status, limit=limit, offset=offset, user_id=None)
+class _ThreadTestRunManager:
+ async def list_by_thread(self, _thread_id: str, *, user_id=None, limit: int = 100) -> list:
+ return []
+
+ @asynccontextmanager
+ async def reserve_thread_operation(self, _thread_id: str, **_kwargs):
+ yield
+
+
def _build_thread_app() -> tuple[FastAPI, InMemoryStore, InMemorySaver]:
"""Build a stub-authed FastAPI app wired with an in-memory ThreadMetaStore.
@@ -66,11 +76,82 @@ def _build_thread_app() -> tuple[FastAPI, InMemoryStore, InMemorySaver]:
checkpointer = InMemorySaver()
app.state.store = store
app.state.checkpointer = checkpointer
+ app.state.run_manager = _ThreadTestRunManager()
app.state.thread_store = _PermissiveThreadMetaStore(store)
app.include_router(threads.router)
return app, store, checkpointer
+def test_compact_rejects_run_owned_by_another_worker(monkeypatch) -> None:
+ """The HTTP guard must consult the shared store, not only local run memory."""
+ from deerflow.runtime import RunManager, RunStatus
+ from deerflow.runtime.runs.store.memory import MemoryRunStore
+
+ app, _store, _checkpointer = _build_thread_app()
+ run_store = MemoryRunStore()
+ owner = RunManager(store=run_store, worker_id="worker-a")
+ non_owner = RunManager(store=run_store, worker_id="worker-b")
+ app.state.run_manager = non_owner
+ monkeypatch.setattr(
+ threads,
+ "build_checkpoint_state_mutation_accessor",
+ lambda *_args, **_kwargs: (SimpleNamespace(), None),
+ )
+
+ async def _seed_active_run() -> None:
+ active = await owner.create_or_reject("thread-compact-race")
+ await owner.set_status(active.run_id, RunStatus.running)
+
+ asyncio.run(_seed_active_run())
+
+ with TestClient(app) as client:
+ created = client.post("/api/threads", json={"thread_id": "thread-compact-race"})
+ assert created.status_code == 200, created.text
+ response = client.post("/api/threads/thread-compact-race/compact", json={"force": True})
+
+ assert response.status_code == 409
+ assert response.json()["detail"] == "Thread has a run in flight. Compact after the run finishes."
+
+
+def test_update_state_rejects_run_owned_by_another_worker(monkeypatch) -> None:
+ """All out-of-run writes share the same durable thread-operation admission."""
+ from deerflow.runtime import RunManager, RunStatus
+ from deerflow.runtime.runs.store.memory import MemoryRunStore
+
+ app, _store, _checkpointer = _build_thread_app()
+ run_store = MemoryRunStore()
+ owner = RunManager(store=run_store, worker_id="worker-a")
+ app.state.run_manager = RunManager(store=run_store, worker_id="worker-b")
+ accessor = SimpleNamespace(
+ graph=None,
+ aupdate=AsyncMock(side_effect=AssertionError("state write must not run")),
+ aget=AsyncMock(),
+ )
+ monkeypatch.setattr(
+ threads,
+ "build_thread_checkpoint_state_mutation_accessor",
+ AsyncMock(return_value=(accessor, {"configurable": {"thread_id": "thread-state-race"}})),
+ )
+
+ async def _seed_active_run() -> None:
+ active = await owner.create_or_reject("thread-state-race")
+ await owner.set_status(active.run_id, RunStatus.running)
+
+ asyncio.run(_seed_active_run())
+
+ with TestClient(app) as client:
+ created = client.post("/api/threads", json={"thread_id": "thread-state-race"})
+ assert created.status_code == 200, created.text
+ response = client.post(
+ "/api/threads/thread-state-race/state",
+ json={"values": {"title": "must not write"}},
+ )
+
+ assert response.status_code == 409
+ assert response.json()["detail"] == "Thread has a run in flight. Update state after the run finishes."
+ accessor.aupdate.assert_not_awaited()
+
+
class _RawStateAccessor:
def __init__(self, checkpointer: InMemorySaver):
self.checkpointer = checkpointer
@@ -593,6 +674,44 @@ def test_goal_status_and_clear_round_trip() -> None:
assert "goal" not in state_response.json()["values"]
+def test_goal_mutations_reject_run_owned_by_another_worker() -> None:
+ """PUT and DELETE goal writes share the durable thread-operation boundary."""
+ from deerflow.runtime import RunManager, RunStatus
+ from deerflow.runtime.runs.store.memory import MemoryRunStore
+
+ app, _store, _checkpointer = _build_thread_app()
+ run_store = MemoryRunStore()
+ owner = RunManager(store=run_store, worker_id="worker-a")
+ app.state.run_manager = RunManager(store=run_store, worker_id="worker-b")
+ thread_id = "thread-goal-race"
+
+ async def _seed_active_run() -> None:
+ active = await owner.create_or_reject(thread_id)
+ await owner.set_status(active.run_id, RunStatus.running)
+
+ with TestClient(app) as client:
+ created = client.post("/api/threads", json={"thread_id": thread_id})
+ assert created.status_code == 200, created.text
+ initial_goal = client.put(
+ f"/api/threads/{thread_id}/goal",
+ json={"objective": "Original goal"},
+ )
+ assert initial_goal.status_code == 200, initial_goal.text
+ assert client.portal is not None
+ client.portal.call(_seed_active_run)
+ put_response = client.put(
+ f"/api/threads/{thread_id}/goal",
+ json={"objective": "Must not be written"},
+ )
+ delete_response = client.delete(f"/api/threads/{thread_id}/goal")
+ goal_response = client.get(f"/api/threads/{thread_id}/goal")
+
+ assert put_response.status_code == 409, put_response.text
+ assert delete_response.status_code == 409, delete_response.text
+ assert goal_response.status_code == 200, goal_response.text
+ assert goal_response.json()["goal"]["objective"] == "Original goal"
+
+
def test_internal_owner_header_assigns_thread_to_owner() -> None:
import asyncio
@@ -1116,7 +1235,7 @@ def test_branch_thread_can_prepare_regenerate_without_branch_run_events() -> Non
return []
app.state.run_event_store = SimpleNamespace(list_messages=list_messages)
- app.state.run_manager = SimpleNamespace(list_by_thread=list_by_thread)
+ app.state.run_manager.list_by_thread = list_by_thread
human = HumanMessage(id="human-1", content="Question", additional_kwargs={"run_id": source_run_id})
ai = AIMessage(id="ai-1", content="Answer")
@@ -1741,7 +1860,7 @@ def test_state_endpoints_preserve_extension_reducer_channels(monkeypatch, mode)
return []
app.state.run_event_store = SimpleNamespace(list_messages=list_messages)
- app.state.run_manager = SimpleNamespace(list_by_thread=list_by_thread)
+ app.state.run_manager.list_by_thread = list_by_thread
recorded_updates: list[dict] = []
real_mutation_builder = gateway_services.build_checkpoint_state_mutation_accessor
diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md
index 7db3826a3..19944de56 100644
--- a/frontend/AGENTS.md
+++ b/frontend/AGENTS.md
@@ -77,7 +77,7 @@ Composer drafts are tab-scoped browser state. `core/threads/composer-draft.ts` s
Auth UI note: the login page's "keep me signed in" option submits only `remember_me` to the Gateway and may persist only the email address through `core/auth/remember-login.ts`. Passwords and tokens must never be stored in frontend storage; the `HttpOnly access_token` and readable `csrf_token` cookies remain Gateway-owned.
-`/goal` and `/compact` are built-in composer commands, not skill activations. `src/components/workspace/input-box.tsx` intercepts `/goal`, `/goal clear`, and `/goal ` before normal chat submission, calling Gateway `GET/PUT/DELETE /api/threads/{thread_id}/goal`. Setting `/goal ` also submits the condition text as the next user task so the agent starts running immediately; status and clear do not start a run. Goal and compact requests are tied to the current `threadId` with an `AbortController`, so switching threads or unmounting the composer aborts in-flight requests and stale responses cannot update the new thread's composer state. The chat pages render `GoalStatus` above the composer from `AgentThreadState.goal`, with local optimistic state until the next stream `values` update arrives. `/compact` calls `POST /api/threads/{thread_id}/compact` to summarize older active context while leaving the full visible chat history intact; it is skipped on new/empty threads and blocked server-side while a run is in flight.
+`/goal` and `/compact` are built-in composer commands, not skill activations. `src/components/workspace/input-box.tsx` intercepts `/goal`, `/goal clear`, and `/goal ` before normal chat submission, calling Gateway `GET/PUT/DELETE /api/threads/{thread_id}/goal`. Setting `/goal ` also submits the condition text as the next user task so the agent starts running immediately; status and clear do not start a run. Goal and compact requests are tied to the current `threadId` with an `AbortController`, so switching threads or unmounting the composer aborts in-flight requests and stale responses cannot update the new thread's composer state. The chat pages render `GoalStatus` above the composer from `AgentThreadState.goal`, with local optimistic state until the next stream `values` update arrives. `/compact` calls `POST /api/threads/{thread_id}/compact` to summarize older active context while leaving the full visible chat history intact; it is skipped on new/empty threads and blocked server-side while a run is in flight. Thread rename uses the same serialized state-write route; the rename dialog stays open and surfaces the server error when an active run returns 409.
Human input requests are a structured message protocol layered on normal chat history. The backend writes request payloads to `ToolMessage.artifact.human_input`, `src/core/messages/human-input.ts` owns the runtime validators/types, and `src/components/workspace/messages/human-input-card.tsx` renders the reusable card. `MessageList` owns answered/latest/pending state for visible cards, but derives answered responses from raw `thread.messages` because replies are hidden; pending cards clear when the hidden reply appears, when dispatch is dropped, or when a new `thread.error` reports an async stream failure. Page-level submit callbacks must send a normal human message and put `hide_from_ui: true` plus the response payload in the fourth `sendMessage(..., options)` argument as `options.additionalKwargs`; the third argument remains run context such as `{ agent_name }`. Composer entry points should disable normal bottom input while `hasOpenHumanInputRequest(...)` is true so users answer through the card and preserve response metadata.
diff --git a/frontend/src/components/workspace/recent-chat-list.tsx b/frontend/src/components/workspace/recent-chat-list.tsx
index 4c591fd59..826b8af87 100644
--- a/frontend/src/components/workspace/recent-chat-list.tsx
+++ b/frontend/src/components/workspace/recent-chat-list.tsx
@@ -167,12 +167,25 @@ export function RecentChatList() {
const handleRenameSubmit = useCallback(() => {
if (renameThreadId && renameValue.trim()) {
- renameThread({ threadId: renameThreadId, title: renameValue.trim() });
- setRenameDialogOpen(false);
- setRenameThreadId(null);
- setRenameValue("");
+ renameThread(
+ { threadId: renameThreadId, title: renameValue.trim() },
+ {
+ onSuccess: () => {
+ setRenameDialogOpen(false);
+ setRenameThreadId(null);
+ setRenameValue("");
+ },
+ onError: (error) => {
+ toast.error(
+ error instanceof Error && error.message
+ ? error.message
+ : t.common.renameFailed,
+ );
+ },
+ },
+ );
}
- }, [renameThread, renameThreadId, renameValue]);
+ }, [renameThread, renameThreadId, renameValue, t.common.renameFailed]);
const handleShare = useCallback(
async (thread: AgentThread) => {
diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts
index e35269af4..7caec8dcb 100644
--- a/frontend/src/core/i18n/locales/en-US.ts
+++ b/frontend/src/core/i18n/locales/en-US.ts
@@ -24,6 +24,7 @@ export const enUS: Translations = {
delete: "Delete",
edit: "Edit",
rename: "Rename",
+ renameFailed: "Failed to rename thread.",
share: "Share",
openInNewWindow: "Open in new window",
close: "Close",
diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts
index 529b34a48..35b3469eb 100644
--- a/frontend/src/core/i18n/locales/types.ts
+++ b/frontend/src/core/i18n/locales/types.ts
@@ -13,6 +13,7 @@ export interface Translations {
delete: string;
edit: string;
rename: string;
+ renameFailed: string;
share: string;
openInNewWindow: string;
close: string;
diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts
index 87723536a..d49d1119b 100644
--- a/frontend/src/core/i18n/locales/zh-CN.ts
+++ b/frontend/src/core/i18n/locales/zh-CN.ts
@@ -24,6 +24,7 @@ export const zhCN: Translations = {
delete: "删除",
edit: "编辑",
rename: "重命名",
+ renameFailed: "重命名会话失败。",
share: "分享",
openInNewWindow: "在新窗口打开",
close: "关闭",
diff --git a/frontend/tests/unit/core/threads/api.test.ts b/frontend/tests/unit/core/threads/api.test.ts
index 042829101..84740909f 100644
--- a/frontend/tests/unit/core/threads/api.test.ts
+++ b/frontend/tests/unit/core/threads/api.test.ts
@@ -155,3 +155,19 @@ test("compactThreadContext posts agent attribution and abort signal", async () =
},
);
});
+
+test("compactThreadContext surfaces an active-run conflict", async () => {
+ fetchWithAuth.mockResolvedValue({
+ ok: false,
+ status: 409,
+ json: async () => ({
+ detail: "Thread has a run in flight. Compact after the run finishes.",
+ }),
+ });
+
+ const { compactThreadContext } = await import("@/core/threads/api");
+
+ await expect(compactThreadContext("thread-1")).rejects.toThrow(
+ "Thread has a run in flight. Compact after the run finishes.",
+ );
+});
From 8af760fc3036044c72498b65c3c131c3fd98f924 Mon Sep 17 00:00:00 2001
From: Huixin615
Date: Sat, 25 Jul 2026 23:26:17 +0800
Subject: [PATCH 05/35] fix(runtime): make orphan reconciliation lease-aware
(#4427)
---
CHANGELOG.md | 4 ++
README.md | 2 +
.../harness/deerflow/runtime/runs/manager.py | 5 ++-
.../tests/test_multi_worker_run_ownership.py | 45 +++++++++++++++++++
backend/tests/test_run_repository.py | 41 +++++++++++++++++
5 files changed, 96 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 382bb17d1..86c8b6f6d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -198,6 +198,9 @@ This section accumulates work toward the **2.1.0** milestone
### Fixed
+- **runtime:** Re-check orphan candidates through an atomic, lease-aware takeover
+ claim so a successful heartbeat after the scan keeps the run active and only
+ one reconciler reports recovery. ([#4424])
- **skills:** Apply `allowed-tools` only to slash-activated or actually loaded
lead-agent skills, preventing passive enabled skills and evaluation fixtures
from removing MCP, web, file, and delegation tools from every run. ([#4095],
@@ -1114,3 +1117,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#4287]: https://github.com/bytedance/deer-flow/pull/4287
[#4288]: https://github.com/bytedance/deer-flow/pull/4288
[#4324]: https://github.com/bytedance/deer-flow/issues/4324
+[#4424]: https://github.com/bytedance/deer-flow/issues/4424
diff --git a/README.md b/README.md
index d77e58444..9fc1a6507 100644
--- a/README.md
+++ b/README.md
@@ -276,6 +276,8 @@ DeerFlow still uses `Forwarded` / `X-Forwarded-*` headers to recover the browser
> [!IMPORTANT]
> The Gateway still owns active run tasks in process, so production defaults to a single Gateway worker (`GATEWAY_WORKERS=1`). Multi-worker deployments require Postgres, the Redis stream bridge (`stream_bridge.type: redis`), and `run_ownership.heartbeat_enabled: true`. The bridge shares SSE delivery and `Last-Event-ID` replay across workers, while lease reconciliation marks runs from dead workers as errors, publishes the terminal stream marker, schedules retained-stream cleanup, and updates the affected thread status. SSE and `/wait` consumers also refresh durable status on heartbeats as a fallback if terminal publication fails. The rolling retained-buffer TTL (`stream_ttl_seconds`) remains a cleanup safety net rather than a run timeout. IM channel state and other process-local services still need their own multi-worker coordination.
+>
+> Reconciliation uses an atomic takeover claim that re-checks the lease after candidate selection, so a successful owner renewal wins over orphan recovery and only one reconciler can report a run as recovered.
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed Docker development guide.
diff --git a/backend/packages/harness/deerflow/runtime/runs/manager.py b/backend/packages/harness/deerflow/runtime/runs/manager.py
index 1dfaad500..a52739067 100644
--- a/backend/packages/harness/deerflow/runtime/runs/manager.py
+++ b/backend/packages/harness/deerflow/runtime/runs/manager.py
@@ -1213,7 +1213,10 @@ class RunManager:
Rows with a still-valid lease are skipped — they belong to another live
worker. Rows with a NULL lease (pre-ownership data) are reclaimed as
- well, matching the original single-worker recovery behaviour.
+ well, matching the original single-worker recovery behaviour. The
+ candidate scan is only an optimization: each row is claimed with a
+ lease-aware conditional update so a heartbeat renewal after the scan
+ always wins over reconciliation.
"""
if self._store is None:
return []
diff --git a/backend/tests/test_multi_worker_run_ownership.py b/backend/tests/test_multi_worker_run_ownership.py
index 67f0f6fc2..57f237748 100644
--- a/backend/tests/test_multi_worker_run_ownership.py
+++ b/backend/tests/test_multi_worker_run_ownership.py
@@ -403,6 +403,51 @@ async def test_reconciliation_skips_candidate_when_owner_renews_lease_after_scan
assert datetime.fromisoformat(stored["lease_expires_at"]) > datetime.now(UTC)
+@pytest.mark.anyio
+async def test_concurrent_reconcilers_report_one_successful_claim():
+ """Two reapers scanning the same candidate must report one recovery."""
+ store = MemoryRunStore()
+ grace = 10
+ run_id = "contended-orphan"
+ await store.put(
+ run_id,
+ thread_id="thread-1",
+ status="running",
+ owner_worker_id="worker-dead",
+ lease_expires_at=(datetime.now(UTC) - timedelta(seconds=grace + 5)).isoformat(),
+ created_at=(datetime.now(UTC) - timedelta(seconds=120)).isoformat(),
+ )
+
+ original_scan = store.list_inflight_with_expired_lease
+ both_scanned = asyncio.Event()
+ scan_lock = asyncio.Lock()
+ scan_count = 0
+
+ async def synchronized_scan(*, before=None, grace_seconds=10):
+ nonlocal scan_count
+ rows = [dict(row) for row in await original_scan(before=before, grace_seconds=grace_seconds)]
+ async with scan_lock:
+ scan_count += 1
+ if scan_count == 2:
+ both_scanned.set()
+ await asyncio.wait_for(both_scanned.wait(), timeout=1)
+ return rows
+
+ store.list_inflight_with_expired_lease = synchronized_scan
+ managers = [
+ _make_manager(store=store, run_ownership_config=_lease_config(heartbeat_enabled=True, grace_seconds=grace)),
+ _make_manager(store=store, run_ownership_config=_lease_config(heartbeat_enabled=True, grace_seconds=grace)),
+ ]
+
+ results = await asyncio.gather(*(manager.reconcile_orphaned_inflight_runs(error="orphaned") for manager in managers))
+
+ assert sorted(len(recovered) for recovered in results) == [0, 1]
+ assert [record.run_id for recovered in results for record in recovered] == [run_id]
+ row = await store.get(run_id)
+ assert row is not None
+ assert row["status"] == "error"
+
+
@pytest.mark.anyio
async def test_reconciliation_claims_null_lease_runs():
"""Pre-ownership rows (NULL lease) must be reclaimed."""
diff --git a/backend/tests/test_run_repository.py b/backend/tests/test_run_repository.py
index 82a2b3ef8..95952d463 100644
--- a/backend/tests/test_run_repository.py
+++ b/backend/tests/test_run_repository.py
@@ -958,6 +958,47 @@ class TestRunRepository:
assert row["status"] == "running"
await _cleanup()
+ @pytest.mark.anyio
+ async def test_reconciliation_skips_run_renewed_after_scan(self, tmp_path):
+ """The SQL takeover CAS must reject a candidate renewed after its scan."""
+ repo = await _make_repo(tmp_path)
+ grace = 10
+ run_id = "renewed-after-scan"
+ owner_worker_id = "worker-alive"
+ try:
+ await repo.put(
+ run_id,
+ thread_id="t1",
+ status="running",
+ owner_worker_id=owner_worker_id,
+ lease_expires_at=(datetime.now(UTC) - timedelta(seconds=grace + 5)).isoformat(),
+ created_at=(datetime.now(UTC) - timedelta(seconds=120)).isoformat(),
+ )
+ original_scan = repo.list_inflight_with_expired_lease
+
+ async def scan_then_renew(*, before=None, grace_seconds=10):
+ rows = await original_scan(before=before, grace_seconds=grace_seconds)
+ renewed = await repo.update_lease(
+ run_id,
+ owner_worker_id=owner_worker_id,
+ lease_expires_at=(datetime.now(UTC) + timedelta(seconds=60)).isoformat(),
+ )
+ assert renewed is True
+ return rows
+
+ repo.list_inflight_with_expired_lease = scan_then_renew
+ manager = RunManager(store=repo)
+
+ recovered = await manager.reconcile_orphaned_inflight_runs(error="orphaned")
+
+ row = await repo.get(run_id)
+ assert recovered == []
+ assert row is not None
+ assert row["status"] == "running"
+ assert datetime.fromisoformat(row["lease_expires_at"]) > datetime.now(UTC)
+ finally:
+ await _cleanup()
+
@pytest.mark.anyio
async def test_claim_for_takeover_succeeds_with_null_lease(self, tmp_path):
repo = await _make_repo(tmp_path)
From 735f67a5b27264d6166b45b56687a9896ad10bc3 Mon Sep 17 00:00:00 2001
From: MiaoRuidx <965742329@qq.com>
Date: Sat, 25 Jul 2026 23:50:21 +0800
Subject: [PATCH 06/35] fix: guard pending run startup cancellation (#4450)
* fix: guard pending run startup cancellation
* fix(run): address startup review feedback
* fix(run): narrow start_run store contract
---------
Co-authored-by: MiaoRuidx <12540796+MiaoRuidx@users.noreply.github.com>
Co-authored-by: Willem Jiang
---
CHANGELOG.md | 4 +
backend/AGENTS.md | 1 +
backend/app/gateway/services.py | 185 +++++++++++++-----
.../harness/deerflow/persistence/run/sql.py | 14 ++
.../harness/deerflow/runtime/runs/manager.py | 88 ++++++++-
.../deerflow/runtime/runs/store/base.py | 8 +
.../deerflow/runtime/runs/store/memory.py | 8 +
.../harness/deerflow/runtime/runs/worker.py | 32 ++-
backend/tests/test_gateway_services.py | 166 +++++++++++++++-
backend/tests/test_goal_worker.py | 14 +-
backend/tests/test_run_duration_checkpoint.py | 8 +-
backend/tests/test_run_manager.py | 60 +++++-
backend/tests/test_run_repository.py | 19 ++
backend/tests/test_run_worker_rollback.py | 55 +++++-
.../tests/test_worker_langfuse_metadata.py | 5 +-
.../test_worker_stream_subgraph_namespace.py | 6 +-
16 files changed, 604 insertions(+), 69 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 86c8b6f6d..4c2ffb08d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -198,6 +198,10 @@ This section accumulates work toward the **2.1.0** milestone
### Fixed
+- **runtime:** Thread metadata now switches to `running` only after the run passes
+ the startup barrier, so pending-cancelled runs no longer briefly project
+ `running`; clients may observe the prior thread status during worker startup.
+ ([#4450])
- **runtime:** Re-check orphan candidates through an atomic, lease-aware takeover
claim so a successful heartbeat after the scan keeps the run active and only
one reconciler reports recovery. ([#4424])
diff --git a/backend/AGENTS.md b/backend/AGENTS.md
index e0c7e7f2f..f460fc239 100644
--- a/backend/AGENTS.md
+++ b/backend/AGENTS.md
@@ -450,6 +450,7 @@ metadata only.
- `RunManager.get()` is async; direct callers must `await` it.
- The history batch helpers `list_successful_regenerate_sources()` and `get_many_by_thread()` default to `user_id=AUTO`: they resolve the request user and fail closed when no user context exists. Migration/admin callers that intentionally need an unscoped read must pass `user_id=None` explicitly.
- When a persistent `RunStore` is configured, `get()` and `list_by_thread()` hydrate historical runs from the store. In-memory records win for the same `run_id` so task, abort, and stream-control state stays attached to active local runs.
+- Thread metadata status switches to `running` only after `RunManager.try_start()` succeeds. Pending-cancelled runs therefore skip the old `running` projection, while clients may observe the prior thread status during the short worker-startup window.
- `cancel()` returns a :class:`~deerflow.runtime.CancelOutcome` enum: `cancelled` (local cancel), `taken_over` (non-owning worker claimed the run because the owner's lease expired — marks it as `error`), `lease_valid_elsewhere` (owner's lease is still alive — caller should return 409 + `Retry-After`), `not_active_locally` (heartbeat disabled, preserving the old 409 path), `not_cancellable` (terminal state), or `unknown` (not found in memory or store). `create_or_reject(..., multitask_strategy="interrupt"|"rollback")` persists interrupted status through `RunStore.update_status()`, matching normal `set_status()` transitions.
- Store-only hydrated runs are readable history. In multi-worker mode with heartbeat enabled, cancel on a store-only run can take over (mark `error`) when the owner's lease has expired past the grace window; otherwise it fails with 409 + `Retry-After`. In single-worker mode (heartbeat off), store-only runs still return 409.
- Startup/orphan reconciliation must claim stale active rows with `RunStore.claim_for_takeover()`, not a plain `update_status()`. The final claim re-checks `status` and lease expiry atomically, so a heartbeat renewal between the candidate scan and the recovery write keeps the run active.
diff --git a/backend/app/gateway/services.py b/backend/app/gateway/services.py
index 15844c6ec..a6d7dab2b 100644
--- a/backend/app/gateway/services.py
+++ b/backend/app/gateway/services.py
@@ -41,6 +41,7 @@ from deerflow.runtime import (
CheckpointStateAccessor,
ConflictError,
DisconnectMode,
+ RunContext,
RunManager,
RunRecord,
RunStatus,
@@ -92,6 +93,8 @@ _TERMINAL_RUN_STATUSES = {
RunStatus.interrupted,
}
+_THREAD_METADATA_SETUP_TIMEOUT_SECONDS = 5.0
+
_SERVER_OWNED_MESSAGE_METADATA_KEYS = frozenset(
{
_DYNAMIC_CONTEXT_REMINDER_KEY,
@@ -126,6 +129,51 @@ def _run_is_terminal(record: RunRecord) -> bool:
return record.status in _TERMINAL_RUN_STATUSES
+def _consume_task_result(task: asyncio.Task) -> None:
+ """Retrieve a detached task's exception without propagating cancellation."""
+ if not task.cancelled():
+ task.exception()
+
+
+def _log_thread_metadata_task_result(task: asyncio.Task, *, thread_id: str) -> None:
+ """Log detached metadata setup failures while ignoring cancellation."""
+ if task.cancelled():
+ return
+ try:
+ task.result()
+ except asyncio.CancelledError:
+ return
+ except Exception:
+ logger.warning(
+ "Failed to ensure thread_meta for %s after worker detached (non-fatal)",
+ sanitize_log_param(thread_id),
+ exc_info=True,
+ )
+
+
+async def _ensure_thread_metadata(
+ run_ctx: RunContext,
+ record: RunRecord,
+ *,
+ owner_user_id: str | None,
+) -> None:
+ """Ensure an admitted run's thread exists without delaying task attachment."""
+ thread_store = run_ctx.thread_store
+ existing = await thread_store.get(record.thread_id)
+ if existing is None and owner_user_id:
+ unscoped = await thread_store.get(record.thread_id, user_id=None)
+ if unscoped is not None:
+ if unscoped.get("user_id") != owner_user_id:
+ await thread_store.update_owner(record.thread_id, owner_user_id, user_id=None)
+ existing = await thread_store.get(record.thread_id)
+ if existing is None:
+ await thread_store.create(
+ record.thread_id,
+ assistant_id=record.assistant_id,
+ metadata=record.metadata,
+ )
+
+
async def _terminal_record_stream_missing(bridge: StreamBridge, record: RunRecord) -> bool:
"""True when a terminal run has no retained stream on bridges that can tell."""
if not _run_is_terminal(record):
@@ -979,49 +1027,6 @@ async def start_run(
owner_context_token = set_current_user(SimpleNamespace(id=owner_user_id)) if owner_user_id else None
try:
- try:
- async with goal_thread_lock(thread_id):
- record = await run_mgr.create_or_reject(
- thread_id,
- body.assistant_id,
- on_disconnect=disconnect,
- metadata=body.metadata or {},
- # Persist a secret-redacted copy of the config: the run record is
- # written to runs.kwargs_json and echoed by the run API, so a
- # request-scoped secret (#3861) must not ride along. The live
- # config built below keeps the secrets for the actual run.
- kwargs={"input": body.input, "config": redact_config_secrets(body.config)},
- multitask_strategy=body.multitask_strategy,
- model_name=model_name,
- user_id=owner_user_id,
- )
- except ConflictError as exc:
- raise HTTPException(status_code=409, detail=str(exc)) from exc
- except UnsupportedStrategyError as exc:
- raise HTTPException(status_code=501, detail=str(exc)) from exc
-
- # Upsert thread metadata so the thread appears in /threads/search,
- # even for threads that were never explicitly created via POST /threads
- # (e.g. stateless runs).
- try:
- existing = await run_ctx.thread_store.get(thread_id)
- if existing is None and owner_user_id:
- unscoped_existing = await run_ctx.thread_store.get(thread_id, user_id=None)
- if unscoped_existing is not None:
- if unscoped_existing.get("user_id") != owner_user_id:
- await run_ctx.thread_store.update_owner(thread_id, owner_user_id, user_id=None)
- existing = await run_ctx.thread_store.get(thread_id)
- if existing is None:
- await run_ctx.thread_store.create(
- thread_id,
- assistant_id=body.assistant_id,
- metadata=body.metadata,
- )
- else:
- await run_ctx.thread_store.update_status(thread_id, "running")
- except Exception:
- logger.warning("Failed to upsert thread_meta for %s (non-fatal)", sanitize_log_param(thread_id))
-
agent_factory = resolve_agent_factory(body.assistant_id)
is_internal_caller = getattr(getattr(request, "state", None), "auth_source", None) == AUTH_SOURCE_INTERNAL
command = getattr(body, "command", None)
@@ -1049,8 +1054,59 @@ async def start_run(
request_context=getattr(body, "context", None),
)
- task = asyncio.create_task(
- run_agent(
+ async def run_after_metadata(record: RunRecord) -> None:
+ metadata_task = asyncio.create_task(
+ _ensure_thread_metadata(
+ run_ctx,
+ record,
+ owner_user_id=owner_user_id,
+ )
+ )
+ abort_task = asyncio.create_task(record.abort_event.wait())
+ metadata_failure_logged = False
+ try:
+ done, _ = await asyncio.wait(
+ (metadata_task, abort_task),
+ timeout=_THREAD_METADATA_SETUP_TIMEOUT_SECONDS,
+ return_when=asyncio.FIRST_COMPLETED,
+ )
+ if metadata_task in done:
+ try:
+ metadata_task.result()
+ except asyncio.CancelledError:
+ pass
+ except Exception:
+ metadata_failure_logged = True
+ logger.warning(
+ "Failed to ensure thread_meta for %s (non-fatal)",
+ sanitize_log_param(thread_id),
+ exc_info=True,
+ )
+ elif abort_task not in done:
+ logger.warning(
+ "Timed out ensuring thread_meta for %s after %.1fs",
+ sanitize_log_param(thread_id),
+ _THREAD_METADATA_SETUP_TIMEOUT_SECONDS,
+ )
+ finally:
+ if metadata_task.done():
+ if not metadata_failure_logged:
+ _log_thread_metadata_task_result(metadata_task, thread_id=thread_id)
+ else:
+ metadata_task.cancel()
+ metadata_task.add_done_callback(
+ lambda task: _log_thread_metadata_task_result(
+ task,
+ thread_id=thread_id,
+ )
+ )
+ if not abort_task.done():
+ abort_task.cancel()
+ abort_task.add_done_callback(_consume_task_result)
+ # Continue through run_agent even after metadata abort/timeout:
+ # its startup barrier is the single path that turns pending
+ # cancellation into no-agent-construction plus publish_end.
+ await run_agent(
bridge,
run_mgr,
record,
@@ -1063,8 +1119,43 @@ async def start_run(
interrupt_before=body.interrupt_before,
interrupt_after=body.interrupt_after,
)
- )
- record.task = task
+
+ try:
+ async with goal_thread_lock(thread_id):
+ record = await run_mgr.create_or_reject(
+ thread_id,
+ body.assistant_id,
+ on_disconnect=disconnect,
+ metadata=body.metadata or {},
+ # Persist a secret-redacted copy of the config: the run record is
+ # written to runs.kwargs_json and echoed by the run API, so a
+ # request-scoped secret (#3861) must not ride along. The live
+ # config built above keeps the secrets for the actual run.
+ kwargs={"input": body.input, "config": redact_config_secrets(body.config)},
+ multitask_strategy=body.multitask_strategy,
+ model_name=model_name,
+ user_id=owner_user_id,
+ )
+
+ worker = run_after_metadata(record)
+ try:
+ # No await is allowed between durable admission and task
+ # attachment. Metadata setup runs inside the attached
+ # worker so a pending cancellation can bypass stalled
+ # thread-store IO and still reach run_agent's startup
+ # barrier / stream finalization.
+ record.task = asyncio.create_task(worker)
+ except Exception as exc:
+ worker.close()
+ await run_mgr.fail_start_if_pending(
+ record.run_id,
+ error=f"Failed to attach run worker: {exc}",
+ )
+ raise
+ except ConflictError as exc:
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
+ except UnsupportedStrategyError as exc:
+ raise HTTPException(status_code=501, detail=str(exc)) from exc
# Title sync is handled by worker.py's finally block which reads the
# title from the checkpoint and calls thread_store.update_display_name
diff --git a/backend/packages/harness/deerflow/persistence/run/sql.py b/backend/packages/harness/deerflow/persistence/run/sql.py
index d364d7258..94c148dc8 100644
--- a/backend/packages/harness/deerflow/persistence/run/sql.py
+++ b/backend/packages/harness/deerflow/persistence/run/sql.py
@@ -224,6 +224,20 @@ class RunRepository(RunStore):
await session.commit()
return result.rowcount != 0
+ async def start_run(self, run_id: str) -> bool:
+ """Start only a still-pending run; cancelled rows must not be resurrected."""
+ async with self._sf() as session:
+ result = await session.execute(
+ update(RunRow)
+ .where(
+ RunRow.run_id == run_id,
+ RunRow.status == "pending",
+ )
+ .values(status="running", updated_at=datetime.now(UTC))
+ )
+ await session.commit()
+ return result.rowcount != 0
+
async def update_model_name(self, run_id, model_name):
async with self._sf() as session:
await session.execute(update(RunRow).where(RunRow.run_id == run_id).values(model_name=self._normalize_model_name(model_name), updated_at=datetime.now(UTC)))
diff --git a/backend/packages/harness/deerflow/runtime/runs/manager.py b/backend/packages/harness/deerflow/runtime/runs/manager.py
index a52739067..835ed0292 100644
--- a/backend/packages/harness/deerflow/runtime/runs/manager.py
+++ b/backend/packages/harness/deerflow/runtime/runs/manager.py
@@ -167,6 +167,8 @@ class RunRecord:
created_at: str = ""
updated_at: str = ""
task: asyncio.Task | None = field(default=None, repr=False)
+ # Serializes startup if an admitted run is ever handed to more than one worker path.
+ start_lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False)
abort_event: asyncio.Event = field(default_factory=asyncio.Event, repr=False)
abort_action: str = "interrupt"
error: str | None = None
@@ -190,6 +192,17 @@ class RunRecord:
stop_reason: str | None = None
+class RunStartOutcome(StrEnum):
+ """Result of the pending-to-running startup barrier."""
+
+ started = "started"
+ cancelled = "cancelled"
+
+
+class RunStartupError(RuntimeError):
+ """Raised when durable startup cannot be resolved safely."""
+
+
OrphanRecoveryCallback = Callable[[list[RunRecord]], Awaitable[None]]
@@ -646,6 +659,69 @@ class RunManager:
sources.add(source)
return sources
+ async def try_start(self, run_id: str) -> RunStartOutcome:
+ """Transition an uncancelled pending run to running before building the agent."""
+ async with self._lock:
+ record = self._runs.get(run_id)
+ if record is None:
+ raise RunStartupError(f"Cannot start unknown run {run_id}")
+
+ async with record.start_lock:
+ async with self._lock:
+ if record.abort_event.is_set() or record.status != RunStatus.pending:
+ return RunStartOutcome.cancelled
+
+ if self._store is not None:
+ try:
+ updated = await self._call_store_with_retry(
+ "start_run",
+ run_id,
+ lambda: self._store.start_run(run_id),
+ )
+ except Exception as exc:
+ raise RunStartupError(f"Failed to start run {run_id}: {exc}") from exc
+ if updated is False:
+ async with self._lock:
+ if record.status == RunStatus.pending:
+ record.status = RunStatus.interrupted
+ record.abort_event.set()
+ record.updated_at = _now_iso()
+ return RunStartOutcome.cancelled
+
+ async with self._lock:
+ if record.abort_event.is_set() or record.status != RunStatus.pending:
+ restore_status = record.status
+ restore_error = record.error
+ restore_stop_reason = record.stop_reason
+ else:
+ record.status = RunStatus.running
+ record.updated_at = _now_iso()
+ logger.info("Run %s -> %s", run_id, RunStatus.running.value)
+ return RunStartOutcome.started
+
+ if self._store is not None:
+ await self._persist_status(
+ record,
+ restore_status,
+ error=restore_error,
+ stop_reason=restore_stop_reason,
+ )
+ return RunStartOutcome.cancelled
+
+ async def fail_start_if_pending(self, run_id: str, *, error: str) -> bool:
+ """Mark an admitted run as failed if its worker task could not be attached."""
+ async with self._lock:
+ record = self._runs.get(run_id)
+ if record is None or record.status != RunStatus.pending:
+ return False
+ record.status = RunStatus.error
+ record.error = error
+ record.abort_event.set()
+ record.updated_at = _now_iso()
+
+ await self._persist_status(record, RunStatus.error, error=error)
+ return True
+
async def get_many_by_thread(
self,
thread_id: str,
@@ -713,6 +789,7 @@ class RunManager:
run_id: str,
*,
poll_interval: float = 0.01,
+ abort_event: asyncio.Event | None = None,
) -> None:
"""Wait until older same-thread runs have finished post-cancel cleanup."""
while True:
@@ -729,7 +806,14 @@ class RunManager:
if not found_current or not prior_finalizing:
return
- await asyncio.sleep(poll_interval)
+ if abort_event is None:
+ await asyncio.sleep(poll_interval)
+ continue
+ try:
+ await asyncio.wait_for(abort_event.wait(), timeout=poll_interval)
+ except TimeoutError:
+ continue
+ return
async def has_later_run(self, thread_id: str, run_id: str) -> bool:
"""Return whether a newer in-memory run has been admitted for the thread."""
@@ -824,7 +908,7 @@ class RunManager:
record.abort_event.set()
task_active = record.task is not None and not record.task.done()
record.finalizing = task_active
- if task_active:
+ if task_active and record.status == RunStatus.running:
record.task.cancel()
record.status = RunStatus.interrupted
record.updated_at = _now_iso()
diff --git a/backend/packages/harness/deerflow/runtime/runs/store/base.py b/backend/packages/harness/deerflow/runtime/runs/store/base.py
index 677868ffe..b6796ccec 100644
--- a/backend/packages/harness/deerflow/runtime/runs/store/base.py
+++ b/backend/packages/harness/deerflow/runtime/runs/store/base.py
@@ -95,6 +95,14 @@ class RunStore(abc.ABC):
"""
pass
+ @abc.abstractmethod
+ async def start_run(self, run_id: str) -> bool:
+ """Atomically transition a pending run to running.
+
+ Returns ``False`` when the row is missing or no longer pending.
+ """
+ pass
+
@abc.abstractmethod
async def delete(self, run_id: str) -> None:
pass
diff --git a/backend/packages/harness/deerflow/runtime/runs/store/memory.py b/backend/packages/harness/deerflow/runtime/runs/store/memory.py
index 0bacd8f7d..eac66fabd 100644
--- a/backend/packages/harness/deerflow/runtime/runs/store/memory.py
+++ b/backend/packages/harness/deerflow/runtime/runs/store/memory.py
@@ -125,6 +125,14 @@ class MemoryRunStore(RunStore):
run["updated_at"] = datetime.now(UTC).isoformat()
return True
+ async def start_run(self, run_id) -> bool:
+ run = self._runs.get(run_id)
+ if run is None or run["status"] != "pending":
+ return False
+ run["status"] = "running"
+ run["updated_at"] = datetime.now(UTC).isoformat()
+ return True
+
async def update_model_name(self, run_id, model_name):
if run_id in self._runs:
self._runs[run_id]["model_name"] = model_name
diff --git a/backend/packages/harness/deerflow/runtime/runs/worker.py b/backend/packages/harness/deerflow/runtime/runs/worker.py
index 731d68972..32da098cc 100644
--- a/backend/packages/harness/deerflow/runtime/runs/worker.py
+++ b/backend/packages/harness/deerflow/runtime/runs/worker.py
@@ -75,7 +75,7 @@ from deerflow.utils.messages import message_to_text
from deerflow.workspace_changes import capture_workspace_snapshot, record_workspace_changes
from deerflow.workspace_changes.types import WorkspaceSnapshot
-from .manager import RunManager, RunRecord
+from .manager import RunManager, RunRecord, RunStartOutcome
from .naming import resolve_root_run_name
from .schemas import RunStatus
@@ -417,12 +417,29 @@ async def run_agent(
# streaming starts and flushed in the finally block. Pre-bound to None so the
# finally is safe even if an exception fires before streaming begins.
subagent_events: _SubagentEventBuffer | None = None
+ started = False
try:
normalized_stream_modes = normalize_stream_modes(stream_modes)
requested_modes: set[str] = set(normalized_stream_modes)
lg_modes = to_langgraph_stream_modes(normalized_stream_modes)
- await run_manager.wait_for_prior_finalizing(thread_id, run_id)
+ await run_manager.wait_for_prior_finalizing(
+ thread_id,
+ run_id,
+ abort_event=record.abort_event,
+ )
+
+ start_outcome = await run_manager.try_start(run_id)
+ if start_outcome is not RunStartOutcome.started:
+ return
+ started = True
+
+ if thread_store is not None:
+ try:
+ await thread_store.update_status(thread_id, "running")
+ except Exception:
+ logger.debug("Failed to update thread_meta status for %s (non-fatal)", thread_id)
+
mode = ctx.checkpoint_channel_mode
inject_checkpoint_mode(config, mode)
checkpoint_config = {
@@ -472,9 +489,6 @@ async def run_agent(
progress_reporter=lambda snapshot: run_manager.update_run_progress(run_id, **snapshot),
)
- # 1. Mark running
- await run_manager.set_status(run_id, RunStatus.running)
-
if event_store is not None:
workspace_changes_user_id = get_effective_user_id()
try:
@@ -822,7 +836,7 @@ async def run_agent(
except Exception:
logger.warning("Failed to persist run completion for %s (non-fatal)", run_id, exc_info=True)
- if checkpointer is not None and record.status == RunStatus.interrupted:
+ if started and checkpointer is not None and record.status == RunStatus.interrupted:
try:
await run_manager.wait_for_prior_finalizing(thread_id, run_id)
if not await run_manager.has_later_started_run(thread_id, run_id):
@@ -831,7 +845,7 @@ async def run_agent(
logger.debug("Failed to generate interrupted title for thread %s (non-fatal)", thread_id)
# Sync title from checkpoint to threads_meta.display_name
- if checkpointer is not None and thread_store is not None:
+ if started and checkpointer is not None and thread_store is not None:
try:
ckpt_config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
ckpt_tuple = await checkpointer.aget_tuple(ckpt_config)
@@ -845,7 +859,7 @@ async def run_agent(
# Persist run duration to checkpoint metadata so history reads
# don't need to correlate runs and events.
- if checkpointer is not None and record.status == RunStatus.success:
+ if started and checkpointer is not None and record.status == RunStatus.success:
try:
created = datetime.fromisoformat(record.created_at.replace("Z", "+00:00"))
updated = datetime.fromisoformat(record.updated_at.replace("Z", "+00:00"))
@@ -863,7 +877,7 @@ async def run_agent(
logger.debug("Failed to persist run duration for thread %s run %s (non-fatal)", thread_id, run_id)
# Update threads_meta status based on run outcome
- if thread_store is not None:
+ if started and thread_store is not None:
try:
final_status = "idle" if record.status == RunStatus.success else record.status.value
await thread_store.update_status(thread_id, final_status)
diff --git a/backend/tests/test_gateway_services.py b/backend/tests/test_gateway_services.py
index e237ce9e0..457bb5059 100644
--- a/backend/tests/test_gateway_services.py
+++ b/backend/tests/test_gateway_services.py
@@ -2,7 +2,10 @@
from __future__ import annotations
+import asyncio
import json
+import logging
+from types import SimpleNamespace
import pytest
@@ -18,6 +21,39 @@ def _stub_app_config():
reset_app_config()
+def _make_start_run_request(run_manager, *, thread_store=None, auth_source=None):
+ from langgraph.checkpoint.memory import InMemorySaver
+ from langgraph.store.memory import InMemoryStore
+
+ from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
+
+ store = InMemoryStore()
+ return SimpleNamespace(
+ headers={},
+ state=SimpleNamespace(auth_source=auth_source),
+ app=SimpleNamespace(
+ state=SimpleNamespace(
+ stream_bridge=SimpleNamespace(),
+ run_manager=run_manager,
+ checkpointer=InMemorySaver(),
+ store=store,
+ run_event_store=SimpleNamespace(),
+ run_events_config=None,
+ thread_store=thread_store or MemoryThreadMetaStore(store),
+ )
+ ),
+ )
+
+
+def _run_create_request(content="hello", **kwargs):
+ from app.gateway.routers.thread_runs import RunCreateRequest
+
+ return RunCreateRequest(
+ input={"messages": [{"role": "user", "content": content}]},
+ **kwargs,
+ )
+
+
def test_format_sse_basic():
from app.gateway.services import format_sse
@@ -797,6 +833,130 @@ def test_apply_checkpoint_to_run_config_rejects_missing_checkpoint():
assert "missing" in exc.value.detail
+@pytest.mark.asyncio
+async def test_start_run_checkpoint_validation_failure_does_not_admit_run(_stub_app_config):
+ from unittest.mock import patch
+
+ from fastapi import HTTPException
+
+ from app.gateway.services import start_run
+ from deerflow.runtime import RunManager
+ from deerflow.runtime.runs.store.memory import MemoryRunStore
+
+ thread_id = "thread-invalid-checkpoint"
+ run_store = MemoryRunStore()
+ run_manager = RunManager(store=run_store)
+ request = _make_start_run_request(run_manager)
+ invalid_body = _run_create_request(
+ checkpoint_id="missing-checkpoint",
+ )
+
+ with (
+ patch("app.gateway.services.resolve_agent_factory", return_value=object()),
+ pytest.raises(HTTPException, match="Checkpoint missing-checkpoint not found"),
+ ):
+ await start_run(invalid_body, thread_id, request)
+
+ assert await run_manager.list_by_thread(thread_id, user_id=None) == []
+ assert await run_store.list_by_thread(thread_id, user_id=None) == []
+
+
+@pytest.mark.asyncio
+async def test_pending_cancel_bypasses_thread_metadata_and_logs_failure(_stub_app_config, caplog):
+ from unittest.mock import AsyncMock, patch
+
+ from app.gateway.services import start_run
+ from deerflow.runtime import RunManager
+ from deerflow.runtime.runs.store.memory import MemoryRunStore
+
+ metadata_started = asyncio.Event()
+
+ async def get_thread(_thread_id):
+ metadata_started.set()
+ try:
+ await asyncio.Event().wait()
+ except asyncio.CancelledError as exc:
+ raise RuntimeError("thread metadata store failed after cancellation") from exc
+
+ async def fake_run_agent(*_args, **_kwargs):
+ return None
+
+ run_manager = RunManager(store=MemoryRunStore())
+ body = _run_create_request()
+ request = _make_start_run_request(
+ run_manager,
+ thread_store=SimpleNamespace(
+ get=AsyncMock(side_effect=get_thread),
+ create=AsyncMock(),
+ update_owner=AsyncMock(),
+ ),
+ )
+ caplog.set_level(logging.WARNING, logger="app.gateway.services")
+ with (
+ patch("app.gateway.services.resolve_agent_factory", return_value=object()),
+ patch("app.gateway.services.run_agent", side_effect=fake_run_agent),
+ ):
+ record = await start_run(body, "thread-cancel-log-meta", request)
+ await asyncio.wait_for(metadata_started.wait(), timeout=1)
+ assert record.task is not None
+ await run_manager.cancel(record.run_id)
+ await asyncio.wait_for(record.task, timeout=1)
+ await asyncio.sleep(0)
+
+ assert "thread metadata store failed after cancellation" in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_thread_metadata_timeout_logs_and_run_still_starts(_stub_app_config, caplog, monkeypatch):
+ from unittest.mock import AsyncMock, patch
+
+ import app.gateway.services as services
+ from app.gateway.services import start_run
+ from deerflow.runtime import RunManager
+ from deerflow.runtime.runs.manager import RunStartOutcome
+ from deerflow.runtime.runs.schemas import RunStatus
+ from deerflow.runtime.runs.store.memory import MemoryRunStore
+
+ metadata_started = asyncio.Event()
+ run_agent_called = asyncio.Event()
+
+ async def get_thread(_thread_id):
+ metadata_started.set()
+ await asyncio.Event().wait()
+
+ async def fake_run_agent(_bridge, run_manager, record, **_kwargs):
+ run_agent_called.set()
+ start_outcome = await run_manager.try_start(record.run_id)
+ assert start_outcome is RunStartOutcome.started
+
+ monkeypatch.setattr(services, "_THREAD_METADATA_SETUP_TIMEOUT_SECONDS", 0.01)
+ run_manager = RunManager(store=MemoryRunStore())
+ body = _run_create_request()
+ request = _make_start_run_request(
+ run_manager,
+ thread_store=SimpleNamespace(
+ get=AsyncMock(side_effect=get_thread),
+ create=AsyncMock(),
+ update_owner=AsyncMock(),
+ ),
+ )
+ caplog.set_level(logging.WARNING, logger="app.gateway.services")
+
+ with (
+ patch("app.gateway.services.resolve_agent_factory", return_value=object()),
+ patch("app.gateway.services.run_agent", side_effect=fake_run_agent),
+ ):
+ record = await start_run(body, "thread-timeout-meta", request)
+ await asyncio.wait_for(metadata_started.wait(), timeout=1)
+ assert record.task is not None
+ await asyncio.wait_for(record.task, timeout=1)
+
+ assert run_agent_called.is_set()
+ assert record.status == RunStatus.running
+ assert (await run_manager.get(record.run_id)).status == RunStatus.running
+ assert "Timed out ensuring thread_meta for thread-timeout-meta" in caplog.text
+
+
def test_context_merges_into_configurable():
"""Context values must be merged into config['configurable'] by start_run.
@@ -1844,7 +2004,7 @@ async def test_run_agent_full_mode_rejects_delta_before_graph_invocation():
CHECKPOINT_MODE_METADATA_KEY,
INTERNAL_CHECKPOINT_MODE_KEY,
)
- from deerflow.runtime.runs.manager import RunRecord
+ from deerflow.runtime.runs.manager import RunRecord, RunStartOutcome
from deerflow.runtime.runs.schemas import DisconnectMode, RunStatus
from deerflow.runtime.runs.worker import RunContext, run_agent
@@ -1859,6 +2019,7 @@ async def test_run_agent_full_mode_rejects_delta_before_graph_invocation():
cleanup=AsyncMock(),
)
run_manager = SimpleNamespace(
+ try_start=AsyncMock(return_value=RunStartOutcome.started),
wait_for_prior_finalizing=AsyncMock(),
set_status=AsyncMock(),
)
@@ -1909,7 +2070,7 @@ async def test_run_agent_full_mode_checks_selected_checkpoint_before_graph():
from unittest.mock import AsyncMock, MagicMock, call
from deerflow.runtime.checkpoint_mode import CHECKPOINT_MODE_METADATA_KEY
- from deerflow.runtime.runs.manager import RunRecord
+ from deerflow.runtime.runs.manager import RunRecord, RunStartOutcome
from deerflow.runtime.runs.schemas import DisconnectMode, RunStatus
from deerflow.runtime.runs.worker import RunContext, run_agent
@@ -1930,6 +2091,7 @@ async def test_run_agent_full_mode_checks_selected_checkpoint_before_graph():
cleanup=AsyncMock(),
)
run_manager = SimpleNamespace(
+ try_start=AsyncMock(return_value=RunStartOutcome.started),
wait_for_prior_finalizing=AsyncMock(),
set_status=AsyncMock(),
)
diff --git a/backend/tests/test_goal_worker.py b/backend/tests/test_goal_worker.py
index 68f73a7c4..d42312ebb 100644
--- a/backend/tests/test_goal_worker.py
+++ b/backend/tests/test_goal_worker.py
@@ -9,7 +9,7 @@ from langgraph.checkpoint.memory import InMemorySaver
from deerflow.runtime.checkpoint_state import CheckpointStateAccessor, build_state_mutation_graph
from deerflow.runtime.goal import GoalEvaluation, attach_goal_evaluation, build_goal_state, latest_visible_assistant_signature, read_thread_goal, write_thread_goal
from deerflow.runtime.runs import worker
-from deerflow.runtime.runs.manager import RunRecord
+from deerflow.runtime.runs.manager import RunRecord, RunStartOutcome
from deerflow.runtime.runs.schemas import DisconnectMode, RunStatus
@@ -606,6 +606,10 @@ async def test_run_agent_does_not_stream_continuation_after_abort(monkeypatch):
return _gen()
class FakeRunManager:
+ async def try_start(self, _run_id):
+ record.status = RunStatus.running
+ return RunStartOutcome.started
+
async def set_status(self, _run_id, status, **_kwargs):
record.status = status
@@ -683,6 +687,10 @@ async def test_run_agent_reuses_goal_evaluator_model_for_goal_loop(monkeypatch):
return _gen()
class FakeRunManager:
+ async def try_start(self, _run_id):
+ record.status = RunStatus.running
+ return RunStartOutcome.started
+
async def set_status(self, _run_id, status, **_kwargs):
record.status = status
@@ -859,6 +867,10 @@ async def test_run_agent_strips_branch_checkpoint_for_goal_continuation(monkeypa
return _gen()
class FakeRunManager:
+ async def try_start(self, _run_id):
+ record.status = RunStatus.running
+ return RunStartOutcome.started
+
async def set_status(self, _run_id, status, **_kwargs):
record.status = status
diff --git a/backend/tests/test_run_duration_checkpoint.py b/backend/tests/test_run_duration_checkpoint.py
index 1a6948281..7119de203 100644
--- a/backend/tests/test_run_duration_checkpoint.py
+++ b/backend/tests/test_run_duration_checkpoint.py
@@ -10,7 +10,8 @@ from langgraph.checkpoint.memory import InMemorySaver
import deerflow.runtime.runs.worker as worker
from deerflow.runtime.goal import goal_thread_lock
-from deerflow.runtime.runs.manager import RunManager
+from deerflow.runtime.runs.manager import RunManager, RunStartOutcome
+from deerflow.runtime.runs.schemas import RunStatus
from deerflow.runtime.runs.worker import RunContext, _persist_run_duration, run_agent
@@ -367,6 +368,11 @@ async def test_successful_subsecond_run_persists_zero_duration(monkeypatch: pyte
async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
yield {"messages": []}
+ async def try_start(run_id):
+ record.status = RunStatus.running
+ return RunStartOutcome.started
+
+ monkeypatch.setattr(run_manager, "try_start", try_start)
monkeypatch.setattr(run_manager, "set_status", set_status)
monkeypatch.setattr(worker, "_persist_run_duration", persist_duration)
diff --git a/backend/tests/test_run_manager.py b/backend/tests/test_run_manager.py
index 13ad3d44c..68e66fddb 100644
--- a/backend/tests/test_run_manager.py
+++ b/backend/tests/test_run_manager.py
@@ -11,7 +11,7 @@ from sqlalchemy.exc import DatabaseError as SQLAlchemyDatabaseError
from deerflow.config.run_ownership_config import RunOwnershipConfig
from deerflow.runtime import DisconnectMode, RunManager, RunStatus, ThreadOperationKind
-from deerflow.runtime.runs.manager import CancelOutcome, ConflictError, PersistenceRetryPolicy
+from deerflow.runtime.runs.manager import CancelOutcome, ConflictError, PersistenceRetryPolicy, RunStartOutcome
from deerflow.runtime.runs.store.memory import MemoryRunStore
ISO_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}")
@@ -384,6 +384,64 @@ async def test_status_persistence_does_not_retry_permanent_sqlalchemy_errors():
assert store.status_update_attempts == 1
+@pytest.mark.anyio
+async def test_try_start_respects_durable_and_racing_cancels():
+ """Startup must not resurrect durable or locally racing cancels."""
+ store = MemoryRunStore()
+ manager = RunManager(store=store)
+ record = await manager.create_or_reject("thread-1")
+ await store.update_status(record.run_id, RunStatus.interrupted.value)
+
+ assert await manager.try_start(record.run_id) == RunStartOutcome.cancelled
+ assert record.status == RunStatus.interrupted
+ assert (await store.get(record.run_id))["status"] == RunStatus.interrupted.value
+
+ record = await manager.create_or_reject("thread-2")
+ original_start_run = store.start_run
+
+ async def start_then_cancel(run_id):
+ updated = await original_start_run(run_id)
+ await manager.cancel(record.run_id)
+ return updated
+
+ store.start_run = start_then_cancel
+
+ assert await manager.try_start(record.run_id) == RunStartOutcome.cancelled
+ assert record.status == RunStatus.interrupted
+ assert (await store.get(record.run_id))["status"] == RunStatus.interrupted.value
+
+
+@pytest.mark.anyio
+async def test_fail_start_if_pending_marks_pending_run_error_and_persists():
+ """Worker attach failures should finalize only runs still pending startup."""
+ store = MemoryRunStore()
+ manager = RunManager(store=store)
+ record = await manager.create_or_reject("thread-1")
+ error = "Failed to attach run worker: boom"
+
+ assert await manager.fail_start_if_pending(record.run_id, error=error) is True
+
+ stored = await store.get(record.run_id)
+ assert record.status == RunStatus.error
+ assert record.error == error
+ assert record.abort_event.is_set()
+ assert stored is not None
+ assert stored["status"] == RunStatus.error.value
+ assert stored["error"] == error
+
+ running = await manager.create_or_reject("thread-2")
+ assert await manager.try_start(running.run_id) == RunStartOutcome.started
+
+ assert await manager.fail_start_if_pending(running.run_id, error="late") is False
+
+ stored_running = await store.get(running.run_id)
+ assert running.status == RunStatus.running
+ assert running.error is None
+ assert stored_running is not None
+ assert stored_running["status"] == RunStatus.running.value
+ assert stored_running["error"] is None
+
+
@pytest.mark.anyio
async def test_completion_persistence_recreates_missing_store_row():
"""Completion updates should recreate a missing row and persist final counters."""
diff --git a/backend/tests/test_run_repository.py b/backend/tests/test_run_repository.py
index 95952d463..d1d9e0a83 100644
--- a/backend/tests/test_run_repository.py
+++ b/backend/tests/test_run_repository.py
@@ -44,6 +44,9 @@ class _CustomRunStoreWithoutProgress(RunStore):
async def update_status(self, *args, **kwargs):
return None
+ async def start_run(self, *args, **kwargs):
+ return False
+
async def delete(self, *args, **kwargs):
return None
@@ -153,6 +156,22 @@ class TestRunRepository:
assert updated is False
await _cleanup()
+ @pytest.mark.anyio
+ async def test_start_run_only_updates_pending_rows(self, tmp_path):
+ repo = await _make_repo(tmp_path)
+ await repo.put("pending-run", thread_id="t1", status="pending")
+ await repo.put("cancelled-run", thread_id="t2", status="pending")
+ await repo.update_status("cancelled-run", "interrupted")
+
+ assert await repo.start_run("pending-run") is True
+ assert await repo.start_run("cancelled-run") is False
+
+ pending_row = await repo.get("pending-run")
+ cancelled_row = await repo.get("cancelled-run")
+ assert pending_row["status"] == "running"
+ assert cancelled_row["status"] == "interrupted"
+ await _cleanup()
+
@pytest.mark.anyio
async def test_update_status_with_error(self, tmp_path):
repo = await _make_repo(tmp_path)
diff --git a/backend/tests/test_run_worker_rollback.py b/backend/tests/test_run_worker_rollback.py
index 9082f2c20..dfd1aa493 100644
--- a/backend/tests/test_run_worker_rollback.py
+++ b/backend/tests/test_run_worker_rollback.py
@@ -18,7 +18,7 @@ from langgraph.types import Overwrite
from deerflow.agents.thread_state import merge_artifacts, merge_message_writes
from deerflow.runtime.checkpoint_state import CheckpointStateAccessor
from deerflow.runtime.context_keys import CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY
-from deerflow.runtime.runs.manager import ConflictError, RunManager
+from deerflow.runtime.runs.manager import CancelOutcome, ConflictError, RunManager
from deerflow.runtime.runs.schemas import RunStatus
from deerflow.runtime.runs.worker import (
RollbackPoint,
@@ -45,6 +45,48 @@ class FakeCheckpointer:
self.aput_writes = AsyncMock()
+@pytest.mark.anyio
+async def test_pending_cancel_stops_waiting_for_prior_finalization():
+ run_manager = RunManager()
+ prior = await run_manager.create("thread-cancel-while-waiting")
+ prior.status = RunStatus.interrupted
+ prior.finalizing = True
+ record = await run_manager.create("thread-cancel-while-waiting")
+ bridge = SimpleNamespace(
+ publish=AsyncMock(),
+ publish_end=AsyncMock(),
+ cleanup=AsyncMock(),
+ )
+ factory_called = False
+
+ def agent_factory(**_kwargs):
+ nonlocal factory_called
+ factory_called = True
+ raise AssertionError("cancelled pending run must not build an agent")
+
+ record.task = asyncio.create_task(
+ run_agent(
+ bridge,
+ run_manager,
+ record,
+ ctx=RunContext(checkpointer=None),
+ agent_factory=agent_factory,
+ graph_input={"messages": []},
+ config={},
+ )
+ )
+ await asyncio.sleep(0)
+
+ outcome = await run_manager.cancel(record.run_id)
+ await asyncio.wait_for(record.task, timeout=0.2)
+
+ assert outcome == CancelOutcome.cancelled
+ assert prior.finalizing is True
+ assert factory_called is False
+ assert record.status == RunStatus.interrupted
+ bridge.publish_end.assert_awaited_once_with(record.run_id)
+
+
def _make_rollback_point(*, checkpoint_id="ckpt-1", messages=("before",), pending_writes=()):
return RollbackPoint(
config={
@@ -639,8 +681,6 @@ async def test_run_agent_marks_rollback_unusable_when_capture_fails():
"""
run_manager = RunManager()
record = await run_manager.create("thread-1")
- record.abort_action = "rollback"
- record.abort_event.set()
bridge = SimpleNamespace(
publish=AsyncMock(),
publish_end=AsyncMock(),
@@ -664,6 +704,10 @@ async def test_run_agent_marks_rollback_unusable_when_capture_fails():
class DummyAgent:
async def aget_state(self, _config):
+ # Cancel after the worker crosses its startup barrier so this test
+ # exercises the running rollback path, not pending cancellation.
+ record.abort_action = "rollback"
+ record.abort_event.set()
raise RuntimeError("materialization failed")
async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
@@ -2729,14 +2773,16 @@ async def test_worker_finally_block_swallows_helper_exceptions(monkeypatch):
"""
import deerflow.runtime.runs.worker as worker_module
+ helper_called = asyncio.Event()
+
async def _boom(*_args, **_kwargs):
+ helper_called.set()
raise RuntimeError("forced helper failure")
monkeypatch.setattr(worker_module, "_ensure_interrupted_title", _boom)
run_manager = RunManager()
record = await run_manager.create("thread-1")
- record.status = RunStatus.interrupted
bridge = SimpleNamespace(
publish=AsyncMock(),
@@ -2792,5 +2838,6 @@ async def test_worker_finally_block_swallows_helper_exceptions(monkeypatch):
# The helper raised, but the run still reaches the threads_meta status sync
# and ``publish_end`` — i.e. the SSE stream is closed cleanly and the row
# reflects the run outcome.
+ assert helper_called.is_set()
assert captured_status.get("status") == ("thread-1", "interrupted")
bridge.publish_end.assert_awaited_once_with(record.run_id)
diff --git a/backend/tests/test_worker_langfuse_metadata.py b/backend/tests/test_worker_langfuse_metadata.py
index ee820cb1d..346e11e45 100644
--- a/backend/tests/test_worker_langfuse_metadata.py
+++ b/backend/tests/test_worker_langfuse_metadata.py
@@ -11,7 +11,7 @@ import asyncio
import pytest
-from deerflow.runtime.runs.manager import RunRecord
+from deerflow.runtime.runs.manager import RunRecord, RunStartOutcome
from deerflow.runtime.runs.schemas import DisconnectMode, RunStatus
from deerflow.runtime.runs.worker import RunContext, run_agent
from deerflow.trace_context import (
@@ -42,6 +42,9 @@ class _FakeAgent:
class _FakeRunManager:
+ async def try_start(self, _run_id: str) -> RunStartOutcome:
+ return RunStartOutcome.started
+
async def wait_for_prior_finalizing(self, *_args, **_kwargs) -> None:
return None
diff --git a/backend/tests/test_worker_stream_subgraph_namespace.py b/backend/tests/test_worker_stream_subgraph_namespace.py
index ce8f6b763..0c1c82046 100644
--- a/backend/tests/test_worker_stream_subgraph_namespace.py
+++ b/backend/tests/test_worker_stream_subgraph_namespace.py
@@ -23,7 +23,7 @@ from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from packaging.version import Version
from deerflow.runtime.runs import worker
-from deerflow.runtime.runs.manager import RunRecord
+from deerflow.runtime.runs.manager import RunRecord, RunStartOutcome
from deerflow.runtime.runs.schemas import DisconnectMode, RunStatus
from deerflow.runtime.runs.worker import (
_compose_sse_event,
@@ -316,6 +316,10 @@ class _IntegrationRunManager:
def __init__(self, record: RunRecord) -> None:
self._record = record
+ async def try_start(self, _run_id):
+ self._record.status = RunStatus.running
+ return RunStartOutcome.started
+
async def wait_for_prior_finalizing(self, *_args, **_kwargs):
return None
From b41a8d44dfd7f47067d1b9ba86e4077d10642c07 Mon Sep 17 00:00:00 2001
From: Willem Jiang
Date: Sun, 26 Jul 2026 07:06:26 +0800
Subject: [PATCH 07/35] fix(lint):fix the lint error on the main branch (#4461)
---
backend/tests/test_run_manager.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/backend/tests/test_run_manager.py b/backend/tests/test_run_manager.py
index 68e66fddb..13b92fcb0 100644
--- a/backend/tests/test_run_manager.py
+++ b/backend/tests/test_run_manager.py
@@ -11,7 +11,7 @@ from sqlalchemy.exc import DatabaseError as SQLAlchemyDatabaseError
from deerflow.config.run_ownership_config import RunOwnershipConfig
from deerflow.runtime import DisconnectMode, RunManager, RunStatus, ThreadOperationKind
-from deerflow.runtime.runs.manager import CancelOutcome, ConflictError, PersistenceRetryPolicy, RunStartOutcome
+from deerflow.runtime.runs.manager import CancelOutcome, ConflictError, PersistenceRetryPolicy, RunStartOutcome
from deerflow.runtime.runs.store.memory import MemoryRunStore
ISO_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}")
From 37c343fe30cf67867f179d042ea92d466fd2612d Mon Sep 17 00:00:00 2001
From: Aari
Date: Sun, 26 Jul 2026 07:39:39 +0800
Subject: [PATCH 08/35] fix(summarization): summarize with the run model, fall
back on summary-provider failure (#4361)
* fix(summarization): own the run model for compaction; bound failure
With summarization.model_name: null the summary model resolved to
config.models[0] while the executing model is selected per run; when they
differ and models[0]'s provider is broken (expired key, quota, outage)
compaction silently failed every triggered turn and context grew unbounded
until the main provider 400s the run (#3103's shape), even though the run's
own model was healthy.
Model ownership is now sourced from the builders, not re-derived at runtime:
- The lead, subagent, and manual /compact builders each pass the resolved run
model into create_summarization_middleware(run_model_name=...). The middleware
no longer reads runtime.context / get_config(), which do not carry a custom
agent's or a subagent's resolved model, so a custom-agent lead run and a
distinct-model subagent now summarize with their own model, not models[0] /
the parent's. Runtime re-resolution and the per-name model cache are removed.
- model_name: null summarizes with the run's own model; an explicitly configured
summary model generates and falls back to the run model on failure. The
fallback is built lazily after the primary fails and its construction is
guarded, so a broken fallback cannot skip a healthy primary or escape the
automatic failure boundary.
Failure is bounded and side-effect-safe:
- An empty or whitespace-only response is treated as a generation failure, not a
valid summary, so compaction never removes all history for an empty replacement.
- compact_state/acompact_state take raise_on_failure independent of force: the
manual /compact path always surfaces a generation failure (even force=false)
and routes it to the existing ContextCompactionFailed path (HTTP 500 ->
frontend error toast) instead of an unconsumed response reason. The automatic
path leaves compaction state unchanged.
- before_summarization hooks fire only after a replacement summary exists.
SummarizationConfig.model_name, config.example.yaml, and docs/summarization.md
document the final lead/subagent/manual ownership rules.
Part of RFC #4346 (section A). Evaluating fraction/triggers against the run
model's profile (profile ownership) is a separate follow-up.
* fix(summarization): manual /compact model ownership + fail-open construct/parse
Manual /compact carried only agent_name, so it derived the run model from the
custom-agent model or config.models[0] and missed the request-selected model the
run path uses (request -> custom-agent -> default). Carry model_name through
ThreadCompactRequest and the frontend compact call, resolve with the same
precedence, and move the custom-agent config read off the event loop (asyncio
.to_thread) with user_id so the strict blocking-IO gate is not bypassed by the
broad except.
Make one summary attempt own its full lifecycle so the fail-open boundary covers
construction and response parsing, not just invocation: build each candidate model
lazily and guarded (a raising constructor falls through to the healthy run model
instead of breaking agent construction), build the model_name:null primary from the
run model rather than config.models[0], and run response text extraction inside the
invocation try so a failing .text accessor falls back instead of escaping compaction.
Adds factory-level constructor-failure, response-extraction-failure (sync/async), and
route-path model-ownership tests.
---
backend/app/gateway/routers/threads.py | 2 +
backend/docs/summarization.md | 15 +-
.../deerflow/agents/lead_agent/agent.py | 13 +-
.../middlewares/summarization_middleware.py | 342 +++++++++++--
.../tool_error_handling_middleware.py | 5 +
.../deerflow/config/summarization_config.py | 5 +-
.../deerflow/runtime/context_compaction.py | 72 ++-
backend/tests/test_context_compaction.py | 185 ++++++-
.../tests/test_durable_context_middleware.py | 2 +-
.../tests/test_lead_agent_model_resolution.py | 88 +++-
.../tests/test_summarization_middleware.py | 483 +++++++++++++++++-
.../test_tool_error_handling_middleware.py | 7 +-
config.example.yaml | 6 +-
.../src/components/workspace/input-box.tsx | 3 +
frontend/src/core/threads/api.ts | 2 +
15 files changed, 1144 insertions(+), 86 deletions(-)
diff --git a/backend/app/gateway/routers/threads.py b/backend/app/gateway/routers/threads.py
index 122f9b65d..b5ba42527 100644
--- a/backend/app/gateway/routers/threads.py
+++ b/backend/app/gateway/routers/threads.py
@@ -442,6 +442,7 @@ class ThreadCompactRequest(BaseModel):
force: bool = Field(default=True, description="Run compaction even if automatic summarization thresholds are not met")
keep: ContextSize | None = Field(default=None, description="Optional retention policy for this compaction only")
agent_name: str | None = Field(default=None, max_length=128, description="Optional custom agent name for memory attribution")
+ model_name: str | None = Field(default=None, max_length=128, description="Optional model to summarize with; resolved request override -> custom-agent model -> default, mirroring run model selection")
class ThreadCompactResponse(BaseModel):
@@ -1128,6 +1129,7 @@ async def compact_thread(thread_id: str, body: ThreadCompactRequest, request: Re
force=body.force,
user_id=get_effective_user_id(),
agent_name=body.agent_name,
+ model_name=body.model_name,
)
except ConflictError:
raise HTTPException(status_code=409, detail="Thread has a run in flight. Compact after the run finishes.") from None
diff --git a/backend/docs/summarization.md b/backend/docs/summarization.md
index 582c7ef65..e478f07a9 100644
--- a/backend/docs/summarization.md
+++ b/backend/docs/summarization.md
@@ -21,7 +21,7 @@ Summarization is configured in `config.yaml` under the `summarization` key:
```yaml
summarization:
enabled: true
- model_name: null # Use default model or specify a lightweight model
+ model_name: null # null = summarize with the run's own model (see below); or name a lightweight model
# Trigger conditions (OR logic - any condition triggers summarization)
trigger:
@@ -61,8 +61,11 @@ summarization:
#### `model_name`
- **Type**: String or null
-- **Default**: `null` (uses default model)
-- **Description**: Model to use for generating summaries. Recommended to use a lightweight, cost-effective model like `gpt-4o-mini` or equivalent.
+- **Default**: `null`
+- **Description**: Model to use for generating summaries.
+ - **`null` (model ownership)**: summarize with the model the run actually executes with — the lead run's resolved model, a subagent's own model, or a thread's custom-agent model — **not** `config.models[0]`. This keeps compaction working on a run whose model is healthy even when `models[0]`'s provider is broken (expired key, quota, outage).
+ - **Set to a model name**: that model generates summaries. If its provider fails, compaction **falls back to the run's own model** so a broken summary provider cannot disable compaction while a working model is available. Recommended to use a lightweight, cost-effective model like `gpt-4o-mini` or equivalent.
+ - Ownership applies to all three paths — automatic lead compaction, subagent compaction, and manual `/compact`. Manual `/compact` resolves the run model with the same precedence as a normal run: the model selected for the request (`POST /api/threads/{id}/compact` body `model_name`, sent by the frontend from the composer's current model) → the thread's custom-agent model → the default. A whitespace-only summary response is treated as a generation failure (it is never committed as a valid empty summary).
#### `trigger`
- **Type**: Single `ContextSize` or list of `ContextSize` objects
@@ -223,9 +226,9 @@ The middleware intelligently preserves message context:
- Summaries don't require the most powerful models
- Significant cost savings on high-volume applications
-- **Default**: If `model_name` is `null`, uses the default model
- - May be more expensive but ensures consistency
- - Good for simple setups
+- **Default**: If `model_name` is `null`, summarizes with the run's own model (not `models[0]`)
+ - Keeps compaction working when `models[0]`'s provider is broken but the run's model is healthy
+ - Good for simple setups; no separate summary provider to keep credentialed
### Optimization Tips
diff --git a/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/backend/packages/harness/deerflow/agents/lead_agent/agent.py
index 5242696d7..189c87cee 100644
--- a/backend/packages/harness/deerflow/agents/lead_agent/agent.py
+++ b/backend/packages/harness/deerflow/agents/lead_agent/agent.py
@@ -133,9 +133,14 @@ def _resolve_model_name(requested_model_name: str | None = None, *, app_config:
return default_model_name
-def _create_summarization_middleware(*, app_config: AppConfig | None = None) -> DeerFlowSummarizationMiddleware | None:
- """Create and configure the summarization middleware from config."""
- return create_summarization_middleware(app_config=app_config)
+def _create_summarization_middleware(*, app_config: AppConfig | None = None, run_model_name: str | None = None) -> DeerFlowSummarizationMiddleware | None:
+ """Create and configure the summarization middleware from config.
+
+ ``run_model_name`` is the resolved run model; it is the source of truth for
+ ``model_name: null`` summarization and the explicit-summary-model fallback, so a
+ custom agent's model is used instead of ``config.models[0]``.
+ """
+ return create_summarization_middleware(app_config=app_config, run_model_name=run_model_name)
def _create_todo_list_middleware(is_plan_mode: bool) -> TodoMiddleware | None:
@@ -359,7 +364,7 @@ def build_middlewares(
)
# Add summarization middleware if enabled
- summarization_middleware = _create_summarization_middleware(app_config=resolved_app_config)
+ summarization_middleware = _create_summarization_middleware(app_config=resolved_app_config, run_model_name=model_name)
if summarization_middleware is not None:
middlewares.append(summarization_middleware)
diff --git a/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py
index 7321e6096..44e6cf4ac 100644
--- a/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py
+++ b/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py
@@ -21,6 +21,25 @@ from deerflow.models import create_chat_model
logger = logging.getLogger(__name__)
_SUMMARY_TRIGGER_MESSAGE_NAME = "summary"
+_UNSET = object()
+# Valid non-generated summaries for the empty / too-long-to-summarize edges; these
+# short-circuit model invocation (and must not be treated as generation failures).
+_CANNED_SUMMARIES = frozenset(
+ {
+ "No previous conversation history.",
+ "Previous conversation was too long to summarize.",
+ }
+)
+
+
+class SummaryGenerationError(RuntimeError):
+ """Summary generation failed after exhausting the run-model fallback.
+
+ Raised only when a caller opts in via ``raise_on_failure`` (the manual
+ ``/compact`` path) so a real failure is reported distinctly from "nothing to
+ compact". The automatic path leaves ``raise_on_failure`` False and swallows the
+ failure, leaving compaction state unchanged for the turn.
+ """
@dataclass(frozen=True)
@@ -82,22 +101,115 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
self,
*args,
before_summarization: list[BeforeSummarizationHook] | None = None,
+ app_config: Any | None = None,
+ configured_model_name: str | None = None,
+ run_model_name: str | None = None,
+ anchor_model_name: str | None = _UNSET, # type: ignore[assignment]
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
self._before_summarization_hooks = before_summarization or []
+ # Model-ownership state. The model that actually executes the run is selected
+ # per run and is the authoritative source of truth, so the caller (lead /
+ # subagent / manual builders) supplies it directly as ``run_model_name``
+ # instead of the middleware re-deriving it from ``runtime.context`` /
+ # ``get_config()`` — those fields do not carry a custom agent's or a subagent's
+ # resolved model.
+ #
+ # ``configured_model_name`` is the explicitly configured summary model
+ # (``None`` => summarize with the run's own model). ``run_model_name`` is the
+ # model the run executes with; when they differ and the summary provider is
+ # broken (expired key, quota, outage) the run's own working model can still
+ # compact.
+ self._app_config = app_config
+ self._configured_summary_model_name = configured_model_name
+ self._run_model_name = run_model_name
# The summary LLM call runs inside a LangGraph middleware hook, so its token
# stream would otherwise be captured by the messages-tuple stream callback and
# broadcast to the frontend as a phantom AI message. Tag a dedicated model copy
# with TAG_NOSTREAM so the streaming handler skips it.
# Keep self.model untagged so the parent's profile / ls_params inspection still works.
- #
- # Preserve any tags already bound on the model (e.g. "middleware:summarize" set in
- # lead_agent/agent.py for RunJournal attribution): RunnableBinding.with_config does a
- # shallow merge that would otherwise overwrite the existing tags list entirely.
- existing_tags = list((getattr(self.model, "config", None) or {}).get("tags") or [])
+ self._summary_model = self._tag_nostream(self.model)
+ # ``self.model`` is the pre-built *anchor* model: it drives the parent's token
+ # counter / profile inspection and is reused verbatim by generation when a
+ # candidate matches its name. The factory builds it guarded and passes its name
+ # explicitly; direct construction (tests) mirrors the old factory choice
+ # (configured model, else default) so the passed ``model`` is the primary.
+ if anchor_model_name is _UNSET:
+ self._anchor_model_name = configured_model_name or self._default_model_name()
+ else:
+ self._anchor_model_name = anchor_model_name
+ # Nostream generation models built lazily by name and cached (None = a build
+ # that failed, so a broken candidate config is not retried every turn and does
+ # not escape the fail-open boundary).
+ self._model_cache: dict[str | None, Any] = {}
+
+ def _tag_nostream(self, model: Any) -> Any:
+ """Return a copy of ``model`` carrying TAG_NOSTREAM without clobbering tags.
+
+ lead_agent/agent.py binds "middleware:summarize" for RunJournal attribution;
+ RunnableBinding.with_config shallow-merges config, so existing tags must be
+ preserved explicitly instead of being overwritten with just [TAG_NOSTREAM].
+ """
+ existing_tags = list((getattr(model, "config", None) or {}).get("tags") or [])
merged_tags = [*existing_tags, TAG_NOSTREAM] if TAG_NOSTREAM not in existing_tags else existing_tags
- self._summary_model = self.model.with_config(tags=merged_tags)
+ return model.with_config(tags=merged_tags)
+
+ def _default_model_name(self) -> str | None:
+ if self._app_config is None:
+ return None
+ models = getattr(self._app_config, "models", None)
+ return models[0].name if models else None
+
+ def _generation_candidate_names(self) -> list[str | None]:
+ """Ordered summary-generation candidates by name (deduplicated).
+
+ Explicit summary model: the configured model first, then the run's own model
+ as a distinct fallback. ``model_name: null``: the run's own model only — its
+ construction is the primary, so there is no eager dependency on
+ ``config.models[0]`` (a bare default is used only when no run model was
+ resolved). A ``None`` entry means "let ``create_chat_model`` pick the default",
+ which only occurs when nothing resolves a name.
+ """
+ default = self._default_model_name()
+ if self._configured_summary_model_name is not None:
+ names = [self._configured_summary_model_name, self._run_model_name or default]
+ else:
+ names = [self._run_model_name or default]
+ deduped: list[str | None] = []
+ seen: set[str | None] = set()
+ for name in names:
+ if name in seen:
+ continue
+ seen.add(name)
+ deduped.append(name)
+ return deduped
+
+ def _model_for(self, name: str | None) -> Any | None:
+ """The nostream summary model for ``name``, built lazily and guarded.
+
+ Returns the pre-built anchor when ``name`` matches it (no rebuild), otherwise
+ constructs and caches. A construction failure is caught and cached as ``None``
+ so a broken candidate config never escapes the fail-open boundary, is never
+ retried this turn, and still lets the next candidate run.
+ """
+ if name == self._anchor_model_name:
+ return self._summary_model
+ if name in self._model_cache:
+ return self._model_cache[name]
+ try:
+ model = create_chat_model(
+ name=name,
+ thinking_enabled=False,
+ app_config=self._app_config,
+ attach_tracing=False,
+ )
+ built = self._tag_nostream(model.with_config(tags=["middleware:summarize"]))
+ except Exception:
+ logger.exception("Failed to build summary model %r; trying the next candidate", name)
+ built = None
+ self._model_cache[name] = built
+ return built
@override
def _create_summary(self, messages_to_summarize: list[AnyMessage]) -> str | None:
@@ -107,6 +219,31 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
async def _acreate_summary(self, messages_to_summarize: list[AnyMessage]) -> str | None:
return await self._asummarize_with(messages_to_summarize)
+ def _prepare_summary_prompt(self, messages_to_summarize: list[AnyMessage], previous_summary: str | None) -> str | None:
+ """Return the formatted prompt, or a canned string for the empty/too-long edges.
+
+ A non-``None`` return that is not a real prompt (the two canned strings) is a
+ valid summary and short-circuits generation; ``None`` means "build a prompt".
+ """
+ if not messages_to_summarize:
+ return "No previous conversation history."
+ prompt = self._build_summary_prompt(messages_to_summarize, previous_summary=previous_summary)
+ if prompt is None:
+ return "Previous conversation was too long to summarize."
+ return prompt
+
+ @staticmethod
+ def _nonempty_summary(text: Any) -> str | None:
+ """Normalize a model response's text; a blank/whitespace-only body is a failure.
+
+ Committing ``""`` as a summary would fire the before_summarization hooks and
+ remove all prior history for an empty replacement, so an empty body is treated
+ as generation failure (try the fallback, or leave state unchanged) rather than
+ a valid summary.
+ """
+ stripped = text.strip() if isinstance(text, str) else ""
+ return stripped or None
+
def _summarize_with(self, messages_to_summarize: list[AnyMessage], previous_summary: str | None = None) -> str | None:
"""Mirror the parent ``_create_summary`` but invoke the nostream-tagged model.
@@ -114,38 +251,84 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
cached and reused across concurrent runs, so a temporary swap would leak the
``RunnableBinding`` to other coroutines during ``await`` and break parent logic
that inspects the raw model (``profile`` / ``_get_ls_params``).
+
+ Generation uses the run's own model (``model_name: null``) or the explicitly
+ configured summary model, falling back to the run model on failure so a broken
+ summary provider cannot disable compaction while a working model is available.
"""
- if not messages_to_summarize:
- return "No previous conversation history."
- prompt = self._build_summary_prompt(messages_to_summarize, previous_summary=previous_summary)
- if prompt is None:
- return "Previous conversation was too long to summarize."
- try:
- response = self._summary_model.invoke(
- prompt,
- config={"metadata": {"lc_source": "summarization"}},
- )
- return response.text.strip()
- except Exception:
- logger.exception("Summary generation failed; skipping compaction this turn")
- return None
+ prompt = self._prepare_summary_prompt(messages_to_summarize, previous_summary)
+ if prompt is None or prompt in _CANNED_SUMMARIES:
+ return prompt
+ # Walk the ordered candidates; each attempt owns its full lifecycle (lazy
+ # guarded construction -> invoke -> text extraction -> non-empty validation),
+ # and any failure at any stage falls through to the next candidate. When all
+ # candidates fail the caller leaves compaction state unchanged.
+ names = self._generation_candidate_names()
+ for index, name in enumerate(names):
+ text = self._invoke_summary(self._model_for(name), prompt, last=index == len(names) - 1)
+ if text is not None:
+ return text
+ return None
async def _asummarize_with(self, messages_to_summarize: list[AnyMessage], previous_summary: str | None = None) -> str | None:
"""Async counterpart of :meth:`_summarize_with` using the nostream model."""
- if not messages_to_summarize:
- return "No previous conversation history."
- prompt = self._build_summary_prompt(messages_to_summarize, previous_summary=previous_summary)
- if prompt is None:
- return "Previous conversation was too long to summarize."
- try:
- response = await self._summary_model.ainvoke(
- prompt,
- config={"metadata": {"lc_source": "summarization"}},
- )
- return response.text.strip()
- except Exception:
- logger.exception("Summary generation failed; skipping compaction this turn")
+ prompt = self._prepare_summary_prompt(messages_to_summarize, previous_summary)
+ if prompt is None or prompt in _CANNED_SUMMARIES:
+ return prompt
+ names = self._generation_candidate_names()
+ for index, name in enumerate(names):
+ text = await self._ainvoke_summary(self._model_for(name), prompt, last=index == len(names) - 1)
+ if text is not None:
+ return text
+ return None
+
+ def _invoke_summary(self, model: Any | None, prompt: str, *, last: bool = False) -> str | None:
+ """Invoke ``model`` for a summary; ``None`` on error or a blank response.
+
+ Text extraction / non-empty validation runs *inside* the try: reading the
+ response's ``.text`` is part of consuming the provider result, so a failing
+ accessor must convert to a candidate failure (fall through) rather than escape
+ the fail-open boundary.
+ """
+ if model is None:
return None
+ try:
+ response = model.invoke(prompt, config={"metadata": {"lc_source": "summarization"}})
+ return self._checked_summary(response, last)
+ except Exception:
+ self._log_summary_error(last)
+ return None
+
+ async def _ainvoke_summary(self, model: Any | None, prompt: str, *, last: bool = False) -> str | None:
+ """Async counterpart of :meth:`_invoke_summary`."""
+ if model is None:
+ return None
+ try:
+ response = await model.ainvoke(prompt, config={"metadata": {"lc_source": "summarization"}})
+ return self._checked_summary(response, last)
+ except Exception:
+ self._log_summary_error(last)
+ return None
+
+ def _checked_summary(self, response: Any, last: bool) -> str | None:
+ summary = self._nonempty_summary(getattr(response, "text", None))
+ if summary is None:
+ self._log_summary_empty(last)
+ return summary
+
+ @staticmethod
+ def _log_summary_error(last: bool) -> None:
+ if last:
+ logger.exception("Summary generation failed; skipping compaction this turn")
+ else:
+ logger.warning("Summary generation failed; falling back to the run model", exc_info=True)
+
+ @staticmethod
+ def _log_summary_empty(last: bool) -> None:
+ if last:
+ logger.warning("Summary model returned empty text; skipping compaction this turn")
+ else:
+ logger.warning("Summary model returned empty text; falling back to the run model")
@staticmethod
def _summary_count_message(summary_text: str) -> HumanMessage:
@@ -303,15 +486,30 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
runtime: Runtime,
*,
force: bool = False,
+ raise_on_failure: bool = False,
) -> ContextCompactionResult | None:
+ """Summarize old context and retain the active tail.
+
+ ``force`` bypasses the automatic trigger threshold (a manual caller always
+ wants to compact). ``raise_on_failure`` is a *separate* concern: when set (the
+ manual ``/compact`` path), a generation failure raises ``SummaryGenerationError``
+ so it can be reported distinctly from "nothing to compact"; the automatic path
+ leaves it False and swallows the failure, retrying on a later triggered turn.
+ """
prepared = self._prepare_compaction(state, force=force)
if prepared is None:
return None
messages_to_summarize, preserved_messages, previous_summary, total_tokens = prepared
- self._fire_hooks(messages_to_summarize, preserved_messages, runtime)
summary = self._summarize_with(messages_to_summarize, previous_summary=previous_summary)
if summary is None:
+ if raise_on_failure:
+ raise SummaryGenerationError("summary generation failed")
return None
+ # Fire hooks only once a replacement summary exists — flushing pre-compaction
+ # messages into durable memory for a summary that never materializes would
+ # duplicate that work on the next attempt. Messages are still removed after
+ # this returns (in _maybe_summarize), so hooks run before they are gone.
+ self._fire_hooks(messages_to_summarize, preserved_messages, runtime)
return ContextCompactionResult(
summary_text=summary,
messages_to_summarize=tuple(messages_to_summarize),
@@ -325,15 +523,20 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
runtime: Runtime,
*,
force: bool = False,
+ raise_on_failure: bool = False,
) -> ContextCompactionResult | None:
+ """Async counterpart of :meth:`compact_state` (see it for ``raise_on_failure``)."""
prepared = self._prepare_compaction(state, force=force)
if prepared is None:
return None
messages_to_summarize, preserved_messages, previous_summary, total_tokens = prepared
- self._fire_hooks(messages_to_summarize, preserved_messages, runtime)
summary = await self._asummarize_with(messages_to_summarize, previous_summary=previous_summary)
if summary is None:
+ if raise_on_failure:
+ raise SummaryGenerationError("summary generation failed")
return None
+ # Fire hooks only once a replacement summary exists (see compact_state).
+ self._fire_hooks(messages_to_summarize, preserved_messages, runtime)
return ContextCompactionResult(
summary_text=summary,
messages_to_summarize=tuple(messages_to_summarize),
@@ -444,11 +647,37 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
logger.exception("before_summarization hook %s failed", hook_name)
+def _build_summary_anchor(candidate_names: list[str | None], app_config: Any) -> tuple[Any | None, str | None]:
+ """Build the first constructible model among ``candidate_names`` (guarded).
+
+ The returned model is tagged for RunJournal attribution but *not* TAG_NOSTREAM (the
+ middleware wraps a nostream copy). It becomes the parent's token-counter / profile
+ anchor and is reused for generation when a candidate matches its name. A per-name
+ construction failure is swallowed and the next candidate tried, so a broken primary
+ constructor neither breaks agent construction nor skips the healthy run model; a
+ trailing ``None`` name asks ``create_chat_model`` for its own default. Returns
+ ``(None, None)`` when nothing can be constructed.
+ """
+ tried: set[str | None] = set()
+ for name in candidate_names:
+ if name in tried:
+ continue
+ tried.add(name)
+ try:
+ model = create_chat_model(name=name, thinking_enabled=False, app_config=app_config, attach_tracing=False)
+ except Exception:
+ logger.exception("Failed to build summary anchor model %r; trying the next candidate", name)
+ continue
+ return model.with_config(tags=["middleware:summarize"]), name
+ return None, None
+
+
def create_summarization_middleware(
*,
app_config: Any | None = None,
keep: tuple[str, int | float] | None = None,
skip_memory_flush: bool = False,
+ run_model_name: str | None = None,
) -> DeerFlowSummarizationMiddleware | None:
"""Create the configured summarization middleware.
@@ -456,6 +685,13 @@ def create_summarization_middleware(
use this factory so model resolution, hooks, prompt config, and retention
defaults cannot drift.
+ ``run_model_name`` is the model the run actually executes with, resolved by the
+ caller (the lead / subagent / manual builders each already resolve it) and passed
+ in as the authoritative source of truth for ``model_name: null`` summarization and
+ the explicit-summary-model fallback. The middleware does not re-derive it from
+ ``runtime.context`` / ``get_config()``, which do not carry a custom agent's or a
+ subagent's resolved model.
+
``skip_memory_flush`` omits the ``memory_flush_hook`` that otherwise
flushes pre-compaction messages into the durable memory queue. The lead
chain keeps it (research should persist); the subagent chain sets it so a
@@ -477,23 +713,25 @@ def create_summarization_middleware(
else:
trigger = config.trigger.to_tuple()
- if config.model_name:
- model = create_chat_model(
- name=config.model_name,
- thinking_enabled=False,
- app_config=resolved_app_config,
- attach_tracing=False,
- )
- else:
- model = create_chat_model(
- thinking_enabled=False,
- app_config=resolved_app_config,
- attach_tracing=False,
- )
- model = model.with_config(tags=["middleware:summarize"])
+ default_name = resolved_app_config.models[0].name if getattr(resolved_app_config, "models", None) else None
+ # Build the anchor (token-counter / profile model, reused for generation) guarded,
+ # rather than eagerly building the configured/default model and letting a broken
+ # constructor escape. Candidates in order: the primary generation model (configured
+ # summary model, else the run's own model), then the run model, then the default,
+ # then ``None`` (create_chat_model's default) as a last resort. So the null case
+ # builds from ``run_model_name`` — not ``config.models[0]`` — and a broken primary
+ # falls through to the healthy run model instead of failing agent construction.
+ primary_name = config.model_name or run_model_name or default_name
+ anchor_model, anchor_name = _build_summary_anchor(
+ [primary_name, run_model_name or default_name, default_name, None],
+ resolved_app_config,
+ )
+ if anchor_model is None:
+ logger.warning("Summarization is enabled but no summary model could be constructed; compaction is unavailable for this build")
+ return None
kwargs: dict[str, Any] = {
- "model": model,
+ "model": anchor_model,
"trigger": trigger,
"keep": keep or config.keep.to_tuple(),
}
@@ -511,4 +749,8 @@ def create_summarization_middleware(
return DeerFlowSummarizationMiddleware(
**kwargs,
before_summarization=hooks,
+ app_config=resolved_app_config,
+ configured_model_name=config.model_name,
+ run_model_name=run_model_name,
+ anchor_model_name=anchor_name,
)
diff --git a/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py
index 9f47d4318..70c3f59e4 100644
--- a/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py
+++ b/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py
@@ -475,6 +475,11 @@ def build_subagent_runtime_middlewares(
summarization_middleware = create_summarization_middleware(
app_config=app_config,
skip_memory_flush=True,
+ # The subagent's resolved model is the source of truth for null-model
+ # summarization: the subagent context/configurable does not carry the child
+ # model (it inherits the parent's), so passing it directly is what makes a
+ # distinct-model subagent summarize with its own model, not the parent's.
+ run_model_name=model_name,
)
if summarization_middleware is not None:
middlewares.append(summarization_middleware)
diff --git a/backend/packages/harness/deerflow/config/summarization_config.py b/backend/packages/harness/deerflow/config/summarization_config.py
index d59e841a1..ac130ee29 100644
--- a/backend/packages/harness/deerflow/config/summarization_config.py
+++ b/backend/packages/harness/deerflow/config/summarization_config.py
@@ -28,7 +28,10 @@ class SummarizationConfig(BaseModel):
)
model_name: str | None = Field(
default=None,
- description="Model name to use for summarization (None = use a lightweight model)",
+ description="Model name to use for summarization. None = summarize with the model the run "
+ "actually executes with (the lead run's model, a subagent's own model, or a thread's "
+ "custom-agent model), not config.models[0]. When set, that model generates and the run's "
+ "own model is used as a fallback if the configured summary provider fails.",
)
trigger: ContextSize | list[ContextSize] | None = Field(
default=None,
diff --git a/backend/packages/harness/deerflow/runtime/context_compaction.py b/backend/packages/harness/deerflow/runtime/context_compaction.py
index b279c862c..fbb6c2e6d 100644
--- a/backend/packages/harness/deerflow/runtime/context_compaction.py
+++ b/backend/packages/harness/deerflow/runtime/context_compaction.py
@@ -2,15 +2,19 @@
from __future__ import annotations
+import asyncio
+import logging
from dataclasses import dataclass
from types import SimpleNamespace
from langgraph.types import Overwrite
-from deerflow.agents.middlewares.summarization_middleware import DeerFlowSummarizationMiddleware, create_summarization_middleware
+from deerflow.agents.middlewares.summarization_middleware import DeerFlowSummarizationMiddleware, SummaryGenerationError, create_summarization_middleware
from deerflow.config.app_config import AppConfig, get_app_config
from deerflow.runtime.checkpoint_state import CheckpointStateAccessor
+logger = logging.getLogger(__name__)
+
class ContextCompactionDisabled(RuntimeError):
"""Raised when manual compaction is requested while summarization is disabled."""
@@ -38,13 +42,60 @@ def _create_compaction_middleware(
*,
app_config: AppConfig,
keep: tuple[str, int | float] | None,
+ run_model_name: str | None = None,
) -> DeerFlowSummarizationMiddleware:
- middleware = create_summarization_middleware(app_config=app_config, keep=keep)
+ middleware = create_summarization_middleware(app_config=app_config, keep=keep, run_model_name=run_model_name)
if middleware is None:
raise ContextCompactionDisabled("Context compaction is disabled.")
return middleware
+def _safe_load_agent_config(agent_name: str, user_id: str | None):
+ """Load a custom agent's config, returning ``None`` on any failure.
+
+ A missing / unparseable agent config must not fail compaction; the run model is a
+ best-effort optimization and the default is a safe fallback. The caller runs this
+ off the event loop via ``asyncio.to_thread``, so the strict blocking-IO detector
+ does not flag the filesystem read and the broad ``except`` here cannot mask a
+ ``BlockingError`` raised on the loop.
+ """
+ from deerflow.config.agents_config import load_agent_config
+
+ try:
+ return load_agent_config(agent_name, user_id=user_id)
+ except Exception:
+ logger.warning("Could not load agent config for %r; using the default model for summarization", agent_name, exc_info=True)
+ return None
+
+
+async def _aresolve_thread_model_name(
+ model_name: str | None,
+ agent_name: str | None,
+ user_id: str | None,
+ app_config: AppConfig,
+) -> str | None:
+ """Resolve the model a thread should summarize with, mirroring lead resolution.
+
+ Precedence matches ``lead_agent._resolve_model_name``: an explicit request model
+ override (validated against configured models) wins, else the thread's custom-agent
+ configured model, else ``config.models[0]``. Manual ``/compact`` does not execute
+ the agent, so there is no live runtime carrying the selected model — the caller
+ (route / client) supplies it as ``model_name`` the same way a normal run submits
+ ``context.model_name``. The custom-agent config read happens only when no request
+ model was supplied, runs off the event loop, and passes the owning ``user_id`` so
+ the per-user agent directory resolves.
+ """
+ default = app_config.models[0].name if getattr(app_config, "models", None) else None
+ candidate = model_name
+ if not candidate and agent_name:
+ agent_config = await asyncio.to_thread(_safe_load_agent_config, agent_name, user_id)
+ if agent_config and agent_config.model:
+ candidate = agent_config.model
+ if candidate and app_config.get_model_config(candidate):
+ return candidate
+ return default
+
+
async def compact_thread_context(
accessor: CheckpointStateAccessor,
thread_id: str,
@@ -53,11 +104,13 @@ async def compact_thread_context(
force: bool = True,
user_id: str | None = None,
agent_name: str | None = None,
+ model_name: str | None = None,
app_config: AppConfig | None = None,
) -> ThreadCompactionResult:
"""Summarize old messages in a thread and write a compacted checkpoint."""
resolved_app_config = app_config or get_app_config()
- middleware = _create_compaction_middleware(app_config=resolved_app_config, keep=keep)
+ run_model_name = await _aresolve_thread_model_name(model_name, agent_name, user_id, resolved_app_config)
+ middleware = _create_compaction_middleware(app_config=resolved_app_config, keep=keep, run_model_name=run_model_name)
read_config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
snapshot = await accessor.aget(read_config)
@@ -80,7 +133,18 @@ async def compact_thread_context(
if agent_name:
runtime_context["agent_name"] = agent_name
runtime = SimpleNamespace(context=runtime_context)
- result = await middleware.acompact_state(state, runtime, force=force) # type: ignore[arg-type]
+ try:
+ # ``raise_on_failure`` is independent of ``force``: a manual caller always wants
+ # a generation failure surfaced (even a force=False call that met the threshold),
+ # so it must not collapse into the force=False "nothing to compact" branch below.
+ result = await middleware.acompact_state(state, runtime, force=force, raise_on_failure=True) # type: ignore[arg-type]
+ except SummaryGenerationError as exc:
+ # A compressible thread whose summary LLM failed (after the run-model fallback)
+ # is a real failure, distinct from "nothing to compact". Route it to the
+ # already-consumed ContextCompactionFailed path (HTTP 500 -> frontend error
+ # toast) instead of a compacted=False result that reads as "does not need
+ # compaction".
+ raise ContextCompactionFailed("summary generation failed") from exc
if result is None:
return ThreadCompactionResult(thread_id=thread_id, compacted=False, reason="not_enough_messages")
diff --git a/backend/tests/test_context_compaction.py b/backend/tests/test_context_compaction.py
index 29c47d8a5..679532756 100644
--- a/backend/tests/test_context_compaction.py
+++ b/backend/tests/test_context_compaction.py
@@ -10,9 +10,10 @@ from langgraph.graph.message import add_messages
from langgraph.types import Overwrite
from app.gateway import services as gateway_services
+from deerflow.agents.middlewares.summarization_middleware import SummaryGenerationError
from deerflow.runtime import context_compaction
from deerflow.runtime.checkpoint_state import CheckpointStateAccessor
-from deerflow.runtime.context_compaction import compact_thread_context
+from deerflow.runtime.context_compaction import ContextCompactionFailed, compact_thread_context
class _FakeAccessor:
@@ -56,7 +57,7 @@ class _FakeCompactionMiddleware:
return None
return (state["messages"][:-1], state["messages"][-1:], state.get("summary_text"), 123)
- async def acompact_state(self, state, runtime, *, force=False):
+ async def acompact_state(self, state, runtime, *, force=False, raise_on_failure=False):
self.runtime_contexts.append(dict(runtime.context))
prepared = self._prepare_compaction(state, force=force)
if prepared is None:
@@ -90,6 +91,9 @@ async def test_compact_thread_context_reads_materialized_state_and_overwrites_me
"_create_compaction_middleware",
lambda **_kwargs: middleware,
)
+ import deerflow.config.agents_config as agents_config
+
+ monkeypatch.setattr(agents_config, "load_agent_config", lambda name, **_kw: None)
result = await compact_thread_context(
accessor,
@@ -255,3 +259,180 @@ async def test_compact_thread_context_returns_noop_without_writing(monkeypatch):
assert result.reason == "not_enough_messages"
assert accessor.update_args is None
assert middleware.prepare_calls == 1
+
+
+@pytest.mark.asyncio
+async def test_compact_thread_context_raises_on_summary_generation_failure(monkeypatch):
+ """A compressible thread whose summary LLM fails (after the run-model fallback) is
+ a real failure: it must surface via the already-consumed ``ContextCompactionFailed``
+ path (HTTP 500 -> frontend error toast), not a ``compacted=False`` result that the
+ frontend renders as the benign "does not need compaction" toast."""
+ accessor = _FakeAccessor(
+ {
+ "messages": [
+ HumanMessage(content="old question"),
+ AIMessage(content="old answer"),
+ HumanMessage(content="latest question"),
+ ],
+ }
+ )
+
+ captured: dict[str, object] = {}
+
+ class _FailingMiddleware:
+ async def acompact_state(self, state, runtime, *, force=False, raise_on_failure=False):
+ # The manual path must opt into raise_on_failure regardless of force.
+ captured["raise_on_failure"] = raise_on_failure
+ raise SummaryGenerationError("summary generation failed")
+
+ monkeypatch.setattr(
+ context_compaction,
+ "_create_compaction_middleware",
+ lambda **_kwargs: _FailingMiddleware(),
+ )
+
+ with pytest.raises(ContextCompactionFailed):
+ await compact_thread_context(accessor, "thread-1", app_config=SimpleNamespace())
+
+ assert captured["raise_on_failure"] is True
+ assert accessor.update_args is None
+
+
+def _model_app_config(*names):
+ models = [SimpleNamespace(name=n) for n in names]
+ return SimpleNamespace(
+ models=models,
+ get_model_config=lambda n: next((m for m in models if m.name == n), None),
+ )
+
+
+@pytest.mark.asyncio
+async def test_resolve_request_model_overrides_agent_and_default(monkeypatch):
+ """The selected request model wins over both the custom-agent model and the default,
+ mirroring lead resolution (request -> agent -> default). A supplied request model
+ also short-circuits the agent-config filesystem read entirely."""
+ import deerflow.config.agents_config as agents_config
+
+ def _fail(name, **_kw):
+ raise AssertionError("agent config must not be loaded when a request model is supplied")
+
+ monkeypatch.setattr(agents_config, "load_agent_config", _fail)
+ app_config = _model_app_config("default-model", "agent-model", "selected-model")
+
+ got = await context_compaction._aresolve_thread_model_name("selected-model", "research-agent", "user-1", app_config)
+ assert got == "selected-model"
+
+
+@pytest.mark.asyncio
+async def test_resolve_invalid_request_model_falls_to_default_not_agent(monkeypatch):
+ """A request model that is not a configured model falls straight to the default —
+ the agent model is only consulted when no request model was supplied, matching
+ ``lead_agent._resolve_model_name(requested or agent)``."""
+ import deerflow.config.agents_config as agents_config
+
+ monkeypatch.setattr(agents_config, "load_agent_config", lambda name, **_kw: SimpleNamespace(model="agent-model"))
+ app_config = _model_app_config("default-model", "agent-model")
+
+ got = await context_compaction._aresolve_thread_model_name("ghost-model", "research-agent", "user-1", app_config)
+ assert got == "default-model"
+
+
+@pytest.mark.asyncio
+async def test_resolve_agent_model_when_no_request_model(monkeypatch):
+ """With no request model, a custom-agent thread summarizes with its own model, and
+ the agent config is loaded with the owning ``user_id`` (per-user agent directory)."""
+ import deerflow.config.agents_config as agents_config
+
+ seen: dict[str, object] = {}
+
+ def _load(name, *, user_id=None):
+ seen["name"] = name
+ seen["user_id"] = user_id
+ return SimpleNamespace(model="agent-model")
+
+ monkeypatch.setattr(agents_config, "load_agent_config", _load)
+ app_config = _model_app_config("default-model", "agent-model")
+
+ got = await context_compaction._aresolve_thread_model_name(None, "research-agent", "user-1", app_config)
+ assert got == "agent-model"
+ assert seen == {"name": "research-agent", "user_id": "user-1"}
+
+
+@pytest.mark.asyncio
+async def test_resolve_falls_back_to_default(monkeypatch):
+ """No agent, an unloadable agent config, or an agent whose model is not configured all
+ resolve to the default model rather than failing compaction."""
+ import deerflow.config.agents_config as agents_config
+
+ app_config = _model_app_config("default-model")
+
+ # No request model and no agent → default.
+ assert await context_compaction._aresolve_thread_model_name(None, None, "user-1", app_config) == "default-model"
+
+ # Agent config cannot be loaded (missing/unparseable) → default, not a crash.
+ def _raise(name, **_kw):
+ raise FileNotFoundError("agent directory not found")
+
+ monkeypatch.setattr(agents_config, "load_agent_config", _raise)
+ assert await context_compaction._aresolve_thread_model_name(None, "ghost-agent", "user-1", app_config) == "default-model"
+
+ # Agent's model is not a configured model → default.
+ monkeypatch.setattr(agents_config, "load_agent_config", lambda name, **_kw: SimpleNamespace(model="unconfigured"))
+ assert await context_compaction._aresolve_thread_model_name(None, "x-agent", "user-1", app_config) == "default-model"
+
+
+@pytest.mark.asyncio
+async def test_resolve_agent_config_load_runs_off_the_event_loop(monkeypatch):
+ """The blocking agent-config read must run off the event loop (``asyncio.to_thread``),
+ not synchronously before the first await where the strict blocking-IO gate would flag
+ it and the broad except would then mask the ``BlockingError`` as a default fallback."""
+ import threading
+
+ import deerflow.config.agents_config as agents_config
+
+ main_thread = threading.get_ident()
+ seen: dict[str, object] = {}
+
+ def _load(name, *, user_id=None):
+ seen["thread"] = threading.get_ident()
+ return SimpleNamespace(model="agent-model")
+
+ monkeypatch.setattr(agents_config, "load_agent_config", _load)
+ app_config = _model_app_config("default-model", "agent-model")
+
+ got = await context_compaction._aresolve_thread_model_name(None, "research-agent", "user-1", app_config)
+ assert got == "agent-model"
+ assert seen["thread"] != main_thread
+
+
+@pytest.mark.asyncio
+async def test_compact_thread_context_threads_selected_model_to_factory(monkeypatch):
+ """Route/client path: the model selected for the thread request reaches the
+ summarization factory as ``run_model_name`` — covering the real call, not only the
+ resolver in isolation (request override -> agent -> default)."""
+ import deerflow.config.agents_config as agents_config
+
+ monkeypatch.setattr(agents_config, "load_agent_config", lambda name, **_kw: SimpleNamespace(model="agent-model"))
+ app_config = _model_app_config("default-model", "agent-model", "selected-model")
+
+ captured: dict[str, object] = {}
+
+ def _capture(**kwargs):
+ captured["run_model_name"] = kwargs.get("run_model_name")
+ return _FakeCompactionMiddleware()
+
+ monkeypatch.setattr(context_compaction, "_create_compaction_middleware", _capture)
+
+ messages = [HumanMessage(content="old"), AIMessage(content="a"), HumanMessage(content="new")]
+
+ # 1. Explicit request model wins.
+ await compact_thread_context(_FakeAccessor({"messages": messages}), "thread-1", app_config=app_config, user_id="user-1", agent_name="research-agent", model_name="selected-model")
+ assert captured["run_model_name"] == "selected-model"
+
+ # 2. No request model → the custom agent's model.
+ await compact_thread_context(_FakeAccessor({"messages": messages}), "thread-1", app_config=app_config, user_id="user-1", agent_name="research-agent")
+ assert captured["run_model_name"] == "agent-model"
+
+ # 3. No request model, no agent → default.
+ await compact_thread_context(_FakeAccessor({"messages": messages}), "thread-1", app_config=app_config, user_id="user-1")
+ assert captured["run_model_name"] == "default-model"
diff --git a/backend/tests/test_durable_context_middleware.py b/backend/tests/test_durable_context_middleware.py
index 97f97cf12..c374bf92e 100644
--- a/backend/tests/test_durable_context_middleware.py
+++ b/backend/tests/test_durable_context_middleware.py
@@ -503,7 +503,7 @@ class TestMiddlewareRegistration:
summary_sentinel = object()
monkeypatch.setattr(lead_agent_module, "build_lead_runtime_middlewares", lambda *, app_config, lazy_init=True: [])
- monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda *, app_config=None: summary_sentinel)
+ monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda **_kwargs: summary_sentinel)
monkeypatch.setattr(lead_agent_module, "_create_todo_list_middleware", lambda is_plan_mode: None)
middlewares = lead_agent_module.build_middlewares(
diff --git a/backend/tests/test_lead_agent_model_resolution.py b/backend/tests/test_lead_agent_model_resolution.py
index cf51f2ba7..2c6ad3939 100644
--- a/backend/tests/test_lead_agent_model_resolution.py
+++ b/backend/tests/test_lead_agent_model_resolution.py
@@ -609,6 +609,38 @@ def test_build_middlewares_passes_explicit_app_config_to_shared_factory(monkeypa
assert middlewares[0] == "base-middleware"
+def test_build_middlewares_passes_run_model_name_to_summarization(monkeypatch):
+ """The resolved run model (e.g. a custom agent's model, distinct from models[0])
+ is threaded into the summarization factory, so null-model compaction summarizes
+ with it rather than config.models[0]. Production-shaped: the model reaches the
+ factory as an argument, not via runtime.context."""
+ app_config = _make_app_config(
+ [
+ _make_model("default-model", supports_thinking=False),
+ _make_model("custom-agent-model", supports_thinking=False),
+ ]
+ )
+ captured: dict[str, object] = {}
+
+ monkeypatch.setattr(lead_agent_module, "build_lead_runtime_middlewares", lambda *, app_config, lazy_init: ["base-middleware"])
+ monkeypatch.setattr(
+ lead_agent_module,
+ "_create_summarization_middleware",
+ lambda **kwargs: captured.update(kwargs) or None,
+ )
+ monkeypatch.setattr(lead_agent_module, "_create_todo_list_middleware", lambda is_plan_mode: None)
+ monkeypatch.setattr(lead_agent_module, "TitleMiddleware", lambda *, app_config: "title-middleware")
+ monkeypatch.setattr(lead_agent_module, "MemoryMiddleware", lambda agent_name=None, *, memory_config: "memory-middleware")
+
+ lead_agent_module.build_middlewares(
+ {"configurable": {"is_plan_mode": False, "subagent_enabled": False}},
+ model_name="custom-agent-model",
+ app_config=app_config,
+ )
+
+ assert captured["run_model_name"] == "custom-agent-model"
+
+
def test_build_middlewares_orders_skill_activation_before_policy_and_durable_context(monkeypatch):
from deerflow.agents.middlewares.durable_context_middleware import DurableContextMiddleware
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
@@ -616,7 +648,7 @@ def test_build_middlewares_orders_skill_activation_before_policy_and_durable_con
app_config = _make_app_config([_make_model("safe-model", supports_thinking=False)])
monkeypatch.setattr(lead_agent_module, "build_lead_runtime_middlewares", lambda *, app_config, lazy_init=True: [])
- monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda *, app_config=None: None)
+ monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda **_kwargs: None)
monkeypatch.setattr(lead_agent_module, "_create_todo_list_middleware", lambda is_plan_mode: None)
middlewares = lead_agent_module.build_middlewares(
@@ -644,7 +676,7 @@ def test_compiled_skill_policy_chain_filters_schema_and_blocks_execution(monkeyp
loop_detection=LoopDetectionConfig(enabled=False),
)
monkeypatch.setattr(lead_agent_module, "build_lead_runtime_middlewares", lambda *, app_config, lazy_init=True: [])
- monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda *, app_config=None: None)
+ monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda **_kwargs: None)
monkeypatch.setattr(lead_agent_module, "_create_todo_list_middleware", lambda is_plan_mode: None)
middlewares = lead_agent_module.build_middlewares(
@@ -715,7 +747,7 @@ def test_build_middlewares_places_mcp_routing_before_deferred_filter(monkeypatch
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config)
monkeypatch.setattr(lead_agent_module, "build_lead_runtime_middlewares", lambda *, app_config, lazy_init=True: [])
- monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda *, app_config=None: None)
+ monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda **_kwargs: None)
monkeypatch.setattr(lead_agent_module, "_create_todo_list_middleware", lambda is_plan_mode: None)
middlewares = lead_agent_module.build_middlewares(
@@ -746,7 +778,7 @@ def test_build_middlewares_uses_loop_detection_config(monkeypatch):
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config)
monkeypatch.setattr(lead_agent_module, "build_lead_runtime_middlewares", lambda *, app_config, lazy_init=True: [])
- monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda *, app_config=None: None)
+ monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda **_kwargs: None)
monkeypatch.setattr(lead_agent_module, "_create_todo_list_middleware", lambda is_plan_mode: None)
middlewares = lead_agent_module.build_middlewares(
@@ -772,7 +804,7 @@ def test_build_middlewares_omits_loop_detection_when_disabled(monkeypatch):
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config)
monkeypatch.setattr(lead_agent_module, "build_lead_runtime_middlewares", lambda *, app_config, lazy_init=True: [])
- monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda *, app_config=None: None)
+ monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda **_kwargs: None)
monkeypatch.setattr(lead_agent_module, "_create_todo_list_middleware", lambda is_plan_mode: None)
middlewares = lead_agent_module.build_middlewares(
@@ -799,7 +831,7 @@ def test_build_middlewares_injects_configured_extension_middlewares(monkeypatch)
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config)
monkeypatch.setattr(lead_agent_module, "build_lead_runtime_middlewares", lambda *, app_config, lazy_init=True: [])
- monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda *, app_config=None: None)
+ monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda **_kwargs: None)
monkeypatch.setattr(lead_agent_module, "_create_todo_list_middleware", lambda is_plan_mode: None)
middlewares = lead_agent_module.build_middlewares(
@@ -829,7 +861,7 @@ def test_build_middlewares_passes_subagent_total_limit_from_app_config(monkeypat
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config)
monkeypatch.setattr(lead_agent_module, "build_lead_runtime_middlewares", lambda *, app_config, lazy_init=True: [])
- monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda *, app_config=None: None)
+ monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda **_kwargs: None)
monkeypatch.setattr(lead_agent_module, "_create_todo_list_middleware", lambda is_plan_mode: None)
middlewares = lead_agent_module.build_middlewares(
@@ -852,7 +884,7 @@ def test_build_middlewares_allows_runtime_subagent_total_limit_override(monkeypa
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config)
monkeypatch.setattr(lead_agent_module, "build_lead_runtime_middlewares", lambda *, app_config, lazy_init=True: [])
- monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda *, app_config=None: None)
+ monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda **_kwargs: None)
monkeypatch.setattr(lead_agent_module, "_create_todo_list_middleware", lambda is_plan_mode: None)
middlewares = lead_agent_module.build_middlewares(
@@ -881,7 +913,7 @@ def test_build_middlewares_rejects_invalid_configured_extension_middleware(monke
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config)
monkeypatch.setattr(lead_agent_module, "build_lead_runtime_middlewares", lambda *, app_config, lazy_init=True: [])
- monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda *, app_config=None: None)
+ monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda **_kwargs: None)
monkeypatch.setattr(lead_agent_module, "_create_todo_list_middleware", lambda is_plan_mode: None)
with pytest.raises(ValueError, match="not an instance of type"):
@@ -901,7 +933,7 @@ def test_build_middlewares_rejects_configured_extension_class_with_wrong_base(mo
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config)
monkeypatch.setattr(lead_agent_module, "build_lead_runtime_middlewares", lambda *, app_config, lazy_init=True: [])
- monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda *, app_config=None: None)
+ monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda **_kwargs: None)
monkeypatch.setattr(lead_agent_module, "_create_todo_list_middleware", lambda is_plan_mode: None)
with pytest.raises(ValueError, match="is not a subclass of AgentMiddleware"):
@@ -921,7 +953,7 @@ def test_build_middlewares_reraises_configured_extension_instantiation_failure(m
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config)
monkeypatch.setattr(lead_agent_module, "build_lead_runtime_middlewares", lambda *, app_config, lazy_init=True: [])
- monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda *, app_config=None: None)
+ monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda **_kwargs: None)
monkeypatch.setattr(lead_agent_module, "_create_todo_list_middleware", lambda is_plan_mode: None)
with pytest.raises(RuntimeError, match="configured middleware init failed"):
@@ -941,7 +973,7 @@ def test_build_middlewares_rejects_missing_configured_extension_module(monkeypat
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config)
monkeypatch.setattr(lead_agent_module, "build_lead_runtime_middlewares", lambda *, app_config, lazy_init=True: [])
- monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda *, app_config=None: None)
+ monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda **_kwargs: None)
monkeypatch.setattr(lead_agent_module, "_create_todo_list_middleware", lambda is_plan_mode: None)
with pytest.raises(ImportError, match="Could not import module definitely_missing_pkg.middlewares_typo"):
@@ -986,7 +1018,10 @@ def test_create_summarization_middleware_uses_configured_model_alias(monkeypatch
fake_model.with_config.assert_called_once_with(tags=["middleware:summarize"])
-def test_create_summarization_middleware_omits_model_name_when_unconfigured(monkeypatch):
+def test_create_summarization_middleware_uses_default_when_unconfigured(monkeypatch):
+ """Null summary config with no supplied run model builds the anchor from the default
+ model — now with an explicit name rather than relying on create_chat_model's internal
+ default, so the factory has no implicit models[0] dependency. Same resulting model."""
app_config = _make_app_config([_make_model("default-model", supports_thinking=False)])
app_config.summarization = SummarizationConfig(enabled=True, model_name=None)
app_config.memory = MemoryConfig(enabled=False)
@@ -1004,10 +1039,35 @@ def test_create_summarization_middleware_omits_model_name_when_unconfigured(monk
middleware = lead_agent_module._create_summarization_middleware(app_config=app_config)
- assert "name" not in captured
+ assert captured["name"] == "default-model"
assert captured["thinking_enabled"] is False
assert captured["app_config"] is app_config
assert middleware["model"] is fake_model
+ assert middleware["anchor_model_name"] == "default-model"
+
+
+def test_create_summarization_middleware_threads_run_model_name(monkeypatch):
+ """A custom agent's resolved model reaches the middleware as run_model_name (the
+ model-ownership source of truth), while configured_model_name stays None for a
+ null summarization config."""
+ app_config = _make_app_config(
+ [
+ _make_model("default-model", supports_thinking=False),
+ _make_model("custom-agent-model", supports_thinking=False),
+ ]
+ )
+ app_config.summarization = SummarizationConfig(enabled=True, model_name=None)
+ app_config.memory = MemoryConfig(enabled=False)
+
+ fake_model = MagicMock()
+ fake_model.with_config.return_value = fake_model
+ monkeypatch.setattr(summarization_middleware_module, "create_chat_model", lambda **kwargs: fake_model)
+ monkeypatch.setattr(summarization_middleware_module, "DeerFlowSummarizationMiddleware", lambda **kwargs: kwargs)
+
+ middleware = lead_agent_module._create_summarization_middleware(app_config=app_config, run_model_name="custom-agent-model")
+
+ assert middleware["run_model_name"] == "custom-agent-model"
+ assert middleware["configured_model_name"] is None
def test_create_summarization_middleware_uses_frontend_supported_update_key(monkeypatch):
diff --git a/backend/tests/test_summarization_middleware.py b/backend/tests/test_summarization_middleware.py
index fcb350834..7f4456e83 100644
--- a/backend/tests/test_summarization_middleware.py
+++ b/backend/tests/test_summarization_middleware.py
@@ -2,7 +2,7 @@ from __future__ import annotations
from types import SimpleNamespace
from unittest import mock
-from unittest.mock import MagicMock
+from unittest.mock import AsyncMock, MagicMock
import pytest
from langchain.agents import create_agent
@@ -13,7 +13,7 @@ from langgraph.constants import TAG_NOSTREAM
from deerflow.agents.memory.summarization_hook import memory_flush_hook
from deerflow.agents.middlewares.dynamic_context_middleware import _DYNAMIC_CONTEXT_REMINDER_KEY, DynamicContextMiddleware, is_dynamic_context_reminder
-from deerflow.agents.middlewares.summarization_middleware import DeerFlowSummarizationMiddleware, SummarizationEvent, create_summarization_middleware
+from deerflow.agents.middlewares.summarization_middleware import DeerFlowSummarizationMiddleware, SummarizationEvent, SummaryGenerationError, create_summarization_middleware
from deerflow.agents.thread_state import ThreadState
from deerflow.config.memory_config import MemoryConfig
from deerflow.config.summarization_config import SummarizationConfig
@@ -78,6 +78,7 @@ def _middleware(
) -> DeerFlowSummarizationMiddleware:
model = MagicMock()
model.invoke.return_value = SimpleNamespace(text="compressed summary")
+ model.ainvoke = AsyncMock(return_value=SimpleNamespace(text="compressed summary"))
model.with_config.return_value = model
return DeerFlowSummarizationMiddleware(
model=model,
@@ -727,3 +728,481 @@ def test_benign_summary_input_text_preserved() -> None:
assert out is not None
assert "User: what is the plan" in out
assert "prior recap text" in out
+
+
+def _app_config(model_names=("default-model",)):
+ """Minimal AppConfig-shaped stub: ordered ``models`` + ``get_model_config``."""
+ models = [SimpleNamespace(name=name) for name in model_names]
+
+ def get_model_config(name):
+ return next((model for model in models if model.name == name), None)
+
+ return SimpleNamespace(models=models, get_model_config=get_model_config)
+
+
+def _tracking_create_chat_model(built: list, *, fail: bool = False):
+ """Patch target for ``create_chat_model``: records built names, returns a mock
+ whose summary text is ``from-`` (or fails on invoke when ``fail``)."""
+
+ def _factory(*, name=None, thinking_enabled=False, app_config=None, attach_tracing=True, **kwargs):
+ model = MagicMock()
+ model.with_config.return_value = model
+ if fail:
+ model.invoke.side_effect = RuntimeError("provider down")
+ model.ainvoke = AsyncMock(side_effect=RuntimeError("provider down"))
+ else:
+ model.invoke.return_value = SimpleNamespace(text=f"from-{name}")
+ model.ainvoke = AsyncMock(return_value=SimpleNamespace(text=f"from-{name}"))
+ built.append(name)
+ return model
+
+ return _factory
+
+
+def _blank_model(*, text: str = " \n\t ") -> MagicMock:
+ """A model whose response body is whitespace-only (a generation failure)."""
+ model = MagicMock()
+ model.with_config.return_value = model
+ model.invoke.return_value = SimpleNamespace(text=text)
+ model.ainvoke = AsyncMock(return_value=SimpleNamespace(text=text))
+ return model
+
+
+def test_null_model_summarizes_with_the_run_model(monkeypatch: pytest.MonkeyPatch) -> None:
+ """model_name: null must summarize with the model the run actually uses, not
+ config.models[0]. This is the model-ownership fix: a run on a non-default model
+ while models[0]'s provider is broken must still compact.
+
+ Production-shaped: the run model is supplied at build time (``run_model_name``),
+ and the runtime carries NO ``model_name`` in its context — the previous fixture
+ injected ``runtime.context['model_name']``, which the production custom-agent /
+ subagent contexts never populate."""
+ built: list = []
+ monkeypatch.setattr("deerflow.agents.middlewares.summarization_middleware.create_chat_model", _tracking_create_chat_model(built))
+
+ default_model = MagicMock()
+ default_model.with_config.return_value = default_model
+ default_model.invoke.return_value = SimpleNamespace(text="from-default")
+ middleware = DeerFlowSummarizationMiddleware(
+ model=default_model,
+ trigger=("messages", 4),
+ keep=("messages", 2),
+ token_counter=len,
+ app_config=_app_config(("default-model", "run-model")),
+ configured_model_name=None,
+ run_model_name="run-model",
+ )
+
+ result = middleware.before_model({"messages": _messages()}, _runtime())
+
+ # The run model was built + used; the default (models[0]) never generated a summary.
+ assert built == ["run-model"]
+ default_model.invoke.assert_not_called()
+ assert result is not None
+ assert result["summary_text"] == "from-run-model"
+
+
+def test_explicit_summary_model_failure_falls_back_to_run_model(monkeypatch: pytest.MonkeyPatch) -> None:
+ """An explicitly configured summary model generates; if its provider is broken,
+ compaction falls back to the run's own (working) model instead of no-op'ing.
+ The fallback is built lazily only after the primary fails."""
+ built: list = []
+ monkeypatch.setattr("deerflow.agents.middlewares.summarization_middleware.create_chat_model", _tracking_create_chat_model(built))
+
+ explicit = MagicMock()
+ explicit.with_config.return_value = explicit
+ explicit.invoke.side_effect = RuntimeError("summary provider down")
+ middleware = DeerFlowSummarizationMiddleware(
+ model=explicit,
+ trigger=("messages", 4),
+ keep=("messages", 2),
+ token_counter=len,
+ app_config=_app_config(("default-model", "run-model")),
+ configured_model_name="summary-model",
+ run_model_name="run-model",
+ )
+
+ result = middleware.before_model({"messages": _messages()}, _runtime())
+
+ explicit.invoke.assert_called_once() # primary tried
+ assert built == ["run-model"] # fallback built (lazily, after primary failed) + used
+ assert result is not None
+ assert result["summary_text"] == "from-run-model"
+
+
+@pytest.mark.anyio
+async def test_async_explicit_failure_falls_back_to_run_model(monkeypatch: pytest.MonkeyPatch) -> None:
+ """The async path applies the same run-model fallback as the sync path."""
+ built: list = []
+ monkeypatch.setattr("deerflow.agents.middlewares.summarization_middleware.create_chat_model", _tracking_create_chat_model(built))
+
+ explicit = MagicMock()
+ explicit.with_config.return_value = explicit
+ explicit.ainvoke = AsyncMock(side_effect=RuntimeError("summary provider down"))
+ middleware = DeerFlowSummarizationMiddleware(
+ model=explicit,
+ trigger=("messages", 4),
+ keep=("messages", 2),
+ token_counter=len,
+ app_config=_app_config(("default-model", "run-model")),
+ configured_model_name="summary-model",
+ run_model_name="run-model",
+ )
+
+ result = await middleware.abefore_model({"messages": _messages()}, _runtime())
+
+ explicit.ainvoke.assert_awaited_once()
+ assert built == ["run-model"]
+ assert result is not None
+ assert result["summary_text"] == "from-run-model"
+
+
+def test_both_summary_models_failing_returns_none_on_automatic_path(monkeypatch: pytest.MonkeyPatch) -> None:
+ """When the explicit model and the run-model fallback both fail, the automatic
+ path leaves compaction state unchanged (returns None) rather than raising."""
+ built: list = []
+ monkeypatch.setattr("deerflow.agents.middlewares.summarization_middleware.create_chat_model", _tracking_create_chat_model(built, fail=True))
+
+ explicit = MagicMock()
+ explicit.with_config.return_value = explicit
+ explicit.invoke.side_effect = RuntimeError("provider down")
+ middleware = DeerFlowSummarizationMiddleware(
+ model=explicit,
+ trigger=("messages", 4),
+ keep=("messages", 2),
+ token_counter=len,
+ app_config=_app_config(("default-model", "run-model")),
+ configured_model_name="summary-model",
+ run_model_name="run-model",
+ )
+
+ result = middleware.before_model({"messages": _messages()}, _runtime())
+
+ assert result is None
+ explicit.invoke.assert_called_once() # primary tried
+ assert built == ["run-model"] # fallback tried too
+
+
+def test_explicit_summary_model_equal_to_run_model_is_not_retried(monkeypatch: pytest.MonkeyPatch) -> None:
+ """When the configured summary model IS the run model, there is no distinct
+ fallback: the failed model must not be re-invoked (that would just burn another
+ call against a provider we already know is down) and no second model is built."""
+ built: list = []
+ monkeypatch.setattr("deerflow.agents.middlewares.summarization_middleware.create_chat_model", _tracking_create_chat_model(built, fail=True))
+
+ explicit = MagicMock()
+ explicit.with_config.return_value = explicit
+ explicit.invoke.side_effect = RuntimeError("provider down")
+ middleware = DeerFlowSummarizationMiddleware(
+ model=explicit,
+ trigger=("messages", 4),
+ keep=("messages", 2),
+ token_counter=len,
+ app_config=_app_config(("summary-model",)),
+ configured_model_name="summary-model",
+ run_model_name="summary-model", # run model == configured summary model
+ )
+
+ result = middleware.before_model({"messages": _messages()}, _runtime())
+
+ assert result is None
+ explicit.invoke.assert_called_once() # tried exactly once, not twice
+ assert built == [] # no distinct fallback model was built
+
+
+def test_fallback_construction_error_does_not_escape_automatic_path(monkeypatch: pytest.MonkeyPatch) -> None:
+ """A broken run-model config must not skip the healthy primary or escape the
+ automatic failure boundary: the primary is tried first, and a fallback that
+ fails to *construct* is swallowed (returns None), not raised."""
+
+ def _failing_build(*, name=None, **kwargs):
+ raise RuntimeError("cannot build run model")
+
+ monkeypatch.setattr("deerflow.agents.middlewares.summarization_middleware.create_chat_model", _failing_build)
+
+ explicit = MagicMock()
+ explicit.with_config.return_value = explicit
+ explicit.invoke.side_effect = RuntimeError("summary provider down")
+ middleware = DeerFlowSummarizationMiddleware(
+ model=explicit,
+ trigger=("messages", 4),
+ keep=("messages", 2),
+ token_counter=len,
+ app_config=_app_config(("default-model", "run-model")),
+ configured_model_name="summary-model",
+ run_model_name="run-model",
+ )
+
+ result = middleware.before_model({"messages": _messages()}, _runtime())
+
+ assert result is None
+ explicit.invoke.assert_called_once() # healthy-or-not, the primary was still tried first
+
+
+def test_blank_summary_response_is_not_committed_null_case(monkeypatch: pytest.MonkeyPatch) -> None:
+ """A whitespace-only model response is a generation failure, not a valid empty
+ summary: the automatic path returns None (history preserved, no RemoveMessage)
+ instead of removing all history for an empty replacement."""
+ run_model = _blank_model()
+ monkeypatch.setattr("deerflow.agents.middlewares.summarization_middleware.create_chat_model", lambda **kwargs: run_model)
+
+ default_model = MagicMock()
+ default_model.with_config.return_value = default_model
+ middleware = DeerFlowSummarizationMiddleware(
+ model=default_model,
+ trigger=("messages", 4),
+ keep=("messages", 2),
+ token_counter=len,
+ app_config=_app_config(("default-model", "run-model")),
+ configured_model_name=None,
+ run_model_name="run-model",
+ )
+
+ result = middleware.before_model({"messages": _messages()}, _runtime())
+
+ assert result is None
+ run_model.invoke.assert_called_once()
+
+
+@pytest.mark.anyio
+async def test_blank_summary_response_is_not_committed_async(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Async counterpart: a whitespace-only response leaves compaction state unchanged."""
+ run_model = _blank_model()
+ monkeypatch.setattr("deerflow.agents.middlewares.summarization_middleware.create_chat_model", lambda **kwargs: run_model)
+
+ default_model = MagicMock()
+ default_model.with_config.return_value = default_model
+ middleware = DeerFlowSummarizationMiddleware(
+ model=default_model,
+ trigger=("messages", 4),
+ keep=("messages", 2),
+ token_counter=len,
+ app_config=_app_config(("default-model", "run-model")),
+ configured_model_name=None,
+ run_model_name="run-model",
+ )
+
+ result = await middleware.abefore_model({"messages": _messages()}, _runtime())
+
+ assert result is None
+ run_model.ainvoke.assert_awaited_once()
+
+
+def test_blank_primary_summary_falls_back_to_run_model(monkeypatch: pytest.MonkeyPatch) -> None:
+ """A blank primary response is treated as failure and triggers the run-model
+ fallback, exactly as a raised exception would."""
+ built: list = []
+ monkeypatch.setattr("deerflow.agents.middlewares.summarization_middleware.create_chat_model", _tracking_create_chat_model(built))
+
+ explicit = _blank_model() # primary returns whitespace
+ middleware = DeerFlowSummarizationMiddleware(
+ model=explicit,
+ trigger=("messages", 4),
+ keep=("messages", 2),
+ token_counter=len,
+ app_config=_app_config(("default-model", "run-model")),
+ configured_model_name="summary-model",
+ run_model_name="run-model",
+ )
+
+ result = middleware.before_model({"messages": _messages()}, _runtime())
+
+ explicit.invoke.assert_called_once() # blank primary counted as a failure
+ assert built == ["run-model"] # fallback built + used
+ assert result is not None
+ assert result["summary_text"] == "from-run-model"
+
+
+def test_manual_compaction_failure_raises_summary_generation_error(monkeypatch: pytest.MonkeyPatch) -> None:
+ """A manual /compact opts into ``raise_on_failure`` so a generation failure raises,
+ letting the caller report it distinctly from "nothing to compact". This is now
+ decoupled from ``force`` (which only bypasses the trigger threshold)."""
+ default_model = MagicMock()
+ default_model.with_config.return_value = default_model
+ default_model.invoke.side_effect = RuntimeError("provider down")
+ middleware = DeerFlowSummarizationMiddleware(
+ model=default_model,
+ trigger=("messages", 4),
+ keep=("messages", 2),
+ token_counter=len,
+ app_config=_app_config(("default-model",)),
+ configured_model_name=None,
+ run_model_name="default-model",
+ )
+
+ with pytest.raises(SummaryGenerationError):
+ middleware.compact_state({"messages": _messages()}, _runtime(), force=True, raise_on_failure=True)
+
+
+def test_force_alone_does_not_raise_on_failure(monkeypatch: pytest.MonkeyPatch) -> None:
+ """``force`` bypasses the threshold but must NOT, on its own, raise on a generation
+ failure — only ``raise_on_failure`` does. The automatic path (force + no
+ raise_on_failure) leaves state unchanged."""
+ default_model = MagicMock()
+ default_model.with_config.return_value = default_model
+ default_model.invoke.side_effect = RuntimeError("provider down")
+ middleware = DeerFlowSummarizationMiddleware(
+ model=default_model,
+ trigger=("messages", 4),
+ keep=("messages", 2),
+ token_counter=len,
+ app_config=_app_config(("default-model",)),
+ configured_model_name=None,
+ run_model_name="default-model",
+ )
+
+ assert middleware.compact_state({"messages": _messages()}, _runtime(), force=True) is None
+
+
+def test_before_summarization_hook_not_fired_when_summary_fails(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Hooks must not enqueue durable-memory work for a summary that never
+ materializes; on failure the automatic path returns None without firing hooks."""
+ default_model = MagicMock()
+ default_model.with_config.return_value = default_model
+ default_model.invoke.side_effect = RuntimeError("provider down")
+ captured: list[SummarizationEvent] = []
+ middleware = DeerFlowSummarizationMiddleware(
+ model=default_model,
+ trigger=("messages", 4),
+ keep=("messages", 2),
+ token_counter=len,
+ app_config=_app_config(("default-model",)),
+ configured_model_name=None,
+ run_model_name="default-model",
+ before_summarization=[captured.append],
+ )
+
+ # The run uses the default model, which fails; no distinct fallback in the null case.
+ assert middleware.before_model({"messages": _messages()}, _runtime()) is None
+ assert captured == []
+
+
+def _factory_app_config(model_names, *, summary_model_name=None):
+ """AppConfig-shaped stub for the factory: summarization enabled + ordered models."""
+ models = [SimpleNamespace(name=name) for name in model_names]
+ return SimpleNamespace(
+ summarization=SummarizationConfig(enabled=True, model_name=summary_model_name),
+ memory=MemoryConfig(enabled=False),
+ models=models,
+ get_model_config=lambda name: next((model for model in models if model.name == name), None),
+ )
+
+
+def test_factory_null_case_anchor_is_run_model_not_models0(monkeypatch):
+ """model_name: null builds the summary model from ``run_model_name``, never
+ config.models[0]. A run on a non-default model whose models[0] provider is broken
+ still gets a working summarization middleware — the factory has no eager models[0]
+ dependency."""
+ built: list = []
+ monkeypatch.setattr("deerflow.agents.middlewares.summarization_middleware.create_chat_model", _tracking_create_chat_model(built))
+
+ middleware = create_summarization_middleware(
+ app_config=_factory_app_config(("models0", "run-model")),
+ keep=("messages", 2),
+ run_model_name="run-model",
+ )
+
+ assert middleware is not None
+ assert built == ["run-model"] # anchor built from the run model, not models0 / None
+ result = middleware.compact_state({"messages": _messages()}, _runtime(), force=True)
+ assert result is not None
+ assert result.summary_text == "from-run-model"
+
+
+def test_factory_configured_constructor_failure_falls_back_to_run_model(monkeypatch):
+ """A configured summary model whose *constructor* raises must not break middleware
+ creation or skip the healthy run model. The anchor falls through the broken
+ constructor to the run model, and generation summarizes with it (the reviewer's
+ ``configured='broken-summary'`` reproduction)."""
+ built: list = []
+
+ def _factory(*, name=None, thinking_enabled=False, app_config=None, attach_tracing=True, **kwargs):
+ if name == "broken-summary":
+ raise RuntimeError("cannot construct summary provider")
+ model = MagicMock()
+ model.with_config.return_value = model
+ model.invoke.return_value = SimpleNamespace(text=f"from-{name}")
+ model.ainvoke = AsyncMock(return_value=SimpleNamespace(text=f"from-{name}"))
+ built.append(name)
+ return model
+
+ monkeypatch.setattr("deerflow.agents.middlewares.summarization_middleware.create_chat_model", _factory)
+
+ middleware = create_summarization_middleware(
+ app_config=_factory_app_config(("models0", "run-model"), summary_model_name="broken-summary"),
+ keep=("messages", 2),
+ run_model_name="run-model",
+ )
+
+ assert middleware is not None # a broken configured constructor did not break creation
+ assert "run-model" in built # the healthy run model was constructed as the anchor
+ result = middleware.compact_state({"messages": _messages()}, _runtime(), force=True)
+ assert result is not None
+ assert result.summary_text == "from-run-model"
+
+
+class _RaisingTextResponse:
+ """A provider response whose ``.text`` accessor fails — a realistic malformed result."""
+
+ @property
+ def text(self):
+ raise ValueError("malformed content blocks")
+
+
+def _text_raises_model() -> MagicMock:
+ model = MagicMock()
+ model.with_config.return_value = model
+ model.invoke.return_value = _RaisingTextResponse()
+ model.ainvoke = AsyncMock(return_value=_RaisingTextResponse())
+ return model
+
+
+def test_text_extraction_failure_falls_back_to_run_model(monkeypatch):
+ """A response whose ``.text`` accessor raises is part of consuming the provider
+ result, so it must be a candidate failure that falls back to the run model — not an
+ exception that escapes automatic compaction."""
+ built: list = []
+ monkeypatch.setattr("deerflow.agents.middlewares.summarization_middleware.create_chat_model", _tracking_create_chat_model(built))
+
+ primary = _text_raises_model()
+ middleware = DeerFlowSummarizationMiddleware(
+ model=primary,
+ trigger=("messages", 4),
+ keep=("messages", 2),
+ token_counter=len,
+ app_config=_app_config(("default-model", "run-model")),
+ configured_model_name="summary-model",
+ run_model_name="run-model",
+ )
+
+ result = middleware.before_model({"messages": _messages()}, _runtime())
+
+ primary.invoke.assert_called_once() # primary invoked; its .text raised
+ assert built == ["run-model"] # fell back to the run model
+ assert result is not None
+ assert result["summary_text"] == "from-run-model"
+
+
+@pytest.mark.anyio
+async def test_text_extraction_failure_falls_back_to_run_model_async(monkeypatch):
+ """Async counterpart: a ``.text`` accessor failure falls back to the run model."""
+ built: list = []
+ monkeypatch.setattr("deerflow.agents.middlewares.summarization_middleware.create_chat_model", _tracking_create_chat_model(built))
+
+ primary = _text_raises_model()
+ middleware = DeerFlowSummarizationMiddleware(
+ model=primary,
+ trigger=("messages", 4),
+ keep=("messages", 2),
+ token_counter=len,
+ app_config=_app_config(("default-model", "run-model")),
+ configured_model_name="summary-model",
+ run_model_name="run-model",
+ )
+
+ result = await middleware.abefore_model({"messages": _messages()}, _runtime())
+
+ primary.ainvoke.assert_awaited_once()
+ assert built == ["run-model"]
+ assert result is not None
+ assert result["summary_text"] == "from-run-model"
diff --git a/backend/tests/test_tool_error_handling_middleware.py b/backend/tests/test_tool_error_handling_middleware.py
index 4873fcc1b..71a6283eb 100644
--- a/backend/tests/test_tool_error_handling_middleware.py
+++ b/backend/tests/test_tool_error_handling_middleware.py
@@ -681,10 +681,11 @@ def test_subagent_runtime_middlewares_attach_durable_context_before_summarizatio
sentinel = object()
captured: dict[str, object] = {}
- def fake_create_summarization_middleware(*, app_config=None, keep=None, skip_memory_flush=False):
+ def fake_create_summarization_middleware(*, app_config=None, keep=None, skip_memory_flush=False, run_model_name=None):
captured["app_config"] = app_config
captured["keep"] = keep
captured["skip_memory_flush"] = skip_memory_flush
+ captured["run_model_name"] = run_model_name
return sentinel
# summarization is enabled by default False; flip it on so the factory path
@@ -703,6 +704,10 @@ def test_subagent_runtime_middlewares_attach_durable_context_before_summarizatio
# skip_memory_flush=True so subagent-internal turns are not flushed into the
# PARENT thread's durable memory (#3875 Phase 3 review).
assert captured["skip_memory_flush"] is True
+ # Model ownership: the subagent's own resolved model is threaded into the factory
+ # so a distinct-model subagent summarizes with its model, not the parent's — the
+ # subagent context/configurable never carries the child model.
+ assert captured["run_model_name"] == "test-model"
durable = [middleware for middleware in middlewares if isinstance(middleware, DurableContextMiddleware)]
assert len(durable) == 1
# ``_skills_root`` is ``posixpath.normpath(container_path)``, so compare against
diff --git a/config.example.yaml b/config.example.yaml
index 3df21fb18..a21d8ab3b 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -1522,7 +1522,11 @@ title:
summarization:
enabled: true
- # Model to use for summarization (null = use default model)
+ # Model to use for summarization.
+ # null = summarize with the model the run actually uses (the lead run's model, a
+ # subagent's own model, or a thread's custom-agent model), NOT models[0].
+ # set = that model generates; if its provider fails, compaction falls back to the
+ # run's own model so a broken summary provider cannot disable compaction.
# Recommended: Use a lightweight, cost-effective model like "gpt-4o-mini" or similar
model_name: null
diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx
index 4f8f438b1..7d122fc47 100644
--- a/frontend/src/components/workspace/input-box.tsx
+++ b/frontend/src/components/workspace/input-box.tsx
@@ -1001,6 +1001,8 @@ export function InputBox({
signal,
agentName:
typeof context.agent_name === "string" ? context.agent_name : null,
+ modelName:
+ typeof context.model_name === "string" ? context.model_name : null,
});
if (
!isCurrentGoalRequest(compactRequestStateRef.current, request, threadId)
@@ -1039,6 +1041,7 @@ export function InputBox({
}
}, [
context.agent_name,
+ context.model_name,
queryClient,
t.inputBox.compactFailed,
t.inputBox.compactSkipped,
diff --git a/frontend/src/core/threads/api.ts b/frontend/src/core/threads/api.ts
index 67b311d37..83ff52077 100644
--- a/frontend/src/core/threads/api.ts
+++ b/frontend/src/core/threads/api.ts
@@ -17,6 +17,7 @@ export type ThreadCompactResponse = {
export type CompactThreadContextOptions = {
signal?: AbortSignal;
agentName?: string | null;
+ modelName?: string | null;
};
export type ThreadBranchResponse = {
@@ -110,6 +111,7 @@ export async function compactThreadContext(
body: JSON.stringify({
force: true,
...(options.agentName ? { agent_name: options.agentName } : {}),
+ ...(options.modelName ? { model_name: options.modelName } : {}),
}),
signal: options.signal,
},
From 7ea8eac44574c6d2246e1528c1bd68548e52ec4e Mon Sep 17 00:00:00 2001
From: Aari
Date: Sun, 26 Jul 2026 07:44:40 +0800
Subject: [PATCH 09/35] test(frontend): run hook-level tests in a DOM
environment (#4453)
Frontend unit tests run in a plain node environment, so nothing that
renders can be covered and no hook can be tested under real React. The
workaround that forces is already in the tree: the use-global-shortcuts
test mocks out react itself, so its useEffect never re-runs on a
dependency change and never goes through React's commit/cleanup
ordering -- it pins the stub rather than the hook.
Split the suite into two rstest projects: *.test.ts(x) keeps running on
node, *.dom.test.ts(x) runs on happy-dom. A global DOM environment was
measured first and rejected -- it takes the same 743 tests from 3.3s to
9.5s -- and rstest has no per-file environment docblock, so projects is
the only way to charge that cost to the tests that need it.
useIsMobile is the first consumer: it had no tests and cannot have any
without a document, since it reads window.matchMedia.
---
frontend/AGENTS.md | 2 +
frontend/package.json | 3 +
frontend/pnpm-lock.yaml | 166 +++++++++++++++++-
frontend/rstest.config.ts | 25 ++-
.../tests/unit/hooks/use-mobile.dom.test.ts | 82 +++++++++
5 files changed, 274 insertions(+), 4 deletions(-)
create mode 100644 frontend/tests/unit/hooks/use-mobile.dom.test.ts
diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md
index 19944de56..ca6c2ca3c 100644
--- a/frontend/AGENTS.md
+++ b/frontend/AGENTS.md
@@ -32,6 +32,8 @@ DeerFlow Frontend is a Next.js 16 web interface for an AI agent system. It commu
Unit tests live under `tests/unit/` and mirror the `src/` layout (e.g., `tests/unit/core/api/stream-mode.test.ts` tests `src/core/api/stream-mode.ts`). Powered by Rstest; import source modules via the `@/` path alias.
+Rstest runs them as two projects (`rstest.config.ts`). `*.test.ts` / `*.test.tsx` run in a plain **node** environment — that is nearly the whole suite, and it is the default for anything that is pure logic. `*.dom.test.ts` / `*.dom.test.tsx` run in **happy-dom**, for tests that need a document: hooks driven through `renderHook` from `@testing-library/react`, and components. Keep the split — a DOM environment costs roughly 3x the runtime of the node suite, so tests that do not render should not opt into it. A hook whose behavior only exists under real React (effect ordering, cleanup on unmount, re-render on store change) belongs in a `.dom.test.*` file rather than a node test that mocks `react` itself.
+
E2E tests live under `tests/e2e/` and use Playwright with Chromium. They mock all backend APIs via `page.route()` network interception and test real page interactions (navigation, chat input, streaming responses). Config: `playwright.config.ts`.
## Architecture
diff --git a/frontend/package.json b/frontend/package.json
index f0297280c..bbb8e579a 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -102,12 +102,15 @@
"@rsbuild/plugin-react": "^2.1.0",
"@rstest/core": "^0.10.6",
"@tailwindcss/postcss": "^4.0.15",
+ "@testing-library/dom": "^10.4.1",
+ "@testing-library/react": "^16.3.2",
"@types/gsap": "^3.0.0",
"@types/node": "^20.14.10",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"eslint": "^9.23.0",
"eslint-config-next": "^15.2.3",
+ "happy-dom": "^20.11.1",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index a79934bda..6922567d9 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -248,10 +248,16 @@ importers:
version: 2.1.0(@rsbuild/core@2.0.15)(@rspack/core@2.0.8(@swc/helpers@0.5.23))
'@rstest/core':
specifier: ^0.10.6
- version: 0.10.6
+ version: 0.10.6(happy-dom@20.11.1)
'@tailwindcss/postcss':
specifier: ^4.0.15
version: 4.1.18
+ '@testing-library/dom':
+ specifier: ^10.4.1
+ version: 10.4.1
+ '@testing-library/react':
+ specifier: ^16.3.2
+ version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@types/gsap':
specifier: ^3.0.0
version: 3.0.0
@@ -270,6 +276,9 @@ importers:
eslint-config-next:
specifier: ^15.2.3
version: 15.5.12(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ happy-dom:
+ specifier: ^20.11.1
+ version: 20.11.1
postcss:
specifier: ^8.5.3
version: 8.5.6
@@ -317,6 +326,10 @@ packages:
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
+ '@babel/code-frame@7.29.7':
+ resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
+ engines: {node: '>=6.9.0'}
+
'@babel/helper-string-parser@7.29.7':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
@@ -2207,6 +2220,25 @@ packages:
'@tanstack/virtual-core@3.13.23':
resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==}
+ '@testing-library/dom@10.4.1':
+ resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
+ engines: {node: '>=18'}
+
+ '@testing-library/react@16.3.2':
+ resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@testing-library/dom': ^10.0.0
+ '@types/react': ^18.0.0 || ^19.0.0
+ '@types/react-dom': ^18.0.0 || ^19.0.0
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@theguild/remark-mermaid@0.3.0':
resolution: {integrity: sha512-Fy1J4FSj8totuHsHFpaeWyWRaRSIvpzGTRoEfnNJc1JmLV9uV70sYE3zcT+Jj5Yw20Xq4iCsiT+3Ho49BBZcBQ==}
peerDependencies:
@@ -2233,6 +2265,9 @@ packages:
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
+ '@types/aria-query@5.0.4':
+ resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
+
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@@ -2398,6 +2433,12 @@ packages:
'@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
+ '@types/whatwg-mimetype@3.0.2':
+ resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
+
+ '@types/ws@8.18.1':
+ resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
+
'@typescript-eslint/eslint-plugin@8.55.0':
resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -2683,6 +2724,10 @@ packages:
ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
+ ansi-regex@5.0.1:
+ resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
+ engines: {node: '>=8'}
+
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
@@ -2705,6 +2750,9 @@ packages:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
+ aria-query@5.3.0:
+ resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
+
aria-query@5.3.2:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'}
@@ -2815,6 +2863,10 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
+ buffer-image-size@0.6.4:
+ resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==}
+ engines: {node: '>=4.0'}
+
c12@3.3.3:
resolution: {integrity: sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==}
peerDependencies:
@@ -3249,6 +3301,9 @@ packages:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
+ dom-accessibility-api@0.5.16:
+ resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
+
dompurify@3.3.1:
resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==}
@@ -3690,6 +3745,10 @@ packages:
hachure-fill@0.5.2:
resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
+ happy-dom@20.11.1:
+ resolution: {integrity: sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==}
+ engines: {node: '>=20.0.0'}
+
has-bigints@1.1.0:
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
engines: {node: '>= 0.4'}
@@ -4204,6 +4263,10 @@ packages:
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ lz-string@1.5.0:
+ resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
+ hasBin: true
+
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -4871,6 +4934,10 @@ packages:
engines: {node: '>=14'}
hasBin: true
+ pretty-format@27.5.1:
+ resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
+ engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+
pretty-ms@9.3.0:
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
engines: {node: '>=18'}
@@ -4910,6 +4977,9 @@ packages:
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
+ react-is@17.0.2:
+ resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
+
react-medium-image-zoom@5.4.1:
resolution: {integrity: sha512-DD2iZYaCfAwiQGR8AN62r/cDJYoXhezlYJc5HY4TzBUGuGge43CptG0f7m0PEIM72aN6GfpjohvY1yYdtCJB7g==}
peerDependencies:
@@ -5736,6 +5806,10 @@ packages:
webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
+ whatwg-mimetype@3.0.0:
+ resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
+ engines: {node: '>=12'}
+
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@@ -5764,6 +5838,18 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
+ ws@8.21.1:
+ resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==}
+ engines: {node: '>=10.0.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: '>=5.0.2'
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+
yaml@2.8.3:
resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==}
engines: {node: '>= 14.6'}
@@ -5852,6 +5938,12 @@ snapshots:
package-manager-detector: 1.6.0
tinyexec: 1.0.2
+ '@babel/code-frame@7.29.7':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.29.7
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
'@babel/helper-string-parser@7.29.7': {}
'@babel/helper-validator-identifier@7.29.7': {}
@@ -7540,10 +7632,12 @@ snapshots:
optionalDependencies:
'@rspack/core': 2.0.8(@swc/helpers@0.5.23)
- '@rstest/core@0.10.6':
+ '@rstest/core@0.10.6(happy-dom@20.11.1)':
dependencies:
'@rsbuild/core': 2.0.15
'@types/chai': 5.2.3
+ optionalDependencies:
+ happy-dom: 20.11.1
transitivePeerDependencies:
- '@module-federation/runtime-tools'
- core-js
@@ -7754,6 +7848,27 @@ snapshots:
'@tanstack/virtual-core@3.13.23': {}
+ '@testing-library/dom@10.4.1':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/runtime': 7.28.6
+ '@types/aria-query': 5.0.4
+ aria-query: 5.3.0
+ dom-accessibility-api: 0.5.16
+ lz-string: 1.5.0
+ picocolors: 1.1.1
+ pretty-format: 27.5.1
+
+ '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ '@babel/runtime': 7.28.6
+ '@testing-library/dom': 10.4.1
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+ optionalDependencies:
+ '@types/react': 19.2.13
+ '@types/react-dom': 19.2.3(@types/react@19.2.13)
+
'@theguild/remark-mermaid@0.3.0(react@19.2.4)':
dependencies:
mermaid: 11.12.2
@@ -7791,6 +7906,8 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@types/aria-query@5.0.4': {}
+
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
@@ -7976,6 +8093,12 @@ snapshots:
'@types/uuid@10.0.0': {}
+ '@types/whatwg-mimetype@3.0.2': {}
+
+ '@types/ws@8.18.1':
+ dependencies:
+ '@types/node': 20.19.33
+
'@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -8315,6 +8438,8 @@ snapshots:
json-schema-traverse: 0.4.1
uri-js: 4.4.1
+ ansi-regex@5.0.1: {}
+
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
@@ -8334,6 +8459,10 @@ snapshots:
dependencies:
tslib: 2.8.1
+ aria-query@5.3.0:
+ dependencies:
+ dequal: 2.0.3
+
aria-query@5.3.2: {}
array-buffer-byte-length@1.0.2:
@@ -8457,6 +8586,10 @@ snapshots:
dependencies:
fill-range: 7.1.1
+ buffer-image-size@0.6.4:
+ dependencies:
+ '@types/node': 20.19.33
+
c12@3.3.3:
dependencies:
chokidar: 5.0.0
@@ -8915,6 +9048,8 @@ snapshots:
dependencies:
esutils: 2.0.3
+ dom-accessibility-api@0.5.16: {}
+
dompurify@3.3.1:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -9561,6 +9696,19 @@ snapshots:
hachure-fill@0.5.2: {}
+ happy-dom@20.11.1:
+ dependencies:
+ '@types/node': 20.19.33
+ '@types/whatwg-mimetype': 3.0.2
+ '@types/ws': 8.18.1
+ buffer-image-size: 0.6.4
+ entities: 7.0.1
+ whatwg-mimetype: 3.0.0
+ ws: 8.21.1
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
has-bigints@1.1.0: {}
has-flag@4.0.0: {}
@@ -10113,6 +10261,8 @@ snapshots:
dependencies:
react: 19.2.4
+ lz-string@1.5.0: {}
+
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -11121,6 +11271,12 @@ snapshots:
prettier@3.8.1: {}
+ pretty-format@27.5.1:
+ dependencies:
+ ansi-regex: 5.0.1
+ ansi-styles: 5.2.0
+ react-is: 17.0.2
+
pretty-ms@9.3.0:
dependencies:
parse-ms: 4.0.0
@@ -11160,6 +11316,8 @@ snapshots:
react-is@16.13.1: {}
+ react-is@17.0.2: {}
+
react-medium-image-zoom@5.4.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
react: 19.2.4
@@ -12196,6 +12354,8 @@ snapshots:
webpack-virtual-modules@0.6.2: {}
+ whatwg-mimetype@3.0.0: {}
+
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0
@@ -12245,6 +12405,8 @@ snapshots:
word-wrap@1.2.5: {}
+ ws@8.21.1: {}
+
yaml@2.8.3: {}
yocto-queue@0.1.0: {}
diff --git a/frontend/rstest.config.ts b/frontend/rstest.config.ts
index 1e9cded4d..a67b270ff 100644
--- a/frontend/rstest.config.ts
+++ b/frontend/rstest.config.ts
@@ -3,7 +3,9 @@ import { resolve } from "path";
import { pluginReact } from "@rsbuild/plugin-react";
import { defineConfig } from "@rstest/core";
-export default defineConfig({
+// Build wiring is identical across projects; only the environment and which
+// files each one claims differ.
+const shared = {
plugins: [pluginReact()],
resolve: {
alias: {
@@ -15,5 +17,24 @@ export default defineConfig({
// Rsbuild processes that CSS import instead of Node trying to load it.
bundleDependencies: ["streamdown", "katex"],
},
- include: ["tests/unit/**/*.test.ts"],
+};
+
+export default defineConfig({
+ projects: [
+ {
+ ...shared,
+ name: "node",
+ include: ["tests/unit/**/*.test.ts", "tests/unit/**/*.test.tsx"],
+ // A DOM environment costs roughly 3x the runtime of this suite, so the
+ // pure-logic tests that make up nearly all of it stay on node and only
+ // `*.dom.test.*` pays for a document.
+ exclude: { patterns: ["**/*.dom.test.*"], override: false },
+ },
+ {
+ ...shared,
+ name: "dom",
+ testEnvironment: "happy-dom",
+ include: ["tests/unit/**/*.dom.test.ts", "tests/unit/**/*.dom.test.tsx"],
+ },
+ ],
});
diff --git a/frontend/tests/unit/hooks/use-mobile.dom.test.ts b/frontend/tests/unit/hooks/use-mobile.dom.test.ts
new file mode 100644
index 000000000..f2ef77040
--- /dev/null
+++ b/frontend/tests/unit/hooks/use-mobile.dom.test.ts
@@ -0,0 +1,82 @@
+import { afterEach, describe, expect, it } from "@rstest/core";
+import { act, cleanup, renderHook } from "@testing-library/react";
+
+import { useIsMobile } from "@/hooks/use-mobile";
+
+type HappyDOMWindow = typeof window & {
+ happyDOM: { setViewport: (viewport: { width?: number }) => void };
+};
+
+function setViewportWidth(width: number) {
+ (window as HappyDOMWindow).happyDOM.setViewport({ width });
+}
+
+afterEach(() => {
+ cleanup();
+ setViewportWidth(1024);
+});
+
+describe("useIsMobile", () => {
+ it("reports the viewport it mounted into", () => {
+ setViewportWidth(1024);
+ expect(renderHook(() => useIsMobile()).result.current).toBe(false);
+ cleanup();
+
+ setViewportWidth(500);
+ expect(renderHook(() => useIsMobile()).result.current).toBe(true);
+ });
+
+ it("re-renders when the viewport crosses the breakpoint", () => {
+ setViewportWidth(1024);
+ const { result } = renderHook(() => useIsMobile());
+ expect(result.current).toBe(false);
+
+ act(() => setViewportWidth(500));
+ expect(result.current).toBe(true);
+
+ act(() => setViewportWidth(1024));
+ expect(result.current).toBe(false);
+ });
+
+ it("switches at 768px", () => {
+ setViewportWidth(767);
+ expect(renderHook(() => useIsMobile()).result.current).toBe(true);
+ cleanup();
+
+ setViewportWidth(768);
+ expect(renderHook(() => useIsMobile()).result.current).toBe(false);
+ });
+
+ it("drops its media-query listener on unmount", () => {
+ // The subscribe callback creates its own MediaQueryList, so the only way to
+ // observe the leak is to count through the factory it calls.
+ const nativeMatchMedia = window.matchMedia.bind(window);
+ let added = 0;
+ let removed = 0;
+ window.matchMedia = (query: string) => {
+ const mql = nativeMatchMedia(query);
+ const nativeAdd = mql.addEventListener.bind(mql);
+ const nativeRemove = mql.removeEventListener.bind(mql);
+ mql.addEventListener = ((...args: Parameters) => {
+ added += 1;
+ return nativeAdd(...args);
+ }) as typeof mql.addEventListener;
+ mql.removeEventListener = ((...args: Parameters) => {
+ removed += 1;
+ return nativeRemove(...args);
+ }) as typeof mql.removeEventListener;
+ return mql;
+ };
+
+ try {
+ const { unmount } = renderHook(() => useIsMobile());
+ expect(added).toBeGreaterThan(0);
+ expect(removed).toBe(0);
+
+ unmount();
+ expect(removed).toBe(added);
+ } finally {
+ window.matchMedia = nativeMatchMedia;
+ }
+ });
+});
From 68797c57592d7ac7f243d50883a987b9e11c4962 Mon Sep 17 00:00:00 2001
From: Aari
Date: Sun, 26 Jul 2026 08:00:48 +0800
Subject: [PATCH 10/35] fix(gateway): scope branch history seed run ids per
inherited turn (#4459)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Branch creation seeds the new thread's run-event feed from its checkpoint
so inherited history survives the first run (#4380). Every seeded row
carried one shared run id, but run_id is a *turn* identity to the feed's
consumers, not a provenance tag: regenerating the inherited answer
resolves that row's run id as the superseded source, and
GET /messages/page then drops every row carrying it. One shared id for
the whole seed therefore deleted the complete inherited history on a
branch's first regenerate, leaving only the regenerated turn.
Group seeded rows into one synthetic run per inherited turn
(branch-seed-{thread_id}-{n}), a new turn opening at each persisted human
message — the same boundary a real run has, including the allowlisted
hidden ask_clarification reply, which resumes as its own run. Supersession
is then confined to the turn actually regenerated.
Co-authored-by: Willem Jiang
---
backend/AGENTS.md | 2 +-
backend/app/gateway/routers/threads.py | 2 +-
.../harness/deerflow/runtime/journal.py | 19 +++++-
backend/tests/test_branch_history_seed.py | 58 ++++++++++++++++++-
backend/tests/test_thread_messages_page.py | 41 +++++++++++++
backend/tests/test_threads_router.py | 3 +-
6 files changed, 118 insertions(+), 7 deletions(-)
diff --git a/backend/AGENTS.md b/backend/AGENTS.md
index f460fc239..73caca7c8 100644
--- a/backend/AGENTS.md
+++ b/backend/AGENTS.md
@@ -427,7 +427,7 @@ Localhost persistence deliberately reads the direct request `Host` and ignores `
| **Skills** (`/api/skills`) | `GET /` - list skills; `GET /{name}` - details; `PUT /{name}` - update enabled; `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`); `POST /reload` - admin-only process-local prompt-cache invalidation after trusted external filesystem changes |
| **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data |
| **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete |
-| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; `POST /branches` - create a new main-thread branch from a completed assistant turn checkpoint and, when an addressable pre-user replay checkpoint exists, materialize it into the branch namespace so the inherited response remains regeneratable. Workspace files are not checkpointed, so the branch only best-effort copies the current workspace when branching from the **latest** turn (`workspace_clone_mode="current_thread_best_effort"`); branching from an older/historical turn skips the copy (`workspace_clone_mode="skipped_historical_turn"`) so the branch never inherits files that only exist in a later timeline. Branch creation also seeds the new thread's run-event feed from the branch checkpoint's visible messages (`history_seed_mode` in the response): the thread feed reads run_events, not checkpoints, so without the seed the inherited history disappears from the UI after the branch's first run (#4380); `GET /goal`, `PUT /goal`, `DELETE /goal` - read, set, and clear the active thread goal; `POST /compact` - manually summarize older active context into `summary_text` and retain the recent message window, blocked while a run is in flight; unexpected failures are logged server-side and return a generic 500 detail |
+| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; `POST /branches` - create a new main-thread branch from a completed assistant turn checkpoint and, when an addressable pre-user replay checkpoint exists, materialize it into the branch namespace so the inherited response remains regeneratable. Workspace files are not checkpointed, so the branch only best-effort copies the current workspace when branching from the **latest** turn (`workspace_clone_mode="current_thread_best_effort"`); branching from an older/historical turn skips the copy (`workspace_clone_mode="skipped_historical_turn"`) so the branch never inherits files that only exist in a later timeline. Branch creation also seeds the new thread's run-event feed from the branch checkpoint's visible messages (`history_seed_mode` in the response): the thread feed reads run_events, not checkpoints, so without the seed the inherited history disappears from the UI after the branch's first run (#4380). Seeded rows are grouped into one synthetic run per inherited turn (`branch-seed-{thread_id}-{n}`, a new turn opening at every persisted human message, including an allowlisted hidden `ask_clarification` reply) because `run_id` is a turn identity to the feed's consumers, not a provenance tag: regenerating an inherited answer supersedes that row's whole `run_id` in `GET /messages/page`, so one shared id for the entire seed deleted the complete inherited history on a branch's first regenerate (#4458); `GET /goal`, `PUT /goal`, `DELETE /goal` - read, set, and clear the active thread goal; `POST /compact` - manually summarize older active context into `summary_text` and retain the recent message window, blocked while a run is in flight; unexpected failures are logged server-side and return a generic 500 detail |
| **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types |
| **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)`) |
diff --git a/backend/app/gateway/routers/threads.py b/backend/app/gateway/routers/threads.py
index b5ba42527..0e323a18b 100644
--- a/backend/app/gateway/routers/threads.py
+++ b/backend/app/gateway/routers/threads.py
@@ -888,7 +888,7 @@ async def branch_thread(thread_id: str, body: ThreadBranchRequest, request: Requ
seed_events = build_branch_history_seed_events(
_checkpoint_messages(snapshot),
thread_id=new_thread_id,
- run_id=f"branch-seed-{new_thread_id}",
+ run_id_prefix=f"branch-seed-{new_thread_id}",
parent_thread_id=thread_id,
)
if seed_events:
diff --git a/backend/packages/harness/deerflow/runtime/journal.py b/backend/packages/harness/deerflow/runtime/journal.py
index bf5706a33..2112fcffc 100644
--- a/backend/packages/harness/deerflow/runtime/journal.py
+++ b/backend/packages/harness/deerflow/runtime/journal.py
@@ -91,7 +91,7 @@ def build_branch_history_seed_events(
messages: Sequence[Any],
*,
thread_id: str,
- run_id: str,
+ run_id_prefix: str,
parent_thread_id: str,
) -> list[dict]:
"""Serialize a branch checkpoint's messages into run-event message rows.
@@ -104,6 +104,18 @@ def build_branch_history_seed_events(
checkpoint snapshot the branch was created from keeps the feed
consistent with what the branch actually contains.
+ Rows are grouped into one synthetic run per inherited turn
+ (``{run_id_prefix}-{n}``), a new turn starting at every persisted human
+ message — the same boundary a real run has, since a run begins with a
+ human input (including the allowlisted hidden ``ask_clarification``
+ reply, which resumes as its own run). ``run_id`` is a *turn* identity to
+ the feed's consumers, not merely a provenance tag: regenerating the last
+ inherited answer resolves that row's ``run_id`` as the superseded source
+ (``_find_target_run_id``) and ``GET /messages/page`` then drops **every**
+ row carrying it. One shared id for the whole seed therefore deleted the
+ complete inherited history on the branch's first regenerate (#4458); one
+ id per turn confines the drop to the turn actually regenerated.
+
Mirrors RunJournal's message-event contract so seeded rows are
indistinguishable from journaled ones except by the ``branch_seed``
marker: same event types, ``category="message"``, ``content=
@@ -125,6 +137,8 @@ def build_branch_history_seed_events(
events: list[dict] = []
created_at = datetime.now(UTC).isoformat()
seed_metadata = {"branch_seed": True, "branch_parent_thread_id": parent_thread_id}
+ # Messages ahead of the first human turn (none in practice) stay in turn 0.
+ turn_index = 0
for raw_message in messages:
message = _coerce_seed_message(raw_message)
if not isinstance(message, BaseMessage):
@@ -132,6 +146,7 @@ def build_branch_history_seed_events(
if isinstance(message, HumanMessage):
if not _should_persist_human_input_message(message):
continue
+ turn_index += 1
event_type = "llm.human.input"
content = restore_original_human_message(message).model_dump()
metadata: dict[str, Any] = {"caller": "lead_agent", **seed_metadata}
@@ -149,7 +164,7 @@ def build_branch_history_seed_events(
events.append(
{
"thread_id": thread_id,
- "run_id": run_id,
+ "run_id": f"{run_id_prefix}-{turn_index}",
"event_type": event_type,
"category": "message",
"content": content,
diff --git a/backend/tests/test_branch_history_seed.py b/backend/tests/test_branch_history_seed.py
index 4d391910d..c5ca0de88 100644
--- a/backend/tests/test_branch_history_seed.py
+++ b/backend/tests/test_branch_history_seed.py
@@ -19,7 +19,7 @@ def _seed(messages):
return build_branch_history_seed_events(
messages,
thread_id="branch-thread",
- run_id="branch-seed-branch-thread",
+ run_id_prefix="branch-seed-branch-thread",
parent_thread_id="parent-thread",
)
@@ -36,7 +36,7 @@ def test_seed_serializes_visible_history_in_order() -> None:
assert [event["event_type"] for event in events] == ["llm.human.input", "llm.ai.response", "llm.tool.result"]
assert all(event["category"] == "message" for event in events)
assert all(event["thread_id"] == "branch-thread" for event in events)
- assert all(event["run_id"] == "branch-seed-branch-thread" for event in events)
+ assert all(event["run_id"] == "branch-seed-branch-thread-1" for event in events)
assert [event["content"]["id"] for event in events] == ["h1", "a1", "t1"]
# Human/AI rows carry the journal's caller tag; every row carries the
# seed provenance marker.
@@ -159,6 +159,60 @@ def test_seed_restores_original_user_content() -> None:
assert "original_user_content" not in events[0]["content"]["additional_kwargs"]
+def test_seed_scopes_one_synthetic_run_per_inherited_turn() -> None:
+ """``run_id`` is a turn identity to the feed, not a provenance tag: the
+ regenerate path supersedes a whole ``run_id``, so packing every inherited
+ turn into one id deleted the complete history on the first regenerate
+ (#4458). Each human message opens the next synthetic run."""
+ events = _seed(
+ [
+ HumanMessage(id="h1", content="turn one"),
+ AIMessage(id="a1", content="answer one"),
+ HumanMessage(id="h2", content="turn two"),
+ AIMessage(id="a2", content="calling a tool", tool_calls=[{"name": "search", "args": {}, "id": "call-1", "type": "tool_call"}]),
+ ToolMessage(id="t2", content="tool output", tool_call_id="call-1"),
+ AIMessage(id="a2b", content="answer two"),
+ ]
+ )
+
+ assert [(event["content"]["id"], event["run_id"]) for event in events] == [
+ ("h1", "branch-seed-branch-thread-1"),
+ ("a1", "branch-seed-branch-thread-1"),
+ ("h2", "branch-seed-branch-thread-2"),
+ ("a2", "branch-seed-branch-thread-2"),
+ ("t2", "branch-seed-branch-thread-2"),
+ ("a2b", "branch-seed-branch-thread-2"),
+ ]
+
+
+def test_seed_opens_a_new_turn_at_a_hidden_clarification_reply() -> None:
+ """An answered clarification resumes as its own run, so the reply is a turn
+ boundary exactly like a visible user message."""
+ response = {
+ "version": 1,
+ "kind": "human_input_response",
+ "source": "ask_clarification",
+ "request_id": "req-1",
+ "value": "yes",
+ "response_kind": "text",
+ }
+ events = _seed(
+ [
+ HumanMessage(id="h1", content="question"),
+ AIMessage(id="a1", content="which one?"),
+ HumanMessage(id="h-response", content="yes", additional_kwargs={"hide_from_ui": True, "human_input_response": response}),
+ AIMessage(id="a2", content="answer"),
+ ]
+ )
+
+ assert [event["run_id"] for event in events] == [
+ "branch-seed-branch-thread-1",
+ "branch-seed-branch-thread-1",
+ "branch-seed-branch-thread-2",
+ "branch-seed-branch-thread-2",
+ ]
+
+
def test_seed_roundtrips_through_memory_store_feed() -> None:
"""put_batch preserves order and list_messages returns the seeded feed."""
store = MemoryRunEventStore()
diff --git a/backend/tests/test_thread_messages_page.py b/backend/tests/test_thread_messages_page.py
index 246bd00c1..07bfb6129 100644
--- a/backend/tests/test_thread_messages_page.py
+++ b/backend/tests/test_thread_messages_page.py
@@ -9,10 +9,12 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from _router_auth_helpers import make_authed_test_app
from fastapi.testclient import TestClient
+from langchain_core.messages import AIMessage, HumanMessage
from app.gateway.routers import thread_runs
from deerflow.runtime import RunRecord
from deerflow.runtime.events.store.memory import MemoryRunEventStore
+from deerflow.runtime.journal import build_branch_history_seed_events
def _make_app(event_store: MemoryRunEventStore, *, superseded: set[str] | None = None, records=None, feedback=None):
@@ -185,6 +187,45 @@ def test_thread_page_filters_all_successfully_superseded_runs_before_filling():
assert body["next_before_seq"] is None
+def test_thread_page_keeps_earlier_branch_turns_when_the_last_one_is_regenerated():
+ """Regenerating a branch's inherited answer must not delete the turns before
+ it (#4458).
+
+ Supersession is run-scoped, so it can only stay confined to the regenerated
+ turn while each seeded turn owns its own synthetic run id — this pins the
+ seed builder's turn scoping against the filter that consumes it.
+ """
+ store = MemoryRunEventStore()
+ branch_thread = "thread-1"
+
+ async def seed():
+ seed_events = build_branch_history_seed_events(
+ [
+ HumanMessage(id="h1", content="turn one"),
+ AIMessage(id="a1", content="answer one"),
+ HumanMessage(id="h2", content="turn two"),
+ AIMessage(id="a2", content="answer two"),
+ ],
+ thread_id=branch_thread,
+ run_id_prefix=f"branch-seed-{branch_thread}",
+ parent_thread_id="parent-thread",
+ )
+ await store.put_batch(seed_events)
+ # The regenerate run re-journals the same human id plus a fresh answer.
+ await _put_message(store, "live-run", "human", "h2")
+ await _put_message(store, "live-run", "ai", "a2-new")
+ # Resolve the superseded source the way `_find_target_run_id` does:
+ # the run id carried by the regenerated assistant row itself.
+ return next(event["run_id"] for event in seed_events if event["content"]["id"] == "a2")
+
+ superseded_run_id = asyncio.run(seed())
+ app = _make_app(store, superseded={superseded_run_id})
+ with TestClient(app) as client:
+ body = client.get("/api/threads/thread-1/messages/page?limit=50").json()
+
+ assert [row["content"]["id"] for row in body["data"]] == ["h1", "a1", "h2", "a2-new"]
+
+
def test_thread_page_logs_rows_missing_sequence_values(caplog):
store = AsyncMock()
store.list_messages.return_value = [{"run_id": "run-1", "content": {"type": "human"}}]
diff --git a/backend/tests/test_threads_router.py b/backend/tests/test_threads_router.py
index df2c702f9..99a937a26 100644
--- a/backend/tests/test_threads_router.py
+++ b/backend/tests/test_threads_router.py
@@ -2013,7 +2013,8 @@ def test_branch_seeds_run_events_with_parent_history(monkeypatch, mode) -> None:
assert [row["content"]["id"] for row in rows] == ["h1", "t1", "a1"]
assert [row["event_type"] for row in rows] == ["llm.human.input", "llm.tool.result", "llm.ai.response"]
assert all(row["category"] == "message" for row in rows)
- assert all(row["run_id"] == f"branch-seed-{branch_thread_id}" for row in rows)
+ # One synthetic run per inherited turn (#4458): this source has a single turn.
+ assert all(row["run_id"] == f"branch-seed-{branch_thread_id}-1" for row in rows)
assert all((row.get("metadata") or {}).get("branch_seed") is True for row in rows)
seqs = [row["seq"] for row in rows]
assert seqs == sorted(seqs)
From 7aa314b4c1fc38e3db11e23ddea329595aeb9ebc Mon Sep 17 00:00:00 2001
From: Ryker_Feng <90562015+18062706139fcz@users.noreply.github.com>
Date: Sun, 26 Jul 2026 08:09:17 +0800
Subject: [PATCH 11/35] feat: add Lark CLI integration (#3971)
* feat: add lark cli integration
* fix: polish lark integration actions
* feat: support lark incremental permissions
* fix: detect lark authorization completion
* fix: harden lark integration install
* feat: expand lark auth scopes and reuse host auth in sandbox
Default lark auth to least-privilege (recommend=false, base sign-in only)
and expose the full set of lark-cli --domain business domains as native
--domain grants instead of a 4-domain read-only mapping. Resolve the
skill pack from the latest larksuite/cli GitHub release at install time
with content-hash integrity, and surface version/runtime drift in status.
Share the per-user lark-cli config/data profile between the Gateway
Settings auth flow and agent conversations by mounting the integration
dirs into the AIO sandbox and injecting the matching env for lark-cli
commands, with an allowlisted extra_mounts path in the provisioner/K8s
backend and traversal guards on integration paths.
* style: fix lint issues from ruff and prettier
Sort imports in the provisioner PVC test and re-wrap two long i18n
description strings to satisfy backend ruff and frontend prettier CI.
* fix(lark): address managed integration review feedback
* fix(frontend): stabilize integrations settings e2e
* test(sandbox): isolate remote backend legacy visibility check
* test: fix backend unit failures after merge
* Harden Lark integration review fixes
* Format Lark integration E2E test
* fix(lark): harden sandbox credential exposure and status disclosure
Address willem_bd's security review on PR #3971:
- Mount the per-user lark-cli config dir (long-lived appSecret) read-only
into the AIO sandbox; only the refreshable-token data dir stays writable.
- Redact host filesystem paths (install_path, cli.path) from
GET /lark/status and the config/auth complete responses for non-admin
callers, fail-closed on any auth error.
- Document the npm postinstall trade-off (--ignore-scripts is not viable
because @larksuite/cli fetches its platform binary in postinstall).
- Document the sandbox credential trust boundary in AGENTS.md and README,
pointing at the sidecar-broker follow-up (#4338).
---------
Co-authored-by: Willem Jiang
---
AGENTS.md | 1 +
README.md | 67 +
backend/AGENTS.md | 10 +-
backend/Dockerfile | 12 +-
backend/app/gateway/app.py | 4 +
backend/app/gateway/routers/integrations.py | 354 ++++
.../aio_sandbox/aio_sandbox_provider.py | 134 +-
.../deerflow/community/aio_sandbox/backend.py | 4 +
.../community/aio_sandbox/local_backend.py | 5 +-
.../community/aio_sandbox/remote_backend.py | 73 +-
.../deerflow/config/extensions_config.py | 2 +-
.../packages/harness/deerflow/config/paths.py | 38 +
.../harness/deerflow/integrations/__init__.py | 1 +
.../harness/deerflow/integrations/lark_cli.py | 1637 +++++++++++++++++
.../sandbox/local/local_sandbox_provider.py | 10 +
.../harness/deerflow/sandbox/tools.py | 66 +-
.../storage/user_scoped_skill_storage.py | 25 +-
.../packages/harness/deerflow/skills/types.py | 2 +
.../blocking_io/test_integrations_router.py | 130 ++
backend/tests/test_aio_sandbox_provider.py | 160 ++
backend/tests/test_gateway_imports.py | 17 +-
backend/tests/test_lark_cli_integration.py | 1599 ++++++++++++++++
.../test_local_sandbox_provider_mounts.py | 29 +
backend/tests/test_paths_user_isolation.py | 25 +
.../tests/test_provisioner_mount_contract.py | 28 +
backend/tests/test_provisioner_pvc_volumes.py | 246 +++
backend/tests/test_remote_sandbox_backend.py | 103 +-
backend/tests/test_sandbox_tools_security.py | 57 +
.../tests/test_user_scoped_skill_storage.py | 18 +
docker/docker-compose-dev.yaml | 6 +
docker/docker-compose.yaml | 6 +
docker/lark-cli-init/Dockerfile | 63 +
docker/lark-cli-init/README.md | 63 +
docker/lark-cli-init/build-runtime.sh | 87 +
docker/lark-cli-init/entrypoint.sh | 24 +
docker/provisioner/README.md | 24 +
docker/provisioner/app.py | 269 ++-
frontend/AGENTS.md | 2 +-
.../integrations/lark/auth/complete/route.ts | 36 +
.../api/integrations/lark/auth/start/route.ts | 9 +
.../lark/config/complete/route.ts | 36 +
.../integrations/lark/config/start/route.ts | 10 +
.../api/integrations/lark/install/route.ts | 39 +
.../api/integrations/lark/status/route.ts | 34 +
.../src/app/workspace/workspace-content.tsx | 4 +
.../components/workspace/command-palette.tsx | 9 +-
.../components/workspace/settings/index.ts | 8 +-
.../settings/integrations-settings-page.tsx | 882 +++++++++
.../settings/settings-dialog-host.tsx | 25 +
.../settings/settings-dialog-store.ts | 89 +
.../workspace/settings/settings-dialog.tsx | 12 +-
.../workspace/workspace-nav-menu.tsx | 18 +-
.../workspace-settings-deep-link.tsx | 68 +
frontend/src/core/i18n/locales/en-US.ts | 198 ++
frontend/src/core/i18n/locales/types.ts | 100 +
frontend/src/core/i18n/locales/zh-CN.ts | 183 ++
frontend/src/core/integrations/lark/api.ts | 153 ++
frontend/src/core/integrations/lark/hooks.ts | 68 +
frontend/src/core/integrations/lark/index.ts | 3 +
frontend/src/core/integrations/lark/types.ts | 102 +
frontend/tests/e2e/integrations.spec.ts | 156 ++
frontend/tests/e2e/utils/mock-api.ts | 179 ++
.../settings/settings-dialog-store.test.ts | 57 +
.../unit/core/integrations/lark/api.test.ts | 309 ++++
64 files changed, 8132 insertions(+), 56 deletions(-)
create mode 100644 backend/app/gateway/routers/integrations.py
create mode 100644 backend/packages/harness/deerflow/integrations/__init__.py
create mode 100644 backend/packages/harness/deerflow/integrations/lark_cli.py
create mode 100644 backend/tests/blocking_io/test_integrations_router.py
create mode 100644 backend/tests/test_lark_cli_integration.py
create mode 100644 backend/tests/test_provisioner_mount_contract.py
create mode 100644 docker/lark-cli-init/Dockerfile
create mode 100644 docker/lark-cli-init/README.md
create mode 100644 docker/lark-cli-init/build-runtime.sh
create mode 100644 docker/lark-cli-init/entrypoint.sh
create mode 100644 frontend/src/app/mock/api/integrations/lark/auth/complete/route.ts
create mode 100644 frontend/src/app/mock/api/integrations/lark/auth/start/route.ts
create mode 100644 frontend/src/app/mock/api/integrations/lark/config/complete/route.ts
create mode 100644 frontend/src/app/mock/api/integrations/lark/config/start/route.ts
create mode 100644 frontend/src/app/mock/api/integrations/lark/install/route.ts
create mode 100644 frontend/src/app/mock/api/integrations/lark/status/route.ts
create mode 100644 frontend/src/components/workspace/settings/integrations-settings-page.tsx
create mode 100644 frontend/src/components/workspace/settings/settings-dialog-host.tsx
create mode 100644 frontend/src/components/workspace/settings/settings-dialog-store.ts
create mode 100644 frontend/src/components/workspace/workspace-settings-deep-link.tsx
create mode 100644 frontend/src/core/integrations/lark/api.ts
create mode 100644 frontend/src/core/integrations/lark/hooks.ts
create mode 100644 frontend/src/core/integrations/lark/index.ts
create mode 100644 frontend/src/core/integrations/lark/types.ts
create mode 100644 frontend/tests/e2e/integrations.spec.ts
create mode 100644 frontend/tests/unit/components/workspace/settings/settings-dialog-store.test.ts
create mode 100644 frontend/tests/unit/core/integrations/lark/api.test.ts
diff --git a/AGENTS.md b/AGENTS.md
index 3d396ebaf..9f1567c63 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -50,6 +50,7 @@ deer-flow/
├── frontend/ # Next.js frontend (pnpm) — see frontend/AGENTS.md
├── docker/ # docker-compose files, nginx config, provisioner
├── skills/ # Agent skills: public/ (committed), custom/ (gitignored)
+│ # Managed integration skill packs are installed per user under .deer-flow/users/{user_id}/skills/integrations/
├── contracts/ # Cross-component JSON contracts (e.g. subagent status, skill review)
├── scripts/ # Root orchestration scripts invoked by the Makefile (check, configure, doctor, support_bundle, serve, nginx, docker, deploy, setup_wizard)
├── tests/ # Root-level tests (currently tests/skills/ — public skill tests)
diff --git a/README.md b/README.md
index 9fc1a6507..5c802a919 100644
--- a/README.md
+++ b/README.md
@@ -664,6 +664,70 @@ An enabled skill's `allowed-tools` policy applies only after that skill is expli
When you install `.skill` archives through the Gateway, DeerFlow accepts standard optional frontmatter metadata such as `version`, `author`, and `compatibility` instead of rejecting otherwise valid external skills.
+Managed integrations install shared read-only skill packs without mixing them
+into custom skills. The Lark/Feishu CLI integration is available under
+`Settings → Integrations → Lark / Feishu CLI`; an administrator installs or
+upgrades the official `lark-*` pack once under
+`{DEER_FLOW_HOME}/integrations/skills/lark-cli`, and every user discovers that
+same pack with an independent enabled state. Each user's app configuration and
+OAuth data remain isolated under
+`{DEER_FLOW_HOME}/users/{user_id}/integrations/lark-cli/{config,data}`. These
+secret directories are restricted to `0700`, regular credential files to
+`0600`, and symlinks are rejected.
+
+After installation, users can click **Connect Lark** to open a browser
+authorization link; no terminal authorization is required. The same UI can
+request additional permission domains such as Calendar, Docs, or Drive, or a
+specific OAuth scope reported by `lark-cli`. A cheap status refresh only
+inspects the local credential tree, so the UI reports **Credentials configured
+(not live-verified)** until an explicit browser completion performs live token
+verification. The action then remains **Reconnect Lark** so users can replace
+or extend authorization. If an agent hits missing Lark authorization during a
+conversation, the managed `lark-shared` guidance points the user back to the
+same settings entry with `?settings=integrations`.
+
+Installing the Lark skill pack resolves the latest official `larksuite/cli`
+release from GitHub and downloads that version's skills at install time, so the
+Gateway needs outbound internet access for that step (it falls back to a
+bottom-line pinned version if the release lookup fails). The settings page shows
+the installed version and, when available, the newest published version so an
+admin can reinstall to upgrade. Air-gapped deployments can pre-stage the archive
+and point `DEER_FLOW_LARK_CLI_SKILLS_ARCHIVE` at the local file. Integrity does
+not depend on a pinned archive byte hash (GitHub does not guarantee stable
+source-archive bytes); instead the download is restricted to the official GitHub
+host, every archive member passes structural safety guards, and a content hash
+of the effective installed skill tree (including DeerFlow's injected shared
+guidance) is recorded so content changes are auditable across reinstalls.
+
+When `sandbox.use` selects the AIO provider, the same install also downloads the
+official Linux amd64 and arm64 CLI release archives, verifies their published
+SHA-256 checksums, safely extracts one executable per architecture, and mounts
+the resulting runtime read-only at `/mnt/integrations/lark-cli/runtime`. An
+architecture-selecting launcher in that mount makes `lark-cli` available in the
+sandbox `PATH`. Air-gapped AIO deployments can pre-stage a symlink-free runtime
+tree containing `bin/lark-cli` plus both `linux-{amd64,arm64}/lark-cli` files and
+set `DEER_FLOW_LARK_CLI_SANDBOX_RUNTIME_DIR` to that directory.
+
+> **Sandbox trust boundary:** the browser never receives the Lark app secret, but
+> agent conversations run `lark-cli` inside the sandbox, so the per-user
+> credential directories are mounted into it: `config` (holding the long-lived
+> `appSecret`) is mounted **read-only** and `data` (refreshable OAuth tokens)
+> writable. Both remain *readable* by any process the agent runs there, so code
+> reached via prompt injection in a tool result could read them. Treat the
+> sandbox as inside the Lark credential trust boundary until the sidecar
+> credential-broker follow-up removes these mounts from sandbox execution.
+
+For remote/Kubernetes deployments (the provisioner backend), the sandbox
+`lark-cli` runtime can instead be supplied by an optional init container that
+copies the binaries into a shared `emptyDir` — no install-time GitHub download and
+no hostPath/PVC runtime mount. Publish the image under
+[`docker/lark-cli-init`](docker/lark-cli-init/README.md) and set
+`LARK_CLI_INIT_IMAGE` on the provisioner; it stays off (legacy behavior) when
+unset. The Lark integration status (`GET /api/integrations/lark/status`) reports
+`sandbox_runtime_mode` and `sandbox_runtime_ready` so the Settings UI shows
+whether `lark-cli` will actually be present in the sandbox at chat time, rather
+than a green status hiding a later `command not found`.
+
If a trusted operator manages the configured skills directory through an external mount such as MinIO, NFS, or CSI, an administrator can call `POST /api/skills/reload` after changing files. This invalidates skill prompt caches for the current Gateway process and waits up to the bounded refresh timeout so subsequent runs rescan the latest files; running tasks are unchanged. A loader-level filesystem failure returns a generic server error and preserves the last successfully loaded process cache rather than publishing an empty catalog. Uvicorn workers and Kubernetes Pods must each be targeted separately. Direct mount writes bypass the validation, SkillScan, and history applied by DeerFlow's install/edit APIs, so only operator-controlled systems should have write access.
Skill installs and agent-managed skill edits run through **SkillScan**, a native deterministic safety scanner before the LLM-based skill scanner. Phase 1 runs offline with no Semgrep/OpenGrep dependency, blocks high-confidence `CRITICAL` findings such as private keys or shell execution, and passes warning findings to the LLM scanner for contextual review. Python instance-client exfiltration checks follow a minimal same-scope evidence chain: a simple name bound to a known client constructor, optional name-to-name aliases, and an actual outbound method or context-manager use supported by that constructor. Constructor roots must be proven imports; bare canonical-looking names are not inferred as modules. Nested scopes do not inherit client handles and inherit only constructor import aliases that are never rebound in the enclosing scope. Comprehensions, walrus-bearing statements, annotations, complex binding targets, unsupported operations, and ambiguous branch flows produce no finding from this signal; skipped constructs conservatively invalidate every name they may bind so stale client state cannot create a finding. A deterministic work budget or recursion limit reached by this best-effort analysis does not discard findings already collected for the file. Set `skill_scan.enabled: false` in `config.yaml` to disable only the deterministic analyzers; safe archive extraction and the LLM scanner still run.
@@ -713,6 +777,9 @@ Web UI chat links percent-encode custom thread identifiers before placing them i
/mnt/skills/custom
└── your-custom-skill/SKILL.md ← yours
+
+/mnt/skills/integrations
+└── lark-cli/lark-doc/SKILL.md ← managed, read-only
```
#### Claude Code Integration
diff --git a/backend/AGENTS.md b/backend/AGENTS.md
index 73caca7c8..88677c7fa 100644
--- a/backend/AGENTS.md
+++ b/backend/AGENTS.md
@@ -48,6 +48,7 @@ deer-flow/
│ │ │ └── registry.py # Agent registry
│ │ ├── tools/builtins/ # Built-in tools (present_files, ask_clarification, view_image, review_skill_package)
│ │ ├── mcp/ # MCP integration (tools, cache, client)
+│ │ ├── integrations/ # Managed first-party integration installers (e.g. Lark CLI skill pack)
│ │ ├── models/ # Model factory with thinking/vision support
│ │ ├── skills/ # Skills discovery, loading, parsing
│ │ ├── config/ # Configuration system (app, model, sandbox, tool, etc.)
@@ -219,6 +220,9 @@ Blocking-IO runtime gate (`tests/blocking_io/`):
SQLite path resolution plus `ensure_sqlite_parent_dir`, fix for #1912);
`test_jsonl_run_event_store.py` (locks `JsonlRunEventStore`'s async
API offloading its file IO via `asyncio.to_thread`);
+ `test_integrations_router.py` (locks Lark integration install and auth
+ completion route handlers offloading archive filesystem work and `lark-cli`
+ subprocesses);
`test_uploads_middleware.py` (locks `UploadsMiddleware.abefore_agent`
offloading the uploads-directory scan off the event loop);
`test_uploads_router.py` (locks Gateway upload/list/delete endpoints
@@ -425,6 +429,7 @@ Localhost persistence deliberately reads the direct request `Host` and ignores `
| **Console** (`/api/console`) | Read-only cross-thread observability for the current user (the data layer for an operations dashboard or external monitoring): `GET /stats` - headline counters (runs/threads/agents/tokens/cost); `GET /runs` - paginated run history joined with thread titles (per-run cost); `GET /usage` - zero-filled daily token series + per-model breakdown with spend. Queries `runs`/`threads_meta` directly as a reporting layer (no new `RunStore` methods); requires a SQL database backend — returns 503 on `database.backend: memory`. Real-cost estimation reads optional `models[*].pricing` (`currency`, `input_per_million`, `output_per_million`, `input_cache_hit_per_million`; `ModelConfig` is `extra="allow"`, so no schema change) and prices each run from its `token_usage_by_model` input/output split. Pricing is **cache-aware**: `RunJournal` accumulates prompt-cache hits from `usage_metadata.input_token_details.cache_read` into a sparse `cache_read_tokens` bucket key (also threaded through `SubagentTokenCollector` → `record_external_llm_usage_records`), and cache-hit input tokens are billed at `input_cache_hit_per_million` (omitted → billed at the miss price, a conservative upper bound). Legacy rows fall back to run-level totals at `model_name`; unpriced models yield `cost: null` and cost fields are null when no pricing is configured |
| **MCP** (`/api/mcp`) | `GET /config` - get config; `PUT /config` - update config (saves to extensions_config.json) |
| **Skills** (`/api/skills`) | `GET /` - list skills; `GET /{name}` - details; `PUT /{name}` - update enabled; `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`); `POST /reload` - admin-only process-local prompt-cache invalidation after trusted external filesystem changes |
+| **Integrations** (`/api/integrations`) | `GET /lark/status` - inspect managed Lark/Feishu CLI integration state, including `sandbox_runtime_mode` / `sandbox_runtime_ready` (whether `lark-cli` will actually be present in the sandbox at chat time); `POST /lark/install` - admin-only install of the official `lark-*` managed skill pack; `POST /lark/config/start` and `/lark/config/complete` - internal first-time Lark connection setup; `POST /lark/auth/start` and `/lark/auth/complete` - browser device-flow user authorization without terminal access, with optional `domains` / exact `scope` for incremental permission grants |
| **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data |
| **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete |
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; `POST /branches` - create a new main-thread branch from a completed assistant turn checkpoint and, when an addressable pre-user replay checkpoint exists, materialize it into the branch namespace so the inherited response remains regeneratable. Workspace files are not checkpointed, so the branch only best-effort copies the current workspace when branching from the **latest** turn (`workspace_clone_mode="current_thread_best_effort"`); branching from an older/historical turn skips the copy (`workspace_clone_mode="skipped_historical_turn"`) so the branch never inherits files that only exist in a later timeline. Branch creation also seeds the new thread's run-event feed from the branch checkpoint's visible messages (`history_seed_mode` in the response): the thread feed reads run_events, not checkpoints, so without the seed the inherited history disappears from the UI after the branch's first run (#4380). Seeded rows are grouped into one synthetic run per inherited turn (`branch-seed-{thread_id}-{n}`, a new turn opening at every persisted human message, including an allowlisted hidden `ask_clarification` reply) because `run_id` is a turn identity to the feed's consumers, not a provenance tag: regenerating an inherited answer supersedes that row's whole `run_id` in `GET /messages/page`, so one shared id for the entire seed deleted the complete inherited history on a branch's first regenerate (#4458); `GET /goal`, `PUT /goal`, `DELETE /goal` - read, set, and clear the active thread goal; `POST /compact` - manually summarize older active context into `summary_text` and retain the recent message window, blocked while a run is in flight; unexpected failures are logged server-side and return a generic 500 detail |
@@ -614,9 +619,9 @@ E2B output sync records remote file versions and actual host file metadata in a
### Skills System (`packages/harness/deerflow/skills/`)
-- **Location**: `deer-flow/skills/{public,custom}/`
+- **Location**: global public skills live under `deer-flow/skills/public/`; user-authored custom skills live under `{DEER_FLOW_HOME}/users/{user_id}/skills/custom/`; globally managed integration skills live under `{DEER_FLOW_HOME}/integrations/skills/{provider}/`; per-user integration credentials remain under `{DEER_FLOW_HOME}/users/{user_id}/integrations/{provider}/{config,data}`
- **Format**: Directory with `SKILL.md` (YAML frontmatter: name, description, license, allowed-tools, required-secrets)
-- **Loading**: `load_skills()` recursively scans namespace directories under `skills/{public,custom}`, but stops descending once it finds a `SKILL.md`; that directory is a package boundary, so no nested `SKILL.md` is registered as a runtime skill. SkillScan has a deliberately narrower packaging rule: known eval fixtures are permitted as support data, while other nested `SKILL.md` files are reported as package defects. It parses runtime metadata and reads enabled state from extensions_config.json.
+- **Loading**: `load_skills()` recursively scans public, per-user custom, global integration, and legacy custom locations for `SKILL.md`, parses metadata, and reads enabled state from extensions_config.json plus per-user skill state for non-public categories; that directory is a package boundary, so no nested `SKILL.md` is registered as a runtime skill. SkillScan has a deliberately narrower packaging rule: known eval fixtures are permitted as support data, while other nested `SKILL.md` files are reported as package defects. It parses runtime metadata and reads enabled state from extensions_config.json.
- **External reload**: `POST /api/skills/reload` is an admin-only, process-local invalidation hook for trusted MinIO/NFS/CSI writes. `SkillStorage` instances do not cache a catalog — `load_skills()` scans on every call — so the route clears all `(app_config, user_id)` entries and the rendered prompt-section LRU, then waits up to the shared refresh timeout for the existing off-loop single-flight refresh. Each invalidation receives a generation-bound result handle; a successful scan atomically replaces the global enabled-skills cache, while a loader-level failure propagates to the HTTP waiter and preserves the last-known-good global cache. Per-user/config scans capture the refresh version and cannot repopulate shared caches if invalidation occurs while they are loading. A timed-out HTTP wait fails generically while the daemon refresh worker continues. Subsequent runs rescan after a successful reload; active runs keep their existing snapshot. Each Uvicorn worker/Kubernetes Pod must be targeted separately. Direct mount writes bypass install/edit validation, SkillScan, and history, so mounted roots are an operator-controlled trust boundary.
- **Tool policy**: Lead-agent `allowed-tools` declarations apply dynamically only to slash-activated skills and skills captured in `ThreadState.skill_context` through configured `read_file` loads; passive enabled skills and custom-agent skill allowlists remain discoverable without clamping the global toolset. Slash policy is dominant for its run, preventing subsequently read skills from widening explicit authority; autonomous captured skills use the existing union only when no slash source exists. `tool_search` and `describe_skill` stay available as framework discovery infrastructure, while every discovered or promoted business tool still requires active-policy permission for schema visibility and execution; `task` likewise requires an explicit declaration. Each active model call intentionally reloads the full live registry so enable/disable changes, frontmatter edits, and custom/public name-shadow winners take effect without a stale TTL or unsafe direct-path cache; all tool calls produced by that model step reuse the resulting source-and-path-signed decision. Registry failures and all-invalid active sets fail closed, while stale individual paths are skipped when another valid skill remains. This is best-effort behavioral scoping, not a hard security boundary: alternate loading paths are not captured and bounded autonomous context may evict entries. Subagents still filter statically because their configured skills are all loaded into the session at startup.
- **Injection (legacy / default)**: Enabled skills are listed in the agent system prompt with full metadata and container paths (`` block). Controlled by `skills.deferred_discovery: false` (default).
@@ -625,6 +630,7 @@ E2B output sync records remote file versions and actual host file metadata in a
- `skills/describe.py` — `build_describe_skill_tool(catalog)` builds the `describe_skill` tool as a closure; `build_skill_search_setup(skills, enabled, ...)` produces a `SkillSearchSetup(describe_skill_tool, skill_names)` that is wired into both the LangGraph agent factory (`agent.py`) and the embedded client (`client.py`).
- **Slash activation**: `/skill-name task` loads that enabled skill's `SKILL.md` for the current model call only. The resolver rejects leading whitespace, missing separators, reserved channel commands (`/new`, `/help`, `/bootstrap`, `/status`, `/models`, `/memory`, `/goal`), disabled skills, and skills outside a custom agent's whitelist.
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
+- **Managed integrations**: Lark/Feishu CLI support installs one global official `lark-*` pack as read-only `SkillCategory.INTEGRATION` entries under `/mnt/skills/integrations/lark-cli/...`; enabled flags, app configuration, and OAuth data remain per-user. Install resolves the newest `larksuite/cli` release from GitHub (`releases/latest`) at install time (falling back to a bottom-line pinned version if the lookup fails) rather than hard-coding the pack version; integrity relies on the official host + structural archive guards + a recorded hash of the effective installed tree after shared guidance injection (not a pinned archive-byte SHA, which GitHub does not keep stable). The Gateway image still installs a pinned `@larksuite/cli` binary, so `get_lark_integration_status` surfaces `latest_available_version` and `runtime_version_mismatch` for the UI. AIO installs additionally verify and publish official Linux amd64/arm64 binaries under `{DEER_FLOW_HOME}/integrations/lark-cli/sandbox-cli`, mounted read-only at `/mnt/integrations/lark-cli/runtime`; `/mnt/integrations/lark-cli/config` (app credentials, incl. the long-lived `appSecret`) is mounted **read-only** into the sandbox and `/mnt/integrations/lark-cli/data` (refreshable OAuth tokens) stays writable, both mapping to owner-only per-user credential directories. **Sandbox trust boundary:** those two dirs are still *readable* by arbitrary sandbox processes (the agent's `bash` tool, or code reached via prompt-injection in a tool result), so the app secret and tokens are exposed to sandbox-side code even though they never reach the browser — the read-only config mount only prevents in-sandbox tampering, not read/exfiltration. The sidecar credential-broker follow-up (issue #4338) is the planned fix that removes these plaintext mounts from sandbox execution. `DEER_FLOW_LARK_CLI_SANDBOX_RUNTIME_DIR` supplies a validated, symlink-free pre-staged runtime for air-gapped deployments. For the remote provisioner (K8s), the runtime is instead provisioned by an optional init container + shared `emptyDir` (Pattern A): set `LARK_CLI_INIT_IMAGE` on the provisioner (see `docker/lark-cli-init/`) and the Gateway sends `provision_lark_cli_runtime` on sandbox create once the pack is installed, so remote installs skip the Gateway-side GitHub download entirely. `get_lark_integration_status(check_runtime=True)` surfaces `sandbox_runtime_mode` (`none` / `gateway-download` / `init-container`) and `sandbox_runtime_ready` (init-container mode reads the provisioner `GET /api/capabilities`) so a green UI can't hide a chat-time `lark-cli: command not found`. Cheap status probes are explicitly not live-verified; users authorize or reconnect through the browser device-flow endpoints instead of running terminal commands.
- **SkillScan**: `packages/harness/deerflow/skills/skillscan/` is the native deterministic scanner for `.skill` archives and agent-managed skill writes. It runs offline before the LLM scanner, emits structured findings (`rule_id`, `severity`, `file`, `line`, `message`, `remediation`, redacted `evidence` — category/analyzer are encoded in the `rule_id` prefix), blocks `CRITICAL`, and passes warning findings into `scan_skill_content()`. `scan_archive_preflight()` / `scan_skill_dir()` are pure sync functions (dispatch off the event loop); `enforce_static_scan()` applies the blocking policy and the `skill_scan.enabled` kill switch. The Python instance-client signal deliberately follows only a one-level, same-scope evidence chain (PR #4265 review): a proven imported constructor bound to a simple name, optional name-to-name alias propagation, rebinding invalidation, and a constructor-supported outbound method or context-manager use; bare canonical-looking names never fall back to module identity. Nested scopes never inherit client handles and inherit only constructor aliases proven stable by a binding-only enclosing-scope prepass. Comprehensions, walrus-bearing statements, annotations, executable expressions inside complex binding targets, unsupported operations, and ambiguous flows produce no finding from this signal; skipped constructs invalidate all names they may bind, while representative false negatives are pinned by `test_python_declared_false_negatives_stay_unreported`. Compound bodies are walked from isolated copies so wrapping code in `if True:` is not a bypass, while copied scope entries, binding-only prepasses, and AST visits consume a deterministic work budget and the walk stops after its first sink. Budget or recursion exhaustion skips only this best-effort signal and retains deterministic findings already collected for the file. Do not add Semgrep/OpenGrep or YAML rule-engine dependencies to the core path; Phase 1 rule specs live in Python constants next to their analyzers in `skillscan/orchestrator.py`.
- **Skill Review Core**: `packages/harness/deerflow/skills/review/` provides read-only package snapshots, deterministic facts, resource/eval analysis, report rendering, and the CLI (`python -m deerflow.skills.review.cli`). It reuses the shared frontmatter helper and SkillScan; it must not import `app.*`, execute target scripts, install dependencies, or call networks. JSON contracts live in `contracts/skill_review/`. The `review_skill_package` built-in tool labels results with `review_subject_entry` and never `skill_context_entry`, so reviewing a target does not activate it, bind its `required-secrets`, or apply its `allowed-tools`. Its model-visible `ToolMessage.content` is a compact JSON payload with untrusted control tags neutralized; the full raw review payload, including Markdown renders, stays in `ToolMessage.artifact`. CI should run the CLI with `--fail-on error --fail-on-incomplete` so blocker/error findings and truncated/not-assessed packages fail the gate. The public `skills/public/skill-reviewer` skill owns semantic readiness review and suggestions only; mutation and runtime experiments remain owned by `skill-creator`.
diff --git a/backend/Dockerfile b/backend/Dockerfile
index 110a182a3..a284bb19b 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -13,6 +13,8 @@ FROM python:3.12-slim-bookworm AS builder
ARG NODE_MAJOR=22
ARG APT_MIRROR
ARG UV_INDEX_URL
+ARG NPM_REGISTRY
+ARG LARK_CLI_NPM_VERSION=1.0.65
# Optional extras to install (e.g. "postgres" for PostgreSQL support)
# Usage: docker build --build-arg UV_EXTRAS=postgres,discord ...
ARG UV_EXTRAS
@@ -36,6 +38,11 @@ RUN apt-get update && apt-get install -y \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
+# Install the official Lark/Feishu CLI used by the managed Lark integration.
+RUN if [ -n "${NPM_REGISTRY}" ]; then npm config set registry "${NPM_REGISTRY}"; fi \
+ && npm install -g @larksuite/cli@${LARK_CLI_NPM_VERSION} \
+ && lark-cli --version
+
# Install uv (source image overridable via UV_IMAGE build arg)
COPY --from=uv-source /uv /uvx /usr/local/bin/
@@ -95,7 +102,10 @@ ENV PYTHONIOENCODING=utf-8
COPY --from=builder /usr/bin/node /usr/bin/node
COPY --from=builder /usr/lib/node_modules /usr/lib/node_modules
RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/bin/npm \
- && ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/bin/npx
+ && ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/bin/npx \
+ && LARK_CLI_BIN="$(node -p 'const bin=require("/usr/lib/node_modules/@larksuite/cli/package.json").bin; typeof bin === "string" ? bin : (bin["lark-cli"] || Object.values(bin)[0])')" \
+ && ln -s "../lib/node_modules/@larksuite/cli/${LARK_CLI_BIN#./}" /usr/bin/lark-cli \
+ && lark-cli --version
# Install Docker CLI (for DooD: allows starting sandbox containers via host Docker socket)
COPY --from=docker:cli /usr/local/bin/docker /usr/local/bin/docker
diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py
index 8fde90f27..08b16bc9d 100644
--- a/backend/app/gateway/app.py
+++ b/backend/app/gateway/app.py
@@ -25,6 +25,7 @@ from app.gateway.routers import (
feedback,
github_webhooks,
input_polish,
+ integrations,
mcp,
memory,
models,
@@ -516,6 +517,9 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for
# Skills API is mounted at /api/skills
app.include_router(skills.router)
+ # First-party integrations API is mounted at /api/integrations
+ app.include_router(integrations.router)
+
# Artifacts API is mounted at /api/threads/{thread_id}/artifacts
app.include_router(artifacts.router)
diff --git a/backend/app/gateway/routers/integrations.py b/backend/app/gateway/routers/integrations.py
new file mode 100644
index 000000000..1620a57aa
--- /dev/null
+++ b/backend/app/gateway/routers/integrations.py
@@ -0,0 +1,354 @@
+import asyncio
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException, Request
+from pydantic import BaseModel, Field
+
+from app.gateway.deps import get_config, require_admin_user
+from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
+from deerflow.config.app_config import AppConfig
+from deerflow.integrations.lark_cli import (
+ LARK_AUTH_COMPLETE_DEFAULT_WAIT_SECONDS,
+ LARK_AUTH_COMPLETE_MAX_WAIT_SECONDS,
+ LARK_AUTH_COMPLETE_MIN_WAIT_SECONDS,
+ LarkAuthCompleteResult,
+ LarkAuthProbe,
+ LarkAuthStartResult,
+ LarkCliProbe,
+ LarkConfigCompleteResult,
+ LarkConfigStartResult,
+ LarkInstallResult,
+ LarkIntegrationStatus,
+ complete_lark_auth,
+ complete_lark_config,
+ get_lark_integration_status,
+ install_lark_integration,
+ start_lark_auth,
+ start_lark_config,
+)
+from deerflow.runtime.user_context import get_effective_user_id
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api/integrations", tags=["integrations"])
+
+_ADMIN_REQUIRED_DETAIL = "Admin privileges required to install integrations."
+
+
+async def _is_admin_user(request: Request) -> bool:
+ """Non-raising admin check used to gate host-path disclosure in responses.
+
+ Fails closed: any error (missing middleware state, auth failure) is treated
+ as non-admin so host paths are redacted rather than accidentally exposed.
+ """
+ try:
+ await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
+ except Exception:
+ return False
+ return True
+
+
+class LarkCliProbeResponse(BaseModel):
+ available: bool = Field(..., description="Whether lark-cli is available to the Gateway, either managed by DeerFlow or on PATH")
+ path: str | None = Field(None, description="Resolved lark-cli executable path")
+ version: str | None = Field(None, description="lark-cli --version output")
+ error: str | None = Field(None, description="Probe failure message")
+
+
+class LarkAuthProbeResponse(BaseModel):
+ status: str = Field(..., description="Auth status: authenticated, not_configured, unavailable, or error")
+ message: str | None = Field(None, description="Human-readable status detail")
+ user: str | None = Field(None, description="Authenticated Lark/Feishu user display value when available")
+ verified: bool = Field(False, description="Whether this status came from a live token verification")
+
+
+class LarkIntegrationStatusResponse(BaseModel):
+ installed: bool = Field(..., description="Whether the managed Lark skill pack is installed")
+ version: str = Field(..., description="Installed Lark CLI skill-pack version (from manifest, resolved at install time)")
+ manifest_version: str | None = Field(None, description="Installed manifest version")
+ latest_available_version: str | None = Field(None, description="Newest larksuite/cli release version available on GitHub, when known")
+ runtime_version_mismatch: bool = Field(False, description="Whether the installed skill-pack version differs from the Gateway runtime lark-cli binary")
+ app_configured: bool = Field(..., description="Whether lark-cli has app_id/app_secret configured for this user")
+ app_id: str | None = Field(None, description="Configured Lark app ID")
+ app_brand: str | None = Field(None, description="Configured Lark brand: feishu or lark")
+ skills_expected: int = Field(..., description="Number of skills expected in the official pack")
+ skills_installed: int = Field(..., description="Number of installed managed Lark skills")
+ installed_skills: list[str] = Field(default_factory=list, description="Installed managed Lark skill names")
+ enabled_skills: list[str] = Field(default_factory=list, description="Installed Lark skills currently enabled for this user")
+ install_path: str = Field(..., description="Host path of the managed Lark skill pack")
+ cli: LarkCliProbeResponse
+ auth: LarkAuthProbeResponse
+ sandbox_runtime_mode: str = Field("none", description="How lark-cli is provisioned into the sandbox: none, gateway-download, or init-container")
+ sandbox_runtime_ready: bool = Field(False, description="Whether the sandbox lark-cli runtime is provisioned and usable at chat time")
+ sandbox_runtime_detail: str | None = Field(None, description="Human-readable reason when the sandbox runtime is not ready")
+
+
+class LarkInstallResponse(BaseModel):
+ success: bool
+ installed_skills: list[str]
+ message: str
+ status: LarkIntegrationStatusResponse
+
+
+class LarkAuthStartRequest(BaseModel):
+ recommend: bool = Field(default=False, description="Request the official recommended auto-approve scopes")
+ domains: list[str] = Field(default_factory=list, description="Optional Lark auth domains, e.g. calendar or docs")
+ scope: str | None = Field(default=None, description="Optional explicit OAuth scope string")
+
+
+class LarkConfigStartRequest(BaseModel):
+ brand: str = Field(default="feishu", description="Lark brand to start app registration for: feishu or lark")
+
+
+class LarkConfigStartResponse(BaseModel):
+ verification_url: str = Field(..., description="URL the user should open in a browser to configure the Lark app")
+ device_code: str = Field(..., description="Device code used by config/complete after browser approval")
+ expires_in: int | None = Field(None, description="Seconds before the configuration URL expires")
+ interval: int | None = Field(None, description="Suggested polling interval from Lark")
+ user_code: str | None = Field(None, description="Optional user code shown by Lark")
+ brand: str = Field(..., description="Brand used for this app registration flow")
+
+
+class LarkConfigCompleteRequest(BaseModel):
+ device_code: str = Field(..., description="Device code returned by config/start")
+ brand: str = Field(default="feishu", description="Brand returned by config/start")
+ interval: int | None = Field(default=None, description="Polling interval returned by config/start")
+ expires_in: int | None = Field(default=None, description="Expiration returned by config/start")
+
+
+class LarkConfigCompleteResponse(BaseModel):
+ success: bool
+ message: str
+ status: LarkIntegrationStatusResponse
+
+
+class LarkAuthStartResponse(BaseModel):
+ verification_url: str = Field(..., description="URL the user should open in a browser to authorize")
+ device_code: str = Field(..., description="Device code used by the complete endpoint after browser approval")
+ expires_in: int | None = Field(None, description="Seconds before the authorization URL expires")
+ user_code: str | None = Field(None, description="Optional user code shown by Lark")
+ hint: str | None = Field(None, description="Optional guidance returned by lark-cli")
+
+
+class LarkAuthCompleteRequest(BaseModel):
+ device_code: str = Field(..., description="Device code returned by auth/start")
+ wait_timeout_seconds: int = Field(
+ default=LARK_AUTH_COMPLETE_DEFAULT_WAIT_SECONDS,
+ ge=LARK_AUTH_COMPLETE_MIN_WAIT_SECONDS,
+ le=LARK_AUTH_COMPLETE_MAX_WAIT_SECONDS,
+ description="Maximum seconds for this device-code poll; automatic UI polling uses a shorter wait",
+ )
+
+
+class LarkAuthCompleteResponse(BaseModel):
+ success: bool
+ message: str
+ status: LarkIntegrationStatusResponse
+
+
+def _cli_probe_to_response(probe: LarkCliProbe) -> LarkCliProbeResponse:
+ return LarkCliProbeResponse(
+ available=probe.available,
+ path=probe.path,
+ version=probe.version,
+ error=probe.error,
+ )
+
+
+def _auth_probe_to_response(probe: LarkAuthProbe) -> LarkAuthProbeResponse:
+ return LarkAuthProbeResponse(
+ status=probe.status,
+ message=probe.message,
+ user=probe.user,
+ verified=probe.verified,
+ )
+
+
+def _status_to_response(status: LarkIntegrationStatus, *, include_host_paths: bool = True) -> LarkIntegrationStatusResponse:
+ cli = _cli_probe_to_response(status.cli)
+ if not include_host_paths:
+ # Host filesystem paths (Gateway layout) are admin-only info; redact them
+ # for non-admin callers of the otherwise non-gated status/complete routes.
+ cli = cli.model_copy(update={"path": None})
+ return LarkIntegrationStatusResponse(
+ installed=status.installed,
+ version=status.version,
+ manifest_version=status.manifest_version,
+ latest_available_version=status.latest_available_version,
+ runtime_version_mismatch=status.runtime_version_mismatch,
+ app_configured=status.app_configured,
+ app_id=status.app_id,
+ app_brand=status.app_brand,
+ skills_expected=status.skills_expected,
+ skills_installed=status.skills_installed,
+ installed_skills=list(status.installed_skills),
+ enabled_skills=list(status.enabled_skills),
+ install_path=status.install_path if include_host_paths else "",
+ cli=cli,
+ auth=_auth_probe_to_response(status.auth),
+ sandbox_runtime_mode=status.sandbox_runtime_mode,
+ sandbox_runtime_ready=status.sandbox_runtime_ready,
+ sandbox_runtime_detail=status.sandbox_runtime_detail,
+ )
+
+
+def _install_to_response(result: LarkInstallResult) -> LarkInstallResponse:
+ return LarkInstallResponse(
+ success=result.success,
+ installed_skills=list(result.installed_skills),
+ message=result.message,
+ status=_status_to_response(result.status),
+ )
+
+
+def _config_start_to_response(result: LarkConfigStartResult) -> LarkConfigStartResponse:
+ return LarkConfigStartResponse(
+ verification_url=result.verification_url,
+ device_code=result.device_code,
+ expires_in=result.expires_in,
+ interval=result.interval,
+ user_code=result.user_code,
+ brand=result.brand,
+ )
+
+
+def _config_complete_to_response(result: LarkConfigCompleteResult, *, include_host_paths: bool = True) -> LarkConfigCompleteResponse:
+ return LarkConfigCompleteResponse(
+ success=result.success,
+ message=result.message,
+ status=_status_to_response(result.status, include_host_paths=include_host_paths),
+ )
+
+
+def _auth_start_to_response(result: LarkAuthStartResult) -> LarkAuthStartResponse:
+ return LarkAuthStartResponse(
+ verification_url=result.verification_url,
+ device_code=result.device_code,
+ expires_in=result.expires_in,
+ user_code=result.user_code,
+ hint=result.hint,
+ )
+
+
+def _auth_complete_to_response(result: LarkAuthCompleteResult, *, include_host_paths: bool = True) -> LarkAuthCompleteResponse:
+ return LarkAuthCompleteResponse(
+ success=result.success,
+ message=result.message,
+ status=_status_to_response(result.status, include_host_paths=include_host_paths),
+ )
+
+
+@router.get("/lark/status", response_model=LarkIntegrationStatusResponse, summary="Get Lark/Feishu Integration Status")
+async def get_lark_status(request: Request, config: AppConfig = Depends(get_config)) -> LarkIntegrationStatusResponse:
+ try:
+ status = await asyncio.to_thread(get_lark_integration_status, get_effective_user_id(), config, check_latest=True, check_runtime=True)
+ return _status_to_response(status, include_host_paths=await _is_admin_user(request))
+ except Exception as e:
+ logger.error("Failed to get Lark integration status: %s", e, exc_info=True)
+ raise HTTPException(status_code=500, detail="Failed to get Lark integration status.")
+
+
+@router.post("/lark/install", response_model=LarkInstallResponse, summary="Install Lark/Feishu Skill Pack")
+async def install_lark(request: Request, config: AppConfig = Depends(get_config)) -> LarkInstallResponse:
+ await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
+ try:
+ result = await asyncio.to_thread(install_lark_integration, get_effective_user_id(), config)
+ await refresh_skills_system_prompt_cache_async()
+ return _install_to_response(result)
+ except FileNotFoundError as e:
+ raise HTTPException(status_code=404, detail=str(e))
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("Failed to install Lark integration: %s", e, exc_info=True)
+ raise HTTPException(status_code=500, detail="Failed to install Lark integration.")
+
+
+@router.post("/lark/config/start", response_model=LarkConfigStartResponse, summary="Start Lark/Feishu App Configuration")
+async def start_lark_app_config(body: LarkConfigStartRequest) -> LarkConfigStartResponse:
+ try:
+ result = await asyncio.to_thread(
+ start_lark_config,
+ get_effective_user_id(),
+ brand=body.brand,
+ )
+ return _config_start_to_response(result)
+ except FileNotFoundError as e:
+ raise HTTPException(status_code=404, detail=str(e))
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except TimeoutError as e:
+ raise HTTPException(status_code=504, detail=str(e))
+ except Exception as e:
+ logger.error("Failed to start Lark connection setup: %s", e, exc_info=True)
+ raise HTTPException(status_code=500, detail="Failed to start Lark connection setup.")
+
+
+@router.post("/lark/config/complete", response_model=LarkConfigCompleteResponse, summary="Complete Lark/Feishu App Configuration")
+async def complete_lark_app_config(request: Request, body: LarkConfigCompleteRequest, config: AppConfig = Depends(get_config)) -> LarkConfigCompleteResponse:
+ try:
+ result = await asyncio.to_thread(
+ complete_lark_config,
+ get_effective_user_id(),
+ config,
+ device_code=body.device_code,
+ brand=body.brand,
+ interval=body.interval,
+ expires_in=body.expires_in,
+ )
+ return _config_complete_to_response(result, include_host_paths=await _is_admin_user(request))
+ except FileNotFoundError as e:
+ raise HTTPException(status_code=404, detail=str(e))
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except TimeoutError as e:
+ raise HTTPException(status_code=504, detail=str(e))
+ except Exception as e:
+ logger.error("Failed to complete Lark connection setup: %s", e, exc_info=True)
+ raise HTTPException(status_code=500, detail="Failed to complete Lark connection setup.")
+
+
+@router.post("/lark/auth/start", response_model=LarkAuthStartResponse, summary="Start Lark/Feishu Browser Authorization")
+async def start_lark_browser_auth(body: LarkAuthStartRequest) -> LarkAuthStartResponse:
+ try:
+ result = await asyncio.to_thread(
+ start_lark_auth,
+ get_effective_user_id(),
+ domains=tuple(body.domains),
+ scope=body.scope,
+ recommend=body.recommend,
+ )
+ return _auth_start_to_response(result)
+ except FileNotFoundError as e:
+ raise HTTPException(status_code=404, detail=str(e))
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except TimeoutError as e:
+ raise HTTPException(status_code=504, detail=str(e))
+ except Exception as e:
+ logger.error("Failed to start Lark authorization: %s", e, exc_info=True)
+ raise HTTPException(status_code=500, detail="Failed to start Lark authorization.")
+
+
+@router.post("/lark/auth/complete", response_model=LarkAuthCompleteResponse, summary="Complete Lark/Feishu Browser Authorization")
+async def complete_lark_browser_auth(request: Request, body: LarkAuthCompleteRequest, config: AppConfig = Depends(get_config)) -> LarkAuthCompleteResponse:
+ try:
+ result = await asyncio.to_thread(
+ complete_lark_auth,
+ get_effective_user_id(),
+ config,
+ device_code=body.device_code,
+ wait_timeout_seconds=body.wait_timeout_seconds,
+ )
+ return _auth_complete_to_response(result, include_host_paths=await _is_admin_user(request))
+ except FileNotFoundError as e:
+ raise HTTPException(status_code=404, detail=str(e))
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except TimeoutError as e:
+ raise HTTPException(status_code=504, detail=str(e))
+ except Exception as e:
+ logger.error("Failed to complete Lark authorization: %s", e, exc_info=True)
+ raise HTTPException(status_code=500, detail="Failed to complete Lark authorization.")
diff --git a/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py b/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py
index 09bccad91..f3f7df088 100644
--- a/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py
+++ b/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py
@@ -39,6 +39,8 @@ from deerflow.community.warm_pool_lifecycle import (
)
from deerflow.config import get_app_config
from deerflow.config.paths import VIRTUAL_PATH_PREFIX, get_paths, join_host_path
+from deerflow.integrations.lark_cli import INTEGRATION_ID as LARK_CLI_INTEGRATION_ID
+from deerflow.integrations.lark_cli import LARK_CLI_SANDBOX_CONFIG_DIR, LARK_CLI_SANDBOX_DATA_DIR, LARK_CLI_SANDBOX_RUNTIME_DIR, ensure_lark_cli_credential_tree, lark_skills_installed
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.sandbox.sandbox import Sandbox
from deerflow.sandbox.sandbox_provider import SandboxProvider
@@ -744,7 +746,40 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
mounts.extend(skills_mounts)
logger.info(f"Adding skills mounts: {skills_mounts}")
- return mounts
+ user_skill_mounts = self._get_user_skill_mounts(user_id=user_id)
+ if user_skill_mounts:
+ mounts.extend(user_skill_mounts)
+ logger.info(f"Adding user skill mounts: {user_skill_mounts}")
+
+ lark_cli_mounts = self._get_lark_cli_runtime_mounts(user_id=user_id)
+ if lark_cli_mounts:
+ mounts.extend(lark_cli_mounts)
+ logger.info(f"Adding Lark CLI runtime mounts: {lark_cli_mounts}")
+
+ return self._dedupe_mounts_by_container_path(mounts)
+
+ @staticmethod
+ def _dedupe_mounts_by_container_path(mounts: list[tuple[str, str, bool]]) -> list[tuple[str, str, bool]]:
+ """Keep the first mount for each container path.
+
+ Duplicate container paths are rejected by the provisioner and can also
+ fail local Docker creation. The earlier mount wins because mount helpers
+ are appended in priority order: thread data, skill roots, integration
+ skill roots, then integration runtimes/credentials.
+ """
+ seen: set[str] = set()
+ deduped: list[tuple[str, str, bool]] = []
+ for host_path, container_path, read_only in mounts:
+ if container_path in seen:
+ logger.warning(
+ "Skipping duplicate sandbox mount for container path %s from host %s",
+ container_path,
+ host_path,
+ )
+ continue
+ seen.add(container_path)
+ deduped.append((host_path, container_path, read_only))
+ return deduped
@staticmethod
def _get_thread_mounts(thread_id: str, *, user_id: str | None = None) -> list[tuple[str, str, bool]]:
@@ -839,6 +874,84 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
return mounts
+ @staticmethod
+ def _get_user_skill_mounts(*, user_id: str | None = None) -> list[tuple[str, str, bool]]:
+ """Mount managed integration skills into AIO sandboxes.
+
+ Per-user custom skills are already mounted by ``_get_skills_mounts``.
+ This helper adds the shared integration skill root so sandbox paths match
+ the skill registry without duplicating ``/mnt/skills/custom``.
+ """
+ try:
+ config = get_app_config()
+ paths = get_paths()
+ skills_container_path = config.skills.container_path
+ paths.integration_skills_dir().mkdir(parents=True, exist_ok=True)
+ return [
+ (paths.host_integration_skills_dir(), f"{skills_container_path}/integrations", True),
+ ]
+ except Exception as e:
+ logger.warning(f"Could not setup user skill mounts: {e}")
+ return []
+
+ @staticmethod
+ def _lark_integration_active(user_id: str | None = None) -> bool:
+ """Whether the managed Lark skill pack is installed for this user.
+
+ Drives whether a sandbox requests the lark-cli runtime (init container /
+ Gateway-download mount). Independent of whether a local ``sandbox-cli``
+ dir exists, so remote/K8s can opt in without a Gateway-side download.
+ """
+ try:
+ effective_user_id = AioSandboxProvider._effective_acquire_user_id(user_id)
+ return lark_skills_installed(effective_user_id)
+ except Exception as e: # pragma: no cover - defensive
+ logger.warning(f"Could not determine Lark integration state: {e}")
+ return False
+
+ @staticmethod
+ def _get_lark_cli_runtime_mounts(*, user_id: str | None = None) -> list[tuple[str, str, bool]]:
+ """Mount the per-user lark-cli config/data dirs used by Settings auth.
+
+ Settings endpoints run ``lark-cli`` on the Gateway with
+ ``LARKSUITE_CLI_CONFIG_DIR`` / ``DATA_DIR`` pointing at
+ ``users/{user}/integrations/lark-cli``. Agent conversations run
+ ``lark-cli`` inside the sandbox, so those same directories must be
+ mounted into the container or the CLI sees a separate unauthenticated
+ profile.
+
+ The ``config`` dir holds the long-lived Lark ``appSecret`` (written by
+ ``lark-cli config init`` on the Gateway, never in-sandbox), so it is
+ mounted **read-only**: sandbox processes only need to read it, and a
+ read-only bind stops a compromised agent from tampering with or
+ replacing the app credentials. The ``data`` dir holds refreshable OAuth
+ tokens that ``lark-cli auth`` updates in-sandbox, so it stays writable.
+ This is defense-in-depth only — both dirs remain readable to arbitrary
+ sandbox processes until the auth-proxy follow-up (issue #4338) lands.
+ See the sandbox trust-boundary note in ``backend/AGENTS.md``.
+ """
+ try:
+ paths = get_paths()
+ effective_user_id = AioSandboxProvider._effective_acquire_user_id(user_id)
+ ensure_lark_cli_credential_tree(effective_user_id, paths=paths)
+ mounts = [
+ (paths.host_user_integration_config_dir(effective_user_id, LARK_CLI_INTEGRATION_ID), LARK_CLI_SANDBOX_CONFIG_DIR, True),
+ (paths.host_user_integration_data_dir(effective_user_id, LARK_CLI_INTEGRATION_ID), LARK_CLI_SANDBOX_DATA_DIR, False),
+ ]
+ runtime_dir = paths.base_dir / "integrations" / LARK_CLI_INTEGRATION_ID / "sandbox-cli"
+ if runtime_dir.is_dir():
+ mounts.append(
+ (
+ join_host_path(str(paths.host_base_dir), "integrations", LARK_CLI_INTEGRATION_ID, "sandbox-cli"),
+ LARK_CLI_SANDBOX_RUNTIME_DIR,
+ True,
+ )
+ )
+ return mounts
+ except Exception as e:
+ logger.warning(f"Could not setup Lark CLI runtime mounts: {e}")
+ return []
+
# ── Idle timeout management ──────────────────────────────────────────
def _cleanup_idle_resources(self, idle_timeout: float) -> None:
@@ -1694,6 +1807,7 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
"""
effective_user_id = self._effective_acquire_user_id(user_id)
extra_mounts = self._get_extra_mounts(thread_id, user_id=effective_user_id)
+ provision_lark_cli_runtime = self._lark_integration_active(effective_user_id)
# Enforce replicas: only warm-pool containers count toward eviction budget.
# Active sandboxes are in use by live threads and must not be forcibly stopped.
@@ -1702,7 +1816,13 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
evicted = self._evict_oldest_warm()
self._log_replicas_soft_cap(replicas, sandbox_id, evicted)
- info = self._backend.create(thread_id, sandbox_id, extra_mounts=extra_mounts or None, user_id=effective_user_id)
+ info = self._backend.create(
+ thread_id,
+ sandbox_id,
+ extra_mounts=extra_mounts or None,
+ user_id=effective_user_id,
+ provision_lark_cli_runtime=provision_lark_cli_runtime,
+ )
# Wait for sandbox to be ready
if not wait_for_sandbox_ready(info.sandbox_url, timeout=60):
@@ -1715,6 +1835,7 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
"""Async counterpart to ``_create_sandbox``."""
effective_user_id = self._effective_acquire_user_id(user_id)
extra_mounts = await asyncio.to_thread(self._get_extra_mounts, thread_id, user_id=effective_user_id)
+ provision_lark_cli_runtime = await asyncio.to_thread(self._lark_integration_active, effective_user_id)
# Enforce replicas: only warm-pool containers count toward eviction budget.
# Active sandboxes are in use by live threads and must not be forcibly stopped.
@@ -1723,7 +1844,14 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
evicted = await asyncio.to_thread(self._evict_oldest_warm)
self._log_replicas_soft_cap(replicas, sandbox_id, evicted)
- info = await asyncio.to_thread(self._backend.create, thread_id, sandbox_id, extra_mounts=extra_mounts or None, user_id=effective_user_id)
+ info = await asyncio.to_thread(
+ self._backend.create,
+ thread_id,
+ sandbox_id,
+ extra_mounts=extra_mounts or None,
+ user_id=effective_user_id,
+ provision_lark_cli_runtime=provision_lark_cli_runtime,
+ )
# Wait for sandbox to be ready without blocking the event loop.
if not await wait_for_sandbox_ready_async(info.sandbox_url, timeout=60):
diff --git a/backend/packages/harness/deerflow/community/aio_sandbox/backend.py b/backend/packages/harness/deerflow/community/aio_sandbox/backend.py
index 0ff9cb3ea..28e614890 100644
--- a/backend/packages/harness/deerflow/community/aio_sandbox/backend.py
+++ b/backend/packages/harness/deerflow/community/aio_sandbox/backend.py
@@ -81,6 +81,7 @@ class SandboxBackend(ABC):
extra_mounts: list[tuple[str, str, bool]] | None = None,
*,
user_id: str | None = None,
+ provision_lark_cli_runtime: bool = False,
) -> SandboxInfo:
"""Create/provision a new sandbox.
@@ -90,6 +91,9 @@ class SandboxBackend(ABC):
extra_mounts: Additional volume mounts as (host_path, container_path, read_only) tuples.
Ignored by backends that don't manage containers (e.g., remote).
user_id: User bucket that the sandbox should mount or provision for.
+ provision_lark_cli_runtime: Ask the backend to provision the sandbox
+ lark-cli runtime via its native mechanism (e.g. the provisioner's
+ init container + emptyDir). Backends that can't do this ignore it.
Returns:
SandboxInfo with connection details.
diff --git a/backend/packages/harness/deerflow/community/aio_sandbox/local_backend.py b/backend/packages/harness/deerflow/community/aio_sandbox/local_backend.py
index 6876a3ebb..29f211405 100644
--- a/backend/packages/harness/deerflow/community/aio_sandbox/local_backend.py
+++ b/backend/packages/harness/deerflow/community/aio_sandbox/local_backend.py
@@ -271,6 +271,7 @@ class LocalContainerBackend(SandboxBackend):
extra_mounts: list[tuple[str, str, bool]] | None = None,
*,
user_id: str | None = None,
+ provision_lark_cli_runtime: bool = False,
) -> SandboxInfo:
"""Start a new container and return its connection info.
@@ -280,6 +281,8 @@ class LocalContainerBackend(SandboxBackend):
extra_mounts: Additional volume mounts as (host_path, container_path, read_only) tuples.
user_id: User bucket already reflected in extra_mounts. Accepted for
interface compatibility with remote backends.
+ provision_lark_cli_runtime: Ignored — the local backend provisions the
+ lark-cli runtime via the Gateway-download bind mount in extra_mounts.
Returns:
SandboxInfo with container details.
@@ -287,7 +290,7 @@ class LocalContainerBackend(SandboxBackend):
Raises:
RuntimeError: If the container fails to start.
"""
- del user_id
+ del user_id, provision_lark_cli_runtime
container_name = f"{self._container_prefix}-{sandbox_id}"
# Retry loop: if Docker rejects the port (e.g. a stale container still
diff --git a/backend/packages/harness/deerflow/community/aio_sandbox/remote_backend.py b/backend/packages/harness/deerflow/community/aio_sandbox/remote_backend.py
index fb2692199..75e8372dd 100644
--- a/backend/packages/harness/deerflow/community/aio_sandbox/remote_backend.py
+++ b/backend/packages/harness/deerflow/community/aio_sandbox/remote_backend.py
@@ -29,6 +29,48 @@ from .sandbox_info import SandboxInfo
logger = logging.getLogger(__name__)
+_PROVISIONER_EXTRA_MOUNT_PATHS = {
+ "/mnt/acp-workspace",
+ "/mnt/skills/custom",
+ "/mnt/skills/integrations",
+ "/mnt/integrations/lark-cli/config",
+ "/mnt/integrations/lark-cli/data",
+ "/mnt/integrations/lark-cli/runtime",
+}
+
+_LARK_CLI_RUNTIME_CONTAINER_PATH = "/mnt/integrations/lark-cli/runtime"
+
+
+def _provisioner_extra_mounts_payload(
+ extra_mounts: list[tuple[str, str, bool]] | None,
+ *,
+ provision_lark_cli_runtime: bool = False,
+) -> list[dict[str, object]]:
+ """Return only extra mounts the provisioner knows how to recreate safely.
+
+ When ``provision_lark_cli_runtime`` is set, the provisioner supplies the
+ lark-cli runtime via an init container + emptyDir, so the runtime extra mount
+ is dropped here to avoid a colliding hostPath/PVC mount at the same path. The
+ per-user config/data credential mounts are always forwarded.
+ """
+ if not extra_mounts:
+ return []
+
+ payload: list[dict[str, object]] = []
+ for host_path, container_path, read_only in extra_mounts:
+ if container_path not in _PROVISIONER_EXTRA_MOUNT_PATHS:
+ continue
+ if provision_lark_cli_runtime and container_path == _LARK_CLI_RUNTIME_CONTAINER_PATH:
+ continue
+ payload.append(
+ {
+ "host_path": host_path,
+ "container_path": container_path,
+ "read_only": read_only,
+ }
+ )
+ return payload
+
class RemoteSandboxBackend(SandboxBackend):
"""Backend that delegates sandbox lifecycle to the provisioner service.
@@ -72,13 +114,20 @@ class RemoteSandboxBackend(SandboxBackend):
extra_mounts: list[tuple[str, str, bool]] | None = None,
*,
user_id: str | None = None,
+ provision_lark_cli_runtime: bool = False,
) -> SandboxInfo:
"""Create a sandbox Pod + Service via the provisioner.
Calls ``POST /api/sandboxes`` which creates a dedicated Pod +
NodePort Service in k3s.
"""
- return self._provisioner_create(thread_id, sandbox_id, extra_mounts, user_id=user_id)
+ return self._provisioner_create(
+ thread_id,
+ sandbox_id,
+ extra_mounts,
+ user_id=user_id,
+ provision_lark_cli_runtime=provision_lark_cli_runtime,
+ )
def destroy(self, info: SandboxInfo) -> None:
"""Destroy a sandbox Pod + Service via the provisioner."""
@@ -149,20 +198,28 @@ class RemoteSandboxBackend(SandboxBackend):
extra_mounts: list[tuple[str, str, bool]] | None = None,
*,
user_id: str | None = None,
+ provision_lark_cli_runtime: bool = False,
) -> SandboxInfo:
"""POST /api/sandboxes → create Pod + Service."""
- del extra_mounts
effective_user_id = user_id or get_effective_user_id()
include_legacy_skills = user_should_see_legacy_skills(effective_user_id)
+ payload = {
+ "sandbox_id": sandbox_id,
+ "thread_id": thread_id,
+ "user_id": effective_user_id,
+ "include_legacy_skills": include_legacy_skills,
+ "provision_lark_cli_runtime": provision_lark_cli_runtime,
+ }
+ provisioner_extra_mounts = _provisioner_extra_mounts_payload(
+ extra_mounts,
+ provision_lark_cli_runtime=provision_lark_cli_runtime,
+ )
+ if provisioner_extra_mounts:
+ payload["extra_mounts"] = provisioner_extra_mounts
try:
resp = requests.post(
f"{self._provisioner_url}/api/sandboxes",
- json={
- "sandbox_id": sandbox_id,
- "thread_id": thread_id,
- "user_id": effective_user_id,
- "include_legacy_skills": include_legacy_skills,
- },
+ json=payload,
headers=self._auth_headers(),
timeout=30,
)
diff --git a/backend/packages/harness/deerflow/config/extensions_config.py b/backend/packages/harness/deerflow/config/extensions_config.py
index 1e886387f..0f2040879 100644
--- a/backend/packages/harness/deerflow/config/extensions_config.py
+++ b/backend/packages/harness/deerflow/config/extensions_config.py
@@ -315,7 +315,7 @@ class ExtensionsConfig(BaseModel):
skill_config = self.skills.get(skill_name)
if skill_config is None:
# Default to enabled for all skill categories
- return skill_category in ("public", "custom", "legacy")
+ return skill_category in ("public", "custom", "legacy", "integrations")
return skill_config.enabled
diff --git a/backend/packages/harness/deerflow/config/paths.py b/backend/packages/harness/deerflow/config/paths.py
index be52e1950..1d846f7e4 100644
--- a/backend/packages/harness/deerflow/config/paths.py
+++ b/backend/packages/harness/deerflow/config/paths.py
@@ -12,6 +12,7 @@ VIRTUAL_PATH_PREFIX = "/mnt/user-data"
_SAFE_THREAD_ID_RE = re.compile(r"^[A-Za-z0-9_\-]+$")
_SAFE_USER_ID_RE = re.compile(r"^[A-Za-z0-9_\-]+$")
+_SAFE_INTEGRATION_ID_RE = re.compile(r"^[A-Za-z0-9_.\-]+$")
_UNSAFE_USER_ID_CHAR_RE = re.compile(r"[^A-Za-z0-9_\-]")
_SAFE_USER_ID_DIGEST_HEX_LEN = 16
@@ -37,6 +38,18 @@ def _validate_user_id(user_id: str) -> str:
return user_id
+def _validate_integration_id(integration_id: str) -> str:
+ """Validate an integration ID before using it in filesystem paths."""
+ if not _SAFE_INTEGRATION_ID_RE.match(integration_id):
+ raise ValueError(f"Invalid integration_id {integration_id!r}: only alphanumeric characters, dots, hyphens, and underscores are allowed.")
+ # The charset allows dots for names like ``some.integration``; reject the
+ # bare ``.``/``..`` path components so a future caller cannot escape the
+ # per-integration namespace via ``_join_host_path(..., integration_id, ...)``.
+ if integration_id in {".", ".."}:
+ raise ValueError(f"Invalid integration_id {integration_id!r}: '.' and '..' are not allowed.")
+ return integration_id
+
+
def make_safe_user_id(raw: str) -> str:
"""Normalize an external identity into the user-id charset (``[A-Za-z0-9_-]``).
@@ -236,6 +249,15 @@ class Paths:
"""
return self.user_skills_dir(user_id) / "custom"
+ def integration_skills_dir(self) -> Path:
+ """Globally installed managed integration skills.
+
+ Layout: ``{base_dir}/integrations/skills/{provider}/{skill}/``. The
+ package contents are shared and read-only; credentials and enabled
+ state remain user-scoped elsewhere under ``users/{user_id}``.
+ """
+ return self.base_dir / "integrations" / "skills"
+
def thread_dir(self, thread_id: str, *, user_id: str | None = None) -> Path:
"""
Host path for a thread's data.
@@ -325,6 +347,22 @@ class Paths:
"""Host path for the ACP workspace mount source."""
return _join_host_path(self.host_thread_dir(thread_id, user_id=user_id), "acp-workspace")
+ def host_user_custom_skills_dir(self, user_id: str) -> str:
+ """Host path for a user's custom skills directory, preserving Windows path syntax."""
+ return _join_host_path(self._host_base_dir_str(), "users", _validate_user_id(user_id), "skills", "custom")
+
+ def host_integration_skills_dir(self) -> str:
+ """Host path for globally installed managed integration skills."""
+ return _join_host_path(self._host_base_dir_str(), "integrations", "skills")
+
+ def host_user_integration_config_dir(self, user_id: str, integration_id: str) -> str:
+ """Host path for a user's managed integration runtime config directory."""
+ return _join_host_path(self._host_base_dir_str(), "users", _validate_user_id(user_id), "integrations", _validate_integration_id(integration_id), "config")
+
+ def host_user_integration_data_dir(self, user_id: str, integration_id: str) -> str:
+ """Host path for a user's managed integration runtime data directory."""
+ return _join_host_path(self._host_base_dir_str(), "users", _validate_user_id(user_id), "integrations", _validate_integration_id(integration_id), "data")
+
def ensure_thread_dirs(self, thread_id: str, *, user_id: str | None = None) -> None:
"""Create all standard sandbox directories for a thread.
diff --git a/backend/packages/harness/deerflow/integrations/__init__.py b/backend/packages/harness/deerflow/integrations/__init__.py
new file mode 100644
index 000000000..83d0c5cb6
--- /dev/null
+++ b/backend/packages/harness/deerflow/integrations/__init__.py
@@ -0,0 +1 @@
+"""First-party integration installers and status helpers."""
diff --git a/backend/packages/harness/deerflow/integrations/lark_cli.py b/backend/packages/harness/deerflow/integrations/lark_cli.py
new file mode 100644
index 000000000..1ed11413c
--- /dev/null
+++ b/backend/packages/harness/deerflow/integrations/lark_cli.py
@@ -0,0 +1,1637 @@
+"""Managed Lark/Feishu CLI integration support.
+
+The integration installs the official ``lark-*`` AI-agent skills into a
+global read-only managed integration skill directory. It deliberately
+does not use the ordinary custom-skill archive path: this is a trusted,
+versioned first-party integration package, not user-authored mutable content.
+
+Version resolution & integrity
+-------------------------------
+The installed skill-pack version follows the Gateway runtime ``lark-cli``
+binary version (``lark-cli --version``). This keeps the managed skills aligned
+with the server-side CLI that will execute them. ``FALLBACK_LARK_CLI_VERSION``
+matches the Dockerfile/npm pin and is used only when the runtime binary is
+unavailable or does not report a parseable version.
+
+Integrity is enforced without pinning a per-version archive byte hash (GitHub
+does not guarantee source-archive bytes are stable across their internal git
+upgrades, and pinning conflicts with tracking latest). Instead:
+
+* the download source is fixed to the official GitHub host over HTTPS and the
+ version only comes from the Gateway runtime CLI version or the pinned
+ fallback (no external URL injection);
+* every archive member passes structural guards (zip-slip / symlink /
+ executable-binary / size / required-skill completeness / ``SKILL.md`` parse);
+* a **content** SHA-256 over the extracted skill tree, after DeerFlow's shared
+ guidance is injected, is recorded in the manifest, so a reinstall whose
+ effective skill content changed is detectable/auditable even when GitHub
+ re-packs identical content with different archive bytes.
+
+Runtime coupling: the npm-installed ``lark-cli`` binary version is pinned in
+``backend/Dockerfile`` (``ARG LARK_CLI_NPM_VERSION``) and
+``docker/docker-compose*.yaml`` as a bootstrap fallback. The admin install path
+also manages a writable DeerFlow-owned Gateway CLI under
+``.deer-flow/integrations/lark-cli/gateway-cli`` and prefers it over the system
+PATH, so users do not need to run terminal installation commands. Reinstalling
+the integration refreshes both the managed Gateway CLI and the skill pack to the
+same version when network access is available. ``get_lark_integration_status``
+surfaces ``latest_available_version`` and ``runtime_version_mismatch`` for
+operators, and ``test_python_and_docker_lark_cli_versions_match`` pins the
+fallback constant to the Dockerfile ARG so packaged deployments do not silently
+diverge.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import io
+import json
+import logging
+import os
+import posixpath
+import re
+import shutil
+import subprocess
+import tarfile
+import tempfile
+import threading
+import time
+import urllib.parse
+import urllib.request
+import zipfile
+from contextlib import contextmanager
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from pathlib import Path, PurePosixPath
+from typing import Any
+
+try:
+ import fcntl
+except ImportError: # pragma: no cover - Windows fallback
+ fcntl = None # type: ignore[assignment]
+ import msvcrt
+
+from deerflow.config.app_config import AppConfig
+from deerflow.config.paths import Paths, get_paths
+from deerflow.skills.installer import is_executable_binary_prefix, is_symlink_member, is_unsafe_zip_member
+from deerflow.skills.parser import parse_skill_file
+from deerflow.skills.permissions import make_skill_tree_sandbox_readable
+from deerflow.skills.types import SKILL_MD_FILE, SkillCategory
+
+logger = logging.getLogger(__name__)
+
+INTEGRATION_ID = "lark-cli"
+# Matches the Gateway image/npm pin. Used when the runtime binary is unavailable
+# or reports an unparsable version.
+FALLBACK_LARK_CLI_VERSION = "v1.0.65"
+LARK_CLI_NPM_VERSION = FALLBACK_LARK_CLI_VERSION.removeprefix("v")
+LARK_CLI_NPM_PACKAGE = "@larksuite/cli"
+LARK_CLI_GITHUB_REPO = "larksuite/cli"
+LARK_CLI_LATEST_RELEASE_API = f"https://api.github.com/repos/{LARK_CLI_GITHUB_REPO}/releases/latest"
+LARK_CLI_SOURCE_ARCHIVE_ENV = "DEER_FLOW_LARK_CLI_SKILLS_ARCHIVE"
+LARK_CLI_SANDBOX_RUNTIME_SOURCE_ENV = "DEER_FLOW_LARK_CLI_SANDBOX_RUNTIME_DIR"
+LARK_CLI_DOWNLOAD_TIMEOUT_SECONDS = 60
+LARK_CLI_NPM_INSTALL_TIMEOUT_SECONDS = 180
+LARK_HTTP_TIMEOUT_SECONDS = 20
+LARK_CONFIG_POLL_TIMEOUT_SECONDS = 45
+LARK_AUTH_COMPLETE_DEFAULT_WAIT_SECONDS = 45
+LARK_AUTH_COMPLETE_MIN_WAIT_SECONDS = 5
+LARK_AUTH_COMPLETE_MAX_WAIT_SECONDS = 45
+LARK_CLI_LATEST_VERSION_TTL_SECONDS = 3600
+LARK_CLI_MAX_ARCHIVE_BYTES = 128 * 1024 * 1024
+LARK_CLI_MAX_EXTRACTED_BYTES = 256 * 1024 * 1024
+LARK_CLI_MAX_RUNTIME_ASSET_BYTES = 128 * 1024 * 1024
+LARK_CLI_MANIFEST_FILE = ".deerflow-lark-cli-manifest.json"
+LARK_CLI_SANDBOX_CONFIG_DIR = "/mnt/integrations/lark-cli/config"
+LARK_CLI_SANDBOX_DATA_DIR = "/mnt/integrations/lark-cli/data"
+LARK_CLI_SANDBOX_RUNTIME_DIR = "/mnt/integrations/lark-cli/runtime"
+LARK_CLI_LINUX_ARCHES = ("amd64", "arm64")
+LARK_CLI_RUNTIME_MANIFEST_FILE = ".deerflow-lark-cli-runtime.json"
+
+# Arch-dispatch launcher for the sandbox runtime layout. Shared by the Gateway
+# writer (`_write_lark_cli_sandbox_launcher`) and the `docker/lark-cli-init`
+# init image so the two producers of `bin/lark-cli` never drift.
+LARK_CLI_SANDBOX_LAUNCHER_SCRIPT = """#!/bin/sh
+set -eu
+case "$(uname -m)" in
+ x86_64|amd64) arch=amd64 ;;
+ aarch64|arm64) arch=arm64 ;;
+ *) echo "Unsupported sandbox architecture: $(uname -m)" >&2; exit 126 ;;
+esac
+script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+exec "$script_dir/../linux-$arch/lark-cli" "$@"
+"""
+_VERSION_TAG_RE = re.compile(r"v?\d+\.\d+\.\d+")
+_DEERFLOW_LARK_SHARED_GUIDANCE_MARKER = ""
+_DEERFLOW_LARK_SHARED_GUIDANCE_LEGACY_MARKERS = ("",)
+_LARK_APP_REGISTRATION_PATH = "/oauth/v1/app/registration"
+
+LARK_SKILL_NAMES: tuple[str, ...] = (
+ "lark-approval",
+ "lark-apps",
+ "lark-attendance",
+ "lark-base",
+ "lark-calendar",
+ "lark-contact",
+ "lark-doc",
+ "lark-drive",
+ "lark-event",
+ "lark-im",
+ "lark-mail",
+ "lark-markdown",
+ "lark-minutes",
+ "lark-note",
+ "lark-okr",
+ "lark-openapi-explorer",
+ "lark-shared",
+ "lark-sheets",
+ "lark-skill-maker",
+ "lark-slides",
+ "lark-task",
+ "lark-vc",
+ "lark-vc-agent",
+ "lark-whiteboard",
+ "lark-wiki",
+ "lark-workflow-meeting-summary",
+ "lark-workflow-standup-report",
+)
+LARK_SKILL_NAME_SET = frozenset(LARK_SKILL_NAMES)
+_LARK_INSTALL_THREAD_LOCK = threading.Lock()
+_LARK_RUNTIME_INSTALL_THREAD_LOCK = threading.Lock()
+
+
+@dataclass(frozen=True)
+class LarkCliProbe:
+ available: bool
+ path: str | None = None
+ version: str | None = None
+ error: str | None = None
+
+
+@dataclass(frozen=True)
+class LarkAuthProbe:
+ status: str
+ message: str | None = None
+ user: str | None = None
+ verified: bool = False
+
+
+@dataclass(frozen=True)
+class LarkIntegrationStatus:
+ installed: bool
+ version: str
+ manifest_version: str | None
+ latest_available_version: str | None
+ runtime_version_mismatch: bool
+ app_configured: bool
+ app_id: str | None
+ app_brand: str | None
+ skills_expected: int
+ skills_installed: int
+ installed_skills: tuple[str, ...]
+ enabled_skills: tuple[str, ...]
+ install_path: str
+ cli: LarkCliProbe
+ auth: LarkAuthProbe
+ sandbox_runtime_mode: str = "none"
+ sandbox_runtime_ready: bool = False
+ sandbox_runtime_detail: str | None = None
+
+
+@dataclass(frozen=True)
+class LarkInstallResult:
+ success: bool
+ installed_skills: tuple[str, ...]
+ status: LarkIntegrationStatus
+ message: str
+
+
+@dataclass(frozen=True)
+class LarkConfigStartResult:
+ verification_url: str
+ device_code: str
+ expires_in: int | None = None
+ interval: int | None = None
+ user_code: str | None = None
+ brand: str = "feishu"
+
+
+@dataclass(frozen=True)
+class LarkConfigCompleteResult:
+ success: bool
+ status: LarkIntegrationStatus
+ message: str
+
+
+@dataclass(frozen=True)
+class LarkAuthStartResult:
+ verification_url: str
+ device_code: str
+ expires_in: int | None = None
+ user_code: str | None = None
+ hint: str | None = None
+
+
+@dataclass(frozen=True)
+class LarkAuthCompleteResult:
+ success: bool
+ status: LarkIntegrationStatus
+ message: str
+
+
+def lark_integration_root(_user_id: str | None = None) -> Path:
+ """Return the shared root for globally installed managed Lark skills.
+
+ ``_user_id`` is accepted temporarily for source compatibility with the
+ pre-global-install API; it does not influence the shared package path.
+ """
+ return get_paths().integration_skills_dir() / INTEGRATION_ID
+
+
+def lark_manifest_path(user_id: str) -> Path:
+ return lark_integration_root(user_id) / LARK_CLI_MANIFEST_FILE
+
+
+def lark_skills_installed(user_id: str | None = None) -> bool:
+ """Whether the managed Lark skill pack is installed.
+
+ Mirrors the ``installed`` field of :func:`get_lark_integration_status`
+ (manifest present and ``lark-shared`` extracted) without probing the CLI or
+ auth. Used to decide whether a sandbox should request the lark-cli runtime.
+ """
+ root = lark_integration_root(user_id)
+ manifest = _read_manifest(root)
+ if not manifest:
+ return False
+ return "lark-shared" in _installed_lark_skill_names(root)
+
+
+def lark_cli_config_dir(user_id: str) -> Path:
+ return get_paths().user_dir(user_id) / "integrations" / INTEGRATION_ID / "config"
+
+
+def lark_cli_data_dir(user_id: str) -> Path:
+ return get_paths().user_dir(user_id) / "integrations" / INTEGRATION_ID / "data"
+
+
+def ensure_lark_cli_credential_tree(user_id: str, *, paths: Paths | None = None) -> None:
+ """Make the user's secret-bearing Lark CLI tree owner-only.
+
+ The CLI writes plaintext app secrets and OAuth tokens beneath this tree.
+ Reject links before changing modes so a compromised tree cannot redirect a
+ chmod or subsequent CLI write outside the user's integration directory.
+ """
+ paths = paths or get_paths()
+ root = paths.user_dir(user_id) / "integrations" / INTEGRATION_ID
+ if root.is_symlink():
+ raise ValueError(f"Lark CLI credential path must not be a symlink: {root}")
+ root.mkdir(parents=True, exist_ok=True, mode=0o700)
+ root.chmod(0o700)
+ for required in (root / "config", root / "data"):
+ if required.is_symlink():
+ raise ValueError(f"Lark CLI credential path must not be a symlink: {required}")
+ required.mkdir(parents=True, exist_ok=True, mode=0o700)
+ for path in root.rglob("*"):
+ if path.is_symlink():
+ raise ValueError(f"Lark CLI credential path must not be a symlink: {path}")
+ if path.is_dir():
+ path.chmod(0o700)
+ elif path.is_file():
+ path.chmod(0o600)
+ else:
+ raise ValueError(f"Unsupported file type in Lark CLI credential tree: {path}")
+
+
+def lark_cli_managed_gateway_dir() -> Path:
+ """Gateway-scoped DeerFlow-managed lark-cli install root."""
+ return get_paths().base_dir / "integrations" / INTEGRATION_ID / "gateway-cli"
+
+
+def lark_cli_managed_sandbox_dir() -> Path:
+ """Gateway-visible source directory mounted into Linux AIO sandboxes."""
+ return get_paths().base_dir / "integrations" / INTEGRATION_ID / "sandbox-cli"
+
+
+def _lark_cli_release_asset_name(version: str, arch: str) -> str:
+ tag = _normalize_lark_cli_version_tag(version)
+ if tag is None:
+ raise ValueError(f"Invalid Lark CLI version tag: {version!r}")
+ if arch not in LARK_CLI_LINUX_ARCHES:
+ raise ValueError(f"Unsupported Lark CLI Linux architecture: {arch!r}")
+ return f"lark-cli-{tag.removeprefix('v')}-linux-{arch}.tar.gz"
+
+
+def _lark_cli_release_asset_url(version: str, asset_name: str) -> str:
+ tag = _normalize_lark_cli_version_tag(version)
+ if tag is None:
+ raise ValueError(f"Invalid Lark CLI version tag: {version!r}")
+ quoted_asset = urllib.parse.quote(asset_name, safe="")
+ return f"https://github.com/{LARK_CLI_GITHUB_REPO}/releases/download/{tag}/{quoted_asset}"
+
+
+def _download_lark_release_asset(version: str, asset_name: str, *, max_bytes: int = LARK_CLI_MAX_RUNTIME_ASSET_BYTES) -> bytes:
+ """Download one official release asset with a strict size bound."""
+ request = urllib.request.Request(
+ _lark_cli_release_asset_url(version, asset_name),
+ headers={"Accept": "application/octet-stream", "User-Agent": "deer-flow"},
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=LARK_CLI_DOWNLOAD_TIMEOUT_SECONDS) as response:
+ chunks: list[bytes] = []
+ total = 0
+ while chunk := response.read(1024 * 1024):
+ total += len(chunk)
+ if total > max_bytes:
+ raise ValueError(f"Lark CLI release asset {asset_name!r} is too large.")
+ chunks.append(chunk)
+ except ValueError:
+ raise
+ except Exception as exc: # noqa: BLE001 - network boundary
+ raise ValueError(f"Could not download official Lark CLI release asset {asset_name!r} for {version}.") from exc
+ return b"".join(chunks)
+
+
+def _release_checksums(raw: bytes) -> dict[str, str]:
+ checksums: dict[str, str] = {}
+ try:
+ text = raw.decode("utf-8")
+ except UnicodeDecodeError as exc:
+ raise ValueError("Lark CLI release checksums are not valid UTF-8.") from exc
+ for line in text.splitlines():
+ parts = line.strip().split()
+ if len(parts) < 2 or not re.fullmatch(r"[0-9a-fA-F]{64}", parts[0]):
+ continue
+ checksums[parts[-1].lstrip("*")] = parts[0].lower()
+ return checksums
+
+
+def _extract_lark_cli_runtime_binary(archive: bytes, destination: Path) -> None:
+ """Safely extract the single CLI executable from an official tar archive."""
+ candidate: bytes | None = None
+ total = 0
+ try:
+ with tarfile.open(fileobj=io.BytesIO(archive), mode="r:*") as tf:
+ for member in tf.getmembers():
+ normalized = posixpath.normpath(member.name.replace("\\", "/"))
+ parts = PurePosixPath(normalized).parts
+ if normalized.startswith("/") or ".." in parts or member.issym() or member.islnk() or not (member.isdir() or member.isfile()):
+ raise ValueError(f"Unsafe Lark CLI runtime archive member: {member.name}")
+ if member.isfile():
+ total += member.size
+ if total > LARK_CLI_MAX_RUNTIME_ASSET_BYTES:
+ raise ValueError("Lark CLI runtime archive expands beyond the allowed size.")
+ if PurePosixPath(normalized).name == "lark-cli":
+ extracted = tf.extractfile(member)
+ if extracted is None or candidate is not None:
+ raise ValueError("Lark CLI runtime archive must contain exactly one lark-cli executable.")
+ candidate = extracted.read()
+ except tarfile.TarError as exc:
+ raise ValueError("Lark CLI runtime archive is not a valid tar archive.") from exc
+ if not candidate:
+ raise ValueError("Lark CLI runtime archive does not contain a lark-cli executable.")
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ destination.write_bytes(candidate)
+ destination.chmod(0o755)
+
+
+def _write_lark_cli_sandbox_launcher(staging: Path) -> None:
+ launcher = staging / "bin" / "lark-cli"
+ launcher.parent.mkdir(parents=True, exist_ok=True)
+ launcher.write_text(LARK_CLI_SANDBOX_LAUNCHER_SCRIPT, encoding="utf-8")
+ launcher.chmod(0o755)
+
+
+def _validate_lark_cli_sandbox_runtime(root: Path) -> None:
+ if root.is_symlink() or not root.is_dir():
+ raise ValueError("Managed Lark CLI sandbox runtime root must be a regular directory, not a symlink.")
+ for path in root.rglob("*"):
+ if path.is_symlink():
+ raise ValueError(f"Managed Lark CLI sandbox runtime must not contain a symlink: {path}")
+ if not (path.is_dir() or path.is_file()):
+ raise ValueError(f"Managed Lark CLI sandbox runtime contains an unsupported file type: {path}")
+ for relative in (Path("bin/lark-cli"), *(Path(f"linux-{arch}/lark-cli") for arch in LARK_CLI_LINUX_ARCHES)):
+ candidate = root / relative
+ if not candidate.is_file():
+ raise ValueError(f"Managed Lark CLI sandbox runtime is missing a regular file: {relative}")
+ if candidate.stat().st_mode & 0o111 == 0:
+ raise ValueError(f"Managed Lark CLI sandbox runtime file is not executable: {relative}")
+
+
+def _read_json_object_file(path: Path) -> dict[str, Any] | None:
+ if not path.is_file():
+ return None
+ try:
+ parsed = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return None
+ return parsed if isinstance(parsed, dict) else None
+
+
+@contextmanager
+def _exclusive_install_lock(lock_path: Path, thread_lock):
+ """Hold one advisory file lock plus its in-process counterpart."""
+ with thread_lock, lock_path.open("a+b") as lock_file:
+ lock_file.seek(0, os.SEEK_END)
+ if lock_file.tell() == 0:
+ lock_file.write(b"\0")
+ lock_file.flush()
+ lock_file.seek(0)
+ if fcntl is not None:
+ fcntl.flock(lock_file, fcntl.LOCK_EX)
+ else: # pragma: no cover - Windows fallback
+ msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
+ try:
+ yield
+ finally:
+ lock_file.seek(0)
+ if fcntl is not None:
+ fcntl.flock(lock_file, fcntl.LOCK_UN)
+ else: # pragma: no cover - Windows fallback
+ msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
+
+
+def _ensure_managed_sandbox_lark_cli(version: str) -> Path:
+ """Install verified official Linux binaries for AIO sandbox execution."""
+ tag = _normalize_lark_cli_version_tag(version)
+ if tag is None:
+ raise ValueError(f"Invalid Lark CLI version tag: {version!r}")
+ target = lark_cli_managed_sandbox_dir()
+ parent = target.parent
+ parent.mkdir(parents=True, exist_ok=True)
+ with _exclusive_install_lock(parent / ".sandbox-cli.install.lock", _LARK_RUNTIME_INSTALL_THREAD_LOCK):
+ return _ensure_managed_sandbox_lark_cli_locked(tag, target, parent)
+
+
+def _ensure_managed_sandbox_lark_cli_locked(tag: str, target: Path, parent: Path) -> Path:
+ manifest = _read_json_object_file(target / LARK_CLI_RUNTIME_MANIFEST_FILE)
+ if manifest and manifest.get("version") == tag:
+ _validate_lark_cli_sandbox_runtime(target)
+ return target
+
+ staging = Path(tempfile.mkdtemp(prefix=".installing-sandbox-cli-", dir=str(parent)))
+ backup: Path | None = None
+ try:
+ source_override = os.getenv(LARK_CLI_SANDBOX_RUNTIME_SOURCE_ENV)
+ if source_override:
+ source = Path(source_override)
+ _validate_lark_cli_sandbox_runtime(source)
+ shutil.copytree(source, staging, dirs_exist_ok=True, symlinks=False)
+ else:
+ checksums = _release_checksums(_download_lark_release_asset(tag, "checksums.txt", max_bytes=1024 * 1024))
+ for arch in LARK_CLI_LINUX_ARCHES:
+ asset_name = _lark_cli_release_asset_name(tag, arch)
+ archive = _download_lark_release_asset(tag, asset_name)
+ expected = checksums.get(asset_name)
+ actual = hashlib.sha256(archive).hexdigest()
+ if expected is None or actual != expected:
+ raise ValueError(f"Lark CLI release asset checksum mismatch: {asset_name}")
+ _extract_lark_cli_runtime_binary(archive, staging / f"linux-{arch}" / "lark-cli")
+ _write_lark_cli_sandbox_launcher(staging)
+
+ _validate_lark_cli_sandbox_runtime(staging)
+ (staging / LARK_CLI_RUNTIME_MANIFEST_FILE).write_text(
+ json.dumps({"version": tag}, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ if target.exists():
+ backup = parent / f".replacing-sandbox-cli-{os.getpid()}"
+ if backup.exists():
+ shutil.rmtree(backup, ignore_errors=True)
+ target.rename(backup)
+ staging.rename(target)
+ if backup is not None:
+ shutil.rmtree(backup, ignore_errors=True)
+ return target
+ except Exception:
+ if backup is not None and backup.exists() and not target.exists():
+ backup.rename(target)
+ raise
+ finally:
+ shutil.rmtree(staging, ignore_errors=True)
+
+
+def _lark_cli_managed_bin_dir() -> Path:
+ return lark_cli_managed_gateway_dir() / "node_modules" / ".bin"
+
+
+def _lark_cli_managed_path() -> str | None:
+ for name in ("lark-cli", "lark-cli.cmd"):
+ candidate = _lark_cli_managed_bin_dir() / name
+ if candidate.exists():
+ return str(candidate)
+ return None
+
+
+def lark_cli_env_overlay(user_id: str, *, sandbox_paths: bool = False) -> dict[str, str]:
+ """Environment overlay for lark-cli using DeerFlow-managed credentials.
+
+ The directories are per-user so a local trusted-mode login cannot bleed
+ across accounts. Auth Proxy support can later replace these directories for
+ sandbox execution without changing the status API contract.
+ """
+ if sandbox_paths:
+ config_dir: Path | str = LARK_CLI_SANDBOX_CONFIG_DIR
+ data_dir: Path | str = LARK_CLI_SANDBOX_DATA_DIR
+ else:
+ config_dir = lark_cli_config_dir(user_id)
+ data_dir = lark_cli_data_dir(user_id)
+ ensure_lark_cli_credential_tree(user_id)
+ overlay = {
+ "LARKSUITE_CLI_CONFIG_DIR": str(config_dir),
+ "LARKSUITE_CLI_DATA_DIR": str(data_dir),
+ "LARKSUITE_CLI_NO_UPDATE_NOTIFIER": "1",
+ "LARKSUITE_CLI_NO_SKILLS_NOTIFIER": "1",
+ }
+ if not sandbox_paths and _lark_cli_managed_path() is not None:
+ overlay["PATH"] = f"{_lark_cli_managed_bin_dir()}{os.pathsep}{os.environ.get('PATH', '')}"
+ elif sandbox_paths:
+ overlay["PATH"] = f"{LARK_CLI_SANDBOX_RUNTIME_DIR}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
+ return overlay
+
+
+def lark_cli_env(user_id: str) -> dict[str, str]:
+ """Full environment for Gateway-side lark-cli probes."""
+ return {**os.environ, **lark_cli_env_overlay(user_id)}
+
+
+def probe_lark_cli() -> LarkCliProbe:
+ path = _resolve_lark_cli_path()
+ if path is None:
+ return LarkCliProbe(available=False, error="lark-cli is not installed on the Gateway")
+ return _probe_lark_cli_at_path(path)
+
+
+def _probe_lark_cli_at_path(path: str) -> LarkCliProbe:
+ try:
+ result = subprocess.run(
+ [path, "--version"],
+ check=False,
+ capture_output=True,
+ text=True,
+ timeout=5,
+ )
+ except Exception as exc: # noqa: BLE001 - probe boundary
+ return LarkCliProbe(available=False, path=path, error=str(exc))
+
+ output = (result.stdout or result.stderr or "").strip()
+ if result.returncode != 0:
+ return LarkCliProbe(available=False, path=path, error=output or f"exit code {result.returncode}")
+ return LarkCliProbe(available=True, path=path, version=output or None)
+
+
+def probe_lark_auth(user_id: str, *, verify: bool = False) -> LarkAuthProbe:
+ """Probe the user's Lark authorization state.
+
+ By default this only checks local token presence (``auth status --json``),
+ which is cheap and offline — suitable for the frequently-polled status
+ endpoint. Pass ``verify=True`` to add ``--verify`` for a live token check
+ against Lark; reserve that for the explicit "complete authorization" step
+ since it costs a network round-trip on every call.
+ """
+ path = _resolve_lark_cli_path()
+ if path is None:
+ return LarkAuthProbe(status="unavailable", message="lark-cli is not installed on the Gateway")
+ app_config = read_lark_app_config(user_id)
+ if not app_config["configured"]:
+ return LarkAuthProbe(status="not_configured", message="Lark app is not configured")
+ args = [path, "auth", "status", "--json"]
+ if verify:
+ args.append("--verify")
+ try:
+ result = subprocess.run(
+ args,
+ check=False,
+ capture_output=True,
+ text=True,
+ timeout=8,
+ env=lark_cli_env(user_id),
+ )
+ except subprocess.TimeoutExpired:
+ return LarkAuthProbe(status="error", message="lark-cli auth status timed out")
+ except Exception as exc: # noqa: BLE001 - probe boundary
+ return LarkAuthProbe(status="error", message=str(exc))
+
+ raw = (result.stdout or result.stderr or "").strip()
+ data: dict[str, Any] | None = None
+ if raw:
+ try:
+ parsed = json.loads(raw)
+ data = parsed if isinstance(parsed, dict) else None
+ except json.JSONDecodeError:
+ data = None
+
+ if result.returncode != 0:
+ message = _auth_error_message(data) if data else raw
+ return LarkAuthProbe(status="not_authorized", message=message or "Lark user authorization is not configured")
+
+ user = None
+ if data:
+ identities = data.get("identities")
+ if isinstance(identities, dict):
+ user_info = identities.get("user")
+ if isinstance(user_info, dict):
+ user = str(user_info.get("userName") or user_info.get("openId") or "") or None
+ if user is None and data.get("userName"):
+ user = str(data["userName"])
+ if verify:
+ return LarkAuthProbe(
+ status="authenticated",
+ user=user,
+ message="Lark/Feishu authorization is live-verified.",
+ verified=True,
+ )
+ return LarkAuthProbe(
+ status="authenticated",
+ user=user,
+ message="Lark/Feishu credentials are configured locally but not live-verified.",
+ verified=False,
+ )
+
+
+def _resolve_sandbox_runtime_readiness(
+ config: AppConfig,
+ *,
+ probe: bool,
+) -> tuple[str, bool, str | None]:
+ """Resolve the sandbox lark-cli runtime mode and readiness.
+
+ Modes:
+ - ``none``: sandboxes don't run lark-cli (non-AIO provider).
+ - ``gateway-download``: local AIO — the Gateway stages a ``sandbox-cli`` dir
+ and bind-mounts it; ready when that dir validates.
+ - ``init-container``: remote provisioner — a lark-cli init image provisions
+ the runtime; ready when the provisioner reports the init image is configured.
+
+ ``probe`` gates the (best-effort, short-timeout) provisioner capability call.
+ """
+ if not _uses_aio_sandbox(config):
+ return "none", False, "Sandbox does not run lark-cli in this configuration."
+
+ if _uses_remote_provisioner(config):
+ if not probe:
+ return "init-container", False, None
+ init_image = _probe_provisioner_lark_cli_init_image(config)
+ if init_image is None:
+ return "init-container", False, "Could not reach the provisioner to confirm the lark-cli init image."
+ if not init_image:
+ return "init-container", False, "The provisioner has no lark-cli init image configured (LARK_CLI_INIT_IMAGE)."
+ return "init-container", True, None
+
+ # Local AIO: Gateway-download runtime dir.
+ runtime_dir = lark_cli_managed_sandbox_dir()
+ try:
+ _validate_lark_cli_sandbox_runtime(runtime_dir)
+ except (ValueError, OSError):
+ return "gateway-download", False, "The managed sandbox lark-cli runtime is not installed."
+ return "gateway-download", True, None
+
+
+def get_lark_integration_status(
+ user_id: str,
+ config: AppConfig,
+ *,
+ verify_auth: bool = False,
+ check_latest: bool = False,
+ check_runtime: bool = False,
+) -> LarkIntegrationStatus:
+ root = lark_integration_root(user_id)
+ manifest = _read_manifest(root)
+ app_config = read_lark_app_config(user_id)
+ installed_skills = tuple(sorted(_installed_lark_skill_names(root)))
+ enabled_skills = tuple(sorted(_enabled_lark_skill_names(user_id, config)))
+ manifest_version = str(manifest.get("version")) if manifest else None
+ cli = probe_lark_cli()
+ latest_available = _cached_latest_lark_cli_version() if check_latest else None
+ runtime_mode, runtime_ready, runtime_detail = _resolve_sandbox_runtime_readiness(config, probe=check_runtime)
+ return LarkIntegrationStatus(
+ installed=bool(manifest) and "lark-shared" in installed_skills,
+ version=manifest_version or FALLBACK_LARK_CLI_VERSION,
+ manifest_version=manifest_version,
+ latest_available_version=latest_available,
+ runtime_version_mismatch=_versions_drifted(manifest_version, cli.version),
+ app_configured=bool(app_config["configured"]),
+ app_id=app_config["app_id"],
+ app_brand=app_config["brand"],
+ skills_expected=len(LARK_SKILL_NAMES),
+ skills_installed=len(installed_skills),
+ installed_skills=installed_skills,
+ enabled_skills=enabled_skills,
+ install_path=str(root),
+ cli=cli,
+ auth=probe_lark_auth(user_id, verify=verify_auth),
+ sandbox_runtime_mode=runtime_mode,
+ sandbox_runtime_ready=runtime_ready,
+ sandbox_runtime_detail=runtime_detail,
+ )
+
+
+def _normalize_version(value: str | None) -> str | None:
+ """Extract a comparable ``major.minor.patch`` from a version-ish string."""
+ if not value:
+ return None
+ match = re.search(r"\d+\.\d+\.\d+", value)
+ return match.group(0) if match else None
+
+
+def _versions_drifted(manifest_version: str | None, cli_version: str | None) -> bool:
+ """True when both versions are known and their numeric cores differ.
+
+ The manifest records the installed skill-pack version; ``cli_version`` is
+ the Gateway runtime ``lark-cli`` binary. Unknown on either side means we
+ cannot claim a mismatch, so we stay quiet.
+ """
+ left = _normalize_version(manifest_version)
+ right = _normalize_version(cli_version)
+ if left is None or right is None:
+ return False
+ return left != right
+
+
+def _resolve_runtime_lark_cli_version() -> str:
+ """Resolve the skill-pack version that matches the Gateway runtime CLI.
+
+ Managed Lark skills are executed by the server-side ``lark-cli`` binary, so
+ integration installs should align to that binary rather than blindly taking
+ GitHub's newest release. Packaged deployments install the pinned fallback in
+ the Gateway image; local/dev deployments can override this by putting a
+ newer ``lark-cli`` on the Gateway PATH and restarting the backend.
+ """
+ cli = probe_lark_cli()
+ version = _normalize_version(cli.version)
+ return f"v{version}" if version is not None else FALLBACK_LARK_CLI_VERSION
+
+
+def read_lark_app_config(user_id: str) -> dict[str, str | bool | None]:
+ ensure_lark_cli_credential_tree(user_id)
+ config_path = lark_cli_config_dir(user_id) / "config.json"
+ if not config_path.is_file():
+ return {"configured": False, "app_id": None, "brand": None}
+ try:
+ data = json.loads(config_path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return {"configured": False, "app_id": None, "brand": None}
+ if not isinstance(data, dict):
+ return {"configured": False, "app_id": None, "brand": None}
+ apps = data.get("apps")
+ if not isinstance(apps, list) or not apps:
+ return {"configured": False, "app_id": None, "brand": None}
+ current = data.get("currentApp")
+ app = None
+ if isinstance(current, str) and current:
+ app = next((candidate for candidate in apps if isinstance(candidate, dict) and (candidate.get("name") == current or candidate.get("appId") == current)), None)
+ if app is None:
+ app = apps[0] if isinstance(apps[0], dict) else None
+ if not isinstance(app, dict):
+ return {"configured": False, "app_id": None, "brand": None}
+ app_id = str(app.get("appId") or "").strip()
+ app_secret = app.get("appSecret")
+ brand = str(app.get("brand") or "feishu").strip() or "feishu"
+ return {"configured": bool(app_id and app_secret), "app_id": app_id or None, "brand": brand}
+
+
+def install_lark_integration(
+ user_id: str,
+ config: AppConfig,
+ *,
+ source_archive: str | Path | None = None,
+) -> LarkInstallResult:
+ env_archive = os.getenv(LARK_CLI_SOURCE_ARCHIVE_ENV)
+ if source_archive is not None:
+ archive_path = Path(source_archive)
+ resolved_version = None
+ created_temp_archive = False
+ elif env_archive:
+ archive_path = Path(env_archive)
+ resolved_version = None
+ created_temp_archive = False
+ else:
+ cli = _ensure_managed_gateway_lark_cli()
+ runtime_version = _normalize_version(cli.version)
+ resolved_version = f"v{runtime_version}" if runtime_version is not None else FALLBACK_LARK_CLI_VERSION
+ archive_path = _download_lark_archive(resolved_version)
+ created_temp_archive = True
+
+ previous = _read_manifest(lark_integration_root(user_id))
+ previous_content_sha = str(previous.get("content_sha256")) if previous else None
+ try:
+ installed_skills, content_sha = _install_lark_skills_from_archive(user_id, archive_path, version=resolved_version)
+ finally:
+ if created_temp_archive:
+ try:
+ archive_path.unlink(missing_ok=True)
+ except OSError:
+ pass
+
+ if _uses_aio_sandbox(config) and not _uses_remote_provisioner(config):
+ installed_manifest = _read_manifest(lark_integration_root()) or {}
+ sandbox_version = str(installed_manifest.get("version") or resolved_version or FALLBACK_LARK_CLI_VERSION)
+ _ensure_managed_sandbox_lark_cli(sandbox_version)
+
+ status = get_lark_integration_status(user_id, config)
+ content_changed = previous_content_sha is not None and previous_content_sha != content_sha
+ message = f"Installed {len(installed_skills)} Lark/Feishu skills."
+ if content_changed:
+ message += " Skill content changed since the previous install."
+ return LarkInstallResult(
+ success=True,
+ installed_skills=installed_skills,
+ status=status,
+ message=message,
+ )
+
+
+def _uses_aio_sandbox(config: AppConfig) -> bool:
+ sandbox = getattr(config, "sandbox", None)
+ use = getattr(sandbox, "use", None)
+ if use is None and isinstance(sandbox, dict):
+ use = sandbox.get("use")
+ return isinstance(use, str) and "aio_sandbox" in use.lower()
+
+
+def _sandbox_config_value(config: AppConfig, key: str) -> str:
+ """Read a sandbox config value as a string, tolerating dict/attr configs."""
+ sandbox = getattr(config, "sandbox", None)
+ value = getattr(sandbox, key, None)
+ if value is None and isinstance(sandbox, dict):
+ value = sandbox.get(key)
+ return str(value).strip() if value else ""
+
+
+def _uses_remote_provisioner(config: AppConfig) -> bool:
+ """True when sandboxes are provisioned by a remote provisioner (K8s mode)."""
+ return bool(_sandbox_config_value(config, "provisioner_url"))
+
+
+def _probe_provisioner_lark_cli_init_image(config: AppConfig) -> bool | None:
+ """Best-effort read of the provisioner's lark-cli init-image capability.
+
+ Returns True/False when the provisioner answers, or None when it can't be
+ reached. Used only to surface a sandbox-runtime readiness signal; failures
+ degrade to "not ready" rather than raising.
+ """
+ base = _sandbox_config_value(config, "provisioner_url")
+ if not base:
+ return None
+ api_key = _sandbox_config_value(config, "provisioner_api_key")
+ headers = {"X-API-Key": api_key} if api_key else {}
+ url = f"{base.rstrip('/')}/api/capabilities"
+ try:
+ request = urllib.request.Request(url, headers={"User-Agent": "deer-flow", **headers})
+ with urllib.request.urlopen(request, timeout=5) as response:
+ payload = json.loads(response.read().decode("utf-8"))
+ return bool(payload.get("lark_cli_init_image"))
+ except Exception:
+ return None
+
+
+def start_lark_config(user_id: str, *, brand: str = "feishu") -> LarkConfigStartResult:
+ """Start the browser flow that creates/binds a Lark OAuth app for this user."""
+ parsed_brand = _normalize_lark_brand(brand)
+ begin_data = _request_lark_app_registration_begin(parsed_brand)
+ user_code = str(begin_data.get("user_code") or "").strip()
+ device_code = str(begin_data.get("device_code") or "").strip()
+ if not user_code or not device_code:
+ raise ValueError("Lark app registration did not return a user_code and device_code.")
+ verification_url = _build_lark_config_verification_url(parsed_brand, user_code)
+ return LarkConfigStartResult(
+ verification_url=verification_url,
+ device_code=device_code,
+ expires_in=_int_or_none(begin_data.get("expires_in")),
+ interval=_int_or_none(begin_data.get("interval")),
+ user_code=user_code,
+ brand=parsed_brand,
+ )
+
+
+def complete_lark_config(
+ user_id: str,
+ config: AppConfig,
+ *,
+ device_code: str,
+ brand: str = "feishu",
+ interval: int | None = None,
+ expires_in: int | None = None,
+) -> LarkConfigCompleteResult:
+ """Complete app registration and persist app credentials through lark-cli."""
+ device_code = device_code.strip()
+ if not device_code:
+ raise ValueError("device_code is required.")
+ parsed_brand = _normalize_lark_brand(brand)
+ result = _poll_lark_app_registration(
+ device_code=device_code,
+ brand=parsed_brand,
+ interval=interval or 5,
+ expires_in=expires_in or 300,
+ )
+ if not result.get("client_secret") and _tenant_brand(result) == "lark":
+ # Lark CLI starts polling on the Feishu accounts host for both brands.
+ # For Lark tenants that response can include user_info.tenant_brand and
+ # client_id but omit client_secret; polling the Lark accounts host with
+ # the same device_code returns the complete app credentials.
+ result = _poll_lark_app_registration(
+ device_code=device_code,
+ brand="lark",
+ interval=interval or 5,
+ expires_in=expires_in or 300,
+ )
+
+ app_id = str(result.get("client_id") or "").strip()
+ app_secret = str(result.get("client_secret") or "").strip()
+ final_brand = _tenant_brand(result) or parsed_brand
+ if not app_id or not app_secret:
+ raise ValueError("Lark app registration succeeded but did not return app credentials.")
+
+ _save_lark_app_config_with_cli(user_id, app_id=app_id, app_secret=app_secret, brand=final_brand)
+ status = get_lark_integration_status(user_id, config)
+ return LarkConfigCompleteResult(
+ success=True,
+ status=status,
+ message="Lark/Feishu connection setup completed.",
+ )
+
+
+def start_lark_auth(
+ user_id: str,
+ *,
+ domains: tuple[str, ...] = (),
+ scope: str | None = None,
+ recommend: bool = False,
+) -> LarkAuthStartResult:
+ """Start a non-blocking Lark device authorization flow.
+
+ The returned URL is safe to show in the browser UI or in a chat message.
+ ``device_code`` must be sent back to :func:`complete_lark_auth` after the
+ user finishes authorization in Lark/Feishu.
+ """
+ path = _require_lark_cli_path()
+ args = [path, "auth", "login", "--no-wait", "--json"]
+ if recommend:
+ args.append("--recommend")
+ if scope:
+ args.extend(["--scope", scope])
+ for domain in domains:
+ if domain:
+ args.extend(["--domain", domain])
+
+ data = _run_lark_cli_json(args, user_id=user_id, timeout=20)
+ verification_url = str(data.get("verification_url") or data.get("verification_uri_complete") or "").strip()
+ device_code = str(data.get("device_code") or "").strip()
+ if not verification_url or not device_code:
+ raise ValueError("lark-cli did not return a verification_url and device_code.")
+
+ return LarkAuthStartResult(
+ verification_url=verification_url,
+ device_code=device_code,
+ expires_in=_int_or_none(data.get("expires_in")),
+ user_code=str(data.get("user_code") or "") or None,
+ hint=str(data.get("hint") or "") or None,
+ )
+
+
+def complete_lark_auth(
+ user_id: str,
+ config: AppConfig,
+ *,
+ device_code: str,
+ wait_timeout_seconds: int = LARK_AUTH_COMPLETE_DEFAULT_WAIT_SECONDS,
+) -> LarkAuthCompleteResult:
+ """Complete a Lark device authorization flow after the user approves it."""
+ device_code = device_code.strip()
+ if not device_code:
+ raise ValueError("device_code is required.")
+ if not LARK_AUTH_COMPLETE_MIN_WAIT_SECONDS <= wait_timeout_seconds <= LARK_AUTH_COMPLETE_MAX_WAIT_SECONDS:
+ raise ValueError(f"wait_timeout_seconds must be between {LARK_AUTH_COMPLETE_MIN_WAIT_SECONDS} and {LARK_AUTH_COMPLETE_MAX_WAIT_SECONDS}.")
+
+ path = _require_lark_cli_path()
+ _run_lark_cli_json(
+ [path, "auth", "login", "--device-code", device_code, "--json"],
+ user_id=user_id,
+ timeout=wait_timeout_seconds,
+ allow_empty_success=True,
+ )
+ status = get_lark_integration_status(user_id, config, verify_auth=True)
+ return LarkAuthCompleteResult(
+ success=status.auth.status == "authenticated",
+ status=status,
+ message="Lark/Feishu authorization completed." if status.auth.status == "authenticated" else (status.auth.message or "Lark/Feishu authorization status is still pending."),
+ )
+
+
+def _resolve_lark_cli_path() -> str | None:
+ return _lark_cli_managed_path() or shutil.which("lark-cli")
+
+
+def _ensure_managed_gateway_lark_cli() -> LarkCliProbe:
+ """Install/update the DeerFlow-managed Gateway lark-cli.
+
+ This is called by the admin install endpoint so non-technical users do not
+ need to install ``@larksuite/cli`` in a terminal. If npm/GitHub are not
+ reachable but an existing CLI is already available (managed or on PATH), we
+ keep using it and let the skill-pack install align to that runtime version.
+ """
+ target_version = _resolve_latest_lark_cli_version()
+ current = probe_lark_cli()
+ current_version = _normalize_version(current.version)
+ if current.available and current_version == _normalize_version(target_version):
+ return current
+
+ try:
+ return _install_managed_gateway_lark_cli(target_version)
+ except Exception:
+ fallback = probe_lark_cli()
+ if fallback.available:
+ logger.warning("Could not update managed lark-cli; using existing Gateway lark-cli", exc_info=True)
+ return fallback
+ raise
+
+
+def _install_managed_gateway_lark_cli(version: str) -> LarkCliProbe:
+ normalized = _normalize_lark_cli_version_tag(version)
+ if normalized is None:
+ raise ValueError(f"Invalid Lark CLI npm version: {version!r}")
+ npm_version = normalized.removeprefix("v")
+ npm = shutil.which("npm")
+ if npm is None:
+ raise FileNotFoundError("npm is not available on the Gateway; cannot install managed @larksuite/cli.")
+
+ install_root = lark_cli_managed_gateway_dir()
+ install_root.mkdir(parents=True, exist_ok=True)
+ # NOTE: this runs @larksuite/cli's install scripts (postinstall fetches the
+ # platform lark-cli binary), so `--ignore-scripts` is not viable here — the
+ # CLI would be unusable without it. The tradeoff: an admin-triggered install
+ # executes the official package's install scripts (and those of its deps)
+ # with Gateway privileges, so a supply-chain compromise of that package is
+ # the blast radius. This mirrors the pinned `npm install -g` in the Gateway
+ # Dockerfile; both are gated behind the admin install action.
+ result = subprocess.run(
+ [
+ npm,
+ "install",
+ "--prefix",
+ str(install_root),
+ "--no-audit",
+ "--no-fund",
+ f"{LARK_CLI_NPM_PACKAGE}@{npm_version}",
+ ],
+ check=False,
+ capture_output=True,
+ text=True,
+ timeout=LARK_CLI_NPM_INSTALL_TIMEOUT_SECONDS,
+ env={**os.environ, "npm_config_update_notifier": "false"},
+ )
+ if result.returncode != 0:
+ raw = (result.stderr or result.stdout or "").strip()
+ raise ValueError(raw or f"npm install {LARK_CLI_NPM_PACKAGE}@{npm_version} exited with code {result.returncode}")
+
+ path = _lark_cli_managed_path()
+ if path is None:
+ raise FileNotFoundError("Managed lark-cli install completed, but no lark-cli binary was found.")
+ probe = _probe_lark_cli_at_path(path)
+ if not probe.available:
+ raise ValueError(probe.error or "Managed lark-cli install did not produce a runnable CLI.")
+ return probe
+
+
+def _require_lark_cli_path() -> str:
+ path = _resolve_lark_cli_path()
+ if path is None:
+ raise FileNotFoundError("lark-cli is not installed on the Gateway. Install the managed Lark integration as an admin, or rebuild the Gateway image with @larksuite/cli installed.")
+ return path
+
+
+def _normalize_lark_brand(brand: str) -> str:
+ return "lark" if brand.strip().lower() == "lark" else "feishu"
+
+
+def _lark_endpoints(brand: str) -> dict[str, str]:
+ if _normalize_lark_brand(brand) == "lark":
+ return {
+ "open": "https://open.larksuite.com",
+ "accounts": "https://accounts.larksuite.com",
+ }
+ return {
+ "open": "https://open.feishu.cn",
+ "accounts": "https://accounts.feishu.cn",
+ }
+
+
+def _request_lark_app_registration_begin(brand: str) -> dict[str, Any]:
+ # lark-cli uses the Feishu accounts endpoint for the begin step, then
+ # switches to the tenant brand only if the poll response indicates Lark.
+ accounts_url = _lark_endpoints("feishu")["accounts"] + _LARK_APP_REGISTRATION_PATH
+ body = urllib.parse.urlencode(
+ {
+ "action": "begin",
+ "archetype": "PersonalAgent",
+ "auth_method": "client_secret",
+ "request_user_info": "open_id tenant_brand",
+ }
+ ).encode("utf-8")
+ data = _post_lark_form(accounts_url, body)
+ if "error" in data:
+ raise ValueError(str(data.get("error_description") or data.get("error") or "Lark app registration failed."))
+ return data
+
+
+def _build_lark_config_verification_url(brand: str, user_code: str) -> str:
+ base = f"{_lark_endpoints(brand)['open']}/page/cli"
+ # lpv/ocv mirror the *runtime* lark-cli client version doing the auth, which
+ # is the server-side Gateway binary — not the latest available skill-pack
+ # version.
+ runtime_version = _resolve_runtime_lark_cli_version()
+ query = urllib.parse.urlencode(
+ {
+ "user_code": user_code,
+ "lpv": runtime_version,
+ "ocv": runtime_version,
+ "from": "cli",
+ }
+ )
+ return f"{base}?{query}"
+
+
+def _poll_lark_app_registration(
+ *,
+ device_code: str,
+ brand: str,
+ interval: int,
+ expires_in: int,
+) -> dict[str, Any]:
+ accounts_url = _lark_endpoints(brand)["accounts"] + _LARK_APP_REGISTRATION_PATH
+ deadline = time.monotonic() + min(max(expires_in, 1), LARK_CONFIG_POLL_TIMEOUT_SECONDS)
+ poll_interval = max(min(interval, 10), 1)
+ last_error = "authorization_pending"
+ while time.monotonic() < deadline:
+ body = urllib.parse.urlencode({"action": "poll", "device_code": device_code}).encode("utf-8")
+ data = _post_lark_form(accounts_url, body)
+ if not data.get("error") and data.get("client_id"):
+ return data
+ error = str(data.get("error") or "")
+ last_error = str(data.get("error_description") or error or "Lark app registration is still pending.")
+ if error == "authorization_pending":
+ time.sleep(poll_interval)
+ continue
+ if error == "slow_down":
+ poll_interval = min(poll_interval + 5, 30)
+ time.sleep(poll_interval)
+ continue
+ raise ValueError(last_error)
+ raise TimeoutError(f"Lark app registration is still pending: {last_error}")
+
+
+def _post_lark_form(url: str, body: bytes) -> dict[str, Any]:
+ request = urllib.request.Request(
+ url,
+ data=body,
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
+ method="POST",
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=LARK_HTTP_TIMEOUT_SECONDS) as response:
+ raw = response.read().decode("utf-8")
+ except Exception as exc: # noqa: BLE001 - network boundary
+ raise ValueError(f"Lark app registration request failed: {exc}") from exc
+ parsed = _parse_json_object(raw)
+ if parsed is None:
+ raise ValueError("Lark app registration returned non-JSON response.")
+ return parsed
+
+
+def _tenant_brand(result: dict[str, Any]) -> str | None:
+ user_info = result.get("user_info")
+ if not isinstance(user_info, dict):
+ return None
+ brand = str(user_info.get("tenant_brand") or "").strip().lower()
+ return brand if brand in {"feishu", "lark"} else None
+
+
+def _save_lark_app_config_with_cli(user_id: str, *, app_id: str, app_secret: str, brand: str) -> None:
+ path = _require_lark_cli_path()
+ try:
+ try:
+ result = subprocess.run(
+ [path, "config", "init", "--app-id", app_id, "--app-secret-stdin", "--brand", _normalize_lark_brand(brand)],
+ input=app_secret + "\n",
+ check=False,
+ capture_output=True,
+ text=True,
+ timeout=15,
+ env=lark_cli_env(user_id),
+ )
+ except subprocess.TimeoutExpired as exc:
+ raise TimeoutError("Timed out while saving Lark connection setup.") from exc
+ finally:
+ ensure_lark_cli_credential_tree(user_id)
+ if result.returncode != 0:
+ raw = (result.stderr or result.stdout or "").strip()
+ parsed = _parse_json_object(raw)
+ message = _auth_error_message(parsed) if parsed else raw
+ raise ValueError(message or f"lark-cli config init exited with code {result.returncode}")
+
+
+def _run_lark_cli_json(
+ args: list[str],
+ *,
+ user_id: str,
+ timeout: int,
+ allow_empty_success: bool = False,
+) -> dict[str, Any]:
+ try:
+ try:
+ result = subprocess.run(
+ args,
+ check=False,
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ env=lark_cli_env(user_id),
+ )
+ except subprocess.TimeoutExpired as exc:
+ raise TimeoutError("Timed out waiting for Lark/Feishu authorization. Complete authorization in the browser, then try again.") from exc
+ finally:
+ # OAuth commands may create new plaintext token files after the
+ # pre-command environment guard has run. Re-harden every file even on
+ # timeout or CLI failure before returning control to the Gateway.
+ ensure_lark_cli_credential_tree(user_id)
+
+ stdout = (result.stdout or "").strip()
+ stderr = (result.stderr or "").strip()
+ raw = stdout or stderr
+ parsed = _parse_json_object(raw)
+
+ if result.returncode != 0:
+ message = _auth_error_message(parsed) if parsed else raw
+ raise ValueError(message or f"lark-cli exited with code {result.returncode}")
+
+ if not raw and allow_empty_success:
+ return {}
+ if parsed is None:
+ if allow_empty_success:
+ return {}
+ raise ValueError(raw or "lark-cli did not return JSON output.")
+ return parsed
+
+
+def _parse_json_object(raw: str) -> dict[str, Any] | None:
+ if not raw:
+ return None
+ try:
+ data = json.loads(raw)
+ except json.JSONDecodeError:
+ return None
+ return data if isinstance(data, dict) else None
+
+
+def _int_or_none(value: Any) -> int | None:
+ try:
+ return int(value)
+ except (TypeError, ValueError):
+ return None
+
+
+def _auth_error_message(data: dict[str, Any] | None) -> str | None:
+ if not data:
+ return None
+ error = data.get("error")
+ if isinstance(error, dict):
+ for key in ("message", "hint", "type"):
+ value = error.get(key)
+ if value:
+ return str(value)
+ for key in ("message", "msg", "hint"):
+ value = data.get(key)
+ if value:
+ return str(value)
+ return None
+
+
+def _read_manifest(root: Path) -> dict[str, Any] | None:
+ path = root / LARK_CLI_MANIFEST_FILE
+ if not path.is_file():
+ return None
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return None
+ return data if isinstance(data, dict) else None
+
+
+def _installed_lark_skill_names(root: Path) -> set[str]:
+ names: set[str] = set()
+ if not root.is_dir():
+ return names
+ for skill_name in LARK_SKILL_NAMES:
+ if (root / skill_name / SKILL_MD_FILE).is_file():
+ names.add(skill_name)
+ return names
+
+
+def _enabled_lark_skill_names(user_id: str, config: AppConfig) -> set[str]:
+ from deerflow.skills.storage import get_or_new_user_skill_storage
+
+ try:
+ storage = get_or_new_user_skill_storage(user_id, app_config=config)
+ return {skill.name for skill in storage.load_skills(enabled_only=True) if skill.name in LARK_SKILL_NAME_SET}
+ except Exception:
+ return set()
+
+
+def _resolve_latest_lark_cli_version() -> str:
+ """Resolve the newest published ``larksuite/cli`` release tag.
+
+ Queries the official ``releases/latest`` API. Any failure (rate limit,
+ offline, air-gapped, malformed payload) falls back to
+ ``FALLBACK_LARK_CLI_VERSION`` so an install can still proceed with a known
+ good version rather than aborting.
+ """
+ try:
+ request = urllib.request.Request(
+ LARK_CLI_LATEST_RELEASE_API,
+ headers={"Accept": "application/vnd.github+json", "User-Agent": "deer-flow"},
+ )
+ with urllib.request.urlopen(request, timeout=LARK_HTTP_TIMEOUT_SECONDS) as response:
+ raw = response.read().decode("utf-8")
+ data = json.loads(raw)
+ tag = str(data.get("tag_name") or "").strip() if isinstance(data, dict) else ""
+ version = _normalize_lark_cli_version_tag(tag)
+ if version is not None:
+ return version
+ except Exception: # noqa: BLE001 - version discovery is best-effort
+ pass
+ return FALLBACK_LARK_CLI_VERSION
+
+
+def _cached_latest_lark_cli_version() -> str | None:
+ """Best-effort latest version for status display, cached with a short TTL.
+
+ Returns ``None`` on failure so the status endpoint never blocks the UI on a
+ GitHub outage; the install path uses :func:`_resolve_latest_lark_cli_version`
+ which has its own fallback.
+ """
+ now = time.monotonic()
+ cached = getattr(_cached_latest_lark_cli_version, "_cache", None)
+ if cached is not None and now - cached[0] < LARK_CLI_LATEST_VERSION_TTL_SECONDS:
+ return cached[1]
+ try:
+ request = urllib.request.Request(
+ LARK_CLI_LATEST_RELEASE_API,
+ headers={"Accept": "application/vnd.github+json", "User-Agent": "deer-flow"},
+ )
+ with urllib.request.urlopen(request, timeout=LARK_HTTP_TIMEOUT_SECONDS) as response:
+ data = json.loads(response.read().decode("utf-8"))
+ tag = str(data.get("tag_name") or "").strip() if isinstance(data, dict) else ""
+ version = _normalize_lark_cli_version_tag(tag)
+ except Exception: # noqa: BLE001 - status probe is best-effort
+ version = None
+ _cached_latest_lark_cli_version._cache = (now, version) # type: ignore[attr-defined]
+ return version
+
+
+def _lark_archive_url(version: str) -> str:
+ tag = _normalize_lark_cli_version_tag(version)
+ if tag is None:
+ raise ValueError(f"Invalid Lark CLI version tag: {version!r}")
+ return f"https://codeload.github.com/{LARK_CLI_GITHUB_REPO}/zip/refs/tags/{tag}"
+
+
+def _normalize_lark_cli_version_tag(value: str | None) -> str | None:
+ tag = (value or "").strip()
+ if not _VERSION_TAG_RE.fullmatch(tag):
+ return None
+ return tag if tag.startswith("v") else f"v{tag}"
+
+
+def _download_lark_archive(version: str) -> Path:
+ fd, archive_name = tempfile.mkstemp(prefix="lark-cli-skills-", suffix=".zip")
+ os.close(fd)
+ archive_path = Path(archive_name)
+ url = _lark_archive_url(version)
+ try:
+ with urllib.request.urlopen(url, timeout=LARK_CLI_DOWNLOAD_TIMEOUT_SECONDS) as response:
+ total = 0
+ with archive_path.open("wb") as out:
+ while chunk := response.read(1024 * 1024):
+ total += len(chunk)
+ if total > LARK_CLI_MAX_ARCHIVE_BYTES:
+ raise ValueError("Lark CLI source archive is too large.")
+ out.write(chunk)
+ except ValueError:
+ archive_path.unlink(missing_ok=True)
+ raise
+ except Exception as exc: # noqa: BLE001 - network boundary
+ archive_path.unlink(missing_ok=True)
+ raise ValueError(f"Could not download the Lark skill pack ({version}) from GitHub. Check the Gateway's internet access, or pre-stage the archive via {LARK_CLI_SOURCE_ARCHIVE_ENV}.") from exc
+ return archive_path
+
+
+def _content_sha256(root: Path, skill_names: set[str]) -> str:
+ """SHA-256 over effective installed skill contents (not archive bytes).
+
+ The caller computes this after injecting DeerFlow's shared guidance, so the
+ digest covers both official extracted files and the guidance users/agents
+ actually read. It remains stable across GitHub re-packs of identical
+ content. Paths and bytes are hashed in sorted order for determinism.
+ """
+ digest = hashlib.sha256()
+ for skill_name in sorted(skill_names):
+ skill_dir = root / skill_name
+ if not skill_dir.is_dir():
+ continue
+ for file_path in sorted(p for p in skill_dir.rglob("*") if p.is_file()):
+ rel = file_path.relative_to(root).as_posix()
+ digest.update(rel.encode("utf-8"))
+ digest.update(b"\0")
+ digest.update(file_path.read_bytes())
+ digest.update(b"\0")
+ return digest.hexdigest()
+
+
+def _infer_lark_archive_version(zf: zipfile.ZipFile) -> str | None:
+ """Infer version from GitHub source archive roots such as ``cli-1.0.65/``.
+
+ This keeps air-gapped / pre-staged archives from being mislabeled as the
+ fallback version when the archive itself clearly identifies its release.
+ """
+ for info in zf.infolist():
+ normalized = posixpath.normpath(info.filename.replace("\\", "/"))
+ parts = PurePosixPath(normalized).parts
+ if not parts:
+ continue
+ match = re.fullmatch(r"cli-(\d+\.\d+\.\d+)", parts[0])
+ if match:
+ return f"v{match.group(1)}"
+ return None
+
+
+def _install_lark_skills_from_archive(user_id: str, archive_path: Path, *, version: str | None = None) -> tuple[tuple[str, ...], str]:
+ if not archive_path.is_file():
+ raise FileNotFoundError(f"Lark CLI skills archive not found: {archive_path}")
+
+ parent = get_paths().integration_skills_dir()
+ parent.mkdir(parents=True, exist_ok=True)
+ with _lark_install_lock(parent):
+ return _install_lark_skills_from_archive_locked(archive_path, parent, version=version)
+
+
+@contextmanager
+def _lark_install_lock(parent: Path):
+ """Serialize the cross-process atomic replacement of the global pack."""
+ with _exclusive_install_lock(parent / ".lark-cli.install.lock", _LARK_INSTALL_THREAD_LOCK):
+ yield
+
+
+def _install_lark_skills_from_archive_locked(
+ archive_path: Path,
+ parent: Path,
+ *,
+ version: str | None = None,
+) -> tuple[tuple[str, ...], str]:
+ target = parent / INTEGRATION_ID
+ staging_parent = Path(tempfile.mkdtemp(prefix=".installing-lark-cli-", dir=str(parent)))
+ staging_target = staging_parent / INTEGRATION_ID
+ staging_target.mkdir(parents=True, exist_ok=True)
+
+ backup: Path | None = None
+ try:
+ with zipfile.ZipFile(archive_path, "r") as zf:
+ archive_version = version or _infer_lark_archive_version(zf)
+ extracted = _extract_lark_skills(zf, staging_target)
+ _validate_extracted_lark_skills(staging_target, extracted)
+ _append_deerflow_lark_shared_guidance(staging_target)
+ content_sha = _content_sha256(staging_target, extracted)
+ _write_manifest(staging_target, extracted, version=archive_version, content_sha256=content_sha)
+ make_skill_tree_sandbox_readable(staging_target)
+
+ if target.exists():
+ backup = parent / f".replacing-{INTEGRATION_ID}-{os.getpid()}"
+ if backup.exists():
+ shutil.rmtree(backup)
+ target.rename(backup)
+ staging_target.rename(target)
+ if backup is not None:
+ # Best-effort: the new skills are already live after the rename, so a
+ # transient error deleting the old backup must not flip a successful
+ # install into a failure (the except-branch restore guard would also
+ # not fire because ``target`` now exists with the new content).
+ shutil.rmtree(backup, ignore_errors=True)
+ return tuple(sorted(extracted)), content_sha
+ except Exception:
+ if backup is not None and backup.exists() and not target.exists():
+ backup.rename(target)
+ raise
+ finally:
+ shutil.rmtree(staging_parent, ignore_errors=True)
+
+
+def _extract_lark_skills(zf: zipfile.ZipFile, destination: Path) -> set[str]:
+ extracted: set[str] = set()
+ total_written = 0
+ dest_root = destination.resolve()
+
+ for info in zf.infolist():
+ if info.is_dir():
+ continue
+ if is_unsafe_zip_member(info) or is_symlink_member(info):
+ raise ValueError(f"Unsafe Lark CLI archive member: {info.filename!r}")
+
+ skill_name, relative = _resolve_lark_skill_member(info.filename)
+ if skill_name is None or relative is None:
+ continue
+
+ target = dest_root / skill_name / relative
+ if not target.resolve().is_relative_to(dest_root):
+ raise ValueError(f"Archive member escapes destination: {info.filename!r}")
+ target.parent.mkdir(parents=True, exist_ok=True)
+
+ with zf.open(info) as src, target.open("wb") as out:
+ first_chunk = True
+ while chunk := src.read(65536):
+ if first_chunk and is_executable_binary_prefix(chunk):
+ raise ValueError(f"Archive contains executable binary member: {info.filename!r}")
+ first_chunk = False
+ total_written += len(chunk)
+ if total_written > LARK_CLI_MAX_EXTRACTED_BYTES:
+ raise ValueError("Lark CLI skills archive expands to too much data.")
+ out.write(chunk)
+ extracted.add(skill_name)
+
+ return extracted
+
+
+def _resolve_lark_skill_member(raw_name: str) -> tuple[str | None, Path | None]:
+ normalized = posixpath.normpath(raw_name.replace("\\", "/"))
+ if normalized in {"", "."} or normalized.startswith("../"):
+ return None, None
+ parts = PurePosixPath(normalized).parts
+
+ if "skills" in parts:
+ idx = parts.index("skills")
+ if len(parts) <= idx + 2:
+ return None, None
+ skill_name = parts[idx + 1]
+ rel_parts = parts[idx + 2 :]
+ elif parts and parts[0] in LARK_SKILL_NAME_SET:
+ skill_name = parts[0]
+ rel_parts = parts[1:]
+ else:
+ return None, None
+
+ if skill_name not in LARK_SKILL_NAME_SET or not rel_parts:
+ return None, None
+ if any(part in {"", ".", ".."} for part in rel_parts):
+ raise ValueError(f"Unsafe Lark skill archive member: {raw_name!r}")
+ return skill_name, Path(*rel_parts)
+
+
+def _validate_extracted_lark_skills(root: Path, extracted: set[str]) -> None:
+ missing = sorted(set(LARK_SKILL_NAMES) - extracted)
+ if missing:
+ raise ValueError(f"Lark CLI archive is missing required skills: {', '.join(missing)}")
+
+ for skill_name in LARK_SKILL_NAMES:
+ skill_file = root / skill_name / SKILL_MD_FILE
+ parsed = parse_skill_file(skill_file, SkillCategory.INTEGRATION, relative_path=Path(INTEGRATION_ID) / skill_name)
+ if parsed is None:
+ raise ValueError(f"Invalid Lark skill metadata: {skill_name}/{SKILL_MD_FILE}")
+ if parsed.name != skill_name:
+ raise ValueError(f"Lark skill directory {skill_name!r} declares name {parsed.name!r}")
+
+
+def _append_deerflow_lark_shared_guidance(root: Path) -> None:
+ skill_file = root / "lark-shared" / SKILL_MD_FILE
+ content = skill_file.read_text(encoding="utf-8")
+ if _DEERFLOW_LARK_SHARED_GUIDANCE_MARKER in content:
+ return
+ for legacy_marker in _DEERFLOW_LARK_SHARED_GUIDANCE_LEGACY_MARKERS:
+ if legacy_marker in content:
+ content = content.split(legacy_marker, maxsplit=1)[0].rstrip()
+ break
+ guidance = f"""
+
+{_DEERFLOW_LARK_SHARED_GUIDANCE_MARKER}
+
+## DeerFlow 授权入口
+
+在 DeerFlow 中,如果 `lark-cli auth status` 或业务命令提示未配置、未登录、token 过期或缺少用户授权:
+
+1. 不要要求用户在终端执行 `lark-cli config init`、`lark-cli auth login` 或 `lark-cli auth login --device-code`。
+2. 回复用户这个可点击链接:[打开飞书授权设置](?settings=integrations)。
+3. 告诉用户在 **Settings → Integrations → Lark / Feishu CLI** 点击“连接飞书”,在浏览器里完成授权后再回来继续当前任务。
+4. 如果错误中包含缺失的 `scope`、`permission_violations` 或建议的 `--domain`,告诉用户在该设置页选择对应权限域(例如日历选择 Calendar),或把具体 scope 填入“Exact OAuth scope / 具体 OAuth scope”后重新授权。
+
+只有在用户明确说明已经完成授权后,才继续调用具体的 `lark-cli` 业务命令。
+"""
+ skill_file.write_text(content.rstrip() + guidance + "\n", encoding="utf-8")
+
+
+def _write_manifest(root: Path, installed_skills: set[str], *, version: str | None, content_sha256: str) -> None:
+ resolved_version = version or FALLBACK_LARK_CLI_VERSION
+ manifest = {
+ "provider": INTEGRATION_ID,
+ "version": resolved_version,
+ "source": _lark_archive_url(resolved_version),
+ "content_sha256": content_sha256,
+ "installed_at": datetime.now(UTC).isoformat(),
+ "skills": sorted(installed_skills),
+ }
+ (root / LARK_CLI_MANIFEST_FILE).write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
diff --git a/backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py b/backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py
index c02f2cb32..470f690bd 100644
--- a/backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py
+++ b/backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py
@@ -136,6 +136,7 @@ class LocalSandboxProvider(SandboxProvider):
_RESERVED_CONTAINER_PREFIXES = [
f"{container_path}/public",
f"{container_path}/custom",
+ f"{container_path}/integrations",
f"{container_path}/legacy",
_ACP_WORKSPACE_VIRTUAL_PREFIX,
_USER_DATA_VIRTUAL_PREFIX,
@@ -287,7 +288,9 @@ class LocalSandboxProvider(SandboxProvider):
config = get_app_config()
skills_container_path = config.skills.container_path
user_custom_path = paths.user_custom_skills_dir(effective_user_id)
+ integrations_path = paths.integration_skills_dir()
user_custom_path.mkdir(parents=True, exist_ok=True)
+ integrations_path.mkdir(parents=True, exist_ok=True)
mappings.append(
PathMapping(
@@ -296,6 +299,13 @@ class LocalSandboxProvider(SandboxProvider):
read_only=True,
)
)
+ mappings.append(
+ PathMapping(
+ container_path=f"{skills_container_path}/integrations",
+ local_path=str(integrations_path),
+ read_only=True,
+ )
+ )
except Exception as exc:
logger.warning("Could not setup per-thread custom skills mount: %s", exc, exc_info=True)
diff --git a/backend/packages/harness/deerflow/sandbox/tools.py b/backend/packages/harness/deerflow/sandbox/tools.py
index 4955fb7e4..48db29f7b 100644
--- a/backend/packages/harness/deerflow/sandbox/tools.py
+++ b/backend/packages/harness/deerflow/sandbox/tools.py
@@ -163,6 +163,7 @@ def _extract_skill_name_from_skills_path(path: str) -> str | None:
/mnt/skills/public/bootstrap/SKILL.md → "bootstrap"
/mnt/skills/custom/my-skill/SKILL.md → "my-skill"
/mnt/skills/legacy/my-skill/references/... → "my-skill"
+ /mnt/skills/integrations/lark-cli/lark-doc/SKILL.md → "lark-doc"
/mnt/skills/public/bootstrap/ → "bootstrap"
Returns None if the path doesn't contain a recognizable skill name pattern.
"""
@@ -173,16 +174,22 @@ def _extract_skill_name_from_skills_path(path: str) -> str | None:
relative = path[len(skills_prefix) :].lstrip("/")
if not relative:
return None
- # Expected patterns: "public//...", "custom//...", "legacy//..."
+ # Expected patterns: "public//...", "custom//...",
+ # "legacy//...", "integrations///..."
# or "/..." (direct skill access). Empty segments are dropped so a
# directory entry ("public/", as `ls` emits for dirs) is still recognized as
# a category root rather than yielding an empty skill name.
parts = [part for part in relative.split("/") if part]
if len(parts) >= 2 and parts[0] in ("public", "custom", "legacy"):
return parts[1]
- if len(parts) == 1 and parts[0] in ("public", "custom", "legacy"):
+ if len(parts) >= 3 and parts[0] == "integrations":
+ return parts[2]
+ if len(parts) == 1 and parts[0] in ("public", "custom", "legacy", "integrations"):
# Category root like /mnt/skills/custom — not a skill path.
return None
+ if len(parts) == 2 and parts[0] == "integrations":
+ # Provider root like /mnt/skills/integrations/lark-cli.
+ return None
if len(parts) >= 1:
# Direct path like /mnt/skills/my-skill/SKILL.md
return parts[0]
@@ -215,6 +222,8 @@ def _is_disabled_skill_path(path: str, *, user_id: str | None = None) -> bool:
category = "custom"
elif relative.startswith("legacy/"):
category = "legacy"
+ elif relative.startswith("integrations/"):
+ category = "integrations"
else:
# Try to infer from storage
effective_uid = user_id or get_effective_user_id()
@@ -307,7 +316,7 @@ def _resolve_skills_path(path: str) -> str:
relative = path[len(skills_container) :].lstrip("/")
- # Per-user custom skills: resolve to user-specific directory.
+ # Per-user custom and globally managed integration skills.
# ``skill_manage_tool`` writes custom skills to the per-user directory,
# and ``LocalSandboxProvider._build_thread_path_mappings`` mounts
# ``/mnt/skills/custom`` to that same per-user dir. Without this
@@ -327,6 +336,22 @@ def _resolve_skills_path(path: str) -> str:
return str(user_custom_dir / custom_relative)
return str(user_custom_dir)
+ if relative == "integrations" or relative.startswith("integrations/"):
+ from deerflow.config.paths import get_paths
+
+ paths = get_paths()
+ integrations_dir = paths.integration_skills_dir()
+ integrations_relative = relative[len("integrations") :].lstrip("/")
+ if not integrations_relative:
+ return str(integrations_dir)
+ # Defense-in-depth: even though _reject_path_traversal runs upstream for
+ # sandbox callers, confirm the resolved path stays within the global
+ # integration dir so a lexical ``../`` cannot escape it here.
+ resolved = (integrations_dir / integrations_relative).resolve()
+ if not resolved.is_relative_to(integrations_dir.resolve()):
+ raise PermissionError("Access denied: path traversal detected")
+ return str(integrations_dir / integrations_relative)
+
return _join_path_preserving_style(skills_host, relative)
@@ -758,11 +783,11 @@ def mask_local_paths_in_output(output: str, thread_data: ThreadDataState | None)
"""Mask host absolute paths from local sandbox output using virtual paths.
Handles user-data paths (per-thread), skills paths (global + per-user
- custom), and ACP workspace paths (per-thread).
+ custom + managed integrations), and ACP workspace paths (per-thread).
"""
# Build the ordered (host_base, virtual_base) source list. Order is
# preserved from the original implementation: skills, then per-user
- # custom skills, then ACP workspace, then user-data mappings (longest
+ # custom/integration skills, then ACP workspace, then user-data mappings (longest
# host path first). Custom mount host paths are masked by
# LocalSandbox._reverse_resolve_paths_in_output().
sources: list[tuple[str, str]] = []
@@ -782,9 +807,13 @@ def mask_local_paths_in_output(output: str, thread_data: ThreadDataState | None)
user_id = get_effective_user_id()
user_custom_dir = get_paths().user_custom_skills_dir(user_id)
+ integrations_dir = get_paths().integration_skills_dir()
if user_custom_dir.exists():
skills_container = _get_skills_container_path()
sources.append((str(user_custom_dir), f"{skills_container}/custom"))
+ if integrations_dir.exists():
+ skills_container = _get_skills_container_path()
+ sources.append((str(integrations_dir), f"{skills_container}/integrations"))
except Exception:
pass
@@ -1697,6 +1726,30 @@ def _github_env_from_runtime(runtime: Runtime) -> dict[str, str] | None:
return {"GH_TOKEN": token, "GITHUB_TOKEN": token}
+_LARK_CLI_COMMAND_RE = re.compile(r"(? dict[str, str] | None:
+ """Expose Settings-page Lark auth to sandbox ``lark-cli`` commands.
+
+ Settings authorizes ``lark-cli`` under DeerFlow's per-user integration
+ config/data directories. Agent conversations invoke ``lark-cli`` through the
+ sandbox, so lark commands must receive those same directories or they see an
+ unrelated unauthenticated profile. Keep this scoped to commands that
+ actually call ``lark-cli`` so ordinary bash calls do not switch AIO into the
+ env-bearing execution path.
+ """
+ if not _LARK_CLI_COMMAND_RE.search(command):
+ return None
+ try:
+ from deerflow.integrations.lark_cli import lark_cli_env_overlay
+
+ return lark_cli_env_overlay(resolve_runtime_user_id(runtime), sandbox_paths=sandbox_paths)
+ except Exception:
+ logger.warning("Could not build Lark CLI env overlay; running command without managed auth", exc_info=True)
+ return None
+
+
@tool("bash", parse_docstring=True)
def bash_tool(runtime: Runtime, description: str, command: str) -> str:
"""Execute a bash command in a Linux environment.
@@ -1723,8 +1776,11 @@ def bash_tool(runtime: Runtime, description: str, command: str) -> str:
injected_env = read_active_secrets(getattr(runtime, "context", None)) or None
identity_prefix = _channel_identity_prefix(runtime)
github_env = _github_env_from_runtime(runtime)
+ lark_cli_env = _lark_cli_env_from_runtime(runtime, command, sandbox_paths=not is_local_sandbox(runtime))
if github_env:
injected_env = {**(injected_env or {}), **github_env}
+ if lark_cli_env:
+ injected_env = {**(injected_env or {}), **lark_cli_env}
if is_local_sandbox(runtime):
if not is_host_bash_allowed():
return f"Error: {LOCAL_HOST_BASH_DISABLED_MESSAGE}"
diff --git a/backend/packages/harness/deerflow/skills/storage/user_scoped_skill_storage.py b/backend/packages/harness/deerflow/skills/storage/user_scoped_skill_storage.py
index 785be182a..123ee43a4 100644
--- a/backend/packages/harness/deerflow/skills/storage/user_scoped_skill_storage.py
+++ b/backend/packages/harness/deerflow/skills/storage/user_scoped_skill_storage.py
@@ -8,6 +8,7 @@ Layout::
/public//SKILL.md ← global, read-only
//SKILL.md ← per-user, read-write
+ ///SKILL.md ← per-user, read-only
/.history/.jsonl ← per-user history
/_skill_states.json ← per-user enabled state
//SKILL.md ← legacy fallback, read-only
@@ -82,6 +83,7 @@ class UserScopedSkillStorage(LocalSkillStorage):
self._user_id = _validate_user_id(user_id)
paths = get_paths()
self._user_custom_root: Path = paths.user_custom_skills_dir(self._user_id)
+ self._integrations_root: Path = paths.integration_skills_dir()
self._user_skills_root: Path = paths.user_skills_dir(self._user_id)
self._global_custom_root: Path = self._host_root / SkillCategory.CUSTOM.value
self._skill_states_file: Path = self._user_skills_root / "_skill_states.json"
@@ -256,8 +258,11 @@ class UserScopedSkillStorage(LocalSkillStorage):
is_global_custom_fallback = (self._global_custom_root / normalized_name / SKILL_MD_FILE).exists()
if is_global_public:
raise ValueError(f"'{name}' is a built-in skill. Use the skill_manage tool to create your own version — it will shadow the built-in one.")
+ is_integration = any((candidate / SKILL_MD_FILE).exists() for candidate in self._integrations_root.glob(f"*/{normalized_name}") if candidate.is_dir())
if is_global_custom_fallback:
raise ValueError(f"'{name}' is a legacy shared skill (not editable). To customise it, create your own version with the same name — it will shadow the shared one.")
+ if is_integration:
+ raise ValueError(f"'{name}' is a managed integration skill and cannot be edited. Create a custom skill with another name if you need a modified workflow.")
raise FileNotFoundError(f"Custom skill '{name}' not found.")
def _iter_skill_files(self) -> Iterable[tuple[SkillCategory, Path, Path]]:
@@ -271,7 +276,17 @@ class UserScopedSkillStorage(LocalSkillStorage):
dir_names.clear()
yield SkillCategory.PUBLIC, public_path, Path(current_root) / SKILL_MD_FILE
- # 2. Custom skills: prefer user-level directory
+ # 2. Managed integration skills: globally installed, read-only. Their
+ # enabled state is still merged from this user's _skill_states.json.
+ integration_path = self._integrations_root
+ if integration_path.exists() and integration_path.is_dir():
+ for current_root, dir_names, file_names in os.walk(integration_path, followlinks=True):
+ dir_names[:] = sorted(name for name in dir_names if not name.startswith("."))
+ if SKILL_MD_FILE not in file_names:
+ continue
+ yield SkillCategory.INTEGRATION, integration_path, Path(current_root) / SKILL_MD_FILE
+
+ # 3. Custom skills: prefer user-level directory
user_custom_exists = False
user_custom_path = self._user_custom_root
if user_custom_path.exists() and user_custom_path.is_dir():
@@ -283,7 +298,7 @@ class UserScopedSkillStorage(LocalSkillStorage):
user_custom_exists = True
yield SkillCategory.CUSTOM, user_custom_path, Path(current_root) / SKILL_MD_FILE
- # 3. Fallback: if user has no custom skills, load from global custom
+ # 4. Fallback: if user has no custom skills, load from global custom
# as LEGACY (read-only) so legacy skills are visible but not
# editable/deletable by the user. LEGACY skills are mounted at
# /mnt/skills/legacy// in the sandbox so their supporting
@@ -370,6 +385,10 @@ class UserScopedSkillStorage(LocalSkillStorage):
"""Host path to this user's custom skills root directory."""
return self._user_custom_root
+ def get_user_integrations_root(self) -> Path:
+ """Host path to this user's managed integration skills root directory."""
+ return self._user_integrations_root
+
# ------------------------------------------------------------------
# Path validation — accept per-user custom root as well as global root
# ------------------------------------------------------------------
@@ -382,7 +401,7 @@ class UserScopedSkillStorage(LocalSkillStorage):
would reject them. This override allows both roots.
"""
resolved_file = skill_file.resolve()
- for allowed_root in (self._host_root.resolve(), self._user_custom_root.resolve()):
+ for allowed_root in (self._host_root.resolve(), self._user_custom_root.resolve(), self._user_integrations_root.resolve()):
try:
resolved_file.relative_to(allowed_root)
return resolved_file
diff --git a/backend/packages/harness/deerflow/skills/types.py b/backend/packages/harness/deerflow/skills/types.py
index b9ea5fe5c..53b67a981 100644
--- a/backend/packages/harness/deerflow/skills/types.py
+++ b/backend/packages/harness/deerflow/skills/types.py
@@ -12,6 +12,7 @@ class SkillCategory(StrEnum):
- ``PUBLIC``: built-in skill bundled with the platform, read-only.
- ``CUSTOM``: user-authored skill that can be edited or deleted.
+ - ``INTEGRATION``: managed third-party integration skill, read-only.
- ``LEGACY``: global custom skill from before user-isolation migration,
presented as read-only (visible but not editable/deletable). These
skills are mounted at ``/mnt/skills/legacy//`` in the sandbox.
@@ -19,6 +20,7 @@ class SkillCategory(StrEnum):
PUBLIC = "public"
CUSTOM = "custom"
+ INTEGRATION = "integrations"
LEGACY = "legacy"
diff --git a/backend/tests/blocking_io/test_integrations_router.py b/backend/tests/blocking_io/test_integrations_router.py
new file mode 100644
index 000000000..7f8fa3482
--- /dev/null
+++ b/backend/tests/blocking_io/test_integrations_router.py
@@ -0,0 +1,130 @@
+"""Regression anchors: integrations router must not block the event loop.
+
+The Lark integration handlers are async FastAPI route handlers, but the work
+they dispatch includes zip reads, filesystem staging, manifest writes, and
+``lark-cli`` subprocess calls. Those phases must stay behind
+``asyncio.to_thread``; if a future refactor runs them inline, the strict
+Blockbuster gate raises ``BlockingError`` and these anchors fail.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import os
+import zipfile
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+
+from app.gateway.routers import integrations
+from deerflow.config import paths as paths_module
+from deerflow.integrations import lark_cli
+
+pytestmark = pytest.mark.asyncio
+
+
+def _skill_content(name: str) -> str:
+ return f"---\nname: {name}\ndescription: {name} integration skill\n---\n\n# {name}\n"
+
+
+def _build_lark_archive(archive: Path) -> None:
+ archive.parent.mkdir(parents=True, exist_ok=True)
+ with zipfile.ZipFile(archive, "w") as zf:
+ for skill_name in lark_cli.LARK_SKILL_NAMES:
+ zf.writestr(f"cli-1.0.65/skills/{skill_name}/SKILL.md", _skill_content(skill_name))
+ zf.writestr(f"cli-1.0.65/skills/{skill_name}/references/readme.md", f"# {skill_name}\n")
+
+
+def _write_stub_lark_cli(path: Path) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(
+ """#!/bin/sh
+if [ "$1" = "--version" ]; then
+ echo "v1.0.65"
+ exit 0
+fi
+
+if [ "$1" = "auth" ] && [ "$2" = "login" ]; then
+ echo "{}"
+ exit 0
+fi
+
+if [ "$1" = "auth" ] && [ "$2" = "status" ]; then
+ echo '{"identities":{"user":{"userName":"Alice"}}}'
+ exit 0
+fi
+
+echo "{}"
+exit 0
+""",
+ encoding="utf-8",
+ )
+ path.chmod(0o755)
+
+
+def _reset_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path / "home"))
+ monkeypatch.setattr(paths_module, "_paths", None)
+
+
+async def _config(tmp_path: Path) -> SimpleNamespace:
+ skills_root = tmp_path / "skills"
+ await asyncio.to_thread((skills_root / "public").mkdir, parents=True, exist_ok=True)
+ await asyncio.to_thread((skills_root / "custom").mkdir, parents=True, exist_ok=True)
+ return SimpleNamespace(
+ skills=SimpleNamespace(
+ get_skills_path=lambda: skills_root,
+ container_path="/mnt/skills",
+ use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage",
+ )
+ )
+
+
+async def test_lark_install_route_does_not_block_event_loop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ _reset_paths(tmp_path, monkeypatch)
+ config = await _config(tmp_path)
+ archive = tmp_path / "fixtures" / "lark-cli.zip"
+ await asyncio.to_thread(_build_lark_archive, archive)
+
+ async def _allow_admin(*_args, **_kwargs) -> None:
+ return None
+
+ refresh_calls = 0
+
+ async def _refresh_cache() -> None:
+ nonlocal refresh_calls
+ refresh_calls += 1
+
+ monkeypatch.setenv(lark_cli.LARK_CLI_SOURCE_ARCHIVE_ENV, str(archive))
+ monkeypatch.setattr(lark_cli, "probe_lark_cli", lambda: lark_cli.LarkCliProbe(available=True, path="/usr/bin/lark-cli", version="v1.0.65"))
+ monkeypatch.setattr(lark_cli, "probe_lark_auth", lambda _user_id, **_kwargs: lark_cli.LarkAuthProbe(status="not_configured", message="not configured"))
+ monkeypatch.setattr(integrations, "get_effective_user_id", lambda: "loop-user")
+ monkeypatch.setattr(integrations, "require_admin_user", _allow_admin)
+ monkeypatch.setattr(integrations, "refresh_skills_system_prompt_cache_async", _refresh_cache, raising=False)
+
+ response = await integrations.install_lark(request=None, config=config)
+
+ assert response.success is True
+ assert refresh_calls == 1
+ install_root = await asyncio.to_thread(lark_cli.lark_integration_root, "loop-user")
+ assert await asyncio.to_thread((install_root / "lark-doc" / "SKILL.md").exists)
+
+
+async def test_lark_auth_complete_route_does_not_block_event_loop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ _reset_paths(tmp_path, monkeypatch)
+ config = await _config(tmp_path)
+ cli_path = tmp_path / "bin" / "lark-cli"
+ await asyncio.to_thread(_write_stub_lark_cli, cli_path)
+
+ monkeypatch.setenv("PATH", f"{cli_path.parent}{os.pathsep}{os.environ.get('PATH', '')}")
+ monkeypatch.setattr(integrations, "get_effective_user_id", lambda: "loop-user")
+
+ response = await integrations.complete_lark_browser_auth(
+ request=None,
+ body=integrations.LarkAuthCompleteRequest(device_code="device-code"),
+ config=config,
+ )
+
+ assert response.status.cli.available is True
+ assert response.status.cli.version == "v1.0.65"
diff --git a/backend/tests/test_aio_sandbox_provider.py b/backend/tests/test_aio_sandbox_provider.py
index f34e0d999..4da31b581 100644
--- a/backend/tests/test_aio_sandbox_provider.py
+++ b/backend/tests/test_aio_sandbox_provider.py
@@ -2,6 +2,7 @@
import asyncio
import importlib
+import stat
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
@@ -119,6 +120,108 @@ def test_get_thread_mounts_uses_explicit_user_id(tmp_path, monkeypatch):
assert container_paths["/mnt/user-data/outputs"] == str(tmp_path / "users" / "ou-user" / "threads" / "thread-4" / "user-data" / "outputs")
+def test_get_lark_cli_runtime_mounts_uses_user_auth_dirs(tmp_path, monkeypatch):
+ """Sandbox lark-cli commands must read the same auth dirs as Settings."""
+ aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
+ lark_cli = importlib.import_module("deerflow.integrations.lark_cli")
+ monkeypatch.setattr(aio_mod, "get_paths", lambda: Paths(base_dir=tmp_path))
+ monkeypatch.setattr(aio_mod, "get_effective_user_id", lambda: "default")
+ runtime_dir = tmp_path / "integrations" / "lark-cli" / "sandbox-cli"
+ runtime_dir.mkdir(parents=True)
+
+ mounts = aio_mod.AioSandboxProvider._get_lark_cli_runtime_mounts(user_id="alice")
+ container_paths = {container_path: (host_path, read_only) for host_path, container_path, read_only in mounts}
+
+ assert container_paths[lark_cli.LARK_CLI_SANDBOX_CONFIG_DIR] == (
+ str(tmp_path / "users" / "alice" / "integrations" / "lark-cli" / "config"),
+ True,
+ )
+ assert container_paths[lark_cli.LARK_CLI_SANDBOX_DATA_DIR] == (
+ str(tmp_path / "users" / "alice" / "integrations" / "lark-cli" / "data"),
+ False,
+ )
+ assert stat.S_IMODE((tmp_path / "users" / "alice" / "integrations" / "lark-cli" / "config").stat().st_mode) == 0o700
+ assert stat.S_IMODE((tmp_path / "users" / "alice" / "integrations" / "lark-cli" / "data").stat().st_mode) == 0o700
+ assert container_paths["/mnt/integrations/lark-cli/runtime"] == (
+ str(runtime_dir),
+ True,
+ )
+
+
+def test_get_user_skill_mounts_mounts_only_global_integrations(tmp_path, monkeypatch):
+ aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
+ skills_root = tmp_path / "skills"
+ (skills_root / "public").mkdir(parents=True)
+ config = SimpleNamespace(
+ skills=SimpleNamespace(
+ get_skills_path=lambda: skills_root,
+ container_path="/mnt/skills",
+ )
+ )
+ monkeypatch.setattr(aio_mod, "get_app_config", lambda: config)
+ monkeypatch.setattr(aio_mod, "get_paths", lambda: Paths(base_dir=tmp_path / "home"))
+
+ alice = {container: host for host, container, _read_only in aio_mod.AioSandboxProvider._get_user_skill_mounts(user_id="alice")}
+ bob = {container: host for host, container, _read_only in aio_mod.AioSandboxProvider._get_user_skill_mounts(user_id="bob")}
+
+ assert set(alice) == {"/mnt/skills/integrations"}
+ assert set(bob) == {"/mnt/skills/integrations"}
+ assert alice["/mnt/skills/integrations"] == bob["/mnt/skills/integrations"]
+ assert alice["/mnt/skills/integrations"] == str(tmp_path / "home" / "integrations" / "skills")
+
+
+def test_get_extra_mounts_provisioner_payload_has_unique_container_paths(tmp_path, monkeypatch, provisioner_module):
+ """Full AIO mount composition must not send duplicate paths to provisioner."""
+ aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
+ lark_cli = importlib.import_module("deerflow.integrations.lark_cli")
+ remote_backend = importlib.import_module("deerflow.community.aio_sandbox.remote_backend")
+ skills_root = tmp_path / "skills"
+ (skills_root / "public").mkdir(parents=True)
+ home = tmp_path / "home"
+ config = SimpleNamespace(
+ skills=SimpleNamespace(
+ get_skills_path=lambda: skills_root,
+ container_path="/mnt/skills",
+ )
+ )
+ runtime_dir = home / "integrations" / "lark-cli" / "sandbox-cli"
+ runtime_dir.mkdir(parents=True)
+
+ monkeypatch.setattr(aio_mod, "get_app_config", lambda: config)
+ monkeypatch.setattr(aio_mod, "get_paths", lambda: Paths(base_dir=home))
+ monkeypatch.setattr(aio_mod, "get_effective_user_id", lambda: "default")
+ monkeypatch.setattr(aio_mod, "user_should_see_legacy_skills", lambda *_args, **_kwargs: False)
+
+ provider = _make_provider(tmp_path)
+ mounts = provider._get_extra_mounts("thread-1", user_id="alice")
+ container_paths = [container for _host, container, _read_only in mounts]
+
+ assert len(container_paths) == len(set(container_paths))
+ assert "/mnt/skills/custom" in container_paths
+ assert "/mnt/skills/integrations" in container_paths
+ assert lark_cli.LARK_CLI_SANDBOX_CONFIG_DIR in container_paths
+ assert lark_cli.LARK_CLI_SANDBOX_DATA_DIR in container_paths
+ assert lark_cli.LARK_CLI_SANDBOX_RUNTIME_DIR in container_paths
+
+ payload = remote_backend._provisioner_extra_mounts_payload(mounts)
+ payload_paths = [str(item["container_path"]) for item in payload]
+ assert len(payload_paths) == len(set(payload_paths))
+
+ provisioner_module.DEER_FLOW_HOST_BASE_DIR = str(home)
+ validated = provisioner_module._validated_extra_mounts([provisioner_module.ExtraMount(**item) for item in payload])
+ validated_paths = [mount.container_path for mount in validated]
+
+ assert len(validated_paths) == len(set(validated_paths))
+ assert set(validated_paths) == {
+ "/mnt/acp-workspace",
+ "/mnt/skills/custom",
+ "/mnt/skills/integrations",
+ lark_cli.LARK_CLI_SANDBOX_CONFIG_DIR,
+ lark_cli.LARK_CLI_SANDBOX_DATA_DIR,
+ lark_cli.LARK_CLI_SANDBOX_RUNTIME_DIR,
+ }
+
+
def test_join_host_path_preserves_windows_drive_letter_style():
base = r"C:\Users\demo\deer-flow\backend\.deer-flow"
@@ -405,6 +508,7 @@ def test_remote_backend_create_forwards_effective_user_id(monkeypatch):
"thread_id": "thread-42",
"user_id": "user-7",
"include_legacy_skills": True,
+ "provision_lark_cli_runtime": False,
}
@@ -435,6 +539,62 @@ def test_remote_backend_create_prefers_explicit_user_id(monkeypatch):
assert posted["json"]["include_legacy_skills"] is False
+def test_create_sandbox_requests_runtime_when_lark_installed(tmp_path, monkeypatch):
+ """The provider must request lark-cli runtime provisioning when Lark is installed."""
+ aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
+ provider = _make_provider(tmp_path)
+ provider._config = {"replicas": 3}
+ provider._thread_locks = {}
+ provider._warm_pool = {}
+ provider._sandbox_infos = {}
+ provider._thread_sandboxes = {}
+ provider._last_activity = {}
+ provider._lock = aio_mod.threading.Lock()
+
+ captured: dict = {}
+
+ def _create(thread_id, sandbox_id, *, extra_mounts=None, user_id=None, provision_lark_cli_runtime=False):
+ captured["provision_lark_cli_runtime"] = provision_lark_cli_runtime
+ return aio_mod.SandboxInfo(sandbox_id=sandbox_id, sandbox_url="http://sandbox")
+
+ provider._backend = SimpleNamespace(create=_create, destroy=MagicMock(), discover=MagicMock(return_value=None))
+ monkeypatch.setattr(aio_mod, "wait_for_sandbox_ready", lambda *_a, **_k: True)
+ monkeypatch.setattr(provider, "_get_extra_mounts", lambda *_a, **_k: [])
+ monkeypatch.setattr(aio_mod.AioSandboxProvider, "_lark_integration_active", staticmethod(lambda user_id=None: True))
+ monkeypatch.setattr(provider, "_register_created_sandbox", lambda *a, **k: "sandbox-lark")
+
+ provider._create_sandbox("thread-lark", "sandbox-lark", user_id="alice")
+ assert captured["provision_lark_cli_runtime"] is True
+
+
+def test_create_sandbox_skips_runtime_when_lark_absent(tmp_path, monkeypatch):
+ """No runtime provisioning request when the Lark skill pack is not installed."""
+ aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
+ provider = _make_provider(tmp_path)
+ provider._config = {"replicas": 3}
+ provider._thread_locks = {}
+ provider._warm_pool = {}
+ provider._sandbox_infos = {}
+ provider._thread_sandboxes = {}
+ provider._last_activity = {}
+ provider._lock = aio_mod.threading.Lock()
+
+ captured: dict = {}
+
+ def _create(thread_id, sandbox_id, *, extra_mounts=None, user_id=None, provision_lark_cli_runtime=False):
+ captured["provision_lark_cli_runtime"] = provision_lark_cli_runtime
+ return aio_mod.SandboxInfo(sandbox_id=sandbox_id, sandbox_url="http://sandbox")
+
+ provider._backend = SimpleNamespace(create=_create, destroy=MagicMock(), discover=MagicMock(return_value=None))
+ monkeypatch.setattr(aio_mod, "wait_for_sandbox_ready", lambda *_a, **_k: True)
+ monkeypatch.setattr(provider, "_get_extra_mounts", lambda *_a, **_k: [])
+ monkeypatch.setattr(aio_mod.AioSandboxProvider, "_lark_integration_active", staticmethod(lambda user_id=None: False))
+ monkeypatch.setattr(provider, "_register_created_sandbox", lambda *a, **k: "sandbox-nolark")
+
+ provider._create_sandbox("thread-nolark", "sandbox-nolark", user_id="alice")
+ assert captured["provision_lark_cli_runtime"] is False
+
+
# ── Sandbox client teardown (#2872) ──────────────────────────────────────────
diff --git a/backend/tests/test_gateway_imports.py b/backend/tests/test_gateway_imports.py
index 09772ce67..7272ea2f1 100644
--- a/backend/tests/test_gateway_imports.py
+++ b/backend/tests/test_gateway_imports.py
@@ -8,23 +8,28 @@ import sys
from pathlib import Path
+def _gateway_import_env() -> dict[str, str]:
+ backend_root = Path(__file__).resolve().parents[1]
+ harness_root = backend_root / "packages" / "harness"
+ python_path_entries = [str(backend_root), str(harness_root)]
+ if existing_python_path := os.environ.get("PYTHONPATH"):
+ python_path_entries.append(existing_python_path)
+ return {**os.environ, "PYTHONPATH": os.pathsep.join(python_path_entries)}
+
+
def test_gateway_app_imports_first_without_subagent_import_cycle() -> None:
"""The replay gateway imports app.gateway.app in a clean process."""
- backend_root = Path(__file__).resolve().parents[1]
- env = {**os.environ, "PYTHONPATH": str(backend_root)}
result = subprocess.run(
[sys.executable, "-c", "from app.gateway.app import app"],
capture_output=True,
text=True,
- env=env,
+ env=_gateway_import_env(),
)
assert result.returncode == 0, result.stderr
def test_subagent_package_public_executor_exports_are_lazy_importable() -> None:
"""The package-level executor exports must not re-enter their own import."""
- backend_root = Path(__file__).resolve().parents[1]
- env = {**os.environ, "PYTHONPATH": str(backend_root)}
result = subprocess.run(
[
sys.executable,
@@ -33,7 +38,7 @@ def test_subagent_package_public_executor_exports_are_lazy_importable() -> None:
],
capture_output=True,
text=True,
- env=env,
+ env=_gateway_import_env(),
)
assert result.returncode == 0, result.stderr
assert "SubagentExecutor SubagentResult" in result.stdout
diff --git a/backend/tests/test_lark_cli_integration.py b/backend/tests/test_lark_cli_integration.py
new file mode 100644
index 000000000..a91ebcd50
--- /dev/null
+++ b/backend/tests/test_lark_cli_integration.py
@@ -0,0 +1,1599 @@
+from __future__ import annotations
+
+import hashlib
+import inspect
+import io
+import json
+import multiprocessing
+import re
+import shutil
+import stat
+import subprocess
+import tarfile
+import threading
+import time
+import zipfile
+from concurrent.futures import ThreadPoolExecutor
+from pathlib import Path
+from types import SimpleNamespace
+from uuid import uuid4
+
+import pytest
+from _router_auth_helpers import make_authed_test_app
+from fastapi.testclient import TestClient
+
+from app.gateway.auth.models import User
+from app.gateway.deps import get_config
+from app.gateway.routers import integrations as integrations_router
+from deerflow.config import paths as paths_module
+from deerflow.config.paths import Paths
+from deerflow.integrations import lark_cli
+from deerflow.sandbox.tools import _lark_cli_env_from_runtime
+from deerflow.skills.storage import reset_skill_storage
+from deerflow.skills.storage.user_scoped_skill_storage import UserScopedSkillStorage
+from deerflow.skills.types import SkillCategory
+
+
+def _skill_content(name: str) -> str:
+ return f"---\nname: {name}\ndescription: {name} integration skill\n---\n\n# {name}\n"
+
+
+def _make_lark_cli_source_zip(tmp_path: Path, *, omit_skill: str | None = None, renamed_skill: str | None = None) -> Path:
+ archive = tmp_path / "lark-cli.zip"
+ with zipfile.ZipFile(archive, "w") as zf:
+ for skill_name in lark_cli.LARK_SKILL_NAMES:
+ if skill_name == omit_skill:
+ continue
+ declared_name = f"{skill_name}-renamed" if skill_name == renamed_skill else skill_name
+ zf.writestr(f"cli-1.0.65/skills/{skill_name}/SKILL.md", _skill_content(declared_name))
+ zf.writestr(f"cli-1.0.65/skills/{skill_name}/references/readme.md", f"# {skill_name}\n")
+ return archive
+
+
+def _make_lark_cli_binary_tar(payload: bytes, *, member_name: str = "lark-cli") -> bytes:
+ buffer = io.BytesIO()
+ with tarfile.open(fileobj=buffer, mode="w:gz") as tf:
+ info = tarfile.TarInfo(member_name)
+ info.mode = 0o755
+ info.size = len(payload)
+ tf.addfile(info, io.BytesIO(payload))
+ return buffer.getvalue()
+
+
+def _assert_lark_root_missing(user_id: str) -> None:
+ root = lark_cli.lark_integration_root(user_id)
+ assert not root.exists()
+
+
+def _config(skills_root: Path):
+ return SimpleNamespace(
+ skills=SimpleNamespace(
+ get_skills_path=lambda: skills_root,
+ container_path="/mnt/skills",
+ use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage",
+ )
+ )
+
+
+def _patch_paths(monkeypatch, base_dir: Path) -> None:
+ monkeypatch.setattr(paths_module, "_paths", Paths(base_dir=base_dir))
+
+
+def test_sandbox_lark_cli_env_prepends_managed_linux_runtime() -> None:
+ overlay = lark_cli.lark_cli_env_overlay("alice", sandbox_paths=True)
+
+ assert overlay["PATH"].split(":", 1)[0] == "/mnt/integrations/lark-cli/runtime/bin"
+ assert overlay["LARKSUITE_CLI_CONFIG_DIR"] == "/mnt/integrations/lark-cli/config"
+ assert overlay["LARKSUITE_CLI_DATA_DIR"] == "/mnt/integrations/lark-cli/data"
+
+
+def test_init_image_launcher_matches_python_constant() -> None:
+ """The init image's build script must embed the same launcher as the Gateway.
+
+ Both produce ``bin/lark-cli``; if they drift, the sandbox PATH contract
+ breaks for one provisioning mode.
+ """
+ repo_root = Path(lark_cli.__file__).resolve().parents[5]
+ build_script = repo_root / "docker" / "lark-cli-init" / "build-runtime.sh"
+ assert build_script.is_file(), f"missing init image build script at {build_script}"
+ body = build_script.read_text(encoding="utf-8")
+ # The launcher heredoc in the build script must contain the exact script body.
+ assert lark_cli.LARK_CLI_SANDBOX_LAUNCHER_SCRIPT.strip() in body
+
+
+def test_managed_sandbox_runtime_verifies_and_installs_linux_archives(monkeypatch, tmp_path) -> None:
+ assert hasattr(lark_cli, "_ensure_managed_sandbox_lark_cli"), "managed sandbox runtime installer is missing"
+ _patch_paths(monkeypatch, tmp_path / "home")
+ archives = {
+ "lark-cli-1.0.65-linux-amd64.tar.gz": _make_lark_cli_binary_tar(b"amd64-binary"),
+ "lark-cli-1.0.65-linux-arm64.tar.gz": _make_lark_cli_binary_tar(b"arm64-binary"),
+ }
+ checksums = "".join(f"{hashlib.sha256(payload).hexdigest()} {name}\n" for name, payload in archives.items()).encode()
+ assets = {"checksums.txt": checksums, **archives}
+
+ monkeypatch.setattr(lark_cli, "_download_lark_release_asset", lambda _version, name, **_kwargs: assets[name])
+
+ runtime = lark_cli._ensure_managed_sandbox_lark_cli("v1.0.65")
+
+ assert (runtime / "linux-amd64" / "lark-cli").read_bytes() == b"amd64-binary"
+ assert (runtime / "linux-arm64" / "lark-cli").read_bytes() == b"arm64-binary"
+ assert stat.S_IMODE((runtime / "linux-amd64" / "lark-cli").stat().st_mode) == 0o755
+ launcher = (runtime / "bin" / "lark-cli").read_text(encoding="utf-8")
+ assert "uname -m" in launcher
+ assert "x86_64" in launcher and "aarch64" in launcher
+
+
+def test_managed_sandbox_runtime_rejects_checksum_mismatch(monkeypatch, tmp_path) -> None:
+ assert hasattr(lark_cli, "_ensure_managed_sandbox_lark_cli"), "managed sandbox runtime installer is missing"
+ _patch_paths(monkeypatch, tmp_path / "home")
+ archives = {
+ "lark-cli-1.0.65-linux-amd64.tar.gz": _make_lark_cli_binary_tar(b"amd64-binary"),
+ "lark-cli-1.0.65-linux-arm64.tar.gz": _make_lark_cli_binary_tar(b"arm64-binary"),
+ }
+ bad_checksums = "".join(f"{'0' * 64} {name}\n" for name in archives).encode()
+ assets = {"checksums.txt": bad_checksums, **archives}
+ monkeypatch.setattr(lark_cli, "_download_lark_release_asset", lambda _version, name, **_kwargs: assets[name])
+
+ with pytest.raises(ValueError, match="checksum"):
+ lark_cli._ensure_managed_sandbox_lark_cli("v1.0.65")
+
+ assert not lark_cli.lark_cli_managed_sandbox_dir().exists()
+
+
+def test_managed_sandbox_runtime_rejects_unsafe_tar_member(monkeypatch, tmp_path) -> None:
+ assert hasattr(lark_cli, "_ensure_managed_sandbox_lark_cli"), "managed sandbox runtime installer is missing"
+ _patch_paths(monkeypatch, tmp_path / "home")
+ unsafe = _make_lark_cli_binary_tar(b"binary", member_name="../lark-cli")
+ safe = _make_lark_cli_binary_tar(b"binary")
+ archives = {
+ "lark-cli-1.0.65-linux-amd64.tar.gz": unsafe,
+ "lark-cli-1.0.65-linux-arm64.tar.gz": safe,
+ }
+ checksums = "".join(f"{hashlib.sha256(payload).hexdigest()} {name}\n" for name, payload in archives.items()).encode()
+ assets = {"checksums.txt": checksums, **archives}
+ monkeypatch.setattr(lark_cli, "_download_lark_release_asset", lambda _version, name, **_kwargs: assets[name])
+
+ with pytest.raises(ValueError, match="Unsafe Lark CLI runtime archive member"):
+ lark_cli._ensure_managed_sandbox_lark_cli("v1.0.65")
+
+
+def test_managed_sandbox_runtime_accepts_prestaged_airgapped_tree(monkeypatch, tmp_path) -> None:
+ _patch_paths(monkeypatch, tmp_path / "home")
+ source = tmp_path / "pre-staged"
+ for arch in ("amd64", "arm64"):
+ binary = source / f"linux-{arch}" / "lark-cli"
+ binary.parent.mkdir(parents=True)
+ binary.write_bytes(f"{arch}-binary".encode())
+ binary.chmod(0o755)
+ launcher = source / "bin" / "lark-cli"
+ launcher.parent.mkdir(parents=True)
+ launcher.write_text("#!/bin/sh\n", encoding="utf-8")
+ launcher.chmod(0o755)
+ monkeypatch.setenv(lark_cli.LARK_CLI_SANDBOX_RUNTIME_SOURCE_ENV, str(source))
+ monkeypatch.setattr(
+ lark_cli,
+ "_download_lark_release_asset",
+ lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("air-gapped install must not download")),
+ )
+
+ runtime = lark_cli._ensure_managed_sandbox_lark_cli("v1.0.65")
+
+ assert (runtime / "linux-amd64" / "lark-cli").read_bytes() == b"amd64-binary"
+ assert (runtime / "linux-arm64" / "lark-cli").read_bytes() == b"arm64-binary"
+
+
+def test_managed_sandbox_runtime_rejects_any_symlink_in_prestaged_tree(monkeypatch, tmp_path) -> None:
+ _patch_paths(monkeypatch, tmp_path / "home")
+ source = tmp_path / "pre-staged"
+ for arch in ("amd64", "arm64"):
+ binary = source / f"linux-{arch}" / "lark-cli"
+ binary.parent.mkdir(parents=True)
+ binary.write_bytes(f"{arch}-binary".encode())
+ binary.chmod(0o755)
+ launcher = source / "bin" / "lark-cli"
+ launcher.parent.mkdir(parents=True)
+ launcher.write_text("#!/bin/sh\n", encoding="utf-8")
+ launcher.chmod(0o755)
+ outside = tmp_path / "outside-secret"
+ outside.write_text("must-not-be-copied", encoding="utf-8")
+ try:
+ (source / "extra-link").symlink_to(outside)
+ except OSError as exc:
+ pytest.skip(f"symlinks are not available: {exc}")
+ monkeypatch.setenv(lark_cli.LARK_CLI_SANDBOX_RUNTIME_SOURCE_ENV, str(source))
+
+ with pytest.raises(ValueError, match="symlink"):
+ lark_cli._ensure_managed_sandbox_lark_cli("v1.0.65")
+
+ assert not lark_cli.lark_cli_managed_sandbox_dir().exists()
+
+
+def test_managed_sandbox_runtime_rejects_non_executable_prestaged_binary(monkeypatch, tmp_path) -> None:
+ _patch_paths(monkeypatch, tmp_path / "home")
+ source = tmp_path / "pre-staged"
+ for arch in ("amd64", "arm64"):
+ binary = source / f"linux-{arch}" / "lark-cli"
+ binary.parent.mkdir(parents=True)
+ binary.write_bytes(f"{arch}-binary".encode())
+ binary.chmod(0o755)
+ (source / "linux-arm64" / "lark-cli").chmod(0o644)
+ launcher = source / "bin" / "lark-cli"
+ launcher.parent.mkdir(parents=True)
+ launcher.write_text("#!/bin/sh\n", encoding="utf-8")
+ launcher.chmod(0o755)
+ monkeypatch.setenv(lark_cli.LARK_CLI_SANDBOX_RUNTIME_SOURCE_ENV, str(source))
+
+ with pytest.raises(ValueError, match="executable"):
+ lark_cli._ensure_managed_sandbox_lark_cli("v1.0.65")
+
+ assert not lark_cli.lark_cli_managed_sandbox_dir().exists()
+
+
+def test_concurrent_managed_sandbox_runtime_installs_serialize_replacement(monkeypatch, tmp_path) -> None:
+ _patch_paths(monkeypatch, tmp_path / "home")
+ source = tmp_path / "pre-staged"
+ for arch in ("amd64", "arm64"):
+ binary = source / f"linux-{arch}" / "lark-cli"
+ binary.parent.mkdir(parents=True)
+ binary.write_bytes(f"{arch}-binary".encode())
+ binary.chmod(0o755)
+ launcher = source / "bin" / "lark-cli"
+ launcher.parent.mkdir(parents=True)
+ launcher.write_text("#!/bin/sh\n", encoding="utf-8")
+ launcher.chmod(0o755)
+ monkeypatch.setenv(lark_cli.LARK_CLI_SANDBOX_RUNTIME_SOURCE_ENV, str(source))
+
+ real_validate = lark_cli._validate_lark_cli_sandbox_runtime
+ start = threading.Barrier(2)
+ state_lock = threading.Lock()
+ active = 0
+ max_active = 0
+
+ def _slow_validate(root):
+ nonlocal active, max_active
+ with state_lock:
+ active += 1
+ max_active = max(max_active, active)
+ try:
+ time.sleep(0.15)
+ return real_validate(root)
+ finally:
+ with state_lock:
+ active -= 1
+
+ def _install():
+ start.wait()
+ return lark_cli._ensure_managed_sandbox_lark_cli("v1.0.65")
+
+ monkeypatch.setattr(lark_cli, "_validate_lark_cli_sandbox_runtime", _slow_validate)
+ with ThreadPoolExecutor(max_workers=2) as pool:
+ results = [future.result(timeout=5) for future in [pool.submit(_install) for _ in range(2)]]
+
+ assert results[0] == results[1] == lark_cli.lark_cli_managed_sandbox_dir()
+ assert max_active == 1
+ assert not list(results[0].parent.glob(".replacing-sandbox-cli-*"))
+
+
+def test_install_lark_integration_installs_one_readonly_pack_for_all_users(monkeypatch, tmp_path):
+ reset_skill_storage()
+ _patch_paths(monkeypatch, tmp_path / "home")
+ skills_root = tmp_path / "skills"
+ (skills_root / "public").mkdir(parents=True)
+ (skills_root / "custom").mkdir()
+ config = _config(skills_root)
+ archive = _make_lark_cli_source_zip(tmp_path)
+
+ monkeypatch.setattr(lark_cli, "probe_lark_cli", lambda: lark_cli.LarkCliProbe(available=True, path="/usr/bin/lark-cli", version="v1.0.65"))
+ monkeypatch.setattr(lark_cli, "probe_lark_auth", lambda _user_id, **_kwargs: lark_cli.LarkAuthProbe(status="not_configured", message="not configured"))
+
+ result = lark_cli.install_lark_integration("alice", config, source_archive=archive)
+
+ assert result.success is True
+ assert "lark-doc" in result.installed_skills
+ assert result.status.installed is True
+ root = lark_cli.lark_integration_root("alice")
+ assert root == lark_cli.lark_integration_root("bob")
+ assert root == tmp_path / "home" / "integrations" / "skills" / "lark-cli"
+ assert (root / "lark-doc" / "SKILL.md").is_file()
+ assert (root / lark_cli.LARK_CLI_MANIFEST_FILE).is_file()
+ shared_content = (root / "lark-shared" / "SKILL.md").read_text(encoding="utf-8")
+ assert "?settings=integrations" in shared_content
+ assert "不要要求用户在终端执行" in shared_content
+ assert "Exact OAuth scope" in shared_content
+
+ storage = UserScopedSkillStorage("alice", host_path=str(skills_root), app_config=config)
+ skills = storage.load_skills(enabled_only=False)
+ lark_doc = next(skill for skill in skills if skill.name == "lark-doc")
+ assert lark_doc.category == SkillCategory.INTEGRATION
+ assert lark_doc.get_container_file_path("/mnt/skills") == "/mnt/skills/integrations/lark-cli/lark-doc/SKILL.md"
+ assert lark_doc.enabled is True
+
+ bob_storage = UserScopedSkillStorage("bob", host_path=str(skills_root), app_config=config)
+ bob_lark_doc = next(skill for skill in bob_storage.load_skills(enabled_only=False) if skill.name == "lark-doc")
+ assert bob_lark_doc.category == SkillCategory.INTEGRATION
+ assert bob_lark_doc.skill_file == root / "lark-doc" / "SKILL.md"
+ reset_skill_storage()
+
+
+def test_aio_install_provisions_matching_linux_sandbox_runtime(monkeypatch, tmp_path) -> None:
+ reset_skill_storage()
+ _patch_paths(monkeypatch, tmp_path / "home")
+ skills_root = tmp_path / "skills"
+ (skills_root / "public").mkdir(parents=True)
+ (skills_root / "custom").mkdir()
+ config = _config(skills_root)
+ config.sandbox = SimpleNamespace(use="deerflow.community.aio_sandbox:AioSandboxProvider")
+ archive = _make_lark_cli_source_zip(tmp_path)
+ provisioned_versions: list[str] = []
+
+ monkeypatch.setattr(lark_cli, "probe_lark_cli", lambda: lark_cli.LarkCliProbe(available=True, path="/usr/bin/lark-cli", version="v1.0.65"))
+ monkeypatch.setattr(lark_cli, "probe_lark_auth", lambda _user_id, **_kwargs: lark_cli.LarkAuthProbe(status="not_configured", message="not configured"))
+ monkeypatch.setattr(
+ lark_cli,
+ "_ensure_managed_sandbox_lark_cli",
+ lambda version: provisioned_versions.append(version) or lark_cli.lark_cli_managed_sandbox_dir(),
+ )
+
+ result = lark_cli.install_lark_integration("alice", config, source_archive=archive)
+
+ assert result.success is True
+ assert provisioned_versions == ["v1.0.65"]
+ reset_skill_storage()
+
+
+def test_remote_provisioner_install_skips_gateway_sandbox_runtime(monkeypatch, tmp_path) -> None:
+ reset_skill_storage()
+ _patch_paths(monkeypatch, tmp_path / "home")
+ skills_root = tmp_path / "skills"
+ (skills_root / "public").mkdir(parents=True)
+ (skills_root / "custom").mkdir()
+ config = _config(skills_root)
+ config.sandbox = SimpleNamespace(
+ use="deerflow.community.aio_sandbox:AioSandboxProvider",
+ provisioner_url="http://provisioner:8002",
+ )
+ archive = _make_lark_cli_source_zip(tmp_path)
+ provisioned_versions: list[str] = []
+
+ monkeypatch.setattr(lark_cli, "probe_lark_cli", lambda: lark_cli.LarkCliProbe(available=True, path="/usr/bin/lark-cli", version="v1.0.65"))
+ monkeypatch.setattr(lark_cli, "probe_lark_auth", lambda _user_id, **_kwargs: lark_cli.LarkAuthProbe(status="not_configured", message="not configured"))
+ monkeypatch.setattr(
+ lark_cli,
+ "_ensure_managed_sandbox_lark_cli",
+ lambda version: provisioned_versions.append(version) or lark_cli.lark_cli_managed_sandbox_dir(),
+ )
+
+ result = lark_cli.install_lark_integration("alice", config, source_archive=archive)
+
+ assert result.success is True
+ # Remote provisioner mode gets the runtime from an init container, so the
+ # Gateway must not download Linux binaries at install time.
+ assert provisioned_versions == []
+ reset_skill_storage()
+
+
+def test_status_runtime_mode_none_for_non_aio(monkeypatch, tmp_path) -> None:
+ config = _config(tmp_path / "skills")
+ config.sandbox = SimpleNamespace(use="deerflow.sandbox.local:LocalSandboxProvider")
+ mode, ready, detail = lark_cli._resolve_sandbox_runtime_readiness(config, probe=True)
+ assert mode == "none"
+ assert ready is False
+ assert detail
+
+
+def test_status_runtime_mode_gateway_download_ready(monkeypatch, tmp_path) -> None:
+ _patch_paths(monkeypatch, tmp_path / "home")
+ config = _config(tmp_path / "skills")
+ config.sandbox = SimpleNamespace(use="deerflow.community.aio_sandbox:AioSandboxProvider")
+
+ # Stage a valid runtime dir so validation passes.
+ runtime = lark_cli.lark_cli_managed_sandbox_dir()
+ (runtime / "bin").mkdir(parents=True)
+ (runtime / "bin" / "lark-cli").write_text("#!/bin/sh\n", encoding="utf-8")
+ (runtime / "bin" / "lark-cli").chmod(0o755)
+ for arch in lark_cli.LARK_CLI_LINUX_ARCHES:
+ (runtime / f"linux-{arch}").mkdir(parents=True)
+ target = runtime / f"linux-{arch}" / "lark-cli"
+ target.write_bytes(b"\x7fELF")
+ target.chmod(0o755)
+
+ mode, ready, detail = lark_cli._resolve_sandbox_runtime_readiness(config, probe=True)
+ assert mode == "gateway-download"
+ assert ready is True
+ assert detail is None
+
+
+def test_status_runtime_mode_gateway_download_not_ready(monkeypatch, tmp_path) -> None:
+ _patch_paths(monkeypatch, tmp_path / "home")
+ config = _config(tmp_path / "skills")
+ config.sandbox = SimpleNamespace(use="deerflow.community.aio_sandbox:AioSandboxProvider")
+ mode, ready, detail = lark_cli._resolve_sandbox_runtime_readiness(config, probe=True)
+ assert mode == "gateway-download"
+ assert ready is False
+ assert detail
+
+
+def test_status_runtime_mode_init_container_ready(monkeypatch, tmp_path) -> None:
+ config = _config(tmp_path / "skills")
+ config.sandbox = SimpleNamespace(
+ use="deerflow.community.aio_sandbox:AioSandboxProvider",
+ provisioner_url="http://provisioner:8002",
+ )
+ monkeypatch.setattr(lark_cli, "_probe_provisioner_lark_cli_init_image", lambda _config: True)
+ mode, ready, detail = lark_cli._resolve_sandbox_runtime_readiness(config, probe=True)
+ assert mode == "init-container"
+ assert ready is True
+ assert detail is None
+
+
+def test_status_runtime_mode_init_container_not_configured(monkeypatch, tmp_path) -> None:
+ config = _config(tmp_path / "skills")
+ config.sandbox = SimpleNamespace(
+ use="deerflow.community.aio_sandbox:AioSandboxProvider",
+ provisioner_url="http://provisioner:8002",
+ )
+ monkeypatch.setattr(lark_cli, "_probe_provisioner_lark_cli_init_image", lambda _config: False)
+ mode, ready, detail = lark_cli._resolve_sandbox_runtime_readiness(config, probe=True)
+ assert mode == "init-container"
+ assert ready is False
+ assert detail
+
+
+def test_status_runtime_mode_init_container_unreachable(monkeypatch, tmp_path) -> None:
+ config = _config(tmp_path / "skills")
+ config.sandbox = SimpleNamespace(
+ use="deerflow.community.aio_sandbox:AioSandboxProvider",
+ provisioner_url="http://provisioner:8002",
+ )
+ monkeypatch.setattr(lark_cli, "_probe_provisioner_lark_cli_init_image", lambda _config: None)
+ mode, ready, detail = lark_cli._resolve_sandbox_runtime_readiness(config, probe=True)
+ assert mode == "init-container"
+ assert ready is False
+ assert detail
+
+
+def test_status_runtime_probe_skipped_when_not_requested(monkeypatch, tmp_path) -> None:
+ config = _config(tmp_path / "skills")
+ config.sandbox = SimpleNamespace(
+ use="deerflow.community.aio_sandbox:AioSandboxProvider",
+ provisioner_url="http://provisioner:8002",
+ )
+
+ def _fail(_config): # pragma: no cover - must not be called
+ raise AssertionError("provisioner should not be probed when probe=False")
+
+ monkeypatch.setattr(lark_cli, "_probe_provisioner_lark_cli_init_image", _fail)
+ mode, ready, detail = lark_cli._resolve_sandbox_runtime_readiness(config, probe=False)
+ assert mode == "init-container"
+ assert ready is False
+ assert detail is None
+
+
+def test_install_lark_integration_is_idempotent_across_reinstalls(monkeypatch, tmp_path):
+ reset_skill_storage()
+ _patch_paths(monkeypatch, tmp_path / "home")
+ skills_root = tmp_path / "skills"
+ (skills_root / "public").mkdir(parents=True)
+ (skills_root / "custom").mkdir()
+ config = _config(skills_root)
+ archive = _make_lark_cli_source_zip(tmp_path)
+
+ monkeypatch.setattr(lark_cli, "probe_lark_cli", lambda: lark_cli.LarkCliProbe(available=True, path="/usr/bin/lark-cli", version="v1.0.65"))
+ monkeypatch.setattr(lark_cli, "probe_lark_auth", lambda _user_id, **_kwargs: lark_cli.LarkAuthProbe(status="not_configured", message="not configured"))
+
+ first = lark_cli.install_lark_integration("alice", config, source_archive=archive)
+ root = lark_cli.lark_integration_root("alice")
+ # Drop a stray file so the reinstall must replace the whole tree, not merge.
+ stray = root / "lark-doc" / "stray.txt"
+ stray.write_text("stale", encoding="utf-8")
+
+ second = lark_cli.install_lark_integration("alice", config, source_archive=archive)
+
+ assert first.installed_skills == second.installed_skills
+ assert second.status.installed is True
+ assert (root / "lark-doc" / "SKILL.md").is_file()
+ assert not stray.exists()
+ # No leftover backup/staging dirs beside the target after a reinstall.
+ parent = root.parent
+ leftovers = [p.name for p in parent.iterdir() if p.name not in {lark_cli.INTEGRATION_ID, ".lark-cli.install.lock"}]
+ assert leftovers == []
+ reset_skill_storage()
+
+
+@pytest.mark.parametrize("_attempt", range(5))
+def test_concurrent_lark_skill_reinstalls_serialize_atomic_replacement(monkeypatch, tmp_path, _attempt) -> None:
+ _patch_paths(monkeypatch, tmp_path / "home")
+ archive = _make_lark_cli_source_zip(tmp_path)
+ real_extract = lark_cli._extract_lark_skills
+ start = threading.Barrier(2)
+ state_lock = threading.Lock()
+ active = 0
+ max_active = 0
+
+ def _slow_extract(zf, destination):
+ nonlocal active, max_active
+ with state_lock:
+ active += 1
+ max_active = max(max_active, active)
+ try:
+ time.sleep(0.15)
+ return real_extract(zf, destination)
+ finally:
+ with state_lock:
+ active -= 1
+
+ def _install():
+ start.wait()
+ return lark_cli._install_lark_skills_from_archive("alice", archive, version="v1.0.65")
+
+ monkeypatch.setattr(lark_cli, "_extract_lark_skills", _slow_extract)
+ with ThreadPoolExecutor(max_workers=2) as pool:
+ futures = [pool.submit(_install) for _ in range(2)]
+ results = [future.result(timeout=5) for future in futures]
+
+ assert results[0] == results[1]
+ assert max_active == 1
+ root = lark_cli.lark_integration_root()
+ assert (root / "lark-doc" / "SKILL.md").is_file()
+ assert not list(root.parent.glob(".replacing-lark-cli-*"))
+
+
+@pytest.mark.skipif(
+ "fork" not in multiprocessing.get_all_start_methods() or lark_cli.fcntl is None,
+ reason="requires POSIX fork and fcntl",
+)
+def test_concurrent_lark_skill_reinstalls_serialize_across_processes(monkeypatch, tmp_path) -> None:
+ _patch_paths(monkeypatch, tmp_path / "home")
+ archive = _make_lark_cli_source_zip(tmp_path)
+ real_extract = lark_cli._extract_lark_skills
+ context = multiprocessing.get_context("fork")
+ start = context.Barrier(2)
+ active = context.Value("i", 0)
+ max_active = context.Value("i", 0)
+ results = context.Queue()
+
+ def _slow_extract(zf, destination):
+ with active.get_lock(), max_active.get_lock():
+ active.value += 1
+ max_active.value = max(max_active.value, active.value)
+ try:
+ time.sleep(0.2)
+ return real_extract(zf, destination)
+ finally:
+ with active.get_lock():
+ active.value -= 1
+
+ def _install():
+ try:
+ start.wait(timeout=5)
+ installed, digest = lark_cli._install_lark_skills_from_archive("alice", archive, version="v1.0.65")
+ results.put((installed, digest, None))
+ except BaseException as exc: # noqa: BLE001 - propagate child failure
+ results.put((None, None, repr(exc)))
+
+ monkeypatch.setattr(lark_cli, "_extract_lark_skills", _slow_extract)
+ processes = [context.Process(target=_install) for _ in range(2)]
+ for process in processes:
+ process.start()
+ for process in processes:
+ process.join(timeout=10)
+
+ assert [process.exitcode for process in processes] == [0, 0]
+ child_results = [results.get(timeout=2) for _ in processes]
+ assert all(error is None for _installed, _digest, error in child_results), child_results
+ assert child_results[0][:2] == child_results[1][:2]
+ assert max_active.value == 1
+ assert not list(lark_cli.lark_integration_root().parent.glob(".replacing-lark-cli-*"))
+
+
+def test_install_lark_integration_succeeds_when_backup_cleanup_fails(monkeypatch, tmp_path):
+ reset_skill_storage()
+ _patch_paths(monkeypatch, tmp_path / "home")
+ skills_root = tmp_path / "skills"
+ (skills_root / "public").mkdir(parents=True)
+ (skills_root / "custom").mkdir()
+ config = _config(skills_root)
+ archive = _make_lark_cli_source_zip(tmp_path)
+
+ monkeypatch.setattr(lark_cli, "probe_lark_cli", lambda: lark_cli.LarkCliProbe(available=True, path="/usr/bin/lark-cli", version="v1.0.65"))
+ monkeypatch.setattr(lark_cli, "probe_lark_auth", lambda _user_id, **_kwargs: lark_cli.LarkAuthProbe(status="not_configured", message="not configured"))
+
+ # First install lays down the target so the reinstall has a backup to clean.
+ lark_cli.install_lark_integration("alice", config, source_archive=archive)
+
+ real_rmtree = shutil.rmtree
+ forced_raises = {"count": 0}
+
+ def _rmtree(path, *args, **kwargs):
+ # The post-rename backup deletion is best-effort and now passes
+ # ignore_errors=True, so a transient FS error there must not flip a
+ # successful install into a failure. Force any rmtree that does *not*
+ # ignore errors to raise, proving the success path no longer depends on
+ # a fragile backup cleanup.
+ if kwargs.get("ignore_errors"):
+ return real_rmtree(path, *args, **kwargs)
+ forced_raises["count"] += 1
+ raise OSError("transient FS error during backup cleanup")
+
+ monkeypatch.setattr(lark_cli.shutil, "rmtree", _rmtree)
+
+ result = lark_cli.install_lark_integration("alice", config, source_archive=archive)
+
+ assert result.success is True
+ root = lark_cli.lark_integration_root("alice")
+ assert (root / "lark-doc" / "SKILL.md").is_file()
+ # No non-ignoring rmtree is relied upon on the success path, and no leftover
+ # backup dir remains beside the target after the reinstall.
+ assert forced_raises["count"] == 0
+ leftovers = [p.name for p in root.parent.iterdir() if p.name not in {lark_cli.INTEGRATION_ID, ".lark-cli.install.lock"}]
+ assert leftovers == []
+ reset_skill_storage()
+
+
+def test_install_lark_integration_records_content_sha_in_manifest(monkeypatch, tmp_path):
+ reset_skill_storage()
+ _patch_paths(monkeypatch, tmp_path / "home")
+ skills_root = tmp_path / "skills"
+ (skills_root / "public").mkdir(parents=True)
+ (skills_root / "custom").mkdir()
+ config = _config(skills_root)
+ archive = _make_lark_cli_source_zip(tmp_path)
+
+ monkeypatch.setattr(lark_cli, "probe_lark_cli", lambda: lark_cli.LarkCliProbe(available=True, path="/usr/bin/lark-cli", version="v1.0.65"))
+ monkeypatch.setattr(lark_cli, "probe_lark_auth", lambda _user_id, **_kwargs: lark_cli.LarkAuthProbe(status="not_configured", message="not configured"))
+
+ lark_cli.install_lark_integration("alice", config, source_archive=archive)
+
+ manifest = json.loads((lark_cli.lark_integration_root("alice") / lark_cli.LARK_CLI_MANIFEST_FILE).read_text(encoding="utf-8"))
+ assert manifest["version"] == "v1.0.65"
+ assert isinstance(manifest["content_sha256"], str)
+ assert len(manifest["content_sha256"]) == 64
+ reset_skill_storage()
+
+
+def test_install_lark_integration_reports_content_change_on_reinstall(monkeypatch, tmp_path):
+ reset_skill_storage()
+ _patch_paths(monkeypatch, tmp_path / "home")
+ skills_root = tmp_path / "skills"
+ (skills_root / "public").mkdir(parents=True)
+ (skills_root / "custom").mkdir()
+ config = _config(skills_root)
+ archive = _make_lark_cli_source_zip(tmp_path)
+
+ monkeypatch.setattr(lark_cli, "probe_lark_cli", lambda: lark_cli.LarkCliProbe(available=True, path="/usr/bin/lark-cli", version="v1.0.65"))
+ monkeypatch.setattr(lark_cli, "probe_lark_auth", lambda _user_id, **_kwargs: lark_cli.LarkAuthProbe(status="not_configured", message="not configured"))
+
+ first = lark_cli.install_lark_integration("alice", config, source_archive=archive)
+ assert "content changed" not in first.message
+
+ changed_dir = tmp_path / "changed"
+ changed_dir.mkdir()
+ changed_archive = _make_lark_cli_source_zip(changed_dir)
+ with zipfile.ZipFile(changed_archive, "a") as zf:
+ zf.writestr("cli-1.0.65/skills/lark-doc/references/extra.md", "# extra content\n")
+
+ second = lark_cli.install_lark_integration("alice", config, source_archive=changed_archive)
+ assert "content changed" in second.message
+ reset_skill_storage()
+
+
+def test_install_lark_integration_rejects_zip_slip_member(monkeypatch, tmp_path):
+ _patch_paths(monkeypatch, tmp_path / "home")
+ config = _config(tmp_path / "skills")
+ archive = _make_lark_cli_source_zip(tmp_path)
+ with zipfile.ZipFile(archive, "a") as zf:
+ zf.writestr("../evil.txt", "escape")
+
+ with pytest.raises(ValueError, match="Unsafe Lark CLI archive member"):
+ lark_cli.install_lark_integration("alice", config, source_archive=archive)
+
+ _assert_lark_root_missing("alice")
+
+
+def test_install_lark_integration_rejects_symlink_member(monkeypatch, tmp_path):
+ _patch_paths(monkeypatch, tmp_path / "home")
+ config = _config(tmp_path / "skills")
+ archive = _make_lark_cli_source_zip(tmp_path)
+ link_info = zipfile.ZipInfo("cli-1.0.65/skills/lark-doc/references/link")
+ link_info.external_attr = (stat.S_IFLNK | 0o777) << 16
+ with zipfile.ZipFile(archive, "a") as zf:
+ zf.writestr(link_info, "target")
+
+ with pytest.raises(ValueError, match="Unsafe Lark CLI archive member"):
+ lark_cli.install_lark_integration("alice", config, source_archive=archive)
+
+ _assert_lark_root_missing("alice")
+
+
+def test_install_lark_integration_rejects_executable_binary_member(monkeypatch, tmp_path):
+ _patch_paths(monkeypatch, tmp_path / "home")
+ config = _config(tmp_path / "skills")
+ archive = _make_lark_cli_source_zip(tmp_path)
+ with zipfile.ZipFile(archive, "a") as zf:
+ zf.writestr("cli-1.0.65/skills/lark-doc/bin/tool", b"\x7fELFbinary")
+
+ with pytest.raises(ValueError, match="executable binary member"):
+ lark_cli.install_lark_integration("alice", config, source_archive=archive)
+
+ _assert_lark_root_missing("alice")
+
+
+def test_install_lark_integration_rejects_oversized_extraction(monkeypatch, tmp_path):
+ _patch_paths(monkeypatch, tmp_path / "home")
+ config = _config(tmp_path / "skills")
+ archive = _make_lark_cli_source_zip(tmp_path)
+ monkeypatch.setattr(lark_cli, "LARK_CLI_MAX_EXTRACTED_BYTES", 128)
+
+ with pytest.raises(ValueError, match="expands to too much data"):
+ lark_cli.install_lark_integration("alice", config, source_archive=archive)
+
+ _assert_lark_root_missing("alice")
+
+
+def test_install_lark_integration_rejects_missing_required_skill(monkeypatch, tmp_path):
+ _patch_paths(monkeypatch, tmp_path / "home")
+ config = _config(tmp_path / "skills")
+ archive = _make_lark_cli_source_zip(tmp_path, omit_skill="lark-doc")
+
+ with pytest.raises(ValueError, match="missing required skills: lark-doc"):
+ lark_cli.install_lark_integration("alice", config, source_archive=archive)
+
+ _assert_lark_root_missing("alice")
+
+
+def test_install_lark_integration_rejects_renamed_skill_metadata(monkeypatch, tmp_path):
+ _patch_paths(monkeypatch, tmp_path / "home")
+ config = _config(tmp_path / "skills")
+ archive = _make_lark_cli_source_zip(tmp_path, renamed_skill="lark-doc")
+
+ with pytest.raises(ValueError, match="declares name 'lark-doc-renamed'"):
+ lark_cli.install_lark_integration("alice", config, source_archive=archive)
+
+ _assert_lark_root_missing("alice")
+
+
+def test_fallback_and_docker_lark_cli_versions_match():
+ dockerfile = Path(__file__).resolve().parents[1] / "Dockerfile"
+ match = re.search(r"^ARG LARK_CLI_NPM_VERSION=(?P\S+)$", dockerfile.read_text(encoding="utf-8"), re.MULTILINE)
+
+ assert match is not None
+ assert lark_cli.LARK_CLI_NPM_VERSION == match.group("version")
+ assert lark_cli.FALLBACK_LARK_CLI_VERSION == f"v{lark_cli.LARK_CLI_NPM_VERSION}"
+
+
+def test_resolve_lark_cli_path_prefers_managed_gateway_cli(monkeypatch, tmp_path):
+ _patch_paths(monkeypatch, tmp_path / "home")
+ managed_bin = lark_cli.lark_cli_managed_gateway_dir() / "node_modules" / ".bin" / "lark-cli"
+ managed_bin.parent.mkdir(parents=True)
+ managed_bin.write_text("#!/bin/sh\n", encoding="utf-8")
+
+ monkeypatch.setattr(lark_cli.shutil, "which", lambda _name: "/usr/bin/lark-cli")
+
+ assert lark_cli._resolve_lark_cli_path() == str(managed_bin)
+
+
+def test_install_managed_gateway_lark_cli_uses_deerflow_prefix(monkeypatch, tmp_path):
+ _patch_paths(monkeypatch, tmp_path / "home")
+ captured: dict[str, object] = {}
+
+ monkeypatch.setattr(lark_cli.shutil, "which", lambda name: "/usr/bin/npm" if name == "npm" else None)
+
+ def _run(args, **kwargs):
+ captured["args"] = args
+ captured["kwargs"] = kwargs
+ managed_bin = lark_cli.lark_cli_managed_gateway_dir() / "node_modules" / ".bin" / "lark-cli"
+ managed_bin.parent.mkdir(parents=True)
+ managed_bin.write_text("#!/bin/sh\n", encoding="utf-8")
+ return subprocess.CompletedProcess(args=args, returncode=0, stdout="", stderr="")
+
+ monkeypatch.setattr(lark_cli.subprocess, "run", _run)
+ monkeypatch.setattr(lark_cli, "_probe_lark_cli_at_path", lambda path: lark_cli.LarkCliProbe(available=True, path=path, version="lark-cli VERSION 1.2.3"))
+
+ result = lark_cli._install_managed_gateway_lark_cli("v1.2.3")
+
+ assert result.available is True
+ assert result.version == "lark-cli VERSION 1.2.3"
+ assert captured["args"] == [
+ "/usr/bin/npm",
+ "install",
+ "--prefix",
+ str(lark_cli.lark_cli_managed_gateway_dir()),
+ "--no-audit",
+ "--no-fund",
+ "@larksuite/cli@1.2.3",
+ ]
+
+
+def test_install_lark_integration_installs_managed_gateway_cli_before_skill_pack(monkeypatch, tmp_path):
+ reset_skill_storage()
+ _patch_paths(monkeypatch, tmp_path / "home")
+ skills_root = tmp_path / "skills"
+ (skills_root / "public").mkdir(parents=True)
+ (skills_root / "custom").mkdir()
+ config = _config(skills_root)
+ archive = _make_lark_cli_source_zip(tmp_path)
+ downloaded_versions: list[str] = []
+
+ monkeypatch.setattr(lark_cli, "probe_lark_auth", lambda _user_id, **_kwargs: lark_cli.LarkAuthProbe(status="not_configured", message="not configured"))
+ monkeypatch.setattr(lark_cli, "_ensure_managed_gateway_lark_cli", lambda: lark_cli.LarkCliProbe(available=True, path="/managed/bin/lark-cli", version="lark-cli VERSION 9.9.9"))
+
+ def _download(version: str) -> Path:
+ downloaded_versions.append(version)
+ return archive
+
+ monkeypatch.setattr(lark_cli, "_download_lark_archive", _download)
+
+ result = lark_cli.install_lark_integration("alice", config)
+
+ assert downloaded_versions == ["v9.9.9"]
+ assert result.status.manifest_version == "v9.9.9"
+ reset_skill_storage()
+
+
+def test_resolve_latest_lark_cli_version_uses_release_tag(monkeypatch):
+ class _Resp:
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args):
+ return False
+
+ def read(self):
+ return json.dumps({"tag_name": "v1.2.3"}).encode("utf-8")
+
+ monkeypatch.setattr(lark_cli.urllib.request, "urlopen", lambda *a, **k: _Resp())
+ assert lark_cli._resolve_latest_lark_cli_version() == "v1.2.3"
+
+
+def test_resolve_latest_lark_cli_version_falls_back_on_error(monkeypatch):
+ def _boom(*a, **k):
+ raise OSError("network down")
+
+ monkeypatch.setattr(lark_cli.urllib.request, "urlopen", _boom)
+ assert lark_cli._resolve_latest_lark_cli_version() == lark_cli.FALLBACK_LARK_CLI_VERSION
+
+
+def test_lark_archive_url_rejects_invalid_version_tag():
+ with pytest.raises(ValueError, match="Invalid Lark CLI version tag"):
+ lark_cli._lark_archive_url("v1.2.3/../../evil")
+
+
+def test_start_lark_auth_returns_browser_url(monkeypatch, tmp_path):
+ _patch_paths(monkeypatch, tmp_path / "home")
+ captured: dict[str, object] = {}
+
+ def _run(args, **kwargs):
+ captured["args"] = args
+ captured["env"] = kwargs["env"]
+ return subprocess.CompletedProcess(
+ args=args,
+ returncode=0,
+ stdout=json.dumps(
+ {
+ "verification_url": "https://open.feishu.cn/auth/mock",
+ "device_code": "device-code",
+ "expires_in": 600,
+ }
+ ),
+ stderr="",
+ )
+
+ monkeypatch.setattr(lark_cli.shutil, "which", lambda _name: "/usr/bin/lark-cli")
+ monkeypatch.setattr(lark_cli.subprocess, "run", _run)
+
+ result = lark_cli.start_lark_auth("alice", domains=("calendar",), recommend=True)
+
+ assert result.verification_url == "https://open.feishu.cn/auth/mock"
+ assert result.device_code == "device-code"
+ assert captured["args"] == [
+ "/usr/bin/lark-cli",
+ "auth",
+ "login",
+ "--no-wait",
+ "--json",
+ "--recommend",
+ "--domain",
+ "calendar",
+ ]
+ env = captured["env"]
+ assert isinstance(env, dict)
+ assert env["LARKSUITE_CLI_CONFIG_DIR"].endswith("users/alice/integrations/lark-cli/config")
+
+
+def test_start_lark_auth_uses_minimal_login_by_default(monkeypatch, tmp_path):
+ _patch_paths(monkeypatch, tmp_path / "home")
+ captured: dict[str, object] = {}
+
+ def _run(args, **kwargs):
+ captured["args"] = args
+ captured["env"] = kwargs["env"]
+ return subprocess.CompletedProcess(
+ args=args,
+ returncode=0,
+ stdout=json.dumps(
+ {
+ "verification_url": "https://open.feishu.cn/auth/mock",
+ "device_code": "device-code",
+ "expires_in": 600,
+ }
+ ),
+ stderr="",
+ )
+
+ monkeypatch.setattr(lark_cli.shutil, "which", lambda _name: "/usr/bin/lark-cli")
+ monkeypatch.setattr(lark_cli.subprocess, "run", _run)
+
+ result = lark_cli.start_lark_auth("alice")
+
+ assert result.verification_url == "https://open.feishu.cn/auth/mock"
+ assert captured["args"] == [
+ "/usr/bin/lark-cli",
+ "auth",
+ "login",
+ "--no-wait",
+ "--json",
+ ]
+
+
+def test_lark_cli_env_from_runtime_exposes_settings_auth_to_lark_commands(monkeypatch, tmp_path):
+ _patch_paths(monkeypatch, tmp_path / "home")
+ runtime = SimpleNamespace(context={"user_id": "alice"})
+
+ env = _lark_cli_env_from_runtime(runtime, "lark-cli auth status --json", sandbox_paths=False)
+
+ assert env is not None
+ assert env["LARKSUITE_CLI_CONFIG_DIR"].endswith("users/alice/integrations/lark-cli/config")
+ assert env["LARKSUITE_CLI_DATA_DIR"].endswith("users/alice/integrations/lark-cli/data")
+
+
+def test_lark_cli_env_hardens_existing_credential_tree(monkeypatch, tmp_path) -> None:
+ _patch_paths(monkeypatch, tmp_path / "home")
+ config_dir = lark_cli.lark_cli_config_dir("alice")
+ data_dir = lark_cli.lark_cli_data_dir("alice")
+ config_dir.mkdir(parents=True)
+ data_dir.mkdir(parents=True)
+ secret_file = config_dir / "config.json"
+ token_file = data_dir / "auth.json"
+ secret_file.write_text('{"appSecret":"secret"}', encoding="utf-8")
+ token_file.write_text('{"token":"secret"}', encoding="utf-8")
+ config_dir.chmod(0o755)
+ data_dir.chmod(0o777)
+ secret_file.chmod(0o644)
+ token_file.chmod(0o666)
+
+ lark_cli.lark_cli_env_overlay("alice")
+
+ assert stat.S_IMODE(config_dir.stat().st_mode) == 0o700
+ assert stat.S_IMODE(data_dir.stat().st_mode) == 0o700
+ assert stat.S_IMODE(secret_file.stat().st_mode) == 0o600
+ assert stat.S_IMODE(token_file.stat().st_mode) == 0o600
+
+
+def test_lark_cli_env_rejects_symlinks_in_credential_tree(monkeypatch, tmp_path) -> None:
+ _patch_paths(monkeypatch, tmp_path / "home")
+ config_dir = lark_cli.lark_cli_config_dir("alice")
+ config_dir.mkdir(parents=True)
+ outside = tmp_path / "outside-secret"
+ outside.write_text("secret", encoding="utf-8")
+ try:
+ (config_dir / "config.json").symlink_to(outside)
+ except (NotImplementedError, OSError) as exc:
+ pytest.skip(f"symlinks are not available: {exc}")
+
+ with pytest.raises(ValueError, match="symlink"):
+ lark_cli.lark_cli_env_overlay("alice")
+
+
+def test_save_lark_app_config_rehardens_files_written_by_cli(monkeypatch, tmp_path) -> None:
+ _patch_paths(monkeypatch, tmp_path / "home")
+ monkeypatch.setattr(lark_cli, "_require_lark_cli_path", lambda: "/usr/bin/lark-cli")
+
+ def _run(args, **kwargs):
+ config_file = Path(kwargs["env"]["LARKSUITE_CLI_CONFIG_DIR"]) / "config.json"
+ config_file.write_text('{"appSecret":"secret"}', encoding="utf-8")
+ config_file.chmod(0o644)
+ return subprocess.CompletedProcess(args=args, returncode=0, stdout="", stderr="")
+
+ monkeypatch.setattr(lark_cli.subprocess, "run", _run)
+
+ lark_cli._save_lark_app_config_with_cli("alice", app_id="cli_app", app_secret="secret", brand="feishu")
+
+ config_file = lark_cli.lark_cli_config_dir("alice") / "config.json"
+ assert stat.S_IMODE(config_file.stat().st_mode) == 0o600
+
+
+def test_lark_cli_json_rehardens_auth_files_written_by_cli(monkeypatch, tmp_path) -> None:
+ _patch_paths(monkeypatch, tmp_path / "home")
+
+ def _run(args, **kwargs):
+ token_file = Path(kwargs["env"]["LARKSUITE_CLI_DATA_DIR"]) / "auth.json"
+ token_file.write_text('{"token":"secret"}', encoding="utf-8")
+ token_file.chmod(0o644)
+ return subprocess.CompletedProcess(args=args, returncode=0, stdout="{}", stderr="")
+
+ monkeypatch.setattr(lark_cli.subprocess, "run", _run)
+
+ lark_cli._run_lark_cli_json(["/usr/bin/lark-cli", "auth", "login"], user_id="alice", timeout=5)
+
+ token_file = lark_cli.lark_cli_data_dir("alice") / "auth.json"
+ assert stat.S_IMODE(token_file.stat().st_mode) == 0o600
+
+
+def test_lark_cli_env_from_runtime_uses_container_paths_for_sandbox_lark_commands():
+ runtime = SimpleNamespace(context={"user_id": "alice"})
+
+ env = _lark_cli_env_from_runtime(runtime, "/usr/bin/lark-cli auth status", sandbox_paths=True)
+
+ assert env is not None
+ assert env["LARKSUITE_CLI_CONFIG_DIR"] == lark_cli.LARK_CLI_SANDBOX_CONFIG_DIR
+ assert env["LARKSUITE_CLI_DATA_DIR"] == lark_cli.LARK_CLI_SANDBOX_DATA_DIR
+
+
+def test_lark_cli_env_from_runtime_ignores_non_lark_commands(tmp_path, monkeypatch):
+ _patch_paths(monkeypatch, tmp_path / "home")
+ runtime = SimpleNamespace(context={"user_id": "alice"})
+
+ assert _lark_cli_env_from_runtime(runtime, "echo hello", sandbox_paths=False) is None
+
+
+def test_lark_auth_probe_distinguishes_local_configuration_from_live_verification(monkeypatch, tmp_path) -> None:
+ assert "verified" in lark_cli.LarkAuthProbe.__dataclass_fields__
+ _patch_paths(monkeypatch, tmp_path / "home")
+ config_file = lark_cli.lark_cli_config_dir("alice") / "config.json"
+ config_file.parent.mkdir(parents=True)
+ config_file.write_text(
+ json.dumps(
+ {
+ "currentApp": "cli_app",
+ "apps": [
+ {
+ "name": "cli_app",
+ "appId": "cli_app",
+ "appSecret": "secret",
+ "brand": "feishu",
+ }
+ ],
+ }
+ ),
+ encoding="utf-8",
+ )
+ calls: list[list[str]] = []
+
+ monkeypatch.setattr(lark_cli, "_resolve_lark_cli_path", lambda: "/usr/bin/lark-cli")
+
+ def _run(args, **_kwargs):
+ calls.append(args)
+ return subprocess.CompletedProcess(
+ args=args,
+ returncode=0,
+ stdout='{"identities":{"user":{"userName":"Alice"}}}',
+ stderr="",
+ )
+
+ monkeypatch.setattr(lark_cli.subprocess, "run", _run)
+
+ configured = lark_cli.probe_lark_auth("alice", verify=False)
+ live_verified = lark_cli.probe_lark_auth("alice", verify=True)
+
+ assert configured.status == "authenticated"
+ assert configured.verified is False
+ assert "not live-verified" in (configured.message or "")
+ assert live_verified.verified is True
+ assert "live-verified" in (live_verified.message or "")
+ assert calls[0] == ["/usr/bin/lark-cli", "auth", "status", "--json"]
+ assert calls[1] == ["/usr/bin/lark-cli", "auth", "status", "--json", "--verify"]
+
+
+def test_complete_lark_auth_polls_device_code_and_returns_status(monkeypatch, tmp_path):
+ _patch_paths(monkeypatch, tmp_path / "home")
+ skills_root = tmp_path / "skills"
+ (skills_root / "public").mkdir(parents=True)
+ (skills_root / "custom").mkdir()
+ config = _config(skills_root)
+ captured: dict[str, object] = {}
+
+ def _run_lark_cli_json(args, **kwargs):
+ captured["args"] = args
+ captured["kwargs"] = kwargs
+ return {}
+
+ monkeypatch.setattr(lark_cli, "_resolve_lark_cli_path", lambda: "/usr/bin/lark-cli")
+ monkeypatch.setattr(lark_cli, "_run_lark_cli_json", _run_lark_cli_json)
+ monkeypatch.setattr(
+ lark_cli,
+ "get_lark_integration_status",
+ lambda _user_id, _config, **_kwargs: lark_cli.LarkIntegrationStatus(
+ installed=True,
+ version="v1.0.65",
+ manifest_version="v1.0.65",
+ latest_available_version=None,
+ runtime_version_mismatch=False,
+ app_configured=True,
+ app_id="cli_mock",
+ app_brand="feishu",
+ skills_expected=27,
+ skills_installed=27,
+ installed_skills=("lark-doc",),
+ enabled_skills=("lark-doc",),
+ install_path="/tmp/lark",
+ cli=lark_cli.LarkCliProbe(available=True),
+ auth=lark_cli.LarkAuthProbe(status="authenticated", user="Alice"),
+ ),
+ )
+
+ result = lark_cli.complete_lark_auth("alice", config, device_code="device-code")
+
+ assert result.success is True
+ assert captured["args"] == [
+ "/usr/bin/lark-cli",
+ "auth",
+ "login",
+ "--device-code",
+ "device-code",
+ "--json",
+ ]
+ assert captured["kwargs"] == {
+ "user_id": "alice",
+ "timeout": 45,
+ "allow_empty_success": True,
+ }
+
+
+def test_complete_lark_auth_accepts_short_automatic_poll_timeout(monkeypatch, tmp_path) -> None:
+ assert "wait_timeout_seconds" in inspect.signature(lark_cli.complete_lark_auth).parameters
+ _patch_paths(monkeypatch, tmp_path / "home")
+ config = _config(tmp_path / "skills")
+ captured: dict[str, object] = {}
+
+ monkeypatch.setattr(lark_cli, "_require_lark_cli_path", lambda: "/usr/bin/lark-cli")
+ monkeypatch.setattr(
+ lark_cli,
+ "_run_lark_cli_json",
+ lambda _args, **kwargs: captured.update(kwargs) or {},
+ )
+ monkeypatch.setattr(
+ lark_cli,
+ "get_lark_integration_status",
+ lambda _user_id, _config, **_kwargs: lark_cli.LarkIntegrationStatus(
+ installed=True,
+ version="v1.0.65",
+ manifest_version="v1.0.65",
+ latest_available_version=None,
+ runtime_version_mismatch=False,
+ app_configured=True,
+ app_id="cli_mock",
+ app_brand="feishu",
+ skills_expected=27,
+ skills_installed=27,
+ installed_skills=("lark-doc",),
+ enabled_skills=("lark-doc",),
+ install_path="/tmp/lark",
+ cli=lark_cli.LarkCliProbe(available=True),
+ auth=lark_cli.LarkAuthProbe(status="authenticated", user="Alice"),
+ ),
+ )
+
+ result = lark_cli.complete_lark_auth(
+ "alice",
+ config,
+ device_code="device-code",
+ wait_timeout_seconds=8,
+ )
+
+ assert result.success is True
+ assert captured["timeout"] == 8
+
+
+def test_auth_complete_request_bounds_poll_timeout() -> None:
+ model = integrations_router.LarkAuthCompleteRequest(device_code="device-code", wait_timeout_seconds=8)
+ assert "wait_timeout_seconds" in type(model).model_fields
+ assert model.wait_timeout_seconds == 8
+ with pytest.raises(ValueError):
+ integrations_router.LarkAuthCompleteRequest(device_code="device-code", wait_timeout_seconds=4)
+ with pytest.raises(ValueError):
+ integrations_router.LarkAuthCompleteRequest(device_code="device-code", wait_timeout_seconds=46)
+
+
+def test_start_lark_config_returns_app_registration_url(monkeypatch, tmp_path):
+ _patch_paths(monkeypatch, tmp_path / "home")
+ monkeypatch.setattr(
+ lark_cli,
+ "_request_lark_app_registration_begin",
+ lambda _brand: {
+ "user_code": "abc",
+ "device_code": "config-device-code",
+ "expires_in": 600,
+ "interval": 5,
+ },
+ )
+
+ result = lark_cli.start_lark_config("alice", brand="feishu")
+
+ assert result.device_code == "config-device-code"
+ assert result.user_code == "abc"
+ assert result.verification_url.startswith("https://open.feishu.cn/page/cli?")
+ assert "user_code=abc" in result.verification_url
+
+
+def test_complete_lark_config_saves_app_credentials_and_returns_status(monkeypatch, tmp_path):
+ _patch_paths(monkeypatch, tmp_path / "home")
+ skills_root = tmp_path / "skills"
+ (skills_root / "public").mkdir(parents=True)
+ (skills_root / "custom").mkdir()
+ config = _config(skills_root)
+ captured: dict[str, object] = {}
+
+ monkeypatch.setattr(
+ lark_cli,
+ "_poll_lark_app_registration",
+ lambda **_kwargs: {
+ "client_id": "cli_mock",
+ "client_secret": "secret",
+ "user_info": {"tenant_brand": "feishu"},
+ },
+ )
+ monkeypatch.setattr(
+ lark_cli,
+ "_save_lark_app_config_with_cli",
+ lambda user_id, **kwargs: captured.update({"user_id": user_id, **kwargs}),
+ )
+ monkeypatch.setattr(
+ lark_cli,
+ "get_lark_integration_status",
+ lambda _user_id, _config, **_kwargs: lark_cli.LarkIntegrationStatus(
+ installed=True,
+ version="v1.0.65",
+ manifest_version="v1.0.65",
+ latest_available_version=None,
+ runtime_version_mismatch=False,
+ app_configured=True,
+ app_id="cli_mock",
+ app_brand="feishu",
+ skills_expected=27,
+ skills_installed=27,
+ installed_skills=("lark-doc",),
+ enabled_skills=("lark-doc",),
+ install_path="/tmp/lark",
+ cli=lark_cli.LarkCliProbe(available=True),
+ auth=lark_cli.LarkAuthProbe(status="not_authorized", user=None),
+ ),
+ )
+
+ result = lark_cli.complete_lark_config("alice", config, device_code="config-device-code", brand="feishu")
+
+ assert result.success is True
+ assert captured == {
+ "user_id": "alice",
+ "app_id": "cli_mock",
+ "app_secret": "secret",
+ "brand": "feishu",
+ }
+
+
+def test_complete_lark_config_repolls_lark_tenant_for_client_secret(monkeypatch, tmp_path):
+ _patch_paths(monkeypatch, tmp_path / "home")
+ skills_root = tmp_path / "skills"
+ (skills_root / "public").mkdir(parents=True)
+ (skills_root / "custom").mkdir()
+ config = _config(skills_root)
+ poll_calls: list[dict[str, object]] = []
+ captured: dict[str, object] = {}
+
+ def _poll_lark_app_registration(**kwargs):
+ poll_calls.append(kwargs)
+ if kwargs["brand"] == "feishu":
+ return {
+ "client_id": "cli_mock",
+ "user_info": {"tenant_brand": "lark"},
+ }
+ return {
+ "client_id": "cli_mock",
+ "client_secret": "secret",
+ "user_info": {"tenant_brand": "lark"},
+ }
+
+ monkeypatch.setattr(lark_cli, "_poll_lark_app_registration", _poll_lark_app_registration)
+ monkeypatch.setattr(
+ lark_cli,
+ "_save_lark_app_config_with_cli",
+ lambda user_id, **kwargs: captured.update({"user_id": user_id, **kwargs}),
+ )
+ monkeypatch.setattr(
+ lark_cli,
+ "get_lark_integration_status",
+ lambda _user_id, _config, **_kwargs: lark_cli.LarkIntegrationStatus(
+ installed=True,
+ version="v1.0.65",
+ manifest_version="v1.0.65",
+ latest_available_version=None,
+ runtime_version_mismatch=False,
+ app_configured=True,
+ app_id="cli_mock",
+ app_brand="lark",
+ skills_expected=27,
+ skills_installed=27,
+ installed_skills=("lark-doc",),
+ enabled_skills=("lark-doc",),
+ install_path="/tmp/lark",
+ cli=lark_cli.LarkCliProbe(available=True),
+ auth=lark_cli.LarkAuthProbe(status="not_authorized", user=None),
+ ),
+ )
+
+ result = lark_cli.complete_lark_config("alice", config, device_code="config-device-code", brand="feishu")
+
+ assert result.success is True
+ assert [call["brand"] for call in poll_calls] == ["feishu", "lark"]
+ assert captured == {
+ "user_id": "alice",
+ "app_id": "cli_mock",
+ "app_secret": "secret",
+ "brand": "lark",
+ }
+
+
+def _make_user(system_role: str) -> User:
+ return User(email=f"{system_role}-integration@example.com", password_hash="x", system_role=system_role, id=uuid4())
+
+
+def _make_app(*, system_role: str, config):
+ app = make_authed_test_app(user_factory=lambda: _make_user(system_role))
+ app.dependency_overrides[get_config] = lambda: config
+ app.include_router(integrations_router.router)
+ return app
+
+
+def test_lark_install_requires_admin(monkeypatch, tmp_path):
+ config = _config(tmp_path / "skills")
+ app = _make_app(system_role="user", config=config)
+
+ def _should_not_install(*args, **kwargs):
+ raise AssertionError("install should be admin-gated")
+
+ monkeypatch.setattr(integrations_router, "install_lark_integration", _should_not_install)
+
+ with TestClient(app) as client:
+ response = client.post("/api/integrations/lark/install")
+
+ assert response.status_code == 403
+
+
+def test_lark_status_is_available_to_authenticated_users(monkeypatch, tmp_path):
+ config = _config(tmp_path / "skills")
+ app = _make_app(system_role="user", config=config)
+
+ monkeypatch.setattr(
+ integrations_router,
+ "get_lark_integration_status",
+ lambda _user_id, _config, **_kwargs: lark_cli.LarkIntegrationStatus(
+ installed=False,
+ version="v1.0.65",
+ manifest_version=None,
+ latest_available_version=None,
+ runtime_version_mismatch=False,
+ app_configured=False,
+ app_id=None,
+ app_brand=None,
+ skills_expected=27,
+ skills_installed=0,
+ installed_skills=(),
+ enabled_skills=(),
+ install_path="/tmp/lark-cli",
+ cli=lark_cli.LarkCliProbe(available=False, error="missing"),
+ auth=lark_cli.LarkAuthProbe(status="unavailable", message="missing"),
+ ),
+ )
+
+ with TestClient(app) as client:
+ response = client.get("/api/integrations/lark/status")
+
+ assert response.status_code == 200
+ assert response.json()["installed"] is False
+
+
+def _status_with_host_paths() -> lark_cli.LarkIntegrationStatus:
+ return lark_cli.LarkIntegrationStatus(
+ installed=True,
+ version="v1.0.65",
+ manifest_version="v1.0.65",
+ latest_available_version=None,
+ runtime_version_mismatch=False,
+ app_configured=True,
+ app_id="cli_mock",
+ app_brand="feishu",
+ skills_expected=27,
+ skills_installed=27,
+ installed_skills=("lark-doc",),
+ enabled_skills=("lark-doc",),
+ install_path="/home/deer-flow/.deer-flow/integrations/skills/lark-cli",
+ cli=lark_cli.LarkCliProbe(available=True, path="/usr/bin/lark-cli", version="1.0.65"),
+ auth=lark_cli.LarkAuthProbe(status="authenticated", user="alice"),
+ )
+
+
+def test_lark_status_redacts_host_paths_for_non_admin(monkeypatch, tmp_path):
+ config = _config(tmp_path / "skills")
+ app = _make_app(system_role="user", config=config)
+ monkeypatch.setattr(integrations_router, "get_lark_integration_status", lambda *_a, **_k: _status_with_host_paths())
+
+ with TestClient(app) as client:
+ body = client.get("/api/integrations/lark/status").json()
+
+ assert body["install_path"] == ""
+ assert body["cli"]["path"] is None
+ # Non-sensitive fields are still reported.
+ assert body["installed"] is True
+ assert body["cli"]["version"] == "1.0.65"
+
+
+def test_lark_status_exposes_host_paths_for_admin(monkeypatch, tmp_path):
+ config = _config(tmp_path / "skills")
+ app = _make_app(system_role="admin", config=config)
+ monkeypatch.setattr(integrations_router, "get_lark_integration_status", lambda *_a, **_k: _status_with_host_paths())
+
+ with TestClient(app) as client:
+ body = client.get("/api/integrations/lark/status").json()
+
+ assert body["install_path"] == "/home/deer-flow/.deer-flow/integrations/skills/lark-cli"
+ assert body["cli"]["path"] == "/usr/bin/lark-cli"
+
+
+def test_lark_config_start_route_returns_browser_url(monkeypatch, tmp_path):
+ config = _config(tmp_path / "skills")
+ app = _make_app(system_role="user", config=config)
+
+ monkeypatch.setattr(
+ integrations_router,
+ "start_lark_config",
+ lambda _user_id, **_kwargs: lark_cli.LarkConfigStartResult(
+ verification_url="https://open.feishu.cn/page/cli?user_code=config",
+ device_code="config-device-code",
+ expires_in=600,
+ interval=5,
+ user_code="config",
+ brand="feishu",
+ ),
+ )
+
+ with TestClient(app) as client:
+ response = client.post("/api/integrations/lark/config/start", json={"brand": "feishu"})
+
+ assert response.status_code == 200
+ assert response.json()["verification_url"] == "https://open.feishu.cn/page/cli?user_code=config"
+ assert response.json()["device_code"] == "config-device-code"
+
+
+def test_lark_config_complete_route_saves_app_credentials(monkeypatch, tmp_path):
+ config = _config(tmp_path / "skills")
+ app = _make_app(system_role="user", config=config)
+
+ monkeypatch.setattr(
+ integrations_router,
+ "complete_lark_config",
+ lambda _user_id, _config, *, device_code, **_kwargs: lark_cli.LarkConfigCompleteResult(
+ success=True,
+ message=f"configured {device_code}",
+ status=lark_cli.LarkIntegrationStatus(
+ installed=True,
+ version="v1.0.65",
+ manifest_version="v1.0.65",
+ latest_available_version=None,
+ runtime_version_mismatch=False,
+ app_configured=True,
+ app_id="cli_mock",
+ app_brand="feishu",
+ skills_expected=27,
+ skills_installed=27,
+ installed_skills=("lark-doc",),
+ enabled_skills=("lark-doc",),
+ install_path="/tmp/lark",
+ cli=lark_cli.LarkCliProbe(available=True),
+ auth=lark_cli.LarkAuthProbe(status="not_authorized", user=None),
+ ),
+ ),
+ )
+
+ with TestClient(app) as client:
+ response = client.post(
+ "/api/integrations/lark/config/complete",
+ json={"device_code": "config-device-code", "brand": "feishu", "interval": 5, "expires_in": 600},
+ )
+
+ assert response.status_code == 200
+ assert response.json()["success"] is True
+ assert response.json()["status"]["app_configured"] is True
+
+
+def test_lark_auth_start_route_returns_browser_url(monkeypatch, tmp_path):
+ config = _config(tmp_path / "skills")
+ app = _make_app(system_role="user", config=config)
+ captured_kwargs: dict[str, object] = {}
+
+ monkeypatch.setattr(
+ integrations_router,
+ "start_lark_auth",
+ lambda _user_id, **kwargs: (
+ captured_kwargs.update(kwargs)
+ or lark_cli.LarkAuthStartResult(
+ verification_url="https://open.feishu.cn/auth/mock",
+ device_code="device-code",
+ expires_in=600,
+ )
+ ),
+ )
+
+ with TestClient(app) as client:
+ response = client.post("/api/integrations/lark/auth/start", json={})
+
+ assert response.status_code == 200
+ assert response.json()["verification_url"] == "https://open.feishu.cn/auth/mock"
+ assert response.json()["device_code"] == "device-code"
+ assert captured_kwargs == {"domains": (), "scope": None, "recommend": False}
+
+
+def test_lark_auth_start_route_passes_explicit_recommend(monkeypatch, tmp_path):
+ config = _config(tmp_path / "skills")
+ app = _make_app(system_role="user", config=config)
+ captured_kwargs: dict[str, object] = {}
+
+ monkeypatch.setattr(
+ integrations_router,
+ "start_lark_auth",
+ lambda _user_id, **kwargs: (
+ captured_kwargs.update(kwargs)
+ or lark_cli.LarkAuthStartResult(
+ verification_url="https://open.feishu.cn/auth/mock",
+ device_code="device-code",
+ expires_in=600,
+ )
+ ),
+ )
+
+ with TestClient(app) as client:
+ response = client.post("/api/integrations/lark/auth/start", json={"recommend": True})
+
+ assert response.status_code == 200
+ assert response.json()["verification_url"] == "https://open.feishu.cn/auth/mock"
+ assert response.json()["device_code"] == "device-code"
+ assert captured_kwargs == {"domains": (), "scope": None, "recommend": True}
+
+
+def test_lark_auth_complete_route_polls_device_code(monkeypatch, tmp_path):
+ config = _config(tmp_path / "skills")
+ app = _make_app(system_role="user", config=config)
+ captured_kwargs = {}
+
+ def _complete_auth(_user_id, _config, **kwargs):
+ captured_kwargs.update(kwargs)
+ return lark_cli.LarkAuthCompleteResult(
+ success=True,
+ message=f"completed {kwargs['device_code']}",
+ status=lark_cli.LarkIntegrationStatus(
+ installed=True,
+ version="v1.0.65",
+ manifest_version="v1.0.65",
+ latest_available_version=None,
+ runtime_version_mismatch=False,
+ app_configured=True,
+ app_id="cli_mock",
+ app_brand="feishu",
+ skills_expected=27,
+ skills_installed=27,
+ installed_skills=("lark-doc",),
+ enabled_skills=("lark-doc",),
+ install_path="/tmp/lark",
+ cli=lark_cli.LarkCliProbe(available=True),
+ auth=lark_cli.LarkAuthProbe(status="authenticated", user="Alice", verified=True),
+ ),
+ )
+
+ monkeypatch.setattr(integrations_router, "complete_lark_auth", _complete_auth)
+
+ with TestClient(app) as client:
+ response = client.post("/api/integrations/lark/auth/complete", json={"device_code": "device-code"})
+
+ assert response.status_code == 200
+ assert response.json()["success"] is True
+ assert response.json()["status"]["auth"]["status"] == "authenticated"
+ assert response.json()["status"]["auth"]["verified"] is True
+ assert captured_kwargs == {"device_code": "device-code", "wait_timeout_seconds": 45}
diff --git a/backend/tests/test_local_sandbox_provider_mounts.py b/backend/tests/test_local_sandbox_provider_mounts.py
index 1628ad9f5..f072d6378 100644
--- a/backend/tests/test_local_sandbox_provider_mounts.py
+++ b/backend/tests/test_local_sandbox_provider_mounts.py
@@ -544,6 +544,35 @@ class TestMultipleMounts:
class TestLocalSandboxProviderMounts:
+ def test_thread_mappings_mount_global_integrations_for_every_user(self, tmp_path):
+ from deerflow.config.paths import Paths
+
+ paths = Paths(base_dir=tmp_path / "home")
+ skills_dir = tmp_path / "skills"
+ (skills_dir / "public").mkdir(parents=True)
+ (skills_dir / "custom").mkdir()
+ config = SimpleNamespace(
+ skills=SimpleNamespace(
+ container_path="/mnt/skills",
+ get_skills_path=lambda: skills_dir,
+ use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage",
+ )
+ )
+
+ with (
+ patch("deerflow.config.get_app_config", return_value=config),
+ patch("deerflow.config.paths.get_paths", return_value=paths),
+ ):
+ alice = LocalSandboxProvider._build_thread_path_mappings("thread-a", user_id="alice")
+ bob = LocalSandboxProvider._build_thread_path_mappings("thread-b", user_id="bob")
+
+ alice_integrations = next(mapping for mapping in alice if mapping.container_path == "/mnt/skills/integrations")
+ bob_integrations = next(mapping for mapping in bob if mapping.container_path == "/mnt/skills/integrations")
+ expected = str(tmp_path / "home" / "integrations" / "skills")
+ assert alice_integrations.local_path == expected
+ assert bob_integrations.local_path == expected
+ assert alice_integrations.read_only is True
+
def test_setup_path_mappings_uses_configured_skills_container_path_as_reserved_prefix(self, tmp_path):
skills_dir = tmp_path / "skills"
skills_dir.mkdir()
diff --git a/backend/tests/test_paths_user_isolation.py b/backend/tests/test_paths_user_isolation.py
index 5f91e32e1..a7197bcec 100644
--- a/backend/tests/test_paths_user_isolation.py
+++ b/backend/tests/test_paths_user_isolation.py
@@ -66,6 +66,31 @@ class TestMakeSafeUserId:
make_safe_user_id("")
+class TestValidateIntegrationId:
+ def test_accepts_dotted_integration_id(self):
+ from deerflow.config.paths import _validate_integration_id
+
+ assert _validate_integration_id("lark-cli") == "lark-cli"
+ assert _validate_integration_id("some.integration") == "some.integration"
+
+ @pytest.mark.parametrize("integration_id", [".", ".."])
+ def test_rejects_dot_and_dotdot(self, integration_id):
+ from deerflow.config.paths import _validate_integration_id
+
+ with pytest.raises(ValueError, match="Invalid integration_id"):
+ _validate_integration_id(integration_id)
+
+ @pytest.mark.parametrize("integration_id", [".", ".."])
+ def test_host_integration_config_dir_rejects_dot_traversal(self, paths: Paths, integration_id):
+ with pytest.raises(ValueError, match="Invalid integration_id"):
+ paths.host_user_integration_config_dir("alice", integration_id)
+
+ @pytest.mark.parametrize("integration_id", [".", ".."])
+ def test_host_integration_data_dir_rejects_dot_traversal(self, paths: Paths, integration_id):
+ with pytest.raises(ValueError, match="Invalid integration_id"):
+ paths.host_user_integration_data_dir("alice", integration_id)
+
+
class TestUserDir:
def test_user_dir(self, paths: Paths):
assert paths.user_dir("alice") == paths.base_dir / "users" / "alice"
diff --git a/backend/tests/test_provisioner_mount_contract.py b/backend/tests/test_provisioner_mount_contract.py
new file mode 100644
index 000000000..403b890e7
--- /dev/null
+++ b/backend/tests/test_provisioner_mount_contract.py
@@ -0,0 +1,28 @@
+"""Keep Gateway/provisioner extra-mount allowlists in lockstep."""
+
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+
+
+def _literal_assignment(path: Path, name: str):
+ tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
+ for node in tree.body:
+ if isinstance(node, ast.Assign) and any(isinstance(target, ast.Name) and target.id == name for target in node.targets):
+ return ast.literal_eval(node.value)
+ raise AssertionError(f"{name} not found in {path}")
+
+
+def test_gateway_and_provisioner_extra_mount_contracts_match() -> None:
+ gateway_path = REPO_ROOT / "backend/packages/harness/deerflow/community/aio_sandbox/remote_backend.py"
+ provisioner_path = REPO_ROOT / "docker/provisioner/app.py"
+
+ gateway_paths = _literal_assignment(gateway_path, "_PROVISIONER_EXTRA_MOUNT_PATHS")
+ provisioner_paths = _literal_assignment(provisioner_path, "ALLOWED_EXTRA_MOUNT_PATHS")
+
+ assert gateway_paths == provisioner_paths
+ assert "/mnt/integrations/lark-cli/runtime" in gateway_paths
+ assert _literal_assignment(provisioner_path, "MAX_EXTRA_MOUNTS") == 9
diff --git a/backend/tests/test_provisioner_pvc_volumes.py b/backend/tests/test_provisioner_pvc_volumes.py
index c5d03f83a..6c61eedc5 100644
--- a/backend/tests/test_provisioner_pvc_volumes.py
+++ b/backend/tests/test_provisioner_pvc_volumes.py
@@ -1,5 +1,6 @@
"""Regression tests for provisioner three-way skills + PVC volume support."""
+import pytest
# ── _build_volumes ─────────────────────────────────────────────────────
@@ -113,6 +114,67 @@ class TestBuildVolumes:
assert volumes[0].name == "skills"
assert volumes[-1].name == "user-data"
+ def test_extra_mount_uses_hostpath_when_userdata_pvc_is_disabled(self, provisioner_module):
+ """Controlled extra mounts should become extra hostPath volumes by default."""
+ provisioner_module.SKILLS_PVC_NAME = ""
+ provisioner_module.USERDATA_PVC_NAME = ""
+ provisioner_module.DEER_FLOW_HOST_BASE_DIR = "/state"
+ extra_mounts = [
+ provisioner_module.ExtraMount(
+ host_path="/state/users/alice/integrations/lark-cli/config",
+ container_path="/mnt/integrations/lark-cli/config",
+ read_only=False,
+ )
+ ]
+
+ volumes = provisioner_module._build_volumes("thread-1", extra_mounts=extra_mounts)
+
+ # skills-public + skills-custom + user-data (3 base) + 1 extra volume.
+ assert len(volumes) == 4
+ extra_vol = volumes[-1]
+ assert extra_vol.name == "extra-0"
+ assert extra_vol.host_path.path == "/state/users/alice/integrations/lark-cli/config"
+ assert extra_vol.host_path.type == "DirectoryOrCreate"
+
+ def test_extra_mount_uses_userdata_pvc_when_configured(self, provisioner_module):
+ """PVC mode should use the same DeerFlow data PVC for runtime config mounts."""
+ provisioner_module.SKILLS_PVC_NAME = ""
+ provisioner_module.USERDATA_PVC_NAME = "userdata-pvc"
+ provisioner_module.DEER_FLOW_HOST_BASE_DIR = "/state"
+ extra_mounts = [
+ provisioner_module.ExtraMount(
+ host_path="/state/users/alice/integrations/lark-cli/config",
+ container_path="/mnt/integrations/lark-cli/config",
+ read_only=False,
+ )
+ ]
+
+ volumes = provisioner_module._build_volumes("thread-1", extra_mounts=extra_mounts)
+
+ # skills-public + skills-custom + user-data (3 base) + 1 extra volume.
+ assert len(volumes) == 4
+ extra_vol = volumes[-1]
+ assert extra_vol.name == "extra-0"
+ assert extra_vol.persistent_volume_claim is not None
+ assert extra_vol.persistent_volume_claim.claim_name == "userdata-pvc"
+ assert extra_vol.host_path is None
+
+ def test_extra_mount_rejects_paths_outside_deerflow_state(self, provisioner_module):
+ """Provisioner must not accept arbitrary hostPath mounts from clients."""
+ provisioner_module.DEER_FLOW_HOST_BASE_DIR = "/state"
+ extra_mounts = [
+ provisioner_module.ExtraMount(
+ host_path="/etc",
+ container_path="/mnt/integrations/lark-cli/config",
+ read_only=False,
+ )
+ ]
+
+ with pytest.raises(provisioner_module.HTTPException) as exc_info:
+ provisioner_module._build_volumes("thread-1", extra_mounts=extra_mounts)
+
+ assert exc_info.value.status_code == 400
+
# ── _build_volume_mounts ───────────────────────────────────────────────
@@ -226,6 +288,64 @@ class TestBuildVolumeMounts:
userdata_mount = mounts[-1]
assert userdata_mount.sub_path == "deer-flow/users/default/threads/thread-42/user-data"
+ # ── Managed integration extra mounts ───────────────────────────────
+
+ def test_extra_mount_adds_volume_mount(self, provisioner_module):
+ """Controlled extra mounts should be mounted at their requested container path."""
+ provisioner_module.SKILLS_PVC_NAME = ""
+ provisioner_module.USERDATA_PVC_NAME = ""
+ provisioner_module.DEER_FLOW_HOST_BASE_DIR = "/state"
+ extra_mounts = [
+ provisioner_module.ExtraMount(
+ host_path="/state/users/alice/integrations/lark-cli/config",
+ container_path="/mnt/integrations/lark-cli/config",
+ read_only=False,
+ )
+ ]
+
+ mounts = provisioner_module._build_volume_mounts("thread-1", extra_mounts=extra_mounts)
+
+ extra_mount = mounts[-1]
+ assert extra_mount.name == "extra-0"
+ assert extra_mount.mount_path == "/mnt/integrations/lark-cli/config"
+ assert extra_mount.read_only is False
+ assert extra_mount.sub_path is None
+
+ def test_extra_mount_uses_pvc_subpath(self, provisioner_module):
+ """PVC extra mounts should point at the same user-scoped DeerFlow path."""
+ provisioner_module.SKILLS_PVC_NAME = ""
+ provisioner_module.USERDATA_PVC_NAME = "userdata-pvc"
+ provisioner_module.DEER_FLOW_HOST_BASE_DIR = "/state"
+ extra_mounts = [
+ provisioner_module.ExtraMount(
+ host_path="/state/users/alice/integrations/lark-cli/config",
+ container_path="/mnt/integrations/lark-cli/config",
+ read_only=False,
+ )
+ ]
+
+ mounts = provisioner_module._build_volume_mounts("thread-1", extra_mounts=extra_mounts)
+
+ extra_mount = mounts[-1]
+ assert extra_mount.name == "extra-0"
+ assert extra_mount.sub_path == "deer-flow/users/alice/integrations/lark-cli/config"
+
+ def test_extra_mount_rejects_unknown_container_path(self, provisioner_module):
+ """Only first-party managed mount paths are accepted."""
+ provisioner_module.DEER_FLOW_HOST_BASE_DIR = "/state"
+ extra_mounts = [
+ provisioner_module.ExtraMount(
+ host_path="/state/users/alice/integrations/lark-cli/config",
+ container_path="/mnt/secrets",
+ read_only=False,
+ )
+ ]
+
+ with pytest.raises(provisioner_module.HTTPException) as exc_info:
+ provisioner_module._build_volume_mounts("thread-1", extra_mounts=extra_mounts)
+
+ assert exc_info.value.status_code == 400
+
# ── _build_pod integration ─────────────────────────────────────────────
@@ -293,6 +413,37 @@ class TestBuildPodVolumes:
userdata_mount = pod.spec.containers[0].volume_mounts[-1]
assert userdata_mount.sub_path == "deer-flow/users/user-7/threads/thread-1/user-data"
+ def test_pod_includes_extra_mounts(self, provisioner_module):
+ """Provisioner-created pods should include managed integration runtime mounts."""
+ provisioner_module.SKILLS_PVC_NAME = ""
+ provisioner_module.USERDATA_PVC_NAME = ""
+ provisioner_module.DEER_FLOW_HOST_BASE_DIR = "/state"
+ extra_mounts = [
+ provisioner_module.ExtraMount(
+ host_path="/state/users/alice/integrations/lark-cli/config",
+ container_path="/mnt/integrations/lark-cli/config",
+ read_only=False,
+ ),
+ provisioner_module.ExtraMount(
+ host_path="/state/users/alice/integrations/lark-cli/data",
+ container_path="/mnt/integrations/lark-cli/data",
+ read_only=False,
+ ),
+ ]
+
+ pod = provisioner_module._build_pod(
+ "sandbox-1",
+ "thread-1",
+ user_id="alice",
+ extra_mounts=extra_mounts,
+ )
+
+ # skills-public + skills-custom + user-data (3 base) + 2 extra mounts.
+ assert len(pod.spec.volumes) == 5
+ mount_paths = {mount.mount_path for mount in pod.spec.containers[0].volume_mounts}
+ assert "/mnt/integrations/lark-cli/config" in mount_paths
+ assert "/mnt/integrations/lark-cli/data" in mount_paths
+
def test_pod_three_way_skills_mount_paths(self, provisioner_module):
"""Ensure public/custom/legacy mount paths are correct."""
provisioner_module.SKILLS_PVC_NAME = ""
@@ -315,3 +466,98 @@ class TestBuildPodVolumes:
pod = provisioner_module._build_pod("sandbox-1", "thread-1", user_id="user-7")
skills_mount = pod.spec.containers[0].volume_mounts[0]
assert skills_mount.sub_path == "deer-flow/users/user-7/threads/thread-1/skills"
+
+
+class TestLarkCliInitContainer:
+ """Init-container + emptyDir provisioning of the sandbox lark-cli runtime."""
+
+ def test_no_init_container_when_image_unset(self, provisioner_module):
+ provisioner_module.SKILLS_PVC_NAME = ""
+ provisioner_module.USERDATA_PVC_NAME = ""
+ provisioner_module.LARK_CLI_INIT_IMAGE = ""
+ pod = provisioner_module._build_pod(
+ "sandbox-1",
+ "thread-1",
+ provision_lark_cli_runtime=True,
+ )
+ assert not pod.spec.init_containers
+ volume_names = {v.name for v in pod.spec.volumes}
+ assert provisioner_module.LARK_CLI_RUNTIME_VOLUME_NAME not in volume_names
+
+ def test_no_init_container_when_flag_disabled(self, provisioner_module):
+ provisioner_module.SKILLS_PVC_NAME = ""
+ provisioner_module.USERDATA_PVC_NAME = ""
+ provisioner_module.LARK_CLI_INIT_IMAGE = "deer-flow/lark-cli-init:v1.0.65"
+ pod = provisioner_module._build_pod(
+ "sandbox-1",
+ "thread-1",
+ provision_lark_cli_runtime=False,
+ )
+ assert not pod.spec.init_containers
+
+ def test_init_container_and_emptydir_when_enabled(self, provisioner_module):
+ provisioner_module.SKILLS_PVC_NAME = ""
+ provisioner_module.USERDATA_PVC_NAME = ""
+ provisioner_module.LARK_CLI_INIT_IMAGE = "deer-flow/lark-cli-init:v1.0.65"
+ pod = provisioner_module._build_pod(
+ "sandbox-1",
+ "thread-1",
+ provision_lark_cli_runtime=True,
+ )
+
+ # emptyDir volume shared by init + sandbox containers.
+ runtime_volumes = [v for v in pod.spec.volumes if v.name == provisioner_module.LARK_CLI_RUNTIME_VOLUME_NAME]
+ assert len(runtime_volumes) == 1
+ assert runtime_volumes[0].empty_dir is not None
+
+ # Exactly one init container, pointing at the configured image and
+ # writing the runtime path.
+ assert pod.spec.init_containers is not None
+ assert len(pod.spec.init_containers) == 1
+ init = pod.spec.init_containers[0]
+ assert init.image == "deer-flow/lark-cli-init:v1.0.65"
+ init_mount = init.volume_mounts[0]
+ assert init_mount.name == provisioner_module.LARK_CLI_RUNTIME_VOLUME_NAME
+ assert init_mount.mount_path == provisioner_module.LARK_CLI_RUNTIME_CONTAINER_PATH
+ assert init_mount.read_only is False
+
+ # Sandbox container gets a read-only runtime mount at the same path.
+ sandbox_runtime_mounts = [m for m in pod.spec.containers[0].volume_mounts if m.name == provisioner_module.LARK_CLI_RUNTIME_VOLUME_NAME]
+ assert len(sandbox_runtime_mounts) == 1
+ assert sandbox_runtime_mounts[0].mount_path == provisioner_module.LARK_CLI_RUNTIME_CONTAINER_PATH
+ assert sandbox_runtime_mounts[0].read_only is True
+
+ def test_runtime_extra_mount_dropped_when_init_container_enabled(self, provisioner_module):
+ provisioner_module.SKILLS_PVC_NAME = ""
+ provisioner_module.USERDATA_PVC_NAME = ""
+ provisioner_module.DEER_FLOW_HOST_BASE_DIR = "/state"
+ provisioner_module.LARK_CLI_INIT_IMAGE = "deer-flow/lark-cli-init:v1.0.65"
+ extra_mounts = [
+ provisioner_module.ExtraMount(
+ host_path="/state/users/alice/integrations/lark-cli/config",
+ container_path="/mnt/integrations/lark-cli/config",
+ read_only=False,
+ ),
+ provisioner_module.ExtraMount(
+ host_path="/state/integrations/lark-cli/sandbox-cli",
+ container_path="/mnt/integrations/lark-cli/runtime",
+ read_only=True,
+ ),
+ ]
+
+ pod = provisioner_module._build_pod(
+ "sandbox-1",
+ "thread-1",
+ user_id="alice",
+ extra_mounts=extra_mounts,
+ provision_lark_cli_runtime=True,
+ )
+
+ # The credential config mount stays; the hostPath runtime extra mount is
+ # replaced by the emptyDir supplied by the init container (so the runtime
+ # path is not backed by an extra-* hostPath volume).
+ runtime_mounts = [m for m in pod.spec.containers[0].volume_mounts if m.mount_path == "/mnt/integrations/lark-cli/runtime"]
+ assert len(runtime_mounts) == 1
+ assert runtime_mounts[0].name == provisioner_module.LARK_CLI_RUNTIME_VOLUME_NAME
+ mount_paths = {m.mount_path for m in pod.spec.containers[0].volume_mounts}
+ assert "/mnt/integrations/lark-cli/config" in mount_paths
diff --git a/backend/tests/test_remote_sandbox_backend.py b/backend/tests/test_remote_sandbox_backend.py
index c784b25e9..6affb8ddf 100644
--- a/backend/tests/test_remote_sandbox_backend.py
+++ b/backend/tests/test_remote_sandbox_backend.py
@@ -165,11 +165,12 @@ def test_create_delegates_to_provisioner_create(monkeypatch, expected_user_id):
backend = RemoteSandboxBackend("http://provisioner:8002")
expected = SandboxInfo(sandbox_id="abc123", sandbox_url="http://k3s:31001")
- def mock_create(thread_id: str, sandbox_id: str, extra_mounts=None, *, user_id=None):
+ def mock_create(thread_id: str, sandbox_id: str, extra_mounts=None, *, user_id=None, provision_lark_cli_runtime=False):
assert thread_id == "thread-1"
assert sandbox_id == "abc123"
assert extra_mounts == [("/host", "/container", False)]
assert user_id == expected_user_id
+ assert provision_lark_cli_runtime is True
return expected
monkeypatch.setattr(backend, "_provisioner_create", mock_create)
@@ -179,6 +180,7 @@ def test_create_delegates_to_provisioner_create(monkeypatch, expected_user_id):
"abc123",
extra_mounts=[("/host", "/container", False)],
user_id=expected_user_id,
+ provision_lark_cli_runtime=True,
)
assert result == expected
@@ -194,6 +196,7 @@ def test_provisioner_create_returns_sandbox_info(monkeypatch):
"thread_id": "thread-1",
"user_id": "test-user-autouse",
"include_legacy_skills": True,
+ "provision_lark_cli_runtime": False,
}
assert timeout == 30
return _StubResponse(payload={"sandbox_id": "abc123", "sandbox_url": "http://k3s:31001"})
@@ -205,6 +208,103 @@ def test_provisioner_create_returns_sandbox_info(monkeypatch):
assert info.sandbox_url == "http://k3s:31001"
+def test_provisioner_create_forwards_supported_extra_mounts(monkeypatch):
+ backend = RemoteSandboxBackend("http://provisioner:8002")
+ monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda user_id: False)
+
+ def mock_post(url: str, json: dict, timeout: int, headers=None):
+ assert url == "http://provisioner:8002/api/sandboxes"
+ assert json["include_legacy_skills"] is False
+ assert json["extra_mounts"] == [
+ {
+ "host_path": "/state/users/alice/skills/integrations",
+ "container_path": "/mnt/skills/integrations",
+ "read_only": True,
+ },
+ {
+ "host_path": "/state/users/alice/integrations/lark-cli/config",
+ "container_path": "/mnt/integrations/lark-cli/config",
+ "read_only": False,
+ },
+ ]
+ assert timeout == 30
+ return _StubResponse(payload={"sandbox_id": "abc123", "sandbox_url": "http://k3s:31001"})
+
+ monkeypatch.setattr(requests, "post", mock_post)
+
+ backend._provisioner_create(
+ "thread-1",
+ "abc123",
+ extra_mounts=[
+ ("/state/users/alice/threads/thread-1/user-data/workspace", "/mnt/user-data/workspace", False),
+ ("/skills", "/mnt/skills", True),
+ ("/state/users/alice/skills/integrations", "/mnt/skills/integrations", True),
+ ("/state/users/alice/integrations/lark-cli/config", "/mnt/integrations/lark-cli/config", False),
+ ],
+ user_id="alice",
+ )
+
+
+def test_provisioner_create_strips_runtime_mount_when_init_container_enabled(monkeypatch):
+ backend = RemoteSandboxBackend("http://provisioner:8002")
+ monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda user_id: False)
+
+ captured: dict = {}
+
+ def mock_post(url: str, json: dict, timeout: int, headers=None):
+ captured.update(json)
+ return _StubResponse(payload={"sandbox_id": "abc123", "sandbox_url": "http://k3s:31001"})
+
+ monkeypatch.setattr(requests, "post", mock_post)
+
+ backend._provisioner_create(
+ "thread-1",
+ "abc123",
+ extra_mounts=[
+ ("/state/users/alice/integrations/lark-cli/config", "/mnt/integrations/lark-cli/config", False),
+ ("/state/users/alice/integrations/lark-cli/data", "/mnt/integrations/lark-cli/data", False),
+ ("/state/integrations/lark-cli/sandbox-cli", "/mnt/integrations/lark-cli/runtime", True),
+ ],
+ user_id="alice",
+ provision_lark_cli_runtime=True,
+ )
+
+ assert captured["provision_lark_cli_runtime"] is True
+ container_paths = {mount["container_path"] for mount in captured["extra_mounts"]}
+ # The init container supplies the runtime, so its mount is dropped, but the
+ # per-user credential mounts are still forwarded.
+ assert "/mnt/integrations/lark-cli/runtime" not in container_paths
+ assert "/mnt/integrations/lark-cli/config" in container_paths
+ assert "/mnt/integrations/lark-cli/data" in container_paths
+
+
+def test_provisioner_create_keeps_runtime_mount_when_init_container_disabled(monkeypatch):
+ backend = RemoteSandboxBackend("http://provisioner:8002")
+ monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda user_id: False)
+
+ captured: dict = {}
+
+ def mock_post(url: str, json: dict, timeout: int, headers=None):
+ captured.update(json)
+ return _StubResponse(payload={"sandbox_id": "abc123", "sandbox_url": "http://k3s:31001"})
+
+ monkeypatch.setattr(requests, "post", mock_post)
+
+ backend._provisioner_create(
+ "thread-1",
+ "abc123",
+ extra_mounts=[
+ ("/state/integrations/lark-cli/sandbox-cli", "/mnt/integrations/lark-cli/runtime", True),
+ ],
+ user_id="alice",
+ provision_lark_cli_runtime=False,
+ )
+
+ assert captured["provision_lark_cli_runtime"] is False
+ container_paths = {mount["container_path"] for mount in captured["extra_mounts"]}
+ assert "/mnt/integrations/lark-cli/runtime" in container_paths
+
+
def test_provisioner_create_accepts_anonymous_thread_id(monkeypatch):
backend = RemoteSandboxBackend("http://provisioner:8002")
monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda user_id: False)
@@ -216,6 +316,7 @@ def test_provisioner_create_accepts_anonymous_thread_id(monkeypatch):
"thread_id": None,
"user_id": "test-user-autouse",
"include_legacy_skills": False,
+ "provision_lark_cli_runtime": False,
}
assert timeout == 30
return _StubResponse(payload={"sandbox_id": "anon123", "sandbox_url": "http://k3s:31002"})
diff --git a/backend/tests/test_sandbox_tools_security.py b/backend/tests/test_sandbox_tools_security.py
index 4356d13b9..59d0bc068 100644
--- a/backend/tests/test_sandbox_tools_security.py
+++ b/backend/tests/test_sandbox_tools_security.py
@@ -10,6 +10,7 @@ from deerflow.sandbox.tools import (
VIRTUAL_PATH_PREFIX,
_apply_cwd_prefix,
_compiled_mask_patterns,
+ _extract_skill_name_from_skills_path,
_get_custom_mount_for_path,
_get_custom_mounts,
_is_acp_workspace_path,
@@ -258,6 +259,26 @@ def test_mask_local_paths_no_thread_data_still_masks_skills() -> None:
assert "/mnt/skills/a/b.md" in masked
+def test_mask_local_paths_hides_global_integration_skill_paths(tmp_path: Path) -> None:
+ from deerflow.config.paths import Paths
+
+ paths = Paths(base_dir=tmp_path)
+ integration_dir = tmp_path / "integrations" / "skills" / "lark-cli" / "lark-doc"
+ integration_dir.mkdir(parents=True)
+ output = f"Reading: {integration_dir / 'SKILL.md'}"
+
+ with (
+ patch("deerflow.sandbox.tools._get_skills_container_path", return_value="/mnt/skills"),
+ patch("deerflow.sandbox.tools._get_skills_host_path", return_value="/home/user/deer-flow/skills"),
+ patch("deerflow.config.paths.get_paths", return_value=paths),
+ patch("deerflow.runtime.user_context.get_effective_user_id", return_value="alice"),
+ ):
+ masked = mask_local_paths_in_output(output, _THREAD_DATA)
+
+ assert str(integration_dir) not in masked
+ assert "/mnt/skills/integrations/lark-cli/lark-doc/SKILL.md" in masked
+
+
# ---------- _reject_path_traversal ----------
@@ -372,6 +393,42 @@ def test_resolve_skills_path_resolves_root() -> None:
assert resolved == "/home/user/deer-flow/skills"
+def test_extract_skill_name_from_integration_skill_path() -> None:
+ with patch("deerflow.sandbox.tools._get_skills_container_path", return_value="/mnt/skills"):
+ assert _extract_skill_name_from_skills_path("/mnt/skills/integrations/lark-cli/lark-doc/SKILL.md") == "lark-doc"
+ assert _extract_skill_name_from_skills_path("/mnt/skills/integrations/lark-cli") is None
+
+
+def test_resolve_skills_path_resolves_global_integration_skills(tmp_path: Path) -> None:
+ from deerflow.config.paths import Paths
+
+ paths = Paths(base_dir=tmp_path)
+ expected = tmp_path / "integrations" / "skills" / "lark-cli" / "lark-doc" / "SKILL.md"
+ with (
+ patch("deerflow.sandbox.tools._get_skills_container_path", return_value="/mnt/skills"),
+ patch("deerflow.sandbox.tools._get_skills_host_path", return_value="/home/user/deer-flow/skills"),
+ patch("deerflow.config.paths.get_paths", return_value=paths),
+ patch("deerflow.runtime.user_context.get_effective_user_id", return_value="alice"),
+ ):
+ resolved = _resolve_skills_path("/mnt/skills/integrations/lark-cli/lark-doc/SKILL.md")
+
+ assert resolved == str(expected)
+
+
+def test_resolve_skills_path_blocks_integration_traversal(tmp_path: Path) -> None:
+ from deerflow.config.paths import Paths
+
+ paths = Paths(base_dir=tmp_path)
+ with (
+ patch("deerflow.sandbox.tools._get_skills_container_path", return_value="/mnt/skills"),
+ patch("deerflow.sandbox.tools._get_skills_host_path", return_value="/home/user/deer-flow/skills"),
+ patch("deerflow.config.paths.get_paths", return_value=paths),
+ patch("deerflow.runtime.user_context.get_effective_user_id", return_value="alice"),
+ ):
+ with pytest.raises(PermissionError, match="path traversal detected"):
+ _resolve_skills_path("/mnt/skills/integrations/../../etc/passwd")
+
+
def test_resolve_skills_path_raises_when_not_configured() -> None:
"""Should raise FileNotFoundError when skills directory is not available."""
with (
diff --git a/backend/tests/test_user_scoped_skill_storage.py b/backend/tests/test_user_scoped_skill_storage.py
index 6ca2830e6..532fb070f 100644
--- a/backend/tests/test_user_scoped_skill_storage.py
+++ b/backend/tests/test_user_scoped_skill_storage.py
@@ -136,6 +136,24 @@ class TestSkillLoading:
assert len(public_skills) == 1
assert public_skills[0].name == "deep-research"
+ def test_managed_integration_skills_are_global_but_enabled_per_user(self, base_dir: Path, skills_root: Path, config):
+ integration_dir = base_dir / "integrations" / "skills" / "lark-cli" / "lark-doc"
+ integration_dir.mkdir(parents=True)
+ (integration_dir / "SKILL.md").write_text(_skill_content("lark-doc"), encoding="utf-8")
+
+ with patch("deerflow.config.paths.get_paths", return_value=Paths(base_dir=base_dir)):
+ alice = UserScopedSkillStorage("alice", host_path=str(skills_root), app_config=config)
+ bob = UserScopedSkillStorage("bob", host_path=str(skills_root), app_config=config)
+
+ alice.set_skill_enabled_state("lark-doc", False)
+
+ alice_skill = next(skill for skill in alice.load_skills(enabled_only=False) if skill.name == "lark-doc")
+ bob_skill = next(skill for skill in bob.load_skills(enabled_only=False) if skill.name == "lark-doc")
+ assert alice_skill.category == SkillCategory.INTEGRATION
+ assert alice_skill.skill_file == integration_dir / "SKILL.md"
+ assert alice_skill.enabled is False
+ assert bob_skill.enabled is True
+
def test_public_skill_package_children_are_not_registered(self, user_storage: UserScopedSkillStorage, skills_root: Path):
public_dir = skills_root / "public" / "reviewer"
fixture_dir = public_dir / "evals" / "fixtures" / "injection"
diff --git a/docker/docker-compose-dev.yaml b/docker/docker-compose-dev.yaml
index a623a32f3..8fb8b79fe 100644
--- a/docker/docker-compose-dev.yaml
+++ b/docker/docker-compose-dev.yaml
@@ -46,6 +46,10 @@ services:
environment:
- K8S_NAMESPACE=deer-flow
- SANDBOX_IMAGE=enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest
+ # Optional lark-cli init image (Pattern A). Empty ⇒ legacy runtime mount.
+ # Set to a published tag (e.g. deer-flow/lark-cli-init:v1.0.65) to provision
+ # the sandbox lark-cli runtime via an init container + emptyDir.
+ - LARK_CLI_INIT_IMAGE=${LARK_CLI_INIT_IMAGE:-}
# Host paths for K8s HostPath volumes (must be absolute paths accessible by K8s node)
# On Docker Desktop/OrbStack, use your actual host paths like /Users/username/...
# Set these in your shell before running docker-compose:
@@ -146,6 +150,8 @@ services:
APT_MIRROR: ${APT_MIRROR:-}
UV_IMAGE: ${UV_IMAGE:-ghcr.io/astral-sh/uv:0.7.20}
UV_INDEX_URL: ${UV_INDEX_URL:-https://pypi.org/simple}
+ NPM_REGISTRY: ${NPM_REGISTRY:-}
+ LARK_CLI_NPM_VERSION: ${LARK_CLI_NPM_VERSION:-1.0.65}
container_name: deer-flow-gateway
# Startup logic lives in docker/dev-entrypoint.sh — UV_EXTRAS validation,
# `uv sync --all-packages`, .venv self-heal, and uvicorn handoff. Keeps
diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml
index 32b07f7a7..2e33c49e2 100644
--- a/docker/docker-compose.yaml
+++ b/docker/docker-compose.yaml
@@ -88,6 +88,8 @@ services:
UV_IMAGE: ${UV_IMAGE:-ghcr.io/astral-sh/uv:0.7.20}
UV_INDEX_URL: ${UV_INDEX_URL:-https://pypi.org/simple}
UV_EXTRAS: ${UV_EXTRAS:-}
+ NPM_REGISTRY: ${NPM_REGISTRY:-}
+ LARK_CLI_NPM_VERSION: ${LARK_CLI_NPM_VERSION:-1.0.65}
container_name: deer-flow-gateway
# Gateway hosts the agent runtime with in-process RunManager + StreamBridge
# singletons -- run state lives in this worker's memory. Default to a single
@@ -156,6 +158,10 @@ services:
environment:
- K8S_NAMESPACE=deer-flow
- SANDBOX_IMAGE=enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest
+ # Optional lark-cli init image (Pattern A). Empty ⇒ legacy runtime mount.
+ # Set to a published tag (e.g. deer-flow/lark-cli-init:v1.0.65) to provision
+ # the sandbox lark-cli runtime via an init container + emptyDir.
+ - LARK_CLI_INIT_IMAGE=${LARK_CLI_INIT_IMAGE:-}
- SKILLS_HOST_PATH=${DEER_FLOW_REPO_ROOT}/skills
- THREADS_HOST_PATH=${DEER_FLOW_HOME}/threads
- DEER_FLOW_HOST_BASE_DIR=${DEER_FLOW_HOME}
diff --git a/docker/lark-cli-init/Dockerfile b/docker/lark-cli-init/Dockerfile
new file mode 100644
index 000000000..25941df6e
--- /dev/null
+++ b/docker/lark-cli-init/Dockerfile
@@ -0,0 +1,63 @@
+# syntax=docker/dockerfile:1
+#
+# DeerFlow "lark-cli init" image (Pattern A).
+#
+# Purpose: provide the sandbox `lark-cli` runtime binary to a Kubernetes sandbox
+# Pod via an init container + shared `emptyDir`, instead of downloading Linux
+# binaries from GitHub at install time and mounting them via hostPath/PVC.
+#
+# At BUILD time (network available) this image downloads and SHA-256-verifies the
+# official `larksuite/cli` Linux release binaries and stages the runtime layout:
+#
+# /opt/lark-cli/bin/lark-cli # arch-dispatch launcher (uname -m)
+# /opt/lark-cli/linux-amd64/lark-cli
+# /opt/lark-cli/linux-arm64/lark-cli
+# /opt/lark-cli/.deerflow-lark-cli-runtime.json # {"version": "vX.Y.Z"}
+#
+# This layout is byte-identical to what the Gateway writer
+# (`_write_lark_cli_sandbox_launcher`) produces and what
+# `_validate_lark_cli_sandbox_runtime` enforces, so the sandbox PATH contract
+# (`/mnt/integrations/lark-cli/runtime/bin/lark-cli`) is unchanged.
+#
+# At RUN time the default command copies `/opt/lark-cli/.` into the shared
+# emptyDir mounted at `/mnt/integrations/lark-cli/runtime` and exits.
+#
+# The image tag should encode the lark-cli version so it can be bumped
+# independently of the upstream `all-in-one-sandbox` image.
+#
+# Build (defaults to the pinned version below):
+# docker build -t deer-flow/lark-cli-init:v1.0.65 \
+# --build-arg LARK_CLI_VERSION=v1.0.65 docker/lark-cli-init
+
+FROM debian:bookworm-slim AS builder
+
+ARG APT_MIRROR
+# Version tag on the larksuite/cli GitHub releases (with or without leading "v").
+ARG LARK_CLI_VERSION=v1.0.65
+
+RUN if [ -n "${APT_MIRROR}" ]; then \
+ sed -i "s|deb.debian.org|${APT_MIRROR}|g" /etc/apt/sources.list.d/debian.sources 2>/dev/null || true; \
+ sed -i "s|deb.debian.org|${APT_MIRROR}|g" /etc/apt/sources.list 2>/dev/null || true; \
+ fi
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ ca-certificates \
+ curl \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY build-runtime.sh /usr/local/bin/build-runtime.sh
+RUN chmod +x /usr/local/bin/build-runtime.sh \
+ && LARK_CLI_VERSION="${LARK_CLI_VERSION}" /usr/local/bin/build-runtime.sh /opt/lark-cli
+
+# ── Final image: minimal, just the staged runtime + a copy entrypoint ────────
+FROM debian:bookworm-slim
+
+COPY --from=builder /opt/lark-cli /opt/lark-cli
+COPY entrypoint.sh /usr/local/bin/lark-cli-init
+RUN chmod +x /usr/local/bin/lark-cli-init
+
+# Destination is the emptyDir the provisioner mounts into both this init
+# container and the main sandbox container.
+ENV LARK_CLI_RUNTIME_DEST=/mnt/integrations/lark-cli/runtime
+
+ENTRYPOINT ["/usr/local/bin/lark-cli-init"]
diff --git a/docker/lark-cli-init/README.md b/docker/lark-cli-init/README.md
new file mode 100644
index 000000000..9d958022e
--- /dev/null
+++ b/docker/lark-cli-init/README.md
@@ -0,0 +1,63 @@
+# lark-cli init image (Pattern A)
+
+This image provisions the sandbox `lark-cli` runtime binary into a Kubernetes
+sandbox Pod via an **init container + shared `emptyDir`**, instead of the Gateway
+downloading Linux binaries from GitHub at install time and mounting them via
+hostPath/PVC.
+
+See the design at
+[`docs/superpowers/specs/2026-07-21-lark-sandbox-init-container-design.md`](../../docs/superpowers/specs/2026-07-21-lark-sandbox-init-container-design.md).
+
+## What it does
+
+- **Build time** (network available): downloads and SHA-256-verifies the official
+ `larksuite/cli` Linux release binaries and stages the runtime layout under
+ `/opt/lark-cli`:
+
+ ```
+ /opt/lark-cli/bin/lark-cli # arch-dispatch launcher (uname -m)
+ /opt/lark-cli/linux-amd64/lark-cli
+ /opt/lark-cli/linux-arm64/lark-cli
+ /opt/lark-cli/.deerflow-lark-cli-runtime.json # {"version": "vX.Y.Z"}
+ ```
+
+ This is byte-identical to what the Gateway writer
+ (`_write_lark_cli_sandbox_launcher`) produces and what
+ `_validate_lark_cli_sandbox_runtime` enforces, so the sandbox PATH contract
+ (`/mnt/integrations/lark-cli/runtime/bin/lark-cli`) is unchanged.
+
+- **Run time**: copies `/opt/lark-cli/.` into the emptyDir mounted at
+ `${LARK_CLI_RUNTIME_DEST}` (default `/mnt/integrations/lark-cli/runtime`) and
+ exits `0`.
+
+## Build
+
+```bash
+docker build -t deer-flow/lark-cli-init:v1.0.65 \
+ --build-arg LARK_CLI_VERSION=v1.0.65 \
+ docker/lark-cli-init
+```
+
+The tag should encode the lark-cli version so it can be bumped independently of
+the upstream `all-in-one-sandbox` sandbox image.
+
+## Wiring it into the provisioner
+
+The init-container runtime path is **opt-in** and off by default. Enable it by
+publishing this image and pointing the provisioner at it:
+
+- Set `LARK_CLI_INIT_IMAGE` on the provisioner service to the published tag
+ (e.g. `deer-flow/lark-cli-init:v1.0.65`). Empty ⇒ legacy hostPath / Gateway
+ download path (no behavior change).
+- When set, the provisioner adds a `lark-cli-runtime` `emptyDir` volume, an
+ `lark-cli-init` init container, and a read-only runtime mount on the sandbox
+ container — and ignores any `/mnt/integrations/lark-cli/runtime` hostPath/PVC
+ extra mount (the init container supersedes it). The per-user `config` / `data`
+ credential mounts are unchanged.
+- The provisioner reports whether it is configured via `GET /api/capabilities`
+ (`{"lark_cli_init_image": true|false}`), which the Gateway surfaces as the
+ Lark integration sandbox-runtime readiness signal in `/api/integrations/lark/status`.
+
+> Publishing note: this repository currently ships only backend/frontend images.
+> Publishing a `lark-cli-init` tag is a fast-follow; until then the feature stays
+> behind the empty-default `LARK_CLI_INIT_IMAGE`.
diff --git a/docker/lark-cli-init/build-runtime.sh b/docker/lark-cli-init/build-runtime.sh
new file mode 100644
index 000000000..62090a36d
--- /dev/null
+++ b/docker/lark-cli-init/build-runtime.sh
@@ -0,0 +1,87 @@
+#!/bin/sh
+# Stage the DeerFlow sandbox lark-cli runtime layout from official release
+# binaries. Runs at image BUILD time (network available).
+#
+# Usage: LARK_CLI_VERSION=v1.0.65 build-runtime.sh /opt/lark-cli
+#
+# Produces:
+# /bin/lark-cli arch-dispatch launcher (uname -m)
+# /linux-amd64/lark-cli
+# /linux-arm64/lark-cli
+# /.deerflow-lark-cli-runtime.json {"version": "vX.Y.Z"}
+#
+# The layout mirrors the Gateway writer (_write_lark_cli_sandbox_launcher) and
+# satisfies _validate_lark_cli_sandbox_runtime, so the sandbox PATH contract is
+# unchanged.
+set -eu
+
+DEST="${1:?destination directory required}"
+VERSION="${LARK_CLI_VERSION:?LARK_CLI_VERSION required}"
+REPO="larksuite/cli"
+ARCHES="amd64 arm64"
+
+# Normalize to a leading-v tag and a bare version.
+case "$VERSION" in
+ v*) TAG="$VERSION" ;;
+ *) TAG="v$VERSION" ;;
+esac
+BARE="${TAG#v}"
+
+BASE_URL="https://github.com/${REPO}/releases/download/${TAG}"
+WORK="$(mktemp -d)"
+trap 'rm -rf "$WORK"' EXIT
+
+echo "Downloading lark-cli ${TAG} release checksums..."
+curl -fsSL "${BASE_URL}/checksums.txt" -o "${WORK}/checksums.txt"
+
+mkdir -p "${DEST}/bin"
+
+for arch in $ARCHES; do
+ asset="lark-cli-${BARE}-linux-${arch}.tar.gz"
+ echo "Downloading ${asset}..."
+ curl -fsSL "${BASE_URL}/${asset}" -o "${WORK}/${asset}"
+
+ expected="$(awk -v f="${asset}" '$2 == f || $2 == "*"f {print $1}' "${WORK}/checksums.txt" | head -n1)"
+ if [ -z "$expected" ]; then
+ echo "ERROR: no checksum for ${asset} in checksums.txt" >&2
+ exit 1
+ fi
+ actual="$(sha256sum "${WORK}/${asset}" | awk '{print $1}')"
+ if [ "$expected" != "$actual" ]; then
+ echo "ERROR: checksum mismatch for ${asset} (expected ${expected}, got ${actual})" >&2
+ exit 1
+ fi
+
+ mkdir -p "${DEST}/linux-${arch}"
+ # Extract only the single lark-cli executable from the archive.
+ tar -xzf "${WORK}/${asset}" -C "${WORK}"
+ found="$(find "${WORK}" -type f -name lark-cli ! -path "${DEST}/*" | head -n1)"
+ if [ -z "$found" ]; then
+ echo "ERROR: lark-cli executable not found in ${asset}" >&2
+ exit 1
+ fi
+ install -m 0755 "$found" "${DEST}/linux-${arch}/lark-cli"
+ rm -f "$found"
+done
+
+# Arch-dispatch launcher. Kept byte-identical to
+# LARK_CLI_SANDBOX_LAUNCHER_SCRIPT in
+# backend/packages/harness/deerflow/integrations/lark_cli.py
+# (a unit test asserts the two never drift).
+cat > "${DEST}/bin/lark-cli" <<'LAUNCHER'
+#!/bin/sh
+set -eu
+case "$(uname -m)" in
+ x86_64|amd64) arch=amd64 ;;
+ aarch64|arm64) arch=arm64 ;;
+ *) echo "Unsupported sandbox architecture: $(uname -m)" >&2; exit 126 ;;
+esac
+script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+exec "$script_dir/../linux-$arch/lark-cli" "$@"
+LAUNCHER
+chmod 0755 "${DEST}/bin/lark-cli"
+
+printf '{\n "version": "%s"\n}\n' "$TAG" > "${DEST}/.deerflow-lark-cli-runtime.json"
+
+echo "Staged lark-cli ${TAG} runtime at ${DEST}:"
+ls -R "${DEST}"
diff --git a/docker/lark-cli-init/entrypoint.sh b/docker/lark-cli-init/entrypoint.sh
new file mode 100644
index 000000000..1a1bc9166
--- /dev/null
+++ b/docker/lark-cli-init/entrypoint.sh
@@ -0,0 +1,24 @@
+#!/bin/sh
+# DeerFlow lark-cli init container entrypoint (Pattern A).
+#
+# Copies the staged sandbox runtime layout into the shared emptyDir the
+# provisioner mounts into both this init container and the main sandbox
+# container, then exits. The sandbox container then finds the launcher at
+# ${LARK_CLI_RUNTIME_DEST}/bin/lark-cli — exactly where
+# lark_cli_env_overlay(sandbox_paths=True) points PATH.
+set -eu
+
+SRC="/opt/lark-cli"
+DEST="${LARK_CLI_RUNTIME_DEST:-/mnt/integrations/lark-cli/runtime}"
+
+if [ ! -x "${SRC}/bin/lark-cli" ]; then
+ echo "ERROR: staged runtime missing at ${SRC}/bin/lark-cli" >&2
+ exit 1
+fi
+
+mkdir -p "${DEST}"
+# Copy contents (including the dotfile manifest) into the destination.
+cp -a "${SRC}/." "${DEST}/"
+
+echo "Provisioned lark-cli runtime into ${DEST}:"
+ls -R "${DEST}"
diff --git a/docker/provisioner/README.md b/docker/provisioner/README.md
index 3f2875b50..455717ac9 100644
--- a/docker/provisioner/README.md
+++ b/docker/provisioner/README.md
@@ -145,6 +145,7 @@ The provisioner is configured via environment variables (set in [docker-compose-
|----------|---------|-------------|
| `K8S_NAMESPACE` | `deer-flow` | Kubernetes namespace for sandbox resources |
| `SANDBOX_IMAGE` | `enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest` | AIO-compatible container image for sandbox Pods |
+| `LARK_CLI_INIT_IMAGE` | empty (feature off) | Optional lark-cli init image (Pattern A). When set, sandbox Pods requesting the lark-cli runtime get an init container + shared `emptyDir` that provisions `lark-cli`, instead of a hostPath/PVC runtime mount. See [`docker/lark-cli-init`](../lark-cli-init/README.md) |
| `SKILLS_HOST_PATH` | - | **Host machine** path to skills directory (must be absolute) |
| `THREADS_HOST_PATH` | - | **Host machine** path to threads data directory (must be absolute) |
| `SKILLS_PVC_NAME` | empty (use hostPath) | PVC name for skills volume; when set, sandbox Pods use PVC instead of hostPath |
@@ -163,6 +164,29 @@ For persistent dependencies, build an image that extends the default `all-in-one
See [Building a Custom AIO Sandbox Image](../../backend/docs/CONFIGURATION.md#building-a-custom-aio-sandbox-image) for the runtime contract and a minimal Dockerfile example.
+### Lark CLI sandbox runtime (Pattern A)
+
+Agents run `lark-cli` **inside** the sandbox, so the binary must exist in the
+sandbox container. Instead of the Gateway downloading Linux binaries from GitHub
+at install time and mounting them via hostPath/PVC, the provisioner can inject
+`lark-cli` with an **init container + shared `emptyDir`**:
+
+1. Publish a lark-cli init image (see [`docker/lark-cli-init`](../lark-cli-init/README.md))
+ and set `LARK_CLI_INIT_IMAGE` on the provisioner.
+2. When a sandbox is created with `provision_lark_cli_runtime: true` (the Gateway
+ sends this automatically once the managed Lark skill pack is installed), the
+ Pod gets a `lark-cli-runtime` `emptyDir`, an `lark-cli-init` init container
+ that copies the runtime into it, and a read-only runtime mount on the sandbox
+ container at `/mnt/integrations/lark-cli/runtime`. Any hostPath/PVC extra
+ mount at that path is dropped (the init container supersedes it); the per-user
+ `config`/`data` credential mounts are unchanged.
+
+`GET /api/capabilities` returns `{"lark_cli_init_image": true|false}` so the
+Gateway can surface a sandbox-runtime readiness signal in
+`/api/integrations/lark/status` — a green UI can't then hide a chat-time
+`lark-cli: command not found`.
+
+
### PVC User-Data Upgrade Note
Older provisioner versions mounted PVC user-data from `threads/{thread_id}/user-data`. The user-scoped layout mounts from `deer-flow/users/{user_id}/threads/{thread_id}/user-data`.
diff --git a/docker/provisioner/app.py b/docker/provisioner/app.py
index 8f3dfc83e..6796736b8 100644
--- a/docker/provisioner/app.py
+++ b/docker/provisioner/app.py
@@ -31,6 +31,7 @@ from __future__ import annotations
import logging
import os
+import posixpath
import re
import secrets
import time
@@ -59,6 +60,13 @@ SANDBOX_IMAGE = os.environ.get(
"SANDBOX_IMAGE",
"enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest",
)
+# Optional "lark-cli init" image (Pattern A). When set, sandbox Pods get an init
+# container + shared emptyDir that provisions the lark-cli runtime binary, instead
+# of a hostPath/PVC runtime mount fed by a Gateway-side GitHub download. Empty ⇒
+# feature off (legacy behavior).
+LARK_CLI_INIT_IMAGE = os.environ.get("LARK_CLI_INIT_IMAGE", "")
+LARK_CLI_RUNTIME_CONTAINER_PATH = "/mnt/integrations/lark-cli/runtime"
+LARK_CLI_RUNTIME_VOLUME_NAME = "lark-cli-runtime"
SKILLS_HOST_PATH = os.environ.get("SKILLS_HOST_PATH", "/skills")
THREADS_HOST_PATH = os.environ.get("THREADS_HOST_PATH", "/.deer-flow/threads")
DEER_FLOW_HOST_BASE_DIR = os.environ.get("DEER_FLOW_HOST_BASE_DIR", "/.deer-flow")
@@ -78,6 +86,15 @@ if SANDBOX_SERVICE_TYPE not in {"NodePort", "ClusterIP"}:
SAFE_THREAD_ID_PATTERN = r"^[A-Za-z0-9_\-]+$"
SAFE_USER_ID_PATTERN = r"^[A-Za-z0-9_\-]+$"
DEFAULT_USER_ID = "default"
+MAX_EXTRA_MOUNTS = 9
+ALLOWED_EXTRA_MOUNT_PATHS = {
+ "/mnt/acp-workspace",
+ "/mnt/skills/custom",
+ "/mnt/skills/integrations",
+ "/mnt/integrations/lark-cli/config",
+ "/mnt/integrations/lark-cli/data",
+ "/mnt/integrations/lark-cli/runtime",
+}
# Path to the kubeconfig *inside* the provisioner container.
# Typically the host's ~/.kube/config is mounted here.
@@ -111,6 +128,109 @@ def join_host_path(base: str, *parts: str) -> str:
return str(result)
+def _host_base_dir_for_extra_mounts() -> str:
+ """Return the host-visible DeerFlow state root used for controlled mounts."""
+ if DEER_FLOW_HOST_BASE_DIR:
+ return os.path.normpath(DEER_FLOW_HOST_BASE_DIR)
+
+ normalized_threads = os.path.normpath(THREADS_HOST_PATH)
+ if os.path.basename(normalized_threads) == "threads":
+ return os.path.dirname(normalized_threads)
+ return ""
+
+
+def _is_path_under_base(path: str, base: str) -> bool:
+ """Return whether *path* is inside *base* after normalization."""
+ if not base:
+ return False
+ try:
+ return os.path.commonpath([os.path.normpath(path), os.path.normpath(base)]) == os.path.normpath(base)
+ except ValueError:
+ return False
+
+
+def _normalize_extra_mount_container_path(container_path: str) -> str:
+ normalized = posixpath.normpath(container_path)
+ if not normalized.startswith("/"):
+ raise HTTPException(status_code=400, detail=f"Extra mount path must be absolute: {container_path}")
+ if normalized not in ALLOWED_EXTRA_MOUNT_PATHS:
+ raise HTTPException(status_code=400, detail=f"Unsupported extra mount path: {container_path}")
+ return normalized
+
+
+def _validated_extra_mounts(extra_mounts: list["ExtraMount"] | None) -> list["ExtraMount"]:
+ """Validate extra mounts before converting them into K8s hostPath/PVC mounts."""
+ if not extra_mounts:
+ return []
+ if len(extra_mounts) > MAX_EXTRA_MOUNTS:
+ raise HTTPException(status_code=400, detail=f"Too many extra mounts; max is {MAX_EXTRA_MOUNTS}")
+
+ host_base_dir = _host_base_dir_for_extra_mounts()
+ seen_container_paths: set[str] = set()
+ validated: list[ExtraMount] = []
+ for mount in extra_mounts:
+ host_path = os.path.normpath(mount.host_path)
+ if not os.path.isabs(host_path):
+ raise HTTPException(status_code=400, detail=f"Extra mount host path must be absolute: {mount.host_path}")
+ if not _is_path_under_base(host_path, host_base_dir):
+ raise HTTPException(status_code=400, detail=f"Extra mount host path is outside DeerFlow state: {mount.host_path}")
+
+ container_path = _normalize_extra_mount_container_path(mount.container_path)
+ if container_path in seen_container_paths:
+ raise HTTPException(status_code=400, detail=f"Duplicate extra mount path: {container_path}")
+ seen_container_paths.add(container_path)
+
+ validated.append(
+ ExtraMount(
+ host_path=host_path,
+ container_path=container_path,
+ read_only=mount.read_only,
+ )
+ )
+ return validated
+
+
+def _extra_mount_volume_name(index: int) -> str:
+ return f"extra-{index}"
+
+
+def _lark_cli_runtime_enabled(provision_lark_cli_runtime: bool) -> bool:
+ """Whether to provision the lark-cli runtime via init container + emptyDir."""
+ return bool(LARK_CLI_INIT_IMAGE) and provision_lark_cli_runtime
+
+
+def _runtime_provided_extra_mounts(
+ extra_mounts: list["ExtraMount"] | None,
+ *,
+ provision_lark_cli_runtime: bool,
+) -> list["ExtraMount"]:
+ """Drop the lark-cli runtime extra mount when the init container supersedes it.
+
+ The init container + emptyDir provides ``/mnt/integrations/lark-cli/runtime``,
+ so a hostPath/PVC mount at the same path would collide. The per-user
+ ``config`` / ``data`` credential mounts are left untouched.
+ """
+ if not extra_mounts or not _lark_cli_runtime_enabled(provision_lark_cli_runtime):
+ return list(extra_mounts or [])
+ return [
+ mount
+ for mount in extra_mounts
+ if posixpath.normpath(mount.container_path) != LARK_CLI_RUNTIME_CONTAINER_PATH
+ ]
+
+
+def _extra_mount_pvc_sub_path(host_path: str) -> str:
+ host_base_dir = _host_base_dir_for_extra_mounts()
+ if not _is_path_under_base(host_path, host_base_dir):
+ raise HTTPException(status_code=400, detail=f"Extra mount host path is outside DeerFlow state: {host_path}")
+
+ rel_path = os.path.relpath(os.path.normpath(host_path), host_base_dir)
+ rel_parts = [part for part in rel_path.replace(os.sep, "/").split("/") if part and part != "."]
+ if not rel_parts or any(part == ".." for part in rel_parts):
+ raise HTTPException(status_code=400, detail=f"Invalid extra mount host path: {host_path}")
+ return posixpath.join("deer-flow", *rel_parts)
+
+
# ── K8s client setup ────────────────────────────────────────────────────
core_v1: k8s_client.CoreV1Api | None = None
@@ -219,11 +339,22 @@ async def verify_api_key(request: Request, call_next):
# ── Request / Response models ───────────────────────────────────────────
+class ExtraMount(BaseModel):
+ host_path: str
+ container_path: str
+ read_only: bool = False
+
+
class CreateSandboxRequest(BaseModel):
sandbox_id: str
- thread_id: str = Field(pattern=SAFE_THREAD_ID_PATTERN)
+ thread_id: str | None = Field(default=None, pattern=SAFE_THREAD_ID_PATTERN)
user_id: str = Field(default=DEFAULT_USER_ID, pattern=SAFE_USER_ID_PATTERN)
+ extra_mounts: list[ExtraMount] = Field(default_factory=list)
include_legacy_skills: bool = False
+ # When true (and LARK_CLI_INIT_IMAGE is configured), provision the sandbox
+ # lark-cli runtime via an init container + emptyDir instead of a runtime
+ # hostPath/PVC extra mount.
+ provision_lark_cli_runtime: bool = False
class SandboxResponse(BaseModel):
@@ -252,11 +383,53 @@ def _sandbox_url(sandbox_id: str, node_port: int | None = None) -> str:
return f"http://{NODE_HOST}:{node_port}"
+def _build_extra_volumes(extra_mounts: list[ExtraMount] | None = None) -> list[k8s_client.V1Volume]:
+ volumes: list[k8s_client.V1Volume] = []
+ for index, mount in enumerate(_validated_extra_mounts(extra_mounts)):
+ if USERDATA_PVC_NAME:
+ volumes.append(
+ k8s_client.V1Volume(
+ name=_extra_mount_volume_name(index),
+ persistent_volume_claim=k8s_client.V1PersistentVolumeClaimVolumeSource(
+ claim_name=USERDATA_PVC_NAME,
+ ),
+ )
+ )
+ continue
+
+ volumes.append(
+ k8s_client.V1Volume(
+ name=_extra_mount_volume_name(index),
+ host_path=k8s_client.V1HostPathVolumeSource(
+ path=mount.host_path,
+ type="Directory" if mount.read_only else "DirectoryOrCreate",
+ ),
+ )
+ )
+ return volumes
+
+
+def _build_extra_volume_mounts(extra_mounts: list[ExtraMount] | None = None) -> list[k8s_client.V1VolumeMount]:
+ mounts: list[k8s_client.V1VolumeMount] = []
+ for index, mount in enumerate(_validated_extra_mounts(extra_mounts)):
+ volume_mount = k8s_client.V1VolumeMount(
+ name=_extra_mount_volume_name(index),
+ mount_path=mount.container_path,
+ read_only=mount.read_only,
+ )
+ if USERDATA_PVC_NAME:
+ volume_mount.sub_path = _extra_mount_pvc_sub_path(mount.host_path)
+ mounts.append(volume_mount)
+ return mounts
+
+
def _build_volumes(
thread_id: str,
user_id: str = DEFAULT_USER_ID,
*,
include_legacy_skills: bool = False,
+ extra_mounts: list[ExtraMount] | None = None,
+ provision_lark_cli_runtime: bool = False,
) -> list[k8s_client.V1Volume]:
"""Build volume list: PVC when configured, otherwise hostPath.
@@ -343,6 +516,21 @@ def _build_volumes(
)
volumes.append(userdata_vol)
+ volumes.extend(
+ _build_extra_volumes(
+ _runtime_provided_extra_mounts(
+ extra_mounts,
+ provision_lark_cli_runtime=provision_lark_cli_runtime,
+ )
+ )
+ )
+ if _lark_cli_runtime_enabled(provision_lark_cli_runtime):
+ volumes.append(
+ k8s_client.V1Volume(
+ name=LARK_CLI_RUNTIME_VOLUME_NAME,
+ empty_dir=k8s_client.V1EmptyDirVolumeSource(),
+ )
+ )
return volumes
@@ -351,6 +539,8 @@ def _build_volume_mounts(
user_id: str = DEFAULT_USER_ID,
*,
include_legacy_skills: bool = False,
+ extra_mounts: list[ExtraMount] | None = None,
+ provision_lark_cli_runtime: bool = False,
) -> list[k8s_client.V1VolumeMount]:
"""Build volume mount list, mirroring three-way skills layout.
@@ -405,18 +595,71 @@ def _build_volume_mounts(
if USERDATA_PVC_NAME:
userdata_mount.sub_path = f"deer-flow/users/{user_id}/threads/{thread_id}/user-data"
mounts.append(userdata_mount)
+ mounts.extend(
+ _build_extra_volume_mounts(
+ _runtime_provided_extra_mounts(
+ extra_mounts,
+ provision_lark_cli_runtime=provision_lark_cli_runtime,
+ )
+ )
+ )
+ if _lark_cli_runtime_enabled(provision_lark_cli_runtime):
+ mounts.append(
+ k8s_client.V1VolumeMount(
+ name=LARK_CLI_RUNTIME_VOLUME_NAME,
+ mount_path=LARK_CLI_RUNTIME_CONTAINER_PATH,
+ read_only=True,
+ )
+ )
return mounts
+def _build_lark_cli_init_containers(
+ provision_lark_cli_runtime: bool,
+) -> list[k8s_client.V1Container]:
+ """Init container that copies the lark-cli runtime into the shared emptyDir."""
+ if not _lark_cli_runtime_enabled(provision_lark_cli_runtime):
+ return []
+ return [
+ k8s_client.V1Container(
+ name="lark-cli-init",
+ image=LARK_CLI_INIT_IMAGE,
+ image_pull_policy="IfNotPresent",
+ env=[
+ k8s_client.V1EnvVar(
+ name="LARK_CLI_RUNTIME_DEST",
+ value=LARK_CLI_RUNTIME_CONTAINER_PATH,
+ )
+ ],
+ volume_mounts=[
+ k8s_client.V1VolumeMount(
+ name=LARK_CLI_RUNTIME_VOLUME_NAME,
+ mount_path=LARK_CLI_RUNTIME_CONTAINER_PATH,
+ read_only=False,
+ )
+ ],
+ security_context=k8s_client.V1SecurityContext(
+ privileged=False,
+ allow_privilege_escalation=False,
+ ),
+ )
+ ]
+
+
def _build_pod(
sandbox_id: str,
thread_id: str,
user_id: str = DEFAULT_USER_ID,
*,
include_legacy_skills: bool = False,
+ extra_mounts: list[ExtraMount] | None = None,
+ provision_lark_cli_runtime: bool = False,
) -> k8s_client.V1Pod:
"""Construct a Pod manifest for a single sandbox."""
+ init_containers = (
+ _build_lark_cli_init_containers(provision_lark_cli_runtime) or None
+ )
return k8s_client.V1Pod(
metadata=k8s_client.V1ObjectMeta(
name=_pod_name(sandbox_id),
@@ -477,6 +720,8 @@ def _build_pod(
thread_id,
user_id=user_id,
include_legacy_skills=include_legacy_skills,
+ extra_mounts=extra_mounts,
+ provision_lark_cli_runtime=provision_lark_cli_runtime,
),
security_context=k8s_client.V1SecurityContext(
privileged=False,
@@ -484,10 +729,13 @@ def _build_pod(
),
)
],
+ init_containers=init_containers,
volumes=_build_volumes(
thread_id,
user_id=user_id,
include_legacy_skills=include_legacy_skills,
+ extra_mounts=extra_mounts,
+ provision_lark_cli_runtime=provision_lark_cli_runtime,
),
restart_policy="Always",
),
@@ -573,6 +821,17 @@ async def health():
return {"status": "ok"}
+@app.get("/api/capabilities")
+async def capabilities():
+ """Report provisioner-side capabilities the Gateway cannot infer statically.
+
+ ``lark_cli_init_image`` reflects whether a lark-cli init image is configured,
+ which the Gateway surfaces as the Lark integration sandbox-runtime readiness
+ signal so a green UI can't hide a chat-time ``command not found``.
+ """
+ return {"lark_cli_init_image": bool(LARK_CLI_INIT_IMAGE)}
+
+
@app.post("/api/sandboxes", response_model=SandboxResponse)
def create_sandbox(req: CreateSandboxRequest):
"""Create a sandbox Pod + Service for *sandbox_id*.
@@ -581,16 +840,18 @@ def create_sandbox(req: CreateSandboxRequest):
(idempotent).
"""
sandbox_id = req.sandbox_id
- thread_id = req.thread_id
+ thread_id = req.thread_id or sandbox_id
user_id = req.user_id
include_legacy_skills = req.include_legacy_skills
+ provision_lark_cli_runtime = req.provision_lark_cli_runtime
logger.info(
- "Received request to create sandbox '%s' for thread '%s' user '%s' include_legacy_skills=%s",
+ "Received request to create sandbox '%s' for thread '%s' user '%s' include_legacy_skills=%s provision_lark_cli_runtime=%s",
sandbox_id,
thread_id,
user_id,
include_legacy_skills,
+ _lark_cli_runtime_enabled(provision_lark_cli_runtime),
)
# ── Fast path: sandbox already exists ────────────────────────────
@@ -611,6 +872,8 @@ def create_sandbox(req: CreateSandboxRequest):
thread_id,
user_id=user_id,
include_legacy_skills=include_legacy_skills,
+ extra_mounts=req.extra_mounts,
+ provision_lark_cli_runtime=provision_lark_cli_runtime,
),
)
logger.info(f"Created Pod {_pod_name(sandbox_id)}")
diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md
index ca6c2ca3c..feaf46c79 100644
--- a/frontend/AGENTS.md
+++ b/frontend/AGENTS.md
@@ -55,7 +55,7 @@ The frontend is a stateful chat application. Users create **threads** (conversat
- `workspace/` — Chat page components (messages, artifacts, settings)
- `landing/` — Landing page sections
- `docs/` — Docs / MDX rendering components
-- **`core/`** — Business logic, the heart of the app. Domains include `threads/` (creation, streaming, state), `api/` (LangGraph client singleton), `agents/` (custom agents), `auth/` (authentication), `artifacts/`, `channels/` (IM connections), `i18n/` (en-US, zh-CN), `settings/`, `memory/`, `skills/`, `messages/`, `mcp/`, `models/`, `input-polish/` (pre-send draft rewrite API), `voice-input/` (browser speech-recognition helpers), `suggestions/`, `tasks/`, `todos/`, `tools/`, `workspace-changes/` (run-scoped changed-file summaries and diff fetching), `config/`, `notification/`, `blog/`, plus rendering helpers (`rehype/`, `streamdown/`) and `utils/`.
+- **`core/`** — Business logic, the heart of the app. Domains include `threads/` (creation, streaming, state), `api/` (LangGraph client singleton), `agents/` (custom agents), `auth/` (authentication), `artifacts/`, `channels/` (IM connections), `integrations/` (managed third-party integration status/install clients such as Lark CLI), `i18n/` (en-US, zh-CN), `settings/`, `memory/`, `skills/`, `messages/`, `mcp/`, `models/`, `input-polish/` (pre-send draft rewrite API), `voice-input/` (browser speech-recognition helpers), `suggestions/`, `tasks/`, `todos/`, `tools/`, `workspace-changes/` (run-scoped changed-file summaries and diff fetching), `config/`, `notification/`, `blog/`, plus rendering helpers (`rehype/`, `streamdown/`) and `utils/`.
- **`hooks/`** — Shared React hooks
- **`lib/`** — Utilities (`cn()` from clsx + tailwind-merge)
- **`content/`** — MDX content (blog posts, docs) rendered by the app
diff --git a/frontend/src/app/mock/api/integrations/lark/auth/complete/route.ts b/frontend/src/app/mock/api/integrations/lark/auth/complete/route.ts
new file mode 100644
index 000000000..b046c4ddd
--- /dev/null
+++ b/frontend/src/app/mock/api/integrations/lark/auth/complete/route.ts
@@ -0,0 +1,36 @@
+export function POST() {
+ return Response.json({
+ success: true,
+ message: "Lark/Feishu authorization completed.",
+ status: {
+ installed: true,
+ version: "v1.0.65",
+ manifest_version: "v1.0.65",
+ latest_available_version: "v1.0.65",
+ runtime_version_mismatch: false,
+ app_configured: true,
+ app_id: "cli_mock",
+ app_brand: "feishu",
+ skills_expected: 27,
+ skills_installed: 4,
+ installed_skills: ["lark-doc", "lark-im", "lark-shared", "lark-sheets"],
+ enabled_skills: ["lark-doc", "lark-im", "lark-shared", "lark-sheets"],
+ install_path: "/mock/integrations/skills/lark-cli",
+ cli: {
+ available: true,
+ path: "/usr/bin/lark-cli",
+ version: "lark-cli version v1.0.65",
+ error: null,
+ },
+ auth: {
+ status: "authenticated",
+ message: "Lark authorization is live-verified.",
+ user: "Alice",
+ verified: true,
+ },
+ sandbox_runtime_mode: "init-container",
+ sandbox_runtime_ready: true,
+ sandbox_runtime_detail: null,
+ },
+ });
+}
diff --git a/frontend/src/app/mock/api/integrations/lark/auth/start/route.ts b/frontend/src/app/mock/api/integrations/lark/auth/start/route.ts
new file mode 100644
index 000000000..d798f82fe
--- /dev/null
+++ b/frontend/src/app/mock/api/integrations/lark/auth/start/route.ts
@@ -0,0 +1,9 @@
+export function POST() {
+ return Response.json({
+ verification_url: "https://open.feishu.cn/auth/mock-device",
+ device_code: "mock-device-code",
+ expires_in: 600,
+ user_code: null,
+ hint: null,
+ });
+}
diff --git a/frontend/src/app/mock/api/integrations/lark/config/complete/route.ts b/frontend/src/app/mock/api/integrations/lark/config/complete/route.ts
new file mode 100644
index 000000000..45dd0b98b
--- /dev/null
+++ b/frontend/src/app/mock/api/integrations/lark/config/complete/route.ts
@@ -0,0 +1,36 @@
+export function POST() {
+ return Response.json({
+ success: true,
+ message: "Lark/Feishu connection setup completed.",
+ status: {
+ installed: true,
+ version: "v1.0.65",
+ manifest_version: "v1.0.65",
+ latest_available_version: "v1.0.65",
+ runtime_version_mismatch: false,
+ app_configured: true,
+ app_id: "cli_mock",
+ app_brand: "feishu",
+ skills_expected: 27,
+ skills_installed: 4,
+ installed_skills: ["lark-doc", "lark-im", "lark-shared", "lark-sheets"],
+ enabled_skills: ["lark-doc", "lark-im", "lark-shared", "lark-sheets"],
+ install_path: "/mock/integrations/skills/lark-cli",
+ cli: {
+ available: true,
+ path: "/usr/bin/lark-cli",
+ version: "lark-cli version v1.0.65",
+ error: null,
+ },
+ auth: {
+ status: "not_authorized",
+ message: "Lark user authorization is not configured",
+ user: null,
+ verified: false,
+ },
+ sandbox_runtime_mode: "none",
+ sandbox_runtime_ready: false,
+ sandbox_runtime_detail: null,
+ },
+ });
+}
diff --git a/frontend/src/app/mock/api/integrations/lark/config/start/route.ts b/frontend/src/app/mock/api/integrations/lark/config/start/route.ts
new file mode 100644
index 000000000..567e5ed85
--- /dev/null
+++ b/frontend/src/app/mock/api/integrations/lark/config/start/route.ts
@@ -0,0 +1,10 @@
+export function POST() {
+ return Response.json({
+ verification_url: "https://open.feishu.cn/page/cli?user_code=config",
+ device_code: "mock-config-device-code",
+ expires_in: 600,
+ interval: 5,
+ user_code: "config",
+ brand: "feishu",
+ });
+}
diff --git a/frontend/src/app/mock/api/integrations/lark/install/route.ts b/frontend/src/app/mock/api/integrations/lark/install/route.ts
new file mode 100644
index 000000000..91e7e8dbc
--- /dev/null
+++ b/frontend/src/app/mock/api/integrations/lark/install/route.ts
@@ -0,0 +1,39 @@
+const installedSkills = ["lark-doc", "lark-im", "lark-shared", "lark-sheets"];
+
+export function POST() {
+ return Response.json({
+ success: true,
+ installed_skills: installedSkills,
+ message: `Installed ${installedSkills.length} Lark/Feishu skills.`,
+ status: {
+ installed: true,
+ version: "v1.0.65",
+ manifest_version: "v1.0.65",
+ latest_available_version: "v1.0.65",
+ runtime_version_mismatch: false,
+ app_configured: false,
+ app_id: null,
+ app_brand: null,
+ skills_expected: 27,
+ skills_installed: installedSkills.length,
+ installed_skills: installedSkills,
+ enabled_skills: installedSkills,
+ install_path: "/mock/integrations/skills/lark-cli",
+ cli: {
+ available: true,
+ path: "/usr/bin/lark-cli",
+ version: "lark-cli version v1.0.65",
+ error: null,
+ },
+ auth: {
+ status: "not_configured",
+ message: "lark-cli auth is not configured",
+ user: null,
+ verified: false,
+ },
+ sandbox_runtime_mode: "none",
+ sandbox_runtime_ready: false,
+ sandbox_runtime_detail: null,
+ },
+ });
+}
diff --git a/frontend/src/app/mock/api/integrations/lark/status/route.ts b/frontend/src/app/mock/api/integrations/lark/status/route.ts
new file mode 100644
index 000000000..c694379ec
--- /dev/null
+++ b/frontend/src/app/mock/api/integrations/lark/status/route.ts
@@ -0,0 +1,34 @@
+const status = {
+ installed: false,
+ version: "v1.0.65",
+ manifest_version: null,
+ latest_available_version: "v1.0.65",
+ runtime_version_mismatch: false,
+ app_configured: false,
+ app_id: null,
+ app_brand: null,
+ skills_expected: 27,
+ skills_installed: 0,
+ installed_skills: [],
+ enabled_skills: [],
+ install_path: "/mock/integrations/skills/lark-cli",
+ cli: {
+ available: false,
+ path: null,
+ version: null,
+ error: "lark-cli is not on PATH",
+ },
+ auth: {
+ status: "unavailable",
+ message: "lark-cli is not installed on the Gateway",
+ user: null,
+ verified: false,
+ },
+ sandbox_runtime_mode: "none",
+ sandbox_runtime_ready: false,
+ sandbox_runtime_detail: null,
+};
+
+export function GET() {
+ return Response.json(status);
+}
diff --git a/frontend/src/app/workspace/workspace-content.tsx b/frontend/src/app/workspace/workspace-content.tsx
index ded8c81ee..3e712eb29 100644
--- a/frontend/src/app/workspace/workspace-content.tsx
+++ b/frontend/src/app/workspace/workspace-content.tsx
@@ -5,6 +5,8 @@ import { QueryClientProvider } from "@/components/query-client-provider";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { CommandPalette } from "@/components/workspace/command-palette";
import { GatewayOfflineBanner } from "@/components/workspace/gateway-offline-banner";
+import { SettingsDialogHost } from "@/components/workspace/settings";
+import { WorkspaceSettingsDeepLink } from "@/components/workspace/workspace-settings-deep-link";
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
function parseSidebarOpenCookie(
@@ -37,6 +39,8 @@ export async function WorkspaceContent({
+
+
);
diff --git a/frontend/src/components/workspace/command-palette.tsx b/frontend/src/components/workspace/command-palette.tsx
index 1d754f61d..38bbdc305 100644
--- a/frontend/src/components/workspace/command-palette.tsx
+++ b/frontend/src/components/workspace/command-palette.tsx
@@ -27,14 +27,14 @@ import {
import { useI18n } from "@/core/i18n/hooks";
import { useGlobalShortcuts } from "@/hooks/use-global-shortcuts";
-import { SettingsDialog } from "./settings";
+import { useSettingsDialog } from "./settings";
export function CommandPalette() {
const { t } = useI18n();
const router = useRouter();
+ const { openSettings } = useSettingsDialog();
const [open, setOpen] = useState(false);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
- const [settingsOpen, setSettingsOpen] = useState(false);
const [isMac, setIsMac] = useState(false);
const handleNewChat = useCallback(() => {
@@ -44,8 +44,8 @@ export function CommandPalette() {
const handleOpenSettings = useCallback(() => {
setOpen(false);
- setSettingsOpen(true);
- }, []);
+ openSettings("appearance");
+ }, [openSettings]);
const handleShowShortcuts = useCallback(() => {
setOpen(false);
@@ -72,7 +72,6 @@ export function CommandPalette() {
return (
<>
-
diff --git a/frontend/src/components/workspace/settings/index.ts b/frontend/src/components/workspace/settings/index.ts
index 658450689..861c89520 100644
--- a/frontend/src/components/workspace/settings/index.ts
+++ b/frontend/src/components/workspace/settings/index.ts
@@ -1 +1,7 @@
-export { SettingsDialog } from "./settings-dialog";
+export { SettingsDialog, type SettingsSection } from "./settings-dialog";
+export { SettingsDialogHost } from "./settings-dialog-host";
+export {
+ openSettingsDialog,
+ setSettingsDialogOpen,
+ useSettingsDialog,
+} from "./settings-dialog-store";
diff --git a/frontend/src/components/workspace/settings/integrations-settings-page.tsx b/frontend/src/components/workspace/settings/integrations-settings-page.tsx
new file mode 100644
index 000000000..6424108af
--- /dev/null
+++ b/frontend/src/components/workspace/settings/integrations-settings-page.tsx
@@ -0,0 +1,882 @@
+"use client";
+
+import {
+ CheckCircle2Icon,
+ CopyIcon,
+ ExternalLinkIcon,
+ PlugZapIcon,
+ RefreshCwIcon,
+ XCircleIcon,
+} from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+import { toast } from "sonner";
+
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardAction,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { useAuth } from "@/core/auth/AuthProvider";
+import { useI18n } from "@/core/i18n/hooks";
+import {
+ LarkIntegrationRequestError,
+ type LarkAuthStartRequest,
+ type LarkAuthStartResponse,
+ type LarkConfigStartResponse,
+ type LarkIntegrationStatus,
+ useCompleteLarkAuthorization,
+ useCompleteLarkConfiguration,
+ useInstallLarkIntegration,
+ useLarkIntegrationStatus,
+ useStartLarkAuthorization,
+ useStartLarkConfiguration,
+} from "@/core/integrations/lark";
+import { env } from "@/env";
+import { cn } from "@/lib/utils";
+
+import { SettingsSection } from "./settings-section";
+
+type PendingLarkFlow =
+ | ({ kind: "config" } & LarkConfigStartResponse)
+ | ({ kind: "auth" } & LarkAuthStartResponse);
+
+type LarkAuthDomain =
+ | "approval"
+ | "apps"
+ | "attendance"
+ | "base"
+ | "calendar"
+ | "contact"
+ | "docs"
+ | "drive"
+ | "event"
+ | "im"
+ | "mail"
+ | "markdown"
+ | "mindnotes"
+ | "minutes"
+ | "note"
+ | "okr"
+ | "sheets"
+ | "slides"
+ | "task"
+ | "vc"
+ | "wiki"
+ | "all";
+
+// Mirrors `lark-cli auth login --domain` (available business domains + all).
+const LARK_AUTH_DOMAINS: LarkAuthDomain[] = [
+ "calendar",
+ "im",
+ "docs",
+ "drive",
+ "sheets",
+ "base",
+ "wiki",
+ "task",
+ "mail",
+ "vc",
+ "minutes",
+ "note",
+ "slides",
+ "markdown",
+ "mindnotes",
+ "contact",
+ "approval",
+ "attendance",
+ "okr",
+ "event",
+ "apps",
+ "all",
+];
+
+const AUTOMATIC_LARK_AUTH_WAIT_SECONDS = 8;
+
+function splitScopes(value: string) {
+ return value
+ .split(/[\s,]+/)
+ .map((scope) => scope.trim())
+ .filter(Boolean);
+}
+
+function uniqueScopes(scopes: string[]) {
+ return Array.from(new Set(scopes));
+}
+
+export function IntegrationsSettingsPage() {
+ const { t } = useI18n();
+ return (
+
+
+
+ );
+}
+
+function LarkIntegrationCard() {
+ const { t } = useI18n();
+ const { user } = useAuth();
+ const isAdmin = user?.system_role === "admin";
+ const { data, isLoading, error, refetch, isFetching } =
+ useLarkIntegrationStatus();
+ const install = useInstallLarkIntegration();
+ const startConfig = useStartLarkConfiguration();
+ const completeConfig = useCompleteLarkConfiguration();
+ const startAuth = useStartLarkAuthorization();
+ const completeAuth = useCompleteLarkAuthorization();
+ const [pendingFlow, setPendingFlow] = useState(null);
+ const [isCheckingConnection, setIsCheckingConnection] = useState(false);
+ const [selectedAuthDomains, setSelectedAuthDomains] = useState<
+ LarkAuthDomain[]
+ >([]);
+ const [customAuthScope, setCustomAuthScope] = useState("");
+ const browserWindowRef = useRef(null);
+ const authRequestRef = useRef({ recommend: false });
+ const authToastIdRef = useRef(null);
+ const authRetryTimeoutRef = useRef | null>(
+ null,
+ );
+ const authAttemptIdRef = useRef(0);
+ const authDeadlineRef = useRef(0);
+ const isMountedRef = useRef(true);
+ const connectBusy =
+ startConfig.isPending || completeConfig.isPending || startAuth.isPending;
+ const connectActionBusy = connectBusy || isCheckingConnection;
+ const credentialsConfigured = data?.auth.status === "authenticated";
+ const isConnected = credentialsConfigured && data?.auth.verified === true;
+ // The sandbox-runtime readiness row only applies when the sandbox actually
+ // runs lark-cli (AIO / provisioner modes report a non-"none" mode).
+ const showSandboxRuntime = !!data && data.sandbox_runtime_mode !== "none";
+ const trimmedCustomAuthScope = customAuthScope.trim();
+ const hasAdditionalPermissionRequest =
+ selectedAuthDomains.length > 0 || trimmedCustomAuthScope.length > 0;
+
+ const handleInstall = () => {
+ install.mutate(undefined, {
+ onSuccess: (result) => toast.success(result.message),
+ onError: (err) => {
+ if (err instanceof LarkIntegrationRequestError && err.isAdminRequired) {
+ toast.error(t.settings.integrations.adminRequired);
+ return;
+ }
+ toast.error(err instanceof Error ? err.message : String(err));
+ },
+ });
+ };
+
+ const clearAuthRetryTimer = () => {
+ if (authRetryTimeoutRef.current != null) {
+ clearTimeout(authRetryTimeoutRef.current);
+ authRetryTimeoutRef.current = null;
+ }
+ };
+
+ useEffect(
+ () => () => {
+ isMountedRef.current = false;
+ if (authRetryTimeoutRef.current != null) {
+ clearTimeout(authRetryTimeoutRef.current);
+ }
+ },
+ [],
+ );
+
+ const openPendingBrowserWindow = () => {
+ const browserWindow = window.open("about:blank", "_blank");
+ if (browserWindow) {
+ browserWindow.opener = null;
+ browserWindowRef.current = browserWindow;
+ }
+ return browserWindow;
+ };
+
+ const closePendingBrowserWindow = (browserWindow: Window | null) => {
+ if (!browserWindow) return;
+ browserWindow.close();
+ if (browserWindowRef.current === browserWindow) {
+ browserWindowRef.current = null;
+ }
+ };
+
+ const openAuthorizationUrl = (
+ url: string,
+ browserWindow = browserWindowRef.current,
+ ) => {
+ if (browserWindow && !browserWindow.closed) {
+ browserWindow.location.href = url;
+ browserWindowRef.current = browserWindow;
+ return;
+ }
+ browserWindowRef.current = window.open(
+ url,
+ "_blank",
+ "noopener,noreferrer",
+ );
+ };
+
+ const startUserAuth = (
+ browserWindow = browserWindowRef.current,
+ request = authRequestRef.current,
+ ) => {
+ startAuth.mutate(request, {
+ onSuccess: (result) => {
+ setPendingFlow({ kind: "auth", ...result });
+ openAuthorizationUrl(result.verification_url, browserWindow);
+ authToastIdRef.current = toast.info(
+ t.settings.integrations.lark.authStarted,
+ );
+ startAutomaticAuthorizationCheck(result);
+ },
+ onError: (err) => {
+ closePendingBrowserWindow(browserWindow);
+ toast.error(err instanceof Error ? err.message : String(err));
+ },
+ });
+ };
+
+ const handleContinueConnection = () => {
+ if (!pendingFlow || pendingFlow.kind !== "config") return;
+ completeConfig.mutate(
+ {
+ device_code: pendingFlow.device_code,
+ brand: pendingFlow.brand,
+ interval: pendingFlow.interval,
+ expires_in: pendingFlow.expires_in,
+ },
+ {
+ onSuccess: () => {
+ toast.success(t.settings.integrations.lark.connectionReady);
+ setPendingFlow(null);
+ startUserAuth(browserWindowRef.current);
+ },
+ onError: (err) => {
+ setPendingFlow(null);
+ toast.error(err instanceof Error ? err.message : String(err));
+ },
+ },
+ );
+ };
+
+ const startConnectionFlow = (
+ status: LarkIntegrationStatus,
+ browserWindow: Window | null,
+ ) => {
+ if (!status.app_configured) {
+ startConfig.mutate(
+ { brand: "feishu" },
+ {
+ onSuccess: (result) => {
+ setPendingFlow({ kind: "config", ...result });
+ openAuthorizationUrl(result.verification_url, browserWindow);
+ toast.success(t.settings.integrations.lark.connectionStarted);
+ },
+ onError: (err) => {
+ closePendingBrowserWindow(browserWindow);
+ toast.error(err instanceof Error ? err.message : String(err));
+ },
+ },
+ );
+ return;
+ }
+ startUserAuth(browserWindow);
+ };
+
+ const buildAuthRequest = (): LarkAuthStartRequest => {
+ const domains = selectedAuthDomains.includes("all")
+ ? ["all"]
+ : uniqueScopes(selectedAuthDomains);
+ const scopes = uniqueScopes(splitScopes(trimmedCustomAuthScope));
+ return {
+ recommend: false,
+ domains,
+ scope: scopes.length > 0 ? scopes.join(" ") : null,
+ };
+ };
+
+ const toggleAuthDomain = (domain: LarkAuthDomain) => {
+ setSelectedAuthDomains((current) => {
+ if (domain === "all") {
+ return current.includes("all") ? [] : ["all"];
+ }
+ const withoutAll = current.filter((item) => item !== "all");
+ if (withoutAll.includes(domain)) {
+ return withoutAll.filter((item) => item !== domain);
+ }
+ return [...withoutAll, domain];
+ });
+ };
+
+ const handleConnect = async () => {
+ if (!data) return;
+ authRequestRef.current = buildAuthRequest();
+ // Pre-open the blank tab synchronously inside the click gesture. We cannot
+ // trust the cached auth status here: an `authenticated` cache can be stale
+ // (session expired server-side), and if we skipped the pre-open and then
+ // discovered that only after `await refetch()`, the later `window.open`
+ // would run outside the user gesture and be blocked by the browser. Opening
+ // now and closing below when it turns out unneeded keeps the popup reliable.
+ const browserWindow = openPendingBrowserWindow();
+ setIsCheckingConnection(true);
+ try {
+ const refreshed = await refetch();
+ const latestStatus = refreshed.data ?? data;
+ startConnectionFlow(latestStatus, browserWindow);
+ } catch (err) {
+ closePendingBrowserWindow(browserWindow);
+ toast.error(err instanceof Error ? err.message : String(err));
+ } finally {
+ setIsCheckingConnection(false);
+ }
+ };
+
+ const completeAuthorization = (
+ deviceCode: string,
+ { automatic, attemptId }: { automatic: boolean; attemptId?: number },
+ ) => {
+ const toastOptions =
+ authToastIdRef.current == null
+ ? undefined
+ : { id: authToastIdRef.current };
+ completeAuth.mutate(
+ {
+ device_code: deviceCode,
+ ...(automatic
+ ? { wait_timeout_seconds: AUTOMATIC_LARK_AUTH_WAIT_SECONDS }
+ : {}),
+ },
+ {
+ onSuccess: (result) => {
+ // react-query still fires this after the dialog unmounts; bail so we
+ // don't toast, setState, refetch, or reschedule a retry timer on a
+ // component that is gone.
+ if (!isMountedRef.current) {
+ return;
+ }
+ if (automatic && attemptId !== authAttemptIdRef.current) {
+ return;
+ }
+ if (result.success) {
+ clearAuthRetryTimer();
+ toast.success(result.message, toastOptions);
+ authToastIdRef.current = null;
+ setPendingFlow(null);
+ browserWindowRef.current = null;
+ return;
+ }
+ toast.info(
+ result.message ||
+ t.settings.integrations.lark.authorizationStillPending,
+ toastOptions,
+ );
+ if (automatic && attemptId != null) {
+ scheduleAuthorizationRetry(deviceCode, attemptId);
+ }
+ },
+ onError: (err) => {
+ if (!isMountedRef.current) {
+ return;
+ }
+ if (automatic && attemptId !== authAttemptIdRef.current) {
+ return;
+ }
+ if (
+ automatic &&
+ err instanceof LarkIntegrationRequestError &&
+ err.status === 504
+ ) {
+ toast.info(
+ t.settings.integrations.lark.authorizationStillPending,
+ toastOptions,
+ );
+ if (attemptId != null) {
+ scheduleAuthorizationRetry(deviceCode, attemptId);
+ }
+ return;
+ }
+ toast.error(
+ err instanceof Error ? err.message : String(err),
+ toastOptions,
+ );
+ authToastIdRef.current = null;
+ },
+ },
+ );
+ };
+
+ const scheduleAuthorizationRetry = (
+ deviceCode: string,
+ attemptId: number,
+ ) => {
+ clearAuthRetryTimer();
+ if (!isMountedRef.current) {
+ return;
+ }
+ if (Date.now() >= authDeadlineRef.current) {
+ toast.info(t.settings.integrations.lark.authorizationStillPending);
+ return;
+ }
+ authRetryTimeoutRef.current = setTimeout(() => {
+ completeAuthorization(deviceCode, { automatic: true, attemptId });
+ }, 1500);
+ };
+
+ const startAutomaticAuthorizationCheck = (result: LarkAuthStartResponse) => {
+ clearAuthRetryTimer();
+ const attemptId = authAttemptIdRef.current + 1;
+ authAttemptIdRef.current = attemptId;
+ authDeadlineRef.current =
+ Date.now() + Math.max(result.expires_in ?? 300, 30) * 1000;
+ completeAuthorization(result.device_code, { automatic: true, attemptId });
+ };
+
+ const handleCompleteAuth = () => {
+ if (!pendingFlow) return;
+ if (pendingFlow.kind !== "auth") {
+ return;
+ }
+ clearAuthRetryTimer();
+ authAttemptIdRef.current += 1;
+ completeAuthorization(pendingFlow.device_code, { automatic: false });
+ };
+
+ const handleCopyAuthLink = async () => {
+ if (!pendingFlow) return;
+ try {
+ await navigator.clipboard.writeText(pendingFlow.verification_url);
+ toast.success(t.clipboard.copiedToClipboard);
+ } catch {
+ toast.error(t.clipboard.failedToCopyToClipboard);
+ }
+ };
+
+ const installDisabled =
+ env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
+ !isAdmin ||
+ install.isPending;
+ const authDisabled =
+ env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
+ !data?.installed ||
+ !data?.cli.available ||
+ connectActionBusy;
+
+ const connectButtonLabel = isCheckingConnection
+ ? t.settings.integrations.lark.checkingConnection
+ : connectBusy
+ ? t.settings.integrations.lark.authStarting
+ : credentialsConfigured && hasAdditionalPermissionRequest
+ ? t.settings.integrations.lark.requestPermissions
+ : credentialsConfigured
+ ? t.settings.integrations.lark.connectedAction
+ : t.settings.integrations.lark.connect;
+
+ const permissionDomains = LARK_AUTH_DOMAINS.map((id) => ({
+ id,
+ label: t.settings.integrations.lark.authDomains[id].label,
+ description: t.settings.integrations.lark.authDomains[id].description,
+ }));
+
+ return (
+
+
+
+
+
+ {t.settings.integrations.lark.title}
+
+ {t.settings.integrations.lark.description}
+
+
+
+
+ void refetch()}
+ disabled={isFetching}
+ >
+
+ {t.settings.integrations.refresh}
+
+
+
+
+ {isLoading ? (
+
+ {t.common.loading}
+
+ ) : error ? (
+
+
+ {t.settings.integrations.loadFailed}
+
+ {error instanceof Error ? error.message : String(error)}
+
+
+ ) : data ? (
+ <>
+
+
+
+
+ {showSandboxRuntime && (
+
+ )}
+
+ {data.installed && (
+
+
+ {t.settings.integrations.lark.installedVersion(
+ data.manifest_version ?? data.version,
+ )}
+
+ {data.latest_available_version &&
+ data.latest_available_version !==
+ (data.manifest_version ?? data.version) && (
+
+ {t.settings.integrations.lark.updateAvailable(
+ data.latest_available_version,
+ )}
+
+ )}
+ {data.runtime_version_mismatch && (
+
+ {t.settings.integrations.lark.runtimeVersionMismatch}
+
+ )}
+
+ )}
+
+ {data.installed && data.cli.available && (
+
+
+
+ {t.settings.integrations.lark.permissionTitle}
+
+
+ {t.settings.integrations.lark.permissionDescription}
+
+
+
+ {permissionDomains.map((domain) => {
+ const selected = selectedAuthDomains.includes(domain.id);
+ return (
+ toggleAuthDomain(domain.id)}
+ disabled={connectActionBusy}
+ title={domain.description}
+ >
+ {domain.label}
+
+ );
+ })}
+
+
+
+ setCustomAuthScope(event.currentTarget.value)
+ }
+ disabled={connectActionBusy}
+ placeholder={
+ t.settings.integrations.lark.customScopePlaceholder
+ }
+ aria-label={t.settings.integrations.lark.customScopeLabel}
+ />
+
+ {t.settings.integrations.lark.customScopeDescription}
+
+
+
+ )}
+
+
+ {install.isPending ? (
+
+ ) : null}
+ {install.isPending
+ ? t.settings.integrations.installing
+ : data.installed
+ ? t.settings.integrations.reinstall
+ : t.settings.integrations.install}
+
+ void handleConnect()}
+ disabled={authDisabled}
+ >
+ {connectActionBusy ? (
+
+ ) : null}
+ {connectButtonLabel}
+
+ {!isAdmin && (
+
+ {t.settings.integrations.adminRequired}
+
+ )}
+
+ {install.isPending && (
+
+
+
+ {t.settings.integrations.lark.installingTitle}
+
+
+ {t.settings.integrations.lark.installingDescription}
+
+
+ )}
+ {pendingFlow && (
+
+
+
+ {pendingFlow.kind === "config"
+ ? t.settings.integrations.lark.openConnectionLinkTitle
+ : completeAuth.isPending
+ ? t.settings.integrations.lark.waitingAuthTitle
+ : t.settings.integrations.lark.openAuthLinkTitle}
+
+
+
+
+ {pendingFlow.kind === "config"
+ ? t.settings.integrations.lark
+ .openConnectionLinkDescription
+ : completeAuth.isPending
+ ? t.settings.integrations.lark.waitingAuthDescription
+ : t.settings.integrations.lark
+ .openAuthLinkDescription}
+
+
+ {pendingFlow.verification_url}
+
+
+
+
+
+ {t.settings.integrations.lark.openAuthLink}
+
+
+
void handleCopyAuthLink()}
+ >
+
+ {t.settings.integrations.lark.copyAuthLink}
+
+ {pendingFlow.kind === "config" ? (
+
+ {completeConfig.isPending ? (
+
+ ) : null}
+ {completeConfig.isPending
+ ? t.settings.integrations.lark
+ .preparingAuthorization
+ : t.settings.integrations.lark.continueAuth}
+
+ ) : (
+
+ {completeAuth.isPending
+ ? t.settings.integrations.lark.completingAuth
+ : t.settings.integrations.lark.completeAuth}
+
+ )}
+
+ {pendingFlow.expires_in != null && (
+
+ {t.settings.integrations.lark.authExpiresIn(
+ pendingFlow.expires_in,
+ )}
+
+ )}
+
+
+
+ )}
+ >
+ ) : null}
+
+
+ );
+}
+
+function StatusItem({
+ label,
+ ok,
+ value,
+}: {
+ label: string;
+ ok: boolean;
+ value: string;
+}) {
+ const { t } = useI18n();
+ return (
+
+
+
{label}
+
+ {ok ? (
+
+ ) : (
+
+ )}
+ {ok ? t.settings.integrations.ready : t.settings.integrations.pending}
+
+
+
{value}
+
+ );
+}
+
+function IntegrationNextStep({
+ installed,
+ cliReady,
+ connected,
+ credentialsConfigured,
+}: {
+ installed: boolean;
+ cliReady: boolean;
+ connected: boolean;
+ credentialsConfigured: boolean;
+}) {
+ const { t } = useI18n();
+ if (!installed) {
+ return (
+
+ {t.settings.integrations.lark.installNextTitle}
+
+ {t.settings.integrations.lark.installNextDescription}
+
+
+ );
+ }
+ if (!cliReady) {
+ return (
+
+ {t.settings.integrations.lark.cliNextTitle}
+
+ {t.settings.integrations.lark.cliNextDescription}
+
+
+ );
+ }
+ if (connected) {
+ return (
+
+
+ {t.settings.integrations.lark.connectedTitle}
+
+ {t.settings.integrations.lark.connectedDescription}
+
+
+ );
+ }
+ if (credentialsConfigured) {
+ return (
+
+
+ {t.settings.integrations.lark.configuredTitle}
+
+ {t.settings.integrations.lark.configuredDescription}
+
+
+ );
+ }
+ return (
+
+
+ {t.settings.integrations.lark.authNextTitle}
+
+ {t.settings.integrations.lark.authNextDescription}
+
+
+ );
+}
diff --git a/frontend/src/components/workspace/settings/settings-dialog-host.tsx b/frontend/src/components/workspace/settings/settings-dialog-host.tsx
new file mode 100644
index 000000000..9c4a03767
--- /dev/null
+++ b/frontend/src/components/workspace/settings/settings-dialog-host.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { SettingsDialog } from "./settings-dialog";
+import {
+ setSettingsDialogOpen,
+ useSettingsDialog,
+} from "./settings-dialog-store";
+
+/**
+ * The single application-wide Settings dialog instance.
+ *
+ * Mounted once at the workspace root; every entry point (nav menu, command
+ * palette, deep link) opens it through the shared store rather than mounting
+ * its own dialog.
+ */
+export function SettingsDialogHost() {
+ const { open, section } = useSettingsDialog();
+ return (
+
+ );
+}
diff --git a/frontend/src/components/workspace/settings/settings-dialog-store.ts b/frontend/src/components/workspace/settings/settings-dialog-store.ts
new file mode 100644
index 000000000..dcd3f6acd
--- /dev/null
+++ b/frontend/src/components/workspace/settings/settings-dialog-store.ts
@@ -0,0 +1,89 @@
+"use client";
+
+import { useCallback, useSyncExternalStore } from "react";
+
+import type { SettingsSection } from "./settings-dialog";
+
+type Listener = () => void;
+
+type SettingsDialogState = {
+ open: boolean;
+ section: SettingsSection;
+};
+
+const listeners = new Set();
+
+let state: SettingsDialogState = { open: false, section: "appearance" };
+
+function emitChange() {
+ for (const listener of listeners) {
+ listener();
+ }
+}
+
+function setState(next: SettingsDialogState) {
+ if (next.open === state.open && next.section === state.section) {
+ return;
+ }
+ state = next;
+ emitChange();
+}
+
+export function subscribeSettingsDialog(listener: Listener): () => void {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+}
+
+export function getSettingsDialogSnapshot(): SettingsDialogState {
+ return state;
+}
+
+const SERVER_SNAPSHOT: SettingsDialogState = {
+ open: false,
+ section: "appearance",
+};
+
+function getServerSnapshot(): SettingsDialogState {
+ return SERVER_SNAPSHOT;
+}
+
+export function openSettingsDialog(section: SettingsSection) {
+ setState({ open: true, section });
+}
+
+export function setSettingsDialogOpen(open: boolean) {
+ setState({ open, section: state.section });
+}
+
+/**
+ * Shared open/section state for the single application-wide Settings dialog.
+ *
+ * Multiple entry points (nav menu, command palette, `?settings=` deep link)
+ * drive this one store instead of each mounting its own `SettingsDialog`, so
+ * two dialogs can never be open at once with racing per-instance flows (e.g.
+ * duplicate Lark auth device-code polling).
+ */
+export function useSettingsDialog() {
+ const snapshot = useSyncExternalStore(
+ subscribeSettingsDialog,
+ getSettingsDialogSnapshot,
+ getServerSnapshot,
+ );
+
+ const open = useCallback((section: SettingsSection) => {
+ openSettingsDialog(section);
+ }, []);
+
+ const setOpen = useCallback((next: boolean) => {
+ setSettingsDialogOpen(next);
+ }, []);
+
+ return {
+ open: snapshot.open,
+ section: snapshot.section,
+ openSettings: open,
+ setSettingsOpen: setOpen,
+ };
+}
diff --git a/frontend/src/components/workspace/settings/settings-dialog.tsx b/frontend/src/components/workspace/settings/settings-dialog.tsx
index 4b9e3f373..779b91b0e 100644
--- a/frontend/src/components/workspace/settings/settings-dialog.tsx
+++ b/frontend/src/components/workspace/settings/settings-dialog.tsx
@@ -6,6 +6,7 @@ import {
InfoIcon,
BrainIcon,
PaletteIcon,
+ PlugZapIcon,
SparklesIcon,
UserIcon,
WrenchIcon,
@@ -23,6 +24,7 @@ import { AboutSettingsPage } from "@/components/workspace/settings/about-setting
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
import { ChannelsSettingsPage } from "@/components/workspace/settings/channels-settings-page";
+import { IntegrationsSettingsPage } from "@/components/workspace/settings/integrations-settings-page";
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
import { NotificationSettingsPage } from "@/components/workspace/settings/notification-settings-page";
import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
@@ -30,10 +32,11 @@ import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
-type SettingsSection =
+export type SettingsSection =
| "account"
| "appearance"
| "channels"
+ | "integrations"
| "memory"
| "tools"
| "skills"
@@ -80,6 +83,11 @@ export function SettingsDialog(props: SettingsDialogProps) {
label: t.settings.sections.channels,
icon: CableIcon,
},
+ {
+ id: "integrations",
+ label: t.settings.sections.integrations,
+ icon: PlugZapIcon,
+ },
{
id: "memory",
label: t.settings.sections.memory,
@@ -93,6 +101,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
t.settings.sections.account,
t.settings.sections.appearance,
t.settings.sections.channels,
+ t.settings.sections.integrations,
t.settings.sections.memory,
t.settings.sections.tools,
t.settings.sections.skills,
@@ -153,6 +162,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
)}
{activeSection === "notification" && }
{activeSection === "channels" && }
+ {activeSection === "integrations" && }
{activeSection === "about" && }
diff --git a/frontend/src/components/workspace/workspace-nav-menu.tsx b/frontend/src/components/workspace/workspace-nav-menu.tsx
index 8b99be078..98cd2355c 100644
--- a/frontend/src/components/workspace/workspace-nav-menu.tsx
+++ b/frontend/src/components/workspace/workspace-nav-menu.tsx
@@ -28,7 +28,7 @@ import {
import { useI18n } from "@/core/i18n/hooks";
import { GithubIcon } from "./github-icon";
-import { SettingsDialog } from "./settings";
+import { useSettingsDialog } from "./settings";
function NavMenuButtonContent({
isSidebarOpen,
@@ -51,10 +51,7 @@ function NavMenuButtonContent({
}
export function WorkspaceNavMenu() {
- const [settingsOpen, setSettingsOpen] = useState(false);
- const [settingsDefaultSection, setSettingsDefaultSection] = useState<
- "appearance" | "memory" | "tools" | "skills" | "notification" | "about"
- >("appearance");
+ const { openSettings } = useSettingsDialog();
const [mounted, setMounted] = useState(false);
const { open: isSidebarOpen } = useSidebar();
const { t } = useI18n();
@@ -65,11 +62,6 @@ export function WorkspaceNavMenu() {
return (
<>
-
{mounted ? (
@@ -90,8 +82,7 @@ export function WorkspaceNavMenu() {
{
- setSettingsDefaultSection("appearance");
- setSettingsOpen(true);
+ openSettings("appearance");
}}
>
@@ -139,8 +130,7 @@ export function WorkspaceNavMenu() {
{
- setSettingsDefaultSection("about");
- setSettingsOpen(true);
+ openSettings("about");
}}
>
diff --git a/frontend/src/components/workspace/workspace-settings-deep-link.tsx b/frontend/src/components/workspace/workspace-settings-deep-link.tsx
new file mode 100644
index 000000000..2c08b84be
--- /dev/null
+++ b/frontend/src/components/workspace/workspace-settings-deep-link.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { useEffect, useRef } from "react";
+
+import {
+ openSettingsDialog,
+ type SettingsSection,
+ useSettingsDialog,
+} from "./settings";
+
+const SETTINGS_SECTIONS = new Set([
+ "account",
+ "appearance",
+ "channels",
+ "integrations",
+ "memory",
+ "tools",
+ "skills",
+ "notification",
+ "about",
+]);
+
+function asSettingsSection(value: string | null): SettingsSection | null {
+ if (!value) return null;
+ return SETTINGS_SECTIONS.has(value as SettingsSection)
+ ? (value as SettingsSection)
+ : null;
+}
+
+/**
+ * Bridges the `?settings=` query param to the shared settings dialog
+ * store. It does not mount its own dialog — a single {@link SettingsDialogHost}
+ * renders the one dialog — so a deep link can never race a second dialog opened
+ * from the nav menu or command palette.
+ */
+export function WorkspaceSettingsDeepLink() {
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const { open } = useSettingsDialog();
+ const openedFromDeepLinkRef = useRef(false);
+
+ useEffect(() => {
+ const nextSection = asSettingsSection(searchParams.get("settings"));
+ if (nextSection) {
+ openedFromDeepLinkRef.current = true;
+ openSettingsDialog(nextSection);
+ }
+ }, [searchParams]);
+
+ useEffect(() => {
+ if (open || !openedFromDeepLinkRef.current) {
+ return;
+ }
+ openedFromDeepLinkRef.current = false;
+ if (searchParams.has("settings")) {
+ const next = new URLSearchParams(searchParams);
+ next.delete("settings");
+ const suffix = next.toString();
+ router.replace(suffix ? `${pathname}?${suffix}` : pathname, {
+ scroll: false,
+ });
+ }
+ }, [open, pathname, router, searchParams]);
+
+ return null;
+}
diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts
index 7caec8dcb..d81ef11db 100644
--- a/frontend/src/core/i18n/locales/en-US.ts
+++ b/frontend/src/core/i18n/locales/en-US.ts
@@ -706,6 +706,7 @@ export const enUS: Translations = {
account: "Account",
appearance: "Appearance",
channels: "Channels",
+ integrations: "Integrations",
memory: "Memory",
tools: "Tools",
skills: "Skills",
@@ -817,6 +818,203 @@ export const enUS: Translations = {
disabled:
"Channel connections are not enabled on this server. Ask an administrator to enable channel_connections.",
},
+ integrations: {
+ title: "Integrations",
+ description:
+ "Connect third-party tools and work platforms so agents can use them directly.",
+ refresh: "Refresh",
+ install: "Install",
+ reinstall: "Reinstall",
+ installing: "Installing...",
+ ready: "Ready",
+ pending: "Pending",
+ available: "Available",
+ unavailable: "Unavailable",
+ connected: "Connected",
+ loadFailed: "Failed to load integration status",
+ adminRequired: "Admin privileges are required to install integrations.",
+ lark: {
+ title: "Lark / Feishu CLI",
+ description:
+ "Install the official Lark/Feishu agent skills and let agents use Lark after authorization.",
+ skillPack: "Skill pack",
+ gatewayCli: "Gateway CLI",
+ auth: "Auth",
+ sandboxRuntime: "Sandbox runtime",
+ sandboxRuntimeInitContainer: "Provisioned by init container",
+ sandboxRuntimeGatewayDownload: "Provisioned by Gateway",
+ sandboxRuntimeNotReady:
+ "Not ready — lark-cli may be missing at chat time",
+ notInstalled: "Not installed",
+ skillsInstalled: (installed, expected) =>
+ `${installed}/${expected} skills installed`,
+ installedVersion: (version) => `Installed: ${version}`,
+ updateAvailable: (version) =>
+ `Update available: ${version} — admin reinstall updates the managed Gateway CLI and skill pack`,
+ runtimeVersionMismatch:
+ "Skill pack version differs from the Gateway runtime lark-cli; admin reinstall attempts to update the managed Gateway CLI and realign the skill pack",
+ authNotConfigured: "Not connected",
+ authConfigured: "Credentials configured (not live-verified)",
+ authConfiguredFor: (user) =>
+ `${user} · credentials configured (not live-verified)`,
+ connect: "Connect Lark",
+ authStarting: "Opening connection link...",
+ checkingConnection: "Checking connection...",
+ connectedAction: "Reconnect Lark",
+ requestPermissions: "Request permissions",
+ alreadyConnected:
+ "Lark is already connected. If authorization expires, refresh the status and reconnect.",
+ connectionStarted: "Connection link opened",
+ connectionReady: "Connection is ready. Opening authorization...",
+ authStarted:
+ "Authorization page opened. DeerFlow will detect completion automatically.",
+ authorizationStillPending:
+ 'Authorization is not complete yet. Finish it in the browser; DeerFlow keeps checking automatically. You can click "I completed authorization" if the page does not update.',
+ permissionTitle: "Authorization scope",
+ permissionDescription:
+ "By default, DeerFlow only completes the base sign-in and does not request any business permissions. Select the domains you need here; connected users can re-authorize to add more (scopes accumulate).",
+ authDomains: {
+ calendar: {
+ label: "Calendar",
+ description:
+ "Events, free/busy, RSVP, and meeting-room scheduling.",
+ },
+ im: {
+ label: "Messenger",
+ description:
+ "Send/reply messages, manage group chats, search history, download media.",
+ },
+ docs: {
+ label: "Docs",
+ description: "Create, read, update, and search documents.",
+ },
+ drive: {
+ label: "Drive",
+ description:
+ "Upload/download files, search docs & wiki, manage comments.",
+ },
+ sheets: {
+ label: "Sheets",
+ description: "Read, write, append, find, and export spreadsheets.",
+ },
+ base: {
+ label: "Base",
+ description:
+ "Bitable tables, fields, records, views, dashboards, and workflows.",
+ },
+ wiki: {
+ label: "Wiki",
+ description: "Knowledge spaces, nodes, and wiki documents.",
+ },
+ task: {
+ label: "Tasks",
+ description:
+ "Tasks, task lists, subtasks, comments, and reminders.",
+ },
+ mail: {
+ label: "Mail",
+ description:
+ "Browse, search, read, send, reply, forward, and manage drafts.",
+ },
+ vc: {
+ label: "Meetings",
+ description: "Meeting records, minutes artifacts, and recordings.",
+ },
+ minutes: {
+ label: "Minutes",
+ description: "Meeting minutes content and transcripts.",
+ },
+ note: {
+ label: "Notes",
+ description: "Meeting notes and related content.",
+ },
+ slides: {
+ label: "Slides",
+ description: "Presentations and slide content.",
+ },
+ markdown: {
+ label: "Markdown",
+ description:
+ "Create, fetch, patch, and overwrite Drive-native .md files.",
+ },
+ mindnotes: {
+ label: "Mind notes",
+ description: "Mind notes content.",
+ },
+ contact: {
+ label: "Contacts",
+ description: "Look up users by name/email/phone and read profiles.",
+ },
+ approval: {
+ label: "Approval",
+ description:
+ "Query and act on approval tasks; cancel and CC instances.",
+ },
+ attendance: {
+ label: "Attendance",
+ description: "Query personal attendance check-in records.",
+ },
+ okr: {
+ label: "OKR",
+ description:
+ "Objectives, key results, alignments, indicators, and progress.",
+ },
+ event: {
+ label: "Events",
+ description: "Subscribe to and consume real-time platform events.",
+ },
+ apps: {
+ label: "Apps",
+ description:
+ "Create Spark/Miaoda apps, publish sites, and manage access scope.",
+ },
+ all: {
+ label: "All",
+ description:
+ "Request every business domain supported by lark-cli. Use this only when the missing permission is unclear.",
+ },
+ },
+ customScopeLabel: "Exact OAuth scope",
+ customScopePlaceholder: "For example calendar:calendar.event:read",
+ customScopeDescription:
+ "Advanced: if an error reports a missing scope, paste it here. Examples: calendar:calendar.event:read, calendar:calendar.free_busy:read.",
+ openConnectionLinkTitle: "Continue connecting Lark",
+ openConnectionLinkDescription:
+ "The first connection needs one browser confirmation from Lark. Open the link below and finish the prompt, then return here to continue authorization.",
+ openAuthLinkTitle: "Authorize Lark in your browser",
+ openAuthLinkDescription:
+ "Open the link below to authorize. DeerFlow keeps checking automatically and will save the connection after approval.",
+ waitingAuthTitle: "Waiting for Lark authorization",
+ waitingAuthDescription:
+ "Finish authorization in the browser page that just opened. DeerFlow will update this panel automatically; the button below is only a fallback.",
+ openAuthLink: "Open link",
+ copyAuthLink: "Copy link",
+ completeAuth: "I completed authorization",
+ continueAuth: "I completed browser confirmation, continue",
+ preparingAuthorization: "Preparing authorization...",
+ completingAuth: "Checking...",
+ authExpiresIn: (seconds) =>
+ `This link expires in about ${seconds} seconds.`,
+ installingTitle: "Installing official skill pack",
+ installingDescription:
+ "This usually finishes within 30 seconds; slower networks may take about 1 minute. The status refreshes automatically when installation completes.",
+ installNextTitle: "Install the official skill pack first",
+ installNextDescription:
+ "After installation, /lark-doc, /lark-im, /lark-sheets and related skills appear in the skill index.",
+ cliNextTitle: "Install Gateway CLI",
+ cliNextDescription:
+ "The skill pack is installed, but the Gateway cannot find lark-cli. Admin reinstall attempts to download the managed Gateway CLI; offline deployments can use an image with @larksuite/cli built in.",
+ configuredTitle: "Lark credentials are configured locally",
+ configuredDescription:
+ "Credentials are present, but their current validity has not been checked with Lark. Reconnect to refresh and live-verify authorization.",
+ connectedTitle: "Lark authorization is live-verified",
+ connectedDescription:
+ "The current user's authorization was verified with Lark during this connection flow. Reconnect whenever you need to refresh it or add permissions.",
+ authNextTitle: "Complete browser authorization next",
+ authNextDescription:
+ "Click “Connect Lark”; DeerFlow checks the current status first and opens browser authorization only when disconnected or expired.",
+ },
+ },
skills: {
title: "Agent Skills",
description:
diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts
index 35b3469eb..918d172a0 100644
--- a/frontend/src/core/i18n/locales/types.ts
+++ b/frontend/src/core/i18n/locales/types.ts
@@ -590,6 +590,7 @@ export interface Translations {
account: string;
appearance: string;
channels: string;
+ integrations: string;
memory: string;
tools: string;
skills: string;
@@ -692,6 +693,105 @@ export interface Translations {
description: string;
disabled: string;
};
+ integrations: {
+ title: string;
+ description: string;
+ refresh: string;
+ install: string;
+ reinstall: string;
+ installing: string;
+ ready: string;
+ pending: string;
+ available: string;
+ unavailable: string;
+ connected: string;
+ loadFailed: string;
+ adminRequired: string;
+ lark: {
+ title: string;
+ description: string;
+ skillPack: string;
+ gatewayCli: string;
+ auth: string;
+ sandboxRuntime: string;
+ sandboxRuntimeInitContainer: string;
+ sandboxRuntimeGatewayDownload: string;
+ sandboxRuntimeNotReady: string;
+ notInstalled: string;
+ skillsInstalled: (installed: number, expected: number) => string;
+ installedVersion: (version: string) => string;
+ updateAvailable: (version: string) => string;
+ runtimeVersionMismatch: string;
+ authNotConfigured: string;
+ authConfigured: string;
+ authConfiguredFor: (user: string) => string;
+ connect: string;
+ authStarting: string;
+ checkingConnection: string;
+ connectedAction: string;
+ requestPermissions: string;
+ alreadyConnected: string;
+ connectionStarted: string;
+ connectionReady: string;
+ authStarted: string;
+ authorizationStillPending: string;
+ permissionTitle: string;
+ permissionDescription: string;
+ authDomains: Record<
+ | "approval"
+ | "apps"
+ | "attendance"
+ | "base"
+ | "calendar"
+ | "contact"
+ | "docs"
+ | "drive"
+ | "event"
+ | "im"
+ | "mail"
+ | "markdown"
+ | "mindnotes"
+ | "minutes"
+ | "note"
+ | "okr"
+ | "sheets"
+ | "slides"
+ | "task"
+ | "vc"
+ | "wiki"
+ | "all",
+ { label: string; description: string }
+ >;
+ customScopeLabel: string;
+ customScopePlaceholder: string;
+ customScopeDescription: string;
+ openConnectionLinkTitle: string;
+ openConnectionLinkDescription: string;
+ openAuthLinkTitle: string;
+ openAuthLinkDescription: string;
+ waitingAuthTitle: string;
+ waitingAuthDescription: string;
+ openAuthLink: string;
+ copyAuthLink: string;
+ completeAuth: string;
+ continueAuth: string;
+ preparingAuthorization: string;
+ completingAuth: string;
+ authExpiresIn: (seconds: number) => string;
+ installingTitle: string;
+ installingDescription: string;
+ installNextTitle: string;
+ installNextDescription: string;
+ cliNextTitle: string;
+ cliNextDescription: string;
+ configuredTitle: string;
+ configuredDescription: string;
+ connectedTitle: string;
+ connectedDescription: string;
+ authNextTitle: string;
+ authNextDescription: string;
+ };
+ };
skills: {
title: string;
description: string;
diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts
index d49d1119b..eb996cabf 100644
--- a/frontend/src/core/i18n/locales/zh-CN.ts
+++ b/frontend/src/core/i18n/locales/zh-CN.ts
@@ -679,6 +679,7 @@ export const zhCN: Translations = {
account: "账号",
appearance: "外观",
channels: "渠道",
+ integrations: "集成",
memory: "记忆",
tools: "工具",
skills: "技能",
@@ -786,6 +787,188 @@ export const zhCN: Translations = {
disabled:
"当前服务器未启用渠道连接。请联系管理员开启 channel_connections。",
},
+ integrations: {
+ title: "集成",
+ description: "连接第三方工具和办公生态,让 Agent 能直接使用对应能力。",
+ refresh: "刷新",
+ install: "安装",
+ reinstall: "重新安装",
+ installing: "安装中...",
+ ready: "就绪",
+ pending: "待处理",
+ available: "可用",
+ unavailable: "不可用",
+ connected: "已连接",
+ loadFailed: "加载集成状态失败",
+ adminRequired: "需要管理员权限才能安装集成。",
+ lark: {
+ title: "Lark / 飞书 CLI",
+ description:
+ "安装官方 Lark/Feishu Agent Skills,并在授权后让 Agent 直接使用飞书能力。",
+ skillPack: "技能包",
+ gatewayCli: "Gateway CLI",
+ auth: "授权",
+ sandboxRuntime: "沙箱运行时",
+ sandboxRuntimeInitContainer: "由 init container 提供",
+ sandboxRuntimeGatewayDownload: "由 Gateway 提供",
+ sandboxRuntimeNotReady: "未就绪 —— 对话时 lark-cli 可能不可用",
+ notInstalled: "尚未安装",
+ skillsInstalled: (installed, expected) =>
+ `已安装 ${installed}/${expected} 个技能`,
+ installedVersion: (version) => `已安装版本:${version}`,
+ updateAvailable: (version) =>
+ `有新版本:${version} —— 管理员重新安装会更新 managed Gateway CLI 和技能包`,
+ runtimeVersionMismatch:
+ "技能包版本与 Gateway 运行时 lark-cli 不一致;管理员重新安装会尝试更新 managed Gateway CLI 并重新对齐技能包",
+ authNotConfigured: "尚未连接",
+ authConfigured: "凭证已配置(未实时验证)",
+ authConfiguredFor: (user) => `${user} · 凭证已配置(未实时验证)`,
+ connect: "连接飞书",
+ authStarting: "正在打开连接链接...",
+ checkingConnection: "正在检查连接状态...",
+ connectedAction: "重新连接飞书",
+ requestPermissions: "申请新权限",
+ alreadyConnected:
+ "飞书已连接,无需重复授权。如果授权已过期,刷新状态后可重新连接。",
+ connectionStarted: "连接链接已打开",
+ connectionReady: "连接准备已完成,正在打开授权链接",
+ authStarted: "授权页已打开,DeerFlow 会自动检测授权结果。",
+ authorizationStillPending:
+ "还没有检测到授权完成。请在浏览器完成授权;DeerFlow 会继续自动检测。如果页面没有更新,可点击“我已完成授权”。",
+ permissionTitle: "授权范围",
+ permissionDescription:
+ "默认只完成基础登录,不会申请任何业务权限。按需在这里勾选要授权的业务域;已连接用户可以重新授权继续追加(scope 会累积)。",
+ authDomains: {
+ calendar: {
+ label: "日历",
+ description: "日程、忙闲、日程回复与会议室预定。",
+ },
+ im: {
+ label: "消息",
+ description: "收发/回复消息、管理群聊、搜索记录、下载媒体。",
+ },
+ docs: {
+ label: "文档",
+ description: "创建、读取、编辑和搜索云文档。",
+ },
+ drive: {
+ label: "云空间",
+ description: "上传/下载文件、搜索文档与知识库、管理评论。",
+ },
+ sheets: {
+ label: "电子表格",
+ description: "读取、写入、追加、查找和导出电子表格。",
+ },
+ base: {
+ label: "多维表格",
+ description: "多维表格的表、字段、记录、视图、仪表盘与工作流。",
+ },
+ wiki: {
+ label: "知识库",
+ description: "知识空间、节点与知识库文档。",
+ },
+ task: {
+ label: "任务",
+ description: "任务、清单、子任务、评论与提醒。",
+ },
+ mail: {
+ label: "邮件",
+ description: "浏览、搜索、阅读、发送、回复、转发与管理草稿。",
+ },
+ vc: {
+ label: "视频会议",
+ description: "会议记录、纪要产物与录制。",
+ },
+ minutes: {
+ label: "妙记",
+ description: "会议纪要内容与逐字稿。",
+ },
+ note: {
+ label: "笔记",
+ description: "会议笔记及相关内容。",
+ },
+ slides: {
+ label: "幻灯片",
+ description: "演示文稿与幻灯片内容。",
+ },
+ markdown: {
+ label: "Markdown",
+ description: "创建、获取、局部修改和覆盖云盘原生 .md 文件。",
+ },
+ mindnotes: {
+ label: "思维笔记",
+ description: "思维笔记内容。",
+ },
+ contact: {
+ label: "通讯录",
+ description: "按姓名/邮箱/电话查用户并读取资料。",
+ },
+ approval: {
+ label: "审批",
+ description: "查询和处理审批任务、撤销与抄送实例。",
+ },
+ attendance: {
+ label: "考勤",
+ description: "查询个人考勤打卡记录。",
+ },
+ okr: {
+ label: "OKR",
+ description: "目标、关键结果、对齐、指标与进展。",
+ },
+ event: {
+ label: "实时事件",
+ description: "订阅并消费平台实时事件。",
+ },
+ apps: {
+ label: "妙搭应用",
+ description: "创建 Spark/妙搭应用、发布站点并管理可见范围。",
+ },
+ all: {
+ label: "全部",
+ description:
+ "申请 lark-cli 支持的全部业务域权限。仅在不确定缺哪个权限时使用。",
+ },
+ },
+ customScopeLabel: "具体 OAuth scope",
+ customScopePlaceholder: "例如 calendar:calendar.event:read",
+ customScopeDescription:
+ "高级用法:如果错误里给出了缺失 scope,可直接填在这里。例如 calendar:calendar.event:read、calendar:calendar.free_busy:read。",
+ openConnectionLinkTitle: "继续完成飞书连接",
+ openConnectionLinkDescription:
+ "首次连接需要在浏览器里完成一次飞书确认。打开下面的链接按提示完成;完成后回到这里继续授权。",
+ openAuthLinkTitle: "在浏览器中完成飞书授权",
+ openAuthLinkDescription:
+ "打开下面的链接完成授权。DeerFlow 会持续自动检测,并在授权通过后保存连接状态。",
+ waitingAuthTitle: "等待飞书授权完成",
+ waitingAuthDescription:
+ "请在刚打开的浏览器页面完成授权。DeerFlow 会自动更新这里的状态;下方按钮只是兜底操作。",
+ openAuthLink: "打开链接",
+ copyAuthLink: "复制链接",
+ completeAuth: "我已完成授权",
+ continueAuth: "我已完成浏览器确认,继续授权",
+ preparingAuthorization: "正在准备授权...",
+ completingAuth: "确认中...",
+ authExpiresIn: (seconds) => `链接将在约 ${seconds} 秒后过期。`,
+ installingTitle: "正在安装官方技能包",
+ installingDescription:
+ "通常 30 秒内完成,网络较慢时可能需要约 1 分钟。安装完成后会自动刷新状态。",
+ installNextTitle: "先安装官方技能包",
+ installNextDescription:
+ "安装后,/lark-doc、/lark-im、/lark-sheets 等技能会出现在技能索引中。",
+ cliNextTitle: "需要安装 Gateway CLI",
+ cliNextDescription:
+ "技能包已安装,但 Gateway 找不到 lark-cli。管理员重新安装集成会尝试下载 managed Gateway CLI;离线部署可使用内置 @larksuite/cli 的镜像。",
+ configuredTitle: "飞书凭证已在本地配置",
+ configuredDescription:
+ "当前只确认本地存在凭证,尚未向飞书实时验证有效性。重新连接可刷新并实时验证授权。",
+ connectedTitle: "飞书授权已实时验证",
+ connectedDescription:
+ "本次连接流程已向飞书验证当前用户授权。需要刷新授权或追加权限时,可重新连接。",
+ authNextTitle: "下一步完成浏览器授权",
+ authNextDescription:
+ "点击“连接飞书”后,DeerFlow 会先检查当前状态;未连接或授权过期时会拉起浏览器授权。",
+ },
+ },
skills: {
title: "技能",
description: "管理 Agent Skill 配置和启用状态。",
diff --git a/frontend/src/core/integrations/lark/api.ts b/frontend/src/core/integrations/lark/api.ts
new file mode 100644
index 000000000..dac796523
--- /dev/null
+++ b/frontend/src/core/integrations/lark/api.ts
@@ -0,0 +1,153 @@
+import { fetch } from "@/core/api/fetcher";
+import { getBackendBaseURL } from "@/core/config";
+
+import type {
+ LarkAuthCompleteRequest,
+ LarkAuthCompleteResponse,
+ LarkAuthStartRequest,
+ LarkAuthStartResponse,
+ LarkConfigCompleteRequest,
+ LarkConfigCompleteResponse,
+ LarkConfigStartRequest,
+ LarkConfigStartResponse,
+ LarkInstallResponse,
+ LarkIntegrationStatus,
+} from "./types";
+
+export class LarkIntegrationRequestError extends Error {
+ readonly status: number;
+
+ constructor(status: number, message: string) {
+ super(message);
+ this.name = "LarkIntegrationRequestError";
+ this.status = status;
+ }
+
+ get isAdminRequired(): boolean {
+ return this.status === 403;
+ }
+}
+
+async function readErrorDetail(response: Response): Promise {
+ const data = (await response.json().catch(() => ({}))) as {
+ detail?: string;
+ };
+ return data.detail ?? `HTTP ${response.status}: ${response.statusText}`;
+}
+
+export async function loadLarkIntegrationStatus(): Promise {
+ const response = await fetch(
+ `${getBackendBaseURL()}/api/integrations/lark/status`,
+ );
+ if (!response.ok) {
+ throw new LarkIntegrationRequestError(
+ response.status,
+ await readErrorDetail(response),
+ );
+ }
+ return response.json();
+}
+
+export async function installLarkIntegration(): Promise {
+ const response = await fetch(
+ `${getBackendBaseURL()}/api/integrations/lark/install`,
+ {
+ method: "POST",
+ },
+ );
+ if (!response.ok) {
+ throw new LarkIntegrationRequestError(
+ response.status,
+ await readErrorDetail(response),
+ );
+ }
+ return response.json();
+}
+
+export async function startLarkAuthorization(
+ request: LarkAuthStartRequest = {},
+): Promise {
+ const response = await fetch(
+ `${getBackendBaseURL()}/api/integrations/lark/auth/start`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(request),
+ },
+ );
+ if (!response.ok) {
+ throw new LarkIntegrationRequestError(
+ response.status,
+ await readErrorDetail(response),
+ );
+ }
+ return response.json();
+}
+
+export async function startLarkConfiguration(
+ request: LarkConfigStartRequest = {},
+): Promise {
+ const response = await fetch(
+ `${getBackendBaseURL()}/api/integrations/lark/config/start`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(request),
+ },
+ );
+ if (!response.ok) {
+ throw new LarkIntegrationRequestError(
+ response.status,
+ await readErrorDetail(response),
+ );
+ }
+ return response.json();
+}
+
+export async function completeLarkConfiguration(
+ request: LarkConfigCompleteRequest,
+): Promise {
+ const response = await fetch(
+ `${getBackendBaseURL()}/api/integrations/lark/config/complete`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(request),
+ },
+ );
+ if (!response.ok) {
+ throw new LarkIntegrationRequestError(
+ response.status,
+ await readErrorDetail(response),
+ );
+ }
+ return response.json();
+}
+
+export async function completeLarkAuthorization(
+ request: LarkAuthCompleteRequest,
+): Promise {
+ const response = await fetch(
+ `${getBackendBaseURL()}/api/integrations/lark/auth/complete`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(request),
+ },
+ );
+ if (!response.ok) {
+ throw new LarkIntegrationRequestError(
+ response.status,
+ await readErrorDetail(response),
+ );
+ }
+ return response.json();
+}
diff --git a/frontend/src/core/integrations/lark/hooks.ts b/frontend/src/core/integrations/lark/hooks.ts
new file mode 100644
index 000000000..22d57b7f0
--- /dev/null
+++ b/frontend/src/core/integrations/lark/hooks.ts
@@ -0,0 +1,68 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+
+import {
+ completeLarkAuthorization,
+ completeLarkConfiguration,
+ installLarkIntegration,
+ loadLarkIntegrationStatus,
+ startLarkAuthorization,
+ startLarkConfiguration,
+} from "./api";
+
+export const larkIntegrationQueryKey = ["integrations", "lark"] as const;
+
+export function useLarkIntegrationStatus() {
+ return useQuery({
+ queryKey: larkIntegrationQueryKey,
+ queryFn: loadLarkIntegrationStatus,
+ });
+}
+
+export function useInstallLarkIntegration() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: installLarkIntegration,
+ onSuccess: async (result) => {
+ queryClient.setQueryData(larkIntegrationQueryKey, result.status);
+ await queryClient.invalidateQueries({
+ queryKey: larkIntegrationQueryKey,
+ });
+ await queryClient.invalidateQueries({ queryKey: ["skills"] });
+ },
+ });
+}
+
+export function useStartLarkAuthorization() {
+ return useMutation({
+ mutationFn: startLarkAuthorization,
+ });
+}
+
+export function useStartLarkConfiguration() {
+ return useMutation({
+ mutationFn: startLarkConfiguration,
+ });
+}
+
+export function useCompleteLarkConfiguration() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: completeLarkConfiguration,
+ onSuccess: async (result) => {
+ queryClient.setQueryData(larkIntegrationQueryKey, result.status);
+ await queryClient.invalidateQueries({
+ queryKey: larkIntegrationQueryKey,
+ });
+ },
+ });
+}
+
+export function useCompleteLarkAuthorization() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: completeLarkAuthorization,
+ onSuccess: (result) => {
+ queryClient.setQueryData(larkIntegrationQueryKey, result.status);
+ },
+ });
+}
diff --git a/frontend/src/core/integrations/lark/index.ts b/frontend/src/core/integrations/lark/index.ts
new file mode 100644
index 000000000..0733bf1a4
--- /dev/null
+++ b/frontend/src/core/integrations/lark/index.ts
@@ -0,0 +1,3 @@
+export * from "./api";
+export * from "./hooks";
+export * from "./types";
diff --git a/frontend/src/core/integrations/lark/types.ts b/frontend/src/core/integrations/lark/types.ts
new file mode 100644
index 000000000..7e8c107b5
--- /dev/null
+++ b/frontend/src/core/integrations/lark/types.ts
@@ -0,0 +1,102 @@
+export interface LarkCliProbe {
+ available: boolean;
+ path: string | null;
+ version: string | null;
+ error: string | null;
+}
+
+export interface LarkAuthProbe {
+ status:
+ | "authenticated"
+ | "not_authorized"
+ | "not_configured"
+ | "unavailable"
+ | "error";
+ message: string | null;
+ user: string | null;
+ verified: boolean;
+}
+
+export type LarkSandboxRuntimeMode =
+ | "none"
+ | "gateway-download"
+ | "init-container";
+
+export interface LarkIntegrationStatus {
+ installed: boolean;
+ version: string;
+ manifest_version: string | null;
+ latest_available_version: string | null;
+ runtime_version_mismatch: boolean;
+ app_configured: boolean;
+ app_id: string | null;
+ app_brand: string | null;
+ skills_expected: number;
+ skills_installed: number;
+ installed_skills: string[];
+ enabled_skills: string[];
+ install_path: string;
+ cli: LarkCliProbe;
+ auth: LarkAuthProbe;
+ sandbox_runtime_mode: LarkSandboxRuntimeMode;
+ sandbox_runtime_ready: boolean;
+ sandbox_runtime_detail: string | null;
+}
+
+export interface LarkInstallResponse {
+ success: boolean;
+ installed_skills: string[];
+ message: string;
+ status: LarkIntegrationStatus;
+}
+
+export interface LarkAuthStartRequest {
+ recommend?: boolean;
+ domains?: string[];
+ scope?: string | null;
+}
+
+export interface LarkAuthStartResponse {
+ verification_url: string;
+ device_code: string;
+ expires_in: number | null;
+ user_code: string | null;
+ hint: string | null;
+}
+
+export interface LarkConfigStartRequest {
+ brand?: "feishu" | "lark";
+}
+
+export interface LarkConfigStartResponse {
+ verification_url: string;
+ device_code: string;
+ expires_in: number | null;
+ interval: number | null;
+ user_code: string | null;
+ brand: "feishu" | "lark";
+}
+
+export interface LarkConfigCompleteRequest {
+ device_code: string;
+ brand: "feishu" | "lark";
+ interval: number | null;
+ expires_in: number | null;
+}
+
+export interface LarkConfigCompleteResponse {
+ success: boolean;
+ message: string;
+ status: LarkIntegrationStatus;
+}
+
+export interface LarkAuthCompleteRequest {
+ device_code: string;
+ wait_timeout_seconds?: number;
+}
+
+export interface LarkAuthCompleteResponse {
+ success: boolean;
+ message: string;
+ status: LarkIntegrationStatus;
+}
diff --git a/frontend/tests/e2e/integrations.spec.ts b/frontend/tests/e2e/integrations.spec.ts
new file mode 100644
index 000000000..083d81034
--- /dev/null
+++ b/frontend/tests/e2e/integrations.spec.ts
@@ -0,0 +1,156 @@
+import { expect, test } from "@playwright/test";
+
+import { mockLangGraphAPI } from "./utils/mock-api";
+
+test.describe("Integrations settings", () => {
+ test("opens integrations settings from a query-string deep link", async ({
+ page,
+ }) => {
+ mockLangGraphAPI(page);
+
+ await page.goto("/workspace/chats/new?settings=integrations");
+
+ const dialog = page.getByRole("dialog", { name: "Settings" });
+ await expect(dialog).toBeVisible();
+ await expect(dialog.getByText("Lark / Feishu CLI")).toBeVisible();
+ });
+
+ test("keeps a single settings dialog across deep link and nav menu openings", async ({
+ page,
+ }) => {
+ mockLangGraphAPI(page);
+
+ // Deep link opens the shared dialog on Integrations.
+ await page.goto("/workspace/chats/new?settings=integrations");
+ const dialog = page.getByRole("dialog", { name: "Settings" });
+ await expect(dialog).toBeVisible();
+ await expect(dialog.getByText("Lark / Feishu CLI")).toBeVisible();
+ await expect(page.getByRole("dialog", { name: "Settings" })).toHaveCount(1);
+
+ // Close the modal before using the sidebar. While the modal is open, the
+ // background is intentionally inert and Playwright should not be able to
+ // click sidebar controls there.
+ await page.keyboard.press("Escape");
+ await expect(page.getByRole("dialog", { name: "Settings" })).toHaveCount(0);
+
+ // Opening again from the nav menu must still use the same shared host, not
+ // mount a second SettingsDialog instance.
+ const sidebar = page.locator("[data-sidebar='sidebar']");
+ await sidebar.getByRole("button", { name: /Settings and more/ }).click();
+ await page.getByRole("menuitem", { name: "Settings" }).click();
+
+ // Exactly one Settings dialog is mounted/visible at any time.
+ await expect(page.getByRole("dialog", { name: "Settings" })).toHaveCount(1);
+ });
+
+ test("can install the Lark integration skill pack from settings", async ({
+ page,
+ }) => {
+ mockLangGraphAPI(page);
+ let authStartRequest: unknown;
+ const authCompleteRequests: unknown[] = [];
+ await page.route(
+ "**/api/integrations/lark/auth/complete",
+ async (route) => {
+ authCompleteRequests.push(route.request().postDataJSON());
+ await route.fallback();
+ },
+ );
+ await page.route("**/api/integrations/lark/config/start", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ verification_url: "about:blank",
+ device_code: "mock-config-device-code",
+ expires_in: 600,
+ interval: 5,
+ user_code: "config",
+ brand: "feishu",
+ }),
+ });
+ });
+ await page.route("**/api/integrations/lark/auth/start", async (route) => {
+ authStartRequest = route.request().postDataJSON();
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ verification_url: "https://open.feishu.cn/auth/mock-device",
+ device_code: "mock-device-code",
+ expires_in: 600,
+ user_code: null,
+ hint: null,
+ }),
+ });
+ });
+
+ await page.goto("/workspace/chats/new");
+
+ const sidebar = page.locator("[data-sidebar='sidebar']");
+ await sidebar.getByRole("button", { name: /Settings and more/ }).click();
+ await page.getByRole("menuitem", { name: "Settings" }).click();
+
+ const dialog = page.getByRole("dialog", { name: "Settings" });
+ await expect(dialog).toBeVisible();
+ await dialog.getByRole("button", { name: "Integrations" }).click();
+
+ await expect(dialog.getByText("Lark / Feishu CLI")).toBeVisible();
+ await expect(
+ dialog.getByText("Install the official skill pack first"),
+ ).toBeVisible();
+
+ await dialog.getByRole("button", { name: "Install" }).click();
+ await expect(
+ page.getByText("Installed 3 Lark/Feishu skills."),
+ ).toBeVisible();
+
+ // Sandbox-runtime readiness row surfaces once the init-container runtime is
+ // reported ready, so a green UI can't hide a chat-time command-not-found.
+ await expect(dialog.getByText("Sandbox runtime")).toBeVisible();
+ await expect(
+ dialog.getByText("Provisioned by init container"),
+ ).toBeVisible();
+
+ await dialog.getByRole("button", { name: "Calendar" }).click();
+ await dialog
+ .getByLabel("Exact OAuth scope")
+ .fill("calendar:calendar.event:read");
+ await dialog.getByRole("button", { name: "Connect Lark" }).click();
+ await expect(dialog.getByText("about:blank")).toBeVisible();
+ await expect(dialog.getByText(/app configuration/i)).toHaveCount(0);
+
+ await dialog
+ .getByRole("button", {
+ name: "I completed browser confirmation, continue",
+ })
+ .click();
+ await expect
+ .poll(() => authStartRequest)
+ .toMatchObject({
+ recommend: false,
+ domains: ["calendar"],
+ scope: "calendar:calendar.event:read",
+ });
+
+ await expect
+ .poll(() => authCompleteRequests)
+ .toContainEqual({
+ device_code: "mock-device-code",
+ wait_timeout_seconds: 8,
+ });
+ await expect(
+ dialog.getByText("Lark authorization is live-verified"),
+ ).toBeVisible();
+ await expect(
+ page.getByText("Authorization page opened. Waiting for completion..."),
+ ).toHaveCount(0);
+
+ await dialog.getByRole("button", { name: "Calendar" }).click();
+ await dialog.getByLabel("Exact OAuth scope").fill("");
+ await dialog.getByRole("button", { name: "Reconnect Lark" }).click();
+ await expect(
+ dialog.getByText("https://open.feishu.cn/auth/mock-device"),
+ ).toBeVisible();
+ });
+});
diff --git a/frontend/tests/e2e/utils/mock-api.ts b/frontend/tests/e2e/utils/mock-api.ts
index 95cec6651..300935787 100644
--- a/frontend/tests/e2e/utils/mock-api.ts
+++ b/frontend/tests/e2e/utils/mock-api.ts
@@ -258,6 +258,42 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
max_file_size: 50 * 1024 * 1024,
max_total_size: 100 * 1024 * 1024,
};
+ let larkIntegrationStatus = {
+ installed: false,
+ version: "v1.0.65",
+ manifest_version: null as string | null,
+ latest_available_version: "v1.0.65" as string | null,
+ runtime_version_mismatch: false,
+ app_configured: false,
+ app_id: null as string | null,
+ app_brand: null as string | null,
+ skills_expected: 27,
+ skills_installed: 0,
+ installed_skills: [] as string[],
+ enabled_skills: [] as string[],
+ install_path: "/tmp/deer-flow/integrations/skills/lark-cli",
+ cli: {
+ available: false,
+ path: null as string | null,
+ version: null as string | null,
+ error: "lark-cli is not on PATH" as string | null,
+ },
+ auth: {
+ status: "unavailable",
+ message: "lark-cli is not installed on the Gateway" as string | null,
+ user: null as string | null,
+ verified: false,
+ },
+ sandbox_runtime_mode: "init-container" as
+ | "none"
+ | "gateway-download"
+ | "init-container",
+ sandbox_runtime_ready: false,
+ sandbox_runtime_detail:
+ "The provisioner has no lark-cli init image configured (LARK_CLI_INIT_IMAGE)." as
+ | string
+ | null,
+ };
const featureFlags = {
agentsApiEnabled: options?.features?.agentsApiEnabled ?? true,
browserControlEnabled: options?.features?.browserControlEnabled ?? true,
@@ -1066,6 +1102,149 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
return route.fallback();
});
+ void page.route("**/api/integrations/lark/status", (route) => {
+ if (route.request().method() === "GET") {
+ return route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(larkIntegrationStatus),
+ });
+ }
+ return route.fallback();
+ });
+
+ void page.route("**/api/integrations/lark/install", (route) => {
+ if (route.request().method() === "POST") {
+ larkIntegrationStatus = {
+ installed: true,
+ version: "v1.0.65",
+ manifest_version: "v1.0.65",
+ latest_available_version: "v1.0.65",
+ runtime_version_mismatch: false,
+ app_configured: false,
+ app_id: null,
+ app_brand: null,
+ skills_expected: 27,
+ skills_installed: 3,
+ installed_skills: ["lark-doc", "lark-im", "lark-shared"],
+ enabled_skills: ["lark-doc", "lark-im", "lark-shared"],
+ install_path: "/tmp/deer-flow/integrations/skills/lark-cli",
+ cli: {
+ available: true,
+ path: "/usr/bin/lark-cli",
+ version: "lark-cli version v1.0.65",
+ error: null,
+ },
+ auth: {
+ status: "not_configured",
+ message: "lark-cli auth is not configured",
+ user: null,
+ verified: false,
+ },
+ sandbox_runtime_mode: "init-container",
+ sandbox_runtime_ready: true,
+ sandbox_runtime_detail: null,
+ };
+ return route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ success: true,
+ installed_skills: ["lark-doc", "lark-im", "lark-shared"],
+ message: "Installed 3 Lark/Feishu skills.",
+ status: larkIntegrationStatus,
+ }),
+ });
+ }
+ return route.fallback();
+ });
+
+ void page.route("**/api/integrations/lark/config/start", (route) => {
+ if (route.request().method() === "POST") {
+ return route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ verification_url: "https://open.feishu.cn/page/cli?user_code=config",
+ device_code: "mock-config-device-code",
+ expires_in: 600,
+ interval: 5,
+ user_code: "config",
+ brand: "feishu",
+ }),
+ });
+ }
+ return route.fallback();
+ });
+
+ void page.route("**/api/integrations/lark/config/complete", (route) => {
+ if (route.request().method() === "POST") {
+ larkIntegrationStatus = {
+ ...larkIntegrationStatus,
+ app_configured: true,
+ app_id: "cli_mock",
+ app_brand: "feishu",
+ auth: {
+ status: "not_authorized",
+ message: "Lark user authorization is not configured",
+ user: null,
+ verified: false,
+ },
+ };
+ return route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ success: true,
+ message: "Lark/Feishu connection setup completed.",
+ status: larkIntegrationStatus,
+ }),
+ });
+ }
+ return route.fallback();
+ });
+
+ void page.route("**/api/integrations/lark/auth/start", (route) => {
+ if (route.request().method() === "POST") {
+ return route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ verification_url: "https://open.feishu.cn/auth/mock-device",
+ device_code: "mock-device-code",
+ expires_in: 600,
+ user_code: null,
+ hint: null,
+ }),
+ });
+ }
+ return route.fallback();
+ });
+
+ void page.route("**/api/integrations/lark/auth/complete", (route) => {
+ if (route.request().method() === "POST") {
+ larkIntegrationStatus = {
+ ...larkIntegrationStatus,
+ auth: {
+ status: "authenticated",
+ message: "Lark/Feishu authorization is live-verified.",
+ user: "Alice",
+ verified: true,
+ },
+ };
+ return route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ success: true,
+ message: "Lark/Feishu authorization completed.",
+ status: larkIntegrationStatus,
+ }),
+ });
+ }
+ return route.fallback();
+ });
+
// Follow-up suggestions — input box auto-suggest after AI response
void page.route("**/api/threads/*/suggestions", (route) => {
if (route.request().method() === "POST") {
diff --git a/frontend/tests/unit/components/workspace/settings/settings-dialog-store.test.ts b/frontend/tests/unit/components/workspace/settings/settings-dialog-store.test.ts
new file mode 100644
index 000000000..b76215b4a
--- /dev/null
+++ b/frontend/tests/unit/components/workspace/settings/settings-dialog-store.test.ts
@@ -0,0 +1,57 @@
+import { afterEach, expect, test } from "@rstest/core";
+
+import {
+ getSettingsDialogSnapshot,
+ openSettingsDialog,
+ setSettingsDialogOpen,
+ subscribeSettingsDialog,
+} from "@/components/workspace/settings/settings-dialog-store";
+
+afterEach(() => {
+ // Reset shared module state between tests.
+ setSettingsDialogOpen(false);
+});
+
+test("starts closed on the default section", () => {
+ expect(getSettingsDialogSnapshot()).toEqual({
+ open: false,
+ section: "appearance",
+ });
+});
+
+test("openSettingsDialog opens on the requested section", () => {
+ openSettingsDialog("integrations");
+ expect(getSettingsDialogSnapshot()).toEqual({
+ open: true,
+ section: "integrations",
+ });
+});
+
+test("setSettingsDialogOpen(false) keeps the last section", () => {
+ openSettingsDialog("channels");
+ setSettingsDialogOpen(false);
+ expect(getSettingsDialogSnapshot()).toEqual({
+ open: false,
+ section: "channels",
+ });
+});
+
+test("notifies subscribers only on real state changes", () => {
+ let notifications = 0;
+ const unsubscribe = subscribeSettingsDialog(() => {
+ notifications += 1;
+ });
+
+ openSettingsDialog("integrations");
+ // Opening again on the same section is a no-op and must not re-notify.
+ openSettingsDialog("integrations");
+ expect(notifications).toBe(1);
+
+ openSettingsDialog("memory");
+ expect(notifications).toBe(2);
+
+ unsubscribe();
+ openSettingsDialog("about");
+ // No further notifications after unsubscribe.
+ expect(notifications).toBe(2);
+});
diff --git a/frontend/tests/unit/core/integrations/lark/api.test.ts b/frontend/tests/unit/core/integrations/lark/api.test.ts
new file mode 100644
index 000000000..816e05981
--- /dev/null
+++ b/frontend/tests/unit/core/integrations/lark/api.test.ts
@@ -0,0 +1,309 @@
+import { beforeEach, describe, expect, rs, test } from "@rstest/core";
+
+rs.mock("@/core/api/fetcher", () => ({
+ fetch: rs.fn(),
+}));
+
+rs.mock("@/core/config", () => ({
+ getBackendBaseURL: () => "/backend",
+}));
+
+import { fetch as fetcher } from "@/core/api/fetcher";
+import {
+ completeLarkAuthorization,
+ completeLarkConfiguration,
+ installLarkIntegration,
+ LarkIntegrationRequestError,
+ loadLarkIntegrationStatus,
+ startLarkAuthorization,
+ startLarkConfiguration,
+} from "@/core/integrations/lark/api";
+
+const mockedFetch = rs.mocked(fetcher);
+
+function jsonResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ statusText: status >= 400 ? "Bad Request" : "OK",
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+beforeEach(() => {
+ mockedFetch.mockReset();
+});
+
+describe("lark integration api", () => {
+ test("loads status", async () => {
+ mockedFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ installed: false,
+ version: "v1.0.65",
+ manifest_version: null,
+ latest_available_version: "v1.0.65",
+ runtime_version_mismatch: false,
+ app_configured: false,
+ app_id: null,
+ app_brand: null,
+ skills_expected: 27,
+ skills_installed: 0,
+ installed_skills: [],
+ enabled_skills: [],
+ install_path: "/tmp/lark-cli",
+ cli: { available: false, path: null, version: null, error: "missing" },
+ auth: { status: "unavailable", message: "missing", user: null },
+ sandbox_runtime_mode: "init-container",
+ sandbox_runtime_ready: false,
+ sandbox_runtime_detail: "init image not configured",
+ }),
+ );
+
+ await expect(loadLarkIntegrationStatus()).resolves.toMatchObject({
+ installed: false,
+ version: "v1.0.65",
+ sandbox_runtime_mode: "init-container",
+ sandbox_runtime_ready: false,
+ });
+ expect(mockedFetch).toHaveBeenCalledWith(
+ "/backend/api/integrations/lark/status",
+ );
+ });
+
+ test("installs integration", async () => {
+ mockedFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ success: true,
+ installed_skills: ["lark-doc"],
+ message: "Installed 1 Lark/Feishu skills.",
+ status: {
+ installed: true,
+ version: "v1.0.65",
+ manifest_version: "v1.0.65",
+ latest_available_version: "v1.0.65",
+ runtime_version_mismatch: false,
+ app_configured: false,
+ app_id: null,
+ app_brand: null,
+ skills_expected: 27,
+ skills_installed: 1,
+ installed_skills: ["lark-doc"],
+ enabled_skills: ["lark-doc"],
+ install_path: "/tmp/lark-cli",
+ cli: {
+ available: true,
+ path: "/usr/bin/lark-cli",
+ version: "v1.0.65",
+ error: null,
+ },
+ auth: {
+ status: "not_configured",
+ message: "not configured",
+ user: null,
+ },
+ },
+ }),
+ );
+
+ await expect(installLarkIntegration()).resolves.toMatchObject({
+ success: true,
+ installed_skills: ["lark-doc"],
+ });
+ expect(mockedFetch).toHaveBeenCalledWith(
+ "/backend/api/integrations/lark/install",
+ { method: "POST" },
+ );
+ });
+
+ test("surfaces admin-required install errors", async () => {
+ mockedFetch.mockResolvedValueOnce(
+ jsonResponse(403, { detail: "Admin privileges required." }),
+ );
+
+ const promise = installLarkIntegration();
+ await expect(promise).rejects.toMatchObject({
+ name: "LarkIntegrationRequestError",
+ status: 403,
+ isAdminRequired: true,
+ message: "Admin privileges required.",
+ });
+ await expect(promise).rejects.toBeInstanceOf(LarkIntegrationRequestError);
+ });
+
+ test("starts browser authorization", async () => {
+ mockedFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ verification_url: "https://open.feishu.cn/auth/mock",
+ device_code: "device-code",
+ expires_in: 600,
+ user_code: null,
+ hint: null,
+ }),
+ );
+
+ await expect(
+ startLarkAuthorization({
+ recommend: true,
+ domains: ["calendar"],
+ scope: "calendar:calendar.event:read",
+ }),
+ ).resolves.toEqual({
+ verification_url: "https://open.feishu.cn/auth/mock",
+ device_code: "device-code",
+ expires_in: 600,
+ user_code: null,
+ hint: null,
+ });
+ expect(mockedFetch).toHaveBeenCalledWith(
+ "/backend/api/integrations/lark/auth/start",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ recommend: true,
+ domains: ["calendar"],
+ scope: "calendar:calendar.event:read",
+ }),
+ },
+ );
+ });
+
+ test("starts connection setup", async () => {
+ mockedFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ verification_url: "https://open.feishu.cn/page/cli?user_code=config",
+ device_code: "config-device-code",
+ expires_in: 600,
+ interval: 5,
+ user_code: "config",
+ brand: "feishu",
+ }),
+ );
+
+ await expect(startLarkConfiguration({ brand: "feishu" })).resolves.toEqual({
+ verification_url: "https://open.feishu.cn/page/cli?user_code=config",
+ device_code: "config-device-code",
+ expires_in: 600,
+ interval: 5,
+ user_code: "config",
+ brand: "feishu",
+ });
+ expect(mockedFetch).toHaveBeenCalledWith(
+ "/backend/api/integrations/lark/config/start",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ brand: "feishu" }),
+ },
+ );
+ });
+
+ test("completes connection setup", async () => {
+ mockedFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ success: true,
+ message: "Lark/Feishu connection setup completed.",
+ status: {
+ installed: true,
+ version: "v1.0.65",
+ manifest_version: "v1.0.65",
+ latest_available_version: "v1.0.65",
+ runtime_version_mismatch: false,
+ app_configured: true,
+ app_id: "cli_mock",
+ app_brand: "feishu",
+ skills_expected: 27,
+ skills_installed: 1,
+ installed_skills: ["lark-doc"],
+ enabled_skills: ["lark-doc"],
+ install_path: "/tmp/lark-cli",
+ cli: {
+ available: true,
+ path: "/usr/bin/lark-cli",
+ version: "v1.0.65",
+ error: null,
+ },
+ auth: {
+ status: "not_authorized",
+ message: "not authorized",
+ user: null,
+ },
+ },
+ }),
+ );
+
+ await expect(
+ completeLarkConfiguration({
+ device_code: "config-device-code",
+ brand: "feishu",
+ interval: 5,
+ expires_in: 600,
+ }),
+ ).resolves.toMatchObject({
+ success: true,
+ status: { app_configured: true, app_id: "cli_mock" },
+ });
+ expect(mockedFetch).toHaveBeenCalledWith(
+ "/backend/api/integrations/lark/config/complete",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ device_code: "config-device-code",
+ brand: "feishu",
+ interval: 5,
+ expires_in: 600,
+ }),
+ },
+ );
+ });
+
+ test("completes browser authorization", async () => {
+ mockedFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ success: true,
+ message: "Lark/Feishu authorization completed.",
+ status: {
+ installed: true,
+ version: "v1.0.65",
+ manifest_version: "v1.0.65",
+ latest_available_version: "v1.0.65",
+ runtime_version_mismatch: false,
+ app_configured: true,
+ app_id: "cli_mock",
+ app_brand: "feishu",
+ skills_expected: 27,
+ skills_installed: 1,
+ installed_skills: ["lark-doc"],
+ enabled_skills: ["lark-doc"],
+ install_path: "/tmp/lark-cli",
+ cli: {
+ available: true,
+ path: "/usr/bin/lark-cli",
+ version: "v1.0.65",
+ error: null,
+ },
+ auth: {
+ status: "authenticated",
+ message: "ok",
+ user: "Alice",
+ },
+ },
+ }),
+ );
+
+ await expect(
+ completeLarkAuthorization({ device_code: "device-code" }),
+ ).resolves.toMatchObject({
+ success: true,
+ status: { auth: { status: "authenticated", user: "Alice" } },
+ });
+ expect(mockedFetch).toHaveBeenCalledWith(
+ "/backend/api/integrations/lark/auth/complete",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ device_code: "device-code" }),
+ },
+ );
+ });
+});
From f090f0180662e8d387b98807b584d0a0cb47fb09 Mon Sep 17 00:00:00 2001
From: Aari
Date: Sun, 26 Jul 2026 08:19:13 +0800
Subject: [PATCH 12/35] perf(frontend): coalesce streaming renders to a frame
budget instead of per chunk (#4425)
* perf(frontend): coalesce streaming renders to a frame budget instead of per chunk
While a run streams, the merge/group/render pipeline consumed every SSE chunk
as its own React update (~60/s), re-rendering the whole thread tree per token.
Enable the SDK's same-tick batching (throttle: true) and publish the
render-facing messages snapshot at most once per 80 ms with a leading edge and
a trailing flush, keyed through a memoized merge so identities stay stable
between flushes. Lifecycle consumers (optimistic clearing, summarization
capture, usage baselines) keep reading the per-chunk array.
* perf(frontend): keep the transient bridge order array identity stable
mergeTransientHistoryBridgeOrder cloned unconditionally, so the render-time
call handed the coalesced merge memo a fresh array identity on every render
while the transient history bridge was open, re-running mergeMessages between
flushes. Clone lazily and return the input order when nothing is appended; the
merge only ever appends, so an unchanged length means unchanged content.
Consumers only read the returned order, so reusing the input is safe.
* perf(frontend): drive the render coalescer from a monotonic clock
The coalescing interval was measured with Date.now(). A backward wall-clock
step (NTP correction, sleep/wake) turns the elapsed term negative, so the
scheduled delay becomes interval + jump and the rendered snapshot stalls for
the length of the jump. Read performance.now() once per effect invocation
instead; the timer callback re-reads it because timers fire late and the next
interval must start from the real flush.
Seed the last-flush marker with -Infinity so the first update of a stream
still takes the leading edge under a page-load-relative clock.
* perf(frontend): reset the coalescer flush baseline when a stream ends
The leading-edge flush was scoped to the hook instance rather than to each
stream: a run starting within one interval of the previous one found a recent
flush baseline and deferred its first frame. Drop the baseline when leaving
the streaming state so every stream opens on the leading edge.
* perf(frontend): disarm the trailing flush when the leading edge wins
decideCoalesce checks the elapsed interval before the pending-timer flag, so
an update arriving past the interval takes the leading edge while a trailing
timer is still armed. Timers fire late under main-thread load -- exactly the
regime this coalescer targets -- so that timer then publishes a second time
and slips the flush baseline forward, breaking the at-most-one-flush-per-
interval property when it matters most.
Disarm the pending timer in the flush-now branch, and cover the previously
untested elapsed >= interval && hasPendingTimer quadrant.
* perf(frontend): stop syncing the render snapshot while idle
The snapshot is only read while streaming, so keeping it current on every
idle messages change costs one wasted render per history refetch or thread
navigation. Dropping that publish outright is not safe either: the leading
edge runs in a passive effect, but the render where isStreaming flips true
paints first and returns the snapshot, so a stale one would be painted --
after a thread switch, another thread's messages, since the chat page
deliberately avoids re-mounting on navigation.
Make the snapshot nullable, where null means no snapshot belongs to the
current stream, and return the live array while it is null. The idle branch
then writes state once per stream end instead of once per idle update, and
the stale-frame window does not exist rather than being short.
---
frontend/src/core/threads/hooks.ts | 217 ++++++++++++++++--
.../tests/e2e/artifact-stream-state.spec.ts | 78 ++++++-
.../tests/unit/core/threads/coalesce.test.ts | 97 ++++++++
.../unit/core/threads/message-merge.test.ts | 23 ++
4 files changed, 378 insertions(+), 37 deletions(-)
create mode 100644 frontend/tests/unit/core/threads/coalesce.test.ts
diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts
index efcae9588..4a9525586 100644
--- a/frontend/src/core/threads/hooks.ts
+++ b/frontend/src/core/threads/hooks.ts
@@ -140,6 +140,10 @@ export function buildThreadSubmitMessages({
];
}
+// Stable identity for "no optimistic messages" so the merged-messages memo
+// below is not invalidated by a fresh empty array on every render.
+const EMPTY_MESSAGES: Message[] = [];
+
const EMPTY_THREAD_VALUES: AgentThreadState = {
title: "",
messages: [],
@@ -650,19 +654,22 @@ export function mergeTransientHistoryBridge(
export function mergeTransientHistoryBridgeOrder(
currentOrder: readonly string[],
capturedMessages: Message[],
-): string[] {
+): readonly string[] {
const capturedOrder = dedupeMessagesByIdentity(capturedMessages)
.map(messageIdentity)
.filter(isNonEmptyString);
- const merged = [...currentOrder];
+ // Clone lazily and return the input when nothing is appended: this runs per
+ // render while the bridge is active, and a fresh array would invalidate the
+ // coalesced render memo on every chunk (#4409 Phase 1).
+ let merged: string[] | null = null;
const seen = new Set(currentOrder);
for (const identity of capturedOrder) {
if (!seen.has(identity)) {
seen.add(identity);
- merged.push(identity);
+ (merged ??= [...currentOrder]).push(identity);
}
}
- return merged;
+ return merged ?? currentOrder;
}
export function resolveThreadTransientHistoryBridge(
@@ -753,6 +760,136 @@ export function getSummarizationMiddlewareMessages(
return undefined;
}
+export const STREAM_RENDER_COALESCE_MS = 80;
+
+export type CoalesceDecision =
+ | { action: "flush-now" }
+ | { action: "schedule"; delayMs: number }
+ | { action: "wait" };
+
+/**
+ * Decide how an incoming stream update reaches the rendered snapshot: flush
+ * immediately once a full interval has elapsed (leading edge), otherwise
+ * schedule exactly one trailing flush for the interval remainder. Unlike a
+ * debounce, the delay never extends past the interval, so a dense stream can
+ * never starve rendering.
+ */
+export function decideCoalesce(
+ nowMs: number,
+ lastFlushMs: number,
+ intervalMs: number,
+ hasPendingTimer: boolean,
+): CoalesceDecision {
+ if (nowMs - lastFlushMs >= intervalMs) {
+ return { action: "flush-now" };
+ }
+ if (hasPendingTimer) {
+ return { action: "wait" };
+ }
+ return { action: "schedule", delayMs: intervalMs - (nowMs - lastFlushMs) };
+}
+
+/**
+ * While a run is streaming, expose the messages array as a snapshot that
+ * updates at most once per interval instead of once per SSE chunk, so the
+ * merge/group/render pipeline runs per frame budget rather than per token
+ * (#4409 Phase 1). When the stream is idle the latest array passes straight
+ * through, keeping non-stream updates immediate.
+ */
+function sameMessageArray(a: Message[], b: Message[]): boolean {
+ return (
+ a === b ||
+ (a.length === b.length && a.every((message, index) => message === b[index]))
+ );
+}
+
+export function useCoalescedStreamMessages(
+ messages: Message[],
+ isStreaming: boolean,
+ intervalMs: number = STREAM_RENDER_COALESCE_MS,
+): Message[] {
+ // `null` means "no snapshot belongs to the current stream": the live array is
+ // returned until the leading-edge flush lands, so a snapshot left over from an
+ // earlier stream can never be painted. This hook outlives thread switches (the
+ // chat page deliberately avoids re-mounting, see its `onStart` comment), so a
+ // retained snapshot would otherwise be another thread's messages.
+ const [snapshot, setSnapshot] = useState(null);
+ const latestRef = useRef(messages);
+ latestRef.current = messages;
+ // Monotonic clock: a wall-clock step (NTP, sleep/wake) between two reads
+ // would otherwise be added to the remaining interval and stall the flush for
+ // the length of the jump. -Infinity means "never flushed", so the first
+ // update of a stream always takes the leading edge.
+ const lastFlushRef = useRef(Number.NEGATIVE_INFINITY);
+ const timerRef = useRef | null>(null);
+
+ // Every publication goes through the shallow-equality guard: inputs whose
+ // identity churns without content change (the SDK getter mints fresh arrays)
+ // must not re-trigger renders, or this effect would setState-loop.
+ const publish = useCallback(() => {
+ setSnapshot((previous) =>
+ previous !== null && sameMessageArray(previous, latestRef.current)
+ ? previous
+ : latestRef.current,
+ );
+ }, []);
+
+ const clearPendingFlush = useCallback(() => {
+ if (timerRef.current !== null) {
+ clearTimeout(timerRef.current);
+ timerRef.current = null;
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!isStreaming) {
+ clearPendingFlush();
+ // Drop the flush baseline so the leading edge is per stream rather than
+ // per hook instance: a run starting within one interval of the previous
+ // one must not have its first frame deferred. Dropping the snapshot with
+ // it costs one render per stream end, versus one per idle message change
+ // if the snapshot were instead kept in sync while nothing reads it.
+ lastFlushRef.current = Number.NEGATIVE_INFINITY;
+ setSnapshot((previous) => (previous === null ? previous : null));
+ return;
+ }
+ const now = performance.now();
+ const decision = decideCoalesce(
+ now,
+ lastFlushRef.current,
+ intervalMs,
+ timerRef.current !== null,
+ );
+ if (decision.action === "flush-now") {
+ // A trailing timer can still be armed here: timers fire late under
+ // main-thread load, which is exactly when a chunk overtakes one. Leaving
+ // it would publish a second time and slip the next interval forward.
+ clearPendingFlush();
+ lastFlushRef.current = now;
+ publish();
+ } else if (decision.action === "schedule") {
+ timerRef.current = setTimeout(() => {
+ timerRef.current = null;
+ // Read the clock again: timers fire late under load, and the next
+ // interval must start from the real flush.
+ lastFlushRef.current = performance.now();
+ publish();
+ }, decision.delayMs);
+ }
+ }, [messages, isStreaming, intervalMs, publish, clearPendingFlush]);
+
+ useEffect(
+ () => () => {
+ if (timerRef.current !== null) {
+ clearTimeout(timerRef.current);
+ }
+ },
+ [],
+ );
+
+ return isStreaming && snapshot !== null ? snapshot : messages;
+}
+
export function upsertThreadInSearchCache(
queryClient: QueryClient,
thread: AgentThread,
@@ -1073,6 +1210,10 @@ export function useThreadStream({
threadId: onStreamThreadId,
reconnectOnMount: true,
fetchStateHistory: { limit: 1 },
+ // Coalesce same-tick stream events into one React notification. Only the
+ // boolean tier is safe: the SDK's numeric tier is a trailing debounce that
+ // starves UI updates while chunks keep arriving faster than the window.
+ throttle: true,
onCreated(meta) {
handleStreamStart(meta.thread_id, meta.run_id);
const now = new Date().toISOString();
@@ -1271,16 +1412,18 @@ export function useThreadStream({
const hasVisibleStreamState =
Boolean(threadId) || liveMessagesThreadId === currentViewThreadId;
- const persistedMessages = useMemo(
- () =>
- hasVisibleStreamState
- ? thread.messages.filter(
- (message) =>
- !message.id || !pendingSupersededMessageIds.has(message.id),
- )
- : [],
- [hasVisibleStreamState, pendingSupersededMessageIds, thread.messages],
- );
+ const persistedMessages = useMemo(() => {
+ if (!hasVisibleStreamState) {
+ return EMPTY_MESSAGES;
+ }
+ const filtered = thread.messages.filter(
+ (message) => !message.id || !pendingSupersededMessageIds.has(message.id),
+ );
+ // The SDK getter mints a fresh [] on every read while the stream has no
+ // values; normalize to a stable identity so downstream effects keyed on
+ // this array cannot re-fire (and setState-loop) on idle renders.
+ return filtered.length === 0 ? EMPTY_MESSAGES : filtered;
+ }, [hasVisibleStreamState, pendingSupersededMessageIds, thread.messages]);
const visibleHistory = useMemo(
() => (threadId ? history : []),
[history, threadId],
@@ -1298,7 +1441,7 @@ export function useThreadStream({
// Full identity order of each captured checkpoint. Confirmed bridge entries
// are pruned from the message buffer, but remain here as non-rendering
// anchors so an older rescue can be placed before a newest-first page.
- const transientHistoryOrderRef = useRef([]);
+ const transientHistoryOrderRef = useRef([]);
const transientHistoryThreadIdRef = useRef(null);
const summarizedRef = useRef>(null);
// Track human message count before sending to prevent clearing optimistic
@@ -1705,11 +1848,23 @@ export function useThreadStream({
messagesRef.current = persistedMessages;
}
- const visibleOptimisticMessages = getVisibleOptimisticMessages(
+ // Render-facing coalesced snapshot. Refs, counters and usage tracking keep
+ // consuming the per-chunk array above so lifecycle semantics (optimistic
+ // clearing, summarization capture, token-usage baselines) are unchanged.
+ const renderMessages = useCoalescedStreamMessages(
+ persistedMessages,
+ thread.isLoading,
+ );
+
+ const rawVisibleOptimisticMessages = getVisibleOptimisticMessages(
optimisticThreadId === currentViewThreadId ? optimisticMessages : [],
prevHumanMsgCountRef.current,
humanMessageCount,
);
+ const visibleOptimisticMessages =
+ rawVisibleOptimisticMessages.length === 0
+ ? EMPTY_MESSAGES
+ : rawVisibleOptimisticMessages;
const transientHistoryOrder =
transientHistoryBridgeRef.current.length > 0 &&
@@ -1735,18 +1890,30 @@ export function useThreadStream({
}
}, [persistedMessages, threadId]);
- const effectiveHistory = resolveThreadTransientHistoryBridge(
- visibleHistory,
- transientHistoryBridgeRef.current,
- transientHistoryThreadIdRef.current,
+ // The transient-bridge refs mutate in lockstep with stream/history updates
+ // already captured by these deps, and resolveTransientHistoryBridge is
+ // idempotent for entries canonical history has absorbed, so memoizing on the
+ // coalesced snapshot cannot pin a stale bridge.
+ const mergedMessages = useMemo(() => {
+ const effectiveHistory = resolveThreadTransientHistoryBridge(
+ visibleHistory,
+ transientHistoryBridgeRef.current,
+ transientHistoryThreadIdRef.current,
+ threadId,
+ transientHistoryOrder,
+ );
+ return mergeMessages(
+ effectiveHistory,
+ renderMessages,
+ visibleOptimisticMessages,
+ );
+ }, [
+ renderMessages,
threadId,
transientHistoryOrder,
- );
- const mergedMessages = mergeMessages(
- effectiveHistory,
- persistedMessages,
+ visibleHistory,
visibleOptimisticMessages,
- );
+ ]);
const pendingUsageMessages = thread.isLoading
? getMessagesAfterBaseline(
persistedMessages,
diff --git a/frontend/tests/e2e/artifact-stream-state.spec.ts b/frontend/tests/e2e/artifact-stream-state.spec.ts
index 2768bae6c..aff3f451c 100644
--- a/frontend/tests/e2e/artifact-stream-state.spec.ts
+++ b/frontend/tests/e2e/artifact-stream-state.spec.ts
@@ -17,6 +17,18 @@ const THREAD_MESSAGES = [
content: "Created a markdown report.",
},
];
+const FOLLOW_UP_MESSAGES = [
+ {
+ type: "human",
+ id: "msg-human-artifact-follow-up",
+ content: [{ type: "text", text: "Continue" }],
+ },
+ {
+ type: "ai",
+ id: "msg-ai-artifact-follow-up",
+ content: "Updated response while the artifact list is omitted.",
+ },
+];
function streamWithoutArtifacts(route: Route) {
const events = [
@@ -27,18 +39,7 @@ function streamWithoutArtifacts(route: Route) {
{
event: "values",
data: {
- messages: [
- {
- type: "human",
- id: "msg-human-artifact-follow-up",
- content: [{ type: "text", text: "Continue" }],
- },
- {
- type: "ai",
- id: "msg-ai-artifact-follow-up",
- content: "Updated response while the artifact list is omitted.",
- },
- ],
+ messages: FOLLOW_UP_MESSAGES,
},
},
];
@@ -68,7 +69,60 @@ test("keeps artifact trigger after stream values omit artifacts", async ({
],
});
+ // A real gateway persists the run's messages, so the SDK's end-of-run state
+ // refetch returns them. This spec's stream route bypasses the shared mock's
+ // thread upsert, which would otherwise leave post-run state looking like the
+ // run never happened — an impossible backend state that only a same-tick
+ // render could paper over. Serve the persisted turn once the run has started;
+ // stream values still omit artifacts, which is the invariant under test.
+ let runStarted = false;
+ await page.route("**/api/langgraph/threads/*/history", (route) => {
+ if (!runStarted) {
+ return route.fallback();
+ }
+ return route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify([
+ {
+ values: {
+ title: "Artifact stream state",
+ goal: null,
+ messages: [...THREAD_MESSAGES, ...FOLLOW_UP_MESSAGES],
+ artifacts: [ARTIFACT_PATH],
+ },
+ next: [],
+ metadata: {},
+ created_at: "2025-01-01T00:00:00Z",
+ parent_config: null,
+ },
+ ]),
+ });
+ });
+ await page.route(/\/api\/threads\/([^/]+)\/messages\/page/, (route) => {
+ if (!runStarted) {
+ return route.fallback();
+ }
+ return route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ data: [...THREAD_MESSAGES, ...FOLLOW_UP_MESSAGES].map(
+ (message, index) => ({
+ run_id: RUN_ID,
+ seq: index + 1,
+ content: message,
+ metadata: { caller: "lead_agent" },
+ created_at: `2025-01-01T00:00:${String(index).padStart(2, "0")}Z`,
+ }),
+ ),
+ has_more: false,
+ next_before_seq: null,
+ }),
+ });
+ });
await page.route("**/api/langgraph/threads/*/runs/stream", (route) => {
+ runStarted = true;
return streamWithoutArtifacts(route);
});
diff --git a/frontend/tests/unit/core/threads/coalesce.test.ts b/frontend/tests/unit/core/threads/coalesce.test.ts
new file mode 100644
index 000000000..862a1ae5d
--- /dev/null
+++ b/frontend/tests/unit/core/threads/coalesce.test.ts
@@ -0,0 +1,97 @@
+import { describe, expect, it } from "@rstest/core";
+
+import {
+ decideCoalesce,
+ STREAM_RENDER_COALESCE_MS,
+} from "@/core/threads/hooks";
+
+describe("decideCoalesce", () => {
+ it("flushes immediately once a full interval has elapsed (leading edge)", () => {
+ expect(decideCoalesce(1000, 900, 80, false)).toEqual({
+ action: "flush-now",
+ });
+ expect(decideCoalesce(1000, 920, 80, false)).toEqual({
+ action: "flush-now",
+ });
+ });
+
+ it("schedules exactly one trailing flush for the interval remainder", () => {
+ expect(decideCoalesce(1000, 950, 80, false)).toEqual({
+ action: "schedule",
+ delayMs: 30,
+ });
+ });
+
+ it("waits while a trailing flush is already pending", () => {
+ expect(decideCoalesce(1000, 950, 80, true)).toEqual({ action: "wait" });
+ });
+
+ it("still takes the leading edge when a late trailing flush is pending", () => {
+ // Timer callbacks queue behind long tasks, so an update can arrive past the
+ // interval while the trailing flush is still armed. The decision is
+ // flush-now, which obliges the call site to disarm that timer — otherwise
+ // it publishes a second time and slips the next interval forward.
+ expect(decideCoalesce(1000, 900, 80, true)).toEqual({
+ action: "flush-now",
+ });
+ });
+
+ it("never delays a flush beyond the interval, unlike a debounce", () => {
+ // Simulate a dense stream: updates every 10ms. A debounce would keep
+ // resetting its timer and never fire; here the trailing flush scheduled at
+ // the first update stays put and every later update just waits on it.
+ let lastFlush = 0;
+ let pendingUntil: number | null = null;
+ const flushes: number[] = [];
+ for (let now = 10; now <= 400; now += 10) {
+ if (pendingUntil !== null && now >= pendingUntil) {
+ flushes.push(pendingUntil);
+ lastFlush = pendingUntil;
+ pendingUntil = null;
+ }
+ const decision = decideCoalesce(
+ now,
+ lastFlush,
+ 80,
+ pendingUntil !== null,
+ );
+ if (decision.action === "flush-now") {
+ flushes.push(now);
+ lastFlush = now;
+ } else if (decision.action === "schedule") {
+ pendingUntil = now + decision.delayMs;
+ }
+ }
+ expect(flushes.length).toBeGreaterThanOrEqual(4);
+ for (let i = 1; i < flushes.length; i++) {
+ const gap = flushes[i]! - flushes[i - 1]!;
+ expect(gap).toBeGreaterThanOrEqual(80);
+ expect(gap).toBeLessThanOrEqual(90);
+ }
+ });
+
+ it("takes the leading edge when nothing has flushed yet", () => {
+ // The hook seeds lastFlush with -Infinity on a monotonic clock whose epoch
+ // is page load, so the first update of a stream must not be deferred.
+ expect(decideCoalesce(5, Number.NEGATIVE_INFINITY, 80, false)).toEqual({
+ action: "flush-now",
+ });
+ });
+
+ it("keeps the scheduled delay inside the interval", () => {
+ // Holds for every elapsed value a monotonic clock can produce, which is why
+ // the call site needs no clamp on the timeout delay.
+ for (let elapsed = 0; elapsed < 80; elapsed++) {
+ const decision = decideCoalesce(1000, 1000 - elapsed, 80, false);
+ expect(decision.action).toBe("schedule");
+ if (decision.action !== "schedule") continue;
+ expect(decision.delayMs).toBeGreaterThan(0);
+ expect(decision.delayMs).toBeLessThanOrEqual(80);
+ }
+ });
+
+ it("exports a frame-scale default interval", () => {
+ expect(STREAM_RENDER_COALESCE_MS).toBeGreaterThanOrEqual(50);
+ expect(STREAM_RENDER_COALESCE_MS).toBeLessThanOrEqual(100);
+ });
+});
diff --git a/frontend/tests/unit/core/threads/message-merge.test.ts b/frontend/tests/unit/core/threads/message-merge.test.ts
index 3162ccef7..5a1ba2457 100644
--- a/frontend/tests/unit/core/threads/message-merge.test.ts
+++ b/frontend/tests/unit/core/threads/message-merge.test.ts
@@ -911,6 +911,29 @@ test("mergeTransientHistoryBridgeOrder retains confirmed overlap as a non-render
]);
});
+test("mergeTransientHistoryBridgeOrder returns the same array when nothing is new", () => {
+ const order = mergeTransientHistoryBridgeOrder(
+ [],
+ [summarizationHuman1, summarizationAi1],
+ );
+
+ // Identity, not just equality: this runs per render while the bridge is
+ // active and feeds the coalesced render memo (#4409 Phase 1).
+ expect(mergeTransientHistoryBridgeOrder(order, [summarizationAi1])).toBe(
+ order,
+ );
+ expect(
+ mergeTransientHistoryBridgeOrder(order, [
+ summarizationHuman1,
+ summarizationAi1,
+ ]),
+ ).toBe(order);
+ expect(mergeTransientHistoryBridgeOrder(order, [])).toBe(order);
+ expect(
+ mergeTransientHistoryBridgeOrder(order, [summarizationHuman2]),
+ ).not.toBe(order);
+});
+
test("mergeTransientHistoryBridgeOrder keeps a recaptured protected prefix in place", () => {
const protectedInput = {
id: "protected-input",
From adac3e185e782a7e43801e867fefa04dcb65f376 Mon Sep 17 00:00:00 2001
From: Aari
Date: Sun, 26 Jul 2026 08:25:01 +0800
Subject: [PATCH 13/35] perf(frontend): stop re-deriving message content on
every stream chunk (#4441)
---
frontend/src/core/messages/utils.ts | 57 ++++++++++--
.../tests/unit/core/messages/utils.test.ts | 91 +++++++++++++++++++
2 files changed, 138 insertions(+), 10 deletions(-)
diff --git a/frontend/src/core/messages/utils.ts b/frontend/src/core/messages/utils.ts
index c4a6d71f3..8041b3493 100644
--- a/frontend/src/core/messages/utils.ts
+++ b/frontend/src/core/messages/utils.ts
@@ -364,7 +364,12 @@ export function extractTextFromMessage(message: Message) {
const THINK_OPEN_TAG = "";
const THINK_TAG_RE = /\s*([\s\S]*?)\s*<\/think>/g;
-function splitInlineReasoning(content: string) {
+interface InlineReasoningSplit {
+ content: string;
+ reasoning: string | null;
+}
+
+function splitInlineReasoning(content: string): InlineReasoningSplit {
const reasoningParts: string[] = [];
// First pass: strip every fully closed `... ` pair and
@@ -401,11 +406,29 @@ function splitInlineReasoning(content: string) {
};
}
+// The split is re-derived on every render: `hasContent`, `hasReasoning`,
+// `extractContentFromMessage` and `extractReasoningContentFromMessage` all run
+// over the whole message list on each stream chunk, so an unmemoized scan costs
+// O(total content) per chunk — quadratic across a long run. Cache per message
+// object, keyed by the exact content string it was derived from so a message
+// whose `content` is reassigned recomputes instead of serving a stale split.
+const inlineReasoningCache = new WeakMap<
+ object,
+ { content: string; split: InlineReasoningSplit }
+>();
+
function splitInlineReasoningFromAIMessage(message: Message) {
if (message.type !== "ai" || typeof message.content !== "string") {
return null;
}
- return splitInlineReasoning(message.content);
+ const content = message.content;
+ const cached = inlineReasoningCache.get(message);
+ if (cached?.content === content) {
+ return cached.split;
+ }
+ const split = splitInlineReasoning(content);
+ inlineReasoningCache.set(message, { content, split });
+ return split;
}
export function extractContentFromMessage(message: Message) {
@@ -454,7 +477,7 @@ export function extractReasoningContentFromMessage(message: Message) {
}
}
if (typeof message.content === "string") {
- return splitInlineReasoning(message.content).reasoning;
+ return splitInlineReasoningFromAIMessage(message)?.reasoning ?? null;
}
return null;
}
@@ -507,7 +530,9 @@ export function hasReasoning(message: Message) {
return (part as unknown as { type: "thinking" })?.type === "thinking";
}
if (typeof message.content === "string") {
- return splitInlineReasoning(message.content).reasoning !== null;
+ return (
+ (splitInlineReasoningFromAIMessage(message)?.reasoning ?? null) !== null
+ );
}
return false;
}
@@ -567,14 +592,26 @@ export function findToolCallResult(toolCallId: string, messages: Message[]) {
}
export function isHiddenFromUIMessage(message: Message) {
+ if (message.additional_kwargs?.hide_from_ui === true) {
+ return true;
+ }
+ if (
+ typeof message.name === "string" &&
+ HIDDEN_CONTROL_MESSAGE_NAMES.has(message.name)
+ ) {
+ return true;
+ }
+ // Only the human branch consults the text. Extracting it up front made every
+ // caller pay a full content scan for every AI message it was about to
+ // discard, and this predicate runs over the whole message list on each
+ // stream chunk (grouping, dedup, human-input state).
+ if (message.type !== "human") {
+ return false;
+ }
const content = extractTextFromMessage(message);
return (
- message.additional_kwargs?.hide_from_ui === true ||
- (typeof message.name === "string" &&
- HIDDEN_CONTROL_MESSAGE_NAMES.has(message.name)) ||
- (message.type === "human" &&
- content.includes("") &&
- stripUploadedFilesTag(content).length === 0)
+ content.includes("") &&
+ stripUploadedFilesTag(content).length === 0
);
}
diff --git a/frontend/tests/unit/core/messages/utils.test.ts b/frontend/tests/unit/core/messages/utils.test.ts
index 106ca524e..4c0e69c87 100644
--- a/frontend/tests/unit/core/messages/utils.test.ts
+++ b/frontend/tests/unit/core/messages/utils.test.ts
@@ -14,6 +14,7 @@ import {
hasContent,
hasReasoning,
isAssistantMessageGroupStreaming,
+ isHiddenFromUIMessage,
parseUploadedFiles,
stripInternalMarkers,
stripUploadedFilesTag,
@@ -295,6 +296,96 @@ describe("inline tag splitting", () => {
expect(extractContentFromMessage(message)).toBe("Documentation: `");
expect(extractReasoningContentFromMessage(message)).toBeNull();
});
+
+ test("re-splits when the same message object gets new content", () => {
+ // Streaming replaces `content` on the accumulating message, so a split
+ // cached against the message object must be keyed by the content it was
+ // derived from.
+ const message = aiMessage("first one");
+
+ expect(extractContentFromMessage(message)).toBe("one");
+ expect(extractReasoningContentFromMessage(message)).toBe("first");
+
+ (message as { content: string }).content = "second two";
+
+ expect(extractContentFromMessage(message)).toBe("two");
+ expect(extractReasoningContentFromMessage(message)).toBe("second");
+ expect(hasReasoning(message)).toBe(true);
+ });
+});
+
+describe("isHiddenFromUIMessage", () => {
+ function contentReadCounter(
+ message: Omit,
+ content: string,
+ ) {
+ const counter = { reads: 0 };
+ const probe = {
+ ...message,
+ get content() {
+ counter.reads++;
+ return content;
+ },
+ } as unknown as Message;
+ return { probe, counter };
+ }
+
+ test("does not read AI content to decide visibility", () => {
+ // Only the human branch consults the text, but this predicate runs over
+ // every message on every stream chunk (grouping, dedup, human-input
+ // state), so reading AI content here costs a full content scan per
+ // message per chunk.
+ const { probe, counter } = contentReadCounter(
+ { id: "ai-visible", type: "ai" } as Message,
+ "long reasoning a long streamed answer",
+ );
+
+ expect(isHiddenFromUIMessage(probe)).toBe(false);
+ expect(counter.reads).toBe(0);
+ });
+
+ test("does not read content when a control message name already hides it", () => {
+ const { probe, counter } = contentReadCounter(
+ { id: "summary-1", type: "ai", name: "summary" } as Message,
+ "compressed context",
+ );
+
+ expect(isHiddenFromUIMessage(probe)).toBe(true);
+ expect(counter.reads).toBe(0);
+ });
+
+ test("still hides a human message that is only slash skill activation", () => {
+ const message = {
+ id: "slash-activation",
+ type: "human",
+ content:
+ "\n# SKILL.md \n ",
+ } as Message;
+
+ expect(isHiddenFromUIMessage(message)).toBe(true);
+ });
+
+ test("keeps a human message that carries real text alongside the activation", () => {
+ const message = {
+ id: "slash-activation-with-task",
+ type: "human",
+ content:
+ "\n# SKILL.md \n \nreal user task",
+ } as Message;
+
+ expect(isHiddenFromUIMessage(message)).toBe(false);
+ });
+
+ test("hides any message flagged with hide_from_ui", () => {
+ const message = {
+ id: "hidden-1",
+ type: "human",
+ content: "internal reply",
+ additional_kwargs: { hide_from_ui: true },
+ } as Message;
+
+ expect(isHiddenFromUIMessage(message)).toBe(true);
+ });
});
describe("human message internal context stripping", () => {
From b5d2a504a99b692842fac53e27e3536993a337be Mon Sep 17 00:00:00 2001
From: Wu JiaCheng <134792033+patrick-andstar@users.noreply.github.com>
Date: Sun, 26 Jul 2026 08:32:04 +0800
Subject: [PATCH 14/35] perf(messages): index tool call results (#4411)
---
frontend/AGENTS.md | 2 +
.../workspace/messages/message-group.tsx | 38 +++--
.../workspace/messages/message-group.test.ts | 154 ++++++++++++++++++
3 files changed, 182 insertions(+), 12 deletions(-)
diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md
index feaf46c79..988abfcb8 100644
--- a/frontend/AGENTS.md
+++ b/frontend/AGENTS.md
@@ -85,6 +85,8 @@ Human input requests are a structured message protocol layered on normal chat hi
Tool-calling AI messages can contain user-visible text as well as `tool_calls`. `core/messages/utils.ts` keeps these turns in an `assistant:processing` group, and `components/workspace/messages/message-group.tsx` must render the visible text as a processing step instead of treating the message as only tool metadata. This preserves provider text such as error explanations or "trying another approach" notes during tool-heavy runs.
+`MessageGroup` builds its tool-result and browser-preview lookups once per processing group before converting messages to steps. The lookup preserves the first non-empty result and first screenshot-bearing browser view for each tool-call ID, matching the streamed-message display semantics without repeatedly scanning the full group for every tool call.
+
### Key Patterns
- **Server Components by default**, `"use client"` only for interactive components
diff --git a/frontend/src/components/workspace/messages/message-group.tsx b/frontend/src/components/workspace/messages/message-group.tsx
index a12096c00..82e647534 100644
--- a/frontend/src/components/workspace/messages/message-group.tsx
+++ b/frontend/src/components/workspace/messages/message-group.tsx
@@ -36,7 +36,7 @@ import type { TokenDebugStep } from "@/core/messages/usage-model";
import {
extractContentFromMessage,
extractReasoningContentFromMessage,
- findToolCallResult,
+ extractTextFromMessage,
} from "@/core/messages/utils";
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
import { env } from "@/env";
@@ -895,27 +895,41 @@ interface BrowserViewMeta {
title?: string;
}
-function findBrowserViewMeta(
- toolCallId: string,
- messages: Message[],
-): BrowserViewMeta | undefined {
+function indexToolCallData(messages: Message[]) {
+ const toolCallResults = new Map();
+ const browserViews = new Map();
+
for (const message of messages) {
- if (message.type === "tool" && message.tool_call_id === toolCallId) {
- const meta = (
+ if (message.type !== "tool" || !message.tool_call_id) {
+ continue;
+ }
+
+ const toolCallId = message.tool_call_id;
+ if (!toolCallResults.has(toolCallId)) {
+ const result = extractTextFromMessage(message);
+ if (result) {
+ toolCallResults.set(toolCallId, result);
+ }
+ }
+
+ if (!browserViews.has(toolCallId)) {
+ const browserView = (
message.additional_kwargs as
| { browser_view?: BrowserViewMeta }
| undefined
)?.browser_view;
- if (meta && typeof meta.screenshot === "string") {
- return meta;
+ if (browserView && typeof browserView.screenshot === "string") {
+ browserViews.set(toolCallId, browserView);
}
}
}
- return undefined;
+
+ return { browserViews, toolCallResults };
}
function convertToSteps(messages: Message[]): CoTStep[] {
const steps: CoTStep[] = [];
+ const { browserViews, toolCallResults } = indexToolCallData(messages);
for (const [messageIndex, message] of messages.entries()) {
if (message.type === "ai") {
const content = extractContentFromMessage(message);
@@ -950,7 +964,7 @@ function convertToSteps(messages: Message[]): CoTStep[] {
};
const toolCallId = tool_call.id;
if (toolCallId) {
- const toolCallResult = findToolCallResult(toolCallId, messages);
+ const toolCallResult = toolCallResults.get(toolCallId);
if (toolCallResult) {
try {
const json = JSON.parse(toolCallResult);
@@ -959,7 +973,7 @@ function convertToSteps(messages: Message[]): CoTStep[] {
step.result = toolCallResult;
}
}
- step.browserView = findBrowserViewMeta(toolCallId, messages);
+ step.browserView = browserViews.get(toolCallId);
}
steps.push(step);
}
diff --git a/frontend/tests/unit/components/workspace/messages/message-group.test.ts b/frontend/tests/unit/components/workspace/messages/message-group.test.ts
index ad2eec510..3971f2d82 100644
--- a/frontend/tests/unit/components/workspace/messages/message-group.test.ts
+++ b/frontend/tests/unit/components/workspace/messages/message-group.test.ts
@@ -204,6 +204,160 @@ describe("MessageGroup", () => {
expect(visibleHtml).toContain('decoding="async"');
expect(deferredHtml).not.toContain(" {
+ const html = renderGroup([
+ {
+ id: "ai-1",
+ type: "ai",
+ content: "",
+ tool_calls: [
+ {
+ id: "call-fetch",
+ name: "web_fetch",
+ args: { url: "https://example.com" },
+ },
+ {
+ id: "call-task",
+ name: "task",
+ args: { description: "Do not render this subagent call" },
+ },
+ ],
+ } as Message,
+ {
+ id: "tool-empty",
+ type: "tool",
+ name: "web_fetch",
+ tool_call_id: "call-fetch",
+ content: "",
+ } as Message,
+ {
+ id: "tool-first",
+ type: "tool",
+ name: "web_fetch",
+ tool_call_id: "call-fetch",
+ content: "# First fetched title\n\nFirst result.",
+ } as Message,
+ {
+ id: "tool-later",
+ type: "tool",
+ name: "web_fetch",
+ tool_call_id: "call-fetch",
+ content: "# Later fetched title\n\nLater result.",
+ } as Message,
+ ]);
+
+ expect(html).toContain("First fetched title");
+ expect(html).not.toContain("Later fetched title");
+ expect(html).not.toContain("Do not render this subagent call");
+ });
+
+ it("keeps the first browser view that includes a screenshot", () => {
+ const html = renderGroup(
+ [
+ {
+ id: "ai-1",
+ type: "ai",
+ content: "",
+ tool_calls: [
+ {
+ id: "call-browser",
+ name: "browser_navigate",
+ args: { url: "https://example.com" },
+ },
+ ],
+ } as Message,
+ {
+ id: "tool-without-shot",
+ type: "tool",
+ name: "browser_navigate",
+ tool_call_id: "call-browser",
+ content: "Opened without a preview.",
+ additional_kwargs: {
+ browser_view: { url: "https://example.com" },
+ },
+ } as Message,
+ {
+ id: "tool-first-shot",
+ type: "tool",
+ name: "browser_navigate",
+ tool_call_id: "call-browser",
+ content: "Opened with the first preview.",
+ additional_kwargs: {
+ browser_view: {
+ screenshot: "/mnt/user-data/outputs/first-browser.png",
+ url: "https://example.com/first",
+ },
+ },
+ } as Message,
+ {
+ id: "tool-later-shot",
+ type: "tool",
+ name: "browser_navigate",
+ tool_call_id: "call-browser",
+ content: "Opened with a later preview.",
+ additional_kwargs: {
+ browser_view: {
+ screenshot: "/mnt/user-data/outputs/later-browser.png",
+ url: "https://example.com/later",
+ },
+ },
+ } as Message,
+ ],
+ { threadId: "thread-1" },
+ );
+
+ expect(html).toContain("first-browser.png");
+ expect(html).not.toContain("later-browser.png");
+ expect(html).toContain("https://example.com/first");
+ });
+
+ it("renders the earliest JSON tool result after an empty streamed update", () => {
+ const html = renderGroup([
+ {
+ id: "ai-1",
+ type: "ai",
+ content: "",
+ tool_calls: [
+ {
+ id: "call-search",
+ name: "web_search",
+ args: { query: "DeerFlow" },
+ },
+ ],
+ } as Message,
+ {
+ id: "tool-empty",
+ type: "tool",
+ name: "web_search",
+ tool_call_id: "call-search",
+ content: "",
+ } as Message,
+ {
+ id: "tool-first",
+ type: "tool",
+ name: "web_search",
+ tool_call_id: "call-search",
+ content: JSON.stringify([
+ { title: "First source", url: "https://first.example" },
+ ]),
+ } as Message,
+ {
+ id: "tool-later",
+ type: "tool",
+ name: "web_search",
+ tool_call_id: "call-search",
+ content: JSON.stringify([
+ { title: "Later source", url: "https://later.example" },
+ ]),
+ } as Message,
+ ]);
+
+ expect(html).toContain("First source");
+ expect(html).toContain("https://first.example");
+ expect(html).not.toContain("Later source");
+ expect(html).not.toContain("https://later.example");
+ });
});
function renderGroup(
From 6e6c078595e24579a6523a42b1cf4014245a92cf Mon Sep 17 00:00:00 2001
From: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
Date: Sun, 26 Jul 2026 09:02:40 +0800
Subject: [PATCH 15/35] fix(sandbox): unwrap Overwrite-wrapped sandbox state in
after_agent (#4381)
* fix(sandbox): unwrap Overwrite-wrapped sandbox state in after_agent
Fork-restored checkpoints can deliver the sandbox channel still wrapped
in langgraph.types.Overwrite: the rollback restore applies replace-style
writes through a state-mutation graph in delta checkpoint mode, and
after_agent/aafter_agent then crash subscripting the wrapper
("TypeError: 'Overwrite' object is not subscriptable") on the next
sandbox tool run in the forked conversation. Unwrap before reading the
sandbox id, and pin both hooks against an Overwrite-wrapped state.
Refs #4380 (bug 1 of 2; the history-loss half is a separate display path)
Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
* fix(sandbox): don't release fork-restored parent sandboxes
The Overwrite-wrapped value replays the parent thread's sandbox state, so releasing it from the forked run would evict the parent's warm sandbox. _unwrap_sandbox now reports the wrapped form, and both after_agent hooks skip the release for it while keeping the normal path unchanged.
Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
---------
Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
Co-authored-by: Willem Jiang
---
.../harness/deerflow/sandbox/middleware.py | 34 ++++++++++-
backend/tests/test_sandbox_middleware.py | 58 ++++++++++++++++++-
2 files changed, 88 insertions(+), 4 deletions(-)
diff --git a/backend/packages/harness/deerflow/sandbox/middleware.py b/backend/packages/harness/deerflow/sandbox/middleware.py
index a6ebbf7ea..9937bcdb6 100644
--- a/backend/packages/harness/deerflow/sandbox/middleware.py
+++ b/backend/packages/harness/deerflow/sandbox/middleware.py
@@ -9,7 +9,7 @@ from langchain.agents.middleware import AgentMiddleware
from langchain_core.messages import ToolMessage
from langgraph.prebuilt.tool_node import ToolCallRequest
from langgraph.runtime import Runtime
-from langgraph.types import Command
+from langgraph.types import Command, Overwrite
from deerflow.agents.thread_state import SandboxStateField, ThreadDataState
from deerflow.runtime.user_context import resolve_runtime_user_id
@@ -25,6 +25,24 @@ class SandboxMiddlewareState(AgentState):
thread_data: NotRequired[ThreadDataState | None]
+def _unwrap_sandbox(sandbox: object) -> tuple[object, bool]:
+ """Unwrap an ``Overwrite``-wrapped sandbox channel value, if present.
+
+ Fork-restored checkpoints can deliver the sandbox channel still wrapped in
+ ``langgraph.types.Overwrite`` (the rollback restore applies replace-style
+ writes through a state-mutation graph in delta checkpoint mode). Reading
+ ``sandbox["sandbox_id"]`` on the wrapper itself crashes with ``TypeError:
+ 'Overwrite' object is not subscriptable``, so unwrap before use.
+
+ Returns ``(value, fork_restored)``. The wrapped form replays the parent
+ thread's sandbox state, so callers must not treat the sandbox as owned by
+ this run (e.g. release it).
+ """
+ if isinstance(sandbox, Overwrite):
+ return sandbox.value, True
+ return sandbox, False
+
+
class SandboxMiddleware(AgentMiddleware[SandboxMiddlewareState]):
"""Create a sandbox environment and assign it to an agent.
@@ -99,9 +117,14 @@ class SandboxMiddleware(AgentMiddleware[SandboxMiddlewareState]):
@override
def after_agent(self, state: SandboxMiddlewareState, runtime: Runtime) -> dict | None:
- sandbox = state.get("sandbox")
+ sandbox, fork_restored = _unwrap_sandbox(state.get("sandbox"))
if sandbox is not None:
sandbox_id = sandbox["sandbox_id"]
+ if fork_restored:
+ # The wrapped value replays the parent thread's sandbox state;
+ # releasing it here would evict the parent's warm sandbox.
+ logger.info(f"Not releasing fork-restored sandbox {sandbox_id}")
+ return None
logger.info(f"Releasing sandbox {sandbox_id}")
get_sandbox_provider().release(sandbox_id)
return None
@@ -117,9 +140,14 @@ class SandboxMiddleware(AgentMiddleware[SandboxMiddlewareState]):
@override
async def aafter_agent(self, state: SandboxMiddlewareState, runtime: Runtime) -> dict | None:
- sandbox = state.get("sandbox")
+ sandbox, fork_restored = _unwrap_sandbox(state.get("sandbox"))
if sandbox is not None:
sandbox_id = sandbox["sandbox_id"]
+ if fork_restored:
+ # The wrapped value replays the parent thread's sandbox state;
+ # releasing it here would evict the parent's warm sandbox.
+ logger.info(f"Not releasing fork-restored sandbox {sandbox_id}")
+ return None
logger.info(f"Releasing sandbox {sandbox_id}")
await self._release_sandbox_async(sandbox_id)
return None
diff --git a/backend/tests/test_sandbox_middleware.py b/backend/tests/test_sandbox_middleware.py
index 53f61a080..ad8818add 100644
--- a/backend/tests/test_sandbox_middleware.py
+++ b/backend/tests/test_sandbox_middleware.py
@@ -9,7 +9,7 @@ from langchain.tools import ToolRuntime
from langchain_core.messages import ToolMessage
from langgraph.prebuilt.tool_node import ToolCallRequest
from langgraph.runtime import Runtime
-from langgraph.types import Command
+from langgraph.types import Command, Overwrite
from deerflow.agents.thread_state import ThreadState
from deerflow.sandbox.middleware import SandboxMiddleware, SandboxMiddlewareState
@@ -252,6 +252,62 @@ async def test_aafter_agent_delegates_to_super_when_no_sandbox(monkeypatch: pyte
assert calls == [(state, runtime)]
+def test_after_agent_unwraps_overwrite_sandbox_state() -> None:
+ """Fork-restored state may carry the sandbox channel Overwrite-wrapped."""
+ provider = _AsyncOnlyProvider()
+ set_sandbox_provider(provider)
+ try:
+ state = {"sandbox": Overwrite({"sandbox_id": "fork-restored"})}
+ result = SandboxMiddleware().after_agent(state, Runtime(context={}))
+ finally:
+ reset_sandbox_provider()
+
+ assert result is None
+ # The wrapped value replays the parent's sandbox; this run must not release it.
+ assert provider.released_ids == []
+
+
+def test_after_agent_releases_own_sandbox_state() -> None:
+ provider = _AsyncOnlyProvider()
+ set_sandbox_provider(provider)
+ try:
+ state = {"sandbox": {"sandbox_id": "own-sandbox"}}
+ result = SandboxMiddleware().after_agent(state, Runtime(context={}))
+ finally:
+ reset_sandbox_provider()
+
+ assert result is None
+ assert provider.released_ids == ["own-sandbox"]
+
+
+@pytest.mark.anyio
+async def test_aafter_agent_unwraps_overwrite_sandbox_state() -> None:
+ provider = _AsyncOnlyProvider()
+ set_sandbox_provider(provider)
+ try:
+ state = {"sandbox": Overwrite({"sandbox_id": "fork-restored"})}
+ result = await SandboxMiddleware().aafter_agent(state, Runtime(context={}))
+ finally:
+ reset_sandbox_provider()
+
+ assert result is None
+ assert provider.released_ids == []
+
+
+@pytest.mark.anyio
+async def test_aafter_agent_releases_own_sandbox_state() -> None:
+ provider = _AsyncOnlyProvider()
+ set_sandbox_provider(provider)
+ try:
+ state = {"sandbox": {"sandbox_id": "own-sandbox"}}
+ result = await SandboxMiddleware().aafter_agent(state, Runtime(context={}))
+ finally:
+ reset_sandbox_provider()
+
+ assert result is None
+ assert provider.released_ids == ["own-sandbox"]
+
+
# ---------------------------------------------------------------------------
# wrap_tool_call / awrap_tool_call: persistent sandbox state via Command
# ---------------------------------------------------------------------------
From 5d073991c28eedf0bda25aceac4ef7ef319c3ce3 Mon Sep 17 00:00:00 2001
From: Daoyuan Li <94409450+DaoyuanLi2816@users.noreply.github.com>
Date: Sat, 25 Jul 2026 18:47:57 -0700
Subject: [PATCH 16/35] fix(sandbox): widen boxlite/aio_sandbox tenant hash and
verify identity on reclaim (#4171)
* fix(sandbox): prevent truncated tenant ID reuse
* fix(sandbox): handle late same-tenant box registration
---
.../aio_sandbox/aio_sandbox_provider.py | 142 ++++++++++++++--
.../deerflow/community/boxlite/provider.py | 131 ++++++++++++++-
.../tests/blocking_io/test_aio_sandbox_get.py | 2 +
.../tests/blocking_io/test_sandbox_release.py | 2 +
backend/tests/test_aio_sandbox_provider.py | 107 ++++++++++++
backend/tests/test_boxlite_provider.py | 159 +++++++++++++++++-
.../test_sandbox_orphan_reconciliation.py | 2 +
7 files changed, 515 insertions(+), 30 deletions(-)
diff --git a/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py b/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py
index f3f7df088..39f75d212 100644
--- a/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py
+++ b/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py
@@ -87,6 +87,19 @@ class SandboxBeingDestroyedError(RuntimeError):
self.sandbox_id = sandbox_id
+class SandboxIdentityCollisionError(RuntimeError):
+ """A deterministic ID is already tracked for a different user/thread."""
+
+ def __init__(
+ self,
+ sandbox_id: str,
+ stored_key: tuple[str, str] | None,
+ requested_key: tuple[str, str],
+ ) -> None:
+ super().__init__(f"sandbox ID collision for {sandbox_id}: tracked identity is {stored_key!r}, requested identity is {requested_key!r}")
+ self.sandbox_id = sandbox_id
+
+
def _lock_file_exclusive(lock_file) -> None:
if fcntl is not None:
fcntl.flock(lock_file, fcntl.LOCK_EX)
@@ -183,6 +196,8 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
# Containers here can be reclaimed quickly (no cold-start) or destroyed
# when replicas capacity is exhausted.
self._warm_pool: dict[str, tuple[SandboxInfo, float]] = {}
+ self._active_sandbox_identity: dict[str, tuple[str, str] | None] = {}
+ self._warm_pool_identity: dict[str, tuple[str, str] | None] = {}
# sandbox_id -> when reconciliation first saw it running with no lease.
# Gates adoption behind a recovery grace (see _adoptable_after_grace).
self._unowned_since: dict[str, float] = {}
@@ -700,6 +715,7 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
logger.debug("Deferring container %s during reconciliation: this instance is tearing it down", info.sandbox_id)
continue
self._warm_pool[info.sandbox_id] = (info, current_time)
+ self._warm_pool_identity[info.sandbox_id] = None
self._unowned_since.pop(info.sandbox_id, None)
adopted += 1
logger.info(f"Adopted container {info.sandbox_id} into warm pool (age: {age:.0f}s)")
@@ -728,8 +744,42 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
Includes user_id so a previously-created default-bucket sandbox cannot be
reused for an auth/channel run that should mount a user-scoped bucket.
+
+ During a mixed-version rollout, older 8-character containers are not
+ reused under the new 16-character identity. They remain eligible for
+ normal orphan cleanup while the first new-version acquire cold-starts.
"""
- return hashlib.sha256(f"{user_id}:{thread_id}".encode()).hexdigest()[:8]
+ return hashlib.sha256(f"{user_id}:{thread_id}".encode()).hexdigest()[:16]
+
+ def _assert_active_identity_available_locked(
+ self,
+ sandbox_id: str,
+ requested_key: tuple[str, str],
+ ) -> None:
+ """Fail closed if an active truncated ID belongs to another identity."""
+ if sandbox_id not in self._sandboxes and sandbox_id not in self._sandbox_infos:
+ return
+
+ stored_key = self._active_sandbox_identity.get(sandbox_id)
+ if stored_key is None:
+ matching_keys = [key for key, mapped_id in self._thread_sandboxes.items() if mapped_id == sandbox_id]
+ if len(matching_keys) == 1:
+ stored_key = matching_keys[0]
+ if stored_key != requested_key:
+ raise SandboxIdentityCollisionError(sandbox_id, stored_key, requested_key)
+
+ def _assert_warm_identity_available_locked(
+ self,
+ sandbox_id: str,
+ requested_key: tuple[str, str],
+ ) -> None:
+ """Fail closed if a warm ID changed tenants during an acquire."""
+ if sandbox_id not in self._warm_pool:
+ return
+ # Startup-adopted entries have unknown identity until their first reclaim.
+ stored_key = self._warm_pool_identity.get(sandbox_id)
+ if stored_key is not None and stored_key != requested_key:
+ raise SandboxIdentityCollisionError(sandbox_id, stored_key, requested_key)
# ── Mount helpers ────────────────────────────────────────────────────
@@ -1069,8 +1119,10 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
sandbox = self._sandboxes.pop(sandbox_id, None)
self._sandbox_infos.pop(sandbox_id, None)
+ self._active_sandbox_identity.pop(sandbox_id, None)
self._last_activity.pop(sandbox_id, None)
self._warm_pool.pop(sandbox_id, None)
+ self._warm_pool_identity.pop(sandbox_id, None)
self._acquire_epoch.pop(sandbox_id, None)
for key, mapped_id in list(self._thread_sandboxes.items()):
if mapped_id == sandbox_id:
@@ -1310,6 +1362,7 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
with self._lock:
if sandbox_id not in self._warm_pool:
return None
+ self._assert_warm_identity_available_locked(sandbox_id, key)
if self._being_torn_down_locally(sandbox_id):
# The entry deliberately stays in `_warm_pool` for the whole stop
# (so a refused claim does not lose the container), so pool
@@ -1350,13 +1403,16 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
# that is already stopped.
logger.info("Warm-pool sandbox %s was claimed for teardown while publishing ownership; not reclaiming it", sandbox_id)
return None
+ self._assert_warm_identity_available_locked(sandbox_id, key)
warm_item = self._warm_pool.pop(sandbox_id, None)
if warm_item is None:
return None
+ self._warm_pool_identity.pop(sandbox_id, None)
info, _ = warm_item
sandbox = AioSandbox(id=sandbox_id, base_url=info.sandbox_url)
self._sandboxes[sandbox_id] = sandbox
self._sandbox_infos[sandbox_id] = info
+ self._active_sandbox_identity[sandbox_id] = key
self._last_activity[sandbox_id] = time.time()
self._thread_sandboxes[key] = sandbox_id
@@ -1385,6 +1441,7 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
prevent. The window is a peer's in-flight container stop, so the
thread's next turn discovers nothing and cold-starts cleanly.
"""
+ key = self._thread_key(thread_id, user_id)
with self._lock:
if self._being_torn_down_locally(info.sandbox_id):
# Discovery is the fall-through once the caches miss, so it is
@@ -1392,9 +1449,10 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
# only refuse this once the reaper's `del:` claim has landed;
# until then it succeeds against our own lease.
raise SandboxBeingDestroyedError(info.sandbox_id)
+ self._assert_active_identity_available_locked(info.sandbox_id, key)
+ self._assert_warm_identity_available_locked(info.sandbox_id, key)
sandbox = AioSandbox(id=info.sandbox_id, base_url=info.sandbox_url)
- key = self._thread_key(thread_id, user_id)
# Ownership first, so a failure cannot leave a tracked-but-unowned sandbox.
# There is no container to roll back (we did not create it), but the
# host-side HTTP client constructed above is ours and must not leak —
@@ -1408,6 +1466,8 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
# trip. Do not install a client for a container that reaper
# has already committed to stopping.
raise SandboxBeingDestroyedError(info.sandbox_id)
+ self._assert_active_identity_available_locked(info.sandbox_id, key)
+ self._assert_warm_identity_available_locked(info.sandbox_id, key)
# Active and warm are exclusive states, and only this insert can
# violate that: a warm entry for the same id is stale the moment
# the id becomes active. Leaving it there gives the container two
@@ -1416,11 +1476,17 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
# agent is actively using while `_sandboxes` still hands out its
# client.
self._warm_pool.pop(info.sandbox_id, None)
+ self._warm_pool_identity.pop(info.sandbox_id, None)
self._sandboxes[info.sandbox_id] = sandbox
self._sandbox_infos[info.sandbox_id] = info
+ self._active_sandbox_identity[info.sandbox_id] = key
self._last_activity[info.sandbox_id] = time.time()
self._thread_sandboxes[key] = info.sandbox_id
- except (OwnershipBackendError, SandboxBeingDestroyedError):
+ except (
+ OwnershipBackendError,
+ SandboxBeingDestroyedError,
+ SandboxIdentityCollisionError,
+ ):
try:
sandbox.close()
except Exception as e:
@@ -1433,6 +1499,14 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
def _register_created_sandbox(self, thread_id: str | None, sandbox_id: str, info: SandboxInfo, *, user_id: str | None = None) -> str:
"""Track a newly-created sandbox in the active maps."""
sandbox = AioSandbox(id=sandbox_id, base_url=info.sandbox_url)
+ key = (
+ self._thread_key(
+ thread_id,
+ self._effective_acquire_user_id(user_id),
+ )
+ if thread_id
+ else None
+ )
# Ownership first. Unlike the discover path there IS something to roll
# back: we just started this container, and an unowned running container
# is exactly what a peer's reconciliation adopts. Leaking it would hand a
@@ -1441,9 +1515,34 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
# mid-stop leaves a teardown marker until its TTL lapses. Roll back on
# both, or the container we just started is leaked.
try:
+ if key is not None:
+ with self._lock:
+ self._assert_active_identity_available_locked(sandbox_id, key)
+ self._assert_warm_identity_available_locked(sandbox_id, key)
self._publish_ownership(sandbox_id)
- except (OwnershipBackendError, SandboxBeingDestroyedError):
- logger.error("Could not publish ownership for new sandbox %s; destroying it rather than leaking an unowned container", sandbox_id)
+
+ with self._lock:
+ if key is not None:
+ self._assert_active_identity_available_locked(sandbox_id, key)
+ self._assert_warm_identity_available_locked(sandbox_id, key)
+ # Same exclusivity rule as the discover path.
+ self._warm_pool.pop(sandbox_id, None)
+ self._warm_pool_identity.pop(sandbox_id, None)
+ self._sandboxes[sandbox_id] = sandbox
+ self._sandbox_infos[sandbox_id] = info
+ self._active_sandbox_identity[sandbox_id] = key
+ self._last_activity[sandbox_id] = time.time()
+ if key is not None:
+ self._thread_sandboxes[key] = sandbox_id
+ except (
+ OwnershipBackendError,
+ SandboxBeingDestroyedError,
+ SandboxIdentityCollisionError,
+ ):
+ logger.error(
+ "Could not register new sandbox %s; destroying it rather than leaking an untracked container",
+ sandbox_id,
+ )
try:
sandbox.close()
except Exception as e:
@@ -1451,18 +1550,13 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
try:
self._backend.destroy(info)
except Exception as e:
- logger.error("Failed to destroy unowned sandbox %s after ownership failure: %s", sandbox_id, e)
+ logger.error(
+ "Failed to destroy sandbox %s after registration failure: %s",
+ sandbox_id,
+ e,
+ )
raise
- with self._lock:
- # Same exclusivity rule as the discover path.
- self._warm_pool.pop(sandbox_id, None)
- self._sandboxes[sandbox_id] = sandbox
- self._sandbox_infos[sandbox_id] = info
- self._last_activity[sandbox_id] = time.time()
- if thread_id:
- self._thread_sandboxes[self._thread_key(thread_id, self._effective_acquire_user_id(user_id))] = sandbox_id
-
logger.info(f"Created sandbox {sandbox_id} for thread {thread_id} at {info.sandbox_url}")
return sandbox_id
@@ -1498,6 +1592,7 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
sandbox = self._sandboxes.pop(sandbox_id, None)
info = self._sandbox_infos.pop(sandbox_id, None)
+ self._active_sandbox_identity.pop(sandbox_id, None)
thread_keys_to_remove = [key for key, sid in self._thread_sandboxes.items() if sid == sandbox_id]
for key in thread_keys_to_remove:
del self._thread_sandboxes[key]
@@ -1507,6 +1602,7 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
info, _ = self._warm_pool.pop(sandbox_id)
else:
self._warm_pool.pop(sandbox_id, None)
+ self._warm_pool_identity.pop(sandbox_id, None)
return sandbox, info, True
@@ -1623,7 +1719,10 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
# The pop stays deferred relative to the *stop* (a refused or failed
# stop keeps the entry), just no longer relative to the reservation.
with self._lock:
- self._warm_pool.pop(sandbox_id, None)
+ current = self._warm_pool.get(sandbox_id)
+ if current is not None and current[0] is entry:
+ self._warm_pool.pop(sandbox_id, None)
+ self._warm_pool_identity.pop(sandbox_id, None)
finally:
self._finish_local_teardown(sandbox_id)
@@ -1692,6 +1791,10 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
# Deterministic ID for thread-specific, random for anonymous
sandbox_id = self._sandbox_id_for_thread(thread_id, user_id)
+ if thread_id:
+ key = self._thread_key(thread_id, user_id)
+ with self._lock:
+ self._assert_active_identity_available_locked(sandbox_id, key)
# ── Layer 1.5: Warm pool (container still running, no cold-start) ──
reclaimed_id = self._reclaim_warm_pool_sandbox(thread_id, sandbox_id, user_id=user_id)
@@ -1715,6 +1818,10 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
# Deterministic ID for thread-specific, random for anonymous
sandbox_id = self._sandbox_id_for_thread(thread_id, user_id)
+ if thread_id:
+ key = self._thread_key(thread_id, user_id)
+ with self._lock:
+ self._assert_active_identity_available_locked(sandbox_id, key)
# ── Layer 1.5: Warm pool (container still running, no cold-start) ──
reclaimed_id = await asyncio.to_thread(self._reclaim_warm_pool_sandbox, thread_id, sandbox_id, user_id=user_id)
@@ -1909,10 +2016,12 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
thread_keys_to_remove = [key for key, sid in self._thread_sandboxes.items() if sid == sandbox_id]
for key in thread_keys_to_remove:
del self._thread_sandboxes[key]
+ active_identity = self._active_sandbox_identity.pop(sandbox_id, None)
self._last_activity.pop(sandbox_id, None)
# Park in warm pool — container keeps running
if info and sandbox_id not in self._warm_pool:
self._warm_pool[sandbox_id] = (info, time.time())
+ self._warm_pool_identity[sandbox_id] = thread_keys_to_remove[0] if thread_keys_to_remove else active_identity
if sandbox is not None:
# Defense-in-depth: close() already swallows its own errors; this
@@ -2015,6 +2124,7 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
sandbox_ids = list(self._sandboxes.keys())
warm_items = list(self._warm_pool.items())
self._warm_pool.clear()
+ self._warm_pool_identity.clear()
self._stop_idle_checker()
# Stop renewing before destroying: the destroy paths claim ownership
diff --git a/backend/packages/harness/deerflow/community/boxlite/provider.py b/backend/packages/harness/deerflow/community/boxlite/provider.py
index fa0981be4..e49fecdb1 100644
--- a/backend/packages/harness/deerflow/community/boxlite/provider.py
+++ b/backend/packages/harness/deerflow/community/boxlite/provider.py
@@ -41,6 +41,7 @@ T = TypeVar("T")
DEFAULT_IMAGE = "python:3.12-slim"
_BOX_NAME_PREFIX = "deer-flow-boxlite-"
+_NO_ACTIVE_IDENTITY = object()
# DeerFlow's virtual prefixes, materialised on the box rootfs at start so the
# Sandbox file APIs (which address /mnt/user-data/...) resolve natively.
_VIRTUAL_DIRS = (
@@ -51,6 +52,18 @@ _VIRTUAL_DIRS = (
)
+class SandboxIdentityCollisionError(RuntimeError):
+ """A deterministic ID is already tracked for a different user/thread."""
+
+ def __init__(
+ self,
+ sandbox_id: str,
+ stored_key: tuple[str, str] | None,
+ requested_key: tuple[str, str],
+ ) -> None:
+ super().__init__(f"Sandbox ID collision for {sandbox_id}: active box belongs to {stored_key!r}, not {requested_key!r}")
+
+
def _import_simplebox() -> type[SimpleBox]:
"""Import BoxLite's async ``SimpleBox`` lazily.
@@ -166,8 +179,13 @@ class BoxliteProvider(WarmPoolLifecycleMixin[BoxliteBox], SandboxProvider):
Includes user_id so a box created for one user's bucket cannot be
reclaimed by another user's thread with the same thread_id.
+
+ The 8-to-16-character change intentionally causes one cold start during
+ a mixed-version rollout. Older 8-character boxes remain in the warm pool
+ until the normal orphan cleanup removes them; they are never reused
+ under a new 16-character identity.
"""
- return hashlib.sha256(f"{user_id}:{thread_id}".encode()).hexdigest()[:8]
+ return hashlib.sha256(f"{user_id}:{thread_id}".encode()).hexdigest()[:16]
# ── Provider ────────────────────────────────────────────────────────
@@ -176,6 +194,8 @@ class BoxliteProvider(WarmPoolLifecycleMixin[BoxliteBox], SandboxProvider):
self._boxes: dict[str, BoxliteBox] = {}
self._thread_boxes: dict[tuple[str, str], str] = {}
self._warm_pool: dict[str, tuple[BoxliteBox, float]] = {}
+ self._active_box_identity: dict[str, tuple[str, str] | None] = {}
+ self._warm_pool_identity: dict[str, tuple[str, str] | None] = {}
self._skip_health_check_warm_ids: set[str] = set()
self._acquire_locks: dict[str, threading.Lock] = {}
self._idle_checker_stop = threading.Event()
@@ -245,7 +265,9 @@ class BoxliteProvider(WarmPoolLifecycleMixin[BoxliteBox], SandboxProvider):
def _destroy_warm_entry(self, sandbox_id: str, entry: BoxliteBox, *, reason: str) -> None:
"""Close a removed warm-pool entry and log with context."""
with self._lock:
- self._skip_health_check_warm_ids.discard(sandbox_id)
+ if sandbox_id not in self._warm_pool:
+ self._skip_health_check_warm_ids.discard(sandbox_id)
+ self._warm_pool_identity.pop(sandbox_id, None)
try:
entry.close()
if reason == "idle_timeout":
@@ -268,6 +290,8 @@ class BoxliteProvider(WarmPoolLifecycleMixin[BoxliteBox], SandboxProvider):
with self._lock:
active_box = self._boxes.pop(sandbox_id, None)
warm_entry = self._warm_pool.pop(sandbox_id, None)
+ self._active_box_identity.pop(sandbox_id, None)
+ self._warm_pool_identity.pop(sandbox_id, None)
self._skip_health_check_warm_ids.discard(sandbox_id)
for key in [k for k, sid in self._thread_boxes.items() if sid == sandbox_id]:
self._thread_boxes.pop(key, None)
@@ -335,6 +359,7 @@ class BoxliteProvider(WarmPoolLifecycleMixin[BoxliteBox], SandboxProvider):
box_runtime.stop()
continue
self._warm_pool[sandbox_id] = (wrapped, now)
+ self._warm_pool_identity[sandbox_id] = None
adopted += 1
logger.info("Adopted existing BoxLite box %s (%s) into warm pool", sandbox_id, name)
@@ -348,6 +373,7 @@ class BoxliteProvider(WarmPoolLifecycleMixin[BoxliteBox], SandboxProvider):
box = self._create_box(sandbox_id)
with self._lock:
self._boxes[box.id] = box
+ self._active_box_identity[box.id] = None
return box.id
key = self._thread_key(thread_id, user_id)
@@ -358,17 +384,42 @@ class BoxliteProvider(WarmPoolLifecycleMixin[BoxliteBox], SandboxProvider):
existing = self._thread_boxes.get(key)
if existing is not None and existing in self._boxes:
return existing
+ if sandbox_id in self._boxes:
+ owner = self._active_box_identity.get(sandbox_id)
+ if owner != key:
+ raise SandboxIdentityCollisionError(
+ sandbox_id,
+ owner,
+ key,
+ )
+ self._thread_boxes[key] = sandbox_id
+ return sandbox_id
- reclaimed = self._reclaim_warm_pool(sandbox_id)
+ reclaimed = self._reclaim_warm_pool(sandbox_id, key)
if reclaimed is not None:
with self._lock:
self._thread_boxes[key] = reclaimed
return reclaimed
box = self._create_box(sandbox_id)
+ conflict: tuple[str, str] | None | object = _NO_ACTIVE_IDENTITY
with self._lock:
- self._boxes[box.id] = box
- self._thread_boxes[key] = box.id
+ if box.id in self._boxes:
+ conflict = self._active_box_identity.get(box.id)
+ if conflict == key:
+ self._thread_boxes[key] = box.id
+ else:
+ self._boxes[box.id] = box
+ self._active_box_identity[box.id] = key
+ self._thread_boxes[key] = box.id
+ if conflict is not _NO_ACTIVE_IDENTITY:
+ box.close()
+ if conflict != key:
+ raise SandboxIdentityCollisionError(
+ sandbox_id,
+ conflict,
+ key,
+ )
return box.id
def _create_box(self, sandbox_id: str) -> BoxliteBox:
@@ -410,15 +461,19 @@ class BoxliteProvider(WarmPoolLifecycleMixin[BoxliteBox], SandboxProvider):
close_box: BoxliteBox | None = None
with self._lock:
box = self._boxes.pop(sandbox_id, None)
- for key in [k for k, sid in self._thread_boxes.items() if sid == sandbox_id]:
+ released_keys = [k for k, sid in self._thread_boxes.items() if sid == sandbox_id]
+ for key in released_keys:
self._thread_boxes.pop(key, None)
+ active_identity = self._active_box_identity.pop(sandbox_id, None)
if box is None:
return
if self._shutdown_called:
close_box = box
self._skip_health_check_warm_ids.discard(sandbox_id)
+ self._warm_pool_identity.pop(sandbox_id, None)
else:
self._warm_pool[sandbox_id] = (box, time.time())
+ self._warm_pool_identity[sandbox_id] = released_keys[0] if released_keys else active_identity
self._skip_health_check_warm_ids.add(sandbox_id)
if close_box is not None:
@@ -427,7 +482,7 @@ class BoxliteProvider(WarmPoolLifecycleMixin[BoxliteBox], SandboxProvider):
else:
logger.info("Released sandbox %s to warm pool (VM still running)", sandbox_id)
- def _reclaim_warm_pool(self, sandbox_id: str) -> str | None:
+ def _reclaim_warm_pool(self, sandbox_id: str, expected_key: tuple[str, str]) -> str | None:
"""Try to reclaim a warm-pool box by sandbox_id.
Returns sandbox_id on success, None if not found or dead.
@@ -440,6 +495,14 @@ class BoxliteProvider(WarmPoolLifecycleMixin[BoxliteBox], SandboxProvider):
with self._lock:
if sandbox_id not in self._warm_pool:
return None
+ # Startup-adopted entries have unknown identity until their first reclaim.
+ stored_key = self._warm_pool_identity.get(sandbox_id)
+ if stored_key is not None and stored_key != expected_key:
+ raise SandboxIdentityCollisionError(
+ sandbox_id,
+ stored_key,
+ expected_key,
+ )
box, released_at = self._warm_pool[sandbox_id]
skip_eligible = sandbox_id in self._skip_health_check_warm_ids
@@ -449,10 +512,21 @@ class BoxliteProvider(WarmPoolLifecycleMixin[BoxliteBox], SandboxProvider):
# health-check round trip, but never return an adapter that this
# process already knows is closed.
with self._lock:
+ current = self._warm_pool.get(sandbox_id)
+ stored_key = self._warm_pool_identity.get(sandbox_id)
+ if stored_key is not None and stored_key != expected_key:
+ raise SandboxIdentityCollisionError(
+ sandbox_id,
+ stored_key,
+ expected_key,
+ )
+ if current is None or current[0] is not box:
+ return None
warm_entry = self._warm_pool.pop(sandbox_id, None)
if warm_entry is None:
return None # Raced with another thread
self._skip_health_check_warm_ids.discard(sandbox_id)
+ self._warm_pool_identity.pop(sandbox_id, None)
box, _ = warm_entry
if box.is_closed:
logger.warning("Warm-pool box %s was closed before skipped health check reclaim", sandbox_id)
@@ -460,6 +534,7 @@ class BoxliteProvider(WarmPoolLifecycleMixin[BoxliteBox], SandboxProvider):
else:
close_box = None
self._boxes[sandbox_id] = box
+ self._active_box_identity[sandbox_id] = expected_key
if close_box is not None:
close_box.close()
return None
@@ -476,26 +551,60 @@ class BoxliteProvider(WarmPoolLifecycleMixin[BoxliteBox], SandboxProvider):
if "ok" not in result:
logger.warning("Warm pool box %s health check failed: %s", sandbox_id, result)
with self._lock:
- warm_entry = self._warm_pool.pop(sandbox_id, None)
+ current = self._warm_pool.get(sandbox_id)
+ stored_key = self._warm_pool_identity.get(sandbox_id)
+ if current is not None and current[0] is not box and stored_key is not None and stored_key != expected_key:
+ raise SandboxIdentityCollisionError(
+ sandbox_id,
+ stored_key,
+ expected_key,
+ )
+ warm_entry = self._warm_pool.pop(sandbox_id, None) if current is not None and current[0] is box else None
+ if warm_entry is not None:
+ self._warm_pool_identity.pop(sandbox_id, None)
if warm_entry is not None:
self._destroy_warm_entry(sandbox_id, warm_entry[0], reason="health_check_failed")
return None
+ except SandboxIdentityCollisionError:
+ raise
except Exception as e:
logger.warning("Warm pool box %s health check error: %s", sandbox_id, e)
with self._lock:
- warm_entry = self._warm_pool.pop(sandbox_id, None)
+ current = self._warm_pool.get(sandbox_id)
+ stored_key = self._warm_pool_identity.get(sandbox_id)
+ if current is not None and current[0] is not box and stored_key is not None and stored_key != expected_key:
+ raise SandboxIdentityCollisionError(
+ sandbox_id,
+ stored_key,
+ expected_key,
+ )
+ warm_entry = self._warm_pool.pop(sandbox_id, None) if current is not None and current[0] is box else None
+ if warm_entry is not None:
+ self._warm_pool_identity.pop(sandbox_id, None)
if warm_entry is not None:
self._destroy_warm_entry(sandbox_id, warm_entry[0], reason="health_check_failed")
return None
# Promote from warm pool to active
with self._lock:
+ current = self._warm_pool.get(sandbox_id)
+ stored_key = self._warm_pool_identity.get(sandbox_id)
+ if stored_key is not None and stored_key != expected_key:
+ raise SandboxIdentityCollisionError(
+ sandbox_id,
+ stored_key,
+ expected_key,
+ )
+ if current is None or current[0] is not box:
+ return None
warm_entry = self._warm_pool.pop(sandbox_id, None)
if warm_entry is None:
return None # Raced with another thread
self._skip_health_check_warm_ids.discard(sandbox_id)
+ self._warm_pool_identity.pop(sandbox_id, None)
box, _ = warm_entry
self._boxes[sandbox_id] = box
+ self._active_box_identity[sandbox_id] = expected_key
logger.info("Reclaimed warm-pool box %s", sandbox_id)
return sandbox_id
@@ -513,8 +622,10 @@ class BoxliteProvider(WarmPoolLifecycleMixin[BoxliteBox], SandboxProvider):
now = time.time()
for sandbox_id, box in self._boxes.items():
self._warm_pool.setdefault(sandbox_id, (box, now))
+ self._warm_pool_identity.setdefault(sandbox_id, self._active_box_identity.get(sandbox_id))
self._skip_health_check_warm_ids.discard(sandbox_id)
self._boxes.clear()
+ self._active_box_identity.clear()
self._thread_boxes.clear()
self._acquire_locks.clear()
@@ -531,6 +642,8 @@ class BoxliteProvider(WarmPoolLifecycleMixin[BoxliteBox], SandboxProvider):
warm = [box for box, _ in self._warm_pool.values()]
self._boxes.clear()
self._warm_pool.clear()
+ self._active_box_identity.clear()
+ self._warm_pool_identity.clear()
self._thread_boxes.clear()
self._acquire_locks.clear()
self._skip_health_check_warm_ids.clear()
diff --git a/backend/tests/blocking_io/test_aio_sandbox_get.py b/backend/tests/blocking_io/test_aio_sandbox_get.py
index 4f6e11761..5ad2f1313 100644
--- a/backend/tests/blocking_io/test_aio_sandbox_get.py
+++ b/backend/tests/blocking_io/test_aio_sandbox_get.py
@@ -82,6 +82,8 @@ def _make_provider(tmp_path: Path):
provider._thread_locks = {}
provider._last_activity = {}
provider._warm_pool = {}
+ provider._active_sandbox_identity = {}
+ provider._warm_pool_identity = {}
provider._local_teardown = set()
provider._acquire_epoch = {}
provider._acquire_epoch_counter = 0
diff --git a/backend/tests/blocking_io/test_sandbox_release.py b/backend/tests/blocking_io/test_sandbox_release.py
index 2dcd4b6b7..226c8e284 100644
--- a/backend/tests/blocking_io/test_sandbox_release.py
+++ b/backend/tests/blocking_io/test_sandbox_release.py
@@ -75,6 +75,7 @@ def _make_provider_with_active_sandbox(tmp_path: Path, sandbox_id: str):
provider = AioSandboxProvider.__new__(AioSandboxProvider)
provider._lock = threading.Lock()
provider._sandboxes = {sandbox_id: MagicMock()}
+ provider._active_sandbox_identity = {sandbox_id: ("default", "thread-1")}
provider._sandbox_infos = {
sandbox_id: SandboxInfo(
sandbox_id=sandbox_id,
@@ -87,6 +88,7 @@ def _make_provider_with_active_sandbox(tmp_path: Path, sandbox_id: str):
provider._thread_locks = {}
provider._last_activity = {sandbox_id: 1.0}
provider._warm_pool = {}
+ provider._warm_pool_identity = {}
provider._unowned_since = {}
provider._local_teardown = set()
provider._acquire_epoch = {}
diff --git a/backend/tests/test_aio_sandbox_provider.py b/backend/tests/test_aio_sandbox_provider.py
index 4da31b581..6e08d5146 100644
--- a/backend/tests/test_aio_sandbox_provider.py
+++ b/backend/tests/test_aio_sandbox_provider.py
@@ -1,6 +1,8 @@
"""Tests for AioSandboxProvider mount helpers."""
import asyncio
+import contextlib
+import hashlib
import importlib
import stat
from types import SimpleNamespace
@@ -11,6 +13,11 @@ import pytest
from deerflow.config.paths import Paths, join_host_path
from deerflow.runtime.user_context import reset_current_user, set_current_user
+_LEGACY_COLLIDING_IDENTITIES = (
+ ("user-9721", "thread-9721"),
+ ("user-94361", "thread-94361"),
+)
+
# ── ensure_thread_dirs ───────────────────────────────────────────────────────
@@ -62,6 +69,8 @@ def _make_provider(tmp_path):
provider = aio_mod.AioSandboxProvider.__new__(aio_mod.AioSandboxProvider)
provider._config = {"idle_timeout": 600, "replicas": 3}
provider._sandboxes = {}
+ provider._active_sandbox_identity = {}
+ provider._warm_pool_identity = {}
provider._local_teardown = set()
provider._acquire_epoch = {}
provider._acquire_epoch_counter = 0
@@ -883,3 +892,101 @@ def test_create_sandbox_evicts_oldest_warm_replica_via_shared_lifecycle(tmp_path
assert "warm-oldest" not in provider._warm_pool
assert provider._warm_pool == {"warm-newest": (newest_info, 20.0)}
assert provider._sandbox_infos["created"] is created_info
+
+
+def _make_tenant_isolation_provider(tmp_path, monkeypatch):
+ aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
+ provider = _make_provider(tmp_path)
+ provider._lock = aio_mod.threading.Lock()
+ provider._sandboxes = {}
+ provider._sandbox_infos = {}
+ provider._thread_sandboxes = {}
+ provider._thread_locks = {}
+ provider._last_activity = {}
+ provider._warm_pool = {}
+ provider._active_sandbox_identity = {}
+ provider._warm_pool_identity = {}
+ provider._shutdown_called = False
+ provider._config = {"replicas": 3, "idle_timeout": 0}
+
+ create_calls = []
+
+ def _create(thread_id, sandbox_id, **kwargs):
+ create_calls.append((thread_id, sandbox_id, kwargs.get("user_id")))
+ return aio_mod.SandboxInfo(
+ sandbox_id=sandbox_id,
+ sandbox_url=f"http://sandbox-{len(create_calls)}.local",
+ container_name=f"deer-flow-sandbox-{sandbox_id}",
+ )
+
+ provider._backend = SimpleNamespace(
+ create=MagicMock(side_effect=_create),
+ destroy=MagicMock(),
+ discover=MagicMock(return_value=None),
+ is_alive=MagicMock(return_value=True),
+ list_running=MagicMock(return_value=[]),
+ )
+ provider._claim_ownership = MagicMock(return_value=True)
+ provider._held_teardown_lease = lambda _sandbox_id: contextlib.nullcontext()
+
+ monkeypatch.setattr(aio_mod, "get_paths", lambda: Paths(base_dir=tmp_path))
+ monkeypatch.setattr(
+ aio_mod.AioSandboxProvider,
+ "_get_extra_mounts",
+ lambda self, thread_id, *, user_id=None: [],
+ )
+ monkeypatch.setattr(
+ aio_mod,
+ "wait_for_sandbox_ready",
+ lambda _url, timeout=60: True,
+ )
+ return provider, create_calls, aio_mod
+
+
+def test_aio_wider_id_separates_known_legacy_collision():
+ aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
+ identity_a, identity_b = _LEGACY_COLLIDING_IDENTITIES
+ user_a, thread_a = identity_a
+ user_b, thread_b = identity_b
+
+ old_a = hashlib.sha256(f"{user_a}:{thread_a}".encode()).hexdigest()[:8]
+ old_b = hashlib.sha256(f"{user_b}:{thread_b}".encode()).hexdigest()[:8]
+
+ assert old_a == old_b
+ assert aio_mod.AioSandboxProvider._deterministic_sandbox_id(
+ thread_a,
+ user_a,
+ ) != aio_mod.AioSandboxProvider._deterministic_sandbox_id(
+ thread_b,
+ user_b,
+ )
+
+
+def test_aio_forced_collision_never_overwrites_active_tenant(
+ tmp_path,
+ monkeypatch,
+):
+ provider, create_calls, aio_mod = _make_tenant_isolation_provider(
+ tmp_path,
+ monkeypatch,
+ )
+ monkeypatch.setattr(
+ aio_mod.AioSandboxProvider,
+ "_deterministic_sandbox_id",
+ staticmethod(lambda thread_id, user_id: "deadbeefdeadbeef"),
+ )
+
+ sandbox_id = provider.acquire("thread-a", user_id="user-a")
+ info_a = provider._sandbox_infos[sandbox_id]
+ provider.release(sandbox_id)
+
+ assert sandbox_id in provider._warm_pool
+
+ with pytest.raises(aio_mod.SandboxIdentityCollisionError):
+ provider.acquire("thread-b", user_id="user-b")
+
+ assert provider._warm_pool[sandbox_id][0] is info_a
+ provider._backend.destroy.assert_not_called()
+ assert len(create_calls) == 1
+ assert provider.acquire("thread-a", user_id="user-a") == sandbox_id
+ assert provider._sandbox_infos[sandbox_id] is info_a
diff --git a/backend/tests/test_boxlite_provider.py b/backend/tests/test_boxlite_provider.py
index dff07e888..f163285dd 100644
--- a/backend/tests/test_boxlite_provider.py
+++ b/backend/tests/test_boxlite_provider.py
@@ -7,6 +7,7 @@ which need a live box.
from __future__ import annotations
import asyncio
+import hashlib
import logging
import sys
import threading
@@ -18,6 +19,11 @@ import pytest
from deerflow.community.boxlite.box import BoxliteBox
from deerflow.community.boxlite.provider import BoxliteProvider, _import_simplebox
+_LEGACY_COLLIDING_IDENTITIES = (
+ ("user-9721", "thread-9721"),
+ ("user-94361", "thread-94361"),
+)
+
# ── Fake BoxLite SDK ──────────────────────────────────────────────────
@@ -262,7 +268,7 @@ def test_sandbox_id_deterministic(monkeypatch):
id1 = provider._sandbox_id("thread-1", "user-a")
id2 = provider._sandbox_id("thread-1", "user-a")
assert id1 == id2
- assert len(id1) == 8
+ assert len(id1) == 16
def test_sandbox_id_different_users(monkeypatch):
@@ -460,7 +466,7 @@ def test_explicit_recent_reclaim_skip_avoids_health_check(monkeypatch):
monkeypatch.setattr(box, "execute_command", _fail_if_called)
- reclaimed = provider._reclaim_warm_pool(sid)
+ reclaimed = provider._reclaim_warm_pool(sid, ("u1", "thread-1"))
assert reclaimed == sid
assert sid in provider._boxes
assert sid not in provider._warm_pool
@@ -494,7 +500,7 @@ def test_recent_reclaim_validates_by_default(monkeypatch):
monkeypatch.setattr(box, "execute_command", _record_health_check)
- reclaimed = provider._reclaim_warm_pool(sid)
+ reclaimed = provider._reclaim_warm_pool(sid, ("u1", "thread-1"))
assert reclaimed == sid
assert calls == 1
assert sid in provider._boxes
@@ -526,7 +532,7 @@ def test_default_recent_reclaim_drops_dead_warm_box(monkeypatch):
monkeypatch.setattr(box, "execute_command", _dead_health_check)
- reclaimed = provider._reclaim_warm_pool(sid)
+ reclaimed = provider._reclaim_warm_pool(sid, ("u1", "thread-1"))
assert reclaimed is None
assert sid not in provider._boxes
assert sid not in provider._warm_pool
@@ -592,7 +598,10 @@ def test_adopted_warm_pool_box_still_health_checks(monkeypatch):
monkeypatch.setattr(adopted, "execute_command", _record_health_check)
- reclaimed = provider._reclaim_warm_pool("adopted")
+ reclaimed = provider._reclaim_warm_pool(
+ "adopted",
+ ("some-user", "some-thread"),
+ )
assert reclaimed == "adopted"
assert calls == 1
assert "adopted" in provider._boxes
@@ -1044,3 +1053,143 @@ def test_shutdown_stops_idle_reaper_and_destroys_all_boxes(monkeypatch):
assert len(provider._boxes) == 0
assert len(provider._warm_pool) == 0
assert len(provider._thread_boxes) == 0
+
+
+def test_wider_id_separates_known_legacy_collision():
+ identity_a, identity_b = _LEGACY_COLLIDING_IDENTITIES
+ user_a, thread_a = identity_a
+ user_b, thread_b = identity_b
+
+ old_a = hashlib.sha256(f"{user_a}:{thread_a}".encode()).hexdigest()[:8]
+ old_b = hashlib.sha256(f"{user_b}:{thread_b}".encode()).hexdigest()[:8]
+
+ assert old_a == old_b
+ assert BoxliteProvider._sandbox_id(thread_a, user_a) != BoxliteProvider._sandbox_id(
+ thread_b,
+ user_b,
+ )
+
+
+def test_forced_collision_never_overwrites_active_tenant(monkeypatch):
+ monkeypatch.setattr(
+ "deerflow.community.boxlite.provider.get_app_config",
+ lambda: _stub_config(),
+ )
+ monkeypatch.setattr(
+ "deerflow.community.boxlite.provider._import_simplebox",
+ lambda: _FakeBox,
+ )
+ monkeypatch.setattr(
+ BoxliteProvider,
+ "_sandbox_id",
+ staticmethod(lambda thread_id, user_id: "deadbeefdeadbeef"),
+ )
+
+ provider = BoxliteProvider()
+ provider._loop.run = _fake_run
+
+ sandbox_id = provider.acquire("thread-a", user_id="user-a")
+ box_a = provider.get(sandbox_id)
+ provider.release(sandbox_id)
+
+ assert box_a is not None
+ assert sandbox_id in provider._warm_pool
+
+ with pytest.raises(RuntimeError, match="Sandbox ID collision"):
+ provider.acquire("thread-b", user_id="user-b")
+
+ assert provider._warm_pool[sandbox_id][0] is box_a
+ assert not box_a.is_closed
+ assert provider.acquire("thread-a", user_id="user-a") == sandbox_id
+ assert provider.get(sandbox_id) is box_a
+ provider.shutdown()
+
+
+def test_late_same_tenant_collision_reuses_active_box(monkeypatch):
+ monkeypatch.setattr(
+ "deerflow.community.boxlite.provider.get_app_config",
+ lambda: _stub_config(),
+ )
+
+ provider = BoxliteProvider()
+ sandbox_id = "deadbeefdeadbeef"
+ key = ("user-a", "thread-a")
+ active = BoxliteBox(
+ sandbox_id,
+ _FakeBox(name=f"deer-flow-boxlite-{sandbox_id}"),
+ _fake_run,
+ default_env={},
+ )
+ duplicate = BoxliteBox(
+ sandbox_id,
+ _FakeBox(name=f"deer-flow-boxlite-{sandbox_id}"),
+ _fake_run,
+ default_env={},
+ )
+
+ monkeypatch.setattr(
+ BoxliteProvider,
+ "_sandbox_id",
+ staticmethod(lambda thread_id, user_id: sandbox_id),
+ )
+
+ def _create_box_with_late_registration(_sandbox_id):
+ with provider._lock:
+ provider._boxes[sandbox_id] = active
+ provider._active_box_identity[sandbox_id] = key
+ return duplicate
+
+ monkeypatch.setattr(provider, "_create_box", _create_box_with_late_registration)
+
+ assert provider.acquire("thread-a", user_id="user-a") == sandbox_id
+ assert duplicate.is_closed
+ assert provider.get(sandbox_id) is active
+ assert provider._thread_boxes[key] == sandbox_id
+ provider.shutdown()
+
+
+def test_failed_health_check_does_not_remove_swapped_warm_entry(monkeypatch):
+ monkeypatch.setattr(
+ "deerflow.community.boxlite.provider.get_app_config",
+ lambda: _stub_config(),
+ )
+
+ provider = BoxliteProvider()
+ stale = BoxliteBox(
+ "shared-id",
+ _FakeBox(name="deer-flow-boxlite-shared-id"),
+ _fake_run,
+ default_env={},
+ )
+ replacement = BoxliteBox(
+ "shared-id",
+ _FakeBox(name="deer-flow-boxlite-shared-id"),
+ _fake_run,
+ default_env={},
+ )
+ provider._warm_pool["shared-id"] = (stale, time.time())
+ provider._warm_pool_identity["shared-id"] = ("user-a", "thread-a")
+
+ def _swap_during_health_check(*args, **kwargs):
+ with provider._lock:
+ provider._warm_pool["shared-id"] = (replacement, time.time())
+ provider._warm_pool_identity["shared-id"] = (
+ "user-b",
+ "thread-b",
+ )
+ return "not healthy"
+
+ monkeypatch.setattr(stale, "execute_command", _swap_during_health_check)
+
+ with pytest.raises(RuntimeError, match="Sandbox ID collision"):
+ provider._reclaim_warm_pool(
+ "shared-id",
+ ("user-a", "thread-a"),
+ )
+ assert provider._warm_pool["shared-id"][0] is replacement
+ assert provider._warm_pool_identity["shared-id"] == (
+ "user-b",
+ "thread-b",
+ )
+ assert not replacement.is_closed
+ provider.shutdown()
diff --git a/backend/tests/test_sandbox_orphan_reconciliation.py b/backend/tests/test_sandbox_orphan_reconciliation.py
index a0a2aa7b8..a68de083c 100644
--- a/backend/tests/test_sandbox_orphan_reconciliation.py
+++ b/backend/tests/test_sandbox_orphan_reconciliation.py
@@ -431,6 +431,8 @@ def _make_provider_for_reconciliation(tmp_path=None, *, worker_id: str = "worker
provider._thread_locks = {}
provider._last_activity = {}
provider._warm_pool = {}
+ provider._active_sandbox_identity = {}
+ provider._warm_pool_identity = {}
provider._unowned_since = {}
provider._local_teardown = set()
provider._acquire_epoch = {}
From b5cc3a81c3a1605f96a8a2963ce38efec7be3f18 Mon Sep 17 00:00:00 2001
From: Daoyuan Li <94409450+DaoyuanLi2816@users.noreply.github.com>
Date: Sat, 25 Jul 2026 18:52:16 -0700
Subject: [PATCH 17/35] fix(auth): resolve email accounts case-insensitively
(#4101)
* fix(auth): handle email addresses case-insensitively
Normalize new and changed addresses, use case-insensitive lookup, and keep legacy case-variant rows stable during unrelated updates.
* fix(auth): reject legacy case-variant registrations
---------
Co-authored-by: Willem Jiang
---
backend/app/gateway/auth/repositories/base.py | 9 +-
.../app/gateway/auth/repositories/sqlite.py | 62 +++-
backend/tests/test_auth.py | 311 ++++++++++++++++++
3 files changed, 376 insertions(+), 6 deletions(-)
diff --git a/backend/app/gateway/auth/repositories/base.py b/backend/app/gateway/auth/repositories/base.py
index b5baa02c7..3ec18f75c 100644
--- a/backend/app/gateway/auth/repositories/base.py
+++ b/backend/app/gateway/auth/repositories/base.py
@@ -30,7 +30,10 @@ class UserRepository(ABC):
user: User object to create
Returns:
- Created User with ID assigned
+ Created User with ID assigned. ``email`` is the canonical
+ (lowercase) stored form, which may differ in case from what was
+ passed in -- implementations mutate the input ``user`` in place
+ to reflect this rather than returning a fresh object.
Raises:
ValueError: If email already exists
@@ -69,7 +72,9 @@ class UserRepository(ABC):
user: User object with updated fields
Returns:
- Updated User
+ Updated User. ``email`` is the canonical (lowercase) stored
+ form -- implementations mutate the input ``user`` in place to
+ reflect this rather than returning a fresh object.
Raises:
UserNotFoundError: If no row exists for ``user.id``. This is
diff --git a/backend/app/gateway/auth/repositories/sqlite.py b/backend/app/gateway/auth/repositories/sqlite.py
index 3ee3978e3..c9adeac8e 100644
--- a/backend/app/gateway/auth/repositories/sqlite.py
+++ b/backend/app/gateway/auth/repositories/sqlite.py
@@ -24,6 +24,25 @@ from app.gateway.auth.repositories.base import UserNotFoundError, UserRepository
from deerflow.persistence.user.model import UserRow
+def _normalize_email(email: str) -> str:
+ """Canonicalise an email address for storage and lookup.
+
+ An email identifies exactly one account regardless of the case the client
+ sends. The two write paths would otherwise disagree: local registration
+ normalises through ``EmailStr``, which lowercases only the *domain* and
+ keeps the local-part case (``Victim@X.COM`` -> ``Victim@x.com``), while OIDC
+ provisioning lowercases the whole address (``-> victim@x.com``). Combined
+ with the previous case-sensitive lookup, ``Victim@x.com`` and
+ ``victim@x.com`` resolved to two separate rows, defeating the invariant
+ that a local account blocks an SSO login on the same email.
+
+ Canonicalising to lowercase at every write site and matching
+ case-insensitively on read closes that gap for new accounts while letting
+ existing mixed-case rows keep resolving, without a destructive bulk rewrite.
+ """
+ return email.lower()
+
+
class SQLiteUserRepository(UserRepository):
"""Async user repository backed by the shared SQLAlchemy engine."""
@@ -65,9 +84,20 @@ class SQLiteUserRepository(UserRepository):
# ── CRUD ──────────────────────────────────────────────────────────
async def create_user(self, user: User) -> User:
- """Insert a new user. Raises ``ValueError`` on duplicate email."""
+ """Insert a new user. Raises ``ValueError`` on duplicate email.
+
+ The email is canonicalised to lowercase before insert so the existing
+ unique constraint enforces case-insensitive uniqueness for new rows and
+ the returned ``User`` reflects the stored form.
+ """
+ user.email = _normalize_email(user.email)
row = self._user_to_row(user)
async with self._sf() as session:
+ # The unique constraint is case-sensitive, so it cannot catch a
+ # canonical address colliding with a mixed-case legacy row.
+ existing = select(UserRow.id).where(func.lower(UserRow.email) == user.email).limit(1)
+ if await session.scalar(existing) is not None:
+ raise ValueError(f"Email already registered: {user.email}")
session.add(row)
try:
await session.commit()
@@ -82,10 +112,17 @@ class SQLiteUserRepository(UserRepository):
return self._row_to_user(row) if row is not None else None
async def get_user_by_email(self, email: str) -> User | None:
- stmt = select(UserRow).where(UserRow.email == email)
+ # Case-insensitive match: an account is keyed by its email regardless of
+ # the case the caller supplies (see ``_normalize_email``). ``.first()``
+ # with a deterministic ``created_at`` ordering resolves to the oldest
+ # account instead of raising if a pre-fix database already holds two
+ # rows differing only in case, so the fix never turns a legacy duplicate
+ # pair into a 500. ``id`` is a secondary tiebreaker so the choice stays
+ # deterministic even if two legacy rows share the same ``created_at``.
+ stmt = select(UserRow).where(func.lower(UserRow.email) == _normalize_email(email)).order_by(UserRow.created_at, UserRow.id).limit(1)
async with self._sf() as session:
result = await session.execute(stmt)
- row = result.scalar_one_or_none()
+ row = result.scalars().first()
return self._row_to_user(row) if row is not None else None
async def update_user(self, user: User) -> User:
@@ -99,7 +136,24 @@ class SQLiteUserRepository(UserRepository):
# success would let the caller log "password reset" for
# a row that no longer exists.
raise UserNotFoundError(f"User {user.id} no longer exists")
- row.email = user.email
+ # Canonicalise the email only when it actually changes, comparing
+ # case-insensitively against the stored value, then mirror the
+ # persisted value back onto the returned object. Re-lowercasing an
+ # *unchanged* legacy mixed-case email — e.g. a password-only update
+ # on a pre-fix ``Victim@x.com`` row while a canonical ``victim@x.com``
+ # row also exists — would rewrite it onto the other row's unique
+ # email and raise IntegrityError, surfacing as a 500 on the
+ # change-password / reset-admin paths that do not catch it. Guarding
+ # against the *raw* stored value (``canonical != row.email``) is not
+ # enough: the mixed-case row's canonical form still differs from its
+ # own stored casing, so it would rewrite and collide anyway. A genuine
+ # change still normalises, so the unique constraint keeps enforcing
+ # case-insensitive uniqueness for updated rows; get_user_by_email
+ # already resolves legacy mixed-case rows case-insensitively on read.
+ canonical_email = _normalize_email(user.email)
+ if canonical_email != _normalize_email(row.email):
+ row.email = canonical_email
+ user.email = row.email
row.password_hash = user.password_hash
row.system_role = user.system_role
row.oauth_provider = user.oauth_provider
diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py
index 4491bb8c5..b2b6aa1a4 100644
--- a/backend/tests/test_auth.py
+++ b/backend/tests/test_auth.py
@@ -478,6 +478,317 @@ def test_update_user_raises_when_row_concurrently_deleted(tmp_path):
asyncio.run(_run())
+# ── Email case-insensitivity (account collision invariant) ──────────────────
+#
+# Regression coverage for the case-collision gap: local registration normalises
+# email through ``EmailStr`` (lowercases only the domain) while OIDC lowercases
+# the whole address, and the repo lookup used to be case-sensitive, so
+# ``Victim@x.com`` and ``victim@x.com`` became two separate accounts — defeating
+# the invariant that a local account blocks an SSO login on the same email
+# (flagged on PR #3506, fixed there only OIDC-side). The repo now canonicalises
+# to lowercase on write and matches case-insensitively on read.
+
+
+def test_email_lookup_is_case_insensitive(tmp_path):
+ """A user registered with mixed case resolves for any-case lookup."""
+ import asyncio
+
+ from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
+
+ async def _run() -> None:
+ from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
+
+ url = f"sqlite+aiosqlite:///{tmp_path}/scratch.db"
+ await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
+ try:
+ repo = SQLiteUserRepository(get_session_factory())
+ created = await repo.create_user(User(email="Victim@x.com", password_hash="h", system_role="user"))
+ # Stored canonical (lowercase) and reflected back on the returned object.
+ assert created.email == "victim@x.com"
+
+ for variant in ("victim@x.com", "VICTIM@X.COM", "Victim@x.com"):
+ found = await repo.get_user_by_email(variant)
+ assert found is not None, f"lookup missed {variant!r}"
+ assert str(found.id) == str(created.id)
+ finally:
+ await close_engine()
+
+ asyncio.run(_run())
+
+
+def test_create_user_rejects_email_differing_only_in_case(tmp_path):
+ """The second case-variant registration collides on the canonical email."""
+ import asyncio
+
+ from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
+
+ async def _run() -> None:
+ from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
+
+ url = f"sqlite+aiosqlite:///{tmp_path}/scratch.db"
+ await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
+ try:
+ repo = SQLiteUserRepository(get_session_factory())
+ await repo.create_user(User(email="Victim@x.com", password_hash="h", system_role="user"))
+ with pytest.raises(ValueError):
+ await repo.create_user(User(email="victim@x.com", password_hash="h", system_role="user"))
+ assert await repo.count_users() == 1
+ finally:
+ await close_engine()
+
+ asyncio.run(_run())
+
+
+def test_create_user_rejects_legacy_mixed_case_email(tmp_path):
+ """Registration must not duplicate a mixed-case row created before normalization."""
+ import asyncio
+ from uuid import uuid4
+
+ from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
+
+ async def _run() -> None:
+ from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
+ from deerflow.persistence.user.model import UserRow
+
+ url = f"sqlite+aiosqlite:///{tmp_path}/scratch.db"
+ await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
+ try:
+ sf = get_session_factory()
+ repo = SQLiteUserRepository(sf)
+ async with sf() as session:
+ session.add(
+ UserRow(
+ id=str(uuid4()),
+ email="Victim@x.com",
+ password_hash="h",
+ system_role="user",
+ needs_setup=False,
+ token_version=0,
+ )
+ )
+ await session.commit()
+
+ with pytest.raises(ValueError, match="Email already registered"):
+ await repo.create_user(User(email="victim@x.com", password_hash="h", system_role="user"))
+ assert await repo.count_users() == 1
+ finally:
+ await close_engine()
+
+ asyncio.run(_run())
+
+
+def test_update_user_normalizes_email(tmp_path):
+ """Changing an email through update_user stores the canonical lowercase form."""
+ import asyncio
+
+ from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
+
+ async def _run() -> None:
+ from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
+
+ url = f"sqlite+aiosqlite:///{tmp_path}/scratch.db"
+ await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
+ try:
+ repo = SQLiteUserRepository(get_session_factory())
+ user = await repo.create_user(User(email="user@x.com", password_hash="h", system_role="user"))
+ user.email = "New@Mixed.COM"
+ await repo.update_user(user)
+ refetched = await repo.get_user_by_id(str(user.id))
+ assert refetched is not None
+ assert refetched.email == "new@mixed.com"
+ assert await repo.get_user_by_email("NEW@MIXED.com") is not None
+ finally:
+ await close_engine()
+
+ asyncio.run(_run())
+
+
+def test_distinct_emails_remain_distinct(tmp_path):
+ """Case-folding must not collapse genuinely different addresses."""
+ import asyncio
+
+ from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
+
+ async def _run() -> None:
+ from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
+
+ url = f"sqlite+aiosqlite:///{tmp_path}/scratch.db"
+ await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
+ try:
+ repo = SQLiteUserRepository(get_session_factory())
+ alice = await repo.create_user(User(email="alice@x.com", password_hash="h", system_role="user"))
+ bob = await repo.create_user(User(email="bob@x.com", password_hash="h", system_role="user"))
+ assert await repo.count_users() == 2
+ fa = await repo.get_user_by_email("Alice@x.com")
+ fb = await repo.get_user_by_email("BOB@x.com")
+ assert fa is not None and str(fa.id) == str(alice.id)
+ assert fb is not None and str(fb.id) == str(bob.id)
+ assert str(fa.id) != str(fb.id)
+ finally:
+ await close_engine()
+
+ asyncio.run(_run())
+
+
+def test_legacy_mixed_case_duplicate_rows_resolve_without_error(tmp_path):
+ """A pre-fix DB with two case-variant rows resolves to the oldest, never 500s.
+
+ Migration-safety: existing installations may already hold ``Victim@x.com``
+ and ``victim@x.com`` as separate rows. The case-insensitive lookup must not
+ raise ``MultipleResultsFound``; it deterministically returns the oldest
+ (most-established) account.
+ """
+ import asyncio
+ from datetime import UTC, datetime, timedelta
+ from uuid import uuid4
+
+ from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
+
+ async def _run() -> None:
+ from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
+ from deerflow.persistence.user.model import UserRow
+
+ url = f"sqlite+aiosqlite:///{tmp_path}/scratch.db"
+ await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
+ try:
+ sf = get_session_factory()
+ repo = SQLiteUserRepository(sf)
+ older = datetime.now(UTC) - timedelta(days=5)
+ newer = datetime.now(UTC)
+ # Insert raw rows (bypassing create_user's normalisation) to mimic
+ # data written before this fix.
+ async with sf() as session:
+ session.add(UserRow(id=str(uuid4()), email="Victim@x.com", password_hash="h", system_role="user", created_at=older, needs_setup=False, token_version=0))
+ session.add(UserRow(id=str(uuid4()), email="victim@x.com", password_hash="h", system_role="user", created_at=newer, needs_setup=False, token_version=0))
+ await session.commit()
+
+ found = await repo.get_user_by_email("VICTIM@X.COM")
+ assert found is not None
+ assert found.email == "Victim@x.com" # oldest wins, deterministically
+ finally:
+ await close_engine()
+
+ asyncio.run(_run())
+
+
+def test_update_user_on_legacy_mixed_case_row_does_not_collide(tmp_path):
+ """A password-only update on a legacy mixed-case row must not 500.
+
+ Migration-safety, write side. A pre-fix DB may already hold two rows
+ differing only in case (``Victim@x.com`` + ``victim@x.com``). A password
+ change or admin reset reloads the row and calls ``update_user`` with the
+ email unchanged. ``update_user`` must not opportunistically re-lowercase the
+ mixed-case email, because that collides with the already-canonical row's
+ unique email and raises ``IntegrityError`` — which surfaces as a 500 on the
+ change-password / reset-admin paths that don't catch it. The read path was
+ hardened for this legacy state; the write path must match, so only a genuine
+ email change (differing case-insensitively from the stored value) rewrites
+ the column.
+ """
+ import asyncio
+ from datetime import UTC, datetime, timedelta
+ from uuid import uuid4
+
+ from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
+
+ async def _run() -> None:
+ from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
+ from deerflow.persistence.user.model import UserRow
+
+ url = f"sqlite+aiosqlite:///{tmp_path}/scratch.db"
+ await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
+ try:
+ sf = get_session_factory()
+ repo = SQLiteUserRepository(sf)
+ mixed_id = str(uuid4())
+ canonical_id = str(uuid4())
+ older = datetime.now(UTC) - timedelta(days=5)
+ newer = datetime.now(UTC)
+ # Raw rows written before the fix (the mixed-case row is the oldest).
+ async with sf() as session:
+ session.add(UserRow(id=mixed_id, email="Victim@x.com", password_hash="old-hash", system_role="user", created_at=older, needs_setup=False, token_version=0))
+ session.add(UserRow(id=canonical_id, email="victim@x.com", password_hash="canonical-hash", system_role="user", created_at=newer, needs_setup=False, token_version=0))
+ await session.commit()
+
+ # Simulate a password change on the mixed-case row: reload it, keep
+ # the email as-stored, set a new hash + bump the token version.
+ mixed = await repo.get_user_by_id(mixed_id)
+ assert mixed is not None
+ assert mixed.email == "Victim@x.com"
+ mixed.password_hash = "new-hash-after-change"
+ mixed.token_version += 1
+
+ # Must not raise IntegrityError even though lowercasing the email
+ # would collide with the canonical row's unique email.
+ await repo.update_user(mixed)
+
+ # The mixed-case row kept its stored casing and took the new password.
+ refetched = await repo.get_user_by_id(mixed_id)
+ assert refetched is not None
+ assert refetched.email == "Victim@x.com"
+ assert refetched.password_hash == "new-hash-after-change"
+ assert refetched.token_version == 1
+
+ # The canonical row is untouched, and no row was lost or merged.
+ canonical = await repo.get_user_by_id(canonical_id)
+ assert canonical is not None
+ assert canonical.email == "victim@x.com"
+ assert canonical.password_hash == "canonical-hash"
+ assert await repo.count_users() == 2
+
+ # Case-insensitive lookup still resolves the mixed-case row (oldest wins).
+ found = await repo.get_user_by_email("VICTIM@X.COM")
+ assert found is not None
+ assert str(found.id) == mixed_id
+ finally:
+ await close_engine()
+
+ asyncio.run(_run())
+
+
+def test_oidc_login_blocked_by_existing_local_account_across_case(tmp_path):
+ """End-to-end invariant: an SSO login cannot create a duplicate of a local
+ account whose email differs only in case.
+
+ Uses the real repository + provider + provisioning (no mocks), so it covers
+ the cross-path gap the mock-based OIDC tests could not: local registration
+ keeps the local-part case (``Victim@x.com``) while OIDC lowercases the whole
+ address (``victim@x.com``).
+ """
+ import asyncio
+
+ from app.gateway.auth.local_provider import LocalAuthProvider
+ from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
+ from app.gateway.auth.user_provisioning import get_or_provision_oidc_user
+ from deerflow.config.auth_config import OIDCProviderConfig
+
+ async def _run() -> None:
+ from fastapi import HTTPException
+
+ from app.gateway.auth.oidc import OIDCIdentity
+ from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
+
+ url = f"sqlite+aiosqlite:///{tmp_path}/scratch.db"
+ await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
+ try:
+ provider = LocalAuthProvider(SQLiteUserRepository(get_session_factory()))
+ await provider.create_user(email="Victim@x.com", password="pw-abc-123!", system_role="user")
+
+ cfg = OIDCProviderConfig(display_name="Test SSO", issuer="https://issuer.example.com", client_id="deer-flow", auto_create_users=True)
+ identity = OIDCIdentity(provider="keycloak", subject="sub-1", email="Victim@x.com", email_verified=True, name="Victim", claims={})
+
+ with pytest.raises(HTTPException) as exc_info:
+ await get_or_provision_oidc_user(provider_id="keycloak", provider_config=cfg, identity=identity, local_provider=provider)
+
+ assert exc_info.value.status_code == 409
+ # No duplicate auto-created — the local account still owns the email.
+ assert await provider.count_users() == 1
+ finally:
+ await close_engine()
+
+ asyncio.run(_run())
+
+
# ── Token Versioning ───────────────────────────────────────────────────────
From 183280ebfcab6a5344dcbd3cda78a2cb5857aa18 Mon Sep 17 00:00:00 2001
From: nonoge <41133441+zhangbububu@users.noreply.github.com>
Date: Sun, 26 Jul 2026 08:56:17 +0700
Subject: [PATCH 18/35] fix browser extra detection for indentless YAML (#4367)
---
backend/tests/test_detect_uv_extras.py | 8 ++++++++
scripts/detect_uv_extras.py | 8 +++++---
2 files changed, 13 insertions(+), 3 deletions(-)
diff --git a/backend/tests/test_detect_uv_extras.py b/backend/tests/test_detect_uv_extras.py
index 3e1ee3c39..0429ea871 100644
--- a/backend/tests/test_detect_uv_extras.py
+++ b/backend/tests/test_detect_uv_extras.py
@@ -157,6 +157,14 @@ def test_detect_from_config_browser_via_browser_navigate_tool(tmp_path):
assert detect.detect_from_config(cfg) == ["browser"]
+def test_detect_from_config_browser_via_indentless_tools_list(tmp_path):
+ cfg = tmp_path / "config.yaml"
+ cfg.write_text(
+ "tools:\n- name: web_fetch\n group: web\n- name: browser_navigate\n group: browser\n",
+ )
+ assert detect.detect_from_config(cfg) == ["browser"]
+
+
def test_detect_from_config_ignores_commented_browser_tool(tmp_path):
cfg = tmp_path / "config.yaml"
cfg.write_text(
diff --git a/scripts/detect_uv_extras.py b/scripts/detect_uv_extras.py
index f2bdc230a..deb552008 100755
--- a/scripts/detect_uv_extras.py
+++ b/scripts/detect_uv_extras.py
@@ -237,14 +237,16 @@ def tools_include_name(lines: list[str], tool_name: str) -> bool:
continue
if not inside:
continue
+ name_match = _LIST_ITEM_NAME_RE.match(line)
+ if name_match:
+ if _unquote(name_match.group(1).strip()) == tool_name:
+ return True
+ continue
stripped = line.lstrip()
indent = len(line) - len(stripped)
if indent == 0:
inside = False
continue
- name_match = _LIST_ITEM_NAME_RE.match(line)
- if name_match and _unquote(name_match.group(1).strip()) == tool_name:
- return True
return False
From f881996e1abb2ce04ad451e6abfaa3a1d5721c24 Mon Sep 17 00:00:00 2001
From: urzeye <20869204+urzeye@users.noreply.github.com>
Date: Sun, 26 Jul 2026 10:05:05 +0800
Subject: [PATCH 19/35] fix(auth): recover from setup status timeouts (#4371)
* fix(auth): recover from setup status timeouts
* test(auth): cover setup status recovery flows
---------
Co-authored-by: Willem Jiang
---
.github/workflows/e2e-tests.yml | 6 +
frontend/playwright.auth.config.ts | 49 ++++++++
frontend/src/app/(auth)/login/page.tsx | 61 ++++++++--
frontend/src/app/(auth)/setup/page.tsx | 48 +++++++-
frontend/src/core/auth/constants.ts | 1 +
frontend/src/core/auth/server.ts | 7 +-
frontend/src/core/auth/setup.ts | 22 ++--
frontend/src/core/i18n/locales/en-US.ts | 4 +
frontend/src/core/i18n/locales/types.ts | 3 +
frontend/src/core/i18n/locales/zh-CN.ts | 4 +
.../e2e-auth/auth-setup-recovery.spec.ts | 102 ++++++++++++++++
frontend/tests/unit/core/auth/setup.test.ts | 112 +++++++++++++++++-
12 files changed, 394 insertions(+), 25 deletions(-)
create mode 100644 frontend/playwright.auth.config.ts
create mode 100644 frontend/src/core/auth/constants.ts
create mode 100644 frontend/tests/e2e-auth/auth-setup-recovery.spec.ts
diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml
index 9a8b30a0e..7480fa27b 100644
--- a/.github/workflows/e2e-tests.yml
+++ b/.github/workflows/e2e-tests.yml
@@ -54,6 +54,12 @@ jobs:
env:
SKIP_ENV_VALIDATION: '1'
+ - name: Run auth recovery E2E tests
+ working-directory: frontend
+ run: pnpm exec playwright test -c playwright.auth.config.ts
+ env:
+ SKIP_ENV_VALIDATION: '1'
+
- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
diff --git a/frontend/playwright.auth.config.ts b/frontend/playwright.auth.config.ts
new file mode 100644
index 000000000..9d53b807a
--- /dev/null
+++ b/frontend/playwright.auth.config.ts
@@ -0,0 +1,49 @@
+import { defineConfig, devices } from "@playwright/test";
+
+const frontendPort = process.env.E2E_AUTH_FRONTEND_PORT ?? "3001";
+const frontendURL =
+ process.env.PLAYWRIGHT_AUTH_BASE_URL ?? `http://localhost:${frontendPort}`;
+const skipWebServer = process.env.PLAYWRIGHT_SKIP_WEB_SERVER === "1";
+
+/**
+ * Auth-enabled E2E coverage for the login and setup pages. The default config
+ * runs with auth disabled so workspace tests can skip a real session; that
+ * would SSR-redirect these pages before page.route() can exercise recovery.
+ */
+export default defineConfig({
+ testDir: "./tests/e2e-auth",
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ workers: process.env.CI ? 1 : undefined,
+ reporter: process.env.CI ? "github" : "html",
+ timeout: 30_000,
+
+ use: {
+ baseURL: frontendURL,
+ locale: "en-US",
+ trace: "on-first-retry",
+ },
+
+ projects: [
+ {
+ name: "chromium",
+ use: { ...devices["Desktop Chrome"] },
+ },
+ ],
+
+ webServer: skipWebServer
+ ? undefined
+ : {
+ command: `pnpm build && pnpm exec next start -p ${frontendPort}`,
+ url: frontendURL,
+ reuseExistingServer: !process.env.CI,
+ timeout: 240_000,
+ env: {
+ SKIP_ENV_VALIDATION: "1",
+ DEER_FLOW_AUTH_DISABLED: "0",
+ DEER_FLOW_INTERNAL_GATEWAY_BASE_URL: "http://127.0.0.1:65535",
+ DEER_FLOW_TRUSTED_ORIGINS: frontendURL,
+ },
+ },
+});
diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx
index 0e28b58c6..b7acbc309 100644
--- a/frontend/src/app/(auth)/login/page.tsx
+++ b/frontend/src/app/(auth)/login/page.tsx
@@ -72,7 +72,10 @@ export default function LoginPage() {
const [setupStatus, setSetupStatus] = useState(
null,
);
- const [setupStatusChecked, setSetupStatusChecked] = useState(false);
+ const [setupStatusPhase, setSetupStatusPhase] = useState<
+ "checking" | "ready" | "unavailable"
+ >("checking");
+ const [setupStatusAttempt, setSetupStatusAttempt] = useState(0);
// Extract error from query params (e.g., ?error=sso_failed)
const errorParam = searchParams.get("error");
@@ -93,10 +96,15 @@ export default function LoginPage() {
const nextParam = searchParams.get("next");
const redirectPath = validateNextParam(nextParam) ?? "/workspace";
const regularSignupAllowed = canCreateRegularAccount({
- checked: setupStatusChecked,
+ // A failed probe must not expose registration while the system's setup
+ // state is unknown. Existing users can still sign in normally.
+ checked: setupStatusPhase === "ready",
status: setupStatus,
});
const systemNeedsAdminSetup = setupStatus?.needs_setup === true;
+ const showSetupStatusUnavailable =
+ setupStatusPhase === "unavailable" ||
+ (setupStatusAttempt > 0 && setupStatusPhase === "checking");
// Redirect if already authenticated (client-side, post-login)
useEffect(() => {
@@ -113,14 +121,17 @@ export default function LoginPage() {
}
}, []);
- // Fetch setup state and SSO providers
+ // Fetch setup state independently so retrying a slow Gateway does not also
+ // refetch unrelated auth-provider configuration.
useEffect(() => {
let cancelled = false;
+ setSetupStatusPhase("checking");
void fetchSetupStatus()
.then((data) => {
if (cancelled) return;
setSetupStatus(data);
+ setSetupStatusPhase("ready");
if (data.needs_setup) {
setIsLogin(true);
}
@@ -128,14 +139,20 @@ export default function LoginPage() {
.catch(() => {
if (!cancelled) {
setSetupStatus(null);
- }
- })
- .finally(() => {
- if (!cancelled) {
- setSetupStatusChecked(true);
+ setSetupStatusPhase("unavailable");
}
});
+ return () => {
+ cancelled = true;
+ };
+ }, [setupStatusAttempt]);
+
+ // SSO providers are static for the page lifetime and should not be coupled to
+ // setup-status retries.
+ useEffect(() => {
+ let cancelled = false;
+
void fetch("/api/v1/auth/providers")
.then((r) => r.json())
.then(
@@ -234,6 +251,34 @@ export default function LoginPage() {
+ {showSetupStatusUnavailable && (
+
+
{t.login.serviceUnavailableTitle}
+
+ {t.login.serviceUnavailableDescription}
+
+
{
+ setSetupStatusPhase("checking");
+ setSetupStatusAttempt((attempt) => attempt + 1);
+ }}
+ >
+ {setupStatusPhase === "checking"
+ ? t.login.pleaseWait
+ : t.login.retry}
+
+
+ )}
+
{systemNeedsAdminSetup && (
{t.login.adminSetupRequiredTitle}
diff --git a/frontend/src/app/(auth)/setup/page.tsx b/frontend/src/app/(auth)/setup/page.tsx
index 28ce8cc1f..d2120257f 100644
--- a/frontend/src/app/(auth)/setup/page.tsx
+++ b/frontend/src/app/(auth)/setup/page.tsx
@@ -16,14 +16,17 @@ import {
isSystemAlreadyInitializedError,
} from "@/core/auth/setup";
import { parseAuthError } from "@/core/auth/types";
+import { useI18n } from "@/core/i18n/hooks";
-type SetupMode = "loading" | "init_admin" | "change_password";
+type SetupMode = "loading" | "init_admin" | "change_password" | "unavailable";
export default function SetupPage() {
const router = useRouter();
const { user, isAuthenticated } = useAuth();
const { theme, resolvedTheme } = useTheme();
+ const { t } = useI18n();
const [mode, setMode] = useState
("loading");
+ const [setupStatusAttempt, setSetupStatusAttempt] = useState(0);
// --- Shared state ---
const [email, setEmail] = useState("");
@@ -44,7 +47,9 @@ export default function SetupPage() {
if (isAuthenticated && user?.needs_setup) {
setMode("change_password");
} else if (!isAuthenticated) {
- // Check if the system has no users yet
+ // Check if the system has no users yet. A slow Gateway must not leave the
+ // setup page in an infinite loading state or silently redirect away.
+ setMode("loading");
void fetchSetupStatus()
.then((data: { needs_setup?: boolean }) => {
if (cancelled) return;
@@ -56,7 +61,7 @@ export default function SetupPage() {
}
})
.catch(() => {
- if (!cancelled) router.replace("/login");
+ if (!cancelled) setMode("unavailable");
});
} else {
// Authenticated but needs_setup is false — already set up
@@ -66,7 +71,7 @@ export default function SetupPage() {
return () => {
cancelled = true;
};
- }, [isAuthenticated, user, router]);
+ }, [isAuthenticated, user, router, setupStatusAttempt]);
// ── Init-admin handler ─────────────────────────────────────────────
const handleInitAdmin = async (e: React.SubmitEvent) => {
@@ -166,6 +171,41 @@ export default function SetupPage() {
);
}
+ if (mode === "unavailable") {
+ return (
+
+
+
+
+ {t.login.serviceUnavailableTitle}
+
+
+ {t.login.serviceUnavailableDescription}
+
+
+
+ {
+ setMode("loading");
+ setSetupStatusAttempt((attempt) => attempt + 1);
+ }}
+ >
+ {t.login.retry}
+
+ router.replace("/login")}
+ >
+ {t.login.signIn}
+
+
+
+
+ );
+ }
+
// ── Admin initialization form ──────────────────────────────────────
if (mode === "init_admin") {
return (
diff --git a/frontend/src/core/auth/constants.ts b/frontend/src/core/auth/constants.ts
new file mode 100644
index 000000000..5b29b2701
--- /dev/null
+++ b/frontend/src/core/auth/constants.ts
@@ -0,0 +1 @@
+export const AUTH_REQUEST_TIMEOUT_MS = 5_000;
diff --git a/frontend/src/core/auth/server.ts b/frontend/src/core/auth/server.ts
index 198ef087c..622f52e94 100644
--- a/frontend/src/core/auth/server.ts
+++ b/frontend/src/core/auth/server.ts
@@ -3,12 +3,11 @@ import { cookies } from "next/headers";
import { isStaticWebsiteOnly } from "../static-mode";
import { AUTH_DISABLED_USER, isAuthDisabledMode } from "./auth-disabled-user";
+import { AUTH_REQUEST_TIMEOUT_MS } from "./constants";
import { getGatewayConfig } from "./gateway-config";
import { STATIC_WEBSITE_USER } from "./static-user";
import { type AuthResult, userSchema } from "./types";
-const SSR_AUTH_TIMEOUT_MS = 5_000;
-
/**
* Fetch the authenticated user from the gateway using the request's cookies.
* Returns a tagged AuthResult — callers use exhaustive switch, no try/catch.
@@ -43,7 +42,7 @@ export async function getServerSideUser(): Promise {
const setupController = new AbortController();
const setupTimeout = setTimeout(
() => setupController.abort(),
- SSR_AUTH_TIMEOUT_MS,
+ AUTH_REQUEST_TIMEOUT_MS,
);
try {
const setupRes = await fetch(
@@ -68,7 +67,7 @@ export async function getServerSideUser(): Promise {
}
const controller = new AbortController();
- const timeout = setTimeout(() => controller.abort(), SSR_AUTH_TIMEOUT_MS);
+ const timeout = setTimeout(() => controller.abort(), AUTH_REQUEST_TIMEOUT_MS);
try {
const res = await fetch(`${internalGatewayUrl}/api/v1/auth/me`, {
diff --git a/frontend/src/core/auth/setup.ts b/frontend/src/core/auth/setup.ts
index 055dc05c2..1c484dcac 100644
--- a/frontend/src/core/auth/setup.ts
+++ b/frontend/src/core/auth/setup.ts
@@ -1,3 +1,4 @@
+import { AUTH_REQUEST_TIMEOUT_MS } from "./constants";
import { parseAuthError } from "./types";
export type SetupStatusResponse = {
@@ -16,14 +17,21 @@ export const setupStatusFetchInit = {
} satisfies RequestInit;
export async function fetchSetupStatus(): Promise {
- const response = await fetch(
- "/api/v1/auth/setup-status",
- setupStatusFetchInit,
- );
- if (!response.ok) {
- throw new Error(`setup-status failed: ${response.status}`);
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), AUTH_REQUEST_TIMEOUT_MS);
+
+ try {
+ const response = await fetch("/api/v1/auth/setup-status", {
+ ...setupStatusFetchInit,
+ signal: controller.signal,
+ });
+ if (!response.ok) {
+ throw new Error(`setup-status failed: ${response.status}`);
+ }
+ return (await response.json()) as SetupStatusResponse;
+ } finally {
+ clearTimeout(timeout);
}
- return (await response.json()) as SetupStatusResponse;
}
export function isSystemAlreadyInitializedError(data: unknown): boolean {
diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts
index d81ef11db..ca81ba209 100644
--- a/frontend/src/core/i18n/locales/en-US.ts
+++ b/frontend/src/core/i18n/locales/en-US.ts
@@ -1092,6 +1092,10 @@ export const enUS: Translations = {
haveAccountSignIn: "Already have an account? Sign in",
backToHome: "← Back to home",
networkError: "Network error. Please try again.",
+ serviceUnavailableTitle: "Service temporarily unavailable",
+ serviceUnavailableDescription:
+ "The Gateway is taking too long to respond. Check that it is running, then try again.",
+ retry: "Try again",
authFailed: "Authentication failed.",
errors: {
sso_failed: "SSO login failed. Please try again or use email login.",
diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts
index 918d172a0..9fc9a1824 100644
--- a/frontend/src/core/i18n/locales/types.ts
+++ b/frontend/src/core/i18n/locales/types.ts
@@ -862,6 +862,9 @@ export interface Translations {
haveAccountSignIn: string;
backToHome: string;
networkError: string;
+ serviceUnavailableTitle: string;
+ serviceUnavailableDescription: string;
+ retry: string;
authFailed: string;
errors: {
sso_failed: string;
diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts
index eb996cabf..6d6e30209 100644
--- a/frontend/src/core/i18n/locales/zh-CN.ts
+++ b/frontend/src/core/i18n/locales/zh-CN.ts
@@ -1043,6 +1043,10 @@ export const zhCN: Translations = {
haveAccountSignIn: "已有账号?立即登录",
backToHome: "← 返回首页",
networkError: "网络错误,请重试。",
+ serviceUnavailableTitle: "服务暂时不可用",
+ serviceUnavailableDescription:
+ "网关响应时间过长。请确认服务正在运行,然后重试。",
+ retry: "重试",
authFailed: "身份验证失败。",
errors: {
sso_failed: "SSO 登录失败,请重试或使用邮箱登录。",
diff --git a/frontend/tests/e2e-auth/auth-setup-recovery.spec.ts b/frontend/tests/e2e-auth/auth-setup-recovery.spec.ts
new file mode 100644
index 000000000..d6a725bc7
--- /dev/null
+++ b/frontend/tests/e2e-auth/auth-setup-recovery.spec.ts
@@ -0,0 +1,102 @@
+import { expect, test, type Page } from "@playwright/test";
+
+const SETUP_STATUS_URL = "**/api/v1/auth/setup-status";
+const SERVICE_UNAVAILABLE_TITLE = "Service temporarily unavailable";
+
+async function mockSetupStatusRecovery(
+ page: Page,
+ recoveredStatus: {
+ needs_setup: boolean;
+ registration_enabled?: boolean;
+ },
+) {
+ let requestCount = 0;
+
+ await page.route(SETUP_STATUS_URL, (route) => {
+ if (route.request().method() !== "GET") {
+ return route.fallback();
+ }
+
+ requestCount += 1;
+ if (requestCount === 1) {
+ return route.fulfill({
+ status: 503,
+ contentType: "application/json",
+ body: JSON.stringify({ detail: "Gateway unavailable" }),
+ });
+ }
+
+ return route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(recoveredStatus),
+ });
+ });
+
+ return () => requestCount;
+}
+
+test.describe("auth setup-status recovery", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.route("**/api/v1/auth/providers", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ providers: [] }),
+ }),
+ );
+ });
+
+ test("login restores registration after setup-status retry", async ({
+ page,
+ }) => {
+ const getRequestCount = await mockSetupStatusRecovery(page, {
+ needs_setup: false,
+ registration_enabled: true,
+ });
+
+ await page.goto("/login");
+
+ await expect(page.getByText(SERVICE_UNAVAILABLE_TITLE)).toBeVisible();
+ await expect(page.getByRole("button", { name: "Sign In" })).toBeEnabled();
+ await expect(
+ page.getByRole("button", { name: /Don't have an account/i }),
+ ).toHaveCount(0);
+ expect(getRequestCount()).toBe(1);
+
+ await page.getByRole("button", { name: "Try again" }).click();
+
+ await expect
+ .poll(getRequestCount, { message: "setup-status should be retried" })
+ .toBe(2);
+ await expect(page.getByText(SERVICE_UNAVAILABLE_TITLE)).toBeHidden();
+ await expect(
+ page.getByRole("button", { name: /Don't have an account/i }),
+ ).toBeVisible();
+ });
+
+ test("setup restores the administrator form after setup-status retry", async ({
+ page,
+ }) => {
+ const getRequestCount = await mockSetupStatusRecovery(page, {
+ needs_setup: true,
+ });
+
+ await page.goto("/setup");
+
+ await expect(page.getByText(SERVICE_UNAVAILABLE_TITLE)).toBeVisible();
+ await expect(page.getByRole("button", { name: "Try again" })).toBeVisible();
+ expect(getRequestCount()).toBe(1);
+
+ await page.getByRole("button", { name: "Try again" }).click();
+
+ await expect
+ .poll(getRequestCount, { message: "setup-status should be retried" })
+ .toBe(2);
+ await expect(page.getByText(SERVICE_UNAVAILABLE_TITLE)).toBeHidden();
+ await expect(
+ page.getByRole("button", { name: "Create Admin Account" }),
+ ).toBeVisible();
+ await expect(page.getByRole("textbox", { name: "Email" })).toBeVisible();
+ });
+});
diff --git a/frontend/tests/unit/core/auth/setup.test.ts b/frontend/tests/unit/core/auth/setup.test.ts
index 2937fc2a2..acd31a8c7 100644
--- a/frontend/tests/unit/core/auth/setup.test.ts
+++ b/frontend/tests/unit/core/auth/setup.test.ts
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, rs, test } from "@rstest/core";
+import { AUTH_REQUEST_TIMEOUT_MS } from "@/core/auth/constants";
import {
canCreateRegularAccount,
fetchSetupStatus,
@@ -7,8 +8,32 @@ import {
setupStatusFetchInit,
} from "@/core/auth/setup";
+function pendingUntilAborted(signal: AbortSignal): Promise {
+ return new Promise((_resolve, reject) => {
+ signal.addEventListener(
+ "abort",
+ () => {
+ reject(
+ signal.reason instanceof Error
+ ? signal.reason
+ : new Error("The request was aborted"),
+ );
+ },
+ { once: true },
+ );
+ });
+}
+
+function requestSignal(init?: RequestInit): AbortSignal {
+ if (!init?.signal) {
+ throw new Error("Expected fetch to receive an AbortSignal");
+ }
+ return init.signal;
+}
+
describe("auth setup helpers", () => {
afterEach(() => {
+ rs.useRealTimers();
rs.unstubAllGlobals();
});
@@ -20,7 +45,7 @@ describe("auth setup helpers", () => {
});
test("fetchSetupStatus uses the shared no-store request options", async () => {
- const fetchMock = rs.fn(() =>
+ const fetchMock = rs.fn((_input: RequestInfo | URL, _init?: RequestInit) =>
Promise.resolve(
new Response(JSON.stringify({ needs_setup: true }), {
status: 200,
@@ -33,7 +58,90 @@ describe("auth setup helpers", () => {
await expect(fetchSetupStatus()).resolves.toEqual({ needs_setup: true });
expect(fetchMock).toHaveBeenCalledWith(
"/api/v1/auth/setup-status",
- setupStatusFetchInit,
+ expect.objectContaining(setupStatusFetchInit),
+ );
+ expect(fetchMock.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal);
+ });
+
+ test("aborts a setup-status request that remains pending", async () => {
+ rs.useFakeTimers();
+ const fetchMock = rs.fn((_input: RequestInfo | URL, init?: RequestInit) =>
+ pendingUntilAborted(requestSignal(init)),
+ );
+ rs.stubGlobal("fetch", fetchMock);
+
+ const request = fetchSetupStatus();
+ const rejection = expect(request).rejects.toMatchObject({
+ name: "AbortError",
+ });
+
+ await rs.advanceTimersByTimeAsync(AUTH_REQUEST_TIMEOUT_MS);
+ await rejection;
+
+ expect(fetchMock.mock.calls[0]![1]!.signal!.aborted).toBe(true);
+ });
+
+ test("clears the timeout after a successful setup-status response", async () => {
+ rs.useFakeTimers();
+ const signals: AbortSignal[] = [];
+ const fetchMock = rs.fn((_input: RequestInfo | URL, init?: RequestInit) => {
+ signals.push(requestSignal(init));
+ return Promise.resolve(
+ new Response(JSON.stringify({ needs_setup: false }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ });
+ rs.stubGlobal("fetch", fetchMock);
+
+ await expect(fetchSetupStatus()).resolves.toEqual({ needs_setup: false });
+ await rs.advanceTimersByTimeAsync(AUTH_REQUEST_TIMEOUT_MS);
+
+ expect(signals[0]!.aborted).toBe(false);
+ });
+
+ test("a retry uses a fresh signal after the first request times out", async () => {
+ rs.useFakeTimers();
+ const signals: AbortSignal[] = [];
+ const fetchMock = rs.fn((_input: RequestInfo | URL, init?: RequestInit) => {
+ const signal = requestSignal(init);
+ signals.push(signal);
+ if (signals.length === 1) {
+ return pendingUntilAborted(signal);
+ }
+ return Promise.resolve(
+ new Response(JSON.stringify({ needs_setup: true }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ });
+ rs.stubGlobal("fetch", fetchMock);
+
+ const firstRequest = fetchSetupStatus();
+ const firstRejection = expect(firstRequest).rejects.toMatchObject({
+ name: "AbortError",
+ });
+ await rs.advanceTimersByTimeAsync(AUTH_REQUEST_TIMEOUT_MS);
+ await firstRejection;
+
+ await expect(fetchSetupStatus()).resolves.toEqual({ needs_setup: true });
+
+ expect(signals).toHaveLength(2);
+ expect(signals[1]!).not.toBe(signals[0]!);
+ expect(signals[0]!.aborted).toBe(true);
+ expect(signals[1]!.aborted).toBe(false);
+ });
+
+ test("preserves setup-status HTTP errors", async () => {
+ rs.stubGlobal(
+ "fetch",
+ rs.fn(() => Promise.resolve(new Response(null, { status: 503 }))),
+ );
+
+ await expect(fetchSetupStatus()).rejects.toThrow(
+ "setup-status failed: 503",
);
});
From 009b4ffcab6f5c9b030f2c405348cf4bd5bf7e6f Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Sun, 26 Jul 2026 10:34:32 +0800
Subject: [PATCH 20/35] build(deps-dev): bump postcss from 8.5.6 to 8.5.18 in
/frontend (#4464)
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.6 to 8.5.18.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.6...8.5.18)
---
updated-dependencies:
- dependency-name: postcss
dependency-version: 8.5.18
dependency-type: direct:development
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
frontend/package.json | 2 +-
frontend/pnpm-lock.yaml | 105 +++++++++++++++++++++++++++-------------
2 files changed, 73 insertions(+), 34 deletions(-)
diff --git a/frontend/package.json b/frontend/package.json
index bbb8e579a..b46524f4c 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -111,7 +111,7 @@
"eslint": "^9.23.0",
"eslint-config-next": "^15.2.3",
"happy-dom": "^20.11.1",
- "postcss": "^8.5.3",
+ "postcss": "^8.5.18",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^4.0.15",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 6922567d9..d03c515f3 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -280,8 +280,8 @@ importers:
specifier: ^20.11.1
version: 20.11.1
postcss:
- specifier: ^8.5.3
- version: 8.5.6
+ specifier: ^8.5.18
+ version: 8.5.23
prettier:
specifier: ^3.5.3
version: 3.8.1
@@ -757,89 +757,105 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@@ -999,36 +1015,42 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@napi-rs/simple-git-linux-arm64-musl@0.1.22':
resolution: {integrity: sha512-MOs7fPyJiU/wqOpKzAOmOpxJ/TZfP4JwmvPad/cXTOWYwwyppMlXFRms3i98EU3HOazI/wMU2Ksfda3+TBluWA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@napi-rs/simple-git-linux-ppc64-gnu@0.1.22':
resolution: {integrity: sha512-L59dR30VBShRUIZ5/cQHU25upNgKS0AMQ7537J6LCIUEFwwXrKORZKJ8ceR+s3Sr/4jempWVvMdjEpFDE4HYww==}
engines: {node: '>= 10'}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@napi-rs/simple-git-linux-s390x-gnu@0.1.22':
resolution: {integrity: sha512-4FHkPlCSIZUGC6HiADffbe6NVoTBMd65pIwcd40IDbtFKOgFMBA+pWRqKiQ21FERGH16Zed7XHJJoY3jpOqtmQ==}
engines: {node: '>= 10'}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@napi-rs/simple-git-linux-x64-gnu@0.1.22':
resolution: {integrity: sha512-Ei1tM5Ho/dwknF3pOzqkNW9Iv8oFzRxE8uOhrITcdlpxRxVrBVptUF6/0WPdvd7R9747D/q61QG/AVyWsWLFKw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@napi-rs/simple-git-linux-x64-musl@0.1.22':
resolution: {integrity: sha512-zRYxg7it0p3rLyEJYoCoL2PQJNgArVLyNavHW03TFUAYkYi5bxQ/UFNVpgxMaXohr5yu7qCBqeo9j4DWeysalg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@napi-rs/simple-git-win32-arm64-msvc@0.1.22':
resolution: {integrity: sha512-XGFR1fj+Y9cWACcovV2Ey/R2xQOZKs8t+7KHPerYdJ4PtjVzGznI4c2EBHXtdOIYvkw7tL5rZ7FN1HJKdD5Quw==}
@@ -1084,24 +1106,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@next/swc-linux-arm64-musl@16.2.6':
resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@next/swc-linux-x64-gnu@16.2.6':
resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@next/swc-linux-x64-musl@16.2.6':
resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@next/swc-win32-arm64-msvc@16.2.6':
resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==}
@@ -1729,24 +1755,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@resvg/resvg-js-linux-arm64-musl@2.6.2':
resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@resvg/resvg-js-linux-x64-gnu@2.6.2':
resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@resvg/resvg-js-linux-x64-musl@2.6.2':
resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@resvg/resvg-js-win32-arm64-msvc@2.6.2':
resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==}
@@ -1808,66 +1838,79 @@ packages:
resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.62.2':
resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==}
cpu: [arm]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.62.2':
resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.62.2':
resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.62.2':
resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==}
cpu: [loong64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.62.2':
resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==}
cpu: [loong64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.62.2':
resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.62.2':
resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==}
cpu: [ppc64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.62.2':
resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.62.2':
resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==}
cpu: [riscv64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.62.2':
resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.62.2':
resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.62.2':
resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-openbsd-x64@4.62.2':
resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==}
@@ -1931,21 +1974,25 @@ packages:
resolution: {integrity: sha512-pSI+npPQE/uDtiboqvcOIRJbEV2+B+H1xffmko/gw50la92oTUW60kVULFwsb6L0+GVCzIcwX3yq60GtYIn+Ug==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@rspack/binding-linux-arm64-musl@2.0.8':
resolution: {integrity: sha512-igjJ43yxWQ72GZqjDDZSSHax9/Vg+6rLMmOvFglTJUkQpB4Tyvu/YjW+WRjYj2xRw6blOjLxUSJWASvuSqqlvg==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@rspack/binding-linux-x64-gnu@2.0.8':
resolution: {integrity: sha512-zrkoEOnqj1hOEBO5T2I/2Ts2HSJsYFh1qXwMpK4dMJFGGNWDfNeUa6/LF5uq3VINF3JUl7RL47AgrucoSZJXPA==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@rspack/binding-linux-x64-musl@2.0.8':
resolution: {integrity: sha512-6CtDaGZjNDvJd9TBp7a9zABbrPORO21W96+3ZcGBn0YNUPUk4ARxIxrTTpeJ/1F41QDM8AYIkGDdqEYMqTYBsA==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@rspack/binding-wasm32-wasi@2.0.8':
resolution: {integrity: sha512-Yf4SiqTUroT5Ju+te0YAY2xxKOb35tECsO21v7hYyGa705wrgoAK/MmF7enOvs9GR1iZIqgiLD/wxsIxl8GjJw==}
@@ -2153,24 +2200,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
@@ -2602,41 +2653,49 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
+ libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -4196,24 +4255,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [musl]
lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
@@ -4538,16 +4601,6 @@ packages:
resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
hasBin: true
- nanoid@3.3.11:
- resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
- engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
- hasBin: true
-
- nanoid@3.3.12:
- resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
- engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
- hasBin: true
-
nanoid@3.3.16:
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -4856,12 +4909,8 @@ packages:
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
engines: {node: ^10 || ^12 || >=14}
- postcss@8.5.19:
- resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==}
- engines: {node: ^10 || ^12 || >=14}
-
- postcss@8.5.6:
- resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
+ postcss@8.5.23:
+ resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
engines: {node: ^10 || ^12 || >=14}
prelude-ls@1.2.1:
@@ -7830,7 +7879,7 @@ snapshots:
'@alloc/quick-lru': 5.2.0
'@tailwindcss/node': 4.1.18
'@tailwindcss/oxide': 4.1.18
- postcss: 8.5.6
+ postcss: 8.5.23
tailwindcss: 4.1.18
'@tanstack/query-core@5.90.20': {}
@@ -8360,7 +8409,7 @@ snapshots:
'@vue/shared': 3.5.28
estree-walker: 2.0.2
magic-string: 0.30.21
- postcss: 8.5.19
+ postcss: 8.5.23
source-map-js: 1.2.1
'@vue/compiler-ssr@3.5.28':
@@ -10834,10 +10883,6 @@ snapshots:
mustache@4.2.0: {}
- nanoid@3.3.11: {}
-
- nanoid@3.3.12: {}
-
nanoid@3.3.16: {}
nanoid@5.1.6: {}
@@ -11246,20 +11291,14 @@ snapshots:
postcss-value-parser@4.2.0: {}
postcss@8.4.31:
- dependencies:
- nanoid: 3.3.12
- picocolors: 1.1.1
- source-map-js: 1.2.1
-
- postcss@8.5.19:
dependencies:
nanoid: 3.3.16
picocolors: 1.1.1
source-map-js: 1.2.1
- postcss@8.5.6:
+ postcss@8.5.23:
dependencies:
- nanoid: 3.3.11
+ nanoid: 3.3.16
picocolors: 1.1.1
source-map-js: 1.2.1
@@ -12311,7 +12350,7 @@ snapshots:
esbuild: 0.27.7
fdir: 6.5.0(picomatch@4.0.5)
picomatch: 4.0.5
- postcss: 8.5.19
+ postcss: 8.5.23
rollup: 4.62.2
tinyglobby: 0.2.17
optionalDependencies:
From d1aeea2c3e1c313351b1eeb14f473c4cf1eb85a8 Mon Sep 17 00:00:00 2001
From: Aari
Date: Sun, 26 Jul 2026 10:44:50 +0800
Subject: [PATCH 21/35] fix(checkpoint): unwrap Overwrite first writes into
empty channels (#4383)
* fix(gateway): stop persisting Overwrite wrappers into empty reducer channels on branch
Thread branching (and POST /state on a never-written channel) wraps copied
reducer values in Overwrite. Upstream BinaryOperatorAggregate.update seeds
an empty (MISSING) channel with values[0] verbatim without unwrapping, so
Union-typed channels (sandbox/goal/todos/promoted) stored the wrapper
literally and the next consumer crashed with TypeError: 'Overwrite' object
is not subscriptable (#4380). Patch the channel to unwrap the first write
(mirroring DeltaChannel semantics), and stop copying thread-scoped channels
(sandbox, thread_data) into branches: the parent's sandbox_id would bind
the branch to the parent's workspace and release lifecycle.
* refactor(checkpoint): drop private _get_overwrite import for a local Overwrite check
Importing langgraph's underscored _get_overwrite at module top level meant an
upstream refactor that drops it - plausibly the same release that fixes the
bug - would fail this module's import and crash startup before the probe can
stand the patch down. Replace it with a local helper on the public Overwrite
type, and fix two test docstring nits.
* refactor(checkpoint): write patch flags via their constants to avoid drift
Both saver patches read their "already patched" idempotence flag through a
module constant (_PATCH_FLAG / _BINOP_PATCH_FLAG) but wrote it as a hard-coded
attribute literal, so renaming the constant would silently break the guard and
double-apply the patch. Write via the same constant (setattr), dropping the
now-unneeded attr-defined ignores.
---------
Co-authored-by: Willem Jiang
---
backend/AGENTS.md | 4 +-
backend/app/gateway/routers/threads.py | 10 ++
.../harness/deerflow/checkpoint_patches.py | 97 +++++++++++++-
backend/tests/test_checkpoint_patches.py | 91 ++++++++++++++
backend/tests/test_threads_router.py | 118 ++++++++++++++++++
5 files changed, 316 insertions(+), 4 deletions(-)
create mode 100644 backend/tests/test_checkpoint_patches.py
diff --git a/backend/AGENTS.md b/backend/AGENTS.md
index 88677c7fa..22f08a28a 100644
--- a/backend/AGENTS.md
+++ b/backend/AGENTS.md
@@ -432,7 +432,7 @@ Localhost persistence deliberately reads the direct request `Host` and ignores `
| **Integrations** (`/api/integrations`) | `GET /lark/status` - inspect managed Lark/Feishu CLI integration state, including `sandbox_runtime_mode` / `sandbox_runtime_ready` (whether `lark-cli` will actually be present in the sandbox at chat time); `POST /lark/install` - admin-only install of the official `lark-*` managed skill pack; `POST /lark/config/start` and `/lark/config/complete` - internal first-time Lark connection setup; `POST /lark/auth/start` and `/lark/auth/complete` - browser device-flow user authorization without terminal access, with optional `domains` / exact `scope` for incremental permission grants |
| **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data |
| **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete |
-| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; `POST /branches` - create a new main-thread branch from a completed assistant turn checkpoint and, when an addressable pre-user replay checkpoint exists, materialize it into the branch namespace so the inherited response remains regeneratable. Workspace files are not checkpointed, so the branch only best-effort copies the current workspace when branching from the **latest** turn (`workspace_clone_mode="current_thread_best_effort"`); branching from an older/historical turn skips the copy (`workspace_clone_mode="skipped_historical_turn"`) so the branch never inherits files that only exist in a later timeline. Branch creation also seeds the new thread's run-event feed from the branch checkpoint's visible messages (`history_seed_mode` in the response): the thread feed reads run_events, not checkpoints, so without the seed the inherited history disappears from the UI after the branch's first run (#4380). Seeded rows are grouped into one synthetic run per inherited turn (`branch-seed-{thread_id}-{n}`, a new turn opening at every persisted human message, including an allowlisted hidden `ask_clarification` reply) because `run_id` is a turn identity to the feed's consumers, not a provenance tag: regenerating an inherited answer supersedes that row's whole `run_id` in `GET /messages/page`, so one shared id for the entire seed deleted the complete inherited history on a branch's first regenerate (#4458); `GET /goal`, `PUT /goal`, `DELETE /goal` - read, set, and clear the active thread goal; `POST /compact` - manually summarize older active context into `summary_text` and retain the recent message window, blocked while a run is in flight; unexpected failures are logged server-side and return a generic 500 detail |
+| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; `POST /branches` - create a new main-thread branch from a completed assistant turn checkpoint and, when an addressable pre-user replay checkpoint exists, materialize it into the branch namespace so the inherited response remains regeneratable. Workspace files are not checkpointed, so the branch only best-effort copies the current workspace when branching from the **latest** turn (`workspace_clone_mode="current_thread_best_effort"`); branching from an older/historical turn skips the copy (`workspace_clone_mode="skipped_historical_turn"`) so the branch never inherits files that only exist in a later timeline. Thread-scoped runtime channels (`sandbox`, `thread_data`) are not copied onto the branch: the parent's `sandbox_id` binds path mappings and the release lifecycle to the parent's workspace, so the branch lazily acquires its own sandbox instead. Branch creation also seeds the new thread's run-event feed from the branch checkpoint's visible messages (`history_seed_mode` in the response): the thread feed reads run_events, not checkpoints, so without the seed the inherited history disappears from the UI after the branch's first run (#4380). Seeded rows are grouped into one synthetic run per inherited turn (`branch-seed-{thread_id}-{n}`, a new turn opening at every persisted human message, including an allowlisted hidden `ask_clarification` reply) because `run_id` is a turn identity to the feed's consumers, not a provenance tag: regenerating an inherited answer supersedes that row's whole `run_id` in `GET /messages/page`, so one shared id for the entire seed deleted the complete inherited history on a branch's first regenerate (#4458); `GET /goal`, `PUT /goal`, `DELETE /goal` - read, set, and clear the active thread goal; `POST /compact` - manually summarize older active context into `summary_text` and retain the recent message window, blocked while a run is in flight; unexpected failures are logged server-side and return a generic 500 detail |
| **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types |
| **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)`) |
@@ -872,7 +872,7 @@ Checkpointer storage runs in one of two channel modes, selected by `checkpoint_c
**Where things live**:
- `runtime/checkpoint_mode.py` — mode freeze, marker injection, delta detection, compatibility gate, both error types
- `runtime/checkpoint_state.py` — `CheckpointStateAccessor`, `build_state_mutation_graph`, `RollbackPoint`
-- `checkpoint_patches.py` (package root) — saver patches: delta-history folding for `InMemorySaver` (delegating to the base walk), stable message IDs across materialization, upstream first-write drop fix
+- `checkpoint_patches.py` (package root) — checkpoint-machinery patches: delta-history folding for `InMemorySaver` (delegating to the base walk), stable message IDs across materialization, upstream first-write drop fix, and `BinaryOperatorAggregate` unwrapping an `Overwrite` first write into an empty (MISSING) channel — Union-typed reducer channels (`sandbox`/`goal`/`todos`/`promoted`) have no constructible default, so a replace-style write into a fresh branch thread or a never-written channel stored the wrapper literally and crashed the next consumer (#4380; probe-guarded, stands down if upstream fixes it)
- `agents/thread_state.py` — `ThreadState`/`DeltaThreadState`, `DELTA_MESSAGES_FIELD` (`DeltaChannel` with `snapshot_frequency=1000`), schema adaptation helpers
- `runtime/context_compaction.py` — compaction via accessor + mutation graph (reference consumer)
- Tests: `tests/test_checkpoint_mode.py` (freeze/detect/gate), `tests/test_checkpoint_state.py` (accessor/mutation graph), `tests/test_delta_channel_checkpointers.py` (saver parity), `tests/test_threads_checkpoint_mode.py`, `tests/test_gateway_checkpoint_mode.py` (dual-mode e2e parity), `tests/test_context_compaction.py` (mutation-graph write, no scheduling), `tests/test_run_worker_rollback.py`
diff --git a/backend/app/gateway/routers/threads.py b/backend/app/gateway/routers/threads.py
index 0e323a18b..b3cf2d5da 100644
--- a/backend/app/gateway/routers/threads.py
+++ b/backend/app/gateway/routers/threads.py
@@ -97,6 +97,14 @@ def _checkpoint_mode_http_error(exc: Exception, thread_id: str) -> HTTPException
_SERVER_RESERVED_METADATA_KEYS: frozenset[str] = frozenset({"owner_id", "user_id"})
_SIDECAR_METADATA_KEY = "deerflow_sidecar"
_BRANCH_METADATA_KEY = "deerflow_branch"
+# Thread-scoped runtime channels a branch must NOT inherit from its parent:
+# ``sandbox.sandbox_id`` binds path mappings and the release lifecycle to the
+# *parent* thread, so copying it would make the branch read/write the parent's
+# workspace (bypassing the per-branch user-data clone) and release the
+# parent's sandbox after its first run; the branch lazily acquires its own
+# sandbox keyed by its own thread_id instead. ``thread_data`` is recomputed
+# from the branch's thread_id by ThreadDataMiddleware on every run.
+_BRANCH_EXCLUDED_CHANNELS = frozenset({"sandbox", "thread_data"})
_BRANCH_HISTORY_SCAN_LIMIT = 200
_BRANCH_HISTORY_RAW_SCAN_LIMIT = _BRANCH_HISTORY_SCAN_LIMIT * 2
@@ -830,6 +838,8 @@ async def branch_thread(thread_id: str, body: ThreadBranchRequest, request: Requ
def branch_values(source_snapshot: Any) -> dict[str, Any]:
values: dict[str, Any] = {}
for key, value in dict(source_snapshot.values).items():
+ if key in _BRANCH_EXCLUDED_CHANNELS:
+ continue
if key in branch_reducer_fields:
values[key] = Overwrite(list(value) if key == "messages" and isinstance(value, list) else value)
else:
diff --git a/backend/packages/harness/deerflow/checkpoint_patches.py b/backend/packages/harness/deerflow/checkpoint_patches.py
index c82cc573c..180bbcea2 100644
--- a/backend/packages/harness/deerflow/checkpoint_patches.py
+++ b/backend/packages/harness/deerflow/checkpoint_patches.py
@@ -1,4 +1,4 @@
-"""Compatibility patches for third-party checkpoint savers.
+"""Compatibility patches for third-party checkpoint machinery.
Lives at the top-level package (not ``deerflow.runtime``) so it can be
imported from ``deerflow.agents.thread_state`` without pulling in the heavy
@@ -12,10 +12,14 @@ from __future__ import annotations
import importlib.metadata
import logging
+from collections.abc import Sequence
from typing import Any
+from langgraph.channels.binop import BinaryOperatorAggregate
from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.checkpoint.memory import InMemorySaver
+from langgraph.errors import ErrorCode, InvalidUpdateError, create_error_message
+from langgraph.types import Overwrite
from packaging.version import Version
logger = logging.getLogger(__name__)
@@ -91,9 +95,98 @@ def ensure_inmemory_delta_history_patch() -> None:
return
InMemorySaver.get_delta_channel_history = _get_delta_channel_history_via_base # type: ignore[method-assign]
InMemorySaver.aget_delta_channel_history = _aget_delta_channel_history_via_base # type: ignore[method-assign]
- InMemorySaver._deerflow_delta_history_patched = True # type: ignore[attr-defined]
+ setattr(InMemorySaver, _PATCH_FLAG, True)
except (AttributeError, TypeError):
logger.warning("Failed to apply the InMemorySaver delta-history patch; leaving the upstream implementation untouched.", exc_info=True)
+_BINOP_PATCH_FLAG = "_deerflow_overwrite_first_write_patched"
+_unpatched_binop_update = BinaryOperatorAggregate.update
+
+
+def _as_overwrite(value: Any) -> tuple[bool, Any]:
+ """Local stand-in for langgraph's private ``_get_overwrite``.
+
+ Matches only the public ``Overwrite`` *class* form - the sole form
+ DeerFlow's write paths produce for these Union channels (the branch and
+ ``/state`` routes wrap replace-style writes in ``Overwrite(...)``).
+ Avoiding the underscored ``_get_overwrite`` import keeps an upstream
+ refactor that drops it - plausibly the very release that fixes the bug -
+ from failing this module's import and crashing startup before the probe
+ can stand the patch down. The dict sentinel form upstream also accepts is
+ an internal serialization detail DeerFlow never emits into these channels.
+ """
+ if isinstance(value, Overwrite):
+ return True, value.value
+ return False, None
+
+
+def _binop_first_write_stores_overwrite_wrapper() -> bool:
+ """Probe whether upstream still stores an Overwrite first write literally.
+
+ Uses a Union-typed channel (no constructible default, so it starts
+ MISSING) - the same shape as ``ThreadState``'s ``sandbox`` / ``goal`` /
+ ``todos`` / ``promoted`` channels.
+ """
+ channel = BinaryOperatorAggregate(dict | None, lambda existing, new: new)
+ channel.key = "deerflow-overwrite-probe"
+ channel.update([Overwrite({"probe": True})])
+ return isinstance(channel.get(), Overwrite)
+
+
+def _binop_update_unwrapping_empty_channel(self: Any, values: Sequence[Any]) -> bool:
+ """``BinaryOperatorAggregate.update`` that unwraps an Overwrite first write.
+
+ Only intercepts the empty-channel + leading-Overwrite case; everything
+ else delegates to the upstream implementation. The intercepted case
+ mirrors upstream's own post-Overwrite batch semantics: later plain values
+ are skipped and a second Overwrite raises ``InvalidUpdateError``.
+ """
+ if not self.is_available() and values:
+ is_overwrite, overwrite_value = _as_overwrite(values[0])
+ if is_overwrite:
+ self.value = overwrite_value
+ for value in values[1:]:
+ if _as_overwrite(value)[0]:
+ msg = create_error_message(
+ message="Can receive only one Overwrite value per super-step.",
+ error_code=ErrorCode.INVALID_CONCURRENT_GRAPH_UPDATE,
+ )
+ raise InvalidUpdateError(msg)
+ return True
+ return _unpatched_binop_update(self, values)
+
+
+def ensure_binop_overwrite_first_write_patch() -> None:
+ """Fix ``Overwrite`` first writes being stored literally on empty channels.
+
+ Upstream ``BinaryOperatorAggregate.update`` seeds an empty channel
+ (``self.value is MISSING``) with ``values[0]`` verbatim - without the
+ Overwrite unwrapping the rest of the method applies. Channels whose type
+ is a Union (``SandboxState | None``, ``GoalState | None``, ...) have no
+ constructible default, so they start MISSING; a replace-style write into
+ a fresh thread (thread branching) or a never-written channel (state
+ update) then persists the ``Overwrite`` wrapper itself into the
+ checkpoint, and the next consumer crashes with ``TypeError: 'Overwrite'
+ object is not subscriptable`` (#4380). ``DeltaChannel.update`` already
+ unwraps in the same situation, so this also removes a behavioral
+ inconsistency between the two reducer channel types.
+
+ Idempotent. Guarded by a behavioral probe instead of a version pin: if a
+ future LangGraph unwraps the first write itself, the probe reports the
+ bug as absent and the patch stands down.
+ """
+ if getattr(BinaryOperatorAggregate, _BINOP_PATCH_FLAG, False):
+ return
+ try:
+ if not _binop_first_write_stores_overwrite_wrapper():
+ # Upstream unwraps the first write itself: nothing to patch.
+ return
+ BinaryOperatorAggregate.update = _binop_update_unwrapping_empty_channel # type: ignore[method-assign]
+ setattr(BinaryOperatorAggregate, _BINOP_PATCH_FLAG, True)
+ except Exception:
+ logger.warning("Failed to apply the BinaryOperatorAggregate Overwrite first-write patch; leaving the upstream implementation untouched.", exc_info=True)
+
+
ensure_inmemory_delta_history_patch()
+ensure_binop_overwrite_first_write_patch()
diff --git a/backend/tests/test_checkpoint_patches.py b/backend/tests/test_checkpoint_patches.py
new file mode 100644
index 000000000..0a25bdf95
--- /dev/null
+++ b/backend/tests/test_checkpoint_patches.py
@@ -0,0 +1,91 @@
+"""Guards for the BinaryOperatorAggregate first-write Overwrite patch (#4380).
+
+Upstream ``BinaryOperatorAggregate.update`` has a fast path for empty channels
+(``self.value is MISSING``) that stores the first incoming value without
+unwrapping ``Overwrite``. Reducer channels whose type is a Union
+(``SandboxState | None``, ``GoalState | None``, ...) have no constructible
+default, so they start MISSING — a replace-style write into a fresh thread
+(thread branching) or a never-written channel (state update) persists the
+wrapper literally, and the next consumer crashes with ``TypeError:
+'Overwrite' object is not subscriptable``. ``DeltaChannel`` already unwraps in
+the same situation; the patch aligns ``BinaryOperatorAggregate`` with it.
+"""
+
+import pytest
+from langgraph.channels.binop import BinaryOperatorAggregate
+from langgraph.errors import InvalidUpdateError
+from langgraph.types import Overwrite
+
+import deerflow.agents.thread_state # noqa: F401 - applies checkpoint patches at import
+from deerflow import checkpoint_patches
+
+
+def _replace(existing, new):
+ return new
+
+
+def _empty_union_channel() -> BinaryOperatorAggregate:
+ """A channel shaped like ``sandbox``/``goal``/``todos``: Union type, starts MISSING."""
+ channel = BinaryOperatorAggregate(dict | None, _replace)
+ channel.key = "probe"
+ return channel
+
+
+def test_first_write_overwrite_unwraps_into_empty_channel() -> None:
+ channel = _empty_union_channel()
+ channel.update([Overwrite({"sandbox_id": "local:thread-2"})])
+
+ stored = channel.get()
+ assert not isinstance(stored, Overwrite)
+ # The exact read that crashed in #4380 (SandboxMiddleware.aafter_agent).
+ assert stored["sandbox_id"] == "local:thread-2"
+
+
+def test_overwrite_into_populated_channel_still_replaces() -> None:
+ channel = _empty_union_channel()
+ channel.update([{"sandbox_id": "old"}])
+ channel.update([Overwrite({"sandbox_id": "new"})])
+
+ assert channel.get() == {"sandbox_id": "new"}
+
+
+def test_plain_first_write_still_seeds_then_reduces() -> None:
+ channel = _empty_union_channel()
+ channel.update([{"sandbox_id": "first"}])
+ assert channel.get() == {"sandbox_id": "first"}
+
+ channel.update([{"sandbox_id": "second"}])
+ assert channel.get() == {"sandbox_id": "second"}
+
+
+def test_values_after_first_write_overwrite_are_ignored() -> None:
+ """Upstream semantics: after an Overwrite, later plain values in the batch are skipped."""
+ channel = _empty_union_channel()
+ channel.update([Overwrite({"sandbox_id": "kept"}), {"sandbox_id": "ignored"}])
+
+ assert channel.get() == {"sandbox_id": "kept"}
+
+
+def test_double_overwrite_in_one_batch_raises_on_empty_channel() -> None:
+ channel = _empty_union_channel()
+ with pytest.raises(InvalidUpdateError):
+ channel.update([Overwrite({"sandbox_id": "a"}), Overwrite({"sandbox_id": "b"})])
+
+
+def test_binop_overwrite_patch_is_active() -> None:
+ """The compatibility patch must be applied in every test/app process."""
+ assert getattr(BinaryOperatorAggregate, checkpoint_patches._BINOP_PATCH_FLAG, False) is True
+ assert BinaryOperatorAggregate.update is checkpoint_patches._binop_update_unwrapping_empty_channel
+
+
+def test_binop_overwrite_patch_stands_down_when_upstream_fixed(monkeypatch) -> None:
+ """If upstream unwraps the first write itself, the patch must not reinstall."""
+ monkeypatch.setattr(checkpoint_patches, "_binop_first_write_stores_overwrite_wrapper", lambda: False)
+ monkeypatch.delattr(BinaryOperatorAggregate, checkpoint_patches._BINOP_PATCH_FLAG, raising=False)
+ sentinel = object()
+ monkeypatch.setattr(BinaryOperatorAggregate, "update", sentinel)
+
+ checkpoint_patches.ensure_binop_overwrite_first_write_patch()
+
+ assert getattr(BinaryOperatorAggregate, checkpoint_patches._BINOP_PATCH_FLAG, False) is False
+ assert BinaryOperatorAggregate.update is sentinel
diff --git a/backend/tests/test_threads_router.py b/backend/tests/test_threads_router.py
index 99a937a26..bc751f12f 100644
--- a/backend/tests/test_threads_router.py
+++ b/backend/tests/test_threads_router.py
@@ -2054,6 +2054,124 @@ def test_branch_history_seed_failure_keeps_branch_usable(monkeypatch) -> None:
assert branch_response.json()["history_seed_mode"] == "failed"
+async def _seed_union_channel_source(checkpointer, custom_factory, mode, source_thread_id):
+ """Seed a completed turn plus Union-typed reducer channels (sandbox/goal/todos)."""
+ accessor = CheckpointStateAccessor.bind(custom_factory(), checkpointer, mode=mode)
+ config = {"configurable": {"thread_id": source_thread_id, "checkpoint_ns": ""}}
+ await accessor.aupdate(
+ config,
+ {"messages": [HumanMessage(id="h1", content="question")], "goal": {"objective": "ship the fix"}},
+ as_node="model",
+ )
+ await accessor.aupdate(
+ config,
+ {
+ "messages": [AIMessage(id="a1", content="answer")],
+ "todos": [{"content": "write tests", "status": "pending"}],
+ "sandbox": {"sandbox_id": "local:parent-thread"},
+ "thread_data": {"workspace_path": "/parent/workspace"},
+ },
+ as_node="model",
+ )
+
+
+def _branch_union_channel_thread(monkeypatch, mode):
+ """Drive POST /branches on a source seeded with Union-typed channels; return branch values."""
+ app, _store, checkpointer = _build_thread_app()
+ custom_factory = _wire_extension_agent(monkeypatch, app, checkpointer, mode)
+ source_thread_id = f"union-branch-source-{mode}"
+
+ with TestClient(app) as client:
+ created = client.post(
+ "/api/threads",
+ json={"thread_id": source_thread_id, "metadata": {}, "assistant_id": "extension-agent"},
+ )
+ assert created.status_code == 200, created.text
+
+ asyncio.run(_seed_union_channel_source(checkpointer, custom_factory, mode, source_thread_id))
+
+ branch_response = client.post(
+ f"/api/threads/{source_thread_id}/branches",
+ json={"message_id": "a1", "message_ids": ["a1"]},
+ )
+ assert branch_response.status_code == 200, branch_response.text
+ branch_thread_id = branch_response.json()["thread_id"]
+
+ async def materialize():
+ accessor = CheckpointStateAccessor.bind(custom_factory(), checkpointer, mode=mode)
+ snapshot = await accessor.aget({"configurable": {"thread_id": branch_thread_id, "checkpoint_ns": ""}})
+ return snapshot.values
+
+ return asyncio.run(materialize())
+
+
+@pytest.mark.parametrize("mode", ["full", "delta"])
+def test_branch_copies_union_typed_reducer_channels_as_plain_values(monkeypatch, mode) -> None:
+ """Branching must not persist Overwrite wrappers into the fresh thread (#4380).
+
+ Union-typed reducer channels (``goal``, ``todos``, ``promoted``,
+ ``sandbox``) have no constructible default, so they start MISSING on the
+ branch thread; an ``Overwrite`` first write that isn't unwrapped is stored
+ literally and the next consumer crashes with ``TypeError: 'Overwrite'
+ object is not subscriptable``.
+ """
+ branch_values = _branch_union_channel_thread(monkeypatch, mode)
+
+ # The exact crash shape from #4380: subscripting the copied channel value.
+ assert branch_values["goal"]["objective"] == "ship the fix"
+ assert branch_values["todos"] == [{"content": "write tests", "status": "pending"}]
+ assert not any(isinstance(value, Overwrite) for value in branch_values.values())
+
+
+@pytest.mark.parametrize("mode", ["full", "delta"])
+def test_branch_does_not_inherit_thread_scoped_channels(monkeypatch, mode) -> None:
+ """The branch must acquire its own sandbox and thread paths, not the parent's.
+
+ ``sandbox.sandbox_id`` binds path mappings and the release lifecycle to
+ the parent thread, so inheriting it would make the branch read/write the
+ parent's workspace and release the parent's sandbox after its first run;
+ ``thread_data`` is recomputed from the branch's own thread_id by
+ ThreadDataMiddleware on every run.
+ """
+ branch_values = _branch_union_channel_thread(monkeypatch, mode)
+
+ assert branch_values.get("sandbox") is None
+ assert branch_values.get("thread_data") is None
+
+
+@pytest.mark.parametrize("mode", ["full", "delta"])
+def test_update_thread_state_overwrite_into_never_written_channel(monkeypatch, mode) -> None:
+ """POST /state must store a plain value when the reducer channel was never written.
+
+ Same mechanism as the branch case (#4380): ``goal`` starts MISSING on a
+ thread that never wrote it, and the endpoint's replace-style ``Overwrite``
+ wrapping must not be persisted literally.
+ """
+ app, _store, checkpointer = _build_thread_app()
+ custom_factory = _wire_extension_agent(monkeypatch, app, checkpointer, mode)
+ source_thread_id = f"never-written-goal-{mode}"
+
+ with TestClient(app) as client:
+ created = client.post(
+ "/api/threads",
+ json={"thread_id": source_thread_id, "metadata": {}, "assistant_id": "extension-agent"},
+ )
+ assert created.status_code == 200, created.text
+
+ asyncio.run(_seed_extension_source(checkpointer, custom_factory, mode, source_thread_id))
+
+ update_response = client.post(
+ f"/api/threads/{source_thread_id}/state",
+ json={"values": {"goal": {"objective": "finish"}}},
+ )
+ assert update_response.status_code == 200, update_response.text
+ assert update_response.json()["values"]["goal"] == {"objective": "finish"}
+
+ read_response = client.get(f"/api/threads/{source_thread_id}/state")
+ assert read_response.status_code == 200, read_response.text
+ assert read_response.json()["values"]["goal"] == {"objective": "finish"}
+
+
def test_update_thread_state_rejects_unknown_state_fields(monkeypatch) -> None:
"""Unknown fields fail 422 instead of a false-success 200."""
app, _store, checkpointer = _build_thread_app()
From dd3c5a179892332b3ed4e1d706ea811f805f8955 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Sun, 26 Jul 2026 10:51:16 +0800
Subject: [PATCH 22/35] build(deps): bump next from 16.2.6 to 16.2.11 in
/frontend (#4463)
Bumps [next](https://github.com/vercel/next.js) from 16.2.6 to 16.2.11.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.6...v16.2.11)
---
updated-dependencies:
- dependency-name: next
dependency-version: 16.2.11
dependency-type: direct:production
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
frontend/package.json | 2 +-
frontend/pnpm-lock.yaml | 153 ++++++++++++++++++++--------------------
2 files changed, 78 insertions(+), 77 deletions(-)
diff --git a/frontend/package.json b/frontend/package.json
index b46524f4c..f7a234725 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -72,7 +72,7 @@
"lucide-react": "^0.562.0",
"motion": "^12.26.2",
"nanoid": "^5.1.6",
- "next": "^16.2.6",
+ "next": "^16.2.11",
"next-themes": "^0.4.6",
"nextra": "^4.6.1",
"nextra-theme-docs": "^4.6.1",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index d03c515f3..0d9568646 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -168,17 +168,17 @@ importers:
specifier: ^5.1.6
version: 5.1.6
next:
- specifier: ^16.2.6
- version: 16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ specifier: ^16.2.11
+ version: 16.2.11(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
nextra:
specifier: ^4.6.1
- version: 4.6.1(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
+ version: 4.6.1(next@16.2.11(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
nextra-theme-docs:
specifier: ^4.6.1
- version: 4.6.1(@types/react@19.2.13)(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nextra@4.6.1(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
+ version: 4.6.1(@types/react@19.2.13)(next@16.2.11(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nextra@4.6.1(next@16.2.11(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
nuxt-og-image:
specifier: ^5.1.13
version: 5.1.13(@unhead/vue@2.1.4(vue@3.5.28(typescript@5.9.3)))(unstorage@1.17.4)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3))(vue@3.5.28(typescript@5.9.3))
@@ -474,6 +474,9 @@ packages:
'@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
+ '@emnapi/runtime@1.11.3':
+ resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
+
'@emnapi/wasi-threads@1.1.0':
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
@@ -1083,60 +1086,60 @@ packages:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
- '@next/env@16.2.6':
- resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==}
+ '@next/env@16.2.11':
+ resolution: {integrity: sha512-0do5A3BJ2gxWr0ZCMcD6BhW+e595jyxdTl3rXTS6lOtD8ektMiW6CO+EPwt1Eca1DBnm90r/7GdiKWBKxH++DA==}
'@next/eslint-plugin-next@15.5.12':
resolution: {integrity: sha512-+ZRSDFTv4aC96aMb5E41rMjysx8ApkryevnvEYZvPZO52KvkqP5rNExLUXJFr9P4s0f3oqNQR6vopCZsPWKDcQ==}
- '@next/swc-darwin-arm64@16.2.6':
- resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==}
+ '@next/swc-darwin-arm64@16.2.11':
+ resolution: {integrity: sha512-wryL4pjKmDwGv2ox6+GZDFxvmtSRLqApBR8kL1j4+vhB7Z5vJC/zAnXpiR9Xkfzl0AS8WLMnsuGV/UKI67/rrw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
- '@next/swc-darwin-x64@16.2.6':
- resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==}
+ '@next/swc-darwin-x64@16.2.11':
+ resolution: {integrity: sha512-aZl2j4f/fLyjQvOhv0Oe9UaMAQHolYpKhctsoYzplSumKJKPUmgjcf6545aBtysLTcu994TREd0+pSgNE4ohmg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
- '@next/swc-linux-arm64-gnu@16.2.6':
- resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==}
+ '@next/swc-linux-arm64-gnu@16.2.11':
+ resolution: {integrity: sha512-5jEriyEnH/LWFy27L2ZG0XaLlyEJIjhsImEsiS9P563PKEVp2BVups/xfOucIrsvVntp11oNcZwjHvaDPYVB5g==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@next/swc-linux-arm64-musl@16.2.6':
- resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==}
+ '@next/swc-linux-arm64-musl@16.2.11':
+ resolution: {integrity: sha512-eIjcpx2fnnFSSkZDbTxy74KnokUXDjfoLClpWelfgHLf621aTqswhwXQ7GkD5K5rplrS6LZ/Bj+mVuvzluBOEg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@next/swc-linux-x64-gnu@16.2.6':
- resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==}
+ '@next/swc-linux-x64-gnu@16.2.11':
+ resolution: {integrity: sha512-8WgzpaWMs46qJT9kiV47cje86L0x/Mu9t8/Gwj+pnbgW3rETVfCnaScPjlYUwNScpOozdcIMHWmAvuZJUonR2w==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@next/swc-linux-x64-musl@16.2.6':
- resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==}
+ '@next/swc-linux-x64-musl@16.2.11':
+ resolution: {integrity: sha512-I3UgPds7G4ZYnTb/H+5GBGuUT2DhAk6j0mL6A4s63RjFs74wB2hOWP0vaxsK+3NJraExt3eYEPQ/UtT0x/64Nw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
- '@next/swc-win32-arm64-msvc@16.2.6':
- resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==}
+ '@next/swc-win32-arm64-msvc@16.2.11':
+ resolution: {integrity: sha512-n89CjtcThnjrwgJMAiI5xbqwLY51zvwC9tSlArmVndAJLYVl9T9UAdlkXTmZvE++idoXe8KdglQlhNRdUp1c6g==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
- '@next/swc-win32-x64-msvc@16.2.6':
- resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==}
+ '@next/swc-win32-x64-msvc@16.2.11':
+ resolution: {integrity: sha512-md8CLNggS1Dx9pUgApzps5uAf+N8GN9xywzmNx9vHAWo94HtBwCCqkSnhIrdfQe83Dhz8Lfo/20Nb1Zxal092w==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -2128,9 +2131,6 @@ packages:
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
- '@swc/helpers@0.5.21':
- resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==}
-
'@swc/helpers@0.5.23':
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
@@ -2895,8 +2895,8 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
- baseline-browser-mapping@2.10.29:
- resolution: {integrity: sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==}
+ baseline-browser-mapping@2.11.3:
+ resolution: {integrity: sha512-sbT0Ui/CZwyAyy7icT1Gw5P1LKRlFaHwaF6tDCW5YHq2X5SeeZFphBuIagopSfwSSZq3sQcbmEL072yphxm7ew==}
engines: {node: '>=6.0.0'}
hasBin: true
@@ -2957,8 +2957,8 @@ packages:
camelize@1.0.1:
resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==}
- caniuse-lite@1.0.30001792:
- resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==}
+ caniuse-lite@1.0.30001806:
+ resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==}
canvas-confetti@1.9.4:
resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
@@ -4629,8 +4629,8 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
- next@16.2.6:
- resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==}
+ next@16.2.11:
+ resolution: {integrity: sha512-B339zaqbyK8cmxhoAvLrcwoabwCP1wz21zSzfqxqXAemTu2BXnH7tQnfcglKv1vnMUIDBc+Hth7XODQriTZiRQ==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -5264,8 +5264,8 @@ packages:
engines: {node: '>=10'}
hasBin: true
- semver@7.8.0:
- resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==}
+ semver@7.8.5:
+ resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
hasBin: true
@@ -6301,6 +6301,11 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@emnapi/runtime@1.11.3':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
'@emnapi/wasi-threads@1.1.0':
dependencies:
tslib: 2.8.1
@@ -6578,7 +6583,7 @@ snapshots:
'@img/sharp-wasm32@0.34.5':
dependencies:
- '@emnapi/runtime': 1.10.0
+ '@emnapi/runtime': 1.11.3
optional: true
'@img/sharp-win32-arm64@0.34.5':
@@ -6833,7 +6838,7 @@ snapshots:
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.8.1
- '@emnapi/runtime': 1.10.0
+ '@emnapi/runtime': 1.11.3
'@tybys/wasm-util': 0.10.1
optional: true
@@ -6844,34 +6849,34 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
- '@next/env@16.2.6': {}
+ '@next/env@16.2.11': {}
'@next/eslint-plugin-next@15.5.12':
dependencies:
fast-glob: 3.3.1
- '@next/swc-darwin-arm64@16.2.6':
+ '@next/swc-darwin-arm64@16.2.11':
optional: true
- '@next/swc-darwin-x64@16.2.6':
+ '@next/swc-darwin-x64@16.2.11':
optional: true
- '@next/swc-linux-arm64-gnu@16.2.6':
+ '@next/swc-linux-arm64-gnu@16.2.11':
optional: true
- '@next/swc-linux-arm64-musl@16.2.6':
+ '@next/swc-linux-arm64-musl@16.2.11':
optional: true
- '@next/swc-linux-x64-gnu@16.2.6':
+ '@next/swc-linux-x64-gnu@16.2.11':
optional: true
- '@next/swc-linux-x64-musl@16.2.6':
+ '@next/swc-linux-x64-musl@16.2.11':
optional: true
- '@next/swc-win32-arm64-msvc@16.2.6':
+ '@next/swc-win32-arm64-msvc@16.2.11':
optional: true
- '@next/swc-win32-x64-msvc@16.2.6':
+ '@next/swc-win32-x64-msvc@16.2.11':
optional: true
'@nodelib/fs.scandir@2.1.5':
@@ -7434,7 +7439,7 @@ snapshots:
'@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@react-types/shared': 3.33.1(react@19.2.4)
- '@swc/helpers': 0.5.21
+ '@swc/helpers': 0.5.23
clsx: 2.1.1
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
@@ -7445,13 +7450,13 @@ snapshots:
'@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@react-stately/flags': 3.1.2
'@react-types/shared': 3.33.1(react@19.2.4)
- '@swc/helpers': 0.5.21
+ '@swc/helpers': 0.5.23
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@react-aria/ssr@3.9.10(react@19.2.4)':
dependencies:
- '@swc/helpers': 0.5.21
+ '@swc/helpers': 0.5.23
react: 19.2.4
'@react-aria/utils@3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
@@ -7460,18 +7465,18 @@ snapshots:
'@react-stately/flags': 3.1.2
'@react-stately/utils': 3.11.0(react@19.2.4)
'@react-types/shared': 3.33.1(react@19.2.4)
- '@swc/helpers': 0.5.21
+ '@swc/helpers': 0.5.23
clsx: 2.1.1
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@react-stately/flags@3.1.2':
dependencies:
- '@swc/helpers': 0.5.21
+ '@swc/helpers': 0.5.23
'@react-stately/utils@3.11.0(react@19.2.4)':
dependencies:
- '@swc/helpers': 0.5.21
+ '@swc/helpers': 0.5.23
react: 19.2.4
'@react-types/shared@3.33.1(react@19.2.4)':
@@ -7793,10 +7798,6 @@ snapshots:
dependencies:
tslib: 2.8.1
- '@swc/helpers@0.5.21':
- dependencies:
- tslib: 2.8.1
-
'@swc/helpers@0.5.23':
dependencies:
tslib: 2.8.1
@@ -8609,7 +8610,7 @@ snapshots:
base64-js@1.5.1: {}
- baseline-browser-mapping@2.10.29: {}
+ baseline-browser-mapping@2.11.3: {}
best-effort-json-parser@1.2.1: {}
@@ -8677,7 +8678,7 @@ snapshots:
camelize@1.0.1: {}
- caniuse-lite@1.0.30001792: {}
+ caniuse-lite@1.0.30001806: {}
canvas-confetti@1.9.4: {}
@@ -10012,7 +10013,7 @@ snapshots:
is-bun-module@2.0.0:
dependencies:
- semver: 7.8.0
+ semver: 7.8.5
is-callable@1.2.7: {}
@@ -10898,25 +10899,25 @@ snapshots:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
- next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
+ next@16.2.11(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
- '@next/env': 16.2.6
+ '@next/env': 16.2.11
'@swc/helpers': 0.5.15
- baseline-browser-mapping: 2.10.29
- caniuse-lite: 1.0.30001792
+ baseline-browser-mapping: 2.11.3
+ caniuse-lite: 1.0.30001806
postcss: 8.4.31
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
styled-jsx: 5.1.6(react@19.2.4)
optionalDependencies:
- '@next/swc-darwin-arm64': 16.2.6
- '@next/swc-darwin-x64': 16.2.6
- '@next/swc-linux-arm64-gnu': 16.2.6
- '@next/swc-linux-arm64-musl': 16.2.6
- '@next/swc-linux-x64-gnu': 16.2.6
- '@next/swc-linux-x64-musl': 16.2.6
- '@next/swc-win32-arm64-msvc': 16.2.6
- '@next/swc-win32-x64-msvc': 16.2.6
+ '@next/swc-darwin-arm64': 16.2.11
+ '@next/swc-darwin-x64': 16.2.11
+ '@next/swc-linux-arm64-gnu': 16.2.11
+ '@next/swc-linux-arm64-musl': 16.2.11
+ '@next/swc-linux-x64-gnu': 16.2.11
+ '@next/swc-linux-x64-musl': 16.2.11
+ '@next/swc-win32-arm64-msvc': 16.2.11
+ '@next/swc-win32-x64-msvc': 16.2.11
'@opentelemetry/api': 1.9.0
'@playwright/test': 1.59.1
sharp: 0.34.5
@@ -10924,13 +10925,13 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
- nextra-theme-docs@4.6.1(@types/react@19.2.13)(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nextra@4.6.1(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
+ nextra-theme-docs@4.6.1(@types/react@19.2.13)(next@16.2.11(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nextra@4.6.1(next@16.2.11(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
dependencies:
'@headlessui/react': 2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
clsx: 2.1.1
- next: 16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ next: 16.2.11(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-themes: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- nextra: 4.6.1(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
+ nextra: 4.6.1(next@16.2.11(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
react: 19.2.4
react-compiler-runtime: 19.1.0-rc.3(react@19.2.4)
react-dom: 19.2.4(react@19.2.4)
@@ -10942,7 +10943,7 @@ snapshots:
- immer
- use-sync-external-store
- nextra@4.6.1(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3):
+ nextra@4.6.1(next@16.2.11(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3):
dependencies:
'@formatjs/intl-localematcher': 0.6.2
'@headlessui/react': 2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -10963,7 +10964,7 @@ snapshots:
mdast-util-gfm: 3.1.0
mdast-util-to-hast: 13.2.1
negotiator: 1.0.0
- next: 16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ next: 16.2.11(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-compiler-runtime: 19.1.0-rc.3(react@19.2.4)
react-dom: 19.2.4(react@19.2.4)
@@ -11733,7 +11734,7 @@ snapshots:
semver@7.7.4: {}
- semver@7.8.0: {}
+ semver@7.8.5: {}
server-only@0.0.1: {}
@@ -11763,7 +11764,7 @@ snapshots:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
- semver: 7.8.0
+ semver: 7.8.5
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
From 2f60bee388e0f71caa8c5e447676ef85499a3679 Mon Sep 17 00:00:00 2001
From: Zhengcy05 <1825478405@qq.com>
Date: Sun, 26 Jul 2026 14:43:08 +0800
Subject: [PATCH 23/35] fix: surface length-capped model responses (#4309)
* fix: surface length-capped model responses
* fix: avoid the influence of the mid-turn
* fix: correcting semantic annotations
* fix: add ModelLengthTerminationDetector to compatible providers
* fix:delete redundancy code
* fix:supplementing log information improves observability
* fix: align the document and complete the assertions.
* fix: unit test
* fix: revert AGENTS.md
* fix: unit test
* fix: add annotation and skip AIMessage has empty content
---
backend/AGENTS.md | 5 +-
.../deerflow/agents/lead_agent/agent.py | 7 +
.../model_length_finish_reason_middleware.py | 130 ++++++++++++++
.../model_length_termination_detectors.py | 126 ++++++++++++++
.../harness/deerflow/runtime/runs/worker.py | 1 +
backend/tests/_replay_fixture.py | 5 +-
.../tests/test_lead_agent_model_resolution.py | 14 +-
.../tests/test_loop_detection_stop_reason.py | 53 ++++++
...t_model_length_finish_reason_middleware.py | 161 ++++++++++++++++++
...test_model_length_termination_detectors.py | 62 +++++++
10 files changed, 556 insertions(+), 8 deletions(-)
create mode 100644 backend/packages/harness/deerflow/agents/middlewares/model_length_finish_reason_middleware.py
create mode 100644 backend/packages/harness/deerflow/agents/middlewares/model_length_termination_detectors.py
create mode 100644 backend/tests/test_model_length_finish_reason_middleware.py
create mode 100644 backend/tests/test_model_length_termination_detectors.py
diff --git a/backend/AGENTS.md b/backend/AGENTS.md
index 22f08a28a..35241fcb3 100644
--- a/backend/AGENTS.md
+++ b/backend/AGENTS.md
@@ -361,8 +361,9 @@ Before changing a later authorization phase, read the [authorization RFC](../doc
30. **Custom middlewares** - *(optional)* Any `custom_middlewares` passed to `build_middlewares` are injected here, before config-declared extensions and the terminal-response/safety/clarification tail
31. **Configured extension middlewares** - *(optional, if `extensions.middlewares` is set in `config.yaml` or `extensions_config.json`)* Zero-argument `AgentMiddleware` classes loaded from `module.path:ClassName` entries via `deerflow.reflection.resolve_class`. Missing packages, invalid classes, and broken modules fail loudly at agent creation. These run after built-ins/programmatic custom middleware and after the lead/subagent loop/token guards, but before the terminal-response/safety/clarification tail; subagents receive the same configured extension middleware class list before their safety tail. Treat these files as trusted operator config because middleware paths instantiate arbitrary code. Gateway skill/MCP toggle endpoints preserve this field through `to_file_dict()` but must not add a write path for `extensions.middlewares` without an explicit trust-boundary review. Lead-only vs subagent-only middleware lists and per-context constructor parameters are not expressible in this MVP.
32. **TerminalResponseMiddleware** - When a provider returns an empty terminal `AIMessage` after tool execution, injects a hidden recovery prompt and retries the model once; a second empty response is replaced in checkpoint state by a visible error fallback marked for the run worker, so the run finishes as an error instead of a silent success
-33. **SafetyFinishReasonMiddleware** - *(optional, if `safety_finish_reason.enabled`)* Repairs AIMessages the provider safety-terminated (e.g. `finish_reason=content_filter`): strips truncated tool calls so they are not executed (#3028), and — when the response is otherwise blank (no tool calls, no visible content) — backfills a user-facing explanation so the empty message is not persisted and then rejected by strict OpenAI-compatible providers on the next request (`message ... with role 'assistant' must not be empty`), which would otherwise strand the whole thread (#4393). A safety-terminated response that still carries visible text is left untouched. Registered after terminal-response/custom/configured middlewares so LangChain's reverse-order `after_model` dispatch runs it first
-34. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, writes a readable `ToolMessage.content` fallback plus structured `ToolMessage.artifact.human_input` request payload, and interrupts via `Command(goto=END)` (must be last). Because this middleware can short-circuit tool execution before LangChain emits `on_tool_end`, `RunJournal` performs a root-run final reconciliation for allowlisted clarification `ToolMessage`s whose `tool_call_id` was produced by the current run, so human-input request cards remain recoverable from `run_events` after checkpoint compaction. Human Input Card replies are submitted as `hide_from_ui` `HumanMessage`s with `additional_kwargs.human_input_response`; `RunJournal` persists only allowlisted hidden response sources (currently `ask_clarification`) as `llm.human.input`, which preserves answered-card state after compaction without exposing generic internal hidden context.
+33. **ModelLengthFinishReasonMiddleware** - Records `stop_reason=model_length_capped` when provider-specific length detectors match a terminal `AIMessage` without tool-call intent (`finish_reason=length` / `MAX_TOKENS`, or `stop_reason=max_tokens`), preserving the original assistant content and never reparsing textual tool-call-like envelopes
+34. **SafetyFinishReasonMiddleware** - *(optional, if `safety_finish_reason.enabled`)* Suppresses tool execution when the provider safety-terminated the response (e.g. `finish_reason=content_filter`); registered after terminal-response/custom/configured middlewares so LangChain's reverse-order `after_model` dispatch runs it first
+35. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, writes a readable `ToolMessage.content` fallback plus structured `ToolMessage.artifact.human_input` request payload, and interrupts via `Command(goto=END)` (must be last). Because this middleware can short-circuit tool execution before LangChain emits `on_tool_end`, `RunJournal` performs a root-run final reconciliation for allowlisted clarification `ToolMessage`s whose `tool_call_id` was produced by the current run, so human-input request cards remain recoverable from `run_events` after checkpoint compaction. Human Input Card replies are submitted as `hide_from_ui` `HumanMessage`s with `additional_kwargs.human_input_response`; `RunJournal` persists only allowlisted hidden response sources (currently `ask_clarification`) as `llm.human.input`, which preserves answered-card state after compaction without exposing generic internal hidden context.
### Configuration System
diff --git a/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/backend/packages/harness/deerflow/agents/lead_agent/agent.py
index 189c87cee..b20c2e337 100644
--- a/backend/packages/harness/deerflow/agents/lead_agent/agent.py
+++ b/backend/packages/harness/deerflow/agents/lead_agent/agent.py
@@ -36,6 +36,7 @@ from deerflow.agents.middlewares.clarification_middleware import ClarificationMi
from deerflow.agents.middlewares.configured_extensions import load_configured_extension_middlewares
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
+from deerflow.agents.middlewares.model_length_finish_reason_middleware import ModelLengthFinishReasonMiddleware
from deerflow.agents.middlewares.safety_finish_reason_middleware import SafetyFinishReasonMiddleware
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
from deerflow.agents.middlewares.summarization_middleware import DeerFlowSummarizationMiddleware, create_summarization_middleware
@@ -452,6 +453,12 @@ def build_middlewares(
# allowing LangChain's no-tool-call router to end a silent successful run.
middlewares.append(TerminalResponseMiddleware())
+ # A provider may also cap the final assistant response at the model output
+ # limit. Preserve the assistant content unchanged, but stamp a run-level
+ # stop_reason so Gateway consumers can tell a length-capped completion from
+ # a clean one.
+ middlewares.append(ModelLengthFinishReasonMiddleware())
+
# SafetyFinishReasonMiddleware — suppress tool execution when the provider
# safety-terminated the response. Registered after the terminal-response
# and custom/configured middlewares so LangChain's reverse-order after_model
diff --git a/backend/packages/harness/deerflow/agents/middlewares/model_length_finish_reason_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/model_length_finish_reason_middleware.py
new file mode 100644
index 000000000..49650e469
--- /dev/null
+++ b/backend/packages/harness/deerflow/agents/middlewares/model_length_finish_reason_middleware.py
@@ -0,0 +1,130 @@
+"""Surface provider length-capped model responses as run stop reasons.
+
+Background — see issue bytedance/deer-flow#4271.
+
+Some providers stop generation because the output budget is exhausted and
+surface that through ``finish_reason='length'`` while still returning assistant
+content. DeerFlow should preserve that content for audit, but it should not
+silently treat the run as an uncapped clean completion when the provider has
+explicitly signaled truncation.
+
+This middleware keeps that boundary narrow:
+- it only marks a run-level stop reason when the final AIMessage is capped
+ by a provider length signal and still has visible content;
+- it never rewrites the assistant content or reparses XML-like text into a
+ tool call;
+- it ignores any response that still carries tool-call intent, malformed
+ tool-call metadata, or no visible content, so only terminal assistant
+ responses with visible content can be marked capped.
+
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, override
+
+from langchain.agents import AgentState
+from langchain.agents.middleware import AgentMiddleware
+from langchain_core.messages import AIMessage
+from langgraph.runtime import Runtime
+
+from deerflow.agents.middlewares.model_length_termination_detectors import (
+ ModelLengthTermination,
+ ModelLengthTerminationDetector,
+ default_detectors,
+)
+
+MODEL_LENGTH_CAPPED_STOP_REASON = "model_length_capped"
+logger = logging.getLogger(__name__)
+
+
+def _has_tool_call_intent_or_error(message: AIMessage) -> bool:
+ if message.tool_calls or getattr(message, "invalid_tool_calls", None):
+ return True
+ additional_kwargs = message.additional_kwargs or {}
+ return bool(additional_kwargs.get("tool_calls") or additional_kwargs.get("function_call"))
+
+
+def _has_visible_content(message: AIMessage) -> bool:
+ content = message.content
+ if isinstance(content, str):
+ return bool(content.strip())
+ if isinstance(content, list):
+ for block in content:
+ if isinstance(block, str) and block.strip():
+ return True
+ if isinstance(block, dict) and block.get("type") in {"text", "output_text"}:
+ text = block.get("text")
+ if isinstance(text, str) and text.strip():
+ return True
+ return False
+
+
+class ModelLengthFinishReasonMiddleware(AgentMiddleware[AgentState]):
+ """Record provider length caps for terminal assistant responses with content.
+
+ If the last AIMessage still carries tool-call intent, this middleware
+ leaves it alone and lets the normal tool-handling path decide what to do.
+ """
+
+ def __init__(self, detectors: list[ModelLengthTerminationDetector] | None = None) -> None:
+ super().__init__()
+ self._detectors: list[ModelLengthTerminationDetector] = list(detectors) if detectors else default_detectors()
+
+ def _detect(self, message: AIMessage) -> ModelLengthTermination | None:
+ for detector in self._detectors:
+ try:
+ hit = detector.detect(message)
+ except Exception: # noqa: BLE001 - provider detectors must not break a run
+ logger.exception("ModelLengthTerminationDetector %r raised; treating as no-match", getattr(detector, "name", type(detector).__name__))
+ continue
+ if hit is not None:
+ return hit
+ return None
+
+ def _apply(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
+ messages = list(state.get("messages") or [])
+ if not messages or not isinstance(messages[-1], AIMessage):
+ return None
+
+ last = messages[-1]
+ if _has_tool_call_intent_or_error(last):
+ return None
+ if not _has_visible_content(last):
+ return None
+
+ termination = self._detect(last)
+ if termination is None:
+ return None
+
+ ctx = getattr(runtime, "context", None)
+ thread_id = ctx.get("thread_id") if isinstance(ctx, dict) else None
+ run_id = ctx.get("run_id") if isinstance(ctx, dict) else None
+ stamped_stop_reason = False
+ if isinstance(ctx, dict):
+ # Preserve any earlier cap reason carried across hidden continuation turns.
+ if "stop_reason" not in ctx:
+ ctx["stop_reason"] = MODEL_LENGTH_CAPPED_STOP_REASON
+ stamped_stop_reason = True
+ logger.info(
+ "Provider model length cap detected",
+ extra={
+ "thread_id": thread_id,
+ "run_id": run_id,
+ "message_id": getattr(last, "id", None),
+ "detector": termination.detector,
+ "reason_field": termination.reason_field,
+ "reason_value": termination.reason_value,
+ "stamped_stop_reason": stamped_stop_reason,
+ },
+ )
+ return None
+
+ @override
+ def after_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
+ return self._apply(state, runtime)
+
+ @override
+ async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
+ return self._apply(state, runtime)
diff --git a/backend/packages/harness/deerflow/agents/middlewares/model_length_termination_detectors.py b/backend/packages/harness/deerflow/agents/middlewares/model_length_termination_detectors.py
new file mode 100644
index 000000000..8158f5a99
--- /dev/null
+++ b/backend/packages/harness/deerflow/agents/middlewares/model_length_termination_detectors.py
@@ -0,0 +1,126 @@
+"""Detectors for provider-side model length termination signals.
+
+Different providers report "the response hit the output-token limit" through
+different fields and values. Keep those provider details here so
+``ModelLengthFinishReasonMiddleware`` can stay focused on when to mark a run,
+not on which provider emitted which spelling.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any, Protocol, runtime_checkable
+
+from langchain_core.messages import AIMessage
+
+
+@dataclass(frozen=True)
+class ModelLengthTermination:
+ """A detected model-output length cap."""
+
+ detector: str
+ reason_field: str
+ reason_value: str
+ extras: dict[str, Any] = field(default_factory=dict)
+
+
+@runtime_checkable
+class ModelLengthTerminationDetector(Protocol):
+ """Strategy interface for provider length-cap detection."""
+
+ name: str
+
+ def detect(self, message: AIMessage) -> ModelLengthTermination | None:
+ """Return a hit when *message* indicates output length truncation."""
+ ...
+
+
+def _get_metadata_value(message: AIMessage, field_name: str) -> str | None:
+ """Read a string metadata value from common LangChain provider fields."""
+ for container_name in ("response_metadata", "additional_kwargs"):
+ container = getattr(message, container_name, None) or {}
+ if not isinstance(container, dict):
+ continue
+ value = container.get(field_name)
+ if isinstance(value, str) and value:
+ return value
+ return None
+
+
+class OpenAICompatibleLengthDetector:
+ """OpenAI-compatible ``finish_reason == "length"`` signal."""
+
+ name = "openai_compatible_length"
+
+ def __init__(self, finish_reasons: list[str] | tuple[str, ...] | None = None) -> None:
+ configured = finish_reasons if finish_reasons is not None else ("length",)
+ self._finish_reasons: frozenset[str] = frozenset(r.lower() for r in configured)
+
+ def detect(self, message: AIMessage) -> ModelLengthTermination | None:
+ value = _get_metadata_value(message, "finish_reason")
+ if value is None or value.lower() not in self._finish_reasons:
+ return None
+ return ModelLengthTermination(
+ detector=self.name,
+ reason_field="finish_reason",
+ reason_value=value,
+ )
+
+
+class AnthropicMaxTokensDetector:
+ """Anthropic ``stop_reason == "max_tokens"`` signal."""
+
+ name = "anthropic_max_tokens"
+
+ def __init__(self, stop_reasons: list[str] | tuple[str, ...] | None = None) -> None:
+ configured = stop_reasons if stop_reasons is not None else ("max_tokens",)
+ self._stop_reasons: frozenset[str] = frozenset(r.lower() for r in configured)
+
+ def detect(self, message: AIMessage) -> ModelLengthTermination | None:
+ value = _get_metadata_value(message, "stop_reason")
+ if value is None or value.lower() not in self._stop_reasons:
+ return None
+ return ModelLengthTermination(
+ detector=self.name,
+ reason_field="stop_reason",
+ reason_value=value,
+ )
+
+
+class GeminiMaxTokensDetector:
+ """Gemini / Vertex AI ``finish_reason == "MAX_TOKENS"`` signal."""
+
+ name = "gemini_max_tokens"
+
+ def __init__(self, finish_reasons: list[str] | tuple[str, ...] | None = None) -> None:
+ configured = finish_reasons if finish_reasons is not None else ("MAX_TOKENS",)
+ self._finish_reasons: frozenset[str] = frozenset(r.upper() for r in configured)
+
+ def detect(self, message: AIMessage) -> ModelLengthTermination | None:
+ value = _get_metadata_value(message, "finish_reason")
+ if value is None or value.upper() not in self._finish_reasons:
+ return None
+ return ModelLengthTermination(
+ detector=self.name,
+ reason_field="finish_reason",
+ reason_value=value,
+ )
+
+
+def default_detectors() -> list[ModelLengthTerminationDetector]:
+ """Built-in detector set used for provider length-cap signals."""
+ return [
+ OpenAICompatibleLengthDetector(),
+ AnthropicMaxTokensDetector(),
+ GeminiMaxTokensDetector(),
+ ]
+
+
+__all__ = [
+ "AnthropicMaxTokensDetector",
+ "GeminiMaxTokensDetector",
+ "ModelLengthTermination",
+ "ModelLengthTerminationDetector",
+ "OpenAICompatibleLengthDetector",
+ "default_detectors",
+]
diff --git a/backend/packages/harness/deerflow/runtime/runs/worker.py b/backend/packages/harness/deerflow/runtime/runs/worker.py
index 32da098cc..7faa1794b 100644
--- a/backend/packages/harness/deerflow/runtime/runs/worker.py
+++ b/backend/packages/harness/deerflow/runtime/runs/worker.py
@@ -761,6 +761,7 @@ async def run_agent(
# token_budget -> "token_capped"
# safety_finish_reason -> "safety_capped"
# subagent_limit -> "subagent_limit_capped"
+ # model_length_finish_reason -> "model_length_capped"
#
# If more guards grow stop_reason semantics, consider a publish/
# collect pattern (e.g. each guard middleware publishes its cap
diff --git a/backend/tests/_replay_fixture.py b/backend/tests/_replay_fixture.py
index 5886c3f15..a0220cbfb 100644
--- a/backend/tests/_replay_fixture.py
+++ b/backend/tests/_replay_fixture.py
@@ -156,7 +156,10 @@ def drive_gateway(app, *, prompt: str, context: dict) -> list[dict]:
body = {
"assistant_id": "lead_agent",
"input": {"messages": [{"role": "user", "content": prompt}]},
- "config": {"recursion_limit": 50},
+ # Keep replay close to the Gateway default. A tighter limit can
+ # produce false golden drift when protocol-neutral middleware adds
+ # graph steps without changing the streamed SSE contract.
+ "config": {"recursion_limit": 100},
"context": context,
"stream_mode": ["values"],
}
diff --git a/backend/tests/test_lead_agent_model_resolution.py b/backend/tests/test_lead_agent_model_resolution.py
index 2c6ad3939..c8dc0f7c0 100644
--- a/backend/tests/test_lead_agent_model_resolution.py
+++ b/backend/tests/test_lead_agent_model_resolution.py
@@ -545,15 +545,18 @@ def test_build_middlewares_uses_resolved_model_name_for_vision(monkeypatch):
assert any(isinstance(m, lead_agent_module.ViewImageMiddleware) for m in middlewares)
# verify the custom middleware is injected correctly.
# With this test's default safety config enabled, the tail order is:
- # ..., custom, TerminalResponseMiddleware, SafetyFinishReasonMiddleware,
- # ClarificationMiddleware, so the custom mock sits at index [-4].
- assert len(middlewares) > 0 and isinstance(middlewares[-4], MagicMock)
+ # ..., custom, TerminalResponseMiddleware, ModelLengthFinishReasonMiddleware,
+ # SafetyFinishReasonMiddleware, ClarificationMiddleware, so the custom mock
+ # sits at index [-5].
+ assert len(middlewares) > 0 and isinstance(middlewares[-5], MagicMock)
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
+ from deerflow.agents.middlewares.model_length_finish_reason_middleware import ModelLengthFinishReasonMiddleware
from deerflow.agents.middlewares.safety_finish_reason_middleware import SafetyFinishReasonMiddleware
from deerflow.agents.middlewares.terminal_response_middleware import TerminalResponseMiddleware
- assert isinstance(middlewares[-3], TerminalResponseMiddleware)
+ assert isinstance(middlewares[-4], TerminalResponseMiddleware)
+ assert isinstance(middlewares[-3], ModelLengthFinishReasonMiddleware)
assert isinstance(middlewares[-2], SafetyFinishReasonMiddleware)
assert isinstance(middlewares[-1], ClarificationMiddleware)
@@ -842,10 +845,11 @@ def test_build_middlewares_injects_configured_extension_middlewares(monkeypatch)
)
middleware_types = [type(m).__name__ for m in middlewares]
- assert middleware_types[-5:] == [
+ assert middleware_types[-6:] == [
"ConfiguredGuardMiddleware",
"ConfiguredAuditMiddleware",
"TerminalResponseMiddleware",
+ "ModelLengthFinishReasonMiddleware",
"SafetyFinishReasonMiddleware",
"ClarificationMiddleware",
]
diff --git a/backend/tests/test_loop_detection_stop_reason.py b/backend/tests/test_loop_detection_stop_reason.py
index ac66042e8..b1e909093 100644
--- a/backend/tests/test_loop_detection_stop_reason.py
+++ b/backend/tests/test_loop_detection_stop_reason.py
@@ -221,6 +221,59 @@ async def test_worker_surfaces_stop_reason_from_safety_finish_reason():
assert fetched.stop_reason == "safety_capped"
+@pytest.mark.asyncio
+async def test_worker_surfaces_stop_reason_from_model_length_finish_reason():
+ """The worker persists ``stop_reason=model_length_capped`` when the
+ provider caps a terminal assistant response with ``finish_reason=length``."""
+ from deerflow.agents.middlewares.model_length_finish_reason_middleware import ModelLengthFinishReasonMiddleware
+
+ mw = ModelLengthFinishReasonMiddleware()
+ captured_runtime: list[Any] = [None]
+
+ class DummyAgent:
+ metadata: dict[str, Any] = {"model_name": "test-model"}
+
+ async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
+ runtime = ((config or {}).get("configurable") or {}).get("__pregel_runtime")
+ assert runtime is not None
+ captured_runtime[0] = runtime
+
+ msg = AIMessage(
+ content=('/mnt/user-data/outputs/report.md # partial'),
+ tool_calls=[],
+ invalid_tool_calls=[],
+ response_metadata={"finish_reason": "length"},
+ )
+ mw._apply({"messages": [msg]}, runtime)
+
+ yield {"messages": [msg]}
+
+ run_manager = RunManager()
+ record = await run_manager.create("thread-1")
+ bridge = AsyncMock()
+ bridge.publish = AsyncMock()
+ bridge.publish_end = AsyncMock()
+ bridge.cleanup = AsyncMock()
+
+ await run_agent(
+ bridge,
+ run_manager,
+ record,
+ ctx=RunContext(checkpointer=None),
+ agent_factory=lambda *, config: DummyAgent(),
+ graph_input={"messages": []},
+ config={},
+ )
+
+ assert captured_runtime[0] is not None
+ assert captured_runtime[0].context.get("stop_reason") == "model_length_capped"
+
+ fetched = await run_manager.get(record.run_id)
+ assert fetched is not None
+ assert fetched.status == RunStatus.success
+ assert fetched.stop_reason == "model_length_capped"
+
+
@pytest.mark.asyncio
async def test_worker_surfaces_stop_reason_from_subagent_limit():
"""The worker persists ``stop_reason=subagent_limit_capped`` when the
diff --git a/backend/tests/test_model_length_finish_reason_middleware.py b/backend/tests/test_model_length_finish_reason_middleware.py
new file mode 100644
index 000000000..eedaedeb4
--- /dev/null
+++ b/backend/tests/test_model_length_finish_reason_middleware.py
@@ -0,0 +1,161 @@
+"""Unit tests for ModelLengthFinishReasonMiddleware."""
+
+import logging
+from unittest.mock import MagicMock
+
+from langchain_core.messages import AIMessage, HumanMessage
+
+from deerflow.agents.middlewares.model_length_finish_reason_middleware import (
+ MODEL_LENGTH_CAPPED_STOP_REASON,
+ ModelLengthFinishReasonMiddleware,
+)
+
+_MW_LOGGER = "deerflow.agents.middlewares.model_length_finish_reason_middleware"
+
+
+def _runtime(run_id: str = "run-1"):
+ runtime = MagicMock()
+ runtime.context = {"thread_id": "thread-1", "run_id": run_id}
+ return runtime
+
+
+def test_finish_reason_length_records_stop_reason_without_rewriting_textual_tool_call():
+ mw = ModelLengthFinishReasonMiddleware()
+ runtime = _runtime()
+ msg = AIMessage(
+ content=('/mnt/user-data/outputs/report.md # partial'),
+ tool_calls=[],
+ invalid_tool_calls=[],
+ response_metadata={"finish_reason": "length"},
+ )
+
+ result = mw._apply({"messages": [msg]}, runtime)
+
+ assert result is None
+ assert runtime.context["stop_reason"] == MODEL_LENGTH_CAPPED_STOP_REASON
+ assert msg.content.startswith('')
+ assert msg.tool_calls == []
+ assert msg.invalid_tool_calls == []
+
+
+def test_finish_reason_stop_with_tool_call_example_in_prose_passes_through():
+ mw = ModelLengthFinishReasonMiddleware()
+ runtime = _runtime()
+ msg = AIMessage(
+ content=('Here is an example, not a real tool call:\n\n```xml\n \n```'),
+ response_metadata={"finish_reason": "stop"},
+ )
+
+ assert mw._apply({"messages": [msg]}, runtime) is None
+ assert "stop_reason" not in runtime.context
+
+
+def test_additional_kwargs_finish_reason_length_records_stop_reason():
+ mw = ModelLengthFinishReasonMiddleware()
+ runtime = _runtime()
+ msg = AIMessage(
+ content="partial",
+ additional_kwargs={"finish_reason": "length"},
+ )
+
+ assert mw._apply({"messages": [msg]}, runtime) is None
+ assert runtime.context["stop_reason"] == MODEL_LENGTH_CAPPED_STOP_REASON
+
+
+def test_gemini_max_tokens_finish_reason_records_stop_reason():
+ mw = ModelLengthFinishReasonMiddleware()
+ runtime = _runtime()
+ msg = AIMessage(
+ content="partial",
+ response_metadata={"finish_reason": "MAX_TOKENS"},
+ )
+
+ assert mw._apply({"messages": [msg]}, runtime) is None
+ assert runtime.context["stop_reason"] == MODEL_LENGTH_CAPPED_STOP_REASON
+
+
+def test_anthropic_max_tokens_stop_reason_records_stop_reason():
+ mw = ModelLengthFinishReasonMiddleware()
+ runtime = _runtime()
+ msg = AIMessage(
+ content="partial",
+ response_metadata={"stop_reason": "max_tokens"},
+ )
+
+ assert mw._apply({"messages": [msg]}, runtime) is None
+ assert runtime.context["stop_reason"] == MODEL_LENGTH_CAPPED_STOP_REASON
+
+
+def test_length_cap_detection_logs_observability_fields(caplog):
+ mw = ModelLengthFinishReasonMiddleware()
+ runtime = _runtime(run_id="run-observe")
+ msg = AIMessage(
+ id="msg-1",
+ content="partial",
+ response_metadata={"finish_reason": "length"},
+ )
+
+ with caplog.at_level(logging.INFO, logger=_MW_LOGGER):
+ assert mw._apply({"messages": [msg]}, runtime) is None
+
+ records = [record for record in caplog.records if record.message == "Provider model length cap detected"]
+ assert len(records) == 1
+ record = records[0]
+ assert record.thread_id == "thread-1"
+ assert record.run_id == "run-observe"
+ assert record.message_id == "msg-1"
+ assert record.detector == "openai_compatible_length"
+ assert record.reason_field == "finish_reason"
+ assert record.reason_value == "length"
+ assert record.stamped_stop_reason is True
+
+
+def test_finish_reason_length_with_tool_calls_passes_through():
+ mw = ModelLengthFinishReasonMiddleware()
+ runtime = _runtime()
+ msg = AIMessage(
+ content="",
+ tool_calls=[
+ {
+ "id": "call_write_1",
+ "name": "write_file",
+ "args": {"path": "/mnt/user-data/outputs/report.md", "content": "# partial"},
+ }
+ ],
+ response_metadata={"finish_reason": "length"},
+ )
+
+ assert mw._apply({"messages": [msg]}, runtime) is None
+ assert "stop_reason" not in runtime.context
+
+
+def test_empty_finish_reason_length_passes_through_for_terminal_response_recovery():
+ mw = ModelLengthFinishReasonMiddleware()
+ runtime = _runtime()
+ msg = AIMessage(content="", response_metadata={"finish_reason": "length"})
+
+ assert mw._apply({"messages": [msg]}, runtime) is None
+ assert "stop_reason" not in runtime.context
+
+
+def test_existing_stop_reason_is_not_overwritten(caplog):
+ mw = ModelLengthFinishReasonMiddleware()
+ runtime = _runtime()
+ runtime.context["stop_reason"] = "token_capped"
+ msg = AIMessage(content="partial", response_metadata={"finish_reason": "length"})
+
+ with caplog.at_level(logging.INFO, logger=_MW_LOGGER):
+ assert mw._apply({"messages": [msg]}, runtime) is None
+
+ assert runtime.context["stop_reason"] == "token_capped"
+ records = [record for record in caplog.records if record.message == "Provider model length cap detected"]
+ assert len(records) == 1
+ assert records[0].stamped_stop_reason is False
+
+
+def test_non_ai_last_message_passes_through():
+ mw = ModelLengthFinishReasonMiddleware()
+ runtime = _runtime()
+
+ assert mw._apply({"messages": [HumanMessage(content="hello")]}, runtime) is None
+ assert "stop_reason" not in runtime.context
diff --git a/backend/tests/test_model_length_termination_detectors.py b/backend/tests/test_model_length_termination_detectors.py
new file mode 100644
index 000000000..61eb0ea3f
--- /dev/null
+++ b/backend/tests/test_model_length_termination_detectors.py
@@ -0,0 +1,62 @@
+"""Unit tests for provider length-termination detectors."""
+
+from langchain_core.messages import AIMessage
+
+from deerflow.agents.middlewares.model_length_termination_detectors import (
+ AnthropicMaxTokensDetector,
+ GeminiMaxTokensDetector,
+ OpenAICompatibleLengthDetector,
+ default_detectors,
+)
+
+
+def test_openai_compatible_length_detector_matches_finish_reason_length():
+ hit = OpenAICompatibleLengthDetector().detect(
+ AIMessage(
+ content="partial",
+ response_metadata={"finish_reason": "length"},
+ )
+ )
+
+ assert hit is not None
+ assert hit.detector == "openai_compatible_length"
+ assert hit.reason_field == "finish_reason"
+ assert hit.reason_value == "length"
+
+
+def test_gemini_max_tokens_detector_matches_uppercase_finish_reason():
+ hit = GeminiMaxTokensDetector().detect(
+ AIMessage(
+ content="partial",
+ response_metadata={"finish_reason": "MAX_TOKENS"},
+ )
+ )
+
+ assert hit is not None
+ assert hit.detector == "gemini_max_tokens"
+ assert hit.reason_field == "finish_reason"
+ assert hit.reason_value == "MAX_TOKENS"
+
+
+def test_anthropic_max_tokens_detector_matches_stop_reason():
+ hit = AnthropicMaxTokensDetector().detect(
+ AIMessage(
+ content="partial",
+ response_metadata={"stop_reason": "max_tokens"},
+ )
+ )
+
+ assert hit is not None
+ assert hit.detector == "anthropic_max_tokens"
+ assert hit.reason_field == "stop_reason"
+ assert hit.reason_value == "max_tokens"
+
+
+def test_default_detectors_cover_openai_anthropic_and_gemini():
+ names = [detector.name for detector in default_detectors()]
+
+ assert names == [
+ "openai_compatible_length",
+ "anthropic_max_tokens",
+ "gemini_max_tokens",
+ ]
From e646188ab468103c801af5a769fd94211d273820 Mon Sep 17 00:00:00 2001
From: Aari
Date: Sun, 26 Jul 2026 15:00:42 +0800
Subject: [PATCH 24/35] fix(runtime): accept the SDK's default
stream_resumable=false (#4468)
RunCreateRequest declared stream_resumable as None-only, but langgraph_sdk
defaults it to False (not None) and its payload filter only drops None, so
every SDK request carried "stream_resumable": false and was rejected with 422.
False means "no resumable stream", which is what DeerFlow already serves.
This broke every IM channel run (runs.stream and runs.create both send the
field; only runs.wait omits it) and any external langgraph_sdk client. The web
frontend was unaffected because its compatibility wrapper does not send it.
An explicit true still returns 422. The new regression test drives a real SDK
client to capture its default payload and posts that to the HTTP boundary, so a
future non-None SDK default cannot regress this silently.
Fixes #4466
---
backend/AGENTS.md | 2 +-
backend/app/gateway/run_models.py | 18 ++++++--
backend/docs/API.md | 3 +-
backend/tests/test_run_request_validation.py | 47 +++++++++++++++++++-
4 files changed, 63 insertions(+), 7 deletions(-)
diff --git a/backend/AGENTS.md b/backend/AGENTS.md
index 35241fcb3..59226f6b9 100644
--- a/backend/AGENTS.md
+++ b/backend/AGENTS.md
@@ -452,7 +452,7 @@ are size-limited; binary, large, and sensitive-looking paths are persisted as
metadata only.
**RunManager / RunStore contract**:
-- LangGraph-compatible run requests validate their supported subset before creating a run. `runtime/stream_modes.py` is the shared backend contract for public stream modes and the worker's `graph.astream` mapping; the public `messages-tuple` mode maps to LangGraph's internal `messages` mode, while public `messages`, `events`, and other unsupported modes are rejected instead of being dropped or replaced with `values`. `app/gateway/run_models.py::RunCreateRequest` is shared by HTTP and internal scheduled launch paths, retains only truthful compatibility defaults for unimplemented options (`if_not_exists="create"` plus `None` placeholders), returns 422 for unsupported values including `on_completion="complete"`, `on_completion="continue"`, and `multitask_strategy="enqueue"`, and forbids undeclared SDK options so fields such as `checkpoint_during` and `durability` cannot be silently discarded.
+- LangGraph-compatible run requests validate their supported subset before creating a run. `runtime/stream_modes.py` is the shared backend contract for public stream modes and the worker's `graph.astream` mapping; the public `messages-tuple` mode maps to LangGraph's internal `messages` mode, while public `messages`, `events`, and other unsupported modes are rejected instead of being dropped or replaced with `values`. `app/gateway/run_models.py::RunCreateRequest` is shared by HTTP and internal scheduled launch paths, retains only truthful compatibility defaults for unimplemented options (`if_not_exists="create"` plus `None` placeholders), returns 422 for unsupported values including `on_completion="complete"`, `on_completion="continue"`, and `multitask_strategy="enqueue"`, and forbids undeclared SDK options so fields such as `checkpoint_during` and `durability` cannot be silently discarded. A placeholder must still accept the stock SDK's own default: `langgraph_sdk` drops only `None` from its run payload, so `stream_resumable=False` reaches every request and means "non-resumable", which is what DeerFlow serves — rejecting it 422'd every IM channel run (#4466). `tests/test_run_request_validation.py::test_gateway_accepts_langgraph_sdk_default_payload` pins the real SDK payload against this boundary; channel tests mock the SDK client and cannot catch this class of drift.
- `RunManager.get()` is async; direct callers must `await` it.
- The history batch helpers `list_successful_regenerate_sources()` and `get_many_by_thread()` default to `user_id=AUTO`: they resolve the request user and fail closed when no user context exists. Migration/admin callers that intentionally need an unscoped read must pass `user_id=None` explicitly.
- When a persistent `RunStore` is configured, `get()` and `list_by_thread()` hydrate historical runs from the store. In-memory records win for the same `run_id` so task, abort, and stream-control state stays attached to active local runs.
diff --git a/backend/app/gateway/run_models.py b/backend/app/gateway/run_models.py
index cfb8d1a7e..08e648501 100644
--- a/backend/app/gateway/run_models.py
+++ b/backend/app/gateway/run_models.py
@@ -28,7 +28,7 @@ class RunCreateRequest(BaseModel):
interrupt_after: list[str] | Literal["*"] | None = Field(default=None, description="Nodes to interrupt after")
stream_mode: list[RunStreamMode] | RunStreamMode | None = Field(default=None, description="Supported stream mode(s)")
stream_subgraphs: bool = Field(default=False, description="Include subgraph events")
- stream_resumable: None = Field(default=None, description="Compatibility placeholder; resumable SSE is not supported")
+ stream_resumable: Literal[False] | None = Field(default=None, description="Compatibility placeholder; only the SDK's non-resumable default (null/false) is accepted")
on_disconnect: Literal["cancel", "continue"] = Field(default="cancel", description="Behaviour on SSE disconnect")
on_completion: None = Field(default=None, description="Compatibility placeholder; completion behavior is not supported")
multitask_strategy: Literal["reject", "rollback", "interrupt"] = Field(default="reject", description="Concurrency strategy")
@@ -38,7 +38,6 @@ class RunCreateRequest(BaseModel):
@field_validator(
"webhook",
- "stream_resumable",
"on_completion",
"multitask_strategy",
"after_seconds",
@@ -53,7 +52,6 @@ class RunCreateRequest(BaseModel):
supported_defaults = {
"webhook": None,
- "stream_resumable": None,
"on_completion": None,
"multitask_strategy": {"reject", "rollback", "interrupt"},
"after_seconds": None,
@@ -73,6 +71,20 @@ class RunCreateRequest(BaseModel):
)
return value
+ @field_validator("stream_resumable", mode="before")
+ @classmethod
+ def reject_resumable_streams(cls, value: Any) -> Any:
+ # LangGraph SDK clients always send this field (its default is ``False``, which the
+ # payload's ``None`` filter keeps). ``False`` asks for the non-resumable stream
+ # DeerFlow already serves, so only an explicit ``True`` requests the unsupported feature.
+ if value is None or value is False:
+ return value
+ raise PydanticCustomError(
+ "unsupported_run_option",
+ "Run option '{option}' is not supported by DeerFlow",
+ {"option": "stream_resumable"},
+ )
+
@field_validator("stream_mode", mode="before")
@classmethod
def reject_unsupported_stream_modes(cls, value: Any) -> Any:
diff --git a/backend/docs/API.md b/backend/docs/API.md
index 329b32e78..b23ddeb9d 100644
--- a/backend/docs/API.md
+++ b/backend/docs/API.md
@@ -109,7 +109,8 @@ Content-Type: application/json
**Run Option Compatibility:**
- Supported concurrency strategies: `reject`, `rollback`, and `interrupt`
- Compatibility default: `if_not_exists="create"`; this matches DeerFlow's current behavior
-- Unsupported options return `422`: `webhook`, `stream_resumable`, `after_seconds`, `feedback_keys`, any non-null `on_completion` value (including the SDK values `"complete"` and `"continue"`), `if_not_exists="reject"`, and `multitask_strategy="enqueue"`
+- Unsupported options return `422`: `webhook`, `stream_resumable=true`, `after_seconds`, `feedback_keys`, any non-null `on_completion` value (including the SDK values `"complete"` and `"continue"`), `if_not_exists="reject"`, and `multitask_strategy="enqueue"`
+- `stream_resumable=false` is accepted: it is the LangGraph SDK's default and requests the non-resumable stream DeerFlow already serves
- Undeclared SDK options, including `checkpoint_during` and `durability`, also return `422` instead of being silently discarded
**Recursion Limit:**
diff --git a/backend/tests/test_run_request_validation.py b/backend/tests/test_run_request_validation.py
index d428e99dc..1e8516e7a 100644
--- a/backend/tests/test_run_request_validation.py
+++ b/backend/tests/test_run_request_validation.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import asyncio
from typing import Any
import pytest
@@ -32,7 +33,7 @@ def client() -> TestClient:
("field", "value"),
[
("webhook", "https://example.com/callback"),
- ("stream_resumable", False),
+ ("stream_resumable", True),
("on_completion", "complete"),
("on_completion", "continue"),
("on_completion", "keep"),
@@ -115,6 +116,47 @@ def test_run_request_keeps_supported_modes_and_compatibility_defaults() -> None:
assert body.if_not_exists == "create"
+def _sdk_default_payload(method: str) -> dict[str, Any]:
+ """Capture the body a stock ``langgraph_sdk`` client sends for a default run."""
+ from langgraph_sdk.client import get_client
+
+ client = get_client(url="http://gateway.invalid")
+ captured: dict[str, Any] = {}
+
+ def capture(*_args: Any, json: dict[str, Any] | None = None, **_kwargs: Any) -> None:
+ captured.update(json or {})
+
+ async def capture_post(*args: Any, **kwargs: Any) -> dict[str, Any]:
+ capture(*args, **kwargs)
+ return {}
+
+ if method == "stream":
+ client.runs.http.stream = capture # type: ignore[method-assign]
+ # ``stream()`` is a sync factory: the payload is built and handed to the
+ # transport before the returned async iterator is consumed.
+ client.runs.stream("thread-id", "deerflow", input={"messages": []})
+ else:
+ client.runs.http.post = capture_post # type: ignore[method-assign]
+ asyncio.run(client.runs.create("thread-id", "deerflow", input={"messages": []}))
+ return captured
+
+
+@pytest.mark.parametrize("method", ["stream", "create"])
+def test_gateway_accepts_langgraph_sdk_default_payload(client: TestClient, method: str) -> None:
+ """The SDK's own defaults must never be rejected as unsupported options (#4466).
+
+ ``stream_resumable`` defaults to ``False`` rather than ``None``, so the SDK's
+ drop-``None`` payload filter always forwards it; every IM channel run goes
+ through this path.
+ """
+ payload = _sdk_default_payload(method)
+
+ assert payload["stream_resumable"] is False, "SDK contract changed; update this test"
+ response = client.post("/api/runs/stream", json=payload)
+
+ assert response.status_code != 422, response.text
+
+
@pytest.mark.parametrize(
"payload",
[
@@ -203,8 +245,9 @@ def test_openapi_run_option_schema_exposes_only_supported_values() -> None:
properties = schema["properties"]
assert schema["additionalProperties"] is False
- for field in ("webhook", "stream_resumable", "after_seconds", "feedback_keys"):
+ for field in ("webhook", "after_seconds", "feedback_keys"):
assert properties[field]["type"] == "null"
+ assert properties["stream_resumable"]["anyOf"] == [{"const": False, "type": "boolean"}, {"type": "null"}]
assert properties["on_completion"]["type"] == "null"
assert properties["if_not_exists"]["const"] == "create"
assert properties["multitask_strategy"]["enum"] == ["reject", "rollback", "interrupt"]
From 04b7e693f03a639288cc3aa1b3ed08a865492fe8 Mon Sep 17 00:00:00 2001
From: Khanh Dang <97304629+khanhdlq@users.noreply.github.com>
Date: Sun, 26 Jul 2026 14:34:59 +0700
Subject: [PATCH 25/35] docs: fix scheduled-task source links (#4397)
---
.../specs/2026-07-01-scheduled-tasks-mvp-design.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/superpowers/specs/2026-07-01-scheduled-tasks-mvp-design.md b/docs/superpowers/specs/2026-07-01-scheduled-tasks-mvp-design.md
index b9381063a..4cded6718 100644
--- a/docs/superpowers/specs/2026-07-01-scheduled-tasks-mvp-design.md
+++ b/docs/superpowers/specs/2026-07-01-scheduled-tasks-mvp-design.md
@@ -274,8 +274,8 @@ For each poll cycle:
MVP must extract or reuse a non-router helper based on existing logic in:
-- [backend/app/gateway/services.py](/Users/nowcoder/Desktop/auto-code-work/deer-flow/.worktrees/scheduled-tasks-mvp/backend/app/gateway/services.py)
-- [backend/app/gateway/routers/thread_runs.py](/Users/nowcoder/Desktop/auto-code-work/deer-flow/.worktrees/scheduled-tasks-mvp/backend/app/gateway/routers/thread_runs.py)
+- [backend/app/gateway/services.py](../../../backend/app/gateway/services.py)
+- [backend/app/gateway/routers/thread_runs.py](../../../backend/app/gateway/routers/thread_runs.py)
Required property:
From 68c0ffdac88719a9d026dd3ea6e1f18ab039f4e1 Mon Sep 17 00:00:00 2001
From: Ryker_Feng <90562015+18062706139fcz@users.noreply.github.com>
Date: Sun, 26 Jul 2026 20:47:58 +0800
Subject: [PATCH 26/35] feat(frontend): pin recent chats (#4442)
* feat(frontend): pin recent chats
* fix(threads): address pin-chat review feedback
- Stop bumping updated_at on metadata-only PATCH (pin/unpin) via a new
update_metadata(touch=False) path so unpinning no longer jumps a chat
to the top of the updated_at-sorted recent list.
- Narrow patchThreadMetadata to a ThreadMetadataPatchResponse matching
the Gateway's actual response (no values/context).
- Namespace the pinned metadata key as deerflow_pinned for consistency
with deerflow_sidecar / deerflow_branch.
- Cover touch/touch=False behavior in repo + router tests; document the
e2e mock's updated_at preservation now mirrors production.
* style(frontend): format thread utils test
* fix(threads): make pinned ordering server-side
* test(frontend): keep infinite-scroll fixture order stable
* test(frontend): stabilize lark reconnect e2e
* docs: clarify thread pin metadata contract
---
backend/app/gateway/routers/threads.py | 14 ++-
.../persistence/thread_meta/__init__.py | 3 +-
.../deerflow/persistence/thread_meta/base.py | 18 +++-
.../persistence/thread_meta/memory.py | 46 +++++++--
.../deerflow/persistence/thread_meta/sql.py | 28 +++++-
backend/tests/test_thread_meta_repo.py | 34 ++++++-
backend/tests/test_threads_router.py | 94 +++++++++++++++++--
.../components/workspace/recent-chat-list.tsx | 47 +++++++++-
frontend/src/core/i18n/locales/en-US.ts | 3 +
frontend/src/core/i18n/locales/types.ts | 3 +
frontend/src/core/i18n/locales/zh-CN.ts | 3 +
frontend/src/core/threads/api.ts | 39 +++++++-
frontend/src/core/threads/hooks.ts | 92 +++++++++++++++++-
frontend/src/core/threads/utils.ts | 24 +++++
frontend/tests/e2e/integrations.spec.ts | 10 ++
.../e2e/thread-list-infinite-scroll.spec.ts | 10 +-
frontend/tests/e2e/thread-list-pin.spec.ts | 92 ++++++++++++++++++
frontend/tests/e2e/utils/mock-api.ts | 84 ++++++++++++++++-
.../tests/unit/core/threads/utils.test.ts | 53 +++++++++++
19 files changed, 662 insertions(+), 35 deletions(-)
create mode 100644 frontend/tests/e2e/thread-list-pin.spec.ts
diff --git a/backend/app/gateway/routers/threads.py b/backend/app/gateway/routers/threads.py
index b3cf2d5da..31ac9ac8b 100644
--- a/backend/app/gateway/routers/threads.py
+++ b/backend/app/gateway/routers/threads.py
@@ -45,6 +45,7 @@ from app.gateway.utils import sanitize_log_param
from deerflow.agents.thread_state import THREAD_STATE_REDUCER_FIELDS
from deerflow.config.paths import Paths, get_paths
from deerflow.config.summarization_config import ContextSize
+from deerflow.persistence.thread_meta import THREAD_PINNED_METADATA_KEY
from deerflow.runtime import serialize_channel_values_for_api
from deerflow.runtime.checkpoint_mode import CheckpointModeMismatchError, CheckpointModeReconfigurationError
from deerflow.runtime.checkpoint_state import graph_reducer_channels, graph_state_schema, graph_writable_channels
@@ -116,6 +117,11 @@ def _strip_reserved_metadata(metadata: dict[str, Any] | None) -> dict[str, Any]:
return {k: v for k, v in metadata.items() if k not in _SERVER_RESERVED_METADATA_KEYS}
+def _is_pin_metadata_patch(metadata: dict[str, Any]) -> bool:
+ """Return True for the narrow pin/unpin PATCH shape."""
+ return set(metadata) == {THREAD_PINNED_METADATA_KEY} and isinstance(metadata.get(THREAD_PINNED_METADATA_KEY), bool)
+
+
def _message_id(message: Any) -> str | None:
if isinstance(message, dict):
raw = message.get("id")
@@ -973,13 +979,17 @@ async def patch_thread(thread_id: str, body: ThreadPatchRequest, request: Reques
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
# ``body.metadata`` already stripped by ``ThreadPatchRequest._strip_reserved``.
+ # Pin/unpin is not conversation activity, so it must not bump ``updated_at``.
+ # Other metadata PATCH callers keep the public endpoint's existing recency
+ # contract unless they get their own explicit no-touch API surface.
+ touch = not _is_pin_metadata_patch(body.metadata)
try:
- await thread_store.update_metadata(thread_id, body.metadata)
+ await thread_store.update_metadata(thread_id, body.metadata, touch=touch)
except Exception:
logger.exception("Failed to patch thread %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to update thread")
- # Re-read to get the merged metadata + refreshed updated_at
+ # Re-read to get the merged metadata and the store's timestamp decision.
record = await thread_store.get(thread_id) or record
return ThreadResponse(
thread_id=thread_id,
diff --git a/backend/packages/harness/deerflow/persistence/thread_meta/__init__.py b/backend/packages/harness/deerflow/persistence/thread_meta/__init__.py
index b5231f0f9..66151d4ab 100644
--- a/backend/packages/harness/deerflow/persistence/thread_meta/__init__.py
+++ b/backend/packages/harness/deerflow/persistence/thread_meta/__init__.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
-from deerflow.persistence.thread_meta.base import InvalidMetadataFilterError, ThreadMetaStore
+from deerflow.persistence.thread_meta.base import THREAD_PINNED_METADATA_KEY, InvalidMetadataFilterError, ThreadMetaStore
from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.persistence.thread_meta.sql import ThreadMetaRepository
@@ -16,6 +16,7 @@ if TYPE_CHECKING:
__all__ = [
"InvalidMetadataFilterError",
"MemoryThreadMetaStore",
+ "THREAD_PINNED_METADATA_KEY",
"ThreadMetaRepository",
"ThreadMetaRow",
"ThreadMetaStore",
diff --git a/backend/packages/harness/deerflow/persistence/thread_meta/base.py b/backend/packages/harness/deerflow/persistence/thread_meta/base.py
index 4207b4daa..f9fecb30d 100644
--- a/backend/packages/harness/deerflow/persistence/thread_meta/base.py
+++ b/backend/packages/harness/deerflow/persistence/thread_meta/base.py
@@ -19,6 +19,11 @@ from typing import Any
from deerflow.runtime.user_context import AUTO, _AutoSentinel
+# Cross-component metadata key. Keep in sync with
+# ``frontend/src/core/threads/utils.ts`` and
+# ``frontend/tests/e2e/utils/mock-api.ts``.
+THREAD_PINNED_METADATA_KEY = "deerflow_pinned"
+
class InvalidMetadataFilterError(ValueError):
"""Raised when all client-supplied metadata filter keys are rejected."""
@@ -51,6 +56,12 @@ class ThreadMetaStore(abc.ABC):
offset: int = 0,
user_id: str | None | _AutoSentinel = AUTO,
) -> list[dict[str, Any]]:
+ """Search threads.
+
+ Results are ordered with pinned threads first
+ (``metadata.deerflow_pinned is True``), then by ``updated_at`` and
+ ``thread_id`` descending within each group.
+ """
pass
@abc.abstractmethod
@@ -62,12 +73,17 @@ class ThreadMetaStore(abc.ABC):
pass
@abc.abstractmethod
- async def update_metadata(self, thread_id: str, metadata: dict, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
+ async def update_metadata(self, thread_id: str, metadata: dict, *, touch: bool = True, user_id: str | None | _AutoSentinel = AUTO) -> None:
"""Merge ``metadata`` into the thread's metadata field.
Existing keys are overwritten by the new values; keys absent from
``metadata`` are preserved. No-op if the thread does not exist
or the owner check fails.
+
+ When ``touch`` is ``True`` (default) the row's ``updated_at`` is
+ refreshed so the change bumps recency ordering. Pass ``touch=False``
+ for metadata that is not conversation activity (e.g. pin/unpin) so the
+ thread keeps its place in ``updated_at``-sorted lists.
"""
pass
diff --git a/backend/packages/harness/deerflow/persistence/thread_meta/memory.py b/backend/packages/harness/deerflow/persistence/thread_meta/memory.py
index b17d994f8..310b13032 100644
--- a/backend/packages/harness/deerflow/persistence/thread_meta/memory.py
+++ b/backend/packages/harness/deerflow/persistence/thread_meta/memory.py
@@ -11,11 +11,12 @@ from typing import Any
from langgraph.store.base import BaseStore
-from deerflow.persistence.thread_meta.base import ThreadMetaStore
+from deerflow.persistence.thread_meta.base import THREAD_PINNED_METADATA_KEY, ThreadMetaStore
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
from deerflow.utils.time import coerce_iso, now_iso
THREADS_NS: tuple[str, ...] = ("threads",)
+SEARCH_PAGE_SIZE = 500
class MemoryThreadMetaStore(ThreadMetaStore):
@@ -75,6 +76,12 @@ class MemoryThreadMetaStore(ThreadMetaStore):
offset: int = 0,
user_id: str | None | _AutoSentinel = AUTO,
) -> list[dict[str, Any]]:
+ """Search threads by materializing matches, then sorting in Python.
+
+ The memory backend loads all matching rows in chunks before slicing so
+ it can mirror SQL's pinned-first ordering. Use the SQL store for
+ scalable paginated I/O.
+ """
resolved_user_id = resolve_user_id(user_id, method_name="MemoryThreadMetaStore.search")
filter_dict: dict[str, Any] = {}
if metadata:
@@ -84,13 +91,25 @@ class MemoryThreadMetaStore(ThreadMetaStore):
if resolved_user_id is not None:
filter_dict["user_id"] = resolved_user_id
- items = await self._store.asearch(
- THREADS_NS,
- filter=filter_dict or None,
- limit=limit,
- offset=offset,
- )
- return [self._item_to_dict(item) for item in items]
+ items = []
+ search_offset = 0
+ while True:
+ page = await self._store.asearch(
+ THREADS_NS,
+ filter=filter_dict or None,
+ limit=SEARCH_PAGE_SIZE,
+ offset=search_offset,
+ )
+ if not page:
+ break
+ items.extend(page)
+ if len(page) < SEARCH_PAGE_SIZE:
+ break
+ search_offset += len(page)
+
+ records = [self._item_to_dict(item) for item in items]
+ records.sort(key=self._sort_key, reverse=True)
+ return records[offset : offset + limit]
async def check_access(self, thread_id: str, user_id: str, *, require_existing: bool = False) -> bool:
item = await self._store.aget(THREADS_NS, thread_id)
@@ -117,14 +136,15 @@ class MemoryThreadMetaStore(ThreadMetaStore):
record["updated_at"] = now_iso()
await self._store.aput(THREADS_NS, thread_id, record)
- async def update_metadata(self, thread_id: str, metadata: dict, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
+ async def update_metadata(self, thread_id: str, metadata: dict, *, touch: bool = True, user_id: str | None | _AutoSentinel = AUTO) -> None:
record = await self._get_owned_record(thread_id, user_id, "MemoryThreadMetaStore.update_metadata")
if record is None:
return
merged = dict(record.get("metadata") or {})
merged.update(metadata)
record["metadata"] = merged
- record["updated_at"] = now_iso()
+ if touch:
+ record["updated_at"] = now_iso()
await self._store.aput(THREADS_NS, thread_id, record)
async def update_owner(self, thread_id: str, owner_user_id: str, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
@@ -157,3 +177,9 @@ class MemoryThreadMetaStore(ThreadMetaStore):
"created_at": coerce_iso(val.get("created_at", "")),
"updated_at": coerce_iso(val.get("updated_at", "")),
}
+
+ @staticmethod
+ def _sort_key(record: dict[str, Any]) -> tuple[bool, str, str]:
+ metadata = record.get("metadata")
+ pinned = isinstance(metadata, dict) and metadata.get(THREAD_PINNED_METADATA_KEY) is True
+ return (pinned, str(record.get("updated_at") or ""), str(record.get("thread_id") or ""))
diff --git a/backend/packages/harness/deerflow/persistence/thread_meta/sql.py b/backend/packages/harness/deerflow/persistence/thread_meta/sql.py
index a5e7f51c5..56ae32196 100644
--- a/backend/packages/harness/deerflow/persistence/thread_meta/sql.py
+++ b/backend/packages/harness/deerflow/persistence/thread_meta/sql.py
@@ -6,11 +6,12 @@ import logging
from datetime import UTC, datetime
from typing import Any
-from sqlalchemy import select, update
+from sqlalchemy import case, select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+from sqlalchemy.orm.attributes import flag_modified
from deerflow.persistence.json_compat import json_match
-from deerflow.persistence.thread_meta.base import InvalidMetadataFilterError, ThreadMetaStore
+from deerflow.persistence.thread_meta.base import THREAD_PINNED_METADATA_KEY, InvalidMetadataFilterError, ThreadMetaStore
from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
from deerflow.utils.time import coerce_iso
@@ -123,7 +124,15 @@ class ThreadMetaRepository(ThreadMetaStore):
context. Pass ``user_id=None`` to bypass (migration/CLI).
"""
resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.search")
- stmt = select(ThreadMetaRow).order_by(ThreadMetaRow.updated_at.desc(), ThreadMetaRow.thread_id.desc())
+ pinned_order = case(
+ (json_match(ThreadMetaRow.metadata_json, THREAD_PINNED_METADATA_KEY, True), 1),
+ else_=0,
+ )
+ stmt = select(ThreadMetaRow).order_by(
+ pinned_order.desc(),
+ ThreadMetaRow.updated_at.desc(),
+ ThreadMetaRow.thread_id.desc(),
+ )
if resolved_user_id is not None:
stmt = stmt.where(ThreadMetaRow.user_id == resolved_user_id)
if status:
@@ -190,6 +199,7 @@ class ThreadMetaRepository(ThreadMetaStore):
thread_id: str,
metadata: dict,
*,
+ touch: bool = True,
user_id: str | None | _AutoSentinel = AUTO,
) -> None:
"""Merge ``metadata`` into ``metadata_json``.
@@ -197,6 +207,9 @@ class ThreadMetaRepository(ThreadMetaStore):
Read-modify-write inside a single session/transaction so concurrent
callers see consistent state. No-op if the row does not exist or
the user_id check fails.
+
+ ``touch`` refreshes ``updated_at`` (default); pass ``touch=False`` to
+ preserve recency ordering for metadata-only changes such as pin/unpin.
"""
resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.update_metadata")
async with self._sf() as session:
@@ -208,7 +221,14 @@ class ThreadMetaRepository(ThreadMetaStore):
merged = dict(row.metadata_json or {})
merged.update(metadata)
row.metadata_json = merged
- row.updated_at = datetime.now(UTC)
+ if touch:
+ row.updated_at = datetime.now(UTC)
+ else:
+ # ``updated_at`` has an ``onupdate`` hook that fires on any row
+ # UPDATE unless the column has an explicit SET value. Mark the
+ # current value dirty so SQLAlchemy emits it in SET, skips the
+ # hook, and preserves recency ordering.
+ flag_modified(row, "updated_at")
await session.commit()
async def update_owner(
diff --git a/backend/tests/test_thread_meta_repo.py b/backend/tests/test_thread_meta_repo.py
index c6fff8868..b07f477fe 100644
--- a/backend/tests/test_thread_meta_repo.py
+++ b/backend/tests/test_thread_meta_repo.py
@@ -4,7 +4,7 @@ import logging
import pytest
-from deerflow.persistence.thread_meta import InvalidMetadataFilterError, ThreadMetaRepository
+from deerflow.persistence.thread_meta import THREAD_PINNED_METADATA_KEY, InvalidMetadataFilterError, ThreadMetaRepository
@pytest.fixture
@@ -137,6 +137,38 @@ class TestThreadMetaRepository:
async def test_update_metadata_nonexistent_is_noop(self, repo):
await repo.update_metadata("nonexistent", {"k": "v"}) # should not raise
+ @pytest.mark.anyio
+ async def test_update_metadata_touches_updated_at_by_default(self, repo):
+ await repo.create("t1", metadata={"a": 1})
+ original = (await repo.get("t1"))["updated_at"]
+
+ await repo.update_metadata("t1", {"b": 2})
+
+ record = await repo.get("t1")
+ assert record["metadata"] == {"a": 1, "b": 2}
+ assert record["updated_at"] >= original
+
+ @pytest.mark.anyio
+ async def test_update_metadata_touch_false_preserves_updated_at(self, repo):
+ await repo.create("t1", metadata={"a": 1})
+ original = (await repo.get("t1"))["updated_at"]
+
+ # Pin/unpin style patch must not bump recency ordering.
+ await repo.update_metadata("t1", {THREAD_PINNED_METADATA_KEY: True}, touch=False)
+
+ record = await repo.get("t1")
+ assert record["metadata"] == {"a": 1, THREAD_PINNED_METADATA_KEY: True}
+ assert record["updated_at"] == original
+
+ @pytest.mark.anyio
+ async def test_search_orders_pinned_threads_before_newer_unpinned_threads(self, repo):
+ await repo.create("older-pinned", metadata={THREAD_PINNED_METADATA_KEY: True})
+ await repo.create("newer-unpinned")
+
+ results = await repo.search(limit=1)
+
+ assert [record["thread_id"] for record in results] == ["older-pinned"]
+
@pytest.mark.anyio
async def test_update_owner_with_bypass_moves_row(self, repo):
await repo.create("t1", user_id="default", metadata={"source": "channel"})
diff --git a/backend/tests/test_threads_router.py b/backend/tests/test_threads_router.py
index bc751f12f..e2c0f5f2f 100644
--- a/backend/tests/test_threads_router.py
+++ b/backend/tests/test_threads_router.py
@@ -17,7 +17,7 @@ from langgraph.types import Overwrite
from app.gateway import services as gateway_services
from app.gateway.routers import thread_runs, threads
from deerflow.config.paths import Paths
-from deerflow.persistence.thread_meta import InvalidMetadataFilterError
+from deerflow.persistence.thread_meta import THREAD_PINNED_METADATA_KEY, InvalidMetadataFilterError
from deerflow.persistence.thread_meta.memory import THREADS_NS, MemoryThreadMetaStore
from deerflow.runtime.checkpoint_state import CheckpointStateAccessor
@@ -957,7 +957,12 @@ def test_get_thread_preserves_metadata_status_without_checkpoint(stored_status:
assert response.json()["status"] == stored_status
-def test_patch_thread_returns_iso_and_advances_updated_at() -> None:
+def test_patch_thread_pin_returns_iso_and_preserves_updated_at() -> None:
+ """A pin/unpin PATCH must not bump ``updated_at``.
+
+ Pinning or unpinning a chat does not represent conversation activity.
+ Timestamps are still surfaced as ISO via ``coerce_iso``.
+ """
app, store, _checkpointer = _build_thread_app()
thread_id = "patch-target"
@@ -982,16 +987,53 @@ def test_patch_thread_returns_iso_and_advances_updated_at() -> None:
asyncio.run(_seed())
with TestClient(app) as client:
- response = client.patch(f"/api/threads/{thread_id}", json={"metadata": {"k": "v1"}})
+ response = client.patch(
+ f"/api/threads/{thread_id}",
+ json={"metadata": {THREAD_PINNED_METADATA_KEY: True}},
+ )
assert response.status_code == 200, response.text
body = response.json()
assert _ISO_TIMESTAMP_RE.match(body["created_at"]), body["created_at"]
assert _ISO_TIMESTAMP_RE.match(body["updated_at"]), body["updated_at"]
- # Patch issues a fresh ``updated_at`` via ``MemoryThreadMetaStore.update_metadata``,
- # so it must be > the migrated legacy ``created_at`` (both ISO strings
- # sort lexicographically by time when the format is consistent).
- assert body["updated_at"] > body["created_at"]
+ # ``touch=False`` preserves the original ``updated_at``; both timestamps
+ # derive from the same legacy value, so they coerce to the same ISO string.
+ assert body["updated_at"] == body["created_at"]
+ assert body["metadata"] == {"k": "v0", THREAD_PINNED_METADATA_KEY: True}
+
+
+def test_patch_thread_non_pin_metadata_bumps_updated_at() -> None:
+ """The public metadata PATCH endpoint still bumps recency by default."""
+ app, store, _checkpointer = _build_thread_app()
+ thread_id = "patch-target"
+
+ legacy_created = "946684800.000000"
+ legacy_updated = "946684800.000000"
+
+ async def _seed() -> None:
+ await store.aput(
+ THREADS_NS,
+ thread_id,
+ {
+ "thread_id": thread_id,
+ "status": "idle",
+ "created_at": legacy_created,
+ "updated_at": legacy_updated,
+ "metadata": {"k": "v0"},
+ },
+ )
+
+ import asyncio
+
+ asyncio.run(_seed())
+
+ with TestClient(app) as client:
+ response = client.patch(f"/api/threads/{thread_id}", json={"metadata": {"k": "v1"}})
+
+ assert response.status_code == 200, response.text
+ body = response.json()
+ assert _ISO_TIMESTAMP_RE.match(body["updated_at"]), body["updated_at"]
+ assert body["updated_at"] != body["created_at"]
assert body["metadata"] == {"k": "v1"}
@@ -1044,6 +1086,44 @@ def test_search_threads_normalizes_legacy_unix_seconds_to_iso() -> None:
assert _ISO_TIMESTAMP_RE.match(item["updated_at"]), item
+def test_search_threads_returns_pinned_threads_before_newer_unpinned_threads() -> None:
+ app, store, _checkpointer = _build_thread_app()
+
+ async def _seed() -> None:
+ await store.aput(
+ THREADS_NS,
+ "newer-unpinned",
+ {
+ "thread_id": "newer-unpinned",
+ "status": "idle",
+ "created_at": "2026-07-01T00:00:00+00:00",
+ "updated_at": "2026-07-20T00:00:00+00:00",
+ "metadata": {},
+ },
+ )
+ await store.aput(
+ THREADS_NS,
+ "older-pinned",
+ {
+ "thread_id": "older-pinned",
+ "status": "idle",
+ "created_at": "2026-06-01T00:00:00+00:00",
+ "updated_at": "2026-06-01T00:00:00+00:00",
+ "metadata": {THREAD_PINNED_METADATA_KEY: True},
+ },
+ )
+
+ import asyncio
+
+ asyncio.run(_seed())
+
+ with TestClient(app) as client:
+ response = client.post("/api/threads/search", json={"limit": 1})
+
+ assert response.status_code == 200, response.text
+ assert [item["thread_id"] for item in response.json()] == ["older-pinned"]
+
+
def test_memory_thread_meta_store_writes_iso_on_create() -> None:
"""``MemoryThreadMetaStore.create`` must emit ISO so newly created
threads serialize correctly without depending on the router's
diff --git a/frontend/src/components/workspace/recent-chat-list.tsx b/frontend/src/components/workspace/recent-chat-list.tsx
index 826b8af87..c7fd24cb7 100644
--- a/frontend/src/components/workspace/recent-chat-list.tsx
+++ b/frontend/src/components/workspace/recent-chat-list.tsx
@@ -6,6 +6,8 @@ import {
FileText,
MoreHorizontal,
Pencil,
+ Pin,
+ PinOff,
Share2,
Trash2,
} from "lucide-react";
@@ -53,12 +55,15 @@ import {
import {
useDeleteThread,
useInfiniteThreads,
+ usePinThread,
useRenameThread,
} from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import {
channelSourceOfThread,
+ isThreadPinned,
pathOfThread,
+ sortPinnedThreads,
titleOfThread,
} from "@/core/threads/utils";
import { env } from "@/env";
@@ -91,6 +96,7 @@ export function RecentChatList() {
return true;
});
}, [infiniteThreads]);
+ const displayedThreads = useMemo(() => sortPinnedThreads(threads), [threads]);
const sentinelRef = useRef(null);
useEffect(() => {
@@ -112,6 +118,7 @@ export function RecentChatList() {
const { mutate: deleteThread } = useDeleteThread();
const { mutate: renameThread } = useRenameThread();
+ const { mutate: updatePinnedThread } = usePinThread();
// Rename dialog state
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
@@ -187,6 +194,25 @@ export function RecentChatList() {
}
}, [renameThread, renameThreadId, renameValue, t.common.renameFailed]);
+ const handleTogglePin = useCallback(
+ (thread: AgentThread) => {
+ updatePinnedThread(
+ {
+ threadId: thread.thread_id,
+ pinned: !isThreadPinned(thread),
+ },
+ {
+ onError: (err) => {
+ toast.error(
+ err instanceof Error ? err.message : t.chats.pinChatFailed,
+ );
+ },
+ },
+ );
+ },
+ [t.chats.pinChatFailed, updatePinnedThread],
+ );
+
const handleShare = useCallback(
async (thread: AgentThread) => {
// Always use Vercel URL for sharing so others can access
@@ -251,9 +277,10 @@ export function RecentChatList() {
- {threads.map((thread) => {
+ {displayedThreads.map((thread) => {
const isActive = pathOfThread(thread) === pathname;
const channelSource = channelSourceOfThread(thread);
+ const pinned = isThreadPinned(thread);
return (
+ {pinned && (
+
+ )}
{titleOfThread(thread)}
@@ -296,6 +329,18 @@ export function RecentChatList() {
side={"right"}
align={"start"}
>
+ handleTogglePin(thread)}
+ >
+ {pinned ? (
+
+ ) : (
+
+ )}
+
+ {pinned ? t.chats.unpinChat : t.chats.pinChat}
+
+
handleRenameClick(
diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts
index ca81ba209..42de3ac12 100644
--- a/frontend/src/core/i18n/locales/en-US.ts
+++ b/frontend/src/core/i18n/locales/en-US.ts
@@ -514,6 +514,9 @@ export const enUS: Translations = {
loadMoreToSearch: "Load more to search older conversations",
loadingMore: "Loading more...",
loadOlderChats: "Load older chats",
+ pinChat: "Pin chat",
+ unpinChat: "Unpin chat",
+ pinChatFailed: "Failed to update pinned chat",
},
// Sidecar
diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts
index 9fc9a1824..7d5e3ece1 100644
--- a/frontend/src/core/i18n/locales/types.ts
+++ b/frontend/src/core/i18n/locales/types.ts
@@ -413,6 +413,9 @@ export interface Translations {
loadMoreToSearch: string;
loadingMore: string;
loadOlderChats: string;
+ pinChat: string;
+ unpinChat: string;
+ pinChatFailed: string;
};
// Sidecar
diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts
index 6d6e30209..4544b9429 100644
--- a/frontend/src/core/i18n/locales/zh-CN.ts
+++ b/frontend/src/core/i18n/locales/zh-CN.ts
@@ -491,6 +491,9 @@ export const zhCN: Translations = {
loadMoreToSearch: "加载更多以搜索更早的对话",
loadingMore: "正在加载...",
loadOlderChats: "加载更早的对话",
+ pinChat: "置顶对话",
+ unpinChat: "取消置顶",
+ pinChatFailed: "更新对话置顶状态失败",
},
// Sidecar
diff --git a/frontend/src/core/threads/api.ts b/frontend/src/core/threads/api.ts
index 83ff52077..3759e0160 100644
--- a/frontend/src/core/threads/api.ts
+++ b/frontend/src/core/threads/api.ts
@@ -1,7 +1,7 @@
import { fetch as fetchWithAuth } from "@/core/api/fetcher";
import { getBackendBaseURL } from "@/core/config";
-import type { ThreadTokenUsageResponse } from "./types";
+import type { AgentThread, ThreadTokenUsageResponse } from "./types";
export type ThreadCompactResponse = {
thread_id: string;
@@ -34,6 +34,19 @@ export type BranchThreadFromTurnInput = {
title?: string;
};
+export type ThreadMetadataPatch = Record;
+
+/**
+ * The subset of thread fields the Gateway ``PATCH /api/threads/{id}`` handler
+ * returns with meaningful values. The endpoint's ``ThreadResponse`` model also
+ * serializes default ``values`` and ``interrupts``, but PATCH leaves those empty;
+ * callers that need state should read it via a full thread fetch instead.
+ */
+export type ThreadMetadataPatchResponse = Pick<
+ AgentThread,
+ "thread_id" | "status" | "created_at" | "updated_at" | "metadata"
+>;
+
async function readThreadAPIError(
response: Response,
fallback: string,
@@ -97,6 +110,30 @@ export async function branchThreadFromTurn(
return (await response.json()) as ThreadBranchResponse;
}
+export async function patchThreadMetadata(
+ threadId: string,
+ metadata: ThreadMetadataPatch,
+): Promise {
+ const response = await fetchWithAuth(
+ `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}`,
+ {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ metadata }),
+ },
+ );
+
+ if (!response.ok) {
+ throw new Error(
+ await readThreadAPIError(response, "Failed to update conversation."),
+ );
+ }
+
+ return (await response.json()) as ThreadMetadataPatchResponse;
+}
+
export async function compactThreadContext(
threadId: string,
options: CompactThreadContextOptions = {},
diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts
index 4a9525586..39225040e 100644
--- a/frontend/src/core/threads/hooks.ts
+++ b/frontend/src/core/threads/hooks.ts
@@ -29,7 +29,12 @@ import { messageToStep } from "../tasks/steps";
import type { UploadedFileInfo } from "../uploads";
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
-import { branchThreadFromTurn, fetchThreadTokenUsage } from "./api";
+import {
+ branchThreadFromTurn,
+ fetchThreadTokenUsage,
+ patchThreadMetadata,
+ type ThreadMetadataPatch,
+} from "./api";
import {
buildThreadsSearchQueryOptions,
DEFAULT_THREAD_SEARCH_PARAMS,
@@ -43,6 +48,7 @@ import type {
RunMessage,
ThreadTokenUsageResponse,
} from "./types";
+import { THREAD_PINNED_METADATA_KEY } from "./utils";
export type ThreadStreamOptions = {
threadId?: string | null | undefined;
@@ -2137,6 +2143,62 @@ export function filterInfiniteThreadsCache(
};
}
+function mergeThreadMetadata(
+ thread: AgentThread,
+ metadata: ThreadMetadataPatch,
+): AgentThread {
+ return {
+ ...thread,
+ metadata: {
+ ...(thread.metadata ?? {}),
+ ...metadata,
+ },
+ };
+}
+
+function setThreadMetadataInCaches(
+ queryClient: QueryClient,
+ threadId: string,
+ metadata: ThreadMetadataPatch,
+) {
+ queryClient.setQueriesData(
+ {
+ queryKey: ["threads", "search"],
+ exact: false,
+ },
+ (oldData: Array | undefined) => {
+ if (!oldData) {
+ return oldData;
+ }
+ return oldData.map((thread) =>
+ thread.thread_id === threadId
+ ? mergeThreadMetadata(thread, metadata)
+ : thread,
+ );
+ },
+ );
+ queryClient.setQueriesData(
+ {
+ queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
+ exact: false,
+ },
+ (oldData: InfiniteData | undefined) =>
+ mapInfiniteThreadsCache(oldData, (thread) =>
+ thread.thread_id === threadId
+ ? mergeThreadMetadata(thread, metadata)
+ : thread,
+ ),
+ );
+ queryClient.setQueriesData(
+ {
+ queryKey: ["thread", "metadata", threadId],
+ exact: false,
+ },
+ (oldData: AgentThread | null | undefined) =>
+ oldData ? mergeThreadMetadata(oldData, metadata) : oldData,
+ );
+}
+
export function useInfiniteThreads(
params: InfiniteThreadsParams = {
sortBy: "updated_at",
@@ -2263,6 +2325,34 @@ export function useBranchThread() {
});
}
+export function usePinThread() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async ({
+ threadId,
+ pinned,
+ }: {
+ threadId: string;
+ pinned: boolean;
+ }) =>
+ patchThreadMetadata(threadId, {
+ [THREAD_PINNED_METADATA_KEY]: pinned,
+ }),
+ onSuccess(response, { threadId, pinned }) {
+ setThreadMetadataInCaches(queryClient, threadId, {
+ ...(response.metadata ?? {}),
+ [THREAD_PINNED_METADATA_KEY]: pinned,
+ });
+ },
+ onSettled() {
+ void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
+ void queryClient.invalidateQueries({
+ queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
+ });
+ },
+ });
+}
+
export function useRunDetail(threadId: string, runId: string) {
const apiClient = getAPIClient();
return useQuery({
diff --git a/frontend/src/core/threads/utils.ts b/frontend/src/core/threads/utils.ts
index ae543a6f2..cac0e2c13 100644
--- a/frontend/src/core/threads/utils.ts
+++ b/frontend/src/core/threads/utils.ts
@@ -2,6 +2,12 @@ import type { Message } from "@langchain/langgraph-sdk";
import type { AgentThread, AgentThreadContext } from "./types";
+// Namespaced to match other internal metadata keys (``deerflow_sidecar``,
+// ``deerflow_branch``) so it cannot collide with a future feature or a
+// client-supplied key. Keep in sync with the backend thread_meta constant and
+// the E2E mock-api constant.
+export const THREAD_PINNED_METADATA_KEY = "deerflow_pinned";
+
export type ChannelThreadSource = {
type: "im_channel";
provider: string;
@@ -60,6 +66,24 @@ export function titleOfThread(thread: AgentThread) {
return thread.values?.title ?? "Untitled";
}
+export function isThreadPinned(thread: Pick) {
+ return thread.metadata?.[THREAD_PINNED_METADATA_KEY] === true;
+}
+
+export function sortPinnedThreads>(
+ threads: readonly T[],
+) {
+ return threads
+ .map((thread, index) => ({ thread, index }))
+ .sort((left, right) => {
+ const pinnedDiff =
+ Number(isThreadPinned(right.thread)) -
+ Number(isThreadPinned(left.thread));
+ return pinnedDiff || left.index - right.index;
+ })
+ .map(({ thread }) => thread);
+}
+
const CHANNEL_PROVIDER_LABELS: Record = {
dingtalk: "DingTalk",
discord: "Discord",
diff --git a/frontend/tests/e2e/integrations.spec.ts b/frontend/tests/e2e/integrations.spec.ts
index 083d81034..7be132df8 100644
--- a/frontend/tests/e2e/integrations.spec.ts
+++ b/frontend/tests/e2e/integrations.spec.ts
@@ -49,10 +49,20 @@ test.describe("Integrations settings", () => {
mockLangGraphAPI(page);
let authStartRequest: unknown;
const authCompleteRequests: unknown[] = [];
+ let authCompleteCount = 0;
await page.route(
"**/api/integrations/lark/auth/complete",
async (route) => {
authCompleteRequests.push(route.request().postDataJSON());
+ authCompleteCount += 1;
+ if (authCompleteCount > 1) {
+ await route.fulfill({
+ status: 504,
+ contentType: "application/json",
+ body: JSON.stringify({ detail: "Authorization still pending." }),
+ });
+ return;
+ }
await route.fallback();
},
);
diff --git a/frontend/tests/e2e/thread-list-infinite-scroll.spec.ts b/frontend/tests/e2e/thread-list-infinite-scroll.spec.ts
index f0d75ecc0..5b5681f4f 100644
--- a/frontend/tests/e2e/thread-list-infinite-scroll.spec.ts
+++ b/frontend/tests/e2e/thread-list-infinite-scroll.spec.ts
@@ -11,14 +11,16 @@ const TOTAL_THREADS = 120;
const PAGE_SIZE = 50;
const THREADS = Array.from({ length: TOTAL_THREADS }, (_, i) => {
- // Pad index so titles sort deterministically as strings. The thread-search
- // mock returns threads in the order provided, so paging boundaries are
- // stable across runs.
+ // Pad index so titles sort deterministically as strings. Keep updated_at
+ // monotonically descending to match the backend's updated_at-desc search
+ // order, so paging boundaries are stable across runs.
const index = String(i + 1).padStart(3, "0");
return {
thread_id: `00000000-0000-0000-0000-0000000${index.padStart(5, "0")}`,
title: `Conversation ${index}`,
- updated_at: `2025-06-${String((i % 28) + 1).padStart(2, "0")}T12:00:00Z`,
+ updated_at: new Date(
+ Date.UTC(2025, 5, 30, 12, 0, 0) - i * 60_000,
+ ).toISOString(),
};
});
diff --git a/frontend/tests/e2e/thread-list-pin.spec.ts b/frontend/tests/e2e/thread-list-pin.spec.ts
new file mode 100644
index 000000000..cb8522db6
--- /dev/null
+++ b/frontend/tests/e2e/thread-list-pin.spec.ts
@@ -0,0 +1,92 @@
+import { expect, test, type Page } from "@playwright/test";
+
+import { mockLangGraphAPI, THREAD_PINNED_METADATA_KEY } from "./utils/mock-api";
+
+const NEWEST_THREAD_ID = "00000000-0000-0000-0000-000000000901";
+const OLDER_THREAD_ID = "00000000-0000-0000-0000-000000000902";
+
+async function recentChatTitles(page: Page) {
+ return page
+ .locator('a[data-sidebar="menu-button"][href^="/workspace/chats/"]')
+ .evaluateAll((links) =>
+ links
+ .map((link) => link.textContent?.replace(/\s+/g, " ").trim() ?? "")
+ .filter((text) => text && text !== "New chat"),
+ );
+}
+
+test("sidebar recent chats can be pinned and unpinned", async ({ page }) => {
+ mockLangGraphAPI(page, {
+ threads: [
+ {
+ thread_id: NEWEST_THREAD_ID,
+ title: "Newest chat",
+ updated_at: "2026-07-04T10:00:00Z",
+ },
+ {
+ thread_id: OLDER_THREAD_ID,
+ title: "Older chat",
+ updated_at: "2026-07-03T10:00:00Z",
+ },
+ ],
+ });
+
+ await page.goto("/workspace/chats/new");
+
+ await expect(page.getByText("Newest chat")).toBeVisible({ timeout: 15_000 });
+ await expect
+ .poll(() => recentChatTitles(page))
+ .toEqual(["Newest chat", "Older chat"]);
+
+ const olderItem = page
+ .locator(
+ `a[data-sidebar="menu-button"][href="/workspace/chats/${OLDER_THREAD_ID}"]`,
+ )
+ .locator("xpath=..");
+ await olderItem.hover();
+ await olderItem.getByRole("button", { name: "More" }).click();
+ await page.getByRole("menuitem", { name: "Pin chat" }).click();
+
+ await expect
+ .poll(() => recentChatTitles(page))
+ .toEqual(["Older chat", "Newest chat"]);
+
+ await olderItem.hover();
+ await olderItem.getByRole("button", { name: "More" }).click();
+ await page.getByRole("menuitem", { name: "Unpin chat" }).click();
+
+ await expect
+ .poll(() => recentChatTitles(page))
+ .toEqual(["Newest chat", "Older chat"]);
+});
+
+test("server-side search keeps old pinned chats in the first page", async ({
+ page,
+}) => {
+ mockLangGraphAPI(page, {
+ threads: [
+ ...Array.from({ length: 51 }, (_, index) => ({
+ thread_id: `00000000-0000-0000-0000-000000001${String(index).padStart(3, "0")}`,
+ title: `Recent chat ${index + 1}`,
+ updated_at: new Date(
+ Date.UTC(2026, 6, 25, 10, 0, 0) - index * 60_000,
+ ).toISOString(),
+ })),
+ {
+ thread_id: OLDER_THREAD_ID,
+ title: "Old pinned chat",
+ updated_at: "2026-01-01T10:00:00Z",
+ metadata: { [THREAD_PINNED_METADATA_KEY]: true },
+ },
+ ],
+ });
+
+ await page.goto("/workspace/chats/new");
+
+ await expect(page.getByText("Old pinned chat")).toBeVisible({
+ timeout: 15_000,
+ });
+ await expect
+ .poll(async () => (await recentChatTitles(page)).slice(0, 2))
+ .toEqual(["Old pinned chat", "Recent chat 1"]);
+});
diff --git a/frontend/tests/e2e/utils/mock-api.ts b/frontend/tests/e2e/utils/mock-api.ts
index 300935787..a2966b191 100644
--- a/frontend/tests/e2e/utils/mock-api.ts
+++ b/frontend/tests/e2e/utils/mock-api.ts
@@ -16,6 +16,9 @@ export const MOCK_THREAD_ID = "00000000-0000-0000-0000-000000000001";
export const MOCK_THREAD_ID_2 = "00000000-0000-0000-0000-000000000002";
export const MOCK_SIDECAR_THREAD_ID = "00000000-0000-0000-0000-0000000000aa";
export const MOCK_RUN_ID = "00000000-0000-0000-0000-000000000099";
+// Keep in sync with frontend runtime thread utils and the backend thread_meta
+// constant; the mock must mirror the same metadata contract for pin ordering.
+export const THREAD_PINNED_METADATA_KEY = "deerflow_pinned";
const MOCK_AUTH_USER = {
id: "default",
@@ -318,6 +321,44 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
values: { title: thread.title ?? "Untitled", goal: thread.goal ?? null },
});
+ const threadUpdatedAt = (thread: MockThread) =>
+ Date.parse(thread.updated_at ?? "2025-01-01T00:00:00Z") || 0;
+
+ const sortThreadSearchResults = (items: readonly MockThread[]) =>
+ [...items].sort((left, right) => {
+ const pinnedDiff =
+ Number(right.metadata?.[THREAD_PINNED_METADATA_KEY] === true) -
+ Number(left.metadata?.[THREAD_PINNED_METADATA_KEY] === true);
+ return (
+ pinnedDiff ||
+ threadUpdatedAt(right) - threadUpdatedAt(left) ||
+ right.thread_id.localeCompare(left.thread_id)
+ );
+ });
+
+ const patchThreadMetadata = (
+ threadId: string,
+ metadata: Record,
+ ) => {
+ let updated: MockThread | undefined;
+ threads = threads.map((thread) => {
+ if (thread.thread_id !== threadId) {
+ return thread;
+ }
+ // Preserve ``updated_at`` for pin/unpin metadata changes; the search mock
+ // below mirrors the Gateway's server-side pinned-first ordering.
+ updated = {
+ ...thread,
+ metadata: {
+ ...(thread.metadata ?? {}),
+ ...metadata,
+ },
+ };
+ return updated;
+ });
+ return updated;
+ };
+
// Auth — keep workspace tests independent from a real gateway session.
void page.route("**/api/v1/auth/me", (route) => {
if (route.request().method() === "GET") {
@@ -611,7 +652,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
// Thread search — sidebar thread list & chats list page
void page.route("**/api/langgraph/threads/search", async (route) => {
- let body = threads.map(threadSearchResult);
+ let body = sortThreadSearchResults(threads).map(threadSearchResult);
let limit: number | undefined;
let offset = 0;
@@ -698,10 +739,23 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
});
}
if (route.request().method() === "PATCH") {
+ const body = route.request().postDataJSON() as {
+ metadata?: Record;
+ };
+ const updated = body.metadata
+ ? patchThreadMetadata(threadId, body.metadata)
+ : matchingThread;
+ if (!updated) {
+ return route.fulfill({
+ status: 404,
+ contentType: "application/json",
+ body: JSON.stringify({ detail: "Thread not found" }),
+ });
+ }
return route.fulfill({
status: 200,
contentType: "application/json",
- body: JSON.stringify({ thread_id: MOCK_THREAD_ID }),
+ body: JSON.stringify(threadSearchResult(updated)),
});
}
if (route.request().method() === "DELETE") {
@@ -744,6 +798,32 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
});
void page.route(/\/api\/threads\/[^/]+$/, (route) => {
+ if (route.request().method() === "PATCH") {
+ const threadId = decodeURIComponent(
+ new URL(route.request().url()).pathname.split("/").at(-1) ?? "",
+ );
+ const body = route.request().postDataJSON() as {
+ metadata?: Record;
+ };
+ const matchingThread = threads.find(
+ (thread) => thread.thread_id === threadId,
+ );
+ const updated = body.metadata
+ ? patchThreadMetadata(threadId, body.metadata)
+ : matchingThread;
+ if (!updated) {
+ return route.fulfill({
+ status: 404,
+ contentType: "application/json",
+ body: JSON.stringify({ detail: `Thread ${threadId} not found` }),
+ });
+ }
+ return route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(threadSearchResult(updated)),
+ });
+ }
if (route.request().method() === "DELETE") {
const threadId = decodeURIComponent(
new URL(route.request().url()).pathname.split("/").at(-1) ?? "",
diff --git a/frontend/tests/unit/core/threads/utils.test.ts b/frontend/tests/unit/core/threads/utils.test.ts
index e51967d73..d1d0f17de 100644
--- a/frontend/tests/unit/core/threads/utils.test.ts
+++ b/frontend/tests/unit/core/threads/utils.test.ts
@@ -1,12 +1,27 @@
import type { Message } from "@langchain/langgraph-sdk";
import { expect, test } from "@rstest/core";
+import type { AgentThread } from "@/core/threads/types";
import {
channelSourceOfThread,
+ isThreadPinned,
pathOfThread,
+ sortPinnedThreads,
textOfMessage,
+ THREAD_PINNED_METADATA_KEY,
} from "@/core/threads/utils";
+function makeThread(
+ threadId: string,
+ metadata: Record = {},
+): AgentThread {
+ return {
+ thread_id: threadId,
+ metadata,
+ values: { title: threadId },
+ } as unknown as AgentThread;
+}
+
test("uses standard chat route when thread has no agent context", () => {
expect(pathOfThread("thread-123")).toBe("/workspace/chats/thread-123");
expect(
@@ -62,6 +77,44 @@ test("prefers context.agent_name over metadata.agent_name", () => {
).toBe("/workspace/agents/from-context/chats/thread-789");
});
+test("reads pinned thread metadata strictly from the pinned metadata key", () => {
+ expect(
+ isThreadPinned(
+ makeThread("pinned", { [THREAD_PINNED_METADATA_KEY]: true }),
+ ),
+ ).toBe(true);
+ expect(
+ isThreadPinned(
+ makeThread("false", { [THREAD_PINNED_METADATA_KEY]: false }),
+ ),
+ ).toBe(false);
+ expect(
+ isThreadPinned(
+ makeThread("truthy", { [THREAD_PINNED_METADATA_KEY]: "true" }),
+ ),
+ ).toBe(false);
+ expect(isThreadPinned(makeThread("legacy-bare-key", { pinned: true }))).toBe(
+ false,
+ );
+ expect(isThreadPinned(makeThread("missing"))).toBe(false);
+});
+
+test("sortPinnedThreads keeps pinned threads first without reordering groups", () => {
+ const threads = [
+ makeThread("recent-1"),
+ makeThread("pinned-1", { [THREAD_PINNED_METADATA_KEY]: true }),
+ makeThread("recent-2"),
+ makeThread("pinned-2", { [THREAD_PINNED_METADATA_KEY]: true }),
+ ];
+
+ expect(sortPinnedThreads(threads).map((thread) => thread.thread_id)).toEqual([
+ "pinned-1",
+ "pinned-2",
+ "recent-1",
+ "recent-2",
+ ]);
+});
+
test("reads IM channel source metadata", () => {
expect(
channelSourceOfThread({
From 8145d66a33a34baf8f1e9b93926d74a133e07c41 Mon Sep 17 00:00:00 2001
From: lllyfff <122260771+lllyfff@users.noreply.github.com>
Date: Sun, 26 Jul 2026 21:16:36 +0800
Subject: [PATCH 27/35] feat(memory): memory message processing (#4447)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(memory): signals-based update pipeline + always-on watermark/trivial filter
Refactor the DeerMem memory update pipeline (message_processing -> queue ->
updater) around a signals frozenset seam, replacing the
(filtered, correction_detected, reinforcement_detected) 3-tuple with
(filtered, signals: frozenset[str]) end to end.
message_processing:
- Externalize signal-detection patterns to YAML (message_patterns/*.yaml).
- Extend signals from correction/reinforcement to a 6-class set
(correction/reinforcement/preference/identity/goal/decision); detect_signals
returns a frozenset aligned with the fact category enum.
- Pure-acknowledgment turns ("ok"/"好的"/...) are always filtered out before
enqueue (whole-message fullmatch), saving an extraction LLM call.
queue (core/queue.py):
- In-memory list + debounce timer, with flush_sync (graceful-shutdown drain
that joins an in-flight worker under a hard timeout) and queue_max_depth
backpressure (signal-bearing updates always admitted; QueueFull otherwise).
- Same-key updates coalesce with a signal union; per-batch success/fail summary.
updater (core/updater.py):
- head500+tail500 message truncation (replaces the 1000-char head chop).
- Always-on per-thread watermark: feed only messages added since the last
extraction. The watermark is in-memory and is not advanced on failure, so a
failed/lost update is re-fed on the next conversation turn.
- [MANUAL] prompt marker for user-authored facts (source.type="manual").
- Post-invoke extraction_callback (host-injected) emitting facts_extracted /
facts_accepted / rejected_low_confidence; the host default logs metrics and
flags >60% rejection.
Confidence filtering remains in _apply_updates (the existing
fact_confidence_threshold check); there is no separate write gate.
Consolidation stays opt-in (lossy). The ABC add/add_nowait signature is
unchanged, so the summarization flush hook and host are unaffected.
Tests: add test_message_processing_signals, test_updater_truncation,
test_updater_watermark; update queue/updater/consolidation/staleness/pluggable
tests for the signals seam.
Co-Authored-By: Claude
* fix(memory): harden update pipeline per PR review
- Catch QueueFull in DeerMem.add/add_nowait so backpressure degrades to
'update skipped' instead of propagating into after_agent /
summarization_hook and breaking the agent run (peer middlewares
self-guard; MemoryMiddleware was the lone exception). Emergency
(add_nowait) always admits under backpressure -- its data cannot be
re-fed next turn.
- Rewrite the watermark from index-based to content/identity-based
(_message_identity + _feed_after_watermark) so it stays correct when
summarization removes the conversation front -- an index watermark
pointed at the wrong message and silently skipped un-extracted tail
turns. The emergency flush bypasses the watermark (bypass_watermark on
ConversationContext, threaded through update_memory) and coexists with
(does not replace) a pending normal update, so a flush cannot drop a
pending update's un-extracted tail.
- Populate facts_accepted / rejected_low_confidence inside _apply_updates
at the real confidence-filter site (passed_threshold) instead of
re-deriving the threshold in _finalize_update -- eliminates metric drift.
- Emit extraction metrics in a finally with an 'attempted' flag so
exception failures (parse error, apply_changes raise after retry) are
observable, not only the happy path.
- Re-detect signals on the post-watermark feed for the extraction hint so
it no longer references turns the LLM cannot see; admission-time signals
still drive backpressure.
- Move the post-batch reschedule inside the queue lock to close a
non-atomic self._timer race with a concurrent add.
Co-Authored-By: Claude
* fix(memory): address follow-up review nits (LRU, metric name, docstring)
- Bound the in-memory watermark cache with a configurable LRU
(watermark_max_keys, default 4096, 0=unbounded). A dropped key re-extracts
one batch on that thread's next turn (the documented restart behavior), so
eviction is safe and preserves the content-identity watermark's
front-removal guarantee. Adds _watermark_get/_watermark_set helpers and a
bounded-LRU regression test.
- Rename the extraction metric facts_accepted -> facts_passed_confidence so
the name matches what the >60% rejection-rate warning assumes (a
confidence-gate signal, not a persisted-fact count); drop the stale
"historical semantics" justification. Brand-new callback, one consumer.
- Fix the stale test_message_processing_signals module docstring: the signals
seam is already swapped to frozenset, and a stale stage-numbering prefix is
removed.
Co-Authored-By: Claude
---------
Co-authored-by: Claude
---
backend/.gitignore | 4 +
backend/AGENTS.md | 1 +
.../memory/backends/deermem/deer_mem.py | 88 +++--
.../memory/backends/deermem/deermem/config.py | 28 ++
.../core/message_patterns/decision.yaml | 27 ++
.../deermem/core/message_patterns/goal.yaml | 27 ++
.../core/message_patterns/identity.yaml | 26 ++
.../core/message_patterns/preference.yaml | 29 ++
.../core/message_patterns/trivial.yaml | 36 ++
.../deermem/core/message_processing.py | 92 +++++
.../backends/deermem/deermem/core/prompt.py | 11 +-
.../backends/deermem/deermem/core/queue.py | 147 +++++---
.../backends/deermem/deermem/core/updater.py | 348 +++++++++++++++---
.../harness/deerflow/agents/memory/manager.py | 46 +++
backend/tests/test_deermem_self_contained.py | 27 ++
backend/tests/test_memory_consolidation.py | 9 +-
.../tests/test_memory_manager_pluggable.py | 2 +-
backend/tests/test_memory_queue.py | 80 ++--
.../tests/test_memory_queue_user_isolation.py | 16 +-
backend/tests/test_memory_staleness_review.py | 12 +-
backend/tests/test_message_processing.py | 9 +-
.../tests/test_message_processing_signals.py | 139 +++++++
backend/tests/test_updater_truncation.py | 54 +++
backend/tests/test_updater_watermark.py | 183 +++++++++
config.example.yaml | 7 +
25 files changed, 1248 insertions(+), 200 deletions(-)
create mode 100644 backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_patterns/decision.yaml
create mode 100644 backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_patterns/goal.yaml
create mode 100644 backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_patterns/identity.yaml
create mode 100644 backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_patterns/preference.yaml
create mode 100644 backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_patterns/trivial.yaml
create mode 100644 backend/tests/test_message_processing_signals.py
create mode 100644 backend/tests/test_updater_truncation.py
create mode 100644 backend/tests/test_updater_watermark.py
diff --git a/backend/.gitignore b/backend/.gitignore
index 3967bcb3a..23b010539 100644
--- a/backend/.gitignore
+++ b/backend/.gitignore
@@ -31,3 +31,7 @@ config.yaml
# Claude Code settings
.claude/settings.local.json
+
+# pytest --basetemp workaround dirs (sandbox tmp_path permission)
+.pytest_tmp/
+.pytest_tmp_run/
diff --git a/backend/AGENTS.md b/backend/AGENTS.md
index 59226f6b9..c31e058bd 100644
--- a/backend/AGENTS.md
+++ b/backend/AGENTS.md
@@ -810,6 +810,7 @@ Focused regression coverage for the updater lives in `backend/tests/test_memory_
- `consolidation_min_facts` - Minimum facts in a category to trigger consolidation review (default: 8; range: 3–30)
- `consolidation_max_groups_per_cycle` - Maximum categories the LLM can merge in one cycle (default: 3; range: 1–10; also controls the LLM's prompt instruction)
- `consolidation_max_sources` - Maximum source facts per merge group; prevents over-merging (default: 8; range: 2–20)
+- `watermark_max_keys` - Soft cap on the in-memory conversation-watermark cache (one entry per distinct thread/user/agent). A bounded LRU: when over capacity the least-recently-used entry is dropped, and a dropped key re-extracts one batch on that thread's next turn (same as a restart). Bounds memory in long-lived gateways handling many threads (default: 4096; 0 = unbounded)
### Reflection System (`packages/harness/deerflow/reflection/`)
diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deer_mem.py b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deer_mem.py
index a78d217d9..f9d7cf563 100644
--- a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deer_mem.py
+++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deer_mem.py
@@ -33,14 +33,15 @@ from deerflow.agents.memory.manager import MemoryConflictError, MemoryCorruption
from .deermem.config import DeerMemConfig
from .deermem.core.llm import build_llm
from .deermem.core.message_processing import (
- detect_correction,
- detect_reinforcement,
+ SIGNAL_NAMES,
+ detect_signals,
filter_messages_for_memory,
+ filter_trivial,
load_patterns,
)
from .deermem.core.paths import DEFAULT_AGENT_BUCKET
from .deermem.core.prompt import format_memory_for_injection, load_prompt, load_prompt_messages, warm_tiktoken_cache
-from .deermem.core.queue import MemoryUpdateQueue
+from .deermem.core.queue import MemoryUpdateQueue, QueueFull
from .deermem.core.storage import MemoryRevisionConflict, MemoryStorageCorruption, create_storage
from .deermem.core.updater import MemoryUpdater, _coerce_source_confidence
@@ -100,8 +101,7 @@ class DeerMem(MemoryManager):
_llm: Any = PrivateAttr(default=None)
_updater: Any = PrivateAttr(default=None)
_queue: Any = PrivateAttr(default=None)
- _correction_patterns: Any = PrivateAttr(default=None)
- _reinforcement_patterns: Any = PrivateAttr(default=None)
+ _trivial_patterns: Any = PrivateAttr(default=None)
# DeerMem implements search() (case-insensitive substring over stored facts),
# so it is valid for mode="tool" (the base invariant validator requires this
@@ -121,8 +121,12 @@ class DeerMem(MemoryManager):
# Signal-detection patterns (externalized YAML; ``patterns_dir`` override
# or bundled defaults = pre-externalization behavior). Loaded once at
# construction and reused by ``_prepare_update``'s detect_* calls.
- self._correction_patterns = load_patterns("correction", patterns_dir=self._config.patterns_dir)
- self._reinforcement_patterns = load_patterns("reinforcement", patterns_dir=self._config.patterns_dir)
+ # Pre-load trivial + signal patterns at construction so a misconfigured
+ # patterns_dir (missing / invalid yaml) surfaces at startup, not on the
+ # first update. Compiled patterns are cached by load_patterns.
+ self._trivial_patterns = load_patterns("trivial", patterns_dir=self._config.patterns_dir)
+ for _signal_name in SIGNAL_NAMES:
+ load_patterns(_signal_name, patterns_dir=self._config.patterns_dir)
# host_llm (host-injected default model) takes precedence over build_llm(model)
# so zero-config DeerMem (empty `model`) still extracts via the app default,
# mirroring pre-abstraction `model_name: null`. Standalone (no factory) -> None.
@@ -168,7 +172,7 @@ class DeerMem(MemoryManager):
``model_post_init`` (shared with direct construction).
"""
config_dict = dict(backend_config or {})
- for key in ("should_keep_hidden_message", "trace_context_manager"):
+ for key in ("should_keep_hidden_message", "trace_context_manager", "extraction_callback"):
if key not in config_dict and key in host_hooks:
config_dict[key] = host_hooks[key]
if "host_llm" not in config_dict:
@@ -207,16 +211,25 @@ class DeerMem(MemoryManager):
prepared = self._prepare_update(messages)
if prepared is None:
return
- filtered, correction_detected, reinforcement_detected = prepared
- self._queue.add(
- thread_id=thread_id,
- messages=filtered,
- agent_name=_resolve_agent_name(agent_name),
- user_id=user_id,
- trace_id=trace_id,
- correction_detected=correction_detected,
- reinforcement_detected=reinforcement_detected,
- )
+ filtered, signals = prepared
+ # DeerMem owns the queue, so it owns the backpressure degradation: a
+ # QueueFull here is logged + dropped so memory backpressure degrades to
+ # "update skipped" rather than propagating into
+ # MemoryMiddleware.after_agent and breaking the agent run (peer
+ # middlewares self-guard the same way). The dropped update is re-fed
+ # next turn (the middleware passes the full conversation each cycle, and
+ # the watermark does not advance on a non-enqueued turn).
+ try:
+ self._queue.add(
+ thread_id=thread_id,
+ messages=filtered,
+ agent_name=_resolve_agent_name(agent_name),
+ user_id=user_id,
+ trace_id=trace_id,
+ signals=signals,
+ )
+ except QueueFull as e:
+ logger.warning("Memory update rejected under backpressure (thread=%s): %s", thread_id, e)
def add_nowait(
self,
@@ -234,37 +247,44 @@ class DeerMem(MemoryManager):
prepared = self._prepare_update(messages)
if prepared is None:
return
- filtered, correction_detected, reinforcement_detected = prepared
- self._queue.add_nowait(
- thread_id=thread_id,
- messages=filtered,
- agent_name=_resolve_agent_name(agent_name),
- user_id=user_id,
- correction_detected=correction_detected,
- reinforcement_detected=reinforcement_detected,
- )
+ filtered, signals = prepared
+ # Defense-in-depth: the emergency path always admits under backpressure
+ # (see _enqueue_locked), so QueueFull is not expected here -- but the
+ # emergency flush is invoked from summarization_hook, so a propagated
+ # exception would break summarization. Catch + log to be safe.
+ try:
+ self._queue.add_nowait(
+ thread_id=thread_id,
+ messages=filtered,
+ agent_name=_resolve_agent_name(agent_name),
+ user_id=user_id,
+ signals=signals,
+ )
+ except QueueFull as e:
+ logger.warning("Memory emergency flush rejected under backpressure (thread=%s): %s", thread_id, e)
def _prepare_update(
self,
messages: list[Any],
- ) -> tuple[list[Any], bool, bool] | None:
+ ) -> tuple[list[Any], frozenset[str]] | None:
"""Filter to user+final-AI messages, require both, detect signals.
- Returns ``(filtered, correction_detected, reinforcement_detected)``
- or ``None`` when there is no meaningful conversation (missing a user
- or an assistant turn).
+ Returns ``(filtered, signals)`` where ``signals`` is the set of signal
+ classes detected in the recent turns, or ``None`` when there is no
+ meaningful conversation (missing a user or an assistant turn, or every
+ turn dropped as a trivial pure-acknowledgment).
"""
filtered = filter_messages_for_memory(
messages,
should_keep_hidden_message=self._config.should_keep_hidden_message,
)
+ filtered = filter_trivial(filtered, patterns=self._trivial_patterns)
user_messages = [m for m in filtered if getattr(m, "type", None) == "human"]
assistant_messages = [m for m in filtered if getattr(m, "type", None) == "ai"]
if not user_messages or not assistant_messages:
return None
- correction_detected = detect_correction(filtered, patterns=self._correction_patterns)
- reinforcement_detected = not correction_detected and detect_reinforcement(filtered, patterns=self._reinforcement_patterns)
- return filtered, correction_detected, reinforcement_detected
+ signals = detect_signals(filtered, patterns_dir=self._config.patterns_dir)
+ return filtered, frozenset(signals)
# ── Read ─────────────────────────────────────────────────────────────
def get_context(
diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/config.py b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/config.py
index 5af788615..651c24832 100644
--- a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/config.py
+++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/config.py
@@ -79,6 +79,11 @@ class DeerMemConfig(BaseModel):
le=300,
description="Seconds to wait before processing queued updates (debounce).",
)
+ queue_max_depth: int = Field(
+ default=1000,
+ ge=0,
+ description=("Backpressure cap on pending items. 0 = unlimited. When the cap is reached, new non-signal updates are rejected (QueueFull); signal updates are always admitted so important memories are never shed."),
+ )
# ── Facts ────────────────────────────────────────────────────────────
max_facts: int = Field(default=100, ge=10, le=500, description="Maximum number of facts to store.")
fact_confidence_threshold: float = Field(
@@ -199,6 +204,29 @@ class DeerMemConfig(BaseModel):
le=20,
description=("Maximum number of source facts per consolidation group. Prevents the LLM from merging too many facts into one and losing important details."),
)
+ # ── Extraction quality callback (post-invoke observability) ─────────
+ extraction_callback: Any = Field(
+ default=None,
+ description=(
+ "Optional ``callback(metrics)`` invoked AFTER the extraction LLM "
+ "call (token usage, facts passing/rejected by the confidence "
+ "filter, rejection rate, prompt version). The host injects a "
+ "Langfuse-based callback to emit an extraction span; None = no "
+ "post-invoke observability. Set programmatically (not from YAML)."
+ ),
+ )
+ # ── Watermark cache (in-memory, bounded LRU) ─────────────────────────
+ watermark_max_keys: int = Field(
+ default=4096,
+ ge=0,
+ description=(
+ "Soft cap on the in-memory conversation-watermark cache (one entry "
+ "per distinct thread/user/agent). The cache is a bounded LRU: when "
+ "over capacity the least-recently-used entry is dropped, and a "
+ "dropped key re-extracts one batch on that thread's next turn (the "
+ "same as a restart). 0 = unbounded."
+ ),
+ )
# ── Message processing (externalized patterns / prompts) ──
patterns_dir: str | None = Field(
default=None,
diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_patterns/decision.yaml b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_patterns/decision.yaml
new file mode 100644
index 000000000..7c503f6ba
--- /dev/null
+++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_patterns/decision.yaml
@@ -0,0 +1,27 @@
+# Decision signal patterns for detect_signals (message_processing).
+#
+# Detects that the user made a decision / chose an option -- a high-value "what
+# to remember" signal. Matched via ``search`` over the last 6 human turns. Keep
+# these narrow to avoid false positives.
+#
+# Each list entry is either:
+# - a string -> compiled with no flags
+# - a mapping {pattern, flags} -> flags is a list of names: "ignorecase"
+#
+# In single-quoted strings backslashes are literal (regex \b stays \b); double
+# '' for a literal single quote (apostrophe).
+
+- pattern: '\blet.s (?:go with|use|pick|choose)\b'
+ flags: [ignorecase]
+- pattern: '\bI.ll (?:go with|use|pick|choose)\b'
+ flags: [ignorecase]
+- pattern: '\bwe (?:should|will) (?:go with|use|pick|choose)\b'
+ flags: [ignorecase]
+- pattern: '\bI (?:decide|chose|selected) to\b'
+ flags: [ignorecase]
+- '就用'
+- '决定用'
+- '决定采用'
+- '我们选'
+- '我选'
+- '就采用'
diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_patterns/goal.yaml b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_patterns/goal.yaml
new file mode 100644
index 000000000..4f65b3acd
--- /dev/null
+++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_patterns/goal.yaml
@@ -0,0 +1,27 @@
+# Goal signal patterns for detect_signals (message_processing).
+#
+# Detects that the user stated an objective / intent / plan -- a high-value
+# "what to remember" signal. Matched via ``search`` over the last 6 human
+# turns. Keep these narrow to avoid false positives.
+#
+# Each list entry is either:
+# - a string -> compiled with no flags
+# - a mapping {pattern, flags} -> flags is a list of names: "ignorecase"
+#
+# In single-quoted strings backslashes are literal (regex \b stays \b); double
+# '' for a literal single quote (apostrophe).
+
+- pattern: '\bI (?:plan to|want to|am going to|aim to|intend to)\b'
+ flags: [ignorecase]
+- pattern: '\bmy goal is\b'
+ flags: [ignorecase]
+- pattern: '\bI.m going to\b'
+ flags: [ignorecase]
+- pattern: '\bnext I.ll\b'
+ flags: [ignorecase]
+- '我的目标是'
+- '我打算'
+- '我准备'
+- '接下来我要'
+- '接下来我打算'
+- '我想做'
diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_patterns/identity.yaml b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_patterns/identity.yaml
new file mode 100644
index 000000000..41e43b23d
--- /dev/null
+++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_patterns/identity.yaml
@@ -0,0 +1,26 @@
+# Identity signal patterns for detect_signals (message_processing).
+#
+# Detects that the user stated something about who they are (role, profession,
+# background) -- a high-value "what to remember" signal. Matched via ``search``
+# over the last 6 human turns. Keep these narrow to avoid false positives.
+#
+# Each list entry is either:
+# - a string -> compiled with no flags
+# - a mapping {pattern, flags} -> flags is a list of names: "ignorecase"
+#
+# In single-quoted strings backslashes are literal (regex \b stays \b); double
+# '' for a literal single quote (apostrophe).
+
+- pattern: '\bI am (?:a |an )?[a-z][a-z -]{2,}\b'
+ flags: [ignorecase]
+- pattern: '\bI work as\b'
+ flags: [ignorecase]
+- pattern: '\bI.m (?:a |an )?[a-z][a-z -]{2,}\b'
+ flags: [ignorecase]
+- pattern: '\bmy job is\b'
+ flags: [ignorecase]
+- '我是'
+- '我的职业是'
+- '我的工作是'
+- '我担任'
+- '我是一名'
diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_patterns/preference.yaml b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_patterns/preference.yaml
new file mode 100644
index 000000000..c35a05c39
--- /dev/null
+++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_patterns/preference.yaml
@@ -0,0 +1,29 @@
+# Preference signal patterns for detect_signals (message_processing).
+#
+# Detects that the user stated a preference / dislike -- a high-value "what to
+# remember" signal. Matched via ``search`` over the last 6 human turns. Keep
+# these narrow to avoid false positives; extend here without touching code.
+#
+# Each list entry is either:
+# - a string -> compiled with no flags
+# - a mapping {pattern, flags} -> flags is a list of names: "ignorecase"
+#
+# In single-quoted strings backslashes are literal (regex \b stays \b); double
+# '' for a literal single quote (apostrophe).
+
+- pattern: '\bI (?:really )?(?:prefer|like|love|enjoy|favor)\b'
+ flags: [ignorecase]
+- pattern: '\bI (?:really )?(?:hate|dislike|don.t like|cannot stand)\b'
+ flags: [ignorecase]
+- pattern: '\bmy favorite\b'
+ flags: [ignorecase]
+- pattern: '\bI.d rather\b'
+ flags: [ignorecase]
+- '我喜欢'
+- '我偏好'
+- '我更喜欢'
+- '我偏爱'
+- '我讨厌'
+- '我不喜欢'
+- '我钟爱'
+- '我最喜欢'
diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_patterns/trivial.yaml b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_patterns/trivial.yaml
new file mode 100644
index 000000000..a7e44534f
--- /dev/null
+++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_patterns/trivial.yaml
@@ -0,0 +1,36 @@
+# Trivial pure-acknowledgment patterns for filter_trivial (message_processing).
+#
+# Each entry is a regex matched against the WHOLE (stripped) human message via
+# ``fullmatch`` -- a message is trivial only if it is nothing but an ack, so a
+# substantive turn that happens to contain "ok" is never dropped. Trailing
+# punctuation/whitespace is stripped before matching, so "ok." / "好的!" count.
+#
+# Each list entry is either:
+# - a string -> compiled with no flags
+# - a mapping {pattern, flags} -> flags is a list of names: "ignorecase"
+#
+# Keep these narrow: a false positive silently drops a real user turn from
+# memory. Extend here without touching code.
+
+- pattern: '嗯+'
+- pattern: 'ok(?:ay)?'
+ flags: [ignorecase]
+- pattern: '好的[呢]?'
+- pattern: '好[的呀]?'
+- pattern: '谢谢'
+- pattern: '感谢'
+- pattern: '多谢'
+- pattern: '对'
+- pattern: '是的'
+- pattern: '收到'
+- pattern: '明白'
+- pattern: '了解'
+- pattern: '知道了'
+- pattern: 'got it'
+ flags: [ignorecase]
+- pattern: 'thanks'
+ flags: [ignorecase]
+- pattern: 'thank you'
+ flags: [ignorecase]
+- pattern: 'cheers'
+ flags: [ignorecase]
diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_processing.py b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_processing.py
index 0407b42d3..5d58e7849 100644
--- a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_processing.py
+++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_processing.py
@@ -246,3 +246,95 @@ def detect_reinforcement(messages: list[Any], *, patterns: list[re.Pattern[str]]
return True
return False
+
+
+# Signal classes detected by :func:`detect_signals`. Names align with the fact
+# ``category`` enum (CORE_CATEGORIES) so a signal can drive an extraction
+# category hint directly, except ``reinforcement`` (no same-named category; it
+# maps to preference/behavior in the extraction hint). Keep new signal names in
+# sync with CORE_CATEGORIES before adding them here.
+SIGNAL_NAMES: tuple[str, ...] = (
+ "correction",
+ "reinforcement",
+ "preference",
+ "identity",
+ "goal",
+ "decision",
+)
+
+
+def detect_signals(
+ messages: list[Any],
+ *,
+ patterns_dir: str | None = None,
+) -> set[str]:
+ """Detect signal classes in the recent conversation turns.
+
+ Returns the set of signal names whose patterns match any of the last 6
+ human turns. This generalizes :func:`detect_correction` /
+ :func:`detect_reinforcement` (which remain for backward compatibility) to
+ the full signal set. The window stays ``messages[-6:]``.
+ """
+ recent_user_msgs = [msg for msg in messages[-6:] if getattr(msg, "type", None) == "human"]
+ if not recent_user_msgs:
+ return set()
+
+ hits: set[str] = set()
+ for name in SIGNAL_NAMES:
+ patterns = load_patterns(name, patterns_dir=patterns_dir)
+ if not patterns:
+ continue
+ for msg in recent_user_msgs:
+ content = extract_message_text(msg).strip()
+ if content and any(pattern.search(content) for pattern in patterns):
+ hits.add(name)
+ break
+ return hits
+
+
+# Trailing characters stripped before a whole-message trivial match: a pure
+# acknowledgment with trailing punctuation ("ok.", "好的!") is still trivial.
+_TRIVIAL_TRAIL = " \t\n\r.。,,!!??;;"
+
+
+def filter_trivial(
+ messages: list[Any],
+ *,
+ patterns: list[re.Pattern[str]] | None = None,
+ patterns_dir: str | None = None,
+) -> list[Any]:
+ """Drop pure-acknowledgment human turns and their AI replies.
+
+ A human turn is "trivial" when its whole (stripped) text matches a trivial
+ pattern (e.g. "嗯", "ok", "好的", "谢谢") -- matched via ``fullmatch`` so a
+ substantive turn containing "ok" is never dropped. The matched human turn
+ and its following assistant reply are both removed (reusing the
+ ``skip_next_ai`` discipline from :func:`filter_messages_for_memory`). When
+ every turn is trivial, the result is empty, which the caller treats as "do
+ not enqueue" (saving an extraction LLM call).
+ """
+ if patterns is None:
+ patterns = load_patterns("trivial", patterns_dir=patterns_dir)
+ if not patterns:
+ return list(messages)
+
+ result: list[Any] = []
+ skip_next_ai = False
+ for msg in messages:
+ msg_type = getattr(msg, "type", None)
+ if msg_type == "human":
+ content = extract_message_text(msg).strip().rstrip(_TRIVIAL_TRAIL)
+ is_trivial = bool(content) and any(pattern.fullmatch(content) for pattern in patterns)
+ if is_trivial:
+ skip_next_ai = True
+ continue
+ result.append(msg)
+ skip_next_ai = False
+ elif msg_type == "ai":
+ tool_calls = getattr(msg, "tool_calls", None)
+ if not tool_calls:
+ if skip_next_ai:
+ skip_next_ai = False
+ continue
+ result.append(msg)
+ return result
diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompt.py b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompt.py
index 950d329b0..36c49eedb 100644
--- a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompt.py
+++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompt.py
@@ -762,9 +762,16 @@ def format_conversation_for_update(messages: list[Any]) -> str:
if not content:
continue
- # Truncate very long messages
+ # Truncate very long messages: keep the head (topic / opening) and the
+ # tail (conclusion / "remember X" instruction), dropping the middle.
+ # A head-only chop loses the tail's directives; a head+tail split
+ # preserves both. The separator is plain ASCII (no < > &) so the
+ # html.escape below leaves it intact and tells the LLM where text was
+ # cut. Escape happens after truncation, so the boundary never splits an
+ # entity (entities only exist after escaping).
if len(str(content)) > 1000:
- content = str(content)[:1000] + "..."
+ s = str(content)
+ content = s[:500] + "\n...[truncated]...\n" + s[-500:]
# Escape < > & before embedding into the block of
# the memory_update prompt. This raw user turn is the most attacker-influenced
diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/queue.py b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/queue.py
index 14a8f0f1c..b422262fb 100644
--- a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/queue.py
+++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/queue.py
@@ -1,4 +1,18 @@
-"""Memory update queue with debounce mechanism."""
+"""Memory update queue with debounce mechanism.
+
+The queue collects conversation contexts and processes them after a
+configurable debounce period; multiple contexts for the same
+``(thread_id, user_id, agent_name)`` key are coalesced into one update.
+
+The queue is a process-local in-memory list plus a debounce
+:class:`~threading.Timer`. Items still pending at process exit are lost
+(best-effort :meth:`MemoryUpdateQueue.flush_sync` drain softens this for
+graceful shutdown). Memory updates are best-effort: a failed or lost update is
+re-fed on the next conversation turn (the middleware passes the full
+conversation each cycle, and the updater's watermark does not advance on
+failure), so an in-memory queue covers the realistic graceful-deploy case
+without a persistence layer.
+"""
from __future__ import annotations
@@ -17,6 +31,25 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
+class QueueFull(Exception):
+ """Raised when a non-signal update is rejected under backpressure.
+
+ Signal-bearing updates (any detected signal) are always admitted so that
+ important memories are never shed; only non-signal updates are rejected
+ once ``queue_max_depth`` is reached. Callers may catch this to degrade
+ (e.g. fall back to a synchronous write on the emergency path).
+ """
+
+
+def queue_key(
+ thread_id: str,
+ user_id: str | None,
+ agent_name: str | None,
+) -> tuple[str, str | None, str | None]:
+ """Return the debounce identity for a memory update target."""
+ return (thread_id, user_id, agent_name)
+
+
@dataclass
class ConversationContext:
"""Context for a conversation to be processed for memory update."""
@@ -27,8 +60,14 @@ class ConversationContext:
agent_name: str | None = None
user_id: str | None = None
trace_id: str | None = None
- correction_detected: bool = False
- reinforcement_detected: bool = False
+ signals: frozenset[str] = field(default_factory=frozenset)
+ # Emergency (summarization) flushes bypass the updater's index watermark:
+ # the subset they carry is a one-shot "extract before removal" snapshot whose
+ # own length would otherwise regress the conversation watermark. Such contexts
+ # also coexist with (do not replace) a pending normal update for the same key
+ # so a flush cannot drop a pending normal update's un-extracted tail. See
+ # ``_enqueue_locked``'s match-key + backpressure handling.
+ bypass_watermark: bool = False
class MemoryUpdateQueue:
@@ -43,7 +82,7 @@ class MemoryUpdateQueue:
"""Initialize the memory update queue with injected config + updater."""
self._config = config
self._updater = updater
- self._queue: list[ConversationContext] = []
+ self._items: list[ConversationContext] = []
self._lock = threading.Lock()
self._timer: threading.Timer | None = None
self._processing = False
@@ -54,15 +93,6 @@ class MemoryUpdateQueue:
self._processing_thread: threading.Thread | None = None
self._reprocess_pending = False
- @staticmethod
- def _queue_key(
- thread_id: str,
- user_id: str | None,
- agent_name: str | None,
- ) -> tuple[str, str | None, str | None]:
- """Return the debounce identity for a memory update target."""
- return (thread_id, user_id, agent_name)
-
def add(
self,
thread_id: str,
@@ -70,8 +100,7 @@ class MemoryUpdateQueue:
agent_name: str | None = None,
user_id: str | None = None,
trace_id: str | None = None,
- correction_detected: bool = False,
- reinforcement_detected: bool = False,
+ signals: frozenset[str] | None = None,
) -> None:
"""Add a conversation to the update queue.
@@ -84,8 +113,9 @@ class MemoryUpdateQueue:
raw threads).
trace_id: Request trace id captured at enqueue time so the
later Timer thread can attach it to memory LLM tracing metadata.
- correction_detected: Whether recent turns include an explicit correction signal.
- reinforcement_detected: Whether recent turns include a positive reinforcement signal.
+ signals: Signal classes detected in the conversation (correction /
+ reinforcement / preference / ...), used as extraction hints. Any
+ signal is admitted under backpressure.
"""
with self._lock:
self._enqueue_locked(
@@ -94,12 +124,12 @@ class MemoryUpdateQueue:
agent_name=agent_name,
user_id=user_id,
trace_id=trace_id,
- correction_detected=correction_detected,
- reinforcement_detected=reinforcement_detected,
+ signals=frozenset(signals) if signals else frozenset(),
+ bypass_watermark=False,
)
self._reset_timer()
- logger.info("Memory update queued for thread %s, queue size: %d", thread_id, len(self._queue))
+ logger.info("Memory update queued for thread %s, queue size: %d", thread_id, len(self._items))
def add_nowait(
self,
@@ -108,8 +138,7 @@ class MemoryUpdateQueue:
agent_name: str | None = None,
user_id: str | None = None,
trace_id: str | None = None,
- correction_detected: bool = False,
- reinforcement_detected: bool = False,
+ signals: frozenset[str] | None = None,
) -> None:
"""Add a conversation and start processing immediately in the background."""
with self._lock:
@@ -119,12 +148,12 @@ class MemoryUpdateQueue:
agent_name=agent_name,
user_id=user_id,
trace_id=trace_id,
- correction_detected=correction_detected,
- reinforcement_detected=reinforcement_detected,
+ signals=frozenset(signals) if signals else frozenset(),
+ bypass_watermark=True,
)
self._schedule_timer(0)
- logger.info("Memory update queued for immediate processing on thread %s, queue size: %d", thread_id, len(self._queue))
+ logger.info("Memory update queued for immediate processing on thread %s, queue size: %d", thread_id, len(self._items))
def _enqueue_locked(
self,
@@ -134,28 +163,45 @@ class MemoryUpdateQueue:
agent_name: str | None,
user_id: str | None,
trace_id: str | None,
- correction_detected: bool,
- reinforcement_detected: bool,
- ) -> None:
- queue_key = self._queue_key(thread_id, user_id, agent_name)
- existing_context = next(
- (context for context in self._queue if self._queue_key(context.thread_id, context.user_id, context.agent_name) == queue_key),
+ signals: frozenset[str],
+ bypass_watermark: bool = False,
+ ) -> ConversationContext:
+ key = queue_key(thread_id, user_id, agent_name)
+ # Emergency (bypass) and normal updates coexist: the match key includes
+ # ``bypass_watermark`` so a summarization flush (bypass=True) never
+ # replaces a pending normal update for the same (thread, user, agent) --
+ # replacing it would drop the normal update's un-extracted tail, which
+ # the next turn may not re-feed if the user stops. Both are processed
+ # independently instead.
+ existing = next(
+ (c for c in self._items if queue_key(c.thread_id, c.user_id, c.agent_name) == key and c.bypass_watermark == bypass_watermark),
None,
)
- merged_correction_detected = correction_detected or (existing_context.correction_detected if existing_context is not None else False)
- merged_reinforcement_detected = reinforcement_detected or (existing_context.reinforcement_detected if existing_context is not None else False)
+ # Backpressure: once depth reaches the cap, reject NEW non-signal normal
+ # items. Same-key updates merge (do not grow depth); signal-bearing items
+ # and emergency (bypass) flushes are always admitted. Signals capture
+ # important memories, and the emergency path captures messages about to
+ # be removed by summarization -- neither can be re-fed next turn, so
+ # shedding them under load would lose data rather than merely defer it.
+ max_depth = self._config.queue_max_depth
+ if max_depth > 0 and not bypass_watermark and not signals and existing is None and len(self._items) >= max_depth:
+ raise QueueFull(f"memory update queue is full (depth {len(self._items)} >= {max_depth}); non-signal update for thread {thread_id} rejected")
+
+ # Merge by signal union: a signal seen on any update for this key stays.
+ merged_signals = signals | (existing.signals if existing is not None else frozenset())
context = ConversationContext(
thread_id=thread_id,
messages=messages,
agent_name=agent_name,
user_id=user_id,
trace_id=trace_id,
- correction_detected=merged_correction_detected,
- reinforcement_detected=merged_reinforcement_detected,
+ signals=merged_signals,
+ bypass_watermark=bypass_watermark,
)
-
- self._queue = [context for context in self._queue if self._queue_key(context.thread_id, context.user_id, context.agent_name) != queue_key]
- self._queue.append(context)
+ if existing is not None:
+ self._items = [c for c in self._items if not (queue_key(c.thread_id, c.user_id, c.agent_name) == key and c.bypass_watermark == bypass_watermark)]
+ self._items.append(context)
+ return context
def _reset_timer(self) -> None:
"""Reset the debounce timer."""
@@ -196,13 +242,13 @@ class MemoryUpdateQueue:
self._reprocess_pending = True
return
- if not self._queue:
+ if not self._items:
return
self._processing = True
self._processing_thread = threading.current_thread()
- contexts_to_process = self._queue.copy()
- self._queue.clear()
+ contexts_to_process = self._items
+ self._items = []
self._timer = None
logger.info("Processing %d queued memory updates", len(contexts_to_process))
@@ -217,10 +263,10 @@ class MemoryUpdateQueue:
messages=context.messages,
thread_id=context.thread_id,
agent_name=context.agent_name,
- correction_detected=context.correction_detected,
- reinforcement_detected=context.reinforcement_detected,
+ signals=context.signals,
user_id=context.user_id,
trace_id=context.trace_id,
+ bypass_watermark=context.bypass_watermark,
)
if success:
succeeded += 1
@@ -248,9 +294,16 @@ class MemoryUpdateQueue:
with self._lock:
self._processing = False
self._processing_thread = None
+ # Reschedule inside the lock: ``_schedule_timer`` read-cancels-
+ # reassigns ``self._timer`` non-atomically, and a concurrent
+ # ``add``'s ``_reset_timer`` (also under the lock) touches the
+ # same field. Holding the lock makes the reschedule atomic w.r.t.
+ # ``add``. ``_schedule_timer`` only calls ``Timer.start()`` (no
+ # synchronous lock acquisition), so this cannot deadlock.
if self._reprocess_pending:
self._reprocess_pending = False
- if self._queue:
+ if self._items:
+ # New work arrived mid-processing: re-run immediately.
self._schedule_timer(0)
def flush(self, *, skip_inter_item_delay: bool = False) -> None:
@@ -306,7 +359,7 @@ class MemoryUpdateQueue:
# (1) Wait for an in-flight _process_queue first (bounded). Otherwise
# flush() would see _processing=True, no-op, and we would report
# success while that worker is still mid-LLM-call on a daemon thread
- # that exit will kill — losing the contexts it already pulled out.
+ # that exit will kill - losing the contexts it already pulled out.
with self._lock:
in_flight = self._processing_thread
if in_flight is not None:
@@ -356,7 +409,7 @@ class MemoryUpdateQueue:
if self._timer is not None:
self._timer.cancel()
self._timer = None
- self._queue.clear()
+ self._items = []
self._processing = False
self._processing_thread = None
self._reprocess_pending = False
@@ -365,7 +418,7 @@ class MemoryUpdateQueue:
def pending_count(self) -> int:
"""Get the number of pending updates."""
with self._lock:
- return len(self._queue)
+ return len(self._items)
@property
def is_processing(self) -> bool:
diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/updater.py b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/updater.py
index 608edbab3..7f81c0e3e 100644
--- a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/updater.py
+++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/updater.py
@@ -10,10 +10,12 @@ import logging
import math
import re
import uuid
+from collections import OrderedDict
from datetime import UTC, datetime, timedelta
from typing import Any
from ..config import DeerMemConfig
+from .message_processing import detect_signals, extract_message_text
from .prompt import (
format_conversation_for_update,
load_prompt,
@@ -610,6 +612,52 @@ def _escape_memory_for_prompt(memory: Any) -> Any:
return memory
+def _memory_with_manual_markers(memory: Any) -> Any:
+ """Return a deep copy of ``memory`` with ``[MANUAL]`` prefixed onto the
+ content of manually-authored facts (``source.type == "manual"``).
+
+ The marker is a prompt-only signal that tells the extraction LLM a fact is a
+ high-trust user edit; the persisted memory is untouched (this copy is only
+ fed to the prompt). Idempotent: a fact already carrying the prefix is not
+ double-marked.
+ """
+ display = copy.deepcopy(memory)
+ if not isinstance(display, dict):
+ return display
+ for fact in display.get("facts", []):
+ if not isinstance(fact, dict):
+ continue
+ src = fact.get("source")
+ src_type = src.get("type") if isinstance(src, dict) else None
+ if src_type == "manual":
+ content = fact.get("content")
+ if isinstance(content, str) and not content.startswith("[MANUAL]"):
+ fact["content"] = "[MANUAL] " + content
+ return display
+
+
+def _message_identity(msg: Any) -> tuple[str, ...] | None:
+ """Return a hashable identity for ``msg`` for watermark tracking.
+
+ The watermark is content/identity based rather than index based so it stays
+ valid when summarization removes the conversation front (an index watermark
+ would point at the wrong message after a front removal, silently skipping
+ un-extracted turns). Prefers the langgraph message ``id`` (unique, robust to
+ duplicate content); falls back to ``(type, content)`` when no id is set
+ (e.g. plain ``HumanMessage(content=...)`` in tests). Returns ``None`` for a
+ message with neither id nor extractable text -- the caller then feeds the
+ full list, which is safe over-extraction and never loss.
+ """
+ mid = getattr(msg, "id", None)
+ if isinstance(mid, str) and mid:
+ return ("id", mid)
+ text = extract_message_text(msg)
+ if not text:
+ return None
+ msg_type = getattr(msg, "type", "") or ""
+ return ("content", msg_type, text)
+
+
class MemoryUpdater:
"""Updates memory using LLM based on conversation context."""
@@ -632,6 +680,12 @@ class MemoryUpdater:
self._llm = llm
self._prompts_dir = prompts_dir
self._callbacks = callbacks
+ # Watermark: last-extracted message identity per (thread_id, user_id,
+ # agent_name), held in memory so a restart re-extracts one batch. The
+ # cache is a bounded LRU (config.watermark_max_keys) so a long-lived
+ # gateway handling many threads cannot grow it without limit; a dropped
+ # key re-extracts one batch on that thread's next turn.
+ self._watermarks: OrderedDict[tuple[str | None, str | None, str | None], tuple[str, ...] | None] = OrderedDict()
# ── Data access + fact CRUD (formerly module-level functions; use self._storage) ──
@@ -917,37 +971,45 @@ class MemoryUpdater:
raise OSError(f"Failed to save memory data after updating fact '{fact_id}'")
return updated_memory
- def _build_correction_hint(
- self,
- correction_detected: bool,
- reinforcement_detected: bool,
- ) -> str:
- """Build optional prompt hints for correction and reinforcement signals."""
- correction_hint = ""
- if correction_detected:
- correction_hint = (
+ def _build_signal_hints(self, signals: frozenset[str]) -> str:
+ """Build optional prompt hints for the detected signal classes.
+
+ Each present signal contributes one instruction nudging the extraction
+ LLM toward the right category and confidence. The variable is still
+ rendered into the template's ``{correction_hint}`` slot (the name is
+ historical -- it now carries the full signal-hint set, plus the manual
+ fact note appended by :meth:`_prepare_update_prompt`).
+ """
+ hints: list[str] = []
+ if "correction" in signals:
+ hints.append(
"IMPORTANT: Explicit correction signals were detected in this conversation. "
"Pay special attention to what the agent got wrong, what the user corrected, "
"and record the correct approach as a fact with category "
'"correction" and confidence >= 0.95 when appropriate.'
)
- if reinforcement_detected:
- reinforcement_hint = (
+ if "reinforcement" in signals:
+ hints.append(
"IMPORTANT: Positive reinforcement signals were detected in this conversation. "
"The user explicitly confirmed the agent's approach was correct or helpful. "
"Record the confirmed approach, style, or preference as a fact with category "
'"preference" or "behavior" and confidence >= 0.9 when appropriate.'
)
- correction_hint = (correction_hint + "\n" + reinforcement_hint).strip() if correction_hint else reinforcement_hint
-
- return correction_hint
+ if "preference" in signals:
+ hints.append('IMPORTANT: A preference signal was detected. Record the user\'s stated preference or dislike as a fact with category "preference" and high confidence.')
+ if "identity" in signals:
+ hints.append('IMPORTANT: An identity signal was detected. Record the user\'s stated role, profession, or background as a fact with category "identity" and high confidence.')
+ if "goal" in signals:
+ hints.append('IMPORTANT: A goal signal was detected. Record the user\'s stated objective or intent as a fact with category "goal" and high confidence.')
+ if "decision" in signals:
+ hints.append('IMPORTANT: A decision signal was detected. Record the user\'s decision or chosen option as a fact with category "decision" and high confidence.')
+ return "\n".join(hints)
def _prepare_update_prompt(
self,
messages: list[Any],
agent_name: str | None,
- correction_detected: bool,
- reinforcement_detected: bool,
+ signals: frozenset[str],
user_id: str | None = None,
) -> tuple[dict[str, Any], list[Any]] | None:
"""Load memory and build the update prompt for a conversation."""
@@ -960,10 +1022,16 @@ class MemoryUpdater:
if not conversation_text.strip():
return None
- correction_hint = self._build_correction_hint(
- correction_detected=correction_detected,
- reinforcement_detected=reinforcement_detected,
- )
+ correction_hint = self._build_signal_hints(signals)
+
+ # Manual-fact signal: tag high-trust user-authored facts with a [MANUAL]
+ # prefix in the prompt's current_memory and instruct the model to preserve
+ # them unless the new conversation is an explicit, unambiguous correction.
+ display_memory = current_memory
+ if self._has_manual_facts(current_memory):
+ display_memory = _memory_with_manual_markers(current_memory)
+ manual_hint = "NOTE: Facts marked [MANUAL] are high-trust user-authored edits. Update them only when the new conversation is an explicit, unambiguous correction; otherwise preserve them as-is."
+ correction_hint = (correction_hint + "\n" + manual_hint).strip() if correction_hint else manual_hint
# ── Build staleness review section ──
staleness_section = ""
@@ -986,7 +1054,7 @@ class MemoryUpdater:
)
variables = {
- "current_memory": json.dumps(_escape_memory_for_prompt(current_memory), indent=2, ensure_ascii=False),
+ "current_memory": json.dumps(_escape_memory_for_prompt(display_memory), indent=2, ensure_ascii=False),
"conversation": conversation_text,
"correction_hint": correction_hint,
"staleness_review_section": staleness_section,
@@ -995,6 +1063,45 @@ class MemoryUpdater:
prompt = load_prompt_messages("memory_update", variables, agent_name=agent_name, prompts_dir=self._prompts_dir)
return current_memory, prompt
+ def _has_manual_facts(self, memory: dict[str, Any]) -> bool:
+ """Return whether ``memory`` contains any user-authored (manual) fact."""
+ return any(isinstance(f, dict) and isinstance(f.get("source"), dict) and f.get("source", {}).get("type") == "manual" for f in memory.get("facts", []))
+
+ def _emit_extraction_metrics(
+ self,
+ metrics: dict[str, Any],
+ *,
+ thread_id: str | None,
+ user_id: str | None,
+ trace_id: str | None,
+ model_name: str | None,
+ response: Any,
+ success: bool,
+ ) -> None:
+ """Invoke the post-extraction observability callback (Langfuse span etc.).
+
+ No-op when ``extraction_callback`` is unset (default). Exceptions from
+ the callback are logged and swallowed so observability never breaks the
+ update path.
+ """
+ callback = self._config.extraction_callback
+ if callback is None:
+ return
+ usage = getattr(response, "usage_metadata", None)
+ payload: dict[str, Any] = {
+ "thread_id": thread_id,
+ "user_id": user_id,
+ "trace_id": trace_id,
+ "model_name": model_name,
+ "success": success,
+ "token_usage": usage if isinstance(usage, dict) else None,
+ }
+ payload.update(metrics)
+ try:
+ callback(payload)
+ except Exception:
+ logger.warning("extraction_callback raised; ignoring", exc_info=True)
+
def _finalize_update(
self,
current_memory: dict[str, Any],
@@ -1002,9 +1109,18 @@ class MemoryUpdater:
thread_id: str | None,
agent_name: str | None,
user_id: str | None = None,
+ *,
+ metrics: dict[str, Any] | None = None,
) -> bool:
"""Parse the model response, apply updates, and persist memory."""
update_data = _parse_memory_update_response(response_content)
+ if metrics is not None:
+ extracted = update_data.get("newFacts", [])
+ extracted_list = extracted if isinstance(extracted, list) else []
+ metrics["facts_extracted"] = len(extracted_list)
+ # facts_passed_confidence / rejected_low_confidence are populated
+ # inside _apply_updates at the real confidence-filter site, so the
+ # metric tracks the actual filter rather than a re-derived copy here.
if getattr(type(self._storage), "apply_changes", None) is not MemoryStorage.apply_changes:
for attempt in range(3):
# Deep-copy before in-place mutation so a failed commit cannot
@@ -1012,7 +1128,7 @@ class MemoryUpdater:
# complete extraction result is reapplied to a fresh document;
# its trim/consolidation/delete decisions are snapshot-wide and
# must never be replayed as disjoint point writes.
- updated_memory = self._apply_updates(copy.deepcopy(current_memory), update_data, thread_id)
+ updated_memory = self._apply_updates(copy.deepcopy(current_memory), update_data, thread_id, metrics=metrics)
updated_memory = _strip_upload_mentions_from_memory(updated_memory)
current_by_id = {str(fact.get("id")): fact for fact in current_memory.get("facts", [])}
updated_by_id = {str(fact.get("id")): fact for fact in updated_memory.get("facts", [])}
@@ -1044,7 +1160,7 @@ class MemoryUpdater:
raise AssertionError("bounded extracted-update retry did not return or raise")
# Deep-copy before in-place mutation so a subsequent save() failure
# cannot corrupt the still-cached original object reference.
- updated_memory = self._apply_updates(copy.deepcopy(current_memory), update_data, thread_id)
+ updated_memory = self._apply_updates(copy.deepcopy(current_memory), update_data, thread_id, metrics=metrics)
updated_memory = _strip_upload_mentions_from_memory(updated_memory)
return self._storage.save(
updated_memory,
@@ -1058,10 +1174,11 @@ class MemoryUpdater:
messages: list[Any],
thread_id: str | None = None,
agent_name: str | None = None,
- correction_detected: bool = False,
- reinforcement_detected: bool = False,
+ signals: frozenset[str] = frozenset(),
user_id: str | None = None,
trace_id: str | None = None,
+ *,
+ bypass_watermark: bool = False,
) -> bool:
"""Update memory asynchronously by delegating to the sync path.
@@ -1076,10 +1193,10 @@ class MemoryUpdater:
messages=messages,
thread_id=thread_id,
agent_name=agent_name,
- correction_detected=correction_detected,
- reinforcement_detected=reinforcement_detected,
+ signals=signals,
user_id=user_id,
trace_id=trace_id,
+ bypass_watermark=bypass_watermark,
)
def _do_update_memory_sync(
@@ -1087,10 +1204,11 @@ class MemoryUpdater:
messages: list[Any],
thread_id: str | None = None,
agent_name: str | None = None,
- correction_detected: bool = False,
- reinforcement_detected: bool = False,
+ signals: frozenset[str] = frozenset(),
user_id: str | None = None,
trace_id: str | None = None,
+ *,
+ bypass_watermark: bool = False,
) -> bool:
"""Pure-sync memory update; bind ``trace_id`` into the request-trace
ContextVar for the worker thread, then delegate to the impl.
@@ -1110,45 +1228,128 @@ class MemoryUpdater:
messages=messages,
thread_id=thread_id,
agent_name=agent_name,
- correction_detected=correction_detected,
- reinforcement_detected=reinforcement_detected,
+ signals=signals,
user_id=user_id,
trace_id=trace_id,
+ bypass_watermark=bypass_watermark,
)
return self._do_update_memory_sync_impl(
messages=messages,
thread_id=thread_id,
agent_name=agent_name,
- correction_detected=correction_detected,
- reinforcement_detected=reinforcement_detected,
+ signals=signals,
user_id=user_id,
trace_id=trace_id,
+ bypass_watermark=bypass_watermark,
)
+ def _watermark_get(self, key: tuple[str | None, str | None, str | None]) -> tuple[str, ...] | None:
+ """Return the watermark for ``key``, marking it most-recently-used.
+
+ Uses key presence (not value truthiness) so a stored ``None`` identity
+ still counts as a live entry for LRU ordering.
+ """
+ if key not in self._watermarks:
+ return None
+ self._watermarks.move_to_end(key)
+ return self._watermarks[key]
+
+ def _watermark_set(
+ self,
+ key: tuple[str | None, str | None, str | None],
+ value: tuple[str, ...] | None,
+ ) -> None:
+ """Store ``value`` for ``key``, evicting the least-recently-used entry
+ when the bounded LRU cache exceeds ``config.watermark_max_keys``.
+
+ A dropped key is safe: the next turn for that thread finds no watermark
+ and re-extracts one batch (the documented restart behavior). ``0`` =
+ unbounded (no eviction).
+ """
+ self._watermarks[key] = value
+ self._watermarks.move_to_end(key)
+ cap = self._config.watermark_max_keys
+ if cap > 0 and len(self._watermarks) > cap:
+ self._watermarks.popitem(last=False)
+
+ def _feed_after_watermark(
+ self,
+ watermark_key: tuple[str | None, str | None, str | None],
+ messages: list[Any],
+ ) -> list[Any]:
+ """Return the slice of ``messages`` not yet extracted.
+
+ The watermark stores the identity of the last-extracted message (see
+ :func:`_message_identity`). If that message is still present, everything
+ *after* it is fed; if it is absent (front removed by summarization, or
+ the first-ever extraction for this key) the full list is fed. Re-feeding
+ is safe over-extraction -- it never skips a turn, which is the only
+ failure direction that would lose facts.
+ """
+ last_id = self._watermark_get(watermark_key)
+ if last_id is None:
+ return messages
+ for i, msg in enumerate(messages):
+ if _message_identity(msg) == last_id:
+ return messages[i + 1 :]
+ return messages
+
def _do_update_memory_sync_impl(
self,
messages: list[Any],
thread_id: str | None = None,
agent_name: str | None = None,
- correction_detected: bool = False,
- reinforcement_detected: bool = False,
+ signals: frozenset[str] = frozenset(),
user_id: str | None = None,
trace_id: str | None = None,
+ *,
+ bypass_watermark: bool = False,
) -> bool:
"""Pure-sync memory update using ``model.invoke()``.
Uses the *sync* LLM call path so no event loop is created. This
guarantees that the langchain provider's globally cached async
httpx ``AsyncClient`` / connection pool (the one shared with the
- lead agent) is never touched — no cross-loop connection reuse is
+ lead agent) is never touched - no cross-loop connection reuse is
possible.
+
+ Watermark: the middleware passes the full conversation each turn, so
+ without skipping already-extracted turns every update re-feeds old
+ messages. The watermark stores the identity of the last-extracted
+ message (content/id based, in-memory only) so it stays correct when
+ summarization removes the conversation front; a restart loses it and
+ re-extracts one batch. ``bypass_watermark`` is set by the emergency
+ (summarization) flush path: the subset it carries is a one-shot
+ "extract before removal" snapshot, so it is fed in full and does not
+ read or advance the conversation watermark (advancing it from the
+ subset's own length would regress the watermark and skip un-extracted
+ tail turns on the next normal feed).
"""
+ metrics: dict[str, Any] = {}
+ response: Any = None
+ model_name: str | None = None
+ success = False
+ attempted = False
try:
+ watermark_key = (thread_id, user_id, agent_name)
+ if bypass_watermark:
+ # Emergency flush: extract the carried subset in full.
+ feed_messages = messages
+ else:
+ feed_messages = self._feed_after_watermark(watermark_key, messages)
+ if not feed_messages:
+ logger.debug("Memory update skipped: no new messages since watermark (thread=%s)", thread_id)
+ return True
+ # Re-detect signals on the post-watermark feed so extraction hints
+ # reference only turns the LLM will actually see. The admission-time
+ # ``signals`` (detected on the full conversation in DeerMem) already
+ # served their purpose (backpressure admission at enqueue); the hint
+ # is a soft nudge and must not point at turns the watermark excluded.
+ feed_signals = detect_signals(feed_messages, patterns_dir=self._config.patterns_dir)
prepared = self._prepare_update_prompt(
- messages=messages,
+ messages=feed_messages,
agent_name=agent_name,
- correction_detected=correction_detected,
- reinforcement_detected=reinforcement_detected,
+ signals=feed_signals,
user_id=user_id,
)
if prepared is None:
@@ -1173,30 +1374,56 @@ class MemoryUpdater:
model_name=model_name,
)
logger.info("Invoking memory-update LLM (thread=%s trace_id=%s)", thread_id, trace_id)
+ attempted = True
response = model.invoke(prompt, config=invoke_config)
- return self._finalize_update(
+ success = self._finalize_update(
current_memory=current_memory,
response_content=response.content,
thread_id=thread_id,
agent_name=agent_name,
user_id=user_id,
+ metrics=metrics,
)
+ if success and not bypass_watermark:
+ # Advance the watermark to the last message fed (the feed is a
+ # suffix, so this is messages[-1]). Skipped on the emergency
+ # path -- the subset's last message is older than the
+ # conversation's latest, so advancing from it would regress.
+ self._watermark_set(watermark_key, _message_identity(messages[-1]))
+ return success
except json.JSONDecodeError as e:
logger.warning("Failed to parse LLM response for memory update: %s", e)
return False
except Exception as e:
logger.exception("Memory update failed: %s", e)
return False
+ finally:
+ # Emit metrics even when _finalize_update (or invoke) raises, so the
+ # observability callback sees exception failures (parse errors,
+ # storage errors after retry) rather than only the happy path. The
+ # pre-attempt early returns (no new messages, empty conversation, no
+ # model) do not emit, matching the prior behavior.
+ if attempted:
+ self._emit_extraction_metrics(
+ metrics,
+ thread_id=thread_id,
+ user_id=user_id,
+ trace_id=trace_id,
+ model_name=model_name,
+ response=response,
+ success=success,
+ )
def update_memory(
self,
messages: list[Any],
thread_id: str | None = None,
agent_name: str | None = None,
- correction_detected: bool = False,
- reinforcement_detected: bool = False,
+ signals: frozenset[str] = frozenset(),
user_id: str | None = None,
trace_id: str | None = None,
+ *,
+ bypass_watermark: bool = False,
) -> bool:
"""Synchronously update memory using the sync LLM path.
@@ -1213,12 +1440,15 @@ class MemoryUpdater:
messages: List of conversation messages.
thread_id: Optional thread ID for tracking source.
agent_name: If provided, updates per-agent memory. If None, updates global memory.
- correction_detected: Whether recent turns include an explicit correction signal.
- reinforcement_detected: Whether recent turns include a positive reinforcement signal.
+ signals: Signal classes detected in the conversation (correction /
+ reinforcement / preference / ...), used as extraction hints.
user_id: If provided, scopes memory to a specific user.
Returns:
- True if update was successful, False otherwise.
+ True if the update persisted. False on any failure (no content,
+ unparseable response, LLM error); failures are swallowed (best-effort)
+ -- a failed update is re-fed on the next conversation turn because the
+ watermark does not advance on failure.
"""
try:
loop = asyncio.get_running_loop()
@@ -1232,10 +1462,10 @@ class MemoryUpdater:
messages=messages,
thread_id=thread_id,
agent_name=agent_name,
- correction_detected=correction_detected,
- reinforcement_detected=reinforcement_detected,
+ signals=signals,
user_id=user_id,
trace_id=trace_id,
+ bypass_watermark=bypass_watermark,
)
return future.result()
except Exception:
@@ -1246,10 +1476,10 @@ class MemoryUpdater:
messages=messages,
thread_id=thread_id,
agent_name=agent_name,
- correction_detected=correction_detected,
- reinforcement_detected=reinforcement_detected,
+ signals=signals,
user_id=user_id,
trace_id=trace_id,
+ bypass_watermark=bypass_watermark,
)
def _apply_updates(
@@ -1257,6 +1487,8 @@ class MemoryUpdater:
current_memory: dict[str, Any],
update_data: dict[str, Any],
thread_id: str | None = None,
+ *,
+ metrics: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Apply LLM-generated updates to memory.
@@ -1264,6 +1496,11 @@ class MemoryUpdater:
current_memory: Current memory data.
update_data: Updates from LLM.
thread_id: Optional thread ID for tracking.
+ metrics: Optional observability dict. When provided, populated with
+ ``facts_passed_confidence`` / ``rejected_low_confidence`` counted
+ at the real confidence-filter site below (the only acceptance
+ gate for new facts), so the metric cannot drift from the actual
+ filter the way a re-derived count in the caller could.
Returns:
Updated memory data.
@@ -1398,9 +1635,18 @@ class MemoryUpdater:
# Creation-time lifetime cap shared with the consolidation path below, so
# both fact-creation sites apply the identical bound in one place.
creation_cap = int(config.staleness_age_days * config.staleness_max_lifetime_multiplier)
+ # Counted at the confidence-gate site (the only real accept filter for new
+ # facts) so the ``facts_passed_confidence`` metric mirrors the actual
+ # filter and cannot drift from it. Facts below the threshold are the
+ # reject count; duplicate / empty / over-cap facts that pass the
+ # threshold are still counted here -- the metric is a confidence-gate
+ # signal (the host's rejection-rate warning monitors confidence
+ # filtering, not dedup / over-cap), not a persisted-fact count.
+ passed_threshold = 0
for fact in new_facts:
confidence = fact.get("confidence", 0.5)
if confidence >= config.fact_confidence_threshold:
+ passed_threshold += 1
raw_content = fact.get("content", "")
if not isinstance(raw_content, str):
continue
@@ -1439,6 +1685,10 @@ class MemoryUpdater:
if fact_key is not None:
existing_fact_keys.add(fact_key)
+ if metrics is not None:
+ metrics["facts_passed_confidence"] = passed_threshold
+ metrics["rejected_low_confidence"] = len(new_facts) - passed_threshold
+
# Enforce max facts limit (coerced confidence -- see _trim_facts_to_max).
current_memory["facts"] = _trim_facts_to_max(current_memory["facts"], config.max_facts)
diff --git a/backend/packages/harness/deerflow/agents/memory/manager.py b/backend/packages/harness/deerflow/agents/memory/manager.py
index fd88495e7..dd6d7c7f8 100644
--- a/backend/packages/harness/deerflow/agents/memory/manager.py
+++ b/backend/packages/harness/deerflow/agents/memory/manager.py
@@ -656,6 +656,51 @@ def _host_default_llm() -> Any:
return None
+def _host_default_extraction_callback(payload: Any) -> None:
+ """deer-flow default for DeerMem's ``extraction_callback`` slot.
+
+ Logs post-extraction metrics (token usage, facts passing/rejected by the
+ confidence filter, gate rejection rate) for ops observability, and flags a
+ high rejection rate
+ (>60%) so a prompt/threshold regression is visible without inspecting every
+ trace. A Langfuse-aware callback can replace this to emit a dedicated
+ extraction span; the metrics keys are stable for that handoff. Exceptions
+ are never raised (the DeerMem side already wraps the call).
+ """
+ if not isinstance(payload, dict):
+ return
+ extracted = payload.get("facts_extracted")
+ passed_confidence = payload.get("facts_passed_confidence")
+ rejected = payload.get("rejected_low_confidence", 0)
+ thread_id = payload.get("thread_id")
+ model_name = payload.get("model_name")
+ if isinstance(extracted, int) and isinstance(passed_confidence, int) and extracted > 0:
+ rejection_rate = (extracted - passed_confidence) / extracted
+ logger.info(
+ "Memory extraction metrics: thread=%s model=%s extracted=%d passed_confidence=%d rejected=%d rejection_rate=%.2f",
+ thread_id,
+ model_name,
+ extracted,
+ passed_confidence,
+ rejected,
+ rejection_rate,
+ )
+ if rejection_rate > 0.6:
+ logger.warning(
+ "Memory extraction rejection rate %.0f%% exceeds 60%% - review extraction prompt / confidence threshold (thread=%s)",
+ rejection_rate * 100,
+ thread_id,
+ )
+ else:
+ logger.info(
+ "Memory extraction metrics: thread=%s model=%s success=%s token_usage=%s",
+ thread_id,
+ model_name,
+ payload.get("success"),
+ payload.get("token_usage"),
+ )
+
+
def _collect_host_hooks() -> dict[str, Any]:
"""Provide host hook callables for backends to consume in ``from_config``.
@@ -674,6 +719,7 @@ def _collect_host_hooks() -> dict[str, Any]:
"should_keep_hidden_message": _host_default_should_keep_hidden_message,
"trace_context_manager": request_trace_context,
"host_llm_factory": _host_default_llm,
+ "extraction_callback": _host_default_extraction_callback,
}
diff --git a/backend/tests/test_deermem_self_contained.py b/backend/tests/test_deermem_self_contained.py
index c34a71143..54e2d6eb3 100644
--- a/backend/tests/test_deermem_self_contained.py
+++ b/backend/tests/test_deermem_self_contained.py
@@ -55,6 +55,33 @@ def _deermem_with_fake_llm(backend_config=None, payload=None, callbacks=None) ->
return dm
+def test_add_swallows_queue_full_so_backpressure_does_not_break_caller(deermem_data_dir, caplog) -> None:
+ """Regression: QueueFull raised under backpressure is caught in
+ DeerMem.add (the backend owns the queue, so it owns the degradation) so
+ memory backpressure degrades to "update skipped" instead of propagating into
+ MemoryMiddleware.after_agent and breaking the agent run -- peer middlewares
+ self-guard the same way."""
+ import logging
+
+ dm = _deermem_with_fake_llm(backend_config={"storage_path": str(deermem_data_dir), "queue_max_depth": 1})
+ # Stop the debounce timer so enqueued items stay pending (the cap persists
+ # across the second add instead of being drained by a timer fire).
+ dm._queue._schedule_timer = lambda *a, **k: None
+
+ conv = [HumanMessage("Please explain quantum computing in detail"), AIMessage("Quantum computing uses qubits and superposition.")]
+ # First add fills the queue to its depth cap (non-signal, new key).
+ dm.add("thread-A", conv, agent_name="lead_agent", user_id="u")
+ assert dm._queue.pending_count == 1
+
+ # Second add for a different key hits the cap -> QueueFull internally. It
+ # must be caught: no exception escapes DeerMem.add.
+ with caplog.at_level(logging.WARNING, logger="deerflow.agents.memory.backends.deermem.deer_mem"):
+ dm.add("thread-B", conv, agent_name="lead_agent", user_id="u")
+ assert "rejected under backpressure" in caplog.text
+ # thread-B was rejected (not enqueued); only thread-A remains.
+ assert dm._queue.pending_count == 1
+
+
def test_di_construction_owns_dependencies():
dm = DeerMem(backend_config={"max_facts": 50, "storage_path": "/tmp/x"})
assert dm._config.max_facts == 50
diff --git a/backend/tests/test_memory_consolidation.py b/backend/tests/test_memory_consolidation.py
index fd6bf470b..a2c92f988 100644
--- a/backend/tests/test_memory_consolidation.py
+++ b/backend/tests/test_memory_consolidation.py
@@ -1472,8 +1472,7 @@ class TestPrepareUpdatePromptConsolidation:
result = updater._prepare_update_prompt(
messages=[msg],
agent_name=None,
- correction_detected=False,
- reinforcement_detected=False,
+ signals=frozenset(),
)
assert result is not None
@@ -1497,8 +1496,7 @@ class TestPrepareUpdatePromptConsolidation:
result = updater._prepare_update_prompt(
messages=[msg],
agent_name=None,
- correction_detected=False,
- reinforcement_detected=False,
+ signals=frozenset(),
)
assert result is not None
@@ -1521,8 +1519,7 @@ class TestPrepareUpdatePromptConsolidation:
result = updater._prepare_update_prompt(
messages=[msg],
agent_name=None,
- correction_detected=False,
- reinforcement_detected=False,
+ signals=frozenset(),
)
assert result is not None
diff --git a/backend/tests/test_memory_manager_pluggable.py b/backend/tests/test_memory_manager_pluggable.py
index e7b4ce33a..7de42b9c2 100644
--- a/backend/tests/test_memory_manager_pluggable.py
+++ b/backend/tests/test_memory_manager_pluggable.py
@@ -193,7 +193,7 @@ def test_deermem_shutdown_flush_drains_a_pending_update() -> None:
mock_updater = MagicMock()
mock_updater.update_memory.return_value = True
deermem._queue._updater = mock_updater
- deermem._queue._queue = [ConversationContext(thread_id=f"t{i}", messages=["m"], agent_name="lead_agent") for i in range(3)]
+ deermem._queue._items = [ConversationContext(thread_id=f"t{i}", messages=["m"], agent_name="lead_agent") for i in range(3)]
assert deermem.shutdown_flush(5.0) is True
assert deermem._queue.pending_count == 0
assert mock_updater.update_memory.call_count == 3
diff --git a/backend/tests/test_memory_queue.py b/backend/tests/test_memory_queue.py
index 13d3c5e34..5c5b9de28 100644
--- a/backend/tests/test_memory_queue.py
+++ b/backend/tests/test_memory_queue.py
@@ -13,20 +13,20 @@ def _queue(updater: MagicMock | None = None) -> MemoryUpdateQueue:
def test_queue_add_preserves_existing_correction_flag_for_same_thread() -> None:
queue = _queue()
- with patch.object(queue, "_reset_timer"):
- queue.add(thread_id="thread-1", messages=["first"], correction_detected=True)
- queue.add(thread_id="thread-1", messages=["second"], correction_detected=False)
+ with patch.object(queue, "_schedule_timer"):
+ queue.add(thread_id="thread-1", messages=["first"], signals=frozenset({"correction"}))
+ queue.add(thread_id="thread-1", messages=["second"], signals=frozenset())
- assert len(queue._queue) == 1
- assert queue._queue[0].messages == ["second"]
- assert queue._queue[0].correction_detected is True
+ assert len(queue._items) == 1
+ assert queue._items[0].messages == ["second"]
+ assert "correction" in queue._items[0].signals
def test_process_queue_forwards_correction_flag_to_updater() -> None:
mock_updater = MagicMock()
mock_updater.update_memory.return_value = True
queue = _queue(mock_updater)
- queue._queue = [ConversationContext(thread_id="thread-1", messages=["conversation"], agent_name="lead_agent", correction_detected=True)]
+ queue._items = [ConversationContext(thread_id="thread-1", messages=["conversation"], agent_name="lead_agent", signals=frozenset({"correction"}))]
queue._process_queue()
@@ -34,29 +34,29 @@ def test_process_queue_forwards_correction_flag_to_updater() -> None:
messages=["conversation"],
thread_id="thread-1",
agent_name="lead_agent",
- correction_detected=True,
- reinforcement_detected=False,
+ signals=frozenset({"correction"}),
user_id=None,
trace_id=None,
+ bypass_watermark=False,
)
def test_queue_add_preserves_existing_reinforcement_flag_for_same_thread() -> None:
queue = _queue()
- with patch.object(queue, "_reset_timer"):
- queue.add(thread_id="thread-1", messages=["first"], reinforcement_detected=True)
- queue.add(thread_id="thread-1", messages=["second"], reinforcement_detected=False)
+ with patch.object(queue, "_schedule_timer"):
+ queue.add(thread_id="thread-1", messages=["first"], signals=frozenset({"reinforcement"}))
+ queue.add(thread_id="thread-1", messages=["second"], signals=frozenset())
- assert len(queue._queue) == 1
- assert queue._queue[0].messages == ["second"]
- assert queue._queue[0].reinforcement_detected is True
+ assert len(queue._items) == 1
+ assert queue._items[0].messages == ["second"]
+ assert "reinforcement" in queue._items[0].signals
def test_process_queue_forwards_reinforcement_flag_to_updater() -> None:
mock_updater = MagicMock()
mock_updater.update_memory.return_value = True
queue = _queue(mock_updater)
- queue._queue = [ConversationContext(thread_id="thread-1", messages=["conversation"], agent_name="lead_agent", reinforcement_detected=True)]
+ queue._items = [ConversationContext(thread_id="thread-1", messages=["conversation"], agent_name="lead_agent", signals=frozenset({"reinforcement"}))]
queue._process_queue()
@@ -64,10 +64,10 @@ def test_process_queue_forwards_reinforcement_flag_to_updater() -> None:
messages=["conversation"],
thread_id="thread-1",
agent_name="lead_agent",
- correction_detected=False,
- reinforcement_detected=True,
+ signals=frozenset({"reinforcement"}),
user_id=None,
trace_id=None,
+ bypass_watermark=False,
)
@@ -99,7 +99,7 @@ def test_add_nowait_cancels_existing_timer_and_starts_immediate_timer() -> None:
existing_timer.cancel.assert_called_once_with()
timer_cls.assert_called_once_with(0, queue._process_queue)
assert queue.pending_count == 1
- assert queue._queue[0].agent_name == "lead-agent"
+ assert queue._items[0].agent_name == "lead-agent"
assert created_timer.daemon is True
created_timer.start.assert_called_once_with()
@@ -127,14 +127,14 @@ def test_finishing_worker_reschedules_once_when_reprocess_pending() -> None:
schedules exactly one follow-up run (not a per-arrival timer spin)."""
mock_updater = MagicMock()
queue = _queue(mock_updater)
- queue._queue = [ConversationContext(thread_id="thread-1", messages=["first"], agent_name="lead_agent")]
+ queue._items = [ConversationContext(thread_id="thread-1", messages=["first"], agent_name="lead_agent")]
queue._reprocess_pending = True
created_timer = MagicMock()
def _enqueue_more_while_processing(**_kwargs) -> bool:
# Simulate a new update arriving mid-processing so the finally block sees
# remaining work and reschedules exactly once.
- queue._queue.append(ConversationContext(thread_id="thread-2", messages=["second"], agent_name="lead_agent"))
+ queue._items.append(ConversationContext(thread_id="thread-2", messages=["second"], agent_name="lead_agent"))
return True
mock_updater.update_memory.side_effect = _enqueue_more_while_processing
@@ -154,7 +154,7 @@ def test_finishing_worker_does_not_reschedule_when_no_work_remains() -> None:
mock_updater = MagicMock()
mock_updater.update_memory.return_value = True
queue = _queue(mock_updater)
- queue._queue = [ConversationContext(thread_id="thread-1", messages=["only"], agent_name="lead_agent")]
+ queue._items = [ConversationContext(thread_id="thread-1", messages=["only"], agent_name="lead_agent")]
queue._reprocess_pending = True
with patch("deerflow.agents.memory.backends.deermem.deermem.core.queue.threading.Timer") as timer_cls:
@@ -193,19 +193,19 @@ def test_queue_keeps_updates_for_different_agents_in_same_thread() -> None:
queue.add(thread_id="thread-1", messages=["agent-b"], agent_name="agent-b")
assert queue.pending_count == 2
- assert [context.agent_name for context in queue._queue] == ["agent-a", "agent-b"]
+ assert [context.agent_name for context in queue._items] == ["agent-a", "agent-b"]
def test_queue_still_coalesces_updates_for_same_agent_in_same_thread() -> None:
queue = _queue()
- with patch.object(queue, "_reset_timer"):
- queue.add(thread_id="thread-1", messages=["first"], agent_name="agent-a", correction_detected=True)
- queue.add(thread_id="thread-1", messages=["second"], agent_name="agent-a", correction_detected=False)
+ with patch.object(queue, "_schedule_timer"):
+ queue.add(thread_id="thread-1", messages=["first"], agent_name="agent-a", signals=frozenset({"correction"}))
+ queue.add(thread_id="thread-1", messages=["second"], agent_name="agent-a", signals=frozenset())
assert queue.pending_count == 1
- assert queue._queue[0].agent_name == "agent-a"
- assert queue._queue[0].messages == ["second"]
- assert queue._queue[0].correction_detected is True
+ assert queue._items[0].agent_name == "agent-a"
+ assert queue._items[0].messages == ["second"]
+ assert "correction" in queue._items[0].signals
def test_process_queue_updates_different_agents_in_same_thread_separately() -> None:
@@ -224,8 +224,8 @@ def test_process_queue_updates_different_agents_in_same_thread_separately() -> N
assert mock_updater.update_memory.call_count == 2
mock_updater.update_memory.assert_has_calls(
[
- call(messages=["agent-a"], thread_id="thread-1", agent_name="agent-a", correction_detected=False, reinforcement_detected=False, user_id=None, trace_id=None),
- call(messages=["agent-b"], thread_id="thread-1", agent_name="agent-b", correction_detected=False, reinforcement_detected=False, user_id=None, trace_id=None),
+ call(messages=["agent-a"], thread_id="thread-1", agent_name="agent-a", signals=frozenset(), user_id=None, trace_id=None, bypass_watermark=False),
+ call(messages=["agent-b"], thread_id="thread-1", agent_name="agent-b", signals=frozenset(), user_id=None, trace_id=None, bypass_watermark=False),
]
)
@@ -234,7 +234,7 @@ def test_process_queue_forwards_trace_id_to_updater() -> None:
mock_updater = MagicMock()
mock_updater.update_memory.return_value = True
queue = _queue(mock_updater)
- queue._queue = [ConversationContext(thread_id="thread-1", messages=["conversation"], agent_name="lead_agent", trace_id="trace-memory-1")]
+ queue._items = [ConversationContext(thread_id="thread-1", messages=["conversation"], agent_name="lead_agent", trace_id="trace-memory-1")]
queue._process_queue()
@@ -242,10 +242,10 @@ def test_process_queue_forwards_trace_id_to_updater() -> None:
messages=["conversation"],
thread_id="thread-1",
agent_name="lead_agent",
- correction_detected=False,
- reinforcement_detected=False,
+ signals=frozenset(),
user_id=None,
trace_id="trace-memory-1",
+ bypass_watermark=False,
)
@@ -272,7 +272,7 @@ def test_flush_sync_drains_pending_queue_and_returns_true() -> None:
mock_updater = MagicMock()
mock_updater.update_memory.return_value = True
queue = _queue(mock_updater)
- queue._queue = [ConversationContext(thread_id="thread-1", messages=["conversation"], agent_name="lead_agent")]
+ queue._items = [ConversationContext(thread_id="thread-1", messages=["conversation"], agent_name="lead_agent")]
with (
patch(_QUEUE_MODULE + ".MemoryUpdater", create=True),
@@ -286,17 +286,17 @@ def test_flush_sync_drains_pending_queue_and_returns_true() -> None:
messages=["conversation"],
thread_id="thread-1",
agent_name="lead_agent",
- correction_detected=False,
- reinforcement_detected=False,
+ signals=frozenset(),
user_id=None,
trace_id=None,
+ bypass_watermark=False,
)
def test_flush_sync_returns_false_when_flush_exceeds_timeout() -> None:
"""flush_sync does not block past ``timeout``; a slow flush returns False."""
queue = _queue()
- queue._queue = [ConversationContext(thread_id="thread-1", messages=["conversation"], agent_name="lead_agent")]
+ queue._items = [ConversationContext(thread_id="thread-1", messages=["conversation"], agent_name="lead_agent")]
release = threading.Event()
def _slow_flush() -> None:
@@ -379,7 +379,7 @@ def test_flush_sync_returns_false_when_flush_raises() -> None:
caller never logs a contradictory 'completed' next to the exception
(review comment #2)."""
queue = _queue()
- queue._queue = [ConversationContext(thread_id="thread-1", messages=["conversation"], agent_name="lead_agent")]
+ queue._items = [ConversationContext(thread_id="thread-1", messages=["conversation"], agent_name="lead_agent")]
with patch.object(queue, "flush", side_effect=RuntimeError("boom")):
completed = queue.flush_sync(timeout=5.0)
@@ -393,7 +393,7 @@ def test_flush_sync_skips_inter_item_delay_on_drain_path() -> None:
mock_updater = MagicMock()
mock_updater.update_memory.return_value = True
queue = _queue(mock_updater)
- queue._queue = [ConversationContext(thread_id=f"thread-{i}", messages=["conversation"], agent_name="lead_agent") for i in range(3)]
+ queue._items = [ConversationContext(thread_id=f"thread-{i}", messages=["conversation"], agent_name="lead_agent") for i in range(3)]
with patch(_QUEUE_MODULE + ".time.sleep") as mock_sleep:
completed = queue.flush_sync(timeout=5.0)
diff --git a/backend/tests/test_memory_queue_user_isolation.py b/backend/tests/test_memory_queue_user_isolation.py
index 2de38bfcd..033f6f19c 100644
--- a/backend/tests/test_memory_queue_user_isolation.py
+++ b/backend/tests/test_memory_queue_user_isolation.py
@@ -24,8 +24,8 @@ def test_queue_add_stores_user_id():
q = _queue()
with patch.object(q, "_reset_timer"):
q.add(thread_id="t1", messages=["msg"], user_id="alice")
- assert len(q._queue) == 1
- assert q._queue[0].user_id == "alice"
+ assert len(q._items) == 1
+ assert q._items[0].user_id == "alice"
q.clear()
@@ -49,8 +49,8 @@ def test_queue_keeps_updates_for_different_users_in_same_thread_and_agent():
q.add(thread_id="main", messages=["bob update"], agent_name="researcher", user_id="bob")
assert q.pending_count == 2
- assert [context.user_id for context in q._queue] == ["alice", "bob"]
- assert [context.messages for context in q._queue] == [["alice update"], ["bob update"]]
+ assert [context.user_id for context in q._items] == ["alice", "bob"]
+ assert [context.messages for context in q._items] == [["alice update"], ["bob update"]]
def test_queue_still_coalesces_updates_for_same_user_thread_and_agent():
@@ -60,9 +60,9 @@ def test_queue_still_coalesces_updates_for_same_user_thread_and_agent():
q.add(thread_id="main", messages=["second"], agent_name="researcher", user_id="alice")
assert q.pending_count == 1
- assert q._queue[0].messages == ["second"]
- assert q._queue[0].user_id == "alice"
- assert q._queue[0].agent_name == "researcher"
+ assert q._items[0].messages == ["second"]
+ assert q._items[0].user_id == "alice"
+ assert q._items[0].agent_name == "researcher"
def test_add_nowait_keeps_different_users_separate():
@@ -72,4 +72,4 @@ def test_add_nowait_keeps_different_users_separate():
q.add_nowait(thread_id="main", messages=["bob update"], agent_name="researcher", user_id="bob")
assert q.pending_count == 2
- assert [context.user_id for context in q._queue] == ["alice", "bob"]
+ assert [context.user_id for context in q._items] == ["alice", "bob"]
diff --git a/backend/tests/test_memory_staleness_review.py b/backend/tests/test_memory_staleness_review.py
index 3db4a15dc..1fcabc59e 100644
--- a/backend/tests/test_memory_staleness_review.py
+++ b/backend/tests/test_memory_staleness_review.py
@@ -1156,8 +1156,7 @@ class TestPrepareUpdatePromptStaleness:
result = updater._prepare_update_prompt(
messages=[msg],
agent_name=None,
- correction_detected=False,
- reinforcement_detected=False,
+ signals=frozenset(),
)
assert result is not None
@@ -1180,8 +1179,7 @@ class TestPrepareUpdatePromptStaleness:
result = updater._prepare_update_prompt(
messages=[msg],
agent_name=None,
- correction_detected=False,
- reinforcement_detected=False,
+ signals=frozenset(),
)
assert result is not None
@@ -1205,8 +1203,7 @@ class TestPrepareUpdatePromptStaleness:
result = updater._prepare_update_prompt(
messages=[msg],
agent_name=None,
- correction_detected=False,
- reinforcement_detected=False,
+ signals=frozenset(),
)
assert result is not None
@@ -1229,8 +1226,7 @@ class TestPrepareUpdatePromptStaleness:
result = updater._prepare_update_prompt(
messages=[msg],
agent_name=None,
- correction_detected=False,
- reinforcement_detected=False,
+ signals=frozenset(),
)
assert result is not None
diff --git a/backend/tests/test_message_processing.py b/backend/tests/test_message_processing.py
index d40ad95ca..d36654eb4 100644
--- a/backend/tests/test_message_processing.py
+++ b/backend/tests/test_message_processing.py
@@ -107,13 +107,12 @@ def test_prepare_update_missing_role_returns_none(tmp_path):
assert m._prepare_update([]) is None
-def test_prepare_update_returns_3tuple_with_correction_true(tmp_path):
+def test_prepare_update_returns_signals_with_correction(tmp_path):
m = _make_deermem(tmp_path)
r = m._prepare_update([_human("That's wrong, use uv"), _ai("ok")])
- assert r is not None and len(r) == 3
- filtered, corr, rein = r
- assert corr is True
- assert rein is False
+ assert r is not None and len(r) == 2
+ filtered, signals = r
+ assert "correction" in signals
assert len(filtered) == 2
diff --git a/backend/tests/test_message_processing_signals.py b/backend/tests/test_message_processing_signals.py
new file mode 100644
index 000000000..5d32f1203
--- /dev/null
+++ b/backend/tests/test_message_processing_signals.py
@@ -0,0 +1,139 @@
+"""Tests for message-processing signal detection and trivial filtering.
+
+Pins three behaviors: ``detect_signals`` recognizes all six signal classes
+(correction, reinforcement, preference, identity, goal, decision);
+``filter_trivial`` drops pure-ack turns and their replies while keeping
+substantive turns; and ``_prepare_update`` returns the full signal set as a
+``frozenset`` (not just correction/reinforcement), so every detected class
+flows through to the extraction prompt.
+"""
+
+from __future__ import annotations
+
+from langchain_core.messages import AIMessage, HumanMessage
+
+from deerflow.agents.memory.backends.deermem.deer_mem import DeerMem
+from deerflow.agents.memory.backends.deermem.deermem.core.message_processing import (
+ detect_signals,
+ extract_message_text,
+ filter_trivial,
+)
+
+
+def _human(text: str) -> HumanMessage:
+ return HumanMessage(content=text)
+
+
+def _ai(text: str) -> AIMessage:
+ return AIMessage(content=text)
+
+
+# ── detect_signals: the 6 signal classes ───────────────────────────────────
+
+
+def test_detect_signals_correction() -> None:
+ assert "correction" in detect_signals([_human("That's wrong, use uv"), _ai("ok")])
+
+
+def test_detect_signals_reinforcement() -> None:
+ assert "reinforcement" in detect_signals([_human("perfect, exactly right"), _ai("ok")])
+
+
+def test_detect_signals_preference() -> None:
+ assert "preference" in detect_signals([_human("I prefer uv over pip"), _ai("ok")])
+
+
+def test_detect_signals_identity() -> None:
+ assert "identity" in detect_signals([_human("I am an engineer"), _ai("ok")])
+
+
+def test_detect_signals_goal() -> None:
+ assert "goal" in detect_signals([_human("I plan to migrate to uv"), _ai("ok")])
+
+
+def test_detect_signals_decision() -> None:
+ assert "decision" in detect_signals([_human("let's go with uv"), _ai("ok")])
+
+
+def test_detect_signals_none_for_substantive_turn() -> None:
+ assert detect_signals([_human("what is the weather"), _ai("sunny")]) == set()
+
+
+def test_detect_signals_multiple_classes_in_one_turn() -> None:
+ # A turn that states both a preference and an identity surfaces both.
+ signals = detect_signals([_human("I am an engineer and I prefer uv"), _ai("ok")])
+ assert "identity" in signals
+ assert "preference" in signals
+
+
+# ── filter_trivial ─────────────────────────────────────────────────────────
+
+
+def test_filter_trivial_drops_pure_ack_and_its_reply() -> None:
+ msgs = [_human("嗯"), _ai("thanks"), _human("what next"), _ai("let's see")]
+ result = filter_trivial(msgs)
+ # "嗯" + its AI "thanks" dropped; the substantive pair is kept.
+ assert len(result) == 2
+ assert extract_message_text(result[0]) == "what next"
+
+
+def test_filter_trivial_keeps_substantive_message_containing_ok() -> None:
+ msgs = [_human("use uv to install, ok?"), _ai("done")]
+ result = filter_trivial(msgs)
+ assert len(result) == 2 # not dropped: not a whole-message ack
+
+
+def test_filter_trivial_all_trivial_returns_empty() -> None:
+ msgs = [_human("好的"), _ai("嗯")]
+ assert filter_trivial(msgs) == []
+
+
+def test_filter_trivial_tolerates_trailing_punctuation() -> None:
+ msgs = [_human("ok."), _ai("ok!")]
+ assert filter_trivial(msgs) == []
+
+
+def test_filter_trivial_no_patterns_keeps_all() -> None:
+ msgs = [_human("ok"), _ai("ok")]
+ assert filter_trivial(msgs, patterns=[]) == msgs
+
+
+# ── _prepare_update: seam-stable 3-tuple projection ────────────────────────
+
+
+def _make_deermem(tmp_path, **overrides) -> DeerMem:
+ cfg = {"storage_path": str(tmp_path)}
+ cfg.update(overrides)
+ return DeerMem(backend_config=cfg)
+
+
+def test_prepare_update_all_trivial_returns_none(tmp_path) -> None:
+ m = _make_deermem(tmp_path)
+ assert m._prepare_update([_human("好的"), _ai("嗯")]) is None
+
+
+def test_prepare_update_returns_correction_signal(tmp_path) -> None:
+ m = _make_deermem(tmp_path)
+ r = m._prepare_update([_human("That's wrong, use uv"), _ai("ok")])
+ assert r is not None and len(r) == 2
+ _filtered, signals = r
+ assert "correction" in signals
+
+
+def test_prepare_update_returns_reinforcement_signal(tmp_path) -> None:
+ m = _make_deermem(tmp_path)
+ r = m._prepare_update([_human("perfect, exactly right"), _ai("ok")])
+ assert r is not None and len(r) == 2
+ _filtered, signals = r
+ assert "reinforcement" in signals
+
+
+def test_prepare_update_returns_new_signals_after_swap(tmp_path) -> None:
+ # After the signals-seam swap, the full signal set flows through (not just
+ # correction/reinforcement): a preference turn surfaces "preference".
+ m = _make_deermem(tmp_path)
+ r = m._prepare_update([_human("I prefer uv over pip"), _ai("ok")])
+ assert r is not None and len(r) == 2
+ _filtered, signals = r
+ assert "preference" in signals
+ assert len(_filtered) == 2 # not trivial -> kept
diff --git a/backend/tests/test_updater_truncation.py b/backend/tests/test_updater_truncation.py
new file mode 100644
index 000000000..23b48bbf4
--- /dev/null
+++ b/backend/tests/test_updater_truncation.py
@@ -0,0 +1,54 @@
+"""Tests for the head500 + tail500 message truncation in format_conversation_for_update."""
+
+from __future__ import annotations
+
+from langchain_core.messages import AIMessage, HumanMessage
+
+from deerflow.agents.memory.backends.deermem.deermem.core.prompt import format_conversation_for_update
+
+
+def test_long_message_keeps_head_and_tail_drops_middle() -> None:
+ # 600 head chars + 400 middle + 600 tail = 1600 (> 1000 -> truncated).
+ long_content = "H" * 600 + "M" * 400 + "T" * 600
+ result = format_conversation_for_update([HumanMessage(content=long_content)])
+
+ assert "[truncated]" in result
+ # The first 500 and last 500 characters survive.
+ assert "H" * 500 in result
+ assert "T" * 500 in result
+ # The middle block is dropped.
+ assert "M" * 400 not in result
+
+
+def test_message_under_threshold_is_not_truncated() -> None:
+ result = format_conversation_for_update([HumanMessage(content="a short message")])
+ assert "[truncated]" not in result
+ assert "a short message" in result
+
+
+def test_message_exactly_1000_chars_is_not_truncated() -> None:
+ # The guard is strictly greater-than 1000, so 1000 chars pass through whole.
+ result = format_conversation_for_update([HumanMessage(content="x" * 1000)])
+ assert "[truncated]" not in result
+
+
+def test_message_1001_chars_is_truncated() -> None:
+ result = format_conversation_for_update([HumanMessage(content="x" * 1001)])
+ assert "[truncated]" in result
+
+
+def test_truncation_then_html_escape_preserves_head_marker() -> None:
+ # A leading "" must be HTML-escaped after truncation (block-breakout
+ # defense), and the head is preserved up to the 500-char boundary.
+ long_content = "" + "y" * 1500
+ result = format_conversation_for_update([HumanMessage(content=long_content)])
+ assert "<b>" in result
+ assert "[truncated]" in result
+
+
+def test_truncation_applies_to_ai_messages_too() -> None:
+ long_content = "A" * 700 + "B" * 700
+ result = format_conversation_for_update([AIMessage(content=long_content)])
+ assert "[truncated]" in result
+ assert "A" * 500 in result
+ assert "B" * 500 in result
diff --git a/backend/tests/test_updater_watermark.py b/backend/tests/test_updater_watermark.py
new file mode 100644
index 000000000..989394ea4
--- /dev/null
+++ b/backend/tests/test_updater_watermark.py
@@ -0,0 +1,183 @@
+"""Tests for the in-memory watermark (skip already-extracted messages)."""
+
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from langchain_core.messages import AIMessage, HumanMessage
+
+from deerflow.agents.memory.backends.deermem.deermem.config import DeerMemConfig
+from deerflow.agents.memory.backends.deermem.deermem.core.storage import MemoryStorage
+from deerflow.agents.memory.backends.deermem.deermem.core.updater import MemoryUpdater, _message_identity
+
+
+class _FakeLLM:
+ """Returns a canned empty-update response; counts invocations."""
+
+ def __init__(self) -> None:
+ self.invoke_count = 0
+ self._response = _EmptyResponse()
+
+ def invoke(self, prompt: Any, config: Any = None) -> Any:
+ self.invoke_count += 1
+ return self._response
+
+
+class _EmptyResponse:
+ content = json.dumps(
+ {
+ "user": {},
+ "history": {},
+ "newFacts": [],
+ "factsToRemove": [],
+ "staleFactsToRemove": [],
+ "staleFactsToExtend": [],
+ "factsToConsolidate": [],
+ }
+ )
+ usage_metadata: dict[str, int] | None = None
+
+
+class _FakeStorage(MemoryStorage):
+ """Minimal in-memory storage stub (load/save) for the save() path."""
+
+ def __init__(self) -> None:
+ self.memory: dict[str, Any] = {"version": "2.0", "revision": 0, "user": {}, "history": {}, "facts": []}
+
+ def load(self, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
+ return json.loads(json.dumps(self.memory))
+
+ def reload(self, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
+ return self.load(agent_name, user_id=user_id)
+
+ def save(self, memory_data: dict[str, Any], agent_name: str | None = None, *, user_id: str | None = None, expected_revision: int | None = None) -> bool:
+ self.memory = json.loads(json.dumps(memory_data))
+ return True
+
+
+def _config(**overrides: Any) -> DeerMemConfig:
+ base: dict[str, Any] = {}
+ base.update(overrides)
+ return DeerMemConfig(**base)
+
+
+def _msgs(*texts: str) -> list[Any]:
+ out: list[Any] = []
+ for t in texts:
+ out.append(HumanMessage(content=t))
+ out.append(AIMessage(content=f"reply-{t}"))
+ return out
+
+
+def test_watermark_skips_already_extracted_messages() -> None:
+ llm = _FakeLLM()
+ updater = MemoryUpdater(_config(), _FakeStorage(), llm)
+ messages = _msgs("first")
+
+ updater.update_memory(messages, thread_id="t1", agent_name="a", user_id="u")
+ assert llm.invoke_count == 1
+
+ # Same messages again -> nothing new since the watermark -> skipped, no LLM call.
+ result = updater.update_memory(messages, thread_id="t1", agent_name="a", user_id="u")
+ assert result is True
+ assert llm.invoke_count == 1
+
+
+def test_watermark_feeds_only_new_messages_on_growth() -> None:
+ llm = _FakeLLM()
+ updater = MemoryUpdater(_config(), _FakeStorage(), llm)
+ messages = _msgs("first")
+ updater.update_memory(messages, thread_id="t1", agent_name="a", user_id="u")
+ assert llm.invoke_count == 1
+
+ # Append a new turn; only the new turn is fed (watermark = prior length).
+ messages += _msgs("second")
+ updater.update_memory(messages, thread_id="t1", agent_name="a", user_id="u")
+ assert llm.invoke_count == 2
+
+
+def test_watermark_is_per_thread() -> None:
+ llm = _FakeLLM()
+ updater = MemoryUpdater(_config(), _FakeStorage(), llm)
+ messages = _msgs("first")
+ # Thread t1 extracts; thread t2 has its own watermark (starts at 0).
+ updater.update_memory(messages, thread_id="t1", agent_name="a", user_id="u")
+ updater.update_memory(messages, thread_id="t2", agent_name="a", user_id="u")
+ assert llm.invoke_count == 2
+
+
+def test_watermark_resets_when_conversation_shrinks() -> None:
+ llm = _FakeLLM()
+ updater = MemoryUpdater(_config(), _FakeStorage(), llm)
+ long_msgs = _msgs("first", "second", "third")
+ updater.update_memory(long_msgs, thread_id="t1", agent_name="a", user_id="u")
+ assert llm.invoke_count == 1
+ # A shorter message list (e.g. after summarization) must not get stuck at a
+ # watermark past the end; it re-extracts from the start.
+ short_msgs = _msgs("only")
+ updater.update_memory(short_msgs, thread_id="t1", agent_name="a", user_id="u")
+ assert llm.invoke_count == 2
+
+
+def test_watermark_front_removal_does_not_skip_pending_tail() -> None:
+ """Regression: an index watermark skips un-extracted turns when
+ summarization removes the conversation front. The watermark is
+ content/identity based, so after a front removal it finds the last-extracted
+ message at its NEW index and feeds the real pending tail instead of slicing
+ past the (now shorter) list.
+
+ Setup: 6 messages; pre-set the watermark to msg[3] so msgs[0..3] are
+ "already extracted" and msgs[4..5] are pending. Summarization then removes
+ the front pair (msgs[0..1]). The surviving 4-message list must still feed
+ the pending pair (msgs[4..5]) -- an index watermark (=4) would slice [4:]
+ on a 4-element list and feed nothing, losing them.
+ """
+ llm = _FakeLLM()
+ updater = MemoryUpdater(_config(), _FakeStorage(), llm)
+ msgs = _msgs("a", "b", "c") # [H a, A ra, H b, A rb, H c, A rc]
+ updater._watermarks[("t1", "u", "a")] = _message_identity(msgs[3]) # ...A rb extracted
+ surviving = msgs[2:] # summarization removed the front pair
+ updater.update_memory(surviving, thread_id="t1", agent_name="a", user_id="u")
+ # The pending tail (H c, A rc) was fed -> exactly one extraction.
+ assert llm.invoke_count == 1
+
+
+def test_emergency_flush_bypasses_watermark_and_does_not_regress() -> None:
+ """Regression: the emergency (summarization) flush path
+ bypasses the watermark -- it extracts its subset in full and does NOT
+ advance the conversation watermark (advancing from the subset's own last
+ message, which is older than the conversation's latest, would regress it
+ and skip the real tail on the next normal feed)."""
+ llm = _FakeLLM()
+ updater = MemoryUpdater(_config(), _FakeStorage(), llm)
+ msgs = _msgs("a", "b") # [H a, A ra, H b, A rb]
+ key = ("t1", "u", "a")
+ updater._watermarks[key] = _message_identity(msgs[1]) # ...A ra extracted; H b, A rb pending
+ # Emergency flush of the front subset about to be removed.
+ updater.update_memory(msgs[:2], thread_id="t1", agent_name="a", user_id="u", bypass_watermark=True)
+ assert llm.invoke_count == 1 # subset extracted in full
+ # Watermark did not regress to the subset's last message.
+ assert updater._watermarks[key] == _message_identity(msgs[1])
+ # A subsequent normal feed of the full conversation still extracts the tail.
+ updater.update_memory(msgs, thread_id="t1", agent_name="a", user_id="u")
+ assert llm.invoke_count == 2
+
+
+def test_watermark_cache_is_bounded_lru() -> None:
+ """The watermark cache is a bounded LRU: over capacity it drops the
+ least-recently-used key, and a dropped key re-extracts one batch on the
+ next turn for that thread (no loss)."""
+ llm = _FakeLLM()
+ updater = MemoryUpdater(_config(watermark_max_keys=2), _FakeStorage(), llm)
+ msgs = _msgs("first")
+ # Three distinct threads fill the cache (cap=2); the least-recently-used
+ # key (t1) is evicted.
+ updater.update_memory(msgs, thread_id="t1", agent_name="a", user_id="u")
+ updater.update_memory(msgs, thread_id="t2", agent_name="a", user_id="u")
+ updater.update_memory(msgs, thread_id="t3", agent_name="a", user_id="u")
+ assert len(updater._watermarks) == 2
+ assert ("t1", "u", "a") not in updater._watermarks # evicted (LRU)
+ # t1's next turn finds no watermark -> re-extracts one batch (not skipped).
+ updater.update_memory(msgs, thread_id="t1", agent_name="a", user_id="u")
+ assert llm.invoke_count == 4 # t1, t2, t3, then t1 re-extract
diff --git a/config.example.yaml b/config.example.yaml
index a21d8ab3b..476e82123 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -1649,6 +1649,10 @@ memory:
file_lock_timeout_seconds: 10 # per-scope cross-process advisory lock timeout (single-machine local filesystem)
retrieval_adapter: "" # optional dotted factory(config) supplied by the retrieval module
debounce_seconds: 30 # Wait time before processing queued updates
+ # Backpressure cap on pending items. 0 = unlimited. At the cap, new
+ # non-signal updates are rejected (QueueFull); signal updates are always
+ # admitted so important memories are never shed.
+ queue_max_depth: 1000
model: # LLM for memory extraction; omit all fields = no extraction (non-LLM ops still work; an update raises)
# provider: openai
# model: gpt-4o-mini
@@ -1696,6 +1700,9 @@ memory:
consolidation_min_facts: 8 # min facts in one category to trigger review (3-30)
consolidation_max_groups_per_cycle: 3 # max groups merged per update cycle (1-10)
consolidation_max_sources: 8 # max source facts per consolidation group (2-20)
+ # extraction_callback is a host-injected post-extraction observability hook
+ # (token usage, facts accepted/rejected, rejection rate). The factory
+ # injects a logging default; set programmatically to emit a Langfuse span.
# Message processing (externalized patterns / prompt templates):
# patterns_dir - dir with correction.yaml / reinforcement.yaml overriding
# the bundled signal-detection patterns. None (default) =
From 55c2153080d3b3288d0b112a5dee5697b74229ac Mon Sep 17 00:00:00 2001
From: Aari
Date: Sun, 26 Jul 2026 21:20:36 +0800
Subject: [PATCH 28/35] fix(frontend): restore resizing for the artifacts and
sidecar panels (#4469)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(frontend): restore resizing for the artifacts and sidecar panels
#3934 replaced the right panel's ResizablePanelGroup with a fixed CSS grid to
animate open/close, which removed the drag handle; #4187 then reintroduced a
resizable group for the browser panel only. The artifacts and sidecar panels
have had no way to resize since, while the browser divider still drags.
All three right panels now share one panel group, so there is no per-panel-kind
layout fork. Open/close goes through the side panel's collapse()/resize() so the
width still animates, and a dragged width survives closing and reopening.
Three library-specific constraints, each found by a failing test:
- the size transition is applied from the group as
[&>[data-panel]]:transition-[flex-grow], because
lands on an inner wrapper while the element the library sizes is its own
[data-panel] div;
- reopening uses resize(remembered) rather than expand(), which falls back to
minSize until the library has recorded a size, and the remembered width is
read before collapse() because the closing animation reports shrinking sizes;
- during the animation the content is held at its final width in cqw and
clipped, as the previous grid layout did — letting it reflow every frame makes
the message list re-run its scroll-to-bottom and re-wraps the sidecar
composer.
Fixes #4465
* fix(frontend): remove unreachable panel max size
---
frontend/AGENTS.md | 1 +
.../components/workspace/chats/chat-box.tsx | 185 ++++++++++-------
.../tests/e2e/artifact-panel-resize.spec.ts | 192 ++++++++++++++++++
3 files changed, 310 insertions(+), 68 deletions(-)
create mode 100644 frontend/tests/e2e/artifact-panel-resize.spec.ts
diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md
index 988abfcb8..33ba03568 100644
--- a/frontend/AGENTS.md
+++ b/frontend/AGENTS.md
@@ -107,6 +107,7 @@ Tool-calling AI messages can contain user-visible text as well as `tool_calls`.
- `src/components/workspace/messages/message-list.tsx` owns human-input card answered/latest/pending gating; entry pages only translate a submitted card response into `sendMessage` calls.
- `src/components/workspace/browser-view/browser-view-panel.tsx` forwards each physical pointer click as one `click` input; do not also emit `down`/`up` for the same gesture because the remote Playwright click would run twice.
- `src/core/threads/hooks.ts` owns pre-submit upload state and thread submission.
+- `src/components/workspace/chats/chat-box.tsx` owns the desktop right-panel layout, and **all three** right panels (artifacts, sidecar, browser) share one `ResizablePanelGroup` — do not fork a non-resizable branch per panel kind, which is how the artifacts divider silently lost its drag handle (#4465). Open/close is `collapse()` / `resize()` on the side panel's imperative handle, not conditional rendering, so the width can animate. Three constraints hold that together: the size transition is applied from the group as `[&>[data-panel]]:transition-[flex-grow]` because the sized flex item is the library's own `[data-panel]` element rather than the child `className` lands on; it is applied only while an open/close is in flight, so a drag is not interpolated frame by frame; and during the animation the panel content is held at its final width in `cqw` and clipped, because a reflowing message list re-runs its scroll-to-bottom (pinned by `tests/e2e/sidecar-chat.spec.ts`'s no-animated-scroll test) and a re-wrapping composer changes which responsive labels it shows.
## Code Style
diff --git a/frontend/src/components/workspace/chats/chat-box.tsx b/frontend/src/components/workspace/chats/chat-box.tsx
index bd3963100..b8398c892 100644
--- a/frontend/src/components/workspace/chats/chat-box.tsx
+++ b/frontend/src/components/workspace/chats/chat-box.tsx
@@ -1,6 +1,7 @@
import { FilesIcon, XIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
+import { usePanelRef } from "react-resizable-panels";
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
@@ -30,6 +31,7 @@ import { useThread } from "../messages/context";
import { SidecarPanel, useMaybeSidecar } from "../sidecar";
const RIGHT_PANEL_ANIMATION_MS = 280;
+const RIGHT_PANEL_DEFAULT_SIZE = "40%";
type RightPanelKind = "sidecar" | "artifacts" | "browser";
@@ -126,6 +128,67 @@ const ChatBox: React.FC<{
return pathname.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
}, [pathname]);
+ const sidePanelRef = usePanelRef();
+ // Width the panel reopens at: the last size the user dragged it to.
+ const openSizeRef = useRef(RIGHT_PANEL_DEFAULT_SIZE);
+ // While the panel width animates, the content is held at its final width and
+ // clipped instead of reflowing every frame — a reflowing message list keeps
+ // re-running its scroll-to-bottom (pinned by the sidecar scroll regression
+ // test), and re-wrapping text mid-animation looks unsettled. Expressed in
+ // `cqw` against the group so it tracks the final width even if the group
+ // itself resizes during the animation.
+ const [pinnedContentWidth, setPinnedContentWidth] = useState(
+ null,
+ );
+ // Size transitions belong to open/close only: leaving them on during a drag
+ // would interpolate every pointer frame and make the handle feel laggy.
+ const [animatingRightPanel, setAnimatingRightPanel] = useState(false);
+ const rightPanelOpenRef = useRef(rightPanelOpen);
+ // Read once: `defaultSize` only applies on mount, the effect below owns every
+ // later open/close.
+ const [initialRightPanelSize] = useState(() =>
+ rightPanelOpen ? RIGHT_PANEL_DEFAULT_SIZE : "0%",
+ );
+
+ useEffect(() => {
+ if (rightPanelOpenRef.current === rightPanelOpen) {
+ return;
+ }
+ rightPanelOpenRef.current = rightPanelOpen;
+
+ setAnimatingRightPanel(true);
+ if (!rightPanelOpen) {
+ // Remember the width now: the closing animation reports shrinking sizes,
+ // so sampling those would reopen the panel a few pixels wide.
+ const size = sidePanelRef.current?.getSize().asPercentage ?? 0;
+ if (size > 0) {
+ openSizeRef.current = `${size}%`;
+ }
+ }
+
+ const openPercentage = Number.parseFloat(openSizeRef.current);
+ setPinnedContentWidth(
+ Number.isFinite(openPercentage) ? `${openPercentage}cqw` : null,
+ );
+
+ // resize() rather than expand(): the library expands to `minSize` until it
+ // has recorded a size of its own, which would reopen narrower than before.
+ if (rightPanelOpen) {
+ sidePanelRef.current?.resize(openSizeRef.current);
+ } else {
+ sidePanelRef.current?.collapse();
+ }
+
+ const timeout = window.setTimeout(() => {
+ setAnimatingRightPanel(false);
+ setPinnedContentWidth(null);
+ }, RIGHT_PANEL_ANIMATION_MS);
+
+ return () => {
+ window.clearTimeout(timeout);
+ };
+ }, [rightPanelOpen, sidePanelRef]);
+
useEffect(() => {
if (activeRightPanel) {
setRenderedRightPanel(activeRightPanel);
@@ -260,83 +323,69 @@ const ChatBox: React.FC<{
}
return (
- [data-panel]]:transition-[flex-grow] [&>[data-panel]]:duration-[280ms] [&>[data-panel]]:ease-out motion-reduce:[&>[data-panel]]:transition-none",
)}
>
- {activeRightPanel === "browser" ? (
-
+
+ {children}
+
+
+
+
+
-
-
- {children}
-
-
-
-
-
-
-
- ) : (
- <>
-
- {children}
-
-
-
- {rightPanelContent}
-
-
- >
- )}
-
+ {rightPanelContent}
+
+
+
+
);
};
diff --git a/frontend/tests/e2e/artifact-panel-resize.spec.ts b/frontend/tests/e2e/artifact-panel-resize.spec.ts
new file mode 100644
index 000000000..b821f6ced
--- /dev/null
+++ b/frontend/tests/e2e/artifact-panel-resize.spec.ts
@@ -0,0 +1,192 @@
+import { type Locator, type Page, expect, test } from "@playwright/test";
+
+import { mockLangGraphAPI } from "./utils/mock-api";
+
+const ARTIFACT_PATH = "/artifact-fixtures/report.html";
+const THREAD_ID = "00000000-0000-0000-0000-000000003125";
+
+function writeFileMessages() {
+ return [
+ {
+ type: "human",
+ id: "msg-human-artifact",
+ content: [{ type: "text", text: "Create a report artifact" }],
+ },
+ {
+ type: "ai",
+ id: "msg-ai-write-artifact",
+ content: "",
+ tool_calls: [
+ {
+ id: "write-file-artifact",
+ name: "write_file",
+ args: {
+ description: "Writing report artifact",
+ path: ARTIFACT_PATH,
+ content:
+ "Report draft ",
+ },
+ },
+ ],
+ },
+ {
+ type: "tool",
+ id: "msg-tool-write-artifact",
+ name: "write_file",
+ tool_call_id: "write-file-artifact",
+ content: "OK",
+ },
+ ];
+}
+
+async function panelWidth(panel: Locator): Promise {
+ const box = await panel.boundingBox();
+ return box?.width ?? 0;
+}
+
+/** Drag the divider left by `distance` px, widening the right panel. */
+async function widenPanel(handle: Locator, distance: number): Promise {
+ // hover() waits for a stable bounding box: the open animation moves the
+ // 1px-wide divider, so coordinates read any earlier miss it entirely.
+ await handle.hover();
+ await expect(handle).toHaveAttribute("data-separator", "hover");
+
+ const box = await handle.boundingBox();
+ expect(box).not.toBeNull();
+ const y = box!.y + box!.height / 2;
+ const x = box!.x + box!.width / 2;
+
+ const mouse = handle.page().mouse;
+ await mouse.down();
+ await expect(handle).toHaveAttribute("data-separator", "active");
+ await mouse.move(x - distance, y, { steps: 10 });
+ await mouse.up();
+}
+
+async function openArtifact(page: Page): Promise {
+ await expect(page.getByText(ARTIFACT_PATH)).toBeVisible({ timeout: 15_000 });
+ await page.getByText(ARTIFACT_PATH).click();
+}
+
+test.describe("Artifacts panel resize", () => {
+ test.beforeEach(async ({ page }) => {
+ mockLangGraphAPI(page, {
+ threads: [
+ {
+ thread_id: THREAD_ID,
+ title: "Artifact panel resize",
+ messages: writeFileMessages(),
+ },
+ ],
+ });
+ await page.goto(`/workspace/chats/${THREAD_ID}`);
+ });
+
+ test("the divider resizes the artifacts panel", async ({ page }) => {
+ await openArtifact(page);
+
+ const artifactsPanel = page.locator("#artifacts");
+ await expect(artifactsPanel).toBeVisible();
+
+ const handle = page.locator('[data-slot="resizable-handle"]');
+ await expect(handle).toBeVisible();
+ await handle.hover();
+
+ const widthBefore = await panelWidth(artifactsPanel);
+ expect(widthBefore).toBeGreaterThan(0);
+
+ await widenPanel(handle, 200);
+
+ await expect
+ .poll(async () => panelWidth(artifactsPanel))
+ .toBeGreaterThan(widthBefore + 100);
+ });
+
+ test("a dragged width is kept when the panel is reopened", async ({
+ page,
+ }) => {
+ await openArtifact(page);
+
+ const artifactsPanel = page.locator("#artifacts");
+ await expect(artifactsPanel).toBeVisible();
+
+ const handle = page.locator('[data-slot="resizable-handle"]');
+ await handle.hover();
+ const widthBefore = await panelWidth(artifactsPanel);
+ await widenPanel(handle, 200);
+ await expect
+ .poll(async () => panelWidth(artifactsPanel))
+ .toBeGreaterThan(widthBefore + 100);
+ const widthAfterDrag = await panelWidth(artifactsPanel);
+
+ await artifactsPanel
+ .getByRole("button", { name: /close/i })
+ .first()
+ .click();
+ await expect(artifactsPanel).toBeHidden();
+
+ await openArtifact(page);
+ await expect(artifactsPanel).toBeVisible();
+
+ await expect
+ .poll(async () => panelWidth(artifactsPanel))
+ .toBeGreaterThan(widthAfterDrag - 20);
+ });
+ test("opening animates the width, dragging does not", async ({ page }) => {
+ await expect(page.getByText(ARTIFACT_PATH)).toBeVisible({
+ timeout: 15_000,
+ });
+
+ // Listen for the transition instead of sampling widths: `transitionrun`
+ // fires when the transition is created, so a loaded machine dropping
+ // frames cannot turn a real animation into a missed one.
+ await page.evaluate(() => {
+ const store = window as unknown as { __transitions?: string[] };
+ store.__transitions = [];
+ document.addEventListener(
+ "transitionrun",
+ (event) => {
+ store.__transitions?.push(event.propertyName);
+ },
+ true,
+ );
+ });
+
+ await page.getByText(ARTIFACT_PATH).click();
+
+ await expect
+ .poll(async () =>
+ page.evaluate(
+ () =>
+ (window as unknown as { __transitions: string[] }).__transitions,
+ ),
+ )
+ .toContain("flex-grow");
+
+ const artifactsPanel = page.locator("#artifacts");
+ const handle = page.locator('[data-slot="resizable-handle"]');
+ await expect(artifactsPanel).toBeVisible();
+ await handle.hover();
+
+ const transitionDuringDrag = await (async () => {
+ const box = await handle.boundingBox();
+ const mouse = page.mouse;
+ await mouse.down();
+ await mouse.move(box!.x - 120, box!.y + box!.height / 2, { steps: 5 });
+ const transition = await page.evaluate(() => {
+ // `[data-panel]` is the element the library sizes; the child that
+ // `className` lands on is not the flex item.
+ const panel = document
+ .querySelector("#artifacts")
+ ?.closest("[data-panel]");
+ return panel
+ ? window.getComputedStyle(panel).transitionProperty
+ : "missing";
+ });
+ await mouse.up();
+ return transition;
+ })();
+
+ expect(transitionDuringDrag).not.toContain("flex-grow");
+ });
+});
From e17aff57a0fae24e51389a3ac40b60dc3a50a600 Mon Sep 17 00:00:00 2001
From: DeepCold
Date: Sun, 26 Jul 2026 21:34:50 +0800
Subject: [PATCH 29/35] fix(frontend): allow dev-server access from
non-localhost hosts (#4471)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Opening the dev stack on a LAN address or a proxied hostname serves the
SSR HTML but never hydrates: Next.js answers /_next/*, /__nextjs_font/*,
and HMR with 403 for any host it was not started on. The page renders, so
it looks up — but no client handler is attached, and the login form's
onSubmit never fires. It reads as "login is broken" rather than as an
asset problem, and the only clue is a warning in the dev-server log.
Wire Next's allowedDevOrigins to a new DEER_FLOW_DEV_ALLOWED_ORIGINS env
var. Unset by default, so the localhost-only default is unchanged; it is
also dev-only, as Next ignores allowedDevOrigins in production builds.
Entries are reduced to the bare host that allowedDevOrigins matches
against, since an entry pasted from the address bar as
"http://192.168.1.10:2026" would otherwise match nothing and leave the
operator with the same 403 they were trying to fix.
Reported in #54 and #203.
Co-authored-by: Claude Opus 5 (1M context)
---
frontend/.env.example | 9 ++++
frontend/AGENTS.md | 2 +
frontend/next.config.js | 2 +
frontend/src/dev-origins.js | 59 +++++++++++++++++++++
frontend/tests/unit/dev-origins.test.ts | 69 +++++++++++++++++++++++++
5 files changed, 141 insertions(+)
create mode 100644 frontend/src/dev-origins.js
create mode 100644 frontend/tests/unit/dev-origins.test.ts
diff --git a/frontend/.env.example b/frontend/.env.example
index aeb458e5b..afd5b3cfb 100644
--- a/frontend/.env.example
+++ b/frontend/.env.example
@@ -20,3 +20,12 @@
# Defaults to localhost — only override for non-local deployments.
# DEER_FLOW_INTERNAL_GATEWAY_BASE_URL="http://localhost:8001"
# DEER_FLOW_TRUSTED_ORIGINS="http://localhost:3000,http://localhost:2026"
+
+# Extra hosts allowed to load Next.js dev-server resources (`/_next/*`, fonts,
+# HMR). Development only — production builds ignore it.
+# Needed to open `pnpm dev` / `make docker-start` on anything other than
+# localhost, such as a LAN address or a proxied hostname: without it Next.js
+# answers those requests with 403, so the page renders server-side but never
+# hydrates and no interaction (including the login form) works.
+# Comma-separated; a full URL is reduced to its host.
+# DEER_FLOW_DEV_ALLOWED_ORIGINS="192.168.1.10,*.local"
diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md
index 33ba03568..1040ceed8 100644
--- a/frontend/AGENTS.md
+++ b/frontend/AGENTS.md
@@ -128,6 +128,8 @@ NEXT_PUBLIC_LANGGRAPH_BASE_URL=http://localhost:8001/api
Leave these unset for the standard `make dev` / Docker flow, where nginx serves the public `/api/langgraph/*` prefix and rewrites it to Gateway's native `/api/*` routes.
+To reach a dev server on anything other than localhost — a LAN address, or a proxied hostname — list the host in `DEER_FLOW_DEV_ALLOWED_ORIGINS` (comma-separated; a full URL is reduced to its host). It feeds Next's `allowedDevOrigins`, which gates `/_next/*`, fonts, and HMR. Without it those requests get a 403 and the page renders server-side but never hydrates, so nothing on it — including the login form — responds. Development only; production builds ignore it.
+
## Resources
- [LangGraph Documentation](https://langchain-ai.github.io/langgraph/)
diff --git a/frontend/next.config.js b/frontend/next.config.js
index 7007d59fc..c1b984c2f 100644
--- a/frontend/next.config.js
+++ b/frontend/next.config.js
@@ -3,6 +3,7 @@
* for Docker builds.
*/
import "./src/env.js";
+import { getAllowedDevOrigins } from "./src/dev-origins.js";
function getInternalServiceURL(envKey, fallbackURL) {
const configured = process.env[envKey]?.trim();
@@ -25,6 +26,7 @@ const config = {
defaultLocale: "en",
},
devIndicators: false,
+ allowedDevOrigins: getAllowedDevOrigins(),
async rewrites() {
const rewrites = [];
const gatewayURL = getInternalServiceURL(
diff --git a/frontend/src/dev-origins.js b/frontend/src/dev-origins.js
new file mode 100644
index 000000000..b092a9a6d
--- /dev/null
+++ b/frontend/src/dev-origins.js
@@ -0,0 +1,59 @@
+/**
+ * Hosts allowed to load Next.js dev-server resources (`/_next/*`,
+ * `/__nextjs_font/*`, HMR) from an origin other than the one `pnpm dev` was
+ * started on.
+ *
+ * Next.js answers those requests with 403 unless the host is listed, so a dev
+ * stack opened on a LAN address or a proxied hostname serves the SSR HTML but
+ * never hydrates: the page renders and nothing on it responds.
+ *
+ * Dev-only — Next ignores `allowedDevOrigins` in production builds.
+ */
+
+/**
+ * Reduce one entry to the bare host `allowedDevOrigins` matches against.
+ *
+ * Next matches on host alone, so an entry that still carries a scheme, port, or
+ * path matches nothing and leaves the caller with the same 403 they were trying
+ * to fix. Accept the URL people naturally copy out of the address bar.
+ *
+ * @param {string} value
+ * @returns {string} bare host, or `""` if the entry was empty
+ */
+function normalizeHost(value) {
+ let host = value.trim();
+ if (!host) return "";
+
+ host = host.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "");
+ host = host.replace(/[/?#].*$/, "");
+
+ const bracketedIpv6 = /^\[([^\]]+)\](?::\d+)?$/.exec(host);
+ if (bracketedIpv6) return bracketedIpv6[1];
+
+ // A bare IPv6 literal has several colons and no port to strip; only a single
+ // colon can be a `host:port` separator.
+ if ((host.match(/:/g) ?? []).length === 1) {
+ host = host.replace(/:\d+$/, "");
+ }
+ return host;
+}
+
+/**
+ * Parse a comma-separated host list into the shape `allowedDevOrigins` expects.
+ *
+ * @param {string | undefined} raw
+ * @returns {string[]}
+ */
+export function parseAllowedDevOrigins(raw) {
+ return (raw ?? "").split(",").map(normalizeHost).filter(Boolean);
+}
+
+/**
+ * Read the configured hosts from the environment.
+ *
+ * @param {Record} [env]
+ * @returns {string[]}
+ */
+export function getAllowedDevOrigins(env = process.env) {
+ return parseAllowedDevOrigins(env.DEER_FLOW_DEV_ALLOWED_ORIGINS);
+}
diff --git a/frontend/tests/unit/dev-origins.test.ts b/frontend/tests/unit/dev-origins.test.ts
new file mode 100644
index 000000000..382772330
--- /dev/null
+++ b/frontend/tests/unit/dev-origins.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, test } from "@rstest/core";
+
+import { getAllowedDevOrigins, parseAllowedDevOrigins } from "@/dev-origins";
+
+describe("parseAllowedDevOrigins", () => {
+ test("returns an empty list when unset or empty", () => {
+ expect(parseAllowedDevOrigins(undefined)).toEqual([]);
+ expect(parseAllowedDevOrigins("")).toEqual([]);
+ expect(parseAllowedDevOrigins(" ")).toEqual([]);
+ });
+
+ test("splits a comma-separated list and trims each entry", () => {
+ expect(parseAllowedDevOrigins(" 192.168.1.10 , dev.example.com ")).toEqual([
+ "192.168.1.10",
+ "dev.example.com",
+ ]);
+ });
+
+ test("drops empty entries from trailing or doubled commas", () => {
+ expect(parseAllowedDevOrigins("a.example,,b.example,")).toEqual([
+ "a.example",
+ "b.example",
+ ]);
+ });
+
+ test("reduces a pasted URL to the bare host Next matches on", () => {
+ expect(parseAllowedDevOrigins("http://192.168.1.10:2026")).toEqual([
+ "192.168.1.10",
+ ]);
+ expect(parseAllowedDevOrigins("https://dev.example.com/")).toEqual([
+ "dev.example.com",
+ ]);
+ expect(parseAllowedDevOrigins("http://dev.example.com/login?x=1")).toEqual([
+ "dev.example.com",
+ ]);
+ });
+
+ test("preserves wildcard patterns", () => {
+ expect(parseAllowedDevOrigins("*.local, *.example.com")).toEqual([
+ "*.local",
+ "*.example.com",
+ ]);
+ });
+
+ test("strips the port from a bracketed IPv6 host without mangling the address", () => {
+ expect(parseAllowedDevOrigins("[::1]:2026")).toEqual(["::1"]);
+ expect(parseAllowedDevOrigins("http://[fe80::1]:3000")).toEqual([
+ "fe80::1",
+ ]);
+ });
+
+ test("leaves a bare IPv6 literal intact", () => {
+ // Several colons and no port to strip — treating the last group as a port
+ // would corrupt the address.
+ expect(parseAllowedDevOrigins("fe80::1")).toEqual(["fe80::1"]);
+ });
+});
+
+describe("getAllowedDevOrigins", () => {
+ test("reads DEER_FLOW_DEV_ALLOWED_ORIGINS", () => {
+ expect(
+ getAllowedDevOrigins({ DEER_FLOW_DEV_ALLOWED_ORIGINS: "192.168.1.10" }),
+ ).toEqual(["192.168.1.10"]);
+ });
+
+ test("defaults to an empty list, keeping localhost-only the default", () => {
+ expect(getAllowedDevOrigins({})).toEqual([]);
+ });
+});
From 1c7531242cabe5593788843d7212f01dbff1a22f Mon Sep 17 00:00:00 2001
From: Vanzeren <53075619+Vanzeren@users.noreply.github.com>
Date: Sun, 26 Jul 2026 21:45:47 +0800
Subject: [PATCH 30/35] feat(runtime): record terminal artifact delivery
receipts (slice 1 of #4272) (#4365)
* feat(runtime): record terminal artifact delivery receipts (#4272)
* fix(runtime): persist delivery receipts across recovery
* test(runtime): cover delivery receipt invariants
* fix(runtime): preserve terminal status on receipt outages
---
README.md | 13 +-
backend/AGENTS.md | 42 +-
backend/app/gateway/deps.py | 15 +-
backend/docs/CONFIGURATION.md | 2 +-
.../deerflow/runtime/events/store/base.py | 20 +
.../deerflow/runtime/events/store/db.py | 53 +++
.../deerflow/runtime/events/store/jsonl.py | 30 ++
.../deerflow/runtime/events/store/memory.py | 29 ++
.../harness/deerflow/runtime/journal.py | 64 ++++
.../harness/deerflow/runtime/runs/manager.py | 55 ++-
.../harness/deerflow/runtime/runs/worker.py | 162 ++++++--
.../blocking_io/test_jsonl_run_event_store.py | 19 +-
.../blocking_io/test_run_journal_callbacks.py | 39 ++
backend/tests/test_gateway_run_recovery.py | 10 +-
.../tests/test_multi_worker_postgres_gate.py | 43 ++-
backend/tests/test_run_event_store.py | 53 +++
backend/tests/test_run_journal.py | 274 +++++++++++++
backend/tests/test_run_manager.py | 61 +++
backend/tests/test_run_worker_delivery.py | 362 ++++++++++++++++++
19 files changed, 1310 insertions(+), 36 deletions(-)
create mode 100644 backend/tests/blocking_io/test_run_journal_callbacks.py
create mode 100644 backend/tests/test_run_worker_delivery.py
diff --git a/README.md b/README.md
index 5c802a919..708230039 100644
--- a/README.md
+++ b/README.md
@@ -275,7 +275,7 @@ Browser login uses `HttpOnly` session cookies. The login page offers a "keep me
DeerFlow still uses `Forwarded` / `X-Forwarded-*` headers to recover the browser-facing scheme and origin behind a proxy. The bundled nginx sets `X-Forwarded-Proto`, but preserves an upstream HTTPS value and does not overwrite every forwarded header. Configure the outer trusted proxy to replace or strip client-supplied forwarding headers before traffic reaches DeerFlow.
> [!IMPORTANT]
-> The Gateway still owns active run tasks in process, so production defaults to a single Gateway worker (`GATEWAY_WORKERS=1`). Multi-worker deployments require Postgres, the Redis stream bridge (`stream_bridge.type: redis`), and `run_ownership.heartbeat_enabled: true`. The bridge shares SSE delivery and `Last-Event-ID` replay across workers, while lease reconciliation marks runs from dead workers as errors, publishes the terminal stream marker, schedules retained-stream cleanup, and updates the affected thread status. SSE and `/wait` consumers also refresh durable status on heartbeats as a fallback if terminal publication fails. The rolling retained-buffer TTL (`stream_ttl_seconds`) remains a cleanup safety net rather than a run timeout. IM channel state and other process-local services still need their own multi-worker coordination.
+> The Gateway still owns active run tasks in process, so production defaults to a single Gateway worker (`GATEWAY_WORKERS=1`). Multi-worker deployments require Postgres, the Redis stream bridge (`stream_bridge.type: redis`), `run_ownership.heartbeat_enabled: true`, and `run_events.backend: db`; process-local memory/JSONL event stores cannot enforce singleton delivery receipts across workers. The bridge shares SSE delivery and `Last-Event-ID` replay across workers, while lease reconciliation marks runs from dead workers as errors, persists their delivery receipts, publishes the terminal stream marker, schedules retained-stream cleanup, and updates the affected thread status. SSE and `/wait` consumers also refresh durable status on heartbeats as a fallback if terminal publication fails. Malformed reconnect IDs live-tail new events instead of replaying the retained buffer, and the rolling retained-buffer TTL (`stream_ttl_seconds`) remains a cleanup safety net rather than a run timeout. IM channel state and other process-local services still need their own multi-worker coordination.
>
> Reconciliation uses an atomic takeover claim that re-checks the lease after candidate selection, so a successful owner renewal wins over orphan recovery and only one reconciler can report a run as recovered.
@@ -574,6 +574,17 @@ logging:
When enabled, every Gateway HTTP response includes `X-Trace-Id`, logs include `trace_id`, and Langfuse traces created by that request include `metadata.deerflow_trace_id` with the same value.
+Gateway run history also records one terminal `run.delivery` receipt per run,
+including zero-output and crash-recovered runs. The receipt is persisted before
+the durable terminal run status during normal execution. Orphan recovery first
+atomically claims an expired lease and then idempotently backfills the receipt,
+so a stale recovery scan cannot overwrite a live run's detailed delivery facts.
+Receipt persistence remains best-effort during an event-store outage. Runs that
+fail checkpoint preflight (or are cancelled while waiting for prior
+finalization) keep the existing completion-data behavior: they receive the
+zero-delivery receipt but do not overwrite RunStore completion fields with an
+empty snapshot.
+
#### LangSmith Tracing
DeerFlow has built-in [LangSmith](https://smith.langchain.com) integration for observability. When enabled, all LLM calls, agent runs, and tool executions are traced and visible in the LangSmith dashboard.
diff --git a/backend/AGENTS.md b/backend/AGENTS.md
index c31e058bd..7e72d57d0 100644
--- a/backend/AGENTS.md
+++ b/backend/AGENTS.md
@@ -219,7 +219,9 @@ Blocking-IO runtime gate (`tests/blocking_io/`):
for #1917); `test_sqlite_lifespan.py` (locks the offload around
SQLite path resolution plus `ensure_sqlite_parent_dir`, fix for #1912);
`test_jsonl_run_event_store.py` (locks `JsonlRunEventStore`'s async
- API offloading its file IO via `asyncio.to_thread`);
+ API — including idempotent singleton-event writes — offloading its file IO
+ via `asyncio.to_thread`); `test_run_journal_callbacks.py` (locks
+ `RunJournal.run_inline` tool callbacks to in-memory/event-loop-safe work);
`test_integrations_router.py` (locks Lark integration install and auth
completion route handlers offloading archive filesystem work and `lark-cli`
subprocesses);
@@ -451,6 +453,44 @@ captures a pre-run and post-run snapshot of the thread-owned `workspace` and
are size-limited; binary, large, and sensitive-looking paths are persisted as
metadata only.
+**Run delivery receipts**: `RunJournal` records each non-empty artifact update
+once per tool `Command` for the terminal `run.delivery` event. When a command
+contains multiple messages, a unique tool name resolved from matching
+`ToolMessage` entries supplies attribution; additional command messages do not
+duplicate artifact paths or counts. If multiple different tool names resolve
+for one flat artifact update, the paths remain counted but unattributed because
+the command does not carry a per-path mapping. `RunJournal` callbacks set
+`run_inline=True`: they do only in-memory bookkeeping or schedule async writes,
+and staying on the run's event-loop thread serializes parallel tool callbacks
+before terminal delivery recording and flushing. Each worker creates a separate
+journal per run before cancellable/fallible preflight work, so checkpoint
+compatibility failures and cancellation while waiting for prior finalization
+still emit a zero-delivery receipt. The worker flushes ordinary journal events,
+idempotently persists the run-scoped receipt, and only then persists the staged
+terminal run status. A receipt failure is retried on a short bounded schedule
+while the owning worker still knows the real outcome and holds the lease. If
+all attempts fail, the worker persists that real terminal status instead of
+leaving a successful run inflight for orphan recovery to rewrite as an error;
+the receipt remains best-effort in that outage case. Orphan recovery first
+atomically claims an expired lease, then uses the same singleton write to
+backfill a zero-delivery receipt. This ordering prevents a stale recovery scan
+from overwriting a live run's later detailed receipt; an event-store outage
+does not undo the terminal takeover. An existing detailed receipt is preserved
+when a worker crashed after writing it. Event stores
+serialize `put_if_absent` with ordinary thread writers: memory and JSONL provide
+the documented single-process guarantee, while the DB store adds per-thread
+in-process locks and PostgreSQL advisory locks for cross-process writers.
+Moving journal construction ahead of preflight is receipt-only on early failure
+paths: a separate boundary flag preserves the previous completion-data
+semantics, so checkpoint incompatibility or cancellation while waiting for an
+older finalizing run does not persist an empty completion snapshot. Worker tests
+pin one accumulated receipt across multiple goal-continuation `_stream_once`
+calls; journal tests drive LangChain's real async callback dispatcher against a
+single journal to pin serialized, deduplicated parallel tool callbacks.
+Multi-worker deployments therefore require `run_events.backend: db` for shared,
+ordered delivery events; the startup gate rejects process-local memory and
+JSONL event stores when `GATEWAY_WORKERS > 1`.
+
**RunManager / RunStore contract**:
- LangGraph-compatible run requests validate their supported subset before creating a run. `runtime/stream_modes.py` is the shared backend contract for public stream modes and the worker's `graph.astream` mapping; the public `messages-tuple` mode maps to LangGraph's internal `messages` mode, while public `messages`, `events`, and other unsupported modes are rejected instead of being dropped or replaced with `values`. `app/gateway/run_models.py::RunCreateRequest` is shared by HTTP and internal scheduled launch paths, retains only truthful compatibility defaults for unimplemented options (`if_not_exists="create"` plus `None` placeholders), returns 422 for unsupported values including `on_completion="complete"`, `on_completion="continue"`, and `multitask_strategy="enqueue"`, and forbids undeclared SDK options so fields such as `checkpoint_during` and `durability` cannot be silently discarded. A placeholder must still accept the stock SDK's own default: `langgraph_sdk` drops only `None` from its run payload, so `stream_resumable=False` reaches every request and means "non-resumable", which is what DeerFlow serves — rejecting it 422'd every IM channel run (#4466). `tests/test_run_request_validation.py::test_gateway_accepts_langgraph_sdk_default_payload` pins the real SDK payload against this boundary; channel tests mock the SDK client and cannot catch this class of drift.
- `RunManager.get()` is async; direct callers must `await` it.
diff --git a/backend/app/gateway/deps.py b/backend/app/gateway/deps.py
index 9946f5c3f..9c1afefa2 100644
--- a/backend/app/gateway/deps.py
+++ b/backend/app/gateway/deps.py
@@ -58,14 +58,16 @@ def _browser_tools_enabled_in_config(config: AppConfig) -> bool:
def _enforce_postgres_for_multi_worker(config: AppConfig) -> None:
"""Refuse unsafe multi-worker configurations before persistence starts.
- Three checks (all must pass for multi-worker):
+ Four checks (all must pass for multi-worker):
1. Process-local browser sessions must be disabled. Browser tools keep
Chromium and Playwright objects in one worker's memory, while ordinary
uvicorn dispatch provides no thread-id affinity.
2. The DB backend must be Postgres — SQLite write-locks cannot support
concurrent multi-process access.
- 3. ``run_ownership.heartbeat_enabled`` must be True — without heartbeat,
+ 3. ``run_events.backend`` must be ``db``. Memory and JSONL stores are
+ process-local, so workers cannot enforce a shared singleton receipt.
+ 4. ``run_ownership.heartbeat_enabled`` must be True — without heartbeat,
every run has a NULL lease, so reconciliation treats all inflight
runs as orphans and Worker B would kill Worker A's live runs on
every rolling update or scale-up.
@@ -89,6 +91,14 @@ def _enforce_postgres_for_multi_worker(config: AppConfig) -> None:
if backend != "postgres":
raise SystemExit(f"GATEWAY_WORKERS={workers} requires database.backend='postgres', but database.backend is '{backend}'. SQLite cannot support concurrent multi-process access. Set GATEWAY_WORKERS=1 or switch to Postgres.")
+ run_events_backend = getattr(getattr(config, "run_events", None), "backend", None)
+ if run_events_backend != "db":
+ raise SystemExit(
+ f"GATEWAY_WORKERS={workers} requires run_events.backend='db', but run_events.backend is '{run_events_backend}'. "
+ "Memory and JSONL event stores are process-local, so delivery receipt singleton guarantees cannot hold across workers. "
+ "Set GATEWAY_WORKERS=1 or configure run_events.backend: db."
+ )
+
run_ownership = getattr(config, "run_ownership", None)
if run_ownership is None or not run_ownership.heartbeat_enabled:
raise SystemExit(
@@ -448,6 +458,7 @@ async def langgraph_runtime(app: FastAPI, startup_config: AppConfig) -> AsyncGen
app.state.run_manager = RunManager(
store=app.state.run_store,
run_ownership_config=run_ownership_config,
+ event_store=app.state.run_event_store,
on_orphans_recovered=terminalize_recovered_runs,
)
# Startup recovery: mark inflight runs whose lease has expired as error.
diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md
index bf58c8282..17d23a5a3 100644
--- a/backend/docs/CONFIGURATION.md
+++ b/backend/docs/CONFIGURATION.md
@@ -248,7 +248,7 @@ Notes:
- `enabled: false` keeps background polling off by default.
- `max_concurrent_runs` is a global cap on active scheduled runs (queued/running run rows); each poll cycle claims only into the remaining budget, so long runs accumulating across cycles cannot exceed it.
- All scheduler fields are restart-required; edits need a Gateway restart.
-- Multi-worker deployments (`GATEWAY_WORKERS > 1`) must use the Postgres database backend. SQLite silently ignores row-level locks, so multiple workers can double-fire the same task. The process-local agentic browser tool group is incompatible with multiple Gateway workers; keep `GATEWAY_WORKERS=1` while `browser_navigate` is enabled. Browser control also requires the backend `browser` extra (`cd backend && uv sync --extra browser && uv run playwright install chromium`); startup detects enabled browser config and fails fast when Playwright is missing, and `/api/features` reports `browser_control.enabled=false` until the runtime is available.
+- Multi-worker deployments (`GATEWAY_WORKERS > 1`) must use the Postgres database backend, enable run ownership heartbeats, and set `run_events.backend: db`. SQLite silently ignores row-level locks, while memory and JSONL run-event stores are process-local and cannot enforce singleton delivery receipts across workers; startup rejects these combinations. The process-local agentic browser tool group is incompatible with multiple Gateway workers; keep `GATEWAY_WORKERS=1` while `browser_navigate` is enabled. Browser control also requires the backend `browser` extra (`cd backend && uv sync --extra browser && uv run playwright install chromium`); startup detects enabled browser config and fails fast when Playwright is missing, and `/api/features` reports `browser_control.enabled=false` until the runtime is available.
- The MVP supports thread reuse and fresh-thread-per-run execution modes.
- The MVP supports only `once` and `cron`.
- Manual trigger uses the same scheduled-task resource and run lifecycle.
diff --git a/backend/packages/harness/deerflow/runtime/events/store/base.py b/backend/packages/harness/deerflow/runtime/events/store/base.py
index af4858310..df552e40d 100644
--- a/backend/packages/harness/deerflow/runtime/events/store/base.py
+++ b/backend/packages/harness/deerflow/runtime/events/store/base.py
@@ -51,6 +51,26 @@ class RunEventStore(abc.ABC):
Returns complete records with seq assigned.
"""
+ @abc.abstractmethod
+ async def put_if_absent(
+ self,
+ *,
+ thread_id: str,
+ run_id: str,
+ event_type: str,
+ category: str,
+ content: str | dict = "",
+ metadata: dict | None = None,
+ created_at: str | None = None,
+ ) -> tuple[dict, bool]:
+ """Write one event unless this run already has the same event type.
+
+ The check and write must be serialized with ordinary writers for the
+ thread. Returns ``(record, created)``. This is the durability primitive
+ used by terminal run receipts, whose recovery path may safely retry
+ after a worker crash.
+ """
+
@abc.abstractmethod
async def list_messages(
self,
diff --git a/backend/packages/harness/deerflow/runtime/events/store/db.py b/backend/packages/harness/deerflow/runtime/events/store/db.py
index e365a976b..2227fd725 100644
--- a/backend/packages/harness/deerflow/runtime/events/store/db.py
+++ b/backend/packages/harness/deerflow/runtime/events/store/db.py
@@ -194,6 +194,59 @@ class DbRunEventStore(RunEventStore):
rows.append(row)
return [self._row_to_dict(r) for r in rows]
+ async def put_if_absent(
+ self,
+ *,
+ thread_id,
+ run_id,
+ event_type,
+ category,
+ content="",
+ metadata=None,
+ created_at=None,
+ ):
+ """Idempotently insert a run-scoped singleton event.
+
+ ``_max_seq_for_thread`` takes the same PostgreSQL advisory lock used by
+ every normal writer (and the in-process lock covers SQLite), so the
+ existence check cannot race another ``put_if_absent`` or journal write.
+ Terminal delivery receipts use this method on both the worker and
+ recovery paths; ordinary event types remain append-only.
+ """
+ content, metadata = self._truncate_trace(category, content, metadata)
+ db_content, metadata = self._content_to_db(content, metadata)
+ user_id = self._user_id_from_context()
+ async with self._get_write_lock(thread_id):
+ async with self._sf() as session:
+ async with session.begin():
+ max_seq = await self._max_seq_for_thread(session, thread_id)
+ stmt = (
+ select(RunEventRow)
+ .where(
+ RunEventRow.thread_id == thread_id,
+ RunEventRow.run_id == run_id,
+ RunEventRow.event_type == event_type,
+ )
+ .order_by(RunEventRow.seq.asc())
+ .limit(1)
+ )
+ existing = await session.scalar(stmt)
+ if existing is not None:
+ return self._row_to_dict(existing), False
+ row = RunEventRow(
+ thread_id=thread_id,
+ run_id=run_id,
+ user_id=user_id,
+ event_type=event_type,
+ category=category,
+ content=db_content,
+ event_metadata=metadata,
+ seq=(max_seq or 0) + 1,
+ created_at=datetime.fromisoformat(created_at) if created_at else datetime.now(UTC),
+ )
+ session.add(row)
+ return self._row_to_dict(row), True
+
async def list_messages(
self,
thread_id,
diff --git a/backend/packages/harness/deerflow/runtime/events/store/jsonl.py b/backend/packages/harness/deerflow/runtime/events/store/jsonl.py
index cbca8c8f5..a1af143b6 100644
--- a/backend/packages/harness/deerflow/runtime/events/store/jsonl.py
+++ b/backend/packages/harness/deerflow/runtime/events/store/jsonl.py
@@ -178,6 +178,36 @@ class JsonlRunEventStore(RunEventStore):
results.extend(records)
return results
+ async def put_if_absent(
+ self,
+ *,
+ thread_id,
+ run_id,
+ event_type,
+ category,
+ content="",
+ metadata=None,
+ created_at=None,
+ ):
+ async with self._get_write_lock(thread_id):
+ existing = await asyncio.to_thread(self._read_run_events, thread_id, run_id)
+ for event in existing:
+ if event.get("event_type") == event_type:
+ return event, False
+ await self._ensure_seq_loaded(thread_id)
+ record = {
+ "thread_id": thread_id,
+ "run_id": run_id,
+ "event_type": event_type,
+ "category": category,
+ "content": content,
+ "metadata": metadata or {},
+ "seq": self._next_seq(thread_id),
+ "created_at": created_at or datetime.now(UTC).isoformat(),
+ }
+ await asyncio.to_thread(self._write_record, record)
+ return record, True
+
async def _write_batch_async(self, thread_id: str, batch: list[dict[str, Any]]) -> list[dict[str, Any]]:
async with self._get_write_lock(thread_id):
await self._ensure_seq_loaded(thread_id)
diff --git a/backend/packages/harness/deerflow/runtime/events/store/memory.py b/backend/packages/harness/deerflow/runtime/events/store/memory.py
index da5ce7ba2..113792560 100644
--- a/backend/packages/harness/deerflow/runtime/events/store/memory.py
+++ b/backend/packages/harness/deerflow/runtime/events/store/memory.py
@@ -93,6 +93,35 @@ class MemoryRunEventStore(RunEventStore):
results.append(record)
return results
+ async def put_if_absent(
+ self,
+ *,
+ thread_id,
+ run_id,
+ event_type,
+ category,
+ content="",
+ metadata=None,
+ created_at=None,
+ ):
+ # No await occurs between the lookup and append, so this is atomic for
+ # the backend's documented single-event-loop concurrency model.
+ for event in self._events_by_run.get(thread_id, {}).get(run_id, []):
+ if event["event_type"] == event_type:
+ return event, False
+ return (
+ self._put_one(
+ thread_id=thread_id,
+ run_id=run_id,
+ event_type=event_type,
+ category=category,
+ content=content,
+ metadata=metadata,
+ created_at=created_at,
+ ),
+ True,
+ )
+
async def list_messages(self, thread_id, *, limit=50, before_seq=None, after_seq=None, user_id: str | None | _AutoSentinel = AUTO):
# ``messages`` is messages-only and seq-sorted, so the seq window is a
# contiguous slice located with bisect (O(log m)) rather than a full scan.
diff --git a/backend/packages/harness/deerflow/runtime/journal.py b/backend/packages/harness/deerflow/runtime/journal.py
index 2112fcffc..0fb232aac 100644
--- a/backend/packages/harness/deerflow/runtime/journal.py
+++ b/backend/packages/harness/deerflow/runtime/journal.py
@@ -178,6 +178,12 @@ def build_branch_history_seed_events(
class RunJournal(BaseCallbackHandler):
"""LangChain callback handler that captures events to RunEventStore."""
+ # Every callback only updates in-memory run state or schedules async IO.
+ # Keeping callbacks on the run's event-loop thread serializes mutations
+ # from parallel tool calls and prevents cancelled executor callbacks from
+ # racing terminal delivery recording and flush.
+ run_inline = True
+
def __init__(
self,
run_id: str,
@@ -242,6 +248,11 @@ class RunJournal(BaseCallbackHandler):
self._current_run_tool_call_names: dict[str, str] = {}
self._persisted_tool_message_identities: set[str] = set()
+ # Artifact-production tracking for the terminal run.delivery event
+ # (#4272 slice 1). Deduped by (path, tool_name); insertion order kept.
+ self._produced_artifacts: list[tuple[str, str | None]] = []
+ self._produced_artifact_keys: set[tuple[str, str | None]] = set()
+
# -- Lifecycle callbacks --
@staticmethod
@@ -489,11 +500,26 @@ class RunJournal(BaseCallbackHandler):
elif isinstance(output, Command):
cmd = cast(Command, output)
messages = cmd.update.get("messages", [])
+ # A non-empty ``artifacts`` update is only produced on the
+ # success path (e.g. present_files returns an error ToolMessage
+ # without touching state when validation fails), so its
+ # presence is the artifact-production signal (#4272 slice 1).
+ artifacts = cmd.update.get("artifacts")
+ artifact_tool_names: set[str] = set()
for message in messages:
if isinstance(message, BaseMessage):
self._persist_tool_result_message(message)
+ if artifacts and isinstance(message, ToolMessage):
+ tool_call_id = getattr(message, "tool_call_id", None)
+ if isinstance(tool_call_id, str):
+ tool_name = self._current_run_tool_call_names.get(tool_call_id)
+ if tool_name:
+ artifact_tool_names.add(tool_name)
else:
logger.warning(f"on_tool_end {run_id}: command update message is not BaseMessage: {type(message)}")
+ if artifacts:
+ artifact_tool_name = next(iter(artifact_tool_names)) if len(artifact_tool_names) == 1 else None
+ self._record_produced_artifacts(artifacts, artifact_tool_name)
else:
logger.warning(f"on_tool_end {run_id}: output is not ToolMessage: {type(output)}")
finally:
@@ -784,6 +810,44 @@ class RunJournal(BaseCallbackHandler):
)
self._memory_context_recorded = True
+ def _record_produced_artifacts(self, artifacts: Any, tool_name: str | None) -> None:
+ """Accumulate produced artifact paths, deduped by (path, tool_name)."""
+ if not isinstance(artifacts, list):
+ return
+ for path in artifacts:
+ if not isinstance(path, str) or not path:
+ continue
+ key = (path, tool_name)
+ if key not in self._produced_artifact_keys:
+ self._produced_artifact_keys.add(key)
+ self._produced_artifacts.append(key)
+
+ def get_delivery_content(self) -> dict[str, Any]:
+ """Return the terminal delivery fact accumulated for this run.
+
+ This is a fact record, not a verdict: runs that produced no artifacts
+ emit ``presented: 0``.
+ """
+ by_tool: dict[str, list[str]] = {}
+ paths: list[str] = []
+ for path, tool_name in self._produced_artifacts:
+ paths.append(path)
+ if tool_name:
+ by_tool.setdefault(tool_name, []).append(path)
+ return {"presented": len(paths), "paths": paths, "by_tool": by_tool}
+
+ def record_delivery(self) -> None:
+ """Buffer the terminal ``run.delivery`` event for this run (#4272 slice 1).
+
+ Kept for direct journal users. The worker uses the event store's
+ idempotent singleton write so crash recovery can safely backfill it.
+ """
+ self._put(
+ event_type="run.delivery",
+ category="outputs",
+ content=self.get_delivery_content(),
+ )
+
async def flush(self) -> None:
"""Force flush remaining buffer. Called in worker's finally block."""
if self._pending_flush_tasks:
diff --git a/backend/packages/harness/deerflow/runtime/runs/manager.py b/backend/packages/harness/deerflow/runtime/runs/manager.py
index 835ed0292..4d816f8d0 100644
--- a/backend/packages/harness/deerflow/runtime/runs/manager.py
+++ b/backend/packages/harness/deerflow/runtime/runs/manager.py
@@ -24,6 +24,7 @@ from .schemas import DisconnectMode, RunStatus, ThreadOperationKind
if TYPE_CHECKING:
from deerflow.config.run_ownership_config import RunOwnershipConfig
+ from deerflow.runtime.events.store.base import RunEventStore
from deerflow.runtime.runs.store.base import RunStore
logger = logging.getLogger(__name__)
@@ -221,6 +222,7 @@ class RunManager:
persistence_retry_policy: PersistenceRetryPolicy | None = None,
worker_id: str | None = None,
run_ownership_config: RunOwnershipConfig | None = None,
+ event_store: RunEventStore | None = None,
on_orphans_recovered: OrphanRecoveryCallback | None = None,
) -> None:
self._runs: dict[str, RunRecord] = {}
@@ -234,6 +236,7 @@ class RunManager:
self._persistence_retry_policy = persistence_retry_policy or PersistenceRetryPolicy()
self._worker_id = worker_id or _generate_worker_id()
self._run_ownership_config = run_ownership_config
+ self._event_store = event_store
self._on_orphans_recovered = on_orphans_recovered
self._heartbeat_task: asyncio.Task | None = None
self._heartbeat_stop: asyncio.Event | None = None
@@ -757,7 +760,15 @@ class RunManager:
logger.warning("Failed to map store row for run %s", run_id, exc_info=True)
return records_by_id
- async def set_status(self, run_id: str, status: RunStatus, *, error: str | None = None, stop_reason: str | None = None) -> None:
+ async def set_status(
+ self,
+ run_id: str,
+ status: RunStatus,
+ *,
+ error: str | None = None,
+ stop_reason: str | None = None,
+ persist: bool = True,
+ ) -> None:
"""Transition a run to a new status."""
async with self._lock:
record = self._runs.get(run_id)
@@ -770,9 +781,43 @@ class RunManager:
record.error = error
if stop_reason is not None:
record.stop_reason = stop_reason
- await self._persist_status(record, status, error=error, stop_reason=stop_reason)
+ if persist:
+ await self._persist_status(record, status, error=error, stop_reason=stop_reason)
logger.info("Run %s -> %s", run_id, status.value)
+ async def persist_current_status(self, run_id: str) -> bool:
+ """Persist the status already staged on the in-memory run record."""
+ async with self._lock:
+ record = self._runs.get(run_id)
+ if record is None:
+ logger.warning("persist_current_status called for unknown run %s", run_id)
+ return False
+ status = record.status
+ error = record.error
+ stop_reason = record.stop_reason
+ return await self._persist_status(record, status, error=error, stop_reason=stop_reason)
+
+ async def _ensure_delivery_receipt(self, record: RunRecord) -> bool:
+ """Idempotently persist a zero-delivery receipt during recovery."""
+ if self._event_store is None:
+ return True
+ try:
+ await self._event_store.put_if_absent(
+ thread_id=record.thread_id,
+ run_id=record.run_id,
+ event_type="run.delivery",
+ category="outputs",
+ content={"presented": 0, "paths": [], "by_tool": {}},
+ )
+ return True
+ except Exception:
+ logger.warning(
+ "Failed to backfill delivery receipt for recovered run %s; preserving its terminal status",
+ record.run_id,
+ exc_info=True,
+ )
+ return False
+
async def set_finalizing(self, run_id: str, finalizing: bool) -> None:
"""Mark whether a run is performing post-cancel cleanup."""
async with self._lock:
@@ -1355,6 +1400,12 @@ class RunManager:
record.stop_reason = stop_reason
record.updated_at = now
if record.operation_kind == ThreadOperationKind.run:
+ # The atomic takeover above must win before writing a zero-delivery
+ # receipt; otherwise a stale scan could race a heartbeat renewal and
+ # permanently overwrite a live run's later detailed receipt. The
+ # receipt remains best-effort, matching normal terminal delivery
+ # when its event store is unavailable.
+ await self._ensure_delivery_receipt(record)
recovered.append(record)
if recovered:
diff --git a/backend/packages/harness/deerflow/runtime/runs/worker.py b/backend/packages/harness/deerflow/runtime/runs/worker.py
index 7faa1794b..cd8cfd17d 100644
--- a/backend/packages/harness/deerflow/runtime/runs/worker.py
+++ b/backend/packages/harness/deerflow/runtime/runs/worker.py
@@ -103,6 +103,57 @@ async def _checkpoint_thread_lock(thread_id: str) -> AsyncIterator[None]:
yield
+_DELIVERY_RECEIPT_RETRY_DELAYS_SECONDS = (0.1, 0.5)
+
+
+async def _persist_delivery_receipt(
+ event_store: Any,
+ *,
+ thread_id: str,
+ run_id: str,
+ content: dict[str, Any],
+) -> bool:
+ """Persist a terminal receipt with short bounded retries.
+
+ The owning worker still knows the real terminal outcome and renews its
+ lease while this coroutine runs. Retrying here handles transient event
+ store failures without handing a successful run to orphan recovery, which
+ cannot reconstruct either the terminal status or the detailed receipt.
+ """
+ attempts = len(_DELIVERY_RECEIPT_RETRY_DELAYS_SECONDS) + 1
+ for attempt in range(attempts):
+ try:
+ await event_store.put_if_absent(
+ thread_id=thread_id,
+ run_id=run_id,
+ event_type="run.delivery",
+ category="outputs",
+ content=content,
+ )
+ return True
+ except Exception:
+ if attempt == attempts - 1:
+ logger.warning(
+ "Failed to persist delivery receipt for run %s after %d attempts; preserving the real terminal status without a receipt",
+ run_id,
+ attempts,
+ exc_info=True,
+ )
+ return False
+ delay = _DELIVERY_RECEIPT_RETRY_DELAYS_SECONDS[attempt]
+ logger.warning(
+ "Failed to persist delivery receipt for run %s (attempt %d/%d); retrying in %.1fs",
+ run_id,
+ attempt + 1,
+ attempts,
+ delay,
+ exc_info=True,
+ )
+ await asyncio.sleep(delay)
+
+ return False # pragma: no cover - loop always returns
+
+
# Keep this streaming policy separate from middleware write-authorization sets.
_LARGE_FILE_TOOL_NAMES = frozenset({"str_replace", "write_file"})
_LARGE_FILE_TOOL_BATCH_SIZE = 32
@@ -393,6 +444,7 @@ async def run_agent(
event_store = ctx.event_store
run_events_config = ctx.run_events_config
thread_store = ctx.thread_store
+ terminal_status_kwargs = {"persist": False} if event_store is not None else {}
run_id = record.run_id
thread_id = record.thread_id
@@ -413,6 +465,12 @@ async def run_agent(
accessor: CheckpointStateAccessor | None = None
rollback_point: RollbackPoint | None = None
journal = None
+ # Journal construction moved ahead of preflight so every terminal run can
+ # emit a receipt. Completion persistence keeps its prior boundary: before
+ # #4272 the journal did not exist until preflight had succeeded, so early
+ # checkpoint failures / cancellation while waiting did not write an empty
+ # completion snapshot into RunStore.
+ persist_completion = False
# Buffers subagent step events for batched persistence (#3779); assigned once
# streaming starts and flushed in the finally block. Pre-bound to None so the
# finally is safe even if an exception fires before streaming begins.
@@ -423,6 +481,22 @@ async def run_agent(
normalized_stream_modes = normalize_stream_modes(stream_modes)
requested_modes: set[str] = set(normalized_stream_modes)
lg_modes = to_langgraph_stream_modes(normalized_stream_modes)
+ # Initialize the run-scoped journal before any fallible or cancellable
+ # preflight work. Every terminal run with an event store must reach the
+ # shared finally block with a journal available for its run.delivery
+ # receipt, including checkpoint validation failures and cancellation
+ # while waiting for an earlier run to finish finalizing.
+ if event_store is not None:
+ from deerflow.runtime.journal import RunJournal
+
+ journal = RunJournal(
+ run_id=run_id,
+ thread_id=thread_id,
+ event_store=event_store,
+ track_token_usage=getattr(run_events_config, "track_token_usage", True),
+ progress_reporter=lambda snapshot: run_manager.update_run_progress(run_id, **snapshot),
+ )
+
await run_manager.wait_for_prior_finalizing(
thread_id,
run_id,
@@ -439,7 +513,6 @@ async def run_agent(
await thread_store.update_status(thread_id, "running")
except Exception:
logger.debug("Failed to update thread_meta status for %s (non-fatal)", thread_id)
-
mode = ctx.checkpoint_channel_mode
inject_checkpoint_mode(config, mode)
checkpoint_config = {
@@ -472,22 +545,7 @@ async def run_agent(
mode,
)
- # Initialize RunJournal + write human_message event.
- # These are inside the try block so any exception (e.g. a DB
- # error writing the event) flows through the except/finally
- # path that publishes an "end" event to the SSE bridge —
- # otherwise a failure here would leave the stream hanging
- # with no terminator.
- if event_store is not None:
- from deerflow.runtime.journal import RunJournal
-
- journal = RunJournal(
- run_id=run_id,
- thread_id=thread_id,
- event_store=event_store,
- track_token_usage=getattr(run_events_config, "track_token_usage", True),
- progress_reporter=lambda snapshot: run_manager.update_run_progress(run_id, **snapshot),
- )
+ persist_completion = True
if event_store is not None:
workspace_changes_user_id = get_effective_user_id()
@@ -731,7 +789,12 @@ async def run_agent(
await run_manager.set_finalizing(run_id, True)
action = record.abort_action
if action == "rollback":
- await run_manager.set_status(run_id, RunStatus.error, error="Rolled back by user")
+ await run_manager.set_status(
+ run_id,
+ RunStatus.error,
+ error="Rolled back by user",
+ **terminal_status_kwargs,
+ )
try:
await _rollback_to_pre_run_checkpoint(
accessor=accessor,
@@ -745,13 +808,22 @@ async def run_agent(
except Exception:
logger.warning("Failed to rollback checkpoint for run %s", run_id, exc_info=True)
else:
- await run_manager.set_status(run_id, RunStatus.interrupted)
+ await run_manager.set_status(
+ run_id,
+ RunStatus.interrupted,
+ **terminal_status_kwargs,
+ )
elif llm_error_fallback_message or (journal is not None and journal.had_llm_error_fallback):
error_msg = llm_error_fallback_message
if error_msg is None and journal is not None:
error_msg = journal.llm_error_fallback_message
error_msg = error_msg or "LLM provider failed after retries"
- await run_manager.set_status(run_id, RunStatus.error, error=error_msg)
+ await run_manager.set_status(
+ run_id,
+ RunStatus.error,
+ error=error_msg,
+ **terminal_status_kwargs,
+ )
else:
runtime_context = runtime.context if isinstance(runtime.context, dict) else None
# Guard middlewares that hard-stop a run by stripping tool_calls
@@ -769,13 +841,23 @@ async def run_agent(
# collects the most severe / first / all reasons) instead of each
# guard writing directly to the same key.
stop_reason = runtime_context.get("stop_reason") if runtime_context is not None else None
- await run_manager.set_status(run_id, RunStatus.success, stop_reason=stop_reason)
+ await run_manager.set_status(
+ run_id,
+ RunStatus.success,
+ stop_reason=stop_reason,
+ **terminal_status_kwargs,
+ )
except asyncio.CancelledError:
await run_manager.set_finalizing(run_id, True)
action = record.abort_action
if action == "rollback":
- await run_manager.set_status(run_id, RunStatus.error, error="Rolled back by user")
+ await run_manager.set_status(
+ run_id,
+ RunStatus.error,
+ error="Rolled back by user",
+ **terminal_status_kwargs,
+ )
try:
await _rollback_to_pre_run_checkpoint(
accessor=accessor,
@@ -789,13 +871,22 @@ async def run_agent(
except Exception:
logger.warning("Run %s cancellation rollback failed", run_id, exc_info=True)
else:
- await run_manager.set_status(run_id, RunStatus.interrupted)
+ await run_manager.set_status(
+ run_id,
+ RunStatus.interrupted,
+ **terminal_status_kwargs,
+ )
logger.info("Run %s was cancelled", run_id)
except Exception as exc:
error_msg = f"{exc}"
logger.exception("Run %s failed: %s", run_id, error_msg)
- await run_manager.set_status(run_id, RunStatus.error, error=error_msg)
+ await run_manager.set_status(
+ run_id,
+ RunStatus.error,
+ error=error_msg,
+ **terminal_status_kwargs,
+ )
await bridge.publish(
run_id,
"error",
@@ -823,13 +914,34 @@ async def run_agent(
except Exception:
logger.warning("Failed to record workspace changes for run %s", run_id, exc_info=True)
- # Flush any buffered journal events and persist completion data
+ # Flush buffered journal events before the terminal receipt. The
+ # receipt uses a run-scoped idempotent write shared with recovery, then
+ # the staged terminal status is persisted. This ordering closes the
+ # crash window where a terminal run could otherwise outlive its receipt.
if journal is not None:
try:
await journal.flush()
except Exception:
logger.warning("Failed to flush journal for run %s", run_id, exc_info=True)
+ await _persist_delivery_receipt(
+ event_store,
+ thread_id=thread_id,
+ run_id=run_id,
+ content=journal.get_delivery_content(),
+ )
+
+ if event_store is not None:
+ try:
+ # Even after bounded receipt retries are exhausted, persist the
+ # real worker outcome. Leaving a successful row inflight would
+ # let lease recovery rewrite it as an error with a synthetic
+ # zero receipt.
+ await run_manager.persist_current_status(run_id)
+ except Exception:
+ logger.warning("Failed to persist terminal status for run %s after delivery receipt attempts", run_id, exc_info=True)
+
+ if journal is not None and persist_completion:
try:
# Persist token usage + convenience fields to RunStore
completion = journal.get_completion_data()
diff --git a/backend/tests/blocking_io/test_jsonl_run_event_store.py b/backend/tests/blocking_io/test_jsonl_run_event_store.py
index 15a78f054..50f1cb317 100644
--- a/backend/tests/blocking_io/test_jsonl_run_event_store.py
+++ b/backend/tests/blocking_io/test_jsonl_run_event_store.py
@@ -38,7 +38,7 @@ async def test_jsonl_run_event_store_async_api_does_not_block_event_loop(tmp_pat
thread_dir.mkdir(parents=True, exist_ok=True)
(thread_dir / "r0.jsonl").write_text('{"seq": 1, "category": "message", "run_id": "r0"}\n', encoding="utf-8")
- # writes: put + put_batch
+ # writes: put + put_batch + idempotent singleton insert
record = await store.put(thread_id="t1", run_id="r1", event_type="message", category="message", content="hi")
assert record["seq"] >= 2
batch = await store.put_batch(
@@ -48,6 +48,23 @@ async def test_jsonl_run_event_store_async_api_does_not_block_event_loop(tmp_pat
]
)
assert len(batch) == 2
+ singleton, created = await store.put_if_absent(
+ thread_id="t1",
+ run_id="r1",
+ event_type="run.delivery",
+ category="outputs",
+ content={"presented": 0, "paths": [], "by_tool": {}},
+ )
+ duplicate, duplicate_created = await store.put_if_absent(
+ thread_id="t1",
+ run_id="r1",
+ event_type="run.delivery",
+ category="outputs",
+ content={"presented": 99},
+ )
+ assert created is True
+ assert duplicate_created is False
+ assert duplicate == singleton
# reads: list_messages / list_events / list_messages_by_run / count_messages.
# list_events is exercised both without and with the event_types filter so
diff --git a/backend/tests/blocking_io/test_run_journal_callbacks.py b/backend/tests/blocking_io/test_run_journal_callbacks.py
new file mode 100644
index 000000000..c404c2e20
--- /dev/null
+++ b/backend/tests/blocking_io/test_run_journal_callbacks.py
@@ -0,0 +1,39 @@
+"""Regression anchor: inline RunJournal callbacks stay event-loop safe."""
+
+from __future__ import annotations
+
+from uuid import uuid4
+
+import pytest
+from langchain_core.callbacks.manager import ahandle_event
+from langchain_core.messages import AIMessage, ToolMessage
+from langgraph.types import Command
+
+from deerflow.runtime.events.store.memory import MemoryRunEventStore
+from deerflow.runtime.journal import RunJournal
+
+pytestmark = pytest.mark.asyncio
+
+
+async def test_inline_tool_callback_does_not_block_event_loop() -> None:
+ journal = RunJournal("run-1", "thread-1", MemoryRunEventStore(), flush_threshold=100)
+ journal._remember_current_run_tool_calls(
+ AIMessage(content="", tool_calls=[{"id": "call-1", "name": "present_files", "args": {}}]),
+ caller="lead_agent",
+ )
+ command = Command(
+ update={
+ "artifacts": ["/mnt/user-data/outputs/report.md"],
+ "messages": [ToolMessage("Successfully presented files", tool_call_id="call-1")],
+ }
+ )
+
+ await ahandle_event(
+ [journal],
+ "on_tool_end",
+ "ignore_agent",
+ command,
+ run_id=uuid4(),
+ )
+
+ assert journal.get_delivery_content()["paths"] == ["/mnt/user-data/outputs/report.md"]
diff --git a/backend/tests/test_gateway_run_recovery.py b/backend/tests/test_gateway_run_recovery.py
index 1b23234ee..f7e17b3d6 100644
--- a/backend/tests/test_gateway_run_recovery.py
+++ b/backend/tests/test_gateway_run_recovery.py
@@ -34,9 +34,17 @@ class _FakeRunManager:
recovered_runs = [SimpleNamespace(run_id="run-1", thread_id="thread-1")]
latest_by_thread: dict[str, list[SimpleNamespace]] = {}
- def __init__(self, *, store, run_ownership_config=None, on_orphans_recovered=None):
+ def __init__(
+ self,
+ *,
+ store,
+ run_ownership_config=None,
+ event_store=None,
+ on_orphans_recovered=None,
+ ):
self.store = store
self.run_ownership_config = run_ownership_config
+ self.event_store = event_store
self.on_orphans_recovered = on_orphans_recovered
self.reconcile_calls: list[dict] = []
self.list_by_thread_calls: list[dict] = []
diff --git a/backend/tests/test_multi_worker_postgres_gate.py b/backend/tests/test_multi_worker_postgres_gate.py
index ced8298d1..b142fd925 100644
--- a/backend/tests/test_multi_worker_postgres_gate.py
+++ b/backend/tests/test_multi_worker_postgres_gate.py
@@ -24,10 +24,21 @@ from deerflow.config.database_config import DatabaseConfig
from deerflow.config.run_ownership_config import RunOwnershipConfig
-def _config_with_backend(backend: str, *, heartbeat_enabled: bool | None = None, browser_enabled: bool = False) -> SimpleNamespace:
+def _config_with_backend(
+ backend: str,
+ *,
+ heartbeat_enabled: bool | None = None,
+ browser_enabled: bool = False,
+ run_events_backend: str = "db",
+) -> SimpleNamespace:
run_ownership = RunOwnershipConfig(heartbeat_enabled=heartbeat_enabled) if heartbeat_enabled is not None else None
tools = [SimpleNamespace(name="browser_navigate")] if browser_enabled else []
- return SimpleNamespace(database=DatabaseConfig(backend=backend), run_ownership=run_ownership, tools=tools)
+ return SimpleNamespace(
+ database=DatabaseConfig(backend=backend),
+ run_ownership=run_ownership,
+ run_events=SimpleNamespace(backend=run_events_backend),
+ tools=tools,
+ )
# ---------------------------------------------------------------------------
@@ -54,6 +65,34 @@ def test_gate_allows_multi_worker_with_postgres_and_heartbeat(monkeypatch):
_enforce_postgres_for_multi_worker(_config_with_backend("postgres", heartbeat_enabled=True))
+@pytest.mark.parametrize("run_events_backend", ["memory", "jsonl"])
+def test_gate_rejects_process_local_run_events_with_multi_worker(monkeypatch, run_events_backend):
+ monkeypatch.setenv("GATEWAY_WORKERS", "2")
+ with pytest.raises(SystemExit) as exc_info:
+ _enforce_postgres_for_multi_worker(
+ _config_with_backend(
+ "postgres",
+ heartbeat_enabled=True,
+ run_events_backend=run_events_backend,
+ )
+ )
+ msg = str(exc_info.value)
+ assert "run_events.backend='db'" in msg
+ assert run_events_backend in msg
+ assert "GATEWAY_WORKERS=1" in msg
+
+
+def test_gate_allows_process_local_run_events_for_single_worker(monkeypatch):
+ monkeypatch.setenv("GATEWAY_WORKERS", "1")
+ for run_events_backend in ("memory", "jsonl"):
+ _enforce_postgres_for_multi_worker(
+ _config_with_backend(
+ "sqlite",
+ run_events_backend=run_events_backend,
+ )
+ )
+
+
def test_gate_rejects_process_local_browser_with_multi_worker(monkeypatch):
monkeypatch.setenv("GATEWAY_WORKERS", "2")
with pytest.raises(SystemExit) as exc_info:
diff --git a/backend/tests/test_run_event_store.py b/backend/tests/test_run_event_store.py
index 6480ef2ee..41df9cac1 100644
--- a/backend/tests/test_run_event_store.py
+++ b/backend/tests/test_run_event_store.py
@@ -58,6 +58,28 @@ class TestPutAndSeq:
record = await store.put(thread_id="t1", run_id="r1", event_type="llm_end", category="trace", metadata=meta)
assert record["metadata"] == meta
+ @pytest.mark.anyio
+ async def test_put_if_absent_preserves_first_run_scoped_event(self, store):
+ first, created = await store.put_if_absent(
+ thread_id="t1",
+ run_id="r1",
+ event_type="run.delivery",
+ category="outputs",
+ content={"presented": 1},
+ )
+ duplicate, duplicate_created = await store.put_if_absent(
+ thread_id="t1",
+ run_id="r1",
+ event_type="run.delivery",
+ category="outputs",
+ content={"presented": 0},
+ )
+
+ assert created is True
+ assert duplicate_created is False
+ assert duplicate == first
+ assert [event["content"] for event in await store.list_events("t1", "r1") if event["event_type"] == "run.delivery"] == [{"presented": 1}]
+
# -- list_messages --
@@ -343,6 +365,24 @@ class TestDbRunEventStore:
await close_engine()
+ @pytest.mark.anyio
+ async def test_put_if_absent_is_idempotent(self, tmp_path):
+ from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
+ from deerflow.runtime.events.store.db import DbRunEventStore
+
+ url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
+ await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
+ s = DbRunEventStore(get_session_factory())
+
+ first, created = await s.put_if_absent(thread_id="t1", run_id="r1", event_type="run.delivery", category="outputs", content={"presented": 2})
+ duplicate, duplicate_created = await s.put_if_absent(thread_id="t1", run_id="r1", event_type="run.delivery", category="outputs", content={"presented": 0})
+
+ assert created is True
+ assert duplicate_created is False
+ assert duplicate["seq"] == first["seq"]
+ assert duplicate["content"] == {"presented": 2}
+ await close_engine()
+
@pytest.mark.anyio
async def test_trace_content_truncation(self, tmp_path):
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
@@ -695,6 +735,19 @@ class TestJsonlRunEventStore:
messages = await s.list_messages("t1")
assert len(messages) == 1
+ @pytest.mark.anyio
+ async def test_put_if_absent_is_idempotent(self, tmp_path):
+ from deerflow.runtime.events.store.jsonl import JsonlRunEventStore
+
+ s = JsonlRunEventStore(base_dir=tmp_path / "jsonl")
+ first, created = await s.put_if_absent(thread_id="t1", run_id="r1", event_type="run.delivery", category="outputs", content={"presented": 2})
+ duplicate, duplicate_created = await s.put_if_absent(thread_id="t1", run_id="r1", event_type="run.delivery", category="outputs", content={"presented": 0})
+
+ assert created is True
+ assert duplicate_created is False
+ assert duplicate == first
+ assert len(await s.list_events("t1", "r1", event_types=["run.delivery"])) == 1
+
@pytest.mark.anyio
async def test_file_at_correct_path(self, tmp_path):
from deerflow.runtime.events.store.jsonl import JsonlRunEventStore
diff --git a/backend/tests/test_run_journal.py b/backend/tests/test_run_journal.py
index 4308cc55c..f76f06549 100644
--- a/backend/tests/test_run_journal.py
+++ b/backend/tests/test_run_journal.py
@@ -1259,3 +1259,277 @@ class TestChatModelStartHumanMessage:
j.on_chat_model_start({}, [], run_id=uuid4(), tags=["lead_agent"])
await j.flush()
assert j._first_human_msg is None
+
+
+class TestDeliveryTracking:
+ """Slice 1 (#4272): journal records artifact production for run.delivery."""
+
+ @staticmethod
+ def _register_tool_call(j: RunJournal, tool_call_id: str, name: str) -> None:
+ from langchain_core.messages import AIMessage
+
+ ai = AIMessage(content="", tool_calls=[{"id": tool_call_id, "name": name, "args": {}}])
+ j._remember_current_run_tool_calls(ai, caller="lead_agent")
+
+ def test_callbacks_run_inline_to_serialize_parallel_mutations(self, journal_setup):
+ j, _ = journal_setup
+
+ # LangChain dispatches synchronous handlers with run_inline=False via
+ # run_in_executor, allowing parallel tool callbacks to mutate one
+ # journal from different threads.
+ assert j.run_inline is True
+
+ @pytest.mark.anyio
+ async def test_concurrent_callbacks_on_one_journal_are_serialized(self, journal_setup):
+ from langchain_core.callbacks.manager import ahandle_event
+ from langchain_core.messages import ToolMessage
+ from langgraph.types import Command
+
+ j, _ = journal_setup
+ commands = []
+ for index, path in enumerate(("report.md", "report.md", "appendix.md"), start=1):
+ tool_call_id = f"call_{index}"
+ self._register_tool_call(j, tool_call_id, "present_files")
+ commands.append(
+ Command(
+ update={
+ "artifacts": [f"/mnt/user-data/outputs/{path}"],
+ "messages": [ToolMessage("Successfully presented files", tool_call_id=tool_call_id)],
+ }
+ )
+ )
+
+ # This is the real LangChain async callback dispatcher. Because the
+ # journal is run_inline, each synchronous mutation completes on the
+ # event-loop thread instead of racing in executor threads.
+ await asyncio.gather(
+ *(
+ ahandle_event(
+ [j],
+ "on_tool_end",
+ "ignore_agent",
+ command,
+ run_id=uuid4(),
+ )
+ for command in commands
+ )
+ )
+
+ content = j.get_delivery_content()
+ assert content["presented"] == 2
+ assert set(content["paths"]) == {
+ "/mnt/user-data/outputs/report.md",
+ "/mnt/user-data/outputs/appendix.md",
+ }
+ assert set(content["by_tool"]["present_files"]) == set(content["paths"])
+
+ @pytest.mark.anyio
+ async def test_concurrent_runs_keep_delivery_accumulators_isolated(self):
+ from langchain_core.messages import ToolMessage
+ from langgraph.types import Command
+
+ store = MemoryRunEventStore()
+ journals = [RunJournal(run_id, "t1", store, flush_threshold=100) for run_id in ("r1", "r2")]
+
+ async def finish_run(journal: RunJournal, index: int) -> None:
+ tool_call_id = f"call_run_{index}"
+ self._register_tool_call(journal, tool_call_id, "present_files")
+ journal.on_tool_end(
+ Command(
+ update={
+ "artifacts": [f"/mnt/user-data/outputs/report-{index}.md"],
+ "messages": [ToolMessage("Successfully presented files", tool_call_id=tool_call_id)],
+ }
+ ),
+ run_id=uuid4(),
+ )
+ await asyncio.sleep(0)
+ journal.record_delivery()
+ await journal.flush()
+
+ await asyncio.gather(*(finish_run(journal, index) for index, journal in enumerate(journals, start=1)))
+
+ for index in (1, 2):
+ events = await store.list_events("t1", f"r{index}")
+ content = next(e for e in events if e["event_type"] == "run.delivery")["content"]
+ assert content == {
+ "presented": 1,
+ "paths": [f"/mnt/user-data/outputs/report-{index}.md"],
+ "by_tool": {"present_files": [f"/mnt/user-data/outputs/report-{index}.md"]},
+ }
+
+ @pytest.mark.anyio
+ async def test_present_files_success_command_recorded_with_attribution(self, journal_setup):
+ from langchain_core.messages import ToolMessage
+ from langgraph.types import Command
+
+ j, store = journal_setup
+ self._register_tool_call(j, "call_1", "present_files")
+ cmd = Command(
+ update={
+ "artifacts": ["/mnt/user-data/outputs/report.md"],
+ "messages": [ToolMessage("Successfully presented files", tool_call_id="call_1")],
+ }
+ )
+ j.on_tool_end(cmd, run_id=uuid4())
+ j.record_delivery()
+ await j.flush()
+
+ events = await store.list_events("t1", "r1")
+ delivery = [e for e in events if e["event_type"] == "run.delivery"]
+ assert len(delivery) == 1
+ content = delivery[0]["content"]
+ assert content["presented"] == 1
+ assert content["paths"] == ["/mnt/user-data/outputs/report.md"]
+ assert content["by_tool"] == {"present_files": ["/mnt/user-data/outputs/report.md"]}
+ assert delivery[0]["category"] == "outputs"
+
+ @pytest.mark.anyio
+ async def test_command_with_multiple_messages_records_artifacts_once(self, journal_setup):
+ from langchain_core.messages import ToolMessage
+ from langgraph.types import Command
+
+ j, store = journal_setup
+ self._register_tool_call(j, "call_multi", "present_files")
+ cmd = Command(
+ update={
+ "artifacts": ["/mnt/user-data/outputs/report.md"],
+ "messages": [
+ ToolMessage("Successfully presented files", tool_call_id="call_multi"),
+ HumanMessage("Additional command message"),
+ ],
+ }
+ )
+ j.on_tool_end(cmd, run_id=uuid4())
+ j.record_delivery()
+ await j.flush()
+
+ events = await store.list_events("t1", "r1")
+ content = next(e for e in events if e["event_type"] == "run.delivery")["content"]
+ assert content == {
+ "presented": 1,
+ "paths": ["/mnt/user-data/outputs/report.md"],
+ "by_tool": {"present_files": ["/mnt/user-data/outputs/report.md"]},
+ }
+
+ @pytest.mark.anyio
+ async def test_command_with_multiple_tool_names_leaves_artifacts_unattributed(self, journal_setup):
+ from langchain_core.messages import ToolMessage
+ from langgraph.types import Command
+
+ j, store = journal_setup
+ self._register_tool_call(j, "call_present", "present_files")
+ self._register_tool_call(j, "call_browser", "browser_screenshot")
+ cmd = Command(
+ update={
+ "artifacts": [
+ "/mnt/user-data/outputs/report.md",
+ "/mnt/user-data/outputs/shot.png",
+ ],
+ "messages": [
+ ToolMessage("Successfully presented files", tool_call_id="call_present"),
+ ToolMessage("Saved browser screenshot", tool_call_id="call_browser"),
+ ],
+ }
+ )
+ j.on_tool_end(cmd, run_id=uuid4())
+ j.record_delivery()
+ await j.flush()
+
+ events = await store.list_events("t1", "r1")
+ content = next(e for e in events if e["event_type"] == "run.delivery")["content"]
+ assert content == {
+ "presented": 2,
+ "paths": [
+ "/mnt/user-data/outputs/report.md",
+ "/mnt/user-data/outputs/shot.png",
+ ],
+ "by_tool": {},
+ }
+
+ @pytest.mark.anyio
+ async def test_error_command_without_artifacts_not_recorded(self, journal_setup):
+ from langchain_core.messages import ToolMessage
+ from langgraph.types import Command
+
+ j, store = journal_setup
+ self._register_tool_call(j, "call_2", "present_files")
+ cmd = Command(update={"messages": [ToolMessage("Error: Only files in /mnt/user-data/outputs can be presented", tool_call_id="call_2")]})
+ j.on_tool_end(cmd, run_id=uuid4())
+ j.record_delivery()
+ await j.flush()
+
+ events = await store.list_events("t1", "r1")
+ delivery = [e for e in events if e["event_type"] == "run.delivery"]
+ assert len(delivery) == 1
+ assert delivery[0]["content"] == {"presented": 0, "paths": [], "by_tool": {}}
+
+ @pytest.mark.anyio
+ async def test_browser_tool_artifacts_recorded_under_producing_tool(self, journal_setup):
+ from langchain_core.messages import ToolMessage
+ from langgraph.types import Command
+
+ j, store = journal_setup
+ self._register_tool_call(j, "call_3", "browser_screenshot")
+ cmd = Command(
+ update={
+ "artifacts": ["/mnt/user-data/outputs/shot.png"],
+ "messages": [ToolMessage("Saved browser screenshot", tool_call_id="call_3")],
+ }
+ )
+ j.on_tool_end(cmd, run_id=uuid4())
+ j.record_delivery()
+ await j.flush()
+
+ events = await store.list_events("t1", "r1")
+ content = next(e for e in events if e["event_type"] == "run.delivery")["content"]
+ assert content["presented"] == 1
+ assert content["by_tool"] == {"browser_screenshot": ["/mnt/user-data/outputs/shot.png"]}
+
+ @pytest.mark.anyio
+ async def test_duplicate_path_tool_pair_recorded_once(self, journal_setup):
+ from langchain_core.messages import ToolMessage
+ from langgraph.types import Command
+
+ j, store = journal_setup
+ self._register_tool_call(j, "call_4", "present_files")
+ for _ in range(2):
+ j.on_tool_end(
+ Command(
+ update={
+ "artifacts": ["/mnt/user-data/outputs/report.md"],
+ "messages": [ToolMessage("Successfully presented files", tool_call_id="call_4")],
+ }
+ ),
+ run_id=uuid4(),
+ )
+ j.record_delivery()
+ await j.flush()
+
+ events = await store.list_events("t1", "r1")
+ content = next(e for e in events if e["event_type"] == "run.delivery")["content"]
+ assert content["presented"] == 1
+ assert content["paths"] == ["/mnt/user-data/outputs/report.md"]
+
+ @pytest.mark.anyio
+ async def test_unattributed_artifacts_counted_without_by_tool_entry(self, journal_setup):
+ from langchain_core.messages import ToolMessage
+ from langgraph.types import Command
+
+ j, store = journal_setup
+ # No _register_tool_call: attribution missing (e.g. tool_call names map miss).
+ cmd = Command(
+ update={
+ "artifacts": ["/mnt/user-data/outputs/anon.txt"],
+ "messages": [ToolMessage("ok", tool_call_id="call_unknown")],
+ }
+ )
+ j.on_tool_end(cmd, run_id=uuid4())
+ j.record_delivery()
+ await j.flush()
+
+ events = await store.list_events("t1", "r1")
+ content = next(e for e in events if e["event_type"] == "run.delivery")["content"]
+ assert content["presented"] == 1
+ assert content["paths"] == ["/mnt/user-data/outputs/anon.txt"]
+ assert content["by_tool"] == {}
diff --git a/backend/tests/test_run_manager.py b/backend/tests/test_run_manager.py
index 13b92fcb0..3ee1e1229 100644
--- a/backend/tests/test_run_manager.py
+++ b/backend/tests/test_run_manager.py
@@ -11,6 +11,7 @@ from sqlalchemy.exc import DatabaseError as SQLAlchemyDatabaseError
from deerflow.config.run_ownership_config import RunOwnershipConfig
from deerflow.runtime import DisconnectMode, RunManager, RunStatus, ThreadOperationKind
+from deerflow.runtime.events.store.memory import MemoryRunEventStore
from deerflow.runtime.runs.manager import CancelOutcome, ConflictError, PersistenceRetryPolicy, RunStartOutcome
from deerflow.runtime.runs.store.memory import MemoryRunStore
@@ -506,6 +507,66 @@ async def test_reconcile_orphaned_inflight_runs_marks_stale_rows_error():
}
+@pytest.mark.anyio
+async def test_reconcile_orphaned_run_backfills_delivery_after_atomic_takeover():
+ """Lease recovery must durably backfill the terminal receipt exactly once."""
+ store = MemoryRunStore()
+ events = MemoryRunEventStore()
+ await store.put("running-run", thread_id="thread-1", status="running", created_at="2026-01-01T00:00:00+00:00")
+ manager = RunManager(store=store, event_store=events)
+
+ first = await manager.reconcile_orphaned_inflight_runs(error="worker crashed", before="2026-01-01T00:00:01+00:00")
+ second = await manager.reconcile_orphaned_inflight_runs(error="worker crashed", before="2026-01-01T00:00:01+00:00")
+
+ assert [record.run_id for record in first] == ["running-run"]
+ assert second == []
+ delivery = await events.list_events("thread-1", "running-run", event_types=["run.delivery"])
+ assert len(delivery) == 1
+ assert delivery[0]["content"] == {"presented": 0, "paths": [], "by_tool": {}}
+ assert (await store.get("running-run"))["status"] == "error"
+
+
+@pytest.mark.anyio
+async def test_reconcile_preserves_delivery_written_before_worker_crash():
+ """A crash after the receipt but before status persistence keeps its facts."""
+ store = MemoryRunStore()
+ events = MemoryRunEventStore()
+ await store.put("running-run", thread_id="thread-1", status="running", created_at="2026-01-01T00:00:00+00:00")
+ await events.put_if_absent(
+ thread_id="thread-1",
+ run_id="running-run",
+ event_type="run.delivery",
+ category="outputs",
+ content={"presented": 1, "paths": ["report.md"], "by_tool": {"present_files": ["report.md"]}},
+ )
+ manager = RunManager(store=store, event_store=events)
+
+ recovered = await manager.reconcile_orphaned_inflight_runs(error="worker crashed", before="2026-01-01T00:00:01+00:00")
+
+ assert [record.run_id for record in recovered] == ["running-run"]
+ delivery = await events.list_events("thread-1", "running-run", event_types=["run.delivery"])
+ assert len(delivery) == 1
+ assert delivery[0]["content"]["presented"] == 1
+
+
+@pytest.mark.anyio
+async def test_reconcile_preserves_terminal_takeover_when_delivery_backfill_fails():
+ """A receipt-store outage must not undo an atomically claimed orphan."""
+
+ class FailingReceiptStore(MemoryRunEventStore):
+ async def put_if_absent(self, **kwargs):
+ raise RuntimeError("event store unavailable")
+
+ store = MemoryRunStore()
+ await store.put("running-run", thread_id="thread-1", status="running", created_at="2026-01-01T00:00:00+00:00")
+ manager = RunManager(store=store, event_store=FailingReceiptStore())
+
+ recovered = await manager.reconcile_orphaned_inflight_runs(error="worker crashed", before="2026-01-01T00:00:01+00:00")
+
+ assert [record.run_id for record in recovered] == ["running-run"]
+ assert (await store.get("running-run"))["status"] == "error"
+
+
@pytest.mark.anyio
async def test_reconcile_orphaned_inflight_runs_skips_live_local_run():
"""Startup recovery should not mark an active row orphaned when this worker owns it."""
diff --git a/backend/tests/test_run_worker_delivery.py b/backend/tests/test_run_worker_delivery.py
new file mode 100644
index 000000000..5cc8123f1
--- /dev/null
+++ b/backend/tests/test_run_worker_delivery.py
@@ -0,0 +1,362 @@
+"""Worker-level regression tests for the terminal run.delivery event (#4272 slice 1)."""
+
+import asyncio
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+from uuid import uuid4
+
+import pytest
+from langchain_core.messages import AIMessage, ToolMessage
+from langgraph.types import Command
+
+from deerflow.runtime.events.store.memory import MemoryRunEventStore
+from deerflow.runtime.runs.manager import RunManager
+from deerflow.runtime.runs.schemas import RunStatus
+from deerflow.runtime.runs.store.memory import MemoryRunStore
+from deerflow.runtime.runs.worker import RunContext, run_agent
+
+
+def _make_bridge():
+ return SimpleNamespace(publish=AsyncMock(), publish_end=AsyncMock(), cleanup=AsyncMock())
+
+
+async def _delivery_events(store: MemoryRunEventStore, thread_id: str, run_id: str) -> list[dict]:
+ events = await store.list_events(thread_id, run_id)
+ return [e for e in events if e["event_type"] == "run.delivery"]
+
+
+@pytest.mark.anyio
+async def test_delivery_event_records_present_files_paths_on_success():
+ run_manager = RunManager()
+ record = await run_manager.create("thread-1")
+ store = MemoryRunEventStore()
+
+ class DummyAgent:
+ async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
+ journal = config["context"]["__run_journal"]
+ ai = AIMessage(content="", tool_calls=[{"id": "call_1", "name": "present_files", "args": {}}])
+ journal._remember_current_run_tool_calls(ai, caller="lead_agent")
+ journal.on_tool_end(
+ Command(
+ update={
+ "artifacts": ["/mnt/user-data/outputs/report.md"],
+ "messages": [ToolMessage("Successfully presented files", tool_call_id="call_1")],
+ }
+ ),
+ run_id=uuid4(),
+ )
+ yield {"messages": []}
+
+ await run_agent(
+ _make_bridge(),
+ run_manager,
+ record,
+ ctx=RunContext(checkpointer=None, event_store=store),
+ agent_factory=lambda *, config: DummyAgent(),
+ graph_input={},
+ config={},
+ )
+ await asyncio.sleep(0)
+
+ delivery = await _delivery_events(store, "thread-1", record.run_id)
+ assert len(delivery) == 1
+ assert delivery[0]["content"]["presented"] == 1
+ assert delivery[0]["content"]["paths"] == ["/mnt/user-data/outputs/report.md"]
+ assert delivery[0]["content"]["by_tool"] == {"present_files": ["/mnt/user-data/outputs/report.md"]}
+ fetched = await run_manager.get(record.run_id)
+ assert fetched.status == RunStatus.success
+
+
+@pytest.mark.anyio
+async def test_delivery_event_presented_zero_without_artifact_production():
+ run_manager = RunManager()
+ record = await run_manager.create("thread-1")
+ store = MemoryRunEventStore()
+
+ class DummyAgent:
+ async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
+ yield {"messages": []}
+
+ await run_agent(
+ _make_bridge(),
+ run_manager,
+ record,
+ ctx=RunContext(checkpointer=None, event_store=store),
+ agent_factory=lambda *, config: DummyAgent(),
+ graph_input={},
+ config={},
+ )
+ await asyncio.sleep(0)
+
+ delivery = await _delivery_events(store, "thread-1", record.run_id)
+ assert len(delivery) == 1
+ assert delivery[0]["content"] == {"presented": 0, "paths": [], "by_tool": {}}
+ fetched = await run_manager.get(record.run_id)
+ assert fetched.status == RunStatus.success
+
+
+@pytest.mark.anyio
+async def test_delivery_event_is_singleton_across_goal_continuations(monkeypatch):
+ run_manager = RunManager()
+ record = await run_manager.create("thread-1")
+ store = MemoryRunEventStore()
+ stream_calls = 0
+ continuation_calls = 0
+
+ class ContinuingAgent:
+ async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
+ nonlocal stream_calls
+ stream_calls += 1
+ journal = config["context"]["__run_journal"]
+ tool_call_id = f"call_{stream_calls}"
+ journal._remember_current_run_tool_calls(
+ AIMessage(content="", tool_calls=[{"id": tool_call_id, "name": "present_files", "args": {}}]),
+ caller="lead_agent",
+ )
+ artifacts = ["/mnt/user-data/outputs/report.md"]
+ if stream_calls == 2:
+ artifacts.append("/mnt/user-data/outputs/appendix.md")
+ journal.on_tool_end(
+ Command(
+ update={
+ "artifacts": artifacts,
+ "messages": [ToolMessage("Successfully presented files", tool_call_id=tool_call_id)],
+ }
+ ),
+ run_id=uuid4(),
+ )
+ yield {"messages": []}
+
+ async def prepare_continuation(**kwargs):
+ nonlocal continuation_calls
+ continuation_calls += 1
+ if continuation_calls == 1:
+ return {"messages": []}
+ return None
+
+ monkeypatch.setattr("deerflow.runtime.runs.worker._prepare_goal_continuation_input", prepare_continuation)
+
+ await run_agent(
+ _make_bridge(),
+ run_manager,
+ record,
+ ctx=RunContext(checkpointer=None, event_store=store),
+ agent_factory=lambda *, config: ContinuingAgent(),
+ graph_input={},
+ config={},
+ )
+
+ delivery = await _delivery_events(store, "thread-1", record.run_id)
+ assert stream_calls == 2
+ assert len(delivery) == 1
+ assert delivery[0]["content"] == {
+ "presented": 2,
+ "paths": [
+ "/mnt/user-data/outputs/report.md",
+ "/mnt/user-data/outputs/appendix.md",
+ ],
+ "by_tool": {
+ "present_files": [
+ "/mnt/user-data/outputs/report.md",
+ "/mnt/user-data/outputs/appendix.md",
+ ]
+ },
+ }
+
+
+@pytest.mark.anyio
+async def test_delivery_event_emitted_exactly_once_on_error_path():
+ run_manager = RunManager()
+ record = await run_manager.create("thread-1")
+ store = MemoryRunEventStore()
+
+ class FailingAgent:
+ async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
+ raise RuntimeError("boom")
+ yield # pragma: no cover - make this an async generator
+
+ await run_agent(
+ _make_bridge(),
+ run_manager,
+ record,
+ ctx=RunContext(checkpointer=None, event_store=store),
+ agent_factory=lambda *, config: FailingAgent(),
+ graph_input={},
+ config={},
+ )
+ await asyncio.sleep(0)
+
+ delivery = await _delivery_events(store, "thread-1", record.run_id)
+ assert len(delivery) == 1
+ assert delivery[0]["content"]["presented"] == 0
+ fetched = await run_manager.get(record.run_id)
+ assert fetched.status == RunStatus.error
+
+
+@pytest.mark.anyio
+async def test_delivery_is_durable_before_terminal_run_status():
+ events = MemoryRunEventStore()
+
+ class OrderingRunStore(MemoryRunStore):
+ async def update_status(self, run_id, status, *, error=None, stop_reason=None):
+ if status not in {"pending", "running"}:
+ receipt = await events.list_events("thread-1", run_id, event_types=["run.delivery"])
+ assert len(receipt) == 1
+ return await super().update_status(run_id, status, error=error, stop_reason=stop_reason)
+
+ run_store = OrderingRunStore()
+ run_manager = RunManager(store=run_store)
+ record = await run_manager.create("thread-1")
+
+ class DummyAgent:
+ async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
+ yield {"messages": []}
+
+ await run_agent(
+ _make_bridge(),
+ run_manager,
+ record,
+ ctx=RunContext(checkpointer=None, event_store=events),
+ agent_factory=lambda *, config: DummyAgent(),
+ graph_input={},
+ config={},
+ )
+
+ assert (await run_store.get(record.run_id))["status"] == "success"
+
+
+@pytest.mark.anyio
+async def test_delivery_write_retries_before_persisting_success():
+ class FlakyReceiptStore(MemoryRunEventStore):
+ def __init__(self):
+ super().__init__()
+ self.attempts = 0
+
+ async def put_if_absent(self, **kwargs):
+ self.attempts += 1
+ if self.attempts == 1:
+ raise RuntimeError("transient event store outage")
+ return await super().put_if_absent(**kwargs)
+
+ event_store = FlakyReceiptStore()
+ run_store = MemoryRunStore()
+ run_manager = RunManager(store=run_store)
+ record = await run_manager.create("thread-1")
+
+ class DummyAgent:
+ async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
+ yield {"messages": []}
+
+ await run_agent(
+ _make_bridge(),
+ run_manager,
+ record,
+ ctx=RunContext(checkpointer=None, event_store=event_store),
+ agent_factory=lambda *, config: DummyAgent(),
+ graph_input={},
+ config={},
+ )
+
+ assert event_store.attempts == 2
+ assert len(await _delivery_events(event_store, "thread-1", record.run_id)) == 1
+ assert (await run_store.get(record.run_id))["status"] == "success"
+
+
+@pytest.mark.anyio
+async def test_delivery_write_failure_preserves_real_durable_terminal_status():
+ class FailingReceiptStore(MemoryRunEventStore):
+ def __init__(self):
+ super().__init__()
+ self.attempts = 0
+
+ async def put_if_absent(self, **kwargs):
+ self.attempts += 1
+ raise RuntimeError("event store unavailable")
+
+ run_store = MemoryRunStore()
+ run_manager = RunManager(store=run_store)
+ record = await run_manager.create("thread-1")
+ event_store = FailingReceiptStore()
+
+ class DummyAgent:
+ async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
+ yield {"messages": []}
+
+ await run_agent(
+ _make_bridge(),
+ run_manager,
+ record,
+ ctx=RunContext(checkpointer=None, event_store=event_store),
+ agent_factory=lambda *, config: DummyAgent(),
+ graph_input={},
+ config={},
+ )
+
+ # A receipt outage must not let lease recovery rewrite a genuine success
+ # as an error. After bounded retries, preserve the worker's real outcome.
+ assert event_store.attempts > 1
+ assert record.status == RunStatus.success
+ assert (await run_store.get(record.run_id))["status"] == "success"
+
+
+@pytest.mark.anyio
+async def test_delivery_event_emitted_when_checkpoint_preflight_fails(monkeypatch):
+ run_manager = RunManager()
+ run_manager.update_run_completion = AsyncMock(wraps=run_manager.update_run_completion)
+ record = await run_manager.create("thread-1")
+ store = MemoryRunEventStore()
+ compatibility_check = AsyncMock(side_effect=RuntimeError("incompatible checkpoint"))
+ monkeypatch.setattr("deerflow.runtime.runs.worker.aensure_checkpoint_mode_compatible", compatibility_check)
+
+ def unexpected_agent_factory(**kwargs):
+ raise AssertionError("agent must not be built after preflight failure")
+
+ await run_agent(
+ _make_bridge(),
+ run_manager,
+ record,
+ ctx=RunContext(checkpointer=object(), event_store=store),
+ agent_factory=unexpected_agent_factory,
+ graph_input={},
+ config={},
+ )
+
+ delivery = await _delivery_events(store, "thread-1", record.run_id)
+ assert len(delivery) == 1
+ assert delivery[0]["content"] == {"presented": 0, "paths": [], "by_tool": {}}
+ fetched = await run_manager.get(record.run_id)
+ assert fetched.status == RunStatus.error
+ run_manager.update_run_completion.assert_not_awaited()
+
+
+@pytest.mark.anyio
+async def test_delivery_event_emitted_when_cancelled_waiting_for_prior_finalization(monkeypatch):
+ run_manager = RunManager()
+ run_manager.update_run_completion = AsyncMock(wraps=run_manager.update_run_completion)
+ record = await run_manager.create("thread-1")
+ store = MemoryRunEventStore()
+ monkeypatch.setattr(
+ run_manager,
+ "wait_for_prior_finalizing",
+ AsyncMock(side_effect=asyncio.CancelledError()),
+ )
+
+ def unexpected_agent_factory(**kwargs):
+ raise AssertionError("agent must not be built after preflight cancellation")
+
+ await run_agent(
+ _make_bridge(),
+ run_manager,
+ record,
+ ctx=RunContext(checkpointer=None, event_store=store),
+ agent_factory=unexpected_agent_factory,
+ graph_input={},
+ config={},
+ )
+
+ delivery = await _delivery_events(store, "thread-1", record.run_id)
+ assert len(delivery) == 1
+ assert delivery[0]["content"] == {"presented": 0, "paths": [], "by_tool": {}}
+ fetched = await run_manager.get(record.run_id)
+ assert fetched.status == RunStatus.interrupted
+ run_manager.update_run_completion.assert_not_awaited()
From bb9f67aaf1fa2a2544c1abaa88a13a2ec471d568 Mon Sep 17 00:00:00 2001
From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com>
Date: Sun, 26 Jul 2026 21:57:39 +0800
Subject: [PATCH 31/35] fix(runtime): close cancelled replacement admission
(#4472)
---
backend/AGENTS.md | 1 +
.../harness/deerflow/runtime/runs/manager.py | 71 +++++-
backend/tests/test_run_manager.py | 239 ++++++++++++++++++
3 files changed, 308 insertions(+), 3 deletions(-)
diff --git a/backend/AGENTS.md b/backend/AGENTS.md
index 7e72d57d0..2c166b4d6 100644
--- a/backend/AGENTS.md
+++ b/backend/AGENTS.md
@@ -498,6 +498,7 @@ JSONL event stores when `GATEWAY_WORKERS > 1`.
- When a persistent `RunStore` is configured, `get()` and `list_by_thread()` hydrate historical runs from the store. In-memory records win for the same `run_id` so task, abort, and stream-control state stays attached to active local runs.
- Thread metadata status switches to `running` only after `RunManager.try_start()` succeeds. Pending-cancelled runs therefore skip the old `running` projection, while clients may observe the prior thread status during the short worker-startup window.
- `cancel()` returns a :class:`~deerflow.runtime.CancelOutcome` enum: `cancelled` (local cancel), `taken_over` (non-owning worker claimed the run because the owner's lease expired — marks it as `error`), `lease_valid_elsewhere` (owner's lease is still alive — caller should return 409 + `Retry-After`), `not_active_locally` (heartbeat disabled, preserving the old 409 path), `not_cancellable` (terminal state), or `unknown` (not found in memory or store). `create_or_reject(..., multitask_strategy="interrupt"|"rollback")` persists interrupted status through `RunStore.update_status()`, matching normal `set_status()` transitions.
+- Interrupt/rollback admission registers the replacement before its best-effort persistence of locally interrupted predecessors. If the admitting caller is cancelled during that post-registration await, `RunManager` drains a shielded replacement cleanup before propagating `CancelledError`, including across repeated cancellation. The cleanup normally persists `interrupted`; if that best-effort transition fails, it retries the active-to-`interrupted` store transition strictly and verifies the result with the replacement's captured owner identity. A concurrent peer terminal transition wins and is synchronized back into the local record rather than being overwritten or deleted.
- Store-only hydrated runs are readable history. In multi-worker mode with heartbeat enabled, cancel on a store-only run can take over (mark `error`) when the owner's lease has expired past the grace window; otherwise it fails with 409 + `Retry-After`. In single-worker mode (heartbeat off), store-only runs still return 409.
- Startup/orphan reconciliation must claim stale active rows with `RunStore.claim_for_takeover()`, not a plain `update_status()`. The final claim re-checks `status` and lease expiry atomically, so a heartbeat renewal between the candidate scan and the recovery write keeps the run active.
- Run admission and independent checkpoint writes are first-class thread operations. `runs.operation_kind` distinguishes user-visible `run` rows from internal `checkpoint_write` reservations, while every active kind shares the existing durable active-thread uniqueness constraint. New operation kinds must go through `RunStore.create_thread_operation_atomic()` and `RunManager.reserve_thread_operation()` rather than adding another lock or metadata marker. Live and lease-less reservations are non-interruptible; an expired leased reservation can be reclaimed immediately by interrupt/rollback admission without waiting for orphan reconciliation. Lease-less rows stay fail-closed because the store cannot distinguish a stale row from a live checkpoint writer in another heartbeat-disabled worker; a rare failed delete therefore requires startup reconciliation, and heartbeat-disabled multi-worker deployment remains unsupported. Reservation bodies are attached to their caller task so loss detected by lease renewal cancels the checkpoint writer before it can continue after takeover; the context manager translates that lease-loss cancellation to `ConflictError` after cleanup so Gateway mutation routes return a retryable 409 instead of dropping the HTTP request. The cleanup scope begins immediately after durable admission, including the await that attaches the caller task, so cancellation cannot strand a locally renewed pending reservation. A failed renewal is revalidated under the manager lock before cancellation; if the reservation completed and unregistered while the store update was in flight, its request task must not be cancelled after the checkpoint write. Reservations are excluded from run history/reporting and from run-only helpers such as `list_by_thread()` and `has_inflight()`, release uses the captured owner rather than ambient user context, and local cleanup still runs when the best-effort store delete fails. `RunStore.create_run_atomic()` remains a deprecated compatibility shim for external stores that only admit normal runs; new stores must implement `create_thread_operation_atomic()` to support internal operation kinds.
diff --git a/backend/packages/harness/deerflow/runtime/runs/manager.py b/backend/packages/harness/deerflow/runtime/runs/manager.py
index 4d816f8d0..a8924fcab 100644
--- a/backend/packages/harness/deerflow/runtime/runs/manager.py
+++ b/backend/packages/harness/deerflow/runtime/runs/manager.py
@@ -1092,6 +1092,51 @@ class RunManager:
user_id=user_id,
)
+ async def _close_cancelled_admission(self, record: RunRecord) -> None:
+ """Terminalize an unseen replacement and confirm its durable state."""
+ await self.cancel(record.run_id)
+ if self._store is None:
+ return
+
+ stored = await self._call_store_with_retry(
+ "verify cancelled admission",
+ record.run_id,
+ lambda: self._store.get(record.run_id, user_id=record.user_id),
+ )
+ active_statuses = (RunStatus.pending.value, RunStatus.running.value)
+ if stored is not None and stored.get("status") in active_statuses:
+ # `_persist_status` is deliberately best-effort. This compensation
+ # path needs a strict second CAS attempt because the caller never
+ # receives the record and no worker can attach after it returns.
+ # A peer terminal transition wins the CAS and is preserved below.
+ await self._call_store_with_retry(
+ "terminalize cancelled admission",
+ record.run_id,
+ lambda: self._store.update_status(record.run_id, RunStatus.interrupted.value),
+ )
+ stored = await self._call_store_with_retry(
+ "verify terminal cancelled admission",
+ record.run_id,
+ lambda: self._store.get(record.run_id, user_id=record.user_id),
+ )
+ if stored is not None and stored.get("status") in active_statuses:
+ raise RuntimeError(f"Cancelled admission {record.run_id} remains active in the run store")
+
+ if stored is None:
+ async with self._lock:
+ if self._runs.get(record.run_id) is record:
+ self._runs.pop(record.run_id, None)
+ self._unindex_run_locked(record.run_id, record.thread_id)
+ return
+
+ stored_status = RunStatus(stored.get("status") or RunStatus.pending.value)
+ async with self._lock:
+ if self._runs.get(record.run_id) is record:
+ record.status = stored_status
+ record.error = stored.get("error")
+ record.stop_reason = stored.get("stop_reason")
+ record.updated_at = _now_iso()
+
async def _admit_thread_operation(
self,
thread_id: str,
@@ -1260,9 +1305,29 @@ class RunManager:
interrupted_records.append(r)
# Outside the lock: persist interrupted status for locally-cancelled
- # runs. Store-side claimed rows are already finalised.
- for interrupted_record in interrupted_records:
- await self._persist_status(interrupted_record, RunStatus.interrupted)
+ # runs. Store-side claimed rows are already finalised. Cancellation at
+ # this point happens after the replacement was admitted, so close that
+ # new run before propagating cancellation to the caller.
+ try:
+ for interrupted_record in interrupted_records:
+ await self._persist_status(interrupted_record, RunStatus.interrupted)
+ except asyncio.CancelledError:
+ cleanup = asyncio.create_task(self._close_cancelled_admission(record))
+ cleanup.set_name(f"deerflow-close-cancelled-admission-{record.run_id}")
+ while not cleanup.done():
+ try:
+ await asyncio.shield(cleanup)
+ except asyncio.CancelledError:
+ pass
+ except Exception:
+ break
+ try:
+ cleanup.result()
+ except asyncio.CancelledError:
+ logger.error("Cancelled admission cleanup task was itself cancelled for run %s", record.run_id)
+ except Exception:
+ logger.exception("Failed to close run %s after admission was cancelled", record.run_id)
+ raise
logger.info("Run created: run_id=%s thread_id=%s", run_id, thread_id)
return record
diff --git a/backend/tests/test_run_manager.py b/backend/tests/test_run_manager.py
index 3ee1e1229..aeeb294a0 100644
--- a/backend/tests/test_run_manager.py
+++ b/backend/tests/test_run_manager.py
@@ -993,6 +993,245 @@ async def test_create_or_reject_does_not_interrupt_old_run_when_new_run_store_wr
assert stored_old["status"] == "running"
+@pytest.mark.anyio
+@pytest.mark.parametrize("strategy", ["interrupt", "rollback"])
+async def test_create_or_reject_cancellation_after_registration_interrupts_replacement(
+ monkeypatch: pytest.MonkeyPatch,
+ strategy: str,
+) -> None:
+ """Cancellation after admission must not leave the replacement active."""
+ store = MemoryRunStore()
+ manager = RunManager(store=store)
+ old = await manager.create("thread-1")
+ await manager.set_status(old.run_id, RunStatus.running)
+ persist_started = asyncio.Event()
+ release_persist = asyncio.Event()
+ original_persist_status = manager._persist_status
+
+ async def blocking_persist_status(record: Any, status: RunStatus, **kwargs: Any) -> bool:
+ persist_started.set()
+ await asyncio.wait_for(release_persist.wait(), timeout=1)
+ return await original_persist_status(record, status, **kwargs)
+
+ monkeypatch.setattr(manager, "_persist_status", blocking_persist_status)
+ create_task = asyncio.create_task(manager.create_or_reject("thread-1", multitask_strategy=strategy))
+ await asyncio.wait_for(persist_started.wait(), timeout=1)
+ create_task.cancel()
+ release_persist.set()
+
+ with pytest.raises(asyncio.CancelledError):
+ _ = await create_task
+
+ records = await manager.list_by_thread("thread-1")
+ replacement = next(record for record in records if record.run_id != old.run_id)
+ stored_replacement = await store.get(replacement.run_id)
+ assert not await manager.has_inflight("thread-1")
+ assert replacement.status == RunStatus.interrupted
+ assert replacement.abort_event.is_set()
+ assert stored_replacement is not None
+ assert stored_replacement["status"] == RunStatus.interrupted.value
+
+
+@pytest.mark.anyio
+async def test_create_or_reject_repeated_cancellation_drains_replacement_cleanup(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Repeated cancellation must not abandon the durable cleanup task."""
+ store = MemoryRunStore()
+ manager = RunManager(store=store)
+ old = await manager.create("thread-1")
+ await manager.set_status(old.run_id, RunStatus.running)
+ old_persist_started = asyncio.Event()
+ release_old_persist = asyncio.Event()
+ replacement_persist_started = asyncio.Event()
+ release_replacement_persist = asyncio.Event()
+ original_persist_status = manager._persist_status
+
+ async def staged_persist_status(record: Any, status: RunStatus, **kwargs: Any) -> bool:
+ if record.run_id == old.run_id:
+ old_persist_started.set()
+ await asyncio.wait_for(release_old_persist.wait(), timeout=1)
+ else:
+ replacement_persist_started.set()
+ await asyncio.wait_for(release_replacement_persist.wait(), timeout=1)
+ return await original_persist_status(record, status, **kwargs)
+
+ monkeypatch.setattr(manager, "_persist_status", staged_persist_status)
+ create_task = asyncio.create_task(manager.create_or_reject("thread-1", multitask_strategy="interrupt"))
+ await asyncio.wait_for(old_persist_started.wait(), timeout=1)
+ replacement = next(record for record in manager._runs.values() if record.run_id != old.run_id)
+
+ create_task.cancel()
+ release_old_persist.set()
+ await asyncio.wait_for(replacement_persist_started.wait(), timeout=1)
+ create_task.cancel()
+ await asyncio.sleep(0)
+ assert not create_task.done()
+
+ release_replacement_persist.set()
+ with pytest.raises(asyncio.CancelledError):
+ _ = await asyncio.wait_for(create_task, timeout=1)
+
+ stored_replacement = await store.get(replacement.run_id)
+ assert replacement.status == RunStatus.interrupted
+ assert stored_replacement is not None
+ assert stored_replacement["status"] == RunStatus.interrupted.value
+
+
+@pytest.mark.anyio
+async def test_create_or_reject_retries_replacement_when_cancel_status_cannot_persist(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A failed best-effort update must get a strict durable retry."""
+
+ class FailFirstReplacementInterruptStore(MemoryRunStore):
+ failed = False
+
+ async def get(self, run_id: str, *, user_id: str | None = None) -> dict[str, Any] | None:
+ raw = await super().get(run_id)
+ if raw is not None and raw.get("status") == RunStatus.pending.value and raw.get("user_id") is not None and user_id != raw.get("user_id"):
+ raise RuntimeError("replacement lookup was not owner-scoped")
+ return await super().get(run_id, user_id=user_id)
+
+ async def update_status(self, run_id: str, status: str, **kwargs: Any) -> bool:
+ row = await super().get(run_id)
+ if not self.failed and status == RunStatus.interrupted.value and row is not None and row.get("status") == RunStatus.pending.value:
+ self.failed = True
+ raise RuntimeError("replacement status write failed")
+ return await super().update_status(run_id, status, **kwargs)
+
+ store = FailFirstReplacementInterruptStore()
+ manager = RunManager(store=store)
+ old = await manager.create("thread-1", user_id="owner-1")
+ await manager.set_status(old.run_id, RunStatus.running)
+ old_persist_started = asyncio.Event()
+ release_old_persist = asyncio.Event()
+ original_persist_status = manager._persist_status
+
+ async def block_old_persist(record: Any, status: RunStatus, **kwargs: Any) -> bool:
+ if record.run_id != old.run_id:
+ return await original_persist_status(record, status, **kwargs)
+ old_persist_started.set()
+ await asyncio.wait_for(release_old_persist.wait(), timeout=1)
+ return await original_persist_status(record, status, **kwargs)
+
+ monkeypatch.setattr(manager, "_persist_status", block_old_persist)
+ create_task = asyncio.create_task(manager.create_or_reject("thread-1", multitask_strategy="interrupt", user_id="owner-1"))
+ await asyncio.wait_for(old_persist_started.wait(), timeout=1)
+ replacement = next(record for record in manager._runs.values() if record.run_id != old.run_id)
+ create_task.cancel()
+ release_old_persist.set()
+
+ with pytest.raises(asyncio.CancelledError):
+ _ = await asyncio.wait_for(create_task, timeout=1)
+
+ stored_replacement = await store.get(replacement.run_id, user_id="owner-1")
+ assert stored_replacement is not None
+ assert stored_replacement["status"] == RunStatus.interrupted.value
+ assert replacement.status == RunStatus.interrupted
+ assert not await manager.has_inflight("thread-1")
+
+
+@pytest.mark.anyio
+async def test_create_or_reject_cleanup_failure_preserves_caller_cancellation(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Cleanup IO failure must not replace the caller's CancelledError."""
+
+ class FailingReplacementCleanupStore(MemoryRunStore):
+ replacement_update_failed = False
+
+ async def update_status(self, run_id: str, status: str, **kwargs: Any) -> bool:
+ row = await super().get(run_id)
+ if status == RunStatus.interrupted.value and row is not None and row.get("status") == RunStatus.pending.value:
+ self.replacement_update_failed = True
+ raise RuntimeError("replacement status unavailable")
+ return await super().update_status(run_id, status, **kwargs)
+
+ async def get(self, run_id: str, *, user_id: str | None = None) -> dict[str, Any] | None:
+ row = await super().get(run_id, user_id=user_id)
+ if self.replacement_update_failed and row is not None and row.get("status") == RunStatus.pending.value:
+ raise RuntimeError("replacement verification unavailable")
+ return row
+
+ store = FailingReplacementCleanupStore()
+ manager = RunManager(store=store)
+ old = await manager.create("thread-1")
+ await manager.set_status(old.run_id, RunStatus.running)
+ old_persist_started = asyncio.Event()
+ release_old_persist = asyncio.Event()
+ original_persist_status = manager._persist_status
+
+ async def block_old_persist(record: Any, status: RunStatus, **kwargs: Any) -> bool:
+ if record.run_id != old.run_id:
+ return await original_persist_status(record, status, **kwargs)
+ old_persist_started.set()
+ await asyncio.wait_for(release_old_persist.wait(), timeout=1)
+ return await original_persist_status(record, status, **kwargs)
+
+ monkeypatch.setattr(manager, "_persist_status", block_old_persist)
+ create_task = asyncio.create_task(manager.create_or_reject("thread-1", multitask_strategy="interrupt"))
+ await asyncio.wait_for(old_persist_started.wait(), timeout=1)
+ create_task.cancel()
+ release_old_persist.set()
+
+ with pytest.raises(asyncio.CancelledError):
+ _ = await asyncio.wait_for(create_task, timeout=1)
+
+
+@pytest.mark.anyio
+async def test_create_or_reject_preserves_peer_terminal_status_during_cancel_retry(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A peer terminal transition must win the strict cancellation retry."""
+
+ class PeerWinsReplacementInterruptStore(MemoryRunStore):
+ replacement_attempts = 0
+
+ async def update_status(self, run_id: str, status: str, **kwargs: Any) -> bool:
+ row = await self.get(run_id)
+ if status == RunStatus.interrupted.value and row is not None and row.get("status") == RunStatus.pending.value:
+ self.replacement_attempts += 1
+ if self.replacement_attempts == 1:
+ raise RuntimeError("replacement status write failed")
+ await super().update_status(run_id, RunStatus.error.value, error="peer takeover")
+ return False
+ return await super().update_status(run_id, status, **kwargs)
+
+ store = PeerWinsReplacementInterruptStore()
+ manager = RunManager(store=store)
+ old = await manager.create("thread-1")
+ await manager.set_status(old.run_id, RunStatus.running)
+ old_persist_started = asyncio.Event()
+ release_old_persist = asyncio.Event()
+ original_persist_status = manager._persist_status
+
+ async def block_old_persist(record: Any, status: RunStatus, **kwargs: Any) -> bool:
+ if record.run_id != old.run_id:
+ return await original_persist_status(record, status, **kwargs)
+ old_persist_started.set()
+ await asyncio.wait_for(release_old_persist.wait(), timeout=1)
+ return await original_persist_status(record, status, **kwargs)
+
+ monkeypatch.setattr(manager, "_persist_status", block_old_persist)
+ create_task = asyncio.create_task(manager.create_or_reject("thread-1", multitask_strategy="interrupt"))
+ await asyncio.wait_for(old_persist_started.wait(), timeout=1)
+ replacement = next(record for record in manager._runs.values() if record.run_id != old.run_id)
+ create_task.cancel()
+ release_old_persist.set()
+
+ with pytest.raises(asyncio.CancelledError):
+ _ = await asyncio.wait_for(create_task, timeout=1)
+
+ stored_replacement = await store.get(replacement.run_id)
+ assert stored_replacement is not None
+ assert stored_replacement["status"] == RunStatus.error.value
+ assert stored_replacement["error"] == "peer takeover"
+ assert replacement.status == RunStatus.error
+ assert replacement.error == "peer takeover"
+ assert not await manager.has_inflight("thread-1")
+
+
@pytest.mark.anyio
async def test_create_or_reject_rollback_persists_interrupted_status_to_store():
"""rollback strategy should persist interrupted status for old runs."""
From 244ce7739f13d44ce7ee5679eab114eb178f89f1 Mon Sep 17 00:00:00 2001
From: Aari
Date: Sun, 26 Jul 2026 21:59:19 +0800
Subject: [PATCH 32/35] fix(runtime): linearize delta-mode checkpoint resume
(#4460)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(runtime): linearize delta-mode checkpoint resume
Resuming a run from an older checkpoint forks the lineage, and in delta
mode that fork's state cannot be materialized correctly: the delta
history walk collects every pending_writes entry stored on each on-path
ancestor, but a shared parent also carries the writes of the sibling
child that was abandoned. Those writes replay into the fork, so the run
starts from a message list that still contains the answer it was meant to
replace — regenerating in a branched thread surfaced this as the
superseded assistant message reappearing beside the new one after a
reload. All three saver implementations are affected, so write-to-child
ownership is a gap in the upstream delta contract rather than one
saver's slip.
Rather than reimplement that walk, express the fork as what it means:
materialize the requested checkpoint's state, write it as an Overwrite on
the current head (which has no siblings), and run linearly. The abandoned
turn stays in history as the rewritten head's ancestry.
This runs after the rollback point is captured, so cancel-with-rollback
still restores the real pre-run head, and fails closed — an unreadable
resume checkpoint raises instead of falling back to the corrupt fork.
Full mode keeps forking: its checkpoints carry complete channel_values
and need no replay.
* fix(runtime): restore complete delta resume state
* fix(runtime): linearize delta rollback restoration
* Apply suggestions from code review
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* fix(runtime): serialize delta resume preparation
---------
Co-authored-by: Willem Jiang
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---
backend/AGENTS.md | 8 +-
.../harness/deerflow/runtime/runs/worker.py | 227 +++++++-
backend/tests/test_run_worker_delta_resume.py | 495 ++++++++++++++++++
backend/tests/test_run_worker_rollback.py | 33 +-
4 files changed, 721 insertions(+), 42 deletions(-)
create mode 100644 backend/tests/test_run_worker_delta_resume.py
diff --git a/backend/AGENTS.md b/backend/AGENTS.md
index 2c166b4d6..641f7a0ba 100644
--- a/backend/AGENTS.md
+++ b/backend/AGENTS.md
@@ -505,7 +505,7 @@ JSONL event stores when `GATEWAY_WORKERS > 1`.
- Gateway checkpoint mutations outside run execution must use `services.reserve_checkpoint_write()`, which composes the process-local thread lock with the durable `checkpoint_write` reservation. Manual compaction, `POST /threads/{id}/state`, and both goal mutation routes (`PUT` / `DELETE /threads/{id}/goal`, including creation of a missing goal checkpoint) use this boundary, so an existing run blocks the write and the reservation blocks new reject/interrupt/rollback runs across workers.
- `POST /wait` (both thread-scoped and `/api/runs/wait`) drains the stream bridge via `wait_for_run_completion()` instead of bare `await record.task`, so it honours the run's `on_disconnect` setting and cancels the background run on real client disconnect rather than returning a stale checkpoint (issue #3265).
- Redis `StreamBridge` keys use a rolling retained-buffer TTL (`stream_bridge.stream_ttl_seconds`, refreshed on `publish()` / `publish_end()`) as a leak safety net, not as a run timeout. Startup and lease-driven periodic orphan recovery share one Gateway stream-terminalization path: after `RunManager` durably marks a run `error` with `stop_reason=orphan_recovered`, Gateway publishes `END_SENTINEL` and schedules stream cleanup. The periodic store scan, per-row status writes, and Gateway callback run as one supervised single-flight task, so a slow pass is skipped at the next interval instead of piling up or pausing the sole lease-renewal loop. Store retries have bounded attempts/backoff; an individual operation still relies on the database driver/pool timeout. `RunManager.shutdown()` gives active user runs priority within its shared deadline, then drains or cancels orphan recovery. Gateway tracks delayed recovered-stream cleanups and converts unfinished delays to immediate deletes before closing the bridge; the Redis TTL remains the outage safety net. Only startup recovery, before the runtime yields to requests, projects the latest affected thread to `error`; periodic recovery deliberately avoids that non-atomic projection because `ThreadMetaStore` has no `latest_run_id` conditional-update contract. Store-only SSE and `/wait` consumers wait for the bridge's real END marker after an ordinary durable terminal status, because status persistence can precede tail events. The explicit `orphan_recovered` signal is the only heartbeat fallback: its publisher is known to be gone, so it supplies the liveness boundary if END publication fails or the retained key expires. Malformed `Last-Event-ID` reconnect values live-tail new Redis events rather than replaying the retained buffer. Keep cross-component recovery orchestration in Gateway through the generic `RunManager.on_orphans_recovered` callback; do not introduce a harness-to-app dependency. Callback failure warnings include every recovered `run_id` so operators can identify rows whose Gateway-side terminalization needs inspection.
-- Thread-scoped run creation accepts `checkpoint` / `checkpoint_id`; Gateway validates the checkpoint belongs to the request thread before writing `checkpoint_id` / `checkpoint_ns` into `config.configurable` for LangGraph branching.
+- Thread-scoped run creation accepts `checkpoint` / `checkpoint_id`; Gateway validates the checkpoint belongs to the request thread before writing `checkpoint_id` / `checkpoint_ns` into `config.configurable` for LangGraph branching. In `delta` checkpoint mode the worker rewrites that fork into a linear head write before the graph starts (see "A delta-mode run cannot fork" under Checkpoint Channel Modes), because delta state for a fork replays the abandoned sibling's writes.
- Thread-scoped Gateway runs evaluate an active `ThreadState.goal` after the visible turn completes. `runtime/goal.py` asks a non-thinking evaluator model to judge only visible conversation evidence and return a typed blocker; the evaluator model is created once per run and reused across hidden continuation checks. The evaluator runs after the graph root's tracing scope has already closed, so `create_goal_evaluator_model`/`evaluate_goal_completion` attach their own model-level tracing callbacks (`attach_tracing=True`) and inject Langfuse trace metadata (`thread_id`/`user_id`/`deerflow_trace_id`) directly onto the `ainvoke` call — the same standalone-caller pattern as `oneshot_llm.run_oneshot_llm` and `MemoryUpdater` (see Tracing System below). Satisfied goals are cleared; every non-satisfied evaluation — continuable or stand-down — is persisted with `last_evaluation` (the blocker, reason, and evidence summary; outcomes that stop the loop additionally record a `stand_down_reason` for observability), but only `goal_not_met_yet` evaluations are streamed as hidden `HumanMessage` continuations, and only when a durable assistant end-of-turn checkpoint exists, the run has not been aborted, the thread did not change during evaluation, and the no-progress breaker has not fired. The continuation cap is 8 — a hard maximum in the `0`–`8` range; callers requesting more are clamped (`set_goal`/TUI) or rejected with 422 (`PUT /goal`). The no-progress breaker keys on the latest visible assistant evidence (not the evaluator's free-text reason, which an LLM rewords every turn), so two consecutive continuations that add no new visible assistant output stop the loop after 2 attempts. Model-response cleanup helpers such as think-block stripping and code-fence stripping live in `deerflow.utils.llm_text` so `runtime/goal.py` and Gateway suggestion parsing share the same JSON-prep behavior.
- Run event stream changes must keep producer code, `deerflow/constants.py`, `runtime/events/catalog.py`, `contracts/run_event_stream_contract.json`, `backend/docs/RUN_EVENT_STREAM.md`, and `tests/test_run_event_stream_contract.py` in sync. The dependency-free constants module owns the persisted envelope limits (`event_type` 32 characters, `category` 16) and cross-layer workspace event identity; the catalog owns validated runtime definitions and categories. Dynamic middleware tags are limited to 21 characters after the `middleware:` prefix. The JSON contract owns payload schemas, backend-specific storage semantics, legacy aliases, and compatibility rules; conformance tests require both views and all producer groups to agree. `run.end.content` remains opaque and may retain nested Python values in memory while JSONL/database stores stringify non-JSON nested values, so consumers must not assume backend-identical nested output representations.
@@ -908,9 +908,11 @@ Checkpointer storage runs in one of two channel modes, selected by `checkpoint_c
**Replay checkpoint lookup prefers lineage and degrades only for an explicitly missing legacy parent link.** Branch and regenerate paths first walk `parent_config`, which prevents a global chronological scan from selecting a sibling created by regeneration. `CheckpointParentMissingError` alone enables the bounded newest-first history fallback in `app/gateway/checkpoint_lineage.py`; cycles, dangling/non-addressable parents, target mismatches, and depth exhaustion raise `CheckpointLineageIntegrityError` and fail closed instead of selecting a sibling. The compatibility scans request 400 raw checkpoints so up to 200 duration-only entries do not consume the effective branch-history budget; the fallback scans oldest-to-newest internally, skips duration-only checkpoints, and accepts only checkpoints with an addressable id as the replay base. A source history with no discoverable pre-user checkpoint preserves the historical single-checkpoint branch behavior instead of rejecting the branch; regeneration remains unavailable for that inherited response. Existing single-checkpoint branches are not mutated by regenerate preparation, and no raw checkpoint tuple is copied across threads because delta state depends on ancestry and pending writes. Regenerate source-run lookup uses the current thread's exact event, then the server-stamped `run_id` on the copied human message, then verified RunManager content matching; it does not read parent-thread events. Storage or checkpoint-mode failures are not treated as a missing base and still fail closed.
-**Wholesale message replacement uses a state-only mutation graph + `Overwrite`.** `update_state` values pass through channel reducers (`add_messages` merge in full, append in delta), so replacing `messages` wholesale (run rollback, context compaction) requires `{"messages": Overwrite([...])}`. The write goes through `build_state_mutation_graph(as_node, mode, state_schema)` — when the write carries materialized state, `state_schema` MUST be the thread's effective schema (`graph_state_schema(assistant_graph)`), because the base-ThreadState fallback silently discards written channels contributed by custom `AgentMiddleware.state_schema`. Channels absent from the write are unaffected: forked checkpoints clone the parent's channel blobs, so middleware channels survive rollback/compaction regardless of schema (locked by `test_rollback_preserves_middleware_contributed_channels` and `test_compact_thread_context_preserves_middleware_contributed_channels`) — a compiled graph with one no-op node (entry = finish) whose checkpoint machinery (channels/versions/metadata) is identical to the agent graph's but schedules no pending tasks, so the restored/compacted head stays idle instead of re-triggering the agent. Never hand-write checkpoints via `checkpointer.aput` for this; raw writers elsewhere must preserve checkpoint parentage — severed ancestry breaks delta replay (see `runtime/runs/worker.py` writer parenting and `checkpoint_patches.py`).
+**A delta-mode run cannot fork; `runtime/runs/worker.py` linearizes the resume instead.** Resuming from an older checkpoint (regenerate, or any client-supplied `checkpoint`) forks the lineage, and delta state for a fork is not materializable: `BaseCheckpointSaver.get_delta_channel_history` — and the bespoke overrides in `InMemorySaver`/`PostgresSaver` — collect **every** `pending_writes` entry stored on each on-path ancestor, but a shared parent also carries the writes of the sibling child that was abandoned. Those writes replay into the fork, so the run starts from a message list still containing the answer it was supposed to replace (#4458: regenerating in a branched thread showed the superseded assistant message beside the new one after a reload; reproduced on postgres, sqlite, and the in-memory saver). Write-to-child ownership belongs to the upstream delta contract, so DeerFlow does not reimplement the walk: `_linearize_delta_checkpoint_resume` materializes the requested checkpoint's complete state and writes every channel onto the **current head** (which has no siblings) through the state mutation graph, using `Overwrite` for reducer channels and resetting newer head-only channels to their schema default (or `None` when no constructible default exists); it then drops the `checkpoint_id` selector and lets the run proceed linearly, while the abandoned turn stays in history as the rewritten head's ancestry. The worker holds `_checkpoint_thread_lock` across `_capture_rollback_point` and the optional linear rewrite, making the rollback snapshot and rewrite atomic with graph streaming and the preceding run's duration-metadata checkpoint write. Capture preserves the complete real pre-run state; cancel-with-rollback then linearly replaces the current delta head with that captured state rather than forking the now-shared pre-run checkpoint, so the abandoned turn is restored without replaying the resume sibling's writes. The worker also recomputes the current-run message boundary from the rewritten state and fails closed (an unreadable resume checkpoint raises rather than falling back to the corrupt fork). `full` mode keeps forking — its checkpoints carry complete `channel_values` and need no replay — so LangGraph branching semantics are unchanged there. Root namespace only; subgraph namespaces are left alone.
-**Run rollback flow** (`runtime/runs/worker.py`): `_capture_rollback_point` materializes pre-run state (messages via accessor, raw `pending_writes` via `aget_tuple`) into an immutable `RollbackPoint` before the run starts — capture failure disables rollback (fail-closed), never restores partial state. Cancel-with-rollback then forks from the pre-run checkpoint via the mutation graph and replays the captured pending writes.
+**Wholesale state replacement uses a state-only mutation graph + `Overwrite`.** `update_state` values pass through channel reducers (`add_messages` merge in full, append in delta), so replacing reducer values requires `Overwrite` rather than an ordinary update. Full-mode rollback and context compaction replace `messages`; delta resume and delta rollback replace every materialized channel and reset current-head-only channels to their schema default (or `None`). These writes go through `build_state_mutation_graph(as_node, mode, state_schema)`, and `state_schema` MUST be the thread's effective schema (`graph_state_schema(assistant_graph)`), because the base-ThreadState fallback silently discards written channels contributed by custom `AgentMiddleware.state_schema`. Channels absent from a full-mode fork write inherit the parent's channel blobs, so middleware channels survive rollback/compaction (locked by `test_rollback_preserves_middleware_contributed_channels` and `test_compact_thread_context_preserves_middleware_contributed_channels`). The compiled mutation graph has one no-op node (entry = finish) whose checkpoint machinery (channels/versions/metadata) is identical to the agent graph's but schedules no pending tasks, so the restored/compacted head stays idle instead of re-triggering the agent. Never hand-write checkpoints via `checkpointer.aput` for this; raw writers elsewhere must preserve checkpoint parentage — severed ancestry breaks delta replay (see `runtime/runs/worker.py` writer parenting and `checkpoint_patches.py`).
+
+**Run rollback flow** (`runtime/runs/worker.py`): `_capture_rollback_point` materializes the complete pre-run state via the accessor and captures raw `pending_writes` via `aget_tuple` into an immutable `RollbackPoint` before the run starts — capture failure disables rollback (fail-closed), never restores partial state. In `full` mode, cancel-with-rollback forks from the pre-run checkpoint via the mutation graph and inherits non-message channels from that parent. In `delta` mode, forking is unsafe once the cancelled path has attached sibling writes to the pre-run checkpoint, so rollback replaces every captured channel on the current head, using `Overwrite` for reducers and schema defaults for current-head-only channels. Both modes reattach only the captured pre-run pending writes to the restored checkpoint.
**Where things live**:
- `runtime/checkpoint_mode.py` — mode freeze, marker injection, delta detection, compatibility gate, both error types
diff --git a/backend/packages/harness/deerflow/runtime/runs/worker.py b/backend/packages/harness/deerflow/runtime/runs/worker.py
index cd8cfd17d..7af4c76f8 100644
--- a/backend/packages/harness/deerflow/runtime/runs/worker.py
+++ b/backend/packages/harness/deerflow/runtime/runs/worker.py
@@ -40,7 +40,13 @@ from deerflow.runtime.checkpoint_mode import (
aensure_checkpoint_mode_compatible,
inject_checkpoint_mode,
)
-from deerflow.runtime.checkpoint_state import CheckpointStateAccessor, build_state_mutation_graph, graph_state_schema
+from deerflow.runtime.checkpoint_state import (
+ CheckpointStateAccessor,
+ build_state_mutation_graph,
+ graph_reducer_channels,
+ graph_state_schema,
+ graph_writable_channels,
+)
from deerflow.runtime.context_keys import CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY
from deerflow.runtime.goal import (
DEFAULT_MAX_GOAL_CONTINUATIONS,
@@ -647,14 +653,38 @@ async def run_agent(
# failure disables rollback: restoring an empty or partial message
# history would silently truncate the thread.
if checkpointer is not None:
- try:
- rollback_point = await _capture_rollback_point(accessor, checkpointer, checkpoint_config)
- except Exception:
- snapshot_capture_failed = True
- logger.warning("Could not capture pre-run checkpoint snapshot for run %s", run_id, exc_info=True)
- if rollback_point is not None:
- pre_run_checkpoint_id = rollback_point.config.get("configurable", {}).get("checkpoint_id")
- pre_existing_message_ids = _collect_pre_existing_message_ids({"messages": list(rollback_point.messages)})
+ # A previous successful run may still be persisting duration
+ # metadata after its active admission slot is released. Share its
+ # checkpoint lock so the rollback snapshot and any resume rewrite
+ # are one uninterrupted read/write sequence against the head.
+ async with _checkpoint_thread_lock(thread_id):
+ try:
+ rollback_point = await _capture_rollback_point(accessor, checkpointer, checkpoint_config)
+ except Exception:
+ snapshot_capture_failed = True
+ logger.warning("Could not capture pre-run checkpoint snapshot for run %s", run_id, exc_info=True)
+ if rollback_point is not None:
+ pre_run_checkpoint_id = rollback_point.config.get("configurable", {}).get("checkpoint_id")
+ pre_existing_message_ids = _collect_pre_existing_message_ids({"messages": list(rollback_point.messages)})
+
+ # Resuming from an older checkpoint is a fork, and a delta fork
+ # materializes the abandoned sibling's writes back into state
+ # (#4458). Rewrite it as a linear head write *after* the rollback
+ # point is captured, so cancel-with-rollback still restores the
+ # real pre-run head rather than the rolled-back one.
+ resumed_messages = await _linearize_delta_checkpoint_resume(
+ accessor=accessor,
+ checkpointer=checkpointer,
+ config=config,
+ thread_id=thread_id,
+ run_id=run_id,
+ )
+ if resumed_messages is not None:
+ # The graph now starts from the selected state, so the
+ # current-run message boundary is that state, not the head we
+ # captured for rollback.
+ pre_existing_message_ids = _collect_pre_existing_message_ids({"messages": list(resumed_messages)})
+ initial_runnable_config = RunnableConfig(**config)
runtime_ctx[CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY] = frozenset(pre_existing_message_ids)
_install_runtime_context(config, runtime_ctx)
@@ -1354,15 +1384,16 @@ async def _prepare_goal_continuation_input(
@dataclass(frozen=True)
class RollbackPoint:
- """Materialized pre-run state used to fork the pre-run checkpoint lineage.
+ """Materialized pre-run state used to restore the thread after cancellation.
Raw checkpoint blobs cannot reconstruct Delta-channel messages (their
- checkpoints omit ``channel_values``), so rollback restores messages by
- applying an ``Overwrite`` through a state-mutation graph anchored at the
- pre-run checkpoint instead of cloning the raw blob.
+ checkpoints omit the materialized value), so rollback preserves those
+ messages plus delta mode's materialized non-message state in addition to
+ the raw pending writes.
"""
config: dict[str, Any]
+ state_values: dict[str, Any]
messages: tuple[Any, ...]
metadata: dict[str, Any]
pending_writes: tuple[tuple[str, str, Any], ...]
@@ -1384,8 +1415,9 @@ async def _capture_rollback_point(
if not configurable.get("checkpoint_id"):
return None
checkpoint_tuple = await _call_checkpointer_method(checkpointer, "aget_tuple", "get_tuple", snapshot_config)
- values = getattr(snapshot, "values", None) or {}
- messages = values.get("messages") if isinstance(values, dict) else None
+ raw_values = getattr(snapshot, "values", None) or {}
+ messages = raw_values.get("messages") if isinstance(raw_values, dict) else None
+ state_values = copy.deepcopy({key: value for key, value in raw_values.items() if key != "messages"}) if accessor.mode == "delta" and isinstance(raw_values, dict) else {}
return RollbackPoint(
config={
"configurable": {
@@ -1394,12 +1426,132 @@ async def _capture_rollback_point(
"checkpoint_id": configurable.get("checkpoint_id"),
}
},
+ state_values=state_values,
messages=tuple(messages or ()),
metadata=dict(getattr(snapshot, "metadata", None) or {}),
pending_writes=tuple(getattr(checkpoint_tuple, "pending_writes", ()) or ()),
)
+def _complete_state_replacement_values(
+ *,
+ mutation_graph: Any,
+ selected_values: dict[str, Any],
+ current_values: dict[str, Any],
+ run_id: str,
+ operation: str,
+) -> dict[str, Any]:
+ """Build a whole-state replacement through the graph's effective schema."""
+ writable_fields = graph_writable_channels(mutation_graph)
+ reducer_fields = graph_reducer_channels(mutation_graph)
+ if writable_fields is None or reducer_fields is None:
+ raise RuntimeError(f"Run {run_id} could not inspect the state schema for {operation}")
+
+ replacement_values: dict[str, Any] = {}
+ for field_name in writable_fields:
+ if field_name in selected_values:
+ replacement = copy.deepcopy(selected_values[field_name])
+ elif field_name in current_values:
+ # LangGraph has no public "unset channel" update. A fresh channel
+ # exposes its schema default when one exists (for example [] / {});
+ # optional and otherwise-unconstructible channels reset to None.
+ channel = mutation_graph.channels.get(field_name)
+ replacement = copy.deepcopy(channel.get()) if channel is not None and channel.is_available() else None
+ else:
+ continue
+ replacement_values[field_name] = Overwrite(replacement) if field_name in reducer_fields else replacement
+ return replacement_values
+
+
+async def _linearize_delta_checkpoint_resume(
+ *,
+ accessor: CheckpointStateAccessor,
+ checkpointer: Any,
+ config: dict[str, Any],
+ thread_id: str,
+ run_id: str,
+) -> list[Any] | None:
+ """Replace a delta-mode checkpoint fork with an equivalent linear write.
+
+ Resuming from an older checkpoint forks the lineage, and in ``delta`` mode
+ the fork's state cannot be materialized correctly: the delta history walk
+ collects **every** ``pending_writes`` entry stored on each on-path
+ ancestor, but a shared parent also carries the writes of the sibling child
+ that was abandoned. Those writes are replayed into the fork, so the run
+ starts from a message list that still contains the answer it was supposed
+ to replace — regenerating in a branched thread surfaced this as the old
+ assistant message reappearing beside the new one after a reload (#4458).
+ Reproduced on postgres, sqlite, and the in-memory saver; ``full`` mode is
+ unaffected because its checkpoints carry complete ``channel_values`` and
+ need no replay.
+
+ The upstream contract (`BaseCheckpointSaver.get_delta_channel_history` and
+ the savers overriding it) is where write-to-child ownership belongs, so
+ this does not reimplement it. Instead the fork is expressed as what it
+ means: materialize the requested checkpoint's state and write it with
+ replace semantics on the **current head**, which has no other children,
+ then run linearly. Every materialized channel is restored; channels that
+ exist only on the newer head are reset to their schema default (or
+ ``None`` when the channel has no constructible default). The abandoned
+ turn stays in checkpoint history as the rewritten head's ancestry.
+
+ Returns the materialized messages when the resume was linearized, or
+ ``None`` when there was nothing to do (full mode, no checkpoint selector,
+ a non-root namespace, or a selector that already names the head). Failures
+ propagate: silently falling back to the fork would persist the corrupted
+ history this exists to prevent. The worker call site holds
+ ``_checkpoint_thread_lock`` across rollback capture and this rewrite; do
+ not reacquire that non-reentrant lock inside this helper.
+ """
+ if checkpointer is None or accessor.mode != "delta":
+ return None
+ configurable = config.get("configurable")
+ if not isinstance(configurable, dict):
+ return None
+ checkpoint_id = configurable.get("checkpoint_id")
+ if not isinstance(checkpoint_id, str) or not checkpoint_id:
+ return None
+ if configurable.get("checkpoint_ns"):
+ # Subgraph namespaces have their own lineage; the Gateway only selects
+ # root checkpoints, so leave anything else untouched.
+ return None
+
+ head_config: dict[str, Any] = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
+ head = await accessor.aget(head_config)
+ if _checkpoint_id(head) == checkpoint_id:
+ # Selecting the head is already linear — no sibling can exist yet.
+ return None
+
+ source_config: dict[str, Any] = {"configurable": {"thread_id": thread_id, "checkpoint_ns": "", "checkpoint_id": checkpoint_id}}
+ snapshot = await accessor.aget(source_config)
+ values = getattr(snapshot, "values", None) or {}
+ messages = values.get("messages") if isinstance(values, dict) else None
+ if not isinstance(messages, list):
+ raise RuntimeError(f"Run {run_id} could not materialize resume checkpoint {checkpoint_id}")
+
+ # Write through the thread's effective schema so every application and
+ # middleware channel can be restored. Reducer channels need Overwrite to
+ # replace their already-aggregated value instead of merging it again.
+ mutation_graph = build_state_mutation_graph("checkpoint_resume", accessor.mode, graph_state_schema(getattr(accessor, "graph", None)))
+ selected_values = dict(values)
+ head_values = getattr(head, "values", None) or {}
+ head_values = dict(head_values) if isinstance(head_values, dict) else {}
+ replacement_values = _complete_state_replacement_values(
+ mutation_graph=mutation_graph,
+ selected_values=selected_values,
+ current_values=head_values,
+ run_id=run_id,
+ operation="checkpoint resume",
+ )
+
+ mutation_accessor = CheckpointStateAccessor.bind(mutation_graph, checkpointer, mode=accessor.mode)
+ await mutation_accessor.aupdate(head_config, replacement_values, as_node="checkpoint_resume")
+ configurable.pop("checkpoint_id", None)
+ configurable.pop("checkpoint_map", None)
+ logger.info("Run %s linearized a delta-mode resume of checkpoint %s onto thread %s", run_id, checkpoint_id, thread_id)
+ return list(messages)
+
+
async def _rollback_to_pre_run_checkpoint(
*,
accessor: CheckpointStateAccessor | None,
@@ -1409,13 +1561,14 @@ async def _rollback_to_pre_run_checkpoint(
rollback_point: RollbackPoint | None,
snapshot_capture_failed: bool,
) -> None:
- """Fork the pre-run checkpoint lineage with the pre-run messages restored.
+ """Restore the complete pre-run state after a cancelled run.
- The fork is written through a state-only mutation graph (the synthetic
- ``rollback_restore`` node must be registered for ``as_node`` and finishes
- immediately so no agent nodes are scheduled). LangGraph owns the restored
- checkpoint's source/step/channel versions/parent/timestamp; the parent
- pointer back to the pre-run checkpoint is the audit trail.
+ Full mode forks the captured pre-run checkpoint and overwrites messages;
+ all other channels inherit from that parent. Delta mode cannot safely fork
+ once the cancelled path has attached writes to the same parent, so it
+ replaces every captured channel on the current head instead. Both writes
+ use a state-only mutation graph whose synthetic ``rollback_restore`` node
+ finishes immediately and schedules no agent work.
"""
if checkpointer is None:
logger.info("Run %s rollback requested but no checkpointer is configured", run_id)
@@ -1441,15 +1594,35 @@ async def _rollback_to_pre_run_checkpoint(
logger.warning("Run %s rollback skipped: agent accessor unavailable", run_id)
return
- # The restored checkpoint inherits every channel from the pre-run fork;
- # compile the mutation graph with the thread's effective schema so
- # middleware-contributed channels survive (the base ThreadState fallback
- # would silently drop them).
+ # Compile with the thread's effective schema so middleware-contributed
+ # channels survive (the base ThreadState fallback would silently drop
+ # them).
mutation_graph = build_state_mutation_graph("rollback_restore", accessor.mode, graph_state_schema(getattr(accessor, "graph", None)))
mutation_accessor = CheckpointStateAccessor.bind(mutation_graph, checkpointer, mode=accessor.mode)
+ if accessor.mode == "delta":
+ # A delta rollback fork has the same write-ownership problem as a
+ # checkpoint resume: the captured parent now carries writes from the
+ # cancelled sibling. Restore linearly on the current head instead.
+ restore_config: dict[str, Any] = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
+ current = await accessor.aget(restore_config)
+ raw_current_values = getattr(current, "values", None) or {}
+ current_values = dict(raw_current_values) if isinstance(raw_current_values, dict) else {}
+ selected_values = copy.deepcopy(rollback_point.state_values)
+ selected_values["messages"] = list(rollback_point.messages)
+ replacement_values = _complete_state_replacement_values(
+ mutation_graph=mutation_graph,
+ selected_values=selected_values,
+ current_values=current_values,
+ run_id=run_id,
+ operation="rollback",
+ )
+ else:
+ restore_config = rollback_point.config
+ replacement_values = {"messages": Overwrite(list(rollback_point.messages))}
+
restored_config = await mutation_accessor.aupdate(
- rollback_point.config,
- {"messages": Overwrite(list(rollback_point.messages))},
+ restore_config,
+ replacement_values,
as_node="rollback_restore",
)
if not isinstance(restored_config, dict):
diff --git a/backend/tests/test_run_worker_delta_resume.py b/backend/tests/test_run_worker_delta_resume.py
new file mode 100644
index 000000000..d64152bf4
--- /dev/null
+++ b/backend/tests/test_run_worker_delta_resume.py
@@ -0,0 +1,495 @@
+"""Delta-mode checkpoint resume linearization (#4458).
+
+Resuming a run from an older checkpoint forks the lineage. In ``delta`` mode
+that fork cannot be materialized correctly — the delta history walk replays
+every ``pending_writes`` entry stored on each on-path ancestor, including the
+writes of the sibling child that was abandoned — so the run starts from a
+message list that still contains the answer it was meant to replace. These
+tests pin the worker's linearization: the requested state is written onto the
+current head (which has no siblings) and the run proceeds linearly.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from types import SimpleNamespace
+from typing import Annotated, Any, TypedDict
+from unittest.mock import AsyncMock
+
+import pytest
+from langchain_core.messages import AIMessage, AnyMessage, HumanMessage
+from langgraph.channels.delta import DeltaChannel
+from langgraph.checkpoint.memory import InMemorySaver
+from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
+from langgraph.graph import StateGraph
+from langgraph.graph.message import add_messages
+from langgraph.types import Overwrite
+
+from deerflow.agents.thread_state import merge_message_writes
+from deerflow.runtime.checkpoint_state import CheckpointStateAccessor, build_state_mutation_graph
+from deerflow.runtime.runs.manager import RunManager
+from deerflow.runtime.runs.schemas import RunStatus
+from deerflow.runtime.runs.worker import RunContext, _checkpoint_thread_lock, _linearize_delta_checkpoint_resume, run_agent
+
+pytestmark = pytest.mark.anyio
+
+
+@pytest.fixture
+def anyio_backend():
+ return "asyncio"
+
+
+class _DeltaChannelState(TypedDict):
+ messages: Annotated[list[AnyMessage], DeltaChannel(merge_message_writes, snapshot_frequency=1000)]
+
+
+class _FullChannelState(TypedDict):
+ messages: Annotated[list[AnyMessage], add_messages]
+
+
+def _replace_optional_state(existing: dict[str, str] | None, new: dict[str, str] | None) -> dict[str, str] | None:
+ return existing if new is None else new
+
+
+class _ExtendedDeltaChannelState(_DeltaChannelState):
+ notes: Annotated[list[AnyMessage], add_messages]
+ marker: str | None
+ head_only: Annotated[dict[str, str] | None, _replace_optional_state]
+ head_last_only: str | None
+
+
+def _build_answer_graph(state_schema: type, checkpointer: Any, answer_id: str):
+ async def _answer(state: dict[str, Any]) -> dict[str, Any]:
+ return {"messages": [AIMessage(content=f"answer for {answer_id}", id=answer_id)]}
+
+ builder = StateGraph(state_schema)
+ builder.add_node("answer", _answer)
+ builder.set_entry_point("answer")
+ builder.set_finish_point("answer")
+ return builder.compile(checkpointer=checkpointer)
+
+
+def _ids(snapshot: Any) -> list[str]:
+ return [message.id for message in (snapshot.values or {}).get("messages", [])]
+
+
+def _run_config(thread_id: str, checkpoint_id: str | None = None) -> dict[str, Any]:
+ configurable: dict[str, Any] = {"thread_id": thread_id, "checkpoint_ns": ""}
+ if checkpoint_id is not None:
+ configurable["checkpoint_id"] = checkpoint_id
+ return {"configurable": configurable}
+
+
+async def _seed_two_turns(checkpointer: Any, state_schema: type, thread_id: str):
+ """h1 -> a1, h2 -> a2, returning (accessor, head, pre-h2 snapshot)."""
+ config = _run_config(thread_id)
+ await _build_answer_graph(state_schema, checkpointer, "a1").ainvoke({"messages": [HumanMessage(content="q1", id="h1")]}, config)
+ graph = _build_answer_graph(state_schema, checkpointer, "a2")
+ await graph.ainvoke({"messages": [HumanMessage(content="q2", id="h2")]}, config)
+
+ mode = "delta" if state_schema is _DeltaChannelState else "full"
+ accessor = CheckpointStateAccessor.bind(graph, checkpointer, mode=mode)
+ head = await accessor.aget(config)
+ history = await accessor.ahistory(config, limit=20)
+ pre_turn = next(snapshot for snapshot in history if "h2" not in _ids(snapshot))
+ return accessor, head, pre_turn
+
+
+async def test_linearizes_a_delta_resume_onto_the_head():
+ checkpointer = InMemorySaver()
+ accessor, head, pre_turn = await _seed_two_turns(checkpointer, _DeltaChannelState, "thread-1")
+ base_id = pre_turn.config["configurable"]["checkpoint_id"]
+ config = _run_config("thread-1", base_id)
+
+ resumed = await _linearize_delta_checkpoint_resume(
+ accessor=accessor,
+ checkpointer=checkpointer,
+ config=config,
+ thread_id="thread-1",
+ run_id="run-1",
+ )
+
+ assert [message.id for message in resumed] == ["h1", "a1"]
+ # The selector is consumed: the run continues on the (rewritten) head.
+ assert "checkpoint_id" not in config["configurable"]
+ new_head = await accessor.aget(_run_config("thread-1"))
+ assert _ids(new_head) == ["h1", "a1"]
+ assert new_head.config["configurable"]["checkpoint_id"] != head.config["configurable"]["checkpoint_id"]
+
+
+async def test_linearization_restores_all_selected_state_and_clears_newer_channels():
+ checkpointer = InMemorySaver()
+ config = _run_config("thread-state")
+
+ first_graph = _build_answer_graph(_ExtendedDeltaChannelState, checkpointer, "a1")
+ await first_graph.ainvoke(
+ {
+ "messages": [HumanMessage(content="q1", id="h1")],
+ "notes": [HumanMessage(content="selected", id="note-selected")],
+ "marker": "selected",
+ },
+ config,
+ )
+ accessor = CheckpointStateAccessor.bind(first_graph, checkpointer, mode="delta")
+ selected = await accessor.aget(config)
+
+ second_graph = _build_answer_graph(_ExtendedDeltaChannelState, checkpointer, "a2")
+ await second_graph.ainvoke(
+ {
+ "messages": [HumanMessage(content="q2", id="h2")],
+ "notes": [HumanMessage(content="newer", id="note-newer")],
+ "marker": "newer",
+ "head_only": {"value": "must-not-leak"},
+ "head_last_only": "must-not-leak",
+ },
+ config,
+ )
+ accessor = CheckpointStateAccessor.bind(second_graph, checkpointer, mode="delta")
+ selected_id = selected.config["configurable"]["checkpoint_id"]
+
+ await _linearize_delta_checkpoint_resume(
+ accessor=accessor,
+ checkpointer=checkpointer,
+ config=_run_config("thread-state", selected_id),
+ thread_id="thread-state",
+ run_id="run-state",
+ )
+
+ rewritten = await accessor.aget(config)
+ assert _ids(rewritten) == ["h1", "a1"]
+ assert [message.id for message in rewritten.values["notes"]] == ["note-selected"]
+ assert rewritten.values["marker"] == "selected"
+ assert rewritten.values.get("head_only") is None
+ assert rewritten.values.get("head_last_only") is None
+
+
+async def test_regenerating_in_a_branched_thread_does_not_resurrect_the_old_answer(tmp_path):
+ """The #4458 shape end-to-end on a persistent saver.
+
+ A branch writes two synthetic checkpoints (replay base + visible head);
+ regenerating the inherited answer resumes from the replay base. Without
+ linearization the delta walk replays the branch head's own ``Overwrite``
+ — which is stored on that shared parent — and the superseded assistant
+ message comes back alongside the new one.
+ """
+ db_path = tmp_path / "branch.sqlite3"
+ async with AsyncSqliteSaver.from_conn_string(str(db_path)) as checkpointer:
+ await checkpointer.setup()
+ _, source_head, source_pre_turn = await _seed_two_turns(checkpointer, _DeltaChannelState, "parent")
+
+ mutation_graph = build_state_mutation_graph("branch", "delta", _DeltaChannelState)
+ branch_writer = CheckpointStateAccessor.bind(mutation_graph, checkpointer, mode="delta")
+ replay_base_config = await branch_writer.aupdate(
+ _run_config("branch"),
+ {"messages": Overwrite(list(source_pre_turn.values["messages"]))},
+ as_node="branch",
+ )
+ await branch_writer.aupdate(
+ replay_base_config,
+ {"messages": Overwrite(list(source_head.values["messages"]))},
+ as_node="branch",
+ )
+
+ graph = _build_answer_graph(_DeltaChannelState, checkpointer, "a2-new")
+ accessor = CheckpointStateAccessor.bind(graph, checkpointer, mode="delta")
+ branch_history = await accessor.ahistory(_run_config("branch"), limit=20)
+ base = next(snapshot for snapshot in branch_history if "h2" not in _ids(snapshot))
+ config = _run_config("branch", base.config["configurable"]["checkpoint_id"])
+
+ await _linearize_delta_checkpoint_resume(
+ accessor=accessor,
+ checkpointer=checkpointer,
+ config=config,
+ thread_id="branch",
+ run_id="run-regen",
+ )
+ # The regenerate run replays the same human message and answers again.
+ await graph.ainvoke({"messages": [HumanMessage(content="q2", id="h2")]}, config)
+
+ final = await accessor.aget(_run_config("branch"))
+ assert _ids(final) == ["h1", "a1", "h2", "a2-new"]
+
+
+async def test_run_agent_streams_from_the_linearized_delta_resume(tmp_path):
+ """Pin selector removal through ``run_agent`` and its stream config.
+
+ Helper-level tests would still pass if the worker later streamed from the
+ stale ``RunnableConfig`` built before linearization. This exercises the
+ production boundary that hands the rewritten config to ``agent.astream``.
+ """
+ db_path = tmp_path / "worker-branch.sqlite3"
+ async with AsyncSqliteSaver.from_conn_string(str(db_path)) as checkpointer:
+ await checkpointer.setup()
+ _, source_head, source_pre_turn = await _seed_two_turns(checkpointer, _DeltaChannelState, "worker-parent")
+
+ mutation_graph = build_state_mutation_graph("branch", "delta", _DeltaChannelState)
+ branch_writer = CheckpointStateAccessor.bind(mutation_graph, checkpointer, mode="delta")
+ replay_base_config = await branch_writer.aupdate(
+ _run_config("worker-branch"),
+ {"messages": Overwrite(list(source_pre_turn.values["messages"]))},
+ as_node="branch",
+ )
+ await branch_writer.aupdate(
+ replay_base_config,
+ {"messages": Overwrite(list(source_head.values["messages"]))},
+ as_node="branch",
+ )
+
+ read_graph = _build_answer_graph(_DeltaChannelState, checkpointer, "unused")
+ read_accessor = CheckpointStateAccessor.bind(read_graph, checkpointer, mode="delta")
+ branch_history = await read_accessor.ahistory(_run_config("worker-branch"), limit=20)
+ base = next(snapshot for snapshot in branch_history if "h2" not in _ids(snapshot))
+
+ run_manager = RunManager()
+ record = await run_manager.create("worker-branch")
+ bridge = SimpleNamespace(
+ publish=AsyncMock(),
+ publish_end=AsyncMock(),
+ cleanup=AsyncMock(),
+ )
+ created_graphs: list[Any] = []
+
+ def agent_factory(*, config):
+ del config
+ graph = _build_answer_graph(_DeltaChannelState, checkpointer, "a2-new")
+ created_graphs.append(graph)
+ return graph
+
+ await asyncio.wait_for(
+ run_agent(
+ bridge,
+ run_manager,
+ record,
+ ctx=RunContext(checkpointer=checkpointer, checkpoint_channel_mode="delta"),
+ agent_factory=agent_factory,
+ graph_input={"messages": [HumanMessage(content="q2", id="h2")]},
+ config=_run_config("worker-branch", base.config["configurable"]["checkpoint_id"]),
+ stream_modes=["values"],
+ ),
+ timeout=5,
+ )
+
+ assert record.status == RunStatus.success
+ final_accessor = CheckpointStateAccessor.bind(created_graphs[-1], checkpointer, mode="delta")
+ final = await final_accessor.aget(_run_config("worker-branch"))
+ assert _ids(final) == ["h1", "a1", "h2", "a2-new"]
+
+
+async def test_run_agent_serializes_resume_preparation_with_checkpoint_writes(monkeypatch):
+ """Rollback capture and resume linearization share the checkpoint lock.
+
+ A prior successful run can still be persisting duration metadata after its
+ active run slot is released. Resume preparation must wait for that writer
+ so its head snapshot and linear rewrite cannot interleave with the
+ metadata checkpoint's compare-and-write sequence.
+ """
+ checkpointer = InMemorySaver()
+ graph = _build_answer_graph(_DeltaChannelState, checkpointer, "a1")
+ run_manager = RunManager()
+ record = await run_manager.create("worker-lock")
+ bridge = SimpleNamespace(
+ publish=AsyncMock(),
+ publish_end=AsyncMock(),
+ cleanup=AsyncMock(),
+ )
+ factory_called = asyncio.Event()
+ startup_calls: list[str] = []
+
+ def agent_factory(*, config):
+ del config
+ factory_called.set()
+ return graph
+
+ async def capture_rollback_point(*args, **kwargs):
+ del args, kwargs
+ startup_calls.append("capture")
+ return None
+
+ async def linearize_resume(**kwargs):
+ del kwargs
+ startup_calls.append("linearize")
+ return None
+
+ monkeypatch.setattr("deerflow.runtime.runs.worker._capture_rollback_point", capture_rollback_point)
+ monkeypatch.setattr("deerflow.runtime.runs.worker._linearize_delta_checkpoint_resume", linearize_resume)
+
+ async with _checkpoint_thread_lock("worker-lock"):
+ run_task = asyncio.create_task(
+ run_agent(
+ bridge,
+ run_manager,
+ record,
+ ctx=RunContext(checkpointer=checkpointer, checkpoint_channel_mode="delta"),
+ agent_factory=agent_factory,
+ graph_input={"messages": [HumanMessage(content="q1", id="h1")]},
+ config=_run_config("worker-lock"),
+ stream_modes=["values"],
+ )
+ )
+ await asyncio.wait_for(factory_called.wait(), timeout=1)
+ await asyncio.sleep(0)
+ assert startup_calls == []
+
+ await run_task
+ assert startup_calls == ["capture", "linearize"]
+
+
+async def test_cancelled_delta_resume_rolls_back_to_pre_linearization_head(tmp_path):
+ """Rollback must restore the head captured before resume linearization.
+
+ The worker deliberately captures its rollback point before rewriting a
+ selected delta checkpoint onto the head. If that ordering is reversed,
+ cancelling the resumed run would restore the selected historical state
+ and silently discard the abandoned turn that was present before the run.
+ """
+ db_path = tmp_path / "worker-rollback-branch.sqlite3"
+ async with AsyncSqliteSaver.from_conn_string(str(db_path)) as checkpointer:
+ await checkpointer.setup()
+ _, source_head, source_pre_turn = await _seed_two_turns(checkpointer, _DeltaChannelState, "worker-rollback-parent")
+
+ mutation_graph = build_state_mutation_graph("branch", "delta", _DeltaChannelState)
+ branch_writer = CheckpointStateAccessor.bind(mutation_graph, checkpointer, mode="delta")
+ replay_base_config = await branch_writer.aupdate(
+ _run_config("worker-rollback-branch"),
+ {"messages": Overwrite(list(source_pre_turn.values["messages"]))},
+ as_node="branch",
+ )
+ await branch_writer.aupdate(
+ replay_base_config,
+ {"messages": Overwrite(list(source_head.values["messages"]))},
+ as_node="branch",
+ )
+
+ read_graph = _build_answer_graph(_DeltaChannelState, checkpointer, "unused")
+ read_accessor = CheckpointStateAccessor.bind(read_graph, checkpointer, mode="delta")
+ pre_run_head = await read_accessor.aget(_run_config("worker-rollback-branch"))
+ assert _ids(pre_run_head) == ["h1", "a1", "h2", "a2"]
+ branch_history = await read_accessor.ahistory(_run_config("worker-rollback-branch"), limit=20)
+ base = next(snapshot for snapshot in branch_history if "h2" not in _ids(snapshot))
+
+ run_manager = RunManager()
+ record = await run_manager.create("worker-rollback-branch")
+ bridge = SimpleNamespace(
+ publish=AsyncMock(),
+ publish_end=AsyncMock(),
+ cleanup=AsyncMock(),
+ )
+ created_graphs: list[Any] = []
+
+ def agent_factory(*, config):
+ del config
+
+ async def _answer_then_abort(state: dict[str, Any]) -> dict[str, Any]:
+ del state
+ record.abort_action = "rollback"
+ record.abort_event.set()
+ return {"messages": [AIMessage(content="answer for a2-new", id="a2-new")]}
+
+ builder = StateGraph(_DeltaChannelState)
+ builder.add_node("answer", _answer_then_abort)
+ builder.set_entry_point("answer")
+ builder.set_finish_point("answer")
+ graph = builder.compile(checkpointer=checkpointer)
+ created_graphs.append(graph)
+ return graph
+
+ await run_agent(
+ bridge,
+ run_manager,
+ record,
+ ctx=RunContext(checkpointer=checkpointer, checkpoint_channel_mode="delta"),
+ agent_factory=agent_factory,
+ graph_input={"messages": [HumanMessage(content="q2", id="h2")]},
+ config=_run_config("worker-rollback-branch", base.config["configurable"]["checkpoint_id"]),
+ stream_modes=["values"],
+ )
+
+ assert record.status == RunStatus.error
+ final_accessor = CheckpointStateAccessor.bind(created_graphs[-1], checkpointer, mode="delta")
+ final = await final_accessor.aget(_run_config("worker-rollback-branch"))
+ assert _ids(final) == ["h1", "a1", "h2", "a2"]
+
+
+async def test_full_mode_keeps_the_fork():
+ """Full checkpoints carry complete channel values, so the fork materializes
+ correctly and LangGraph's branching semantics stay untouched."""
+ checkpointer = InMemorySaver()
+ accessor, _, pre_turn = await _seed_two_turns(checkpointer, _FullChannelState, "thread-1")
+ config = _run_config("thread-1", pre_turn.config["configurable"]["checkpoint_id"])
+
+ resumed = await _linearize_delta_checkpoint_resume(
+ accessor=accessor,
+ checkpointer=checkpointer,
+ config=config,
+ thread_id="thread-1",
+ run_id="run-1",
+ )
+
+ assert resumed is None
+ assert config["configurable"]["checkpoint_id"] == pre_turn.config["configurable"]["checkpoint_id"]
+
+
+async def test_ordinary_run_without_a_checkpoint_selector_is_untouched():
+ checkpointer = InMemorySaver()
+ accessor, _, _ = await _seed_two_turns(checkpointer, _DeltaChannelState, "thread-1")
+ config = _run_config("thread-1")
+
+ assert (
+ await _linearize_delta_checkpoint_resume(
+ accessor=accessor,
+ checkpointer=checkpointer,
+ config=config,
+ thread_id="thread-1",
+ run_id="run-1",
+ )
+ is None
+ )
+
+
+async def test_selecting_the_head_is_already_linear():
+ """No sibling can exist under the head yet, so there is nothing to rewrite
+ and the thread keeps its checkpoint count."""
+ checkpointer = InMemorySaver()
+ accessor, head, _ = await _seed_two_turns(checkpointer, _DeltaChannelState, "thread-1")
+ head_id = head.config["configurable"]["checkpoint_id"]
+ before = len(await accessor.ahistory(_run_config("thread-1"), limit=50))
+
+ assert (
+ await _linearize_delta_checkpoint_resume(
+ accessor=accessor,
+ checkpointer=checkpointer,
+ config=_run_config("thread-1", head_id),
+ thread_id="thread-1",
+ run_id="run-1",
+ )
+ is None
+ )
+ assert len(await accessor.ahistory(_run_config("thread-1"), limit=50)) == before
+
+
+async def test_unmaterializable_resume_state_fails_closed():
+ """Falling back to the fork would persist the corrupted history this
+ exists to prevent, so an unreadable resume checkpoint raises."""
+ checkpointer = InMemorySaver()
+ accessor, _, pre_turn = await _seed_two_turns(checkpointer, _DeltaChannelState, "thread-1")
+
+ class _NoMessages:
+ def __init__(self, inner):
+ self._inner = inner
+ self.mode = inner.mode
+ self.graph = inner.graph
+
+ async def aget(self, config):
+ snapshot = await self._inner.aget(config)
+ if config.get("configurable", {}).get("checkpoint_id"):
+ return type(snapshot)(**{**snapshot._asdict(), "values": {}})
+ return snapshot
+
+ with pytest.raises(RuntimeError, match="could not materialize resume checkpoint"):
+ await _linearize_delta_checkpoint_resume(
+ accessor=_NoMessages(accessor),
+ checkpointer=checkpointer,
+ config=_run_config("thread-1", pre_turn.config["configurable"]["checkpoint_id"]),
+ thread_id="thread-1",
+ run_id="run-1",
+ )
diff --git a/backend/tests/test_run_worker_rollback.py b/backend/tests/test_run_worker_rollback.py
index dfd1aa493..81813b3dc 100644
--- a/backend/tests/test_run_worker_rollback.py
+++ b/backend/tests/test_run_worker_rollback.py
@@ -88,6 +88,7 @@ async def test_pending_cancel_stops_waiting_for_prior_finalization():
def _make_rollback_point(*, checkpoint_id="ckpt-1", messages=("before",), pending_writes=()):
+ materialized_messages = tuple(messages)
return RollbackPoint(
config={
"configurable": {
@@ -96,7 +97,8 @@ def _make_rollback_point(*, checkpoint_id="ckpt-1", messages=("before",), pendin
"checkpoint_id": checkpoint_id,
}
},
- messages=tuple(messages),
+ state_values={},
+ messages=materialized_messages,
metadata={"source": "input"},
pending_writes=tuple(pending_writes),
)
@@ -1318,11 +1320,14 @@ async def test_rollback_propagates_aput_writes_failure(monkeypatch):
@pytest.mark.anyio
-async def test_rollback_forks_pre_run_lineage_for_delta_checkpoints():
- """Delta channels omit messages from checkpoint blobs, so rollback must
- fork the pre-run lineage through the graph: the restored checkpoint's
- messages are reconstructed by replaying ancestor writes, and writes from
- the cancelled run (not ancestors of the fork) must not leak back in."""
+async def test_rollback_linearizes_delta_restore_onto_cancelled_head():
+ """Delta rollback replaces state on the head instead of creating a fork.
+
+ The cancelled path has already attached writes to the pre-run checkpoint,
+ so forking that checkpoint would replay sibling writes. The restored
+ checkpoint must instead descend from the cancelled head and replace the
+ captured messages there.
+ """
checkpointer = InMemorySaver()
graph = _build_message_append_graph(_DeltaChannelState, checkpointer)
accessor = CheckpointStateAccessor.bind(graph, checkpointer, mode="delta")
@@ -1355,7 +1360,7 @@ async def test_rollback_forks_pre_run_lineage_for_delta_checkpoints():
assert [message.content for message in latest_snapshot.values["messages"]] == ["turn-0", "turn-1"]
restored_tuple = await checkpointer.aget_tuple({"configurable": {"thread_id": "thread-1", "checkpoint_ns": "", "checkpoint_id": restored_checkpoint_id}})
- assert restored_tuple.parent_config["configurable"]["checkpoint_id"] == pre_run_checkpoint_id
+ assert restored_tuple.parent_config["configurable"]["checkpoint_id"] == cancelled_checkpoint_id
assert restored_tuple.metadata.get("source") == "update"
# A non-snapshot Delta checkpoint must not persist the full message list.
raw_messages = restored_tuple.checkpoint.get("channel_values", {}).get("messages")
@@ -1364,7 +1369,7 @@ async def test_rollback_forks_pre_run_lineage_for_delta_checkpoints():
@pytest.mark.anyio
async def test_rollback_restores_pre_run_pending_writes_for_delta_checkpoints():
- """Pre-run pending writes are re-attached to the restored fork; writes
+ """Pre-run pending writes are re-attached to the restored checkpoint; writes
attached to the cancelled run are not."""
checkpointer = InMemorySaver()
graph = _build_message_append_graph(_DeltaChannelState, checkpointer)
@@ -1408,8 +1413,8 @@ async def test_rollback_restores_pre_run_pending_writes_for_delta_checkpoints():
@pytest.mark.anyio
-async def test_rollback_forks_pre_run_lineage_for_delta_checkpoints_sqlite_reopen(tmp_path):
- """Same lineage contract against a disk-backed saver, verified after a
+async def test_rollback_linearizes_delta_restore_sqlite_reopen(tmp_path):
+ """Same linear restore contract against a disk-backed saver, verified after a
close/reopen so only persisted bytes can satisfy the assertions."""
db_path = tmp_path / "rollback.sqlite3"
@@ -1426,6 +1431,8 @@ async def test_rollback_forks_pre_run_lineage_for_delta_checkpoints_sqlite_reope
pre_run_checkpoint_id = rollback_point.config["configurable"]["checkpoint_id"]
await graph.ainvoke({}, thread_config) # cancelled run
+ cancelled_snapshot = await accessor.aget(thread_config)
+ cancelled_checkpoint_id = cancelled_snapshot.config["configurable"]["checkpoint_id"]
await _rollback_to_pre_run_checkpoint(
accessor=accessor,
@@ -1438,8 +1445,10 @@ async def test_rollback_forks_pre_run_lineage_for_delta_checkpoints_sqlite_reope
restored_snapshot = await accessor.aget(thread_config)
restored_checkpoint_id = restored_snapshot.config["configurable"]["checkpoint_id"]
- assert restored_checkpoint_id != pre_run_checkpoint_id
+ assert restored_checkpoint_id not in (pre_run_checkpoint_id, cancelled_checkpoint_id)
assert [message.content for message in restored_snapshot.values["messages"]] == ["turn-0", "turn-1"]
+ restored_tuple = await checkpointer.aget_tuple({"configurable": {"thread_id": "thread-1", "checkpoint_ns": "", "checkpoint_id": restored_checkpoint_id}})
+ assert restored_tuple.parent_config["configurable"]["checkpoint_id"] == cancelled_checkpoint_id
async with AsyncSqliteSaver.from_conn_string(str(db_path)) as checkpointer:
graph = _build_message_append_graph(_DeltaChannelState, checkpointer)
@@ -1451,7 +1460,7 @@ async def test_rollback_forks_pre_run_lineage_for_delta_checkpoints_sqlite_reope
assert [message.content for message in latest_snapshot.values["messages"]] == ["turn-0", "turn-1"]
restored_tuple = await checkpointer.aget_tuple({"configurable": {"thread_id": "thread-1", "checkpoint_ns": "", "checkpoint_id": restored_checkpoint_id}})
- assert restored_tuple.parent_config["configurable"]["checkpoint_id"] == pre_run_checkpoint_id
+ assert restored_tuple.parent_config["configurable"]["checkpoint_id"] == cancelled_checkpoint_id
raw_messages = restored_tuple.checkpoint.get("channel_values", {}).get("messages")
assert not isinstance(raw_messages, list)
From 1cd5dea336469a45a7e7adc3f872070380eddf86 Mon Sep 17 00:00:00 2001
From: Huixin615
Date: Mon, 27 Jul 2026 07:13:06 +0800
Subject: [PATCH 33/35] fix(streaming): signal replay history gaps (#4426)
* fix(streaming): signal replay history gaps
* fix(streaming): guard initial Redis replay window
* fix(frontend): align inactive gap recovery
---------
Co-authored-by: Willem Jiang
---
README.md | 2 +-
backend/AGENTS.md | 1 +
backend/app/gateway/services.py | 58 ++-
backend/docs/API.md | 25 ++
backend/docs/STREAMING.md | 18 +-
.../harness/deerflow/runtime/__init__.py | 4 +-
.../runtime/stream_bridge/__init__.py | 4 +-
.../deerflow/runtime/stream_bridge/base.py | 21 +-
.../deerflow/runtime/stream_bridge/memory.py | 86 ++--
.../deerflow/runtime/stream_bridge/redis.py | 163 +++++++-
backend/tests/test_gateway_services.py | 66 +++
backend/tests/test_stream_bridge.py | 384 ++++++++++++++++--
.../tests/test_wait_disconnect_handling.py | 30 ++
config.example.yaml | 6 +-
frontend/AGENTS.md | 3 +-
frontend/src/core/api/api-client.ts | 222 +++++++++-
frontend/src/core/i18n/locales/en-US.ts | 2 +
frontend/src/core/i18n/locales/types.ts | 1 +
frontend/src/core/i18n/locales/zh-CN.ts | 1 +
frontend/src/core/threads/hooks.ts | 22 +-
.../tests/unit/core/api/api-client.test.ts | 341 ++++++++++++++++
.../unit/core/threads/stream-options.test.ts | 4 +
22 files changed, 1341 insertions(+), 123 deletions(-)
diff --git a/README.md b/README.md
index 708230039..b081c2102 100644
--- a/README.md
+++ b/README.md
@@ -275,7 +275,7 @@ Browser login uses `HttpOnly` session cookies. The login page offers a "keep me
DeerFlow still uses `Forwarded` / `X-Forwarded-*` headers to recover the browser-facing scheme and origin behind a proxy. The bundled nginx sets `X-Forwarded-Proto`, but preserves an upstream HTTPS value and does not overwrite every forwarded header. Configure the outer trusted proxy to replace or strip client-supplied forwarding headers before traffic reaches DeerFlow.
> [!IMPORTANT]
-> The Gateway still owns active run tasks in process, so production defaults to a single Gateway worker (`GATEWAY_WORKERS=1`). Multi-worker deployments require Postgres, the Redis stream bridge (`stream_bridge.type: redis`), `run_ownership.heartbeat_enabled: true`, and `run_events.backend: db`; process-local memory/JSONL event stores cannot enforce singleton delivery receipts across workers. The bridge shares SSE delivery and `Last-Event-ID` replay across workers, while lease reconciliation marks runs from dead workers as errors, persists their delivery receipts, publishes the terminal stream marker, schedules retained-stream cleanup, and updates the affected thread status. SSE and `/wait` consumers also refresh durable status on heartbeats as a fallback if terminal publication fails. Malformed reconnect IDs live-tail new events instead of replaying the retained buffer, and the rolling retained-buffer TTL (`stream_ttl_seconds`) remains a cleanup safety net rather than a run timeout. IM channel state and other process-local services still need their own multi-worker coordination.
+> The Gateway still owns active run tasks in process, so production defaults to a single Gateway worker (`GATEWAY_WORKERS=1`). Multi-worker deployments require Postgres, the Redis stream bridge (`stream_bridge.type: redis`), `run_ownership.heartbeat_enabled: true`, and `run_events.backend: db`; process-local memory/JSONL event stores cannot enforce singleton delivery receipts across workers. The bridge shares SSE delivery and bounded `Last-Event-ID` replay across workers. When a valid reconnect cursor has been trimmed, or a subscriber that already established an empty-stream wait falls behind before its first delivery, Memory and Redis emit a machine-readable SSE `gap` event instead of silently returning a partial replay; the Web UI reloads durable thread/event state and resumes from the retained tail. Lease reconciliation marks runs from dead workers as errors, persists their delivery receipts, publishes the terminal stream marker, schedules retained-stream cleanup, and updates the affected thread status. SSE and `/wait` consumers also refresh durable status on heartbeats as a fallback if terminal publication fails. Malformed Redis reconnect IDs live-tail new events instead of replaying the retained buffer, and the rolling retained-buffer TTL (`stream_ttl_seconds`) remains a cleanup safety net rather than a run timeout. IM channel state and other process-local services still need their own multi-worker coordination.
>
> Reconciliation uses an atomic takeover claim that re-checks the lease after candidate selection, so a successful owner renewal wins over orphan recovery and only one reconciler can report a run as recovered.
diff --git a/backend/AGENTS.md b/backend/AGENTS.md
index 641f7a0ba..55d6f66da 100644
--- a/backend/AGENTS.md
+++ b/backend/AGENTS.md
@@ -504,6 +504,7 @@ JSONL event stores when `GATEWAY_WORKERS > 1`.
- Run admission and independent checkpoint writes are first-class thread operations. `runs.operation_kind` distinguishes user-visible `run` rows from internal `checkpoint_write` reservations, while every active kind shares the existing durable active-thread uniqueness constraint. New operation kinds must go through `RunStore.create_thread_operation_atomic()` and `RunManager.reserve_thread_operation()` rather than adding another lock or metadata marker. Live and lease-less reservations are non-interruptible; an expired leased reservation can be reclaimed immediately by interrupt/rollback admission without waiting for orphan reconciliation. Lease-less rows stay fail-closed because the store cannot distinguish a stale row from a live checkpoint writer in another heartbeat-disabled worker; a rare failed delete therefore requires startup reconciliation, and heartbeat-disabled multi-worker deployment remains unsupported. Reservation bodies are attached to their caller task so loss detected by lease renewal cancels the checkpoint writer before it can continue after takeover; the context manager translates that lease-loss cancellation to `ConflictError` after cleanup so Gateway mutation routes return a retryable 409 instead of dropping the HTTP request. The cleanup scope begins immediately after durable admission, including the await that attaches the caller task, so cancellation cannot strand a locally renewed pending reservation. A failed renewal is revalidated under the manager lock before cancellation; if the reservation completed and unregistered while the store update was in flight, its request task must not be cancelled after the checkpoint write. Reservations are excluded from run history/reporting and from run-only helpers such as `list_by_thread()` and `has_inflight()`, release uses the captured owner rather than ambient user context, and local cleanup still runs when the best-effort store delete fails. `RunStore.create_run_atomic()` remains a deprecated compatibility shim for external stores that only admit normal runs; new stores must implement `create_thread_operation_atomic()` to support internal operation kinds.
- Gateway checkpoint mutations outside run execution must use `services.reserve_checkpoint_write()`, which composes the process-local thread lock with the durable `checkpoint_write` reservation. Manual compaction, `POST /threads/{id}/state`, and both goal mutation routes (`PUT` / `DELETE /threads/{id}/goal`, including creation of a missing goal checkpoint) use this boundary, so an existing run blocks the write and the reservation blocks new reject/interrupt/rollback runs across workers.
- `POST /wait` (both thread-scoped and `/api/runs/wait`) drains the stream bridge via `wait_for_run_completion()` instead of bare `await record.task`, so it honours the run's `on_disconnect` setting and cancels the background run on real client disconnect rather than returning a stale checkpoint (issue #3265).
+- Memory and Redis `StreamBridge` implementations retain only `stream_bridge.queue_maxsize` data events. A syntactically valid `Last-Event-ID` older than the retained watermark, or a live subscriber that falls behind it, yields `StreamGap` before any partial replay. `sse_consumer` maps that control item to an id-less SSE `gap` payload (`stream_replay_gap`) and intentionally leaves the run active; internal `/wait` consumers resume from its latest retained ID because they only need terminal completion. Redis checks bounds plus the non-blocking read in one transaction, using blocking `XREAD` only as a wake-up before repeating the atomic snapshot. For a no-cursor subscriber that established a wait on an empty stream, the first wake response remains provisional until that next snapshot verifies its tail is still retained; this closes the pre-first-delivery trimming window without changing malformed-cursor live tailing. The correctness tradeoff is one three-command snapshot pipeline per poll plus the blocking wake round trip while idle. Malformed cursor behavior remains backend-specific. Memory treats a syntactically numeric cursor below its watermark conservatively as a gap even when the evicted timestamp can no longer be verified; unknown ids at or above the watermark retain the legacy replay-from-earliest policy.
- Redis `StreamBridge` keys use a rolling retained-buffer TTL (`stream_bridge.stream_ttl_seconds`, refreshed on `publish()` / `publish_end()`) as a leak safety net, not as a run timeout. Startup and lease-driven periodic orphan recovery share one Gateway stream-terminalization path: after `RunManager` durably marks a run `error` with `stop_reason=orphan_recovered`, Gateway publishes `END_SENTINEL` and schedules stream cleanup. The periodic store scan, per-row status writes, and Gateway callback run as one supervised single-flight task, so a slow pass is skipped at the next interval instead of piling up or pausing the sole lease-renewal loop. Store retries have bounded attempts/backoff; an individual operation still relies on the database driver/pool timeout. `RunManager.shutdown()` gives active user runs priority within its shared deadline, then drains or cancels orphan recovery. Gateway tracks delayed recovered-stream cleanups and converts unfinished delays to immediate deletes before closing the bridge; the Redis TTL remains the outage safety net. Only startup recovery, before the runtime yields to requests, projects the latest affected thread to `error`; periodic recovery deliberately avoids that non-atomic projection because `ThreadMetaStore` has no `latest_run_id` conditional-update contract. Store-only SSE and `/wait` consumers wait for the bridge's real END marker after an ordinary durable terminal status, because status persistence can precede tail events. The explicit `orphan_recovered` signal is the only heartbeat fallback: its publisher is known to be gone, so it supplies the liveness boundary if END publication fails or the retained key expires. Malformed `Last-Event-ID` reconnect values live-tail new Redis events rather than replaying the retained buffer. Keep cross-component recovery orchestration in Gateway through the generic `RunManager.on_orphans_recovered` callback; do not introduce a harness-to-app dependency. Callback failure warnings include every recovered `run_id` so operators can identify rows whose Gateway-side terminalization needs inspection.
- Thread-scoped run creation accepts `checkpoint` / `checkpoint_id`; Gateway validates the checkpoint belongs to the request thread before writing `checkpoint_id` / `checkpoint_ns` into `config.configurable` for LangGraph branching. In `delta` checkpoint mode the worker rewrites that fork into a linear head write before the graph starts (see "A delta-mode run cannot fork" under Checkpoint Channel Modes), because delta state for a fork replays the abandoned sibling's writes.
- Thread-scoped Gateway runs evaluate an active `ThreadState.goal` after the visible turn completes. `runtime/goal.py` asks a non-thinking evaluator model to judge only visible conversation evidence and return a typed blocker; the evaluator model is created once per run and reused across hidden continuation checks. The evaluator runs after the graph root's tracing scope has already closed, so `create_goal_evaluator_model`/`evaluate_goal_completion` attach their own model-level tracing callbacks (`attach_tracing=True`) and inject Langfuse trace metadata (`thread_id`/`user_id`/`deerflow_trace_id`) directly onto the `ainvoke` call — the same standalone-caller pattern as `oneshot_llm.run_oneshot_llm` and `MemoryUpdater` (see Tracing System below). Satisfied goals are cleared; every non-satisfied evaluation — continuable or stand-down — is persisted with `last_evaluation` (the blocker, reason, and evidence summary; outcomes that stop the loop additionally record a `stand_down_reason` for observability), but only `goal_not_met_yet` evaluations are streamed as hidden `HumanMessage` continuations, and only when a durable assistant end-of-turn checkpoint exists, the run has not been aborted, the thread did not change during evaluation, and the no-progress breaker has not fired. The continuation cap is 8 — a hard maximum in the `0`–`8` range; callers requesting more are clamped (`set_goal`/TUI) or rejected with 422 (`PUT /goal`). The no-progress breaker keys on the latest visible assistant evidence (not the evaluator's free-text reason, which an LLM rewords every turn), so two consecutive continuations that add no new visible assistant output stop the loop after 2 attempts. Model-response cleanup helpers such as think-block stripping and code-fence stripping live in `deerflow.utils.llm_text` so `runtime/goal.py` and Gateway suggestion parsing share the same JSON-prep behavior.
diff --git a/backend/app/gateway/services.py b/backend/app/gateway/services.py
index a6d7dab2b..58e767212 100644
--- a/backend/app/gateway/services.py
+++ b/backend/app/gateway/services.py
@@ -46,6 +46,7 @@ from deerflow.runtime import (
RunRecord,
RunStatus,
StreamBridge,
+ StreamGap,
ThreadOperationKind,
UnsupportedStrategyError,
build_state_mutation_graph,
@@ -1236,11 +1237,27 @@ async def sse_consumer(
yield format_sse("end", None)
return
+ gap_emitted = False
try:
async for entry in bridge.subscribe(record.run_id, last_event_id=last_event_id):
if await request.is_disconnected():
break
+ if isinstance(entry, StreamGap):
+ gap_emitted = True
+ yield format_sse(
+ "gap",
+ {
+ "code": "stream_replay_gap",
+ "run_id": record.run_id,
+ "requested_event_id": entry.requested_event_id,
+ "earliest_available_event_id": entry.earliest_available_event_id,
+ "latest_available_event_id": entry.latest_available_event_id,
+ "recovery": "reload_durable_state",
+ },
+ )
+ return
+
if entry is HEARTBEAT_SENTINEL:
if await _orphan_recovery_observed_after_heartbeat(record, run_mgr):
yield format_sse("end", None)
@@ -1259,7 +1276,7 @@ async def sse_consumer(
# worker holds no in-memory task/abort state for them, so run_mgr.cancel()
# cannot stop the task (it would 409). Skip on_disconnect cancellation for
# those and only act on runs this worker actually owns.
- if not record.store_only and record.status in (RunStatus.pending, RunStatus.running):
+ if not gap_emitted and not record.store_only and record.status in (RunStatus.pending, RunStatus.running):
if record.on_disconnect == DisconnectMode.cancel:
await run_mgr.cancel(record.run_id)
@@ -1297,21 +1314,32 @@ async def wait_for_run_completion(
if await _terminal_record_stream_missing(bridge, record):
return True
+ resume_from_event_id: str | None = None
try:
- async for entry in bridge.subscribe(record.run_id):
- # END_SENTINEL means the run reached a terminal state; honour it
- # even if the client just disconnected so the caller still serializes
- # the real final checkpoint.
- if entry is END_SENTINEL:
- completed = True
- return True
- if entry is HEARTBEAT_SENTINEL and await _orphan_recovery_observed_after_heartbeat(record, run_mgr):
- completed = True
- return True
- if await request.is_disconnected():
- break
- # Heartbeats and regular events: keep waiting for END_SENTINEL.
- return completed
+ while True:
+ gap_seen = False
+ async for entry in bridge.subscribe(record.run_id, last_event_id=resume_from_event_id):
+ # END_SENTINEL means the run reached a terminal state; honour it
+ # even if the client just disconnected so the caller still serializes
+ # the real final checkpoint.
+ if entry is END_SENTINEL:
+ completed = True
+ return True
+ if isinstance(entry, StreamGap):
+ # The wait API only needs terminal completion, not a complete
+ # event replay. Resume at the retained tail rather than
+ # treating a bridge gap as a client disconnect.
+ resume_from_event_id = entry.latest_available_event_id
+ gap_seen = True
+ break
+ if entry is HEARTBEAT_SENTINEL and await _orphan_recovery_observed_after_heartbeat(record, run_mgr):
+ completed = True
+ return True
+ if await request.is_disconnected():
+ return False
+ # Heartbeats and regular events: keep waiting for END_SENTINEL.
+ if not gap_seen:
+ return completed
finally:
if not completed and record.status in (RunStatus.pending, RunStatus.running):
if record.on_disconnect == DisconnectMode.cancel:
diff --git a/backend/docs/API.md b/backend/docs/API.md
index b23ddeb9d..58295ddc4 100644
--- a/backend/docs/API.md
+++ b/backend/docs/API.md
@@ -790,6 +790,31 @@ Both endpoints return `Content-Location: /api/threads/{thread_id}/runs/{run_id}`
The DeerFlow web UI and LangGraph SDK clients rely on this header to discover the
assigned `thread_id` and `run_id` on the first message of a new chat.
+### SSE replay retention and gaps
+
+Clients may reconnect to a run stream with `Last-Event-ID`. Replay history is
+bounded by `stream_bridge.queue_maxsize` (default `256`) and, for Redis, by the
+rolling `stream_ttl_seconds`. A retained cursor resumes after that event with no
+additional control frame.
+
+When a syntactically valid cursor is older than the retained watermark, the
+server sends exactly one `gap` event before any retained data and closes that
+subscription without an `end` event:
+
+```text
+event: gap
+data: {"code":"stream_replay_gap","run_id":"run-123","requested_event_id":"1718000000000-1","earliest_available_event_id":"1718000000100-42","latest_available_event_id":"1718000000200-84","recovery":"reload_durable_state"}
+
+```
+
+The frame deliberately has no SSE `id:`. Consumers must reload durable thread
+state and persisted run events/messages, then may reconnect from
+`latest_available_event_id` to follow newer live events. A gap does not cancel
+the active run. The same signal applies when a no-cursor subscriber has already
+established an empty-stream wait but the first Redis wake-up falls behind before
+delivery; in that case `requested_event_id` is `null`. Malformed cursor handling
+is backend-specific and is not the same as a valid cursor that was evicted.
+
---
## SDK Usage
diff --git a/backend/docs/STREAMING.md b/backend/docs/STREAMING.md
index 5b748ce78..809aee166 100644
--- a/backend/docs/STREAMING.md
+++ b/backend/docs/STREAMING.md
@@ -143,12 +143,28 @@ sequenceDiagram
关键组件:
- `runtime/runs/worker.py::run_agent` — 在 `asyncio.Task` 里跑 `agent.astream()`,把每个 chunk 通过 `serialize(chunk, mode=mode)` 转成 JSON,再 `bridge.publish()`。
-- `runtime/stream_bridge` — 抽象 Queue。`publish/subscribe` 解耦生产者和消费者,支持 `Last-Event-ID` 重连、心跳、多订阅者 fan-out。Redis backend 会在每次 `publish()` / `publish_end()` 刷新 retained stream key TTL;启动恢复与基于 worker lease 的周期恢复共用 Gateway stream terminalization 路径:`RunManager` 先将 orphan run 持久化为 `error` 并写入显式的 `stop_reason=orphan_recovered`,随后 Gateway 发布 `END_SENTINEL` 并安排 stream cleanup。周期扫描、逐行状态写入和 Gateway callback 作为一个受监督的 single-flight 后台 task 执行;慢任务不会堆积,也不会阻塞唯一的 lease heartbeat。shutdown 优先收敛活跃 run,再处理恢复 task;尚未执行的延迟 stream cleanup 会改为立即删除。只有 runtime `yield` 前、无并发请求的启动恢复会把最新受影响 thread 标记为 error;周期恢复不做非原子的 thread 投影。store-only SSE 与 `/wait` consumer 不能把普通 durable terminal status 当成流已完成,否则可能跳过延迟发布的 error 等尾部事件;只有 `orphan_recovered` 信号能在 heartbeat 时触发 END fallback,因为此时 producer 已被确认失联。TTL 仍是 Redis 内存和故障安全网,不是正常的 subscriber 终止机制。
+- `runtime/stream_bridge` — 抽象 Queue。`publish/subscribe` 解耦生产者和消费者,支持 `Last-Event-ID` 重连、心跳、多订阅者 fan-out。Memory 和 Redis 都只保留 `queue_maxsize` 条数据事件;游标早于保留水位线时返回 `StreamGap`,不会从当前最早事件静默部分重放。Redis backend 会在每次 `publish()` / `publish_end()` 刷新 retained stream key TTL;启动恢复与基于 worker lease 的周期恢复共用 Gateway stream terminalization 路径:`RunManager` 先将 orphan run 持久化为 `error` 并写入显式的 `stop_reason=orphan_recovered`,随后 Gateway 发布 `END_SENTINEL` 并安排 stream cleanup。周期扫描、逐行状态写入和 Gateway callback 作为一个受监督的 single-flight 后台 task 执行;慢任务不会堆积,也不会阻塞唯一的 lease heartbeat。shutdown 优先收敛活跃 run,再处理恢复 task;尚未执行的延迟 stream cleanup 会改为立即删除。只有 runtime `yield` 前、无并发请求的启动恢复会把最新受影响 thread 标记为 error;周期恢复不做非原子的 thread 投影。store-only SSE 与 `/wait` consumer 不能把普通 durable terminal status 当成流已完成,否则可能跳过延迟发布的 error 等尾部事件;只有 `orphan_recovered` 信号能在 heartbeat 时触发 END fallback,因为此时 producer 已被确认失联。TTL 仍是 Redis 内存和故障安全网,不是正常的 subscriber 终止机制。
- `app/gateway/services.py::sse_consumer` — 从 bridge 订阅,格式化为 SSE wire 帧。
- `runtime/serialization.py::serialize` — mode-aware 序列化;`messages` mode 下 `serialize_messages_tuple` 把 `(chunk, metadata)` 转成 `[chunk.model_dump(), metadata]`。
**`StreamBridge` 的存在价值**:当生产者(`run_agent` 任务)和消费者(HTTP 连接)在不同的 asyncio task 里运行时,需要一个可以跨 task 传递事件的中介。Queue 同时还承担断连重连的 buffer 和多订阅者的 fan-out。
+### 有界历史与 `gap` 恢复
+
+`Last-Event-ID` 只保证在保留窗口内完整重放,不代表无限历史。有效游标仍在窗口内时,bridge 从该 ID 的下一条事件正常恢复;有效游标已经被 `queue_maxsize` 裁剪,或在线消费者慢到落后水位线时,Gateway 会在任何部分重放之前发送:
+
+```text
+event: gap
+data: {"code":"stream_replay_gap","run_id":"...","requested_event_id":"...","earliest_available_event_id":"...","latest_available_event_id":"...","recovery":"reload_durable_state"}
+
+```
+
+`gap` 帧没有 SSE `id:`,后面也没有正常 `end`;当前订阅随即关闭。它是恢复边界而不是客户端断开,因此不会触发 `on_disconnect=cancel`。客户端必须丢弃不再可信的瞬时状态,重新读取 thread checkpoint 和持久化 run-event/message history,再以 `latest_available_event_id` 为游标跟随新事件。DeerFlow Web UI 自动执行此流程并最多连续恢复五次。
+
+Redis 对无游标、空 stream 上已经建立的阻塞等待也遵循相同契约:第一次 `XREAD` 唤醒的数据在交付前仍是 provisional baseline,bridge 会用下一次事务快照确认其尾 ID 仍在保留窗口。若生产者已经裁剪了该基线,订阅直接返回 `requested_event_id: null` 的 `gap`,不会先交付 retained tail。这个检查有明确的性能代价:每轮订阅需要一个包含 `XRANGE`、`XREVRANGE`、非阻塞 `XREAD` 的事务快照;空闲时还需要单独的阻塞 `XREAD` 来唤醒。
+
+“有效但已淘汰”和 malformed cursor 是不同策略:本契约只要求前者产生 `gap`。Redis 的 malformed ID 仍从 live tail 等待;Memory 对 malformed ID 及序号不低于水位线的未知 ID 仍采用既有的最早保留事件策略。对于序号已经低于水位线的数字格式 foreign ID,Memory 无法再校验已淘汰的 timestamp,因此保守返回 `gap`,优先保证客户端不会把不完整重放误认为完整。
+
---
## DeerFlowClient 路径:sync + in-process
diff --git a/backend/packages/harness/deerflow/runtime/__init__.py b/backend/packages/harness/deerflow/runtime/__init__.py
index c3c4dfb6c..8a65f38df 100644
--- a/backend/packages/harness/deerflow/runtime/__init__.py
+++ b/backend/packages/harness/deerflow/runtime/__init__.py
@@ -14,7 +14,7 @@ from .store import get_store, make_store, reset_store, store_context
# NOTE: ``RedisStreamBridge`` is intentionally not re-exported — ``redis`` is an
# optional extra and importing it here would load ``redis.asyncio`` in every
# process. Import it from ``deerflow.runtime.stream_bridge.redis`` when needed.
-from .stream_bridge import END_SENTINEL, HEARTBEAT_SENTINEL, MemoryStreamBridge, StreamBridge, StreamEvent, make_stream_bridge
+from .stream_bridge import END_SENTINEL, HEARTBEAT_SENTINEL, MemoryStreamBridge, StreamBridge, StreamEvent, StreamGap, StreamItem, make_stream_bridge
__all__ = [
# checkpoint state
@@ -56,5 +56,7 @@ __all__ = [
"MemoryStreamBridge",
"StreamBridge",
"StreamEvent",
+ "StreamGap",
+ "StreamItem",
"make_stream_bridge",
]
diff --git a/backend/packages/harness/deerflow/runtime/stream_bridge/__init__.py b/backend/packages/harness/deerflow/runtime/stream_bridge/__init__.py
index a9ec58f63..eafbef950 100644
--- a/backend/packages/harness/deerflow/runtime/stream_bridge/__init__.py
+++ b/backend/packages/harness/deerflow/runtime/stream_bridge/__init__.py
@@ -8,7 +8,7 @@ by :mod:`asyncio.Queue`.
"""
from .async_provider import make_stream_bridge
-from .base import END_SENTINEL, HEARTBEAT_SENTINEL, StreamBridge, StreamEvent
+from .base import END_SENTINEL, HEARTBEAT_SENTINEL, StreamBridge, StreamEvent, StreamGap, StreamItem
from .memory import MemoryStreamBridge
# NOTE: ``RedisStreamBridge`` is intentionally NOT imported here. ``redis`` is an
@@ -25,5 +25,7 @@ __all__ = [
"MemoryStreamBridge",
"StreamBridge",
"StreamEvent",
+ "StreamGap",
+ "StreamItem",
"make_stream_bridge",
]
diff --git a/backend/packages/harness/deerflow/runtime/stream_bridge/base.py b/backend/packages/harness/deerflow/runtime/stream_bridge/base.py
index bcaf65a66..b303a1d9b 100644
--- a/backend/packages/harness/deerflow/runtime/stream_bridge/base.py
+++ b/backend/packages/harness/deerflow/runtime/stream_bridge/base.py
@@ -30,8 +30,24 @@ class StreamEvent:
data: Any
+@dataclass(frozen=True)
+class StreamGap:
+ """A subscriber cursor can no longer be replayed completely.
+
+ ``requested_event_id`` is the reconnect cursor, or the most recently
+ delivered event for a live subscriber that fell behind. The retained
+ bounds let callers reload durable state and resume at the current tail
+ without mistaking a partial replay for a complete one.
+ """
+
+ requested_event_id: str | None
+ earliest_available_event_id: str
+ latest_available_event_id: str
+
+
HEARTBEAT_SENTINEL = StreamEvent(id="", event="__heartbeat__", data=None)
END_SENTINEL = StreamEvent(id="", event="__end__", data=None)
+type StreamItem = StreamEvent | StreamGap
class StreamBridge(abc.ABC):
@@ -54,12 +70,13 @@ class StreamBridge(abc.ABC):
*,
last_event_id: str | None = None,
heartbeat_interval: float = 15.0,
- ) -> AsyncIterator[StreamEvent]:
+ ) -> AsyncIterator[StreamItem]:
"""Async iterator that yields events for *run_id* (consumer side).
Yields :data:`HEARTBEAT_SENTINEL` when no event arrives within
*heartbeat_interval* seconds. Yields :data:`END_SENTINEL` once
- the producer calls :meth:`publish_end`.
+ the producer calls :meth:`publish_end`. Yields :class:`StreamGap` and
+ stops when the subscriber has fallen behind retained history.
"""
@abc.abstractmethod
diff --git a/backend/packages/harness/deerflow/runtime/stream_bridge/memory.py b/backend/packages/harness/deerflow/runtime/stream_bridge/memory.py
index 2690c8f4c..f9f3cf304 100644
--- a/backend/packages/harness/deerflow/runtime/stream_bridge/memory.py
+++ b/backend/packages/harness/deerflow/runtime/stream_bridge/memory.py
@@ -4,14 +4,16 @@ from __future__ import annotations
import asyncio
import logging
+import re
import time
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
from typing import Any
-from .base import END_SENTINEL, HEARTBEAT_SENTINEL, StreamBridge, StreamEvent
+from .base import END_SENTINEL, HEARTBEAT_SENTINEL, StreamBridge, StreamEvent, StreamGap, StreamItem
logger = logging.getLogger(__name__)
+_MEMORY_STREAM_ID_RE = re.compile(r"\d+-(\d+)")
@dataclass
@@ -56,25 +58,35 @@ class MemoryStreamBridge(StreamBridge):
event, so it equals the event's absolute offset within the run. Returns
``None`` for ids that do not match the expected format.
"""
- _, sep, seq_text = event_id.rpartition("-")
- if not sep:
- return None
- try:
- return int(seq_text)
- except ValueError:
+ match = _MEMORY_STREAM_ID_RE.fullmatch(event_id)
+ if match is None:
return None
+ return int(match.group(1))
- def _resolve_start_offset(self, stream: _RunStream, last_event_id: str | None) -> int:
+ @staticmethod
+ def _make_gap(stream: _RunStream, requested_event_id: str | None) -> StreamGap:
+ return StreamGap(
+ requested_event_id=requested_event_id,
+ earliest_available_event_id=stream.events[0].id,
+ latest_available_event_id=stream.events[-1].id,
+ )
+
+ def _resolve_start_offset(self, stream: _RunStream, last_event_id: str | None) -> int | StreamGap:
if last_event_id is None:
return stream.start_offset
# Event ids embed a per-run, monotonically increasing ``seq`` that equals
# the event's absolute offset, so locate the event by arithmetic in O(1)
- # rather than scanning the retained buffer. The id is verified at the
- # computed index, so a stale/evicted/foreign/malformed id still falls back
- # to replay-from-earliest — identical to the previous linear scan.
+ # rather than scanning the retained buffer. Retained ids are verified at
+ # the computed index. Once an id is below the retained watermark there is
+ # nothing left to verify its timestamp against, so even a numeric foreign
+ # id takes the conservative gap path; reloading durable state is safer
+ # than silently claiming a complete replay. Unknown ids at or above the
+ # watermark keep the legacy replay-from-earliest behavior.
seq = self._parse_event_seq(last_event_id)
if seq is not None:
+ if stream.events and seq < stream.start_offset:
+ return self._make_gap(stream, last_event_id)
local_index = seq - stream.start_offset
if 0 <= local_index < len(stream.events) and stream.events[local_index].id == last_event_id:
return stream.start_offset + local_index + 1
@@ -115,39 +127,57 @@ class MemoryStreamBridge(StreamBridge):
*,
last_event_id: str | None = None,
heartbeat_interval: float = 15.0,
- ) -> AsyncIterator[StreamEvent]:
+ ) -> AsyncIterator[StreamItem]:
stream = self._get_or_create_stream(run_id)
async with stream.condition:
- next_offset = self._resolve_start_offset(stream, last_event_id)
+ start = self._resolve_start_offset(stream, last_event_id)
+ if isinstance(start, StreamGap):
+ gap = start
+ next_offset = stream.start_offset
+ else:
+ gap = None
+ next_offset = start
+
+ if gap is not None:
+ yield gap
+ return
+
+ cursor_event_id = last_event_id
while True:
async with stream.condition:
if next_offset < stream.start_offset:
logger.warning(
- "subscriber for run %s fell behind retained buffer; resuming from offset %s",
+ "subscriber for run %s fell behind retained buffer at offset %s",
run_id,
- stream.start_offset,
+ next_offset,
)
- next_offset = stream.start_offset
-
- local_index = next_offset - stream.start_offset
- if 0 <= local_index < len(stream.events):
- entry = stream.events[local_index]
- next_offset += 1
- elif stream.ended:
- entry = END_SENTINEL
+ entry: StreamItem = self._make_gap(stream, cursor_event_id)
+ should_stop = True
else:
- try:
- await asyncio.wait_for(stream.condition.wait(), timeout=heartbeat_interval)
- except TimeoutError:
- entry = HEARTBEAT_SENTINEL
+ should_stop = False
+
+ local_index = next_offset - stream.start_offset
+ if 0 <= local_index < len(stream.events):
+ entry = stream.events[local_index]
+ next_offset += 1
+ cursor_event_id = entry.id
+ elif stream.ended:
+ entry = END_SENTINEL
else:
- continue
+ try:
+ await asyncio.wait_for(stream.condition.wait(), timeout=heartbeat_interval)
+ except TimeoutError:
+ entry = HEARTBEAT_SENTINEL
+ else:
+ continue
if entry is END_SENTINEL:
yield END_SENTINEL
return
yield entry
+ if should_stop:
+ return
async def cleanup(self, run_id: str, *, delay: float = 0) -> None:
if delay > 0:
diff --git a/backend/packages/harness/deerflow/runtime/stream_bridge/redis.py b/backend/packages/harness/deerflow/runtime/stream_bridge/redis.py
index c1a488a9d..1db1a3193 100644
--- a/backend/packages/harness/deerflow/runtime/stream_bridge/redis.py
+++ b/backend/packages/harness/deerflow/runtime/stream_bridge/redis.py
@@ -27,7 +27,7 @@ except ImportError: # pragma: no cover - only hit when the optional extra is mi
"Or switch to stream_bridge.type: memory in config.yaml for single-process deployment."
) from None
-from .base import END_SENTINEL, HEARTBEAT_SENTINEL, StreamBridge, StreamEvent
+from .base import END_SENTINEL, HEARTBEAT_SENTINEL, StreamBridge, StreamEvent, StreamGap, StreamItem
logger = logging.getLogger(__name__)
@@ -139,6 +139,30 @@ class RedisStreamBridge(StreamBridge):
data=self._decode_data(payload.get("data")),
)
+ @classmethod
+ def _is_end_entry(cls, fields: Mapping[Any, Any]) -> bool:
+ return cls._normalise_fields(fields).get("kind") == _KIND_END
+
+ @staticmethod
+ def _parse_stream_id(event_id: str) -> tuple[int, int] | None:
+ if _REDIS_STREAM_ID_RE.fullmatch(event_id) is None:
+ return None
+ milliseconds, separator, sequence = event_id.partition("-")
+ return int(milliseconds), int(sequence) if separator else 0
+
+ @classmethod
+ def _stream_id_lt(cls, left: str, right: str) -> bool:
+ left_parts = cls._parse_stream_id(left)
+ right_parts = cls._parse_stream_id(right)
+ return left_parts is not None and right_parts is not None and left_parts < right_parts
+
+ @classmethod
+ def _response_tail_id(cls, response: list[Any]) -> str | None:
+ for _stream_name, entries in reversed(response):
+ if entries:
+ return cls._decode(entries[-1][0])
+ return None
+
async def publish(self, run_id: str, event: str, data: Any) -> None:
key = self._stream_key(run_id)
await self._xadd_retained(
@@ -178,28 +202,63 @@ class RedisStreamBridge(StreamBridge):
return "0-0"
return self._decode(event_id)
+ async def _read_retained_snapshot(
+ self,
+ key: str,
+ stream_id: str,
+ ) -> tuple[list[Any], list[Any], list[Any]]:
+ """Atomically read retained bounds and entries after ``stream_id``.
+
+ A blocking ``XREAD`` cannot participate in a Redis transaction. Live
+ subscribers therefore use this non-blocking atomic snapshot for
+ correctness, and a separate blocking read only as a wake-up signal.
+ This deliberately adds a three-command pipeline per poll (and a second
+ round trip while idle) so retained-bound checks cannot race with reads.
+ """
+ async with self._redis.pipeline(transaction=True) as pipe:
+ pipe.xrange(key, count=1)
+ pipe.xrevrange(key, count=1)
+ pipe.xread({key: stream_id}, count=_XREAD_COUNT)
+ earliest, latest, response = await pipe.execute()
+ return earliest, latest, response
+
async def subscribe(
self,
run_id: str,
*,
last_event_id: str | None = None,
heartbeat_interval: float = 15.0,
- ) -> AsyncIterator[StreamEvent]:
+ ) -> AsyncIterator[StreamItem]:
key = self._stream_key(run_id)
stream_id = await self._resolve_start_stream_id(key, last_event_id)
+ gap_detection_enabled = last_event_id is not None and self._parse_stream_id(last_event_id) is not None
+ pending_initial_response: list[Any] | None = None
block_ms = max(1, int(heartbeat_interval * 1000)) if heartbeat_interval > 0 else 1
consecutive_errors = 0
while True:
+ snapshot_stream_id = stream_id
+ pending_initial_tail_id = None
+ if pending_initial_response is not None:
+ pending_initial_tail_id = self._response_tail_id(pending_initial_response)
+ if pending_initial_tail_id is None:
+ pending_initial_response = None
+ else:
+ # The first blocking XREAD began against a proven-empty
+ # stream. Its response is a provisional live baseline, not
+ # yet client-visible data. Validate that baseline against
+ # the retained watermark before yielding any of it.
+ snapshot_stream_id = pending_initial_tail_id
+
try:
- response = await self._redis.xread({key: stream_id}, count=_XREAD_COUNT, block=block_ms)
+ earliest_entries, latest_entries, response = await self._read_retained_snapshot(key, snapshot_stream_id)
except ResponseError:
# Last-Event-ID is client-controlled and validated before XREAD.
# If Redis still rejects the id, fail instead of resetting to
# 0-0, which would replay the whole retained buffer on reconnect.
logger.warning(
"Redis rejected stream id %r for stream bridge subscription",
- stream_id,
+ snapshot_stream_id,
exc_info=True,
)
raise
@@ -218,21 +277,93 @@ class RedisStreamBridge(StreamBridge):
await asyncio.sleep(delay)
continue
else:
- consecutive_errors = 0
+ # A non-empty snapshot is forward progress. For an empty
+ # snapshot, keep any preceding wake-up failure count until the
+ # blocking XREAD itself succeeds; otherwise a permanently
+ # failing blocking read could retry forever because the
+ # non-blocking transaction succeeds between attempts.
+ if response:
+ consecutive_errors = 0
- if not response:
- yield HEARTBEAT_SENTINEL
+ if earliest_entries and (gap_detection_enabled or pending_initial_tail_id is not None):
+ earliest_id = self._decode(earliest_entries[0][0])
+ if self._stream_id_lt(snapshot_stream_id, earliest_id):
+ latest_id = self._decode(latest_entries[0][0])
+ logger.warning(
+ "subscriber for Redis stream %s fell behind retained history at %s",
+ key,
+ snapshot_stream_id,
+ )
+ yield StreamGap(
+ requested_event_id=None if pending_initial_tail_id is not None else stream_id,
+ earliest_available_event_id=earliest_id,
+ latest_available_event_id=latest_id,
+ )
+ return
+
+ responses_to_process = []
+ if pending_initial_response is not None:
+ responses_to_process.append(pending_initial_response)
+ pending_initial_response = None
+ if response:
+ responses_to_process.append(response)
+
+ if not responses_to_process:
+ if latest_entries and self._decode(latest_entries[0][0]) == stream_id and self._is_end_entry(latest_entries[0][1]):
+ yield END_SENTINEL
+ return
+
+ try:
+ wake_response = await self._redis.xread(
+ {key: stream_id},
+ count=_XREAD_COUNT,
+ block=block_ms,
+ )
+ except ResponseError:
+ logger.warning(
+ "Redis rejected stream id %r for stream bridge subscription",
+ stream_id,
+ exc_info=True,
+ )
+ raise
+ except RedisError:
+ consecutive_errors += 1
+ if consecutive_errors > _MAX_SUBSCRIBE_RETRIES:
+ raise
+ delay = min(2**consecutive_errors, heartbeat_interval)
+ logger.warning(
+ "Transient Redis error in stream bridge subscriber (retry %d/%d); backing off %.1fs",
+ consecutive_errors,
+ _MAX_SUBSCRIBE_RETRIES,
+ delay,
+ exc_info=True,
+ )
+ await asyncio.sleep(delay)
+ continue
+ else:
+ consecutive_errors = 0
+
+ if not wake_response:
+ yield HEARTBEAT_SENTINEL
+ elif last_event_id is None and not gap_detection_enabled and not earliest_entries:
+ # Do not discard the first wake-up from a proven-empty
+ # stream. Holding it until the next atomic bounds check
+ # gives no-cursor subscribers the same fell-behind signal
+ # as Memory without changing malformed-cursor live tailing.
+ pending_initial_response = wake_response
continue
- for _stream_name, entries in response:
- for event_id, fields in entries:
- event_id = self._decode(event_id)
- stream_id = event_id
- entry = self._entry_from_redis(event_id, fields)
- if entry is END_SENTINEL:
- yield END_SENTINEL
- return
- yield entry
+ for retained_response in responses_to_process:
+ for _stream_name, entries in retained_response:
+ for event_id, fields in entries:
+ event_id = self._decode(event_id)
+ stream_id = event_id
+ gap_detection_enabled = True
+ entry = self._entry_from_redis(event_id, fields)
+ if entry is END_SENTINEL:
+ yield END_SENTINEL
+ return
+ yield entry
async def cleanup(self, run_id: str, *, delay: float = 0) -> None:
if delay > 0:
diff --git a/backend/tests/test_gateway_services.py b/backend/tests/test_gateway_services.py
index 457bb5059..a9bd9b44f 100644
--- a/backend/tests/test_gateway_services.py
+++ b/backend/tests/test_gateway_services.py
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import json
import logging
+from contextlib import suppress
from types import SimpleNamespace
import pytest
@@ -85,6 +86,71 @@ def test_format_sse_no_event_id():
assert "id:" not in frame
+@pytest.mark.anyio
+async def test_sse_consumer_emits_gap_without_cancelling_run():
+ """A replay gap is a recovery boundary, not a client disconnect."""
+ from app.gateway.services import sse_consumer
+ from deerflow.runtime import DisconnectMode, MemoryStreamBridge, RunManager, RunStatus
+
+ bridge = MemoryStreamBridge(queue_maxsize=2)
+ run_manager = RunManager()
+ record = await run_manager.create("thread-gap", on_disconnect=DisconnectMode.cancel)
+ await run_manager.set_status(record.run_id, RunStatus.running)
+
+ await bridge.publish(record.run_id, "event-1", {"step": 1})
+ evicted_id = bridge._streams[record.run_id].events[0].id
+ await bridge.publish(record.run_id, "event-2", {"step": 2})
+ await bridge.publish(record.run_id, "event-3", {"step": 3})
+ retained = bridge._streams[record.run_id].events
+
+ worker_started = asyncio.Event()
+
+ async def _pending_worker() -> None:
+ worker_started.set()
+ await asyncio.Event().wait()
+
+ record.task = asyncio.create_task(_pending_worker())
+ await worker_started.wait()
+
+ class _ConnectedRequest:
+ headers = {"Last-Event-ID": evicted_id}
+
+ async def is_disconnected(self) -> bool:
+ return False
+
+ try:
+ frames = [
+ frame
+ async for frame in sse_consumer(
+ bridge,
+ record,
+ _ConnectedRequest(),
+ run_manager,
+ )
+ ]
+
+ assert len(frames) == 1
+ assert frames[0].startswith("event: gap\n")
+ assert "\nid:" not in frames[0]
+ assert "\nevent: end\n" not in frames[0]
+ payload = json.loads(frames[0].split("data: ", 1)[1].splitlines()[0])
+ assert payload == {
+ "code": "stream_replay_gap",
+ "run_id": record.run_id,
+ "requested_event_id": evicted_id,
+ "earliest_available_event_id": retained[0].id,
+ "latest_available_event_id": retained[-1].id,
+ "recovery": "reload_durable_state",
+ }
+ assert record.status == RunStatus.running
+ assert not record.abort_event.is_set()
+ assert not record.task.done()
+ finally:
+ record.task.cancel()
+ with suppress(asyncio.CancelledError):
+ await record.task
+
+
def test_sanitize_log_param_strips_control_characters():
from app.gateway.utils import sanitize_log_param
diff --git a/backend/tests/test_stream_bridge.py b/backend/tests/test_stream_bridge.py
index b5c413e53..ebf037891 100644
--- a/backend/tests/test_stream_bridge.py
+++ b/backend/tests/test_stream_bridge.py
@@ -10,7 +10,7 @@ import anyio
import pytest
from deerflow.config.stream_bridge_config import StreamBridgeConfig, set_stream_bridge_config
-from deerflow.runtime import END_SENTINEL, HEARTBEAT_SENTINEL, MemoryStreamBridge, make_stream_bridge
+from deerflow.runtime import END_SENTINEL, HEARTBEAT_SENTINEL, MemoryStreamBridge, StreamGap, make_stream_bridge
# RedisStreamBridge is no longer re-exported from deerflow.runtime (redis is an
# optional extra; see the NOTE in runtime/stream_bridge/__init__.py). Import it
@@ -62,6 +62,10 @@ class _FakeRedis:
entries = list(reversed(self.streams.get(name, [])))
return entries[:count] if count is not None else entries
+ async def xrange(self, name, min="-", max="+", count=None):
+ entries = list(self.streams.get(name, []))
+ return entries[:count] if count is not None else entries
+
async def delete(self, name):
self.deleted.append(name)
self.streams.pop(name, None)
@@ -81,6 +85,30 @@ class _FakeRedis:
self.closed = True
+class _DelayedBlockingReadRedis:
+ """Hold the first blocking XREAD response while the stream is trimmed."""
+
+ def __init__(self, redis) -> None:
+ self._delegate = redis
+ self.blocking_read_started = asyncio.Event()
+ self.wake_response_captured = asyncio.Event()
+ self.release_wake_response = asyncio.Event()
+
+ def __getattr__(self, name):
+ return getattr(self._delegate, name)
+
+ async def xread(self, streams, count=None, block=None):
+ if block is None:
+ return await self._delegate.xread(streams, count=count, block=block)
+
+ self.blocking_read_started.set()
+ response = await self._delegate.xread(streams, count=count, block=block)
+ if response:
+ self.wake_response_captured.set()
+ await self.release_wake_response.wait()
+ return response
+
+
class _FakeRedisPipeline:
def __init__(self, redis: _FakeRedis) -> None:
self.redis = redis
@@ -100,6 +128,18 @@ class _FakeRedisPipeline:
self.ops.append(("expire", name, seconds))
return self
+ def xrange(self, name, min="-", max="+", count=None):
+ self.ops.append(("xrange", name, min, max, count))
+ return self
+
+ def xrevrange(self, name, max="+", min="-", count=None):
+ self.ops.append(("xrevrange", name, max, min, count))
+ return self
+
+ def xread(self, streams, count=None, block=None):
+ self.ops.append(("xread", streams, count, block))
+ return self
+
async def execute(self):
results = []
for op in self.ops:
@@ -109,6 +149,15 @@ class _FakeRedisPipeline:
elif op[0] == "expire":
_, name, seconds = op
results.append(await self.redis.expire(name, seconds))
+ elif op[0] == "xrange":
+ _, name, min_id, max_id, count = op
+ results.append(await self.redis.xrange(name, min=min_id, max=max_id, count=count))
+ elif op[0] == "xrevrange":
+ _, name, max_id, min_id, count = op
+ results.append(await self.redis.xrevrange(name, max=max_id, min=min_id, count=count))
+ elif op[0] == "xread":
+ _, streams, count, block = op
+ results.append(await self.redis.xread(streams, count=count, block=block))
return results
@@ -290,48 +339,58 @@ async def test_subscribe_replays_after_last_event_id(bridge: MemoryStreamBridge)
@pytest.mark.anyio
-async def test_slow_subscriber_does_not_skip_after_buffer_trim():
- """A slow subscriber should continue from the correct absolute offset."""
+async def test_evicted_last_event_id_yields_gap_before_partial_replay():
+ """A valid cursor older than retained history must not silently partial-replay."""
bridge = MemoryStreamBridge(queue_maxsize=2)
- run_id = "run-slow-subscriber"
+ run_id = "run-evicted-cursor"
await bridge.publish(run_id, "e1", {"step": 1})
await bridge.publish(run_id, "e2", {"step": 2})
stream = bridge._streams[run_id]
e1_id = stream.events[0].id
- assert stream.start_offset == 0
-
await bridge.publish(run_id, "e3", {"step": 3}) # trims e1
- assert stream.start_offset == 1
- assert [entry.event for entry in stream.events] == ["e2", "e3"]
-
- resumed_after_e1 = []
- async for entry in bridge.subscribe(
- run_id,
- last_event_id=e1_id,
- heartbeat_interval=1.0,
- ):
- resumed_after_e1.append(entry)
- if len(resumed_after_e1) == 2:
- break
-
- assert [entry.event for entry in resumed_after_e1] == ["e2", "e3"]
- e2_id = resumed_after_e1[0].id
-
await bridge.publish_end(run_id)
received = []
async for entry in bridge.subscribe(
run_id,
- last_event_id=e2_id,
+ last_event_id=e1_id,
heartbeat_interval=1.0,
):
received.append(entry)
- if entry is END_SENTINEL:
- break
- assert [entry.event for entry in received[:-1]] == ["e3"]
- assert received[-1] is END_SENTINEL
+ assert received == [
+ StreamGap(
+ requested_event_id=e1_id,
+ earliest_available_event_id=stream.events[0].id,
+ latest_available_event_id=stream.events[-1].id,
+ )
+ ]
+
+
+@pytest.mark.anyio
+async def test_slow_subscriber_yields_gap_after_buffer_trim():
+ """A live subscriber that falls behind must not silently jump forward."""
+ bridge = MemoryStreamBridge(queue_maxsize=2)
+ run_id = "run-slow-subscriber"
+ await bridge.publish(run_id, "e1", {"step": 1})
+ await bridge.publish(run_id, "e2", {"step": 2})
+
+ subscriber = bridge.subscribe(run_id, heartbeat_interval=1.0)
+ first = await anext(subscriber)
+ assert first.event == "e1"
+
+ await bridge.publish(run_id, "e3", {"step": 3})
+ await bridge.publish(run_id, "e4", {"step": 4})
+
+ stream = bridge._streams[run_id]
+ assert await anext(subscriber) == StreamGap(
+ requested_event_id=first.id,
+ earliest_available_event_id=stream.events[0].id,
+ latest_available_event_id=stream.events[-1].id,
+ )
+ with pytest.raises(StopAsyncIteration):
+ await anext(subscriber)
# ---------------------------------------------------------------------------
@@ -404,11 +463,11 @@ async def test_publish_end_preserves_history_when_space_available():
@pytest.mark.anyio
-async def test_concurrent_tasks_end_sentinel():
- """Multiple concurrent producer/consumer pairs should all terminate properly.
+async def test_concurrent_slow_consumers_receive_gap():
+ """Concurrent consumers must all receive a gap when producers outrun retention.
- Simulates the production scenario where multiple runs share a single
- bridge instance — each must receive its own END sentinel.
+ Each producer fills a four-entry bridge without yielding, so subscribers
+ that started at offset zero cannot observe the first six events.
"""
bridge = MemoryStreamBridge(queue_maxsize=4)
num_runs = 4
@@ -442,7 +501,9 @@ async def test_concurrent_tasks_end_sentinel():
for run_id in run_ids:
events = results[run_id]
- assert events[-1] is END_SENTINEL, f"Run {run_id} did not receive END sentinel"
+ assert len(events) == 1
+ assert isinstance(events[0], StreamGap), f"Run {run_id} did not receive a gap"
+ assert events[0].requested_event_id is None
# ---------------------------------------------------------------------------
@@ -500,6 +561,121 @@ async def test_redis_replays_after_last_event_id(redis_bridge: RedisStreamBridge
assert received[-1] is END_SENTINEL
+@pytest.mark.anyio
+async def test_redis_evicted_last_event_id_yields_gap_before_partial_replay(
+ redis_bridge: RedisStreamBridge,
+):
+ """Redis must distinguish a trimmed cursor from a complete replay."""
+ run_id = "redis-run-evicted-cursor"
+ await redis_bridge.publish(run_id, "e1", {"step": 1})
+ key = redis_bridge._stream_key(run_id)
+ e1_id = redis_bridge._redis.streams[key][0][0]
+ await redis_bridge.publish(run_id, "e2", {"step": 2})
+ await redis_bridge.publish(run_id, "e3", {"step": 3})
+
+ retained_ids = [event_id for event_id, _fields in redis_bridge._redis.streams[key]]
+ received = [
+ entry
+ async for entry in redis_bridge.subscribe(
+ run_id,
+ last_event_id=e1_id,
+ heartbeat_interval=1.0,
+ )
+ ]
+
+ assert received == [
+ StreamGap(
+ requested_event_id=e1_id,
+ earliest_available_event_id=retained_ids[0],
+ latest_available_event_id=retained_ids[-1],
+ )
+ ]
+
+
+@pytest.mark.anyio
+async def test_redis_slow_subscriber_yields_gap_after_buffer_trim(
+ redis_bridge: RedisStreamBridge,
+):
+ """A live Redis subscriber must detect trimming after its last batch."""
+ run_id = "redis-run-slow-subscriber"
+ await redis_bridge.publish(run_id, "e1", {"step": 1})
+ subscriber = redis_bridge.subscribe(run_id, heartbeat_interval=1.0)
+ first = await anext(subscriber)
+ assert first.event == "e1"
+
+ await redis_bridge.publish(run_id, "e2", {"step": 2})
+ await redis_bridge.publish(run_id, "e3", {"step": 3})
+ await redis_bridge.publish(run_id, "e4", {"step": 4})
+ key = redis_bridge._stream_key(run_id)
+ retained_ids = [event_id for event_id, _fields in redis_bridge._redis.streams[key]]
+
+ assert await anext(subscriber) == StreamGap(
+ requested_event_id=first.id,
+ earliest_available_event_id=retained_ids[0],
+ latest_available_event_id=retained_ids[-1],
+ )
+ with pytest.raises(StopAsyncIteration):
+ await anext(subscriber)
+
+
+@pytest.mark.anyio
+async def test_redis_initial_subscriber_yields_gap_when_first_wake_falls_behind():
+ """An established no-cursor wait must not silently replay a trimmed tail."""
+ fake = _FakeRedis()
+ delayed = _DelayedBlockingReadRedis(fake)
+ bridge = RedisStreamBridge(
+ redis_url="redis://fake",
+ queue_maxsize=2,
+ client=delayed,
+ )
+ run_id = "redis-run-initial-subscriber-gap"
+ subscriber = bridge.subscribe(run_id, heartbeat_interval=1.0)
+ first_item = asyncio.create_task(anext(subscriber))
+
+ with anyio.fail_after(2):
+ await delayed.blocking_read_started.wait()
+ await bridge.publish(run_id, "e1", {"step": 1})
+ await delayed.wake_response_captured.wait()
+ await bridge.publish(run_id, "e2", {"step": 2})
+ await bridge.publish(run_id, "e3", {"step": 3})
+ await bridge.publish(run_id, "e4", {"step": 4})
+ delayed.release_wake_response.set()
+ gap = await first_item
+
+ key = bridge._stream_key(run_id)
+ retained_ids = [event_id for event_id, _fields in fake.streams[key]]
+ assert gap == StreamGap(
+ requested_event_id=None,
+ earliest_available_event_id=retained_ids[0],
+ latest_available_event_id=retained_ids[-1],
+ )
+ with pytest.raises(StopAsyncIteration):
+ await anext(subscriber)
+
+
+@pytest.mark.anyio
+async def test_redis_recovery_cursor_at_end_yields_end_immediately(
+ redis_bridge: RedisStreamBridge,
+):
+ """The latest gap cursor may be the internal end marker."""
+ run_id = "redis-run-end-cursor"
+ await redis_bridge.publish(run_id, "event", {})
+ await redis_bridge.publish_end(run_id)
+ key = redis_bridge._stream_key(run_id)
+ end_id = redis_bridge._redis.streams[key][-1][0]
+
+ received = [
+ entry
+ async for entry in redis_bridge.subscribe(
+ run_id,
+ last_event_id=end_id,
+ heartbeat_interval=0.01,
+ )
+ ]
+
+ assert received == [END_SENTINEL]
+
+
@pytest.mark.anyio
async def test_redis_invalid_last_event_id_tails_live_events(redis_bridge: RedisStreamBridge):
"""Malformed reconnect ids should not replay retained Redis events."""
@@ -744,6 +920,27 @@ async def test_redis_transient_error_gives_up_after_max_retries():
pass
+@pytest.mark.anyio
+async def test_redis_blocking_wakeup_error_gives_up_after_max_retries():
+ """Successful snapshots must not hide a permanently failing blocking XREAD."""
+ from redis.exceptions import RedisError
+
+ fake = _FakeRedis()
+ original_xread = fake.xread
+
+ async def fail_blocking_xread(streams, count=None, block=None):
+ if block is not None:
+ raise RedisError("Persistent blocking connection error")
+ return await original_xread(streams, count=count, block=block)
+
+ fake.xread = fail_blocking_xread
+ bridge = RedisStreamBridge(redis_url="redis://fake", queue_maxsize=2, client=fake)
+
+ with pytest.raises(RedisError, match="Persistent blocking connection error"):
+ async for _ in bridge.subscribe("redis-run-blocking-fail", heartbeat_interval=0.01):
+ pass
+
+
# ---------------------------------------------------------------------------
# Factory tests
# ---------------------------------------------------------------------------
@@ -757,7 +954,7 @@ async def test_make_stream_bridge_defaults():
# ---------------------------------------------------------------------------
-# _resolve_start_offset: O(1) seq-indexed resolution (parity with linear scan)
+# _resolve_start_offset: O(1) seq-indexed resolution
# ---------------------------------------------------------------------------
@@ -776,6 +973,7 @@ def _linear_resolve(stream, last_event_id):
[
("1718000000000-0", 0),
("1718000000000-42", 42),
+ ("-1", None), # malformed live-tail sentinel, not a valid event id
("garbage", None), # no separator
("1718000000000-x", None), # non-integer seq
("", None),
@@ -787,8 +985,7 @@ def test_parse_event_seq(event_id, expected):
@pytest.mark.anyio
async def test_resolve_start_offset_matches_linear_scan():
- """The seq-indexed resolver must return exactly what the linear scan returned,
- across retained, evicted, foreign (same seq / wrong ts), malformed, and None ids."""
+ """Retained and unknown cursors preserve the previous linear-scan behavior."""
bridge = MemoryStreamBridge(queue_maxsize=4)
run_id = "run-parity"
ids = []
@@ -802,10 +999,36 @@ async def test_resolve_start_offset_matches_linear_scan():
ts, _, seq_text = stream.events[0].id.rpartition("-")
foreign_id = f"{int(ts) + 1}-{seq_text}"
- candidates = [None, "garbage", "1718000000000-x", "999999-999999", foreign_id, *ids]
+ candidates = [None, "garbage", "1718000000000-x", "999999-999999", foreign_id, *ids[6:]]
for eid in candidates:
assert bridge._resolve_start_offset(stream, eid) == _linear_resolve(stream, eid), eid
+ for evicted_id in ids[:6]:
+ assert bridge._resolve_start_offset(stream, evicted_id) == StreamGap(
+ requested_event_id=evicted_id,
+ earliest_available_event_id=stream.events[0].id,
+ latest_available_event_id=stream.events[-1].id,
+ )
+
+
+@pytest.mark.anyio
+async def test_memory_low_numeric_foreign_cursor_conservatively_yields_gap():
+ """An unverifiable numeric cursor below the watermark takes the safe path."""
+ bridge = MemoryStreamBridge(queue_maxsize=2)
+ run_id = "run-low-foreign-cursor"
+ for index in range(3):
+ await bridge.publish(run_id, f"e{index}", {"index": index})
+
+ stream = bridge._streams[run_id]
+ timestamp, _, _sequence = stream.events[0].id.rpartition("-")
+ foreign_evicted_id = f"{int(timestamp) + 1}-0"
+
+ assert bridge._resolve_start_offset(stream, foreign_evicted_id) == StreamGap(
+ requested_event_id=foreign_evicted_id,
+ earliest_available_event_id=stream.events[0].id,
+ latest_available_event_id=stream.events[-1].id,
+ )
+
@pytest.mark.anyio
async def test_subscribe_with_unknown_last_event_id_replays_from_earliest():
@@ -826,6 +1049,29 @@ async def test_subscribe_with_unknown_last_event_id_replays_from_earliest():
assert received[-1] is END_SENTINEL
+@pytest.mark.anyio
+async def test_memory_malformed_last_event_id_is_not_reported_as_gap():
+ """Malformed cursor policy stays separate from valid evicted cursors."""
+ bridge = MemoryStreamBridge(queue_maxsize=2)
+ run_id = "run-malformed-id"
+ await bridge.publish(run_id, "first", {})
+ await bridge.publish(run_id, "second", {})
+ await bridge.publish_end(run_id)
+
+ received = [
+ entry
+ async for entry in bridge.subscribe(
+ run_id,
+ last_event_id="-1",
+ heartbeat_interval=1.0,
+ )
+ ]
+
+ assert [entry.event for entry in received[:-1]] == ["first", "second"]
+ assert received[-1] is END_SENTINEL
+ assert all(not isinstance(entry, StreamGap) for entry in received)
+
+
@pytest.mark.anyio
async def test_make_stream_bridge_uses_docker_redis_env(monkeypatch):
"""Docker can enable Redis bridge without editing config.yaml."""
@@ -1009,6 +1255,72 @@ async def test_redis_integration_maxlen_trims_history(real_redis_bridge):
assert length == 2
+@pytest.mark.integration
+@requires_redis
+@pytest.mark.anyio
+async def test_redis_integration_evicted_cursor_yields_gap(real_redis_bridge):
+ """Real Redis MAXLEN trimming must produce the same gap contract."""
+ run_id = "integ-gap"
+ await real_redis_bridge.publish(run_id, "event-1", {"i": 1})
+ key = real_redis_bridge._stream_key(run_id)
+ first_id = (await real_redis_bridge._redis.xrange(key, count=1))[0][0]
+ await real_redis_bridge.publish(run_id, "event-2", {"i": 2})
+ await real_redis_bridge.publish(run_id, "event-3", {"i": 3})
+
+ retained = await real_redis_bridge._redis.xrange(key)
+ received = [
+ entry
+ async for entry in real_redis_bridge.subscribe(
+ run_id,
+ last_event_id=first_id,
+ heartbeat_interval=1.0,
+ )
+ ]
+
+ assert received == [
+ StreamGap(
+ requested_event_id=first_id,
+ earliest_available_event_id=retained[0][0],
+ latest_available_event_id=retained[-1][0],
+ )
+ ]
+
+
+@pytest.mark.integration
+@requires_redis
+@pytest.mark.anyio
+async def test_redis_integration_initial_subscriber_yields_gap_when_first_wake_falls_behind(
+ real_redis_bridge,
+):
+ """A real blocking XREAD wake must be validated before first delivery."""
+ raw_redis = real_redis_bridge._redis
+ delayed = _DelayedBlockingReadRedis(raw_redis)
+ real_redis_bridge._redis = delayed
+ run_id = "integ-initial-subscriber-gap"
+ subscriber = real_redis_bridge.subscribe(run_id, heartbeat_interval=1.0)
+ first_item = asyncio.create_task(anext(subscriber))
+
+ with anyio.fail_after(2):
+ await delayed.blocking_read_started.wait()
+ await real_redis_bridge.publish(run_id, "e1", {"step": 1})
+ await delayed.wake_response_captured.wait()
+ await real_redis_bridge.publish(run_id, "e2", {"step": 2})
+ await real_redis_bridge.publish(run_id, "e3", {"step": 3})
+ await real_redis_bridge.publish(run_id, "e4", {"step": 4})
+ delayed.release_wake_response.set()
+ gap = await first_item
+
+ key = real_redis_bridge._stream_key(run_id)
+ retained = await raw_redis.xrange(key)
+ assert gap == StreamGap(
+ requested_event_id=None,
+ earliest_available_event_id=retained[0][0],
+ latest_available_event_id=retained[-1][0],
+ )
+ with pytest.raises(StopAsyncIteration):
+ await anext(subscriber)
+
+
@pytest.mark.integration
@requires_redis
@pytest.mark.anyio
diff --git a/backend/tests/test_wait_disconnect_handling.py b/backend/tests/test_wait_disconnect_handling.py
index dcfec4a03..011744de7 100644
--- a/backend/tests/test_wait_disconnect_handling.py
+++ b/backend/tests/test_wait_disconnect_handling.py
@@ -125,6 +125,36 @@ class TestWaitForRunCompletion:
asyncio.run(run())
+ def test_gap_resumes_from_retained_tail_until_run_ends(self) -> None:
+ """The internal wait path may skip payloads but must still observe END."""
+ from app.gateway.services import wait_for_run_completion
+
+ async def run() -> None:
+ mgr = RunManager()
+ bridge = MemoryStreamBridge(queue_maxsize=2)
+ record = await _create_running_record(mgr, on_disconnect=DisconnectMode.cancel)
+ request = _FakeRequest()
+
+ async def overrun_then_finish() -> None:
+ await asyncio.sleep(0)
+ for step in range(4):
+ await bridge.publish(record.run_id, "values", {"step": step})
+ await asyncio.sleep(0)
+ await mgr.set_status(record.run_id, RunStatus.success)
+ await bridge.publish_end(record.run_id)
+
+ asyncio.create_task(overrun_then_finish())
+ completed = await asyncio.wait_for(
+ wait_for_run_completion(bridge, record, request, mgr),
+ timeout=2.0,
+ )
+
+ assert completed is True
+ assert record.status == RunStatus.success
+ assert not record.abort_event.is_set()
+
+ asyncio.run(run())
+
def test_cancels_run_on_disconnect_when_cancel_mode(self) -> None:
"""on_disconnect=cancel: real disconnect must call run_mgr.cancel()."""
from app.gateway.services import wait_for_run_completion
diff --git a/config.example.yaml b/config.example.yaml
index 476e82123..1282788e1 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -1915,12 +1915,14 @@ run_ownership:
#
# stream_bridge:
# type: memory # single-process only
-# queue_maxsize: 256
+# queue_maxsize: 256 # data events retained per run. A reconnect
+# # older than this window receives SSE `gap`.
#
# stream_bridge:
# type: redis # recommended for Docker / multi-worker gateway
# redis_url: redis://redis:6379/0
-# queue_maxsize: 256 # events retained per run (redis stream MAXLEN)
+# queue_maxsize: 256 # data events retained per run (Redis MAXLEN).
+# # Older valid cursors receive SSE `gap`.
# stream_ttl_seconds: 86400 # rolling TTL for retained stream buffers.
# # Refreshed on each publish/publish_end; set 0
# # to disable. This is not a run timeout.
diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md
index 1040ceed8..9906415b1 100644
--- a/frontend/AGENTS.md
+++ b/frontend/AGENTS.md
@@ -93,7 +93,8 @@ Tool-calling AI messages can contain user-visible text as well as `tool_calls`.
- **Thread hooks** (`useThreadStream`, `useSubmitThread`, `useThreads`) are the primary API interface
- **Thread routes** — construct Web UI chat paths through `core/threads/utils.ts::pathOfThread()`, which percent-encodes both custom agent names and thread IDs before inserting them into route segments
- **LangGraph client** is a singleton obtained via `getAPIClient()` in `core/api/`
-- **Run stream options** are sanitized by `core/api/stream-mode.ts`: the Gateway-supported set is `values`, `messages-tuple`, `updates`, `debug`, `tasks`, `checkpoints`, and `custom`; any request containing an unsupported mode throws before HTTP instead of being partially forwarded or silently defaulting to `values`. `streamResumable` is retained by thread hooks only for SDK-side reconnect bookkeeping but stripped before the HTTP request because the Gateway does not implement resumable SSE. Keep this boundary aligned with the backend request schema; `messages` and `events` are not supported and must not be forwarded.
+- **Run stream options** are sanitized by `core/api/stream-mode.ts`: the Gateway-supported set is `values`, `messages-tuple`, `updates`, `debug`, `tasks`, `checkpoints`, and `custom`; any request containing an unsupported mode throws before HTTP instead of being partially forwarded or silently defaulting to `values`. `streamResumable` is retained by thread hooks only for SDK-side reconnect bookkeeping but stripped before the HTTP request because the Gateway does not accept that request option; actual replay uses the SSE `Last-Event-ID` cursor. Keep this boundary aligned with the backend request schema; `messages` and `events` are not supported and must not be forwarded.
+- **SSE replay gaps** are handled in `core/api/api-client.ts`, which wraps both initial and joined run streams because the upstream SDK ignores unknown event names. An id-less backend `gap` control frame clears stale reconnect metadata, emits an internal `stream_replay_gap` custom event, reloads durable thread values, and rejoins after the server-provided retained tail, with up to five recovery rejoins after the original stream (six total stream calls on an all-gap exhaustion path). The wrapper remains a lazy async iterable because the SDK consumes it with `for await`. `core/threads/hooks.ts` clears optimistic/transient/subtask state, invalidates durable history caches, and shows the localized recovery warning; never let a gap fall through as a normal stream finish or cancel the still-running backend run.
- **Streaming Markdown rendering** is owned by `core/streamdown`: Streamdown's `animated` / `isAnimating` API handles incremental word animation, while the shared `streamdownRenderingPlugins` config registers the named code-highlighting and Mermaid plugins required by Streamdown 2.5. Keep wrappers and derived configs wired to that shared object; do not reintroduce a rehype plugin that wraps every word, because reparsing a growing block remounts old words and replays their animation.
- **Environment validation** uses `@t3-oss/env-nextjs` with Zod schemas (`src/env.js`). Skip with `SKIP_ENV_VALIDATION=1`
- **Subtask step history and runtime metadata** (`core/tasks/`) — the subtask card shows a subagent's full step timeline (#3779): its assistant reasoning turns interleaved with the tools it ran. `Subtask.steps[]` is accumulated live from `task_running` events (appended via `mergeSteps`, not overwritten) and backfilled on expand for historical runs by `fetchSubtaskSteps`, which pages the events endpoint scoped to one task (GET `/runs/{runId}/events?event_types=subagent.step&task_id=…&after_seq=…`) until a short page, so the run-wide limit can't truncate the timeline. `task_started` carries the effective `model_name`; `task_running` carries a cumulative usage snapshot after each completed LLM call. `core/tasks/lifecycle.ts` normalizes these additive events, and `computeNextSubtask` keeps the largest cumulative total so replayed or late SSE frames cannot double-count or roll the folded card backward. Terminal ToolMessage metadata (`subagent_model_name` / `subagent_token_usage`) restores the same values from normal history after reload; no per-card event fetch is needed. `core/tasks/steps.ts` is the pure step model: `messageToStep` (live), `eventsToSteps` (reload), `mergeSteps` (dedup by `message_index`), and `stepsForDisplay` (what the card renders — keeps tool steps + AI steps with text, drops the trailing final-answer AI step when completed since it's shown as `result`). `core/tasks/context.tsx`'s `useUpdateSubtask` applies updates against a `tasksRef` mirroring the latest state (not a closure snapshot), so a late-resolving `fetchSubtaskSteps` backfill merges into current state instead of clobbering SSE steps or sibling subtasks that arrived meanwhile. The owning `run_id` is carried onto history content messages in `buildVisibleHistoryMessages` so the card can resolve the events endpoint.
diff --git a/frontend/src/core/api/api-client.ts b/frontend/src/core/api/api-client.ts
index 5a4370591..f557edcee 100644
--- a/frontend/src/core/api/api-client.ts
+++ b/frontend/src/core/api/api-client.ts
@@ -67,6 +67,59 @@ const TERMINAL_RUN_STATUSES = new Set([
"interrupted",
]);
+// This is a rejoin budget: the original stream is not counted, so exhausting
+// five recovery attempts can consume six streams in total.
+const MAX_STREAM_GAP_RECOVERIES = 5;
+
+export type StreamReplayGapData = {
+ code: "stream_replay_gap";
+ run_id: string;
+ requested_event_id: string | null;
+ earliest_available_event_id: string;
+ latest_available_event_id: string;
+ recovery: "reload_durable_state";
+};
+
+type StreamPart = {
+ id?: string;
+ event: string;
+ data: unknown;
+};
+
+export class StreamReplayGapError extends Error {
+ constructor(
+ readonly gap: StreamReplayGapData,
+ readonly recoveryAttempts: number,
+ readonly recoveryCause?: unknown,
+ ) {
+ super(
+ `Unable to recover SSE history after ${recoveryAttempts} attempts (requested ${gap.requested_event_id ?? "initial stream"}, earliest ${gap.earliest_available_event_id})`,
+ );
+ this.name = "StreamReplayGapError";
+ }
+}
+
+function parseStreamReplayGap(data: unknown): StreamReplayGapData {
+ if (typeof data !== "object" || data === null) {
+ throw new Error("Invalid stream replay gap payload.");
+ }
+
+ const value = data as Record;
+ const requestedEventId = value.requested_event_id;
+ if (
+ value.code !== "stream_replay_gap" ||
+ typeof value.run_id !== "string" ||
+ (requestedEventId !== null && typeof requestedEventId !== "string") ||
+ typeof value.earliest_available_event_id !== "string" ||
+ typeof value.latest_available_event_id !== "string" ||
+ value.recovery !== "reload_durable_state"
+ ) {
+ throw new Error("Invalid stream replay gap payload.");
+ }
+
+ return value as StreamReplayGapData;
+}
+
/**
* Shared matcher for the gateway's 409 conflict responses. The SDK surfaces
* non-2xx responses as ``HTTPError { status, message }`` where ``message`` is
@@ -167,6 +220,105 @@ export function clearReconnectRun(
}
}
+function rememberReconnectRun(
+ threadId: string | null | undefined,
+ runId: string,
+): void {
+ if (typeof window === "undefined" || !threadId) return;
+
+ try {
+ window.sessionStorage.setItem(`lg:stream:${threadId}`, runId);
+ } catch {
+ // Ignore storage access failures so gap recovery remains usable.
+ }
+}
+
+async function* recoverStreamReplayGaps({
+ client,
+ threadId,
+ expectedRunId,
+ initialStream,
+ resume,
+}: {
+ client: LangGraphClient;
+ threadId: string | null | undefined;
+ expectedRunId: () => string | undefined;
+ initialStream: AsyncIterable;
+ resume: (runId: string, lastEventId: string) => AsyncIterable;
+}): AsyncGenerator {
+ let stream = initialStream;
+ let recoveryAttempts = 0;
+
+ while (true) {
+ let gap: StreamReplayGapData | undefined;
+ for await (const entry of stream) {
+ if (entry.event === "gap") {
+ gap = parseStreamReplayGap(entry.data);
+ break;
+ }
+ yield entry;
+ }
+
+ if (!gap) {
+ return;
+ }
+
+ const runId = expectedRunId() ?? gap.run_id;
+ if (!threadId || gap.run_id !== runId) {
+ throw new Error(
+ "Stream replay gap does not match the active thread run.",
+ );
+ }
+ if (recoveryAttempts >= MAX_STREAM_GAP_RECOVERIES) {
+ throw new StreamReplayGapError(gap, recoveryAttempts);
+ }
+ recoveryAttempts += 1;
+
+ // The SDK would otherwise ignore an unknown `gap` event and report a
+ // normal finish. Surface a custom control event to DeerFlow's hook, reload
+ // durable values, then explicitly follow only events newer than the
+ // retained tail captured by the server.
+ clearReconnectRun(threadId, runId);
+ yield {
+ event: "custom",
+ data: { type: "stream_replay_gap", ...gap },
+ };
+
+ const durableState = await client.threads
+ .getState(threadId)
+ .catch((error: unknown) => {
+ throw new StreamReplayGapError(gap, recoveryAttempts, error);
+ });
+ if (durableState.values != null) {
+ yield { event: "values", data: durableState.values };
+ }
+
+ rememberReconnectRun(threadId, runId);
+ stream = resume(runId, gap.latest_available_event_id);
+ }
+}
+
+async function* handleInactiveRunStream({
+ threadId,
+ expectedRunId,
+ stream,
+}: {
+ threadId: string | null | undefined;
+ expectedRunId: () => string | undefined;
+ stream: AsyncIterable;
+}): AsyncGenerator {
+ try {
+ yield* stream;
+ } catch (error) {
+ const runId = expectedRunId();
+ if (runId && isInactiveRunStreamError(error)) {
+ clearReconnectRun(threadId, runId);
+ return;
+ }
+ throw error;
+ }
+}
+
function createCompatibleClient(isMock?: boolean): LangGraphClient {
if (isStaticWebsiteOnly() && !isMock) {
return createStaticClient();
@@ -179,12 +331,44 @@ function createCompatibleClient(isMock?: boolean): LangGraphClient {
});
const originalRunStream = client.runs.stream.bind(client.runs);
- client.runs.stream = ((threadId, assistantId, payload) =>
- originalRunStream(
+ const originalJoinStream = client.runs.joinStream.bind(client.runs);
+ // Preserve the SDK's lazy AsyncIterable contract. Its StreamManager consumes
+ // this return value with `for await`, so run creation still starts on first
+ // iteration rather than when `runs.stream()` is called.
+ client.runs.stream = async function* (threadId, assistantId, payload) {
+ const sanitizedPayload = sanitizeRunStreamOptions(payload);
+ const originalOnRunCreated = sanitizedPayload?.onRunCreated;
+ let runId: string | undefined;
+ const initialStream = originalRunStream(threadId, assistantId, {
+ ...sanitizedPayload,
+ onRunCreated(meta) {
+ runId = meta.run_id;
+ originalOnRunCreated?.(meta);
+ },
+ });
+
+ const recoveredStream = recoverStreamReplayGaps({
+ client,
threadId,
- assistantId,
- sanitizeRunStreamOptions(payload),
- )) as typeof client.runs.stream;
+ expectedRunId: () => runId,
+ initialStream,
+ resume: (resolvedRunId, lastEventId) => {
+ // Keep the recovery run id available to the shared inactive-stream
+ // handler even if the SDK omitted its onRunCreated callback.
+ runId = resolvedRunId;
+ return originalJoinStream(threadId, resolvedRunId, {
+ lastEventId,
+ signal: sanitizedPayload?.signal,
+ streamMode: sanitizedPayload?.streamMode,
+ });
+ },
+ });
+ yield* handleInactiveRunStream({
+ threadId,
+ expectedRunId: () => runId,
+ stream: recoveredStream,
+ });
+ } as typeof client.runs.stream;
const originalCancel = client.runs.cancel.bind(client.runs);
client.runs.cancel = (async (threadId, runId, wait, action, options) => {
@@ -205,7 +389,6 @@ function createCompatibleClient(isMock?: boolean): LangGraphClient {
}
}) as typeof client.runs.cancel;
- const originalJoinStream = client.runs.joinStream.bind(client.runs);
client.runs.joinStream = async function* (threadId, runId, options) {
// Short-circuit reconnects to runs that have already finished: otherwise a
// reload after the backend's stream bridge is reaped blocks forever on a
@@ -215,19 +398,22 @@ function createCompatibleClient(isMock?: boolean): LangGraphClient {
clearReconnectRun(threadId, runId);
return;
}
- try {
- yield* originalJoinStream(
+ const sanitizedOptions = sanitizeRunStreamOptions(options);
+ yield* handleInactiveRunStream({
+ threadId,
+ expectedRunId: () => runId,
+ stream: recoverStreamReplayGaps({
+ client,
threadId,
- runId,
- sanitizeRunStreamOptions(options),
- );
- } catch (error) {
- if (isInactiveRunStreamError(error)) {
- clearReconnectRun(threadId, runId);
- return;
- }
- throw error;
- }
+ expectedRunId: () => runId,
+ initialStream: originalJoinStream(threadId, runId, sanitizedOptions),
+ resume: (resolvedRunId, lastEventId) =>
+ originalJoinStream(threadId, resolvedRunId, {
+ ...sanitizedOptions,
+ lastEventId,
+ }),
+ }),
+ });
} as typeof client.runs.joinStream;
return client;
diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts
index 42de3ac12..dd00518d6 100644
--- a/frontend/src/core/i18n/locales/en-US.ts
+++ b/frontend/src/core/i18n/locales/en-US.ts
@@ -506,6 +506,8 @@ export const enUS: Translations = {
startConversation: "Start a conversation to see messages here",
branchCreated: "Conversation branch created",
branchFailed: "Failed to branch conversation.",
+ streamReplayGap:
+ "Some live updates expired. The conversation was restored from saved state.",
},
// Chats
diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts
index 7d5e3ece1..b037f348e 100644
--- a/frontend/src/core/i18n/locales/types.ts
+++ b/frontend/src/core/i18n/locales/types.ts
@@ -405,6 +405,7 @@ export interface Translations {
startConversation: string;
branchCreated: string;
branchFailed: string;
+ streamReplayGap: string;
};
// Chats
diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts
index 4544b9429..48fcd7713 100644
--- a/frontend/src/core/i18n/locales/zh-CN.ts
+++ b/frontend/src/core/i18n/locales/zh-CN.ts
@@ -483,6 +483,7 @@ export const zhCN: Translations = {
startConversation: "开始新的对话以查看消息",
branchCreated: "已创建分叉对话",
branchFailed: "创建分叉对话失败。",
+ streamReplayGap: "部分实时更新已过期,已从持久化状态恢复对话。",
},
// Chats
diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts
index 39225040e..92825368a 100644
--- a/frontend/src/core/threads/hooks.ts
+++ b/frontend/src/core/threads/hooks.ts
@@ -23,7 +23,7 @@ import { isHiddenFromUIMessage } from "../messages/utils";
import type { FileInMessage } from "../messages/utils";
import type { LocalSettings } from "../settings";
import { isSidecarThread, SIDECAR_METADATA_KEY } from "../sidecar/thread";
-import { useUpdateSubtask } from "../tasks/context";
+import { useSubtaskContext, useUpdateSubtask } from "../tasks/context";
import { taskEventToSubtaskUpdate } from "../tasks/lifecycle";
import { messageToStep } from "../tasks/steps";
import type { UploadedFileInfo } from "../uploads";
@@ -1208,6 +1208,7 @@ export function useThreadStream({
}, []);
const queryClient = useQueryClient();
+ const { tasksRef, setTasks } = useSubtaskContext();
const updateSubtask = useUpdateSubtask();
const thread = useStream({
@@ -1343,6 +1344,25 @@ export function useThreadStream({
? (event as { type: unknown }).type
: undefined;
+ if (eventType === "stream_replay_gap") {
+ setOptimisticMessages([]);
+ setOptimisticThreadId(null);
+ setLiveMessagesThreadId(null);
+ setPendingSupersededRunIds(new Set());
+ setPendingSupersededMessageIds(new Set());
+ messagesRef.current = [];
+ transientHistoryBridgeRef.current = [];
+ transientHistoryOrderRef.current = [];
+ transientHistoryThreadIdRef.current = null;
+ summarizedRef.current = new Set();
+ pendingUsageBaselineMessageIdsRef.current = new Set();
+ tasksRef.current = {};
+ setTasks({});
+ invalidateStoppedThreadCaches(queryClient, threadIdRef.current, isMock);
+ toast.warning(t.conversation.streamReplayGap);
+ return;
+ }
+
const taskUpdate = taskEventToSubtaskUpdate(event);
if (taskUpdate) {
updateSubtask(taskUpdate);
diff --git a/frontend/tests/unit/core/api/api-client.test.ts b/frontend/tests/unit/core/api/api-client.test.ts
index 0d6f39afd..e494569c6 100644
--- a/frontend/tests/unit/core/api/api-client.test.ts
+++ b/frontend/tests/unit/core/api/api-client.test.ts
@@ -5,6 +5,7 @@ import {
getAPIClient,
isInactiveRunStreamError,
isRunNotCancellableError,
+ StreamReplayGapError,
} from "@/core/api/api-client";
function makeSessionStorage() {
@@ -20,6 +21,16 @@ function makeSessionStorage() {
};
}
+function makeSSEResponse(body: string, headers?: HeadersInit) {
+ return new Response(body, {
+ status: 200,
+ headers: {
+ "Content-Type": "text/event-stream",
+ ...headers,
+ },
+ });
+}
+
afterEach(() => {
rs.unstubAllGlobals();
});
@@ -301,6 +312,336 @@ test("proceeds to join when the run is still active", async () => {
expect(sessionStorage.removeItem).toHaveBeenCalledWith("lg:stream:thread-1");
});
+test("recovers a join stream gap from durable state and resumes after the retained tail", async () => {
+ const sessionStorage = makeSessionStorage();
+ sessionStorage.setItem("lg:stream:thread-1", "run-1");
+ const gap = {
+ code: "stream_replay_gap",
+ run_id: "run-1",
+ requested_event_id: "1-0",
+ earliest_available_event_id: "2-0",
+ latest_available_event_id: "3-0",
+ recovery: "reload_durable_state",
+ };
+ const recoveryRequests: RequestInit[] = [];
+ const fetchFn = rs.fn(async (url: string | URL, init?: RequestInit) => {
+ const path = url.toString();
+ if (path.endsWith("/runs/run-1")) {
+ return new Response(JSON.stringify({ status: "running" }), {
+ status: 200,
+ });
+ }
+ if (path.includes("/runs/run-1/stream")) {
+ recoveryRequests.push(init ?? {});
+ if (recoveryRequests.length === 1) {
+ return makeSSEResponse(`event: gap\ndata: ${JSON.stringify(gap)}\n\n`);
+ }
+ return makeSSEResponse("event: end\ndata: null\n\n");
+ }
+ if (path.includes("/threads/thread-1/state")) {
+ return new Response(
+ JSON.stringify({
+ values: { messages: [{ type: "ai", content: "durable" }] },
+ next: [],
+ tasks: [],
+ metadata: {},
+ created_at: null,
+ checkpoint: {},
+ parent_checkpoint: null,
+ }),
+ { status: 200 },
+ );
+ }
+ return new Response(JSON.stringify({ detail: "unexpected request" }), {
+ status: 500,
+ });
+ });
+ rs.stubGlobal("window", {
+ location: { origin: "http://localhost:2026" },
+ sessionStorage,
+ });
+ rs.stubGlobal("fetch", fetchFn);
+
+ const received: Array<{ id?: string; event: string; data: unknown }> = [];
+ for await (const entry of getAPIClient(true).runs.joinStream(
+ "thread-1",
+ "run-1",
+ { lastEventId: "1-0" },
+ )) {
+ received.push(entry);
+ }
+
+ expect(received).toEqual([
+ {
+ event: "custom",
+ data: { type: "stream_replay_gap", ...gap },
+ },
+ {
+ event: "values",
+ data: { messages: [{ type: "ai", content: "durable" }] },
+ },
+ { event: "end", data: null },
+ ]);
+ expect(new Headers(recoveryRequests[1]?.headers).get("Last-Event-ID")).toBe(
+ "3-0",
+ );
+});
+
+test("recovers a gap emitted by the initial run stream", async () => {
+ const sessionStorage = makeSessionStorage();
+ const gap = {
+ code: "stream_replay_gap",
+ run_id: "run-2",
+ requested_event_id: null,
+ earliest_available_event_id: "4-0",
+ latest_available_event_id: "5-0",
+ recovery: "reload_durable_state",
+ };
+ const recoveryHeaders: Headers[] = [];
+ const fetchFn = rs.fn(async (url: string | URL, init?: RequestInit) => {
+ const path = url.toString();
+ if (path.endsWith("/threads/thread-2/runs/stream")) {
+ return makeSSEResponse(`event: gap\ndata: ${JSON.stringify(gap)}\n\n`, {
+ "Content-Location": "/threads/thread-2/runs/run-2",
+ });
+ }
+ if (path.includes("/threads/thread-2/state")) {
+ return new Response(
+ JSON.stringify({
+ values: { messages: [{ type: "ai", content: "checkpoint" }] },
+ }),
+ { status: 200 },
+ );
+ }
+ if (path.includes("/runs/run-2/stream")) {
+ recoveryHeaders.push(new Headers(init?.headers));
+ return makeSSEResponse("event: end\ndata: null\n\n");
+ }
+ return new Response(JSON.stringify({ detail: "unexpected request" }), {
+ status: 500,
+ });
+ });
+ rs.stubGlobal("window", {
+ location: { origin: "http://localhost:2026" },
+ sessionStorage,
+ });
+ rs.stubGlobal("fetch", fetchFn);
+
+ const received: Array<{ id?: string; event: string; data: unknown }> = [];
+ for await (const entry of getAPIClient(true).runs.stream(
+ "thread-2",
+ "lead_agent",
+ { streamResumable: true },
+ )) {
+ received.push(entry);
+ }
+
+ expect(received).toEqual([
+ {
+ event: "custom",
+ data: { type: "stream_replay_gap", ...gap },
+ },
+ {
+ event: "values",
+ data: { messages: [{ type: "ai", content: "checkpoint" }] },
+ },
+ { event: "end", data: null },
+ ]);
+ expect(recoveryHeaders[0]?.get("Last-Event-ID")).toBe("5-0");
+});
+
+test("clears reconnect metadata when an initial stream gap resume is inactive", async () => {
+ const sessionStorage = makeSessionStorage();
+ const gap = {
+ code: "stream_replay_gap",
+ run_id: "run-inactive-resume",
+ requested_event_id: null,
+ earliest_available_event_id: "4-0",
+ latest_available_event_id: "5-0",
+ recovery: "reload_durable_state",
+ };
+ let recoveryRequests = 0;
+ const fetchFn = rs.fn(async (url: string | URL) => {
+ const path = url.toString();
+ if (path.endsWith("/threads/thread-inactive-resume/runs/stream")) {
+ return makeSSEResponse(`event: gap\ndata: ${JSON.stringify(gap)}\n\n`, {
+ "Content-Location":
+ "/threads/thread-inactive-resume/runs/run-inactive-resume",
+ });
+ }
+ if (path.includes("/threads/thread-inactive-resume/state")) {
+ return new Response(
+ JSON.stringify({
+ values: { messages: [{ type: "ai", content: "checkpoint" }] },
+ }),
+ { status: 200 },
+ );
+ }
+ if (path.includes("/runs/run-inactive-resume/stream")) {
+ recoveryRequests += 1;
+ return new Response(
+ JSON.stringify({
+ detail:
+ "Run run-inactive-resume is not active on this worker and cannot be streamed",
+ }),
+ { status: 409 },
+ );
+ }
+ return new Response(JSON.stringify({ detail: "unexpected request" }), {
+ status: 500,
+ });
+ });
+ rs.stubGlobal("window", {
+ location: { origin: "http://localhost:2026" },
+ sessionStorage,
+ });
+ rs.stubGlobal("fetch", fetchFn);
+
+ const stream = getAPIClient(true).runs.stream(
+ "thread-inactive-resume",
+ "lead_agent",
+ { streamResumable: true },
+ );
+
+ await expect(stream.next()).resolves.toMatchObject({
+ done: false,
+ value: {
+ event: "custom",
+ data: { type: "stream_replay_gap", ...gap },
+ },
+ });
+ await expect(stream.next()).resolves.toMatchObject({
+ done: false,
+ value: {
+ event: "values",
+ data: { messages: [{ type: "ai", content: "checkpoint" }] },
+ },
+ });
+ await expect(stream.next()).resolves.toMatchObject({ done: true });
+
+ expect(recoveryRequests).toBe(1);
+ expect(sessionStorage.getItem("lg:stream:thread-inactive-resume")).toBeNull();
+});
+
+test("stops after five consecutive stream gap recoveries", async () => {
+ const sessionStorage = makeSessionStorage();
+ sessionStorage.setItem("lg:stream:thread-gap-loop", "run-gap-loop");
+ let streamCalls = 0;
+ let stateCalls = 0;
+ const fetchFn = rs.fn(async (url: string | URL) => {
+ const path = url.toString();
+ if (path.endsWith("/runs/run-gap-loop")) {
+ return new Response(JSON.stringify({ status: "running" }), {
+ status: 200,
+ });
+ }
+ if (path.includes("/runs/run-gap-loop/stream")) {
+ streamCalls += 1;
+ const gap = {
+ code: "stream_replay_gap",
+ run_id: "run-gap-loop",
+ requested_event_id: `${streamCalls}-0`,
+ earliest_available_event_id: `${streamCalls + 1}-0`,
+ latest_available_event_id: `${streamCalls + 2}-0`,
+ recovery: "reload_durable_state",
+ };
+ return makeSSEResponse(`event: gap\ndata: ${JSON.stringify(gap)}\n\n`);
+ }
+ if (path.includes("/threads/thread-gap-loop/state")) {
+ stateCalls += 1;
+ return new Response(JSON.stringify({ values: { messages: [] } }), {
+ status: 200,
+ });
+ }
+ return new Response(JSON.stringify({ detail: "unexpected request" }), {
+ status: 500,
+ });
+ });
+ rs.stubGlobal("window", {
+ location: { origin: "http://localhost:2026" },
+ sessionStorage,
+ });
+ rs.stubGlobal("fetch", fetchFn);
+
+ const consume = async () => {
+ for await (const entry of getAPIClient(true).runs.joinStream(
+ "thread-gap-loop",
+ "run-gap-loop",
+ { lastEventId: "1-0" },
+ )) {
+ // Drain until the recovery budget is exhausted.
+ void entry;
+ }
+ };
+
+ await expect(consume()).rejects.toBeInstanceOf(StreamReplayGapError);
+ expect(streamCalls).toBe(6);
+ expect(stateCalls).toBe(5);
+});
+
+test("surfaces durable-state recovery failures as a structured gap error", async () => {
+ const sessionStorage = makeSessionStorage();
+ sessionStorage.setItem("lg:stream:thread-gap-state", "run-gap-state");
+ sessionStorage.setItem.mockClear();
+ const gap = {
+ code: "stream_replay_gap",
+ run_id: "run-gap-state",
+ requested_event_id: "1-0",
+ earliest_available_event_id: "2-0",
+ latest_available_event_id: "3-0",
+ recovery: "reload_durable_state",
+ };
+ const fetchFn = rs.fn(async (url: string | URL) => {
+ const path = url.toString();
+ if (path.endsWith("/runs/run-gap-state")) {
+ return new Response(JSON.stringify({ status: "running" }), {
+ status: 200,
+ });
+ }
+ if (path.includes("/runs/run-gap-state/stream")) {
+ return makeSSEResponse(`event: gap\ndata: ${JSON.stringify(gap)}\n\n`);
+ }
+ if (path.includes("/threads/thread-gap-state/state")) {
+ return new Response(JSON.stringify({ detail: "state unavailable" }), {
+ status: 404,
+ });
+ }
+ return new Response(JSON.stringify({ detail: "unexpected request" }), {
+ status: 500,
+ });
+ });
+ rs.stubGlobal("window", {
+ location: { origin: "http://localhost:2026" },
+ sessionStorage,
+ });
+ rs.stubGlobal("fetch", fetchFn);
+
+ const consume = async () => {
+ for await (const entry of getAPIClient(true).runs.joinStream(
+ "thread-gap-state",
+ "run-gap-state",
+ { lastEventId: "1-0" },
+ )) {
+ void entry;
+ }
+ };
+
+ const error = await consume().catch((cause: unknown) => cause);
+ expect(error).toBeInstanceOf(StreamReplayGapError);
+ expect(error).toMatchObject({
+ gap,
+ recoveryAttempts: 1,
+ recoveryCause: expect.any(Error),
+ });
+ expect(sessionStorage.removeItem).toHaveBeenCalledWith(
+ "lg:stream:thread-gap-state",
+ );
+ expect(sessionStorage.setItem).not.toHaveBeenCalledWith(
+ "lg:stream:thread-gap-state",
+ "run-gap-state",
+ );
+});
+
test("short-circuits reconnect to an interrupted (user-cancelled) run", async () => {
// Regression: interrupted is a persisted terminal status written by
// RunManager.cancel(). Reconnecting to it must short-circuit like other
diff --git a/frontend/tests/unit/core/threads/stream-options.test.ts b/frontend/tests/unit/core/threads/stream-options.test.ts
index da9f8adfa..cc089cb19 100644
--- a/frontend/tests/unit/core/threads/stream-options.test.ts
+++ b/frontend/tests/unit/core/threads/stream-options.test.ts
@@ -57,6 +57,10 @@ async function captureThreadStreamOptions() {
}),
}));
rs.doMock("@/core/tasks/context", () => ({
+ useSubtaskContext: () => ({
+ tasksRef: { current: {} },
+ setTasks: rs.fn(),
+ }),
useUpdateSubtask: () => rs.fn(),
}));
From 090e80c1ddade25a26efc2de5087136ebcb1daae Mon Sep 17 00:00:00 2001
From: Huixin615
Date: Mon, 27 Jul 2026 07:25:34 +0800
Subject: [PATCH 34/35] fix(runtime): fail-stop runs when lease ownership
cannot be confirmed (#4431)
* fix(runtime): fail-stop runs after lease expiry
* test(runtime): cover late successful lease renewal
---------
Co-authored-by: Willem Jiang
---
CHANGELOG.md | 11 +-
README.md | 2 +
backend/AGENTS.md | 1 +
.../harness/deerflow/persistence/run/sql.py | 17 +-
.../harness/deerflow/runtime/runs/manager.py | 204 ++++++++++++++--
.../deerflow/runtime/runs/store/base.py | 4 +-
.../deerflow/runtime/runs/store/memory.py | 23 +-
.../harness/deerflow/runtime/runs/worker.py | 29 ++-
.../tests/test_multi_worker_run_ownership.py | 225 ++++++++++++++++++
backend/tests/test_run_repository.py | 15 ++
backend/tests/test_run_worker_delivery.py | 40 +++-
backend/tests/test_run_worker_rollback.py | 44 +++-
config.example.yaml | 5 +
13 files changed, 567 insertions(+), 53 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4c2ffb08d..361ddbbb4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -250,10 +250,12 @@ This section accumulates work toward the **2.1.0** milestone
facts list is empty; stop the busy-spin in the debounced update queue; and
flush the memory queue on graceful shutdown to prevent loss. ([#3719], [#4074],
[#4076], [#4034], [#4217], [#3993], [#3992], [#4073], [#4181])
-- **runs:** Close multi-worker ownership gaps in run atomicity; degrade cancel
- to lease takeover for multi-worker; keep `create_thread` idempotent when the
- insert loses a race; read `stop_reason` from runtime context; and persist run
- duration in checkpoints for history reads. ([#4003], [#4064], [#3800], [#4188],
+- **runs:** Close multi-worker ownership gaps in run atomicity; fail-stop local
+ execution when lease renewal cannot be confirmed before its deadline and
+ fence late completion writes after peer takeover; degrade cancel to lease
+ takeover for multi-worker; keep `create_thread` idempotent when the insert
+ loses a race; read `stop_reason` from runtime context; and persist run duration
+ in checkpoints for history reads. ([#4003], [#4064], [#4414], [#3800], [#4188],
[#4118])
- **runtime:** Serialize SQLite event-store writes to prevent per-thread
sequence collisions; skip hidden human messages in the journal; and drop the
@@ -1121,4 +1123,5 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#4287]: https://github.com/bytedance/deer-flow/pull/4287
[#4288]: https://github.com/bytedance/deer-flow/pull/4288
[#4324]: https://github.com/bytedance/deer-flow/issues/4324
+[#4414]: https://github.com/bytedance/deer-flow/issues/4414
[#4424]: https://github.com/bytedance/deer-flow/issues/4424
diff --git a/README.md b/README.md
index b081c2102..87dace770 100644
--- a/README.md
+++ b/README.md
@@ -277,6 +277,8 @@ DeerFlow still uses `Forwarded` / `X-Forwarded-*` headers to recover the browser
> [!IMPORTANT]
> The Gateway still owns active run tasks in process, so production defaults to a single Gateway worker (`GATEWAY_WORKERS=1`). Multi-worker deployments require Postgres, the Redis stream bridge (`stream_bridge.type: redis`), `run_ownership.heartbeat_enabled: true`, and `run_events.backend: db`; process-local memory/JSONL event stores cannot enforce singleton delivery receipts across workers. The bridge shares SSE delivery and bounded `Last-Event-ID` replay across workers. When a valid reconnect cursor has been trimmed, or a subscriber that already established an empty-stream wait falls behind before its first delivery, Memory and Redis emit a machine-readable SSE `gap` event instead of silently returning a partial replay; the Web UI reloads durable thread/event state and resumes from the retained tail. Lease reconciliation marks runs from dead workers as errors, persists their delivery receipts, publishes the terminal stream marker, schedules retained-stream cleanup, and updates the affected thread status. SSE and `/wait` consumers also refresh durable status on heartbeats as a fallback if terminal publication fails. Malformed Redis reconnect IDs live-tail new events instead of replaying the retained buffer, and the rolling retained-buffer TTL (`stream_ttl_seconds`) remains a cleanup safety net rather than a run timeout. IM channel state and other process-local services still need their own multi-worker coordination.
>
+> With lease heartbeat enabled, a transient RunStore renewal error is retried only until the last confirmed lease expires; the stale worker then cancels local execution and suppresses checkpoint, completion-hook, delivery-receipt, and thread-status finalization. A remote tool side effect already in flight may still be outside local cancellation.
+>
> Reconciliation uses an atomic takeover claim that re-checks the lease after candidate selection, so a successful owner renewal wins over orphan recovery and only one reconciler can report a run as recovered.
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed Docker development guide.
diff --git a/backend/AGENTS.md b/backend/AGENTS.md
index 55d6f66da..bb857bb13 100644
--- a/backend/AGENTS.md
+++ b/backend/AGENTS.md
@@ -500,6 +500,7 @@ JSONL event stores when `GATEWAY_WORKERS > 1`.
- `cancel()` returns a :class:`~deerflow.runtime.CancelOutcome` enum: `cancelled` (local cancel), `taken_over` (non-owning worker claimed the run because the owner's lease expired — marks it as `error`), `lease_valid_elsewhere` (owner's lease is still alive — caller should return 409 + `Retry-After`), `not_active_locally` (heartbeat disabled, preserving the old 409 path), `not_cancellable` (terminal state), or `unknown` (not found in memory or store). `create_or_reject(..., multitask_strategy="interrupt"|"rollback")` persists interrupted status through `RunStore.update_status()`, matching normal `set_status()` transitions.
- Interrupt/rollback admission registers the replacement before its best-effort persistence of locally interrupted predecessors. If the admitting caller is cancelled during that post-registration await, `RunManager` drains a shielded replacement cleanup before propagating `CancelledError`, including across repeated cancellation. The cleanup normally persists `interrupted`; if that best-effort transition fails, it retries the active-to-`interrupted` store transition strictly and verifies the result with the replacement's captured owner identity. A concurrent peer terminal transition wins and is synchronized back into the local record rather than being overwritten or deleted.
- Store-only hydrated runs are readable history. In multi-worker mode with heartbeat enabled, cancel on a store-only run can take over (mark `error`) when the owner's lease has expired past the grace window; otherwise it fails with 409 + `Retry-After`. In single-worker mode (heartbeat off), store-only runs still return 409.
+- A local worker's `RunRecord.lease_expires_at` is the last durably confirmed ownership deadline. `_renew_leases()` bounds each renewal attempt by that deadline: transient store exceptions remain retryable while it is valid, but an exception or blocked call that reaches expiry sets the process-local `ownership_lost` fence, raises `abort_event`, and cancels the run task. Fenced workers do not perform subsequent journal/delivery-receipt, progress/completion/status, checkpoint/thread-metadata, or `on_run_completed` writes; the peer recovery path owns the terminal receipt. `RunStore.update_run_completion()` also refuses to replace a different terminal status, closing the peer-takeover/late-finalization race. `grace_seconds` delays peer reclamation for clock skew but is not extra execution time for an owner that can no longer confirm its lease. Already-committed remote tool side effects remain outside this local cancellation boundary.
- Startup/orphan reconciliation must claim stale active rows with `RunStore.claim_for_takeover()`, not a plain `update_status()`. The final claim re-checks `status` and lease expiry atomically, so a heartbeat renewal between the candidate scan and the recovery write keeps the run active.
- Run admission and independent checkpoint writes are first-class thread operations. `runs.operation_kind` distinguishes user-visible `run` rows from internal `checkpoint_write` reservations, while every active kind shares the existing durable active-thread uniqueness constraint. New operation kinds must go through `RunStore.create_thread_operation_atomic()` and `RunManager.reserve_thread_operation()` rather than adding another lock or metadata marker. Live and lease-less reservations are non-interruptible; an expired leased reservation can be reclaimed immediately by interrupt/rollback admission without waiting for orphan reconciliation. Lease-less rows stay fail-closed because the store cannot distinguish a stale row from a live checkpoint writer in another heartbeat-disabled worker; a rare failed delete therefore requires startup reconciliation, and heartbeat-disabled multi-worker deployment remains unsupported. Reservation bodies are attached to their caller task so loss detected by lease renewal cancels the checkpoint writer before it can continue after takeover; the context manager translates that lease-loss cancellation to `ConflictError` after cleanup so Gateway mutation routes return a retryable 409 instead of dropping the HTTP request. The cleanup scope begins immediately after durable admission, including the await that attaches the caller task, so cancellation cannot strand a locally renewed pending reservation. A failed renewal is revalidated under the manager lock before cancellation; if the reservation completed and unregistered while the store update was in flight, its request task must not be cancelled after the checkpoint write. Reservations are excluded from run history/reporting and from run-only helpers such as `list_by_thread()` and `has_inflight()`, release uses the captured owner rather than ambient user context, and local cleanup still runs when the best-effort store delete fails. `RunStore.create_run_atomic()` remains a deprecated compatibility shim for external stores that only admit normal runs; new stores must implement `create_thread_operation_atomic()` to support internal operation kinds.
- Gateway checkpoint mutations outside run execution must use `services.reserve_checkpoint_write()`, which composes the process-local thread lock with the durable `checkpoint_write` reservation. Manual compaction, `POST /threads/{id}/state`, and both goal mutation routes (`PUT` / `DELETE /threads/{id}/goal`, including creation of a missing goal checkpoint) use this boundary, so an existing run blocks the write and the reservation blocks new reject/interrupt/rollback runs across workers.
diff --git a/backend/packages/harness/deerflow/persistence/run/sql.py b/backend/packages/harness/deerflow/persistence/run/sql.py
index 94c148dc8..a0ea18afb 100644
--- a/backend/packages/harness/deerflow/persistence/run/sql.py
+++ b/backend/packages/harness/deerflow/persistence/run/sql.py
@@ -315,7 +315,8 @@ class RunRepository(RunStore):
) -> bool:
"""Update status + token usage + convenience fields on run completion.
- Returns ``False`` when no run row matched the requested ``run_id``.
+ Returns ``False`` when the row is missing or already has a conflicting
+ terminal outcome.
"""
values: dict[str, Any] = {
"status": status,
@@ -336,8 +337,20 @@ class RunRepository(RunStore):
values["first_human_message"] = first_human_message[:2000]
if error is not None:
values["error"] = error
+ allowed_sources = ["pending", "running"]
+ if status not in allowed_sources:
+ allowed_sources.append(status)
+ if status == "error" and "interrupted" not in allowed_sources:
+ allowed_sources.append("interrupted")
async with self._sf() as session:
- result = await session.execute(update(RunRow).where(RunRow.run_id == run_id).values(**values))
+ result = await session.execute(
+ update(RunRow)
+ .where(
+ RunRow.run_id == run_id,
+ RunRow.status.in_(tuple(allowed_sources)),
+ )
+ .values(**values)
+ )
await session.commit()
return result.rowcount != 0
diff --git a/backend/packages/harness/deerflow/runtime/runs/manager.py b/backend/packages/harness/deerflow/runtime/runs/manager.py
index a8924fcab..eeb965c8b 100644
--- a/backend/packages/harness/deerflow/runtime/runs/manager.py
+++ b/backend/packages/harness/deerflow/runtime/runs/manager.py
@@ -190,6 +190,10 @@ class RunRecord:
finalizing: bool = False
owner_worker_id: str | None = None
lease_expires_at: str | None = None
+ # Process-local fencing signal. Once set, this worker must not perform
+ # further durable run/thread finalization because its lease ownership is
+ # either known to be lost or could not be confirmed before expiry.
+ ownership_lost: bool = False
stop_reason: str | None = None
@@ -365,6 +369,13 @@ class RunManager:
async def _persist_status(self, record: RunRecord, status: RunStatus, *, error: str | None = None, stop_reason: str | None = None) -> bool:
"""Best-effort persist a status transition to the backing store."""
+ if record.ownership_lost:
+ logger.warning(
+ "Skipped status update to %s for run %s after lease ownership was lost",
+ status.value,
+ record.run_id,
+ )
+ return False
if self._store is None:
return True
row_recovery_payload = self._store_put_payload(record, error=error, stop_reason=stop_reason)
@@ -384,12 +395,25 @@ class RunManager:
existing = await self._store.get(record.run_id)
if existing is not None:
existing_status = existing.get("status")
+ if existing_status == status.value:
+ logger.info(
+ "Run %s status update to %s was already persisted",
+ record.run_id,
+ status.value,
+ )
+ return True
if existing_status == "error":
logger.warning(
"Run %s status update to %s skipped: store row already at error (peer takeover)",
record.run_id,
status.value,
)
+ if self.heartbeat_enabled and not record.store_only:
+ await self._mark_ownership_lost(
+ record,
+ reason="A peer terminalized the run before this worker could persist its outcome.",
+ require_active=False,
+ )
else:
logger.info(
"Run %s status update to %s skipped: store row already at %s (local cancel/completion race)",
@@ -446,8 +470,12 @@ class RunManager:
async def update_run_completion(self, run_id: str, **kwargs) -> None:
"""Persist token usage and completion data to the backing store."""
row_recovery_payload: dict[str, Any] | None = None
+ record: RunRecord | None = None
async with self._lock:
record = self._runs.get(run_id)
+ if record is not None and record.ownership_lost:
+ logger.warning("Skipped completion persistence for run %s after lease ownership was lost", run_id)
+ return
if record is not None:
for key, value in kwargs.items():
if key == "status":
@@ -465,6 +493,22 @@ class RunManager:
lambda: self._store.update_run_completion(run_id, **kwargs),
)
if updated is False:
+ existing = await self._store.get(run_id)
+ requested_status = kwargs.get("status")
+ if existing is not None and existing.get("status") != requested_status:
+ existing_status = existing.get("status")
+ logger.warning(
+ "Run completion update for %s skipped because store row is already at %s",
+ run_id,
+ existing_status,
+ )
+ if existing_status == "error" and record is not None and self.heartbeat_enabled:
+ await self._mark_ownership_lost(
+ record,
+ reason="A peer terminalized the run before completion data was persisted.",
+ require_active=False,
+ )
+ return
if row_recovery_payload is None:
logger.warning("Failed to recreate missing run %s for completion persistence", run_id)
return
@@ -486,7 +530,7 @@ class RunManager:
async with self._lock:
record = self._runs.get(run_id)
if record is not None:
- should_persist = record.status == RunStatus.running
+ should_persist = record.status == RunStatus.running and not record.ownership_lost
if record is not None and should_persist:
for key, value in kwargs.items():
if hasattr(record, key) and value is not None:
@@ -775,6 +819,13 @@ class RunManager:
if record is None:
logger.warning("set_status called for unknown run %s", run_id)
return
+ if record.ownership_lost:
+ logger.warning(
+ "Skipped local status transition to %s for run %s after lease ownership was lost",
+ status.value,
+ run_id,
+ )
+ return
record.status = status
record.updated_at = _now_iso()
if error is not None:
@@ -782,7 +833,15 @@ class RunManager:
if stop_reason is not None:
record.stop_reason = stop_reason
if persist:
- await self._persist_status(record, status, error=error, stop_reason=stop_reason)
+ persisted = await self._persist_status(record, status, error=error, stop_reason=stop_reason)
+ if not persisted and self.heartbeat_enabled and status == RunStatus.success and not record.ownership_lost:
+ await self._mark_ownership_lost(
+ record,
+ reason="Successful completion could not be confirmed in the durable run store.",
+ require_active=False,
+ )
+ if record.ownership_lost:
+ return
logger.info("Run %s -> %s", run_id, status.value)
async def persist_current_status(self, run_id: str) -> bool:
@@ -795,7 +854,14 @@ class RunManager:
status = record.status
error = record.error
stop_reason = record.stop_reason
- return await self._persist_status(record, status, error=error, stop_reason=stop_reason)
+ persisted = await self._persist_status(record, status, error=error, stop_reason=stop_reason)
+ if not persisted and self.heartbeat_enabled and status == RunStatus.success and not record.ownership_lost:
+ await self._mark_ownership_lost(
+ record,
+ reason="Successful completion could not be confirmed in the durable run store.",
+ require_active=False,
+ )
+ return persisted
async def _ensure_delivery_receipt(self, record: RunRecord) -> bool:
"""Idempotently persist a zero-delivery receipt during recovery."""
@@ -1519,6 +1585,61 @@ class RunManager:
"""
return self._run_ownership_config.grace_seconds if self._run_ownership_config else 10
+ @staticmethod
+ def _parse_lease_deadline(lease_expires_at: str | None) -> datetime | None:
+ """Parse the last durably confirmed lease expiry.
+
+ Missing or malformed deadlines are unsafe in heartbeat mode: the local
+ worker has no bounded interval during which it can prove ownership.
+ """
+ if lease_expires_at is None:
+ return None
+ try:
+ deadline = datetime.fromisoformat(lease_expires_at)
+ except (TypeError, ValueError):
+ return None
+ if deadline.tzinfo is None:
+ deadline = deadline.replace(tzinfo=UTC)
+ return deadline
+
+ async def _mark_ownership_lost(
+ self,
+ record: RunRecord,
+ *,
+ reason: str,
+ require_active: bool = True,
+ ) -> bool:
+ """Fence one local run and cancel its execution task.
+
+ No store write is attempted here: once the last confirmed lease has
+ expired, this worker is no longer authorized to publish a terminal
+ outcome. A peer reconciler owns durable terminalization.
+ """
+ task_to_cancel: asyncio.Task | None = None
+ async with self._lock:
+ current = self._runs.get(record.run_id)
+ if current is not record:
+ return False
+ if require_active:
+ if record.status not in (RunStatus.pending, RunStatus.running):
+ return False
+ if record.task is not None and record.task.done():
+ return False
+ if record.ownership_lost:
+ return True
+ record.ownership_lost = True
+ record.abort_event.set()
+ record.status = RunStatus.error
+ record.error = reason
+ record.updated_at = _now_iso()
+ if record.task is not None and not record.task.done() and record.task is not asyncio.current_task():
+ task_to_cancel = record.task
+
+ if task_to_cancel is not None:
+ task_to_cancel.cancel()
+ logger.error("Run %s lost lease ownership; local execution was fenced: %s", record.run_id, reason)
+ return True
+
async def start_heartbeat(self) -> None:
"""Start the background lease-renewal task.
@@ -1595,11 +1716,16 @@ class RunManager:
self._schedule_orphan_reconciliation()
async def _renew_leases(self) -> None:
- """Renew the lease on every locally-owned active run."""
+ """Renew locally-owned leases, failing closed at their deadlines.
+
+ ``RunRecord.lease_expires_at`` advances only after a successful durable
+ renewal, so it is the last confirmed ownership deadline. Transient
+ exceptions are tolerated before that deadline; a call that blocks or
+ keeps failing through it fences the local run.
+ """
if self._store is None or self._run_ownership_config is None:
return
lease_seconds = self._run_ownership_config.lease_seconds
- new_expiry = (datetime.now(UTC) + timedelta(seconds=lease_seconds)).isoformat()
async with self._lock:
# Renew any pending/running run owned by this worker unless its
@@ -1615,17 +1741,34 @@ class RunManager:
active_runs = [(rid, record) for rid, record in self._runs.items() if record.status in (RunStatus.pending, RunStatus.running) and record.owner_worker_id == self._worker_id and (record.task is None or not record.task.done())]
for run_id, record in active_runs:
- try:
- updated = await self._call_store_with_retry(
- "update_lease",
- run_id,
- lambda: self._store.update_lease(
- run_id,
- owner_worker_id=self._worker_id,
- lease_expires_at=new_expiry,
- ),
+ confirmed_deadline = self._parse_lease_deadline(record.lease_expires_at)
+ if confirmed_deadline is None or confirmed_deadline <= datetime.now(UTC):
+ await self._mark_ownership_lost(
+ record,
+ reason="Lease ownership could not be confirmed before the last confirmed lease expired.",
)
+ continue
+
+ remaining = (confirmed_deadline - datetime.now(UTC)).total_seconds()
+ new_expiry = (datetime.now(UTC) + timedelta(seconds=lease_seconds)).isoformat()
+ try:
+ async with asyncio.timeout(remaining):
+ updated = await self._call_store_with_retry(
+ "update_lease",
+ run_id,
+ lambda: self._store.update_lease(
+ run_id,
+ owner_worker_id=self._worker_id,
+ lease_expires_at=new_expiry,
+ ),
+ )
if updated:
+ if confirmed_deadline <= datetime.now(UTC):
+ await self._mark_ownership_lost(
+ record,
+ reason="Lease renewal completed after the last confirmed lease had already expired.",
+ )
+ continue
# Unsynced write is benign: ``lease_expires_at`` is the
# only field on an existing record this path mutates, so
# there is no concurrent writer to race against
@@ -1641,18 +1784,29 @@ class RunManager:
# finalisation.
async with self._lock:
still_active = self._runs.get(run_id) is record and record.status in (RunStatus.pending, RunStatus.running) and record.owner_worker_id == self._worker_id and (record.task is None or not record.task.done())
- if still_active:
- logger.warning(
- "Run %s lease renewal failed (status=%s,owner=%s) – worker likely taken over; aborting local task",
- run_id,
- record.status.value,
- record.owner_worker_id,
- )
- record.abort_event.set()
- if record.task is not None:
- record.task.cancel()
+ if still_active:
+ logger.warning(
+ "Run %s lease renewal failed (status=%s,owner=%s) – worker likely taken over; aborting local task",
+ run_id,
+ record.status.value,
+ record.owner_worker_id,
+ )
+ await self._mark_ownership_lost(
+ record,
+ reason="The durable store rejected lease renewal for this worker.",
+ )
except Exception:
- logger.warning("Failed to renew lease for run %s", run_id, exc_info=True)
+ if confirmed_deadline <= datetime.now(UTC):
+ await self._mark_ownership_lost(
+ record,
+ reason="Lease ownership could not be confirmed before the last confirmed lease expired.",
+ )
+ else:
+ logger.warning(
+ "Failed to renew lease for run %s before its confirmed deadline; will retry",
+ run_id,
+ exc_info=True,
+ )
async def _reconcile_orphans_periodic(self) -> None:
"""Sweep for expired leases owned by dead peers.
diff --git a/backend/packages/harness/deerflow/runtime/runs/store/base.py b/backend/packages/harness/deerflow/runtime/runs/store/base.py
index b6796ccec..385d9714e 100644
--- a/backend/packages/harness/deerflow/runtime/runs/store/base.py
+++ b/backend/packages/harness/deerflow/runtime/runs/store/base.py
@@ -146,7 +146,9 @@ class RunStore(abc.ABC):
) -> bool | None:
"""Persist final completion fields.
- Returns ``False`` when the store can prove no row was updated.
+ Implementations must not replace a different terminal status. Returns
+ ``False`` when the row is missing or already has a conflicting terminal
+ outcome.
"""
pass
diff --git a/backend/packages/harness/deerflow/runtime/runs/store/memory.py b/backend/packages/harness/deerflow/runtime/runs/store/memory.py
index eac66fabd..e92356085 100644
--- a/backend/packages/harness/deerflow/runtime/runs/store/memory.py
+++ b/backend/packages/harness/deerflow/runtime/runs/store/memory.py
@@ -144,14 +144,21 @@ class MemoryRunStore(RunStore):
self._unindex_run(run_id, run["thread_id"])
async def update_run_completion(self, run_id, *, status, **kwargs):
- if run_id in self._runs:
- self._runs[run_id]["status"] = status
- for key, value in kwargs.items():
- if value is not None:
- self._runs[run_id][key] = value
- self._runs[run_id]["updated_at"] = datetime.now(UTC).isoformat()
- return True
- return False
+ run = self._runs.get(run_id)
+ if run is None:
+ return False
+ current_status = run.get("status")
+ allowed_sources = {"pending", "running", status}
+ if status == "error":
+ allowed_sources.add("interrupted")
+ if current_status not in allowed_sources:
+ return False
+ run["status"] = status
+ for key, value in kwargs.items():
+ if value is not None:
+ run[key] = value
+ run["updated_at"] = datetime.now(UTC).isoformat()
+ return True
async def update_run_progress(self, run_id, **kwargs):
if run_id in self._runs and self._runs[run_id].get("status") == "running":
diff --git a/backend/packages/harness/deerflow/runtime/runs/worker.py b/backend/packages/harness/deerflow/runtime/runs/worker.py
index 7af4c76f8..38a5ec9ff 100644
--- a/backend/packages/harness/deerflow/runtime/runs/worker.py
+++ b/backend/packages/harness/deerflow/runtime/runs/worker.py
@@ -514,7 +514,7 @@ async def run_agent(
return
started = True
- if thread_store is not None:
+ if not record.ownership_lost and thread_store is not None:
try:
await thread_store.update_status(thread_id, "running")
except Exception:
@@ -927,12 +927,18 @@ async def run_agent(
)
finally:
+ if record.ownership_lost:
+ logger.warning(
+ "Skipping durable finalization for run %s because this worker no longer owns its lease",
+ run_id,
+ )
+
# Persist any subagent step events still buffered (#3779) — including on
# abort/exception paths, where the stream loop broke before its own flush.
- if subagent_events is not None:
+ if not record.ownership_lost and subagent_events is not None:
await subagent_events.flush()
- if event_store is not None and pre_run_workspace_snapshot is not None:
+ if not record.ownership_lost and event_store is not None and pre_run_workspace_snapshot is not None:
try:
await record_workspace_changes(
event_store,
@@ -948,7 +954,8 @@ async def run_agent(
# receipt uses a run-scoped idempotent write shared with recovery, then
# the staged terminal status is persisted. This ordering closes the
# crash window where a terminal run could otherwise outlive its receipt.
- if journal is not None:
+ # A fenced worker leaves receipt recovery to the peer that claimed it.
+ if not record.ownership_lost and journal is not None:
try:
await journal.flush()
except Exception:
@@ -961,7 +968,7 @@ async def run_agent(
content=journal.get_delivery_content(),
)
- if event_store is not None:
+ if not record.ownership_lost and event_store is not None:
try:
# Even after bounded receipt retries are exhausted, persist the
# real worker outcome. Leaving a successful row inflight would
@@ -971,7 +978,7 @@ async def run_agent(
except Exception:
logger.warning("Failed to persist terminal status for run %s after delivery receipt attempts", run_id, exc_info=True)
- if journal is not None and persist_completion:
+ if not record.ownership_lost and journal is not None and persist_completion:
try:
# Persist token usage + convenience fields to RunStore
completion = journal.get_completion_data()
@@ -979,7 +986,7 @@ async def run_agent(
except Exception:
logger.warning("Failed to persist run completion for %s (non-fatal)", run_id, exc_info=True)
- if started and checkpointer is not None and record.status == RunStatus.interrupted:
+ if started and not record.ownership_lost and checkpointer is not None and record.status == RunStatus.interrupted:
try:
await run_manager.wait_for_prior_finalizing(thread_id, run_id)
if not await run_manager.has_later_started_run(thread_id, run_id):
@@ -988,7 +995,7 @@ async def run_agent(
logger.debug("Failed to generate interrupted title for thread %s (non-fatal)", thread_id)
# Sync title from checkpoint to threads_meta.display_name
- if started and checkpointer is not None and thread_store is not None:
+ if started and not record.ownership_lost and checkpointer is not None and thread_store is not None:
try:
ckpt_config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
ckpt_tuple = await checkpointer.aget_tuple(ckpt_config)
@@ -1002,7 +1009,7 @@ async def run_agent(
# Persist run duration to checkpoint metadata so history reads
# don't need to correlate runs and events.
- if started and checkpointer is not None and record.status == RunStatus.success:
+ if started and not record.ownership_lost and checkpointer is not None and record.status == RunStatus.success:
try:
created = datetime.fromisoformat(record.created_at.replace("Z", "+00:00"))
updated = datetime.fromisoformat(record.updated_at.replace("Z", "+00:00"))
@@ -1020,14 +1027,14 @@ async def run_agent(
logger.debug("Failed to persist run duration for thread %s run %s (non-fatal)", thread_id, run_id)
# Update threads_meta status based on run outcome
- if started and thread_store is not None:
+ if started and not record.ownership_lost and thread_store is not None:
try:
final_status = "idle" if record.status == RunStatus.success else record.status.value
await thread_store.update_status(thread_id, final_status)
except Exception:
logger.debug("Failed to update thread_meta status for %s (non-fatal)", thread_id)
- if ctx.on_run_completed is not None:
+ if not record.ownership_lost and ctx.on_run_completed is not None:
try:
await ctx.on_run_completed(record)
except Exception:
diff --git a/backend/tests/test_multi_worker_run_ownership.py b/backend/tests/test_multi_worker_run_ownership.py
index 57f237748..52e0dd6e0 100644
--- a/backend/tests/test_multi_worker_run_ownership.py
+++ b/backend/tests/test_multi_worker_run_ownership.py
@@ -859,6 +859,147 @@ async def test_heartbeat_renews_pending_run_before_task_is_spawned():
assert record.lease_expires_at > original_lease
+@pytest.mark.anyio
+async def test_transient_renewal_exception_before_deadline_keeps_run_alive():
+ """A renewal error is retryable while the last confirmed lease is valid."""
+ config = _lease_config(lease_seconds=30, heartbeat_enabled=True)
+ store = MemoryRunStore()
+ manager = _make_manager(store=store, worker_id="worker-a", run_ownership_config=config)
+ record = await manager.create("thread-1")
+ await manager.set_status(record.run_id, RunStatus.running)
+ record.task = asyncio.create_task(asyncio.sleep(3600))
+ original_update_lease = store.update_lease
+ attempts = 0
+
+ async def fail_once_then_renew(*args, **kwargs):
+ nonlocal attempts
+ attempts += 1
+ if attempts == 1:
+ raise OSError("temporary database outage")
+ return await original_update_lease(*args, **kwargs)
+
+ store.update_lease = fail_once_then_renew
+ original_expiry = record.lease_expires_at
+
+ try:
+ await manager._renew_leases()
+
+ assert record.abort_event.is_set() is False
+ assert record.task.done() is False
+ assert record.lease_expires_at == original_expiry
+
+ await manager._renew_leases()
+
+ assert attempts == 2
+ assert record.abort_event.is_set() is False
+ assert record.task.done() is False
+ assert record.lease_expires_at is not None
+ assert original_expiry is not None
+ assert record.lease_expires_at > original_expiry
+ finally:
+ record.task.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await record.task
+
+
+@pytest.mark.anyio
+async def test_renewal_exception_through_confirmed_expiry_fail_stops_run():
+ """The owner must stop once errors outlive its last confirmed lease."""
+ config = _lease_config(lease_seconds=30, heartbeat_enabled=True)
+ store = MemoryRunStore()
+ manager = _make_manager(store=store, worker_id="worker-a", run_ownership_config=config)
+ record = await manager.create("thread-1")
+ await manager.set_status(record.run_id, RunStatus.running)
+ record.task = asyncio.create_task(asyncio.sleep(3600))
+ attempts = 0
+
+ async def fail_renewal(*_args, **_kwargs):
+ nonlocal attempts
+ attempts += 1
+ raise OSError("database unreachable")
+
+ store.update_lease = fail_renewal
+
+ await manager._renew_leases()
+ assert record.abort_event.is_set() is False
+ assert record.task.done() is False
+
+ expired = (datetime.now(UTC) - timedelta(seconds=1)).isoformat()
+ record.lease_expires_at = expired
+ store._runs[record.run_id]["lease_expires_at"] = expired
+
+ await manager._renew_leases()
+ await asyncio.sleep(0)
+
+ assert attempts == 1
+ assert record.ownership_lost is True
+ assert record.abort_event.is_set() is True
+ assert record.task.cancelled()
+
+
+@pytest.mark.anyio
+async def test_hung_renewal_is_bounded_by_confirmed_lease_deadline():
+ """A blocked store call cannot keep execution alive beyond the lease."""
+ config = _lease_config(lease_seconds=30, heartbeat_enabled=True)
+ store = MemoryRunStore()
+ manager = _make_manager(store=store, worker_id="worker-a", run_ownership_config=config)
+ record = await manager.create("thread-1")
+ await manager.set_status(record.run_id, RunStatus.running)
+ record.task = asyncio.create_task(asyncio.sleep(3600))
+ near_expiry = (datetime.now(UTC) + timedelta(milliseconds=50)).isoformat()
+ record.lease_expires_at = near_expiry
+ store._runs[record.run_id]["lease_expires_at"] = near_expiry
+
+ async def hang_renewal(*_args, **_kwargs):
+ await asyncio.Event().wait()
+
+ store.update_lease = hang_renewal
+
+ await asyncio.wait_for(manager._renew_leases(), timeout=1)
+ await asyncio.sleep(0)
+
+ assert record.ownership_lost is True
+ assert record.abort_event.is_set() is True
+ assert record.task.cancelled()
+
+
+@pytest.mark.anyio
+async def test_late_successful_renewal_still_fences_local_run():
+ """A renewal confirmed after the old deadline cannot revive local work."""
+ config = _lease_config(lease_seconds=30, heartbeat_enabled=True)
+ store = MemoryRunStore()
+ manager = _make_manager(store=store, worker_id="worker-a", run_ownership_config=config)
+ record = await manager.create("thread-1")
+ await manager.set_status(record.run_id, RunStatus.running)
+ record.task = asyncio.create_task(asyncio.sleep(3600))
+ near_expiry = (datetime.now(UTC) + timedelta(milliseconds=50)).isoformat()
+ record.lease_expires_at = near_expiry
+ store._runs[record.run_id]["lease_expires_at"] = near_expiry
+ original_update_lease = store.update_lease
+
+ async def renew_after_timeout_cancellation(*args, **kwargs):
+ try:
+ await asyncio.sleep(3600)
+ except asyncio.CancelledError:
+ # Simulate a store operation that commits successfully despite the
+ # caller's deadline cancellation.
+ pass
+ return await original_update_lease(*args, **kwargs)
+
+ store.update_lease = renew_after_timeout_cancellation
+
+ await asyncio.wait_for(manager._renew_leases(), timeout=1)
+ await asyncio.sleep(0)
+
+ row = await store.get(record.run_id)
+ assert row is not None
+ assert row["lease_expires_at"] > near_expiry
+ assert record.lease_expires_at == near_expiry
+ assert record.ownership_lost is True
+ assert record.abort_event.is_set() is True
+ assert record.task.cancelled()
+
+
@pytest.mark.anyio
async def test_heartbeat_skips_runs_not_owned_by_this_worker():
"""Heartbeat must only renew leases for runs owned by this worker."""
@@ -1779,6 +1920,8 @@ async def test_heartbeat_cancels_task_on_lease_loss():
# Let the event loop process the cancellation (task.cancel() schedules,
# doesn't await).
await asyncio.sleep(0)
+ assert record.ownership_lost is True
+ assert record.abort_event.is_set() is True
assert record.task.cancelled()
@@ -1850,6 +1993,88 @@ async def test_cancel_action_rollback_finalizes_to_error_in_store():
assert row["error"] == "Rolled back by user"
+@pytest.mark.anyio
+async def test_peer_reconciliation_fences_late_success_and_completion():
+ """A stale owner cannot overwrite a peer's terminal takeover."""
+ store = MemoryRunStore()
+ config = _lease_config(heartbeat_enabled=True, grace_seconds=0)
+ owner = _make_manager(store=store, worker_id="worker-a", run_ownership_config=config)
+ peer = _make_manager(store=store, worker_id="worker-b", run_ownership_config=config)
+ record = await owner.create("thread-1")
+ await owner.set_status(record.run_id, RunStatus.running)
+
+ expired = (datetime.now(UTC) - timedelta(seconds=1)).isoformat()
+ record.lease_expires_at = expired
+ store._runs[record.run_id]["lease_expires_at"] = expired
+ recovered = await peer.reconcile_orphaned_inflight_runs(error="peer takeover")
+
+ assert [recovered_record.run_id for recovered_record in recovered] == [record.run_id]
+ assert (await store.get(record.run_id))["status"] == "error"
+
+ await owner.set_status(record.run_id, RunStatus.success)
+ await owner.update_run_completion(record.run_id, status=record.status.value, total_tokens=1)
+
+ row = await store.get(record.run_id)
+ assert record.ownership_lost is True
+ assert record.status == RunStatus.error
+ assert row["status"] == "error"
+ assert row["error"] == "peer takeover"
+
+
+@pytest.mark.anyio
+async def test_unconfirmed_success_is_fenced_when_heartbeat_is_enabled():
+ """A store outage cannot turn an unconfirmed terminal write into local success."""
+ store = MemoryRunStore()
+ manager = _make_manager(
+ store=store,
+ worker_id="worker-a",
+ run_ownership_config=_lease_config(heartbeat_enabled=True),
+ )
+ record = await manager.create("thread-1")
+ await manager.set_status(record.run_id, RunStatus.running)
+
+ async def fail_status_write(*_args, **_kwargs):
+ raise OSError("database unreachable")
+
+ store.update_status = fail_status_write
+
+ await manager.set_status(record.run_id, RunStatus.success)
+
+ row = await store.get(record.run_id)
+ assert record.ownership_lost is True
+ assert record.status == RunStatus.error
+ assert record.abort_event.is_set() is True
+ assert row["status"] == "running"
+
+
+@pytest.mark.anyio
+async def test_unconfirmed_staged_success_is_fenced_on_deferred_persistence():
+ """Receipt-ordered status persistence retains the success fail-close fence."""
+ store = MemoryRunStore()
+ manager = _make_manager(
+ store=store,
+ worker_id="worker-a",
+ run_ownership_config=_lease_config(heartbeat_enabled=True),
+ )
+ record = await manager.create("thread-1")
+ await manager.set_status(record.run_id, RunStatus.running)
+ await manager.set_status(record.run_id, RunStatus.success, persist=False)
+
+ async def fail_status_write(*_args, **_kwargs):
+ raise OSError("database unreachable")
+
+ store.update_status = fail_status_write
+
+ persisted = await manager.persist_current_status(record.run_id)
+
+ row = await store.get(record.run_id)
+ assert persisted is False
+ assert record.ownership_lost is True
+ assert record.status == RunStatus.error
+ assert record.abort_event.is_set() is True
+ assert row["status"] == "running"
+
+
# ---------------------------------------------------------------------------
# cancel() claim_for_takeover False → re-read precision
# ---------------------------------------------------------------------------
diff --git a/backend/tests/test_run_repository.py b/backend/tests/test_run_repository.py
index d1d9e0a83..ca797c63a 100644
--- a/backend/tests/test_run_repository.py
+++ b/backend/tests/test_run_repository.py
@@ -296,6 +296,21 @@ class TestRunRepository:
assert updated is False
await _cleanup()
+ @pytest.mark.anyio
+ async def test_update_run_completion_does_not_replace_terminal_status(self, tmp_path):
+ """Late completion data cannot rewrite a peer's terminal outcome."""
+ repo = await _make_repo(tmp_path)
+ await repo.put("r1", thread_id="t1", status="running")
+ await repo.update_status("r1", "error", error="peer takeover")
+
+ updated = await repo.update_run_completion("r1", status="success", total_tokens=1)
+
+ row = await repo.get("r1")
+ assert updated is False
+ assert row["status"] == "error"
+ assert row["error"] == "peer takeover"
+ await _cleanup()
+
@pytest.mark.anyio
async def test_metadata_preserved(self, tmp_path):
repo = await _make_repo(tmp_path)
diff --git a/backend/tests/test_run_worker_delivery.py b/backend/tests/test_run_worker_delivery.py
index 5cc8123f1..a2ed387ee 100644
--- a/backend/tests/test_run_worker_delivery.py
+++ b/backend/tests/test_run_worker_delivery.py
@@ -2,7 +2,7 @@
import asyncio
from types import SimpleNamespace
-from unittest.mock import AsyncMock
+from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
@@ -95,6 +95,44 @@ async def test_delivery_event_presented_zero_without_artifact_production():
assert fetched.status == RunStatus.success
+@pytest.mark.anyio
+async def test_fenced_worker_leaves_delivery_receipt_to_peer_recovery():
+ """A stale worker must not finalize the singleton delivery receipt."""
+ run_manager = RunManager()
+ record = await run_manager.create("thread-lease-lost")
+ record.ownership_lost = True
+ record.abort_event.set()
+ record.status = RunStatus.error
+ event_store = MemoryRunEventStore()
+ thread_store = SimpleNamespace(
+ update_display_name=AsyncMock(),
+ update_status=AsyncMock(),
+ )
+ on_run_completed = AsyncMock()
+ agent_factory = MagicMock(side_effect=AssertionError("fenced worker started the agent"))
+
+ await run_agent(
+ _make_bridge(),
+ run_manager,
+ record,
+ ctx=RunContext(
+ checkpointer=None,
+ event_store=event_store,
+ thread_store=thread_store,
+ on_run_completed=on_run_completed,
+ ),
+ agent_factory=agent_factory,
+ graph_input={},
+ config={},
+ )
+
+ assert await _delivery_events(event_store, record.thread_id, record.run_id) == []
+ agent_factory.assert_not_called()
+ thread_store.update_display_name.assert_not_awaited()
+ thread_store.update_status.assert_not_awaited()
+ on_run_completed.assert_not_awaited()
+
+
@pytest.mark.anyio
async def test_delivery_event_is_singleton_across_goal_continuations(monkeypatch):
run_manager = RunManager()
diff --git a/backend/tests/test_run_worker_rollback.py b/backend/tests/test_run_worker_rollback.py
index 81813b3dc..abe3f92d1 100644
--- a/backend/tests/test_run_worker_rollback.py
+++ b/backend/tests/test_run_worker_rollback.py
@@ -3,7 +3,7 @@ import copy
from contextlib import suppress
from types import SimpleNamespace
from typing import Annotated, Any, NotRequired, TypedDict
-from unittest.mock import AsyncMock, call, patch
+from unittest.mock import AsyncMock, MagicMock, call, patch
import pytest
from langchain_core.messages import AIMessage, AIMessageChunk, AnyMessage, HumanMessage
@@ -2850,3 +2850,45 @@ async def test_worker_finally_block_swallows_helper_exceptions(monkeypatch):
assert helper_called.is_set()
assert captured_status.get("status") == ("thread-1", "interrupted")
bridge.publish_end.assert_awaited_once_with(record.run_id)
+
+
+@pytest.mark.anyio
+async def test_worker_skips_execution_and_finalization_after_ownership_loss():
+ """A fenced worker closes its stream without starting or finalizing work."""
+ run_manager = RunManager()
+ record = await run_manager.create("thread-lease-lost")
+ record.ownership_lost = True
+ record.abort_event.set()
+ record.status = RunStatus.error
+
+ bridge = SimpleNamespace(
+ publish=AsyncMock(),
+ publish_end=AsyncMock(),
+ cleanup=AsyncMock(),
+ )
+ thread_store = SimpleNamespace(
+ update_display_name=AsyncMock(),
+ update_status=AsyncMock(),
+ )
+ on_run_completed = AsyncMock()
+ agent_factory = MagicMock(side_effect=AssertionError("fenced worker started the agent"))
+
+ await run_agent(
+ bridge,
+ run_manager,
+ record,
+ ctx=RunContext(
+ checkpointer=None,
+ thread_store=thread_store,
+ on_run_completed=on_run_completed,
+ ),
+ agent_factory=agent_factory,
+ graph_input={"messages": []},
+ config={},
+ )
+
+ agent_factory.assert_not_called()
+ thread_store.update_display_name.assert_not_awaited()
+ thread_store.update_status.assert_not_awaited()
+ on_run_completed.assert_not_awaited()
+ bridge.publish_end.assert_awaited_once_with(record.run_id)
diff --git a/config.example.yaml b/config.example.yaml
index 1282788e1..38e0bda11 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -1894,6 +1894,11 @@ scheduler:
# grace_seconds if your environment cannot keep clocks within a few seconds;
# the trade-off is longer recovery latency for genuinely dead workers
# (lease_seconds + grace_seconds from last heartbeat to reclaim).
+#
+# The owning worker itself does NOT use grace_seconds as extra execution time.
+# If renewal cannot be confirmed before lease_seconds expires, it cancels the
+# local run and suppresses durable finalization; grace_seconds only delays when
+# a peer may reclaim the expired row.
run_ownership:
lease_seconds: 30 # Seconds before a run lease expires if not renewed.
From 2e5c8da25729e4cef061242eb6578aa8697cecfa Mon Sep 17 00:00:00 2001
From: March-77 <132431415+March-77@users.noreply.github.com>
Date: Mon, 27 Jul 2026 07:47:39 +0800
Subject: [PATCH 35/35] fix(sandbox): bypass proxies for local AIO traffic
(#4444)
* fix(sandbox): bypass proxies for local AIO traffic
* fix(sandbox): classify public IPv6 proxy targets
---
README.md | 5 ++
backend/AGENTS.md | 2 +-
backend/docs/CONFIGURATION.md | 6 ++
.../community/aio_sandbox/aio_sandbox.py | 13 +++-
.../deerflow/community/aio_sandbox/backend.py | 46 +++++++++---
backend/tests/test_aio_sandbox.py | 41 +++++++++++
backend/tests/test_aio_sandbox_readiness.py | 70 +++++++++++++++++--
7 files changed, 167 insertions(+), 16 deletions(-)
diff --git a/README.md b/README.md
index 87dace770..14d98e3e0 100644
--- a/README.md
+++ b/README.md
@@ -249,6 +249,11 @@ make docker-start # Start services (auto-detects sandbox mode from config.yaml
Docker builds use the upstream `uv` registry by default. If you need faster mirrors in restricted networks, export `UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple` and `NPM_REGISTRY=https://registry.npmmirror.com` before running `make docker-init` or `make docker-start`.
+Local AIO sandbox control traffic is always direct: loopback/private addresses,
+single-label cluster hosts, and Docker/Podman internal hostnames do not inherit
+`HTTP_PROXY` or `HTTPS_PROXY`. External sandbox FQDNs and public IPs still
+honor environment proxy settings.
+
Backend processes automatically pick up `config.yaml` changes on the next config access, so model metadata updates do not require a manual restart during development.
> [!TIP]
diff --git a/backend/AGENTS.md b/backend/AGENTS.md
index bb857bb13..174646b3f 100644
--- a/backend/AGENTS.md
+++ b/backend/AGENTS.md
@@ -530,7 +530,7 @@ copying a raw checkpoint because delta state is not self-contained in one tuple.
**Environment policy** (`sandbox/env_policy.py`): `execute_command` no longer inherits the full `os.environ`. `build_sandbox_env()` scrubs secret-looking names (`*KEY*`/`*SECRET*`/`*TOKEN*`/`*PASS*`/`*CREDENTIAL*`) from the inherited environment before layering injected request secrets on top, so platform credentials (e.g. `OPENAI_API_KEY`) never leak into skill subprocesses. Benign vars (`PATH`, `HOME`, `LANG`, `VIRTUAL_ENV`, ...) are preserved.
**Implementations**:
- `LocalSandboxProvider` - Local filesystem execution. `acquire(thread_id)` returns a per-thread `LocalSandbox` (id `local:{thread_id}`) whose `path_mappings` resolve `/mnt/user-data/{workspace,uploads,outputs}` and `/mnt/acp-workspace` to that thread's host directories, so the public `Sandbox` API honours the `/mnt/user-data` contract uniformly with AIO. `acquire()` / `acquire(None)` keeps the legacy generic singleton (id `local`) for callers without a thread context. Per-thread sandboxes are held in an LRU cache (default 256 entries) guarded by a `threading.Lock`. Legacy global-custom mounts are gated by the same user-scoped skill discovery rule used for prompt/list visibility; providers must not infer visibility from raw directory presence alone.
-- `AioSandboxProvider` (`packages/harness/deerflow/community/`) - Docker-based isolation. Active-cache and warm-pool entries are checked with the backend during acquire/reuse; definitively dead containers are dropped from all in-process maps so the thread can discover or create a fresh sandbox instead of reusing a stale client. Backend health-check failures are treated as unknown, not dead; local discovery likewise treats an unverifiable container as not adoptable and falls through to create rather than failing acquire. `get()` remains an in-memory lookup for event-loop-safe tool paths — it never touches the ownership store (that would be blocking IO on the event loop); ownership is published on acquire/reclaim and refreshed off the event loop by the dedicated renewal thread (`_renew_owned_leases`). Legacy global-custom mounts follow the same shared visibility helper as local and remote providers.
+- `AioSandboxProvider` (`packages/harness/deerflow/community/`) - Docker-based isolation. Active-cache and warm-pool entries are checked with the backend during acquire/reuse; definitively dead containers are dropped from all in-process maps so the thread can discover or create a fresh sandbox instead of reusing a stale client. Backend health-check failures are treated as unknown, not dead; local discovery likewise treats an unverifiable container as not adoptable and falls through to create rather than failing acquire. `get()` remains an in-memory lookup for event-loop-safe tool paths — it never touches the ownership store (that would be blocking IO on the event loop); ownership is published on acquire/reclaim and refreshed off the event loop by the dedicated renewal thread (`_renew_owned_leases`). Legacy global-custom mounts follow the same shared visibility helper as local and remote providers. Readiness probes and `agent_sandbox` clients classify loopback/private IPs, single-label cluster hosts, and Docker/Podman internal hostnames as direct control-plane destinations and set `trust_env=False`; external FQDNs and public IPs retain environment proxy support.
- `E2BSandboxProvider` (`packages/harness/deerflow/community/e2b_sandbox/`) provides E2B remote isolation.
Acquire and release share a per-user and thread lock. The provider lock does
not cover remote IO. `burst_limit` adds capacity only for the `burst` policy.
diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md
index 17d23a5a3..e90355195 100644
--- a/backend/docs/CONFIGURATION.md
+++ b/backend/docs/CONFIGURATION.md
@@ -505,6 +505,12 @@ When you configure `sandbox.mounts`, DeerFlow exposes those `container_path` val
For bare-metal Docker sandbox runs that use localhost, DeerFlow binds the sandbox HTTP port to `127.0.0.1` by default so it is not exposed on every host interface. Docker-outside-of-Docker deployments that connect through `host.docker.internal` keep the broad legacy bind for compatibility. Set `DEER_FLOW_SANDBOX_BIND_HOST` explicitly if your deployment needs a different bind address.
+Sandbox control-plane HTTP calls to loopback/private IPs, single-label cluster
+hosts, and Docker/Podman internal hostnames bypass `HTTP_PROXY`/`HTTPS_PROXY`
+inside the client. This prevents an inherited proxy from returning a misleading
+502 for a healthy local sandbox. Externally hosted sandbox FQDNs and public IPs
+continue to use the normal environment proxy configuration.
+
### Building a Custom AIO Sandbox Image
`AioSandboxProvider` talks to the sandbox container through the `agent-sandbox` SDK. The Dockerfile for the default `enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest` image is not part of this repository; DeerFlow treats that image as an upstream AIO sandbox runtime.
diff --git a/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py b/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py
index 0e07b00a9..5ed2b4bd3 100644
--- a/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py
+++ b/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py
@@ -5,6 +5,7 @@ import shlex
import threading
import uuid
+import httpx
from agent_sandbox import Sandbox as AioSandboxClient
from agent_sandbox.core.api_error import ApiError
@@ -12,6 +13,8 @@ from deerflow.config.paths import VIRTUAL_PATH_PREFIX
from deerflow.sandbox.sandbox import Sandbox, _validate_extra_env
from deerflow.sandbox.search import GrepMatch, path_matches, should_ignore_path, truncate_line
+from .backend import sandbox_http_trust_env
+
logger = logging.getLogger(__name__)
_MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024 # 100 MB
@@ -50,7 +53,15 @@ class AioSandbox(Sandbox):
"""
super().__init__(id)
self._base_url = base_url
- self._client = AioSandboxClient(base_url=base_url, timeout=600)
+ if sandbox_http_trust_env(base_url):
+ self._client = AioSandboxClient(base_url=base_url, timeout=600)
+ else:
+ direct_client = httpx.Client(timeout=600, follow_redirects=True, trust_env=False)
+ self._client = AioSandboxClient(
+ base_url=base_url,
+ timeout=600,
+ httpx_client=direct_client,
+ )
self._home_dir = home_dir
self._lock = threading.Lock()
self._closed = False
diff --git a/backend/packages/harness/deerflow/community/aio_sandbox/backend.py b/backend/packages/harness/deerflow/community/aio_sandbox/backend.py
index 28e614890..c16a42920 100644
--- a/backend/packages/harness/deerflow/community/aio_sandbox/backend.py
+++ b/backend/packages/harness/deerflow/community/aio_sandbox/backend.py
@@ -3,9 +3,11 @@
from __future__ import annotations
import asyncio
+import ipaddress
import logging
import time
from abc import ABC, abstractmethod
+from urllib.parse import urlparse
import httpx
import requests
@@ -15,6 +17,30 @@ from .sandbox_info import SandboxInfo
logger = logging.getLogger(__name__)
+def sandbox_http_trust_env(sandbox_url: str) -> bool:
+ """Whether HTTP clients for *sandbox_url* should inherit proxy settings.
+
+ Local Docker, DooD, and Kubernetes sandbox endpoints are control-plane
+ connections, not internet traffic. Sending them through ``HTTP_PROXY`` can
+ produce a misleading proxy-generated 502 even though the sandbox container
+ is healthy (#3441). External fully-qualified hosts retain normal environment
+ proxy behavior.
+ """
+ try:
+ hostname = (urlparse(sandbox_url).hostname or "").rstrip(".").lower()
+ except ValueError:
+ return True
+ if not hostname:
+ return True
+ if hostname == "localhost" or hostname.endswith(".localhost") or hostname.endswith(".docker.internal") or hostname.endswith(".containers.internal"):
+ return False
+ try:
+ address = ipaddress.ip_address(hostname)
+ except ValueError:
+ return "." in hostname
+ return not (address.is_loopback or address.is_private or address.is_link_local)
+
+
def wait_for_sandbox_ready(sandbox_url: str, timeout: int = 30) -> bool:
"""Poll sandbox health endpoint until ready or timeout.
@@ -26,14 +52,16 @@ def wait_for_sandbox_ready(sandbox_url: str, timeout: int = 30) -> bool:
True if sandbox is ready, False otherwise.
"""
start_time = time.time()
- while time.time() - start_time < timeout:
- try:
- response = requests.get(f"{sandbox_url}/v1/sandbox", timeout=5)
- if response.status_code == 200:
- return True
- except requests.exceptions.RequestException:
- pass
- time.sleep(1)
+ with requests.Session() as session:
+ session.trust_env = sandbox_http_trust_env(sandbox_url)
+ while time.time() - start_time < timeout:
+ try:
+ response = session.get(f"{sandbox_url}/v1/sandbox", timeout=5)
+ if response.status_code == 200:
+ return True
+ except requests.exceptions.RequestException:
+ pass
+ time.sleep(1)
return False
@@ -47,7 +75,7 @@ async def wait_for_sandbox_ready_async(sandbox_url: str, timeout: int = 30, poll
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
- async with httpx.AsyncClient(timeout=5) as client:
+ async with httpx.AsyncClient(timeout=5, trust_env=sandbox_http_trust_env(sandbox_url)) as client:
while True:
remaining = deadline - loop.time()
if remaining <= 0:
diff --git a/backend/tests/test_aio_sandbox.py b/backend/tests/test_aio_sandbox.py
index 9102c23b8..7aa5aed47 100644
--- a/backend/tests/test_aio_sandbox.py
+++ b/backend/tests/test_aio_sandbox.py
@@ -7,6 +7,47 @@ from unittest.mock import MagicMock, patch
import pytest
+def test_local_sandbox_client_bypasses_environment_proxy():
+ """Local sandbox API calls must not inherit HTTP_PROXY (#3441)."""
+ from deerflow.community.aio_sandbox.aio_sandbox import AioSandbox
+
+ sentinel_httpx = MagicMock()
+ with (
+ patch("deerflow.community.aio_sandbox.aio_sandbox.httpx.Client", return_value=sentinel_httpx) as client_cls,
+ patch("deerflow.community.aio_sandbox.aio_sandbox.AioSandboxClient") as sdk_cls,
+ ):
+ AioSandbox(id="test-sandbox", base_url="http://host.docker.internal:8080")
+
+ client_cls.assert_called_once_with(timeout=600, follow_redirects=True, trust_env=False)
+ sdk_cls.assert_called_once_with(
+ base_url="http://host.docker.internal:8080",
+ timeout=600,
+ httpx_client=sentinel_httpx,
+ )
+
+
+@pytest.mark.parametrize(
+ "base_url",
+ [
+ "https://sandbox.example.com",
+ "http://8.8.8.8:8080",
+ "http://[2606:4700:4700::1111]:8080",
+ ],
+)
+def test_external_sandbox_client_keeps_environment_proxy_support(base_url: str):
+ """Externally hosted sandbox URLs retain the SDK's default proxy behavior."""
+ from deerflow.community.aio_sandbox.aio_sandbox import AioSandbox
+
+ with (
+ patch("deerflow.community.aio_sandbox.aio_sandbox.httpx.Client") as client_cls,
+ patch("deerflow.community.aio_sandbox.aio_sandbox.AioSandboxClient") as sdk_cls,
+ ):
+ AioSandbox(id="test-sandbox", base_url=base_url)
+
+ client_cls.assert_not_called()
+ sdk_cls.assert_called_once_with(base_url=base_url, timeout=600)
+
+
@pytest.fixture()
def sandbox():
"""Create an AioSandbox with a mocked client."""
diff --git a/backend/tests/test_aio_sandbox_readiness.py b/backend/tests/test_aio_sandbox_readiness.py
index 1560bbab3..2accb7268 100644
--- a/backend/tests/test_aio_sandbox_readiness.py
+++ b/backend/tests/test_aio_sandbox_readiness.py
@@ -8,11 +8,20 @@ from deerflow.community.aio_sandbox import backend as readiness
class _FakeAsyncClient:
- def __init__(self, *, responses: list[object], calls: list[str], timeout: float, request_timeouts: list[float] | None = None) -> None:
+ def __init__(
+ self,
+ *,
+ responses: list[object],
+ calls: list[str],
+ timeout: float,
+ request_timeouts: list[float] | None = None,
+ trust_env: bool = True,
+ ) -> None:
self._responses = responses
self._calls = calls
self._timeout = timeout
self._request_timeouts = request_timeouts
+ self.trust_env = trust_env
async def __aenter__(self) -> _FakeAsyncClient:
return self
@@ -41,17 +50,65 @@ class _FakeLoop:
return value
+@pytest.mark.parametrize(
+ ("sandbox_url", "expected"),
+ [
+ ("http://localhost:8080", False),
+ ("http://127.0.0.1:8080", False),
+ ("http://[::1]:8080", False),
+ ("http://host.docker.internal:8080", False),
+ ("http://host.containers.internal:8080", False),
+ ("http://k3s:30001", False),
+ ("http://10.0.0.8:8080", False),
+ ("http://8.8.8.8:8080", True),
+ ("http://[2606:4700:4700::1111]:8080", True),
+ ("https://sandbox.example.com", True),
+ ],
+)
+def test_sandbox_http_trust_env_only_uses_proxy_for_external_urls(sandbox_url: str, expected: bool) -> None:
+ assert readiness.sandbox_http_trust_env(sandbox_url) is expected
+
+
+def test_wait_for_sandbox_ready_bypasses_environment_proxy_for_docker_host(monkeypatch: pytest.MonkeyPatch) -> None:
+ sessions: list[object] = []
+
+ class FakeSession:
+ trust_env = True
+
+ def __enter__(self):
+ sessions.append(self)
+ return self
+
+ def __exit__(self, *_exc_info) -> None:
+ return None
+
+ def get(self, url: str, *, timeout: float):
+ assert url == "http://host.docker.internal:8080/v1/sandbox"
+ assert timeout == 5
+ return SimpleNamespace(status_code=200)
+
+ monkeypatch.setattr(readiness.requests, "Session", FakeSession)
+
+ assert readiness.wait_for_sandbox_ready("http://host.docker.internal:8080", timeout=1) is True
+ assert len(sessions) == 1
+ assert sessions[0].trust_env is False
+
+
@pytest.mark.anyio
async def test_wait_for_sandbox_ready_async_uses_nonblocking_polling(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[str] = []
sleeps: list[float] = []
+ clients: list[_FakeAsyncClient] = []
- def fake_client(*, timeout: float):
- return _FakeAsyncClient(
+ def fake_client(*, timeout: float, trust_env: bool):
+ client = _FakeAsyncClient(
responses=[SimpleNamespace(status_code=503), SimpleNamespace(status_code=200)],
calls=calls,
timeout=timeout,
+ trust_env=trust_env,
)
+ clients.append(client)
+ return client
async def fake_sleep(delay: float) -> None:
sleeps.append(delay)
@@ -65,6 +122,7 @@ async def test_wait_for_sandbox_ready_async_uses_nonblocking_polling(monkeypatch
assert calls == ["http://sandbox/v1/sandbox", "http://sandbox/v1/sandbox"]
assert sleeps == [0.05]
+ assert clients[0].trust_env is False
@pytest.mark.anyio
@@ -72,11 +130,12 @@ async def test_wait_for_sandbox_ready_async_retries_request_errors(monkeypatch:
calls: list[str] = []
sleeps: list[float] = []
- def fake_client(*, timeout: float):
+ def fake_client(*, timeout: float, trust_env: bool):
return _FakeAsyncClient(
responses=[readiness.httpx.ConnectError("not ready"), SimpleNamespace(status_code=200)],
calls=calls,
timeout=timeout,
+ trust_env=trust_env,
)
async def fake_sleep(delay: float) -> None:
@@ -97,12 +156,13 @@ async def test_wait_for_sandbox_ready_async_clamps_request_and_sleep_to_deadline
request_timeouts: list[float] = []
sleeps: list[float] = []
- def fake_client(*, timeout: float):
+ def fake_client(*, timeout: float, trust_env: bool):
return _FakeAsyncClient(
responses=[SimpleNamespace(status_code=503)],
calls=calls,
timeout=timeout,
request_timeouts=request_timeouts,
+ trust_env=trust_env,
)
async def fake_sleep(delay: float) -> None: