Files
AgentCompany/tests/unit/scheduler-heartbeat.test.ts
T

153 lines
4.3 KiB
TypeScript

import { join } from "node:path";
import { tmpdir } from "node:os";
import { mkdtemp, rm } from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("electron", () => ({
powerMonitor: {
on: vi.fn(),
removeListener: vi.fn(),
},
safeStorage: {
isEncryptionAvailable: () => false,
getSelectedStorageBackend: () => "basic_text",
encryptString: (value: string) => Buffer.from(value, "utf8"),
decryptString: (value: Buffer) => value.toString("utf8"),
},
}));
import { AppDatabase } from "../../src/main/database";
import { createHeartbeatScheduler } from "../../src/main/orchestration/scheduler-heartbeat";
import type { OrchestrationDependencies } from "../../src/main/orchestration/types";
function createLogger() {
return {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
}
function createCompany(db: AppDatabase): string {
return db.core.company.saveCompany({
name: "Heartbeat Scheduler Co",
description: "Scheduler gate contract test",
status: "active",
});
}
function createConnector(db: AppDatabase): void {
db.core.connectors.saveConnector({
id: "openai-codex",
model: "gpt-5.4",
envBindingText: "",
notes: "Test connector",
});
}
function createWorkspace(db: AppDatabase, companyId: string, localPath: string): string {
return db.core.workspaces.saveWorkspace({
companyId,
name: "Primary",
localPath,
repoUrl: "",
repoRef: "",
isPrimary: true,
});
}
function createAgent(db: AppDatabase, companyId: string, workspaceId: string): string {
const agentId = db.core.agents.saveAgent({
companyId,
name: "Scheduler Agent",
role: "Engineer",
title: "Runtime Engineer",
status: "active",
reportsTo: null,
connectorId: "openai-codex",
workspaceId,
model: "gpt-5.4",
capabilities: "Checks scheduler heartbeat gates",
budgetMonthlyUsd: 100,
});
db.core.agents.setHeartbeat(agentId, true, 1);
return agentId;
}
function createDeps(db: AppDatabase): OrchestrationDependencies {
return {
db,
logger: createLogger(),
publishDomainChanged: vi.fn(),
emitEvent: vi.fn(),
notify: vi.fn(),
createRunToken: () => "test-token",
getApiPort: () => 3210,
dispatchAutomation: vi.fn(),
hasWorkerReady: () => false,
enqueueWorkerRun: vi.fn(),
isQuitting: () => false,
listRuntimeSkillEntries: () => [],
budgetService: {
evaluateBudgetGate: vi.fn(() => ({ allowed: true, blockedBy: [] })),
} as never,
checkpointEngine: { shouldPauseHeartbeat: () => false },
};
}
describe("heartbeat scheduler", () => {
let tempDir: string;
let db: AppDatabase;
beforeEach(async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-23T00:00:00.000Z"));
tempDir = await mkdtemp(join(tmpdir(), "agentcompany-scheduler-heartbeat-"));
db = new AppDatabase(tempDir);
db.init();
});
afterEach(async () => {
db.close();
await rm(tempDir, { recursive: true, force: true });
vi.useRealTimers();
});
it("records worker readiness skips from the heartbeat run decision", () => {
const companyId = createCompany(db);
createConnector(db);
const workspaceId = createWorkspace(db, companyId, tempDir);
const agentId = createAgent(db, companyId, workspaceId);
const startAgentHeartbeatRun = vi.fn(() => ({
status: "skipped" as const,
reason: "system_unavailable" as const,
detail: "worker_not_ready",
}));
const scheduler = createHeartbeatScheduler(
createDeps(db),
() => false,
startAgentHeartbeatRun,
);
try {
scheduler.start();
vi.advanceTimersByTime(15_000);
} finally {
scheduler.stop();
}
expect(startAgentHeartbeatRun).toHaveBeenCalledWith(agentId, companyId, "timer");
const activity = db.projection.snapshot.listSnapshot({ companyId }).activity;
const skip = activity.find((entry) => entry.action === "heartbeat.skipped");
expect(skip?.entityId).toBe(agentId);
expect(JSON.parse(skip?.detail ?? "{}")).toMatchObject({
agentName: "Scheduler Agent",
reason: "system_unavailable",
detail: "worker_not_ready",
trigger: "timer",
});
expect(db.core.agents.getRuntimeState(agentId)).not.toHaveProperty("heartbeatSkip");
});
});