mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-22 04:21:32 +02:00
754 lines
27 KiB
TypeScript
754 lines
27 KiB
TypeScript
import type { AgentTaskDto, CreateAgentTaskDto, UpdateAgentTaskDto } from '@n8n/api-types';
|
|
import { isValidTimeZone } from '@n8n/api-types';
|
|
import { Logger } from '@n8n/backend-common';
|
|
import { GlobalConfig } from '@n8n/config';
|
|
import { TransactionRunner, type User } from '@n8n/db';
|
|
import { OnLeaderStepdown, OnLeaderTakeover, OnPubSubEvent, OnShutdown } from '@n8n/decorators';
|
|
import { Service } from '@n8n/di';
|
|
import { IsNull, Not } from '@n8n/typeorm';
|
|
import { randomUUID } from 'crypto';
|
|
import { DateTime } from 'luxon';
|
|
import { InstanceSettings, ScheduledTaskManager, type ScheduledTaskGroup } from 'n8n-core';
|
|
|
|
import { BadRequestError, NotFoundError } from '@n8n/errors';
|
|
import type { PubSubCommandMap } from '@/scaling/pubsub/pubsub.event-map';
|
|
|
|
import {
|
|
type AgentMutationTelemetryContext,
|
|
buildAgentMutationEvent,
|
|
captureAgentMutation,
|
|
} from './agent-modification-telemetry.service';
|
|
import { AgentExecutionOrchestratorService } from './agent-execution-orchestrator.service';
|
|
import { AgentChangePublisher } from './agent-change-publisher.service';
|
|
import { AgentSaveCompletionService } from './agent-save-completion.service';
|
|
import { AgentSkillRefsService } from './agent-skill-refs.service';
|
|
import { AgentsSettingsService } from './agents-settings.service';
|
|
import { AgentTaskJobRegistrar } from './scheduling/agent-task-job-registrar';
|
|
import { knownTaskTimezone } from './scheduling/task-timezone';
|
|
import { Agent } from './entities/agent.entity';
|
|
import { AgentTask } from './entities/agent-task.entity';
|
|
import type { AgentTaskSnapshot } from './entities/agent-task-snapshot.entity';
|
|
import { isValidCronExpression } from './integrations/cron-validation';
|
|
import { AgentRepository } from './repositories/agent.repository';
|
|
import { getAgentOrThrow } from './utils/get-agent-or-throw';
|
|
import { getAgentConfigHash } from './utils/agent-config-hash';
|
|
import { composeJsonConfig } from './json-config/agent-config-composition';
|
|
import type { AgentSkillRefs } from './json-config/agent-document';
|
|
import {
|
|
type AgentTaskRunLockHandle,
|
|
AgentTaskRunLockRepository,
|
|
} from './repositories/agent-task-run-lock.repository';
|
|
import { AgentTaskSnapshotRepository } from './repositories/agent-task-snapshot.repository';
|
|
import { AgentTaskRepository } from './repositories/agent-task.repository';
|
|
import { markAgentDraftDirty, saveAgentDraftFenced } from './utils/agent-draft.utils';
|
|
import { taskRunMemoryResourceId } from './utils/agent-memory-scope';
|
|
import { generateAgentResourceId } from './utils/agent-resource-id';
|
|
|
|
const TASK_RUN_LOCK_TTL_MS = 5 * 60 * 1000;
|
|
const TASK_RUN_LOCK_RENEW_MS = 60 * 1000;
|
|
const AGENT_TASK_SCHEDULE_GROUP_TYPE = 'agent-task';
|
|
|
|
const agentTaskScheduleGroup = (agentId: string): ScheduledTaskGroup => ({
|
|
type: AGENT_TASK_SCHEDULE_GROUP_TYPE,
|
|
id: agentId,
|
|
});
|
|
|
|
/**
|
|
* Owns an agent's scheduled tasks. Draft task bodies (name/objective/cron and
|
|
* the timezone that cron is evaluated in — null meaning the instance timezone)
|
|
* live in the `agent_task_definition` table; membership and the `enabled` flag live
|
|
* in the agent config as `{ type: 'task', id, enabled }` refs (mirroring
|
|
* skills). Scheduling is driven entirely by the PUBLISHED snapshot rows tied to
|
|
* `activeVersionId`. `ScheduledTaskManager` registers a cron per enabled
|
|
* snapshot row of a published agent (leader-only). Adding, removing, toggling,
|
|
* or editing a task is a draft change that only affects scheduled runs once the
|
|
* agent is (re)published — "republish to apply". Manual "Run now" deliberately
|
|
* runs the live draft body instead.
|
|
*/
|
|
@Service()
|
|
export class AgentTaskService {
|
|
constructor(
|
|
private readonly logger: Logger,
|
|
private readonly globalConfig: GlobalConfig,
|
|
private readonly taskRepository: AgentTaskRepository,
|
|
private readonly taskSnapshotRepository: AgentTaskSnapshotRepository,
|
|
private readonly taskRunLockRepository: AgentTaskRunLockRepository,
|
|
private readonly agentRepository: AgentRepository,
|
|
private readonly agentExecutionOrchestratorService: AgentExecutionOrchestratorService,
|
|
private readonly instanceSettings: InstanceSettings,
|
|
private readonly scheduledTaskManager: ScheduledTaskManager,
|
|
private readonly changePublisher: AgentChangePublisher,
|
|
private readonly durableJobRegistrar: AgentTaskJobRegistrar,
|
|
private readonly saveCompletion: AgentSaveCompletionService,
|
|
private readonly settingsService: AgentsSettingsService,
|
|
private readonly transactionRunner: TransactionRunner,
|
|
private readonly agentSkillRefs: AgentSkillRefsService,
|
|
) {}
|
|
|
|
// ── CRUD ──────────────────────────────────────────────────────────────
|
|
|
|
async list(agentId: string): Promise<AgentTaskDto[]> {
|
|
const tasks = await this.taskRepository.findByAgentId(agentId);
|
|
return tasks.map((task) => this.toDto(task));
|
|
}
|
|
|
|
/**
|
|
* Create a task body and attach a `{ type:'task', id, enabled }` ref to the
|
|
* agent's draft config in one transaction. Does not register a cron job —
|
|
* scheduling follows the published config (see `registerEnabledForAgent`).
|
|
*/
|
|
async create(
|
|
agentId: string,
|
|
projectId: string,
|
|
dto: CreateAgentTaskDto,
|
|
context: AgentMutationTelemetryContext,
|
|
): Promise<AgentTaskDto> {
|
|
const { tasks } = await this.createTasksBatch(agentId, projectId, [dto], context);
|
|
return tasks[0];
|
|
}
|
|
|
|
/**
|
|
* Create multiple task bodies and attach their `{ type:'task', id, enabled }`
|
|
* refs to the agent's draft config in one transaction. Scoped to
|
|
* `projectId` to match the builder tool's existing project check. Does not
|
|
* register cron jobs — scheduling follows the published config.
|
|
*/
|
|
async createTasks(
|
|
agentId: string,
|
|
projectId: string,
|
|
dtos: CreateAgentTaskDto[],
|
|
context: AgentMutationTelemetryContext,
|
|
): Promise<{ tasks: AgentTaskDto[]; configHash: string | null }> {
|
|
const { tasks, agent, skillRefs } = await this.createTasksBatch(
|
|
agentId,
|
|
projectId,
|
|
dtos,
|
|
context,
|
|
);
|
|
return { tasks, configHash: getAgentConfigHash(composeJsonConfig(agent, skillRefs)) };
|
|
}
|
|
|
|
/**
|
|
* Shared implementation behind `create` and `createTasks`. Rejects an empty
|
|
* batch before loading the agent. All-or-nothing: every cron is validated
|
|
* before any row is created, so an invalid item leaves the agent untouched.
|
|
*
|
|
* Task rows and the agent config refs are saved in one transaction.
|
|
*/
|
|
private async createTasksBatch(
|
|
agentId: string,
|
|
projectId: string,
|
|
dtos: CreateAgentTaskDto[],
|
|
context: AgentMutationTelemetryContext,
|
|
): Promise<{ tasks: AgentTaskDto[]; agent: Agent; skillRefs: AgentSkillRefs }> {
|
|
if (dtos.length === 0) {
|
|
throw new BadRequestError('At least one task is required');
|
|
}
|
|
|
|
for (const dto of dtos) {
|
|
this.assertValidCron(dto.cronExpression);
|
|
this.assertValidTimezone(dto.timezone);
|
|
}
|
|
|
|
const agent = await getAgentOrThrow(this.agentRepository, agentId, projectId);
|
|
if (!agent.schema) throw new BadRequestError('Agent has no config yet');
|
|
|
|
const skillRefs = await this.agentSkillRefs.refsForDraft(agent, {});
|
|
const previous = captureAgentMutation(agent, skillRefs);
|
|
|
|
const tasks = dtos.map((dto) => {
|
|
const taskId = generateAgentResourceId(
|
|
'task',
|
|
(agent.schema?.tasks ?? []).map((ref) => ref.id),
|
|
);
|
|
this.attachTaskRef(agent, taskId, dto.enabled ?? true);
|
|
return this.taskRepository.create({
|
|
id: taskId,
|
|
agentId,
|
|
name: dto.name,
|
|
objective: dto.objective,
|
|
cronExpression: dto.cronExpression,
|
|
timezone: dto.timezone ?? null,
|
|
});
|
|
});
|
|
|
|
markAgentDraftDirty(agent);
|
|
|
|
await this.transactionRunner.run({}, async (ctx) => {
|
|
await saveAgentDraftFenced(this.agentRepository, agent, ctx);
|
|
await this.taskRepository.saveDefinitions(tasks, ctx);
|
|
});
|
|
this.saveCompletion.taskSaved(
|
|
buildAgentMutationEvent(agent, skillRefs, projectId, context, previous, { tasks: true }),
|
|
context.pushRef,
|
|
);
|
|
|
|
this.logger.debug('[AgentTaskService] Created tasks', {
|
|
agentId,
|
|
taskIds: tasks.map((task) => task.id),
|
|
});
|
|
return { tasks: tasks.map((task) => this.toDto(task)), agent, skillRefs };
|
|
}
|
|
|
|
/**
|
|
* Update a task body (name/objective/cron/timezone) in the draft. Marks the agent draft
|
|
* dirty but does NOT touch live scheduling: scheduled runs read the published
|
|
* task snapshot rows, so any body edit only takes effect on the next
|
|
* (re)publish — the "republish to apply" contract. Manual "Run now" uses the
|
|
* draft body immediately.
|
|
*/
|
|
async update(
|
|
agentId: string,
|
|
projectId: string,
|
|
taskId: string,
|
|
dto: UpdateAgentTaskDto,
|
|
context: AgentMutationTelemetryContext,
|
|
): Promise<AgentTaskDto> {
|
|
const { task } = await this.updateWithChange(agentId, projectId, taskId, dto, context);
|
|
return task;
|
|
}
|
|
|
|
async updateWithChange(
|
|
agentId: string,
|
|
projectId: string,
|
|
taskId: string,
|
|
dto: UpdateAgentTaskDto,
|
|
context: AgentMutationTelemetryContext,
|
|
): Promise<{ task: AgentTaskDto; changed: boolean }> {
|
|
// Read the revision before the task to detect concurrent replacements.
|
|
const agent = await getAgentOrThrow(this.agentRepository, agentId, projectId);
|
|
const task = await this.getOrThrow(agentId, taskId);
|
|
|
|
const changed = this.applyTaskUpdates(task, dto);
|
|
|
|
// Skip the draft version change and writes when no field changed.
|
|
if (!changed) return { task: this.toDto(task), changed: false };
|
|
|
|
const skillRefs = await this.agentSkillRefs.refsForDraft(agent, {});
|
|
const previous = captureAgentMutation(agent, skillRefs);
|
|
|
|
const saved = await this.transactionRunner.run({}, async (ctx) => {
|
|
markAgentDraftDirty(agent);
|
|
await saveAgentDraftFenced(this.agentRepository, agent, ctx);
|
|
const [savedTask] = await this.taskRepository.saveDefinitions([task], ctx);
|
|
return savedTask;
|
|
});
|
|
this.saveCompletion.taskSaved(
|
|
buildAgentMutationEvent(agent, skillRefs, projectId, context, previous, { tasks: true }),
|
|
context.pushRef,
|
|
);
|
|
|
|
return { task: this.toDto(saved), changed: true };
|
|
}
|
|
|
|
/** Delete a task body and remove its config ref in one transaction. */
|
|
async delete(
|
|
agentId: string,
|
|
projectId: string,
|
|
taskId: string,
|
|
context: AgentMutationTelemetryContext,
|
|
): Promise<void> {
|
|
await this.getOrThrow(agentId, taskId);
|
|
const agent = await getAgentOrThrow(this.agentRepository, agentId, projectId);
|
|
|
|
const skillRefs = await this.agentSkillRefs.refsForDraft(agent, {});
|
|
const previous = captureAgentMutation(agent, skillRefs);
|
|
|
|
if (agent.schema?.tasks) {
|
|
agent.schema.tasks = agent.schema.tasks.filter((ref) => ref.id !== taskId);
|
|
markAgentDraftDirty(agent);
|
|
}
|
|
|
|
await this.transactionRunner.run({}, async (ctx) => {
|
|
await saveAgentDraftFenced(this.agentRepository, agent, ctx);
|
|
await this.taskRepository.deleteForAgent(agentId, [taskId], ctx);
|
|
});
|
|
this.saveCompletion.taskSaved(
|
|
buildAgentMutationEvent(agent, skillRefs, projectId, context, previous, { tasks: true }),
|
|
context.pushRef,
|
|
);
|
|
|
|
this.logger.debug('[AgentTaskService] Deleted task', { agentId, taskId });
|
|
}
|
|
|
|
private attachTaskRef(agent: Agent, taskId: string, enabled: boolean): void {
|
|
if (!agent.schema) throw new BadRequestError('Agent has no config yet');
|
|
agent.schema.tasks = [
|
|
...(agent.schema.tasks ?? []).filter((ref) => ref.id !== taskId),
|
|
{ type: 'task', id: taskId, enabled },
|
|
];
|
|
}
|
|
|
|
// ── Scheduling lifecycle ──────────────────────────────────────────────
|
|
|
|
/**
|
|
* Reconcile an agent's task crons after a publish/unpublish/delete: apply the
|
|
* change locally when this main is leader and, in multi-main mode, broadcast
|
|
* so the leader picks it up even when a follower handled the HTTP request.
|
|
* `registerEnabledForAgent` is idempotent, so a self-delivered broadcast is
|
|
* harmless.
|
|
*/
|
|
async requestReconcile(agentId: string): Promise<void> {
|
|
// Durable scheduling is database state. The main that handles the request
|
|
// writes it directly. There is no cron to register and no peer to broadcast to.
|
|
if (this.durableJobRegistrar.isEnabled()) {
|
|
await this.durableJobRegistrar.reconcile(agentId);
|
|
return;
|
|
}
|
|
|
|
await this.registerEnabledForAgent(agentId);
|
|
this.broadcastTasksChanged(agentId);
|
|
}
|
|
|
|
/**
|
|
* Reconcile an agent's cron jobs against its PUBLISHED config. Registers
|
|
* enabled task snapshots and stops jobs that are no longer enabled/present on
|
|
* the leader; deregisters everything when the agent is unpublished or gone.
|
|
* Used by the local lifecycle path and the pubsub reconcile handler.
|
|
*/
|
|
async registerEnabledForAgent(agentId: string): Promise<void> {
|
|
// With durable scheduling on, no main can hold in-memory crons. If the
|
|
// leader registers them next to the durable jobs, every task runs twice
|
|
// per tick. The guard is here so that it also covers the pubsub reconcile
|
|
// path, for example a broadcast from a peer on the legacy path.
|
|
if (this.durableJobRegistrar.isEnabled()) return;
|
|
|
|
const agent = await this.agentRepository.findOne({
|
|
where: { id: agentId },
|
|
relations: { activeVersion: true },
|
|
});
|
|
if (!agent?.activeVersionId) {
|
|
this.deregisterAgentTasks(agentId);
|
|
return;
|
|
}
|
|
await this.reconcileAgent(agent);
|
|
}
|
|
|
|
/**
|
|
* Apply a peer main's task reconcile. Registration is leader-only, so
|
|
* followers receive the event but do not own live task cron jobs.
|
|
*/
|
|
@OnPubSubEvent('agent-tasks-changed', { instanceType: 'main' })
|
|
async handleTasksChanged(payload: PubSubCommandMap['agent-tasks-changed']): Promise<void> {
|
|
await this.registerEnabledForAgent(payload.agentId);
|
|
}
|
|
|
|
/** Broadcast a task reconcile to peer mains (no-op outside multi-main). */
|
|
private broadcastTasksChanged(agentId: string): void {
|
|
void this.changePublisher.publish({ command: 'agent-tasks-changed', payload: { agentId } });
|
|
}
|
|
|
|
/** Stop all cron jobs belonging to an agent — called on unpublish/agent delete. */
|
|
deregisterAgentTasks(agentId: string): void {
|
|
const group = agentTaskScheduleGroup(agentId);
|
|
const taskIds = this.scheduledTaskManager.getTargetIds(group);
|
|
if (!this.scheduledTaskManager.deregisterGroup(group)) return;
|
|
|
|
for (const taskId of taskIds) {
|
|
this.logger.info('[AgentTaskService] Deregistered task', { taskId });
|
|
}
|
|
}
|
|
|
|
@OnLeaderTakeover()
|
|
async reconnectAll(): Promise<void> {
|
|
// Durable jobs live in the database and survive leader changes. There is
|
|
// nothing to rebuild on takeover.
|
|
if (this.durableJobRegistrar.isEnabled()) return;
|
|
|
|
const agents = await this.agentRepository.find({
|
|
where: { activeVersionId: Not(IsNull()) },
|
|
relations: { activeVersion: true },
|
|
});
|
|
this.logger.debug('[AgentTaskService] Reconnecting published agents', { count: agents.length });
|
|
for (const agent of agents) {
|
|
try {
|
|
await this.reconcileAgent(agent);
|
|
} catch (error) {
|
|
this.logger.error('[AgentTaskService] Failed to reconnect agent tasks', {
|
|
agentId: agent.id,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
@OnLeaderStepdown()
|
|
@OnShutdown()
|
|
stopAll(): void {
|
|
this.scheduledTaskManager.deregisterGroups(AGENT_TASK_SCHEDULE_GROUP_TYPE);
|
|
}
|
|
|
|
/** Register the agent's published + enabled tasks; stop any that no longer qualify. */
|
|
private async reconcileAgent(agent: Agent): Promise<void> {
|
|
// Only the leader owns task crons — a cron firing on multiple mains would
|
|
// run the agent twice per tick. Followers skip registration entirely; the
|
|
// leader applies the change via the `agent-tasks-changed` pubsub reconcile.
|
|
// A former leader drops its jobs via @OnLeaderStepdown/@OnShutdown.
|
|
if (!this.instanceSettings.isLeader) return;
|
|
|
|
if (!agent.activeVersionId) {
|
|
this.deregisterAgentTasks(agent.id);
|
|
return;
|
|
}
|
|
|
|
const snapshots = await this.taskSnapshotRepository.findEnabledByVersionId(
|
|
agent.activeVersionId,
|
|
);
|
|
const enabledIds = new Set(snapshots.map((snapshot) => snapshot.taskId));
|
|
|
|
for (const taskId of this.scheduledTaskManager.getTargetIds(agentTaskScheduleGroup(agent.id))) {
|
|
if (!enabledIds.has(taskId)) this.deregister(agent.id, taskId);
|
|
}
|
|
|
|
if (enabledIds.size === 0) return;
|
|
|
|
// Bodies come from PUBLISHED snapshot rows, so cron/name/objective/timezone
|
|
// are all frozen at publish time; draft edits only apply on the next publish.
|
|
for (const snapshot of snapshots) {
|
|
this.registerOrRefresh(agent.id, snapshot);
|
|
}
|
|
}
|
|
|
|
private registerOrRefresh(agentId: string, snapshot: AgentTaskSnapshot): void {
|
|
const { taskId, cronExpression } = snapshot;
|
|
if (!isValidCronExpression(cronExpression)) {
|
|
this.logger.warn('[AgentTaskService] Skipping task with invalid cron', { taskId });
|
|
this.deregister(agentId, taskId);
|
|
return;
|
|
}
|
|
|
|
this.deregister(agentId, taskId);
|
|
|
|
const timezone = this.resolveTaskTimezone(snapshot.timezone, taskId);
|
|
const registered = this.scheduledTaskManager.register(
|
|
{
|
|
group: agentTaskScheduleGroup(agentId),
|
|
targetId: taskId,
|
|
expression: cronExpression,
|
|
timezone,
|
|
},
|
|
() => {
|
|
void this.startScheduledRun(agentId, taskId).catch((error: unknown) => {
|
|
this.logger.error('[AgentTaskService] Scheduled task lock failed', {
|
|
taskId,
|
|
agentId,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
});
|
|
},
|
|
);
|
|
if (!registered) return;
|
|
|
|
this.logger.info('[AgentTaskService] Registered task', {
|
|
taskId,
|
|
agentId,
|
|
cronExpression,
|
|
timezone,
|
|
});
|
|
}
|
|
|
|
private deregister(agentId: string, taskId: string): void {
|
|
const group = agentTaskScheduleGroup(agentId);
|
|
if (!this.scheduledTaskManager.hasTarget(group, taskId)) return;
|
|
|
|
this.scheduledTaskManager.deregisterTarget(group, taskId);
|
|
this.logger.info('[AgentTaskService] Deregistered task', { taskId });
|
|
}
|
|
|
|
// ── Run ───────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Checks that the task is still published and enabled, takes the run lock,
|
|
* and starts the task run in the background. Resolves as soon as the lock
|
|
* decision is made. Both schedulers enter here: the in-memory cron and the
|
|
* durable handler. The handler must report its dispatch decision within the
|
|
* lease of the occurrence, so it cannot wait for a run that takes minutes.
|
|
*
|
|
* A `stale` task drops its in-memory cron here; the durable handler removes
|
|
* its own job. A lock-acquisition error propagates to the caller. The
|
|
* background continuation logs run errors. It also renews the lock while the
|
|
* run lasts, and releases the lock after the run.
|
|
*/
|
|
async startScheduledRun(
|
|
agentId: string,
|
|
taskId: string,
|
|
): Promise<'started' | 'skipped-active' | 'skipped-disabled' | 'stale'> {
|
|
if (!(await this.settingsService.getEnabled())) return 'skipped-disabled';
|
|
|
|
// Body comes from the PUBLISHED snapshot row, so name/objective/cron
|
|
// reflect publish time rather than live draft edits.
|
|
const agent = await this.agentRepository.findOne({ where: { id: agentId } });
|
|
const snapshot = agent?.activeVersionId
|
|
? await this.taskSnapshotRepository.findByVersionAndTaskId(agent.activeVersionId, taskId)
|
|
: null;
|
|
if (!agent?.activeVersionId || !snapshot?.enabled) {
|
|
this.logger.warn('[AgentTaskService] Task fired but is no longer published and enabled', {
|
|
taskId,
|
|
agentId,
|
|
});
|
|
this.deregister(agentId, taskId);
|
|
return 'stale';
|
|
}
|
|
|
|
const holderId = randomUUID();
|
|
const lock = await this.taskRunLockRepository.acquire(agentId, taskId, {
|
|
holderId,
|
|
ttlMs: TASK_RUN_LOCK_TTL_MS,
|
|
});
|
|
if (!lock) {
|
|
this.logger.info('[AgentTaskService] Skipping task because previous run is still active', {
|
|
taskId,
|
|
agentId,
|
|
});
|
|
return 'skipped-active';
|
|
}
|
|
|
|
void this.runScheduledTask(agent, snapshot, lock);
|
|
return 'started';
|
|
}
|
|
|
|
private startTaskRunLockRenewal(lock: AgentTaskRunLockHandle): ReturnType<typeof setInterval> {
|
|
return setInterval(() => {
|
|
void this.taskRunLockRepository
|
|
.renew(lock, TASK_RUN_LOCK_TTL_MS)
|
|
.then((renewed) => {
|
|
if (!renewed) {
|
|
this.logger.warn('[AgentTaskService] Failed to renew task run lock', {
|
|
taskId: lock.taskId,
|
|
agentId: lock.agentId,
|
|
});
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
this.logger.warn('[AgentTaskService] Failed to renew task run lock', {
|
|
taskId: lock.taskId,
|
|
agentId: lock.agentId,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
});
|
|
}, TASK_RUN_LOCK_RENEW_MS);
|
|
}
|
|
|
|
private async runTask(agent: Agent, snapshot: AgentTaskSnapshot): Promise<void> {
|
|
const { id: agentId, projectId } = agent;
|
|
const { taskId } = snapshot;
|
|
const { message, threadId } = this.buildTaskRunMessage(
|
|
taskId,
|
|
snapshot.objective,
|
|
snapshot.timezone,
|
|
);
|
|
|
|
this.logger.info('[AgentTaskService] Task fired', {
|
|
taskId,
|
|
agentId,
|
|
projectId,
|
|
cronExpression: snapshot.cronExpression,
|
|
timezone: snapshot.timezone,
|
|
});
|
|
|
|
await this.consumeTaskRun(
|
|
'Task run',
|
|
{ taskId, agentId, projectId },
|
|
this.agentExecutionOrchestratorService.executeForTaskPublished({
|
|
agentId,
|
|
projectId,
|
|
message,
|
|
memory: { threadId, resourceId: taskRunMemoryResourceId(taskId) },
|
|
taskId,
|
|
taskVersionId: snapshot.versionId,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Build the single unattended message the agent receives for a task run plus
|
|
* a fresh thread id. Shared by the scheduled path (published snapshot body)
|
|
* and the manual path (live draft body), so the wake-up format stays in sync.
|
|
*/
|
|
private buildTaskRunMessage(
|
|
taskId: string,
|
|
objective: string,
|
|
taskTimezone: string | null,
|
|
): { message: string; threadId: string } {
|
|
// Timestamped in the instance timezone so it agrees with the `get_environment`
|
|
// tool the agent also reads "today" from; the schedule's own zone is named
|
|
// instead of substituted, so the two can never contradict each other.
|
|
const timezone = this.globalConfig.generic.timezone;
|
|
const timestamp = DateTime.now().setZone(timezone).toISO() ?? new Date().toISOString();
|
|
const scheduleTimezone = this.resolveTaskTimezone(taskTimezone, taskId);
|
|
const scheduleNote =
|
|
scheduleTimezone === timezone ? '' : `\nThis task is scheduled in ${scheduleTimezone}.`;
|
|
const message = `${objective}\n\nCurrent date and time: ${timestamp} (timezone: ${timezone})${scheduleNote}`;
|
|
const threadId = `task-${taskId}-${randomUUID()}`;
|
|
return { message, threadId };
|
|
}
|
|
|
|
/**
|
|
* Drive a task-run stream to completion and log it. Shared by the scheduled
|
|
* and manual paths; the caller supplies the already-built stream and a log
|
|
* label.
|
|
*/
|
|
private async consumeTaskRun(
|
|
kind: 'Task run' | 'Manual task run',
|
|
context: Record<string, unknown>,
|
|
stream: AsyncGenerator<unknown>,
|
|
): Promise<void> {
|
|
const startedAt = Date.now();
|
|
try {
|
|
let chunkCount = 0;
|
|
for await (const _chunk of stream) {
|
|
chunkCount += 1;
|
|
}
|
|
this.logger.info(`[AgentTaskService] ${kind} completed`, {
|
|
...context,
|
|
chunkCount,
|
|
durationMs: Date.now() - startedAt,
|
|
});
|
|
} catch (error) {
|
|
this.logger.error(`[AgentTaskService] ${kind} failed`, {
|
|
...context,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute a task immediately as the requesting user, ignoring publish/enabled
|
|
* state (runs the current draft config), for the "Execute task" action. The
|
|
* agent run can be long, so it is kicked off in the background and surfaces
|
|
* as a session; only the lookup is awaited so a missing task still 404s.
|
|
*/
|
|
async runNow(agentId: string, taskId: string, user: User): Promise<void> {
|
|
const task = await this.getOrThrow(agentId, taskId);
|
|
const agent = await this.agentRepository.findOne({ where: { id: agentId } });
|
|
if (!agent) {
|
|
throw new NotFoundError(`Agent "${agentId}" not found`);
|
|
}
|
|
|
|
void this.executeNow(task, agent.projectId, user);
|
|
}
|
|
|
|
private async executeNow(task: AgentTask, projectId: string, user: User): Promise<void> {
|
|
const { message, threadId } = this.buildTaskRunMessage(task.id, task.objective, task.timezone);
|
|
|
|
this.logger.info('[AgentTaskService] Manual task run started', {
|
|
taskId: task.id,
|
|
agentId: task.agentId,
|
|
projectId,
|
|
});
|
|
|
|
await this.consumeTaskRun(
|
|
'Manual task run',
|
|
{ taskId: task.id, agentId: task.agentId, projectId },
|
|
this.agentExecutionOrchestratorService.executeForTaskNow({
|
|
agentId: task.agentId,
|
|
projectId,
|
|
user,
|
|
message,
|
|
memory: { threadId, resourceId: taskRunMemoryResourceId(task.id) },
|
|
taskId: task.id,
|
|
}),
|
|
);
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────
|
|
|
|
private async getOrThrow(agentId: string, taskId: string): Promise<AgentTask> {
|
|
const task = await this.taskRepository.findByIdAndAgentId(taskId, agentId);
|
|
if (!task) {
|
|
throw new NotFoundError(`Task "${taskId}" not found`);
|
|
}
|
|
return task;
|
|
}
|
|
|
|
private assertValidCron(cronExpression: string): void {
|
|
if (!isValidCronExpression(cronExpression)) {
|
|
throw new BadRequestError('Invalid cron expression');
|
|
}
|
|
}
|
|
|
|
private assertValidTimezone(timezone: string | null | undefined): void {
|
|
if (timezone === null || timezone === undefined) return;
|
|
if (!isValidTimeZone(timezone)) {
|
|
throw new BadRequestError('Invalid timezone');
|
|
}
|
|
}
|
|
|
|
private resolveTaskTimezone(taskTimezone: string | null | undefined, taskId: string): string {
|
|
return (
|
|
knownTaskTimezone(taskTimezone, taskId, this.logger) ?? this.globalConfig.generic.timezone
|
|
);
|
|
}
|
|
|
|
private toDto(task: AgentTask): AgentTaskDto {
|
|
return {
|
|
id: task.id,
|
|
name: task.name,
|
|
objective: task.objective,
|
|
cronExpression: task.cronExpression,
|
|
timezone: task.timezone,
|
|
createdAt: task.createdAt.toISOString(),
|
|
updatedAt: task.updatedAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
private applyTaskUpdates(task: AgentTask, dto: UpdateAgentTaskDto): boolean {
|
|
let changed = false;
|
|
if (dto.cronExpression !== undefined) {
|
|
this.assertValidCron(dto.cronExpression);
|
|
if (dto.cronExpression !== task.cronExpression) {
|
|
task.cronExpression = dto.cronExpression;
|
|
changed = true;
|
|
}
|
|
}
|
|
// `null` resets to the instance timezone; omitting the field keeps the
|
|
// current one, so an older client can still update name/objective/cron.
|
|
if (dto.timezone !== undefined) {
|
|
this.assertValidTimezone(dto.timezone);
|
|
const timezone = dto.timezone ?? null;
|
|
if (timezone !== task.timezone) {
|
|
task.timezone = timezone;
|
|
changed = true;
|
|
}
|
|
}
|
|
if (dto.name !== undefined && dto.name !== task.name) {
|
|
task.name = dto.name;
|
|
changed = true;
|
|
}
|
|
if (dto.objective !== undefined && dto.objective !== task.objective) {
|
|
task.objective = dto.objective;
|
|
changed = true;
|
|
}
|
|
|
|
return changed;
|
|
}
|
|
|
|
private async runScheduledTask(
|
|
agent: Agent,
|
|
snapshot: AgentTaskSnapshot,
|
|
lock: AgentTaskRunLockHandle,
|
|
): Promise<void> {
|
|
const agentId = agent.id;
|
|
const taskId = snapshot.taskId;
|
|
const renewInterval = this.startTaskRunLockRenewal(lock);
|
|
try {
|
|
await this.runTask(agent, snapshot);
|
|
} catch (error) {
|
|
this.logger.error('[AgentTaskService] Scheduled task run failed', {
|
|
taskId,
|
|
agentId,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
} finally {
|
|
clearInterval(renewInterval);
|
|
await this.taskRunLockRepository.release(lock).catch((error) => {
|
|
this.logger.warn('[AgentTaskService] Failed to release task run lock', {
|
|
taskId,
|
|
agentId,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
});
|
|
}
|
|
}
|
|
}
|