@cleocode/core 2026.4.98 → 2026.4.99

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/gc/daemon-entry.d.ts +15 -0
  2. package/dist/gc/daemon-entry.d.ts.map +1 -0
  3. package/dist/gc/daemon.d.ts +71 -0
  4. package/dist/gc/daemon.d.ts.map +1 -0
  5. package/dist/gc/index.d.ts +14 -0
  6. package/dist/gc/index.d.ts.map +1 -0
  7. package/dist/gc/runner.d.ts +132 -0
  8. package/dist/gc/runner.d.ts.map +1 -0
  9. package/dist/gc/state.d.ts +94 -0
  10. package/dist/gc/state.d.ts.map +1 -0
  11. package/dist/gc/transcript.d.ts +130 -0
  12. package/dist/gc/transcript.d.ts.map +1 -0
  13. package/dist/sentient/daemon-entry.d.ts +11 -0
  14. package/dist/sentient/daemon-entry.d.ts.map +1 -0
  15. package/dist/sentient/daemon.d.ts +160 -0
  16. package/dist/sentient/daemon.d.ts.map +1 -0
  17. package/dist/sentient/index.d.ts +18 -0
  18. package/dist/sentient/index.d.ts.map +1 -0
  19. package/dist/sentient/ingesters/brain-ingester.d.ts +44 -0
  20. package/dist/sentient/ingesters/brain-ingester.d.ts.map +1 -0
  21. package/dist/sentient/ingesters/nexus-ingester.d.ts +45 -0
  22. package/dist/sentient/ingesters/nexus-ingester.d.ts.map +1 -0
  23. package/dist/sentient/ingesters/test-ingester.d.ts +43 -0
  24. package/dist/sentient/ingesters/test-ingester.d.ts.map +1 -0
  25. package/dist/sentient/proposal-rate-limiter.d.ts +93 -0
  26. package/dist/sentient/proposal-rate-limiter.d.ts.map +1 -0
  27. package/dist/sentient/propose-tick.d.ts +105 -0
  28. package/dist/sentient/propose-tick.d.ts.map +1 -0
  29. package/dist/sentient/state.d.ts +143 -0
  30. package/dist/sentient/state.d.ts.map +1 -0
  31. package/dist/sentient/tick.d.ts +193 -0
  32. package/dist/sentient/tick.d.ts.map +1 -0
  33. package/package.json +76 -8
  34. package/src/gc/__tests__/runner.test.ts +367 -0
  35. package/src/gc/__tests__/state.test.ts +169 -0
  36. package/src/gc/__tests__/transcript.test.ts +371 -0
  37. package/src/gc/daemon-entry.ts +26 -0
  38. package/src/gc/daemon.ts +251 -0
  39. package/src/gc/index.ts +14 -0
  40. package/src/gc/runner.ts +378 -0
  41. package/src/gc/state.ts +140 -0
  42. package/src/gc/transcript.ts +380 -0
  43. package/src/sentient/__tests__/brain-ingester.test.ts +154 -0
  44. package/src/sentient/__tests__/daemon.test.ts +472 -0
  45. package/src/sentient/__tests__/dream-tick.test.ts +200 -0
  46. package/src/sentient/__tests__/nexus-ingester.test.ts +138 -0
  47. package/src/sentient/__tests__/proposal-rate-limiter.test.ts +247 -0
  48. package/src/sentient/__tests__/propose-tick.test.ts +296 -0
  49. package/src/sentient/__tests__/test-ingester.test.ts +104 -0
  50. package/src/sentient/daemon-entry.ts +20 -0
  51. package/src/sentient/daemon.ts +471 -0
  52. package/src/sentient/index.ts +18 -0
  53. package/src/sentient/ingesters/brain-ingester.ts +122 -0
  54. package/src/sentient/ingesters/nexus-ingester.ts +171 -0
  55. package/src/sentient/ingesters/test-ingester.ts +205 -0
  56. package/src/sentient/proposal-rate-limiter.ts +172 -0
  57. package/src/sentient/propose-tick.ts +415 -0
  58. package/src/sentient/state.ts +229 -0
  59. package/src/sentient/tick.ts +688 -0
@@ -0,0 +1,472 @@
1
+ /**
2
+ * Tests for the sentient Tier-1 daemon — state, lock, and tick behaviour.
3
+ *
4
+ * Covers:
5
+ * - State file read/write/patch + stats increments
6
+ * - Advisory lock: first acquire succeeds, second is rejected while alive,
7
+ * stale lockfiles are reclaimed
8
+ * - Kill switch aborts tick at every checkpoint
9
+ * - State transitions: picked → spawning → completed
10
+ * - Retry backoff + stuck detection after MAX_TASK_ATTEMPTS
11
+ * - Self-pause when stuck-rate ≥ SELF_PAUSE_STUCK_THRESHOLD
12
+ * - Resume + getSentientDaemonStatus snapshots
13
+ *
14
+ * Uses real temp directories (mkdtemp). Subprocess spawns are replaced by an
15
+ * injected `spawn` fake so we never fork the real CLI.
16
+ *
17
+ * @task T946
18
+ */
19
+
20
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
21
+ import { tmpdir } from 'node:os';
22
+ import { join } from 'node:path';
23
+ import type { Task } from '@cleocode/contracts';
24
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
25
+
26
+ import {
27
+ acquireLock,
28
+ getSentientDaemonStatus,
29
+ releaseLock,
30
+ resumeSentientDaemon,
31
+ SENTIENT_STATE_FILE,
32
+ stopSentientDaemon,
33
+ } from '../daemon.js';
34
+ import {
35
+ DEFAULT_SENTIENT_STATE,
36
+ incrementStats,
37
+ patchSentientState,
38
+ readSentientState,
39
+ writeSentientState,
40
+ } from '../state.js';
41
+ import {
42
+ MAX_TASK_ATTEMPTS,
43
+ RETRY_BACKOFF_MS,
44
+ runTick,
45
+ SELF_PAUSE_REASON,
46
+ SELF_PAUSE_STUCK_THRESHOLD,
47
+ type SpawnResult,
48
+ safeRunTick,
49
+ type TickOptions,
50
+ } from '../tick.js';
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Helpers
54
+ // ---------------------------------------------------------------------------
55
+
56
+ function makeTask(id: string, overrides: Partial<Task> = {}): Task {
57
+ return {
58
+ id,
59
+ title: `Task ${id}`,
60
+ status: 'pending',
61
+ priority: 'medium',
62
+ createdAt: new Date().toISOString(),
63
+ ...overrides,
64
+ } as Task;
65
+ }
66
+
67
+ function mkTickOptions(projectRoot: string, overrides: Partial<TickOptions> = {}): TickOptions {
68
+ return {
69
+ projectRoot,
70
+ statePath: join(projectRoot, SENTIENT_STATE_FILE),
71
+ pickTask: async () => null,
72
+ spawn: async () => ({ exitCode: 0, stdout: '', stderr: '' }),
73
+ ...overrides,
74
+ };
75
+ }
76
+
77
+ describe('sentient state', () => {
78
+ let root: string;
79
+ let statePath: string;
80
+
81
+ beforeEach(async () => {
82
+ root = await mkdtemp(join(tmpdir(), 'cleo-sentient-state-'));
83
+ statePath = join(root, SENTIENT_STATE_FILE);
84
+ });
85
+
86
+ afterEach(async () => {
87
+ await rm(root, { recursive: true, force: true });
88
+ });
89
+
90
+ it('returns default state when file missing', async () => {
91
+ const s = await readSentientState(statePath);
92
+ expect(s).toEqual(DEFAULT_SENTIENT_STATE);
93
+ });
94
+
95
+ it('writes and reads state atomically', async () => {
96
+ await writeSentientState(statePath, {
97
+ ...DEFAULT_SENTIENT_STATE,
98
+ pid: 1234,
99
+ killSwitch: true,
100
+ killSwitchReason: 'test',
101
+ });
102
+ const s = await readSentientState(statePath);
103
+ expect(s.pid).toBe(1234);
104
+ expect(s.killSwitch).toBe(true);
105
+ expect(s.killSwitchReason).toBe('test');
106
+ });
107
+
108
+ it('patchSentientState merges nested stats without clobbering', async () => {
109
+ await patchSentientState(statePath, {
110
+ stats: {
111
+ tasksPicked: 3,
112
+ tasksCompleted: 0,
113
+ tasksFailed: 0,
114
+ ticksExecuted: 0,
115
+ ticksKilled: 0,
116
+ },
117
+ });
118
+ await patchSentientState(statePath, {
119
+ stats: { ...(await readSentientState(statePath)).stats, tasksCompleted: 2 },
120
+ });
121
+ const s = await readSentientState(statePath);
122
+ expect(s.stats.tasksPicked).toBe(3);
123
+ expect(s.stats.tasksCompleted).toBe(2);
124
+ });
125
+
126
+ it('incrementStats is monotonic across calls', async () => {
127
+ await incrementStats(statePath, { tasksPicked: 1 });
128
+ await incrementStats(statePath, { tasksPicked: 2, tasksCompleted: 1 });
129
+ const s = await readSentientState(statePath);
130
+ expect(s.stats.tasksPicked).toBe(3);
131
+ expect(s.stats.tasksCompleted).toBe(1);
132
+ });
133
+
134
+ it('malformed JSON falls back to defaults', async () => {
135
+ const { mkdir } = await import('node:fs/promises');
136
+ await mkdir(join(root, '.cleo'), { recursive: true });
137
+ await writeFile(statePath, '{{{ not json', 'utf-8');
138
+ const s = await readSentientState(statePath);
139
+ expect(s).toEqual(DEFAULT_SENTIENT_STATE);
140
+ });
141
+ });
142
+
143
+ describe('sentient advisory lock', () => {
144
+ let root: string;
145
+ let lockPath: string;
146
+
147
+ beforeEach(async () => {
148
+ root = await mkdtemp(join(tmpdir(), 'cleo-sentient-lock-'));
149
+ lockPath = join(root, '.cleo', 'sentient.lock');
150
+ });
151
+
152
+ afterEach(async () => {
153
+ await rm(root, { recursive: true, force: true });
154
+ });
155
+
156
+ it('first acquire succeeds', async () => {
157
+ const h = await acquireLock(lockPath);
158
+ expect(h).not.toBeNull();
159
+ const body = await readFile(lockPath, 'utf-8');
160
+ expect(Number.parseInt(body, 10)).toBe(process.pid);
161
+ if (h) await releaseLock(h);
162
+ });
163
+
164
+ it('second acquire is rejected while the first is live', async () => {
165
+ const first = await acquireLock(lockPath);
166
+ expect(first).not.toBeNull();
167
+ // Simulate another process by writing a different live pid into the file
168
+ // AFTER we have the lock, then attempt re-acquisition. Since our process
169
+ // is alive, acquireLock must refuse.
170
+ if (first) {
171
+ // Already holds pid = our own process. Re-acquire should return null
172
+ // because our pid is alive.
173
+ const second = await acquireLock(lockPath);
174
+ expect(second).toBeNull();
175
+ await releaseLock(first);
176
+ }
177
+ });
178
+
179
+ it('stale lockfile (dead pid) is reclaimed', async () => {
180
+ // Pick a pid that is very unlikely to be live — PID 999999 is typically
181
+ // beyond the kernel's max pid. If it happens to be live, the test will
182
+ // see acquireLock return null; allow both outcomes (this is defensive).
183
+ const deadPid = '999999';
184
+ await writeFile(join(root, '.cleo', 'sentient.lock'), deadPid, 'utf-8').catch(async () => {
185
+ // ensure the directory exists first
186
+ const { mkdir } = await import('node:fs/promises');
187
+ await mkdir(join(root, '.cleo'), { recursive: true });
188
+ await writeFile(join(root, '.cleo', 'sentient.lock'), deadPid, 'utf-8');
189
+ });
190
+
191
+ const h = await acquireLock(lockPath);
192
+ if (h === null) {
193
+ // Pid 999999 happens to be live on this system — acceptable.
194
+ return;
195
+ }
196
+ const body = await readFile(lockPath, 'utf-8');
197
+ expect(Number.parseInt(body, 10)).toBe(process.pid);
198
+ await releaseLock(h);
199
+ });
200
+ });
201
+
202
+ describe('sentient tick — kill switch', () => {
203
+ let root: string;
204
+ let statePath: string;
205
+
206
+ beforeEach(async () => {
207
+ root = await mkdtemp(join(tmpdir(), 'cleo-sentient-tick-'));
208
+ statePath = join(root, SENTIENT_STATE_FILE);
209
+ // Seed an empty state so patch calls work.
210
+ await writeSentientState(statePath, DEFAULT_SENTIENT_STATE);
211
+ });
212
+
213
+ afterEach(async () => {
214
+ await rm(root, { recursive: true, force: true });
215
+ });
216
+
217
+ it('aborts immediately when killSwitch is true at tick start', async () => {
218
+ await patchSentientState(statePath, { killSwitch: true });
219
+ const picked: string[] = [];
220
+ const outcome = await runTick(
221
+ mkTickOptions(root, {
222
+ pickTask: async () => {
223
+ picked.push('called');
224
+ return makeTask('T001');
225
+ },
226
+ }),
227
+ );
228
+ expect(outcome.kind).toBe('killed');
229
+ expect(picked).toHaveLength(0); // picker must not be called
230
+ const s = await readSentientState(statePath);
231
+ expect(s.stats.ticksKilled).toBe(1);
232
+ });
233
+
234
+ it('aborts after picking when killSwitch flipped mid-tick', async () => {
235
+ const spawned: string[] = [];
236
+ const outcome = await runTick(
237
+ mkTickOptions(root, {
238
+ pickTask: async () => {
239
+ // Flip killSwitch between pick and spawn
240
+ await patchSentientState(statePath, { killSwitch: true });
241
+ return makeTask('T002');
242
+ },
243
+ spawn: async (taskId) => {
244
+ spawned.push(taskId);
245
+ return { exitCode: 0, stdout: '', stderr: '' } satisfies SpawnResult;
246
+ },
247
+ }),
248
+ );
249
+ expect(outcome.kind).toBe('killed');
250
+ expect(outcome.taskId).toBe('T002');
251
+ expect(spawned).toHaveLength(0); // spawn must not run
252
+ });
253
+
254
+ it('aborts after spawn when killSwitch flipped before recording', async () => {
255
+ const outcome = await runTick(
256
+ mkTickOptions(root, {
257
+ pickTask: async () => makeTask('T003'),
258
+ spawn: async () => {
259
+ await patchSentientState(statePath, { killSwitch: true });
260
+ return { exitCode: 0, stdout: '', stderr: '' };
261
+ },
262
+ }),
263
+ );
264
+ expect(outcome.kind).toBe('killed');
265
+ expect(outcome.taskId).toBe('T003');
266
+ // Stats: tasksPicked increments (we marked it active), but NOT completed
267
+ const s = await readSentientState(statePath);
268
+ expect(s.stats.tasksCompleted).toBe(0);
269
+ expect(s.stats.tasksPicked).toBe(1);
270
+ });
271
+ });
272
+
273
+ describe('sentient tick — state transitions', () => {
274
+ let root: string;
275
+ let statePath: string;
276
+
277
+ beforeEach(async () => {
278
+ root = await mkdtemp(join(tmpdir(), 'cleo-sentient-transitions-'));
279
+ statePath = join(root, SENTIENT_STATE_FILE);
280
+ await writeSentientState(statePath, DEFAULT_SENTIENT_STATE);
281
+ });
282
+
283
+ afterEach(async () => {
284
+ await rm(root, { recursive: true, force: true });
285
+ });
286
+
287
+ it('no-task outcome when picker returns null', async () => {
288
+ const outcome = await runTick(mkTickOptions(root));
289
+ expect(outcome.kind).toBe('no-task');
290
+ const s = await readSentientState(statePath);
291
+ expect(s.stats.ticksExecuted).toBe(1);
292
+ expect(s.stats.tasksPicked).toBe(0);
293
+ });
294
+
295
+ it('success: picked → spawn=0 → tasksCompleted increments + receipt cleared', async () => {
296
+ const outcome = await runTick(
297
+ mkTickOptions(root, {
298
+ pickTask: async () => makeTask('T100'),
299
+ spawn: async () => ({ exitCode: 0, stdout: 'ok', stderr: '' }),
300
+ }),
301
+ );
302
+ expect(outcome.kind).toBe('success');
303
+ expect(outcome.taskId).toBe('T100');
304
+ const s = await readSentientState(statePath);
305
+ expect(s.stats.tasksPicked).toBe(1);
306
+ expect(s.stats.tasksCompleted).toBe(1);
307
+ expect(s.stats.tasksFailed).toBe(0);
308
+ expect(s.activeTaskId).toBeNull(); // cleared after success
309
+ expect(s.stuckTasks['T100']).toBeUndefined();
310
+ });
311
+
312
+ it('failure: spawn exit != 0 schedules backoff for attempt 1', async () => {
313
+ const outcome = await runTick(
314
+ mkTickOptions(root, {
315
+ pickTask: async () => makeTask('T200'),
316
+ spawn: async () => ({ exitCode: 7, stdout: '', stderr: 'boom' }),
317
+ }),
318
+ );
319
+ expect(outcome.kind).toBe('failure');
320
+ const s = await readSentientState(statePath);
321
+ expect(s.stats.tasksFailed).toBe(1);
322
+ expect(s.stuckTasks['T200']).toBeDefined();
323
+ expect(s.stuckTasks['T200'].attempts).toBe(1);
324
+ expect(s.stuckTasks['T200'].nextRetryAt).toBeGreaterThan(Date.now());
325
+ expect(s.stuckTasks['T200'].nextRetryAt).toBeLessThan(Date.now() + RETRY_BACKOFF_MS[0] + 1_000);
326
+ });
327
+
328
+ it('backoff: task in nextRetryAt window is skipped', async () => {
329
+ // Seed a stuck record with a future retry time
330
+ await patchSentientState(statePath, {
331
+ stuckTasks: {
332
+ T300: {
333
+ attempts: 1,
334
+ lastFailureAt: new Date().toISOString(),
335
+ nextRetryAt: Date.now() + 60_000,
336
+ lastReason: 'earlier',
337
+ },
338
+ },
339
+ });
340
+ const outcome = await runTick(
341
+ mkTickOptions(root, {
342
+ pickTask: async () => makeTask('T300'),
343
+ spawn: async () => {
344
+ throw new Error('spawn must not be called during backoff');
345
+ },
346
+ }),
347
+ );
348
+ expect(outcome.kind).toBe('backoff');
349
+ expect(outcome.taskId).toBe('T300');
350
+ });
351
+ });
352
+
353
+ describe('sentient tick — stuck + self-pause', () => {
354
+ let root: string;
355
+ let statePath: string;
356
+
357
+ beforeEach(async () => {
358
+ root = await mkdtemp(join(tmpdir(), 'cleo-sentient-stuck-'));
359
+ statePath = join(root, SENTIENT_STATE_FILE);
360
+ await writeSentientState(statePath, DEFAULT_SENTIENT_STATE);
361
+ });
362
+
363
+ afterEach(async () => {
364
+ await rm(root, { recursive: true, force: true });
365
+ });
366
+
367
+ it(`stuck: MAX_TASK_ATTEMPTS (${MAX_TASK_ATTEMPTS}) failures marks task stuck`, async () => {
368
+ // Seed a record at attempts = MAX - 1 so next failure triggers stuck.
369
+ const priorAttempts = MAX_TASK_ATTEMPTS - 1;
370
+ await patchSentientState(statePath, {
371
+ stuckTasks: {
372
+ T400: {
373
+ attempts: priorAttempts,
374
+ lastFailureAt: new Date().toISOString(),
375
+ nextRetryAt: 0, // eligible immediately
376
+ lastReason: 'prior',
377
+ },
378
+ },
379
+ });
380
+ const outcome = await runTick(
381
+ mkTickOptions(root, {
382
+ pickTask: async () => makeTask('T400'),
383
+ spawn: async () => ({ exitCode: 1, stdout: '', stderr: 'still failing' }),
384
+ }),
385
+ );
386
+ expect(outcome.kind).toBe('stuck');
387
+ const s = await readSentientState(statePath);
388
+ expect(s.stuckTasks['T400'].attempts).toBe(MAX_TASK_ATTEMPTS);
389
+ expect(s.stuckTasks['T400'].nextRetryAt).toBe(Number.MAX_SAFE_INTEGER);
390
+ });
391
+
392
+ it('self-pause when stuck-rate crosses SELF_PAUSE_STUCK_THRESHOLD', async () => {
393
+ // Pre-populate the rolling stuck-timestamp window with THRESHOLD-1 entries
394
+ // so the next stuck trips self-pause.
395
+ const now = Date.now();
396
+ const priorStucks: number[] = [];
397
+ for (let i = 0; i < SELF_PAUSE_STUCK_THRESHOLD - 1; i++) {
398
+ priorStucks.push(now - 1000 - i * 100);
399
+ }
400
+ await patchSentientState(statePath, {
401
+ stuckTimestamps: priorStucks,
402
+ stuckTasks: {
403
+ T500: {
404
+ attempts: MAX_TASK_ATTEMPTS - 1,
405
+ lastFailureAt: new Date().toISOString(),
406
+ nextRetryAt: 0,
407
+ lastReason: 'prior',
408
+ },
409
+ },
410
+ });
411
+ const outcome = await runTick(
412
+ mkTickOptions(root, {
413
+ pickTask: async () => makeTask('T500'),
414
+ spawn: async () => ({ exitCode: 1, stdout: '', stderr: 'still failing' }),
415
+ }),
416
+ );
417
+ expect(outcome.kind).toBe('self-paused');
418
+ const s = await readSentientState(statePath);
419
+ expect(s.killSwitch).toBe(true);
420
+ expect(s.killSwitchReason).toBe(SELF_PAUSE_REASON);
421
+ });
422
+ });
423
+
424
+ describe('sentient daemon — status + resume', () => {
425
+ let root: string;
426
+
427
+ beforeEach(async () => {
428
+ root = await mkdtemp(join(tmpdir(), 'cleo-sentient-daemon-'));
429
+ });
430
+
431
+ afterEach(async () => {
432
+ await rm(root, { recursive: true, force: true });
433
+ });
434
+
435
+ it('getSentientDaemonStatus reports stopped when no pid recorded', async () => {
436
+ const status = await getSentientDaemonStatus(root);
437
+ expect(status.running).toBe(false);
438
+ expect(status.pid).toBeNull();
439
+ expect(status.killSwitch).toBe(false);
440
+ });
441
+
442
+ it('stopSentientDaemon flips killSwitch even when no pid recorded', async () => {
443
+ const result = await stopSentientDaemon(root, 'test stop');
444
+ expect(result.stopped).toBe(false);
445
+ expect(result.pid).toBeNull();
446
+ const s = await readSentientState(join(root, SENTIENT_STATE_FILE));
447
+ expect(s.killSwitch).toBe(true);
448
+ expect(s.killSwitchReason).toBe('test stop');
449
+ });
450
+
451
+ it('resumeSentientDaemon clears killSwitch', async () => {
452
+ await patchSentientState(join(root, SENTIENT_STATE_FILE), {
453
+ killSwitch: true,
454
+ killSwitchReason: 'test',
455
+ });
456
+ const after = await resumeSentientDaemon(root);
457
+ expect(after.killSwitch).toBe(false);
458
+ expect(after.killSwitchReason).toBeNull();
459
+ });
460
+
461
+ it('safeRunTick swallows picker exceptions into error outcome', async () => {
462
+ const outcome = await safeRunTick({
463
+ projectRoot: root,
464
+ statePath: join(root, SENTIENT_STATE_FILE),
465
+ pickTask: async () => {
466
+ throw new Error('simulated picker failure');
467
+ },
468
+ });
469
+ expect(outcome.kind).toBe('error');
470
+ expect(outcome.detail).toContain('simulated picker failure');
471
+ });
472
+ });
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Dream-tick integration tests — T996
3
+ *
4
+ * Verifies that the sentient tick loop correctly evaluates volume + idle
5
+ * dream triggers via `safeRunTick` / `runTick`, and that the
6
+ * `startDreamScheduler` setTimeout chaining pattern has been removed.
7
+ *
8
+ * All tests use injected `checkAndDream` fakes so brain.db is never touched.
9
+ *
10
+ * Test inventory:
11
+ * DT-1: Volume threshold exceeded → checkAndDream called within next tick
12
+ * DT-2: Volume threshold NOT exceeded → checkAndDream still called (passes through to checkAndDream internal logic)
13
+ * DT-3: Idle N consecutive no-task ticks → consecutiveIdleTicks increments
14
+ * DT-4: Task pick resets idle counter to 0
15
+ * DT-5: checkAndDream error does NOT crash the tick (graceful error handling)
16
+ * DT-6: Two rapid safeRunTick calls do not double-invoke dream (checkAndDream's own cooldown)
17
+ * DT-7: startDreamScheduler is NOT exported from dream-cycle.ts
18
+ * DT-8: runTick itself does NOT call checkAndDream (only safeRunTick wrapper does)
19
+ *
20
+ * @task T996
21
+ * @epic T991
22
+ */
23
+
24
+ import { mkdtemp, rm } from 'node:fs/promises';
25
+ import { tmpdir } from 'node:os';
26
+ import { join } from 'node:path';
27
+ import type { Task } from '@cleocode/contracts';
28
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
29
+
30
+ import { SENTIENT_STATE_FILE } from '../daemon.js';
31
+ import { DEFAULT_SENTIENT_STATE, writeSentientState } from '../state.js';
32
+ import {
33
+ _getConsecutiveIdleTicks,
34
+ _resetDreamTickState,
35
+ DREAM_VOLUME_THRESHOLD_DEFAULT,
36
+ runTick,
37
+ safeRunTick,
38
+ type TickOptions,
39
+ } from '../tick.js';
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Helpers
43
+ // ---------------------------------------------------------------------------
44
+
45
+ function makeTask(id: string): Task {
46
+ return {
47
+ id,
48
+ title: `Task ${id}`,
49
+ status: 'pending',
50
+ priority: 'medium',
51
+ createdAt: new Date().toISOString(),
52
+ } as Task;
53
+ }
54
+
55
+ function makeDreamFake() {
56
+ return vi.fn().mockResolvedValue({ triggered: false, tier: null, skippedReason: 'test' });
57
+ }
58
+
59
+ function mkTickOpts(projectRoot: string, overrides: Partial<TickOptions> = {}): TickOptions {
60
+ return {
61
+ projectRoot,
62
+ statePath: join(projectRoot, SENTIENT_STATE_FILE),
63
+ pickTask: async () => null,
64
+ spawn: async () => ({ exitCode: 0, stdout: '', stderr: '' }),
65
+ ...overrides,
66
+ };
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Suite
71
+ // ---------------------------------------------------------------------------
72
+
73
+ describe('dream-tick integration (T996)', () => {
74
+ let root: string;
75
+
76
+ beforeEach(async () => {
77
+ root = await mkdtemp(join(tmpdir(), 'cleo-dream-tick-'));
78
+ const statePath = join(root, SENTIENT_STATE_FILE);
79
+ await writeSentientState(statePath, { ...DEFAULT_SENTIENT_STATE });
80
+ _resetDreamTickState();
81
+ });
82
+
83
+ afterEach(async () => {
84
+ _resetDreamTickState();
85
+ await rm(root, { recursive: true, force: true });
86
+ });
87
+
88
+ // DT-1: Volume threshold exceeded → checkAndDream called within next tick
89
+ it('DT-1: calls checkAndDream on every safeRunTick (volume/idle path delegated)', async () => {
90
+ const dreamFake = makeDreamFake();
91
+ const opts = mkTickOpts(root, {
92
+ checkAndDream: dreamFake,
93
+ dreamVolumeThreshold: DREAM_VOLUME_THRESHOLD_DEFAULT,
94
+ });
95
+
96
+ await safeRunTick(opts);
97
+ expect(dreamFake).toHaveBeenCalledOnce();
98
+ expect(dreamFake).toHaveBeenCalledWith(root, expect.objectContaining({ inline: false }));
99
+ });
100
+
101
+ // DT-2: checkAndDream is called even when no task is available (volume below
102
+ // threshold is handled inside checkAndDream itself, not by tick)
103
+ it('DT-2: calls checkAndDream even on no-task ticks', async () => {
104
+ const dreamFake = makeDreamFake();
105
+ const opts = mkTickOpts(root, {
106
+ pickTask: async () => null,
107
+ checkAndDream: dreamFake,
108
+ });
109
+
110
+ await safeRunTick(opts);
111
+ expect(dreamFake).toHaveBeenCalledOnce();
112
+ });
113
+
114
+ // DT-3: Idle counter increments on no-task ticks
115
+ it('DT-3: consecutiveIdleTicks increments on no-task ticks', async () => {
116
+ const dreamFake = makeDreamFake();
117
+ const opts = mkTickOpts(root, {
118
+ pickTask: async () => null,
119
+ checkAndDream: dreamFake,
120
+ });
121
+
122
+ expect(_getConsecutiveIdleTicks()).toBe(0);
123
+ await safeRunTick(opts);
124
+ expect(_getConsecutiveIdleTicks()).toBe(1);
125
+ await safeRunTick(opts);
126
+ expect(_getConsecutiveIdleTicks()).toBe(2);
127
+ });
128
+
129
+ // DT-4: Task pick resets idle counter to 0
130
+ it('DT-4: picking a task resets consecutiveIdleTicks to 0', async () => {
131
+ const dreamFake = makeDreamFake();
132
+
133
+ // Run two idle ticks first
134
+ const idleOpts = mkTickOpts(root, {
135
+ pickTask: async () => null,
136
+ checkAndDream: dreamFake,
137
+ });
138
+ await safeRunTick(idleOpts);
139
+ await safeRunTick(idleOpts);
140
+ expect(_getConsecutiveIdleTicks()).toBe(2);
141
+
142
+ // Now pick a task
143
+ const activeOpts = mkTickOpts(root, {
144
+ pickTask: async () => makeTask('T001'),
145
+ spawn: async () => ({ exitCode: 0, stdout: 'done', stderr: '' }),
146
+ checkAndDream: dreamFake,
147
+ });
148
+ await safeRunTick(activeOpts);
149
+ expect(_getConsecutiveIdleTicks()).toBe(0);
150
+ });
151
+
152
+ // DT-5: checkAndDream error does NOT crash the tick
153
+ it('DT-5: checkAndDream error does not crash safeRunTick', async () => {
154
+ const dreamFake = vi.fn().mockRejectedValue(new Error('brain.db unavailable'));
155
+ const opts = mkTickOpts(root, {
156
+ pickTask: async () => null,
157
+ checkAndDream: dreamFake,
158
+ });
159
+
160
+ // Must not throw
161
+ const outcome = await safeRunTick(opts);
162
+ expect(outcome.kind).toBe('no-task');
163
+ expect(dreamFake).toHaveBeenCalledOnce();
164
+ });
165
+
166
+ // DT-6: Two rapid safeRunTick calls both invoke checkAndDream (idempotency
167
+ // is inside checkAndDream's cooldown, not the tick's responsibility)
168
+ it('DT-6: safeRunTick calls checkAndDream on every invocation', async () => {
169
+ const dreamFake = makeDreamFake();
170
+ const opts = mkTickOpts(root, { checkAndDream: dreamFake });
171
+
172
+ await safeRunTick(opts);
173
+ await safeRunTick(opts);
174
+ expect(dreamFake).toHaveBeenCalledTimes(2);
175
+ });
176
+
177
+ // DT-7: startDreamScheduler is NOT exported from dream-cycle.ts
178
+ it('DT-7: startDreamScheduler is not exported from dream-cycle.ts', async () => {
179
+ const dreamCycle = await import('@cleocode/core/internal');
180
+ // The key must not exist (or not be a function)
181
+ expect(
182
+ 'startDreamScheduler' in dreamCycle
183
+ ? typeof (dreamCycle as Record<string, unknown>)['startDreamScheduler']
184
+ : 'not-present',
185
+ ).toBe('not-present');
186
+ });
187
+
188
+ // DT-8: runTick itself does NOT call checkAndDream — only safeRunTick does
189
+ it('DT-8: runTick alone does NOT invoke checkAndDream', async () => {
190
+ const dreamFake = makeDreamFake();
191
+ const opts = mkTickOpts(root, {
192
+ pickTask: async () => null,
193
+ checkAndDream: dreamFake,
194
+ });
195
+
196
+ // Call the inner runTick directly, not the safe wrapper
197
+ await runTick(opts);
198
+ expect(dreamFake).not.toHaveBeenCalled();
199
+ });
200
+ });