import { afterEach, describe, expect, it, vi } from "vitest" ;
import {
cancelDetachedTaskRunById,
completeTaskRunByRunId,
createQueuedTaskRun,
createRunningTaskRun,
failTaskRunByRunId,
getDetachedTaskLifecycleRuntime,
getDetachedTaskLifecycleRuntimeRegistration,
registerDetachedTaskRuntime,
recordTaskRunProgressByRunId,
resetDetachedTaskLifecycleRuntimeForTests,
setDetachedTaskLifecycleRuntime,
setDetachedTaskDeliveryStatusByRunId,
startTaskRunByRunId,
tryRecoverTaskBeforeMarkLost,
} from "./detached-task-runtime.js" ;
import type { TaskRecord } from "./task-registry.types.js" ;
const { mockLogWarn } = vi.hoisted(() => ({
mockLogWarn: vi.fn(),
}));
vi.mock("../logging/subsystem.js" , () => ({
createSubsystemLogger: () => ({
subsystem: "tasks/detached-runtime" ,
isEnabled: () => true ,
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: mockLogWarn,
error: vi.fn(),
fatal: vi.fn(),
raw: vi.fn(),
child: vi.fn(),
}),
}));
function createFakeTaskRecord(overrides?: Partial<TaskRecord>): TaskRecord {
return {
taskId: "task-fake" ,
runtime: "cli" ,
requesterSessionKey: "agent:main:main" ,
ownerKey: "agent:main:main" ,
scopeKind: "session" ,
runId: "run-fake" ,
task: "Fake task" ,
status: "running" ,
deliveryStatus: "pending" ,
notifyPolicy: "done_only" ,
createdAt: 1 ,
...overrides,
};
}
describe("detached-task-runtime" , () => {
afterEach(() => {
resetDetachedTaskLifecycleRuntimeForTests();
mockLogWarn.mockClear();
});
it("dispatches lifecycle operations through the installed runtime" , async () => {
const defaultRuntime = getDetachedTaskLifecycleRuntime();
const queuedTask = createFakeTaskRecord({
taskId: "task-queued" ,
runId: "run-queued" ,
status: "queued" ,
});
const runningTask = createFakeTaskRecord({
taskId: "task-running" ,
runId: "run-running" ,
});
const updatedTasks = [runningTask];
const fakeRuntime: typeof defaultRuntime = {
createQueuedTaskRun: vi.fn(() => queuedTask),
createRunningTaskRun: vi.fn(() => runningTask),
startTaskRunByRunId: vi.fn(() => updatedTasks),
recordTaskRunProgressByRunId: vi.fn(() => updatedTasks),
completeTaskRunByRunId: vi.fn(() => updatedTasks),
failTaskRunByRunId: vi.fn(() => updatedTasks),
setDetachedTaskDeliveryStatusByRunId: vi.fn(() => updatedTasks),
cancelDetachedTaskRunById: vi.fn(async () => ({
found: true ,
cancelled: true ,
task: runningTask,
})),
};
setDetachedTaskLifecycleRuntime(fakeRuntime);
expect(
createQueuedTaskRun({
runtime: "cli" ,
ownerKey: "agent:main:main" ,
scopeKind: "session" ,
requesterSessionKey: "agent:main:main" ,
runId: "run-queued" ,
task: "Queue task" ,
}),
).toBe(queuedTask);
expect(
createRunningTaskRun({
runtime: "cli" ,
ownerKey: "agent:main:main" ,
scopeKind: "session" ,
requesterSessionKey: "agent:main:main" ,
runId: "run-running" ,
task: "Run task" ,
}),
).toBe(runningTask);
startTaskRunByRunId({ runId: "run-running" , startedAt: 10 });
recordTaskRunProgressByRunId({ runId: "run-running" , lastEventAt: 20 });
completeTaskRunByRunId({ runId: "run-running" , endedAt: 30 });
failTaskRunByRunId({ runId: "run-running" , endedAt: 40 });
setDetachedTaskDeliveryStatusByRunId({
runId: "run-running" ,
deliveryStatus: "delivered" ,
});
await cancelDetachedTaskRunById({
cfg: {} as never,
taskId: runningTask.taskId,
});
expect(fakeRuntime.createQueuedTaskRun).toHaveBeenCalledWith(
expect.objectContaining({ runId: "run-queued" , task: "Queue task" }),
);
expect(fakeRuntime.createRunningTaskRun).toHaveBeenCalledWith(
expect.objectContaining({ runId: "run-running" , task: "Run task" }),
);
expect(fakeRuntime.startTaskRunByRunId).toHaveBeenCalledWith(
expect.objectContaining({ runId: "run-running" , startedAt: 10 }),
);
expect(fakeRuntime.recordTaskRunProgressByRunId).toHaveBeenCalledWith(
expect.objectContaining({ runId: "run-running" , lastEventAt: 20 }),
);
expect(fakeRuntime.completeTaskRunByRunId).toHaveBeenCalledWith(
expect.objectContaining({ runId: "run-running" , endedAt: 30 }),
);
expect(fakeRuntime.failTaskRunByRunId).toHaveBeenCalledWith(
expect.objectContaining({ runId: "run-running" , endedAt: 40 }),
);
expect(fakeRuntime.setDetachedTaskDeliveryStatusByRunId).toHaveBeenCalledWith(
expect.objectContaining({ runId: "run-running" , deliveryStatus: "delivered" }),
);
expect(fakeRuntime.cancelDetachedTaskRunById).toHaveBeenCalledWith({
cfg: {} as never,
taskId: runningTask.taskId,
});
resetDetachedTaskLifecycleRuntimeForTests();
expect(getDetachedTaskLifecycleRuntime()).toBe(defaultRuntime);
});
it("tracks registered detached runtimes by plugin id" , () => {
const runtime = {
...getDetachedTaskLifecycleRuntime(),
};
registerDetachedTaskRuntime("tests/detached-runtime" , runtime);
expect(getDetachedTaskLifecycleRuntimeRegistration()).toMatchObject({
pluginId: "tests/detached-runtime" ,
runtime,
});
expect(getDetachedTaskLifecycleRuntime()).toBe(runtime);
});
describe("tryRecoverTaskBeforeMarkLost" , () => {
it("returns recovered when hook returns recovered true" , async () => {
const task = createFakeTaskRecord({ taskId: "task-recover" , runtime: "subagent" });
setDetachedTaskLifecycleRuntime({
...getDetachedTaskLifecycleRuntime(),
tryRecoverTaskBeforeMarkLost: vi.fn(() => ({ recovered: true })),
});
const result = await tryRecoverTaskBeforeMarkLost({
taskId: task.taskId,
runtime: task.runtime,
task,
now: 123 ,
});
expect(result).toEqual({ recovered: true });
});
it("returns not recovered when hook returns recovered false" , async () => {
const task = createFakeTaskRecord({ taskId: "task-no-recover" , runtime: "cron" });
setDetachedTaskLifecycleRuntime({
...getDetachedTaskLifecycleRuntime(),
tryRecoverTaskBeforeMarkLost: vi.fn(() => ({ recovered: false })),
});
const result = await tryRecoverTaskBeforeMarkLost({
taskId: task.taskId,
runtime: task.runtime,
task,
now: 456 ,
});
expect(result).toEqual({ recovered: false });
});
it("returns not recovered when hook is not provided" , async () => {
const task = createFakeTaskRecord({ taskId: "task-no-hook" , runtime: "cli" });
const result = await tryRecoverTaskBeforeMarkLost({
taskId: task.taskId,
runtime: task.runtime,
task,
now: 789 ,
});
expect(result).toEqual({ recovered: false });
});
it("returns not recovered and logs warning when hook throws" , async () => {
const task = createFakeTaskRecord({ taskId: "task-throw" , runtime: "acp" });
setDetachedTaskLifecycleRuntime({
...getDetachedTaskLifecycleRuntime(),
tryRecoverTaskBeforeMarkLost: vi.fn(() => {
throw new Error("plugin crashed" );
}),
});
const result = await tryRecoverTaskBeforeMarkLost({
taskId: task.taskId,
runtime: task.runtime,
task,
now: 1 _000 ,
});
expect(result).toEqual({ recovered: false });
expect(mockLogWarn).toHaveBeenCalledWith(
"Detached task recovery hook threw, proceeding with markTaskLost" ,
expect.objectContaining({
taskId: "task-throw" ,
runtime: "acp" ,
elapsedMs: expect.any(Number),
}),
);
});
it("returns not recovered and logs warning when hook returns invalid result" , async () => {
const task = createFakeTaskRecord({ taskId: "task-invalid" , runtime: "cron" });
setDetachedTaskLifecycleRuntime({
...getDetachedTaskLifecycleRuntime(),
tryRecoverTaskBeforeMarkLost: vi.fn(() => ({ nope: true }) as never),
});
const result = await tryRecoverTaskBeforeMarkLost({
taskId: task.taskId,
runtime: task.runtime,
task,
now: 2 _000 ,
});
expect(result).toEqual({ recovered: false });
expect(mockLogWarn).toHaveBeenCalledWith(
"Detached task recovery hook returned invalid result, proceeding with markTaskLost" ,
expect.objectContaining({ taskId: "task-invalid" , runtime: "cron" }),
);
});
it("logs when the recovery hook is slow" , async () => {
const task = createFakeTaskRecord({ taskId: "task-slow" , runtime: "subagent" });
const dateNowSpy = vi.spyOn(Date, "now" );
dateNowSpy.mockReturnValueOnce(10 _000 ).mockReturnValueOnce(16 _000 );
setDetachedTaskLifecycleRuntime({
...getDetachedTaskLifecycleRuntime(),
tryRecoverTaskBeforeMarkLost: vi.fn(async () => ({ recovered: true })),
});
const result = await tryRecoverTaskBeforeMarkLost({
taskId: task.taskId,
runtime: task.runtime,
task,
now: 3 _000 ,
});
expect(result).toEqual({ recovered: true });
expect(mockLogWarn).toHaveBeenCalledWith(
"Detached task recovery hook was slow" ,
expect.objectContaining({ taskId: "task-slow" , runtime: "subagent" , elapsedMs: 6 _000 }),
);
dateNowSpy.mockRestore();
});
});
});
Messung V0.5 in Prozent C=100 H=97 G=98
¤ Dauer der Verarbeitung: 0.4 Sekunden
¤
*© Formatika GbR, Deutschland