diff --git a/docs/design/2026-08-24-scheduled-task-current-session-entrypoints.md b/docs/design/2026-08-24-scheduled-task-current-session-entrypoints.md new file mode 100644 index 00000000000..95e47bb566f --- /dev/null +++ b/docs/design/2026-08-24-scheduled-task-current-session-entrypoints.md @@ -0,0 +1,347 @@ +# Current-session entrypoints for daemon scheduled tasks + +Status: Draft + +Related: #8906, #9361, #9415 + +## Summary + +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. + +## Existing baseline + +The merged #9361 contract is the source of truth: + +- Omitting or passing `null` for `sessionId` creates a dedicated task-owned + session. +- Passing `sessionId` reuses a live, idle session in the selected workspace and + persists `sessionOwnedByTask: false`. +- A caller-owned session is not renamed or closed when its task is renamed or + deleted. +- Archiving the bound session disables the task, unarchiving resumes it, and + deleting the session removes the task. +- Enabled bound sessions are kept resident and rehydrated after daemon restart. +- A session may be bound to at most one scheduled task. + +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. + +## Goals + +- Let the Scheduled Tasks form bind a new task to the currently selected + ordinary conversation. +- Let a user explicitly request current-session binding through `cron_create`. +- Preserve the form's dedicated-session default and `cron_create`'s unbound + durable default. +- Preserve the #9361 ownership, workspace, capacity, lifecycle, and unique + binding checks. +- Fail clearly when the host cannot guarantee daemon-managed restoration. + +## Non-goals + +- Rebinding an existing task through PATCH. +- Binding more than one task to a session. +- Migrating task history between sessions. +- Binding a session with `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. +- Changing the Scheduled Tasks page's existing "Create via chat" action, which + intentionally starts a fresh conversation. +- Solving the remaining legacy teardown-versus-reuse race tracked by #9415. +- Changing token-limit or missed-fire policy. + +## Public behavior + +### Scheduled Tasks form + +The create form gains a two-option session selector: + +- **Dedicated task conversation** — default; omit `sessionId`, preserving the + current behavior. +- **Current conversation** — send the selected session id in the existing + `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: + +- there is no selected session; +- the selected session still has a running turn or pending interaction; +- the selected session is not an eligible top-level ordinary conversation; +- the form's selected workspace differs from the selected session's workspace; + or +- the loaded task list already contains a task with that session id. + +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_create` + +`CronCreateParams` gains: + +```ts +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. + +## Architecture + +### Why the REST path cannot be called directly from `cron_create` + +The 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. + +### Daemon control path + +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: + +```text +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: + +1. validates payload types and the same prompt bounds as the REST route; +2. verifies that the bridge client owns `callerSessionId`; +3. resolves that live session in the bridge that received the request; +4. requires `promptId` to equal that entry's `activePromptId` while + `promptActive` is true, following the existing + `external_tool_guard/prepare` binding pattern; +5. applies the same exact source allow-list as the form: no parent, no + `sourceId`, and `sourceType` absent or `default`; and +6. delegates to a host callback installed only by `qwen 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`. + +### Shared daemon creation command + +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: + +```ts +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: + +```ts +{ + 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. + +### Execution and session switching + +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. + +## Compatibility and rollout + +- `sessionMode` is optional and defaults to the existing unbound tool behavior. +- Existing REST and SDK callers do not change. +- Existing task files require no rewrite. +- One `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. +- Web clients without `scheduled_task_session_reuse` do not render the new + selector, preventing an older daemon from silently ignoring the intent. +- Non-daemon tool callers receive an explicit error rather than creating a + durable task whose bound session cannot be restored. +- The feature can ship in one implementation PR because capability advertising, + UI use, and daemon control support are versioned together. + +## Test plan + +### Core tool + +- Omitted and explicit unbound modes preserve session-only and unbound durable + creation without minting a session. +- Current mode requires `durable: true`, an executing prompt id, and an injected + host capability. +- Current mode forwards the exact schedule and returns the committed task id. +- The permission-classifier input includes `sessionMode`. +- Host failure and method-not-found are surfaced without creating an unbound + fallback task. + +### Bridge and daemon + +- The control method rejects malformed payloads, an unknown caller, and a + caller session not owned by the bridge client. +- A missing or stale prompt id and an active prompt on an owned sibling session + are rejected without creating a task. +- The trusted caller succeeds despite `hasActivePrompt: true` only when its + stamped prompt id matches that session's `activePromptId`. +- REST creation with the same busy session still returns `session_busy`. +- The source matrix accepts only top-level unset/default sessions without a + source id and rejects parented, Channel, side-task, scheduled-task, explicit + standalone, Live Voice, and unknown-source sessions. +- Workspace mismatch, archived/non-live sessions, capacity, generation closure, + and an existing binding preserve #9361 errors. +- A concurrent REST/tool create commits exactly one task. +- The committed task is caller-owned; task rename and deletion do not rename or + close the conversation. +- The capability is absent when either scheduled-task session management or the + ACP host callback is absent. +- A dynamically created workspace runtime receives the same callback as the + primary runtime; current-mode creation routes to the selected runtime rather + than falling back to primary. + +### Web Shell + +- Dedicated mode is the default and omits `sessionId`. +- Current mode sends the outer selected session id while the Scheduled Tasks + page covers the chat pane. +- Capability absence, no selected session, a selected-session active turn, an + ineligible session source, workspace mismatch, and an existing binding disable + the option with the expected explanation. +- Split-pane activity does not disable or replace an idle outer selected + session. +- Edit requests never mutate binding. +- "Create via chat" continues to start a fresh conversation. + +### End to end + +1. In conversation A, create a durable current-session task through + `cron_create`; confirm creation succeeds while the tool turn is active. +2. Switch the Web Shell to conversation B and confirm the scheduled turn appears + in A, not B. +3. Restart the daemon without opening A and confirm A is rehydrated and the next + fire still appears there. +4. Delete the task and confirm A remains open and usable. +5. Repeat creation through the Scheduled Tasks form while A is idle and confirm + it uses the same session without minting a new one. + +## Alternatives rejected + +### Relax `session_busy` for the public endpoint + +REST 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. + +### Write the task file directly from `cron_create` + +This bypasses daemon runtime ownership, capacity and generation checks, and +cannot safely promise keepalive outside `qwen serve`. + +### Defer creation until the tool turn ends + +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. + +### Create a dedicated session and later migrate it + +Migration splits transcript history and adds rollback and ownership transitions +that are unnecessary now that #9361 can bind the intended session directly. diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index ee4f6bfbf81..13879c1a65d 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -490,6 +490,7 @@ operator diagnostic snapshot documented below. | `session_shell_command` | session shell execution is explicitly enabled. | | `session_artifacts_persistence` | session artifact persistence is wired for the runtime. | | `session_generation` | session generation helpers are available. | +| `scheduled_task_session_reuse` | durable scheduled-task session management is active and every managed daemon runtime has installed the callback that lets a task explicitly bind to its current existing session. | | `workspace_generation` | workspace-scoped generation helpers are available. | | `rate_limit` | `--rate-limit` / `QWEN_SERVE_RATE_LIMIT=1` / `ServeOptions.rateLimit` is enabled. | | `workspace_reload` | workspace reload support is available in the embedded route configuration. | diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index bfe0f385af0..829b1ebe8c8 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -296,10 +296,15 @@ describe('qwen serve — capabilities envelope', () => { // Pool tags (`mcp_workspace_pool`, `mcp_pool_restart`) ARE present // because the workspace MCP pool is on by default, as are // `workspace_settings`, `workspace_permissions`, `workspace_voice`, - // `workspace_trust`, `workspace_github_setup`, and - // `workspace_reload`. The CLI serve path always wires `persistSetting`, the - // workspace service, and route-local workspace helpers). - expect(caps.features).toEqual([ + // `workspace_trust`, `workspace_github_setup`, and `workspace_reload`. + // `scheduled_task_session_reuse` appears only after the managed runtime + // mounts, so the fast-path bootstrap and runtime envelopes legitimately + // differ by that tag. Its transition is covered by the serve startup tests. + expect( + caps.features.filter( + (feature) => feature !== 'scheduled_task_session_reuse', + ), + ).toEqual([ 'health', 'daemon_status', 'capabilities', diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index f559eac52a0..ebcd6e2d327 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -4058,6 +4058,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // A Goal turn drains the mid-turn queue but owns no prompt slot, so // nothing else would settle what its last drain missed. settleMidTurnQueueAfterGoalTurn, + opts.onCreateCurrentSessionScheduledTask, ); const rawConnection = new ClientSideConnection( () => diff --git a/packages/acp-bridge/src/bridgeClient.test.ts b/packages/acp-bridge/src/bridgeClient.test.ts index ca0473bd0dd..da774b99356 100644 --- a/packages/acp-bridge/src/bridgeClient.test.ts +++ b/packages/acp-bridge/src/bridgeClient.test.ts @@ -66,7 +66,10 @@ import { MID_TURN_RECONCILIATION_RING_SIZE, TODO_STOP_GUARD_CONTINUATION_CLAIM_METHOD, } from './bridgeTypes.js'; -import type { ClientMcpMessageSender } from './bridgeOptions.js'; +import type { + ClientMcpMessageSender, + CurrentSessionScheduledTaskCreateInfo, +} from './bridgeOptions.js'; import { CancelSentinelCollisionError } from './bridgeErrors.js'; import { CANCEL_VOTE_SENTINEL } from './permissionMediator.js'; import { SessionArtifactStore } from './sessionArtifacts.js'; @@ -93,6 +96,13 @@ function makeClient( ownsSession?: (sessionId: string) => boolean; handler: ExternalToolGuardHandler; }, + currentSessionTask?: { + resolveEntry: (sessionId?: string) => unknown; + ownsSession?: (sessionId: string) => boolean; + handler: NonNullable< + import('./bridgeOptions.js').BridgeOptions['onCreateCurrentSessionScheduledTask'] + >; + }, ): BridgeClient { const noPermissionFlow = () => { throw new Error('test: permission flow should not run in fs-path tests'); @@ -104,7 +114,9 @@ function makeClient( // required (policy/vote/forgetSession/peekSessionFor/pendingCount). const throwerMediator = { request: noPermissionFlow } as never; return new BridgeClient( - (managedGuard?.resolveEntry ?? noPermissionFlow) as never, // resolveEntry + (managedGuard?.resolveEntry ?? + currentSessionTask?.resolveEntry ?? + noPermissionFlow) as never, // resolveEntry noPermissionFlow as never, // resolvePendingRestoreEvents throwerMediator, // mediator (F3 Commit 3) 0, // permissionTimeoutMs (disabled) @@ -113,7 +125,9 @@ function makeClient( undefined, undefined, undefined, - managedGuard?.ownsSession ?? (() => true), + managedGuard?.ownsSession ?? + currentSessionTask?.ownsSession ?? + (() => true), undefined, undefined, undefined, @@ -124,6 +138,10 @@ function makeClient( undefined, undefined, managedGuard?.handler, + undefined, + undefined, + undefined, + currentSessionTask?.handler, ); } @@ -1649,6 +1667,142 @@ describe('BridgeClient — create-sub-session extMethod dispatch', () => { }); }); +describe('BridgeClient — current-session scheduled-task dispatch', () => { + const request = { + callerSessionId: 'session-1', + promptId: 'prompt-1', + cron: '5 9 * * *', + prompt: 'continue the work', + recurring: true, + }; + + function makeCurrentSessionClient( + overrides: Record = {}, + ownsSession: (sessionId: string) => boolean = () => true, + ) { + const entry = { + sessionId: 'session-1', + workspaceCwd: '/workspace', + effectiveCwd: '/workspace', + promptActive: true, + activePromptId: 'prompt-1', + ...overrides, + }; + const handler = vi.fn( + async (_info: CurrentSessionScheduledTaskCreateInfo) => ({ + id: 'cron-1', + cron: request.cron, + }), + ); + const client = makeClient(undefined, undefined, { + resolveEntry: (sessionId) => + sessionId === entry.sessionId ? entry : undefined, + ownsSession, + handler, + }); + return { client, entry, handler }; + } + + it('forwards only the bridge-owned active prompt', async () => { + const { client, handler } = makeCurrentSessionClient(); + + await expect( + client.extMethod( + SERVE_CONTROL_EXT_METHODS.createCurrentSessionScheduledTask, + request, + ), + ).resolves.toEqual({ id: 'cron-1', cron: request.cron }); + expect(handler).toHaveBeenCalledWith({ + ...request, + assertCallerPromptActive: expect.any(Function), + }); + }); + + it('lets the host revalidate the exact prompt before committing', async () => { + const { client, entry, handler } = makeCurrentSessionClient(); + handler.mockImplementation(async (info) => { + info.assertCallerPromptActive(); + entry.activePromptId = 'prompt-2'; + expect(() => info.assertCallerPromptActive()).toThrow(/active prompt/i); + throw new Error('stale prompt'); + }); + + await expect( + client.extMethod( + SERVE_CONTROL_EXT_METHODS.createCurrentSessionScheduledTask, + request, + ), + ).rejects.toThrow('stale prompt'); + }); + + it('preserves scheduled-task business rejections as structured ACP errors', async () => { + const { client, handler } = makeCurrentSessionClient(); + const rejection = new Error('The caller session has a pending interaction'); + rejection.name = 'ExistingSessionScheduledTaskCreateError'; + Object.assign(rejection, { status: 409, code: 'session_busy' }); + handler.mockRejectedValueOnce(rejection); + + const error = await client + .extMethod( + SERVE_CONTROL_EXT_METHODS.createCurrentSessionScheduledTask, + request, + ) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(RequestError); + expect(error).toMatchObject({ + code: -32602, + message: 'The caller session has a pending interaction', + data: { + errorKind: 'session_busy', + status: 409, + hint: 'The caller session has a pending interaction', + }, + }); + }); + + it('rejects a forged session or prompt identity', async () => { + const { client, handler } = makeCurrentSessionClient( + {}, + (sessionId) => sessionId === 'session-1', + ); + + await expect( + client.extMethod( + SERVE_CONTROL_EXT_METHODS.createCurrentSessionScheduledTask, + { ...request, callerSessionId: 'session-2' }, + ), + ).rejects.toThrow(/callerSessionId/i); + await expect( + client.extMethod( + SERVE_CONTROL_EXT_METHODS.createCurrentSessionScheduledTask, + { ...request, promptId: 'prompt-2' }, + ), + ).rejects.toThrow(/active prompt/i); + expect(handler).not.toHaveBeenCalled(); + }); + + it.each([ + { parentSessionId: 'parent-1' }, + { sourceType: 'channel' }, + { sourceType: 'scheduled_task' }, + { sourceType: 'standalone' }, + { sourceType: 'live_voice' }, + { sourceType: 'unknown' }, + { sourceId: 'source-1' }, + ])('rejects an ineligible session source: %j', async (overrides) => { + const { client, handler } = makeCurrentSessionClient(overrides); + + await expect( + client.extMethod( + SERVE_CONTROL_EXT_METHODS.createCurrentSessionScheduledTask, + request, + ), + ).rejects.toThrow(/source/i); + expect(handler).not.toHaveBeenCalled(); + }); +}); + describe('BridgeClient — Live screen-context extMethod dispatch', () => { function makeLiveClient( handler: diff --git a/packages/acp-bridge/src/bridgeClient.ts b/packages/acp-bridge/src/bridgeClient.ts index ab9a4b12f4a..788909ee75d 100644 --- a/packages/acp-bridge/src/bridgeClient.ts +++ b/packages/acp-bridge/src/bridgeClient.ts @@ -55,11 +55,13 @@ import type { ChannelDeliveryInfo, ClientMcpMessageSender, CreateSubSessionHandler, + CurrentSessionScheduledTaskCreateHandler, ExternalToolGuardHandler, LiveScreenContextCaptureHandler, LiveSpeakToUserHandler, LiveTaskToolRequestHandler, } from './bridgeOptions.js'; + import { CHANNEL_DELIVERY_ERROR_CODES, LIVE_TASK_TOOL_NAMES, @@ -94,6 +96,9 @@ import { type SessionAttachmentStore, } from './sessionAttachments.js'; +const MAX_SCHEDULED_TASK_CRON_CHARS = 200; +const MAX_SCHEDULED_TASK_PROMPT_CHARS = 100_000; + /** * Validate a channel-wide active-work snapshot off the wire. * @@ -192,6 +197,28 @@ function isFsErrorShape(err: unknown): err is FsErrorShape { ); } +interface ExistingSessionScheduledTaskCreateErrorShape { + name: 'ExistingSessionScheduledTaskCreateError'; + message: string; + status: number; + code: string; +} + +function isExistingSessionScheduledTaskCreateErrorShape( + err: unknown, +): err is ExistingSessionScheduledTaskCreateErrorShape { + if (!(err instanceof Error)) return false; + const status = (err as Error & { status?: unknown }).status; + const code = (err as Error & { code?: unknown }).code; + return ( + err.name === 'ExistingSessionScheduledTaskCreateError' && + typeof status === 'number' && + Number.isFinite(status) && + typeof code === 'string' && + code.length > 0 + ); +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -493,6 +520,17 @@ function preserveFsErrorOverAcp(err: unknown): never { throw err; } +function preserveScheduledTaskCreateErrorOverAcp(err: unknown): never { + if (isExistingSessionScheduledTaskCreateErrorShape(err)) { + throw new RequestError(err.status >= 500 ? -32603 : -32602, err.message, { + errorKind: err.code, + status: err.status, + hint: err.message, + }); + } + throw err; +} + /** * Translate the mediator's internal `PermissionResolution` to the * ACP-shaped `RequestPermissionResponse` the agent expects. @@ -617,6 +655,9 @@ export interface BridgeClientSessionEntry { sessionId: string; workspaceCwd: string; effectiveCwd: string; + parentSessionId?: string; + sourceType?: string; + sourceId?: string; events: EventBus; artifacts: SessionArtifactStore; attachments: SessionAttachmentStore; @@ -849,6 +890,7 @@ export class BridgeClient implements Client { * source-compatible. */ private readonly onGoalTurnEnded?: (sessionId: string) => void, + private readonly onCreateCurrentSessionScheduledTask?: CurrentSessionScheduledTaskCreateHandler, ) {} async requestPermission( @@ -1237,6 +1279,11 @@ export class BridgeClient implements Client { if (method === SERVE_CONTROL_EXT_METHODS.createSubSession) { return this.handleCreateSubSession(params); } + if ( + method === SERVE_CONTROL_EXT_METHODS.createCurrentSessionScheduledTask + ) { + return this.handleCreateCurrentSessionScheduledTask(params); + } if (method === SERVE_CONTROL_EXT_METHODS.liveCaptureScreenContext) { return this.handleLiveScreenContextCapture(params); } @@ -1839,6 +1886,121 @@ export class BridgeClient implements Client { }; } + private async handleCreateCurrentSessionScheduledTask( + params: Record, + ): Promise> { + if (!this.onCreateCurrentSessionScheduledTask) { + throw RequestError.methodNotFound( + SERVE_CONTROL_EXT_METHODS.createCurrentSessionScheduledTask, + ); + } + const callerSessionId = params['callerSessionId']; + const promptId = params['promptId']; + const cron = params['cron']; + const prompt = params['prompt']; + const recurring = params['recurring']; + if ( + typeof callerSessionId !== 'string' || + callerSessionId.length === 0 || + !this.ownsSession(callerSessionId) + ) { + throw RequestError.invalidParams( + undefined, + '`callerSessionId` must name a session owned by this connection', + ); + } + if (typeof promptId !== 'string' || promptId.length === 0) { + throw RequestError.invalidParams( + undefined, + '`promptId` must be a non-empty string', + ); + } + if ( + typeof cron !== 'string' || + cron.length === 0 || + cron.length > MAX_SCHEDULED_TASK_CRON_CHARS + ) { + throw RequestError.invalidParams( + undefined, + `\`cron\` must be a non-empty string within the ${MAX_SCHEDULED_TASK_CRON_CHARS}-character limit`, + ); + } + if ( + typeof prompt !== 'string' || + prompt.length === 0 || + prompt.length > MAX_SCHEDULED_TASK_PROMPT_CHARS + ) { + throw RequestError.invalidParams( + undefined, + `\`prompt\` must be non-empty and within the ${MAX_SCHEDULED_TASK_PROMPT_CHARS}-character limit`, + ); + } + if (typeof recurring !== 'boolean') { + throw RequestError.invalidParams( + undefined, + '`recurring` must be a boolean', + ); + } + + const entry = this.resolveEntry(callerSessionId); + if ( + !entry || + entry.sessionId !== callerSessionId || + entry.promptActive !== true || + entry.activePromptId !== promptId + ) { + throw RequestError.invalidParams( + undefined, + 'The caller session does not own the active prompt', + ); + } + if ( + entry.parentSessionId !== undefined || + entry.sourceId !== undefined || + (entry.sourceType !== undefined && entry.sourceType !== 'default') + ) { + throw RequestError.invalidParams( + undefined, + 'The caller session source cannot own a scheduled task', + ); + } + + const result = await this.onCreateCurrentSessionScheduledTask({ + callerSessionId, + promptId, + cron, + prompt, + recurring, + assertCallerPromptActive: () => { + const currentEntry = this.resolveEntry(callerSessionId); + if ( + currentEntry !== entry || + currentEntry.promptActive !== true || + currentEntry.activePromptId !== promptId + ) { + throw RequestError.invalidParams( + undefined, + 'The caller session no longer owns the active prompt', + ); + } + }, + }).catch((error: unknown) => + preserveScheduledTaskCreateErrorOverAcp(error), + ); + if ( + typeof result.id !== 'string' || + result.id.length === 0 || + typeof result.cron !== 'string' || + result.cron.length === 0 + ) { + throw RequestError.internalError( + undefined, + 'Scheduled-task host returned an invalid result', + ); + } + return { id: result.id, cron: result.cron }; + } + private async handleLiveScreenContextCapture( params: Record, ): Promise> { diff --git a/packages/acp-bridge/src/bridgeOptions.ts b/packages/acp-bridge/src/bridgeOptions.ts index 87a62c53f84..3090d5572b6 100644 --- a/packages/acp-bridge/src/bridgeOptions.ts +++ b/packages/acp-bridge/src/bridgeOptions.ts @@ -604,6 +604,9 @@ export interface BridgeOptions { * reports itself unavailable (daemon-only). */ onCreateSubSession?: CreateSubSessionHandler; + /** Handles a trusted `cron_create` request to bind a durable task to the + * caller's currently executing daemon session. */ + onCreateCurrentSessionScheduledTask?: CurrentSessionScheduledTaskCreateHandler; /** Handles one child-initiated Channel delivery attempt. The bridge * authenticates the session and publishes the sanitized result event. */ onChannelDelivery?: ChannelDeliveryHandler; @@ -677,6 +680,24 @@ export type CreateSubSessionHandler = ( info: CreateSubSessionInfo, ) => Promise; +export interface CurrentSessionScheduledTaskCreateInfo { + callerSessionId: string; + promptId: string; + cron: string; + prompt: string; + recurring: boolean; + assertCallerPromptActive: () => void; +} + +export interface CurrentSessionScheduledTaskCreateResult { + id: string; + cron: string; +} + +export type CurrentSessionScheduledTaskCreateHandler = ( + info: CurrentSessionScheduledTaskCreateInfo, +) => Promise; + export const MAX_LIVE_SCREEN_CONTEXT_TEXT_CHARS = 32_000; export interface LiveScreenContextCaptureInfo { diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index 26a05494ffa..e50c86e445c 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -221,6 +221,8 @@ export const SERVE_CONTROL_EXT_METHODS = { * `first-turn` mode, which waits for the sub-session's first turn to finish). */ createSubSession: 'qwen/control/create-sub-session', + createCurrentSessionScheduledTask: + 'qwen/control/scheduled-task/create-current', liveCaptureScreenContext: 'qwen/control/live/capture-screen-context', liveTaskTool: 'qwen/control/live/task-tool', liveSpeakToUser: 'qwen/control/live/speak-to-user', diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 090ae123347..daa0d5b3eb9 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -859,6 +859,8 @@ describe('Session', () => { getDisabledSkillNames: vi.fn().mockReturnValue(new Set()), setSubSessionSpawner: vi.fn(), getSubSessionSpawner: vi.fn(), + setCurrentSessionScheduledTaskCreator: vi.fn(), + getCurrentSessionScheduledTaskCreator: vi.fn(), getExtensions: vi.fn().mockReturnValue([]), } as unknown as Config; @@ -7666,6 +7668,7 @@ describe('Session', () => { delete process.env['QWEN_CODE_SERVE']; vi.mocked(mockConfig.setSubSessionSpawner).mockClear(); + vi.mocked(mockConfig.setCurrentSessionScheduledTaskCreator).mockClear(); const standalone = new Session( 'standalone-acp-session', mockConfig, @@ -7674,6 +7677,89 @@ describe('Session', () => { ); expect(standalone).toBeDefined(); expect(mockConfig.setSubSessionSpawner).not.toHaveBeenCalled(); + expect( + mockConfig.setCurrentSessionScheduledTaskCreator, + ).not.toHaveBeenCalled(); + }); + + it('uses the trusted daemon prompt id for a current-session task', async () => { + vi.mocked(mockClient.extMethod).mockResolvedValueOnce({ + id: 'cron-1', + cron: '5 9 * * *', + }); + const create = vi.mocked(mockConfig.setCurrentSessionScheduledTaskCreator) + .mock.calls[0]?.[0]; + expect(create).toBeTypeOf('function'); + + await expect( + core.runWithInvocationContext( + { + version: 1, + sessionId: 'test-session-id', + promptId: 'daemon-prompt-id', + }, + () => + create?.({ + cron: '5 9 * * *', + prompt: 'continue', + recurring: true, + promptId: 'test-session-id########1', + }), + ), + ).resolves.toEqual({ id: 'cron-1', cron: '5 9 * * *' }); + expect(mockClient.extMethod).toHaveBeenCalledWith( + 'qwen/control/scheduled-task/create-current', + { + callerSessionId: 'test-session-id', + promptId: 'daemon-prompt-id', + cron: '5 9 * * *', + prompt: 'continue', + recurring: true, + }, + ); + }); + + it('reports an explicit unavailable error for an older daemon bridge', async () => { + vi.mocked(mockClient.extMethod).mockRejectedValueOnce({ + code: -32601, + message: 'Method not found', + }); + const create = vi.mocked(mockConfig.setCurrentSessionScheduledTaskCreator) + .mock.calls[0]?.[0]; + + await expect( + create?.({ + cron: '5 9 * * *', + prompt: 'continue', + recurring: true, + promptId: 'prompt-1', + }), + ).rejects.toThrow(/current_session_scheduling_unavailable/); + }); + + it('surfaces structured daemon rejections to the current-session tool', async () => { + vi.mocked(mockClient.extMethod).mockRejectedValueOnce({ + code: -32602, + message: 'Invalid params', + data: { + errorKind: 'session_busy', + status: 409, + hint: 'The caller session has a pending interaction', + }, + }); + const create = vi.mocked(mockConfig.setCurrentSessionScheduledTaskCreator) + .mock.calls[0]?.[0]; + + await expect( + create?.({ + cron: '5 9 * * *', + prompt: 'continue', + recurring: true, + promptId: 'prompt-1', + }), + ).rejects.toThrow( + 'session_busy: The caller session has a pending interaction', + ); }); it('drops oldest background notifications when the queue reaches its cap', () => { diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index ba1c6ee4ec4..e39e433bd03 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -2009,6 +2009,7 @@ export class Session implements SessionContext { this.#bindGoalRuntime(); this.#registerBackgroundNotificationCallbacks(); this.#registerSubSessionSpawner(); + this.#registerCurrentSessionScheduledTaskCreator(); this.config .getWorkflowRunRegistry?.() .setApprovalRequestCallback((entry, approval, rawArgs, signal) => @@ -2986,6 +2987,60 @@ export class Session implements SessionContext { }); } + #registerCurrentSessionScheduledTaskCreator(): void { + if (process.env[QWEN_CODE_SERVE_ENV] !== '1') { + return; + } + this.config.setCurrentSessionScheduledTaskCreator(async (req) => { + let resp: Record; + try { + resp = await this.client.extMethod( + SERVE_CONTROL_EXT_METHODS.createCurrentSessionScheduledTask, + { + callerSessionId: this.sessionId, + promptId: getInvocationContext()?.promptId ?? req.promptId, + cron: req.cron, + prompt: req.prompt, + recurring: req.recurring, + }, + ); + } catch (error) { + const code = + error && typeof error === 'object' && 'code' in error + ? (error as { code?: unknown }).code + : undefined; + if (code === -32601) { + throw new Error( + 'current_session_scheduling_unavailable: The daemon does not support current-session scheduling.', + ); + } + const data = + isRecord(error) && isRecord(error['data']) + ? error['data'] + : undefined; + const errorKind = data?.['errorKind']; + if (typeof errorKind === 'string') { + const hint = data?.['hint']; + throw new Error( + `${errorKind}: ${typeof hint === 'string' && hint.length > 0 ? hint : 'Current-session scheduled task creation was rejected.'}`, + ); + } + throw error; + } + if ( + typeof resp['id'] !== 'string' || + resp['id'].length === 0 || + typeof resp['cron'] !== 'string' || + resp['cron'].length === 0 + ) { + throw new Error( + 'cron_create: bridge returned an invalid scheduled-task result', + ); + } + return { id: resp['id'], cron: resp['cron'] }; + }); + } + async enableLiveScreenContext(): Promise { const registry = this.config.getToolRegistry(); const existing = registry.getTool(CAPTURE_SCREEN_CONTEXT_TOOL_NAME); @@ -3467,6 +3522,7 @@ export class Session implements SessionContext { this.unsubscribeChatRecordingFailure?.(); this.unsubscribeChatRecordingFailure = undefined; this.config.setSubSessionSpawner(undefined); + this.config.setCurrentSessionScheduledTaskCreator(undefined); this.config .getWorkflowRunRegistry?.() .setApprovalRequestCallback(undefined); diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 46044d94f72..8a23cc0d158 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -112,6 +112,7 @@ export const SERVE_CAPABILITY_REGISTRY = { session_context_usage: { since: 'v1' }, session_supported_commands: { since: 'v1' }, session_tasks: { since: 'v1' }, + scheduled_task_session_reuse: { since: 'v1' }, session_monitor_tool_correlation: { since: 'v1' }, session_stats: { since: 'v1' }, session_lsp: { since: 'v1' }, @@ -466,6 +467,7 @@ export interface AdvertiseFeatureToggles { sessionShellCommandEnabled?: boolean; sessionArtifactsPersistenceAvailable?: boolean; sessionGenerationAvailable?: boolean; + currentSessionSchedulingAvailable?: boolean; workspaceGenerationAvailable?: boolean; rateLimit?: boolean; reloadAvailable?: boolean; @@ -580,6 +582,10 @@ export const CONDITIONAL_SERVE_FEATURES: ReadonlyMap< 'session_generation', (toggles) => toggles.sessionGenerationAvailable === true, ], + [ + 'scheduled_task_session_reuse', + (toggles) => toggles.currentSessionSchedulingAvailable === true, + ], [ 'workspace_generation', (toggles) => toggles.workspaceGenerationAvailable === true, diff --git a/packages/cli/src/serve/routes/scheduled-tasks.test.ts b/packages/cli/src/serve/routes/scheduled-tasks.test.ts index 96539fab08c..40c63f9c04c 100644 --- a/packages/cli/src/serve/routes/scheduled-tasks.test.ts +++ b/packages/cli/src/serve/routes/scheduled-tasks.test.ts @@ -22,6 +22,7 @@ import { SessionNotFoundError } from '@qwen-code/acp-bridge/bridgeErrors'; import { registerScheduledTasksRoutes, registerWorkspaceQualifiedScheduledTasksRoutes, + createScheduledTaskWithExistingSession, scheduledTaskSessionName, } from './scheduled-tasks.js'; import type { @@ -61,7 +62,10 @@ interface StubBridge { sessionId: string; workspaceCwd: string; hasActivePrompt: boolean; + pendingInteractionCount?: number; + parentSessionId?: string; sourceType?: string; + sourceId?: string; }; liveSessions: Map< string, @@ -69,7 +73,10 @@ interface StubBridge { sessionId: string; workspaceCwd: string; hasActivePrompt: boolean; + pendingInteractionCount?: number; + parentSessionId?: string; sourceType?: string; + sourceId?: string; } >; markSessionCatalogChanged: ReturnType; @@ -134,13 +141,26 @@ function addLiveSession( bridge: StubBridge, sessionId: string, workspaceCwd: string, - options: { busy?: boolean; sourceType?: string } = {}, + options: { + busy?: boolean; + pendingInteractionCount?: number; + parentSessionId?: string; + sourceType?: string; + sourceId?: string; + } = {}, ): void { bridge.liveSessions.set(sessionId, { sessionId, workspaceCwd, hasActivePrompt: options.busy === true, + ...(options.pendingInteractionCount !== undefined + ? { pendingInteractionCount: options.pendingInteractionCount } + : {}), + ...(options.parentSessionId !== undefined + ? { parentSessionId: options.parentSessionId } + : {}), ...(options.sourceType ? { sourceType: options.sourceType } : {}), + ...(options.sourceId ? { sourceId: options.sourceId } : {}), }); } @@ -760,6 +780,121 @@ describe('scheduled-tasks routes', () => { expect(await readCronTasks(h.workspace)).toEqual([]); }); + it('rejects a pending interaction and ineligible session sources', async () => { + addLiveSession(h.bridge, CALLER_SESSION_ID, h.workspace, { + pendingInteractionCount: 1, + }); + const pending = await create({ + cron: '0 9 * * *', + prompt: 'p', + sessionId: CALLER_SESSION_ID, + }); + expect(pending.status).toBe(409); + expect(pending.body.code).toBe('session_busy'); + + h.bridge.liveSessions.delete(CALLER_SESSION_ID); + for (const [index, options] of [ + { parentSessionId: 'parent-1' }, + { sourceType: 'channel' }, + { sourceType: 'standalone' }, + { sourceType: 'live_voice' }, + { sourceType: 'unknown' }, + { sourceId: 'source-1' }, + ].entries()) { + const sessionId = `10000000-0000-4000-8000-${String(index + 10).padStart(12, '0')}`; + addLiveSession(h.bridge, sessionId, h.workspace, options); + const response = await create({ + cron: '0 9 * * *', + prompt: 'p', + sessionId, + }); + expect(response.status).toBe(409); + expect(response.body.code).toBe('session_source_ineligible'); + } + expect(await readCronTasks(h.workspace)).toEqual([]); + }); + + it('allows the trusted cron-tool path to bind its active caller session', async () => { + addLiveSession(h.bridge, BUSY_SESSION_ID, h.workspace, { busy: true }); + + const task = await createScheduledTaskWithExistingSession( + { + workspaceCwd: h.workspace, + runtimeBaseDir: h.scratch, + bridge: h.bridge, + }, + { + sessionId: BUSY_SESSION_ID, + cron: '0 9 * * *', + prompt: 'continue', + recurring: true, + }, + { source: 'cron-tool', assertCallerPromptActive: () => undefined }, + ); + + expect(task).toEqual( + expect.objectContaining({ + sessionId: BUSY_SESSION_ID, + sessionOwnedByTask: false, + }), + ); + expect(task.lastFiredAt).not.toBeNull(); + expect(task.lastFiredAt! % 60_000).toBe(0); + }); + + it('rechecks the exact caller prompt inside the task-file lock', async () => { + addLiveSession(h.bridge, BUSY_SESSION_ID, h.workspace, { busy: true }); + let activePromptId = 'prompt-a'; + const assertCallerPromptActive = vi.fn(() => { + if (activePromptId !== 'prompt-a') throw new Error('stale prompt'); + activePromptId = 'prompt-b'; + }); + + await expect( + createScheduledTaskWithExistingSession( + { + workspaceCwd: h.workspace, + runtimeBaseDir: h.scratch, + bridge: h.bridge, + }, + { + sessionId: BUSY_SESSION_ID, + cron: '0 9 * * *', + prompt: 'continue', + recurring: true, + }, + { source: 'cron-tool', assertCallerPromptActive }, + ), + ).rejects.toThrow('stale prompt'); + expect(assertCallerPromptActive).toHaveBeenCalledTimes(2); + expect(await readCronTasks(h.workspace)).toEqual([]); + }); + + it('keeps pending interactions ineligible on the trusted cron-tool path', async () => { + addLiveSession(h.bridge, BUSY_SESSION_ID, h.workspace, { + busy: true, + pendingInteractionCount: 1, + }); + + await expect( + createScheduledTaskWithExistingSession( + { + workspaceCwd: h.workspace, + runtimeBaseDir: h.scratch, + bridge: h.bridge, + }, + { + sessionId: BUSY_SESSION_ID, + cron: '0 9 * * *', + prompt: 'continue', + recurring: true, + }, + { source: 'cron-tool', assertCallerPromptActive: () => undefined }, + ), + ).rejects.toMatchObject({ code: 'session_busy' }); + expect(await readCronTasks(h.workspace)).toEqual([]); + }); + it('rejects sessions reserved for scheduled tasks', async () => { addLiveSession(h.bridge, OTHER_SESSION_ID, h.workspace, { sourceType: 'scheduled_task', @@ -2167,6 +2302,7 @@ interface QualifiedHarness { secondary: QualifiedRuntime; untrusted: QualifiedRuntime; activity: ConversationRuntimeActivityGate; + workspaceRegistry: WorkspaceRegistry; } /** A registry stub exposing only what the qualified route resolver touches: @@ -2302,7 +2438,15 @@ async function makeQualifiedHarness(): Promise { manageScheduledTaskSessions: true, conversationRuntimeActivity: activity, }); - return { app, scratch, primary, secondary, untrusted, activity }; + return { + app, + scratch, + primary, + secondary, + untrusted, + activity, + workspaceRegistry, + }; } describe('workspace-qualified scheduled-tasks routes', () => { @@ -2404,6 +2548,37 @@ describe('workspace-qualified scheduled-tasks routes', () => { expect(h.secondary.bridge.spawned).toEqual([]); }); + it('maps owner lookup failures to a session error', async () => { + h.workspaceRegistry.resolveLiveSessionOwner = () => { + throw new Error('owner lookup failed'); + }; + + const res = await request(h.app).post('/scheduled-tasks').send({ + cron: '0 9 * * *', + prompt: 'p', + sessionId: CALLER_SESSION_ID, + }); + + expect(res.status).toBe(500); + expect(res.body.code).toBe('scheduled_tasks_session_failed'); + }); + + it('preserves the retry hint for an unavailable session owner', async () => { + h.workspaceRegistry.resolveLiveSessionOwner = () => ({ + kind: 'unavailable', + }); + + const res = await request(h.app).post('/scheduled-tasks').send({ + cron: '0 9 * * *', + prompt: 'p', + sessionId: CALLER_SESSION_ID, + }); + + expect(res.status).toBe(503); + expect(res.body.code).toBe('workspace_runtime_unavailable'); + expect(res.headers['retry-after']).toBe('1'); + }); + it('writes to the targeted workspace’s own cron file on disk', async () => { await request(h.app) .post(qualified(h.secondary.workspaceId)) diff --git a/packages/cli/src/serve/routes/scheduled-tasks.ts b/packages/cli/src/serve/routes/scheduled-tasks.ts index 146843c3799..19a152274e3 100644 --- a/packages/cli/src/serve/routes/scheduled-tasks.ts +++ b/packages/cli/src/serve/routes/scheduled-tasks.ts @@ -67,7 +67,6 @@ import { resolveWorkspaceRuntimeWithLiveCompatibilityFromParam, sendConversationRuntimeUnavailable, sendGenerationClosedError, - sendWorkspaceRuntimeUnavailable, } from '../workspace-route-runtime.js'; import type { ConversationRuntimeActivityGate } from '../conversations/conversation-runtime-activity.js'; @@ -106,7 +105,10 @@ export interface ScheduledTasksSessionBridge { getSessionSummary(sessionId: string): { workspaceCwd: string; hasActivePrompt: boolean; + pendingInteractionCount?: number; + parentSessionId?: string; sourceType?: string; + sourceId?: string; }; } @@ -149,7 +151,7 @@ export function scheduledTaskSessionName(label: string): string { * missing bridge means tasks are created unbound (shared per-project * durable-owner firing) — the same fallback a bridge-less embedding gets. */ -interface ScheduledTaskTarget { +export interface ScheduledTaskTarget { workspaceCwd: string; runtimeBaseDir?: string; bridge?: ScheduledTasksSessionBridge; @@ -159,6 +161,235 @@ interface ScheduledTaskTarget { resolveLiveSessionOwner?: WorkspaceRegistry['resolveLiveSessionOwner']; } +export interface ExistingSessionScheduledTaskCreateInput { + sessionId: string; + cron: string; + prompt: string; + recurring: boolean; + enabled?: boolean; + name?: string; + delivery?: CronTaskDelivery; +} + +export class ExistingSessionScheduledTaskCreateError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + ) { + super(message); + this.name = 'ExistingSessionScheduledTaskCreateError'; + } +} + +function assertReusableScheduledTaskSession( + target: ScheduledTaskTarget, + sessionId: string, + allowActivePrompt: boolean, + assertCallerPromptActive?: () => void, +): void { + const { bridge, workspaceCwd } = target; + if (!bridge) { + throw new ExistingSessionScheduledTaskCreateError( + 409, + 'session_binding_unavailable', + 'Session management is not available for this workspace', + ); + } + assertCallerPromptActive?.(); + let owner: + | ReturnType> + | undefined; + try { + owner = target.resolveLiveSessionOwner?.(sessionId); + } catch (error) { + writeStderrLine( + `qwen serve: failed to resolve scheduled-task session owner '${sessionId}': ${error instanceof Error ? error.message : String(error)}`, + ); + throw new ExistingSessionScheduledTaskCreateError( + 500, + 'scheduled_tasks_session_failed', + 'Failed to look up the requested session', + ); + } + if (owner?.kind === 'unavailable') { + throw new ExistingSessionScheduledTaskCreateError( + 503, + 'workspace_runtime_unavailable', + 'The workspace runtime is unavailable', + ); + } + if (owner?.kind === 'ambiguous') { + throw new ExistingSessionScheduledTaskCreateError( + 500, + 'ambiguous_session_owner', + `Session owner is ambiguous for "${sessionId}"`, + ); + } + if (owner?.kind === 'found' && owner.runtime.workspaceCwd !== workspaceCwd) { + throw new ExistingSessionScheduledTaskCreateError( + 400, + 'session_workspace_mismatch', + "The requested session belongs to a different workspace; use that workspace's scheduled-task endpoint", + ); + } + + let summary: ReturnType; + try { + summary = bridge.getSessionSummary(sessionId); + } catch (error) { + if (error instanceof SessionNotFoundError) { + throw new ExistingSessionScheduledTaskCreateError( + 404, + 'session_not_found', + `Session '${sessionId}' was not found`, + ); + } + writeStderrLine( + `qwen serve: failed to look up scheduled-task session '${sessionId}': ${error instanceof Error ? error.message : String(error)}`, + ); + throw new ExistingSessionScheduledTaskCreateError( + 500, + 'scheduled_tasks_session_failed', + 'Failed to look up the requested session', + ); + } + if (summary.workspaceCwd !== workspaceCwd) { + throw new ExistingSessionScheduledTaskCreateError( + 400, + 'session_workspace_mismatch', + "The requested session belongs to a different workspace; use that workspace's scheduled-task endpoint", + ); + } + if ( + (!allowActivePrompt && summary.hasActivePrompt) || + (summary.pendingInteractionCount ?? 0) > 0 + ) { + throw new ExistingSessionScheduledTaskCreateError( + 409, + 'session_busy', + allowActivePrompt + ? 'The caller session has a pending interaction; resolve it before binding the session to a task' + : 'The requested session is busy; wait for its active prompt or pending interaction to finish before binding it to a task', + ); + } + if (summary.sourceType === 'scheduled_task') { + throw new ExistingSessionScheduledTaskCreateError( + 409, + 'session_already_bound', + 'The requested session is already reserved for a scheduled task', + ); + } + if ( + summary.parentSessionId !== undefined || + summary.sourceId !== undefined || + (summary.sourceType !== undefined && summary.sourceType !== 'default') + ) { + throw new ExistingSessionScheduledTaskCreateError( + 409, + 'session_source_ineligible', + 'The requested session source cannot own a scheduled task', + ); + } +} + +export async function createScheduledTaskWithExistingSession( + target: ScheduledTaskTarget, + input: ExistingSessionScheduledTaskCreateInput, + options: + | { source: 'rest' } + | { source: 'cron-tool'; assertCallerPromptActive: () => void }, +): Promise { + if ( + input.cron.length === 0 || + input.cron.length > MAX_CRON_LENGTH || + validateCron(input.cron) !== null + ) { + throw new ExistingSessionScheduledTaskCreateError( + 400, + 'invalid_cron', + 'The scheduled-task cron expression is invalid', + ); + } + const prompt = input.prompt.trim(); + if (prompt.length === 0 || prompt.length > MAX_PROMPT_LENGTH) { + throw new ExistingSessionScheduledTaskCreateError( + 400, + 'invalid_prompt', + 'The scheduled-task prompt is invalid', + ); + } + const allowActivePrompt = options.source === 'cron-tool'; + const assertCallerPromptActive = + options.source === 'cron-tool' + ? options.assertCallerPromptActive + : undefined; + assertReusableScheduledTaskSession( + target, + input.sessionId, + allowActivePrompt, + assertCallerPromptActive, + ); + target.assertGenerationOpen?.(); + + const now = Date.now(); + const task: DurableCronTask = { + id: generateCronTaskId(), + cron: input.cron, + prompt, + recurring: input.recurring, + createdAt: now, + lastFiredAt: now - (now % 60_000), + enabled: input.enabled !== false, + sessionId: input.sessionId, + sessionOwnedByTask: false, + ...(input.delivery !== undefined ? { delivery: input.delivery } : {}), + ...(input.name !== undefined ? { name: input.name } : {}), + }; + let before: DurableCronTask[] | undefined; + let after: DurableCronTask[] | undefined; + await runWithScheduledTaskTarget(target, () => + updateCronTasks( + target.workspaceCwd, + (tasks) => { + assertReusableScheduledTaskSession( + target, + input.sessionId, + allowActivePrompt, + assertCallerPromptActive, + ); + if ( + tasks.some((candidate) => candidate.sessionId === input.sessionId) + ) { + throw new ExistingSessionScheduledTaskCreateError( + 409, + 'session_already_bound', + 'The requested session is already bound to another scheduled task', + ); + } + if (tasks.length >= MAX_SCHEDULED_TASKS) { + throw new ExistingSessionScheduledTaskCreateError( + 409, + 'max_tasks_reached', + `Maximum number of scheduled tasks (${MAX_SCHEDULED_TASKS}) reached`, + ); + } + before = tasks; + after = [...tasks, task]; + return after; + }, + { assertCanCommit: target.assertGenerationOpen }, + ), + ); + try { + target.assertGenerationOpen?.(); + } catch (error) { + await rollbackCronMutation(target, before, after, 'scheduled-task create'); + throw error; + } + return task; +} + function requireOpenGeneration( target: ScheduledTaskTarget, res: Response, @@ -565,85 +796,59 @@ function registerScheduledTaskCrudRoutes( const enabled = body['enabled'] !== false; const taskId = generateCronTaskId(); - let boundSessionId: string | undefined; - let sessionMintedHere = false; - if (providedSessionId !== undefined && !bridge) { - res.status(409).json({ - error: 'Session management is not available for this workspace', - code: 'session_binding_unavailable', - }); - return; - } - if (bridge) { - if (providedSessionId !== undefined) { - try { - const owner = target.resolveLiveSessionOwner?.(providedSessionId); - if (owner?.kind === 'unavailable') { - sendWorkspaceRuntimeUnavailable(res); - return; - } - if (owner?.kind === 'ambiguous') { - res.status(500).json({ - error: `Session owner is ambiguous for "${providedSessionId}"`, - code: 'ambiguous_session_owner', - }); - return; - } - if ( - owner?.kind === 'found' && - owner.runtime.workspaceCwd !== workspaceCwd - ) { - res.status(400).json({ - error: - "The requested session belongs to a different workspace; use that workspace's scheduled-task endpoint", - code: 'session_workspace_mismatch', - }); - return; - } - const summary = bridge.getSessionSummary(providedSessionId); - if (summary.workspaceCwd !== workspaceCwd) { - res.status(400).json({ - error: - "The requested session belongs to a different workspace; use that workspace's scheduled-task endpoint", - code: 'session_workspace_mismatch', - }); - return; - } - if (summary.hasActivePrompt) { - res.status(409).json({ - error: - 'The requested session is busy; wait for its active prompt to finish before binding it to a task', - code: 'session_busy', - }); - return; - } - if (summary.sourceType === 'scheduled_task') { - res.status(409).json({ - error: - 'The requested session is already reserved for a scheduled task', - code: 'session_already_bound', - }); - return; - } - } catch (err) { - if (err instanceof SessionNotFoundError) { - res.status(404).json({ - error: `Session '${providedSessionId}' was not found`, - code: 'session_not_found', - }); - return; - } - writeStderrLine( - `qwen serve: POST ${base} failed to look up session '${providedSessionId}': ${err instanceof Error ? err.message : String(err)}`, - ); - res.status(500).json({ - error: 'Failed to look up the requested session', - code: 'scheduled_tasks_session_failed', + if (providedSessionId !== undefined) { + try { + const task = await createScheduledTaskWithExistingSession( + target, + { + sessionId: providedSessionId, + cron, + prompt, + recurring, + enabled, + ...(delivery !== undefined ? { delivery } : {}), + ...(nameResult.value !== undefined + ? { name: nameResult.value } + : {}), + }, + { source: 'rest' }, + ); + if (task.delivery && task.sessionId) { + channelDeliveryAuthorizations?.registerScheduledTask(workspaceCwd, { + sessionId: task.sessionId, + taskId: task.id, + target: task.delivery.target, + recurring: task.recurring, + lastFiredAt: task.lastFiredAt ?? undefined, }); + } + res.status(201).json(toView(task)); + } catch (error) { + if (error instanceof ExistingSessionScheduledTaskCreateError) { + if (error.code === 'workspace_runtime_unavailable') { + res.set('Retry-After', '1'); + } + res + .status(error.status) + .json({ error: error.message, code: error.code }); return; } + if (sendActivityGateError(res, error)) return; + if (sendGenerationClosedError(res, error)) return; + writeStderrLine( + `qwen serve: POST ${base} failed to create a task for session '${providedSessionId}': ${error instanceof Error ? error.message : String(error)}`, + ); + res.status(500).json({ + error: 'Failed to create scheduled task', + code: 'scheduled_tasks_write_failed', + }); } + return; + } + let boundSessionId: string | undefined; + let sessionMintedHere = false; + if (bridge) { // Best-effort pre-check; the write-lock checks below are authoritative. try { const tasks = await runWithScheduledTaskTarget(target, () => @@ -660,47 +865,43 @@ function registerScheduledTaskCrudRoutes( // Read failure → skip the pre-check; the write below is authoritative. } if (!requireOpenGeneration(target, res)) return; - if (providedSessionId !== undefined) { - boundSessionId = providedSessionId; - } else { + try { + const session = await runWithScheduledTaskTarget(target, () => + bridge.spawnOrAttach({ + workspaceCwd, + sessionScope: 'thread', + sourceType: 'scheduled_task', + sourceId: taskId, + }), + ); + boundSessionId = session.sessionId; + sessionMintedHere = true; + if (!requireOpenGeneration(target, res)) { + await teardownBoundSession(target, boundSessionId); + return; + } try { - const session = await runWithScheduledTaskTarget(target, () => - bridge.spawnOrAttach({ - workspaceCwd, - sessionScope: 'thread', - sourceType: 'scheduled_task', - sourceId: taskId, + await runWithScheduledTaskTarget(target, async () => + bridge.updateSessionMetadata(boundSessionId!, { + displayName: scheduledTaskSessionName( + nameResult.value ?? prompt, + ), }), ); - boundSessionId = session.sessionId; - sessionMintedHere = true; - if (!requireOpenGeneration(target, res)) { - await teardownBoundSession(target, boundSessionId); - return; - } - try { - await runWithScheduledTaskTarget(target, async () => - bridge.updateSessionMetadata(boundSessionId!, { - displayName: scheduledTaskSessionName( - nameResult.value ?? prompt, - ), - }), - ); - } catch { - // metadata update is non-critical - } - } catch (err) { - if (sendActivityGateError(res, err)) return; - if (sendGenerationClosedError(res, err)) return; - writeStderrLine( - `qwen serve: POST ${base} failed to create the task's session: ${err instanceof Error ? err.message : String(err)}`, - ); - res.status(500).json({ - error: "Failed to create the task's session", - code: 'scheduled_tasks_session_failed', - }); - return; + } catch { + // metadata update is non-critical } + } catch (err) { + if (sendActivityGateError(res, err)) return; + if (sendGenerationClosedError(res, err)) return; + writeStderrLine( + `qwen serve: POST ${base} failed to create the task's session: ${err instanceof Error ? err.message : String(err)}`, + ); + res.status(500).json({ + error: "Failed to create the task's session", + code: 'scheduled_tasks_session_failed', + }); + return; } } @@ -719,9 +920,6 @@ function registerScheduledTaskCrudRoutes( ...(boundSessionId !== undefined ? { sessionId: boundSessionId, - ...(providedSessionId !== undefined - ? { sessionOwnedByTask: false } - : {}), } : {}), ...(nameResult.value !== undefined ? { name: nameResult.value } : {}), @@ -740,8 +938,6 @@ function registerScheduledTaskCrudRoutes( }; let overCap = false; - let alreadyBound = false; - let sessionNoLongerLive = false; let rollbackBefore: DurableCronTask[] | undefined; let rollbackAfter: DurableCronTask[] | undefined; try { @@ -749,30 +945,6 @@ function registerScheduledTaskCrudRoutes( updateCronTasks( workspaceCwd, (tasks) => { - if ( - providedSessionId !== undefined && - tasks.some((task) => task.sessionId === providedSessionId) - ) { - alreadyBound = true; - return tasks; - } - if (providedSessionId !== undefined && bridge) { - try { - if ( - bridge.getSessionSummary(providedSessionId).sourceType === - 'scheduled_task' - ) { - alreadyBound = true; - return tasks; - } - } catch (err) { - if (err instanceof SessionNotFoundError) { - sessionNoLongerLive = true; - return tasks; - } - throw err; - } - } // Cap check under the write lock so two concurrent creates can't both // slip past a stale count. Returning the input unchanged is a no-op // (no write), which the flag below turns into a 409. @@ -823,21 +995,6 @@ function registerScheduledTaskCrudRoutes( }); return; } - if (alreadyBound) { - res.status(409).json({ - error: - 'The requested session is already bound to another scheduled task', - code: 'session_already_bound', - }); - return; - } - if (sessionNoLongerLive) { - res.status(404).json({ - error: `Session '${providedSessionId}' was not found`, - code: 'session_not_found', - }); - return; - } if (task.delivery && task.sessionId) { channelDeliveryAuthorizations?.registerScheduledTask(workspaceCwd, { sessionId: task.sessionId, diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index dfa2feafe06..144d4445673 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -1545,6 +1545,7 @@ describe('runQwenServe telemetry validation', () => { expect(body.workspaceCwd).toBe(canonicalizeWorkspace(primary)); expect(body.features).toContain('multi_workspace_sessions'); expect(body.features).toContain('workspace_runtime_removal'); + expect(body.features).toContain('scheduled_task_session_reuse'); expect(body.limits.maxTotalSessions).toBe(2); expect(body.limits.sessionRestoreTimeoutMs).toBe(90_000); expect(body.workspaces).toEqual([ @@ -1564,6 +1565,9 @@ describe('runQwenServe telemetry validation', () => { index, [bridgeOptions], ] of createBridge.mock.calls.entries()) { + expect(bridgeOptions.onCreateCurrentSessionScheduledTask).toBeTypeOf( + 'function', + ); const target = path.join( tmpDir, `static-runtime-external-${index}.txt`, @@ -1847,6 +1851,12 @@ describe('runQwenServe telemetry validation', () => { expect(createBridge.mock.calls[1]?.[0].onChannelDelivery).toBeTypeOf( 'function', ); + expect( + createBridge.mock.calls[0]?.[0].onCreateCurrentSessionScheduledTask, + ).toBeTypeOf('function'); + expect( + createBridge.mock.calls[1]?.[0].onCreateCurrentSessionScheduledTask, + ).toBeTypeOf('function'); expect(createBridge.mock.calls[1]?.[0]).toMatchObject({ permissionPolicy: 'local-only', sessionRestoreTimeoutMs: 90_000, @@ -5946,6 +5956,13 @@ describe('runQwenServe runtime startup failures', () => { await new Promise((resolve) => setTimeout(resolve, 250)); expect(resolveTelemetrySettings).not.toHaveBeenCalled(); expect(createBridge).not.toHaveBeenCalled(); + const bootstrapCapabilities = (await ( + await fetch(`${handle.url}/capabilities`) + ).json()) as { features: string[] }; + expect(bootstrapCapabilities.features).not.toContain( + 'scheduled_task_session_reuse', + ); + expect(createBridge).not.toHaveBeenCalled(); const healthRes = await fetch(`${handle.url}/health`); expect(healthRes.status).toBe(200); expect(await healthRes.json()).toEqual({ status: 'ok' }); @@ -5955,6 +5972,12 @@ describe('runQwenServe runtime startup failures', () => { }); expect(resolveTelemetrySettings).toHaveBeenCalledTimes(1); await expect(handle.runtimeReady).resolves.toBeUndefined(); + const runtimeCapabilities = (await ( + await fetch(`${handle.url}/capabilities`) + ).json()) as { features: string[] }; + expect(runtimeCapabilities.features).toContain( + 'scheduled_task_session_reuse', + ); await handle.close(); closed = true; @@ -7932,6 +7955,9 @@ describe('runQwenServe runtime startup failures', () => { }); expect(capabilitiesBody.features).not.toContain('client_mcp_over_ws'); expect(capabilitiesBody.features).not.toContain('cdp_tunnel_over_ws'); + expect(capabilitiesBody.features).not.toContain( + 'scheduled_task_session_reuse', + ); const port = new URL(handle.url).port; for (const origin of [ diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index a68cabcf1e2..d183ea7f259 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -150,6 +150,7 @@ import type { PermissionPolicy } from '@qwen-code/acp-bridge'; import type { ChannelDeliveryHandler, ChannelDeliveryHostResult, + CurrentSessionScheduledTaskCreateHandler, ExternalToolGuardHandler, } from '@qwen-code/acp-bridge/bridgeOptions'; import { getCliVersion } from '../utils/version.js'; @@ -1258,6 +1259,7 @@ function currentServeFeaturesForRunQwenServe( opts: ServeOptions, sessionShellCommandEnabled: boolean, sessionArtifactsPersistenceAvailable: boolean, + currentSessionSchedulingAvailable: boolean, env: Readonly>, ): string[] { return getAdvertisedServeFeatures(undefined, { @@ -1276,6 +1278,7 @@ function currentServeFeaturesForRunQwenServe( sessionShellCommandEnabled, sessionArtifactsPersistenceAvailable, sessionGenerationAvailable: true, + currentSessionSchedulingAvailable, workspaceGenerationAvailable: true, rateLimit: opts.rateLimit === true, reloadAvailable: true, @@ -1298,6 +1301,7 @@ function createBootstrapCapabilities(input: { qwenCodeVersion?: string; sessionShellCommandEnabled: boolean; sessionArtifactsPersistenceAvailable: boolean; + currentSessionSchedulingAvailable: boolean; permissionPolicy: PermissionPolicy | undefined; env: Readonly>; }): CapabilitiesEnvelope { @@ -1312,6 +1316,7 @@ function createBootstrapCapabilities(input: { input.opts, input.sessionShellCommandEnabled, input.sessionArtifactsPersistenceAvailable, + input.currentSessionSchedulingAvailable, input.env, ), modelServices: [], @@ -1505,6 +1510,7 @@ function createBootstrapServeApp(input: { qwenCodeVersion?: string; sessionShellCommandEnabled: boolean; sessionArtifactsPersistenceAvailable: boolean; + currentSessionSchedulingAvailable: boolean; permissionPolicy: PermissionPolicy | undefined; multiWorkspaceCapabilitiesRequireRuntime: boolean; getRuntimeError: () => string | undefined; @@ -1523,6 +1529,7 @@ function createBootstrapServeApp(input: { qwenCodeVersion, sessionShellCommandEnabled, sessionArtifactsPersistenceAvailable, + currentSessionSchedulingAvailable, permissionPolicy, multiWorkspaceCapabilitiesRequireRuntime, getRuntimeError, @@ -1588,6 +1595,7 @@ function createBootstrapServeApp(input: { qwenCodeVersion, sessionShellCommandEnabled, sessionArtifactsPersistenceAvailable, + currentSessionSchedulingAvailable, permissionPolicy, env: process.env, }), @@ -1727,6 +1735,7 @@ function createBootstrapServeApp(input: { opts, sessionShellCommandEnabled, sessionArtifactsPersistenceAvailable, + currentSessionSchedulingAvailable, process.env, ), }, @@ -4281,9 +4290,55 @@ async function runQwenServeImpl( // from a child's agent turn and (for 'first-turn') return its result. // Dynamic-imported (not at module scope) so the serve fast-path bundle // closure check doesn't trace create-sub-session's transitive deps. - const { createSubSessionLauncher } = await import( - './create-sub-session.js' - ); + const [{ createSubSessionLauncher }, scheduledTaskRoutes] = + await Promise.all([ + import('./create-sub-session.js'), + import('./routes/scheduled-tasks.js'), + ]); + const createCurrentSessionScheduledTaskHandler = + ( + workspaceCwd: string, + runtimeBaseDir: string, + getBridge: () => AcpSessionBridge | undefined, + assertGenerationOpen: () => void, + ): CurrentSessionScheduledTaskCreateHandler => + async ({ + callerSessionId, + cron, + prompt, + recurring, + assertCallerPromptActive, + }) => { + const targetBridge = getBridge(); + if (!targetBridge) { + throw new Error( + 'Current-session scheduling is unavailable while the workspace runtime is starting.', + ); + } + const task = + await scheduledTaskRoutes.createScheduledTaskWithExistingSession( + { + workspaceCwd, + runtimeBaseDir, + bridge: targetBridge, + assertGenerationOpen, + resolveLiveSessionOwner: (sessionId) => + workspaceRegistryForPersistence.current === undefined + ? { kind: 'unavailable' } + : workspaceRegistryForPersistence.current.resolveLiveSessionOwner( + sessionId, + ), + }, + { + sessionId: callerSessionId, + cron, + prompt, + recurring, + }, + { source: 'cron-tool', assertCallerPromptActive }, + ); + return { id: task.id, cron: task.cron }; + }; // Late-binds the bridge (constructed just below) via `() => bridgeRef`. Only // wired on the daemon-created bridge — an injected `deps.bridge` (embed/test) // brings its own options. @@ -4305,6 +4360,13 @@ async function runQwenServeImpl( // connection that hosts a named client MCP server (#5626). clientMcpSender: clientMcpSenderRegistry.lookup, onCreateSubSession: subSessionLauncher.launch, + onCreateCurrentSessionScheduledTask: + createCurrentSessionScheduledTaskHandler( + boundWorkspace, + primarySessionRuntimeBaseDir, + () => bridgeRef, + () => primaryGenerationGuard.assertOpen(), + ), onChannelDelivery: createBoundChannelDeliveryHandler( boundWorkspace, () => channelWorkerManager, @@ -4750,6 +4812,13 @@ async function runQwenServeImpl( ), clientMcpSender: secondaryClientMcpSenderRegistry.lookup, onCreateSubSession: secondarySubSessionLauncher.launch, + onCreateCurrentSessionScheduledTask: + createCurrentSessionScheduledTaskHandler( + workspaceInput.cwd, + secondaryEnv.sessionRuntimeBaseDir, + () => secondaryBridgeRef, + () => secondaryGenerationGuard.assertOpen(), + ), onChannelDelivery: createBoundChannelDeliveryHandler( workspaceInput.cwd, () => channelWorkerManager, @@ -5322,6 +5391,13 @@ async function runQwenServeImpl( ), clientMcpSender: wsClientMcpRegistry.lookup, onCreateSubSession: wsSubSessionLauncher.launch, + onCreateCurrentSessionScheduledTask: + createCurrentSessionScheduledTaskHandler( + cwd, + wsEnv.sessionRuntimeBaseDir, + () => wsBridgeRef, + () => generationGuard.assertOpen(), + ), onChannelDelivery: createBoundChannelDeliveryHandler( cwd, () => channelWorkerManager, @@ -6016,6 +6092,7 @@ async function runQwenServeImpl( // (keepalive) and reloads them on boot (rehydration). Off by default so // direct createServeApp embeds/tests don't spawn sessions. manageScheduledTaskSessions: true, + currentSessionSchedulingAvailable: deps.bridge === undefined, fsFactory: routeFsFactory, primaryWorkspaceTrusted: trustedWorkspace, primaryRuntimeEnv, @@ -6384,6 +6461,7 @@ async function runQwenServeImpl( qwenCodeVersion: cliVersion, sessionShellCommandEnabled, sessionArtifactsPersistenceAvailable, + currentSessionSchedulingAvailable: false, permissionPolicy, multiWorkspaceCapabilitiesRequireRuntime: workspaceInputs.length > 1, getRuntimeError: () => runtimeStartupError, diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 7ed168e17d7..01a25b67171 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -685,6 +685,9 @@ const EXPECTED_REGISTERED_FEATURES = [ if (feature === 'mcp_guardrail_events') { return [feature, 'external_tool_guard']; } + if (feature === 'session_tasks') { + return [feature, 'scheduled_task_session_reuse']; + } return [feature]; }).filter( (f) => @@ -2972,6 +2975,24 @@ describe('createServeApp', () => { ); continue; } + if (feature === 'scheduled_task_session_reuse') { + expect(predicate({ currentSessionSchedulingAvailable: true })).toBe( + true, + ); + expect(predicate({ currentSessionSchedulingAvailable: false })).toBe( + false, + ); + expect(predicate({})).toBe(false); + expect( + getAdvertisedServeFeatures(undefined, { + currentSessionSchedulingAvailable: true, + }), + ).toContain(feature); + expect(getAdvertisedServeFeatures(undefined, {})).not.toContain( + feature, + ); + continue; + } if (feature === 'workspace_generation') { expect(predicate({ workspaceGenerationAvailable: true })).toBe(true); expect(predicate({ workspaceGenerationAvailable: false })).toBe( @@ -3986,6 +4007,31 @@ describe('createServeApp', () => { expect(unsupported.body.features).not.toContain('session_generation'); }); + it('advertises current-session scheduling only with managed task sessions', async () => { + const unmanaged = await request( + createServeApp(baseOpts, undefined, { + bridge: fakeBridge(), + currentSessionSchedulingAvailable: true, + }), + ) + .get('/capabilities') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(unmanaged.body.features).not.toContain( + 'scheduled_task_session_reuse', + ); + + const managed = await request( + createServeApp(baseOpts, undefined, { + bridge: fakeBridge(), + manageScheduledTaskSessions: true, + currentSessionSchedulingAvailable: true, + }), + ) + .get('/capabilities') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(managed.body.features).toContain('scheduled_task_session_reuse'); + }); + it('advertises workspace generation only when the primary bridge supports it', async () => { const supportedBridge = fakeBridge(); supportedBridge.generateWorkspaceContent = async function* () {}; diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 1bc1a092652..3afc0834d19 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -435,6 +435,9 @@ export interface ServeAppDeps { * a heartbeat timer. */ manageScheduledTaskSessions?: boolean; + /** Advertise current-session task binding only when every managed daemon + * runtime installs the private cron-tool callback. */ + currentSessionSchedulingAvailable?: boolean; /** * Directory of the built Web Shell SPA (`index.html` + `assets/`). When * set (and `opts.serveWebShell !== false`), `createServeApp` mounts the @@ -926,6 +929,9 @@ export function createServeApp( ) ); }, + currentSessionSchedulingAvailable: + deps.manageScheduledTaskSessions === true && + deps.currentSessionSchedulingAvailable === true, workspaceGenerationAvailable: () => { const entry = workspaceRegistry.primaryEntry; const runtime = diff --git a/packages/cli/src/serve/server/serve-features.ts b/packages/cli/src/serve/server/serve-features.ts index fa73e9ca29c..58da5769f67 100644 --- a/packages/cli/src/serve/server/serve-features.ts +++ b/packages/cli/src/serve/server/serve-features.ts @@ -44,6 +44,7 @@ interface CreateServeFeaturesDeps { persistSettingAvailable: boolean; sessionArtifactsPersistenceAvailable: boolean; sessionGenerationAvailable: () => boolean; + currentSessionSchedulingAvailable: boolean; workspaceGenerationAvailable: () => boolean; reloadAvailable: boolean; channelReloadAvailable: () => boolean; @@ -78,6 +79,7 @@ export function createServeFeatures( persistSettingAvailable, sessionArtifactsPersistenceAvailable, sessionGenerationAvailable, + currentSessionSchedulingAvailable, workspaceGenerationAvailable, reloadAvailable, channelReloadAvailable, @@ -131,6 +133,7 @@ export function createServeFeatures( sessionShellCommandEnabled, sessionArtifactsPersistenceAvailable, sessionGenerationAvailable: sessionGenerationAvailable(), + currentSessionSchedulingAvailable, workspaceGenerationAvailable: workspaceGenerationAvailable(), rateLimit: opts.rateLimit === true, reloadAvailable, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index a49dce42449..37585fb7841 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1754,6 +1754,24 @@ export type SubSessionSpawner = ( req: SubSessionSpawnRequest, ) => Promise; +export interface CurrentSessionScheduledTaskCreateRequest { + cron: string; + prompt: string; + recurring: boolean; + promptId: string; +} + +export interface CurrentSessionScheduledTaskCreateResult { + id: string; + cron: string; +} + +/** Daemon-only capability used by `cron_create` to bind a durable task to the + * session whose active turn is executing the tool. */ +export type CurrentSessionScheduledTaskCreator = ( + req: CurrentSessionScheduledTaskCreateRequest, +) => Promise; + /** * A higher-priority static DashScope thinking knob that shadows the global * reasoning-effort tier on the wire (see getReasoningEffortOverride). @@ -9127,6 +9145,8 @@ export class Config { private subSessionSpawner?: SubSessionSpawner; + private currentSessionScheduledTaskCreator?: CurrentSessionScheduledTaskCreator; + /** * Wire the sub-session spawner used by the `create_sub_session` tool. Set by * the daemon/ACP session layer (which routes it to the bridge over @@ -9141,4 +9161,16 @@ export class Config { getSubSessionSpawner(): SubSessionSpawner | undefined { return this.subSessionSpawner; } + + setCurrentSessionScheduledTaskCreator( + creator: CurrentSessionScheduledTaskCreator | undefined, + ): void { + this.currentSessionScheduledTaskCreator = creator; + } + + getCurrentSessionScheduledTaskCreator(): + | CurrentSessionScheduledTaskCreator + | undefined { + return this.currentSessionScheduledTaskCreator; + } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5453a928a14..b810aebb6da 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -241,6 +241,11 @@ export type { PublishedArtifact, } from './tools/artifact/publisher.js'; export type { CronCreateTool, CronCreateParams } from './tools/cron-create.js'; +export type { + CurrentSessionScheduledTaskCreateRequest, + CurrentSessionScheduledTaskCreateResult, + CurrentSessionScheduledTaskCreator, +} from './config/config.js'; export type { CronListTool, CronListParams } from './tools/cron-list.js'; export type { CronDeleteTool, CronDeleteParams } from './tools/cron-delete.js'; export type { ToolSearchTool, ToolSearchParams } from './tools/tool-search.js'; diff --git a/packages/core/src/tools/cron-create.test.ts b/packages/core/src/tools/cron-create.test.ts index a58eec4009e..1a57dba9f9e 100644 --- a/packages/core/src/tools/cron-create.test.ts +++ b/packages/core/src/tools/cron-create.test.ts @@ -6,15 +6,21 @@ import { CronCreateTool } from './cron-create.js'; import { CronScheduler } from '../services/cronScheduler.js'; import { readCronTasks } from '../services/cronTasksFile.js'; import { Storage } from '../config/storage.js'; +import { promptIdContext } from '../utils/promptIdContext.js'; +import type { CurrentSessionScheduledTaskCreator } from '../config/config.js'; let tmpDir: string; -function makeConfig(maxAgeDays = 7) { +function makeConfig( + maxAgeDays = 7, + currentSessionCreator?: CurrentSessionScheduledTaskCreator, +) { const scheduler = new CronScheduler(tmpDir, maxAgeDays * 24 * 60 * 60 * 1000); return { getCronScheduler: () => scheduler, getCronRecurringMaxAgeDays: () => maxAgeDays, getProjectRoot: () => tmpDir, + getCurrentSessionScheduledTaskCreator: () => currentSessionCreator, _scheduler: scheduler, } as unknown as import('../config/config.js').Config & { _scheduler: CronScheduler; @@ -128,6 +134,97 @@ describe('CronCreateTool', () => { expect(tasks).toHaveLength(0); }); + it('creates a durable task in the current daemon session', async () => { + const requests: Array[0]> = + []; + config = makeConfig(7, async (request) => { + requests.push(request); + return { id: 'cron-current', cron: request.cron }; + }); + tool = new CronCreateTool(config); + + const result = await promptIdContext.run('prompt-1', () => + tool + .build({ + cron: '*/5 * * * *', + prompt: ' keep working ', + durable: true, + sessionMode: 'current', + }) + .execute(new AbortController().signal), + ); + + expect(result.error).toBeUndefined(); + expect(result.returnDisplay).toContain('cron-current'); + expect(result.returnDisplay).toContain('[current conversation]'); + expect(result.llmContent).toContain('bound to the current conversation'); + expect(requests).toEqual([ + { + cron: '*/5 * * * *', + prompt: 'keep working', + recurring: true, + promptId: 'prompt-1', + }, + ]); + expect(config._scheduler.list()).toHaveLength(0); + expect(await readCronTasks(tmpDir)).toHaveLength(0); + }); + + it('surfaces a plain-object daemon error for current-session mode', async () => { + const message = 'The caller session does not own the active prompt'; + config = makeConfig(7, async () => { + throw { code: -32602, message }; + }); + tool = new CronCreateTool(config); + + const result = await promptIdContext.run('prompt-1', () => + tool + .build({ + cron: '*/5 * * * *', + prompt: 'keep working', + durable: true, + sessionMode: 'current', + }) + .execute(new AbortController().signal), + ); + + expect(result.llmContent).toBe(`Error creating cron job: ${message}`); + expect(result.returnDisplay).toBe(message); + expect(result.error).toEqual({ message }); + }); + + it('rejects current-session mode for a session-only job', async () => { + const result = await tool + .build({ + cron: '*/5 * * * *', + prompt: 'keep working', + sessionMode: 'current', + }) + .execute(new AbortController().signal); + + expect(result.error?.message).toContain('requires durable: true'); + expect(config._scheduler.list()).toHaveLength(0); + }); + + it('rejects current-session mode without an active daemon prompt', async () => { + config = makeConfig(7, async () => ({ + id: 'unexpected', + cron: '*/5 * * * *', + })); + tool = new CronCreateTool(config); + + const result = await tool + .build({ + cron: '*/5 * * * *', + prompt: 'keep working', + durable: true, + sessionMode: 'current', + }) + .execute(new AbortController().signal); + + expect(result.error?.message).toContain('active daemon prompt'); + }); + it.each(['', ' '])('rejects blank prompt %j', (prompt) => { expect(() => tool.build({ diff --git a/packages/core/src/tools/cron-create.ts b/packages/core/src/tools/cron-create.ts index 1a092895a1d..9ad60379a92 100644 --- a/packages/core/src/tools/cron-create.ts +++ b/packages/core/src/tools/cron-create.ts @@ -10,6 +10,8 @@ import type { PermissionDecision } from '../permissions/types.js'; import { parseCron, nextFireTime } from '../utils/cronParser.js'; import { humanReadableCron } from '../utils/cronDisplay.js'; import { CRON_TASKS_DISPLAY_PATH } from '../services/cronTasksFile.js'; +import { promptIdContext } from '../utils/promptIdContext.js'; +import { getErrorMessage } from '../utils/errors.js'; /** "1 day" / "7 days" / "0.5 days". Callers handle the Infinity case. */ function formatDays(days: number): string { @@ -44,6 +46,7 @@ export interface CronCreateParams { prompt: string; recurring?: boolean; durable?: boolean; + sessionMode?: 'unbound' | 'current'; } class CronCreateInvocation extends BaseToolInvocation< @@ -77,6 +80,7 @@ class CronCreateInvocation extends BaseToolInvocation< const scheduler = this.config.getCronScheduler(); const recurring = this.params.recurring !== false; const durable = this.params.durable === true; + const useCurrentSession = this.params.sessionMode === 'current'; const prompt = this.params.prompt.trim(); try { @@ -87,15 +91,39 @@ class CronCreateInvocation extends BaseToolInvocation< // silently never fire. Throws with a clear message. nextFireTime(this.params.cron, new Date()); - const job = durable - ? await scheduler.createDurable(this.params.cron, prompt, recurring) - : scheduler.create(this.params.cron, prompt, recurring); + if (useCurrentSession && !durable) { + throw new Error( + 'Current-session scheduling requires durable: true because session-only jobs cannot survive a daemon session switch.', + ); + } + + let job: { id: string; cronExpr: string }; + if (useCurrentSession) { + const creator = this.config.getCurrentSessionScheduledTaskCreator(); + const promptId = promptIdContext.getStore(); + if (!creator || !promptId) { + throw new Error( + 'current_session_scheduling_unavailable: Current-session scheduling requires an active daemon prompt.', + ); + } + const created = await creator({ + cron: this.params.cron, + prompt, + recurring, + promptId, + }); + job = { id: created.id, cronExpr: created.cron }; + } else { + job = durable + ? await scheduler.createDurable(this.params.cron, prompt, recurring) + : scheduler.create(this.params.cron, prompt, recurring); + } const display = humanReadableCron(job.cronExpr); - const returnDisplay = `Scheduled ${job.id} (${display})${durable ? ' [durable]' : ''}`; + const returnDisplay = `Scheduled ${job.id} (${display})${durable ? ' [durable]' : ''}${useCurrentSession ? ' [current conversation]' : ''}`; const where = durable - ? `Persisted to ${CRON_TASKS_DISPLAY_PATH}` + ? `Persisted to ${CRON_TASKS_DISPLAY_PATH}${useCurrentSession ? ' and bound to the current conversation' : ''}` : 'Session-only (not written to disk, dies when Qwen Code exits)'; const maxAgeDays = this.config.getCronRecurringMaxAgeDays(); const expiry = Number.isFinite(maxAgeDays) @@ -109,7 +137,7 @@ class CronCreateInvocation extends BaseToolInvocation< return { llmContent, returnDisplay }; } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = getErrorMessage(error); return { llmContent: `Error creating cron job: ${message}`, returnDisplay: message, @@ -153,6 +181,10 @@ export class CronCreateTool extends BaseDeclarativeTool< `Pass durable: true to write to ${CRON_TASKS_DISPLAY_PATH} so the job survives restarts. ` + 'Only use durable: true when the user explicitly asks for persistence ("keep doing this every day", "set this up permanently"). ' + 'Most "remind me in 5 minutes" requests should stay session-only.\n\n' + + '## Session binding\n\n' + + 'By default (sessionMode: "unbound") a durable task stays unbound and uses the existing per-project scheduler owner; it does not reuse this conversation. ' + + 'Use sessionMode: "current" only when the user explicitly wants future runs to continue in this conversation. ' + + 'Current-session mode requires durable: true and an active daemon-backed prompt.\n\n' + '## Runtime behavior\n\n' + 'Jobs only fire while the REPL is idle (not mid-query). The scheduler adds a small deterministic jitter on top of whatever you pick: recurring tasks fire up to 10% of their period late (max 15 min); one-shot tasks landing on :00 or :30 fire up to 90 s early. Picking an off-minute is still the bigger lever.\n\n' + `${recurringExpiryBlurb(maxAgeDays)}\n\n` + @@ -184,6 +216,12 @@ export class CronCreateTool extends BaseDeclarativeTool< type: 'boolean', description: `true = persist to ${CRON_TASKS_DISPLAY_PATH} and survive restarts. false (default) = in-memory only, dies when Qwen Code exits. Use true only when the user asks the task to survive across sessions.`, }, + sessionMode: { + type: 'string', + enum: ['unbound', 'current'], + description: + 'unbound (default) = preserve the existing unbound durable scheduler behavior. current = bind a durable task to this daemon conversation; requires durable: true.', + }, }, required: ['cron', 'prompt'], additionalProperties: false, @@ -227,6 +265,7 @@ export class CronCreateTool extends BaseDeclarativeTool< prompt: params.prompt, recurring: params.recurring ?? true, durable: params.durable ?? false, + sessionMode: params.sessionMode ?? 'unbound', }; } } diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index e7b59201470..308ad09564c 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -466,6 +466,11 @@ const { onCreateViaChat?: () => void; workspaces?: Array<{ id: string; cwd: string }>; lockedWorkspace?: { id: string; cwd: string; primary: boolean }; + currentSession?: { + sessionId?: string; + pendingInteractionCount?: number; + }; + currentSessionSchedulingAvailable?: boolean; } | null, latestGoalsProps: null as { onCreateGoal?: (condition: string) => Promise; @@ -1557,6 +1562,11 @@ vi.doMock('./components/dialogs/ScheduledTasksDialog', async () => { onRunPrompt?: (prompt: string, sessionId: string | null) => Promise; workspaces?: Array<{ id: string; cwd: string }>; lockedWorkspace?: { id: string; cwd: string; primary: boolean }; + currentSession?: { + sessionId?: string; + pendingInteractionCount?: number; + }; + currentSessionSchedulingAvailable?: boolean; }) => { testState.latestScheduledTasksProps = props; return React.createElement('div'); @@ -10567,6 +10577,61 @@ describe('App session callbacks', () => { ); }); + it('refreshes pending interactions when the live prompt boundary settles', async () => { + mockWorkspace.capabilities = { + features: ['scheduled_task_session_reuse'], + workspaces: [ + { id: 'primary', cwd: '/workspace', primary: true, trusted: true }, + ], + }; + const activeStatus = deferred(); + const settledStatus = deferred(); + mockWorkspace.client.sessionStatus + .mockReturnValueOnce(activeStatus.promise) + .mockReturnValueOnce(settledStatus.promise); + testState.sessionHasActivePrompt = true; + testState.prompt = '/schedule'; + const { container, rerender } = renderApp(); + await flush(); + await clickSubmit(container); + await flush(); + + testState.sessionHasActivePrompt = false; + rerender(); + await flush(); + + settledStatus.resolve({ + sessionId: mockConnection.sessionId, + workspaceCwd: mockConnection.workspaceCwd, + hasActivePrompt: false, + pendingInteractionCount: 0, + }); + await flush(); + await vi.waitFor(() => { + expect( + testState.latestScheduledTasksProps?.currentSession + ?.pendingInteractionCount, + ).toBe(0); + }); + + activeStatus.resolve({ + sessionId: mockConnection.sessionId, + workspaceCwd: mockConnection.workspaceCwd, + hasActivePrompt: true, + pendingInteractionCount: 1, + }); + await flush(); + + expect( + testState.latestScheduledTasksProps?.currentSession + ?.pendingInteractionCount, + ).toBe(0); + expect( + testState.latestScheduledTasksProps?.currentSessionSchedulingAvailable, + ).toBe(true); + expect(mockWorkspace.client.sessionStatus).toHaveBeenCalledTimes(2); + }); + it('uses configured composer placeholders by state and falls back for blank values', async () => { const composerPlaceholders = { idle: 'Ask a question', diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index d2091847344..c84fbbfc56d 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -47,6 +47,7 @@ import type { DaemonSessionShellTaskStatus, DaemonSessionTaskStatus, DaemonSessionArtifact, + DaemonSessionSummary, DaemonWorkspaceCapability, DaemonWorkspaceGitStatus, GoalSnapshotV2, @@ -2395,16 +2396,21 @@ export function App({ const [sessionStatusDisplayName, setSessionStatusDisplayName] = useState< string | undefined >(undefined); + const [currentSessionSummary, setCurrentSessionSummary] = useState< + DaemonSessionSummary | undefined + >(undefined); // Tracks the logical session from the latest effect run. In-flight fetches // compare their captured key against this ref on resolve: a match means // the response is still relevant and may set OR clear the worktree state; // a mismatch means connection.sessionId moved on (reconnect cycling or a // user-initiated switch) and the stale response is dropped. const worktreeSessionKeyRef = useRef(undefined); + const sessionStatusRequestRef = useRef(0); useLayoutEffect(() => { setSessionWorktree(undefined); setSessionBranch(undefined); setSessionStatusDisplayName(undefined); + setCurrentSessionSummary(undefined); }, [logicalSessionKey]); // Restore worktree info from the server when switching to an existing // session. The effect intentionally does NOT cancel in-flight fetches on @@ -2415,11 +2421,13 @@ export function App({ const sid = connection.sessionId; const sessionKey = logicalSessionKey; const owner = sessionOwnerGuard.capture(); + const requestId = ++sessionStatusRequestRef.current; worktreeSessionKeyRef.current = sessionKey; if (!sid) { setSessionWorktree(undefined); setSessionBranch(undefined); setSessionStatusDisplayName(undefined); + setCurrentSessionSummary(undefined); return; } if ( @@ -2432,10 +2440,15 @@ export function App({ workspace.client .sessionStatus(sid) .then((summary) => { - if (worktreeSessionKeyRef.current === sessionKey && owner.isCurrent()) { + if ( + sessionStatusRequestRef.current === requestId && + worktreeSessionKeyRef.current === sessionKey && + owner.isCurrent() + ) { setSessionWorktree(summary.worktree); setSessionBranch(summary.branch); setSessionStatusDisplayName(summary.displayName); + setCurrentSessionSummary(summary); } return loadSessionCatalogOnce( workspace.client, @@ -2449,6 +2462,7 @@ export function App({ .then((page) => { if ( worktreeSessionKeyRef.current !== sessionKey || + sessionStatusRequestRef.current !== requestId || !owner.isCurrent() ) { return; @@ -2463,10 +2477,15 @@ export function App({ .catch(() => undefined); }) .catch(() => { - if (worktreeSessionKeyRef.current === sessionKey && owner.isCurrent()) { + if ( + sessionStatusRequestRef.current === requestId && + worktreeSessionKeyRef.current === sessionKey && + owner.isCurrent() + ) { setSessionWorktree(undefined); setSessionBranch(undefined); setSessionStatusDisplayName(undefined); + setCurrentSessionSummary(undefined); } }); }, [ @@ -2478,6 +2497,7 @@ export function App({ connection.workspaceCwd, logicalSessionKey, sessionOwnerGuard, + sessionHasActivePrompt, workspace.client, ]); // Active workspace: the connected session's workspace, else the workspace @@ -12507,6 +12527,19 @@ export function App({ : ordinaryWorkspaces } lockedWorkspace={lockedWorkspaceCapability} + currentSession={ + currentSessionSummary + ? { + ...currentSessionSummary, + hasActivePrompt: + currentSessionSummary.hasActivePrompt === true || + sessionHasActivePrompt, + } + : undefined + } + currentSessionSchedulingAvailable={workspace.capabilities?.features?.includes( + 'scheduled_task_session_reuse', + )} onCreateViaChat={() => { // Start a FRESH session and jump to it so the task- // creation chat doesn't pile onto the current diff --git a/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.test.tsx b/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.test.tsx index 53bad7448f6..fa2adcb2704 100644 --- a/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.test.tsx +++ b/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.test.tsx @@ -71,6 +71,23 @@ async function mount( sessionId: string | null, ) => void | Promise; onError?: (error: unknown, message: string) => void; + currentSession?: { + sessionId: string; + workspaceCwd: string; + parentSessionId?: string; + sourceType?: string; + sourceId?: string; + hasActivePrompt?: boolean; + pendingInteractionCount?: number; + }; + currentSessionSchedulingAvailable?: boolean; + lockedWorkspace?: { + id: string; + cwd: string; + primary: boolean; + trusted: boolean; + kind: 'ordinary'; + }; } = {}, ) { actions.listScheduledTasks.mockResolvedValue(tasks); @@ -92,21 +109,35 @@ async function mount( document.body.appendChild(container); document.body.appendChild(portalRoot); root = createRoot(container); - await act(async () => { - root!.render( - - - - - , - ); - }); + const render = async (nextOpts: typeof opts) => { + await act(async () => { + root!.render( + + + + + , + ); + }); + }; + await render(opts); await flush(); + return { + rerender: async (nextOpts: typeof opts) => { + await render(nextOpts); + await flush(); + }, + }; } // Flush the async list load (and any post-action reload) so state settles. @@ -191,6 +222,176 @@ const baseTask = (over: Partial): MockTask => ({ }); describe('ScheduledTasksDialog editing', () => { + const currentSession = { + sessionId: '10000000-0000-4000-8000-000000000001', + workspaceCwd: '/repo/main', + sourceType: 'default', + }; + + async function enterPromptAndCreate(value: string) { + const prompt = document.querySelector('[role="textbox"]'); + if (!prompt) throw new Error('prompt editor not found'); + act(() => { + prompt.textContent = value; + prompt.dispatchEvent(new InputEvent('input', { bubbles: true })); + }); + click(findButton('Create')); + await flush(); + } + + it('hides session binding without the daemon capability', async () => { + await mount([], { currentSession }); + + click(findButton('New scheduled task')); + + expect(document.body.textContent).not.toContain('Current conversation'); + }); + + it('defaults to a dedicated task conversation and omits sessionId', async () => { + actions.createScheduledTask.mockResolvedValue(baseTask({})); + await mount([], { + currentSession, + currentSessionSchedulingAvailable: true, + }); + + click(findButton('New scheduled task')); + const sessionSelect = Array.from(document.querySelectorAll('select')).find( + (select) => select.querySelector('option[value="current"]'), + ); + expect(sessionSelect?.value).toBe('dedicated'); + await enterPromptAndCreate('continue later'); + + expect(actions.createScheduledTask).toHaveBeenCalledWith( + expect.not.objectContaining({ sessionId: expect.anything() }), + undefined, + ); + }); + + it('sends the outer current session only after an explicit selection', async () => { + actions.createScheduledTask.mockResolvedValue(baseTask({})); + await mount([], { + currentSession, + currentSessionSchedulingAvailable: true, + }); + + click(findButton('New scheduled task')); + const sessionSelect = Array.from(document.querySelectorAll('select')).find( + (select) => select.querySelector('option[value="current"]'), + ); + act(() => { + sessionSelect!.value = 'current'; + sessionSelect!.dispatchEvent(new Event('change', { bubbles: true })); + }); + await enterPromptAndCreate('continue later'); + + expect(actions.createScheduledTask).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: currentSession.sessionId }), + undefined, + ); + }); + + it('returns to a dedicated session when the capability disappears', async () => { + actions.createScheduledTask.mockResolvedValue(baseTask({})); + const { rerender } = await mount([], { + currentSession, + currentSessionSchedulingAvailable: true, + }); + + click(findButton('New scheduled task')); + const sessionSelect = Array.from(document.querySelectorAll('select')).find( + (select) => select.querySelector('option[value="current"]'), + ); + act(() => { + sessionSelect!.value = 'current'; + sessionSelect!.dispatchEvent(new Event('change', { bubbles: true })); + }); + await rerender({ currentSession }); + expect(document.querySelector('option[value="current"]')).toBeNull(); + await enterPromptAndCreate('continue later'); + + expect(actions.createScheduledTask).toHaveBeenCalledWith( + expect.not.objectContaining({ sessionId: expect.anything() }), + undefined, + ); + }); + + it('reenables the current conversation when its interaction resolves', async () => { + const { rerender } = await mount([], { + currentSession: { ...currentSession, pendingInteractionCount: 1 }, + currentSessionSchedulingAvailable: true, + }); + + click(findButton('New scheduled task')); + expect( + document.querySelector('option[value="current"]') + ?.disabled, + ).toBe(true); + await rerender({ + currentSession: { ...currentSession, pendingInteractionCount: 0 }, + currentSessionSchedulingAvailable: true, + }); + expect( + document.querySelector('option[value="current"]') + ?.disabled, + ).toBe(false); + }); + + it.each([ + ['missing', undefined, [], undefined], + ['busy', { ...currentSession, hasActivePrompt: true }, [], undefined], + [ + 'pending interaction', + { ...currentSession, pendingInteractionCount: 1 }, + [], + undefined, + ], + [ + 'child session', + { ...currentSession, parentSessionId: 'parent-1' }, + [], + undefined, + ], + [ + 'channel source', + { ...currentSession, sourceType: 'channel' }, + [], + undefined, + ], + [ + 'workspace mismatch', + currentSession, + [], + { + id: 'other', + cwd: '/repo/other', + primary: false, + trusted: true, + kind: 'ordinary' as const, + }, + ], + [ + 'existing task binding', + currentSession, + [baseTask({ sessionId: currentSession.sessionId })], + undefined, + ], + ])( + 'disables current-session binding for %s', + async (_name, session, tasks, lockedWorkspace) => { + await mount(tasks, { + currentSession: session, + currentSessionSchedulingAvailable: true, + lockedWorkspace, + }); + + click(findButton('New scheduled task')); + const option = document.querySelector( + 'option[value="current"]', + ); + expect(option?.disabled).toBe(true); + }, + ); + it('keeps the prompt placeholder outside the editable textbox', async () => { await mount([]); diff --git a/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.tsx b/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.tsx index 1afc092e820..db3c5b6a349 100644 --- a/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.tsx +++ b/packages/web-shell/client/components/dialogs/ScheduledTasksDialog.tsx @@ -23,6 +23,7 @@ import type { DaemonWorkspaceCapability, DaemonWorkspaceMcpServerStatus, DaemonWorkspaceSkillStatus, + DaemonSessionSummary, } from '@qwen-code/sdk/daemon'; import { sanitizeDisplayText } from '../../hooks/useAtMentionMenu'; import { useI18n } from '../../i18n'; @@ -91,6 +92,8 @@ interface ScheduledTasksDialogProps { workspaces?: DaemonWorkspaceCapability[]; /** Forces all task operations through this workspace's route. */ lockedWorkspace?: DaemonWorkspaceCapability; + currentSession?: DaemonSessionSummary; + currentSessionSchedulingAvailable?: boolean; onError: (error: unknown, fallback: string) => void; } @@ -529,6 +532,8 @@ export function ScheduledTasksDialog({ onOpenSession, workspaces, lockedWorkspace, + currentSession, + currentSessionSchedulingAvailable, onError, }: ScheduledTasksDialogProps) { const { t } = useI18n(); @@ -586,6 +591,12 @@ export function ScheduledTasksDialog({ const [name, setName] = useState(''); const [prompt, setPrompt] = useState(''); const [builder, setBuilder] = useState(DEFAULT_BUILDER); + const [sessionMode, setSessionMode] = useState<'dedicated' | 'current'>( + 'dedicated', + ); + useEffect(() => { + if (!currentSessionSchedulingAvailable) setSessionMode('dedicated'); + }, [currentSessionSchedulingAvailable]); const [submitting, setSubmitting] = useState(false); const [formError, setFormError] = useState(null); const [referenceKind, setReferenceKind] = useState( @@ -735,6 +746,43 @@ export function ScheduledTasksDialog({ const previewCron = buildCron(builder); const previewLabel = previewCron ? describeCron(previewCron, t) : null; + const formWorkspace = lockedWorkspace + ? lockedWorkspace + : operableWorkspaces.find( + (workspace) => workspaceActionId(workspace) === formWorkspaceId, + ); + const currentSessionDisabledReason = (() => { + if (!currentSessionSchedulingAvailable) { + return t('scheduledTasks.session.currentUnsupported'); + } + if (!currentSession?.sessionId) { + return t('scheduledTasks.session.currentUnavailable'); + } + if ( + currentSession.hasActivePrompt || + (currentSession.pendingInteractionCount ?? 0) > 0 + ) { + return t('scheduledTasks.session.currentBusy'); + } + if ( + currentSession.parentSessionId !== undefined || + currentSession.sourceId !== undefined || + (currentSession.sourceType !== undefined && + currentSession.sourceType !== 'default') + ) { + return t('scheduledTasks.session.currentIneligible'); + } + if ( + formWorkspace?.cwd !== undefined && + currentSession.workspaceCwd !== formWorkspace.cwd + ) { + return t('scheduledTasks.session.currentWorkspaceMismatch'); + } + if (tasks?.some((task) => task.sessionId === currentSession.sessionId)) { + return t('scheduledTasks.session.currentAlreadyBound'); + } + return null; + })(); const updateReferencePickerPosition = useCallback(() => { const anchor = referencePopoverRef.current; @@ -892,6 +940,7 @@ export function ScheduledTasksDialog({ setName(''); setPrompt(''); setBuilder(DEFAULT_BUILDER); + setSessionMode('dedicated'); setFormError(null); setShowForm(false); setEditingId(null); @@ -907,6 +956,7 @@ export function ScheduledTasksDialog({ setName(''); setPrompt(''); setBuilder(DEFAULT_BUILDER); + setSessionMode('dedicated'); setFormError(null); resetReferenceState(); setShowForm(true); @@ -923,6 +973,7 @@ export function ScheduledTasksDialog({ // Reverse the cron back onto the pickers; an expression the pickers can't // represent lands in the `custom` field, never silently rewritten. setBuilder(parseCronToBuilder(task.cron)); + setSessionMode('dedicated'); setFormError(null); resetReferenceState(); setShowForm(true); @@ -948,6 +999,15 @@ export function ScheduledTasksDialog({ ); return; } + if (!editingId && sessionMode === 'current') { + if (currentSessionDisabledReason || !currentSession?.sessionId) { + setFormError( + currentSessionDisabledReason ?? + t('scheduledTasks.session.currentUnavailable'), + ); + return; + } + } setSubmitting(true); setFormError(null); try { @@ -972,6 +1032,9 @@ export function ScheduledTasksDialog({ name: name.trim() || null, recurring: true, enabled: true, + ...(sessionMode === 'current' && currentSession?.sessionId + ? { sessionId: currentSession.sessionId } + : {}), }, formWorkspaceId, ); @@ -988,12 +1051,15 @@ export function ScheduledTasksDialog({ }, [ actions, builder, + currentSession, + currentSessionDisabledReason, editingId, formWorkspaceId, name, prompt, reload, resetForm, + sessionMode, t, ]); @@ -1232,6 +1298,43 @@ export function ScheduledTasksDialog({ )} + {!editingId && currentSessionSchedulingAvailable && ( + + )} +