45bfae6e94
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
333 lines
11 KiB
TypeScript
333 lines
11 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|