ograph/packages/dispatcher/test/scheduler.test.ts
小橘 🍊 e82fe8eaba
feat: OGraph Dispatcher — dual-loop actor for task notification (#4 P0) (#17)
* feat: add packages/dispatcher — dual-loop OGraph projection watcher + OC scheduler

Adds a new Node.js daemon that:
- Loop A (ProjectionWatcher): polls OGraph projections, diffs against
  snapshot, merges changes into a pending queue.
  - Idle: 30s poll interval; active (changes detected): 5s
- Loop B (OcScheduler): polls OC session-status, pushes pending queue
  when OC has available slots (>= minAvailable).
  - Idle (no pending): 60s; active (pending): 5s
  - Cooldown of 60s after each push to avoid spam

Tech:
- TypeScript + esbuild (zero runtime external deps)
- Graceful error handling: each poll is independent try-catch, errors
  logged but never crash the process
- Config from ~/.config/ograph/dispatcher.json + env-var overrides
- OGRAPH_CONFIG_FILE env var for config path override
- Push via /tmp/ograph-dispatch.json + openclaw message send (best-effort)

Build: npm run build → dist/index.js
Run:   node dist/index.js

* fix: address PR #17 review — package name, tests, shell safety, first-run

---------

Co-authored-by: 小墨 <xiaomooo@shazhou.work>
2026-04-13 10:01:48 +08:00

73 lines
2.4 KiB
TypeScript

// Tests for scheduler cooldown logic
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// ── minimal cooldown state machine ───────────────────────────────────────────
// Mirrors the cooldown guard in OcScheduler.poll() so we can test it in isolation.
function makeCooldownGuard(cooldownMs: number) {
let lastPushAt = 0;
return {
recordPush() {
lastPushAt = Date.now();
},
isInCooldown(): boolean {
if (lastPushAt === 0) return false;
return Date.now() - lastPushAt < cooldownMs;
},
remainingMs(): number {
if (lastPushAt === 0) return 0;
const elapsed = Date.now() - lastPushAt;
return Math.max(0, cooldownMs - elapsed);
},
};
}
// ── tests ─────────────────────────────────────────────────────────────────────
describe('scheduler cooldown logic', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('not in cooldown before any push', () => {
const guard = makeCooldownGuard(60_000);
expect(guard.isInCooldown()).toBe(false);
});
it('enters cooldown immediately after a push', () => {
const guard = makeCooldownGuard(60_000);
guard.recordPush();
expect(guard.isInCooldown()).toBe(true);
});
it('remains in cooldown within the window', () => {
const guard = makeCooldownGuard(60_000);
guard.recordPush();
vi.advanceTimersByTime(59_999); // just under 60 s
expect(guard.isInCooldown()).toBe(true);
expect(guard.remainingMs()).toBeGreaterThan(0);
});
it('exits cooldown after the window expires', () => {
const guard = makeCooldownGuard(60_000);
guard.recordPush();
vi.advanceTimersByTime(60_001); // just over 60 s
expect(guard.isInCooldown()).toBe(false);
expect(guard.remainingMs()).toBe(0);
});
it('resets cooldown on a second push', () => {
const guard = makeCooldownGuard(60_000);
guard.recordPush();
vi.advanceTimersByTime(30_000); // 30 s into first cooldown
guard.recordPush(); // second push resets the clock
vi.advanceTimersByTime(30_000); // only 30 s since second push
expect(guard.isInCooldown()).toBe(true); // still cooling down
});
});