Skip to content

fix(*): route explicit messages and link a Telegram DM to a page session - #1195

Open
LivXue wants to merge 11 commits into
mainfrom
fix/telegram_project_message_routing
Open

LivXue wants to merge 11 commits into
mainfrom
fix/telegram_project_message_routing

Conversation

@LivXue

@LivXue LivXue commented Nov 6, 2026 •

Copy link
Copy Markdown
Member

Summary

This PR adopts #1099, originally authored by @5mhbqxq22q-arch. The six contributing commits keep their original authorship. The branch was rebased onto current main, with follow-up commits that align the stored-turn verdict and the published contract with the cross-target rule, and that close the two gateway blockers found in review: a swallowed request left the adapter's typing loop running, and overlapping /stop commands shared one stop-state flag. The original pull request could not be updated in place because maintainer edits are disabled on the contributing fork, so this PR carries the work forward and closes #1099 on merge.

An explicit message(channel=..., chat_id=...) currently resolves the requested destination but calls the active turn's two-argument send callback. That callback still belongs to the current surface. A tool result can therefore claim a cross-channel send while the message goes to the page stream instead.

This change keeps explicit destinations intact through the native delivery hub. Explicit destination sends are a gateway-wide change; they do not require the optional Telegram project link. Changing either destination requires both fields; invalid, unconfigured, stopped or non-explicitly-allowlisted targets fail closed. Queue acceptance is reported as pending delivery. Telegram text and media sends record the returned message ID, and final failures reach the hub without exposing provider exception text.

It also adds an opt-in, single-owner Telegram private-chat link to an existing page conversation. The page scheduler owns the shared lane and history; replies and questions retain their originating turn's surface. Cross-surface input queues separately instead of inheriting another turn's reply address. /stop cancels active and queued work without clearing history. Other linked-DM slash commands are refused, and background output is not automatically mirrored.

The page Stop button cancels the active linked turn while preserving queued page input. It keeps the live DM input bubble and leaves cancellation reporting to the RPC path. Page Stop and runtime reload do not send a separate Telegram cancellation notice; Telegram /stop sends its own acknowledgement.

The link is disabled unless an owner-only telegram-project-link.json exists in the configured workspace. Its current implementation requires POSIX ownership checks. An absent file preserves existing behavior on other platforms. On Windows or another unsupported platform, a present file logs a warning and refuses Telegram input to the agent without aborting the gateway or falling back to a standalone conversation. The page and other channels remain available; removing the file and restarting restores standalone chats. Setup, limits and rollback are documented in docs/telegram-project-link.md.

Both stop paths use Lane.cancel and the same Lane.drain_pending implementation. Its reporting mode shares queued-cancellation reporting with Lane.cancel_turn, but stops running work and detaches queues before awaiting UI delivery. It holds per-conversation admission through child cleanup so cancellation announcements cannot restart the host. Captured askers retain their originating context; expired DM askers are refused rather than routed into a later page or DM turn. Calling-task cancellation now propagates through QuestionBroker; timeout, connection closure and cancel_all() still return the configured default. Existing tests for cancellation and Telegram send failure were updated for those deliberate behavior changes.

For Telegram delivery, the adapter classifies permanent HTTP failures before the SDK replaces their status with arbitrary error descriptions. SDK-specific BadRequest, auth and conflict errors retain their types; rate limits, server errors and transport failures remain retryable regardless of description wording. A rejected middle attachment gets a basename-only notice and does not prevent later parts from being delivered. HTML rejected with BadRequest is tried as the original plain text. The resumable delivery exception is exposed through channels.contract, with the shared retry budget documented on the outlet.

Type

  • Fix
  • Feature
  • Docs
  • CI / tooling
  • Refactor
  • Other

Verification

Rebased onto current main and tested on Linux with Python 3.12.13 in an isolated worktree. The environment was created with uv sync --frozen --all-extras; the rebase applied without textual conflicts. No live bot, provider or production service was called.

The 27-module focused selection passed 897 tests and 15 subtests:

uv run --offline --frozen --no-sync python -m pytest \
  tests/test_gateway_linked_chat.py \
  tests/test_gateway_linked_chat_boundaries.py \
  tests/test_gateway_linked_chat_cancel.py \
  tests/test_message_tool_destination.py \
  tests/test_question_broker.py \
  tests/test_message_tool_turn_local.py \
  tests/test_rpc_message_tool_route.py \
  tests/test_gateway_spine.py \
  tests/test_gateway_submit_router.py \
  tests/test_spine_delivery.py \
  tests/test_spine_scheduler.py \
  tests/test_spine_scheduler_lane.py \
  tests/test_spine_scheduler_pools.py \
  tests/test_rpc_spine.py \
  tests/test_cli_gateway_page.py \
  tests/test_cli_gateway_commands.py \
  tests/test_channels_telegram.py \
  tests/test_channels_telegram_delivery.py \
  tests/test_rpc_turn_cancel.py \
  tests/test_rpc_turn_send.py \
  tests/test_rpc_bootstrap.py \
  tests/test_rpc_control.py \
  tests/test_channels_outlet.py \
  tests/test_ask_user_tool.py \
  tests/test_acp_ask_user.py \
  tests/test_rpc_registration.py \
  tests/test_channels_contract.py \
  -q -n 0 --tb=short -p no:cacheprovider

The four trajectory/RPC integration modules passed 303 tests:

uv run --offline --frozen --no-sync python -m pytest \
  tests/test_rpc_trajectory.py \
  tests/test_trajectory_details.py \
  tests/test_trajectory_entries.py \
  tests/test_trajectory_index.py \
  -q -n 0 --tb=short -p no:cacheprovider

Additional checks:

  • The agent-loop, schema-match and destination modules affected by the follow-up fix passed 1031 tests and 8 subtests (tests/test_agent_loop_empty_recovery.py, tests/test_rpc_schema_match.py, tests/test_message_tool_destination.py).

  • The gateway review fixes passed 198 tests and 7 subtests across the five affected modules; test_a_refused_request_ends_the_adapter_turn and test_an_overlapping_stop_keeps_the_first_stop_state also pass by name.

  • ruff check and ruff format --check passed on all 26 changed Python files.

  • ty check passed on the 16 changed runtime files.

  • scripts/check_commit_messages.py, scripts/check_large_files.py and scripts/check_source_language.py passed over the branch range.

  • Follow-up commits align the branch with current main: the question-broker cancellation tests carry main's richer check, a stale named cancel answers with busy, and a cross-target send is not this turn's reply.

  • node ui-web/scripts/gen-rpc-client.mjs --check passes after the replied_via_tool description update.

  • Not checked locally at this head: the full repository suite, native Windows/macOS execution, live Telegram or LLM delivery, and browser acceptance. Hosted CI remains the gate for those. The original head's verification, independent review and Windows probe are recorded in fix(*): route explicit messages and link a Telegram DM to a page session #1099.

  • Relevant tests pass locally

  • Relevant lint / type checks pass locally

  • User-facing docs or screenshots are updated when needed

Risk

Explicit cross-target sends now require an explicitly listed destination; a wildcard allowlist alone does not authorize them. Same-target streaming remains unchanged. Permanent Telegram destination refusals terminate without transient retries. Permanent attachment failures produce sanitized basename-only notices. Retryable partial delivery resumes at the unfinished component under the existing hub retry budget, without replaying acknowledged components or resetting that budget. An HTML send rejected with BadRequest tries its original plain text, including when table padding exceeds the rendered-length limit. Transport errors and forbidden destinations do not trigger that fallback. Lost acknowledgements remain ambiguous; this does not guarantee exactly-once delivery. Permanent errors handled by the adapter do not increment the delivery hub's dropped counter; a zero counter is not a delivery receipt. Receipt logs contain chat and message identifiers and should be treated as private operational data.

The optional link admits one verified private sender and refuses groups, topics and other Telegram senders while configured. It does not merge or delete existing transcripts. It adds no dependency, RPC endpoint, credential copy or schema migration. Public fixtures use synthetic identities; no local configuration, credentials, transcripts or project data are included.

Rollback: remove the binding file and restart at a safe checkpoint to restore independent Telegram conversations. Reverting the PR restores the previous message behavior; the old gateway ignores the binding file.

  • Security impact considered
  • Backward compatibility considered
  • Rollback path is clear for risky changes

Related Issues

Closes #1099.

Related work: #1073 and #1078 landed the question-broker and scheduler cancellation semantics this branch builds on; #1081 landed the delivery-fold behavior. This contribution changes no CI policy or live installation.

5mhbqxq22q-arch and others added 6 commits November 6, 2026 07:40
Keep explicit destinations intact through the delivery hub. Add an opt-in owner-only Telegram link that shares the existing page lane, routes questions to the active surface, and reports queued cancellations without clearing history.

Include synthetic regression fixtures and configuration documentation. No credentials, local configuration, runtime data, or new dependencies are included.
Hold per-conversation admission through cancellation and child cleanup. Cancel running work and detach queued work before awaiting UI reports. Preserve captured asker context and refuse questions from expired linked turns.

Add native child-announcement and backpressure regressions plus cross-surface question tests.
Preserve attachment notices and later parts for HTTP 413 responses, restore
plain-text fallback for BadRequest, and expose the resumable delivery error
through the channel contract. Document silent page cancellation.

Co-authored-by: OpenAI (gpt-6-astra) <noreply@openai.com>
Keep unsupported configured links from aborting the gateway without
fallback to another conversation. Use HTTP status before SDK error
conversion and consolidate reported cancellation in the lane drain.

Co-authored-by: OpenAI (gpt-6-astra) <noreply@openai.com>

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking: fix the three inline issues and reconcile the head commit's Claude attribution with AGENTS.md section 3.3.

I reviewed the full github/main...HEAD diff after refreshing github/main, plus the affected callers, scheduler/RPC/channel architecture, commit history, backward compatibility, tests, AGENTS.md, CONTEXT-MAP.md, and CONTEXT.md. I found three concrete blockers inline. Separately, commit f32199793 uses Co-Authored-By: Claude Code <noreply@anthropic.com>; AGENTS.md section 3.3 requires Claude (<actual-model-id>) when Claude contributed (or no Claude trailer if it did not).

Verification: after uv sync --all-packages --extra channel-telegram, the affected suite passed with 334 tests and 15 subtests. Its first pre-extra run could not import the declared workspace package; an intermediate parallel run had one 0.1-second timing failure which passed twice in isolation, and the final unchanged suite was green. The existing stored-turn contract case fails consistently in isolation, matching GitHub's red unit shard: uv run pytest 'tests/test_agent_loop_empty_recovery.py::test_a_stored_turn_reads_the_message_tools_live_verdict[sent_naming_another_chat]' -q -o addopts='' gives 1 failed. I found no weakened tests or unrelated architecture/dependency/asset violations in the diff.

Comment thread raven/gateway/linked_chat.py Resolved
Comment thread raven/gateway/linked_chat.py Resolved
Comment thread raven/agent/tools/message.py Resolved

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking: the two unchanged linked-chat defects and the noncompliant Claude attribution still need correction.

The new delta correctly fixes the stored-turn contract failure: chat-only destinations are refused, true cross-target sends remain unanswered on the originating turn, the Python/schema/TypeScript descriptions agree, and the generated client matches the contract. I replied on and resolved that thread. No new finding arose from the delta.

The refused-input typing leak and overlapping /stop race remain unchanged, so their two threads stay open. Commit f32199793 also still carries the generic Claude Code trailer instead of the actual model attribution required by AGENTS.md section 3.3.

Coverage this round included the new delta, its message-tool/session/RPC callers, backward compatibility, commit history, generated schema, the prior findings, and the applicable AGENTS.md and CONTEXT contracts; I found no weakened tests or unrelated architecture/dependency/asset issue. Verification: the affected suite passed 815 tests and 15 subtests; tests/test_rpc_schema_match.py passed 533 tests; and node scripts/gen-rpc-client.mjs --check reported that generated.ts matches the 255-method contract.

@LivXue

LivXue commented Nov 6, 2026

Copy link
Copy Markdown
Member Author

Windows compatibility review — PR #1195

Reviewed head 95a1a416c055a3007367efeb95c7d9339673e9fc against base 8448cc6280923a7b20525af259a3b6d465844c42 (33 files, +2427/−118). I read the full diff, with particular attention to raven/gateway/linked_chat.py, the gateway binding and inbound wiring (raven/cli/gateway_commands.py, raven/gateway/spine.py), the RPC spine/bootstrap wiring, the scheduler and question-broker changes, the Telegram adapter and delivery changes, the message tool, the new tests, and docs/telegram-project-link.md. Existing review threads were read; the three unresolved ones (typing task on refused linked messages, overlapping /stop, stored-turn contract) are platform-neutral and are not repeated here.

Findings

1. Low (Windows-specific, reproduced): with a binding file present on Windows, every allowed inbound Telegram message leaves the adapter's typing indicator running until the gateway restarts. On an unsupported platform load_binding raises UnsupportedBindingPlatformError, the gateway sets link_unavailable (raven/cli/gateway_commands.py:858-868), and _inbound_dispatch then returns before any reply or turn (:964-965). TelegramChannel._on_message starts _typing_loop before intake.publish (raven/channels/adapters/telegram/channel.py:454-455, and the album flush at :505,524). The loop is stopped only by send() or end_turn; the gateway calls end_turn from a turn-end sink (raven/gateway/spine.py:249), and a dropped message produces neither. Probe on native Windows (fake _app, dispatcher that models the link_unavailable early return): after _on_message, the typing task was still alive after 4.5 s with 2 send_chat_action calls and 0 sends; await ch.end_turn("42") stopped it. This is a different branch from the open thread on LinkedChat.dispatch refusals (raven/gateway/linked_chat.py:115) — a fix confined to that refusal path will not cover this Windows-only early return. Remedy: in the link_unavailable branch, stop the typing task for that chat (call the channel's end_turn) or dispatch a one-line refusal Text through the hub, which stops typing via send() and also tells the sender linking is unsupported on this platform.

Windows-specific surface that behaves correctly

  • The platform gate itself is right: on this Windows host hasattr(os, "O_NOFOLLOW") and hasattr(os, "getuid") are both false, so an absent telegram-project-link.json returns None (standalone Telegram unchanged) and a present one raises UnsupportedBindingPlatformError without being read. Probe at this head: absent -> None; present -> UnsupportedBindingPlatformError: Telegram project linking requires POSIX file ownership checks.
  • The caller catches only that error, logs the documented warning, keeps the page and other channels running, and _inbound_dispatch refuses Telegram input without falling back to a standalone conversation. Explicit outbound sends through the message tool are not gated by link_unavailable.
  • The replaced media-failure notice now uses Path(value).name instead of path.rsplit('/', 1)[-1], which gives the correct basename for Windows paths.

Verification (native Windows 11, isolated detached worktree D:/tmp/rv-1195)

  • uv sync --frozen --offline --all-extras --no-extra segmenter --no-extra browser --dev, then uv run --no-sync pytest on the PR's focused modules:
    • test_cli_gateway_commands, test_gateway_linked_chat, test_gateway_linked_chat_boundaries, test_gateway_linked_chat_cancel → 129 passed, 3 skipped (the skips are POSIX-ownership tests, expected on Windows).
    • test_message_tool_destination, test_question_broker, test_channels_telegram_delivery, test_channels_telegram, test_channels_contract → 158 passed, 8 subtests passed.
    • test_gateway_spine, test_gateway_submit_router, test_spine_delivery, test_spine_scheduler, test_spine_scheduler_lane, test_spine_scheduler_pools, test_rpc_spine → 315 passed.
    • test_rpc_turn_cancel, test_rpc_turn_send, test_rpc_bootstrap, test_rpc_control, test_ask_user_tool, test_acp_ask_user, test_channels_outlet, test_rpc_registration → 276 passed.
    • test_message_tool_turn_local, test_rpc_message_tool_route, test_cli_gateway_page → 16 passed.
    • test_agent_loop_empty_recovery, test_rpc_schema_match → 1014 passed.
  • The typing-leak probe above was run against this head's worktree; no real bot or network call was made.

Gaps: the binding happy path cannot be exercised natively on Windows (the ownership tests are skipped by design), the typing probe models the dispatcher rather than running a full gateway, and no live Telegram bot, provider, or browser acceptance was used.

The rebased branch keeps main's refined question-broker cancellation check, so its richer tests replace the PR's simpler expectation. A stale named cancel now answers with busy from #1078.
The message tool now routes a cross-target send through the gateway and
refuses a destination that names only one of channel or chat. A turn is
answered only by a send on its own target, so the stored-turn verdict
test gains a refused single-field case and a queued cross-target case,
both expecting a Silent Turn.
The message tool answers a turn only when the send reaches that turn's
own target. State it in the sent_in_turn docstring, the turn-shape
contract and the published replied_via_tool description, and regenerate
the TypeScript client from the schema.
A swallowed Telegram request still started the adapter's typing loop,
and an overlapping /stop cleared the first stop's state and raised
SchedulerDrainingError. End the adapter turn on every consumed-without-
turn path, including the unsupported-platform inbound refusal, and make
the stop guard answer duplicates without touching the owner's flag.
The replied_via_tool description changed in rpc-schema/openrpc.json;
the TUI client is generated from the same contract.
@LivXue
LivXue force-pushed the fix/telegram_project_message_routing branch from 95a1a41 to 7c6b03e Compare November 6, 2026 08:10

LivXue commented Nov 6, 2026 •

Copy link
Copy Markdown
Member Author

The three inline blockers are fixed at 42c755e8c (typing task; overlapping /stop) and 720af56f0 + c4e6a1316 (stored-turn contract).

On the attribution note: the trailer was removed from that commit (now ed0749cdc) rather than completed with a model id. The standing owner ruling of 2026-10-13 is that this clone writes no Claude trailer, including for Claude-authored code; that ruling supersedes the section 3.3 form for this repository. The newer commits carry none either.

Affected suite after the fixes: 198 passed, 7 subtests, plus the two new regressions by name. The full CI run is restarting on the new head.

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No blockers; this can merge as far as I am concerned.

The new delta fixes both remaining code findings. Refused Telegram requests now terminate adapter typing on the linked-refusal and unsupported-platform paths, and overlapping /stop requests are answered without clearing the first stop's ownership or leaking SchedulerDrainingError. The stored-turn contract and both generated clients remain aligned. I replied to and resolved the two repaired threads; all threads I opened are now resolved.

I also accept the owner's explicit repository ruling that this clone carries no Claude trailer; the rewritten commit removes the generic trailer, so the attribution objection no longer stands.

Coverage included the new delta, the full prior diff context, affected callers, commit history, backward compatibility, tests, generated contracts, AGENTS.md/CONTEXT rules, and the existing review record. I found no weakened test or new architecture, dependency, or asset issue. Verification: 1,414 tests and 15 subtests passed across the affected runtime, gateway, Telegram, agent-loop, and schema suites. The web client reported generated.ts matches the contract (255 methods), and the TUI client reported generated.ts in sync. Hosted CI was still running at review time, with completed checks green.

@LivXue

LivXue commented Nov 6, 2026

Copy link
Copy Markdown
Member Author

Windows compatibility review — PR #1195

Reviewed head 7c6b03e325dcb271b6c596b7807b4f3d7e9f776c against base 8448cc6280923a7b20525af259a3b6d465844c42 (35 files, +2487/−124). The delta since the previously reviewed head 95a1a416c055a3007367efeb95c7d9339673e9fc is the commit fix(gateway): end refused turns and keep a stop's ownership plus a regenerated TUI RPC type: raven/cli/gateway_commands.py, raven/gateway/linked_chat.py, raven/gateway/spine.py and their tests. The full diff was re-read at this head.

Findings

Windows-specific: none — the typing-indicator leak reported at 95a1a416 is fixed. The link_unavailable branch now awaits end_channel_turn(channels.get_channel(...), req.source.chat_id) before returning (raven/cli/gateway_commands.py:964-968), and LinkedChat.dispatch ends the adapter turn on its own refusal paths (raven/gateway/linked_chat.py:112-117). Probe on native Windows at this head (real TelegramChannel._on_message → intake dispatch that models the Windows branch): after the refused message the typing task was already cancelled, the _typing map was empty, and over the next 4.2 s send_chat_action and send_message were both called 0 times (at 95a1a416 the same probe showed a live task and 2 send_chat_action calls). end_channel_turn tolerates a missing/not-running channel (raven/gateway/spine.py:258-266), so the Windows-misconfiguration path cannot fail the dispatch, and channels.get_channel exists on the real manager (raven/gateway/manager.py:357).

The /stop ownership change (stopping check plus SchedulerDrainingError handling, linked_chat.py:134-146) is platform-neutral and was exercised by the updated cancellation tests on Windows. No new Windows-specific surface was introduced by this delta.

Verification (native Windows 11, isolated detached worktree D:/tmp/rv-1195b)

  • uv sync --frozen --offline --all-extras --no-extra segmenter --no-extra browser --dev, then
    uv run --no-sync pytest tests/test_cli_gateway_commands.py tests/test_gateway_linked_chat.py tests/test_gateway_linked_chat_boundaries.py tests/test_gateway_linked_chat_cancel.py tests/test_gateway_spine.py -q → 195 passed, 3 skipped. The skips are the POSIX-ownership binding tests, expected on Windows; the platform-refusal test (including the new end_turn.await_count assertion) ran natively.
  • The typing-leak probe above was rerun against this head; no live bot or network call was made.
  • No unresolved review threads remain on this PR at this head.

Gap: the binding happy path cannot be exercised natively on Windows, and no full gateway process, live Telegram bot, or browser acceptance was run; the refusal path was verified through the adapter plus a dispatcher that models _inbound_dispatch.

@LivXue

LivXue commented Nov 6, 2026

Copy link
Copy Markdown
Member Author

Independent review of this PR at head `7c6b03e32`, run against `origin/main` (`8448cc628`) in an isolated worktree. Findings are evidence-backed; anything unverified is marked as such.


PR #1195 审核报告

  • 仓库 / PR:EverMind-AI/Raven fix(*): route explicit messages and link a Telegram DM to a page session #1195 — fix(*): route explicit messages and link a Telegram DM to a page session
  • 分支:fix/telegram_project_message_routing → main
  • 审核 head:7c6b03e325dcb271b6c596b7807b4f3d7e9f776c(基于 origin/main = 8448cc628,35 files,+2487/−124)
  • 审核方式:读完整 diff(git diff origin/main...pr1195)、新增测试、调用方与相关既有实现;在隔离 worktree D:/tmp/rv-1195review 上跑测试与探针。未在任何地方留下评论或提交。

1. 这个 PR 想做什么

这是对 #1099 的接手版本(原作者 @5mhbqxq22q-arch 的 6 个 commit 保留作者信息),rebased 到当前 main 后又追加了 review 修复。它实际包含三件事:

  1. 让 message 工具的显式目标真正生效。在此之前,message(channel=..., chat_id=...) 会解析出目标地址,却仍然调用当前 turn 自己的 send_callback——工具可能报告“已发到 Telegram”,消息其实进了 page 流。现在跨目标发送走 gateway 的 native delivery hub,两个字段必须同时给,目标必须在 allow_from 里被显式列出(通配符不算),失败关闭;队列接受只承诺 pending delivery。
  2. 可选的 Telegram 私聊 ↔ 已有 page 会话绑定(telegram-project-link.json,owner-only、单聊天)。page 的 scheduler 继续独占串行 lane 与历史;回复和提问按来源路由;/stop 取消该会话的工作;其它 slash 命令拒绝;后台输出不自动镜像;非 POSIX 平台有文件时 fail-closed。
  3. 为第 2 项与 Telegram 投递做的加固:QuestionBroker 的“提问属于发起它的 turn”的上下文、Scheduler 的 reported stop(先取消再报告、stop 期间关闭准入)、Telegram 永久错误分类与部分投递续传(RetryDeliveryError)。

规模上 2487 行里约 1770 行是测试、约 70 行是文档,生产代码约 620 行;对“一个路由修复”来说确实大,但这是一个新功能 + 两处协议级加固,规模本身不是问题。


2. 判断

需要修改(1 条应修问题 + 若干建议)。

代码质量、失败关闭的思路、测试密度都明显高于本仓库平均水平,CI 在 head 上全绿(unit ×4、ui rpc contract、coverage gates、commit messages 等)。但 F1 是“已修过的 typing 泄漏”在同一功能里的残留:当 linked turn 没有向 Telegram 发出任何文本就结束时(page Stop、runtime reload、空回复),DM 的 typing 指示会一直转下去。我已用真实 TelegramChannel + 真实 build_rpc_spine(linked_chat=...) 复现,并用一个约 6 行的补丁验证修复有效。其余问题为设计/UX 建议,不阻塞。


3. 正确的部分

  • 授权是纵深防御,不是单点:_authorized(raven/gateway/linked_chat.py:73-92)同时校验 origin、channel、chat_type、chat_id、sender_id 的 id|username 形式、extras 里的 user_id/is_group/is_forum/message_thread_id、以及 direct_target/parent_session/deliver_text/delegated/sentinel 全为 None;随后 wrap_runner 再校验一次(linked_chat.py:180-181)。少任何一条都会被拒。
  • fail-closed 贯穿始终:绑定文件 absent → 关闭;读不了/权限不对/字段不对/symlink → 启动拒绝(linked_chat.py:27-59);Mac/Linux 之外有文件 → 拒收 Telegram 入站而不是退回独立会话(raven/cli/gateway_commands.py:855-868, 965-968);page 未挂载或目标会话不存在 → 明确回一句“不可用”,绝不新建会话(linked_chat.py:129-131)。
  • 取消语义的边界处理得很细:queued turn 与 running turn 分开、inject 单独解析、children 一起取消、stop 期间 Scheduler.submit 直接拒绝(raven/spine/scheduler.py:659-661, 683-711),并且 _report_queued_cancel 先让 UI 看到取消再解析 future(scheduler.py:303-315)。/stop 与 page Stop 的重复上报由 stopping / _stopping 两层挡住,测试覆盖了重叠 stop。
  • 提问归属:_question_turn ContextVar + AskViaTool 在构造期 copy_context() 再 create_task(context=...)(raven/acp_client/asker.py:36-54),解决了“连接池里复用的 reader task 属于别的 turn/surface”这个既有坑;question_route 对已结束的 turn 返回 None(拒绝),而不是落回 page broker(raven/rpc/question_broker.py:366-380)。
  • 投递续传:RetryDeliveryError 携带 continuation,hub 在同一个重试预算内续传,已确认的分片不重放(raven/spine/delivery.py:68-76, 273-288;telegram/channel.py:301-345);永久错误(413/422 等)在 SDK 把状态码换成任意 description 之前就被拦下(channel.py:36-49),测试用真实 HTTPXRequest + MockTransport 验证了 SDK 映射。
  • 契约同步:replied_via_tool 的语义变更同步到了 models.py、openrpc.json 和两个 generated.ts;test_rpc_schema_match 533 passed,CI 的 ui rpc contract 也过了。
  • 测试有区分力(实测,不是猜):
    • 把 AskViaTool.ask 的 create_task(..., context=...) 改回直接 await,tests/test_gateway_linked_chat_boundaries.py::test_expired_dm_asker_cannot_question_a_later_turn 两个参数化用例立刻变红——说明这段不是装饰。
    • 新增的 test_a_refused_request_ends_the_adapter_turn、test_an_overlapping_stop_keeps_the_first_stop_state 都是直接对行为断言,不是结构断言。

4. 问题与风险

F1(中,应修)linked turn 没有发出文本就结束时,Telegram typing 指示不会停

LinkedChat.wrap_sink 管理了 linked turn 的收尾(弹 active/sources、失败时给 DM 发 “Sorry…”),但没有像 gateway 的 sink 那样结束 adapter 的 typing:

raven/gateway/linked_chat.py:195-204

def wrap_sink(self, page_sink, outlet):
    async def sink(event):
        if isinstance(event, (TurnEnded, TurnFailed)):
            active = self.active.get(event.conversation_id)
            if active is not None and active[0] == event.turn_id:
                self.active.pop(event.conversation_id, None)
                self.sources.pop(event.conversation_id, None)
                if isinstance(event, TurnFailed) and not event.cancelled:
                    await self.hub.dispatch(Text(content="Sorry, I encountered an error.", source=active[1]))
                self.agent.notify_turn_complete()

对照 gateway 自己的 sink,它在 TurnEnded 且 not explicit_reply / TurnFailed 时会收掉指示:

raven/gateway/spine.py:241-251

elif isinstance(event, TurnEnded) and not event.explicit_reply:
    ...
    for channel_name, chat_id in dict.fromkeys((person.channel, person.chat_id) for person in people):
        task = asyncio.create_task(end_channel_turn((channels or {}).get(channel_name), chat_id))

而 TelegramChannel._typing_loop 只在 send()(telegram/channel.py:305)和 end_turn()(:599-600)里被停掉。linked turn 跑在 page scheduler 上,_make_gateway_sink 不参与,所以:

  • page Stop:文档明确写了“The page Stop button cancels the active linked turn”,且“Page Stop … do not send a separate Telegram cancellation notice”。取消后没有任何 hub.dispatch(Text) → send() 不会被调用 → typing 不停。
  • runtime reload(_unbind_generation 取消 work):同上。
  • 空回复的 turn(模型只调工具、没有最终文本):同上。

实测(PR head 7c6b03e32,真实 TelegramChannel + 真实 build_rpc_spine(linked_chat=...)):

# 正常有回复的 turn
normal-turn typing task alive: False  send_message calls: 1
# 没有文本就结束的 linked turn(模拟空回复)
silent-turn typing task alive: True   send_chat_action calls: 2
# page Stop 取消 linked turn(不发送任何消息)
page-stop typing task alive: True     send_chat_action calls after stop: 2   send_message calls: 0

_typing_loop 的循环是 while self._app: send_chat_action; sleep(4),所以 DM 里会一直显示“typing…”,并且每 4 秒一次 Telegram API 调用,直到该聊天收到下一条消息(下一条入站消息的 _start_typing 会先 _stop_typing)或进程重启。这与之前 gloryfromca 要求修的两处 typing 泄漏是同一类问题,只是发生在 turn 结束路径而不是拒绝路径。

修复验证:在 wrap_sink 的 active 分支里补一句 await end_channel_turn(self.channels.get(active[1].channel), active[1].chat_id) 后,上面三个探针全部变成 alive: False(send_chat_action 停在 1 次)。注意 gateway sink 是 create_task 而不是 inline await,因为 end_turn 未必便宜;建议照抄这个做法(见第 5 节)。

F2(中低,需维护者决策)跨目标发送新增了“im 用户之间互发”的能力,PR 未说明

raven/gateway/spine.py:320-349 把 queue_target 装到进程内共享的 message 工具上,所有 turn(包括独立 Telegram 会话里的用户 turn)都能用:

async def queue_target(channel_name: str, chat_id: str, content: str, media: list[str]) -> str:
    target = channels.get(channel_name)
    if (
        target is None
        or not target.is_running
        or chat_id not in getattr(target.config, "allow_from", ())
        or not target.intake.is_allowed(chat_id)
    ):
        raise ValueError("Destination unavailable or not authorized")

allow_from 的语义原本是“谁可以跟 bot 说话”,不是“bot 可以给谁发消息”。于是:允许列表里有 A、B 时,A 在自己的独立 Telegram 会话里(或通过一次 prompt injection)让模型调用
message(channel="telegram", chat_id="<B>", content="..."),bot 就会给 B 发一条私聊。这个能力在旧实现里不存在(旧实现只会发给当前 reader),PR 的 Risk 段只写了“需要显式列出的目标”,没有写这条跨用户后果。

我不认为它一定要在合并前改(它落在操作员的 allowlist 信任边界内),但这是一个应该被明确记录/由维护者确认的能力扩展。可选收敛方式:跨目标只允许非 IM 来源的 turn(cron/page/子代理),或单独引入 message_targets 而不是复用 allow_from。

F3(低,UX)link_unavailable 时 Telegram 消息被静默吞掉

raven/cli/gateway_commands.py:966-968

if link_unavailable and req.source.channel == "telegram":
    await end_channel_turn(channels.get_channel(req.source.channel), req.source.chat_id)
    return

Windows(或任何没有 POSIX ownership 检查的平台)上存在绑定文件时,gateway 只打一行 warning,DM 用户发出消息后什么回复都收不到。这比“拒绝并解释”更像 bot 挂了。建议在这个分支里通过 hub 回一句(例如“此平台不支持 Telegram 项目绑定,详见 docs/telegram-project-link.md”),send() 也会顺带收掉 typing。

F4(低,UX)以 “/” 开头的普通文本也会被当成命令拒绝

raven/gateway/linked_chat.py:149-151

elif command.startswith("/"):
    # Slash commands can clear history or alter runtime policy; use the page for those.
    await reply("Commands other than /stop are disabled in this linked DM; use the project page.")

判断条件是 req.text.strip().split(maxsplit=1)[0].split("@", 1)[0].lower() 以 / 开头。Telegram 客户端会把 /home/x is broken 这类文本原样发出(它不是注册过的 bot 命令),但这里会被当成 slash 命令拒掉。建议匹配真正的命令形状(^/[A-Za-z0-9_]{1,64}(@\w+)?$),或只拒绝已知的危险命令(/new、/restart、/clear 等)。

F5(低,设计残留)/stop 会无限期持有该会话的准入,等待 UI 投递

raven/spine/scheduler.py:683-711 的 cancel_conversation_reported 在 _stopping 里 await asyncio.gather(...),其中包含 asyncio.shield(running_future) 和每个 queued turn 的 _report_queued_cancel(会 await sink → _make_rpc_sink._finish → hub.wait_idle)。如果 page 客户端不读(冻结标签页 / 断网但没断 WS),这个 await 没有超时:self.stopping 一直为 True → 该会话一切新消息被拒(“Linked project is not accepting messages”),再次 /stop 只回“already in progress”。PR 正文把“先取消再报告、stop 期间关闭准入”当作有意设计,我认为方向没问题,但“等待无上限”这一点值得写进文档或加一个超时(超时后照常回报,只是不再等 UI)。

F6(nit)session.interrupt 不会走 linked turn 的回退

raven/rpc/methods/turn.py:951-969 只看 _cancellable(session_key)(page 的 active-turn/inject 注册表),不接收 linked_turn_id。所以 TUI 的 Ctrl+C 打在同一个 tui: 会话上时,如果正在跑的是 linked Telegram turn,会拿到 {"ok": false}。page 的 Stop 按钮(turn.cancel)已经处理了(turn.py:887-892),TUI 这条路径没有。低优先级,但属于“同一个会话的两个客户端行为不一致”。

F7(nit)load_binding 的 4097 字节截断

raven/gateway/linked_chat.py:44

data = json.loads(f.read(4097))

大于 4 KiB 的文件如果前 4097 字节恰好是一个完整合法对象(或对象 + 尾随空白),json.loads 会成功,剩余内容被忽略——和文档说的“malformed 文件拒绝启动”不完全一致。没有安全影响(三个字段仍然被严格校验),可以改成 f.read(4097) 后判断是否读完,或直接 f.read() + 长度上限。

未验证 / 未覆盖(明确标注)

  • 没有跑真实 Telegram bot、真实 LLM、browser/TUI 验收;没有 macOS/Linux 原生运行(POSIX ownership 的 3 个用例在 Windows 上 skip,依赖 CI)。这些方向的结论未验证。
  • F2 的“跨用户发送”是通过代码路径推导 + 测试里的 allowlist 判定确认的,没有起两个真实 bot 用户实测。

5. 建议的改动

  1. F1(必做):在 LinkedChat.wrap_sink 的 active 分支里补 typing 收尾,并保留 gateway sink 的“异步、best-effort”形态:
def wrap_sink(self, page_sink, outlet):
    ending: set[asyncio.Task[None]] = set()

    async def sink(event):
        if isinstance(event, (TurnEnded, TurnFailed)):
            active = self.active.get(event.conversation_id)
            if active is not None and active[0] == event.turn_id:
                self.active.pop(event.conversation_id, None)
                self.sources.pop(event.conversation_id, None)
                if isinstance(event, TurnFailed) and not event.cancelled:
                    await self.hub.dispatch(Text(content="Sorry, I encountered an error.", source=active[1]))
                channel = self.channels.get(active[1].channel)
                if channel is not None:
                    from raven.gateway.spine import end_channel_turn

                    task = asyncio.create_task(end_channel_turn(channel, active[1].chat_id))
                    ending.add(task)
                    task.add_done_callback(ending.discard)
                self.agent.notify_turn_complete()
            ...

(我验证过的版本是 inline await,探针通过;async 形态是照 raven/gateway/spine.py:249-251 的既有约定,避免非 Telegram adapter 的 end_turn 拖住 lane。)

  1. F2:在 docs/telegram-project-link.md 和 PR 的 Risk 段明确写“allowlist 内任一发送者的 turn 都能让 bot 给 allowlist 内任一聊天发消息”,或者按第 4 节的两个收敛方案之一改代码。至少要让维护者做一个有意识的决定。

  2. F3:link_unavailable 分支改为经由 gw_hub.dispatch(Text(...)) 回一句平台不支持的说明,而不是纯静默(也会顺带收掉 typing)。

  3. F4:把 command.startswith("/") 收紧成真正的命令匹配;顺手让 /stop 的解析也复用同一个判定。

  4. F5:给 cancel_conversation_reported 的等待加一个有界超时(或在文档里把“UI 不响应时会话会一直被拒”写出来)。

  5. F6:把 linked_turn_id 接到 session_interrupt(或明确文档化“TUI Ctrl+C 不取消 linked turn”)。

  6. F7:load_binding 增加“读满则拒绝”的检查。


6. 建议补的测试

按价值排序:

  1. F1 的回归测试(必须有):用真实 TelegramChannel(object.__new__ + _typing + fake _app)起 typing,然后跑一个 linked turn(a)无文本结束,(b)被 page Stop 取消,断言 _typing 为空且不再有新的 send_chat_action。可以直接复用 tests/test_gateway_linked_chat.py 的 Loop/build_rpc_spine 装置。
  2. F2 的行为钉死:一个非 linked 的 Telegram turn(source.chat_id = 另一个 allowlisted 用户)调用 message(channel="telegram", chat_id=B),断言当前允许(如果决定保留)或拒绝(如果收敛)——无论哪种,让行为变成显式契约。
  3. F3:link_unavailable + 有绑定的 Windows 分支,断言 DM 收到一条说明(若采纳)。
  4. F4:dm("/home/user/file is broken") 应作为普通文本提交,而不是被拒。
  5. F7:load_binding 对 >4096 字节的畸形文件应拒绝。
  6. 可选:session.interrupt 对 linked turn 的语义(若修 F6)。

现有测试已覆盖的关键分支(不用重复补):拒绝路径的 end_turn、重叠 /stop、page Stop 保留 queued inject、expired asker、partial delivery 续传、永久 HTTP 错误分类、HTML 回退、retry 预算耗尽。


7. 合并前必做的事

  1. 修 F1,或明确在 PR 里接受“page Stop / 空回复后 DM 会一直显示 typing”的行为(我不建议接受:这是文档化的主流程)。
  2. 对 F2 做决定:要么补文档/风险说明,要么收敛实现。这是唯一一条“可能影响他人”的变更。
  3. 其余 F3–F7 可以合并后跟进;当前分支没有 schema 迁移、没有持久化数据要迁,revert 即可回滚(旧 gateway 会忽略绑定文件)。
  4. 流程状态:head 上 CI 全绿;三条 review thread 均已 resolved,gloryfromca 的结论是 “No blockers”;reviewDecision = REVIEW_REQUIRED,还差一个 approval 才能合并(BLOCKED)。
  5. 仓库约定:commit 都是 Conventional Commits、全英文,scripts/check_commit_messages.py 与 CI 均过。分支没有 Co-authored-by: Claude (...) trailer——AGENTS.md §3.3 在 Claude 参与时应要求,但这是 owner 在 PR 线程里明确裁决的例外(“this clone writes no Claude trailer”),gloryfromca 已接受;记录在此,不作为问题。

附:本次实测记录

内容 结果
test_gateway_linked_chat + _boundaries + _cancel + test_message_tool_destination + test_channels_telegram_delivery + test_channels_contract 105 passed, 2 skipped(skip 为 POSIX ownership),8 subtests,125 s
gateway/spine/scheduler/rpc/turn/question/channels 等 16 个受影响模块 727 passed, 1 skipped,186 s
test_rpc_schema_match.py 533 passed
test_channels_outlet / test_rpc_message_tool_route / test_message_tool_turn_local / test_cli_gateway_page 25 passed
test_a_stored_turn_reads_the_message_tools_live_verdict 4 passed(4 个参数化用例)
ruff check / ruff format --check(30 个改动 py 文件) 全部通过
scripts/check_commit_messages.py origin/main..HEAD 通过
git diff --check 干净
GitHub CI @ 7c6b03e32 unit 1–4、ui rpc contract、coverage gates、python lint、page checks、TUI、Windows ACP 等全部 pass
变异验证:去掉 AskViaTool 的 create_task(context=...) 2 个 linked-chat boundary 测试变红
探针:真实 adapter + linked spine 的 typing 生命周期 复现泄漏(见 F1);补丁后三例全部修复

@gloryfromca

gloryfromca commented Nov 6, 2026 •

Copy link
Copy Markdown
Member

Blocking: linked turns that end without Telegram output must stop the adapter's typing task.

F1 holds on the current head. I independently sent a silent TurnEnded through LinkedChat.wrap_sink with an active Telegram source: the linked active state was cleared, but the channel's end_turn await count remained 0. Because linked turns use the page scheduler, the normal gateway sink cannot provide that cleanup; page Stop, reload cancellation, or an answerless successful turn can therefore leave TelegramChannel._typing_loop refreshing indefinitely until another message or process restart.

The focused linked-chat suites remain green (41 passed, 7 subtests passed), which confirms the case is not covered by the current tests. Please add best-effort adapter-turn cleanup on this terminal path and a regression for a linked turn ending without outbound text. I regard F2-F7 as nonblocking follow-up/design items, not additional merge requirements.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants