mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(core): Enforce saved Agent plan stop boundaries (no-changelog)
This commit is contained in:
parent
cf4de22c92
commit
da6dbc7ed3
|
|
@ -1,4 +1,3 @@
|
|||
import type { AgentMessageSteeringService } from '../agent-message-steering.service';
|
||||
import type {
|
||||
Agent as RuntimeAgent,
|
||||
CredentialProvider,
|
||||
|
|
@ -26,6 +25,8 @@ import { OperationalError, UserError } from 'n8n-workflow';
|
|||
import type { InstanceSettings } from 'n8n-core';
|
||||
import type { Mock } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { AgentTaskCancellationRepository } from '@/modules/agents/repositories/agent-task-cancellation.repository';
|
||||
import type { AgentMessageSteeringService } from '../agent-message-steering.service';
|
||||
import type { AgentsSettingsService } from '../agents-settings.service';
|
||||
|
||||
import type { ExternalHooks } from '@/external-hooks';
|
||||
|
|
@ -270,12 +271,14 @@ function makeService(sandboxEnabled = false) {
|
|||
}));
|
||||
executionService.finalizeExecution.mockResolvedValue('execution-1');
|
||||
agentRunTracingService.build.mockResolvedValue(undefined);
|
||||
const cancellations = mock<AgentTaskCancellationRepository>();
|
||||
|
||||
const service = new AgentExecutionOrchestratorService(
|
||||
mockLogger(),
|
||||
checkpointStorage,
|
||||
executionService,
|
||||
new AgentTurnExecutionService(
|
||||
cancellations,
|
||||
mockLogger(),
|
||||
executionService,
|
||||
chatExecutionService,
|
||||
|
|
@ -300,6 +303,7 @@ function makeService(sandboxEnabled = false) {
|
|||
|
||||
return {
|
||||
service,
|
||||
cancellations,
|
||||
settingsService,
|
||||
backgroundJobRepository,
|
||||
backgroundJobs,
|
||||
|
|
@ -990,6 +994,26 @@ describe('AgentExecutionOrchestratorService', () => {
|
|||
expect(sdkStart.mock.calls[0].at(-1)).toMatchObject({ approvalContext });
|
||||
});
|
||||
|
||||
it('marks task cancellation in the main model and tool checks', async () => {
|
||||
const { stream, sdkStart, cancellations } = makeTurn({ previewChat: true });
|
||||
await collect(stream);
|
||||
cancellations.isCancelled.mockResolvedValue(true);
|
||||
const options = sdkStart.mock.calls[0]?.[operation === 'start' ? 1 : 2] as
|
||||
| (RunOptions & ExecutionOptions)
|
||||
| undefined;
|
||||
const hook = options?.guardrails?.hooks[0];
|
||||
expect(await hook?.before?.(mock())).toEqual({
|
||||
action: 'stop',
|
||||
code: 'tasks-cancelled',
|
||||
canceled: true,
|
||||
});
|
||||
expect(await hook?.beforeTool?.(mock())).toEqual({
|
||||
action: 'stop',
|
||||
code: 'tasks-cancelled',
|
||||
canceled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
'wires onBudgetNotice into the budget guardrail only when previewChat is %s',
|
||||
async (previewChat) => {
|
||||
|
|
@ -1004,7 +1028,7 @@ describe('AgentExecutionOrchestratorService', () => {
|
|||
const options = sdkStart.mock.calls[0]?.[operation === 'start' ? 1 : 2] as
|
||||
| (RunOptions & ExecutionOptions)
|
||||
| undefined;
|
||||
const hook = options?.guardrails?.hooks[0];
|
||||
const hook = options?.guardrails?.hooks.find((candidate) => candidate.after);
|
||||
if (!hook?.before || !hook.after) throw new Error('Expected a budget guardrail hook');
|
||||
|
||||
const ctx = {
|
||||
|
|
@ -1099,7 +1123,7 @@ describe('AgentExecutionOrchestratorService', () => {
|
|||
|
||||
await expect(resumeAgain()).rejects.toBeInstanceOf(AgentTurnAlreadyRunningError);
|
||||
|
||||
await chatExecutionService.handleCancel({
|
||||
await chatExecutionService.handleForegroundCancel({
|
||||
projectId,
|
||||
agentId,
|
||||
threadId: 'thread-1',
|
||||
|
|
@ -1129,7 +1153,7 @@ describe('AgentExecutionOrchestratorService', () => {
|
|||
const { stream, onExecutionStarted, sdkStart, executionService, chatExecutionService } =
|
||||
makeTurn({ previewChat: true });
|
||||
onExecutionStarted.mockImplementation(() => {
|
||||
void chatExecutionService.handleCancel({
|
||||
void chatExecutionService.handleForegroundCancel({
|
||||
projectId,
|
||||
agentId,
|
||||
threadId: 'thread-1',
|
||||
|
|
@ -1147,6 +1171,77 @@ describe('AgentExecutionOrchestratorService', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it.each(['chunk', 'rejection'] as const)(
|
||||
'ends a stopped chat without an error when the runtime emits an abort %s',
|
||||
async (failure) => {
|
||||
const { stream, sdkStart, chatExecutionService, executionService } = makeTurn({
|
||||
previewChat: true,
|
||||
});
|
||||
const runtimeStream = new TransformStream<StreamChunk, StreamChunk>();
|
||||
const writer = runtimeStream.writable.getWriter();
|
||||
sdkStart.mockResolvedValue({ runId: 'runtime-run-1', stream: runtimeStream.readable });
|
||||
const result = collect(stream);
|
||||
const settled = expect(result).resolves.not.toContainEqual(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
);
|
||||
await writer.write({ type: 'text-delta', id: 'text-1', delta: 'Saved partial response.' });
|
||||
await chatExecutionService.handleForegroundCancel({
|
||||
projectId,
|
||||
agentId,
|
||||
threadId: 'thread-1',
|
||||
executionId: 'execution-1',
|
||||
userId,
|
||||
});
|
||||
const error = new Error('Agent run was aborted');
|
||||
if (failure === 'chunk') {
|
||||
await writer.write({ type: 'error', error });
|
||||
await writer.write({
|
||||
type: 'finish',
|
||||
finishReason: 'error',
|
||||
usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 },
|
||||
});
|
||||
await writer.close();
|
||||
} else {
|
||||
await writer.abort(error);
|
||||
}
|
||||
await settled;
|
||||
expect(executionService.finalizeExecution).toHaveBeenCalledWith(
|
||||
'execution-1',
|
||||
expect.objectContaining({
|
||||
record: expect.objectContaining({
|
||||
finishReason: 'cancelled',
|
||||
error: null,
|
||||
assistantResponse: 'Saved partial response.',
|
||||
...(failure === 'chunk'
|
||||
? { usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 } }
|
||||
: {}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
'keeps chat errors visible when execution ownership is lost: %s',
|
||||
async (ownershipLost) => {
|
||||
const { stream, sdkStart, executionService } = makeTurn({ previewChat: true });
|
||||
const controller = new AbortController();
|
||||
const error = new OperationalError('Execution failed');
|
||||
executionService.getAbortSignal.mockReturnValue(controller.signal);
|
||||
sdkStart.mockImplementation(async () => {
|
||||
if (ownershipLost) controller.abort(error);
|
||||
return {
|
||||
runId: 'runtime-run-1',
|
||||
stream: makeReadableStream([
|
||||
{ type: 'error', error },
|
||||
{ type: 'finish', finishReason: 'error' },
|
||||
]),
|
||||
};
|
||||
});
|
||||
await expect(collect(stream)).resolves.toContainEqual({ type: 'error', error });
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['startExecutionRecording', 'finalizeExecution'] as const)(
|
||||
'reports the recording phase when %s fails',
|
||||
async (recordingOperation) => {
|
||||
|
|
@ -2242,7 +2337,9 @@ describe('AgentExecutionOrchestratorService', () => {
|
|||
}),
|
||||
);
|
||||
|
||||
expect(runtime.agent.stream.mock.calls[0][1]?.guardrails?.hooks).toHaveLength(1);
|
||||
const options = runtime.agent.stream.mock.calls[0][1] as ExecutionOptions;
|
||||
const hooks = options.guardrails?.hooks ?? [];
|
||||
expect(hooks.filter((hook) => hook.after)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects a production turn with a foreign thread or memory scope', async () => {
|
||||
|
|
@ -2776,6 +2873,41 @@ describe('AgentExecutionOrchestratorService', () => {
|
|||
).resolves.toEqual(expect.any(Array));
|
||||
});
|
||||
|
||||
it.each([{ tasks: [] }, backgroundJobSignal])(
|
||||
'records a stop acknowledgement without a results event for tasks %j',
|
||||
async ({ tasks }) => {
|
||||
const { service, runtimeCacheService, executionService } = makeService();
|
||||
const runtime = makeRuntime([
|
||||
{ type: 'text-delta', id: 'text-1', delta: 'The plan is stopped.' },
|
||||
{ type: 'finish', finishReason: 'stop' },
|
||||
]);
|
||||
runtimeCacheService.getRuntime.mockResolvedValue(runtime);
|
||||
await service.executeForWake({
|
||||
backgroundJobSignal: { tasks },
|
||||
pauseReport: true,
|
||||
planStopId: 'plan-stop',
|
||||
wakeJobIds: ['paused-job'],
|
||||
agentId,
|
||||
projectId,
|
||||
message: 'Acknowledge the stopped plan.',
|
||||
memory: { threadId: 'thread-1', resourceId: 'draft-chat:user-1' },
|
||||
identity: { type: 'draft', user, principalHash: userPrincipalHash },
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
const recording = executionService.startExecutionRecording.mock.calls[0][0];
|
||||
expect(recording.initialTimeline).toBeUndefined();
|
||||
expect(recording.wake).toEqual({ jobIds: ['paused-job'], planStopId: 'plan-stop' });
|
||||
expect(executionService.finalizeExecution).toHaveBeenCalledWith(
|
||||
'execution-1',
|
||||
expect.objectContaining({
|
||||
record: expect.objectContaining({
|
||||
timeline: [expect.objectContaining({ type: 'text', content: 'The plan is stopped.' })],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('blocks tools and rejects a pause report interrupted by its guardrail', async () => {
|
||||
const { service, runtimeCacheService } = makeService();
|
||||
const runtime = makeRuntime([
|
||||
|
|
@ -2804,11 +2936,10 @@ describe('AgentExecutionOrchestratorService', () => {
|
|||
});
|
||||
const options = runtime.agent.stream.mock.calls[0][1] as ExecutionOptions;
|
||||
expect(options.toolsEnabled).toBe(false);
|
||||
const hook = options.guardrails?.hooks?.find((candidate) => candidate.beforeTool);
|
||||
expect(await hook?.beforeTool?.(mock())).toMatchObject({
|
||||
action: 'stop',
|
||||
code: 'background-pause-report',
|
||||
});
|
||||
const decisions = await Promise.all(
|
||||
(options.guardrails?.hooks ?? []).map(async (hook) => await hook.beforeTool?.(mock())),
|
||||
);
|
||||
expect(decisions).toContainEqual({ action: 'stop', code: 'background-pause-report' });
|
||||
});
|
||||
|
||||
it('records a background continuation while Agents is disabled', async () => {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
|
|||
import { mock } from 'vitest-mock-extended';
|
||||
import type { ErrorReporter, StorageConfig } from 'n8n-core';
|
||||
|
||||
import { AgentTaskCancellationRepository } from '@/modules/agents/repositories/agent-task-cancellation.repository';
|
||||
import type { Telemetry } from '@/telemetry';
|
||||
|
||||
import type { AgentChatAttachmentService } from '../agent-chat-attachment.service';
|
||||
|
|
@ -134,6 +135,7 @@ describe('AgentExecutionService', () => {
|
|||
txRunner.run.mockImplementation(async (ctx, fn) => await fn(ctx));
|
||||
|
||||
service = new AgentExecutionService(
|
||||
mock<AgentTaskCancellationRepository>(),
|
||||
mockLogger(),
|
||||
agentExecutionRepository,
|
||||
agentExecutionThreadRepository,
|
||||
|
|
@ -769,6 +771,7 @@ describe('AgentExecutionService', () => {
|
|||
it('writes the timeline to blob storage in non-db mode', async () => {
|
||||
storageConfig = mock<StorageConfig>({ modeTag: 'fs' });
|
||||
service = new AgentExecutionService(
|
||||
mock<AgentTaskCancellationRepository>(),
|
||||
mockLogger(),
|
||||
agentExecutionRepository,
|
||||
agentExecutionThreadRepository,
|
||||
|
|
@ -866,6 +869,7 @@ describe('AgentExecutionService', () => {
|
|||
async (failure, currentLocation, shouldDeleteBlob) => {
|
||||
storageConfig = mock<StorageConfig>({ modeTag: 'fs' });
|
||||
service = new AgentExecutionService(
|
||||
mock<AgentTaskCancellationRepository>(),
|
||||
mockLogger(),
|
||||
agentExecutionRepository,
|
||||
agentExecutionThreadRepository,
|
||||
|
|
@ -1480,6 +1484,7 @@ describe('AgentExecutionService', () => {
|
|||
it('preserves an interrupted execution inline without overwriting blob storage', async () => {
|
||||
storageConfig = mock<StorageConfig>({ modeTag: 'fs' });
|
||||
service = new AgentExecutionService(
|
||||
mock<AgentTaskCancellationRepository>(),
|
||||
mockLogger(),
|
||||
agentExecutionRepository,
|
||||
agentExecutionThreadRepository,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import type { AgentExecutionThreadRepository } from '../repositories/agent-execu
|
|||
import type { AgentMessageQueueRepository } from '../repositories/agent-message-queue.repository';
|
||||
import type { AgentMessageRepository } from '../repositories/agent-message.repository';
|
||||
import type { AgentRepository } from '../repositories/agent.repository';
|
||||
import type { AgentTaskCancellationRepository } from '../repositories/agent-task-cancellation.repository';
|
||||
|
||||
function makeThread(overrides: Partial<AgentExecutionThread> = {}): AgentExecutionThread {
|
||||
return mock<AgentExecutionThread>({
|
||||
|
|
@ -56,6 +57,7 @@ describe('AgentMessageQueueService', () => {
|
|||
new Map([['thread-1', N8N_CHAT_PRODUCTION_SOURCE]]),
|
||||
);
|
||||
service = new AgentMessageQueueService(
|
||||
mock<AgentTaskCancellationRepository>(),
|
||||
txRunner,
|
||||
repository,
|
||||
threadRepository,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
type EventService,
|
||||
type ProjectScopeService,
|
||||
} from '@n8n/backend-services';
|
||||
import { AgentTaskCancellationRepository } from '@/modules/agents/repositories/agent-task-cancellation.repository';
|
||||
import type { AgentMessageSteeringService } from '../agent-message-steering.service';
|
||||
import type { Mocked } from 'vitest';
|
||||
import { type AgentJsonConfig } from '@n8n/api-types';
|
||||
|
|
@ -305,6 +306,7 @@ describe('AgentRuntimeReconstructionService integration tools', () => {
|
|||
n8nCheckpointStorage,
|
||||
agentExecutionService,
|
||||
new AgentTurnExecutionService(
|
||||
mock<AgentTaskCancellationRepository>(),
|
||||
logger,
|
||||
agentExecutionService,
|
||||
mock<AgentChatExecutionService>(),
|
||||
|
|
|
|||
|
|
@ -7,10 +7,12 @@ import { AgentMessageQueueService } from '../agent-message-queue.service';
|
|||
import { AgentMessageSteeringService } from '../agent-message-steering.service';
|
||||
import { AgentToolApprovalService } from '../agent-tool-approval.service';
|
||||
import { AgentTurnExecutionService, type AgentTurnRequest } from '../agent-turn-execution.service';
|
||||
import { AgentTaskCancellationRepository } from '../repositories/agent-task-cancellation.repository';
|
||||
|
||||
it('ends a preview turn stopped during preparation without recording an error', async () => {
|
||||
const executions = mock<AgentExecutionService>();
|
||||
const service = new AgentTurnExecutionService(
|
||||
mock<AgentTaskCancellationRepository>(),
|
||||
mock<Logger>(),
|
||||
executions,
|
||||
mock<AgentChatExecutionService>(),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import type { AgentMessageSteeringService } from '../agent-message-steering.service';
|
||||
import type { Agent as RuntimeAgent, ExecutionOptions, RunOptions, StreamChunk } from '@n8n/agents';
|
||||
import type { AgentJsonConfig } from '@n8n/api-types';
|
||||
import { mockInstance, mockLogger } from '@n8n/backend-test-utils';
|
||||
|
|
@ -10,6 +9,8 @@ import { OperationalError, UserError } from 'n8n-workflow';
|
|||
import type { ExecuteAgentWorkflowContext, IRunExecutionData } from 'n8n-workflow';
|
||||
import type { Mock } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { AgentTaskCancellationRepository } from '@/modules/agents/repositories/agent-task-cancellation.repository';
|
||||
import type { AgentMessageSteeringService } from '../agent-message-steering.service';
|
||||
import type { AgentsSettingsService } from '../agents-settings.service';
|
||||
import { SkillService } from '../skills/skill.service';
|
||||
|
||||
|
|
@ -165,6 +166,7 @@ function makeService() {
|
|||
logger,
|
||||
agentRepository,
|
||||
new AgentTurnExecutionService(
|
||||
mock<AgentTaskCancellationRepository>(),
|
||||
mockLogger(),
|
||||
executionService,
|
||||
mock<AgentChatExecutionService>(),
|
||||
|
|
|
|||
|
|
@ -214,6 +214,8 @@ export interface ExecuteForTaskNowConfig extends AgentExecutionInput {
|
|||
}
|
||||
|
||||
export interface ExecuteForWakeConfig extends AgentExecutionInput {
|
||||
wakeJobIds?: string[];
|
||||
planStopId?: string;
|
||||
backgroundJobSignal: AgentBackgroundJobSignal;
|
||||
pauseReport?: boolean;
|
||||
abortSignal: AbortSignal;
|
||||
|
|
@ -227,6 +229,7 @@ export interface ExecuteForWakeConfig extends AgentExecutionInput {
|
|||
}
|
||||
|
||||
export interface StreamChatResponseConfig extends ChatExecutionInput, ChatExecutionCallbacks {
|
||||
wake?: { jobIds: string[]; planStopId?: string };
|
||||
onAdmitted?: () => Promise<void>;
|
||||
access: AgentThreadAccess;
|
||||
messageContext?: IntegrationMessageContext | null;
|
||||
|
|
@ -1118,6 +1121,7 @@ export class AgentExecutionOrchestratorService {
|
|||
threadId: config.memory.threadId,
|
||||
},
|
||||
backgroundJobSignal: config.backgroundJobSignal,
|
||||
wake: config.wake,
|
||||
onExecutionRecorded: config.onExecutionRecorded,
|
||||
chatSurface: config.chatSurface,
|
||||
isWakeRun: config.isWakeRun,
|
||||
|
|
@ -1833,9 +1837,13 @@ export class AgentExecutionOrchestratorService {
|
|||
sandboxPrincipalHash: identity.principalHash,
|
||||
hideUserMessageFromTranscript: true,
|
||||
isWakeRun: true,
|
||||
wake: {
|
||||
jobIds: config.wakeJobIds ?? config.backgroundJobSignal.tasks.map((job) => job.id),
|
||||
...(config.planStopId ? { planStopId: config.planStopId } : {}),
|
||||
},
|
||||
pauseReport: config.pauseReport,
|
||||
sessionMode: 'existing',
|
||||
backgroundJobSignal: config.backgroundJobSignal,
|
||||
backgroundJobSignal: config.pauseReport ? undefined : config.backgroundJobSignal,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { ErrorReporter, StorageConfig } from 'n8n-core';
|
|||
import { OperationalError, UnexpectedError } from 'n8n-workflow';
|
||||
|
||||
import { ConflictError } from '@n8n/errors';
|
||||
import { AgentTaskCancellationRepository } from './repositories/agent-task-cancellation.repository';
|
||||
import type { AgentRunTelemetryType, IAgentConfigurationTelemetryProperties } from '@/interfaces';
|
||||
import { Telemetry } from '@/telemetry';
|
||||
|
||||
|
|
@ -100,6 +101,7 @@ export interface RecordMessageParams {
|
|||
}
|
||||
|
||||
export interface StartExecutionParams extends Omit<RecordMessageParams, 'record' | 'hitlStatus'> {
|
||||
wake?: { jobIds: string[]; planStopId?: string };
|
||||
resourceId: string;
|
||||
messageOrigin?: Omit<AgentMessageOrigin, 'source' | 'hidden'>;
|
||||
hideUserMessageFromTranscript?: boolean;
|
||||
|
|
@ -182,6 +184,7 @@ export class AgentExecutionService {
|
|||
private readonly sideCallUsageInFlightByExecution = new Map<string, Set<Promise<void>>>();
|
||||
|
||||
constructor(
|
||||
private readonly cancellations: AgentTaskCancellationRepository,
|
||||
private readonly logger: Logger,
|
||||
private readonly agentExecutionRepository: AgentExecutionRepository,
|
||||
private readonly agentExecutionThreadRepository: AgentExecutionThreadRepository,
|
||||
|
|
@ -224,6 +227,8 @@ export class AgentExecutionService {
|
|||
const prepared = lockedQueueThread
|
||||
? { thread: lockedQueueThread, created: false }
|
||||
: await this.prepareThread(params, ctx);
|
||||
if (params.wake)
|
||||
await this.cancellations.assertWakeAdmission(params.threadId, params.wake, ctx);
|
||||
const { queueItem, predecessorId } = await this.checkAdmission(params, ctx);
|
||||
const execution = this.agentExecutionRepository.create({
|
||||
threadId: params.threadId,
|
||||
|
|
@ -249,6 +254,19 @@ export class AgentExecutionService {
|
|||
attachments: null,
|
||||
});
|
||||
const inserted = await this.agentExecutionRepository.saveInContext(execution, ctx);
|
||||
await this.cancellations.recordAdmission(
|
||||
params.threadId,
|
||||
inserted.id,
|
||||
{
|
||||
planStopId: params.wake?.planStopId,
|
||||
userInitiated:
|
||||
!params.wake &&
|
||||
!params.hideUserMessageFromTranscript &&
|
||||
params.userMessage !== null &&
|
||||
!params.resumeRunId,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
const inputMessageIds = await this.reserveInput(
|
||||
params,
|
||||
inserted.id,
|
||||
|
|
@ -348,6 +366,8 @@ export class AgentExecutionService {
|
|||
) {
|
||||
throw new AgentTurnAlreadyRunningError();
|
||||
}
|
||||
// A validated stop report must finish before later user input can run.
|
||||
if (params.wake?.planStopId) return { queueItem: null, predecessorId: undefined };
|
||||
const head = await this.queueRepository.findHead(threadId, ctx);
|
||||
if (head?.id !== queueItemId && (head || queueItemId)) throw new AgentTurnAlreadyRunningError();
|
||||
return { queueItem: head, predecessorId: undefined };
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { OperationalError, UnexpectedError, UserError } from 'n8n-workflow';
|
|||
|
||||
import { ConflictError, BadRequestError, NotFoundError } from '@n8n/errors';
|
||||
|
||||
import { AgentTaskCancellationRepository } from './repositories/agent-task-cancellation.repository';
|
||||
import { AgentChatAttachmentService } from './agent-chat-attachment.service';
|
||||
import { AgentMessageSteeringService } from './agent-message-steering.service';
|
||||
import { AgentsSettingsService } from './agents-settings.service';
|
||||
|
|
@ -64,6 +65,7 @@ export class AgentMessageQueueService {
|
|||
onAvailable?: (threadId: string) => void;
|
||||
|
||||
constructor(
|
||||
private readonly cancellations: AgentTaskCancellationRepository,
|
||||
private readonly txRunner: TransactionRunner,
|
||||
private readonly repository: AgentMessageQueueRepository,
|
||||
private readonly threadRepository: AgentExecutionThreadRepository,
|
||||
|
|
@ -358,6 +360,7 @@ export class AgentMessageQueueService {
|
|||
if (!thread) return null;
|
||||
steeringChanged = await this.steering.releaseInactive(thread, ctx);
|
||||
if (await this.isBlocked(thread, ctx)) return null;
|
||||
if (await this.cancellations.blocksQueue(threadId, ctx)) return null;
|
||||
const active = await this.repository.findActive(threadId, ctx);
|
||||
if (active?.executionId)
|
||||
await this.repository.removeActive(threadId, active.executionId, ctx);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type {
|
||||
Agent as RuntimeAgent,
|
||||
ExecutionOptions,
|
||||
GuardrailDecision,
|
||||
ResumeOptions,
|
||||
RunOptions,
|
||||
SideCallUsageReport,
|
||||
|
|
@ -11,6 +12,7 @@ import { Logger } from '@n8n/backend-common';
|
|||
import { Service } from '@n8n/di';
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
|
||||
import { AgentTaskCancellationRepository } from './repositories/agent-task-cancellation.repository';
|
||||
import type { AgentChatSurface, AgentSessionMode } from './utils/agent-thread-access';
|
||||
import { AgentExecutionRecordingError } from './agent-execution-recording.error';
|
||||
import { AgentTurnAlreadyRunningError } from './agent-turn-already-running.error';
|
||||
|
|
@ -48,6 +50,7 @@ export type AgentTurnRequest = { recording: StartExecutionParams } & (
|
|||
);
|
||||
|
||||
interface ExecuteTurnConfig {
|
||||
wake?: StartExecutionParams['wake'];
|
||||
admittedExecution?: AgentExecutionAdmission;
|
||||
onAdmitted?: () => Promise<void>;
|
||||
agentInstance: RuntimeAgent;
|
||||
|
|
@ -99,6 +102,7 @@ function getMaxIterationsChunks(): StreamChunk[] {
|
|||
@Service()
|
||||
export class AgentTurnExecutionService {
|
||||
constructor(
|
||||
private readonly cancellations: AgentTaskCancellationRepository,
|
||||
private readonly logger: Logger,
|
||||
private readonly agentExecutionService: AgentExecutionService,
|
||||
private readonly chatExecutionService: AgentChatExecutionService,
|
||||
|
|
@ -460,6 +464,7 @@ export class AgentTurnExecutionService {
|
|||
const admission = await this.startExecution(
|
||||
{
|
||||
...turn.recording,
|
||||
wake: config.wake,
|
||||
previewChat: config.chatSurface === 'preview' ? true : undefined,
|
||||
acceptsSteering: chatControl !== undefined,
|
||||
resumeRunId: turn.type === 'resume' ? turn.options.runId : undefined,
|
||||
|
|
@ -484,6 +489,18 @@ export class AgentTurnExecutionService {
|
|||
chatControl?: ChatExecutionControl,
|
||||
): Promise<ReadableStream<StreamChunk>> {
|
||||
const { executionId, inputMessageIds } = admission;
|
||||
const checkCancellation = async (): Promise<GuardrailDecision | undefined> =>
|
||||
(await this.cancellations.isCancelled(config.context.threadId, executionId))
|
||||
? { action: 'stop', code: 'tasks-cancelled', canceled: true }
|
||||
: undefined;
|
||||
turn.options.guardrails = {
|
||||
...turn.options.guardrails,
|
||||
hooks: [
|
||||
{ before: checkCancellation, beforeTool: checkCancellation },
|
||||
...(turn.options.guardrails?.hooks ?? []),
|
||||
],
|
||||
};
|
||||
|
||||
if (turn.type === 'start') turn.input = bindExecutionInput(turn.input, inputMessageIds);
|
||||
const executionSignal = this.agentExecutionService.getAbortSignal(executionId);
|
||||
turn.options.abortSignal = turn.options.abortSignal
|
||||
|
|
|
|||
|
|
@ -572,6 +572,17 @@ describe('Agent plan tools', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it.each(['execution-1', 42, undefined])(
|
||||
'passes only string execution metadata to plan writes: %s',
|
||||
async (id) => {
|
||||
await call('update_plan', { ...write, document: { description: 'New detail' } }, {
|
||||
persistence: { ...context.persistence!, hostMetadata: { n8nExecutionId: id } },
|
||||
} as ToolContext);
|
||||
expect(service.replacePlan).toHaveBeenCalledWith(expect.anything(), {
|
||||
sourceExecutionId: typeof id === 'string' ? id : undefined,
|
||||
});
|
||||
},
|
||||
);
|
||||
it('uses the last task end time when all leaves finish before the plan closes', () => {
|
||||
const first = {
|
||||
...original.data.items[0],
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import type { BuiltTool } from '@n8n/agents';
|
||||
import { Tool } from '@n8n/agents/tool';
|
||||
import { planLeaves } from './agent-plan-work-state';
|
||||
import type { OperationContext } from '@n8n/db';
|
||||
import { executionIdFromMetadata } from '../types/agent-queued-message';
|
||||
import type { AgentPlanOperationContext } from '../repositories/agent-plan.repository';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
|
||||
|
|
@ -251,7 +252,7 @@ function scopedTool<Schema extends z.ZodType>(
|
|||
handler: (
|
||||
input: z.output<Schema>,
|
||||
threadId: string,
|
||||
ctx: OperationContext,
|
||||
ctx: AgentPlanOperationContext,
|
||||
) => Promise<AgentPlanSnapshot | null>,
|
||||
) {
|
||||
return new Tool(name)
|
||||
|
|
@ -267,7 +268,11 @@ function scopedTool<Schema extends z.ZodType>(
|
|||
}
|
||||
|
||||
try {
|
||||
return presentPlan(await handler(schema.parse(input), threadId, {}));
|
||||
return presentPlan(
|
||||
await handler(schema.parse(input), threadId, {
|
||||
sourceExecutionId: executionIdFromMetadata(context.persistence?.hostMetadata),
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof AgentPlanWriteConflictError) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -8,11 +8,13 @@ import {
|
|||
type AgentBackgroundJobKind,
|
||||
type AgentBackgroundJobStatus,
|
||||
} from '../entities/agent-background-job.entity';
|
||||
import { AgentTaskCancellationRepository } from './agent-task-cancellation.repository';
|
||||
import { AgentCheckpoint } from '../entities/agent-checkpoint.entity';
|
||||
import { AgentExecution } from '../entities/agent-execution.entity';
|
||||
import { AgentExecutionThreadRepository } from './agent-execution-thread.repository';
|
||||
|
||||
type NewAgentBackgroundJobBase = {
|
||||
sourceExecutionId?: string;
|
||||
id: string;
|
||||
parentAgentId: string;
|
||||
parentThreadId: string;
|
||||
|
|
@ -48,7 +50,16 @@ export type ExpectedBackgroundJobState = {
|
|||
|
||||
export type BackgroundJobGroupItem = Pick<
|
||||
AgentBackgroundJob,
|
||||
'id' | 'kind' | 'title' | 'status' | 'createdAt' | 'settledAt' | 'notifiedAt' | 'pauseRequestId'
|
||||
| 'id'
|
||||
| 'kind'
|
||||
| 'title'
|
||||
| 'status'
|
||||
| 'createdAt'
|
||||
| 'settledAt'
|
||||
| 'notifiedAt'
|
||||
| 'pauseRequestId'
|
||||
| 'planId'
|
||||
| 'planTaskId'
|
||||
>;
|
||||
|
||||
@Service()
|
||||
|
|
@ -57,12 +68,15 @@ export class AgentBackgroundJobRepository extends BaseRepository<AgentBackground
|
|||
dataSource: DataSource,
|
||||
transactionRunner: TransactionRunner,
|
||||
private readonly threadRepository: AgentExecutionThreadRepository,
|
||||
private readonly cancellations: AgentTaskCancellationRepository,
|
||||
) {
|
||||
super(AgentBackgroundJob, dataSource.manager, transactionRunner);
|
||||
}
|
||||
|
||||
async insertSubAgentJobIfCapacity(job: NewSubAgentJob, limit: number): Promise<boolean> {
|
||||
return await this.runInTransaction({}, async (manager, ctx) => {
|
||||
await this.cancellations.lockScope(job.parentThreadId, ctx);
|
||||
await this.cancellations.assertAdmission(job.parentThreadId, job.sourceExecutionId, ctx);
|
||||
if (!(await this.threadRepository.lockById(job.parentThreadId, ctx))) {
|
||||
throw new UserError('Session not found');
|
||||
}
|
||||
|
|
@ -80,12 +94,17 @@ export class AgentBackgroundJobRepository extends BaseRepository<AgentBackground
|
|||
async insertWorkflowJobOrGetExisting(
|
||||
job: NewWorkflowJob,
|
||||
): Promise<{ inserted: true } | { inserted: false; existing: AgentBackgroundJob }> {
|
||||
await this.createQueryBuilder()
|
||||
.insert()
|
||||
.into(AgentBackgroundJob)
|
||||
.values({ ...job, status: 'running' })
|
||||
.orIgnore()
|
||||
.execute();
|
||||
await this.runInTransaction({}, async (manager, ctx) => {
|
||||
await this.cancellations.lockScope(job.parentThreadId, ctx);
|
||||
await manager
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(AgentBackgroundJob)
|
||||
.values({ ...job, status: 'running' })
|
||||
.orIgnore()
|
||||
.execute();
|
||||
// The service stops late workflow receipts before returning them to the caller.
|
||||
});
|
||||
|
||||
const inserted = await this.existsBy({ id: job.id });
|
||||
if (inserted) return { inserted: true };
|
||||
|
|
@ -128,6 +147,8 @@ export class AgentBackgroundJobRepository extends BaseRepository<AgentBackground
|
|||
'settledAt',
|
||||
'notifiedAt',
|
||||
'pauseRequestId',
|
||||
'planId',
|
||||
'planTaskId',
|
||||
],
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
|
|
@ -507,7 +528,7 @@ export class AgentBackgroundJobRepository extends BaseRepository<AgentBackground
|
|||
},
|
||||
{
|
||||
status: settlement.status,
|
||||
result: settlement.result ?? null,
|
||||
result: settlement.result ?? (settlement.status === 'cancelled' ? () => '"result"' : null),
|
||||
error: settlement.error ?? null,
|
||||
settledAt: new Date(),
|
||||
notifiedAt:
|
||||
|
|
@ -520,6 +541,11 @@ export class AgentBackgroundJobRepository extends BaseRepository<AgentBackground
|
|||
return result.affected === 1;
|
||||
}
|
||||
|
||||
async preserveLateCompletedResult(id: string, result: string): Promise<void> {
|
||||
// A confirmed output remains useful even when cancellation won the terminal-state race.
|
||||
await this.update({ id, status: 'cancelled', result: IsNull() }, { result });
|
||||
}
|
||||
|
||||
/** Delete settled jobs older than the cutoff only if their results are marked as delivered. */
|
||||
async deleteSettledBefore(cutoff: Date): Promise<void> {
|
||||
await this.delete({
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { Service } from '@n8n/di';
|
|||
import { DataSource, In, IsNull, Not } from '@n8n/typeorm';
|
||||
import { isDraftIntegration } from '@n8n/api-types';
|
||||
|
||||
import { readInboundUserMessage } from '../utils/inbound-attachments';
|
||||
import { AgentMessageEntity } from '../entities/agent-message.entity';
|
||||
import { AgentMessageQueue } from '../entities/agent-message-queue.entity';
|
||||
import { Agent } from '../entities/agent.entity';
|
||||
import type { AgentQueueDispatch, QueuedUserChatMessage } from '../types/agent-queued-message';
|
||||
|
|
@ -109,6 +111,27 @@ export class AgentMessageQueueRepository extends BaseRepository<AgentMessageQueu
|
|||
return result.affected === 1;
|
||||
}
|
||||
|
||||
async discardPending(threadId: string, ctx: OperationContext) {
|
||||
const manager = this.managerFor(ctx);
|
||||
const items = await this.listPending(threadId, ctx);
|
||||
if (!items.length) return [];
|
||||
const attachmentIds = items.flatMap((item) =>
|
||||
readInboundUserMessage(item.message.content).attachments.map((attachment) => attachment.id),
|
||||
);
|
||||
await manager.delete(AgentMessageQueue, { threadId, executionId: IsNull() });
|
||||
await manager.update(
|
||||
AgentMessageEntity,
|
||||
{ id: In(items.map((item) => item.messageId)) },
|
||||
{
|
||||
content: { role: 'user', content: [] },
|
||||
author: null,
|
||||
modelContent: null,
|
||||
modelContextAt: null,
|
||||
},
|
||||
);
|
||||
return attachmentIds;
|
||||
}
|
||||
|
||||
async findHead(threadId: string, ctx: OperationContext) {
|
||||
return await this.managerFor(ctx).findOne(AgentMessageQueue, {
|
||||
where: { threadId },
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { validate as isUuid } from 'uuid';
|
|||
|
||||
import { AgentPlanHistory } from '../entities/agent-plan-history.entity';
|
||||
import { AgentPlan } from '../entities/agent-plan.entity';
|
||||
import { AgentTaskCancellationRepository } from './agent-task-cancellation.repository';
|
||||
|
||||
export type AgentPlanRecord = Pick<
|
||||
AgentPlan,
|
||||
|
|
@ -25,6 +26,8 @@ export type AgentPlanRevision = Pick<
|
|||
|
||||
export type AgentPlanRevisionMetadata = Omit<AgentPlanRevision, 'planId' | 'data'>;
|
||||
|
||||
export type AgentPlanOperationContext = OperationContext & { sourceExecutionId?: string };
|
||||
|
||||
type PlanWrite = { threadId: string; planId: string; expectedRevision: number };
|
||||
type PlanDocument = { formatVersion: number; data: JsonObject };
|
||||
|
||||
|
|
@ -36,19 +39,25 @@ export class AgentPlanWriteConflictError extends UserError {
|
|||
|
||||
@Service()
|
||||
export class AgentPlanRepository extends BaseRepository<AgentPlan> {
|
||||
constructor(dataSource: DataSource, transactionRunner: TransactionRunner) {
|
||||
constructor(
|
||||
dataSource: DataSource,
|
||||
transactionRunner: TransactionRunner,
|
||||
private readonly cancellations: AgentTaskCancellationRepository,
|
||||
) {
|
||||
super(AgentPlan, dataSource.manager, transactionRunner);
|
||||
}
|
||||
|
||||
async createActivePlan(
|
||||
input: PlanDocument & { id: string; threadId: string },
|
||||
ctx: OperationContext,
|
||||
ctx: AgentPlanOperationContext,
|
||||
): Promise<AgentPlanRecord> {
|
||||
if (!isUuid(input.id)) throw new UserError('The plan ID must be a UUID');
|
||||
this.validateVersion(input.formatVersion);
|
||||
const data = this.serializeData(input.data);
|
||||
|
||||
return await this.runInTransaction(ctx, async (manager) => {
|
||||
return await this.runInTransaction(ctx, async (manager, tx) => {
|
||||
await this.cancellations.lockScope(input.threadId, tx);
|
||||
await this.cancellations.assertAdmission(input.threadId, ctx.sourceExecutionId, tx);
|
||||
const now = new Date();
|
||||
try {
|
||||
await manager
|
||||
|
|
@ -82,14 +91,24 @@ export class AgentPlanRepository extends BaseRepository<AgentPlan> {
|
|||
});
|
||||
}
|
||||
|
||||
async findActivePlan(threadId: string, ctx: OperationContext): Promise<AgentPlanRecord | null> {
|
||||
async findLatestPlan(threadId: string, ctx: AgentPlanOperationContext) {
|
||||
return await this.managerFor(ctx).findOne(AgentPlan, {
|
||||
where: { threadId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findActivePlan(
|
||||
threadId: string,
|
||||
ctx: AgentPlanOperationContext,
|
||||
): Promise<AgentPlanRecord | null> {
|
||||
return await this.managerFor(ctx).findOneBy(AgentPlan, { threadId, closedAt: IsNull() });
|
||||
}
|
||||
|
||||
async findPlan(
|
||||
threadId: string,
|
||||
planId: string,
|
||||
ctx: OperationContext,
|
||||
ctx: AgentPlanOperationContext,
|
||||
): Promise<AgentPlanRecord | null> {
|
||||
return await this.managerFor(ctx).findOneBy(AgentPlan, { id: planId, threadId });
|
||||
}
|
||||
|
|
@ -98,7 +117,7 @@ export class AgentPlanRepository extends BaseRepository<AgentPlan> {
|
|||
threadId: string,
|
||||
planId: string,
|
||||
revision: number,
|
||||
ctx: OperationContext,
|
||||
ctx: AgentPlanOperationContext,
|
||||
): Promise<AgentPlanRevision | null> {
|
||||
return await this.managerFor(ctx).findOne(AgentPlanHistory, {
|
||||
where: { planId, revision, plan: { threadId } },
|
||||
|
|
@ -109,7 +128,7 @@ export class AgentPlanRepository extends BaseRepository<AgentPlan> {
|
|||
threadId: string,
|
||||
planId: string,
|
||||
options: { afterRevision?: number; limit?: number },
|
||||
ctx: OperationContext,
|
||||
ctx: AgentPlanOperationContext,
|
||||
): Promise<{ items: AgentPlanRevisionMetadata[]; nextCursor: number | null }> {
|
||||
const limit = options.limit ?? 50;
|
||||
const afterRevision = options.afterRevision ?? 0;
|
||||
|
|
@ -149,24 +168,26 @@ export class AgentPlanRepository extends BaseRepository<AgentPlan> {
|
|||
|
||||
async replacePlan(
|
||||
input: PlanWrite & PlanDocument,
|
||||
ctx: OperationContext,
|
||||
ctx: AgentPlanOperationContext,
|
||||
): Promise<AgentPlanRecord> {
|
||||
this.validateVersion(input.formatVersion);
|
||||
return await this.writeRevision(input, { ...input, data: this.serializeData(input.data) }, ctx);
|
||||
}
|
||||
|
||||
async closePlan(input: PlanWrite, ctx: OperationContext): Promise<AgentPlanRecord> {
|
||||
async closePlan(input: PlanWrite, ctx: AgentPlanOperationContext): Promise<AgentPlanRecord> {
|
||||
return await this.writeRevision(input, null, ctx);
|
||||
}
|
||||
|
||||
private async writeRevision(
|
||||
input: PlanWrite,
|
||||
document: { formatVersion: number; data: string } | null,
|
||||
ctx: OperationContext,
|
||||
ctx: AgentPlanOperationContext,
|
||||
): Promise<AgentPlanRecord> {
|
||||
this.validateVersion(input.expectedRevision);
|
||||
|
||||
return await this.runInTransaction(ctx, async (manager) => {
|
||||
return await this.runInTransaction(ctx, async (manager, tx) => {
|
||||
await this.cancellations.lockScope(input.threadId, tx);
|
||||
await this.cancellations.assertAdmission(input.threadId, ctx.sourceExecutionId, tx);
|
||||
const now = new Date();
|
||||
const query = manager
|
||||
.createQueryBuilder()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,346 @@
|
|||
import type { SerializableAgentState } from '@n8n/agents';
|
||||
import { AgentsConfig } from '@n8n/config';
|
||||
import { Time } from '@n8n/constants';
|
||||
import { BaseRepository, TransactionRunner, type OperationContext } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { DataSource, In, IsNull, MoreThanOrEqual, Not } from '@n8n/typeorm';
|
||||
import { jsonParse, UserError } from 'n8n-workflow';
|
||||
|
||||
import { checkpointExecutionId } from '../types/agent-queued-message';
|
||||
import type { AgentTaskStop } from '../types/agent-task-stop';
|
||||
import { AgentExecution } from '../entities/agent-execution.entity';
|
||||
import { AgentExecutionThread } from '../entities/agent-execution-thread.entity';
|
||||
import { AgentBackgroundJob } from '../entities/agent-background-job.entity';
|
||||
import { AgentPlan } from '../entities/agent-plan.entity';
|
||||
import { AgentCheckpoint } from '../entities/agent-checkpoint.entity';
|
||||
import { AgentExecutionThreadRepository } from './agent-execution-thread.repository';
|
||||
|
||||
type TaskStopScope = AgentTaskStop & { threadId: string };
|
||||
|
||||
@Service()
|
||||
export class AgentTaskCancellationRepository extends BaseRepository<AgentExecutionThread> {
|
||||
constructor(
|
||||
dataSource: DataSource,
|
||||
transactionRunner: TransactionRunner,
|
||||
private readonly threads: AgentExecutionThreadRepository,
|
||||
private readonly agentsConfig: AgentsConfig,
|
||||
) {
|
||||
super(AgentExecutionThread, dataSource.manager, transactionRunner);
|
||||
}
|
||||
|
||||
async latest(threadId: string, ctx: OperationContext = {}): Promise<TaskStopScope | null> {
|
||||
const thread = await this.managerFor(ctx).findOneBy(AgentExecutionThread, { id: threadId });
|
||||
return this.readStop(thread);
|
||||
}
|
||||
|
||||
async saveStop(stop: TaskStopScope, ctx: OperationContext) {
|
||||
const { threadId, ...taskStop } = stop;
|
||||
await this.managerFor(ctx).update(AgentExecutionThread, { id: threadId }, { taskStop });
|
||||
return stop;
|
||||
}
|
||||
|
||||
async captureGeneration(threadId: string, ctx: OperationContext) {
|
||||
const manager = this.managerFor(ctx);
|
||||
const [executions, jobs, children] = await Promise.all([
|
||||
manager.find(AgentExecution, {
|
||||
where: { threadId },
|
||||
select: ['id'],
|
||||
}),
|
||||
manager.find(AgentBackgroundJob, {
|
||||
where: { parentThreadId: threadId },
|
||||
select: ['id', 'sourceExecutionId', 'childThreadId'],
|
||||
}),
|
||||
manager.find(AgentExecutionThread, { where: { parentThreadId: threadId }, select: ['id'] }),
|
||||
]);
|
||||
return {
|
||||
executionIds: [
|
||||
...new Set([
|
||||
...executions.map((execution) => execution.id),
|
||||
...jobs.flatMap((job) => (job.sourceExecutionId ? [job.sourceExecutionId] : [])),
|
||||
]),
|
||||
],
|
||||
jobIds: jobs.map((job) => job.id),
|
||||
threadIds: [
|
||||
...new Set([
|
||||
...children.map((child) => child.id),
|
||||
...jobs.flatMap((job) => (job.childThreadId ? [job.childThreadId] : [])),
|
||||
]),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async blocksQueue(threadId: string, ctx: OperationContext) {
|
||||
const stop = await this.latest(threadId, ctx);
|
||||
return Boolean(stop && !stop.pause.reportedAt);
|
||||
}
|
||||
|
||||
/** Lock ancestors first so dispatch and cancellation use the same admission boundary. */
|
||||
async lockScope(threadId: string, ctx: OperationContext) {
|
||||
const lineage = await this.lineage(threadId, ctx);
|
||||
for (const thread of lineage.toReversed()) await this.threads.lockById(thread.id, ctx);
|
||||
}
|
||||
|
||||
async assertWakeAdmission(
|
||||
threadId: string,
|
||||
wake: { jobIds: string[]; planStopId?: string },
|
||||
ctx: OperationContext,
|
||||
) {
|
||||
const pause = await this.latest(threadId, ctx);
|
||||
if (wake.planStopId) {
|
||||
if (
|
||||
!pause ||
|
||||
pause.pause.id !== wake.planStopId ||
|
||||
pause.pause.reportExecutionId ||
|
||||
pause.pause.reportedAt ||
|
||||
pause.pause.resumedAt ||
|
||||
(await this.unfinishedWork(pause, ctx)).length
|
||||
) {
|
||||
throw new UserError('This plan stop report is no longer available');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (await this.hasPausedAncestor(threadId, ctx)) {
|
||||
throw new UserError('The plan is stopped. Wait for a new user request.');
|
||||
}
|
||||
}
|
||||
|
||||
async assertAdmission(threadId: string, executionId: string | undefined, ctx: OperationContext) {
|
||||
if (
|
||||
(await this.isCancelled(threadId, executionId, ctx)) ||
|
||||
(await this.hasPausedAncestor(threadId, ctx))
|
||||
) {
|
||||
throw new UserError('These tasks were canceled. Wait for a new user request.');
|
||||
}
|
||||
}
|
||||
|
||||
async isCancelled(threadId: string, executionId?: string, ctx: OperationContext = {}) {
|
||||
const request = await this.latest(threadId, ctx);
|
||||
if (!request) return false;
|
||||
const execution = executionId
|
||||
? await this.managerFor(ctx).findOneBy(AgentExecution, { id: executionId, threadId })
|
||||
: await this.managerFor(ctx).findOne(AgentExecution, {
|
||||
where: { threadId, status: 'running' },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
return Boolean(execution && request.generation.executionIds.includes(execution.id));
|
||||
}
|
||||
|
||||
async targetedJobs(request: TaskStopScope, ctx: OperationContext = {}) {
|
||||
const manager = this.managerFor(ctx);
|
||||
const jobs: AgentBackgroundJob[] = [];
|
||||
const visited = new Set<string>();
|
||||
const pending = [request.threadId, ...request.generation.threadIds];
|
||||
while (pending.length) {
|
||||
const threadId = pending.shift()!;
|
||||
if (visited.has(threadId)) continue;
|
||||
visited.add(threadId);
|
||||
const children = await manager.find(AgentBackgroundJob, {
|
||||
where:
|
||||
threadId === request.threadId
|
||||
? [
|
||||
{ parentThreadId: threadId, id: In(request.generation.jobIds) },
|
||||
{
|
||||
parentThreadId: threadId,
|
||||
sourceExecutionId: In(request.generation.executionIds),
|
||||
},
|
||||
]
|
||||
: { parentThreadId: threadId },
|
||||
});
|
||||
jobs.push(...children);
|
||||
pending.push(...children.flatMap((job) => (job.childThreadId ? [job.childThreadId] : [])));
|
||||
const delegated = await manager.find(AgentExecutionThread, {
|
||||
where: {
|
||||
parentThreadId: threadId,
|
||||
...(threadId === request.threadId ? { id: In(request.generation.threadIds) } : {}),
|
||||
},
|
||||
});
|
||||
pending.push(...delegated.map((thread) => thread.id));
|
||||
}
|
||||
return jobs;
|
||||
}
|
||||
|
||||
async targetedDescendants(request: TaskStopScope, ctx: OperationContext = {}) {
|
||||
const jobs = await this.targetedJobs(request, ctx);
|
||||
const threads = [
|
||||
...request.generation.threadIds,
|
||||
...jobs.flatMap((job) => (job.childThreadId ? [job.childThreadId] : [])),
|
||||
];
|
||||
const seen = new Set(threads);
|
||||
let parents = [...threads];
|
||||
while (parents.length) {
|
||||
const descendants = await this.managerFor(ctx).find(AgentExecutionThread, {
|
||||
where: { parentThreadId: In(parents) },
|
||||
});
|
||||
parents = descendants.filter((thread) => !seen.has(thread.id)).map((thread) => thread.id);
|
||||
for (const id of parents) seen.add(id);
|
||||
}
|
||||
return await this.managerFor(ctx).findBy(AgentExecutionThread, {
|
||||
id: In([...seen].filter((id) => id !== request.threadId)),
|
||||
});
|
||||
}
|
||||
|
||||
async unfinishedWork(request: TaskStopScope, ctx: OperationContext = {}) {
|
||||
const manager = this.managerFor(ctx);
|
||||
const children = await this.targetedDescendants(request, ctx);
|
||||
const childIds = children.map((thread) => thread.id);
|
||||
const executions = await manager.find(AgentExecution, {
|
||||
where: [
|
||||
{ threadId: request.threadId, id: In(request.generation.executionIds), status: 'running' },
|
||||
{ threadId: In(childIds), status: 'running' },
|
||||
],
|
||||
});
|
||||
const checkpoints = await manager.find(AgentCheckpoint, {
|
||||
where: {
|
||||
threadId: request.threadId,
|
||||
state: Not(IsNull()),
|
||||
expired: false,
|
||||
updatedAt: MoreThanOrEqual(
|
||||
new Date(
|
||||
Date.now() - this.agentsConfig.checkpointTtlSeconds * Time.seconds.toMilliseconds,
|
||||
),
|
||||
),
|
||||
},
|
||||
});
|
||||
const jobs = await this.targetedJobs(request, ctx);
|
||||
return [
|
||||
...jobs
|
||||
.filter((job) => ['running', 'suspended'].includes(job.status))
|
||||
.map((job) => ({ jobId: job.id, title: job.title })),
|
||||
...executions.map((execution) => ({
|
||||
jobId: execution.id,
|
||||
title:
|
||||
children.find((child) => child.id === execution.threadId)?.agentName ??
|
||||
'Current response',
|
||||
})),
|
||||
...checkpoints.flatMap((checkpoint) => {
|
||||
const state = jsonParse<SerializableAgentState | null>(checkpoint.state ?? '', {
|
||||
fallbackValue: null,
|
||||
});
|
||||
if (state?.status !== 'suspended') return [];
|
||||
const executionId = checkpointExecutionId(state);
|
||||
if (executionId && !request.generation.executionIds.includes(executionId)) return [];
|
||||
return [{ jobId: executionId ?? request.threadId, title: 'Current response' }];
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
async pausedScope(threadId: string, ctx: OperationContext = {}) {
|
||||
return (
|
||||
(await this.lineage(threadId, ctx))
|
||||
.map((thread) => this.readStop(thread))
|
||||
.find((stop) => stop && !stop.pause.resumedAt) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async hasPausedAncestor(threadId: string, ctx: OperationContext) {
|
||||
return Boolean(await this.pausedScope(threadId, ctx));
|
||||
}
|
||||
|
||||
async recordAdmission(
|
||||
threadId: string,
|
||||
executionId: string,
|
||||
input: {
|
||||
planStopId?: string;
|
||||
userInitiated: boolean;
|
||||
},
|
||||
ctx: OperationContext,
|
||||
) {
|
||||
const stop = await this.latest(threadId, ctx);
|
||||
if (!stop) return;
|
||||
if (input.planStopId) {
|
||||
stop.pause.reportExecutionId = executionId;
|
||||
} else if (input.userInitiated && !stop.pause.resumedAt) {
|
||||
if (!stop.pause.reportedAt) throw new UserError('The plan is still stopping');
|
||||
stop.pause.resumedAt = new Date().toISOString();
|
||||
} else return;
|
||||
await this.saveStop(stop, ctx);
|
||||
}
|
||||
|
||||
async pendingPauseReports() {
|
||||
const isPostgres = this.manager.connection.options.type === 'postgres';
|
||||
const pause = isPostgres
|
||||
? 'thread."taskStop"->\'pause\''
|
||||
: "json_extract(thread.taskStop, '$.pause')";
|
||||
const reportedAt = isPostgres
|
||||
? "thread.\"taskStop\"->'pause'->>'reportedAt'"
|
||||
: "json_extract(thread.taskStop, '$.pause.reportedAt')";
|
||||
const threads = await this.createQueryBuilder('thread')
|
||||
.select('thread.id')
|
||||
.where(`${pause} IS NOT NULL`)
|
||||
.andWhere(`${reportedAt} IS NULL`)
|
||||
.getMany();
|
||||
return threads.map((thread) => thread.id);
|
||||
}
|
||||
|
||||
async pauseReportPlan(stop: TaskStopScope) {
|
||||
return stop.planId
|
||||
? await this.managerFor({}).findOneBy(AgentPlan, {
|
||||
threadId: stop.threadId,
|
||||
id: stop.planId,
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
async pauseReportTarget(threadId: string) {
|
||||
return await this.findOneBy({ id: threadId });
|
||||
}
|
||||
|
||||
async finishPauseReport(threadId: string, stopId: string, failed = false) {
|
||||
return await this.runInTransaction({}, async (manager, ctx) => {
|
||||
await this.threads.lockById(threadId, ctx);
|
||||
const stop = await this.latest(threadId, ctx);
|
||||
if (!stop || stop.pause.id !== stopId || stop.pause.reportedAt) return;
|
||||
const execution = stop.pause.reportExecutionId
|
||||
? await manager.findOneBy(AgentExecution, { id: stop.pause.reportExecutionId })
|
||||
: null;
|
||||
if (execution?.status === 'running' || (!execution && !failed)) return;
|
||||
stop.pause.reportedAt = new Date().toISOString();
|
||||
stop.pause.reportFailed =
|
||||
failed || !execution || ['error', 'interrupted', 'cancelled'].includes(execution.status);
|
||||
// Composer Stop also consumes this report. It must not trigger another reply.
|
||||
await this.saveStop(stop, ctx);
|
||||
const jobs = await this.targetedJobs(stop, ctx);
|
||||
if (jobs.length)
|
||||
await manager.update(
|
||||
AgentBackgroundJob,
|
||||
{
|
||||
id: In(jobs.map((job) => job.id)),
|
||||
status: In(['paused', 'completed', 'failed', 'cancelled']),
|
||||
},
|
||||
{ notifiedAt: new Date(stop.pause.reportedAt) },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private readStop(thread: AgentExecutionThread | null): TaskStopScope | null {
|
||||
if (!thread?.taskStop) return null;
|
||||
const stop = thread.taskStop;
|
||||
return {
|
||||
...stop,
|
||||
threadId: thread.id,
|
||||
// Legacy stops have no pending report and do not pause later turns.
|
||||
pause: stop.pause ?? {
|
||||
id: `legacy:${thread.id}:${stop.requestedAt}`,
|
||||
reportedAt: stop.requestedAt,
|
||||
resumedAt: stop.requestedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async lineage(threadId: string, ctx: OperationContext) {
|
||||
const threads: AgentExecutionThread[] = [];
|
||||
const visited = new Set<string>();
|
||||
let id: string | null = threadId;
|
||||
while (id && !visited.has(id)) {
|
||||
visited.add(id);
|
||||
const thread: AgentExecutionThread | null = await this.managerFor(ctx).findOneBy(
|
||||
AgentExecutionThread,
|
||||
{ id },
|
||||
);
|
||||
if (!thread) break;
|
||||
threads.push(thread);
|
||||
id = thread.parentThreadId;
|
||||
}
|
||||
return threads;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import type { AgentMessageSteeringService } from '../../agent-message-steering.service';
|
||||
import {
|
||||
INLINE_SUB_AGENT_ID,
|
||||
type BuiltAgent,
|
||||
|
|
@ -21,6 +20,8 @@ import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
|
|||
import type { Mocked } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { AgentTaskCancellationRepository } from '@/modules/agents/repositories/agent-task-cancellation.repository';
|
||||
import type { AgentMessageSteeringService } from '../../agent-message-steering.service';
|
||||
import type { AgentExecutionService } from '../../agent-execution.service';
|
||||
import type { AgentMessageQueueService } from '../../agent-message-queue.service';
|
||||
import type { AgentChatExecutionService } from '../../agent-chat-execution.service';
|
||||
|
|
@ -130,6 +131,7 @@ describe('SubAgentRunner', () => {
|
|||
let logger: Mocked<Logger>;
|
||||
let checkpointStorage: Mocked<N8NCheckpointStorage>;
|
||||
let credentialProvider: Mocked<CredentialProvider>;
|
||||
let cancellations: Mocked<AgentTaskCancellationRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -150,9 +152,12 @@ describe('SubAgentRunner', () => {
|
|||
agentExecutionService.finalizeExecution.mockResolvedValue('agent-execution-1');
|
||||
checkpointStorage = mock<N8NCheckpointStorage>();
|
||||
logger = mock<Logger>();
|
||||
cancellations = mock<AgentTaskCancellationRepository>();
|
||||
runner = new SubAgentRunner(
|
||||
cancellations,
|
||||
sourceResolver,
|
||||
new AgentTurnExecutionService(
|
||||
cancellations,
|
||||
logger,
|
||||
agentExecutionService,
|
||||
mock<AgentChatExecutionService>(),
|
||||
|
|
@ -193,6 +198,47 @@ describe('SubAgentRunner', () => {
|
|||
expect(reconstructionService.reconstructFromResolvedSource).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('marks task cancellation in the child model and tool checks', async () => {
|
||||
await runner.run(spawnRequest, {
|
||||
parentAgentId,
|
||||
projectId,
|
||||
credentialProvider,
|
||||
runType: 'production',
|
||||
});
|
||||
cancellations.isCancelled.mockResolvedValue(true);
|
||||
const hooks = childAgent.stream.mock.calls[0][1]?.guardrails?.hooks ?? [];
|
||||
const decisions = await Promise.all([
|
||||
hooks[0]?.before?.(mock()),
|
||||
hooks[0]?.beforeTool?.(mock()),
|
||||
]);
|
||||
expect(decisions).toEqual([
|
||||
{ action: 'stop', code: 'tasks-cancelled', canceled: true },
|
||||
{ action: 'stop', code: 'tasks-cancelled', canceled: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('blocks a tool returned after an ancestor stops and retains the pause checkpoint path', async () => {
|
||||
const shouldPause = vi.fn().mockResolvedValue(false);
|
||||
await runner.run(spawnRequest, {
|
||||
parentAgentId,
|
||||
projectId,
|
||||
credentialProvider,
|
||||
runType: 'production',
|
||||
shouldPause,
|
||||
});
|
||||
cancellations.isCancelled.mockResolvedValue(false);
|
||||
cancellations.hasPausedAncestor.mockResolvedValue(true);
|
||||
const options = childAgent.stream.mock.calls[0][1];
|
||||
expect(await options?.guardrails?.hooks?.[0]?.beforeTool?.(mock())).toEqual({
|
||||
action: 'stop',
|
||||
code: 'tasks-stopped',
|
||||
canceled: true,
|
||||
});
|
||||
expect(await options?.shouldPause?.()).toBe(true);
|
||||
expect(shouldPause).toHaveBeenCalled();
|
||||
expect(cancellations.hasPausedAncestor).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rebuilds the child through the shared reconstruction service and runs it with a fresh prompt', async () => {
|
||||
agentExecutionService.startExecutionRecording.mockImplementation(
|
||||
async (_params, startedAt) => ({
|
||||
|
|
@ -795,7 +841,8 @@ describe('SubAgentRunner', () => {
|
|||
});
|
||||
|
||||
const options = childAgent.stream.mock.calls[0]?.[1];
|
||||
expect(options?.guardrails?.hooks).toEqual([]);
|
||||
expect(options?.guardrails?.hooks).toHaveLength(1);
|
||||
expect(options?.guardrails?.hooks[0].after).toBeUndefined();
|
||||
expect(options?.guardrails?.onNotice).toBeDefined();
|
||||
});
|
||||
|
||||
|
|
@ -903,7 +950,7 @@ describe('SubAgentRunner', () => {
|
|||
expect.objectContaining({
|
||||
runId: 'child-run-1',
|
||||
hostMetadata: { n8nExecutionId: 'agent-execution-1' },
|
||||
shouldPause,
|
||||
shouldPause: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
type DelegateSubAgentCancelRequest,
|
||||
type DelegateSubAgentResumeRequest,
|
||||
type GenerateResult,
|
||||
type GuardrailDecision,
|
||||
type JSONObject,
|
||||
type JSONValue,
|
||||
type SerializableAgentState,
|
||||
|
|
@ -34,6 +35,7 @@ import { isRecord } from '@n8n/utils/is-record';
|
|||
import { jsonParse, UserError } from 'n8n-workflow';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { AgentTaskCancellationRepository } from '../repositories/agent-task-cancellation.repository';
|
||||
import type { AgentRunTelemetryType } from '@/interfaces';
|
||||
|
||||
import type { StartExecutionParams } from '../agent-execution.service';
|
||||
|
|
@ -166,6 +168,7 @@ type ForegroundOperation = {
|
|||
@Service()
|
||||
export class SubAgentRunner {
|
||||
constructor(
|
||||
private readonly cancellations: AgentTaskCancellationRepository,
|
||||
private readonly sourceResolver: SubAgentSourceResolver,
|
||||
private readonly turnExecutionService: AgentTurnExecutionService,
|
||||
private readonly checkpointStorage: N8NCheckpointStorage,
|
||||
|
|
@ -357,6 +360,10 @@ export class SubAgentRunner {
|
|||
{ ...notice, agentId: notice.agentId ?? runtimeSource.source.sourceId },
|
||||
)
|
||||
: undefined;
|
||||
const checkCancellation = async (): Promise<GuardrailDecision | undefined> =>
|
||||
(await this.cancellations.isCancelled(threadId, executionId))
|
||||
? { action: 'stop', code: 'tasks-cancelled', canceled: true }
|
||||
: undefined;
|
||||
const executionOptions = withBudgetGuardrail(
|
||||
{
|
||||
approvalContext: await this.toolApprovalService.createContext(
|
||||
|
|
@ -367,7 +374,21 @@ export class SubAgentRunner {
|
|||
...(telemetry !== undefined ? { telemetry } : {}),
|
||||
...modelStreamStallOptions(this.aiConfig),
|
||||
executionCounter: context.executionCounter,
|
||||
shouldPause: context.shouldPause,
|
||||
shouldPause: async () =>
|
||||
(await context.shouldPause?.()) ||
|
||||
(await this.cancellations.hasPausedAncestor(threadId, {})),
|
||||
guardrails: {
|
||||
hooks: [
|
||||
{
|
||||
before: checkCancellation,
|
||||
beforeTool: async () =>
|
||||
(await checkCancellation()) ??
|
||||
((await this.cancellations.hasPausedAncestor(threadId, {}))
|
||||
? { action: 'stop' as const, code: 'tasks-stopped', canceled: true as const }
|
||||
: undefined),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
useRootSessionCap: true,
|
||||
|
|
|
|||
|
|
@ -79,6 +79,10 @@ export const EXECUTION_METADATA_KEY = 'n8nExecutionId';
|
|||
|
||||
export function checkpointExecutionId(state: SerializableAgentState): string | undefined {
|
||||
if (state.persistence?.delegated) return undefined;
|
||||
const id = state.persistence?.hostMetadata?.[EXECUTION_METADATA_KEY];
|
||||
return executionIdFromMetadata(state.persistence?.hostMetadata);
|
||||
}
|
||||
|
||||
export function executionIdFromMetadata(metadata?: Record<string, unknown>): string | undefined {
|
||||
const id = metadata?.[EXECUTION_METADATA_KEY];
|
||||
return typeof id === 'string' ? id : undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { AgentTaskCancellationRepository } from '@/modules/agents/repositories/agent-task-cancellation.repository';
|
||||
import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { TransactionRunner } from '@n8n/db';
|
||||
|
|
@ -114,7 +115,13 @@ describe('AgentPlanService', () => {
|
|||
},
|
||||
{},
|
||||
);
|
||||
const fresh = new AgentPlanService(new AgentPlanRepository(dataSource, transactionRunner));
|
||||
const fresh = new AgentPlanService(
|
||||
new AgentPlanRepository(
|
||||
dataSource,
|
||||
transactionRunner,
|
||||
Container.get(AgentTaskCancellationRepository),
|
||||
),
|
||||
);
|
||||
expect(await fresh.findActivePlan(threadId, {})).toEqual(updated);
|
||||
expect(updated.readiness.ready).toEqual([second.id]);
|
||||
expect(updated.data.items[0].startedAt).toEqual(updated.data.items[0].endedAt);
|
||||
|
|
|
|||
|
|
@ -401,6 +401,8 @@ describe('AgentBackgroundJobRepository', () => {
|
|||
const jobs = await repository.findGroupCandidates(agentId, 'thread-1');
|
||||
expect(jobs).toHaveLength(1);
|
||||
expect({ ...jobs[0] }).toEqual({
|
||||
planId: null,
|
||||
planTaskId: null,
|
||||
id,
|
||||
kind: 'subagent',
|
||||
title: 'Research',
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { v4 as uuid } from 'uuid';
|
|||
import { mock } from 'vitest-mock-extended';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AgentTaskCancellationRepository } from '@/modules/agents/repositories/agent-task-cancellation.repository';
|
||||
import type { Telemetry } from '@/telemetry';
|
||||
import type { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||
import type { ExternalHooks } from '@/external-hooks';
|
||||
|
|
@ -193,7 +194,14 @@ describe('AgentExecutionRepository', () => {
|
|||
memory.getImplementation.mockReturnValue(memoryBackend);
|
||||
const attachmentService = mock<AgentChatAttachmentService>();
|
||||
const executionLogStore = mock<AgentExecutionLogStore>();
|
||||
const cancellations = new AgentTaskCancellationRepository(
|
||||
connection ?? repository.manager.connection,
|
||||
txRunner,
|
||||
threads,
|
||||
new AgentsConfig(),
|
||||
);
|
||||
const executionService = new AgentExecutionService(
|
||||
cancellations,
|
||||
mockLogger(),
|
||||
executions,
|
||||
threads,
|
||||
|
|
@ -231,6 +239,7 @@ describe('AgentExecutionRepository', () => {
|
|||
mock<AgentExecutionUpdateBroadcaster>(),
|
||||
);
|
||||
const queue = new AgentMessageQueueService(
|
||||
cancellations,
|
||||
txRunner,
|
||||
queueRepository,
|
||||
threads,
|
||||
|
|
@ -249,6 +258,7 @@ describe('AgentExecutionRepository', () => {
|
|||
await finalize();
|
||||
});
|
||||
return {
|
||||
cancellations,
|
||||
steering,
|
||||
settingsService,
|
||||
txRunner,
|
||||
|
|
@ -262,6 +272,7 @@ describe('AgentExecutionRepository', () => {
|
|||
attachmentService,
|
||||
executionLogStore,
|
||||
turns: new AgentTurnExecutionService(
|
||||
cancellations,
|
||||
mockLogger(),
|
||||
executionService,
|
||||
chatExecutionService,
|
||||
|
|
@ -493,6 +504,7 @@ describe('AgentExecutionRepository', () => {
|
|||
);
|
||||
const runner = new SubAgentBackgroundRunner(
|
||||
new SubAgentRunner(
|
||||
mock<AgentTaskCancellationRepository>(),
|
||||
sourceResolver,
|
||||
turns,
|
||||
storage,
|
||||
|
|
@ -1678,6 +1690,84 @@ describe('AgentExecutionRepository', () => {
|
|||
await services.queue.settle(item.thread.id, item.admission.executionId);
|
||||
}
|
||||
|
||||
it('admits one stop report before later input without consuming the queued message', async () => {
|
||||
const services = recordingServices();
|
||||
const threadId = uuid();
|
||||
await enqueue(services, input(threadId, 'Start', 'new'));
|
||||
const first = await claim(services, threadId);
|
||||
await finish(services, first);
|
||||
const stopId = uuid();
|
||||
await services.cancellations.saveStop(
|
||||
{
|
||||
threadId,
|
||||
planId: null,
|
||||
requestedAt: new Date().toISOString(),
|
||||
generation: await services.cancellations.captureGeneration(threadId, {}),
|
||||
failures: [],
|
||||
pause: { id: stopId },
|
||||
},
|
||||
{},
|
||||
);
|
||||
const pending = await enqueue(services, input(threadId, 'Continue'));
|
||||
expect(await services.queue.claimNext(threadId, async () => true)).toBeNull();
|
||||
const recording = {
|
||||
...first.recording,
|
||||
queueItemId: undefined,
|
||||
userMessage: 'Report the stop',
|
||||
hideUserMessageFromTranscript: true,
|
||||
wake: { jobIds: [], planStopId: stopId },
|
||||
};
|
||||
const start = async (params = recording) =>
|
||||
await services.executionService.startExecutionRecording(params, new Date());
|
||||
await expect(
|
||||
start({ ...recording, wake: { jobIds: [], planStopId: uuid() } }),
|
||||
).rejects.toThrow('This plan stop report is no longer available');
|
||||
await expect(start({ ...recording, wake: { jobIds: [], planStopId: '' } })).rejects.toThrow(
|
||||
'The plan is stopped',
|
||||
);
|
||||
const blocker = await repository.save({ id: uuid(), threadId, status: 'running' });
|
||||
await expect(start()).rejects.toBeInstanceOf(AgentTurnAlreadyRunningError);
|
||||
await repository.update(blocker.id, { status: 'success' });
|
||||
await services.queueRepository.update(pending.id, { executionId: blocker.id });
|
||||
await expect(start()).rejects.toBeInstanceOf(AgentTurnAlreadyRunningError);
|
||||
await services.queueRepository.update(pending.id, { executionId: null });
|
||||
const checkpoints = Container.get(AgentCheckpointRepository);
|
||||
const checkpoint = await checkpoints.save({
|
||||
runId: uuid(),
|
||||
agentId,
|
||||
threadId,
|
||||
expired: false,
|
||||
state: JSON.stringify({
|
||||
status: 'suspended',
|
||||
persistence: {
|
||||
threadId,
|
||||
hostMetadata: { [EXECUTION_METADATA_KEY]: blocker.id },
|
||||
},
|
||||
}),
|
||||
});
|
||||
await expect(start()).rejects.toBeInstanceOf(AgentTurnAlreadyRunningError);
|
||||
await checkpoints.delete(checkpoint.runId);
|
||||
const report = await start();
|
||||
expect(report.inputMessageIds).not.toContain(pending.messageId);
|
||||
expect(await services.queueRepository.findItem(threadId, pending.id, {})).toMatchObject({
|
||||
executionId: null,
|
||||
});
|
||||
await expect(start()).rejects.toThrow('This plan stop report is no longer available');
|
||||
expect(await services.queue.claimNext(threadId, async () => true)).toBeNull();
|
||||
const recorder = new ExecutionRecorder();
|
||||
recorder.record({ type: 'text-delta', id: 'report', delta: 'Tasks stopped.' });
|
||||
recorder.record({ type: 'finish', finishReason: 'stop' });
|
||||
await services.executionService.finalizeExecution(report.executionId, {
|
||||
...recording,
|
||||
record: recorder.getMessageRecord(),
|
||||
});
|
||||
await services.cancellations.finishPauseReport(threadId, stopId);
|
||||
const next = await claim(services, threadId);
|
||||
expect(next.admission.inputMessageIds).toEqual([pending.messageId]);
|
||||
await finish(services, next);
|
||||
expect(await services.queue.claimNext(threadId, async () => true)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'finishes active work and cancels pending messages when Agents turns off (attachment cleanup fails: %s)',
|
||||
async (cleanupFails) => {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { DataSource, InsertQueryBuilder } from '@n8n/typeorm';
|
|||
import type { JsonObject } from 'n8n-workflow';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { AgentTaskCancellationRepository } from '@/modules/agents/repositories/agent-task-cancellation.repository';
|
||||
import { AgentExecutionThread } from '@/modules/agents/entities/agent-execution-thread.entity';
|
||||
import { AgentPlanHistory } from '@/modules/agents/entities/agent-plan-history.entity';
|
||||
import { AgentPlan } from '@/modules/agents/entities/agent-plan.entity';
|
||||
|
|
@ -76,7 +77,11 @@ describe('AgentPlanRepository', () => {
|
|||
unknownField: { list: [null, true, 3, 'text', { nested: [] }] },
|
||||
};
|
||||
const plan = await createPlan(data);
|
||||
const freshRepository = new AgentPlanRepository(dataSource, transactionRunner);
|
||||
const freshRepository = new AgentPlanRepository(
|
||||
dataSource,
|
||||
transactionRunner,
|
||||
Container.get(AgentTaskCancellationRepository),
|
||||
);
|
||||
|
||||
expect(await freshRepository.findActivePlan(threadId, {})).toEqual(plan);
|
||||
expect(await freshRepository.findPlan(threadId, plan.id, {})).toEqual(plan);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,515 @@
|
|||
import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils';
|
||||
import { Container } from '@n8n/di';
|
||||
import { DataSource } from '@n8n/typeorm';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { Agent } from '@/modules/agents/entities/agent.entity';
|
||||
import { AgentCheckpoint } from '@/modules/agents/entities/agent-checkpoint.entity';
|
||||
import { AgentExecutionThread } from '@/modules/agents/entities/agent-execution-thread.entity';
|
||||
import { AgentExecution } from '@/modules/agents/entities/agent-execution.entity';
|
||||
import { AgentBackgroundJob } from '@/modules/agents/entities/agent-background-job.entity';
|
||||
import { AgentTaskCancellationRepository } from '@/modules/agents/repositories/agent-task-cancellation.repository';
|
||||
import { AgentMessageQueueRepository } from '@/modules/agents/repositories/agent-message-queue.repository';
|
||||
import { AgentMessageRepository } from '@/modules/agents/repositories/agent-message.repository';
|
||||
import { AgentPlanRepository } from '@/modules/agents/repositories/agent-plan.repository';
|
||||
import { AgentBackgroundJobRepository } from '@/modules/agents/repositories/agent-background-job.repository';
|
||||
|
||||
const beforeStop = new Date('2026-10-01T10:00:00.000Z');
|
||||
const cutoffAt = new Date('2026-10-01T10:01:00.000Z');
|
||||
const afterStop = new Date('2026-10-01T10:02:00.000Z');
|
||||
|
||||
describe('Task cancellation persistence', () => {
|
||||
let db: DataSource;
|
||||
let repository: AgentTaskCancellationRepository;
|
||||
let queue: AgentMessageQueueRepository;
|
||||
let plans: AgentPlanRepository;
|
||||
let thread: AgentExecutionThread;
|
||||
let agentId: string;
|
||||
let projectId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await testModules.loadModules(['agents']);
|
||||
await testDb.init();
|
||||
db = Container.get(DataSource);
|
||||
repository = Container.get(AgentTaskCancellationRepository);
|
||||
queue = Container.get(AgentMessageQueueRepository);
|
||||
plans = Container.get(AgentPlanRepository);
|
||||
({ id: projectId } = await createTeamProject());
|
||||
agentId = randomUUID();
|
||||
await db.getRepository(Agent).save({
|
||||
id: agentId,
|
||||
name: 'Cancellation tests',
|
||||
projectId,
|
||||
integrations: [],
|
||||
tools: {},
|
||||
skills: {},
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
thread = await db.getRepository(AgentExecutionThread).save({
|
||||
id: randomUUID(),
|
||||
agentId,
|
||||
agentName: 'Cancellation tests',
|
||||
projectId,
|
||||
sessionNumber: 1,
|
||||
createdAt: beforeStop,
|
||||
});
|
||||
});
|
||||
afterEach(async () => {
|
||||
await db.getRepository(AgentExecutionThread).delete({ agentId });
|
||||
});
|
||||
afterAll(async () => await testDb.terminate());
|
||||
|
||||
async function request(planId: string | null = null) {
|
||||
return await repository.saveStop(
|
||||
{
|
||||
threadId: thread.id,
|
||||
planId,
|
||||
requestedAt: cutoffAt.toISOString(),
|
||||
generation: await repository.captureGeneration(thread.id, {}),
|
||||
failures: [],
|
||||
pause: { id: randomUUID() },
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
async function enqueue(message: string) {
|
||||
const input = await Container.get(AgentMessageRepository).createInput(
|
||||
{
|
||||
threadId: thread.id,
|
||||
resourceId: 'draft-chat:test',
|
||||
content: { role: 'user', content: [{ type: 'text', text: message }] },
|
||||
origin: { source: 'chat' },
|
||||
},
|
||||
{},
|
||||
);
|
||||
return await queue.enqueue(thread.id, input.id, { kind: 'preview' }, {});
|
||||
}
|
||||
|
||||
it('excludes legacy stops from pending reports without skipping current reports', async () => {
|
||||
const legacy = {
|
||||
planId: null,
|
||||
requestedAt: cutoffAt.toISOString(),
|
||||
generation: { executionIds: [], jobIds: [], threadIds: [] },
|
||||
failures: [],
|
||||
};
|
||||
await db.getRepository(AgentExecutionThread).update(thread.id, {
|
||||
taskStop: legacy,
|
||||
});
|
||||
const current = await db.getRepository(AgentExecutionThread).save({
|
||||
id: randomUUID(),
|
||||
agentId,
|
||||
agentName: 'Current stop',
|
||||
projectId,
|
||||
});
|
||||
await repository.saveStop({ ...legacy, threadId: current.id, pause: { id: randomUUID() } }, {});
|
||||
|
||||
const pending = await repository.pendingPauseReports();
|
||||
expect(pending).not.toContain(thread.id);
|
||||
expect(pending).toContain(current.id);
|
||||
});
|
||||
|
||||
it('reads a legacy stop without scheduling a report or blocking new user input', async () => {
|
||||
const old = await db.getRepository(AgentExecution).save({
|
||||
id: randomUUID(),
|
||||
threadId: thread.id,
|
||||
status: 'cancelled',
|
||||
});
|
||||
const legacy = {
|
||||
planId: null,
|
||||
requestedAt: cutoffAt.toISOString(),
|
||||
generation: await repository.captureGeneration(thread.id, {}),
|
||||
failures: [],
|
||||
};
|
||||
await db.getRepository(AgentExecutionThread).update(thread.id, { taskStop: legacy });
|
||||
const stop = await repository.latest(thread.id);
|
||||
expect(stop).toMatchObject(legacy);
|
||||
expect(await repository.latest(thread.id)).toEqual(stop);
|
||||
expect(await repository.blocksQueue(thread.id, {})).toBe(false);
|
||||
expect(await repository.pausedScope(thread.id)).toBeNull();
|
||||
expect(await repository.isCancelled(thread.id, old.id)).toBe(true);
|
||||
await expect(repository.assertAdmission(thread.id, old.id, {})).rejects.toThrow();
|
||||
const next = await db.getRepository(AgentExecution).save({
|
||||
id: randomUUID(),
|
||||
threadId: thread.id,
|
||||
status: 'running',
|
||||
});
|
||||
await expect(repository.assertAdmission(thread.id, next.id, {})).resolves.toBeUndefined();
|
||||
await repository.recordAdmission(thread.id, next.id, { userInitiated: true }, {});
|
||||
await expect(
|
||||
repository.assertWakeAdmission(thread.id, { jobIds: [], planStopId: stop!.pause.id }, {}),
|
||||
).rejects.toThrow('no longer available');
|
||||
await repository.finishPauseReport(thread.id, stop!.pause.id, true);
|
||||
expect(
|
||||
(await db.getRepository(AgentExecutionThread).findOneByOrFail({ id: thread.id })).taskStop,
|
||||
).toEqual(legacy);
|
||||
});
|
||||
|
||||
it('discards pending and steering messages while preserving later input', async () => {
|
||||
const first = await enqueue('First');
|
||||
await enqueue('Second');
|
||||
const execution = await db
|
||||
.getRepository(AgentExecution)
|
||||
.save({ id: randomUUID(), threadId: thread.id, status: 'running' });
|
||||
await queue.reserveSteering(thread.id, first.id, execution.id, {});
|
||||
await queue.discardPending(thread.id, {});
|
||||
expect(await queue.listPending(thread.id)).toEqual([]);
|
||||
const next = await enqueue('Continue');
|
||||
expect((await queue.findHead(thread.id, {}))?.id).toBe(next.id);
|
||||
});
|
||||
|
||||
it('admits one stop report and waits for it before admitting new plan work', async () => {
|
||||
const old = await db
|
||||
.getRepository(AgentExecution)
|
||||
.save({ id: randomUUID(), threadId: thread.id, status: 'cancelled' });
|
||||
const stop = await request();
|
||||
const report = { jobIds: [], planStopId: stop.pause.id };
|
||||
expect(await repository.pendingPauseReports()).toContain(thread.id);
|
||||
expect(await repository.blocksQueue(thread.id, {})).toBe(true);
|
||||
await expect(repository.assertWakeAdmission(thread.id, { jobIds: [] }, {})).rejects.toThrow(
|
||||
'The plan is stopped',
|
||||
);
|
||||
await expect(repository.assertWakeAdmission(thread.id, report, {})).resolves.toBeUndefined();
|
||||
const execution = await db
|
||||
.getRepository(AgentExecution)
|
||||
.save({ id: randomUUID(), threadId: thread.id, status: 'running' });
|
||||
await repository.recordAdmission(
|
||||
thread.id,
|
||||
execution.id,
|
||||
{ planStopId: stop.pause.id, userInitiated: false },
|
||||
{},
|
||||
);
|
||||
await expect(repository.assertWakeAdmission(thread.id, report, {})).rejects.toThrow(
|
||||
'no longer available',
|
||||
);
|
||||
await repository.finishPauseReport(thread.id, stop.pause.id);
|
||||
expect(await repository.blocksQueue(thread.id, {})).toBe(true);
|
||||
await db.getRepository(AgentExecution).update(execution.id, { status: 'success' });
|
||||
await repository.finishPauseReport(thread.id, stop.pause.id);
|
||||
expect(await repository.blocksQueue(thread.id, {})).toBe(false);
|
||||
expect(await repository.pendingPauseReports()).not.toContain(thread.id);
|
||||
await repository.recordAdmission(thread.id, randomUUID(), { userInitiated: true }, {});
|
||||
expect((await repository.latest(thread.id))?.pause.resumedAt).toBeDefined();
|
||||
expect(await repository.isCancelled(thread.id, old.id)).toBe(true);
|
||||
await expect(
|
||||
repository.assertWakeAdmission(thread.id, { jobIds: [] }, {}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps a stopped child checkpoint and blocks dispatch until Continue', async () => {
|
||||
const child = await db.getRepository(AgentExecutionThread).save({
|
||||
id: randomUUID(),
|
||||
agentId,
|
||||
agentName: 'Child',
|
||||
projectId,
|
||||
parentThreadId: thread.id,
|
||||
sessionNumber: 2,
|
||||
});
|
||||
const job = await db.getRepository(AgentBackgroundJob).save({
|
||||
id: randomUUID(),
|
||||
parentThreadId: thread.id,
|
||||
parentAgentId: agentId,
|
||||
parentResourceId: 'draft-chat:test',
|
||||
parentPrincipalHash: 'principal',
|
||||
title: 'Child',
|
||||
kind: 'subagent',
|
||||
status: 'paused',
|
||||
childThreadId: child.id,
|
||||
});
|
||||
const stop = await request();
|
||||
await db
|
||||
.getRepository(AgentCheckpoint)
|
||||
.save({ runId: randomUUID(), agentId, threadId: child.id, state: '{}', expired: false });
|
||||
expect(await repository.unfinishedWork(stop)).toEqual([]);
|
||||
expect(await repository.isCancelled(child.id)).toBe(false);
|
||||
expect(await repository.hasPausedAncestor(child.id, {})).toBe(true);
|
||||
await expect(repository.assertAdmission(child.id, undefined, {})).rejects.toThrow();
|
||||
await repository.finishPauseReport(thread.id, stop.pause.id, true);
|
||||
await expect(
|
||||
repository.assertWakeAdmission(thread.id, { jobIds: [], planStopId: stop.pause.id }, {}),
|
||||
).rejects.toThrow('no longer available');
|
||||
expect((await repository.latest(thread.id))?.pause).toMatchObject({
|
||||
reportFailed: true,
|
||||
reportedAt: expect.any(String),
|
||||
});
|
||||
expect(
|
||||
(await db.getRepository(AgentBackgroundJob).findOneByOrFail({ id: job.id })).notifiedAt,
|
||||
).not.toBeNull();
|
||||
await repository.recordAdmission(thread.id, randomUUID(), { userInitiated: true }, {});
|
||||
expect(await repository.hasPausedAncestor(child.id, {})).toBe(false);
|
||||
await expect(repository.assertAdmission(child.id, undefined, {})).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('stores only the latest stop boundary on its chat', async () => {
|
||||
const original = await request();
|
||||
const other = await db.getRepository(AgentExecutionThread).save({
|
||||
id: randomUUID(),
|
||||
agentId,
|
||||
agentName: 'Other chat',
|
||||
projectId,
|
||||
sessionNumber: 2,
|
||||
});
|
||||
expect(
|
||||
(await db.getRepository(AgentExecutionThread).findOneByOrFail({ id: thread.id })).taskStop,
|
||||
).toMatchObject({ requestedAt: original.requestedAt });
|
||||
expect(await repository.latest(other.id)).toBeNull();
|
||||
const execution = await db.getRepository(AgentExecution).save({
|
||||
id: randomUUID(),
|
||||
threadId: thread.id,
|
||||
status: 'success',
|
||||
});
|
||||
await request();
|
||||
expect((await repository.latest(thread.id))?.generation.executionIds).toContain(execution.id);
|
||||
const runner = db.createQueryRunner();
|
||||
try {
|
||||
expect(await runner.hasTable('agent_task_cancellation')).toBe(false);
|
||||
} finally {
|
||||
await runner.release();
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks old executions after cancellation and permits new work', async () => {
|
||||
const old = await db
|
||||
.getRepository(AgentExecution)
|
||||
.save({ id: randomUUID(), threadId: thread.id, status: 'success', createdAt: beforeStop });
|
||||
await request();
|
||||
const newer = await db
|
||||
.getRepository(AgentExecution)
|
||||
.save({ id: randomUUID(), threadId: thread.id, status: 'running', createdAt: afterStop });
|
||||
expect(await repository.isCancelled(thread.id, old.id)).toBe(true);
|
||||
expect(await repository.isCancelled(thread.id, newer.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('uses captured identities when another server has a later clock', async () => {
|
||||
const execution = await db.getRepository(AgentExecution).save({
|
||||
id: randomUUID(),
|
||||
threadId: thread.id,
|
||||
status: 'success',
|
||||
createdAt: afterStop,
|
||||
});
|
||||
await request();
|
||||
expect(await repository.isCancelled(thread.id, execution.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('permits work admitted after cancellation even when its timestamp is earlier', async () => {
|
||||
const cancellation = await request();
|
||||
const execution = await db.getRepository(AgentExecution).save({
|
||||
id: randomUUID(),
|
||||
threadId: thread.id,
|
||||
status: 'running',
|
||||
createdAt: beforeStop,
|
||||
});
|
||||
expect(await repository.isCancelled(thread.id, execution.id)).toBe(false);
|
||||
const job = await db.getRepository(AgentBackgroundJob).save({
|
||||
id: randomUUID(),
|
||||
parentThreadId: thread.id,
|
||||
parentAgentId: agentId,
|
||||
parentResourceId: 'draft-chat:test',
|
||||
parentPrincipalHash: 'principal',
|
||||
title: 'New work',
|
||||
kind: 'subagent',
|
||||
status: 'running',
|
||||
sourceExecutionId: execution.id,
|
||||
createdAt: beforeStop,
|
||||
});
|
||||
expect((await repository.targetedJobs(cancellation)).map((item) => item.id)).not.toContain(
|
||||
job.id,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not include descendants from a later user request', async () => {
|
||||
const stop = await request();
|
||||
const child = await db.getRepository(AgentExecutionThread).save({
|
||||
id: randomUUID(),
|
||||
agentId,
|
||||
agentName: 'New child',
|
||||
projectId,
|
||||
parentThreadId: thread.id,
|
||||
sessionNumber: 2,
|
||||
});
|
||||
await db
|
||||
.getRepository(AgentExecution)
|
||||
.save({ id: randomUUID(), threadId: child.id, status: 'running' });
|
||||
expect(await repository.targetedDescendants(stop)).toEqual([]);
|
||||
expect(await repository.unfinishedWork(stop)).toEqual([]);
|
||||
expect(await repository.isCancelled(child.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('waits for a live foreground checkpoint to stop', async () => {
|
||||
const execution = await db.getRepository(AgentExecution).save({
|
||||
id: randomUUID(),
|
||||
threadId: thread.id,
|
||||
status: 'success',
|
||||
hitlStatus: 'suspended',
|
||||
});
|
||||
const stop = await request();
|
||||
const runId = randomUUID();
|
||||
await db.getRepository(AgentCheckpoint).save({
|
||||
runId,
|
||||
agentId,
|
||||
threadId: thread.id,
|
||||
expired: false,
|
||||
state: JSON.stringify({
|
||||
status: 'suspended',
|
||||
persistence: {
|
||||
threadId: thread.id,
|
||||
hostMetadata: { n8nExecutionId: execution.id },
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(await repository.unfinishedWork(stop)).toEqual([
|
||||
{ jobId: execution.id, title: 'Current response' },
|
||||
]);
|
||||
await db
|
||||
.getRepository(AgentCheckpoint)
|
||||
.update({ runId }, { updatedAt: new Date('2000-01-01') });
|
||||
expect(await repository.unfinishedWork(stop)).toEqual([]);
|
||||
await db.getRepository(AgentCheckpoint).update({ runId }, { updatedAt: new Date() });
|
||||
await db.getRepository(AgentCheckpoint).update({ runId }, { state: null, expired: true });
|
||||
expect(await repository.unfinishedWork(stop)).toEqual([]);
|
||||
});
|
||||
|
||||
it('pauses a child admitted before Stop even if its thread starts later', async () => {
|
||||
const child = await db.getRepository(AgentExecutionThread).save({
|
||||
id: randomUUID(),
|
||||
agentId,
|
||||
agentName: 'Child',
|
||||
projectId,
|
||||
sessionNumber: 2,
|
||||
parentThreadId: thread.id,
|
||||
createdAt: afterStop,
|
||||
});
|
||||
await db.getRepository(AgentBackgroundJob).save({
|
||||
id: randomUUID(),
|
||||
parentThreadId: thread.id,
|
||||
parentAgentId: agentId,
|
||||
parentResourceId: 'draft-chat:test',
|
||||
parentPrincipalHash: 'principal',
|
||||
title: 'Child',
|
||||
kind: 'subagent',
|
||||
status: 'cancelled',
|
||||
childThreadId: child.id,
|
||||
createdAt: beforeStop,
|
||||
});
|
||||
await request();
|
||||
expect(await repository.hasPausedAncestor(child.id, {})).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves a descendant checkpoint saved while stopping', async () => {
|
||||
const child = await db.getRepository(AgentExecutionThread).save({
|
||||
id: randomUUID(),
|
||||
agentId,
|
||||
agentName: 'Child',
|
||||
projectId,
|
||||
sessionNumber: 2,
|
||||
parentThreadId: thread.id,
|
||||
createdAt: beforeStop,
|
||||
});
|
||||
const cancellation = await request();
|
||||
const runId = randomUUID();
|
||||
await db
|
||||
.getRepository(AgentCheckpoint)
|
||||
.save({ runId, agentId, threadId: child.id, state: '{}', expired: false });
|
||||
expect(await repository.unfinishedWork(cancellation, {})).toEqual([]);
|
||||
expect(await db.getRepository(AgentCheckpoint).findOneBy({ runId })).not.toBeNull();
|
||||
await db.getRepository(AgentCheckpoint).update({ runId }, { state: null });
|
||||
expect(await repository.unfinishedWork(cancellation, {})).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects dispatch from a canceled execution', async () => {
|
||||
const source = await db
|
||||
.getRepository(AgentExecution)
|
||||
.save({ id: randomUUID(), threadId: thread.id, status: 'running' });
|
||||
await request();
|
||||
await expect(
|
||||
Container.get(AgentBackgroundJobRepository).insertSubAgentJobIfCapacity(
|
||||
{
|
||||
id: randomUUID(),
|
||||
kind: 'subagent',
|
||||
parentThreadId: thread.id,
|
||||
parentAgentId: agentId,
|
||||
parentResourceId: 'draft-chat:test',
|
||||
parentPrincipalHash: 'principal',
|
||||
title: 'Late task',
|
||||
sourceExecutionId: source.id,
|
||||
subAgentId: agentId,
|
||||
childThreadId: randomUUID(),
|
||||
timeoutAt: afterStop,
|
||||
},
|
||||
5,
|
||||
),
|
||||
).rejects.toThrow('These tasks were canceled');
|
||||
});
|
||||
|
||||
it('tracks a workflow that reaches Wait after cancellation so stopping can retry', async () => {
|
||||
const source = await db
|
||||
.getRepository(AgentExecution)
|
||||
.save({ id: randomUUID(), threadId: thread.id, status: 'success', createdAt: beforeStop });
|
||||
const cancellation = await request();
|
||||
const id = randomUUID();
|
||||
await Container.get(AgentBackgroundJobRepository).insertWorkflowJobOrGetExisting({
|
||||
id,
|
||||
kind: 'workflow',
|
||||
parentThreadId: thread.id,
|
||||
parentAgentId: agentId,
|
||||
parentResourceId: 'draft-chat:test',
|
||||
parentPrincipalHash: 'principal',
|
||||
title: 'Waiting workflow',
|
||||
workflowId: 'workflow',
|
||||
childExecutionId: '123',
|
||||
sourceExecutionId: source.id,
|
||||
});
|
||||
expect(await repository.latest(thread.id)).toEqual(cancellation);
|
||||
expect((await repository.targetedJobs(cancellation)).map((job) => job.id)).toContain(id);
|
||||
});
|
||||
|
||||
it('keeps plan history and blocks late writes after Stop', async () => {
|
||||
await db
|
||||
.getRepository(AgentExecution)
|
||||
.save({ id: randomUUID(), threadId: thread.id, status: 'running' });
|
||||
const plan = await plans.createActivePlan(
|
||||
{ id: randomUUID(), threadId: thread.id, formatVersion: 1, data: { result: 'Saved' } },
|
||||
{},
|
||||
);
|
||||
await request(plan.id);
|
||||
await expect(
|
||||
plans.replacePlan(
|
||||
{ threadId: thread.id, planId: plan.id, expectedRevision: 1, formatVersion: 1, data: {} },
|
||||
{},
|
||||
),
|
||||
).rejects.toThrow('These tasks were canceled');
|
||||
const current = await plans.findPlan(thread.id, plan.id, {});
|
||||
expect(current).toMatchObject({ closedAt: null, revision: 1, data: { result: 'Saved' } });
|
||||
expect((await plans.findRevision(thread.id, plan.id, 1, {}))?.data).toEqual({
|
||||
result: 'Saved',
|
||||
});
|
||||
expect(await plans.findRevision(thread.id, plan.id, 2, {})).toBeNull();
|
||||
});
|
||||
|
||||
it('blocks an old wake even if the job finished before Stop', async () => {
|
||||
const job = await db.getRepository(AgentBackgroundJob).save({
|
||||
id: randomUUID(),
|
||||
parentThreadId: thread.id,
|
||||
parentAgentId: agentId,
|
||||
parentResourceId: 'draft-chat:test',
|
||||
parentPrincipalHash: 'principal',
|
||||
title: 'Completed task',
|
||||
kind: 'subagent',
|
||||
status: 'completed',
|
||||
result: 'Saved output',
|
||||
});
|
||||
const stop = await request();
|
||||
await repository.finishPauseReport(thread.id, stop.pause.id, true);
|
||||
await expect(
|
||||
repository.assertWakeAdmission(thread.id, { jobIds: [job.id] }, {}),
|
||||
).rejects.toThrow('The plan is stopped');
|
||||
await expect(repository.assertWakeAdmission(thread.id, { jobIds: [] }, {})).rejects.toThrow(
|
||||
'The plan is stopped',
|
||||
);
|
||||
expect(
|
||||
await db.getRepository(AgentBackgroundJob).findOneByOrFail({ id: job.id }),
|
||||
).toMatchObject({ result: 'Saved output', notifiedAt: expect.any(Date) });
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user