mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
<!-- entire-trail-link-start --> https://entire.com/gh/paperclipai/-/trails/56/changes/paperclip/56 <!-- entire-trail-link-end --> ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Issue monitors save a future check on an existing issue. > - The heartbeat timer still scans monitors on every tick, even when none are due. > - The saved deadline and claim already provide durable scheduling state. > - This pull request gives monitors their own deadline worker and connects state changes to it. > - Idle monitor work stops querying. Existing admission and recovery rules remain in force. ## Linked Issues or Issue Description Refs #17840, #16325. Related: #17171 addresses rearming a monitor after its admitted run fails. This PR changes when monitor inspection runs and follows the merged routine prerequisite. **What existing behavior does this improve?** Issue-monitor scheduling and idle database use. **Current behavior** Heartbeat ticks call the combined agent and monitor sweep. The existing next-deadline query only retains future monitors. Polling covers overdue leftovers, stale claims, and changes that make a saved monitor eligible. **Proposed behavior** Use the shared deadline worker. Retain overdue work with bounded retries. Wake after committed changes to monitor state, issue eligibility, company state, or native ownership. Keep legacy agent ticks separate for the next slice. ## What Changed - Add monitor-specific sweep and deadline methods to the existing scheduling owner. Keep the manual combined method. - Include overdue work and the strict five-minute claim fence. Exclude active native owners until their state changes. - Register monitors with the shared deadline worker. Heartbeat ticks wake only the legacy agent timer worker. - Connect direct issue-state writers and existing execution-control notifications to monitor work. Checkout uses a short transaction around its existing CAS. Provider work stays outside it. - Add real database lifecycle tests and retain routine, startup, admission, execution-control, and native-projection coverage. - Record the obligation, producer inventory, recovery behavior, and limits in `doc/plans/2026-10-27-issue-monitor-sleep-audit.md`. ## Verification - **1,233 passing cases in 35 complete files**, no skips, on accepted source `c24aadfc91e9394d73c799f9e05fc67202160557`. Coverage includes real monitor scheduling/admission, native settlement, committed producers, retained routines and all shared deadline consumer integration suites. - Database build, production server typechecking/build, module boundaries, target migration order and Runner profile identity pass. Twenty-four fresh PostgreSQL 17 schemas plus per-file fixtures applied all 412 journal entries through index 413; every selected source hash and temporary fixture import was restored exactly. - Normal parent reconciliation preserves master's admitted-wake result and scoped issue deletion while keeping committed monitor notifications. All other paths match the automatic merge; packages remain byte-exact to the qualified parent. The final routine-parent publication changes only its audit. - Historical failures and corrections remain in [the monitor audit](doc/plans/2026-10-27-issue-monitor-sleep-audit.md). Old delegated-Slack rate and Runner drain CI failures are retained; the current inherited clock fixture and package source are byte-identical to merged #17835. Fresh final-head CI remains required. - No full local repository-suite, live provider, deployed suspend/wake, external-alarm, production-load or fleet-savings claim. Final-head CI/security, exact-head Greptile 5/5 and actionable Ponytail findings remain separate merge gates. ## Risks - A missing producer notification can strand a monitor after polling is removed. The audit covers ordinary writes, direct status/assignment writers, recovery, and native ownership changes. - The writer model is process-local. Independent SQL writers and other replicas need a supported notification or reconciliation contract. - Overdue work uses the configured heartbeat interval as a retry floor for the monitor worker. New notifications during that backoff wait for its next pass. Normal state-change bursts coalesce for one second. - Notification fanout can inspect monitors during other active work. An unchanged idle subsystem has no recurring monitor scan. - The legacy agent timer still polls. Cloud sleep proofs and external alarm coverage are unchanged. ## Model Used OpenAI Codex (GPT-6-based coding agent), using repository inspection, ordinary Git merges, composition proofs, PostgreSQL fixtures and complete local integration suites. No subagents were used in this reconciliation. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Codex <noreply@openai.com>
325 lines
17 KiB
TypeScript
325 lines
17 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { eq, sql } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { agents, applyPendingMigrations, companies, costEvents, createDb, routineRuns, routineTriggers, type Db } from "@paperclipai/db";
|
|
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
import { routineService } from "../services/routines.js";
|
|
import { budgetService } from "../services/budgets.js";
|
|
import { companyService } from "../services/companies.js";
|
|
import { registerDeadlineWork } from "../services/deadline-work.js";
|
|
import { DELIVERY_QUEUES } from "../services/delivery-work-notifications.js";
|
|
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
|
|
import { idleWorkSnapshot } from "../services/task-admission.js";
|
|
|
|
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_ROUTINE_SCHEDULE_TEST_DATABASE_URL");
|
|
const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
|
(support.supported ? describe : describe.skip)("routine schedule deadline work", () => {
|
|
let db: Db;
|
|
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
|
let companyId: string, agentId: string;
|
|
let coordinator: ReturnType<typeof createDeliveryWorkCoordinator> | undefined;
|
|
let draining = false;
|
|
const errors: unknown[] = [];
|
|
const wakeup = vi.fn(async () => null);
|
|
const service = (owner = db) => routineService(owner, { heartbeat: { wakeup }, runtimeEnv: {} });
|
|
beforeAll(async () => {
|
|
if (external) await applyPendingMigrations(external);
|
|
else temporary = await startEmbeddedPostgresTestDatabase("routine-schedule-work-");
|
|
db = createDb(external ?? temporary!.connectionString, { maxConnections: 6 });
|
|
}, 30_000);
|
|
beforeEach(async () => {
|
|
companyId = randomUUID(); agentId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Scheduled work", issuePrefix: `R${companyId.slice(0, 6)}`,
|
|
defaultResponsibleUserId: randomUUID(), requireBoardApprovalForNewAgents: false });
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Scheduled agent", status: "active", lifecycleState: "ready", adapterType: "codex_local" });
|
|
errors.length = 0; wakeup.mockClear(); draining = false;
|
|
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
|
|
vi.setSystemTime(new Date("2026-07-16T12:30:00Z"));
|
|
});
|
|
afterEach(async () => {
|
|
await coordinator?.stop(); coordinator = undefined;
|
|
vi.useRealTimers(); vi.restoreAllMocks();
|
|
await db.execute(sql`truncate companies cascade`);
|
|
expect(errors).toEqual([]);
|
|
});
|
|
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await temporary?.cleanup(); });
|
|
const create = (owner = db, catchUpPolicy: "skip_missed" | "enqueue_missed_with_cap" = "skip_missed") => service(owner).create(companyId, {
|
|
title: "Scheduled routine", assigneeAgentId: agentId, priority: "medium", status: "active",
|
|
concurrencyPolicy: "coalesce_if_active", catchUpPolicy,
|
|
}, {});
|
|
async function start(options: {
|
|
next?: () => Promise<number | null>;
|
|
run?: (signal: AbortSignal) => Promise<unknown>;
|
|
} = {}) {
|
|
const svc = service();
|
|
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => !draining, canReconcile: () => true,
|
|
onError: error => errors.push(error) });
|
|
|
|
|
|
const worker = registerDeadlineWork(coordinator, DELIVERY_QUEUES.routineSchedules, {
|
|
retryMs: 30_000,
|
|
canRun: () => !draining,
|
|
run: options.run ?? (signal => svc.tickScheduledTriggers(new Date(), () => !draining && !signal.aborted)),
|
|
next: options.next ?? (() => svc.nextScheduledTriggerAt()),
|
|
});
|
|
await worker.ready;
|
|
return worker;
|
|
}
|
|
async function settled() {
|
|
await vi.waitFor(() => expect(idleWorkSnapshot().active).toBe(0));
|
|
}
|
|
async function quiet(duration = 60_000, waitForIdle = true) {
|
|
if (waitForIdle) await settled();
|
|
const select = vi.spyOn(db, "select"), execute = vi.spyOn(db, "execute"), transaction = vi.spyOn(db, "transaction");
|
|
try {
|
|
await vi.advanceTimersByTimeAsync(duration);
|
|
expect(select).not.toHaveBeenCalled(); expect(execute).not.toHaveBeenCalled(); expect(transaction).not.toHaveBeenCalled();
|
|
} finally { select.mockRestore(); execute.mockRestore(); transaction.mockRestore(); }
|
|
}
|
|
|
|
it("retains overdue catch-up work after the first capped pass", async () => {
|
|
const routine = await create(db, "enqueue_missed_with_cap");
|
|
const { trigger } = await service().createTrigger(routine.id, { kind: "schedule", cronExpression: "0 * * * *", timezone: "UTC" }, {});
|
|
await db.update(routineTriggers).set({ nextRunAt: new Date("2026-07-15T10:00:00Z") }).where(eq(routineTriggers.id, trigger.id));
|
|
await start(); await settled();
|
|
expect(await db.select().from(routineRuns)).toHaveLength(25);
|
|
expect(coordinator!.nextWakeAt()).not.toBeNull();
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
await vi.waitFor(async () => expect(await db.select().from(routineRuns)).toHaveLength(27));
|
|
await settled();
|
|
expect(coordinator!.nextWakeAt()).toBe(Date.parse("2026-07-16T13:00:00Z"));
|
|
await quiet();
|
|
});
|
|
|
|
it("disarms on company pause and rearms from the committed reactivation", async () => {
|
|
const routine = await create();
|
|
const { trigger } = await service().createTrigger(routine.id, { kind: "schedule", cronExpression: "0 0 * * *", timezone: "UTC" }, {});
|
|
await start(); await settled();
|
|
expect(coordinator!.nextWakeAt()).toBe(trigger.nextRunAt!.getTime());
|
|
await companyService(db).update(companyId, { status: "paused" });
|
|
await vi.waitFor(() => expect(coordinator!.nextWakeAt()).toBeNull());
|
|
await quiet();
|
|
await companyService(db).update(companyId, { status: "active" });
|
|
await vi.waitFor(() => expect(coordinator!.nextWakeAt()).toBe(trigger.nextRunAt!.getTime()));
|
|
await quiet();
|
|
expect(wakeup).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reactivates from the first trigger, replaces earlier deadlines, and cancels disabled or archived schedules", async () => {
|
|
await start(); await quiet();
|
|
const routine = await create();
|
|
await quiet();
|
|
const created = await service().createTrigger(routine.id, { kind: "schedule", cronExpression: "0 0 * * *", timezone: "UTC" }, {});
|
|
await vi.waitFor(() => expect(coordinator!.nextWakeAt()).toBe(created.trigger.nextRunAt!.getTime()));
|
|
const updated = await service().updateTrigger(created.trigger.id, { cronExpression: "0 * * * *" }, {});
|
|
await vi.waitFor(() => expect(coordinator!.nextWakeAt()).toBe(updated.trigger.nextRunAt!.getTime()));
|
|
await service().updateTrigger(created.trigger.id, { enabled: false }, {});
|
|
await vi.waitFor(() => expect(coordinator!.nextWakeAt()).toBeNull());
|
|
await quiet();
|
|
await service().updateTrigger(created.trigger.id, { enabled: true }, {});
|
|
await vi.waitFor(() => expect(coordinator!.nextWakeAt()).not.toBeNull());
|
|
await service().update(routine.id, { status: "archived" }, {});
|
|
await vi.waitFor(() => expect(coordinator!.nextWakeAt()).toBeNull());
|
|
await quiet();
|
|
expect(wakeup).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("restores a deleted schedule from its revision through the ordinary producer", async () => {
|
|
const routine = await create();
|
|
const created = await service().createTrigger(routine.id, { kind: "schedule", cronExpression: "0 0 * * *", timezone: "UTC" }, {});
|
|
await start();
|
|
await service().deleteTrigger(created.trigger.id, {});
|
|
await vi.waitFor(() => expect(coordinator!.nextWakeAt()).toBeNull());
|
|
await quiet();
|
|
await service().restoreRevision(routine.id, created.revision!.id, {});
|
|
await vi.waitFor(() => expect(coordinator!.nextWakeAt()).toBe(created.trigger.nextRunAt!.getTime()));
|
|
await quiet();
|
|
});
|
|
|
|
it("waits for the outer transaction and forgets rolled-back nested schedules", async () => {
|
|
const routine = await create();
|
|
await start(); await quiet();
|
|
let release!: () => void, entered!: () => void;
|
|
const held = new Promise<void>(resolve => { release = resolve; });
|
|
const written = new Promise<void>(resolve => { entered = resolve; });
|
|
let expected: number | null = null;
|
|
const writing = db.transaction(async tx => {
|
|
const created = await service(tx as unknown as Db).createTrigger(routine.id, { kind: "schedule", cronExpression: "0 0 * * *", timezone: "UTC" }, {});
|
|
expected = created.trigger.nextRunAt!.getTime(); entered(); await held;
|
|
});
|
|
try {
|
|
await written;
|
|
expect(await service().nextScheduledTriggerAt()).toBeNull();
|
|
expect(wakeup).not.toHaveBeenCalled();
|
|
} finally { release(); await writing; }
|
|
await vi.waitFor(() => expect(coordinator!.nextWakeAt()).toBe(expected));
|
|
await expect(db.transaction(async tx => {
|
|
await service(tx as unknown as Db).createTrigger(routine.id, { kind: "schedule", cronExpression: "* * * * *", timezone: "UTC" }, {});
|
|
throw new Error("rollback fixture");
|
|
})).rejects.toThrow("rollback fixture");
|
|
await settled();
|
|
expect(await service().nextScheduledTriggerAt()).toBe(expected);
|
|
expect(coordinator!.nextWakeAt()).toBe(expected);
|
|
await quiet();
|
|
});
|
|
|
|
it("retains the first committed schedule during drain without passive SQL and loads it on resume", async () => {
|
|
const routine = await create();
|
|
await start(); await quiet();
|
|
draining = true;
|
|
const created = await service().createTrigger(routine.id, { kind: "schedule", cronExpression: "0 0 * * *", timezone: "UTC" }, {});
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
await quiet(60_000, false);
|
|
draining = false;
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
await vi.waitFor(() => expect(coordinator!.nextWakeAt()).toBe(created.trigger.nextRunAt!.getTime()));
|
|
await quiet();
|
|
});
|
|
|
|
it("reconstructs an overdue schedule on restart and keeps completed history passive", async () => {
|
|
const routine = await create();
|
|
await service().createTrigger(routine.id, { kind: "schedule", cronExpression: "* * * * *", timezone: "UTC" }, {});
|
|
await start(); await settled();
|
|
await coordinator!.stop(); coordinator = undefined;
|
|
vi.setSystemTime(new Date("2026-07-16T12:35:30Z"));
|
|
await start(); await settled();
|
|
expect(await db.select().from(routineRuns)).toHaveLength(1);
|
|
expect(coordinator!.nextWakeAt()).toBe(Date.parse("2026-07-16T12:36:00Z"));
|
|
await service().update(routine.id, { status: "paused" }, {});
|
|
await vi.waitFor(() => expect(coordinator!.nextWakeAt()).toBeNull());
|
|
await quiet();
|
|
expect(await db.select().from(routineRuns)).toHaveLength(1);
|
|
});
|
|
|
|
it("retries a failed deadline read after drain and becomes quiet after successful reconstruction", async () => {
|
|
const routine = await create();
|
|
const created = await service().createTrigger(routine.id, { kind: "schedule", cronExpression: "0 0 * * *", timezone: "UTC" }, {});
|
|
const next = vi.fn().mockRejectedValueOnce(new Error("deadline unavailable"))
|
|
.mockImplementation(() => service().nextScheduledTriggerAt());
|
|
await start({ next });
|
|
expect(errors.splice(0)).toEqual([expect.objectContaining({ message: "deadline unavailable" })]);
|
|
expect(next).toHaveBeenCalledTimes(1);
|
|
draining = true;
|
|
await quiet(60_000, false);
|
|
expect(next).toHaveBeenCalledTimes(1);
|
|
draining = false;
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
await vi.waitFor(() => expect(coordinator!.nextWakeAt()).toBe(created.trigger.nextRunAt!.getTime()));
|
|
await quiet();
|
|
});
|
|
|
|
it("reconciles a schedule changed while the previous deadline read was in flight", async () => {
|
|
const routine = await create();
|
|
const created = await service().createTrigger(routine.id, { kind: "schedule", cronExpression: "0 0 * * *", timezone: "UTC" }, {});
|
|
let release!: () => void, entered!: () => void;
|
|
const held = new Promise<void>(resolve => { release = resolve; });
|
|
const read = new Promise<void>(resolve => { entered = resolve; });
|
|
const next = vi.fn().mockImplementationOnce(async () => {
|
|
const value = await service().nextScheduledTriggerAt(); entered(); await held; return value;
|
|
}).mockImplementation(() => service().nextScheduledTriggerAt());
|
|
const starting = start({ next });
|
|
let expected: number | undefined;
|
|
try {
|
|
await read;
|
|
const updated = await service().updateTrigger(created.trigger.id, { cronExpression: "0 * * * *" }, {});
|
|
expected = updated.trigger.nextRunAt!.getTime();
|
|
} finally { release(); await starting; }
|
|
await vi.waitFor(() => expect(coordinator!.nextWakeAt()).toBe(expected));
|
|
await quiet();
|
|
});
|
|
|
|
it("stops between claimed trigger batches and leaves unclaimed work for restart", async () => {
|
|
const routine = await create();
|
|
await service().createTrigger(routine.id, { kind: "schedule", cronExpression: "0 * * * *", timezone: "UTC" }, {});
|
|
await service().createTrigger(routine.id, { kind: "schedule", cronExpression: "0 * * * *", timezone: "UTC" }, {});
|
|
await db.update(routineTriggers).set({ nextRunAt: new Date("2026-07-16T12:00:00Z") });
|
|
let candidates = 0;
|
|
await start({ run: () => service().tickScheduledTriggers(new Date(), () => ++candidates === 1) });
|
|
await settled();
|
|
expect(await db.select().from(routineRuns)).toHaveLength(1);
|
|
expect(await service().nextScheduledTriggerAt()).toBe(Date.parse("2026-07-16T12:00:00Z"));
|
|
await coordinator!.stop(); coordinator = undefined;
|
|
await start(); await settled();
|
|
expect(await db.select().from(routineRuns)).toHaveLength(2);
|
|
expect(coordinator!.nextWakeAt()).toBe(Date.parse("2026-07-16T13:00:00Z"));
|
|
await quiet();
|
|
});
|
|
|
|
|
|
it("refreshes a saved deadline advanced by a manual run naming the trigger", async () => {
|
|
const routine = await create();
|
|
const { trigger } = await service().createTrigger(routine.id, { kind: "schedule", cronExpression: "0 0 * * *", timezone: "UTC" }, {});
|
|
await db.update(routineTriggers).set({ nextRunAt: new Date("2026-08-01T00:00:00Z") }).where(eq(routineTriggers.id, trigger.id));
|
|
await start(); await settled();
|
|
expect(coordinator!.nextWakeAt()).toBe(Date.parse("2026-08-01T00:00:00Z"));
|
|
await service().runRoutine(routine.id, { source: "manual", triggerId: trigger.id });
|
|
await vi.waitFor(() => expect(coordinator!.nextWakeAt()).toBe(Date.parse("2026-07-17T00:00:00Z")));
|
|
await quiet();
|
|
});
|
|
|
|
it.each(["archive", "remove"] as const)("disarms a company schedule after %s", async operation => {
|
|
const routine = await create();
|
|
await service().createTrigger(routine.id, { kind: "schedule", cronExpression: "0 0 * * *", timezone: "UTC" }, {});
|
|
await start(); await settled();
|
|
expect(coordinator!.nextWakeAt()).not.toBeNull();
|
|
if (operation === "remove") {
|
|
|
|
|
|
await db.update(agents).set({ status: "terminated", lifecycleState: "terminated" }).where(eq(agents.id, agentId));
|
|
}
|
|
await companyService(db)[operation](companyId);
|
|
await vi.waitFor(() => expect(coordinator!.nextWakeAt()).toBeNull());
|
|
await quiet();
|
|
});
|
|
|
|
it("starts during drain without querying and reconstructs schedules when admission resumes", async () => {
|
|
const routine = await create();
|
|
const { trigger } = await service().createTrigger(routine.id, { kind: "schedule", cronExpression: "0 0 * * *", timezone: "UTC" }, {});
|
|
draining = true;
|
|
const select = vi.spyOn(db, "select");
|
|
try { await start(); expect(select).not.toHaveBeenCalled(); } finally { select.mockRestore(); }
|
|
await quiet(60_000, false);
|
|
draining = false;
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
await vi.waitFor(() => expect(coordinator!.nextWakeAt()).toBe(trigger.nextRunAt!.getTime()));
|
|
await quiet();
|
|
});
|
|
|
|
it("does not consume overdue triggers in a paused company", async () => {
|
|
const routine = await create();
|
|
const { trigger } = await service().createTrigger(routine.id, { kind: "schedule", cronExpression: "0 * * * *", timezone: "UTC" }, {});
|
|
const due = new Date("2026-07-16T12:00:00Z");
|
|
await db.update(routineTriggers).set({ nextRunAt: due }).where(eq(routineTriggers.id, trigger.id));
|
|
await companyService(db).update(companyId, { status: "paused" });
|
|
await start(); await quiet();
|
|
expect(await db.select().from(routineRuns)).toHaveLength(0);
|
|
expect((await db.select().from(routineTriggers))[0]!.nextRunAt).toEqual(due);
|
|
expect(coordinator!.nextWakeAt()).toBeNull();
|
|
});
|
|
|
|
|
|
it.each(["pause", "resume"] as const)("refreshes schedules when budget enforcement changes company state: %s", async operation => {
|
|
const routine = await create();
|
|
const { trigger } = await service().createTrigger(routine.id, { kind: "schedule", cronExpression: "0 0 * * *", timezone: "UTC" }, {});
|
|
await db.insert(costEvents).values({ companyId, agentId, provider: "openai", biller: "openai",
|
|
billingType: "metered_api", model: "fixture", costCents: 125, occurredAt: new Date() });
|
|
const budgets = budgetService(db);
|
|
const limit = (amount: number) => budgets.upsertPolicy(companyId, {
|
|
scopeType: "company", scopeId: companyId, amount, windowKind: "calendar_month_utc", notifyEnabled: false,
|
|
}, null);
|
|
if (operation === "resume") await limit(100);
|
|
await start(); await settled();
|
|
if (operation === "pause") {
|
|
await limit(100);
|
|
await vi.waitFor(() => expect(coordinator!.nextWakeAt()).toBeNull());
|
|
} else {
|
|
expect(coordinator!.nextWakeAt()).toBeNull();
|
|
await limit(200);
|
|
await vi.waitFor(() => expect(coordinator!.nextWakeAt()).toBe(trigger.nextRunAt!.getTime()));
|
|
}
|
|
await quiet();
|
|
});
|
|
|
|
});
|