@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.
- package/dist/gc/daemon-entry.d.ts +15 -0
- package/dist/gc/daemon-entry.d.ts.map +1 -0
- package/dist/gc/daemon.d.ts +71 -0
- package/dist/gc/daemon.d.ts.map +1 -0
- package/dist/gc/index.d.ts +14 -0
- package/dist/gc/index.d.ts.map +1 -0
- package/dist/gc/runner.d.ts +132 -0
- package/dist/gc/runner.d.ts.map +1 -0
- package/dist/gc/state.d.ts +94 -0
- package/dist/gc/state.d.ts.map +1 -0
- package/dist/gc/transcript.d.ts +130 -0
- package/dist/gc/transcript.d.ts.map +1 -0
- package/dist/sentient/daemon-entry.d.ts +11 -0
- package/dist/sentient/daemon-entry.d.ts.map +1 -0
- package/dist/sentient/daemon.d.ts +160 -0
- package/dist/sentient/daemon.d.ts.map +1 -0
- package/dist/sentient/index.d.ts +18 -0
- package/dist/sentient/index.d.ts.map +1 -0
- package/dist/sentient/ingesters/brain-ingester.d.ts +44 -0
- package/dist/sentient/ingesters/brain-ingester.d.ts.map +1 -0
- package/dist/sentient/ingesters/nexus-ingester.d.ts +45 -0
- package/dist/sentient/ingesters/nexus-ingester.d.ts.map +1 -0
- package/dist/sentient/ingesters/test-ingester.d.ts +43 -0
- package/dist/sentient/ingesters/test-ingester.d.ts.map +1 -0
- package/dist/sentient/proposal-rate-limiter.d.ts +93 -0
- package/dist/sentient/proposal-rate-limiter.d.ts.map +1 -0
- package/dist/sentient/propose-tick.d.ts +105 -0
- package/dist/sentient/propose-tick.d.ts.map +1 -0
- package/dist/sentient/state.d.ts +143 -0
- package/dist/sentient/state.d.ts.map +1 -0
- package/dist/sentient/tick.d.ts +193 -0
- package/dist/sentient/tick.d.ts.map +1 -0
- package/package.json +76 -8
- package/src/gc/__tests__/runner.test.ts +367 -0
- package/src/gc/__tests__/state.test.ts +169 -0
- package/src/gc/__tests__/transcript.test.ts +371 -0
- package/src/gc/daemon-entry.ts +26 -0
- package/src/gc/daemon.ts +251 -0
- package/src/gc/index.ts +14 -0
- package/src/gc/runner.ts +378 -0
- package/src/gc/state.ts +140 -0
- package/src/gc/transcript.ts +380 -0
- package/src/sentient/__tests__/brain-ingester.test.ts +154 -0
- package/src/sentient/__tests__/daemon.test.ts +472 -0
- package/src/sentient/__tests__/dream-tick.test.ts +200 -0
- package/src/sentient/__tests__/nexus-ingester.test.ts +138 -0
- package/src/sentient/__tests__/proposal-rate-limiter.test.ts +247 -0
- package/src/sentient/__tests__/propose-tick.test.ts +296 -0
- package/src/sentient/__tests__/test-ingester.test.ts +104 -0
- package/src/sentient/daemon-entry.ts +20 -0
- package/src/sentient/daemon.ts +471 -0
- package/src/sentient/index.ts +18 -0
- package/src/sentient/ingesters/brain-ingester.ts +122 -0
- package/src/sentient/ingesters/nexus-ingester.ts +171 -0
- package/src/sentient/ingesters/test-ingester.ts +205 -0
- package/src/sentient/proposal-rate-limiter.ts +172 -0
- package/src/sentient/propose-tick.ts +415 -0
- package/src/sentient/state.ts +229 -0
- 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
|
+
});
|