@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,193 @@
1
+ /**
2
+ * Sentient Loop Tick — Single-iteration tick runner for the Tier-1 daemon.
3
+ *
4
+ * A tick is one complete pass of:
5
+ * 1. Check killSwitch (abort if true)
6
+ * 2. Pick an unblocked task via @cleocode/core/sdk
7
+ * 3. Check killSwitch again (abort if true)
8
+ * 4. Spawn worker via `cleo orchestrate spawn <taskId> --adapter <adapter>`
9
+ * 5. Check killSwitch again before recording result
10
+ * 6. Record success (receipt + stats) or failure (retry/backoff)
11
+ *
12
+ * Each step re-reads the state file so that a killSwitch flipped mid-tick is
13
+ * honoured on the very next instruction (Round 2 audit §1: "mid-experiment
14
+ * kill limbo").
15
+ *
16
+ * Rate limit: driven by the cron schedule (`*\/5 * * * *` → ≤12 ticks/hour ≤
17
+ * 12 spawns/hour). No in-tick sleep is required — cron provides the cadence.
18
+ *
19
+ * Scoped OUT: Tier 2 (propose) and Tier 3 (sandbox auto-merge) per ADR-054.
20
+ *
21
+ * @task T946
22
+ * @see ADR-054 — Sentient Loop Tier-1
23
+ */
24
+ import type { Task } from '@cleocode/contracts';
25
+ import { type SentientState } from './state.js';
26
+ /**
27
+ * Number of new brain observations since the last consolidation that causes
28
+ * the tick loop to trigger a dream cycle (volume tier).
29
+ * Configurable via the injected `dreamVolumeThreshold` option.
30
+ */
31
+ export declare const DREAM_VOLUME_THRESHOLD_DEFAULT = 50;
32
+ /**
33
+ * Number of consecutive no-task ticks before the idle dream trigger fires.
34
+ * Represents "N idle ticks" — when no task has been picked for this many
35
+ * consecutive ticks, the system is considered sufficiently idle.
36
+ */
37
+ export declare const DREAM_IDLE_TICKS_DEFAULT = 5;
38
+ /** Default adapter used when spawning workers. */
39
+ export declare const DEFAULT_ADAPTER: "claude-code";
40
+ /**
41
+ * Backoff delays between successive retries for the same task (milliseconds).
42
+ * Index n = delay before attempt n+1. After exhaustion the task is `stuck`.
43
+ * 30 s → 5 min → 30 min, then the task is marked stuck.
44
+ */
45
+ export declare const RETRY_BACKOFF_MS: readonly number[];
46
+ /**
47
+ * Maximum spawn attempts per task before it is classified as `stuck`.
48
+ * Matches RETRY_BACKOFF_MS.length but surfaced as a named constant for
49
+ * readability in tests and status output.
50
+ */
51
+ export declare const MAX_TASK_ATTEMPTS: number;
52
+ /**
53
+ * Threshold for self-pause: if this many tasks become `stuck` within a
54
+ * rolling 1-hour window, the daemon flips killSwitch=true and exits.
55
+ */
56
+ export declare const SELF_PAUSE_STUCK_THRESHOLD = 5;
57
+ /** Rolling window (ms) used for stuck-rate calculation. */
58
+ export declare const SELF_PAUSE_WINDOW_MS: number;
59
+ /** Reason stored on the state file when self-pause fires. */
60
+ export declare const SELF_PAUSE_REASON = "self-pause: 5 stuck tasks in 1 hour";
61
+ /** Max wall-clock time for a single spawn before forceful kill (30 min). */
62
+ export declare const SPAWN_TIMEOUT_MS: number;
63
+ /** Discriminant for the tick outcome. */
64
+ export type TickOutcomeKind = 'killed' | 'no-task' | 'backoff' | 'success' | 'failure' | 'stuck' | 'self-paused' | 'error';
65
+ /** Structured outcome of a single tick. */
66
+ export interface TickOutcome {
67
+ /** Discriminant describing how the tick ended. */
68
+ kind: TickOutcomeKind;
69
+ /** Task id that was the subject of this tick (if any). */
70
+ taskId: string | null;
71
+ /** Human-readable detail (one line). */
72
+ detail: string;
73
+ }
74
+ /** Options for {@link runTick}. */
75
+ export interface TickOptions {
76
+ /** Absolute path to the project root (contains `.cleo/`). */
77
+ projectRoot: string;
78
+ /** Absolute path to sentient-state.json. */
79
+ statePath: string;
80
+ /**
81
+ * Adapter to pass to `cleo orchestrate spawn --adapter`. Defaults to
82
+ * {@link DEFAULT_ADAPTER}. Overridden in tests via options.spawn.
83
+ */
84
+ adapter?: string;
85
+ /**
86
+ * Dry-run mode: skip the actual `cleo orchestrate spawn` subprocess and
87
+ * treat the pick as a no-op (still records picked stat). Used by
88
+ * `cleo sentient tick --dry-run`.
89
+ */
90
+ dryRun?: boolean;
91
+ /**
92
+ * Override for the spawn function — lets tests inject a deterministic fake
93
+ * without forking real subprocesses. Must resolve to an exit code.
94
+ *
95
+ * When omitted, the default implementation spawns
96
+ * `cleo orchestrate spawn <taskId> --adapter <adapter>` and resolves with
97
+ * the child's exit code.
98
+ */
99
+ spawn?: (taskId: string, adapter: string) => Promise<SpawnResult>;
100
+ /**
101
+ * Override for the "pick next unblocked task" source. Lets tests return
102
+ * a deterministic task without constructing a SQLite fixture.
103
+ */
104
+ pickTask?: (projectRoot: string) => Promise<Task | null>;
105
+ /**
106
+ * New observation count since last consolidation that triggers the volume
107
+ * dream cycle. Defaults to {@link DREAM_VOLUME_THRESHOLD_DEFAULT}.
108
+ * Pass 0 to disable volume trigger. Injected by tests.
109
+ */
110
+ dreamVolumeThreshold?: number;
111
+ /**
112
+ * Number of consecutive no-task ticks before the idle dream trigger fires.
113
+ * Defaults to {@link DREAM_IDLE_TICKS_DEFAULT}.
114
+ * Pass 0 to disable idle trigger. Injected by tests.
115
+ */
116
+ dreamIdleTicks?: number;
117
+ /**
118
+ * Override for the dream trigger function — lets tests assert dream calls
119
+ * without touching the real brain.db stack.
120
+ * Signature mirrors `checkAndDream` from `@cleocode/core`.
121
+ */
122
+ checkAndDream?: (projectRoot: string, opts?: {
123
+ volumeThreshold?: number;
124
+ inline?: boolean;
125
+ }) => Promise<{
126
+ triggered: boolean;
127
+ tier: string | null;
128
+ skippedReason?: string;
129
+ }>;
130
+ }
131
+ /** Result of a spawn invocation. */
132
+ export interface SpawnResult {
133
+ /** Process exit code (0 = success). */
134
+ exitCode: number;
135
+ /** Captured stdout, truncated by the caller if needed. */
136
+ stdout: string;
137
+ /** Captured stderr, truncated by the caller if needed. */
138
+ stderr: string;
139
+ }
140
+ /**
141
+ * Run a single tick of the sentient loop.
142
+ *
143
+ * Every checkpoint re-reads state so that a killSwitch flipped mid-tick is
144
+ * honoured on the next instruction.
145
+ *
146
+ * @param options - Tick options (see {@link TickOptions})
147
+ * @returns Structured outcome describing how the tick ended.
148
+ */
149
+ export declare function runTick(options: TickOptions): Promise<TickOutcome>;
150
+ /**
151
+ * Convenience wrapper used by the daemon cron handler and the `cleo sentient
152
+ * tick` CLI command. Reads state, runs a tick, swallows any unexpected
153
+ * exception so the cron scheduler never sees a rejection.
154
+ *
155
+ * After the tick completes, evaluates volume + idle dream triggers via
156
+ * {@link maybeTriggerDream}. Dream errors are swallowed independently so
157
+ * they never affect the tick outcome.
158
+ *
159
+ * @param options - Tick options
160
+ * @returns The tick outcome (or an `error` outcome if the tick itself threw).
161
+ */
162
+ export declare function safeRunTick(options: TickOptions): Promise<TickOutcome>;
163
+ /**
164
+ * Type-narrowing helper used by tests and status rendering to identify tick
165
+ * outcomes that consumed a retry attempt.
166
+ */
167
+ export declare function isFailureOutcome(outcome: TickOutcome): outcome is TickOutcome & {
168
+ kind: 'failure' | 'stuck' | 'self-paused';
169
+ };
170
+ /**
171
+ * Returns a shallow view of the current state's kill status.
172
+ * Exposed for diagnostic/test consumers.
173
+ */
174
+ export declare function getKillStatus(statePath: string): Promise<Pick<SentientState, 'killSwitch' | 'killSwitchReason'>>;
175
+ /**
176
+ * Reset dream-cycle in-process state.
177
+ *
178
+ * Intended for test teardown only — clears `consecutiveIdleTicks` so that
179
+ * successive test cases start from a clean slate.
180
+ *
181
+ * @internal
182
+ */
183
+ export declare function _resetDreamTickState(): void;
184
+ /**
185
+ * Return the current consecutive-idle-tick counter value.
186
+ *
187
+ * Read-only accessor for test assertions. The counter is reset to 0 whenever
188
+ * a task is picked, and incremented on each no-task tick.
189
+ *
190
+ * @internal
191
+ */
192
+ export declare function _getConsecutiveIdleTicks(): number;
193
+ //# sourceMappingURL=tick.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tick.d.ts","sourceRoot":"","sources":["../../src/sentient/tick.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cleocode/core",
3
- "version": "2026.4.98",
3
+ "version": "2026.4.99",
4
4
  "description": "CLEO core business logic kernel — tasks, sessions, memory, orchestration, lifecycle, with bundled SQLite store",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -111,6 +111,71 @@
111
111
  "import": "./dist/memory/brain-backfill.js",
112
112
  "require": "./dist/memory/brain-backfill.js"
113
113
  },
114
+ "./sentient": {
115
+ "types": "./dist/sentient/index.d.ts",
116
+ "import": "./dist/sentient/index.js",
117
+ "require": "./dist/sentient/index.js"
118
+ },
119
+ "./sentient/*": {
120
+ "types": "./dist/sentient/*.d.ts",
121
+ "import": "./dist/sentient/*.js",
122
+ "require": "./dist/sentient/*.js"
123
+ },
124
+ "./sentient/daemon.js": {
125
+ "types": "./dist/sentient/daemon.d.ts",
126
+ "import": "./dist/sentient/daemon.js",
127
+ "require": "./dist/sentient/daemon.js"
128
+ },
129
+ "./sentient/state.js": {
130
+ "types": "./dist/sentient/state.d.ts",
131
+ "import": "./dist/sentient/state.js",
132
+ "require": "./dist/sentient/state.js"
133
+ },
134
+ "./sentient/tick.js": {
135
+ "types": "./dist/sentient/tick.d.ts",
136
+ "import": "./dist/sentient/tick.js",
137
+ "require": "./dist/sentient/tick.js"
138
+ },
139
+ "./sentient/propose-tick.js": {
140
+ "types": "./dist/sentient/propose-tick.d.ts",
141
+ "import": "./dist/sentient/propose-tick.js",
142
+ "require": "./dist/sentient/propose-tick.js"
143
+ },
144
+ "./sentient/proposal-rate-limiter.js": {
145
+ "types": "./dist/sentient/proposal-rate-limiter.d.ts",
146
+ "import": "./dist/sentient/proposal-rate-limiter.js",
147
+ "require": "./dist/sentient/proposal-rate-limiter.js"
148
+ },
149
+ "./gc": {
150
+ "types": "./dist/gc/index.d.ts",
151
+ "import": "./dist/gc/index.js",
152
+ "require": "./dist/gc/index.js"
153
+ },
154
+ "./gc/*": {
155
+ "types": "./dist/gc/*.d.ts",
156
+ "import": "./dist/gc/*.js",
157
+ "require": "./dist/gc/*.js"
158
+ },
159
+ "./gc/daemon.js": {
160
+ "types": "./dist/gc/daemon.d.ts",
161
+ "import": "./dist/gc/daemon.js",
162
+ "require": "./dist/gc/daemon.js"
163
+ },
164
+ "./gc/runner.js": {
165
+ "types": "./dist/gc/runner.d.ts",
166
+ "import": "./dist/gc/runner.js",
167
+ "require": "./dist/gc/runner.js"
168
+ },
169
+ "./gc/state.js": {
170
+ "types": "./dist/gc/state.d.ts",
171
+ "import": "./dist/gc/state.js",
172
+ "require": "./dist/gc/state.js"
173
+ },
174
+ "./gc/transcript.js": {
175
+ "types": "./dist/gc/transcript.d.ts",
176
+ "import": "./dist/gc/transcript.js",
177
+ "require": "./dist/gc/transcript.js"
178
+ },
114
179
  "./system/platform-paths.js": {
115
180
  "types": "./dist/system/platform-paths.d.ts",
116
181
  "import": "./dist/system/platform-paths.js",
@@ -148,16 +213,18 @@
148
213
  "tree-sitter-ruby": "^0.23.1",
149
214
  "tree-sitter-rust": "0.23.1",
150
215
  "tree-sitter-typescript": "^0.23.2",
216
+ "check-disk-space": "^3.4.0",
217
+ "node-cron": "^4.2.1",
151
218
  "write-file-atomic": "^7.0.1",
152
219
  "yaml": "^2.8.3",
153
220
  "zod": "^4.3.6",
154
- "@cleocode/adapters": "2026.4.98",
155
- "@cleocode/agents": "2026.4.98",
156
- "@cleocode/caamp": "2026.4.98",
157
- "@cleocode/nexus": "2026.4.98",
158
- "@cleocode/lafs": "2026.4.98",
159
- "@cleocode/skills": "2026.4.98",
160
- "@cleocode/contracts": "2026.4.98"
221
+ "@cleocode/adapters": "2026.4.99",
222
+ "@cleocode/agents": "2026.4.99",
223
+ "@cleocode/lafs": "2026.4.99",
224
+ "@cleocode/caamp": "2026.4.99",
225
+ "@cleocode/contracts": "2026.4.99",
226
+ "@cleocode/nexus": "2026.4.99",
227
+ "@cleocode/skills": "2026.4.99"
161
228
  },
162
229
  "engines": {
163
230
  "node": ">=24.0.0"
@@ -174,6 +241,7 @@
174
241
  "src"
175
242
  ],
176
243
  "devDependencies": {
244
+ "@types/node-cron": "^3.0.11",
177
245
  "@types/proper-lockfile": "^4.1.4",
178
246
  "@types/tar": "^6.1.13",
179
247
  "@types/write-file-atomic": "^4.0.3",
@@ -0,0 +1,367 @@
1
+ /**
2
+ * GC Runner Tests (T735)
3
+ *
4
+ * Covers:
5
+ * - classifyDiskTier: correct tier at all boundary values
6
+ * - retentionMs: correct retention mapping per tier
7
+ * - runGC: dry-run mode makes zero filesystem mutations
8
+ * - runGC: budget cap (>5GB equivalent) triggers URGENT tier prune
9
+ * - runGC: API key absent falls back to 30d-only deletion (circuit breaker)
10
+ * - Crash recovery: pendingPrune paths are re-deleted on resume
11
+ *
12
+ * Uses real temp directories (mkdtemp). No mocked filesystem.
13
+ *
14
+ * @task T735
15
+ * @epic T726
16
+ */
17
+
18
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
19
+ import { tmpdir } from 'node:os';
20
+ import { join } from 'node:path';
21
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
22
+
23
+ // Mock check-disk-space so tests don't depend on actual disk state
24
+ vi.mock('check-disk-space', () => ({
25
+ default: vi.fn(),
26
+ }));
27
+
28
+ import checkDiskSpace from 'check-disk-space';
29
+
30
+ import { classifyDiskTier, DISK_THRESHOLDS, retentionMs, runGC } from '../runner.js';
31
+ import { readGCState } from '../state.js';
32
+
33
+ const mockCheckDisk = vi.mocked(checkDiskSpace);
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Test helpers
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /**
40
+ * Create a fake project directory structure under a temp dir.
41
+ * Writes a session JSONL file with an mtime in the past.
42
+ */
43
+ async function createFakeSession(
44
+ projectsDir: string,
45
+ slug: string,
46
+ sessionId: string,
47
+ ageMs: number,
48
+ ): Promise<string> {
49
+ const slugDir = join(projectsDir, slug);
50
+ await mkdir(slugDir, { recursive: true });
51
+
52
+ const jsonlPath = join(slugDir, `${sessionId}.jsonl`);
53
+ await writeFile(jsonlPath, `{"type":"user","text":"hello"}\n`, 'utf-8');
54
+
55
+ // Set mtime to simulate age
56
+ const pastTime = new Date(Date.now() - ageMs);
57
+ const { utimes } = await import('node:fs/promises');
58
+ await utimes(jsonlPath, pastTime, pastTime);
59
+
60
+ return jsonlPath;
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // classifyDiskTier
65
+ // ---------------------------------------------------------------------------
66
+
67
+ describe('classifyDiskTier', () => {
68
+ it('returns ok for disk usage below WATCH threshold', () => {
69
+ expect(classifyDiskTier(0)).toBe('ok');
70
+ expect(classifyDiskTier(50)).toBe('ok');
71
+ expect(classifyDiskTier(DISK_THRESHOLDS.WATCH - 0.01)).toBe('ok');
72
+ });
73
+
74
+ it('returns watch at WATCH threshold boundary (70%)', () => {
75
+ expect(classifyDiskTier(DISK_THRESHOLDS.WATCH)).toBe('watch');
76
+ expect(classifyDiskTier(70)).toBe('watch');
77
+ expect(classifyDiskTier(84.9)).toBe('watch');
78
+ });
79
+
80
+ it('returns warn at WARN threshold boundary (85%)', () => {
81
+ expect(classifyDiskTier(DISK_THRESHOLDS.WARN)).toBe('warn');
82
+ expect(classifyDiskTier(85)).toBe('warn');
83
+ expect(classifyDiskTier(89.9)).toBe('warn');
84
+ });
85
+
86
+ it('returns urgent at URGENT threshold boundary (90%)', () => {
87
+ expect(classifyDiskTier(DISK_THRESHOLDS.URGENT)).toBe('urgent');
88
+ expect(classifyDiskTier(90)).toBe('urgent');
89
+ expect(classifyDiskTier(94.9)).toBe('urgent');
90
+ });
91
+
92
+ it('returns emergency at EMERGENCY threshold boundary (95%)', () => {
93
+ expect(classifyDiskTier(DISK_THRESHOLDS.EMERGENCY)).toBe('emergency');
94
+ expect(classifyDiskTier(95)).toBe('emergency');
95
+ expect(classifyDiskTier(100)).toBe('emergency');
96
+ });
97
+ });
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // retentionMs
101
+ // ---------------------------------------------------------------------------
102
+
103
+ describe('retentionMs', () => {
104
+ it('returns 30 days for ok tier', () => {
105
+ expect(retentionMs('ok')).toBe(30 * 24 * 60 * 60 * 1000);
106
+ });
107
+
108
+ it('returns 30 days for watch tier', () => {
109
+ expect(retentionMs('watch')).toBe(30 * 24 * 60 * 60 * 1000);
110
+ });
111
+
112
+ it('returns 7 days for warn tier', () => {
113
+ expect(retentionMs('warn')).toBe(7 * 24 * 60 * 60 * 1000);
114
+ });
115
+
116
+ it('returns 3 days for urgent tier', () => {
117
+ expect(retentionMs('urgent')).toBe(3 * 24 * 60 * 60 * 1000);
118
+ });
119
+
120
+ it('returns 1 day for emergency tier', () => {
121
+ expect(retentionMs('emergency')).toBe(1 * 24 * 60 * 60 * 1000);
122
+ });
123
+ });
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // runGC — dry-run makes zero filesystem mutations
127
+ // ---------------------------------------------------------------------------
128
+
129
+ describe('runGC dry-run', () => {
130
+ let tmpDir: string;
131
+ let cleoDir: string;
132
+ let projectsDir: string;
133
+
134
+ beforeEach(async () => {
135
+ tmpDir = await mkdtemp(join(tmpdir(), 'cleo-gc-test-'));
136
+ cleoDir = join(tmpDir, '.cleo');
137
+ projectsDir = join(tmpDir, '.claude', 'projects');
138
+ await mkdir(cleoDir, { recursive: true });
139
+ await mkdir(projectsDir, { recursive: true });
140
+
141
+ // Simulate low disk usage (ok tier) for deterministic prune behavior
142
+ mockCheckDisk.mockResolvedValue({ diskPath: cleoDir, free: 900, size: 1000 } as Awaited<
143
+ ReturnType<typeof checkDiskSpace>
144
+ >);
145
+ });
146
+
147
+ afterEach(async () => {
148
+ await rm(tmpDir, { recursive: true, force: true });
149
+ vi.clearAllMocks();
150
+ });
151
+
152
+ it('dry-run reports prunable paths without deleting any files', async () => {
153
+ // Create a session old enough to be pruned (35 days old — beyond 30d ok-tier retention)
154
+ const OLD_AGE_MS = 35 * 24 * 60 * 60 * 1000;
155
+ const jsonlPath = await createFakeSession(
156
+ projectsDir,
157
+ 'test-project',
158
+ 'session-abc',
159
+ OLD_AGE_MS,
160
+ );
161
+
162
+ const gcResult = await runGC({ cleoDir, projectsDir, dryRun: true });
163
+
164
+ // Dry-run: file must still exist
165
+ const { access } = await import('node:fs/promises');
166
+ await expect(access(jsonlPath)).resolves.toBeUndefined(); // still exists
167
+
168
+ // Dry-run: result reports the path as prunable
169
+ expect(gcResult.pruned.length).toBeGreaterThan(0);
170
+ expect(gcResult.pruned.some((p) => p.path === jsonlPath)).toBe(true);
171
+ });
172
+
173
+ it('dry-run does not write to gc-state.json', async () => {
174
+ await runGC({ cleoDir, projectsDir, dryRun: true });
175
+
176
+ // gc-state.json should not be written in dry-run
177
+ const statePath = join(cleoDir, 'gc-state.json');
178
+ try {
179
+ await readFile(statePath, 'utf-8');
180
+ // If it exists, it shouldn't have updated lastRunAt
181
+ const state = await readGCState(statePath);
182
+ expect(state.lastRunAt).toBeNull();
183
+ } catch {
184
+ // File doesn't exist — that's also fine for dry-run
185
+ }
186
+ });
187
+ });
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // runGC — threshold-based auto-prune
191
+ // ---------------------------------------------------------------------------
192
+
193
+ describe('runGC threshold-based prune', () => {
194
+ let tmpDir: string;
195
+ let cleoDir: string;
196
+ let projectsDir: string;
197
+
198
+ beforeEach(async () => {
199
+ tmpDir = await mkdtemp(join(tmpdir(), 'cleo-gc-thresh-'));
200
+ cleoDir = join(tmpDir, '.cleo');
201
+ projectsDir = join(tmpDir, '.claude', 'projects');
202
+ await mkdir(cleoDir, { recursive: true });
203
+ await mkdir(projectsDir, { recursive: true });
204
+ });
205
+
206
+ afterEach(async () => {
207
+ await rm(tmpDir, { recursive: true, force: true });
208
+ vi.clearAllMocks();
209
+ });
210
+
211
+ it('URGENT tier (90%+ disk): prunes sessions older than 3d', async () => {
212
+ // Simulate URGENT disk pressure (92%)
213
+ mockCheckDisk.mockResolvedValue({ diskPath: cleoDir, free: 80, size: 1000 } as Awaited<
214
+ ReturnType<typeof checkDiskSpace>
215
+ >);
216
+
217
+ // Create a 4-day-old session (older than 3d urgent threshold)
218
+ const OLD_AGE_MS = 4 * 24 * 60 * 60 * 1000;
219
+ const jsonlPath = await createFakeSession(projectsDir, 'proj', 'sess-urgent', OLD_AGE_MS);
220
+
221
+ const gcResult = await runGC({ cleoDir, projectsDir });
222
+
223
+ expect(gcResult.threshold).toBe('urgent');
224
+ expect(gcResult.pruned.some((p) => p.path === jsonlPath)).toBe(true);
225
+ });
226
+
227
+ it('URGENT tier: escalation flag is set in gc-state.json', async () => {
228
+ mockCheckDisk.mockResolvedValue({ diskPath: cleoDir, free: 80, size: 1000 } as Awaited<
229
+ ReturnType<typeof checkDiskSpace>
230
+ >);
231
+
232
+ await createFakeSession(projectsDir, 'proj', 'sess-escalate', 4 * 24 * 60 * 60 * 1000);
233
+ await runGC({ cleoDir, projectsDir });
234
+
235
+ const state = await readGCState(join(cleoDir, 'gc-state.json'));
236
+ expect(state.escalationNeeded).toBe(true);
237
+ });
238
+
239
+ it('OK tier: sessions within 30d are NOT pruned', async () => {
240
+ // Simulate OK disk pressure (30%)
241
+ mockCheckDisk.mockResolvedValue({ diskPath: cleoDir, free: 700, size: 1000 } as Awaited<
242
+ ReturnType<typeof checkDiskSpace>
243
+ >);
244
+
245
+ // Create a 10-day-old session (below 30d ok-tier threshold)
246
+ const RECENT_AGE_MS = 10 * 24 * 60 * 60 * 1000;
247
+ const jsonlPath = await createFakeSession(projectsDir, 'proj', 'sess-recent', RECENT_AGE_MS);
248
+
249
+ const gcResult = await runGC({ cleoDir, projectsDir });
250
+
251
+ // File should still exist
252
+ const { access } = await import('node:fs/promises');
253
+ await expect(access(jsonlPath)).resolves.toBeUndefined();
254
+
255
+ expect(gcResult.threshold).toBe('ok');
256
+ expect(gcResult.pruned.some((p) => p.path === jsonlPath)).toBe(false);
257
+ });
258
+ });
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // runGC — crash recovery via pendingPrune
262
+ // ---------------------------------------------------------------------------
263
+
264
+ describe('runGC crash recovery', () => {
265
+ let tmpDir: string;
266
+ let cleoDir: string;
267
+
268
+ beforeEach(async () => {
269
+ tmpDir = await mkdtemp(join(tmpdir(), 'cleo-gc-crash-'));
270
+ cleoDir = join(tmpDir, '.cleo');
271
+ await mkdir(cleoDir, { recursive: true });
272
+
273
+ mockCheckDisk.mockResolvedValue({ diskPath: cleoDir, free: 900, size: 1000 } as Awaited<
274
+ ReturnType<typeof checkDiskSpace>
275
+ >);
276
+ });
277
+
278
+ afterEach(async () => {
279
+ await rm(tmpDir, { recursive: true, force: true });
280
+ vi.clearAllMocks();
281
+ });
282
+
283
+ it('resumes deletion from pendingPrune when resumeFrom is provided', async () => {
284
+ // Simulate a file that was in the pending list
285
+ const fakeFilePath = join(tmpDir, 'session-pending.jsonl');
286
+ await writeFile(fakeFilePath, '{"type":"user"}\n', 'utf-8');
287
+
288
+ // Run GC with resumeFrom (simulates crash recovery)
289
+ const gcResult = await runGC({ cleoDir, resumeFrom: [fakeFilePath] });
290
+
291
+ // File should be deleted
292
+ const { access } = await import('node:fs/promises');
293
+ await expect(access(fakeFilePath)).rejects.toThrow();
294
+
295
+ expect(gcResult.pruned.some((p) => p.path === fakeFilePath)).toBe(true);
296
+ });
297
+
298
+ it('pendingPrune is cleared after successful deletion', async () => {
299
+ const fakeFilePath = join(tmpDir, 'pending-cleared.jsonl');
300
+ await writeFile(fakeFilePath, '{"type":"user"}\n', 'utf-8');
301
+
302
+ await runGC({ cleoDir, resumeFrom: [fakeFilePath] });
303
+
304
+ const state = await readGCState(join(cleoDir, 'gc-state.json'));
305
+ expect(state.pendingPrune).toBeNull();
306
+ });
307
+
308
+ it('skips ENOENT paths idempotently (already deleted)', async () => {
309
+ // Path that does not exist — should not throw
310
+ const nonExistentPath = join(tmpDir, 'already-gone.jsonl');
311
+
312
+ const gcResult = await runGC({ cleoDir, resumeFrom: [nonExistentPath] });
313
+
314
+ // Should complete without error; path included in pruned (reported as 0 bytes)
315
+ expect(gcResult).toBeDefined();
316
+ });
317
+ });
318
+
319
+ // ---------------------------------------------------------------------------
320
+ // runGC — gc-state.json is written with correct structure
321
+ // ---------------------------------------------------------------------------
322
+
323
+ describe('runGC state persistence', () => {
324
+ let tmpDir: string;
325
+ let cleoDir: string;
326
+ let projectsDir: string;
327
+
328
+ beforeEach(async () => {
329
+ tmpDir = await mkdtemp(join(tmpdir(), 'cleo-gc-state-'));
330
+ cleoDir = join(tmpDir, '.cleo');
331
+ projectsDir = join(tmpDir, '.claude', 'projects');
332
+ await mkdir(cleoDir, { recursive: true });
333
+ await mkdir(projectsDir, { recursive: true });
334
+
335
+ mockCheckDisk.mockResolvedValue({ diskPath: cleoDir, free: 700, size: 1000 } as Awaited<
336
+ ReturnType<typeof checkDiskSpace>
337
+ >);
338
+ });
339
+
340
+ afterEach(async () => {
341
+ await rm(tmpDir, { recursive: true, force: true });
342
+ vi.clearAllMocks();
343
+ });
344
+
345
+ it('writes lastRunAt as ISO-8601 after a successful run', async () => {
346
+ await runGC({ cleoDir, projectsDir });
347
+
348
+ const state = await readGCState(join(cleoDir, 'gc-state.json'));
349
+ expect(state.lastRunAt).toBeTruthy();
350
+ expect(() => new Date(state.lastRunAt as string)).not.toThrow();
351
+ });
352
+
353
+ it('sets lastRunResult to success when no errors', async () => {
354
+ await runGC({ cleoDir, projectsDir });
355
+
356
+ const state = await readGCState(join(cleoDir, 'gc-state.json'));
357
+ expect(state.lastRunResult).toBe('success');
358
+ });
359
+
360
+ it('sets lastDiskUsedPct from check-disk-space result', async () => {
361
+ // 30% used: (1000 - 700) / 1000 = 30%
362
+ await runGC({ cleoDir, projectsDir });
363
+
364
+ const state = await readGCState(join(cleoDir, 'gc-state.json'));
365
+ expect(state.lastDiskUsedPct).toBeCloseTo(30, 1);
366
+ });
367
+ });