mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: enable permission-aware subagents
This commit is contained in:
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* schedule.test.ts — SubagentScheduler engine.
|
||||
*
|
||||
* Tests:
|
||||
* - Static format parsers (cron / relative / interval / detection)
|
||||
* - Job lifecycle (add / update / remove / cleanup)
|
||||
* - Fire path (interval, one-shot) with mocked AgentManager + fake timers
|
||||
* - Past-timestamp rejection
|
||||
* - One-shot auto-disable
|
||||
* - Concurrency-bypass option flows through to manager.spawn
|
||||
*/
|
||||
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NO_FALLBACK, registerAgents, setFallbackSubagent } from "../src/agent-types.js";
|
||||
import { SubagentScheduler } from "../src/schedule.js";
|
||||
import { ScheduleStore } from "../src/schedule-store.js";
|
||||
|
||||
function makeMockManager() {
|
||||
const spawnFn = vi.fn(() => "agent-" + Math.random().toString(36).slice(2, 10));
|
||||
return {
|
||||
spawn: spawnFn,
|
||||
getRecord: vi.fn(() => ({ promise: Promise.resolve("done") })),
|
||||
} as any;
|
||||
}
|
||||
|
||||
function makeMockPi() {
|
||||
return {
|
||||
events: { emit: vi.fn() },
|
||||
} as any;
|
||||
}
|
||||
|
||||
function makeMockCtx() {
|
||||
return {
|
||||
cwd: "/tmp",
|
||||
modelRegistry: { find: vi.fn(), getAll: () => [], getAvailable: () => [] },
|
||||
sessionManager: { getSessionId: () => "sess-1" },
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("SubagentScheduler — static format parsers", () => {
|
||||
it("parseRelativeTime accepts +Ns/Nm/Nh/Nd and rejects bare numbers", () => {
|
||||
const before = Date.now();
|
||||
const iso = SubagentScheduler.parseRelativeTime("+10s");
|
||||
expect(iso).not.toBeNull();
|
||||
const t = new Date(iso!).getTime();
|
||||
expect(t - before).toBeGreaterThanOrEqual(9_000);
|
||||
expect(t - before).toBeLessThanOrEqual(11_000);
|
||||
|
||||
expect(SubagentScheduler.parseRelativeTime("+5m")).not.toBeNull();
|
||||
expect(SubagentScheduler.parseRelativeTime("+1h")).not.toBeNull();
|
||||
expect(SubagentScheduler.parseRelativeTime("+2d")).not.toBeNull();
|
||||
|
||||
// Bare digits / wrong unit / no plus → null
|
||||
expect(SubagentScheduler.parseRelativeTime("10s")).toBeNull();
|
||||
expect(SubagentScheduler.parseRelativeTime("+5x")).toBeNull();
|
||||
expect(SubagentScheduler.parseRelativeTime("hello")).toBeNull();
|
||||
});
|
||||
|
||||
it("parseInterval converts unit-suffixed strings to milliseconds", () => {
|
||||
expect(SubagentScheduler.parseInterval("10s")).toBe(10_000);
|
||||
expect(SubagentScheduler.parseInterval("5m")).toBe(300_000);
|
||||
expect(SubagentScheduler.parseInterval("1h")).toBe(3_600_000);
|
||||
expect(SubagentScheduler.parseInterval("2d")).toBe(172_800_000);
|
||||
|
||||
expect(SubagentScheduler.parseInterval("+5m")).toBeNull(); // relative isn't an interval
|
||||
expect(SubagentScheduler.parseInterval("5x")).toBeNull();
|
||||
expect(SubagentScheduler.parseInterval("five-minutes")).toBeNull();
|
||||
});
|
||||
|
||||
it("validateCronExpression rejects non-6-field expressions", () => {
|
||||
expect(SubagentScheduler.validateCronExpression("* * * * *").valid).toBe(false); // 5 fields
|
||||
expect(SubagentScheduler.validateCronExpression("0 0 9 * * 1").valid).toBe(true);
|
||||
expect(SubagentScheduler.validateCronExpression("0 0 9 * * *").valid).toBe(true);
|
||||
expect(SubagentScheduler.validateCronExpression("not-a-cron").valid).toBe(false);
|
||||
});
|
||||
|
||||
it("detectSchedule tags type and normalizes input", () => {
|
||||
expect(SubagentScheduler.detectSchedule("+10m").type).toBe("once");
|
||||
expect(SubagentScheduler.detectSchedule("5m").type).toBe("interval");
|
||||
expect(SubagentScheduler.detectSchedule("5m").intervalMs).toBe(300_000);
|
||||
expect(SubagentScheduler.detectSchedule("0 0 9 * * 1").type).toBe("cron");
|
||||
|
||||
const iso = "2099-01-01T00:00:00.000Z";
|
||||
const r = SubagentScheduler.detectSchedule(iso);
|
||||
expect(r.type).toBe("once");
|
||||
expect(r.normalized).toBe(iso);
|
||||
|
||||
expect(() => SubagentScheduler.detectSchedule("garbage")).toThrow(/Invalid schedule/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SubagentScheduler — lifecycle", () => {
|
||||
let tmp: string;
|
||||
let store: ScheduleStore;
|
||||
let scheduler: SubagentScheduler;
|
||||
let manager: any;
|
||||
let pi: any;
|
||||
let ctx: any;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), "scheduler-test-"));
|
||||
store = new ScheduleStore(join(tmp, "s.json"));
|
||||
scheduler = new SubagentScheduler();
|
||||
manager = makeMockManager();
|
||||
pi = makeMockPi();
|
||||
ctx = makeMockCtx();
|
||||
scheduler.start(pi, ctx, manager, store);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
scheduler.stop();
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("isActive() reports start/stop state", () => {
|
||||
expect(scheduler.isActive()).toBe(true);
|
||||
scheduler.stop();
|
||||
expect(scheduler.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it("addJob persists, arms, and emits added event", () => {
|
||||
const job = scheduler.addJob({
|
||||
name: "j1",
|
||||
description: "test",
|
||||
schedule: "1h",
|
||||
subagent_type: "general-purpose",
|
||||
prompt: "hi",
|
||||
});
|
||||
expect(job.scheduleType).toBe("interval");
|
||||
expect(scheduler.list()).toHaveLength(1);
|
||||
expect(pi.events.emit).toHaveBeenCalledWith("subagents:scheduled", expect.objectContaining({ type: "added" }));
|
||||
});
|
||||
|
||||
it("addJob rejects duplicate names", () => {
|
||||
scheduler.addJob({ name: "j1", description: "x", schedule: "1h", subagent_type: "general-purpose", prompt: "p" });
|
||||
expect(() => scheduler.addJob({
|
||||
name: "j1", description: "y", schedule: "2h", subagent_type: "general-purpose", prompt: "p2",
|
||||
})).toThrow(/already exists/);
|
||||
});
|
||||
|
||||
it("removeJob clears the job and emits removed", () => {
|
||||
const job = scheduler.addJob({ name: "j1", description: "x", schedule: "1h", subagent_type: "general-purpose", prompt: "p" });
|
||||
expect(scheduler.removeJob(job.id)).toBe(true);
|
||||
expect(scheduler.list()).toEqual([]);
|
||||
expect(pi.events.emit).toHaveBeenCalledWith("subagents:scheduled", expect.objectContaining({ type: "removed", jobId: job.id }));
|
||||
});
|
||||
|
||||
it("updateJob({enabled: false}) unschedules but keeps the record", () => {
|
||||
const job = scheduler.addJob({ name: "j1", description: "x", schedule: "1h", subagent_type: "general-purpose", prompt: "p" });
|
||||
scheduler.updateJob(job.id, { enabled: false });
|
||||
expect(scheduler.list()[0].enabled).toBe(false);
|
||||
expect(scheduler.getNextRun(job.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
// Regression: getNextRun on a freshly-created interval used to return undefined
|
||||
// (the lastRun-based branch needs lastRun, which is undefined before first fire),
|
||||
// surfacing as "Next run: (unknown)" in the agent's create-response.
|
||||
it("getNextRun returns an approximate future time for a fresh interval (no lastRun yet)", () => {
|
||||
const before = Date.now();
|
||||
const job = scheduler.addJob({
|
||||
name: "fresh-interval", description: "x", schedule: "1h",
|
||||
subagent_type: "general-purpose", prompt: "p",
|
||||
});
|
||||
const next = scheduler.getNextRun(job.id);
|
||||
expect(next).toBeDefined();
|
||||
const t = new Date(next!).getTime();
|
||||
// Should be ~now + 1h, with a small tolerance for the time spent in the call
|
||||
expect(t - before).toBeGreaterThanOrEqual(3_600_000 - 1_000);
|
||||
expect(t - before).toBeLessThanOrEqual(3_600_000 + 1_000);
|
||||
});
|
||||
|
||||
// Once a fire happens and `lastRun` is set, getNextRun should pivot to it.
|
||||
it("getNextRun uses lastRun when present for interval jobs", () => {
|
||||
const job = scheduler.addJob({
|
||||
name: "ran-once", description: "x", schedule: "1h",
|
||||
subagent_type: "general-purpose", prompt: "p",
|
||||
});
|
||||
const lastRun = new Date(Date.now() - 30 * 60_000).toISOString(); // 30m ago
|
||||
scheduler.updateJob(job.id, { lastRun });
|
||||
const next = scheduler.getNextRun(job.id);
|
||||
expect(next).toBe(new Date(new Date(lastRun).getTime() + 3_600_000).toISOString());
|
||||
});
|
||||
|
||||
it("rejects past one-shot timestamps upfront — no record created", () => {
|
||||
const past = new Date(Date.now() - 60_000).toISOString();
|
||||
expect(() => scheduler.addJob({
|
||||
name: "past", description: "x", schedule: past, subagent_type: "general-purpose", prompt: "p",
|
||||
})).toThrow(/in the past/);
|
||||
// No dead-on-arrival record left behind
|
||||
expect(scheduler.list()).toEqual([]);
|
||||
});
|
||||
|
||||
// The safety net in scheduleJob's past-branch only fires on store reload —
|
||||
// a once-job persisted with a future ISO whose time has now passed (process
|
||||
// restart after the trigger window). detectSchedule rejects past timestamps
|
||||
// at create time, so this is the only remaining production path.
|
||||
it("disables a previously-enabled one-shot reloaded from disk past its time", () => {
|
||||
const past = new Date(Date.now() - 60_000).toISOString();
|
||||
// Direct store insert bypasses addJob's upfront validation, mimicking a
|
||||
// record that was valid when written but is now stale on reload.
|
||||
store.add({
|
||||
id: "reload-test",
|
||||
name: "reload",
|
||||
description: "reload",
|
||||
schedule: past,
|
||||
scheduleType: "once",
|
||||
subagent_type: "general-purpose",
|
||||
prompt: "x",
|
||||
enabled: true,
|
||||
createdAt: past,
|
||||
runCount: 0,
|
||||
});
|
||||
// Re-arm: stop drops timers, start re-reads store.list() and calls scheduleJob
|
||||
// for every enabled job → the past-branch fires for our seeded record.
|
||||
scheduler.stop();
|
||||
scheduler.start(pi, ctx, manager, store);
|
||||
|
||||
const reloaded = scheduler.list().find(j => j.id === "reload-test");
|
||||
expect(reloaded?.enabled).toBe(false);
|
||||
expect(reloaded?.lastStatus).toBe("error");
|
||||
expect(pi.events.emit).toHaveBeenCalledWith("subagents:scheduled", expect.objectContaining({
|
||||
type: "error", jobId: "reload-test", error: expect.stringMatching(/in the past/),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe("SubagentScheduler — fire path", () => {
|
||||
let tmp: string;
|
||||
let store: ScheduleStore;
|
||||
let scheduler: SubagentScheduler;
|
||||
let manager: any;
|
||||
let pi: any;
|
||||
let ctx: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
tmp = mkdtempSync(join(tmpdir(), "scheduler-fire-"));
|
||||
store = new ScheduleStore(join(tmp, "s.json"));
|
||||
scheduler = new SubagentScheduler();
|
||||
manager = makeMockManager();
|
||||
pi = makeMockPi();
|
||||
ctx = makeMockCtx();
|
||||
scheduler.start(pi, ctx, manager, store);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
scheduler.stop();
|
||||
vi.useRealTimers();
|
||||
// Module-global: restore here, not at the end of a test body, so a failing
|
||||
// assertion can't leak strict dispatch into every test that follows.
|
||||
setFallbackSubagent(undefined);
|
||||
registerAgents(new Map());
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("interval jobs fire repeatedly via setInterval", () => {
|
||||
scheduler.addJob({
|
||||
name: "every-10s", description: "tick", schedule: "10s",
|
||||
subagent_type: "general-purpose", prompt: "tick",
|
||||
});
|
||||
|
||||
expect(manager.spawn).toHaveBeenCalledTimes(0);
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(manager.spawn).toHaveBeenCalledTimes(1);
|
||||
vi.advanceTimersByTime(20_000);
|
||||
expect(manager.spawn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("refuses at fire time when the job's agent type no longer resolves", () => {
|
||||
// The registry is what production populates at activation; a job outliving
|
||||
// its agent must not silently run something else (#183).
|
||||
registerAgents(new Map());
|
||||
setFallbackSubagent(NO_FALLBACK);
|
||||
const job = scheduler.addJob({
|
||||
name: "gone", description: "vanished agent", schedule: "+1s",
|
||||
subagent_type: "deleted-since", prompt: "run",
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(2_000);
|
||||
|
||||
expect(manager.spawn).not.toHaveBeenCalled();
|
||||
const stored = store.get(job.id);
|
||||
expect(stored?.lastStatus).toBe("error");
|
||||
// Pin WHY it failed — "didn't spawn" alone would be satisfied by any
|
||||
// unrelated pre-spawn throw.
|
||||
expect(pi.events.emit).toHaveBeenCalledWith(
|
||||
"subagents:scheduled",
|
||||
expect.objectContaining({
|
||||
type: "error",
|
||||
error: expect.stringContaining("Unknown or disabled agent type"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("one-shot fires once and auto-disables", async () => {
|
||||
const job = scheduler.addJob({
|
||||
name: "soon", description: "once", schedule: "+1s",
|
||||
subagent_type: "general-purpose", prompt: "once",
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(2_000);
|
||||
expect(manager.spawn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The auto-disable update happens synchronously inside the timer callback
|
||||
expect(scheduler.list().find(j => j.id === job.id)?.enabled).toBe(false);
|
||||
|
||||
// Subsequent ticks shouldn't fire again
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(manager.spawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fire passes bypassQueue: true to manager.spawn", () => {
|
||||
scheduler.addJob({
|
||||
name: "every-1s", description: "x", schedule: "1s",
|
||||
subagent_type: "general-purpose", prompt: "x",
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(1_000);
|
||||
expect(manager.spawn).toHaveBeenCalledTimes(1);
|
||||
const optsArg = manager.spawn.mock.calls[0][4];
|
||||
expect(optsArg.bypassQueue).toBe(true);
|
||||
expect(optsArg.isBackground).toBe(true);
|
||||
});
|
||||
|
||||
it("disabled jobs do not fire", () => {
|
||||
const job = scheduler.addJob({
|
||||
name: "off", description: "x", schedule: "1s",
|
||||
subagent_type: "general-purpose", prompt: "x",
|
||||
});
|
||||
scheduler.updateJob(job.id, { enabled: false });
|
||||
vi.advanceTimersByTime(5_000);
|
||||
expect(manager.spawn).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("emits fired event with agentId on successful spawn", () => {
|
||||
scheduler.addJob({
|
||||
name: "fire-once", description: "x", schedule: "+1s",
|
||||
subagent_type: "general-purpose", prompt: "x",
|
||||
});
|
||||
vi.advanceTimersByTime(2_000);
|
||||
expect(pi.events.emit).toHaveBeenCalledWith("subagents:scheduled", expect.objectContaining({
|
||||
type: "fired", name: "fire-once", agentId: expect.stringMatching(/^agent-/),
|
||||
}));
|
||||
});
|
||||
|
||||
it("records lastStatus error and emits when manager.spawn throws", async () => {
|
||||
manager.spawn.mockImplementationOnce(() => { throw new Error("no slots"); });
|
||||
const job = scheduler.addJob({
|
||||
name: "boom", description: "x", schedule: "+1s",
|
||||
subagent_type: "general-purpose", prompt: "x",
|
||||
});
|
||||
vi.advanceTimersByTime(2_000);
|
||||
|
||||
// Update is synchronous in the spawn-throw path
|
||||
expect(scheduler.list().find(j => j.id === job.id)?.lastStatus).toBe("error");
|
||||
expect(pi.events.emit).toHaveBeenCalledWith("subagents:scheduled", expect.objectContaining({
|
||||
type: "error", jobId: job.id, error: "no slots",
|
||||
}));
|
||||
});
|
||||
|
||||
// ── Status reflection from record.status (regression for bug #1) ────
|
||||
// The real AgentManager's promise *always* resolves (its .catch returns ""),
|
||||
// so the schedule's success/error must be inferred from `record.status`,
|
||||
// not from promise resolution. These two tests model that contract.
|
||||
describe("infers success vs error from record.status, not promise resolution", () => {
|
||||
type FakeRecord = { status: string; promise: Promise<string>; resolve: () => void };
|
||||
|
||||
function installFaithfulMock(): Map<string, FakeRecord> {
|
||||
const records = new Map<string, FakeRecord>();
|
||||
manager.spawn.mockImplementation(() => {
|
||||
const id = "agent-" + Math.random().toString(36).slice(2, 10);
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<string>(r => { resolve = () => r(""); });
|
||||
records.set(id, { status: "running", promise, resolve });
|
||||
return id;
|
||||
});
|
||||
manager.getRecord.mockImplementation((id: string) => records.get(id));
|
||||
return records;
|
||||
}
|
||||
|
||||
it("records lastStatus 'error' when the agent terminates with status='error'", async () => {
|
||||
const records = installFaithfulMock();
|
||||
const job = scheduler.addJob({
|
||||
name: "fail-job", description: "x", schedule: "+1s",
|
||||
subagent_type: "general-purpose", prompt: "x",
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(2_000);
|
||||
expect(manager.spawn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The agent ran and ended in error — same shape the real AgentManager produces.
|
||||
const r = [...records.values()][0];
|
||||
r.status = "error";
|
||||
r.resolve();
|
||||
|
||||
// Flush microtasks so .then(finalize) runs.
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(scheduler.list().find(j => j.id === job.id)?.lastStatus).toBe("error");
|
||||
});
|
||||
|
||||
it("records lastStatus 'success' when the agent terminates with status='completed'", async () => {
|
||||
const records = installFaithfulMock();
|
||||
const job = scheduler.addJob({
|
||||
name: "ok-job", description: "x", schedule: "+1s",
|
||||
subagent_type: "general-purpose", prompt: "x",
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(2_000);
|
||||
const r = [...records.values()][0];
|
||||
r.status = "completed";
|
||||
r.resolve();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(scheduler.list().find(j => j.id === job.id)?.lastStatus).toBe("success");
|
||||
});
|
||||
|
||||
it("treats aborted and stopped as errors (terminal failure states)", async () => {
|
||||
const records = installFaithfulMock();
|
||||
const a = scheduler.addJob({
|
||||
name: "abort-job", description: "x", schedule: "+1s",
|
||||
subagent_type: "general-purpose", prompt: "x",
|
||||
});
|
||||
const b = scheduler.addJob({
|
||||
name: "stop-job", description: "x", schedule: "+2s",
|
||||
subagent_type: "general-purpose", prompt: "x",
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(3_000);
|
||||
const recs = [...records.values()];
|
||||
recs[0].status = "aborted";
|
||||
recs[0].resolve();
|
||||
recs[1].status = "stopped";
|
||||
recs[1].resolve();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(scheduler.list().find(j => j.id === a.id)?.lastStatus).toBe("error");
|
||||
expect(scheduler.list().find(j => j.id === b.id)?.lastStatus).toBe("error");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("SubagentScheduler — stopped state", () => {
|
||||
it("throws on mutation when not started", () => {
|
||||
const scheduler = new SubagentScheduler();
|
||||
expect(() => scheduler.addJob({
|
||||
name: "x", description: "x", schedule: "1h", subagent_type: "general-purpose", prompt: "p",
|
||||
})).toThrow(/not started/);
|
||||
});
|
||||
|
||||
it("list() returns empty array when not started", () => {
|
||||
const scheduler = new SubagentScheduler();
|
||||
expect(scheduler.list()).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user