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
13 changes: 9 additions & 4 deletions integration-tests/cli/qwen-serve-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
),
Comment thread
doudouOUC marked this conversation as resolved.
).toEqual([
'health',
'daemon_status',
'capabilities',
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
160 changes: 157 additions & 3 deletions packages/acp-bridge/src/bridgeClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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');
Expand All @@ -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)
Expand All @@ -113,7 +125,9 @@ function makeClient(
undefined,
undefined,
undefined,
managedGuard?.ownsSession ?? (() => true),
managedGuard?.ownsSession ??
currentSessionTask?.ownsSession ??
(() => true),
undefined,
undefined,
undefined,
Expand All @@ -124,6 +138,10 @@ function makeClient(
undefined,
undefined,
managedGuard?.handler,
undefined,
undefined,
undefined,
currentSessionTask?.handler,
);
}

Expand Down Expand Up @@ -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<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 (_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:
Expand Down
Loading
Loading