@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,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.
|
|
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.
|
|
155
|
-
"@cleocode/agents": "2026.4.
|
|
156
|
-
"@cleocode/
|
|
157
|
-
"@cleocode/
|
|
158
|
-
"@cleocode/
|
|
159
|
-
"@cleocode/
|
|
160
|
-
"@cleocode/
|
|
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
|
+
});
|