blob: e8e1718f623b41aff8e1d2aa2332890bedc759d6 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { JsonArrayPageBudget } from './json-array-page-budget.js';
import { randomUUID } from 'node:crypto';
import { botDisplayLabel } from '@maka/core/bot-events';
import { isBotDeliveryProvider } from '@maka/core/bot-chat-settings';
import { messageContentsEqual } from '@maka/core/events';
import { authorizeConnectionModel } from '@maka/core/llm-connections';
import type { ConnectionCatalogEntry } from '@maka/core/runtime-policy';
import {
type CreateScheduledTaskInput,
type ScheduledTask,
type ScheduledTaskExecutionTemplate,
} from '@maka/core/scheduled-task';
import type { SessionHeader } from '@maka/core/session';
import {
buildAgentScheduledTaskCreatePayload,
buildScheduledTaskTool,
SCHEDULED_TASK_NATIVE_EFFECT_SERVICE_ID,
SCHEDULED_TASK_NATIVE_EFFECT_SERVICE_VERSION,
type ScheduledTaskToolAuthority,
} from '@maka/runtime/scheduled-task-tools';
import { type MakaTool } from '@maka/runtime/tool-runtime';
import { stableHash } from '@maka/runtime/request-shape';
import { type SessionManager } from '@maka/runtime/session-manager';
import {
authenticateInteractiveScheduledTaskStoreWriter,
type InteractiveScheduledTaskStoreWriter,
type ScheduledTaskFireClaim,
type ScheduledTaskFireExecution,
ScheduledTaskStoreError,
} from '@maka/storage/scheduled-task-store';
import {
isSessionNotFoundError,
type ExecutionSessionWriter,
type RootTurnAdmission,
} from '@maka/storage/execution-stores';
import type { RuntimePolicyStoresWriter } from '@maka/storage/runtime-policy-stores';
import {
SCHEDULED_TASK_CATALOG_MAX_ITEMS,
SCHEDULED_TASK_PAGE_MAX_ITEMS,
SCHEDULED_TASK_RESULT_MAX_BYTES,
type OperationOutcome,
type ScheduledTaskChangedReason,
type ScheduledTaskMutateInput,
type ScheduledTaskQueryInput,
} from '../protocol/index.js';
import type { ScheduledTaskOperationHandlerMap } from './operation-dispatcher.js';
import type { RuntimeHostResidency } from './host-kernel.js';
import type { HostResidencyKind } from './host-residency-registry.js';
import type { HostedExecutionAuthority } from './hosted-execution-authority.js';
import type { SessionCreateInput } from '../protocol/session-catalog.js';
import { DEFAULT_TOOL_MODE, type ToolMode } from '@maka/core/tool-mode';
const MAX_TIMER_DELAY_MS = 2_147_483_647;
const NATIVE_PROVIDER_RETRY_MS = 5_000;
const SCHEDULED_AGENT_RUN_IDENTITY_REQUIRED =
'ScheduledTask Agent runs require an immutable model connection identity';
type ScheduledTaskSessions = Pick<ExecutionSessionWriter, 'readHeaderSnapshot'>;
type ScheduledTaskRuntime = Pick<SessionManager, 'sendMessage'>;
type ScheduledTaskRoot = Pick<HostedExecutionAuthority, 'admit'>;
type HostScheduledTaskChangeServiceLike = HostScheduledTaskCoordinatorInput['changes'];
interface ScheduledTaskNativeEffects {
hasWorkspaceService(serviceId: string, version: string): boolean;
callWorkspaceService(input: {
readonly serviceId: string;
readonly version: string;
readonly method: string;
readonly input: Record<string, unknown>;
}): Promise<Record<string, unknown>>;
}
export interface HostScheduledTaskCoordinatorInput {
readonly store: InteractiveScheduledTaskStoreWriter;
readonly sessions: ScheduledTaskSessions;
readonly runtime: ScheduledTaskRuntime;
readonly root: ScheduledTaskRoot;
readonly runtimePolicy: RuntimePolicyStoresWriter;
readonly nativeEffects: ScheduledTaskNativeEffects;
readonly createSession: (input: SessionCreateInput, toolMode: ToolMode) => Promise<void>;
readonly changes: {
publish(revision: number, reason: ScheduledTaskChangedReason, taskId: string): void;
};
readonly acquireResidency: (kind?: HostResidencyKind) => RuntimeHostResidency;
readonly requestDrain: () => void;
readonly now?: () => number;
readonly newId?: () => string;
readonly setTimeout?: (callback: () => void, delayMs: number) => unknown;
readonly clearTimeout?: (timer: unknown) => void;
}
export class HostScheduledTaskSessionBusyError extends Error {
readonly name = 'HostScheduledTaskSessionBusyError';
}
/** Stable Agent ScheduledTask target identity used by root-admission retries. */
export function scheduledTaskExecutionFingerprint(
execution: ScheduledTaskExecutionTemplate,
): `sha256:${string}` | undefined {
if (!execution.llmConnectionId) return undefined;
return stableHash([
'scheduled-task-agent-run.v1',
execution.llmConnectionId,
execution.llmConnectionSlug,
execution.model,
]);
}
export interface HostScheduledTaskSessionRetirement {
commit(): void;
rollback(): void;
}
/** Host-owned ScheduledTask catalog, scheduler, fire admission, and tool authority. */
export class HostScheduledTaskCoordinator implements ScheduledTaskToolAuthority {
readonly handlers: ScheduledTaskOperationHandlerMap = {
'scheduled-task.query': (input) => this.#query(input),
'scheduled-task.mutate': (input) => this.#mutate(input),
};
readonly modelTool: MakaTool;
readonly #store: InteractiveScheduledTaskStoreWriter;
readonly #sessions: ScheduledTaskSessions;
readonly #runtime: ScheduledTaskRuntime;
readonly #root: ScheduledTaskRoot;
readonly #runtimePolicy: RuntimePolicyStoresWriter;
readonly #nativeEffects: ScheduledTaskNativeEffects;
readonly #createSession: HostScheduledTaskCoordinatorInput['createSession'];
readonly #changes: HostScheduledTaskChangeServiceLike;
readonly #acquireResidency: HostScheduledTaskCoordinatorInput['acquireResidency'];
readonly #requestDrain: () => void;
readonly #now: () => number;
readonly #newId: () => string;
readonly #setTimeout: (callback: () => void, delayMs: number) => unknown;
readonly #clearTimeout: (timer: unknown) => void;
#lane: Promise<void> = Promise.resolve();
#revision = 0;
#timer: unknown;
#residency: RuntimeHostResidency | undefined;
#prepared = false;
#started = false;
#draining = false;
#handoffHeld = false;
#closed = false;
readonly #retiringSessions = new Set<string>();
constructor(input: HostScheduledTaskCoordinatorInput) {
this.#store = authenticateInteractiveScheduledTaskStoreWriter(input.store);
this.#sessions = input.sessions;
this.#runtime = input.runtime;
this.#root = input.root;
this.#runtimePolicy = input.runtimePolicy;
this.#nativeEffects = input.nativeEffects;
this.#createSession = input.createSession;
this.#changes = input.changes;
this.#acquireResidency = input.acquireResidency;
this.#requestDrain = input.requestDrain;
this.#now = input.now ?? Date.now;
this.#newId = input.newId ?? randomUUID;
this.#setTimeout = input.setTimeout ?? ((callback, delayMs) => setTimeout(callback, delayMs));
this.#clearTimeout = input.clearTimeout ?? ((timer) => clearTimeout(timer as NodeJS.Timeout));
this.modelTool = buildScheduledTaskTool({ authority: this });
}
async prepareRecovery(): Promise<void> {
if (this.#prepared) return;
await this.#store.ready();
this.#prepared = true;
await this.#refreshResidency();
}
async assertRecoveryAdmission(
admission: RootTurnAdmission,
state: 'pending_fire_required' | 'run_recorded',
): Promise<void> {
if (!this.#prepared) {
throw new Error('ScheduledTask recovery admission was inspected before Store recovery');
}
if (admission.execution.kind !== 'scheduled_task') return;
const scheduledTaskId = admission.execution.scheduledTaskId;
const claims = await this.#store.listPendingFires();
const claim = claims.find(
(candidate) =>
candidate.task.id === scheduledTaskId &&
candidate.execution?.sessionId === admission.sessionId &&
candidate.execution.turnId === admission.turnId,
);
const execution = claim?.execution;
const fingerprintMatches =
claim?.task.effect.kind !== 'agent_run' ||
admission.execution.executionFingerprint ===
scheduledTaskExecutionFingerprint(claim.task.effect.execution);
if (!claim && state === 'run_recorded') return;
if (
!claim ||
!execution ||
execution.sessionId !== admission.sessionId ||
execution.turnId !== admission.turnId ||
execution.runId !== admission.runId ||
execution.userMessageId !== admission.userMessageId ||
!fingerprintMatches ||
admission.normalizedInput === null ||
!messageContentsEqual({ text: claim.task.intent.body }, admission.normalizedInput)
) {
throw new Error(`ScheduledTask admission ${admission.turnId} has no matching pending fire`);
}
}
async recover(): Promise<void> {
if (!this.#prepared) throw new Error('ScheduledTask recovery was not prepared');
for (const claim of await this.#store.listPendingFires()) {
if (this.#draining) return;
if (claim.task.effect.kind === 'notify') {
if (claim.nativeState === 'waiting_for_provider') {
await this.#fulfill(claim, true);
} else {
await this.#settleFailure(
claim,
'The previous native notification stopped after delivery admission.',
);
}
continue;
}
if (!claim.execution) {
await this.#settleFailure(
claim,
'The previous fire stopped before a durable Agent execution was admitted.',
);
continue;
}
await this.#fulfill(claim, true);
}
}
start(): void {
if (!this.#prepared) throw new Error('ScheduledTask scheduler started before recovery');
if (this.#draining || this.#started) return;
this.#started = true;
void this.#refresh().catch((error: unknown) => this.#fatal(error));
}
holdForHandoff():
| {
settled(): Promise<void>;
residencies(): readonly RuntimeHostResidency[];
release(): void;
}
| undefined {
if (this.#draining || this.#handoffHeld) return undefined;
this.#handoffHeld = true;
this.#stopTimer();
let released = false;
return {
settled: async () => {
// An admitted native effect or Agent fire finishes normally. No new
// fire may start while the handoff waits for this lane.
for (;;) {
const lane = this.#lane;
await lane;
if (lane === this.#lane) return;
}
},
residencies: () => (this.#residency ? [this.#residency] : []),
release: () => {
if (released) return;
released = true;
this.#handoffHeld = false;
if (!this.#draining) void this.#refresh().catch((error: unknown) => this.#fatal(error));
},
};
}
beginDrain(): void {
if (this.#draining) return;
this.#draining = true;
this.#stopTimer();
this.#releaseResidency();
}
async close(): Promise<void> {
if (this.#closed) return;
this.beginDrain();
await this.#lane;
this.#closed = true;
}
async create(input: {
title: string;
intentBody: string;
schedule:
| { kind: 'once'; runAt: number }
| { kind: 'interval'; everySeconds: number; startAt?: number }
| { kind: 'cron'; expression: string; startAt?: number };
effect: 'session_resume' | 'agent_run' | 'notify_local';
sessionId: string;
maxFires?: number;
}): Promise<ScheduledTask | { error: string }> {
let execution: ScheduledTaskExecutionTemplate | undefined;
if (input.effect !== 'notify_local') {
try {
const header = await this.#readResumableSession(input.sessionId);
if (input.effect === 'agent_run') execution = executionTemplateFromHeader(header);
} catch (error) {
if (isSessionNotFoundError(error)) return { error: 'Session was not found' };
return { error: errorMessage(error) };
}
}
const payload = buildAgentScheduledTaskCreatePayload({
...input,
...(execution ? { execution } : {}),
now: this.#now(),
});
if ('error' in payload) return payload;
try {
return await this.#commitCreate(payload);
} catch (error) {
return { error: errorMessage(error) };
}
}
list(): Promise<readonly ScheduledTask[]> {
return this.#exclusive(() => this.#store.list());
}
beginSessionRetirement(
sessionIds: readonly string[],
): Promise<HostScheduledTaskSessionRetirement> {
const unique = [...new Set(sessionIds)];
return this.#exclusive(async () => {
const targets = new Set(unique);
const [tasks, claims] = await Promise.all([
this.#store.list(),
this.#store.listPendingFires(),
]);
const blocked =
tasks.some(
(task) => task.effect.kind === 'session_resume' && targets.has(task.effect.sessionId),
) || claims.some((claim) => targets.has(claim.execution?.sessionId ?? ''));
if (blocked) {
throw new HostScheduledTaskSessionBusyError(
'Session retirement requires session-bound ScheduledTasks to be removed first',
);
}
for (const sessionId of unique) this.#retiringSessions.add(sessionId);
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
for (const sessionId of unique) this.#retiringSessions.delete(sessionId);
};
return Object.freeze({ commit: finish, rollback: finish });
});
}
async pause(id: string): Promise<ScheduledTask | { error: string }> {
return this.#toolMutation(() =>
this.#commitTask('updated', async () => {
await this.#store.cancelWaitingNativeFire(id);
return this.#store.pause(id);
}),
);
}
async resume(id: string): Promise<ScheduledTask | { error: string }> {
return this.#toolMutation(() => this.#commitTask('updated', () => this.#store.resume(id)));
}
async remove(id: string): Promise<{ ok: true } | { error: string }> {
try {
await this.#exclusive(async () => {
await this.#store.cancelWaitingNativeFire(id);
await this.#store.remove(id);
this.#publish('deleted', id);
await this.#refreshSchedule();
});
return { ok: true };
} catch (error) {
return { error: errorMessage(error) };
}
}
async #query(input: ScheduledTaskQueryInput): Promise<OperationOutcome<'scheduled-task.query'>> {
if (!this.#prepared) return queryFailure('host_not_ready', 'ScheduledTask is not ready');
if (this.#draining) return queryFailure('host_draining', 'Runtime Host is draining');
try {
return await this.#exclusive(async () => {
if (input.kind === 'get') {
return {
ok: true,
result: { kind: 'task', task: (await this.#store.get(input.taskId)) ?? null },
} as const;
}
const tasks = await this.#store.list();
if (input.expectedRevision !== undefined && input.expectedRevision !== this.#revision) {
return {
ok: true,
result: {
kind: 'revision_changed',
expected: input.expectedRevision,
actual: this.#revision,
},
} as const;
}
const offset = input.cursor === undefined ? 0 : Number(input.cursor);
if (
!Number.isSafeInteger(offset) ||
offset < 0 ||
offset > tasks.length ||
(input.cursor !== undefined && offset === tasks.length)
) {
return queryFailure('invalid_request', 'ScheduledTask cursor is invalid');
}
return {
ok: true,
result: createScheduledTaskPage(tasks, this.#revision, offset),
} as const;
});
} catch {
return queryFailure('persistence_failed', 'ScheduledTask catalog is unavailable');
}
}
async #mutate(
input: ScheduledTaskMutateInput,
): Promise<OperationOutcome<'scheduled-task.mutate'>> {
if (!this.#prepared) return mutateFailure('host_not_ready', 'ScheduledTask is not ready');
if (this.#draining) return mutateFailure('host_draining', 'Runtime Host is draining');
try {
if (input.kind === 'create') {
return taskSuccess(
await this.#commitCreate({ ...input.input, createdBy: { kind: 'user' } }),
);
}
if (input.kind === 'delete') {
await this.#exclusive(async () => {
await this.#store.cancelWaitingNativeFire(input.taskId);
await this.#store.remove(input.taskId);
this.#publish('deleted', input.taskId);
await this.#refreshSchedule();
});
return { ok: true, result: { kind: 'deleted', taskId: input.taskId } };
}
if (input.kind === 'trigger_now') {
return taskSuccess(
await this.#exclusive(async () => {
const claim = await this.#store.claimNow(input.taskId, this.#now());
await this.#refreshResidency();
const task = await this.#fulfill(claim, false);
if (!task) throw new ScheduledTaskNativeUnavailableError();
return task;
}),
);
}
const task = await this.#commitTask('updated', () => {
if (input.kind === 'update') {
return this.#cancelWaitingNativeFireThen(input.taskId, () =>
this.#store.update(input.taskId, input.patch, this.#now()),
);
}
if (input.kind === 'pause') {
return this.#cancelWaitingNativeFireThen(input.taskId, () =>
this.#store.pause(input.taskId, this.#now()),
);
}
if (input.kind === 'resume') return this.#store.resume(input.taskId, this.#now());
if (input.kind === 'snooze') {
return this.#cancelWaitingNativeFireThen(input.taskId, () =>
this.#store.snooze(input.taskId, input.delayMs, this.#now()),
);
}
return this.#store.clearRunHistory(input.taskId, this.#now());
});
return taskSuccess(task);
} catch (error) {
if (error instanceof ScheduledTaskStoreError) {
return mutateFailure(
error.code === 'not_found'
? 'not_found'
: error.code === 'invalid_input'
? 'invalid_request'
: 'operation_conflict',
error.message,
);
}
if (error instanceof ScheduledTaskMutationError) {
return mutateFailure(error.code, error.message);
}
if (error instanceof ScheduledTaskNativeUnavailableError) {
return mutateFailure('operation_conflict', errorMessage(error));
}
this.#requestDrain();
return mutateFailure('persistence_failed', 'ScheduledTask mutation failed');
}
}
async #commitCreate(input: CreateScheduledTaskInput): Promise<ScheduledTask> {
return this.#exclusive(async () => {
const incognito = (await this.#runtimePolicy.runtimePolicy.getSnapshot()).policy.privacy
.incognitoActive;
if (incognito) {
throw new ScheduledTaskMutationError(
'operation_conflict',
'SCHEDULED_TASK_INCOGNITO_ACTIVE',
);
}
if ((await this.#store.list()).length >= SCHEDULED_TASK_CATALOG_MAX_ITEMS) {
throw new ScheduledTaskMutationError(
'operation_conflict',
'ScheduledTask catalog limit reached',
);
}
const task = await this.#store.create(input, this.#now());
this.#publish('created', task.id);
await this.#refreshSchedule();
return task;
});
}
#commitTask(
reason: ScheduledTaskChangedReason,
mutate: () => Promise<ScheduledTask>,
): Promise<ScheduledTask> {
return this.#exclusive(async () => {
const task = await mutate();
this.#publish(reason, task.id);
await this.#refreshSchedule();
return task;
});
}
async #cancelWaitingNativeFireThen<T>(taskId: string, operation: () => Promise<T>): Promise<T> {
await this.#store.cancelWaitingNativeFire(taskId);
return operation();
}
async #toolMutation(
operation: () => Promise<ScheduledTask>,
): Promise<ScheduledTask | { error: string }> {
try {
return await operation();
} catch (error) {
return { error: errorMessage(error) };
}
}
async #refresh(): Promise<void> {
if (this.#handoffHeld || this.#draining) return;
// Cover claim admission, native delivery, and persistence, while an idle
// schedule or a read-only catalog query does not claim active work.
const residency = this.#acquireResidency();
try {
await this.#exclusive(async () => {
if (this.#handoffHeld || this.#draining) return;
for (const claim of await this.#store.listPendingFires()) {
if (this.#handoffHeld || this.#draining) break;
if (claim.nativeState === 'waiting_for_provider') {
await this.#fulfill(claim, true);
}
}
while (!this.#draining && !this.#handoffHeld) {
const scan = await this.#store.claimNextDue(this.#now());
for (const expired of scan.expired) this.#publish('updated', expired.id);
const claim = scan.claim;
if (!claim) break;
await this.#refreshResidency();
await this.#fulfill(claim, false);
}
await this.#refreshSchedule();
});
} finally {
residency.release();
}
}
async #fulfill(
claim: ScheduledTaskFireClaim,
recovering: boolean,
): Promise<ScheduledTask | undefined> {
const incognito = (await this.#runtimePolicy.runtimePolicy.getSnapshot()).policy.privacy
.incognitoActive;
if (incognito) {
return this.#settle(claim, 'blocked', '隐私模式已开启,定时任务没有触发。', 'blocked');
}
const task = claim.task;
if (task.effect.kind === 'notify') {
if (recovering && claim.nativeState !== 'waiting_for_provider') {
return this.#settleFailure(
claim,
'The previous native notification stopped before delivery was confirmed.',
);
}
if (task.effect.channel === 'bot' && !isBotDeliveryProvider(task.effect.platform)) {
return this.#settle(
claim,
'blocked',
`${botDisplayLabel(task.effect.platform)} 当前不是可投递目标。`,
'blocked',
);
}
if (
!this.#nativeEffects.hasWorkspaceService(
SCHEDULED_TASK_NATIVE_EFFECT_SERVICE_ID,
SCHEDULED_TASK_NATIVE_EFFECT_SERVICE_VERSION,
)
) {
if (claim.nativeState !== 'waiting_for_provider') {
await this.#store.setFireNativeState(claim.id, 'waiting_for_provider');
}
return undefined;
}
claim = await this.#store.setFireNativeState(claim.id, 'invoking');
try {
await this.#nativeEffects.callWorkspaceService({
serviceId: SCHEDULED_TASK_NATIVE_EFFECT_SERVICE_ID,
version: SCHEDULED_TASK_NATIVE_EFFECT_SERVICE_VERSION,
method: task.effect.channel === 'local' ? 'notify_local' : 'notify_bot',
input:
task.effect.channel === 'local'
? { taskId: task.id, title: task.title }
: {
taskId: task.id,
title: task.title,
body: task.intent.body,
platform: task.effect.platform,
chatId: task.effect.chatId,
},
});
} catch (error) {
return this.#settle(
claim,
'failed',
`Native delivery outcome is unknown: ${errorMessage(error)}`,
'failed',
);
}
return this.#settle(
claim,
'ok',
task.effect.channel === 'local'
? '本地提醒已触发。'
: `已投递到 ${botDisplayLabel(task.effect.platform)}。`,
'fired',
);
}
// Legacy persisted Agent-run templates may still identify their model
// connection by reusable slug only. Never resolve that slug to a
// potentially different Connection entity.
if (task.effect.kind === 'agent_run' && !task.effect.execution.llmConnectionId) {
return this.#settleFailure(claim, SCHEDULED_AGENT_RUN_IDENTITY_REQUIRED);
}
let execution = claim.execution;
if (!execution) {
execution = {
sessionId: task.effect.kind === 'session_resume' ? task.effect.sessionId : this.#newId(),
turnId: this.#newId(),
runId: this.#newId(),
userMessageId: this.#newId(),
};
claim = await this.#store.bindFireExecution(claim.id, execution);
}
try {
await this.#ensureAgentSession(task, execution);
await this.#admitAgentRun(task, execution);
return this.#settle(
claim,
'ok',
task.effect.kind === 'session_resume'
? '已在原任务中继续执行。'
: '已启动 Agent 任务执行。',
'fired',
execution,
);
} catch (error) {
return this.#settleFailure(claim, errorMessage(error));
}
}
async #ensureAgentSession(
task: ScheduledTask,
identity: ScheduledTaskFireExecution,
): Promise<void> {
if (task.effect.kind === 'notify') throw new Error('Task effect is not an Agent execution');
if (task.effect.kind === 'session_resume') {
if (identity.sessionId !== task.effect.sessionId) {
throw new Error('ScheduledTask Session identity changed');
}
await this.#readResumableSession(identity.sessionId);
return;
}
const execution = task.effect.execution;
const connection = await this.#resolveAgentRunConnection(execution);
try {
const existing = await this.#sessions.readHeaderSnapshot(identity.sessionId);
if (
existing.llmConnectionId !== execution.llmConnectionId ||
existing.llmConnectionSlug !== execution.llmConnectionSlug ||
existing.model !== execution.model
) {
throw new Error('ScheduledTask Session model identity changed');
}
return;
} catch (error) {
if (!isSessionNotFoundError(error) && !isMissingRecord(error)) throw error;
}
await this.#createSession(
{
sessionId: identity.sessionId,
workspace:
execution.projectId != null && execution.projectId !== ''
? { kind: 'project', projectId: execution.projectId }
: { kind: 'host_path', path: execution.cwd },
name: task.title,
labels: ['scheduled-task'],
modelTarget: {
kind: 'explicit',
connectionId: connection.connectionId,
connectionSlug: execution.llmConnectionSlug,
model: execution.model,
},
...(execution.thinkingLevel === undefined
? {}
: { thinkingLevel: execution.thinkingLevel }),
permissionMode: execution.permissionMode,
collaborationMode: execution.collaborationMode,
orchestrationMode: execution.orchestrationMode,
},
execution.toolMode ?? DEFAULT_TOOL_MODE,
);
}
async #resolveAgentRunConnection(
execution: ScheduledTaskExecutionTemplate,
): Promise<ConnectionCatalogEntry> {
if (!execution.llmConnectionId) {
throw new Error(SCHEDULED_AGENT_RUN_IDENTITY_REQUIRED);
}
const resolved = await this.#runtimePolicy.operations.resolveExecutionConnection({
kind: 'bound',
connectionId: execution.llmConnectionId,
connectionSlug: execution.llmConnectionSlug,
});
if (resolved.kind !== 'ready') {
throw new Error(
resolved.kind === 'identity_mismatch' || resolved.kind === 'not_found'
? 'ScheduledTask model connection identity changed'
: resolved.kind === 'disabled'
? 'ScheduledTask model connection is disabled'
: resolved.kind === 'credential_not_configured'
? 'ScheduledTask model connection is not ready'
: resolved.kind === 'provider_retired'
? 'ScheduledTask model connection uses a retired provider'
: 'ScheduledTask model connection is unavailable',
);
}
if (!authorizeConnectionModel(resolved.connection, execution.model)) {
throw new Error('ScheduledTask model is no longer enabled');
}
return resolved.connection;
}
async #admitAgentRun(task: ScheduledTask, identity: ScheduledTaskFireExecution): Promise<void> {
if (task.effect.kind === 'agent_run') {
// Re-read the bound Connection immediately before admission. The
// Session/Connection stores have independent write lanes, so this
// second check closes the delete-and-recreate-same-slug window between
// Session creation and AgentRun admission.
await this.#resolveAgentRunConnection(task.effect.execution);
}
const content = { text: task.intent.body };
await this.#root.admit({
...identity,
execution: {
kind: 'scheduled_task',
scheduledTaskId: task.id,
...(task.effect.kind === 'agent_run'
? { executionFingerprint: scheduledTaskExecutionFingerprint(task.effect.execution) }
: {}),
},
content,
start: ({ runId, userMessageId, onRunStarted }) => {
if (runId !== identity.runId || userMessageId !== identity.userMessageId) {
throw new Error('Runtime Host changed the ScheduledTask execution identity');
}
return this.#runtime.sendMessage(
identity.sessionId,
{
turnId: identity.turnId,
...content,
origin: { kind: 'scheduled_task', scheduledTaskId: task.id },
},
{
runId: identity.runId,
userMessageId: identity.userMessageId,
durability: 'required',
onRunStarted: async (startedRunId) => {
if (startedRunId !== identity.runId) {
throw new Error('Runtime started a different ScheduledTask AgentRun identity');
}
await onRunStarted();
},
},
);
},
});
}
async #readResumableSession(sessionId: string): Promise<SessionHeader> {
if (this.#retiringSessions.has(sessionId)) {
throw new Error('Session lifecycle is changing');
}
const header = await this.#sessions.readHeaderSnapshot(sessionId);
if (header.isArchived) {
throw new Error('Archived Sessions cannot own session-bound ScheduledTasks');
}
return header;
}
async #settleFailure(claim: ScheduledTaskFireClaim, message: string): Promise<ScheduledTask> {
return this.#settle(claim, 'failed', message, 'failed', claim.execution);
}
async #settle(
claim: ScheduledTaskFireClaim,
outcome: 'ok' | 'failed' | 'blocked',
message: string,
reason: ScheduledTaskChangedReason,
execution?: ScheduledTaskFireExecution,
): Promise<ScheduledTask> {
const task = await this.#store.settleFire(claim.id, {
at: this.#now(),
outcome,
message,
...(execution ? { sessionId: execution.sessionId, runId: execution.runId } : {}),
});
this.#publish(reason, task.id);
await this.#refreshSchedule();
return task;
}
async #refreshSchedule(): Promise<void> {
this.#stopTimer();
await this.#refreshResidency();
if (!this.#started || this.#draining || this.#handoffHeld) return;
const [tasks, claims] = await Promise.all([this.#store.list(), this.#store.listPendingFires()]);
const next = tasks
.filter((task) => task.status === 'active' && task.nextFireAt !== null)
.reduce<number | null>((earliest, task) => {
const deadline = Math.min(task.nextFireAt!, task.expiresAt ?? task.nextFireAt!);
return earliest === null || deadline < earliest ? deadline : earliest;
}, null);
const waitingForProvider = claims.some((claim) => claim.nativeState === 'waiting_for_provider');
if (this.#draining || this.#handoffHeld) return;
if (next === null && !waitingForProvider) return;
const nextTaskDelay =
next === null
? MAX_TIMER_DELAY_MS
: Math.max(0, Math.min(MAX_TIMER_DELAY_MS, next - this.#now()));
const delay = waitingForProvider
? Math.min(NATIVE_PROVIDER_RETRY_MS, nextTaskDelay)
: nextTaskDelay;
this.#timer = this.#setTimeout(() => {
this.#timer = undefined;
void this.#refresh().catch((error: unknown) => this.#fatal(error));
}, delay);
}
async #refreshResidency(): Promise<void> {
const [tasks, claims] = await Promise.all([this.#store.list(), this.#store.listPendingFires()]);
const shouldHold =
!this.#draining &&
(claims.length > 0 ||
tasks.some((task) => task.status === 'active' && task.nextFireAt !== null));
if (shouldHold && !this.#residency) this.#residency = this.#acquireResidency('idle');
if (!shouldHold) this.#releaseResidency();
}
#releaseResidency(): void {
this.#residency?.release();
this.#residency = undefined;
}
#publish(reason: ScheduledTaskChangedReason, taskId: string): void {
this.#revision += 1;
this.#changes.publish(this.#revision, reason, taskId);
}
#stopTimer(): void {
if (this.#timer === undefined) return;
this.#clearTimeout(this.#timer);
this.#timer = undefined;
}
#exclusive<T>(operation: () => T | Promise<T>): Promise<T> {
const run = this.#lane.then(operation, operation);
this.#lane = run.then(
() => undefined,
() => undefined,
);
return run;
}
#fatal(_error: unknown): void {
this.#requestDrain();
}
}
class ScheduledTaskNativeUnavailableError extends Error {
constructor() {
super('ScheduledTask native delivery is waiting for a Desktop provider');
}
}
class ScheduledTaskMutationError extends Error {
constructor(
readonly code: 'invalid_request' | 'operation_conflict',
message: string,
) {
super(message);
this.name = 'ScheduledTaskMutationError';
}
}
function executionTemplateFromHeader(header: SessionHeader): ScheduledTaskExecutionTemplate {
if (!header.llmConnectionId) {
throw new Error(SCHEDULED_AGENT_RUN_IDENTITY_REQUIRED);
}
return {
cwd: header.cwd,
...(header.projectId === undefined ? {} : { projectId: header.projectId }),
llmConnectionId: header.llmConnectionId,
llmConnectionSlug: header.llmConnectionSlug,
model: header.model,
...(header.thinkingLevel === undefined ? {} : { thinkingLevel: header.thinkingLevel }),
permissionMode: header.permissionMode,
collaborationMode: header.collaborationMode ?? 'agent',
orchestrationMode: header.orchestrationMode ?? 'default',
toolMode: header.toolMode ?? DEFAULT_TOOL_MODE,
};
}
function createScheduledTaskPage(
tasks: readonly ScheduledTask[],
revision: number,
offset: number,
) {
const page: ScheduledTask[] = [];
const budget = new JsonArrayPageBudget(SCHEDULED_TASK_RESULT_MAX_BYTES, {
kind: 'page',
revision,
tasks: [],
nextCursor: null,
});
for (let index = offset; index < tasks.length; index += 1) {
if (page.length >= SCHEDULED_TASK_PAGE_MAX_ITEMS) break;
const task = tasks[index];
if (!task) throw new Error('ScheduledTask page index is invalid');
const nextOffset = offset + page.length + 1;
if (!budget.tryAppend(task, nextOffset < tasks.length ? String(nextOffset) : null)) {
break;
}
page.push(task);
}
if (page.length === 0 && offset < tasks.length) {
throw new Error('A ScheduledTask exceeds the query byte limit');
}
const nextOffset = offset + page.length;
return {
kind: 'page' as const,
revision,
tasks: page,
nextCursor: nextOffset < tasks.length ? String(nextOffset) : null,
};
}
function taskSuccess(task: ScheduledTask): OperationOutcome<'scheduled-task.mutate'> {
return { ok: true, result: { kind: 'task', task } };
}
function queryFailure(
code: 'host_not_ready' | 'host_draining' | 'invalid_request' | 'persistence_failed',
message: string,
): OperationOutcome<'scheduled-task.query'> {
return { ok: false, error: { code, message } };
}
function mutateFailure(
code:
| 'host_not_ready'
| 'host_draining'
| 'invalid_request'
| 'not_found'
| 'operation_conflict'
| 'persistence_failed',
message: string,
): OperationOutcome<'scheduled-task.mutate'> {
return { ok: false, error: { code, message } };
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function isMissingRecord(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as NodeJS.ErrnoException).code === 'ENOENT'
);
}