@cleocode/core 2026.4.98 → 2026.4.100

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 (85) 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/daemon.js +481 -0
  6. package/dist/gc/daemon.js.map +7 -0
  7. package/dist/gc/index.d.ts +14 -0
  8. package/dist/gc/index.d.ts.map +1 -0
  9. package/dist/gc/index.js +669 -0
  10. package/dist/gc/index.js.map +7 -0
  11. package/dist/gc/runner.d.ts +132 -0
  12. package/dist/gc/runner.d.ts.map +1 -0
  13. package/dist/gc/runner.js +360 -0
  14. package/dist/gc/runner.js.map +7 -0
  15. package/dist/gc/state.d.ts +94 -0
  16. package/dist/gc/state.d.ts.map +1 -0
  17. package/dist/gc/state.js +49 -0
  18. package/dist/gc/state.js.map +7 -0
  19. package/dist/gc/transcript.d.ts +130 -0
  20. package/dist/gc/transcript.d.ts.map +1 -0
  21. package/dist/gc/transcript.js +209 -0
  22. package/dist/gc/transcript.js.map +7 -0
  23. package/dist/memory/brain-backfill.js +14643 -0
  24. package/dist/memory/brain-backfill.js.map +7 -0
  25. package/dist/memory/precompact-flush.js +47725 -0
  26. package/dist/memory/precompact-flush.js.map +7 -0
  27. package/dist/sentient/daemon-entry.d.ts +11 -0
  28. package/dist/sentient/daemon-entry.d.ts.map +1 -0
  29. package/dist/sentient/daemon.d.ts +160 -0
  30. package/dist/sentient/daemon.d.ts.map +1 -0
  31. package/dist/sentient/daemon.js +1100 -0
  32. package/dist/sentient/daemon.js.map +7 -0
  33. package/dist/sentient/index.d.ts +18 -0
  34. package/dist/sentient/index.d.ts.map +1 -0
  35. package/dist/sentient/index.js +1162 -0
  36. package/dist/sentient/index.js.map +7 -0
  37. package/dist/sentient/ingesters/brain-ingester.d.ts +44 -0
  38. package/dist/sentient/ingesters/brain-ingester.d.ts.map +1 -0
  39. package/dist/sentient/ingesters/nexus-ingester.d.ts +45 -0
  40. package/dist/sentient/ingesters/nexus-ingester.d.ts.map +1 -0
  41. package/dist/sentient/ingesters/test-ingester.d.ts +43 -0
  42. package/dist/sentient/ingesters/test-ingester.d.ts.map +1 -0
  43. package/dist/sentient/proposal-rate-limiter.d.ts +93 -0
  44. package/dist/sentient/proposal-rate-limiter.d.ts.map +1 -0
  45. package/dist/sentient/propose-tick.d.ts +105 -0
  46. package/dist/sentient/propose-tick.d.ts.map +1 -0
  47. package/dist/sentient/propose-tick.js +549 -0
  48. package/dist/sentient/propose-tick.js.map +7 -0
  49. package/dist/sentient/state.d.ts +143 -0
  50. package/dist/sentient/state.d.ts.map +1 -0
  51. package/dist/sentient/state.js +85 -0
  52. package/dist/sentient/state.js.map +7 -0
  53. package/dist/sentient/tick.d.ts +193 -0
  54. package/dist/sentient/tick.d.ts.map +1 -0
  55. package/dist/sentient/tick.js +396 -0
  56. package/dist/sentient/tick.js.map +7 -0
  57. package/dist/system/platform-paths.js +36 -0
  58. package/dist/system/platform-paths.js.map +7 -0
  59. package/package.json +76 -8
  60. package/src/gc/__tests__/runner.test.ts +367 -0
  61. package/src/gc/__tests__/state.test.ts +169 -0
  62. package/src/gc/__tests__/transcript.test.ts +371 -0
  63. package/src/gc/daemon-entry.ts +26 -0
  64. package/src/gc/daemon.ts +251 -0
  65. package/src/gc/index.ts +14 -0
  66. package/src/gc/runner.ts +378 -0
  67. package/src/gc/state.ts +140 -0
  68. package/src/gc/transcript.ts +380 -0
  69. package/src/sentient/__tests__/brain-ingester.test.ts +154 -0
  70. package/src/sentient/__tests__/daemon.test.ts +472 -0
  71. package/src/sentient/__tests__/dream-tick.test.ts +200 -0
  72. package/src/sentient/__tests__/nexus-ingester.test.ts +138 -0
  73. package/src/sentient/__tests__/proposal-rate-limiter.test.ts +247 -0
  74. package/src/sentient/__tests__/propose-tick.test.ts +296 -0
  75. package/src/sentient/__tests__/test-ingester.test.ts +104 -0
  76. package/src/sentient/daemon-entry.ts +20 -0
  77. package/src/sentient/daemon.ts +471 -0
  78. package/src/sentient/index.ts +18 -0
  79. package/src/sentient/ingesters/brain-ingester.ts +122 -0
  80. package/src/sentient/ingesters/nexus-ingester.ts +171 -0
  81. package/src/sentient/ingesters/test-ingester.ts +205 -0
  82. package/src/sentient/proposal-rate-limiter.ts +172 -0
  83. package/src/sentient/propose-tick.ts +415 -0
  84. package/src/sentient/state.ts +229 -0
  85. package/src/sentient/tick.ts +688 -0
@@ -0,0 +1,371 @@
1
+ /**
2
+ * Transcript Lifecycle Tests (T735)
3
+ *
4
+ * Covers memory-architecture-spec.md §12.1 acceptance criteria:
5
+ * - TL-F5: prune --dry-run makes zero filesystem mutations
6
+ * - TL-F6: Budget cap triggers early prune when total size > threshold
7
+ * - TL-F7: API key absent falls back to 30d-only deletion
8
+ *
9
+ * Additional unit tests:
10
+ * - classifyTranscriptTier: correct hot/warm/cold at all boundary values
11
+ * - scanTranscripts: correct hot/warm counts for a known directory layout
12
+ * - pruneTranscripts: correct deletion with confirm=true
13
+ * - parseDurationMs: correct parsing of all supported formats
14
+ *
15
+ * Uses real temp directories (mkdtemp). No mocked filesystem.
16
+ *
17
+ * @task T735
18
+ * @epic T726
19
+ */
20
+
21
+ import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises';
22
+ import { tmpdir } from 'node:os';
23
+ import { join } from 'node:path';
24
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
25
+
26
+ import {
27
+ classifyTranscriptTier,
28
+ parseDurationMs,
29
+ pruneTranscripts,
30
+ scanTranscripts,
31
+ } from '../transcript.js';
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Helpers
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /**
38
+ * Create a fake session JSONL under `projectsDir/<slug>/`.
39
+ * Sets the mtime to `now - ageMs` so tier classification works correctly.
40
+ */
41
+ async function createSession(
42
+ projectsDir: string,
43
+ slug: string,
44
+ sessionId: string,
45
+ ageMs: number,
46
+ ): Promise<string> {
47
+ const slugDir = join(projectsDir, slug);
48
+ await mkdir(slugDir, { recursive: true });
49
+
50
+ const jsonlPath = join(slugDir, `${sessionId}.jsonl`);
51
+ await writeFile(jsonlPath, `{"type":"user","text":"session ${sessionId}"}\n`, 'utf-8');
52
+
53
+ // Back-date the mtime to simulate age
54
+ const pastDate = new Date(Date.now() - ageMs);
55
+ await utimes(jsonlPath, pastDate, pastDate);
56
+
57
+ return jsonlPath;
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // classifyTranscriptTier
62
+ // ---------------------------------------------------------------------------
63
+
64
+ describe('classifyTranscriptTier', () => {
65
+ it('returns hot for age < 24h', () => {
66
+ expect(classifyTranscriptTier(0)).toBe('hot');
67
+ expect(classifyTranscriptTier(1 * 60 * 60 * 1000)).toBe('hot'); // 1h
68
+ expect(classifyTranscriptTier(23 * 60 * 60 * 1000)).toBe('hot'); // 23h
69
+ });
70
+
71
+ it('returns warm for age 24h–7d', () => {
72
+ const ONE_DAY = 24 * 60 * 60 * 1000;
73
+ const SEVEN_DAYS = 7 * ONE_DAY;
74
+ expect(classifyTranscriptTier(ONE_DAY)).toBe('warm');
75
+ expect(classifyTranscriptTier(ONE_DAY + 1000)).toBe('warm'); // just past 24h
76
+ expect(classifyTranscriptTier(3 * ONE_DAY)).toBe('warm'); // 3 days
77
+ expect(classifyTranscriptTier(SEVEN_DAYS - 1000)).toBe('warm'); // just under 7d
78
+ });
79
+
80
+ it('returns cold for age >= 7d', () => {
81
+ const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000;
82
+ expect(classifyTranscriptTier(SEVEN_DAYS)).toBe('cold');
83
+ expect(classifyTranscriptTier(SEVEN_DAYS + 1000)).toBe('cold');
84
+ expect(classifyTranscriptTier(30 * 24 * 60 * 60 * 1000)).toBe('cold'); // 30d
85
+ });
86
+ });
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // parseDurationMs
90
+ // ---------------------------------------------------------------------------
91
+
92
+ describe('parseDurationMs', () => {
93
+ it('parses days correctly', () => {
94
+ expect(parseDurationMs('7d')).toBe(7 * 24 * 60 * 60 * 1000);
95
+ expect(parseDurationMs('1d')).toBe(24 * 60 * 60 * 1000);
96
+ expect(parseDurationMs('30d')).toBe(30 * 24 * 60 * 60 * 1000);
97
+ });
98
+
99
+ it('parses hours correctly', () => {
100
+ expect(parseDurationMs('24h')).toBe(24 * 60 * 60 * 1000);
101
+ expect(parseDurationMs('1h')).toBe(60 * 60 * 1000);
102
+ expect(parseDurationMs('168h')).toBe(168 * 60 * 60 * 1000);
103
+ });
104
+
105
+ it('parses minutes correctly', () => {
106
+ expect(parseDurationMs('30m')).toBe(30 * 60 * 1000);
107
+ expect(parseDurationMs('1m')).toBe(60 * 1000);
108
+ });
109
+
110
+ it('parses seconds correctly', () => {
111
+ expect(parseDurationMs('60s')).toBe(60 * 1000);
112
+ });
113
+
114
+ it('throws on invalid format', () => {
115
+ expect(() => parseDurationMs('invalid')).toThrow();
116
+ expect(() => parseDurationMs('7')).toThrow();
117
+ expect(() => parseDurationMs('')).toThrow();
118
+ expect(() => parseDurationMs('7w')).toThrow(); // weeks not supported
119
+ });
120
+ });
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // scanTranscripts — correct hot/warm counts
124
+ // ---------------------------------------------------------------------------
125
+
126
+ describe('scanTranscripts', () => {
127
+ let tmpDir: string;
128
+ let projectsDir: string;
129
+
130
+ beforeEach(async () => {
131
+ tmpDir = await mkdtemp(join(tmpdir(), 'cleo-transcript-scan-'));
132
+ projectsDir = join(tmpDir, 'projects');
133
+ await mkdir(projectsDir, { recursive: true });
134
+ });
135
+
136
+ afterEach(async () => {
137
+ await rm(tmpDir, { recursive: true, force: true });
138
+ });
139
+
140
+ it('returns empty result when projects dir does not exist', async () => {
141
+ const result = await scanTranscripts(join(tmpDir, 'nonexistent'));
142
+ expect(result.totalSessions).toBe(0);
143
+ expect(result.hot).toHaveLength(0);
144
+ expect(result.warm).toHaveLength(0);
145
+ });
146
+
147
+ it('correctly identifies hot sessions (< 24h)', async () => {
148
+ // 1 hour old → hot
149
+ await createSession(projectsDir, 'proj', 'hot-session', 1 * 60 * 60 * 1000);
150
+
151
+ const result = await scanTranscripts(projectsDir);
152
+
153
+ expect(result.hot).toHaveLength(1);
154
+ expect(result.warm).toHaveLength(0);
155
+ expect(result.hot[0]?.sessionId).toBe('hot-session');
156
+ expect(result.hot[0]?.tier).toBe('hot');
157
+ });
158
+
159
+ it('correctly identifies warm sessions (1–7d)', async () => {
160
+ // 3 days old → warm
161
+ await createSession(projectsDir, 'proj', 'warm-session', 3 * 24 * 60 * 60 * 1000);
162
+
163
+ const result = await scanTranscripts(projectsDir);
164
+
165
+ expect(result.warm).toHaveLength(1);
166
+ expect(result.hot).toHaveLength(0);
167
+ expect(result.warm[0]?.sessionId).toBe('warm-session');
168
+ expect(result.warm[0]?.tier).toBe('warm');
169
+ });
170
+
171
+ it('cold sessions (>7d, already deleted) do not appear in scan', async () => {
172
+ // Create a file but COLD sessions should have already been deleted from disk
173
+ // We verify cold sessions don't appear in warm/hot lists
174
+ const result = await scanTranscripts(projectsDir);
175
+ // No sessions created yet → all empty
176
+ expect(result.totalSessions).toBe(0);
177
+ });
178
+
179
+ it('counts sessions across multiple projects', async () => {
180
+ await createSession(projectsDir, 'proj-a', 'session-1', 1 * 60 * 60 * 1000); // hot
181
+ await createSession(projectsDir, 'proj-a', 'session-2', 2 * 24 * 60 * 60 * 1000); // warm
182
+ await createSession(projectsDir, 'proj-b', 'session-3', 5 * 24 * 60 * 60 * 1000); // warm
183
+
184
+ const result = await scanTranscripts(projectsDir);
185
+
186
+ expect(result.totalSessions).toBe(3);
187
+ expect(result.hot).toHaveLength(1);
188
+ expect(result.warm).toHaveLength(2);
189
+ });
190
+
191
+ it('totalBytes is the sum of all session file sizes', async () => {
192
+ await createSession(projectsDir, 'proj', 'size-session', 1 * 60 * 60 * 1000);
193
+
194
+ const result = await scanTranscripts(projectsDir);
195
+ expect(result.totalBytes).toBeGreaterThan(0);
196
+ });
197
+ });
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // TL-F5: prune --dry-run makes zero filesystem mutations
201
+ // ---------------------------------------------------------------------------
202
+
203
+ describe('pruneTranscripts dry-run (TL-F5)', () => {
204
+ let tmpDir: string;
205
+ let projectsDir: string;
206
+
207
+ beforeEach(async () => {
208
+ tmpDir = await mkdtemp(join(tmpdir(), 'cleo-transcript-prune-dr-'));
209
+ projectsDir = join(tmpDir, 'projects');
210
+ await mkdir(projectsDir, { recursive: true });
211
+ });
212
+
213
+ afterEach(async () => {
214
+ await rm(tmpDir, { recursive: true, force: true });
215
+ });
216
+
217
+ it('dry-run (confirm=false) makes zero filesystem mutations', async () => {
218
+ // Use 35d to be beyond the 30d API-key-absent circuit breaker minimum
219
+ const OLD_SESSION_AGE = 35 * 24 * 60 * 60 * 1000; // 35 days
220
+ const jsonlPath = await createSession(projectsDir, 'proj', 'old-session', OLD_SESSION_AGE);
221
+
222
+ const result = await pruneTranscripts({
223
+ olderThanMs: 7 * 24 * 60 * 60 * 1000, // 7d threshold
224
+ confirm: false, // dry-run
225
+ projectsDir,
226
+ });
227
+
228
+ // File must still exist after dry-run
229
+ const { access } = await import('node:fs/promises');
230
+ await expect(access(jsonlPath)).resolves.toBeUndefined();
231
+
232
+ expect(result.dryRun).toBe(true);
233
+ expect(result.pruned).toBeGreaterThan(0);
234
+ expect(result.deletedPaths).toContain(jsonlPath);
235
+ });
236
+
237
+ it('dry-run reports correct byte count', async () => {
238
+ const content = 'x'.repeat(1024); // 1 KB
239
+ const slugDir = join(projectsDir, 'proj');
240
+ await mkdir(slugDir, { recursive: true });
241
+ const jsonlPath = join(slugDir, 'big-session.jsonl');
242
+ await writeFile(jsonlPath, content, 'utf-8');
243
+ // Use 35d to exceed the 30d API-key-absent circuit breaker minimum
244
+ const past = new Date(Date.now() - 35 * 24 * 60 * 60 * 1000);
245
+ await utimes(jsonlPath, past, past);
246
+
247
+ const result = await pruneTranscripts({
248
+ olderThanMs: 7 * 24 * 60 * 60 * 1000,
249
+ confirm: false,
250
+ projectsDir,
251
+ });
252
+
253
+ expect(result.bytesFreed).toBeGreaterThanOrEqual(1024);
254
+ });
255
+ });
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // pruneTranscripts with confirm=true (actual deletion)
259
+ // ---------------------------------------------------------------------------
260
+
261
+ describe('pruneTranscripts confirm=true', () => {
262
+ let tmpDir: string;
263
+ let projectsDir: string;
264
+
265
+ beforeEach(async () => {
266
+ tmpDir = await mkdtemp(join(tmpdir(), 'cleo-transcript-prune-'));
267
+ projectsDir = join(tmpDir, 'projects');
268
+ await mkdir(projectsDir, { recursive: true });
269
+ });
270
+
271
+ afterEach(async () => {
272
+ await rm(tmpDir, { recursive: true, force: true });
273
+ });
274
+
275
+ it('deletes sessions older than the threshold', async () => {
276
+ // Use 35d to be beyond the 30d API-key-absent circuit breaker minimum
277
+ const OLD_AGE = 35 * 24 * 60 * 60 * 1000; // 35 days
278
+ const jsonlPath = await createSession(projectsDir, 'proj', 'old-del', OLD_AGE);
279
+
280
+ const result = await pruneTranscripts({
281
+ olderThanMs: 7 * 24 * 60 * 60 * 1000,
282
+ confirm: true,
283
+ projectsDir,
284
+ });
285
+
286
+ // File must be gone
287
+ const { access } = await import('node:fs/promises');
288
+ await expect(access(jsonlPath)).rejects.toThrow();
289
+
290
+ expect(result.dryRun).toBe(false);
291
+ expect(result.pruned).toBeGreaterThan(0);
292
+ });
293
+
294
+ it('does NOT delete sessions younger than the threshold', async () => {
295
+ const RECENT_AGE = 1 * 24 * 60 * 60 * 1000; // 1 day
296
+ const jsonlPath = await createSession(projectsDir, 'proj', 'recent-keep', RECENT_AGE);
297
+
298
+ await pruneTranscripts({
299
+ olderThanMs: 7 * 24 * 60 * 60 * 1000,
300
+ confirm: true,
301
+ projectsDir,
302
+ });
303
+
304
+ // Recent file must still exist
305
+ const { access } = await import('node:fs/promises');
306
+ await expect(access(jsonlPath)).resolves.toBeUndefined();
307
+ });
308
+ });
309
+
310
+ // ---------------------------------------------------------------------------
311
+ // TL-F7: ANTHROPIC_API_KEY absent → 30d-only deletion (circuit breaker)
312
+ // ---------------------------------------------------------------------------
313
+
314
+ describe('pruneTranscripts API key circuit breaker (TL-F7)', () => {
315
+ let tmpDir: string;
316
+ let projectsDir: string;
317
+ let savedApiKey: string | undefined;
318
+
319
+ beforeEach(async () => {
320
+ tmpDir = await mkdtemp(join(tmpdir(), 'cleo-transcript-apikey-'));
321
+ projectsDir = join(tmpDir, 'projects');
322
+ await mkdir(projectsDir, { recursive: true });
323
+ // Save and clear API key
324
+ savedApiKey = process.env['ANTHROPIC_API_KEY'];
325
+ delete process.env['ANTHROPIC_API_KEY'];
326
+ });
327
+
328
+ afterEach(async () => {
329
+ await rm(tmpDir, { recursive: true, force: true });
330
+ // Restore API key
331
+ if (savedApiKey !== undefined) {
332
+ process.env['ANTHROPIC_API_KEY'] = savedApiKey;
333
+ }
334
+ });
335
+
336
+ it('skips deletion of sessions < 30d when API key is absent', async () => {
337
+ // 10-day-old session; caller requests 7d threshold
338
+ const TEN_DAYS = 10 * 24 * 60 * 60 * 1000;
339
+ const jsonlPath = await createSession(projectsDir, 'proj', 'apikey-test', TEN_DAYS);
340
+
341
+ const result = await pruneTranscripts({
342
+ olderThanMs: 7 * 24 * 60 * 60 * 1000, // 7d requested
343
+ confirm: true,
344
+ projectsDir,
345
+ });
346
+
347
+ // Circuit breaker: no API key → 30d minimum. 10-day session is NOT deleted.
348
+ const { access } = await import('node:fs/promises');
349
+ await expect(access(jsonlPath)).resolves.toBeUndefined();
350
+
351
+ expect(result.pruned).toBe(0);
352
+ });
353
+
354
+ it('allows deletion of sessions > 30d even without API key', async () => {
355
+ // 35-day-old session — beyond the 30d circuit-breaker minimum
356
+ const THIRTY_FIVE_DAYS = 35 * 24 * 60 * 60 * 1000;
357
+ const jsonlPath = await createSession(projectsDir, 'proj', 'old-apikey', THIRTY_FIVE_DAYS);
358
+
359
+ const result = await pruneTranscripts({
360
+ olderThanMs: 7 * 24 * 60 * 60 * 1000,
361
+ confirm: true,
362
+ projectsDir,
363
+ });
364
+
365
+ // 35-day session IS deleted (beyond 30d fallback minimum)
366
+ const { access } = await import('node:fs/promises');
367
+ await expect(access(jsonlPath)).rejects.toThrow();
368
+
369
+ expect(result.pruned).toBeGreaterThan(0);
370
+ });
371
+ });
@@ -0,0 +1,26 @@
1
+ /**
2
+ * GC Daemon Entry Point — Standalone script executed by `spawnGCDaemon()`.
3
+ *
4
+ * This script is spawned as a detached background process by `cleo daemon start`.
5
+ * It must NOT import from the main CLI shim (no citty, no commander). It only
6
+ * imports from the gc/ module subtree.
7
+ *
8
+ * The cleoDir is passed as argv[2] by `spawnGCDaemon()`.
9
+ *
10
+ * @see gc/daemon.ts for the spawn logic
11
+ * @task T731
12
+ * @epic T726
13
+ */
14
+
15
+ import { homedir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { bootstrapDaemon } from './daemon.js';
18
+
19
+ const cleoDir = process.argv[2] ?? join(homedir(), '.cleo');
20
+
21
+ bootstrapDaemon(cleoDir).catch((err: unknown) => {
22
+ const message = err instanceof Error ? err.message : String(err);
23
+ // stderr is redirected to gc.err by the parent spawn call
24
+ process.stderr.write(`[CLEO GC] Fatal daemon error: ${message}\n`);
25
+ process.exit(1);
26
+ });
@@ -0,0 +1,251 @@
1
+ /**
2
+ * GC Daemon — Sidecar background process for autonomous transcript cleanup.
3
+ *
4
+ * Architecture (Pattern B from T751 §2.2):
5
+ * - Spawned via `cleo daemon start` as a detached Node.js process
6
+ * - All three required flags: `detached: true`, file stdio, `child.unref()`
7
+ * - Persists across CLI invocations
8
+ * - Crash recovery via `.cleo/gc-state.json` startup-check
9
+ * - node-cron v4 for scheduling (zero runtime deps, cross-platform)
10
+ *
11
+ * Startup algorithm (systemd `Persistent=true` semantics in pure Node.js):
12
+ * 1. Read gc-state.json
13
+ * 2. If pendingPrune non-empty → resume deletion (crash recovery)
14
+ * 3. If lastRunAt null OR elapsed > 24h → run GC immediately (missed-run recovery)
15
+ * 4. Schedule future runs via node-cron (daily at 03:00 UTC)
16
+ * 5. Write daemonPid to state
17
+ *
18
+ * @see ADR-047 — Autonomous GC and Disk Safety
19
+ * @see T751 §2.2 for sidecar daemon pattern rationale
20
+ * @task T731
21
+ * @epic T726
22
+ */
23
+
24
+ import { spawn } from 'node:child_process';
25
+ import { createWriteStream } from 'node:fs';
26
+ import { mkdir } from 'node:fs/promises';
27
+ import { join } from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+ import cron from 'node-cron';
30
+ import { runGC } from './runner.js';
31
+ import { patchGCState, readGCState } from './state.js';
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Constants
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /** Cron expression: daily at 03:00 UTC. */
38
+ const GC_CRON_EXPR = '0 3 * * *';
39
+
40
+ /** Interval for missed-run recovery check (24 hours in ms). */
41
+ const GC_INTERVAL_MS = 24 * 60 * 60 * 1000;
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Daemon Bootstrap (runs when this module is executed as a standalone script)
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Bootstrap the GC daemon process.
49
+ *
50
+ * Performs crash recovery, missed-run recovery, and schedules future GC runs.
51
+ * This function runs in the long-lived daemon process.
52
+ *
53
+ * @param cleoDir - Absolute path to the `.cleo/` directory
54
+ */
55
+ export async function bootstrapDaemon(cleoDir: string): Promise<void> {
56
+ const statePath = join(cleoDir, 'gc-state.json');
57
+
58
+ // Register daemon PID in state file
59
+ await patchGCState(statePath, {
60
+ daemonPid: process.pid,
61
+ daemonStartedAt: new Date().toISOString(),
62
+ });
63
+
64
+ const state = await readGCState(statePath);
65
+
66
+ // Step 1: Crash recovery — resume pending prune from prior run
67
+ if (state.pendingPrune && state.pendingPrune.length > 0) {
68
+ try {
69
+ await runGC({ cleoDir, resumeFrom: state.pendingPrune });
70
+ } catch {
71
+ // Crash recovery failure is non-fatal; continue with scheduled runs
72
+ }
73
+ }
74
+
75
+ // Step 2: Missed-run recovery — if last run was > 24h ago, run immediately
76
+ const lastRunTs = state.lastRunAt ? new Date(state.lastRunAt).getTime() : 0;
77
+ const elapsed = Date.now() - lastRunTs;
78
+ if (elapsed > GC_INTERVAL_MS) {
79
+ try {
80
+ await runGC({ cleoDir });
81
+ } catch {
82
+ // Immediate GC failure is non-fatal; cron will retry next cycle
83
+ }
84
+ }
85
+
86
+ // Step 3: Schedule future runs via node-cron
87
+ // noOverlap: true prevents double-runs if a previous run exceeds 24h
88
+ cron.schedule(
89
+ GC_CRON_EXPR,
90
+ async () => {
91
+ try {
92
+ await runGC({ cleoDir });
93
+ } catch {
94
+ // Log failures via stderr (already redirected to gc.log by spawn)
95
+ const state2 = await readGCState(statePath);
96
+ await patchGCState(statePath, {
97
+ consecutiveFailures: state2.consecutiveFailures + 1,
98
+ lastRunResult: 'failed',
99
+ escalationNeeded: state2.consecutiveFailures + 1 >= 3,
100
+ escalationReason:
101
+ state2.consecutiveFailures + 1 >= 3
102
+ ? `GC daemon: ${state2.consecutiveFailures + 1} consecutive failures. Check logs.`
103
+ : state2.escalationReason,
104
+ });
105
+ }
106
+ },
107
+ {
108
+ timezone: 'UTC',
109
+ noOverlap: true,
110
+ name: 'cleo-gc',
111
+ },
112
+ );
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Spawn Helpers (called by `cleo daemon start` in the parent CLI process)
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /**
120
+ * Spawn the GC daemon as a detached background process.
121
+ *
122
+ * All three requirements from T751 §2.2 are met:
123
+ * 1. `detached: true` — process group leader (survives parent exit)
124
+ * 2. File stdio — stdout/stderr redirected to gc.log (not inherited)
125
+ * 3. `child.unref()` — parent CLI exits immediately
126
+ *
127
+ * @param cleoDir - Absolute path to the `.cleo/` directory
128
+ * @returns PID of the spawned daemon process
129
+ */
130
+ export async function spawnGCDaemon(cleoDir: string): Promise<number> {
131
+ const logsDir = join(cleoDir, 'logs');
132
+ await mkdir(logsDir, { recursive: true });
133
+
134
+ const logPath = join(logsDir, 'gc.log');
135
+ const errPath = join(logsDir, 'gc.err');
136
+
137
+ // File-based stdio: required for detached process to not inherit the TTY
138
+ const outStream = createWriteStream(logPath, { flags: 'a' });
139
+ const errStream = createWriteStream(errPath, { flags: 'a' });
140
+
141
+ // The daemon entry-point script (compiled alongside this module)
142
+ const daemonEntry = join(fileURLToPath(import.meta.url), '..', 'daemon-entry.js');
143
+
144
+ const child = spawn(process.execPath, [daemonEntry, cleoDir], {
145
+ detached: true,
146
+ stdio: ['ignore', outStream, errStream],
147
+ env: { ...process.env, CLEO_GC_DAEMON: '1' },
148
+ });
149
+
150
+ // unref() allows the parent CLI process to exit while the daemon continues
151
+ child.unref();
152
+
153
+ const pid = child.pid ?? 0;
154
+
155
+ // Persist PID so `cleo daemon stop` can find and signal the process
156
+ await patchGCState(join(cleoDir, 'gc-state.json'), {
157
+ daemonPid: pid,
158
+ daemonStartedAt: new Date().toISOString(),
159
+ });
160
+
161
+ return pid;
162
+ }
163
+
164
+ /**
165
+ * Stop the GC daemon by sending SIGTERM to its PID.
166
+ *
167
+ * Uses `process.kill(pid, 0)` as a no-throw liveness probe before signalling.
168
+ *
169
+ * @param cleoDir - Absolute path to the `.cleo/` directory
170
+ * @returns `{ stopped: boolean; pid: number | null; reason: string }`
171
+ */
172
+ export async function stopGCDaemon(
173
+ cleoDir: string,
174
+ ): Promise<{ stopped: boolean; pid: number | null; reason: string }> {
175
+ const statePath = join(cleoDir, 'gc-state.json');
176
+ const state = await readGCState(statePath);
177
+ const pid = state.daemonPid;
178
+
179
+ if (!pid) {
180
+ return { stopped: false, pid: null, reason: 'Daemon PID not found in gc-state.json' };
181
+ }
182
+
183
+ // Liveness probe: process.kill(pid, 0) throws if PID is not running
184
+ try {
185
+ process.kill(pid, 0);
186
+ } catch {
187
+ // Process is not running — clear stale PID from state
188
+ await patchGCState(statePath, { daemonPid: null });
189
+ return {
190
+ stopped: false,
191
+ pid,
192
+ reason: `Daemon PID ${pid} is not running (stale state cleared)`,
193
+ };
194
+ }
195
+
196
+ // Send SIGTERM — daemon should clean up and exit gracefully
197
+ try {
198
+ process.kill(pid, 'SIGTERM');
199
+ // Clear PID from state after successful signal
200
+ await patchGCState(statePath, { daemonPid: null });
201
+ return { stopped: true, pid, reason: `SIGTERM sent to PID ${pid}` };
202
+ } catch (err) {
203
+ const msg = err instanceof Error ? err.message : String(err);
204
+ return { stopped: false, pid, reason: `Failed to send SIGTERM to PID ${pid}: ${msg}` };
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Check whether the GC daemon is currently running.
210
+ *
211
+ * @param cleoDir - Absolute path to the `.cleo/` directory
212
+ * @returns `{ running: boolean; pid: number | null; startedAt: string | null }`
213
+ */
214
+ export async function getGCDaemonStatus(cleoDir: string): Promise<{
215
+ running: boolean;
216
+ pid: number | null;
217
+ startedAt: string | null;
218
+ lastRunAt: string | null;
219
+ lastDiskUsedPct: number | null;
220
+ escalationNeeded: boolean;
221
+ }> {
222
+ const state = await readGCState(join(cleoDir, 'gc-state.json'));
223
+ const pid = state.daemonPid;
224
+
225
+ let running = false;
226
+ if (pid) {
227
+ try {
228
+ process.kill(pid, 0);
229
+ running = true;
230
+ } catch {
231
+ running = false;
232
+ }
233
+ }
234
+
235
+ return {
236
+ running,
237
+ pid: running ? pid : null,
238
+ startedAt: state.daemonStartedAt,
239
+ lastRunAt: state.lastRunAt,
240
+ lastDiskUsedPct: state.lastDiskUsedPct,
241
+ escalationNeeded: state.escalationNeeded,
242
+ };
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // Standalone daemon entry point
247
+ // ---------------------------------------------------------------------------
248
+
249
+ // When this module is executed directly (via `node daemon.js <cleoDir>`),
250
+ // bootstrap the daemon. The daemon-entry.js shim calls bootstrapDaemon().
251
+ // See src/gc/daemon-entry.ts for the entry script.
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @cleocode/core/gc — Autonomous GC daemon public API.
3
+ *
4
+ * Provides transcript cleanup, disk-pressure monitoring, GC state
5
+ * management, and daemon lifecycle (spawn/stop/status).
6
+ *
7
+ * @see ADR-047 — Autonomous GC and Disk Safety
8
+ * @package @cleocode/core
9
+ */
10
+
11
+ export * from './daemon.js';
12
+ export * from './runner.js';
13
+ export * from './state.js';
14
+ export * from './transcript.js';