Files
penguin-harness/packages/server/test/scheduler.test.ts
T
Yaowei Zheng 45bfae6e94 Initialize repository with harness code and assets
Initial import of all source code, config, and README assets: the
packages workspace (cli, core, server, web, docs, landing, skills),
build scripts, tooling config, and CI workflows.

Includes the data-layout revision made on this branch: the local data
root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the
installer keeps its binaries in ~/.penguin), and every Agent lives
under <project>/agents/<agent>/ — path helpers, the three
agent-enumeration scans, the system prompt, built-in Skills, tests
and docs all follow the new layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
2026-07-19 14:06:53 +08:00

333 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Scheduler runtime semantics: missed slots are not
* backfilled; a one-shot task registered after its time is marked missed
* immediately; periodic stepping never fires twice for the same slot; a busy
* target queues and sends once idle; a deleted bound Session marks the task
* invalid, recoverable by editing the file; deleting the file clears the run
* state; new-Session mode; event notifications.
* All tests use doubles and a controlled clock — no real LLM / core Session.
*/
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { saveProjectConfig, scheduleDir } from "@prismshadow/penguin-core";
import { openDatabase } from "../src/db/database.js";
import { ProjectsRepo } from "../src/db/repos/projects.js";
import { SchedulesRepo } from "../src/db/repos/schedules.js";
import { SessionsRepo } from "../src/db/repos/sessions.js";
import { UsersRepo } from "../src/db/repos/users.js";
import type { ErrorRecordArgs } from "../src/runtime/error-recorder.js";
import { Scheduler } from "../src/runtime/scheduler.js";
import type { ScheduleServerEvent } from "../src/api/types.js";
import { makeTempRoot } from "./helpers.js";
const P = "p1";
const A = "agent_x";
const T0 = Date.parse("2026-07-16T09:00:00Z");
const MIN = 60_000;
describe("scheduler", () => {
let root: string;
let db: ReturnType<typeof openDatabase>;
let repo: SchedulesRepo;
let sessions: SessionsRepo;
let nowMs: number;
let busy: Set<string>;
let started: Array<{ sessionId: string; text: string }>;
let created: Array<{
projectId: string;
agentId: string;
workspace?: string;
provider?: string;
modelId?: string;
}>;
let events: Array<{ userId: string; event: ScheduleServerEvent }>;
let errors: ErrorRecordArgs[];
let scheduler: Scheduler;
beforeEach(async () => {
root = await makeTempRoot();
// A schedule's model reference is resolved against the Project config during
// reconciliation, so provide a minimal config here:
// m-bench is globally unique, so provider can be omitted and still resolve uniquely.
await saveProjectConfig(root, P, {
default_model: { provider: "custom", model_id: "m-bench" },
models: [{ provider: "custom", model_id: "m-bench" }],
});
db = openDatabase(":memory:");
const users = new UsersRepo(db);
users.insert({
userId: "owner_a",
passwordHash: "x",
isAdmin: false,
passwordIsInitial: false,
createdAt: "2026-07-16T00:00:00Z",
});
const projects = new ProjectsRepo(db);
projects.insert({ projectId: P, ownerUserId: "owner_a", createdAt: "2026-07-16T00:00:00Z" });
repo = new SchedulesRepo(db);
sessions = new SessionsRepo(db);
nowMs = T0;
busy = new Set();
started = [];
created = [];
events = [];
errors = [];
let seq = 0;
scheduler = new Scheduler({
root,
repo,
projects,
sessions,
runner: {
statusOf: (id) => (busy.has(id) ? "running" : "idle"),
startTask: async (sessionId, input) => {
started.push({ sessionId, text: JSON.stringify(input[0]?.payload ?? "") });
return { sessionId };
},
},
sessionCreator: {
createSession: async (args) => {
created.push(args);
const sessionId = `session-new-${++seq}`;
insertSession(sessionId);
return { sessionId };
},
},
errors: { record: (args) => void errors.push(args) },
notify: (userId, event) => void events.push({ userId, event }),
now: () => nowMs,
});
await fs.mkdir(scheduleDir(root, P, A), { recursive: true });
});
afterEach(() => {
scheduler.stop();
db.close();
});
function insertSession(sessionId: string): void {
sessions.insert({
sessionId,
projectId: P,
agentId: A,
modelId: "m1",
provider: "custom",
workspace: "/tmp/w",
approvalMode: "allow-all",
title: null,
createdAt: new Date(nowMs).toISOString(),
});
}
async function writeFile(name: string, lines: string[]): Promise<void> {
await fs.writeFile(
path.join(scheduleDir(root, P, A), `${name}.toml`),
lines.join("\n"),
"utf8",
);
}
function iso(ms: number): string {
return new Date(ms).toISOString();
}
it("周期任务:登记消化过去时刻(错过不补),到点触发一次且不重复", async () => {
insertSession("session-1");
await writeFile("report", [
`prompt = "报告"`,
`enabled = true`,
`start_at = "${iso(T0 - 60 * MIN)}"`,
`period = "30m"`,
`session_id = "session-1"`,
]);
await scheduler.tickOnce(); // Registration: all slots before 09:00 are consumed without firing.
expect(started).toHaveLength(0);
nowMs = T0 + 30 * MIN; // The next slot that should fire (start+90m)
await scheduler.tickOnce();
expect(started).toHaveLength(1);
expect(started[0]?.sessionId).toBe("session-1");
// Trigger input = <scheduled_task> source block + the prompt body (tells the model this is a scheduled task).
expect(started[0]?.text).toContain("<scheduled_task>");
expect(started[0]?.text).toContain("schedule: report");
expect(started[0]?.text).toContain("报告");
expect(events.map((e) => e.event.type)).toContain("schedule_fired");
expect(events[0]?.userId).toBe("owner_a");
await scheduler.tickOnce(); // The same slot doesn't fire twice.
expect(started).toHaveLength(1);
const state = repo.find(P, A, "report");
expect(state?.lastFiredAt).toBe(iso(T0 + 30 * MIN));
});
it("一次性任务:未来到点触发一次;登记时已过期则标记错过且永不触发", async () => {
insertSession("session-1");
await writeFile("future", [
`prompt = "f"`,
`enabled = true`,
`start_at = "${iso(T0 + 10 * MIN)}"`,
`session_id = "session-1"`,
]);
await writeFile("stale", [
`prompt = "s"`,
`enabled = true`,
`start_at = "${iso(T0 - 10 * MIN)}"`,
`session_id = "session-1"`,
]);
await scheduler.tickOnce();
expect(started).toHaveLength(0);
expect(repo.find(P, A, "stale")?.missed).toBe(true);
nowMs = T0 + 11 * MIN;
await scheduler.tickOnce();
expect(started).toHaveLength(1);
expect(repo.find(P, A, "future")?.firedOnce).toBe(true);
nowMs = T0 + 60 * MIN;
await scheduler.tickOnce();
expect(started).toHaveLength(1);
});
it("忙时排队:目标运行中先排队并通知,空闲后补发;重复到点不叠加", async () => {
insertSession("session-1");
busy.add("session-1");
await writeFile("q", [
`prompt = "排队"`,
`enabled = true`,
`start_at = "${iso(T0)}"`,
`period = "5m"`,
`session_id = "session-1"`,
]);
// Let registration happen before start_at, so the registration baseline
// doesn't consume the first slot.
nowMs = T0 - MIN;
await scheduler.tickOnce();
nowMs = T0;
await scheduler.tickOnce();
expect(started).toHaveLength(0);
expect(events.map((e) => e.event.type)).toEqual(["schedule_queued"]);
nowMs = T0 + 5 * MIN; // Another slot: still busy, so it's just consumed, not re-queued.
await scheduler.tickOnce();
expect(events.map((e) => e.event.type)).toEqual(["schedule_queued"]);
busy.delete("session-1");
nowMs = T0 + 6 * MIN;
await scheduler.tickOnce();
expect(started).toHaveLength(1);
expect(events.map((e) => e.event.type)).toEqual(["schedule_queued", "schedule_fired"]);
});
it("绑定 Session 不存在:记异常并标记失效;文件修改后恢复", async () => {
await writeFile("ghost", [
`prompt = "g"`,
`enabled = true`,
`start_at = "${iso(T0 - MIN)}"`,
`period = "5m"`,
`session_id = "session-gone"`,
]);
nowMs = T0 - 10 * MIN;
await scheduler.tickOnce(); // Registration (the first future slot hasn't arrived yet).
nowMs = T0 + 4 * MIN;
await scheduler.tickOnce(); // Slot arrives: Session doesn't exist → invalidated.
expect(errors.some((e) => e.code === "schedule_session_missing")).toBe(true);
expect(repo.find(P, A, "ghost")?.invalidReason).toBe("session_missing");
nowMs = T0 + 9 * MIN;
await scheduler.tickOnce(); // No further attempts while invalid.
expect(errors.filter((e) => e.code === "schedule_session_missing")).toHaveLength(1);
// Editing the file (rebinding to an existing Session): clears the invalid state and resumes firing.
insertSession("session-2");
await writeFile("ghost", [
`prompt = "g2"`,
`enabled = true`,
`start_at = "${iso(T0 - MIN)}"`,
`period = "5m"`,
`session_id = "session-2"`,
]);
nowMs = T0 + 14 * MIN;
await scheduler.tickOnce();
expect(repo.find(P, A, "ghost")?.invalidReason).toBeNull();
expect(started.map((s) => s.sessionId)).toEqual(["session-2"]);
});
it("enabled=false 不触发;文件删除即清理运行状态", async () => {
insertSession("session-1");
await writeFile("off", [
`prompt = "o"`,
`enabled = false`,
`start_at = "${iso(T0 - MIN)}"`,
`period = "5m"`,
`session_id = "session-1"`,
]);
nowMs = T0 + 30 * MIN;
await scheduler.tickOnce();
expect(started).toHaveLength(0);
expect(repo.find(P, A, "off")).not.toBeNull();
await fs.unlink(path.join(scheduleDir(root, P, A), "off.toml"));
await scheduler.tickOnce();
expect(repo.find(P, A, "off")).toBeNull();
});
it("新建 Session 模式:每次触发开新会话并发送(透传 workspace)", async () => {
await writeFile("fresh", [
`prompt = "新会话"`,
`enabled = true`,
`start_at = "${iso(T0 + MIN)}"`,
`period = "5m"`,
`workspace = "/tmp/ws"`,
`model_id = "m-bench"`,
]);
await scheduler.tickOnce();
nowMs = T0 + MIN;
await scheduler.tickOnce();
nowMs = T0 + 6 * MIN;
await scheduler.tickOnce();
expect(created).toHaveLength(2);
// The file only supplies model_id (no provider): passed through as-is, resolved
// into a paired reference downstream via a unique match.
expect(created[0]).toMatchObject({
projectId: P,
agentId: A,
workspace: "/tmp/ws",
modelId: "m-bench",
});
expect("provider" in created[0]!).toBe(false);
expect(started.map((s) => s.sessionId)).toEqual(["session-new-1", "session-new-2"]);
});
it("新建 Session 模式:文件给出 provider 时成对透传", async () => {
await writeFile("paired", [
`prompt = "成对"`,
`enabled = true`,
`start_at = "${iso(T0 + MIN)}"`,
`provider = "custom"`,
`model_id = "m-bench"`,
]);
await scheduler.tickOnce();
nowMs = T0 + MIN;
await scheduler.tickOnce();
expect(created).toHaveLength(1);
expect(created[0]).toMatchObject({ provider: "custom", modelId: "m-bench" });
});
it("非法文件跳过并记异常,不影响其余任务", async () => {
insertSession("session-1");
await writeFile("bad", [`prompt = "x"`, `enabled = true`, `start_at = "nonsense"`]);
await writeFile("good", [
`prompt = "g"`,
`enabled = true`,
`start_at = "${iso(T0 + MIN)}"`,
`session_id = "session-1"`,
]);
await scheduler.tickOnce();
nowMs = T0 + 2 * MIN;
await scheduler.tickOnce();
expect(errors.some((e) => e.code === "schedule_invalid_file")).toBe(true);
expect(started).toHaveLength(1);
});
});