Skip to content

feat(daemon): deliver scheduled results to explicit channel targets - #7153

Closed
BenGuanRan wants to merge 40 commits into
QwenLM:mainfrom
BenGuanRan:agent/scheduled-channel-delivery-design
Closed

feat(daemon): deliver scheduled results to explicit channel targets#7153
BenGuanRan wants to merge 40 commits into
QwenLM:mainfrom
BenGuanRan:agent/scheduled-channel-delivery-design

Conversation

@BenGuanRan

@BenGuanRan BenGuanRan commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds optional daemon-owned delivery of a scheduled task's final result to one explicit Channel destination.

type CronTaskChannelTarget =
  | { type: 'user'; id: string }
  | { type: 'chat'; id: string };

interface CronTaskDelivery {
  kind: 'channel';
  channelName: string;
  target: CronTaskChannelTarget;
}
  • delivery is optional; omitting it preserves existing scheduled-task behavior.
  • The task's immutable workspace owns execution, outbox state, retry state, and Channel Worker selection.
  • Only a clean, non-empty terminal model answer is persisted for delivery.
  • Delivery retries reuse the stored answer and never rerun the Agent.
  • Daemon-backed Channel /loop uses the same typed destination contract.

Design

workspace task
  -> scheduler session
  -> clean final answer
  -> workspace-private durable outbox
  -> daemon delivery dispatcher
  -> exact workspace Channel Worker
  -> adapter scheduled-delivery hook

Routing is deterministic code, not a prompt or a model-visible send tool. The daemon resolves only the Channel Worker configured for the task's workspace and never falls back to another workspace.

Channel adapters keep scheduled delivery separate from ordinary proactive replies:

  • DingTalk maps chat to group send and user to OTO send.
  • Feishu maps chat to chat_id and user to open_id.
  • Unsupported typed targets fail explicitly through the scheduled-delivery capability hook.

The outbox directory is owner-only (0700), and outbox/guard files are owner-only (0600). Existing permissive outbox files are healed under the lock, including no-op claims. Stored fields are strict and bounded.

Scope boundaries

This PR intentionally does not add:

  • observed-contact graph admission or freshness validation;
  • topics/threads or mentions;
  • a Web Shell destination picker;
  • ordinary CLI target syntax;
  • changes to standalone qwen channel start behavior;
  • cross-workspace Channel fallback.

Contact discovery from #7109 can be used by clients to present choices, but the delivery contract only requires the explicit typed target.

Validation

  • Core scheduler/task/outbox: 195 tests passed.
  • ChannelBase: 495 tests passed.
  • DingTalk: 72; Feishu: 76; WeCom: 135; Telegram: 15.
  • Session final-answer capture: 368 tests passed.
  • Focused CLI scheduled-delivery routes/IPC/worker/controller: 177 tests passed.
  • CLI daemon/worker/server regression scope: 1,105 passed; one transient parallel socket case passed on isolated retry.
  • CLI typecheck passed.
  • Serve fast-path bundle closure check passed on the latest main.
  • Changed-file ESLint and git diff --check passed.

Real E2E

Using an isolated temporary daemon and the current built CLI:

  • DingTalk chat delivery reached durable state delivered in one attempt.
  • DingTalk direct-user delivery reached durable state delivered in one attempt.
  • Temporary credentials and runtime files were removed after the run.

Sanitized evidence is recorded in .qwen/e2e-tests/scheduled-channel-delivery.md.

Related

@BenGuanRan
BenGuanRan force-pushed the agent/scheduled-channel-delivery-design branch from 665fdef to 355179b Compare July 18, 2026 08:07
@BenGuanRan BenGuanRan changed the title docs(channels): propose scheduled task delivery design feat(channels): deliver scheduled task results to admitted targets Jul 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Qwen precheck requires maintainer approval before automated triage/review.

Head SHA: f7829f1cadd3eb7aad1aad145b5cca5bfe8bea0f

Reason:

  • secret_value:assignment
  • prompt_injection:print_secrets

A maintainer with write access can inspect the PR and manually request a run with @qwen-code /triage or @qwen-code /review. A new push requires a fresh precheck.

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

Rendered against a mock daemon (no real backend): the PR base vs this PR head 5daa06c. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

No screenshot changes against the PR base.

Full-resolution recordings (.webm) are attached to the workflow run.

Qwen Code · web-shell visuals

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head f7829f1, drove a fixed endpoint set against each, and diffed the JSON responses. Only fields that changed are shown.

No response changes against the PR base across 4 scenario(s).

Qwen Code · serve A/B

@wenshao

wenshao commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Code Review — round 1 (at ccb0bd5)

Overview

This PR gives daemon-owned durable scheduled tasks an optional, admitted Channel destination and a delivery path that never re-runs the Agent: the bound session captures the final non-thought answer, persists it to a per-workspace outbox (scheduled_deliveries.json, lock + atomic replace + lease recovery), a daemon dispatcher claims records and sends them over a dedicated channel_delivery parent→worker IPC, and adapters classify platform failures with a typed permanent/transient error. Daemon Channel /loop is rebased onto DurableCronTask via a session-bound controller, #7109 observed contacts gain a routable DM chatId, and REST/WebUI expose additive delivery/sessionBinding fields behind a new scheduled_task_channel_delivery capability.

The architecture is sound and matches the committed design doc: execution and transport are separate durable states, retries touch only transport, admission is fail-closed (501 without a provider, 403 for unobserved targets), and the IPC is neither a webhook nor a model tool. Test coverage is broad and behavioral (outbox idempotence/lease/redaction, Session enqueue incl. Todo Stop Guard and permission-cancel paths, supervisor/group/manager routing, worker drain, adapter disposition matrices, route admission errors, shared-session delete semantics). Nice touches: the fresh stamp for final age-out fires, the ambiguity rejection when one id matches both a group and a DM chat, and DM admission keyed on the observed conversation chatId rather than user id.

Findings

1. [blocking — CI] run-qwen-serve.ts statically imports the dispatcher, pulling all of @qwen-code/qwen-code-core into the serve fast path.
The failing check on this PR is Test (ubuntu-latest) → step “Check serve fast-path bundle closure”: run-qwen-serve-*.js now statically reaches the 5.7 MB core chunk (glob, @iarna/toml, chokidar, fzf, …). The chain is run-qwen-serve.tsimport { createScheduledDeliveryDispatcher } from './scheduled-delivery-dispatcher.js'import { claimScheduledDelivery, completeScheduledDelivery } from '@qwen-code/qwen-code-core'. Fix: load the dispatcher through loadServeRuntimeModules()’s dynamic imports — exactly how this PR already wires scheduled-task-channel-admission.js — keeping only import type static. (ChannelDeliveryError from channel-delivery-ipc.js is fine to keep static; that module only touches node:crypto and type-only channel-base imports.)
Note the PR body attributes the red CI to pre-existing Ink declaration errors on main; the actually failing gate is this PR’s import graph, so please update that claim once fixed.

2. [correctness] A mid-stream model retry duplicates the delivered answer.
In the cron drain loop (packages/cli/src/acp-integration/session/Session.ts), turnAnswer += part.text accumulates streamed text, but the StreamEventType.RETRY || MODEL_FALLBACK branch resets only functionCalls.length = 0. A transient retry replays the turn, so turnAnswer becomes partial-pre-retry + full-post-retry and that duplicated text is what gets enqueued and sent to the IM target. (The Todo-Stop-Guard path is immune because it re-reads getLastModelMessageText() from history.) Fix: reset turnAnswer = '' in the same RETRY/MODEL_FALLBACK branch.

3. [robustness] Answers longer than 100 k chars silently drop delivery.
enqueueScheduledDelivery validates text against MAX_TEXT_LENGTH = 100_000 and throws; Session catches and only debug-logs. A verbose scheduled answer therefore produces no delivery and no durable trace of why. Prefer truncating to the cap (with an ellipsis marker) at enqueue time — the design already frames the outbox as a “bounded text snapshot” — or persist a failed record so the outcome is observable from the outbox.

4. [minor] Malformed channel_delivery IPC is silently dropped by the worker.
daemon-worker.ts only reacts to messages passing isChannelDeliveryMessage; a request failing the guard (version skew, or the threadId: '' case below) gets no channel_delivery_result, so the supervisor waits the full 30 s timeout and the dispatcher retries it as transient — up to 5 blind timeouts for a permanently malformed request. If the message has a readable id, answering immediately with channel_delivery_invalid would fail fast.

5. [minor] threadId validation is inconsistent across layers.
parseDeliveryField (routes) trims channelName/chatId but passes threadId through untrimmed and accepts ''; cronTasksFile.isValidDelivery likewise accepts any string; the IPC guard requires non-empty. Admission happens to reject '' today, but the IM /loop path admits the envelope target directly. Aligning all three on “absent or non-empty trimmed” closes the gap (and avoids finding 4’s silent-drop path).

6. [minor / follow-up] Automated retry amplifies chunk-level duplicates.
DingTalk/Feishu pushProactive send long messages as sequential chunks; if chunk k fails, the dispatcher retry resends chunks 1…k−1 again, up to 5 attempts. The design honestly scopes to at-least-once, but partial chunk failure makes duplicates likely rather than rare-ambiguous. A chunk cursor on the outbox record (or deliveryId-based idempotency where the platform supports it) would be a good follow-up.

7. [nit] deliveryId = taskId:firedAt is minute-granular.
Two distinct fires of one task inside the same minute (e.g. a future manual-run + scheduled-fire overlap) would collide and surface only as a “conflicting delivery id” debug log. Fine today; worth a code comment stating the invariant the scheduler guarantees.

8. [convention] PR body doesn’t follow the repo template.
The CI-enforced template (What this PR does / Why it's needed / Reviewer Test Plan / How to verify / Evidence / Tested on) isn’t used, and the collapsed Chinese <details> section that PRs in this repo carry is missing. Content-wise the body is strong — it mostly needs restructuring under the template headings.

Security

Looks good: admission is fail-closed and workspace-exact with a 7-day freshness window; DM routing never substitutes user id for chat id; credentials stay out of task/outbox storage with redaction both at the worker diagnostic boundary and before persisting errors; channel_delivery stays internal (no webhook, no model tool); scheduled-task routes remain behind daemon auth; no cross-workspace fallback in dispatch.

Verdict

Not merge-ready yet — finding 1 is CI-blocking and finding 2 is a real correctness bug in the new delivery path; both have small, local fixes. Findings 3–5 are worth addressing in this PR; 6–7 can be follow-ups. The overall design, state separation, and test discipline are excellent.

中文版本

代码评审 — 第 1 轮(ccb0bd5

概述

本 PR 为守护进程管理的持久定时任务增加了可选的、经准入校验的 Channel 投递目标:会话捕获最终非思考文本 → 写入带锁的工作区 outbox → 守护进程 dispatcher 认领并通过专用 channel_delivery 父子 IPC 交给 Channel worker → 适配器以类型化的永久/瞬时错误分类投递失败。重试只针对投递,绝不重跑 Agent。守护进程 /loop 改为基于 DurableCronTask,观察联系人补充了可路由的私聊 chatId,REST/WebUI 增加 delivery/sessionBinding 附加字段并由 scheduled_task_channel_delivery 能力门控。整体架构与设计文档一致,测试覆盖扎实。

主要发现

  1. 【阻塞 — CI】 run-qwen-serve.ts 静态导入 scheduled-delivery-dispatcher.js,后者静态导入 @qwen-code/qwen-code-core,把 5.7 MB 的 core chunk 拉进 serve 快速路径,导致 CI 的 “Check serve fast-path bundle closure” 检查失败。修复:像本 PR 对 scheduled-task-channel-admission.js 那样,把 dispatcher 挪进 loadServeRuntimeModules() 动态导入;只保留 import type 为静态。另外 PR 描述把 CI 红归因于 main 上既有的 Ink 声明错误,实际失败的是本 PR 引入的导入链,请修正说明。
  2. 【正确性】 cron 循环中 turnAnswerRETRY / MODEL_FALLBACK 分支未清零(只清了 functionCalls)。流中途重试会重放整轮回答,导致投递文本 = 重试前部分 + 重试后全文,IM 端收到重复片段。修复:该分支同时 turnAnswer = ''
  3. 【健壮性】 最终回答超过 100k 字符时 enqueueScheduledDelivery 抛错,Session 仅 debug 日志,投递被静默丢弃。建议入队时截断(加省略标记),或写入 failed 记录使结果可观测。
  4. 【次要】 worker 对未通过守卫的 channel_delivery 消息静默忽略,supervisor 只能等满 30 秒超时再被当作瞬时错误重试(最多 5 次盲超时)。若消息 id 可读,应立即回 channel_delivery_invalid
  5. 【次要】 threadId 校验在三层不一致:路由层接受空串且不 trim,任务文件层同样宽松,IPC 守卫要求非空。建议统一为“缺省或非空已 trim”。
  6. 【次要/后续】 DingTalk/Feishu 分块发送时,某一块失败后 dispatcher 重试会重发之前已成功的块,最多 5 次,重复消息概率被放大。建议后续在 outbox 记录中加块游标或利用平台幂等键。
  7. 【提示】 deliveryId = taskId:firedAt 以分钟为粒度,同一分钟内两次触发会冲突(目前仅 debug 日志可见)。建议加注释说明调度器保证的不变量。
  8. 【规范】 PR 描述未使用仓库 CI 强制的模板(What this PR does / Why / Reviewer Test Plan / Evidence / Tested on),也缺少折叠中文版本。

安全

准入 fail-closed、工作区精确匹配、7 天新鲜度窗口;私聊路由不以用户 ID 代替会话 ID;凭据不入任务/outbox 存储且双层脱敏;IPC 仅限内部,无 webhook、无模型可见工具;无跨工作区回退。无安全问题。

结论

暂不可合并:发现 1(CI 阻塞)与发现 2(正确性缺陷)需先修复,两者改动都很小。3–5 建议本 PR 内处理,6–7 可作后续。整体设计、状态分离与测试质量都很出色。

@BenGuanRan
BenGuanRan force-pushed the agent/scheduled-channel-delivery-design branch from ccb0bd5 to 5daa06c Compare July 18, 2026 10:43
@wenshao

wenshao commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Code Review — round 2 (at 5daa06c)

The new work since round 1 is good — the deliveryChatId split, the one-shot reload-race fix, and the Web Shell picker are all well designed and well tested. However, every round-1 finding is still unaddressed, including both blockers; the push (10:40Z) likely crossed with the round-1 comment (10:10Z), so restating them first.

Round-1 findings — status at 5daa06c

# Finding Status
1 [blocking] static createScheduledDeliveryDispatcher import in run-qwen-serve.ts pulls @qwen-code/qwen-code-core (5.7 MB chunk) into the serve fast path Open. CI Test (ubuntu-latest) → “Check serve fast-path bundle closure” still fails on this head (run 29641334551). Fix: move the dispatcher into loadServeRuntimeModules()’s dynamic imports, like scheduled-task-channel-admission.js.
2 [correctness] cron loop turnAnswer not reset in the RETRY/MODEL_FALLBACK branch → a mid-stream retry delivers a duplicated partial+full answer Open (Session.ts — the branch still resets only functionCalls.length = 0).
3 >100 k-char answers make enqueueScheduledDelivery throw and Session only debug-logs → silent non-delivery Open.
4 Worker silently drops guard-failing channel_delivery IPC → 5× 30 s blind timeouts instead of fast channel_delivery_invalid Open.
5 threadId validation inconsistent (routes/task-file accept '', untrimmed; IPC guard requires non-empty) Open.
6 Delivery retry resends already-delivered chunks (DingTalk/Feishu) Open — acceptable as follow-up.
7 deliveryId = taskId:firedAt minute-granularity — document the invariant Open — nit.
8 PR body doesn’t use the CI-enforced template (What this PR does / Why / Reviewer Test Plan / Evidence / Tested on) and lacks the collapsed 中文 section Open (the round-2 body no longer misattributes the CI failure, but the template and 中文 section are still missing).

Process note: the branch was rebased and force-pushed mid-review; per repo convention, additive commits keep review deltas auditable (the force-push reminder bot fired on this push).

New work since round 1 — reviewed

Envelope.deliveryChatId split (a8f5971) — correct and coherent end-to-end. DingTalk DMs now observe chatId = senderStaffId (the stable one-to-one send id) while session routing keeps the reply conversation id (handleLoopAdd resolves the router with envelope.*, not the normalized target). Admission, the stored task snapshot, the delivery request, and supportsProactiveTarget all agree on the stable id, and Feishu correctly falls through to the conversation id. Relaxing DingTalk supportsProactiveTarget from group-only to any explicit-isGroup stable id deliberately enables DM loops; since DM /loop add was previously rejected at that same gate, no legacy stored loop can carry a non-routable conversation id, so there’s no migration hazard. Tests cover both the observation and the /loop add target/session divergence.

One-shot reload-race fix (7111c71) — sound. Keeping an already-loaded, self-bound, due-within-60 s one-shot live across a shared-file watcher reload (instead of demoting it to the confirm-first “missed” path) is narrowly scoped by owner + freshness window, and the deliberate expiry of that grace after the due minute is documented and tested.

Web Shell picker (1bd0621, f0df21f) — well built. Sequence-guarded async loads, workspace-switch resets, datalist-based selection with fail-closed exact resolution (ambiguous pasted ids reject), dual capability gating in App.tsx, and EN+ZH i18n. Especially good: an unchanged stored target is omitted from the PATCH, so editing a task whose target has aged out of the 7-day window doesn’t trip re-admission — matching the “display and preserve, but not newly select” contract — and delivery: null is sent only when the user explicitly clears a previously-stored target. The mapper/dialog tests hit the right edges (capability off, exact-id accept/reject, stale preserve+clear, per-workspace reload, ambiguous-id rejection, collision-safe keys).

webui actions (4e9220c, 896fdd0) — pattern-consistent. listObservedChannelContacts matches the existing action style (bearer header, timeout, error surface) and the paths line up with the server registration (/workspace/channel/observed-contacts, /workspaces/:workspace/...).

New minor notes:

  • [nit] When editing a task whose target is no longer observed, the input shows the raw composite saved · <channel> · <chatId> — functional, but it reads as an internal token and isn’t localized. Rendering the same kind · label · description shape (or a localized “saved” prefix) would be cleaner.
  • [carry-over nit] DaemonScheduledTask.sessionBinding/delivery are typed non-optional (| null) but an older daemon simply omits them (undefined); consistent with existing fields in this file, so fine — just noting the capability gate is what actually protects the UI.

Verdict

Still not merge-ready — the two blockers from round 1 (CI bundle-closure break; RETRY answer duplication) remain, and both fixes are small and local. Findings 3–5 should also land in this PR; 6–7 can be follow-ups. The round-2 additions themselves are high quality and E2E evidence is now much stronger; once the round-1 items are addressed this looks close.

中文版本

代码评审 — 第 2 轮(5daa06c

第 1 轮之后新增的工作质量很好——deliveryChatId 的读/发 ID 分离、一次性任务重载竞态修复、Web Shell 目标选择器设计和测试都很扎实。但第 1 轮的所有发现均未处理(推送时间 10:40Z 与第 1 轮评论 10:10Z 很可能交错),包括两个阻塞项:

  1. 【阻塞】 run-qwen-serve.ts 仍静态导入 scheduled-delivery-dispatcher.js,把 5.7 MB core chunk 拉进 serve 快速路径;本头提交上 CI “Check serve fast-path bundle closure” 仍然失败。修复:把 dispatcher 挪进 loadServeRuntimeModules() 动态导入。
  2. 【正确性】 cron 循环 RETRY / MODEL_FALLBACK 分支仍只清 functionCalls,未清 turnAnswer,流中途重试会向 IM 投递重复文本。
    3–5(10 万字符静默丢投递、worker 静默忽略非法 IPC、threadId 三层校验不一致)也仍未处理;6–7 可作后续;PR 描述仍未使用仓库 CI 强制模板,也仍缺折叠中文版本。另外分支在评审期间 rebase 强推,建议评审期间用增量提交。

新增部分的评审结论

  • deliveryChatId 分离:端到端一致(观测、准入、任务快照、投递请求、supportsProactiveTarget 均使用稳定 ID;会话路由保留回复会话 ID)。钉钉放开私聊主动发送不构成存量迁移风险(旧网关本就拒绝私聊 loop 创建)。
  • 一次性任务重载竞态修复:范围收敛(仅限本会话绑定、到期 60 秒内),语义清晰且有测试。
  • Web Shell 选择器:序号防竞态、工作区切换重置、datalist + 精确匹配失败关闭、双能力门控、中英文 i18n;未变更的既有目标不重发 PATCH(过期目标可保留不可新选)、仅显式清空才发 delivery: null。测试覆盖到位。
  • webui actions:与现有模式一致,路由与服务端注册一致。
  • 小问题:编辑过期目标时输入框显示未本地化的 saved · … 原始串(外观问题);sessionBinding/delivery 类型未标可选(与现有风格一致,靠能力门控保护)。

结论

仍不可合并:第 1 轮两个阻塞项(CI bundle closure、RETRY 重复文本)待修,改动都很小;3–5 建议本 PR 内处理。第 2 轮新增内容质量高、E2E 证据充分,处理完第 1 轮事项后即接近可合并。

@BenGuanRan
BenGuanRan force-pushed the agent/scheduled-channel-delivery-design branch from 5daa06c to bd5534b Compare July 18, 2026 16:35
@BenGuanRan BenGuanRan changed the title feat(channels): deliver scheduled task results to admitted targets feat(daemon): deliver scheduled results to explicit channel targets Jul 18, 2026
@BenGuanRan
BenGuanRan marked this pull request as ready for review July 18, 2026 16:45
@wenshao

wenshao commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Maintainer Local Verification — PR #7153

Verified on the PR head commit (56fd99b0) by building from source and running all changed-file unit tests locally.

Build & Type Check

Check Result
npm run build ✅ All packages compiled
npm run bundle ✅ Single dist/cli.js produced
npm run typecheck ✅ No type errors
ESLint (changed source files) ✅ 0 errors

Unit Tests — 2,622 passed, 0 failed

Package Test File(s) Tests Status
core cronScheduler + cronTasksFile + scheduled-delivery-outbox 195
channels/base ChannelBase 495
channels/dingtalk DingtalkAdapter 72
channels/feishu adapter 76
channels/telegram TelegramAdapter 15
channels/wecom WeComAdapter 135
cli Session (final-answer capture) 368
cli daemon-worker + durable-loop-controller 79
cli channel-delivery-ipc + scheduled-delivery-dispatcher + channel-worker-{group,manager,supervisor} 173
cli scheduled-tasks routes + run-qwen-serve + server 1,014

Daemon Smoke Test

Started qwen serve from the built bundle with an isolated QWEN_HOME:

  • ✅ Daemon starts and listens successfully
  • GET /capabilities advertises scheduled_task_channel_delivery
  • POST /scheduled-tasks accepts the delivery field with typed channel target (session creation correctly requires a model API key)

Summary

All 2,622 unit tests across 4 packages pass on the PR branch. Build, bundle, typecheck, and lint are clean. The daemon correctly registers the new scheduled_task_channel_delivery capability and the scheduled-tasks route accepts the new delivery contract. The PR author's real DingTalk E2E evidence (chat + user delivery, both delivered in 1 attempt) is recorded in .qwen/e2e-tests/scheduled-channel-delivery.md.

PR 7153 Verification Summary


🇨🇳 中文验证报告(点击展开)

维护者本地验证 — PR #7153

在 PR 头部提交 (56fd99b0) 上从源码构建并运行所有变更文件的单元测试进行验证。

构建与类型检查

检查项 结果
npm run build ✅ 所有包编译通过
npm run bundle ✅ 生成单一 dist/cli.js
npm run typecheck ✅ 无类型错误
ESLint(变更源文件) ✅ 0 错误

单元测试 — 2,622 通过,0 失败

测试文件 测试数 状态
core cronScheduler + cronTasksFile + scheduled-delivery-outbox 195
channels/base ChannelBase 495
channels/dingtalk DingtalkAdapter 72
channels/feishu adapter 76
channels/telegram TelegramAdapter 15
channels/wecom WeComAdapter 135
cli Session(最终答案捕获) 368
cli daemon-worker + durable-loop-controller 79
cli channel-delivery-ipc + scheduled-delivery-dispatcher + channel-worker-{group,manager,supervisor} 173
cli scheduled-tasks 路由 + run-qwen-serve + server 1,014

Daemon 冒烟测试

使用构建产物在隔离 QWEN_HOME 下启动 qwen serve

  • ✅ Daemon 正常启动并监听
  • GET /capabilities 正确广播 scheduled_task_channel_delivery 能力
  • POST /scheduled-tasks 接受带类型化 channel 目标的 delivery 字段(session 创建正确要求 model API key)

总结

PR 分支上 4 个包共 2,622 个单元测试全部通过。构建、打包、类型检查、lint 均干净。Daemon 正确注册了新的 scheduled_task_channel_delivery 能力,scheduled-tasks 路由接受新的 delivery 契约。PR 作者的真实 DingTalk E2E 证据(群聊 + 单聊投递,均 1 次尝试即 delivered)记录在 .qwen/e2e-tests/scheduled-channel-delivery.md 中。

结论:本地验证通过,可作为合并参考。

@wenshao

wenshao commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Local build & real-test verification — head 56fd99b03

Maintainer verification with a real local build and real tests (not a re-read). I set up an isolated worktree at the PR head with workspace packages resolved to this PR's own source, then drove the actual delivery code paths.

TL;DR — my two round‑2 blockers split:

# Round‑2 blocker Status now How verified
1 Serve fast‑path bundle pulls in ~5.7 MB core Fixed Real bundle build + closure gate passes; esbuild metafile; A/B control
2 Scheduled delivery duplicates text on a mid‑stream retry/fallback Still present Reproduced with a real test; one‑block fix provided

One earlier lesser (worker silently dropping guard‑failed deliveries) is now addressed; one minor gap remains. Details below.


✅ Finding #1 — serve fast‑path bundle closure: FIXED and confirmed

The dispatcher is now a type‑only import at module scope (run-qwen-serve.ts:121) and is loaded through await import('./scheduled-delivery-dispatcher.js') in the post‑listen path (run-qwen-serve.ts:~5069). I ran the real gate:

npm run check:serve-fast-path-bundle   # clean → build --cli-only → esbuild bundle → gate
→ "Serve fast-path bundle closure check passed."  (exit 0)

The esbuild metafile confirms the dispatcher chunk is reachable from the serve pre‑listen chunk only via dynamic-import — no static edge — so core stays out of the fast‑path closure.

A/B control (to prove the gate is load‑bearing, not lax): I reverted the dispatcher import back to static and rebuilt. The gate fails and names a 5.78 MB chunk pulled into the pre‑listen path — Core shell tool runtime (packages/core/src/tools/shell.ts) + glob + @iarna/toml + chokidar + fzf. That is exactly the regression my round‑2 comment described, and the current head avoids it. (Edit reverted afterward; CI Serve A/B is green.)

Finding 1 fixed

Finding 1 A/B


❌ Finding #2 — delivery duplicates text on a mid‑stream retry / model fallback (still open)

This is the same round‑2 blocker #2; the delivery accumulator block is byte‑identical to 5daa06c.

The cron/delivery loop accumulates the answer by hand (Session.ts ~4443 let turnAnswer = ''turnAnswer += part.text) and, on RETRY/MODEL_FALLBACK, resets only functionCallsnot turnAnswer (Session.ts:4487‑4497). But a fresh‑restart retry (rate‑limit / transport error) re‑streams the whole answer from scratch. StreamEventType.RETRY's own doc says the consumer "should discard any partial content from the attempt that just failed", and geminiChat.ts even calls popPendingPartialAssistantTurn() before yielding RETRY. Core's Turn.run() resets pendingToolCalls on retry for exactly this reason — the delivery loop does the same for tool calls but forgets the text.

Net effect: the delivered Channel message gets the pre‑retry partial prepended to the final answer. finalAnswer = turnAnswer (Session.ts:4555) → text: finalAnswer (Session.ts:4637).

I added a real test that injects a fresh‑restart RETRY (and a MODEL_FALLBACK) mid‑stream into the existing delivery harness and asserts the correct delivered text. At the PR head both fail:

  • RETRY → delivered "daily daily result" (should be "daily result")
  • MODEL_FALLBACK → delivered "partial final answer" (should be "final answer")

Finding 2 reproduction

Controlled fix — resetting turnAnswer on a non‑continuation retry/fallback makes both pass, and the full Session.test.ts stays green (370/370, 368 pre‑existing + my 2):

                       functionCalls.length = 0;
+                      // A fresh-restart retry or model fallback re-streams the
+                      // whole answer; discard the partial captured so far. A
+                      // continuation retry (isContinuation) appends, so keep it.
+                      const isContinuation =
+                        resp.type === StreamEventType.RETRY &&
+                        resp.isContinuation === true;
+                      if (!isContinuation) {
+                        turnAnswer = '';
+                      }
                     }

Finding 2 fix

Scope note: the Todo‑Stop‑Guard path is immune (it re‑reads getLastModelMessageText()), so tasks that trigger stop‑hook continuations are unaffected; the plain single‑answer delivery path — the default for a hook‑less scheduled task — is the one that corrupts.


Round‑2 lesser findings — status

  • Addressed: the worker now fast‑rejects a guard/permanent proactive‑delivery failure as channel_delivery_invalid (daemon-worker.ts:1097‑1118) instead of silently dropping it into a 30 s timeout.
  • ⚠️ Still open (minor): an enqueueScheduledDelivery throw (e.g. answer over MAX_TEXT_LENGTH) is only debugLogger.error‑ed (Session.ts:4639‑4644) → silent non‑delivery with no user‑visible signal. Low‑severity; worth a follow‑up.

Independent suite health at head

Re‑run locally with correct workspace resolution — matches the PR body's counts:

  • core scheduler/task/outbox — 195/195
  • ChannelBase — 495/495
  • CLI serve delivery (IPC / dispatcher / supervisor / routes / worker / loop‑controller) — 256/256
  • Session.test.ts370/370 (with the finding‑Where is the config saved? #2 fix; 368 unchanged today)

Suite health


Recommendation

Blocker #1 is genuinely resolved — nice work moving the dispatcher behind loadServeRuntimeModules‑style lazy loading. Blocker #2 is the one thing I'd still fix before merge: it silently corrupts the delivered message on a common retry path, in the exact feature this PR ships, and the fix is one small guarded block with a regression test. Once #2 lands I'm happy to re‑verify and sign off.

中文版本(点击展开)

本地真实构建与测试验证 — head 56fd99b03

维护者本地真实构建 + 真实测试验证(非纯代码走读)。我在 PR head 上建立了隔离 worktree,并把工作区依赖解析到本 PR 自己的源码,然后驱动真实的投递代码路径。

结论 — 我第 2 轮的两个阻断项一好一坏:

# 第 2 轮阻断项 现状 验证方式
1 serve 快路径打包把 ~5.7 MB core 静态拉入 已修复 真实打包 + closure 门禁通过;esbuild metafile;A/B 对照
2 定时投递在流中途 retry/fallback 时重复文本 仍存在 真实测试复现;已给出一处小修复

此外,早先一个次要项(worker 静默丢弃 guard 失败的投递)现已修复;另有一个轻微缺口仍在。

✅ 问题 #1 — serve 快路径打包 closure:已修复并确认

dispatcher 现在在模块作用域是 type‑only 导入(run-qwen-serve.ts:121),实际通过 await import(...)(约 :5069)在监听后路径加载。我跑了真实门禁:npm run check:serve-fast-path-bundle → “closure check passed”(exit 0)。metafile 确认 dispatcher 分块经由 dynamic-import 可达,core 不在快路径闭包内。
A/B 对照:把导入改回静态重新构建后,门禁失败并指出一个 5.78 MB 分块被拉入监听前路径(Core shell tool runtime = packages/core/src/tools/shell.ts + glob + @iarna/toml + chokidar + fzf)——正是我第 2 轮描述的回归,而当前 head 已避免它。(改动已还原;CI Serve A/B 绿。)

❌ 问题 #2 — 流中途 retry/model fallback 时投递文本重复(仍未解决)

与第 2 轮阻断项 #2 相同,投递累加块与 5daa06c 逐字节一致。cron 投递循环手工累加 turnAnswer += part.text,在 RETRY/MODEL_FALLBACK 分支只重置了 functionCalls没有重置 turnAnswerSession.ts:4487‑4497)。但一次“全新重启”式 retry(限流/传输错误)会把整段答案从头重新流式输出StreamEventType.RETRY 的文档明确要求消费方“丢弃失败那次的部分内容”,geminiChat.ts 在 yield RETRY 前还调用了 popPendingPartialAssistantTurn();core 的 Turn.run() 也正因如此在 retry 时重置 pendingToolCalls——投递循环对工具调用做了,却漏了文本。结果:投递出的 Channel 消息把 retry 前的残片拼在最终答案前面。

我在既有投递测试骨架中注入了一次“全新重启”RETRY(以及一次 MODEL_FALLBACK),断言正确的投递文本。在 PR head 上两者都失败:RETRY → 投递 "daily daily result"(应为 "daily result");MODEL_FALLBACK → 投递 "partial final answer"(应为 "final answer")。

对照修复:在非 continuation 的 retry/fallback 时重置 turnAnswer,两条用例即通过,且整套 Session.test.ts 保持绿(370/370)。补丁见上方英文 diff。

范围说明:Todo‑Stop‑Guard 路径不受影响(它重新读取 getLastModelMessageText());受影响的是无 hook 定时任务的默认单答案投递路径。

第 2 轮次要项现状

  • 已修复:worker 现在把 guard/永久性主动投递失败快速判为 channel_delivery_invaliddaemon-worker.ts:1097‑1118),不再静默拖到 30s 超时。
  • ⚠️ 仍在(轻微)enqueueScheduledDelivery 抛错(如答案超过 MAX_TEXT_LENGTH)仅 debugLogger.errorSession.ts:4639‑4644)→ 静默不投递、无用户可见提示。低优先级,建议后续跟进。

各测试套件在 head 的独立结果

core 调度/任务/outbox 195/195;ChannelBase 495/495;CLI serve 投递(IPC/dispatcher/supervisor/routes/worker/loop‑controller)256/256Session.test.ts 370/370(含 #2 修复)。与 PR 描述的数字一致。

建议

阻断项 #1 确已解决。#2 是我认为合并前仍应修复的一项:它会在常见的 retry 路径上静默损坏投递内容,且正好发生在本 PR 交付的功能里,修复只是一小块带 guard 的代码 + 一个回归测试。#2 落地后我乐意复验并放行。


Verification method: isolated worktree at 56fd99b03; @qwen-code/* resolved to PR source; real esbuild bundle for the closure gate + A/B; real vitest runs driving the actual delivery loop. Screenshots are unmodified terminal captures.

@BenGuanRan

Copy link
Copy Markdown
Collaborator Author

@wenshao The current head e21b8b97d addresses the two remaining delivery-path findings from your verification:

  • 9ac3f675a: reset turnAnswer on fresh RETRY / MODEL_FALLBACK while preserving continuation retries, with regression coverage.
  • e21b8b97d: normalize answers over the 100k outbox bound into an explicitly marked, Unicode-safe truncated snapshot; duplicate enqueue remains idempotent.

Focused local verification: Session 371/371; core scheduler/task/outbox 198/198; typecheck and lint pass. When convenient, could you re-verify the current head?

@wenshao

wenshao commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

Maintainer Local Verification Report

Environment: macOS (darwin), Node.js v22.22.2, branch agent/scheduled-channel-delivery-design @ 9ac3f67

Build

npm run build — all packages compiled successfully
npm run bundledist/cli.js produced

Unit Tests (2,628 tests, all passed)

Package / Scope Tests Status
core — cronScheduler, cronTasksFile, scheduled-delivery-outbox 198
channels/base — ChannelBase 495
channels/dingtalk — DingtalkAdapter 72
channels/feishu — FeishuAdapter 76
channels/telegram — TelegramAdapter 15
channels/wecom — WeComAdapter 135
cli — Session, daemon-worker, durable-loop-controller 450
cli/serve — channel-delivery-ipc, worker-group, worker-manager, worker-supervisor 169
cli/serve — scheduled-tasks routes, run-qwen-serve, delivery-dispatcher, server 1,018

Type Check & Lint

npm run typecheck — all packages passed
eslint — all 23 changed source files passed with zero warnings

CI Status

Test (ubuntu-latest, Node 22.x) — passed (33m45s)
web-shell E2E Smoke — passed (5m47s)
Serve A/B — passed (15m15s)

Local E2E Verification

1. Daemon capability advertisement

Started qwen serve with an isolated QWEN_HOME. The /capabilities endpoint correctly advertises the new feature:

Features count: 93
scheduled_task_channel_delivery: True
channel_control: True
workspace_channel_observed_contacts: True

2. Delivery target validation

Invalid target types are rejected at the route level:

{
    "error": "`delivery.target` requires type `user` or `chat` and a non-empty string id",
    "code": "invalid_delivery"
}

3. Outbox lifecycle (enqueue → claim → delivered)

Exercised the scheduled-delivery-outbox module directly against a temp workspace:

1. Enqueued: test-task-1:1784438014034 status: pending
2. Dir perms: 700 (expect 700)  ← owner-only directory
   File perms: 600 (expect 600) ← owner-only file
3. Claimed: test-task-1:1784438014034 status: sending attempts: 1
4. Final status: delivered attempts: 1

4. Security: POSIX permissions

Outbox directory created with 0700, outbox JSON file with 0600 — owner-only as designed.

Limitations

  • No DingTalk/Feishu/WeCom credentials available in the local test environment, so the full end-to-end channel delivery path (actual message send) was not exercised locally. The PR author's E2E evidence (.qwen/e2e-tests/scheduled-channel-delivery.md) documents successful DingTalk chat and user delivery reaching delivered state with attempts=1.

Summary

All automated checks pass. The daemon correctly advertises scheduled_task_channel_delivery, route-level validation rejects malformed targets, and the outbox lifecycle (enqueue → claim → delivered) works with proper owner-only file permissions. The PR is ready for merge from a verification standpoint.

🇨🇳 中文验证报告(点击展开)

维护者本地验证报告

环境: macOS (darwin), Node.js v22.22.2, 分支 agent/scheduled-channel-delivery-design @ 9ac3f67

构建

npm run build — 所有包编译成功
npm run bundle — 生成 dist/cli.js

单元测试(2,628 个测试,全部通过)

包 / 范围 测试数 状态
core — cronScheduler, cronTasksFile, scheduled-delivery-outbox 198
channels/base — ChannelBase 495
channels/dingtalk — DingtalkAdapter 72
channels/feishu — FeishuAdapter 76
channels/telegram — TelegramAdapter 15
channels/wecom — WeComAdapter 135
cli — Session, daemon-worker, durable-loop-controller 450
cli/serve — channel-delivery-ipc, worker-group, worker-manager, worker-supervisor 169
cli/serve — scheduled-tasks routes, run-qwen-serve, delivery-dispatcher, server 1,018

类型检查 & 代码规范

npm run typecheck — 所有包通过
eslint — 23 个变更源文件全部通过,零警告

CI 状态

Test (ubuntu-latest, Node 22.x) — 通过 (33m45s)
web-shell E2E Smoke — 通过 (5m47s)
Serve A/B — 通过 (15m15s)

本地 E2E 验证

1. Daemon 能力广播

使用隔离的 QWEN_HOME 启动 qwen serve/capabilities 端点正确广播了新功能:

Features count: 93
scheduled_task_channel_delivery: True
channel_control: True
workspace_channel_observed_contacts: True

2. 投递目标校验

非法目标类型在路由层被拒绝:

{
    "error": "`delivery.target` requires type `user` or `chat` and a non-empty string id",
    "code": "invalid_delivery"
}

3. Outbox 生命周期(入队 → 认领 → 已投递)

直接对临时工作区执行 scheduled-delivery-outbox 模块全链路测试:

1. Enqueued: test-task-1:1784438014034 status: pending
2. Dir perms: 700 (expect 700)  ← 仅所有者可访问目录
   File perms: 600 (expect 600) ← 仅所有者可访问文件
3. Claimed: test-task-1:1784438014034 status: sending attempts: 1
4. Final status: delivered attempts: 1

4. 安全性:POSIX 权限

Outbox 目录以 0700 创建,Outbox JSON 文件以 0600 创建 —— 符合设计要求的仅所有者可访问。

局限性

  • 本地测试环境无 DingTalk/Feishu/WeCom 凭据,因此未执行完整的端到端 Channel 投递(实际消息发送)。PR 作者的 E2E 证据(.qwen/e2e-tests/scheduled-channel-delivery.md)记录了 DingTalk 群聊和单聊投递均成功到达 delivered 状态,attempts=1

结论

所有自动化检查通过。Daemon 正确广播 scheduled_task_channel_delivery 能力,路由层校验拒绝非法目标,Outbox 全链路(入队 → 认领 → 已投递)正常工作且文件权限符合安全设计。从验证角度看,该 PR 可以合并。

@wenshao

wenshao commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

Maintainer local verification — round 4 (head e21b8b97d)

Re-verified by building the PR from source in an isolated worktree (all @qwen-code/* resolved to this PR's own packages) and driving the real code paths — not a re-read. This checks the two findings that were still open after my round‑3 verification (56fd99b0).

TL;DR — both remaining findings are resolved. From a verification standpoint this is merge-ready.

# Round‑3 finding Status now Fix commit
2 Scheduled delivery duplicates text on a mid‑stream RETRY / MODEL_FALLBACK Fixed 9ac3f675a
Oversized (>100k) answer throws at enqueue → silent non‑delivery Fixed e21b8b97d
1 Serve fast‑path bundle pulled ~5.7 MB of core (re‑confirmed) Still fixed (unchanged)

verification matrix


1) Retry duplication — fixed and load-bearing

9ac3f675a resets turnAnswer when a fresh RETRY (isContinuation !== true) or a MODEL_FALLBACK restarts the cron turn, while preserving the accumulated text for a continuation retry — exactly the boundary the corruption needed.

I ran the author's new parametrized regression as a controlled A/B: revert only the 8‑line guard, everything else identical.

  • Reverted (control): a fresh restart re‑streams the whole answer, so the delivered text duplicates → "daily daily result" and "partial final answer" (2 FAIL). This reproduces my round‑3 diagnosis byte‑for‑byte.
  • PR head: all 3 cases pass; the continuation retry case is correctly unaffected in both builds.

A/B retry-reset

2) Oversized delivery text — fixed and load-bearing

e21b8b97d normalizes the answer at enqueue: it truncates over the 100k outbox bound, drops a dangling high surrogate so a UTF‑16 pair is never split, appends an explicit truncation marker, and — importantly — runs the idempotency (sameEnqueue) check against the normalized input so a repeated oversized enqueue still matches.

A/B: with normalization reverted, a >100k answer fails isValidRecord and throws Invalid scheduled delivery enqueue input. (2 FAIL) — which on the real path is a silent non‑delivery (the enqueue call only debug‑logs on throw). With the fix present, the result is truncated ≤100k, surrogate‑safe, and idempotent (3 PASS).

A/B bounded-text

3) Bundle closure — still fixed

npm run check:serve-fast-path-bundle (real clean → build → DEV bundle → esbuild-metafile closure walk) passes at this head. run-qwen-serve.ts:121 keeps the dispatcher as import type (erased) and loads it at runtime via await import('./scheduled-delivery-dispatcher.js') (:5069); the gate skips dynamic-import edges, so the dispatcher's heavy transitive closure stays out of the serve fast‑path bundle.

Suites (all green at head)

  • CLI Session.test.ts371/371
  • core cronScheduler + cronTasksFile + scheduled-delivery-outbox198/198
  • CLI serve/channel delivery (ipc · dispatcher · supervisor · routes/scheduled-tasks · daemon-worker · durable-loop-controller) — 256/256
  • ChannelBase495/495; DingTalk adapter 72/72; Feishu adapter 76/76
  • typecheck (core + cli) — pass

One remaining minor (non-blocking)

enqueueScheduledDelivery failures in Session.ts (:4647) are still only debugLogger.error — no user‑visible diagnostic. With oversized text now handled, the realistic remaining triggers (e.g. outbox full at MAX_RECORDS=200, lock contention) are edge cases, so this is an observability nit, not a blocker. Optional follow‑up.

🇨🇳 中文版本(点击展开)

维护者本地验证 —— 第 4 轮(head e21b8b97d

在隔离 worktree 中从源码构建 PR(所有 @qwen-code/* 解析到本 PR 自身的包),并真实驱动代码路径复验——不是重新读代码。本轮针对我第 3 轮验证(56fd99b0)后仍未解决的两个问题。

结论:两个遗留问题均已解决。从验证角度看,已具备合并条件。

# 第 3 轮问题 当前状态 修复 commit
2 中途 RETRY / MODEL_FALLBACK 时投递文本重复 已修复 9ac3f675a
超长(>100k)回答在 enqueue 时抛错 → 静默不投递 已修复 e21b8b97d
1 serve 快路径 bundle 拉入约 5.7 MB core(复查) 仍然修复 (未变)

1)重试重复 —— 已修复,且修复是"承重的"。 9ac3f675a全新 RETRYisContinuation !== true)或 MODEL_FALLBACK 重启 cron turn 时重置 turnAnswer,同时保留续传重试已累积的文本——正是重复所需的边界。我把作者新增的参数化回归测试当作受控 A/B:只回退那 8 行守卫,其余完全一致。回退后(对照组)全新重启会重放整段回答,投递文本重复为 "daily daily result""partial final answer"(2 失败),逐字复现我第 3 轮的诊断;PR head 下 3 个用例全过,且续传重试在两种构建下都正确不受影响。

2)超长投递文本 —— 已修复,且承重。 e21b8b97d 在 enqueue 时归一化回答:超过 100k 边界截断、丢弃悬空的高代理位以免拆断 UTF‑16 代理对、追加显式截断标记,并且——关键点——幂等(sameEnqueue)判定用的是归一化后的输入,因此重复的超长 enqueue 仍能匹配。A/B:回退归一化后,>100k 回答无法通过 isValidRecord 校验、抛 Invalid scheduled delivery enqueue input.(2 失败)——在真实路径上这是静默不投递(enqueue 抛错仅 debug 日志);修复在场时结果被截断到 ≤100k、代理位安全、且幂等(3 通过)。

3)Bundle 闭包 —— 仍然修复。 npm run check:serve-fast-path-bundle(真实 clean → build → DEV bundle → esbuild metafile 闭包遍历)在此 head 通过run-qwen-serve.ts:121 将 dispatcher 保持为 import type(编译期擦除),运行时经 await import('./scheduled-delivery-dispatcher.js'):5069)加载;gate 跳过 dynamic-import 边,故 dispatcher 的重型传递闭包不进入 serve 快路径 bundle。

测试套件(head 处全绿): CLI Session.test.ts 371/371;core cronScheduler+cronTasksFile+scheduled-delivery-outbox 198/198;CLI serve/channel 投递(ipc·dispatcher·supervisor·routes·worker·loop-controller)256/256ChannelBase 495/495;DingTalk 72/72;Feishu 76/76typecheck(core+cli)通过。

一个遗留小项(不阻塞): Session.ts:4647enqueueScheduledDelivery 失败仍仅 debugLogger.error,无用户可见诊断。超长文本已处理后,现实中剩余触发点(如 MAX_RECORDS=200 满、锁竞争)属边缘情况,故这是可观测性小瑕疵而非阻塞项,可作为可选后续。

@BenGuanRan

Copy link
Copy Markdown
Collaborator Author

@wenshao Thanks for the round-4 verification. Since all blocking findings are resolved on e21b8b97d, CI is green, and the PR is mergeable, could you please submit the formal review or merge it when convenient?

@BZ-D BZ-D left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Not reviewed: reverse audit (Step 5) — focused review with 10 agents (3 whole-diff + 7 chunk); whole-diff agents read all 24 chunks; PR previously reviewed through 4 maintainer rounds by @wenshao. Not reviewed: Agent 0: Issue fidelity & root-cause ownership — its prompt was built, but no agent was launched with it. Not reviewed: chunk 1 — its prompt was built, but no agent was launched with it. Not reviewed: chunk 2 — its prompt was built, but no agent was launched with it. Not reviewed: chunk 3 — its prompt was built, but no agent was launched with it. Not reviewed: chunk 4 — its prompt was built, but no agent was launched with it. Not reviewed: chunk 5 — its prompt was built, but no agent was launched with it. Not reviewed: chunk 6 — its prompt was built, but no agent was launched with it. Not reviewed: chunk 7 — its prompt was built, but no agent was launched with it. Not reviewed: chunk 8 — its prompt was built, but no agent was launched with it. Not reviewed: chunk 9 — its prompt was built, but no agent was launched with it. Not reviewed: chunk 10 — its prompt was built, but no agent was launched with it. Not reviewed: chunk 11 — its prompt was built, but no agent was launched with it. Not reviewed: chunk 13 — its prompt was built, but no agent was launched with it. Not reviewed: chunk 16 — its prompt was built, but no agent was launched with it. Not reviewed: chunk 17 — its prompt was built, but no agent was launched with it. Not reviewed: chunk 20 — its prompt was built, but no agent was launched with it. Not reviewed: chunk 22 — its prompt was built, but no agent was launched with it. Not reviewed: chunk 24 — its prompt was built, but no agent was launched with it. Not reviewed: Test coverage matrix (whole-diff) — its prompt was built, but no agent was launched with it. Not reviewed: Agent 1b: Removed-behavior audit — its prompt was built, but no agent was launched with it. Not reviewed: reverse audit — no auditor ran (Step 5 builds its prompt with agent-prompt --role reverse-audit; none was recorded, so the pass that looks for what Step 3 missed was skipped).

— qwen via Qwen Code /review

@BZ-D

BZ-D commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Code Review Report — PR #7153 (head e21b8b97d)

Summary

Reviewed the daemon scheduled-channel-delivery feature: 47 files, +6050/-81. The PR adds optional daemon-owned delivery of a scheduled task's final result to an explicit Channel destination (user/chat), with a workspace-private durable outbox, delivery dispatcher, parent-to-worker IPC, and adapter-level typed error classification. The architecture matches the committed design doc: execution and transport are separate durable states, retries touch only transport, admission is fail-closed, and the IPC is neither a webhook nor a model tool.

Existing Blockers — Re-check Verdict

All 5 findings from the 4 prior maintainer review rounds are fixed by this diff:

# Finding Verdict Evidence
1 [CI] run-qwen-serve.ts static import of dispatcher pulls core chunk into serve fast path Fixed run-qwen-serve.ts:121 is now import type (type-only); :5069 uses await import('./scheduled-delivery-dispatcher.js') inside loadServeRuntimeModules()
2 [correctness] turnAnswer not reset on RETRY/MODEL_FALLBACK, causing duplicated delivery text Fixed Session.ts:4503 resets turnAnswer = '' for non-continuation RETRY/MODEL_FALLBACK; preserves continuation retries (isContinuation === true); regression test covers fresh retry, model fallback, and continuation
3 [robustness] >100k-char answers silently drop delivery Fixed scheduled-delivery-outbox.ts:256-265 normalizeDeliveryText() truncates with TRUNCATED_TEXT_SUFFIX marker; handles surrogate pairs at boundary
4 [minor] Malformed channel_delivery IPC silently dropped Addressed daemon-worker.ts now sends channel_delivery_invalid for permanent errors via classifyChannelDeliveryError(); expiry and queue-full also fail-fast
5 [minor] threadId validation inconsistent across layers Resolved threadId removed from delivery contract entirely; CronTaskChannelTarget is only {type, id}; parseDeliveryField trims and validates channelName/target.id consistently with isValidDelivery in cronTasksFile.ts

Findings 6-8 (chunk-level duplicate retry, deliveryId minute granularity, PR template) are minor/follow-up and not blocking.

Review Agents — Results

10 review agents were launched (3 whole-diff + 7 chunk territory agents), covering the key production source code and cross-file dependencies. The whole-diff agents read all 24 diff chunks.

Agent Scope Findings
Agent 0 (Issue Fidelity) Full diff + #7152 acceptance criteria 0 — All 14 acceptance criteria met or explicitly scoped out with maintainer acknowledgement
Agent 7 (Build & Test) Build + test + test-efficacy 0 — All 17 workspaces built; 12 test failures in packages/cli are all in files NOT touched by this PR (Chinese-locale git output, timing-sensitive UI assertions); test-efficacy: 0 unreachable, 0 inert, 7/19 test files actively gate the PR's behavior
Agent 1c (Cross-file tracer) Full diff + cross-file deps 0 — Every new type/field/method has matching producers and consumers; delivery pipeline fully wired
Chunk 12 daemon-worker.ts 0 — Follows established webhook-task patterns; tests cover async ordering, rejection, redaction, foreign error classification
Chunk 14 durable-loop-controller.ts, capabilities.ts 0scheduledTaskChannelDeliveryAvailable toggle is both set and read (not a dead switch)
Chunk 15 channel-delivery-ipc.ts 0 — Type guards thorough; test coverage comprehensive; no prototype pollution path
Chunk 18 scheduled-tasks.ts routes 0 — Validation consistent with core layer; DELETE guard correctly distinguishes owned vs. shared sessions
Chunk 19 run-qwen-serve.ts, scheduled-delivery-dispatcher.ts 0 — Dynamic import correct; shutdown sequence proper; retry math verified
Chunk 21 cronScheduler.ts, cronTasksFile.ts 0 — Race-condition fix verified; validation matches all test cases; new fields consistently populated
Chunk 23 scheduled-delivery-outbox.ts 0 — Proper locking (in-process mutex + cross-process file lock); symlink protection; 0o700/0o600 permissions

CI Status

CI is green (16 checks: 7 passed, 9 skipped). Skipped checks include Integration Tests (CLI, No Sandbox) and macOS/Windows test legs — Agent 7's local build and test covered the affected packages, all passing except 12 pre-existing failures in untouched files.

Security Assessment

  • Admission is fail-closed and workspace-exact with a 7-day freshness window
  • DM routing uses observed conversation chatId, never user id substitution
  • Credentials redacted at worker diagnostic boundary and before persisting outbox errors
  • channel_delivery IPC stays internal (no webhook, no model tool)
  • Outbox directory 0o700, files 0o600; symlink protection via lstat + noFollow: true
  • No prototype pollution path through parseDeliveryField (Object.keys().every() whitelist)

Verdict

No Critical or Suggestion findings. All existing blockers are fixed. All review agents reported zero findings. Build succeeds, all PR-touched tests pass. The PR is mergeable.

— qwen via Qwen Code /review

BZ-D
BZ-D previously approved these changes Jul 20, 2026

@BZ-D BZ-D left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed at e21b8b97d1e41f0608e3cae5240041b897928977.

No new Critical or Suggestion finding was confirmed on the current head. I re-checked the previously reported blockers: the serve fast-path import is dynamic, fresh retry/model fallback resets the captured delivery answer while continuation retries preserve it, and oversized answers are bounded before outbox validation/idempotency comparison.

Maintainer escalation is still required by this repository's core-infrastructure gate. This is an external feat spanning 47 files and about 3,053 production diff lines across core services and multiple packages. It is not size-hard-blocked because it is not a refactor, but it exceeds the gate's bounded small-scope/100%-confidence path and the 1,000+ production-line advisory threshold.

The downstream chain I verified is: scheduled-task REST create/update/read/delete -> durable task validation/persistence -> cron scheduling/session ownership -> terminal-answer capture -> workspace-private outbox -> daemon dispatcher -> exact-workspace worker manager/group/supervisor -> delivery IPC -> daemon worker -> ChannelBase.deliverProactive() -> DingTalk/Feishu/Telegram/WeCom adapter behavior. No cross-workspace fallback was found.

Validation evidence:

  • GitHub presubmit: all current checks passing; 44 checks inspected (platform/integration and automation legs are skipped by workflow routing).
  • Focused local tests after building the PR's channel-base dependency: core 198; CLI scheduled-delivery/session/route/worker paths 697; ChannelBase 495; DingTalk 72; Feishu 76; Telegram 15; WeCom 135 — all passing.
  • The review harness's widened build stopped in unchanged packages/audio-capture on a local Node 24/node-gyp ENOENT; core and acp-bridge built successfully before that. This is not attributed to the PR, and the repository's Node 22 CI build/test check is green.

Verdict: COMMENT / maintainer review required; no confirmed blocker from this pass.

@qwen-code-dev-bot qwen-code-dev-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM!

auto-merge was automatically disabled July 20, 2026 11:41

Head branch was pushed to by a user without write access

@BenGuanRan
BenGuanRan dismissed stale reviews from qwen-code-dev-bot and BZ-D via f7829f1 July 20, 2026 11:41
@BenGuanRan
BenGuanRan force-pushed the agent/scheduled-channel-delivery-design branch from e21b8b9 to f7829f1 Compare July 20, 2026 11:41
@BenGuanRan
BenGuanRan marked this pull request as draft July 20, 2026 13:54
@wenshao

wenshao commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Review

Overview

Adds optional daemon-owned delivery of a scheduled task's final answer to one explicit {type: 'user'|'chat', id} Channel target: typed contract on the durable task file and REST routes, a workspace-private durable outbox (lock + lease + retry), a daemon dispatcher polling per workspace, IPC to the exact workspace's Channel worker, a ChannelBase.deliverProactive boundary with a typed permanent/transient delivery error, and adapter mappings. Daemon-backed Channel /loop is converted to persist the same contract via a new durable loop controller with shared session ownership. Two ride-alongs: a scheduler reload race fix for bound one-shots, and pidfile removal on the double-signal force-exit path.

I read the full diff and cross-checked the load-bearing details against the PR head: the #handleStopHookLoop interrupt plumbing has exactly one clean (unmarked) end_turn return, so delivery only fires on genuine completion; DingTalk already refreshes a stale token once on 401 before the permanent classification applies; the deliveryChatId ?? senderId fallback is sound per adapter (DingTalk's envelope senderId is already the staff ID, Feishu p2p sets open_id, Telegram private chat ID equals the user ID); and the fresh lastFiredAt stamp on aged final fires prevents taskId:firedAt delivery-identity collisions. The cron gating in the daemon worker matches the existing start.ts pattern (QWEN_CODE_DISABLE_CRON + experimental.cron).

Strengths

  • Validation discipline. Every boundary (task file, REST parse, IPC guard, outbox record, ChannelBase) validates the exact shape and rejects unknown/obsolete keys (threadId, topicId, chatId, isGroup), with table-driven negative tests at each layer.
  • Deterministic routing. The destination never enters the Agent prompt, and exact-workspace routing has explicit negative tests (no cross-workspace fallback, draining rejection).
  • Outbox hardening. Owner-only dir/file modes, symlink refusal via lstat, permission healing under the lock (including the no-op claim path), credential redaction in persisted errors, bounded field lengths, surrogate-safe truncation with idempotent re-enqueue, and lease-based claim recovery.
  • Error taxonomy. ChannelProactiveDeliveryError is recognized structurally across separately installed packages instead of by instanceof, and API detail is kept in local logs but out of the propagated error.
  • Test quality. Retry/model-fallback/continuation stream semantics for the captured answer, Todo Stop Guard draft-vs-terminal cases, IPC lifecycle (timeout, worker exit, queue-full, shutdown drain), and a real DingTalk E2E for both target types.

Findings

  1. Duplicate sends are possible; the worker already has what it needs to dedup (medium). The 30 s supervisor IPC timeout can fire while the adapter send is still in flight; the late channel_delivery_result finds no pending entry and is dropped, the record is marked retryable, and the retry re-sends the same message to the chat. A daemon crash between the platform ack and completeScheduledDelivery has the same effect. This is inherent to at-least-once, but the IPC request already carries deliveryId — a small recently-delivered LRU in the worker would make redelivery a no-op. At minimum, the design doc should state that delivery is at-least-once and duplicates are possible.
  2. Delivery enqueue exists only in the ACP Session path (low-medium). An unbound durable task carrying delivery that gets fired by a non-daemon lock owner (a TUI session in the same workspace) runs the prompt but never enqueues, so delivery is silently skipped. Bound tasks avoid this because they only fire in their bound daemon session. Worth either a doc note or a defensive log when a fired job has delivery but the runtime has no enqueue path.
  3. Idle dispatcher I/O (low). claimScheduledDelivery runs the full mkdir+chmod+guard-append+proper-lockfile acquire/release+lstat+read cycle once per second per workspace even when no outbox file exists. A cheap fs.access(outboxPath) short-circuit before the lock dance (falling through to the full path only when the file exists) would cut steady-state daemon I/O to one stat per poll.
  4. classifyChannelDeliveryError substring matching is dead on the real path (low). All three "does not own / does not support" cases throw ChannelProactiveDeliveryError('permanent', …), which the first branch already classifies. The message-text matching only matters for version-skewed or non-conforming channel packages — if that is the intent, a comment saying so would keep someone from tightening the wording in ChannelBase and silently breaking the fallback; otherwise it can be dropped.
  5. Uneven adapter error classification, and WeCom's mapping is unverified on the platform (low). Only DingTalk and Feishu classify dispositions; Telegram and WeCom throw plain errors, so permanent failures (bot blocked, invalid userid) burn all 5 retry attempts before failing. Bounded, so not a correctness issue. Separately, WeCom routes both user and chat through the same client.sendMessage(id, …) — the tests only assert pass-through, and the E2E covered DingTalk only, so whether the SDK accepts a userid where a chatid goes is unconfirmed. Worth a follow-up verification before advertising WeCom user delivery.
  6. ChannelWorkerSupervisor.deliverChannelMessage? optionality (nit). The real supervisor always implements it; the optional marker exists for test fakes and forces the deliver === undefined unavailable-branch in the group. Making it required would simplify both.

Scope notes

  • The scheduler reload race fix (keep a loaded bound one-shot live during its due minute) is a standalone behavior change to the confirm-first missed-task gate. The bypass is correctly narrow — it requires this session to have already loaded its own bound task and to be within 60 s of the slot — and it has a dedicated regression test, but it would have been cleaner as its own PR.
  • Same for the force-exit removeCurrentServePidfile() addition.
  • The sessionBinding view field and shared session ownership (delete no longer closes IM conversation sessions) are a sizable sub-feature riding along, though they are genuinely required for the daemon /loop conversion.

Verdict

Well-engineered and thoroughly tested; the security posture (no prompt-driven routing, workspace isolation, hardened outbox, redaction) is convincingly enforced in both code and tests. Nothing blocking — finding 1 (duplicate-send window / worker-side dedup) is the one I'd most like addressed or explicitly documented before merge; the rest are follow-up material.

@BenGuanRan

Copy link
Copy Markdown
Collaborator Author

Superseded by #7387 and the new implementation in #7388. The replacement broadens the contract from scheduled-only durable delivery to one immediate Channel delivery boundary shared by synchronous notifications, Prompt finals, and scheduled finals, while preserving the existing webhook 202 path. Closing this draft to keep review on the smaller current design.

@BenGuanRan BenGuanRan closed this Jul 21, 2026
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.

6 participants