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:
Qiu 2026-10-21 21:56:12 +08:00 committed by GitHub
parent feb7f3ac65
commit a674132eed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 68 additions and 5 deletions

View File

@ -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

View File

@ -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)。
| 渠道 | 传输方式 | 上手难度 |

View File

@ -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`.

View File

@ -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

View File

@ -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