docs/design/2026-08-24-scheduled-task-current-session-entrypoints.md
Status: Draft
Related: #8906, #9361, #9415
PR #9361 added the daemon primitive this feature needs: scheduled-task create
requests may reuse an existing session by sending sessionId. The daemon
validates the live session, records it as caller-owned, keeps it resident, and
restores it after restart. A task bound this way continues to run in that
session even when the Web Shell selects a different conversation.
Two user entrypoints still cannot request that behavior. The Scheduled Tasks
form never sends the current session id, and cron_create has no current-session
mode. This design adds those entrypoints without changing the scheduler,
persisted ownership model, or either entrypoint's existing default behavior.
The merged #9361 contract is the source of truth:
null for sessionId creates a dedicated task-owned
session.sessionId reuses a live, idle session in the selected workspace and
persists sessionOwnedByTask: false.This REST behavior is different from the existing core tool behavior. A durable
task created by cron_create is unbound: it has no sessionId and fires through
the existing shared per-project lock owner. The tool does not mint a dedicated
task conversation today.
The scheduler already maps task.sessionId to boundSessionId and fires the
task only from the matching session. Session execution already serializes cron
turns behind active user turns.
cron_create.cron_create's unbound
durable default.parentSessionId, a channel, side_task,
scheduled_task, or explicit standalone source, a reserved Live Voice
source id, an unknown source value, or an archived or non-live session.The create form gains a two-option session selector:
sessionId, preserving the
current behavior.DaemonCreateScheduledTaskRequest.sessionId field.Here, current means the ordinary session selected by the outer Web Shell
connection (connection.sessionId), even while mainView is the Scheduled
Tasks page and the chat pane is covered. A visible chat pane is not required.
No selection disables the option. Split panes do not replace the outer selected
id and no pane implicitly wins; activity in a non-selected pane neither disables
the option nor supplies the sessionId.
The selected session is eligible only when it is top-level, has no sourceId,
and its sourceType is absent or default. This is the metadata shape of an
ordinary Web Shell conversation. The form rejects channel, side_task,
scheduled_task, and explicit standalone source values, any unknown source
value, and default paired with the reserved realtime_voice: source-id prefix.
The current-conversation option is shown only when the daemon advertises a new
scheduled_task_session_reuse capability. It is disabled with a reason when:
These checks are advisory. The daemon remains authoritative and the form
surfaces its existing session_busy, session_already_bound,
session_workspace_mismatch, session_not_live, and related errors.
Binding is selectable only during creation. Edit mode does not display or send
sessionId. Task cards keep the existing generic "View conversation" action,
which is correct for both dedicated and caller-owned sessions.
cron_createCronCreateParams gains:
sessionMode?: 'unbound' | 'current';
The default is unbound. sessionMode: 'unbound' and an omitted mode both use
the existing paths: durable tasks stay unbound and session-only jobs stay local
to the current process. sessionMode: 'current' is valid only when durable is
true, and the tool description instructs the model to use it only when the
user explicitly asks to keep scheduled work in the current conversation. The
permission-classifier projection includes sessionMode.
The entrypoints therefore have three explicit outcomes:
| Entry point and request | Persisted session binding | Execution ownership |
|---|---|---|
Form default, REST sessionId omitted | Daemon mints a task-owned session | Dedicated task conversation |
Durable cron_create, mode omitted or unbound | No sessionId | Existing shared per-project lock owner |
cron_create mode current | Caller's session with sessionOwnedByTask: false | Caller-owned current conversation |
Outside a daemon-managed ACP session, current mode returns a clear
current_session_scheduling_unavailable error. Unbound durable and session-only
jobs retain their existing paths.
cron_createThe public #9361 endpoint requires a supplied session to be idle. A
cron_create tool call runs inside an active prompt, so its own session is
necessarily busy and a direct REST-equivalent call would return
session_busy.
The busy rule must remain unchanged for ordinary clients: an arbitrary caller must not bind a session while a different turn is mutating it. Current-mode tool creation therefore uses a daemon-only control path. This path trusts the daemon-spawned workspace agent runtime; a shared ACP connection by itself cannot prove that an arbitrary owned session id belongs to the exact executing turn. The control request instead binds daemon-owned prompt state to identifiers stamped inside the runtime, outside the model-visible tool arguments.
Core Config receives an optional CurrentSessionScheduledTaskCreator
capability, following the existing injected daemon-capability pattern. The ACP
Session implementation wires it to a new control request:
qwen/control/scheduled-task/create-current
The core creator input includes the executing promptId captured from the tool
invocation context. The ACP Session object stamps callerSessionId from
this.sessionId and forwards that prompt id. Neither identifier is accepted in
CronCreateParams, and the control request does not accept a separate target
session id.
The bridge handler:
callerSessionId;promptId to equal that entry's activePromptId while
promptActive is true, following the existing
external_tool_guard/prepare binding pattern;sourceId, and sourceType absent or default; andqwen serve runtimes that
manage scheduled-task sessions.The prompt match prevents an accidental busy-sibling binding on a connection that owns multiple sessions. It is a consistency check inside a trusted agent runtime, not a claim that ACP cryptographically authenticates the exact turn. The public REST path never uses this exception and always rejects a busy supplied session.
If no host callback is installed, the bridge returns method-not-found, which the
tool maps to current_session_scheduling_unavailable.
The host callback and the REST route share a focused
createScheduledTaskWithExistingSession command extracted from the #9361
provided-session branch. The command accepts the internal creation source:
type ExistingSessionCreateOptions = {
source: 'rest' | 'cron-tool';
};
The cron-tool source is supplied only by the private host callback after the
bridge has matched the internally stamped caller session and prompt ids to the
live active prompt. Both paths apply the same selected-runtime and workspace
ownership, archive state, scheduled-task-source, capacity, generation, and
unique-binding checks. Only that prompt-matched trusted path may skip the active
prompt rejection; pending interactions remain ineligible. Public REST never
skips either idle check.
The final write-lock check remains authoritative. It revalidates that the session is live and not task-reserved, rejects a concurrent binding, and writes the task with the existing fields:
{
sessionId: callerSessionId,
sessionOwnedByTask: false,
}
No new durable schema or migration is introduced. The task creation timestamp
and lastFiredAt use the same creation-minute anchor as the REST route, so the
task cannot fire from the turn that is still creating it.
After the host commits the task, the control response returns its id and cron
expression. The creating session's file watcher loads the bound task; a
subsequent cron_list remains immediately consistent because durable listing
is file-first.
There is no scheduler change. Once the task is on disk, only the scheduler whose
session id equals the task's boundSessionId may fire it. If a user turn is
active, the cron prompt waits in that session's existing serial queue.
Selecting another Web Shell conversation detaches the previous UI client but does not close the session. Keepalive continues to heartbeat the bound session, and boot rehydration restores it after daemon restart. Restore failures keep the task bound and retry through the existing policy; they never move work into a different conversation.
sessionMode is optional and defaults to the existing unbound tool behavior.currentSessionSchedulingEnabled construction-time condition requires
manageScheduledTaskSessions and the ACP current-session host callback. The
same condition advertises scheduled_task_session_reuse and installs the
callback on the primary and every dynamically created workspace runtime
bridge. A process does not advertise partial support, so a selected workspace
cannot offer the selector and then return method-not-found for cron_create
current mode.scheduled_task_session_reuse do not render the new
selector, preventing an older daemon from silently ignoring the intent.durable: true, an executing prompt id, and an injected
host capability.sessionMode.hasActivePrompt: true only when its
stamped prompt id matches that session's activePromptId.session_busy.sessionId.cron_create; confirm creation succeeds while the tool turn is active.session_busy for the public endpointREST has neither the trusted workspace-runtime context nor the internally stamped prompt identity used by the control path. Relaxing it would let an arbitrary client bind a session another turn is mutating and would weaken #9361 for every API client.
cron_createThis bypasses daemon runtime ownership, capacity and generation checks, and
cannot safely promise keepalive outside qwen serve.
The tool would have to report success before persistence, or keep a process-local deferred operation whose failure cannot be returned to the user. The trusted control path commits before the tool returns.
Migration splits transcript history and adds rollback and ownership transitions that are unnecessary now that #9361 can bind the intended session directly.