Skip to content
347 changes: 347 additions & 0 deletions docs/design/2026-08-24-scheduled-task-current-session-entrypoints.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/developers/qwen-serve-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Comment thread
doudouOUC marked this conversation as resolved.
| `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. |
Expand Down
1 change: 1 addition & 0 deletions packages/acp-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
() =>
Expand Down
104 changes: 102 additions & 2 deletions packages/acp-bridge/src/bridgeClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,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');
Expand All @@ -104,7 +111,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)
Expand All @@ -113,7 +122,9 @@ function makeClient(
undefined,
undefined,
undefined,
managedGuard?.ownsSession ?? (() => true),
managedGuard?.ownsSession ??
currentSessionTask?.ownsSession ??
(() => true),
undefined,
undefined,
undefined,
Expand All @@ -124,6 +135,10 @@ function makeClient(
undefined,
undefined,
managedGuard?.handler,
undefined,
undefined,
undefined,
currentSessionTask?.handler,
);
}

Expand Down Expand Up @@ -1649,6 +1664,91 @@ 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<string, unknown> = {},
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 () => ({ id: 'cron-1', cron: request.cron }));
const client = makeClient(undefined, undefined, {
resolveEntry: (sessionId) =>
sessionId === entry.sessionId ? entry : undefined,
ownsSession,
handler,
});
return { client, 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);
});

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:
Expand Down
114 changes: 114 additions & 0 deletions packages/acp-bridge/src/bridgeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -617,6 +622,9 @@ export interface BridgeClientSessionEntry {
sessionId: string;
workspaceCwd: string;
effectiveCwd: string;
parentSessionId?: string;
sourceType?: string;
sourceId?: string;
Comment thread
doudouOUC marked this conversation as resolved.
events: EventBus;
artifacts: SessionArtifactStore;
attachments: SessionAttachmentStore;
Expand Down Expand Up @@ -849,6 +857,7 @@ export class BridgeClient implements Client {
* source-compatible.
*/
private readonly onGoalTurnEnded?: (sessionId: string) => void,
private readonly onCreateCurrentSessionScheduledTask?: CurrentSessionScheduledTaskCreateHandler,
) {}

async requestPermission(
Expand Down Expand Up @@ -1237,6 +1246,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);
}
Expand Down Expand Up @@ -1839,6 +1853,106 @@ export class BridgeClient implements Client {
};
}

private async handleCreateCurrentSessionScheduledTask(
params: Record<string, unknown>,
): Promise<Record<string, unknown>> {
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 ||

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Source-eligibility from in-memory bridge entry may be incomplete after restart (minor): The check here reads parentSessionId, sourceType, and sourceId from the live BridgeClientSessionEntry. If a session reconnects after a daemon restart and the bridge entry is rebuilt without restoring these lineage fields, the guard passes for a parented sub-session or scheduled-task-source session whose entry fields are undefined.

The assertReusableScheduledTaskSession call downstream — both the pre-validation pass and the write-lock recheck — repeats an equivalent check via bridge.getSessionSummary(), which reads from persisted session data. That fallback provides defense-in-depth. If entries are always rehydrated with lineage fields on reconnect, the fast-path check here is redundant but correct; if they are not, only getSessionSummary catches the case.

Not a blocker given the defense-in-depth path, but worth confirming that BridgeClientSessionEntry entries are always populated with parentSessionId/sourceType/sourceId during session rehydration.

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,
});
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<string, unknown>,
): Promise<Record<string, unknown>> {
Expand Down
20 changes: 20 additions & 0 deletions packages/acp-bridge/src/bridgeOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -677,6 +680,23 @@ export type CreateSubSessionHandler = (
info: CreateSubSessionInfo,
) => Promise<CreateSubSessionResult>;

export interface CurrentSessionScheduledTaskCreateInfo {
callerSessionId: string;
promptId: string;
cron: string;
prompt: string;
recurring: boolean;
}

export interface CurrentSessionScheduledTaskCreateResult {
id: string;
cron: string;
}

export type CurrentSessionScheduledTaskCreateHandler = (
info: CurrentSessionScheduledTaskCreateInfo,
) => Promise<CurrentSessionScheduledTaskCreateResult>;

export const MAX_LIVE_SCREEN_CONTEXT_TEXT_CHARS = 32_000;

export interface LiveScreenContextCaptureInfo {
Expand Down
2 changes: 2 additions & 0 deletions packages/acp-bridge/src/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading