Repository navigation
Conversation
doudouOUC
left a comment
There was a problem hiding this comment.
The overall direction is sound: keep the public REST idle rule, reuse the PR 9361 caller-owned sessionId contract, and add a daemon-only ACP path for cron_create because that tool necessarily runs in a busy turn.
Please tighten three design semantics before implementation. As written they can be implemented as the opposite of the intended behavior, especially the tool default vs REST “dedicated” meaning, and the standalone session filter vs ordinary Web Shell conversations.
Inline notes are on the specific paragraphs.
|
Addressed the design review in
The PR description was updated to match the corrected defaults and trust model. Verification: Prettier check and |
doudouOUC
left a comment
There was a problem hiding this comment.
Re-reviewed 0627a51 against the previous design notes.
The five earlier issues are addressed: distinct form-dedicated vs tool-unbound vs current outcomes, an exact ordinary-session allow-list, a correctly scoped ACP trust/prompt binding, selected-session semantics on the tasks page, and process-wide capability/callback wiring.
Remaining: identifier drift that will leak into the implementation PR if copied literally. See the inline note.
|
Reviewed the new identifier-drift suggestion on |
0627a51 to
5c65b19
Compare
|
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)为单个提交。 |
|
Implementation is now available in Verification after rebasing onto
Manual wall-clock session-switch and daemon-restart E2E has not been run yet; the PR keeps the existing #9361 keepalive/rehydration and caller-owned deletion paths unchanged, and those lifecycle consumers were audited directly. Known inherited base issue: after the latest rebase, full |
Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
5c65b19 to
4693c7a
Compare
|
Rebased onto
CI is rerunning on the new head. The remaining validation boundary is the manual wall-clock session-switch plus daemon-restart E2E already called out in the PR. |
Co-authored-by: Qwen-Coder <[email protected]>
|
CI fix pushed in 1180fcd.
Handled review threads in this push: 0. Existing review threads remain 6/6 resolved. Fresh CI was triggered by the push. |
Co-authored-by: Qwen-Coder <[email protected]>
|
CI fix pushed in
Handled review threads in this push: 0. Existing review threads remain 6/6 resolved. Fresh CI was triggered by the push. |
yiliang114
left a comment
There was a problem hiding this comment.
Review at head 1180fcd — the security surface checks out, but there is one CI-breaking regression to fix first, so not approving yet.
Verified solid: the authorization chain for current-session binding — the ACP Session stamps its own sessionId server-side (not caller-supplied) plus the executing prompt id; the bridge rejects forged session/prompt identity and ineligible sources (tests pin forged-identity and ineligible-source rejections); the daemon route rejects cross-workspace (session_workspace_mismatch), ambiguous ownership (fail-closed), busy/pending/parented/sourced/already-bound sessions; allowActivePrompt is true only for the prompt-matched cron-tool source while the public REST path keeps the idle rule.
Blocking: the incomplete optional chain at App.tsx:12528 (workspace.capabilities?.features.includes(...)) — see the inline comment.
Not approving until the CI break is fixed; everything else in the PR reads as designed.
Co-authored-by: Qwen-Coder <[email protected]>
|
CI fix pushed in
Handled review threads in this push: 0. Existing review threads remain 7/7 resolved. Fresh CI was triggered by the push. |
wenshao
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: cross-file tracer (agent 1c) — the original run and its single allowed relaunch both failed to open the diff.
Not reviewed: reverse audit round 2 chunk 2 — the original auditor and its single allowed relaunch both failed.
Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.
Not reviewed: "agent reverse-audit (round 2)" — the agent made no tool call: it read nothing.
Not reviewed: "agent 1c" — pointed at diff lines it never opened: it made tool calls, but none of them read the diff.
中文说明
仅完成部分审查,审查缺口已披露。
未审查:cross-file tracer (agent 1c) — the original run and its single allowed relaunch both failed to open the diff。
未审查:reverse audit round 2 chunk 2 — the original auditor and its single allowed relaunch both failed。
未审查:反向审计——在 5 轮的反审轮数上限内未收敛。
未审查:"agent reverse-audit (round 2)"——该 agent 未发起任何工具调用:它什么都没读。
未审查:"agent 1c"——启动 prompt 为它指定了 diff 中的行,但它从未打开:有工具调用,却没有一次读取 diff。
— qwen-code via Qwen Code /review (v0.22.0)
Co-authored-by: Qwen-Coder <[email protected]>
|
Review feedback handled in
Verification: full Web Shell App test 541/541; scheduled-task routes 105/105; focused ACP bridge, bootstrap, and dialog suites passed; repository build, typecheck, lint, Prettier/pre-commit formatting, |
|
CI failure fixed in The Ubuntu no-AK capabilities assertion could observe either the bootstrap or mounted-runtime envelope. Verification: targeted capabilities test passed; full qwen-serve-routes integration file 36/36 passed; startup boundary tests 2/2 passed; repository build, typecheck, lint, Prettier/pre-commit formatting, |
Local real-daemon verification of #9838 — one blocker foundI built a real verification environment and ran this PR end-to-end against a live Verdict: needs one more fix before merge. The Web Shell entrypoint works end-to-end exactly as described. The Environment
Blocker —
|
| build | result |
|---|---|
pristine 8ca008d370 |
TOOL failed — Error creating cron job: [object Object], 0 tasks persisted (4/4 runs) |
| + the one-line change | TOOL completed — "Scheduled recurring job xppx55dx … and bound to the current conversation", persisted sessionId = caller conversation, sessionOwnedByTask: false |
| pristine restored | fails again |
Please also add a regression test that pins the two namespaces together, rather than asserting a literal on each side independently — that is the exact seam this defect lives in.
Important — every failure of this path surfaces as [object Object]
Session.ts rethrows the raw JSON-RPC error object for any code other than -32601, and cron-create.ts:139 does error instanceof Error ? error.message : String(error). A plain {code, message} object is not an Error, so String(error) yields [object Object], which is what reaches both the user and the model:
Error creating cron job: [object Object]
Every guard the PR adds (forged session, prompt mismatch, ineligible source, busy caller, already bound) is invisible today. Wrapping the rejection with its message before rethrowing would make all of them actionable — and it is what makes the blocker above hard to diagnose in the field.
Minor — the "Current conversation" option can go stale after leaving and re-entering Scheduled Tasks
Repro (3/3): open an idle conversation → open Scheduled Tasks → New scheduled task → close both → another client starts a turn in that same conversation → reopen Scheduled Tasks → New scheduled task. The daemon reports hasActivePrompt: true and the sidebar shows the running spinner, but the selector still offers Current conversation with the "Runs are kept in a separate conversation…" hint.
This is UI-only and fails closed — I followed it through and the server refused:
POST /scheduled-tasks → 409
{"error":"The requested session is busy; wait for its active prompt or pending
interaction to finish before binding it to a task","code":"session_busy"}
Nothing was persisted. Worth noting that the guard is correct on both paths I would expect users to hit: a fresh page load while the conversation is busy, and a turn started from the Web Shell composer itself — both disable the option with "Wait for the current turn or pending interaction to finish."
What passed
The Web Shell entrypoint and the shared daemon creator are solid. Everything below was observed live, not asserted in a mock.
Web Shell form — the selector renders, defaults to Dedicated, and only sends sessionId after an explicit choice. Captured request bodies:
{"cron":"0 9 * * *","prompt":"PING_UI_DEDICATED","name":"ui-dedicated","recurring":true,"enabled":true}
{"cron":"0 9 * * *","prompt":"PING_UI_CURRENT","name":"ui-current","recurring":true,"enabled":true,
"sessionId":"a1a30584-8319-416c-a3e3-899d6ac39e42"}| # | Check | Result |
|---|---|---|
| 1 | Capability advertised on a daemon-created bridge | ✅ scheduled_task_session_reuse present |
| 2 | Bootstrap envelope omits it until the runtime mounts (round-1 Critical) | ✅ 3097 samples: absent for the first 1166 ms, then present — fixed |
| 3 | Default form arm omits sessionId; dedicated conversation minted |
✅ persisted without sessionOwnedByTask |
| 4 | Explicit arm sends the open conversation's id | ✅ persisted sessionOwnedByTask: false |
| 5 | Wall-clock firing lands in the bound conversation | ✅ 20 runs, every runs[].sessionId = bound conversation |
| 6 | User switched to another conversation meanwhile | ✅ scheduled turns still landed in the bound one; the bound conversation gained one assistant turn per run |
| 7 | Daemon restart → rehydration | ✅ binding unchanged, fired again in the same conversation after restart |
| 8 | Delete a caller-owned task | ✅ conversation survives (200) and still accepts a new turn |
| 9 | Delete a task-owned task (contrast arm) | ✅ dedicated conversation torn down (404) |
| 10 | Bind to an unknown session id | ✅ 404 session_not_found |
| 11 | Bind to a busy session | ✅ 409 session_busy |
| 12 | Bind a session already bound to a task | ✅ 409 session_already_bound |
| 13 | Bind a scheduled_task-owned session |
✅ 409 session_already_bound |
| 14 | Bind a sourced session (sourceType: 'channel') |
✅ 409 session_source_ineligible |
| 15 | sessionMode:'current' without durable:true |
✅ refused: "requires durable: true because session-only jobs cannot survive a daemon session switch" |
| 16 | Selector when no conversation is open | ✅ disabled — "Open an existing conversation before selecting the current conversation." |
| 17 | Selector when the conversation is already bound | ✅ disabled — "The current conversation is already bound to a scheduled task." |
Not covered by this run
Cross-workspace mismatch on a multi-workspace daemon; PATCH rebinding; the #9415 teardown-versus-reuse race; secondary/dynamically-attached workspace runtimes (only the primary runtime was exercised); the injected-bridge negative for capability advertising (not reachable from the CLI); Windows/macOS.
中文说明
#9838 本地真实 daemon 验证 —— 发现一个阻塞问题
我在本地搭建了真实验证环境,针对运行中的 qwen serve daemon 端到端跑通了这个 PR(真实 HTTP、真实 ACP 子进程、真实时钟触发、Chromium 中的真实 Web Shell、真实 daemon 重启)。验证 head 为 8ca008d370bd52acbbe40872076b548868ebcf7c。
结论:合入前还需要一处修复。 Web Shell 入口完全按描述工作;但 cron_create 入口在真实 daemon 上从不成功——Reviewer 测试计划第 3 步 4/4 失败——原因是工具发送的 prompt id 与 bridge 校验的 prompt id 来自两个不同的 id 命名空间。一行改动即可修复,下面附 A/B 证明。
环境
| Worktree | /root/git/pr9838,位于 8ca008d370,全新 npm ci + build,未修改源码 |
| Daemon | node packages/cli/dist/index.js serve --port 4371 --workspace <ws> —— 真实 daemon、真实 ACP 子进程 |
| 模型 | 本地 OpenAI 兼容 mock(marker 决定返回 tool-call / 文本 / 延迟回复),turn 真实但完全隔离 |
| UI | daemon 自带的 Web Shell 构建,headless Chromium(Playwright)驱动,deviceScaleFactor: 2 |
| 时钟 | 真实:任务使用 * * * * *,观察约 30 分钟内的持续触发,并跨越一次 daemon 重启 |
阻塞问题 —— cron_create sessionMode:'current' 在真实 daemon 上必定失败
在未修改的构建上 4/4 复现:工具调用以 failed 结束,没有任务被持久化,用户看到的是 Error creating cron job: [object Object]。
根因 —— 两个不同的 prompt id 命名空间。 我对构建产物 bridgeClient.js 加了临时探针,dump 出比较两侧的实际值(随后已还原,并在原始构建上确认同样失败):
{
"wire_promptId": "247614dd-...########1",
"entry_activePromptId": "8690bd83-e2bc-4796-8691-07457245490b",
"entry_promptActive": true,
"entry_sessionId": "247614dd-...",
"callerSessionId": "247614dd-..."
}连接归属校验通过、promptActive 为 true、来源字段均合格;唯一不满足的是 entry.activePromptId !== promptId,而这个条件永远不可能成立:
packages/core/src/tools/cron-create.ts:102发送promptIdContext.getStore();- 该值在
packages/cli/src/acp-integration/session/Session.ts:4499被设为`${sessionId}########${turn}`,即 core 的 turn 计数; packages/acp-bridge/src/bridge.ts:8812把entry.activePromptId设为pendingEntry.promptId,即POST /session/:id/prompt返回的 daemon 生成的 UUID。
两者永远不会相等,因此 bridgeClient.ts:1917 的守卫在任何配置下都会返回 -32602。
为什么 CI 是绿的。 两侧都只是各自对一个假定的字面量做断言,从未互相校验:Session.test.ts 用 promptId: 'prompt-1' 调用 creator,cron-create.test.ts 用 promptIdContext.run('prompt-1', …) 包裹,bridgeClient.test.ts 则把 entry.activePromptId 设成测试自己传入的值。我在本地跑了这两个套件:cron-create.test.ts 16/16、bridgeClient.test.ts 125/125 全部通过,而真实链路失败。
候选修复(已验证)。 daemon 其实已经把自己的 prompt id 传给了子进程:bridge.ts:8459 用同一个 promptId 构造 InvocationContextV1 { sessionId, promptId }(之后正是它被存为 activePromptId),通过 _meta[INVOCATION_CONTEXT_META_KEY] 下发,Session.ts:4461 用 runWithInvocationContext 在整个 turn 内绑定。因此在 #registerCurrentSessionScheduledTaskCreator 中:
- promptId: req.promptId,
+ promptId: getInvocationContext()?.promptId ?? req.promptId,在运行中的 daemon 上做 A/B,场景相同、其他不变:
| 构建 | 结果 |
|---|---|
原始 8ca008d370 |
TOOL failed —— Error creating cron job: [object Object],0 个任务持久化(4/4) |
| + 上述一行改动 | TOOL completed —— “Scheduled recurring job xppx55dx … and bound to the current conversation”,持久化记录 sessionId = 调用方会话、sessionOwnedByTask: false |
| 还原为原始构建 | 再次失败 |
同时建议补一条把两个命名空间钉在一起的回归测试,而不是两侧各自断言字面量——这正是缺陷所在的接缝。
Important —— 该路径的所有失败都显示为 [object Object]
Session.ts 对 -32601 之外的错误直接 rethrow 原始 JSON-RPC 错误对象,而 cron-create.ts:139 使用 error instanceof Error ? error.message : String(error)。普通的 {code, message} 对象不是 Error,String(error) 得到 [object Object],并原样呈现给用户和模型。这个 PR 新增的所有守卫(伪造会话、prompt 不匹配、来源不合格、调用方忙、已绑定)当前全部不可见。在 rethrow 前包一层带 message 的错误即可让它们变得可诊断——也正是这一点让上面那个阻塞问题在现场极难定位。
Minor —— 离开并重新进入 Scheduled Tasks 后,“当前会话”选项可能变陈旧
复现(3/3):打开一个空闲会话 → 打开 Scheduled Tasks → 新建任务 → 关闭两者 → 另一个客户端在同一会话发起 turn → 重新打开 Scheduled Tasks → 新建任务。此时 daemon 报告 hasActivePrompt: true、侧边栏也显示运行中的转圈,但选择器仍然提供“当前会话”,提示文案仍是“任务运行记录保存在单独创建的会话中”。
这只是 UI 层问题,并且是 fail-closed 的:我实际提交后服务端拒绝了(409 session_busy),没有任何数据被写入。另外,用户真正会走的两条路径上守卫都是正确的:会话处于忙碌状态时全新加载页面、以及在 Web Shell 输入框里自己发起 turn——两种情况都会正确禁用该选项并提示“请等待当前执行或待处理交互结束”。
通过的验证
Web Shell 入口与共享的 daemon creator 都很扎实。下列全部为真实观测结果,而非 mock 断言。
Web Shell 表单 —— 选择器正常渲染、默认“独立任务会话”,只有显式选择后才发送 sessionId。实际抓到的请求体:
{"cron":"0 9 * * *","prompt":"PING_UI_DEDICATED","name":"ui-dedicated","recurring":true,"enabled":true}
{"cron":"0 9 * * *","prompt":"PING_UI_CURRENT","name":"ui-current","recurring":true,"enabled":true,
"sessionId":"a1a30584-8319-416c-a3e3-899d6ac39e42"}| # | 检查项 | 结果 |
|---|---|---|
| 1 | daemon 自建 bridge 上广告该 capability | ✅ 存在 scheduled_task_session_reuse |
| 2 | runtime 挂载前 bootstrap 不广告(round-1 Critical) | ✅ 3097 次采样:前 1166 ms 缺失,之后出现——已修复 |
| 3 | 默认分支不带 sessionId,创建独立会话 |
✅ 持久化记录不含 sessionOwnedByTask |
| 4 | 显式分支发送当前会话 id | ✅ 持久化 sessionOwnedByTask: false |
| 5 | 真实时钟触发落在绑定会话 | ✅ 20 次运行,每条 runs[].sessionId 都是绑定会话 |
| 6 | 期间用户切换到另一个会话 | ✅ 定时执行仍落在绑定会话;transcript 每次运行恰好增加一条回复 |
| 7 | daemon 重启 → 重新加载 | ✅ 绑定不变,重启后继续在同一会话触发 |
| 8 | 删除调用方所有的任务 | ✅ 会话仍存在(200)且可继续对话 |
| 9 | 删除任务所有的任务(对照组) | ✅ 独立会话被销毁(404) |
| 10 | 绑定未知 session id | ✅ 404 session_not_found |
| 11 | 绑定忙碌会话 | ✅ 409 session_busy |
| 12 | 绑定已被任务绑定的会话 | ✅ 409 session_already_bound |
| 13 | 绑定 scheduled_task 来源会话 |
✅ 409 session_already_bound |
| 14 | 绑定有 source 的会话(sourceType: 'channel') |
✅ 409 session_source_ineligible |
| 15 | sessionMode:'current' 未带 durable:true |
✅ 拒绝:“requires durable: true …” |
| 16 | 未打开任何会话时的选择器 | ✅ 禁用——“请先打开一个已有会话。” |
| 17 | 当前会话已被绑定时的选择器 | ✅ 禁用——“当前会话已绑定其他定时任务。” |
本次未覆盖
多 workspace daemon 上的跨 workspace 校验;PATCH 改绑;#9415 的 teardown-versus-reuse 竞态;secondary / 动态挂载的 workspace runtime(本次只走了 primary);capability 广告的 injected-bridge 反例(CLI 无法构造);Windows / macOS。
Co-authored-by: Qwen-Coder <[email protected]>
|
Review feedback addressed in Thanks @wenshao for the real-daemon A/B verification. I independently reproduced the identity mismatch and applied the narrow adapter fix.
Verification: Session tests 694/694 passed; cron-create tests 17/17 passed; the busy-session route witness passed with The feedback was top-level rather than an inline review thread, so there were no GitHub threads to resolve (0/0). |
Co-authored-by: Qwen-Coder <[email protected]>
|
Addressed the latest review on commit
Verification: BridgeClient 126/126, Session 695/695, CronCreate 17/17, scheduled-task focused route test passed, build passed, typecheck passed, lint passed, and bundle passed. The no-AK integration run passed all feature-relevant files; the unrelated SDK tool-control file was affected by local global managed-memory request ordering and passed 30/30 with an isolated memory root. |
|
@qwen-code /triage |
|
@qwen-code /triage |
|
@qwen-code /triage |
Round-2 local real-daemon verification of #9838 — blocker fixed, one diagnosability gap leftI rebuilt the verification environment against the current head Verdict: the round-1 blocker is genuinely fixed — LGTM to merge. Environment
1. The blocker is fixed — real two-build A/B
The identity check was not weakened to get there. I patched the built child to send a forged 2. Lifecycle of a tool-created binding — 9/9This is coverage that was impossible before this head, because the tool entrypoint never produced a binding. Real clock,
3. Runtime wiring — all three install sites bind for real (6/6)Reviewer test-plan step 5 was the largest untested claim; I drove it on a two-workspace daemon and then attached a third at runtime through
4. Web Shell entrypoint — no regression
5. Local fail-closed rules
Follow-up (non-blocking) — half of the rejections still say only
|
| Mutation | Suite | Killed |
|---|---|---|
getInvocationContext()?.promptId ?? req.promptId → req.promptId |
Session | ✅ |
Drop the errorKind mapping in Session.ts |
Session | ✅ |
getErrorMessage(error) → the old ternary |
cron-create | ✅ |
Drop .catch(preserveScheduledTaskCreateErrorOverAcp) |
bridgeClient | ✅ |
Drop the ?? req.promptId fallback |
Session | undefined, which the bridge rejects anyway) |
err.status >= 500 ? -32603 : -32602 → always -32602 |
bridgeClient | Session.ts only branches on -32601) |
Suites at this head, run locally: bridgeClient 126/126, cron-create 17/17, scheduled-task routes 105/105, ScheduledTasksDialog 53/53, Session (targeted) 2/2.
Not covered by this run
The pending-interaction guard on the private path could not be exercised live — the engine serializes approvals, so cron_create never executes while another interaction of the same turn is pending (I drove a parallel write_file + cron_create batch and the tool simply never ran); it stays defense-in-depth. Also uncovered: the injected-bridge capability negative (not reachable from the CLI), PATCH rebinding, multiple tasks per session, the #9415 teardown-versus-reuse race, Windows/macOS.
中文说明
#9838 第二轮本地真实 daemon 验证 —— 阻塞问题已修复,遗留一个可诊断性问题
我针对当前 head cf63c6e82e1144d17252e0d3a606fd99f65d87a1 重新搭建验证环境,并在运行中的 qwen serve daemon 上完整重跑了端到端流程(真实 HTTP、真实 ACP 子进程、真实时钟触发、Chromium 中的真实 Web Shell、真实 daemon 重启、真实多 workspace daemon)。
结论:第一轮的阻塞问题确实已修复,同意合入(LGTM)。 cron_create(sessionMode: 'current') 现在在真实 daemon 上可以成功(4/4),工具创建的绑定其完整生命周期正常(9/9),三个 runtime 安装点(primary、启动时 secondary、动态挂载)都能真实绑定(6/6)。遗留一个不阻塞的后续项:一半的拒绝路径仍然只向用户显示 Invalid params,而这正是当初掩盖第一轮阻塞问题的同一处接缝。
环境
| 工作树 | /root/git/pr9838,head cf63c6e82e,完整 npm run build,场景执行期间未修改源码 |
| Daemon | node packages/cli/dist/index.js serve --port 4371 --workspace <ws> [--workspace <ws2>] [--token …] |
| 模型 | 本地 OpenAI 兼容 mock(marker 驱动:工具调用 / 文本 / 延迟回复 / 两个并行工具调用) |
| UI | daemon 自带的 Web Shell 构建,headless Chromium(Playwright),deviceScaleFactor: 2 |
| 抓包 | QWEN_CLI_ENTRY stdio tap,记录 daemon 与 ACP 子进程之间的每一帧 JSON-RPC |
| A/B | 把第一轮验证过的 head 8ca008d370 作为第二份真实 dist 保留并换入 |
1. 阻塞问题已修复 —— 真实的双构建 A/B
| 分支 | cron_create 在线上发送的 promptId |
结果 |
|---|---|---|
8ca008d370(第一轮 head,换回其 dist) |
d74fb300-…-3235373a659e########1 —— core 的轮次 id |
TOOL failed,Error creating cron job: [object Object],0 条持久化(0/2) |
cf63c6e82e(当前 head) |
e7d91d82-6a34-47a4-9ec5-dc4e943b8486 —— daemon prompt id,与 session/prompt 上的 _meta["qwen-code/invocation"].promptId 完全一致 |
TOOL completed,持久化 sessionId = 调用方会话,sessionOwnedByTask: false(4/4) |
修复并没有削弱身份校验。 我把编译后的子进程改成发送伪造的 promptId,其余保持 head 原样:daemon 拒绝了请求,也没有任何持久化。适配层改的是「发哪个 id」,不是「是否校验」。
2. 工具创建绑定的完整生命周期 —— 9/9
这是本 head 之前无法覆盖的部分,因为工具入口此前根本产生不了绑定。真实时钟、* * * * *,跨重启观察约 7 分钟。
| # | 检查项 | 结果 |
|---|---|---|
| L1 | 工具绑定调用方会话 | ✅ sessionOwnedByTask: false,sessionId = 调用方 |
| L2/L3 | 用户切到另一个会话后,定时执行仍落在绑定会话 | ✅ 2/2 次运行,runs[].sessionId 全部等于调用方 |
| L4 | 绑定会话中确实有定时轮次 | ✅ transcript 中出现 3 次定时 prompt |
| L5 | 用户切换过去的会话未受影响 | ✅ 其中没有定时 prompt |
| L6 | 绑定在 daemon 重启后保留 | ✅ 重新加载,sessionId 不变 |
| L7 | 重启后再次触发仍落在同一会话 | ✅ 运行次数 2 → 3,最后一次 = 调用方 |
| L8 | 重启后调用方会话仍存活 | ✅ 200 |
| L9 | 删除任务后调用方会话仍可用 | ✅ 200 且能完成新一轮对话 |
3. Runtime 接线 —— 三个安装点都真实绑定(6/6)
Reviewer 测试计划第 5 步是最大的未验证声明;我在双 workspace daemon 上驱动,并通过 POST /workspaces 在运行时挂载了第三个。
| # | 检查项 | 结果 |
|---|---|---|
| W0 | 多 workspace daemon 广告 capability | ✅ scheduled_task_session_reuse |
| W1 | primary workspace runtime 可绑定 | ✅ caller-owned |
| W2 | 启动时 secondary workspace runtime 可绑定 | ✅ caller-owned |
| W3 | 动态挂载的 workspace runtime 可绑定(运行时通过 POST /workspaces 挂载,201) |
✅ caller-owned |
| W4 | 跨 workspace 的 REST 绑定被拒绝 | ✅ 400 session_workspace_mismatch |
| W5 | 每个绑定只写入各自 workspace 的 cron 文件 | ✅ ws/ws2/ws3 相互隔离 |
4. Web Shell 入口 —— 无回归
| # | 检查项 | 结果 |
|---|---|---|
| U1 | 广告 capability | ✅ |
| U2 | 没有打开会话时选项禁用 | ✅ "Open an existing conversation before selecting the current conversation." |
| U3 | 默认「独立任务会话」 | ✅ |
| U4 | 空闲会话下可选「当前会话」 | ✅ "Future runs continue in the conversation that is open now." |
| U5 | 已绑定会话时选项禁用 | ✅ "The current conversation is already bound to a scheduled task." |
| U6 | 默认分支不发送 sessionId |
✅ {"cron":"0 9 * * *","prompt":"PING_UI_DEDICATED",…} |
| U7 | 显式分支发送打开会话的 id | ✅ …,"sessionId":"7adeb182-14a3-4f67-b99b-748de845c260" |
| U8 | 用删除行为验证所有权语义 | ✅ 调用方会话存活(200,仍可用);独立任务会话被销毁(404) |
5. 本地 fail-closed 规则
| 检查项 | 结果 |
|---|---|
sessionMode: 'current' 但没有 durable: true |
✅ "Current-session scheduling requires durable: true because session-only jobs cannot survive a daemon session switch." |
在 daemon 之外调用同一工具(qwen -p …) |
✅ "current_session_scheduling_unavailable: Current-session scheduling requires an active daemon prompt." —— 不会回退为未绑定任务 |
| 调用方已绑定任务 | ✅ "session_already_bound: The requested session is already bound to another scheduled task",无持久化 |
后续项(不阻塞)—— 一半的拒绝仍然只显示 Invalid params
[object Object] 的修复对 daemon route 的领域错误 是有效的:它们现在携带 data.errorKind/status/hint,session_already_bound 能以完整句子送达模型。但 bridge 自身的守卫 —— 也就是身份或来源不符时触发的那些 —— 送到用户手里时消息仍然被抹掉。
真实复现,无需插桩,且是用户真的会遇到的场景:一个定时任务的运行请求把后续运行留在它当前所在的会话中。调用方是任务自有会话,来源守卫正确拒绝 —— 而用户和模型看到的全部内容就是:
Error creating cron job: Invalid params
同一守卫的抓包(为便于隔离帧,改用一个 scheduled_task 来源的会话驱动)显示 daemon 确实没有发送更多信息:
OUT {"jsonrpc":"2.0","id":0,"method":"qwen/control/scheduled-task/create-current",
"params":{"callerSessionId":"a23482c7-…","promptId":"e7d91d82-…","cron":"*/13 * * * *","prompt":"R4_SOURCED","recurring":true}}
IN {"jsonrpc":"2.0","id":0,"error":{"code":-32602,"message":"Invalid params"}}
根因。 withLogSafeAcpError(packages/acp-bridge/src/bridge.ts:531)会把每个向外的 RequestError 消息替换为 logSafeRequestErrorMessage(code),并丢弃所有不含 errorKind 的 data。preserveScheduledTaskCreateErrorOverAcp 正是为了绕过这层过滤而加的 —— 但只覆盖 ExistingSessionScheduledTaskCreateError。handleCreateCurrentSessionScheduledTask 中那 8 个 RequestError.invalidParams(undefined, …) 守卫都没有传 data,因此全部塌缩成同样的两个词:
`callerSessionId` must name a session owned by this connection`promptId` must be a non-empty string、`cron` …、`prompt` …、`recurring` …The caller session does not own the active prompt← 第一轮阻塞问题的确切失败点The caller session no longer owns the active promptThe caller session source cannot own a scheduled task
我也通过伪造 id 验证了 prompt 不匹配这条路径:同样是 Invalid params。也就是说,如果这个问题再次回归,现场信号并不会比第一轮更好。
建议修复(改动很小): 给这些守卫与领域错误相同的待遇,例如 RequestError.invalidParams({ errorKind: 'caller_prompt_mismatch', hint: '…' }, '…') —— logSafeRequestErrorData 本就会透传 errorKind/hint,Session.ts 也已经会渲染 errorKind: hint。
关于测试真实性: cron-create.test.ts 断言的是 { code: -32602, message: 'The caller session does not own the active prompt' }。这个消息在该场景下永远不会到达子进程 —— 线上值是 Invalid params。断言对 getErrorMessage 的验证是对的,但读起来像是守卫文案对用户可见,实际并不可见。
作为对比,REST / Web Shell 这一侧处理得很好 —— 同类拒绝的原文会原样显示在表单里。
关于被推迟的 UI 陈旧态问题:推迟是合理的
在当前 head 仍可复现(打开表单 → 退出 → 另一个客户端开始一轮对话 → 重新打开:选项仍然可选),并且是 fail-closed 且带有良好提示:POST /scheduled-tasks 返回 409 session_busy,表单直接渲染服务端的原文,没有任何持久化。忙碌状态下重新加载页面则正确禁用该选项。这一项不需要阻塞合入。
测试牙齿 —— 对三处修复做了 6 个变异
| 变异 | 套件 | 是否被杀死 |
|---|---|---|
getInvocationContext()?.promptId ?? req.promptId → req.promptId |
Session | ✅ |
删除 Session.ts 中的 errorKind 映射 |
Session | ✅ |
getErrorMessage(error) → 旧的三元表达式 |
cron-create | ✅ |
删除 .catch(preserveScheduledTaskCreateErrorOverAcp) |
bridgeClient | ✅ |
删除 ?? req.promptId 兜底 |
Session | undefined,bridge 同样会拒绝) |
err.status >= 500 ? -32603 : -32602 → 恒为 -32602 |
bridgeClient | Session.ts 只对 -32601 分支) |
本地在该 head 上跑的套件:bridgeClient 126/126、cron-create 17/17、scheduled-task routes 105/105、ScheduledTasksDialog 53/53、Session(定向)2/2。
本轮未覆盖
私有路径上的 pending interaction 守卫无法在真实环境触发 —— 引擎会串行处理审批,因此同一轮中另一个交互处于 pending 时 cron_create 根本不会执行(我驱动了 write_file + cron_create 的并行批次,工具压根没有运行);它仍属于纵深防御。另外未覆盖:注入 bridge 时不广告 capability 的反例(从 CLI 无法触达)、PATCH 改绑、单会话绑定多个任务、#9415 的 teardown-versus-reuse 竞态、Windows/macOS。
|
Thanks for the thorough round-2 verification. I independently checked the exact head and agree with the diagnosability finding: these bridge guards still call I am deferring this as a follow-up rather than widening #9838 again. The paths fail closed, the issue is explicitly non-blocking, and this PR has already gone through more than the repository threshold of roughly five review rounds where only Critical fixes should continue landing. The current lifecycle fix remains merge-ready; the structured error taxonomy and wire-realistic coverage should be handled separately. |
|
@qwen-code /triage |
chiga0
left a comment
There was a problem hiding this comment.
No blocking findings.
CI: Test (ubuntu-latest, Node 22.x) ✅ · Real daemon E2E ✅ · Serve A/B ✅ · web-shell E2E Smoke ✅ · Desktop Shell (ubuntu + windows) ✅ · Integration Tests (CLI, No Sandbox) Test (macos-latest / windows-latest, Node 22.x) qwen-serve-routes.test.ts:306 assertion lives outside every npm workspace so it is not exercised by CI.
Authorization chain verified:
- ACP Session stamps its own
sessionId+ executingpromptId(not caller-supplied). Bridge validatescallerSessionIdis owned by this connection ANDentry.activePromptId === promptId. Both hold at the initial check and at write-lock recheck viaassertCallerPromptActive— the closure captures the originalentryobject so object-identity change or prompt-end between calls is caught. assertReusableScheduledTaskSessionis called twice: once before the task object is built, and once inside theupdateCronTaskswrite-lock callback. Both calls invokeassertCallerPromptActive?.()first, serializing eligibility + prompt-active with the atomic write.- REST path keeps the idle rule (
allowActivePrompt = false);cron-toolpath allows an active prompt and rejectspendingInteractionCount > 0at both call sites. - Cross-workspace sessions rejected before session lookup;
ambiguousowner resolution fails closed with 500. preserveScheduledTaskCreateErrorOverAcpconvertsExistingSessionScheduledTaskCreateErrorto typedRequestErrorwitherrorKind— structured rejections surface to the tool caller.Retry-After: 1header is set on theworkspace_runtime_unavailablepath ✓- Optional chain fixed to
workspace.capabilities?.features?.includes(...)✓ - Bootstrap advertises
scheduled_task_session_reuse: false; capability appears only after the managed runtime mounts ✓
One minor finding (R1-1, inline): the source-eligibility check in handleCreateCurrentSessionScheduledTask reads parentSessionId/sourceType/sourceId from the in-memory BridgeClientSessionEntry. After a daemon restart, a reconnected session's bridge entry may not carry this lineage metadata. The fast-path check would pass for a parented sub-session whose entry fields are zero-valued on reconnect. Defense-in-depth is provided by the second authoritative check via bridge.getSessionSummary(), which reads from persisted session data. Not a blocker given the fallback.
Cross-check against existing reviews:
- yiliang114 (head 1180fcd): optional-chain crash → fixed in current head
- wenshao (CHANGES_REQUESTED → APPROVED 2026-08-26): bootstrap capability timing, Retry-After header, promptId threading, pendingInteractionCount staleness, sessionMode reset on capability loss → addressed in current head per wenshao's approval
- qwen-code-ci-bot deferred Suggestion-level (D2-2 through D3-8): missing test pins for source-matrix, control-method payload-validation, classifier projection, submit-time fail-closed — recorded; none are blockers under the convergence posture
- R1-1 → same concern as qwen-code-ci-bot bridgeClient.ts:660 finding — confirmed, mitigated by
getSessionSummaryfallback
Not covered: execution rungs 1-3 not run (no local toolchain). Windows/macOS platform behavior (SKIPPED CI). Integration test suite (SKIPPED CI). Design doc reviewed for algorithmic consistency only.
Reviewed with AI assistance.
| ); | ||
| } | ||
| if ( | ||
| entry.parentSessionId !== undefined || |
There was a problem hiding this comment.
Source-eligibility from in-memory bridge entry may be incomplete after restart (minor): The check here reads parentSessionId, sourceType, and sourceId from the live BridgeClientSessionEntry. If a session reconnects after a daemon restart and the bridge entry is rebuilt without restoring these lineage fields, the guard passes for a parented sub-session or scheduled-task-source session whose entry fields are undefined.
The assertReusableScheduledTaskSession call downstream — both the pre-validation pass and the write-lock recheck — repeats an equivalent check via bridge.getSessionSummary(), which reads from persisted session data. That fallback provides defense-in-depth. If entries are always rehydrated with lineage fields on reconnect, the fast-path check here is redundant but correct; if they are not, only getSessionSummary catches the case.
Not a blocker given the defense-in-depth path, but worth confirming that BridgeClientSessionEntry entries are always populated with parentSessionId/sourceType/sourceId during session rehydration.










What this PR does
This PR adds explicit current-conversation binding to both scheduled-task creation entrypoints while preserving their existing defaults. The Scheduled Tasks form keeps Dedicated task conversation as the default and sends the outer selected conversation's
sessionIdonly after the user selects Current conversation.cron_createkeeps durable tasks unbound by default and addssessionMode: 'current'for an explicitly requested durable current-conversation task.Current-mode tool creation uses a daemon-only ACP control request. The ACP Session stamps its own session id and the executing prompt id, the bridge verifies that the connection owns that session and that the prompt matches its active prompt, and the daemon persists the task with
sessionOwnedByTask: false. The public REST path retains its idle-session rule; only the prompt-matched private path may bind its active caller, and pending interactions remain ineligible.The implementation reuses the existing #9361 lifecycle contract and durable schema. It adds conditional capability advertising, installs the callback on primary, startup-secondary, and dynamically attached workspace runtimes, keeps injected/partial bridges from advertising support, and relies on the existing keepalive, rehydration, deletion, and session scheduler behavior after persistence.
Why it's needed
#9361 added the daemon primitive for reusing an existing session, but users still could not request it from the Scheduled Tasks form or from an active chat. The form always minted a dedicated task conversation, while durable
cron_createstayed unbound and could fire through a different shared per-project owner. These entrypoints let users explicitly keep future scheduled runs in the conversation where the task was requested without changing behavior for existing callers.Reviewer Test Plan
How to verify
scheduled_task_session_reuse. Create a task without changing the conversation selector and confirm a dedicated task conversation is created and the request omitssessionId.sessionOwnedByTask: false. Confirm busy, pending, parented, sourced, cross-workspace, and already-bound sessions cannot be selected.cron_createwithdurable: trueandsessionMode: 'current'. Confirm creation succeeds during that exact prompt, while missing prompt identity, a sibling session, an ineligible source, a pending interaction, or an older bridge fails without an unbound fallback.scheduled_task_session_reuse, and that primary, startup-secondary, and dynamically attached runtimes all receive the host callback when support is enabled.Evidence (Before & After)
sessionIdonly after explicit selection. The component suite passes 51/51 tests.cron_createhad no current-session mode; durable tasks were always unbound.sessionMode: 'current'commits a caller-owned binding through an exact-prompt daemon path, while omitted/unboundbehavior is unchanged. The core suite passes 16/16 tests.Tested on
Environment (optional)
macOS, Node.js 22.22.3, npm 10.9.8.
npm run build,npm run typecheck, andnpm run lintpassed after rebasing ontoorigin/main@3892ca32ca. Focused verification passed for Core (16), ACP bridge (124), scheduled-task routes (102), Web Shell (51), ACP Session (3 targeted), capability registry/server (1 targeted), primary/static-secondary/dynamic runtime wiring (2 targeted), and the formerly failing lazy content-generator test (27).Risk & Scope
Linked Issues
Follow-up to #8906 and #9361. Related to #9415.
中文说明
这个 PR 做了什么
这个 PR 为两个定时任务创建入口增加了显式绑定当前会话的能力,同时保留各自原有的默认行为。Scheduled Tasks 表单仍默认选择“独立任务会话”,只有用户选择“当前会话”后才发送外层选中会话的
sessionId。cron_create的持久化任务默认仍保持未绑定,并新增sessionMode: 'current',用于用户明确要求创建绑定当前会话的持久化任务。工具的 current 模式通过 daemon-only ACP 控制请求创建任务。ACP Session 注入自身 session id 和当前执行中的 prompt id,bridge 校验该连接拥有这个会话且 prompt 与其 active prompt 匹配,daemon 随后以
sessionOwnedByTask: false持久化任务。公开 REST 路径继续要求会话空闲;只有 prompt 精确匹配的私有路径可以绑定正在执行的调用方会话,存在待处理交互时仍然拒绝。实现复用了 #9361 已有的生命周期约定和持久化 schema。它新增条件化 capability 广告,在 primary、启动时 secondary 和动态挂载的 workspace runtime 上安装 callback,禁止注入或仅部分支持的 bridge 广告能力,并在持久化之后继续复用现有 keepalive、rehydration、删除和 session scheduler 行为。
为什么需要
#9361 已经提供了复用已有会话的 daemon 基础能力,但用户仍无法从 Scheduled Tasks 表单或正在执行的聊天中请求该行为。表单始终创建独立任务会话,而持久化
cron_create始终保持未绑定,可能由另一个共享的 per-project owner 执行。新增入口允许用户显式要求后续定时运行留在提出任务的会话中,同时不改变已有调用方的行为。Reviewer 测试计划
如何验证
scheduled_task_session_reuse的 daemon 上打开 Scheduled Tasks。不修改会话选择器创建任务,确认系统创建独立任务会话,并且请求省略sessionId。sessionOwnedByTask: false。确认忙碌、存在待处理交互、有 parent、有特殊来源、跨 workspace 或已绑定的会话无法选择。durable: true和sessionMode: 'current'调用cron_create。确认任务可在该精确 prompt 执行期间创建;缺少 prompt 身份、指向 sibling 会话、来源不符合要求、存在待处理交互或使用旧 bridge 时都会失败,且不会回退为未绑定任务。scheduled_task_session_reuse;启用支持时,primary、启动时 secondary 和动态挂载 runtime 都收到 host callback。证据(Before & After)
sessionId。组件测试 51/51 通过。cron_create没有当前会话模式;持久化任务始终未绑定。sessionMode: 'current'通过精确 prompt 的 daemon 路径提交调用方所有的绑定;省略/unbound行为保持不变。Core 测试 16/16 通过。测试平台
环境(可选)
macOS,Node.js 22.22.3,npm 10.9.8。rebase 到
origin/main@3892ca32ca后,npm run build、npm run typecheck和npm run lint均通过。定向验证全部通过:Core(16)、ACP bridge(124)、scheduled-task routes(102)、Web Shell(51)、ACP Session(定向 3 个)、capability registry/server(定向 1 个)、primary/static-secondary/dynamic runtime 接线(定向 2 个),以及此前失败的 lazy content-generator 测试(27)。风险与范围
关联问题
#8906 和 #9361 的后续工作。与 #9415 相关。