Skip to content

Commit 7b69293

Browse files
doudouOUCqwencoder
andauthored
feat(daemon): Support current-session scheduled tasks (#9838)
* docs(scheduled-tasks): design current-session creation entrypoints Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * docs: clarify scheduled task session semantics Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * feat(daemon): Support current-session scheduled tasks Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: fix CI failure on PR #9838 Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: fix CI failure on PR #9838 Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: fix CI failure on PR #9838 Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#9838) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: fix CI failure on PR #9838 Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#9838) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#9838) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
1 parent 44762ef commit 7b69293

27 files changed

Lines changed: 2132 additions & 192 deletions

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

Lines changed: 347 additions & 0 deletions
Large diffs are not rendered by default.

docs/developers/qwen-serve-protocol.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,7 @@ operator diagnostic snapshot documented below.
490490
| `session_shell_command` | session shell execution is explicitly enabled. |
491491
| `session_artifacts_persistence` | session artifact persistence is wired for the runtime. |
492492
| `session_generation` | session generation helpers are available. |
493+
| `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. |
493494
| `workspace_generation` | workspace-scoped generation helpers are available. |
494495
| `rate_limit` | `--rate-limit` / `QWEN_SERVE_RATE_LIMIT=1` / `ServeOptions.rateLimit` is enabled. |
495496
| `workspace_reload` | workspace reload support is available in the embedded route configuration. |

integration-tests/cli/qwen-serve-routes.test.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -296,10 +296,15 @@ describe('qwen serve — capabilities envelope', () => {
296296
// Pool tags (`mcp_workspace_pool`, `mcp_pool_restart`) ARE present
297297
// because the workspace MCP pool is on by default, as are
298298
// `workspace_settings`, `workspace_permissions`, `workspace_voice`,
299-
// `workspace_trust`, `workspace_github_setup`, and
300-
// `workspace_reload`. The CLI serve path always wires `persistSetting`, the
301-
// workspace service, and route-local workspace helpers).
302-
expect(caps.features).toEqual([
299+
// `workspace_trust`, `workspace_github_setup`, and `workspace_reload`.
300+
// `scheduled_task_session_reuse` appears only after the managed runtime
301+
// mounts, so the fast-path bootstrap and runtime envelopes legitimately
302+
// differ by that tag. Its transition is covered by the serve startup tests.
303+
expect(
304+
caps.features.filter(
305+
(feature) => feature !== 'scheduled_task_session_reuse',
306+
),
307+
).toEqual([
303308
'health',
304309
'daemon_status',
305310
'capabilities',

packages/acp-bridge/src/bridge.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4064,6 +4064,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
40644064
// A Goal turn drains the mid-turn queue but owns no prompt slot, so
40654065
// nothing else would settle what its last drain missed.
40664066
settleMidTurnQueueAfterGoalTurn,
4067+
opts.onCreateCurrentSessionScheduledTask,
40674068
);
40684069
const rawConnection = new ClientSideConnection(
40694070
() =>

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

Lines changed: 157 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,10 @@ import {
6666
MID_TURN_RECONCILIATION_RING_SIZE,
6767
TODO_STOP_GUARD_CONTINUATION_CLAIM_METHOD,
6868
} from './bridgeTypes.js';
69-
import type { ClientMcpMessageSender } from './bridgeOptions.js';
69+
import type {
70+
ClientMcpMessageSender,
71+
CurrentSessionScheduledTaskCreateInfo,
72+
} from './bridgeOptions.js';
7073
import { CancelSentinelCollisionError } from './bridgeErrors.js';
7174
import { CANCEL_VOTE_SENTINEL } from './permissionMediator.js';
7275
import { SessionArtifactStore } from './sessionArtifacts.js';
@@ -93,6 +96,13 @@ function makeClient(
9396
ownsSession?: (sessionId: string) => boolean;
9497
handler: ExternalToolGuardHandler;
9598
},
99+
currentSessionTask?: {
100+
resolveEntry: (sessionId?: string) => unknown;
101+
ownsSession?: (sessionId: string) => boolean;
102+
handler: NonNullable<
103+
import('./bridgeOptions.js').BridgeOptions['onCreateCurrentSessionScheduledTask']
104+
>;
105+
},
96106
): BridgeClient {
97107
const noPermissionFlow = () => {
98108
throw new Error('test: permission flow should not run in fs-path tests');
@@ -104,7 +114,9 @@ function makeClient(
104114
// required (policy/vote/forgetSession/peekSessionFor/pendingCount).
105115
const throwerMediator = { request: noPermissionFlow } as never;
106116
return new BridgeClient(
107-
(managedGuard?.resolveEntry ?? noPermissionFlow) as never, // resolveEntry
117+
(managedGuard?.resolveEntry ??
118+
currentSessionTask?.resolveEntry ??
119+
noPermissionFlow) as never, // resolveEntry
108120
noPermissionFlow as never, // resolvePendingRestoreEvents
109121
throwerMediator, // mediator (F3 Commit 3)
110122
0, // permissionTimeoutMs (disabled)
@@ -113,7 +125,9 @@ function makeClient(
113125
undefined,
114126
undefined,
115127
undefined,
116-
managedGuard?.ownsSession ?? (() => true),
128+
managedGuard?.ownsSession ??
129+
currentSessionTask?.ownsSession ??
130+
(() => true),
117131
undefined,
118132
undefined,
119133
undefined,
@@ -124,6 +138,10 @@ function makeClient(
124138
undefined,
125139
undefined,
126140
managedGuard?.handler,
141+
undefined,
142+
undefined,
143+
undefined,
144+
currentSessionTask?.handler,
127145
);
128146
}
129147

@@ -1649,6 +1667,142 @@ describe('BridgeClient — create-sub-session extMethod dispatch', () => {
16491667
});
16501668
});
16511669

1670+
describe('BridgeClient — current-session scheduled-task dispatch', () => {
1671+
const request = {
1672+
callerSessionId: 'session-1',
1673+
promptId: 'prompt-1',
1674+
cron: '5 9 * * *',
1675+
prompt: 'continue the work',
1676+
recurring: true,
1677+
};
1678+
1679+
function makeCurrentSessionClient(
1680+
overrides: Record<string, unknown> = {},
1681+
ownsSession: (sessionId: string) => boolean = () => true,
1682+
) {
1683+
const entry = {
1684+
sessionId: 'session-1',
1685+
workspaceCwd: '/workspace',
1686+
effectiveCwd: '/workspace',
1687+
promptActive: true,
1688+
activePromptId: 'prompt-1',
1689+
...overrides,
1690+
};
1691+
const handler = vi.fn(
1692+
async (_info: CurrentSessionScheduledTaskCreateInfo) => ({
1693+
id: 'cron-1',
1694+
cron: request.cron,
1695+
}),
1696+
);
1697+
const client = makeClient(undefined, undefined, {
1698+
resolveEntry: (sessionId) =>
1699+
sessionId === entry.sessionId ? entry : undefined,
1700+
ownsSession,
1701+
handler,
1702+
});
1703+
return { client, entry, handler };
1704+
}
1705+
1706+
it('forwards only the bridge-owned active prompt', async () => {
1707+
const { client, handler } = makeCurrentSessionClient();
1708+
1709+
await expect(
1710+
client.extMethod(
1711+
SERVE_CONTROL_EXT_METHODS.createCurrentSessionScheduledTask,
1712+
request,
1713+
),
1714+
).resolves.toEqual({ id: 'cron-1', cron: request.cron });
1715+
expect(handler).toHaveBeenCalledWith({
1716+
...request,
1717+
assertCallerPromptActive: expect.any(Function),
1718+
});
1719+
});
1720+
1721+
it('lets the host revalidate the exact prompt before committing', async () => {
1722+
const { client, entry, handler } = makeCurrentSessionClient();
1723+
handler.mockImplementation(async (info) => {
1724+
info.assertCallerPromptActive();
1725+
entry.activePromptId = 'prompt-2';
1726+
expect(() => info.assertCallerPromptActive()).toThrow(/active prompt/i);
1727+
throw new Error('stale prompt');
1728+
});
1729+
1730+
await expect(
1731+
client.extMethod(
1732+
SERVE_CONTROL_EXT_METHODS.createCurrentSessionScheduledTask,
1733+
request,
1734+
),
1735+
).rejects.toThrow('stale prompt');
1736+
});
1737+
1738+
it('preserves scheduled-task business rejections as structured ACP errors', async () => {
1739+
const { client, handler } = makeCurrentSessionClient();
1740+
const rejection = new Error('The caller session has a pending interaction');
1741+
rejection.name = 'ExistingSessionScheduledTaskCreateError';
1742+
Object.assign(rejection, { status: 409, code: 'session_busy' });
1743+
handler.mockRejectedValueOnce(rejection);
1744+
1745+
const error = await client
1746+
.extMethod(
1747+
SERVE_CONTROL_EXT_METHODS.createCurrentSessionScheduledTask,
1748+
request,
1749+
)
1750+
.catch((caught: unknown) => caught);
1751+
1752+
expect(error).toBeInstanceOf(RequestError);
1753+
expect(error).toMatchObject({
1754+
code: -32602,
1755+
message: 'The caller session has a pending interaction',
1756+
data: {
1757+
errorKind: 'session_busy',
1758+
status: 409,
1759+
hint: 'The caller session has a pending interaction',
1760+
},
1761+
});
1762+
});
1763+
1764+
it('rejects a forged session or prompt identity', async () => {
1765+
const { client, handler } = makeCurrentSessionClient(
1766+
{},
1767+
(sessionId) => sessionId === 'session-1',
1768+
);
1769+
1770+
await expect(
1771+
client.extMethod(
1772+
SERVE_CONTROL_EXT_METHODS.createCurrentSessionScheduledTask,
1773+
{ ...request, callerSessionId: 'session-2' },
1774+
),
1775+
).rejects.toThrow(/callerSessionId/i);
1776+
await expect(
1777+
client.extMethod(
1778+
SERVE_CONTROL_EXT_METHODS.createCurrentSessionScheduledTask,
1779+
{ ...request, promptId: 'prompt-2' },
1780+
),
1781+
).rejects.toThrow(/active prompt/i);
1782+
expect(handler).not.toHaveBeenCalled();
1783+
});
1784+
1785+
it.each([
1786+
{ parentSessionId: 'parent-1' },
1787+
{ sourceType: 'channel' },
1788+
{ sourceType: 'scheduled_task' },
1789+
{ sourceType: 'standalone' },
1790+
{ sourceType: 'live_voice' },
1791+
{ sourceType: 'unknown' },
1792+
{ sourceId: 'source-1' },
1793+
])('rejects an ineligible session source: %j', async (overrides) => {
1794+
const { client, handler } = makeCurrentSessionClient(overrides);
1795+
1796+
await expect(
1797+
client.extMethod(
1798+
SERVE_CONTROL_EXT_METHODS.createCurrentSessionScheduledTask,
1799+
request,
1800+
),
1801+
).rejects.toThrow(/source/i);
1802+
expect(handler).not.toHaveBeenCalled();
1803+
});
1804+
});
1805+
16521806
describe('BridgeClient — Live screen-context extMethod dispatch', () => {
16531807
function makeLiveClient(
16541808
handler:

0 commit comments

Comments
 (0)