mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
fix(channels): recover from undecodable thread-binding state (#7233)
Fixes bytedance/deer-flow#7232. Co-authored-by: Jun516 <174629376+Jun516@users.noreply.github.com>
This commit is contained in:
parent
feb7f3ac65
commit
a674132eed
@ -1036,6 +1036,10 @@ DeerFlow supports receiving tasks from messaging apps. Channels auto-start when
|
||||
|
||||
Cancelling a channel restart discards its pending configuration reload, preserving newer runtime settings applied afterward.
|
||||
|
||||
Invalid UTF-8 in `channels/store.json` follows the same recovery policy as malformed JSON:
|
||||
SQL migration warns and skips the file, leaving it unchanged so channels can start; the
|
||||
memory backend's JSON store warns and starts empty, replacing the file on its next write.
|
||||
|
||||
Conversation-binding, runtime channel-credential and WeChat auth-state saves preserve the original
|
||||
persistence failure when staged-file cleanup also fails. Remaining cleanup still runs, and warnings
|
||||
identify copies that could not be removed. Buzz keeps failed snapshots available for retry, even if
|
||||
|
||||
@ -433,6 +433,8 @@ DeerFlow 支持可配置的 MCP Server 和 skills,用来扩展能力。
|
||||
|
||||
DeerFlow 支持从即时通讯应用接收任务。只要配置完成,对应渠道会自动启动,而且都不需要公网 IP。
|
||||
|
||||
`channels/store.json` 含无效 UTF-8 字节时,采用与 JSON 格式损坏相同的恢复方式:SQL 迁移记录警告并跳过,保留原文件,让渠道可以继续启动;memory 后端的 JSON 存储记录警告并从空状态开始,在下次写入时替换该文件。
|
||||
|
||||
DeerFlow 还可以在 workspace UI 里暴露用户自有的 IM 渠道连接。启用 `channel_connections` 后,已登录用户可以从侧边栏 / Settings > Channels 绑定 Telegram、Slack、Discord、Feishu/Lark、DingTalk、WeChat、WeCom、QQ 或 Buzz。它复用现有的 `channels.*` 出站传输,因此不需要公网 IP 或 provider 回调地址。入站 IM 消息会以所连接的 DeerFlow 用户身份运行。设置和安全注意事项参见 [IM Channel Connections](backend/docs/IM_CHANNEL_CONNECTIONS.md)。
|
||||
|
||||
| 渠道 | 传输方式 | 上手难度 |
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
### IM Channels System (`app/channels/`)
|
||||
|
||||
Store JSON decode errors follow corrupt-file recovery; SQL import keeps the original bytes.
|
||||
|
||||
IM channels use `langgraph-sdk` HTTP; Gateway owns thread lifecycle.
|
||||
WeChat outbound: off-loop cap+1; overflow `None` before encryption/upload; <=0 unlimited.
|
||||
Cancel drains cursor writes; expiry drains cursor/auth reset under `_auth_lock`.
|
||||
|
||||
@ -334,6 +334,9 @@ def _read_legacy_file(path: Path, *, log_missing: bool, on_corrupt: str) -> dict
|
||||
if log_missing:
|
||||
logger.debug("No legacy channel store at %s", path)
|
||||
return None
|
||||
except UnicodeDecodeError:
|
||||
logger.warning(on_corrupt, path)
|
||||
return None
|
||||
except OSError:
|
||||
logger.warning(on_corrupt, path, exc_info=True)
|
||||
return None
|
||||
|
||||
@ -44,6 +44,11 @@ from deerflow.persistence.postgres_schema import build_asyncpg_connect_args
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
CORRUPT_FILE_CONTENTS = (
|
||||
pytest.param(b"not json", id="invalid-json"),
|
||||
pytest.param(b'{"key":"\xff"}', id="invalid-utf8"),
|
||||
)
|
||||
|
||||
|
||||
async def _sqlite_engine(path: Path) -> AsyncEngine:
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{path.as_posix()}")
|
||||
@ -211,13 +216,34 @@ async def test_entries_survive_a_new_store_instance_over_the_same_storage(tmp_pa
|
||||
# ── JSON-specific behaviour kept from the original store ────────────────────
|
||||
|
||||
|
||||
async def test_json_store_corrupt_file_starts_fresh(tmp_path, caplog):
|
||||
@pytest.mark.parametrize("newline", ["\n", "\r\n"], ids=["lf", "crlf"])
|
||||
async def test_valid_utf8_bindings_are_read_and_imported(tmp_path, newline):
|
||||
path = tmp_path / "store.json"
|
||||
path.write_text("not json", encoding="utf-8")
|
||||
contents = json.dumps({"slack:C1": _legacy_entry("thread-你好", user_id="用户")}, ensure_ascii=False, indent=2).replace("\n", newline).encode("utf-8")
|
||||
path.write_bytes(contents)
|
||||
assert await JsonChannelStore(path).get_thread_id("slack", "C1") == "thread-你好"
|
||||
|
||||
engine = await _sqlite_engine(tmp_path / "bindings.db")
|
||||
try:
|
||||
store = _sql_store(engine, legacy_path=path)
|
||||
assert await store.import_legacy_json() == 1
|
||||
assert await store.get_thread_id("slack", "C1") == "thread-你好"
|
||||
assert (await store.list_entries())[0]["user_id"] == "用户"
|
||||
assert not path.exists()
|
||||
assert path.with_name("store.json.migrated").read_bytes() == contents
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("contents", CORRUPT_FILE_CONTENTS)
|
||||
async def test_json_store_corrupt_file_starts_fresh(tmp_path, caplog, contents):
|
||||
path = tmp_path / "store.json"
|
||||
path.write_bytes(contents)
|
||||
store = JsonChannelStore(path)
|
||||
with caplog.at_level(logging.WARNING, logger="app.channels.store"):
|
||||
assert await store.get_thread_id("x", "y") is None
|
||||
assert "Corrupt channel store" in caplog.text
|
||||
assert path.read_bytes() == contents
|
||||
await store.set_thread_id("x", "y", "t")
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == {"x:y": {"thread_id": "t", "user_id": "", "created_at": pytest.approx(time.time(), abs=60), "updated_at": pytest.approx(time.time(), abs=60)}}
|
||||
|
||||
@ -395,15 +421,18 @@ async def test_import_without_a_file_or_without_a_legacy_path_is_a_no_op(tmp_pat
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_import_keeps_a_corrupt_or_malformed_file_and_skips_bad_entries(tmp_path, caplog):
|
||||
@pytest.mark.parametrize("contents", CORRUPT_FILE_CONTENTS)
|
||||
async def test_import_keeps_a_corrupt_or_malformed_file_and_skips_bad_entries(tmp_path, caplog, contents):
|
||||
engine = await _sqlite_engine(tmp_path / "bindings.db")
|
||||
try:
|
||||
corrupt = tmp_path / "corrupt" / "store.json"
|
||||
corrupt.parent.mkdir(parents=True)
|
||||
corrupt.write_text("not json", encoding="utf-8")
|
||||
corrupt.write_bytes(contents)
|
||||
with caplog.at_level(logging.WARNING, logger="app.channels.store"):
|
||||
assert await _sql_store(engine, legacy_path=corrupt).import_legacy_json() == 0
|
||||
assert corrupt.exists()
|
||||
assert corrupt.read_bytes() == contents
|
||||
assert not corrupt.with_name("store.json.migrated").exists()
|
||||
assert await _sql_store(engine).list_entries() == []
|
||||
# Import-path wording: nothing "starts fresh" here, the file is kept and the import skipped.
|
||||
assert "Corrupt legacy channel store at" in caplog.text and "skipping the import (file kept)" in caplog.text
|
||||
assert "starting fresh" not in caplog.text
|
||||
@ -549,6 +578,29 @@ async def test_resolve_channel_store_uses_the_process_engine_by_default(tmp_path
|
||||
# ── ChannelService wiring ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("contents", CORRUPT_FILE_CONTENTS)
|
||||
async def test_channel_service_start_skips_a_corrupt_legacy_file(tmp_path, caplog, contents):
|
||||
from app.channels.service import ChannelService
|
||||
|
||||
path = tmp_path / "store.json"
|
||||
path.write_bytes(contents)
|
||||
engine = await _sqlite_engine(tmp_path / "bindings.db")
|
||||
try:
|
||||
service = ChannelService(channels_config={}, store=_sql_store(engine, legacy_path=path))
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING, logger="app.channels.store"):
|
||||
await service.start()
|
||||
assert service._running is True
|
||||
assert await service.store.list_entries() == []
|
||||
assert path.read_bytes() == contents
|
||||
assert not path.with_name("store.json.migrated").exists()
|
||||
assert "Corrupt legacy channel store at" in caplog.text and "skipping the import (file kept)" in caplog.text
|
||||
finally:
|
||||
await service.stop()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_channel_service_start_imports_the_legacy_file_once_for_the_sql_store(tmp_path):
|
||||
from app.channels.service import ChannelService
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user