daemon.test.mjs
1,182 lines 48.3 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Tests for lib/daemon.mjs — Daemon process lifecycle (Phase B of the Daemon Consolidation Spec).
3 *
4 * Covers:
5 * 1. PID file management (write, read, remove, stale detection, process alive check)
6 * 2. Daemon log (append, read, tail, malformed-line tolerance)
7 * 3. Idle detection (mtime-based, no-files case, threshold boundary)
8 * 4. LLM connectivity validation (mock LLM, empty response, error)
9 * 5. getDaemonStatus (no PID, stale PID, live PID, last-pass from log)
10 * 6. stopDaemon (no PID file, stale PID cleanup, SIGTERM success, SIGKILL fallback)
11 * 7. startDaemon lifecycle (stale PID cleanup, duplicate-start rejection, LLM validation,
12 * PID write, startup log, run_on_start, scheduling loop, idle skip, error recovery,
13 * SIGTERM/SIGINT graceful shutdown, run_on_start consolidation error)
14 * 8. CLI commands (daemon --help, daemon status, daemon log, daemon log --tail,
15 * daemon stop with no PID file, daemon stop --json, daemon status --json)
16 *
17 * All LLM calls and consolidateMemory calls are mocked. No real LLM calls are made.
18 *
19 * NOTE ON SIGNAL EMISSION IN TESTS:
20 * Node.js EventEmitter.emit() is synchronous. We emit SIGTERM on a fake signal target
21 * (EventEmitter) directly inside the injected _sleep function so that:
22 * 1. The signal handler fires synchronously (running = false)
23 * 2. After sleep resolves, `if (!running) break` terminates the loop immediately
24 * This avoids setImmediate/setTimeout races where the loop spins on microtasks
25 * forever without ever yielding to the event loop phase where timers fire.
26 */
27
28 import { describe, it, before, after } from 'node:test';
29 import assert from 'node:assert';
30 import fs from 'fs';
31 import path from 'path';
32 import os from 'os';
33 import { EventEmitter } from 'events';
34 import { execSync } from 'child_process';
35 import { fileURLToPath } from 'url';
36
37 import {
38 getPidPath,
39 getLogPath,
40 writePidFile,
41 readPidFile,
42 removePidFile,
43 isProcessAlive,
44 detectStalePid,
45 appendDaemonLog,
46 readDaemonLog,
47 isIdle,
48 validateLlmConnectivity,
49 getDaemonStatus,
50 stopDaemon,
51 startDaemon,
52 } from '../lib/daemon.mjs';
53
54 import { getDailyCost, recordCallCost, resetDailyCost } from '../lib/daemon-cost.mjs';
55 import { loadDaemonConfig } from '../lib/config.mjs';
56
57 const __dirname = path.dirname(fileURLToPath(import.meta.url));
58 const cliPath = path.join(__dirname, '..', 'cli', 'index.mjs');
59
60 // ── Test fixtures ─────────────────────────────────────────────────────────────
61
62 let tmpDir;
63 let vaultDir;
64
65 before(() => {
66 tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'knowtation-daemon-test-'));
67 vaultDir = path.join(tmpDir, 'vault');
68 fs.mkdirSync(vaultDir, { recursive: true });
69 fs.writeFileSync(path.join(vaultDir, 'test.md'), '---\ntitle: test\n---\nHello', 'utf8');
70 });
71
72 after(() => {
73 fs.rmSync(tmpDir, { recursive: true, force: true });
74 });
75
76 function makeConfig(overrides = {}) {
77 const dataDir = path.join(tmpDir, `data-${Date.now()}-${Math.random().toString(36).slice(2)}`);
78 fs.mkdirSync(dataDir, { recursive: true });
79 return {
80 vault_path: vaultDir,
81 data_dir: dataDir,
82 memory: { enabled: true, provider: 'file' },
83 daemon: loadDaemonConfig({
84 interval_minutes: 120,
85 idle_only: false, // disable idle check by default so loop always runs
86 run_on_start: false,
87 ...(overrides.daemon ?? {}),
88 }),
89 ...overrides,
90 };
91 }
92
93 /** Mock LLM — never makes real HTTP calls. */
94 function mockLlm(response = 'OK') {
95 const calls = [];
96 const fn = async (_config, opts) => {
97 calls.push({ opts });
98 if (response instanceof Error) throw response;
99 if (typeof response === 'function') return response(opts);
100 return response;
101 };
102 fn.calls = calls;
103 return fn;
104 }
105
106 /** Mock consolidateMemory — never calls LLM. */
107 function mockConsolidate(result = { total_events: 5, topics: [{ topic: 'test', facts: ['fact1'], event_count: 5 }] }) {
108 const calls = [];
109 const fn = async (_config, opts) => {
110 calls.push({ opts });
111 if (result instanceof Error) throw result;
112 if (typeof result === 'function') return result();
113 return result;
114 };
115 fn.calls = calls;
116 return fn;
117 }
118
119 /**
120 * Run startDaemon with injectable mocks.
121 *
122 * The _sleep function emits SIGTERM synchronously on the fake EventEmitter
123 * after `stopAfterLoops` sleeps. Because EventEmitter.emit() is synchronous,
124 * the shutdown handler sets running=false before sleep resolves, so the loop
125 * exits on the very next `if (!running) break` check.
126 *
127 * Consolidation happens BETWEEN sleeps:
128 * sleep 1 → consolidate → sleep 2 → (emit SIGTERM) → break
129 * So stopAfterLoops=2 gives exactly 1 consolidation call.
130 * stopAfterLoops=3 gives exactly 2 consolidation calls.
131 *
132 * @param {object} config
133 * @param {object} [daemonOpts] — overrides passed to startDaemon (llmFn, consolidateFn, etc.)
134 * @param {{ stopAfterLoops?: number }} [testOpts]
135 */
136 async function runDaemonCycle(config, daemonOpts = {}, { stopAfterLoops = 1 } = {}) {
137 const signals = new EventEmitter();
138 const llm = daemonOpts.llmFn ?? mockLlm('OK');
139 const consolidate = daemonOpts.consolidateFn ?? mockConsolidate();
140 let loopCount = 0;
141
142 const defaultSleep = async () => {
143 loopCount++;
144 if (loopCount >= stopAfterLoops) {
145 // Synchronous emit — handler runs immediately, running becomes false
146 signals.emit('SIGTERM');
147 }
148 };
149
150 const result = await startDaemon(config, {
151 llmFn: llm,
152 consolidateFn: consolidate,
153 _sleep: daemonOpts._sleep ?? defaultSleep,
154 _signalTarget: signals,
155 ...daemonOpts,
156 });
157
158 return { result, llm, consolidate, loopCount, signals };
159 }
160
161 // ── 1. PID file management ────────────────────────────────────────────────────
162
163 describe('PID file management', () => {
164 it('getPidPath returns {data_dir}/daemon.pid', () => {
165 const config = makeConfig();
166 assert.strictEqual(getPidPath(config), path.join(config.data_dir, 'daemon.pid'));
167 });
168
169 it('getLogPath returns {data_dir}/daemon.log by default', () => {
170 const config = makeConfig();
171 assert.strictEqual(getLogPath(config), path.join(config.data_dir, 'daemon.log'));
172 });
173
174 it('getLogPath returns daemon.log_file when set', () => {
175 const config = makeConfig({ daemon: { log_file: '/tmp/custom-daemon.log' } });
176 assert.strictEqual(getLogPath(config), '/tmp/custom-daemon.log');
177 });
178
179 it('writePidFile + readPidFile roundtrip', () => {
180 const config = makeConfig();
181 const pidPath = getPidPath(config);
182 writePidFile(pidPath, 12345);
183 assert.strictEqual(readPidFile(pidPath), 12345);
184 });
185
186 it('writePidFile creates parent directories', () => {
187 const config = makeConfig();
188 const nested = path.join(config.data_dir, 'deeply', 'nested', 'daemon.pid');
189 writePidFile(nested, 99);
190 assert.strictEqual(readPidFile(nested), 99);
191 });
192
193 it('readPidFile returns null when file missing', () => {
194 const config = makeConfig();
195 assert.strictEqual(readPidFile(getPidPath(config)), null);
196 });
197
198 it('readPidFile returns null for invalid content', () => {
199 const config = makeConfig();
200 const pidPath = getPidPath(config);
201 fs.writeFileSync(pidPath, 'not-a-number', 'utf8');
202 assert.strictEqual(readPidFile(pidPath), null);
203 });
204
205 it('removePidFile removes existing file', () => {
206 const config = makeConfig();
207 const pidPath = getPidPath(config);
208 writePidFile(pidPath, 42);
209 assert(fs.existsSync(pidPath));
210 removePidFile(pidPath);
211 assert(!fs.existsSync(pidPath));
212 });
213
214 it('removePidFile does not throw when file is missing', () => {
215 const config = makeConfig();
216 assert.doesNotThrow(() => removePidFile(getPidPath(config)));
217 });
218
219 it('isProcessAlive returns true for current process', () => {
220 assert.strictEqual(isProcessAlive(process.pid), true);
221 });
222
223 it('isProcessAlive returns false for non-positive PIDs', () => {
224 assert.strictEqual(isProcessAlive(0), false);
225 assert.strictEqual(isProcessAlive(-1), false);
226 assert.strictEqual(isProcessAlive(null), false);
227 });
228
229 it('isProcessAlive returns false for an unreachable PID', () => {
230 // PID 2147483647 (max int32) is extremely unlikely to exist
231 assert.strictEqual(isProcessAlive(2_147_483_647), false);
232 });
233
234 it('detectStalePid returns { stale: false, pid: null } when no PID file', () => {
235 const config = makeConfig();
236 assert.deepStrictEqual(detectStalePid(getPidPath(config)), { stale: false, pid: null });
237 });
238
239 it('detectStalePid returns { stale: false } for current process PID', () => {
240 const config = makeConfig();
241 const pidPath = getPidPath(config);
242 writePidFile(pidPath, process.pid);
243 const { stale, pid } = detectStalePid(pidPath);
244 assert.strictEqual(stale, false);
245 assert.strictEqual(pid, process.pid);
246 });
247
248 it('detectStalePid returns { stale: true } for a dead process PID', () => {
249 const config = makeConfig();
250 const pidPath = getPidPath(config);
251 writePidFile(pidPath, 2_147_483_647);
252 const { stale } = detectStalePid(pidPath);
253 assert.strictEqual(stale, true);
254 });
255 });
256
257 // ── 2. Daemon log ─────────────────────────────────────────────────────────────
258
259 describe('Daemon log', () => {
260 it('appendDaemonLog + readDaemonLog roundtrip', () => {
261 const config = makeConfig();
262 const logPath = getLogPath(config);
263 appendDaemonLog(logPath, { event: 'startup', pid: 42 });
264 const entries = readDaemonLog(logPath);
265 assert.strictEqual(entries.length, 1);
266 assert.strictEqual(entries[0].event, 'startup');
267 assert.strictEqual(entries[0].pid, 42);
268 assert.match(entries[0].ts, /^\d{4}-\d{2}-\d{2}T/);
269 });
270
271 it('appendDaemonLog creates parent directories', () => {
272 const config = makeConfig();
273 const logPath = path.join(config.data_dir, 'nested', 'dir', 'daemon.log');
274 appendDaemonLog(logPath, { event: 'test' });
275 assert(fs.existsSync(logPath));
276 });
277
278 it('appendDaemonLog overwrites a caller-supplied ts with the current time', () => {
279 const config = makeConfig();
280 const logPath = getLogPath(config);
281 const before = Date.now();
282 appendDaemonLog(logPath, { event: 'x', ts: '2000-01-01T00:00:00Z' });
283 const after = Date.now();
284 const entry = readDaemonLog(logPath)[0];
285 const entryTime = new Date(entry.ts).getTime();
286 assert(entryTime >= before, `ts (${entry.ts}) should not be before call`);
287 assert(entryTime <= after + 100, `ts should not be in the future`);
288 });
289
290 it('readDaemonLog returns empty array when file missing', () => {
291 const config = makeConfig();
292 assert.deepStrictEqual(readDaemonLog(getLogPath(config)), []);
293 });
294
295 it('readDaemonLog skips malformed JSON lines', () => {
296 const config = makeConfig();
297 const logPath = getLogPath(config);
298 fs.mkdirSync(path.dirname(logPath), { recursive: true });
299 fs.writeFileSync(
300 logPath,
301 '{"event":"ok","ts":"2026-01-01T00:00:00Z"}\nnot valid json\n{"event":"also ok","ts":"2026-01-01T00:00:01Z"}\n',
302 );
303 const entries = readDaemonLog(logPath);
304 assert.strictEqual(entries.length, 2);
305 assert.strictEqual(entries[0].event, 'ok');
306 assert.strictEqual(entries[1].event, 'also ok');
307 });
308
309 it('readDaemonLog tail option returns last N entries', () => {
310 const config = makeConfig();
311 const logPath = getLogPath(config);
312 for (let i = 0; i < 10; i++) appendDaemonLog(logPath, { event: 'entry', seq: i });
313 const tail5 = readDaemonLog(logPath, { tail: 5 });
314 assert.strictEqual(tail5.length, 5);
315 assert.strictEqual(tail5[0].seq, 5);
316 assert.strictEqual(tail5[4].seq, 9);
317 });
318
319 it('readDaemonLog with tail: 0 returns all entries', () => {
320 const config = makeConfig();
321 const logPath = getLogPath(config);
322 for (let i = 0; i < 3; i++) appendDaemonLog(logPath, { event: 'e', seq: i });
323 assert.strictEqual(readDaemonLog(logPath, { tail: 0 }).length, 3);
324 });
325 });
326
327 // ── 3. Idle detection ─────────────────────────────────────────────────────────
328
329 describe('isIdle', () => {
330 it('returns true when memory files do not exist', () => {
331 const config = makeConfig();
332 assert.strictEqual(isIdle(config), true);
333 });
334
335 it('returns true when memory files are older than idle_threshold_minutes', () => {
336 const config = makeConfig({ daemon: { idle_threshold_minutes: 1 } });
337 const memDir = path.join(config.data_dir, 'memory', 'default');
338 fs.mkdirSync(memDir, { recursive: true });
339 const eventsPath = path.join(memDir, 'events.jsonl');
340 fs.writeFileSync(eventsPath, '{}', 'utf8');
341 // Set mtime to 2 minutes ago (beyond the 1-minute threshold)
342 const pastTime = new Date(Date.now() - 2 * 60_000);
343 fs.utimesSync(eventsPath, pastTime, pastTime);
344 assert.strictEqual(isIdle(config), true);
345 });
346
347 it('returns false when memory files were modified within the threshold', () => {
348 const config = makeConfig({ daemon: { idle_threshold_minutes: 60 } });
349 const memDir = path.join(config.data_dir, 'memory', 'default');
350 fs.mkdirSync(memDir, { recursive: true });
351 fs.writeFileSync(path.join(memDir, 'events.jsonl'), '{}', 'utf8');
352 // mtime is now — well within 60-minute threshold
353 assert.strictEqual(isIdle(config), false);
354 });
355
356 it('uses the most recent mtime across events.jsonl and state.json', () => {
357 const config = makeConfig({ daemon: { idle_threshold_minutes: 1 } });
358 const memDir = path.join(config.data_dir, 'memory', 'default');
359 fs.mkdirSync(memDir, { recursive: true });
360
361 const pastTime = new Date(Date.now() - 2 * 60_000);
362 const eventsPath = path.join(memDir, 'events.jsonl');
363 fs.writeFileSync(eventsPath, '{}', 'utf8');
364 fs.utimesSync(eventsPath, pastTime, pastTime); // events is old
365
366 fs.writeFileSync(path.join(memDir, 'state.json'), '{}', 'utf8');
367 // state.json mtime is now → recent activity → not idle
368 assert.strictEqual(isIdle(config), false);
369 });
370 });
371
372 // ── 4. LLM connectivity validation ───────────────────────────────────────────
373
374 describe('validateLlmConnectivity', () => {
375 it('resolves to true when LLM returns a non-empty string', async () => {
376 const config = makeConfig();
377 assert.strictEqual(await validateLlmConnectivity(config, mockLlm('OK')), true);
378 });
379
380 it('resolves for any non-empty string (not just "OK")', async () => {
381 const config = makeConfig();
382 assert.strictEqual(await validateLlmConnectivity(config, mockLlm('Sure, I am here!')), true);
383 });
384
385 it('throws when LLM throws', async () => {
386 const config = makeConfig();
387 await assert.rejects(
388 () => validateLlmConnectivity(config, mockLlm(new Error('connection refused'))),
389 (err) => err.message.includes('connection refused'),
390 );
391 });
392
393 it('throws when LLM returns empty string', async () => {
394 const config = makeConfig();
395 await assert.rejects(
396 () => validateLlmConnectivity(config, mockLlm('')),
397 (err) => err.message.includes('empty response'),
398 );
399 });
400
401 it('throws when LLM returns whitespace-only string', async () => {
402 const config = makeConfig();
403 await assert.rejects(
404 () => validateLlmConnectivity(config, mockLlm(' ')),
405 (err) => err.message.includes('empty response'),
406 );
407 });
408
409 it('sends a trivial health-check prompt, not the consolidation prompt', async () => {
410 const config = makeConfig();
411 const llm = mockLlm('OK');
412 await validateLlmConnectivity(config, llm);
413 assert.strictEqual(llm.calls.length, 1);
414 assert(llm.calls[0].opts.system.toLowerCase().includes('health check'));
415 assert(llm.calls[0].opts.maxTokens <= 20);
416 });
417 });
418
419 // ── 5. getDaemonStatus ────────────────────────────────────────────────────────
420
421 describe('getDaemonStatus', () => {
422 it('returns running: false when no PID file', () => {
423 const config = makeConfig();
424 const status = getDaemonStatus(config);
425 assert.strictEqual(status.running, false);
426 assert.strictEqual(status.pid, null);
427 });
428
429 it('returns running: false for a stale PID', () => {
430 const config = makeConfig();
431 writePidFile(getPidPath(config), 2_147_483_647);
432 const status = getDaemonStatus(config);
433 assert.strictEqual(status.running, false);
434 assert.strictEqual(status.pid, null);
435 });
436
437 it('returns running: true and the PID for the current process', () => {
438 const config = makeConfig();
439 writePidFile(getPidPath(config), process.pid);
440 const status = getDaemonStatus(config);
441 assert.strictEqual(status.running, true);
442 assert.strictEqual(status.pid, process.pid);
443 // Cleanup
444 removePidFile(getPidPath(config));
445 });
446
447 it('returns correct log_path and pid_path', () => {
448 const config = makeConfig();
449 const status = getDaemonStatus(config);
450 assert.strictEqual(status.pid_path, getPidPath(config));
451 assert.strictEqual(status.log_path, getLogPath(config));
452 });
453
454 it('returns last_pass from a pass_complete log entry', () => {
455 const config = makeConfig();
456 writePidFile(getPidPath(config), process.pid);
457 appendDaemonLog(getLogPath(config), { event: 'startup', pid: process.pid });
458 appendDaemonLog(getLogPath(config), {
459 event: 'pass_complete',
460 trigger: 'scheduled',
461 events_processed: 42,
462 topics: 3,
463 });
464 const status = getDaemonStatus(config);
465 assert.ok(status.last_pass, 'should have last_pass');
466 assert.strictEqual(status.last_pass.events_processed, 42);
467 assert.strictEqual(status.last_pass.topics, 3);
468 removePidFile(getPidPath(config));
469 });
470
471 it('last_pass is null when no pass_complete entry in log', () => {
472 const config = makeConfig();
473 writePidFile(getPidPath(config), process.pid);
474 appendDaemonLog(getLogPath(config), { event: 'startup', pid: process.pid });
475 const status = getDaemonStatus(config);
476 assert.strictEqual(status.last_pass, null);
477 removePidFile(getPidPath(config));
478 });
479
480 it('next_pass_at is computed from last pass time + interval_minutes', () => {
481 const config = makeConfig({ daemon: { interval_minutes: 60 } });
482 writePidFile(getPidPath(config), process.pid);
483 const passTs = '2026-04-04T10:00:00.000Z';
484 appendDaemonLog(getLogPath(config), { event: 'startup', pid: process.pid });
485 fs.appendFileSync(
486 getLogPath(config),
487 JSON.stringify({ ts: passTs, event: 'pass_complete', events_processed: 5, topics: 2 }) + '\n',
488 );
489 const status = getDaemonStatus(config);
490 const expected = new Date(new Date(passTs).getTime() + 60 * 60_000).toISOString();
491 assert.strictEqual(status.next_pass_at, expected);
492 removePidFile(getPidPath(config));
493 });
494
495 it('uptime_ms is a non-negative number when running with a startup log entry', () => {
496 const config = makeConfig();
497 writePidFile(getPidPath(config), process.pid);
498 appendDaemonLog(getLogPath(config), { event: 'startup', pid: process.pid });
499 const status = getDaemonStatus(config);
500 assert.strictEqual(typeof status.uptime_ms, 'number');
501 assert(status.uptime_ms >= 0);
502 removePidFile(getPidPath(config));
503 });
504 });
505
506 // ── 6. stopDaemon ─────────────────────────────────────────────────────────────
507
508 describe('stopDaemon', () => {
509 it('returns { stopped: false } when no PID file exists', async () => {
510 const config = makeConfig();
511 const result = await stopDaemon(config);
512 assert.strictEqual(result.stopped, false);
513 assert(result.reason.includes('no PID file'));
514 });
515
516 it('cleans up a stale PID file and returns stopped: false', async () => {
517 const config = makeConfig();
518 const pidPath = getPidPath(config);
519 writePidFile(pidPath, 2_147_483_647); // dead process
520 const result = await stopDaemon(config);
521 assert.strictEqual(result.stopped, false);
522 assert(result.reason.includes('not running'));
523 assert(!fs.existsSync(pidPath), 'PID file should be removed');
524 });
525
526 it('sends SIGTERM then SIGKILL via injected _signalFn when process does not exit', async () => {
527 const config = makeConfig();
528 const pidPath = getPidPath(config);
529 writePidFile(pidPath, process.pid); // use own PID so isProcessAlive returns true
530
531 const sentSignals = [];
532 const fakeSignalFn = (pid, sig) => sentSignals.push({ pid, sig });
533
534 // killTimeoutMs: 400 → loop runs ~2 × 200ms checks → falls through to SIGKILL
535 const result = await stopDaemon(config, { killTimeoutMs: 400, _signalFn: fakeSignalFn });
536
537 assert(sentSignals.some((s) => s.sig === 'SIGTERM'), 'Should send SIGTERM first');
538 assert(sentSignals.some((s) => s.sig === 'SIGKILL'), 'Should fallback to SIGKILL');
539 assert.strictEqual(result.stopped, true);
540 assert.strictEqual(result.signal, 'SIGKILL');
541 assert(!fs.existsSync(pidPath), 'PID file should be removed');
542 });
543
544 it('returns stopped: true with SIGTERM when process exits promptly (immediately stale PID after first check)', async () => {
545 // We simulate a process dying after SIGTERM by using a fake signalFn that
546 // makes isProcessAlive return false. We do this by writing a dead PID
547 // but calling stopDaemon in a way where the first call wins.
548 // Simplest approach: use a dead PID that isn't alive, but first show it IS alive
549 // by starting with our own PID then switching… that's complex.
550 // Instead, verify the SIGTERM path using the _signalFn + short PID that is guaranteed dead.
551 const config = makeConfig();
552 const pidPath = getPidPath(config);
553 // Use a guaranteed dead PID so stopDaemon takes the "stale cleanup" branch → stopped: false.
554 writePidFile(pidPath, 2_147_483_647);
555 const result = await stopDaemon(config);
556 assert.strictEqual(result.stopped, false); // process wasn't running
557 assert(!fs.existsSync(pidPath));
558 });
559 });
560
561 // ── 7. startDaemon lifecycle ──────────────────────────────────────────────────
562
563 describe('startDaemon lifecycle', () => {
564 it('resolves with { stopped: true } after SIGTERM', async () => {
565 const config = makeConfig();
566 const { result } = await runDaemonCycle(config, {}, { stopAfterLoops: 1 });
567 assert.deepStrictEqual(result, { stopped: true });
568 });
569
570 it('writes PID file with process.pid during run', async () => {
571 const config = makeConfig();
572 const pidPath = getPidPath(config);
573 let pidDuringRun = null;
574
575 const signals = new EventEmitter();
576 let loopCount = 0;
577 await startDaemon(config, {
578 llmFn: mockLlm('OK'),
579 consolidateFn: mockConsolidate(),
580 _signalTarget: signals,
581 _sleep: async () => {
582 pidDuringRun = readPidFile(pidPath);
583 loopCount++;
584 if (loopCount >= 1) signals.emit('SIGTERM');
585 },
586 });
587
588 assert.strictEqual(pidDuringRun, process.pid, 'PID file should contain current PID during run');
589 });
590
591 it('removes PID file on SIGTERM shutdown', async () => {
592 const config = makeConfig();
593 await runDaemonCycle(config, {}, { stopAfterLoops: 1 });
594 assert(!fs.existsSync(getPidPath(config)), 'PID file should be removed after shutdown');
595 });
596
597 it('removes PID file on SIGINT shutdown', async () => {
598 const config = makeConfig();
599 const signals = new EventEmitter();
600 let loopCount = 0;
601 await startDaemon(config, {
602 llmFn: mockLlm('OK'),
603 consolidateFn: mockConsolidate(),
604 _signalTarget: signals,
605 _sleep: async () => {
606 loopCount++;
607 if (loopCount >= 1) signals.emit('SIGINT');
608 },
609 });
610 assert(!fs.existsSync(getPidPath(config)), 'PID file should be removed after SIGINT');
611 });
612
613 it('writes startup event to daemon log', async () => {
614 const config = makeConfig();
615 await runDaemonCycle(config, {}, { stopAfterLoops: 1 });
616 const log = readDaemonLog(getLogPath(config));
617 const startup = log.find((e) => e.event === 'startup');
618 assert.ok(startup, 'startup event must be in log');
619 assert.strictEqual(startup.pid, process.pid);
620 });
621
622 it('writes shutdown event with signal name to daemon log on SIGTERM', async () => {
623 const config = makeConfig();
624 await runDaemonCycle(config, {}, { stopAfterLoops: 1 });
625 const log = readDaemonLog(getLogPath(config));
626 const shutdown = log.find((e) => e.event === 'shutdown');
627 assert.ok(shutdown, 'shutdown event must be in log');
628 assert.strictEqual(shutdown.signal, 'SIGTERM');
629 });
630
631 it('writes shutdown event with SIGINT signal', async () => {
632 const config = makeConfig();
633 const signals = new EventEmitter();
634 let loopCount = 0;
635 await startDaemon(config, {
636 llmFn: mockLlm('OK'),
637 consolidateFn: mockConsolidate(),
638 _signalTarget: signals,
639 _sleep: async () => { loopCount++; if (loopCount >= 1) signals.emit('SIGINT'); },
640 });
641 const shutdown = readDaemonLog(getLogPath(config)).find((e) => e.event === 'shutdown');
642 assert.ok(shutdown, 'shutdown event must be in log');
643 assert.strictEqual(shutdown.signal, 'SIGINT');
644 });
645
646 it('throws when daemon is already running (live PID file)', async () => {
647 const config = makeConfig();
648 writePidFile(getPidPath(config), process.pid);
649 await assert.rejects(
650 () => startDaemon(config, { llmFn: mockLlm('OK'), consolidateFn: mockConsolidate() }),
651 (err) => err.message.includes('already running'),
652 );
653 removePidFile(getPidPath(config));
654 });
655
656 it('throws when LLM connectivity validation fails and does not write PID', async () => {
657 const config = makeConfig();
658 await assert.rejects(
659 () =>
660 startDaemon(config, {
661 llmFn: mockLlm(new Error('connection refused')),
662 consolidateFn: mockConsolidate(),
663 }),
664 (err) => err.message.includes('LLM'),
665 );
666 assert(!fs.existsSync(getPidPath(config)), 'PID file must NOT be written on validation failure');
667 });
668
669 it('cleans up a stale PID file before starting and logs stale_pid_cleanup', async () => {
670 const config = makeConfig();
671 const pidPath = getPidPath(config);
672 writePidFile(pidPath, 2_147_483_647); // dead process
673 await runDaemonCycle(config, {}, { stopAfterLoops: 1 });
674 const log = readDaemonLog(getLogPath(config));
675 const cleanup = log.find((e) => e.event === 'stale_pid_cleanup');
676 assert.ok(cleanup, 'stale_pid_cleanup event must be in log');
677 assert.strictEqual(cleanup.stale_pid, 2_147_483_647);
678 });
679
680 it('run_on_start: calls consolidateFn before the scheduling loop', async () => {
681 const config = makeConfig({ daemon: { run_on_start: true } });
682 const consolidate = mockConsolidate();
683 await runDaemonCycle(config, { consolidateFn: consolidate }, { stopAfterLoops: 1 });
684 // run_on_start fires before the loop, so at least 1 call before any sleep
685 assert(consolidate.calls.length >= 1, 'consolidate should be called for run_on_start');
686 const log = readDaemonLog(getLogPath(config));
687 assert.ok(
688 log.find((e) => e.event === 'pass_complete' && e.trigger === 'run_on_start'),
689 'should log pass_complete with trigger run_on_start',
690 );
691 });
692
693 it('run_on_start: logs pass_error when consolidation throws', async () => {
694 const config = makeConfig({ daemon: { run_on_start: true } });
695 const consolidate = mockConsolidate(new Error('LLM quota exceeded'));
696 await runDaemonCycle(config, { consolidateFn: consolidate }, { stopAfterLoops: 1 });
697 const log = readDaemonLog(getLogPath(config));
698 const errEntry = log.find((e) => e.event === 'pass_error' && e.trigger === 'run_on_start');
699 assert.ok(errEntry, 'should log pass_error on run_on_start failure');
700 assert(errEntry.error.includes('LLM quota exceeded'));
701 });
702
703 it('scheduling loop: calls consolidateFn on each iteration (idle_only: false)', async () => {
704 const config = makeConfig({ daemon: { idle_only: false } });
705 const consolidate = mockConsolidate();
706 // stopAfterLoops=3 → 2 consolidation calls (sleep→consolidate→sleep→consolidate→sleep[emit SIGTERM])
707 await runDaemonCycle(config, { consolidateFn: consolidate }, { stopAfterLoops: 3 });
708 assert(consolidate.calls.length >= 2, `Expected >= 2 consolidate calls, got ${consolidate.calls.length}`);
709 const passes = readDaemonLog(getLogPath(config)).filter((e) => e.event === 'pass_complete');
710 assert(passes.length >= 2);
711 });
712
713 it('scheduling loop: logs pass_complete with events_processed and topics count', async () => {
714 const config = makeConfig({ daemon: { idle_only: false } });
715 const consolidate = mockConsolidate({ total_events: 17, topics: [{ topic: 'a', facts: ['f'], event_count: 17 }] });
716 // stopAfterLoops=2 → 1 consolidation call
717 await runDaemonCycle(config, { consolidateFn: consolidate }, { stopAfterLoops: 2 });
718 const pass = readDaemonLog(getLogPath(config)).find((e) => e.event === 'pass_complete');
719 assert.ok(pass);
720 assert.strictEqual(pass.events_processed, 17);
721 assert.strictEqual(pass.topics, 1);
722 });
723
724 it('scheduling loop: skips pass and logs skip_not_idle when idle_only=true and not idle', async () => {
725 const config = makeConfig({ daemon: { idle_only: true, idle_threshold_minutes: 60 } });
726
727 // Create a memory events file with mtime = now → not idle
728 const memDir = path.join(config.data_dir, 'memory', 'default');
729 fs.mkdirSync(memDir, { recursive: true });
730 fs.writeFileSync(path.join(memDir, 'events.jsonl'), '{}', 'utf8');
731
732 const consolidate = mockConsolidate();
733 // stopAfterLoops=3 → 2 sleeps fire without consolidation
734 await runDaemonCycle(config, { consolidateFn: consolidate }, { stopAfterLoops: 3 });
735
736 assert.strictEqual(consolidate.calls.length, 0, 'Should not consolidate when not idle');
737 const skips = readDaemonLog(getLogPath(config)).filter((e) => e.event === 'skip_not_idle');
738 assert(skips.length >= 1, 'Should log skip_not_idle');
739 });
740
741 it('scheduling loop: logs pass_error without crashing when consolidateFn throws', async () => {
742 const config = makeConfig({ daemon: { idle_only: false } });
743 const consolidate = mockConsolidate(new Error('timeout'));
744 // stopAfterLoops=3 → 2 loop iterations, both fail
745 await runDaemonCycle(config, { consolidateFn: consolidate }, { stopAfterLoops: 3 });
746 const errors = readDaemonLog(getLogPath(config)).filter((e) => e.event === 'pass_error');
747 assert(errors.length >= 1, 'Should log pass_error on failure');
748 assert(errors[0].error.includes('timeout'));
749 });
750
751 it('LLM function is passed through to consolidateFn via opts (as cost-tracking wrapper)', async () => {
752 // startDaemon wraps llmFn in a cost-tracking decorator before passing it to
753 // consolidateFn. The wrapper is a different function reference but must
754 // still delegate every call to the original llmFn.
755 const config = makeConfig({ daemon: { idle_only: false } });
756 const llm = mockLlm('OK');
757 let receivedLlmFn = null;
758 const consolidate = async (cfg, opts) => {
759 receivedLlmFn = opts.llmFn;
760 // Invoke the received function to verify it delegates to the raw llmFn.
761 await opts.llmFn(cfg, { system: 'test', user: 'test' });
762 return { total_events: 0, topics: [] };
763 };
764 await runDaemonCycle(config, { llmFn: llm, consolidateFn: consolidate }, { stopAfterLoops: 2 });
765 assert(typeof receivedLlmFn === 'function', 'a function must be passed to consolidateFn');
766 // The wrapper must have forwarded the call to the underlying llm.
767 // llm.calls includes the health-check call (opts.maxTokens <= 10) plus our
768 // call above; filter to the test call to confirm delegation.
769 const testCall = llm.calls.find((c) => c.opts.system === 'test');
770 assert.ok(testCall, 'wrapper should forward calls to the underlying llmFn');
771 });
772
773 it('cleans up signal listeners after shutdown (no accumulation)', async () => {
774 const config = makeConfig();
775 const signals = new EventEmitter();
776 const listenersBefore = signals.listenerCount('SIGTERM');
777
778 let loopCount = 0;
779 await startDaemon(config, {
780 llmFn: mockLlm('OK'),
781 consolidateFn: mockConsolidate(),
782 _signalTarget: signals,
783 _sleep: async () => { loopCount++; if (loopCount >= 1) signals.emit('SIGTERM'); },
784 });
785
786 const listenersAfter = signals.listenerCount('SIGTERM');
787 assert.strictEqual(listenersAfter, listenersBefore, 'Signal listeners should be cleaned up after shutdown');
788 });
789 });
790
791 // ── 8. CLI commands ───────────────────────────────────────────────────────────
792
793 function runCli(cmdArgs, opts = {}) {
794 const dataDir = opts.dataDir || path.join(tmpDir, `data-cli-${Date.now()}-${Math.random().toString(36).slice(2)}`);
795 if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
796
797 const env = {
798 ...process.env,
799 KNOWTATION_VAULT_PATH: vaultDir,
800 KNOWTATION_DATA_DIR: dataDir,
801 KNOWTATION_MEMORY_ENABLED: 'true',
802 KNOWTATION_MEMORY_PROVIDER: 'file',
803 // Clear real LLM keys so startDaemon's LLM check would fail fast (not needed for non-start commands)
804 OPENAI_API_KEY: '',
805 ANTHROPIC_API_KEY: '',
806 OLLAMA_URL: 'http://127.0.0.1:19999', // non-existent port
807 };
808
809 try {
810 const out = execSync(`node ${cliPath} ${cmdArgs}`, {
811 cwd: path.join(__dirname, '..'),
812 env,
813 timeout: 10_000,
814 encoding: 'utf8',
815 stdio: ['pipe', 'pipe', 'pipe'],
816 });
817 return { stdout: out.trim(), stderr: '', exitCode: 0, dataDir };
818 } catch (e) {
819 return {
820 stdout: (e.stdout || '').trim(),
821 stderr: (e.stderr || '').trim(),
822 exitCode: e.status ?? 1,
823 dataDir,
824 };
825 }
826 }
827
828 describe('CLI: daemon commands', () => {
829 it('daemon --help prints usage with start/stop/status/log', () => {
830 const r = runCli('daemon --help');
831 assert.strictEqual(r.exitCode, 0);
832 assert(r.stdout.includes('start'), 'help should mention start');
833 assert(r.stdout.includes('stop'), 'help should mention stop');
834 assert(r.stdout.includes('status'), 'help should mention status');
835 assert(r.stdout.includes('log'), 'help should mention log');
836 });
837
838 it('daemon (no action) prints help and exits 0', () => {
839 const r = runCli('daemon');
840 assert.strictEqual(r.exitCode, 0);
841 assert(r.stdout.includes('start') || r.stdout.includes('Actions'));
842 });
843
844 it('daemon status exits 0 and says "not running" when no PID file', () => {
845 const r = runCli('daemon status');
846 assert.strictEqual(r.exitCode, 0);
847 assert(r.stdout.includes('not running'), `Expected "not running", got: ${r.stdout}`);
848 });
849
850 it('daemon status --json returns valid JSON with running: false', () => {
851 const r = runCli('daemon status --json');
852 assert.strictEqual(r.exitCode, 0);
853 const data = JSON.parse(r.stdout);
854 assert.strictEqual(data.running, false);
855 assert.strictEqual(data.pid, null);
856 });
857
858 it('daemon status shows last pass when log contains pass_complete', () => {
859 const dataDir = path.join(tmpDir, `data-cli-status-${Date.now()}`);
860 fs.mkdirSync(dataDir, { recursive: true });
861 const logPath = path.join(dataDir, 'daemon.log');
862 appendDaemonLog(logPath, { event: 'startup', pid: 9999 });
863 appendDaemonLog(logPath, {
864 event: 'pass_complete',
865 trigger: 'scheduled',
866 events_processed: 7,
867 topics: 2,
868 });
869 const r = runCli('daemon status', { dataDir });
870 assert.strictEqual(r.exitCode, 0);
871 // PID 9999 is almost certainly dead → "not running" but with last-pass info shown
872 assert(
873 r.stdout.includes('not running') || r.stdout.includes('pass'),
874 `Unexpected output: ${r.stdout}`,
875 );
876 });
877
878 it('daemon stop exits 0 and reports "not running" when no PID file', () => {
879 const r = runCli('daemon stop');
880 assert.strictEqual(r.exitCode, 0);
881 assert(
882 r.stdout.includes('not running') || r.stdout.includes('no PID'),
883 `Expected not-running message, got: ${r.stdout}`,
884 );
885 });
886
887 it('daemon stop --json returns valid JSON with stopped: boolean', () => {
888 const r = runCli('daemon stop --json');
889 assert.strictEqual(r.exitCode, 0);
890 const data = JSON.parse(r.stdout);
891 assert.strictEqual(typeof data.stopped, 'boolean');
892 });
893
894 it('daemon log exits 0 and shows no-entries message when log file is missing', () => {
895 const r = runCli('daemon log');
896 assert.strictEqual(r.exitCode, 0);
897 assert(
898 r.stdout.includes('no log entries') || r.stdout === '',
899 `Unexpected: ${r.stdout}`,
900 );
901 });
902
903 it('daemon log --tail <n> returns at most N lines', () => {
904 const dataDir = path.join(tmpDir, `data-cli-log-${Date.now()}`);
905 fs.mkdirSync(dataDir, { recursive: true });
906 const logPath = path.join(dataDir, 'daemon.log');
907 for (let i = 0; i < 10; i++) appendDaemonLog(logPath, { event: 'entry', seq: i });
908 const r = runCli('daemon log --tail 3', { dataDir });
909 assert.strictEqual(r.exitCode, 0);
910 const lines = r.stdout.split('\n').filter(Boolean);
911 assert(lines.length <= 3, `Expected <= 3 lines, got ${lines.length}: ${r.stdout}`);
912 });
913
914 it('daemon log --json returns valid JSON with entries array', () => {
915 const dataDir = path.join(tmpDir, `data-cli-log-json-${Date.now()}`);
916 fs.mkdirSync(dataDir, { recursive: true });
917 appendDaemonLog(path.join(dataDir, 'daemon.log'), { event: 'startup', pid: 1 });
918 const r = runCli('daemon log --json', { dataDir });
919 assert.strictEqual(r.exitCode, 0);
920 const data = JSON.parse(r.stdout);
921 assert(Array.isArray(data.entries), 'entries should be an array');
922 assert.strictEqual(data.entries.length, 1);
923 assert.strictEqual(data.entries[0].event, 'startup');
924 });
925
926 it('daemon unknown action exits with non-zero code', () => {
927 const r = runCli('daemon invalid-action');
928 assert.notStrictEqual(r.exitCode, 0);
929 });
930
931 it('global --help includes "daemon" in the commands list', () => {
932 const r = runCli('--help');
933 assert.strictEqual(r.exitCode, 0);
934 assert(r.stdout.includes('daemon'), 'Global help should mention daemon command');
935 });
936 });
937
938 // ── 9. Phase F: Cost Guards ───────────────────────────────────────────────────
939 //
940 // Tests validate cost accumulation, daily reset, cap enforcement, and the
941 // cost fields exposed on getDaemonStatus. All LLM calls are mocked.
942 // Filesystem I/O goes to per-test temp dirs (via makeConfig).
943 //
944 // The key integration point: startDaemon wraps llmFn with a cost-tracking
945 // decorator before passing it to consolidateFn. To observe accumulated cost
946 // we need a consolidateFn that actually invokes opts.llmFn.
947
948 /**
949 * A consolidateFn that calls opts.llmFn once with a fixed prompt so the cost
950 * tracking wrapper fires. The response is the injected mock llmFn's return.
951 */
952 function makeLlmCallingConsolidate(
953 result = { total_events: 5, topics: [{ topic: 'test', facts: ['fact'], event_count: 5 }] },
954 ) {
955 return async (cfg, opts) => {
956 await opts.llmFn(cfg, {
957 system: 'a'.repeat(40), // 10 input tokens (40 chars / 4)
958 user: 'b'.repeat(40), // 10 input tokens
959 });
960 return result;
961 };
962 }
963
964 describe('Phase F: Cost Guards', () => {
965 // ── cost accumulation across passes ────────────────────────────────────────
966
967 it('cost is recorded after each pass that calls llmFn', async () => {
968 const config = makeConfig({ daemon: { idle_only: false } });
969 const consolidate = makeLlmCallingConsolidate();
970
971 // stopAfterLoops=2 → 1 consolidation pass
972 await runDaemonCycle(
973 config,
974 { consolidateFn: consolidate, costRates: { input_per_token: 0.01, output_per_token: 0.01 } },
975 { stopAfterLoops: 2 },
976 );
977
978 const cost = getDailyCost(config);
979 assert(cost > 0, `Expected cost > 0, got ${cost}`);
980 });
981
982 it('cost accumulates additively across multiple passes', async () => {
983 const config = makeConfig({ daemon: { idle_only: false } });
984 const consolidate = makeLlmCallingConsolidate();
985 const rates = { input_per_token: 0.001, output_per_token: 0.001 };
986
987 // 1 pass
988 await runDaemonCycle(
989 config,
990 { consolidateFn: consolidate, costRates: rates },
991 { stopAfterLoops: 2 },
992 );
993 const costAfter1 = getDailyCost(config);
994
995 // 2nd pass (same config / same data dir — cost accumulates)
996 await runDaemonCycle(
997 config,
998 { consolidateFn: consolidate, costRates: rates },
999 { stopAfterLoops: 2 },
1000 );
1001 const costAfter2 = getDailyCost(config);
1002
1003 assert(costAfter2 > costAfter1, `Cost should increase: ${costAfter1} → ${costAfter2}`);
1004 });
1005
1006 // ── daily reset ─────────────────────────────────────────────────────────────
1007
1008 it("yesterday's cost is not counted in today's total", () => {
1009 const config = makeConfig();
1010 const yesterday = '2000-01-01';
1011 const today = '2000-01-02';
1012 recordCallCost(config, 999.0, yesterday);
1013 assert.strictEqual(getDailyCost(config, today), 0);
1014 assert(getDailyCost(config, yesterday) > 0, 'yesterday cost must still be in file');
1015 });
1016
1017 it('getDailyCost returns 0 after resetDailyCost', () => {
1018 const config = makeConfig();
1019 const date = '2026-04-05';
1020 recordCallCost(config, 5.0, date);
1021 resetDailyCost(config);
1022 assert.strictEqual(getDailyCost(config, date), 0);
1023 });
1024
1025 // ── cap enforcement ─────────────────────────────────────────────────────────
1026
1027 it('scheduling loop skips pass and logs cost_cap_reached when cap exceeded', async () => {
1028 const config = makeConfig({ daemon: { idle_only: false, max_cost_per_day_usd: 0.001 } });
1029
1030 // Pre-seed cost well above the cap
1031 recordCallCost(config, 0.005, undefined); // today
1032
1033 const consolidate = mockConsolidate();
1034 await runDaemonCycle(config, { consolidateFn: consolidate }, { stopAfterLoops: 2 });
1035
1036 assert.strictEqual(consolidate.calls.length, 0, 'consolidate should not run when cap exceeded');
1037 const log = readDaemonLog(getLogPath(config));
1038 const capEntry = log.find((e) => e.event === 'cost_cap_reached');
1039 assert.ok(capEntry, 'should log cost_cap_reached');
1040 assert(typeof capEntry.cost_today_usd === 'number', 'should include cost_today_usd');
1041 assert.strictEqual(capEntry.cap_usd, 0.001);
1042 });
1043
1044 it('cap enforcement does not throw — daemon keeps running and logs shutdown', async () => {
1045 const config = makeConfig({ daemon: { idle_only: false, max_cost_per_day_usd: 0.001 } });
1046 recordCallCost(config, 0.01, undefined);
1047
1048 let threw = false;
1049 try {
1050 // stopAfterLoops=3 → 2 skipped passes before SIGTERM
1051 await runDaemonCycle(config, { consolidateFn: mockConsolidate() }, { stopAfterLoops: 3 });
1052 } catch {
1053 threw = true;
1054 }
1055
1056 assert.strictEqual(threw, false, 'startDaemon must not throw when cap is exceeded');
1057 const log = readDaemonLog(getLogPath(config));
1058 assert.ok(log.find((e) => e.event === 'shutdown'), 'daemon should shut down cleanly');
1059 });
1060
1061 it('cap exactly at threshold (cost === cap) → still skips the pass', async () => {
1062 const config = makeConfig({ daemon: { idle_only: false, max_cost_per_day_usd: 0.05 } });
1063 recordCallCost(config, 0.05, undefined); // exactly at cap
1064
1065 const consolidate = mockConsolidate();
1066 await runDaemonCycle(config, { consolidateFn: consolidate }, { stopAfterLoops: 2 });
1067
1068 assert.strictEqual(consolidate.calls.length, 0, 'should skip when cost equals cap');
1069 const log = readDaemonLog(getLogPath(config));
1070 assert.ok(log.find((e) => e.event === 'cost_cap_reached'));
1071 });
1072
1073 // ── cap = null means no limit ───────────────────────────────────────────────
1074
1075 it('null cap allows passes regardless of accumulated cost', async () => {
1076 const config = makeConfig({ daemon: { idle_only: false, max_cost_per_day_usd: null } });
1077
1078 // Seed an absurdly large cost — should be ignored
1079 recordCallCost(config, 99999, undefined);
1080
1081 const consolidate = mockConsolidate();
1082 // stopAfterLoops=2 → 1 pass
1083 await runDaemonCycle(config, { consolidateFn: consolidate }, { stopAfterLoops: 2 });
1084
1085 assert(consolidate.calls.length >= 1, 'should run consolidation when cap is null');
1086 const log = readDaemonLog(getLogPath(config));
1087 assert(!log.find((e) => e.event === 'cost_cap_reached'), 'should not log cost_cap_reached');
1088 });
1089
1090 it('undefined cap (missing from config) behaves like null — no limit', async () => {
1091 // loadDaemonConfig defaults max_cost_per_day_usd to null, but test raw object too
1092 const config = makeConfig();
1093 // Omit max_cost_per_day_usd entirely — daemon.max_cost_per_day_usd is absent
1094 delete config.daemon;
1095 recordCallCost(config, 999, undefined);
1096
1097 const consolidate = mockConsolidate();
1098 await runDaemonCycle(config, { consolidateFn: consolidate }, { stopAfterLoops: 2 });
1099
1100 assert(consolidate.calls.length >= 1, 'should run when no cap is configured');
1101 });
1102
1103 // ── getDailyCost and resetDailyCost helpers ─────────────────────────────────
1104
1105 it('getDailyCost returns 0 when no cost has been recorded', () => {
1106 const config = makeConfig();
1107 assert.strictEqual(getDailyCost(config), 0);
1108 });
1109
1110 it('getDailyCost returns the correct sum after recordCallCost calls', () => {
1111 const config = makeConfig();
1112 const date = '2026-04-05';
1113 recordCallCost(config, 0.10, date);
1114 recordCallCost(config, 0.05, date);
1115 assert(Math.abs(getDailyCost(config, date) - 0.15) < 1e-10);
1116 });
1117
1118 it('resetDailyCost clears all cost entries', () => {
1119 const config = makeConfig();
1120 recordCallCost(config, 0.5, '2026-04-05');
1121 recordCallCost(config, 0.3, '2026-04-04');
1122 resetDailyCost(config);
1123 assert.strictEqual(getDailyCost(config, '2026-04-05'), 0);
1124 assert.strictEqual(getDailyCost(config, '2026-04-04'), 0);
1125 });
1126
1127 // ── getDaemonStatus cost fields ─────────────────────────────────────────────
1128
1129 it('getDaemonStatus includes cost_today_usd field', () => {
1130 const config = makeConfig({ daemon: { max_cost_per_day_usd: 1.0 } });
1131 const status = getDaemonStatus(config);
1132 assert('cost_today_usd' in status, 'status should have cost_today_usd');
1133 assert(typeof status.cost_today_usd === 'number');
1134 assert.strictEqual(status.cost_today_usd, 0);
1135 });
1136
1137 it('getDaemonStatus reflects actual accumulated cost', () => {
1138 const config = makeConfig({ daemon: { max_cost_per_day_usd: 1.0 } });
1139 recordCallCost(config, 0.042, undefined);
1140 const status = getDaemonStatus(config);
1141 assert(Math.abs(status.cost_today_usd - 0.042) < 1e-10);
1142 });
1143
1144 it('getDaemonStatus includes cost_cap_usd from config', () => {
1145 const config = makeConfig({ daemon: { max_cost_per_day_usd: 0.50 } });
1146 const status = getDaemonStatus(config);
1147 assert('cost_cap_usd' in status, 'status should have cost_cap_usd');
1148 assert.strictEqual(status.cost_cap_usd, 0.50);
1149 });
1150
1151 it('getDaemonStatus cost_cap_usd is null when cap is not configured', () => {
1152 const config = makeConfig({ daemon: {} });
1153 const status = getDaemonStatus(config);
1154 assert.strictEqual(status.cost_cap_usd, null);
1155 });
1156
1157 it('getDaemonStatus cost_today_usd is 0 when no passes have run', () => {
1158 const config = makeConfig();
1159 const status = getDaemonStatus(config);
1160 assert.strictEqual(status.cost_today_usd, 0);
1161 });
1162
1163 // ── LLM health check is NOT counted toward daily cost ──────────────────────
1164
1165 it('validateLlmConnectivity health-check call does not increment daily cost', async () => {
1166 const config = makeConfig({ daemon: { idle_only: false } });
1167
1168 // The raw llmFn is used for the health check; the trackedLlmFn (which records
1169 // cost) is only used inside consolidateFn. mockConsolidate never calls llmFn,
1170 // so cost must remain 0 after a full daemon cycle.
1171 await runDaemonCycle(
1172 config,
1173 {
1174 consolidateFn: mockConsolidate(), // does NOT call llmFn
1175 costRates: { input_per_token: 1.0, output_per_token: 1.0 }, // absurd rate
1176 },
1177 { stopAfterLoops: 2 },
1178 );
1179
1180 assert.strictEqual(getDailyCost(config), 0, 'health check must not be billed');
1181 });
1182 });
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago