Skip to content

Commit 5c65b19

Browse files
doudouOUCqwencoder
andcommitted
feat(daemon): Support current-session scheduled tasks
Co-authored-by: Qwen-Coder <[email protected]>
1 parent d48f949 commit 5c65b19

24 files changed

Lines changed: 1344 additions & 183 deletions

‎docs/design/2026-08-24-scheduled-task-current-session-entrypoints.md‎

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -193,24 +193,21 @@ tool maps to `current_session_scheduling_unavailable`.
193193

194194
The host callback and the REST route share a focused
195195
`createScheduledTaskWithExistingSession` command extracted from the #9361
196-
provided-session branch. The command accepts an internal binding context:
196+
provided-session branch. The command accepts the internal creation source:
197197

198198
```ts
199-
type ExistingSessionBindingContext =
200-
| { source: 'rest' }
201-
| {
202-
source: 'cron-tool';
203-
callerSessionId: string;
204-
callerPromptId: string;
205-
};
199+
type ExistingSessionCreateOptions = {
200+
source: 'rest' | 'cron-tool';
201+
};
206202
```
207203

208-
Both paths apply the same session-id normalization, selected-runtime and
209-
workspace ownership, archive state, scheduled-task-source, capacity,
210-
generation, and unique-binding checks. Only the prompt-matched trusted
211-
cron-tool path may skip the idle rejection, and only after the bridge matched
212-
the internally stamped caller session and prompt ids to the live active prompt.
213-
Public REST never skips the idle check.
204+
The `cron-tool` source is supplied only by the private host callback after the
205+
bridge has matched the internally stamped caller session and prompt ids to the
206+
live active prompt. Both paths apply the same selected-runtime and workspace
207+
ownership, archive state, scheduled-task-source, capacity, generation, and
208+
unique-binding checks. Only that prompt-matched trusted path may skip the active
209+
prompt rejection; pending interactions remain ineligible. Public REST never
210+
skips either idle check.
214211

215212
The final write-lock check remains authoritative. It revalidates that the
216213
session is live and not task-reserved, rejects a concurrent binding, and writes

‎packages/acp-bridge/src/bridge.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4058,6 +4058,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
40584058
// A Goal turn drains the mid-turn queue but owns no prompt slot, so
40594059
// nothing else would settle what its last drain missed.
40604060
settleMidTurnQueueAfterGoalTurn,
4061+
opts.onCreateCurrentSessionScheduledTask,
40614062
);
40624063
const rawConnection = new ClientSideConnection(
40634064
() =>

‎packages/acp-bridge/src/bridgeClient.test.ts‎

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,13 @@ function makeClient(
9393
ownsSession?: (sessionId: string) => boolean;
9494
handler: ExternalToolGuardHandler;
9595
},
96+
currentSessionTask?: {
97+
resolveEntry: (sessionId?: string) => unknown;
98+
ownsSession?: (sessionId: string) => boolean;
99+
handler: NonNullable<
100+
import('./bridgeOptions.js').BridgeOptions['onCreateCurrentSessionScheduledTask']
101+
>;
102+
},
96103
): BridgeClient {
97104
const noPermissionFlow = () => {
98105
throw new Error('test: permission flow should not run in fs-path tests');
@@ -104,7 +111,9 @@ function makeClient(
104111
// required (policy/vote/forgetSession/peekSessionFor/pendingCount).
105112
const throwerMediator = { request: noPermissionFlow } as never;
106113
return new BridgeClient(
107-
(managedGuard?.resolveEntry ?? noPermissionFlow) as never, // resolveEntry
114+
(managedGuard?.resolveEntry ??
115+
currentSessionTask?.resolveEntry ??
116+
noPermissionFlow) as never, // resolveEntry
108117
noPermissionFlow as never, // resolvePendingRestoreEvents
109118
throwerMediator, // mediator (F3 Commit 3)
110119
0, // permissionTimeoutMs (disabled)
@@ -113,7 +122,9 @@ function makeClient(
113122
undefined,
114123
undefined,
115124
undefined,
116-
managedGuard?.ownsSession ?? (() => true),
125+
managedGuard?.ownsSession ??
126+
currentSessionTask?.ownsSession ??
127+
(() => true),
117128
undefined,
118129
undefined,
119130
undefined,
@@ -124,6 +135,10 @@ function makeClient(
124135
undefined,
125136
undefined,
126137
managedGuard?.handler,
138+
undefined,
139+
undefined,
140+
undefined,
141+
currentSessionTask?.handler,
127142
);
128143
}
129144

@@ -1649,6 +1664,91 @@ describe('BridgeClient — create-sub-session extMethod dispatch', () => {
16491664
});
16501665
});
16511666

1667+
describe('BridgeClient — current-session scheduled-task dispatch', () => {
1668+
const request = {
1669+
callerSessionId: 'session-1',
1670+
promptId: 'prompt-1',
1671+
cron: '5 9 * * *',
1672+
prompt: 'continue the work',
1673+
recurring: true,
1674+
};
1675+
1676+
function makeCurrentSessionClient(
1677+
overrides: Record<string, unknown> = {},
1678+
ownsSession: (sessionId: string) => boolean = () => true,
1679+
) {
1680+
const entry = {
1681+
sessionId: 'session-1',
1682+
workspaceCwd: '/workspace',
1683+
effectiveCwd: '/workspace',
1684+
promptActive: true,
1685+
activePromptId: 'prompt-1',
1686+
...overrides,
1687+
};
1688+
const handler = vi.fn(async () => ({ id: 'cron-1', cron: request.cron }));
1689+
const client = makeClient(undefined, undefined, {
1690+
resolveEntry: (sessionId) =>
1691+
sessionId === entry.sessionId ? entry : undefined,
1692+
ownsSession,
1693+
handler,
1694+
});
1695+
return { client, handler };
1696+
}
1697+
1698+
it('forwards only the bridge-owned active prompt', async () => {
1699+
const { client, handler } = makeCurrentSessionClient();
1700+
1701+
await expect(
1702+
client.extMethod(
1703+
SERVE_CONTROL_EXT_METHODS.createCurrentSessionScheduledTask,
1704+
request,
1705+
),
1706+
).resolves.toEqual({ id: 'cron-1', cron: request.cron });
1707+
expect(handler).toHaveBeenCalledWith(request);
1708+
});
1709+
1710+
it('rejects a forged session or prompt identity', async () => {
1711+
const { client, handler } = makeCurrentSessionClient(
1712+
{},
1713+
(sessionId) => sessionId === 'session-1',
1714+
);
1715+
1716+
await expect(
1717+
client.extMethod(
1718+
SERVE_CONTROL_EXT_METHODS.createCurrentSessionScheduledTask,
1719+
{ ...request, callerSessionId: 'session-2' },
1720+
),
1721+
).rejects.toThrow(/callerSessionId/i);
1722+
await expect(
1723+
client.extMethod(
1724+
SERVE_CONTROL_EXT_METHODS.createCurrentSessionScheduledTask,
1725+
{ ...request, promptId: 'prompt-2' },
1726+
),
1727+
).rejects.toThrow(/active prompt/i);
1728+
expect(handler).not.toHaveBeenCalled();
1729+
});
1730+
1731+
it.each([
1732+
{ parentSessionId: 'parent-1' },
1733+
{ sourceType: 'channel' },
1734+
{ sourceType: 'scheduled_task' },
1735+
{ sourceType: 'standalone' },
1736+
{ sourceType: 'live_voice' },
1737+
{ sourceType: 'unknown' },
1738+
{ sourceId: 'source-1' },
1739+
])('rejects an ineligible session source: %j', async (overrides) => {
1740+
const { client, handler } = makeCurrentSessionClient(overrides);
1741+
1742+
await expect(
1743+
client.extMethod(
1744+
SERVE_CONTROL_EXT_METHODS.createCurrentSessionScheduledTask,
1745+
request,
1746+
),
1747+
).rejects.toThrow(/source/i);
1748+
expect(handler).not.toHaveBeenCalled();
1749+
});
1750+
});
1751+
16521752
describe('BridgeClient — Live screen-context extMethod dispatch', () => {
16531753
function makeLiveClient(
16541754
handler:

‎packages/acp-bridge/src/bridgeClient.ts‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,13 @@ import type {
5555
ChannelDeliveryInfo,
5656
ClientMcpMessageSender,
5757
CreateSubSessionHandler,
58+
CurrentSessionScheduledTaskCreateHandler,
5859
ExternalToolGuardHandler,
5960
LiveScreenContextCaptureHandler,
6061
LiveSpeakToUserHandler,
6162
LiveTaskToolRequestHandler,
6263
} from './bridgeOptions.js';
64+
6365
import {
6466
CHANNEL_DELIVERY_ERROR_CODES,
6567
LIVE_TASK_TOOL_NAMES,
@@ -94,6 +96,9 @@ import {
9496
type SessionAttachmentStore,
9597
} from './sessionAttachments.js';
9698

99+
const MAX_SCHEDULED_TASK_CRON_CHARS = 200;
100+
const MAX_SCHEDULED_TASK_PROMPT_CHARS = 100_000;
101+
97102
/**
98103
* Validate a channel-wide active-work snapshot off the wire.
99104
*
@@ -617,6 +622,9 @@ export interface BridgeClientSessionEntry {
617622
sessionId: string;
618623
workspaceCwd: string;
619624
effectiveCwd: string;
625+
parentSessionId?: string;
626+
sourceType?: string;
627+
sourceId?: string;
620628
events: EventBus;
621629
artifacts: SessionArtifactStore;
622630
attachments: SessionAttachmentStore;
@@ -849,6 +857,7 @@ export class BridgeClient implements Client {
849857
* source-compatible.
850858
*/
851859
private readonly onGoalTurnEnded?: (sessionId: string) => void,
860+
private readonly onCreateCurrentSessionScheduledTask?: CurrentSessionScheduledTaskCreateHandler,
852861
) {}
853862

854863
async requestPermission(
@@ -1237,6 +1246,11 @@ export class BridgeClient implements Client {
12371246
if (method === SERVE_CONTROL_EXT_METHODS.createSubSession) {
12381247
return this.handleCreateSubSession(params);
12391248
}
1249+
if (
1250+
method === SERVE_CONTROL_EXT_METHODS.createCurrentSessionScheduledTask
1251+
) {
1252+
return this.handleCreateCurrentSessionScheduledTask(params);
1253+
}
12401254
if (method === SERVE_CONTROL_EXT_METHODS.liveCaptureScreenContext) {
12411255
return this.handleLiveScreenContextCapture(params);
12421256
}
@@ -1839,6 +1853,106 @@ export class BridgeClient implements Client {
18391853
};
18401854
}
18411855

1856+
private async handleCreateCurrentSessionScheduledTask(
1857+
params: Record<string, unknown>,
1858+
): Promise<Record<string, unknown>> {
1859+
if (!this.onCreateCurrentSessionScheduledTask) {
1860+
throw RequestError.methodNotFound(
1861+
SERVE_CONTROL_EXT_METHODS.createCurrentSessionScheduledTask,
1862+
);
1863+
}
1864+
const callerSessionId = params['callerSessionId'];
1865+
const promptId = params['promptId'];
1866+
const cron = params['cron'];
1867+
const prompt = params['prompt'];
1868+
const recurring = params['recurring'];
1869+
if (
1870+
typeof callerSessionId !== 'string' ||
1871+
callerSessionId.length === 0 ||
1872+
!this.ownsSession(callerSessionId)
1873+
) {
1874+
throw RequestError.invalidParams(
1875+
undefined,
1876+
'`callerSessionId` must name a session owned by this connection',
1877+
);
1878+
}
1879+
if (typeof promptId !== 'string' || promptId.length === 0) {
1880+
throw RequestError.invalidParams(
1881+
undefined,
1882+
'`promptId` must be a non-empty string',
1883+
);
1884+
}
1885+
if (
1886+
typeof cron !== 'string' ||
1887+
cron.length === 0 ||
1888+
cron.length > MAX_SCHEDULED_TASK_CRON_CHARS
1889+
) {
1890+
throw RequestError.invalidParams(
1891+
undefined,
1892+
`\`cron\` must be a non-empty string within the ${MAX_SCHEDULED_TASK_CRON_CHARS}-character limit`,
1893+
);
1894+
}
1895+
if (
1896+
typeof prompt !== 'string' ||
1897+
prompt.length === 0 ||
1898+
prompt.length > MAX_SCHEDULED_TASK_PROMPT_CHARS
1899+
) {
1900+
throw RequestError.invalidParams(
1901+
undefined,
1902+
`\`prompt\` must be non-empty and within the ${MAX_SCHEDULED_TASK_PROMPT_CHARS}-character limit`,
1903+
);
1904+
}
1905+
if (typeof recurring !== 'boolean') {
1906+
throw RequestError.invalidParams(
1907+
undefined,
1908+
'`recurring` must be a boolean',
1909+
);
1910+
}
1911+
1912+
const entry = this.resolveEntry(callerSessionId);
1913+
if (
1914+
!entry ||
1915+
entry.sessionId !== callerSessionId ||
1916+
entry.promptActive !== true ||
1917+
entry.activePromptId !== promptId
1918+
) {
1919+
throw RequestError.invalidParams(
1920+
undefined,
1921+
'The caller session does not own the active prompt',
1922+
);
1923+
}
1924+
if (
1925+
entry.parentSessionId !== undefined ||
1926+
entry.sourceId !== undefined ||
1927+
(entry.sourceType !== undefined && entry.sourceType !== 'default')
1928+
) {
1929+
throw RequestError.invalidParams(
1930+
undefined,
1931+
'The caller session source cannot own a scheduled task',
1932+
);
1933+
}
1934+
1935+
const result = await this.onCreateCurrentSessionScheduledTask({
1936+
callerSessionId,
1937+
promptId,
1938+
cron,
1939+
prompt,
1940+
recurring,
1941+
});
1942+
if (
1943+
typeof result.id !== 'string' ||
1944+
result.id.length === 0 ||
1945+
typeof result.cron !== 'string' ||
1946+
result.cron.length === 0
1947+
) {
1948+
throw RequestError.internalError(
1949+
undefined,
1950+
'Scheduled-task host returned an invalid result',
1951+
);
1952+
}
1953+
return { id: result.id, cron: result.cron };
1954+
}
1955+
18421956
private async handleLiveScreenContextCapture(
18431957
params: Record<string, unknown>,
18441958
): Promise<Record<string, unknown>> {

‎packages/acp-bridge/src/bridgeOptions.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,9 @@ export interface BridgeOptions {
604604
* reports itself unavailable (daemon-only).
605605
*/
606606
onCreateSubSession?: CreateSubSessionHandler;
607+
/** Handles a trusted `cron_create` request to bind a durable task to the
608+
* caller's currently executing daemon session. */
609+
onCreateCurrentSessionScheduledTask?: CurrentSessionScheduledTaskCreateHandler;
607610
/** Handles one child-initiated Channel delivery attempt. The bridge
608611
* authenticates the session and publishes the sanitized result event. */
609612
onChannelDelivery?: ChannelDeliveryHandler;
@@ -677,6 +680,23 @@ export type CreateSubSessionHandler = (
677680
info: CreateSubSessionInfo,
678681
) => Promise<CreateSubSessionResult>;
679682

683+
export interface CurrentSessionScheduledTaskCreateInfo {
684+
callerSessionId: string;
685+
promptId: string;
686+
cron: string;
687+
prompt: string;
688+
recurring: boolean;
689+
}
690+
691+
export interface CurrentSessionScheduledTaskCreateResult {
692+
id: string;
693+
cron: string;
694+
}
695+
696+
export type CurrentSessionScheduledTaskCreateHandler = (
697+
info: CurrentSessionScheduledTaskCreateInfo,
698+
) => Promise<CurrentSessionScheduledTaskCreateResult>;
699+
680700
export const MAX_LIVE_SCREEN_CONTEXT_TEXT_CHARS = 32_000;
681701

682702
export interface LiveScreenContextCaptureInfo {

‎packages/acp-bridge/src/status.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,8 @@ export const SERVE_CONTROL_EXT_METHODS = {
221221
* `first-turn` mode, which waits for the sub-session's first turn to finish).
222222
*/
223223
createSubSession: 'qwen/control/create-sub-session',
224+
createCurrentSessionScheduledTask:
225+
'qwen/control/scheduled-task/create-current',
224226
liveCaptureScreenContext: 'qwen/control/live/capture-screen-context',
225227
liveTaskTool: 'qwen/control/live/task-tool',
226228
liveSpeakToUser: 'qwen/control/live/speak-to-user',

0 commit comments

Comments
 (0)