Skip to content

feat(daemon): Support current-session scheduled tasks - #9838

Merged
doudouOUC merged 10 commits into
QwenLM:mainfrom
doudouOUC:docs/scheduled-task-current-session-entrypoints
Aug 26, 2026
Merged

doudouOUC merged 10 commits into
QwenLM:mainfrom
doudouOUC:docs/scheduled-task-current-session-entrypoints

Conversation

@doudouOUC

@doudouOUC doudouOUC commented Aug 24, 2026 •

Copy link
Copy Markdown
Collaborator

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 sessionId only after the user selects Current conversation. cron_create keeps durable tasks unbound by default and adds sessionMode: '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_create stayed 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

  1. Open Scheduled Tasks on a daemon that advertises scheduled_task_session_reuse. Create a task without changing the conversation selector and confirm a dedicated task conversation is created and the request omits sessionId.
  2. Select an idle top-level ordinary conversation, choose Current conversation, create the task, and confirm the request uses that session id and the persisted task has sessionOwnedByTask: false. Confirm busy, pending, parented, sourced, cross-workspace, and already-bound sessions cannot be selected.
  3. From an active daemon conversation, ask the agent to call cron_create with durable: true and sessionMode: '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.
  4. Switch to another conversation and confirm the scheduled turn remains in the bound conversation. Restart the daemon and confirm the bound conversation is rehydrated. Delete the task and confirm the caller-owned conversation remains open.
  5. Confirm a daemon with an injected bridge or without managed scheduled-task sessions does not advertise 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)

Before After
The Scheduled Tasks form had no conversation selector and never supplied the outer selected session id. The selector is capability-gated, defaults to Dedicated task conversation, validates the current conversation, and sends sessionId only after explicit selection. The component suite passes 51/51 tests.
cron_create had no current-session mode; durable tasks were always unbound. sessionMode: 'current' commits a caller-owned binding through an exact-prompt daemon path, while omitted/unbound behavior is unchanged. The core suite passes 16/16 tests.
The daemon had no authenticated tool path from the active prompt to #9361's existing-session creation contract. ACP, route, capability, Session, and runtime-wiring tests pass, including 124/124 bridge tests and 102/102 scheduled-task route tests.

Tested on

OS Status
🍏 macOS ✅
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

macOS, Node.js 22.22.3, npm 10.9.8. npm run build, npm run typecheck, and npm run lint passed after rebasing onto origin/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

  • Main risk or tradeoff: the private control path crosses Core, ACP, daemon runtime ownership, and Web Shell capability boundaries; incorrect caller/prompt validation or partial capability advertising could bind the wrong session or offer unsupported behavior.
  • Not validated / out of scope: a manual wall-clock session-switch and daemon-restart E2E run, Windows/Linux local execution, PATCH rebinding, multiple tasks per session, task-history migration, non-ordinary sessions, and the legacy teardown-versus-reuse race tracked by fix(serve): serialize scheduled-task session teardown with binding via archive-coordinator lease #9415.
  • Breaking changes / migration notes: none. The new tool field and capability are optional, defaults are unchanged, public REST retains its idle rule, and the persisted task schema needs no migration.

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 测试计划

如何验证

  1. 在广告 scheduled_task_session_reuse 的 daemon 上打开 Scheduled Tasks。不修改会话选择器创建任务,确认系统创建独立任务会话,并且请求省略 sessionId。
  2. 选择一个空闲的顶层普通会话,选择“当前会话”并创建任务,确认请求使用该 session id,持久化任务包含 sessionOwnedByTask: false。确认忙碌、存在待处理交互、有 parent、有特殊来源、跨 workspace 或已绑定的会话无法选择。
  3. 在正在执行的 daemon 会话中要求 agent 使用 durable: true 和 sessionMode: 'current' 调用 cron_create。确认任务可在该精确 prompt 执行期间创建;缺少 prompt 身份、指向 sibling 会话、来源不符合要求、存在待处理交互或使用旧 bridge 时都会失败,且不会回退为未绑定任务。
  4. 切换到另一个会话,确认定时执行仍出现在绑定会话中。重启 daemon,确认绑定会话被重新加载。删除任务,确认调用方所有的会话仍保持打开。
  5. 确认使用注入 bridge 或未管理定时任务会话的 daemon 不广告 scheduled_task_session_reuse;启用支持时,primary、启动时 secondary 和动态挂载 runtime 都收到 host callback。

证据(Before & After)

Before After
Scheduled Tasks 表单没有会话选择器,也不会传递外层选中的 session id。 选择器受 capability 控制,默认选择“独立任务会话”,校验当前会话,并且只在显式选择后发送 sessionId。组件测试 51/51 通过。
cron_create 没有当前会话模式;持久化任务始终未绑定。 sessionMode: 'current' 通过精确 prompt 的 daemon 路径提交调用方所有的绑定;省略/unbound 行为保持不变。Core 测试 16/16 通过。
daemon 没有从 active prompt 经过身份校验调用 #9361 已有会话创建约定的工具路径。 ACP、route、capability、Session 和 runtime 接线测试均通过,其中 bridge 测试 124/124、scheduled-task route 测试 102/102 通过。

测试平台

OS 状态
🍏 macOS ✅
🪟 Windows ⚠️
🐧 Linux ⚠️

环境(可选)

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)。

风险与范围

  • 主要风险或取舍:私有控制路径跨越 Core、ACP、daemon runtime 所有权和 Web Shell capability 边界;调用方/prompt 校验错误或只广告部分能力可能绑定错误会话,或向用户提供当前环境不支持的行为。
  • 未验证 / 范围外:真实时钟下的会话切换与 daemon 重启手工 E2E、Windows/Linux 本地执行、PATCH 改绑、一个会话绑定多个任务、任务历史迁移、非普通会话,以及 fix(serve): serialize scheduled-task session teardown with binding via archive-coordinator lease #9415 记录的 legacy teardown-versus-reuse 竞态。
  • 破坏性变更 / 迁移说明:无。新增工具字段和 capability 都是可选的,默认行为不变,公开 REST 继续保留空闲规则,持久化任务 schema 不需要迁移。

关联问题

#8906 和 #9361 的后续工作。与 #9415 相关。

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread docs/design/2026-08-24-scheduled-task-current-session-entrypoints.md Outdated
Comment thread docs/design/2026-08-24-scheduled-task-current-session-entrypoints.md Outdated
Comment thread docs/design/2026-08-24-scheduled-task-current-session-entrypoints.md Outdated
Comment thread docs/design/2026-08-24-scheduled-task-current-session-entrypoints.md Outdated
Comment thread docs/design/2026-08-24-scheduled-task-current-session-entrypoints.md Outdated
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Addressed the design review in 0627a51299.

Review item Action
Form dedicated vs. tool unbound defaults Fixed: added the three-outcome table and changed the tool option to unbound | current.
Ordinary-session source eligibility Fixed: added an exact top-level unset/default allow-list and explicit deny matrix. The document now uses standalone only for the real reserved source value.
ACP exact-turn attestation Fixed: public REST never skips idle; the trusted runtime stamps callerSessionId and forwards the executing promptId, which must match activePromptId.
Scheduled Tasks selected-session semantics Fixed: current means the outer selected session; no selection disables it, split panes do not win, and non-selected activity is ignored.
Capability/callback consistency Fixed: one condition gates capability advertising and callback installation across primary and dynamic workspace runtimes.

The PR description was updated to match the corrected defaults and trust model. Verification: Prettier check and git diff --check pass.

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Reviewed the new identifier-drift suggestion on 0627a51299. No source change or new commit was needed: the comment contrasts identical spellings, while the design already matches the current bridge-entry, REST-summary, and persisted-task identifiers. The thread has been answered and resolved. Current substantive CI checks are green; the older cancelled route runs were superseded by the latest passing run.

@doudouOUC
doudouOUC force-pushed the docs/scheduled-task-current-session-entrypoints branch from 0627a51 to 5c65b19 Compare August 24, 2026 09:21
@doudouOUC doudouOUC changed the title docs(scheduled-tasks): Design current-session creation entrypoints feat(daemon): Support current-session scheduled tasks Aug 24, 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)为单个提交。

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Implementation is now available in 5c65b19edb.

Verification after rebasing onto origin/main@b5aec6691e:

  • Core cron_create: 16/16 passed.
  • ACP bridge: 124/124 passed.
  • Scheduled-task routes: 102/102 passed, including the trusted active-prompt path, pending-interaction refusal, caller ownership, lifecycle, concurrency, capacity, and generation rollback coverage.
  • Web Shell Scheduled Tasks dialog: 51/51 passed.
  • ACP Session current-mode forwarding/older-bridge behavior: 2 targeted tests passed.
  • Capability registry/server: 3 targeted tests passed.
  • Primary, startup-secondary, and dynamically attached runtime callback wiring: 2 targeted tests passed.
  • npm run lint passed, and git diff origin/main --check is clean.

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 npm run build and npm run typecheck stop in unchanged packages/core/src/core/contentGenerator.test.ts:776. #9676 removed countTokens from the ContentGenerator interface, while the current origin/main test still calls it. The same full build/typecheck passed on the prior base before rebasing; this PR does not modify that area.

@doudouOUC
doudouOUC marked this pull request as ready for review August 24, 2026 09:52
@doudouOUC
doudouOUC enabled auto-merge August 24, 2026 09:52
@doudouOUC
doudouOUC force-pushed the docs/scheduled-task-current-session-entrypoints branch from 5c65b19 to 4693c7a Compare August 24, 2026 10:06
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Rebased onto origin/main@3892ca32ca and force-pushed 4693c7aa5b with an exact lease to pick up the upstream fix for the inherited contentGenerator.test.ts failure.

Item Result
PR patch integrity git range-diff reports all three commits patch-identical; no PR behavior changed during rebase.
Full verification npm run build, npm run typecheck, and npm run lint passed.
Focused verification Core 16/16, ACP bridge 124/124, scheduled-task routes 102/102, Web Shell 51/51, ACP Session 3 targeted, capability gate 1 targeted, runtime wiring 2 targeted, lazy content-generator 27/27.
Review feedback No new external feedback required action; all 6 review threads remain resolved.
PR description Updated the English and Chinese environment/risk sections to remove the now-stale inherited-failure note.

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.

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

CI fix pushed in 1180fcd.

  • Documented the conditional scheduled_task_session_reuse capability so the protocol registry contract is complete.
  • Verified the focused contract test, repository build, typecheck, lint, formatting, diff checks, and two clean audit passes. The independent test-engineer verification also passed.
  • The Web Shell visual-preview workspace-sidebar failure is identical on the PR base and head and comes from an existing mock workspace-filtering bug; PR feat(daemon): Support current-session scheduled tasks #9838 does not modify that fixture or sidebar path.

Handled review threads in this push: 0. Existing review threads remain 6/6 resolved. Fresh CI was triggered by the push.

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

CI fix pushed in 98d15a538f.

  • Made the Web Shell capability lookup fail closed when a partial or malformed workspace snapshot omits features, instead of crashing the Scheduled Tasks page.
  • Verified the formerly failing test and the complete App.test.tsx suite (540/540), repository build, typecheck, lint, changed-file Prettier check, diff checks, and two clean audit passes. Independent test-engineer reproduction and post-fix verification also passed.
  • The repository-wide Web Shell format check still reports five unchanged pre-existing files; the changed file itself is correctly formatted.

Handled review threads in this push: 0. Existing review threads remain 6/6 resolved. Fresh CI was triggered by the push.

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

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.

Comment thread packages/web-shell/client/App.tsx Outdated
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

CI fix pushed in 8291f123a5.

  • Updated the real qwen serve integration capability contract to include scheduled_task_session_reuse when the CLI owns the managed scheduled-task bridge callback.
  • Kept registry ordering exact and corrected the nearby fixture explanation; no production behavior changed.
  • Verified the formerly failing test, the complete qwen-serve-routes.test.ts suite (36/36), repository build, typecheck, lint, changed-file Prettier check, diff checks, and two clean audit passes. Independent test-engineer reproduction and post-fix verification also passed.

Handled review threads in this push: 0. Existing review threads remain 7/7 resolved. Fresh CI was triggered by the push.

@doudouOUC
doudouOUC requested a review from yiliang114 August 24, 2026 14:03

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

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)

Comment thread docs/developers/qwen-serve-protocol.md
Comment thread packages/cli/src/serve/routes/scheduled-tasks.ts Outdated
Comment thread packages/cli/src/serve/routes/scheduled-tasks.ts
Comment thread packages/cli/src/serve/run-qwen-serve.ts Outdated
Comment thread packages/cli/src/serve/run-qwen-serve.ts
Comment thread packages/core/src/tools/cron-create.ts
Comment thread packages/core/src/tools/cron-create.ts
Comment thread packages/web-shell/client/App.tsx
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Review feedback handled in 2391398086

Feedback Decision and action
Bootstrap capability before runtime readiness Fixed: bootstrap omits the runtime-dependent feature; delayed and failed startup witnesses added.
Live-owner resolver error classification Fixed: lookup exceptions map to scheduled_tasks_session_failed.
Missing transient retry hint Fixed: unavailable runtime responses preserve Retry-After: 1.
Prompt A / prompt B lock race Fixed: the exact caller prompt is revalidated before and inside the task-file lock, with a required cron-tool assertion.
Stale pending-interaction state Fixed: prompt-boundary refresh plus latest-request-wins ordering; the regression resolves the newer response before the stale one.
Hidden current-session mode after capability loss Fixed: the mounted form resets to dedicated mode and submits without sessionId.
Three additional test-hardening Suggestions Deferred under the repository guidance for a PR beyond roughly five review rounds; this round is limited to Critical correctness fixes.
App-boundary integration Suggestion Partially addressed by the new capability/session/live-state/stale-response regression; broader split-pane coverage is deferred.

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, git diff --check, and independent test-engineer post-fix audit passed. Replied to and resolved 10/10 surfaced review threads. CI is now rerunning on the pushed head.

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

CI failure fixed in 8ca008d370

The Ubuntu no-AK capabilities assertion could observe either the bootstrap or mounted-runtime envelope. scheduled_task_session_reuse is intentionally absent during bootstrap and present after the managed runtime mounts, so the exact baseline now normalizes only that phase-dependent tag while continuing to reject every other missing, extra, or reordered capability.

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, git diff --check, and independent test-engineer verification passed. No review threads were involved in this CI-only fix (0/0).

@wenshao

wenshao commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Local real-daemon verification of #9838 — one blocker found

I built a real verification environment and ran this PR end-to-end against a live qwen serve daemon (real HTTP, real ACP child processes, real wall-clock cron firing, real Web Shell in Chromium, real daemon restart). Verified at head 8ca008d370bd52acbbe40872076b548868ebcf7c.

Verdict: needs one more fix before merge. The Web Shell entrypoint works end-to-end exactly as described. The cron_create entrypoint never succeeds on a real daemon — reviewer test-plan step 3 fails 4/4 — because the prompt id the tool sends and the prompt id the bridge checks come from two different id namespaces. A one-line change makes it pass; details and an A/B proof below.

Environment

Worktree /root/git/pr9838 at 8ca008d370, clean npm ci + build, no source modifications
Daemon node packages/cli/dist/index.js serve --port 4371 --workspace <ws> — real daemon, real spawned ACP children
Model local OpenAI-compatible mock (deterministic marker → tool-call / text / delayed-reply), so turns are real but hermetic
UI the daemon's own Web Shell build, driven in headless Chromium (Playwright), deviceScaleFactor: 2
Clock real: tasks scheduled * * * * * and observed firing over ~30 minutes, across a daemon restart

Blocker — cron_create sessionMode:'current' always fails on a real daemon

Reproduced 4/4 on the unmodified build. The tool call ends failed, no task is persisted, and the user sees Error creating cron job: [object Object].

tool path fails

Root cause — two different prompt-id namespaces. I instrumented the built bridgeClient.js to dump both sides of the comparison at packages/acp-bridge/src/bridgeClient.ts:1917 (then restored it and confirmed the same failure on the pristine build):

{
  "wire_promptId":        "247614dd-56e9-46f9-bdd3-8a56ea3f455a########1",
  "entry_activePromptId": "8690bd83-e2bc-4796-8691-07457245490b",
  "entry_promptActive":   true,
  "entry_sessionId":      "247614dd-56e9-46f9-bdd3-8a56ea3f455a",
  "callerSessionId":      "247614dd-56e9-46f9-bdd3-8a56ea3f455a"
}

Connection ownership passes, promptActive is true, and the source fields are all eligible. The only failing condition is entry.activePromptId !== promptId, and it can never hold:

  • packages/core/src/tools/cron-create.ts:102 sends promptIdContext.getStore().
  • That store is set at packages/cli/src/acp-integration/session/Session.ts:4499 to `${sessionId}########${turn}` — the core turn counter.
  • packages/acp-bridge/src/bridge.ts:8812 sets entry.activePromptId = pendingEntry.promptId, which is the daemon-minted UUID returned by POST /session/:id/prompt.

These two never coincide, so the guard at bridgeClient.ts:1917 rejects with -32602 on every call, in every configuration.

Why CI is green. Both sides are tested against an assumed literal, never against each other's real value: Session.test.ts calls the creator with promptId: 'prompt-1', cron-create.test.ts wraps in promptIdContext.run('prompt-1', …), and bridgeClient.test.ts sets entry.activePromptId to whatever the test passed in. I ran both suites locally on this head — cron-create.test.ts 16/16 and bridgeClient.test.ts 125/125 pass while the live path fails.

Candidate fix (verified). The daemon already ships its own prompt id to the child: bridge.ts:8459 builds InvocationContextV1 { sessionId, promptId } from the same promptId it later stores as activePromptId, sends it in _meta[INVOCATION_CONTEXT_META_KEY], and Session.ts:4461 binds it for the whole turn via runWithInvocationContext. So in #registerCurrentSessionScheduledTaskCreator:

-          promptId: req.promptId,
+          promptId: getInvocationContext()?.promptId ?? req.promptId,

A/B on the running daemon, same scenario, nothing else changed:

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

tool path with candidate fix

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."

busy guard


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"}

form with current conversation

# 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."

bound task run history

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。

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Review feedback addressed in 16a5786bfe

Thanks @wenshao for the real-daemon A/B verification. I independently reproduced the identity mismatch and applied the narrow adapter fix.

Feedback Decision Action
cron_create(sessionMode: 'current') sends the core turn ID while the bridge validates the daemon prompt ID Agreed — blocker The daemon-specific Session adapter now forwards the trusted invocation-context prompt ID, with the core turn ID retained only as the no-context fallback. A regression test deliberately uses different IDs and asserts the daemon ID on the private wire request.
Plain JSON-RPC errors render as [object Object] Agreed — correctness issue in the new path cron_create now uses the repository error-message normalizer; a plain-object regression test covers all three tool display surfaces.
Current-conversation availability can be stale after leaving and reopening the dialog Deferred This is fail-closed: the daemon returns 409 session_busy before persistence and the task list remains unchanged. Given the current review round, the UI refresh improvement should be handled separately rather than widening this PR further.

Verification: Session tests 694/694 passed; cron-create tests 17/17 passed; the busy-session route witness passed with 409 and no persisted task; repository build, typecheck, lint, Prettier/pre-commit formatting, git diff --check, two clean diff-audit passes, and independent test-engineer verification passed.

The feedback was top-level rather than an inline review thread, so there were no GitHub threads to resolve (0/0).

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Addressed the latest review on commit cf63c6e82e.

Item Decision Result
Scheduled-task business errors degraded to Internal error across ACP Fixed The bridge now maps only the recognized domain error to structured errorKind, status, and bounded hint data; the daemon Session restores the actionable message before cron_create reports it. Unknown host errors remain opaque.
Prompt start and settle both force fresh Web Shell status/catalog reads Deferred Valid non-blocking performance optimization, deferred under the Critical-only convergence posture after roughly five review rounds.
Lock-ordering regression witness Deferred Production still performs the second validation inside the locked mutator; stronger missing-test coverage is a follow-up Suggestion.
Final capabilities integration assertion was not exercised Verified Rebuilt the final bundle and ran qwen-serve-routes.test.ts: 36/36 passed.

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.

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

2 similar comments
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@doudouOUC doudouOUC self-assigned this Aug 26, 2026
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@wenshao

wenshao commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Round-2 local real-daemon verification of #9838 — blocker fixed, one diagnosability gap left

I rebuilt the verification environment against the current head cf63c6e82e1144d17252e0d3a606fd99f65d87a1 and re-ran the whole thing end to end on a live qwen serve daemon (real HTTP, real spawned ACP children, real wall-clock cron firing, real Web Shell in Chromium, real daemon restart, real multi-workspace daemon).

Verdict: the round-1 blocker is genuinely fixed — LGTM to merge. cron_create(sessionMode: 'current') now succeeds on a real daemon (4/4), the full lifecycle of a tool-created binding works (9/9), and all three runtime install sites — primary, startup-secondary and dynamically attached — bind for real (6/6). One non-blocking follow-up remains: half of the rejection paths still reach the user as a bare Invalid params, and it is the same seam that hid the round-1 blocker.

Environment

Worktree /root/git/pr9838 at cf63c6e82e, full npm run build, no source modifications during the scenarios
Daemon node packages/cli/dist/index.js serve --port 4371 --workspace <ws> [--workspace <ws2>] [--token …]
Model local OpenAI-compatible mock (marker-driven: tool-call / text / delayed reply / two parallel tool calls)
UI the daemon's own Web Shell build in headless Chromium (Playwright), deviceScaleFactor: 2
Wire QWEN_CLI_ENTRY stdio tap that tees every JSON-RPC frame between the daemon and its ACP children
A/B the previously verified head 8ca008d370 kept as a second built dist and swapped in

1. The blocker is fixed — real two-build A/B

Arm Wire promptId sent by cron_create Result
8ca008d370 (round-1 head, dist swapped back in) d74fb300-…-3235373a659e########1 — the core turn id TOOL failed, Error creating cron job: [object Object], 0 persisted (0/2)
cf63c6e82e (this head) e7d91d82-6a34-47a4-9ec5-dc4e943b8486 — the daemon prompt id, identical to _meta["qwen-code/invocation"].promptId on session/prompt TOOL completed, persisted sessionId = caller conversation, sessionOwnedByTask: false (4/4)

tool path fixed

The identity check was not weakened to get there. I patched the built child to send a forged promptId and left everything else at head: the daemon rejected it and nothing was persisted. The adapter changed which id is sent, not whether it is checked.

2. Lifecycle of a tool-created binding — 9/9

This is coverage that was impossible before this head, because the tool entrypoint never produced a binding. Real clock, * * * * *, observed over ~7 minutes across a restart.

# Check Result
L1 The tool binds the caller conversation ✅ sessionOwnedByTask: false, sessionId = caller
L2/L3 The user switches to another conversation; runs still land in the bound one ✅ 2/2 runs, every runs[].sessionId = caller
L4 The bound conversation carries the scheduled turns ✅ 3 occurrences of the scheduled prompt in its transcript
L5 The conversation the user switched to is untouched ✅ no scheduled prompt in it
L6 The binding survives a daemon restart ✅ rehydrated with the same sessionId
L7 It fires again into the same conversation after the restart ✅ runs 2 → 3, last run = caller
L8 The caller conversation is still alive after the restart ✅ 200
L9 Deleting the task keeps the caller conversation open and usable ✅ 200 + a new turn completes

bound task run history

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 POST /workspaces.

# Check Result
W0 Capability advertised on a multi-workspace daemon ✅ scheduled_task_session_reuse
W1 Primary workspace runtime binds ✅ caller-owned
W2 Startup-secondary workspace runtime binds ✅ caller-owned
W3 Dynamically attached workspace runtime binds (attached at runtime with POST /workspaces, 201) ✅ caller-owned
W4 Cross-workspace REST bind is refused ✅ 400 session_workspace_mismatch
W5 Each binding is persisted only in its own workspace's cron file ✅ ws/ws2/ws3 isolated

4. Web Shell entrypoint — no regression

# Check Result
U1 Capability advertised ✅
U2 No conversation open → option disabled ✅ "Open an existing conversation before selecting the current conversation."
U3 Defaults to the dedicated task conversation ✅
U4 Idle conversation → option selectable ✅ "Future runs continue in the conversation that is open now."
U5 Already-bound conversation → option disabled ✅ "The current conversation is already bound to a scheduled task."
U6 Default arm omits sessionId ✅ {"cron":"0 9 * * *","prompt":"PING_UI_DEDICATED",…}
U7 Explicit arm sends the open conversation id ✅ …,"sessionId":"7adeb182-14a3-4f67-b99b-748de845c260"
U8 Ownership semantics, proved by deletion ✅ caller-owned conversation survives (200, still usable); the dedicated one is torn down (404)

form with current conversation

5. Local fail-closed rules

Check Result
sessionMode: 'current' without durable: true ✅ "Current-session scheduling requires durable: true because session-only jobs cannot survive a daemon session switch."
The same tool call outside a daemon (qwen -p …) ✅ "current_session_scheduling_unavailable: Current-session scheduling requires an active daemon prompt." — no unbound fallback
Caller already bound to a task ✅ "session_already_bound: The requested session is already bound to another scheduled task", nothing persisted

Follow-up (non-blocking) — half of the rejections still say only Invalid params

The [object Object] fix works for the daemon route's domain errors: those now carry data.errorKind/status/hint, and session_already_bound reaches the model as a full sentence. But the bridge's own guards — the ones that fire on an identity or eligibility mismatch — still reach the user with their message stripped.

Live repro, no instrumentation, a scenario a user can actually hit: a scheduled run asks to keep its own future runs in the conversation it is running in. The caller is a task-owned conversation, so the source guard correctly refuses — and this is all the user and the model get:

Error creating cron job: Invalid params

opaque rejection

The wire tap for the same guard — driven from a scheduled_task-sourced conversation so the frames are easy to isolate — shows the daemon really does send nothing more:

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"}}

Root cause. withLogSafeAcpError (packages/acp-bridge/src/bridge.ts:531) replaces every outbound RequestError message with logSafeRequestErrorMessage(code) and drops any data that has no errorKind. preserveScheduledTaskCreateErrorOverAcp was added precisely to survive that filter — but only for ExistingSessionScheduledTaskCreateError. The eight RequestError.invalidParams(undefined, …) guards in handleCreateCurrentSessionScheduledTask pass no data, so all of them collapse to the same three words:

  • `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 exact failure of round 1
  • The caller session no longer owns the active prompt
  • The caller session source cannot own a scheduled task

I confirmed the prompt-mismatch case too, by forging the id: identical Invalid params. So if this regressed again, the field signal would be no better than it was in round 1.

Suggested fix (small): give those guards the same treatment as the domain errors, e.g. RequestError.invalidParams({ errorKind: 'caller_prompt_mismatch', hint: '…' }, '…') — logSafeRequestErrorData already passes errorKind/hint through, and Session.ts already renders errorKind: hint.

Test-realism note: cron-create.test.ts asserts on { code: -32602, message: 'The caller session does not own the active prompt' }. That message never reaches the child for that case — the wire value is Invalid params. The assertion is right about getErrorMessage, but it reads as if the guard text were user-visible; it is not.

For contrast, the REST/Web Shell surface does this well — the same class of rejection is rendered verbatim in the form:

busy guard message

The deferred UI-staleness item: deferral is reasonable

Still reproduces at head (open the form → leave → another client starts a turn → reopen: the option is still offered), and it is fail-closed with a good message: POST /scheduled-tasks returns 409 session_busy, the form renders the server's sentence, and nothing is persisted. A fresh load while busy correctly disables the option. Nothing here needs to block the merge.

Test teeth — 6 mutants on the three fix hunks

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 ⚠️ survives — untested, and inert (a missing context sends undefined, which the bridge rejects anyway)
err.status >= 500 ? -32603 : -32602 → always -32602 bridgeClient ⚠️ survives — untested, and inert (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 prompt
  • The 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 ⚠️ 存活 —— 无测试覆盖,且无实际影响(无 context 时发送 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。

@doudouOUC

doudouOUC commented Aug 26, 2026 •

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough round-2 verification. I independently checked the exact head and agree with the diagnosability finding: these bridge guards still call RequestError.invalidParams(undefined, ...), while the log-safe ACP wrapper strips the raw message unless structured errorKind data is present.

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.

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

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

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) ⚠️ SKIPPED · Test (macos-latest / windows-latest, Node 22.x) ⚠️ SKIPPED. The integration test suite is SKIPPED, and the updated 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 + executing promptId (not caller-supplied). Bridge validates callerSessionId is owned by this connection AND entry.activePromptId === promptId. Both hold at the initial check and at write-lock recheck via assertCallerPromptActive — the closure captures the original entry object so object-identity change or prompt-end between calls is caught.
  • assertReusableScheduledTaskSession is called twice: once before the task object is built, and once inside the updateCronTasks write-lock callback. Both calls invoke assertCallerPromptActive?.() first, serializing eligibility + prompt-active with the atomic write.
  • REST path keeps the idle rule (allowActivePrompt = false); cron-tool path allows an active prompt and rejects pendingInteractionCount > 0 at both call sites.
  • Cross-workspace sessions rejected before session lookup; ambiguous owner resolution fails closed with 500.
  • preserveScheduledTaskCreateErrorOverAcp converts ExistingSessionScheduledTaskCreateError to typed RequestError with errorKind — structured rejections surface to the tool caller.
  • Retry-After: 1 header is set on the workspace_runtime_unavailable path ✓
  • 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 getSessionSummary fallback

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

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.

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.

@doudouOUC
doudouOUC dismissed a stale review August 26, 2026 07:58

Already have 2 approves,3ks.

@doudouOUC
doudouOUC added this pull request to the merge queue Aug 26, 2026
Merged via the queue into QwenLM:main with commit 7b69293 Aug 26, 2026
310 of 315 checks passed
@doudouOUC
doudouOUC deleted the docs/scheduled-task-current-session-entrypoints branch August 26, 2026 07:59
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.

4 participants