@cleocode/core 2026.4.99 → 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.
- package/dist/gc/daemon.js +481 -0
- package/dist/gc/daemon.js.map +7 -0
- package/dist/gc/index.js +669 -0
- package/dist/gc/index.js.map +7 -0
- package/dist/gc/runner.js +360 -0
- package/dist/gc/runner.js.map +7 -0
- package/dist/gc/state.js +49 -0
- package/dist/gc/state.js.map +7 -0
- package/dist/gc/transcript.js +209 -0
- package/dist/gc/transcript.js.map +7 -0
- package/dist/memory/brain-backfill.js +14643 -0
- package/dist/memory/brain-backfill.js.map +7 -0
- package/dist/memory/precompact-flush.js +47725 -0
- package/dist/memory/precompact-flush.js.map +7 -0
- package/dist/sentient/daemon.js +1100 -0
- package/dist/sentient/daemon.js.map +7 -0
- package/dist/sentient/index.js +1162 -0
- package/dist/sentient/index.js.map +7 -0
- package/dist/sentient/propose-tick.js +549 -0
- package/dist/sentient/propose-tick.js.map +7 -0
- package/dist/sentient/state.js +85 -0
- package/dist/sentient/state.js.map +7 -0
- package/dist/sentient/tick.js +396 -0
- package/dist/sentient/tick.js.map +7 -0
- package/dist/system/platform-paths.js +36 -0
- package/dist/system/platform-paths.js.map +7 -0
- package/package.json +8 -8
|
@@ -0,0 +1,1162 @@
|
|
|
1
|
+
// packages/core/src/sentient/daemon.ts
|
|
2
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
3
|
+
import { createWriteStream, constants as fsConstants, watch } from "node:fs";
|
|
4
|
+
import { open as fsOpen, mkdir as mkdir2 } from "node:fs/promises";
|
|
5
|
+
import { join as join3 } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import cron from "node-cron";
|
|
8
|
+
|
|
9
|
+
// packages/core/src/sentient/ingesters/brain-ingester.ts
|
|
10
|
+
var BRAIN_INGESTER_LIMIT = 10;
|
|
11
|
+
var BRAIN_MIN_CITATION_COUNT = 3;
|
|
12
|
+
var BRAIN_MIN_QUALITY_SCORE = 0.5;
|
|
13
|
+
var BRAIN_LOOKBACK_DAYS = 7;
|
|
14
|
+
function computeBrainWeight(citationCount, qualityScore) {
|
|
15
|
+
return Math.min(citationCount / 10 * qualityScore, 1);
|
|
16
|
+
}
|
|
17
|
+
function runBrainIngester(nativeDb) {
|
|
18
|
+
if (!nativeDb) {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const stmt = nativeDb.prepare(`
|
|
23
|
+
SELECT id, title, text, citation_count, quality_score
|
|
24
|
+
FROM brain_observations
|
|
25
|
+
WHERE type IN ('bugfix', 'decision')
|
|
26
|
+
AND citation_count >= :minCitations
|
|
27
|
+
AND created_at >= datetime('now', :lookback)
|
|
28
|
+
AND quality_score >= :minQuality
|
|
29
|
+
ORDER BY citation_count DESC, quality_score DESC
|
|
30
|
+
LIMIT :limit
|
|
31
|
+
`);
|
|
32
|
+
const rows = stmt.all({
|
|
33
|
+
minCitations: BRAIN_MIN_CITATION_COUNT,
|
|
34
|
+
lookback: `-${BRAIN_LOOKBACK_DAYS} days`,
|
|
35
|
+
minQuality: BRAIN_MIN_QUALITY_SCORE,
|
|
36
|
+
limit: BRAIN_INGESTER_LIMIT
|
|
37
|
+
});
|
|
38
|
+
const candidates = rows.map((row) => {
|
|
39
|
+
const label = row.title ?? row.text.slice(0, 80);
|
|
40
|
+
return {
|
|
41
|
+
source: "brain",
|
|
42
|
+
sourceId: row.id,
|
|
43
|
+
title: `[T2-BRAIN] Recurring issue: ${label}`,
|
|
44
|
+
rationale: `Brain entry ${row.id} cited ${row.citation_count} times (quality ${row.quality_score.toFixed(2)}) in the last ${BRAIN_LOOKBACK_DAYS} days`,
|
|
45
|
+
weight: computeBrainWeight(row.citation_count, row.quality_score)
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
candidates.sort((a, b) => b.weight - a.weight);
|
|
49
|
+
return candidates;
|
|
50
|
+
} catch (err) {
|
|
51
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
52
|
+
process.stderr.write(`[sentient/brain-ingester] WARNING: ${message}
|
|
53
|
+
`);
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// packages/core/src/sentient/ingesters/nexus-ingester.ts
|
|
59
|
+
var NEXUS_BASE_WEIGHT = 0.3;
|
|
60
|
+
var NEXUS_MIN_CALLER_COUNT = 5;
|
|
61
|
+
var NEXUS_MIN_DEGREE = 20;
|
|
62
|
+
var NEXUS_QUERY_LIMIT = 5;
|
|
63
|
+
function toFingerprint(nodeId) {
|
|
64
|
+
return nodeId;
|
|
65
|
+
}
|
|
66
|
+
function runNexusIngester(nativeDb) {
|
|
67
|
+
if (!nativeDb) {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
71
|
+
const candidates = [];
|
|
72
|
+
try {
|
|
73
|
+
const stmtA = nativeDb.prepare(`
|
|
74
|
+
SELECT n.id, n.name, n.file_path, COUNT(r.id) as caller_count
|
|
75
|
+
FROM nexus_nodes n
|
|
76
|
+
JOIN nexus_relations r ON r.target_id = n.id AND r.kind = 'calls'
|
|
77
|
+
WHERE NOT EXISTS (
|
|
78
|
+
SELECT 1 FROM nexus_relations r2
|
|
79
|
+
WHERE r2.source_id = n.id AND r2.kind = 'calls'
|
|
80
|
+
)
|
|
81
|
+
AND n.kind = 'function'
|
|
82
|
+
GROUP BY n.id
|
|
83
|
+
HAVING caller_count > :minCallers
|
|
84
|
+
ORDER BY caller_count DESC
|
|
85
|
+
LIMIT :limit
|
|
86
|
+
`);
|
|
87
|
+
const rowsA = stmtA.all({
|
|
88
|
+
minCallers: NEXUS_MIN_CALLER_COUNT,
|
|
89
|
+
limit: NEXUS_QUERY_LIMIT
|
|
90
|
+
});
|
|
91
|
+
for (const row of rowsA) {
|
|
92
|
+
const fp = toFingerprint(row.id);
|
|
93
|
+
if (seenIds.has(fp)) continue;
|
|
94
|
+
seenIds.add(fp);
|
|
95
|
+
candidates.push({
|
|
96
|
+
source: "nexus",
|
|
97
|
+
sourceId: row.id,
|
|
98
|
+
title: `[T2-NEXUS] Over-coupled symbol: ${row.name} (${row.caller_count} callers)`,
|
|
99
|
+
rationale: `Function ${row.name} in ${row.file_path} has ${row.caller_count} callers but makes no outbound calls \u2014 review for abstraction opportunity`,
|
|
100
|
+
weight: NEXUS_BASE_WEIGHT
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
} catch (err) {
|
|
104
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
105
|
+
process.stderr.write(`[sentient/nexus-ingester] WARNING query A: ${message}
|
|
106
|
+
`);
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const stmtB = nativeDb.prepare(`
|
|
110
|
+
SELECT n.id, n.name, n.file_path, COUNT(r.id) as degree
|
|
111
|
+
FROM nexus_nodes n
|
|
112
|
+
JOIN nexus_relations r ON r.source_id = n.id OR r.target_id = n.id
|
|
113
|
+
GROUP BY n.id
|
|
114
|
+
HAVING degree > :minDegree
|
|
115
|
+
ORDER BY degree DESC
|
|
116
|
+
LIMIT :limit
|
|
117
|
+
`);
|
|
118
|
+
const rowsB = stmtB.all({
|
|
119
|
+
minDegree: NEXUS_MIN_DEGREE,
|
|
120
|
+
limit: NEXUS_QUERY_LIMIT
|
|
121
|
+
});
|
|
122
|
+
for (const row of rowsB) {
|
|
123
|
+
const fp = toFingerprint(row.id);
|
|
124
|
+
if (seenIds.has(fp)) continue;
|
|
125
|
+
seenIds.add(fp);
|
|
126
|
+
candidates.push({
|
|
127
|
+
source: "nexus",
|
|
128
|
+
sourceId: row.id,
|
|
129
|
+
title: `[T2-NEXUS] Over-coupled symbol: ${row.name} (${row.degree} edges)`,
|
|
130
|
+
rationale: `Symbol ${row.name} in ${row.file_path} has ${row.degree} total edges \u2014 review for over-coupling`,
|
|
131
|
+
weight: NEXUS_BASE_WEIGHT
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
} catch (err) {
|
|
135
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
136
|
+
process.stderr.write(`[sentient/nexus-ingester] WARNING query B: ${message}
|
|
137
|
+
`);
|
|
138
|
+
}
|
|
139
|
+
return candidates;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// packages/core/src/sentient/ingesters/test-ingester.ts
|
|
143
|
+
import { readFileSync } from "node:fs";
|
|
144
|
+
import { join } from "node:path";
|
|
145
|
+
var GATES_JSONL_PATH = ".cleo/audit/gates.jsonl";
|
|
146
|
+
var COVERAGE_SUMMARY_PATH = ".cleo/coverage-summary.json";
|
|
147
|
+
var MIN_LINE_COVERAGE_PCT = 80;
|
|
148
|
+
var TEST_BASE_WEIGHT = 0.5;
|
|
149
|
+
function runGatesIngester(projectRoot) {
|
|
150
|
+
const gatesPath = join(projectRoot, GATES_JSONL_PATH);
|
|
151
|
+
let raw;
|
|
152
|
+
try {
|
|
153
|
+
raw = readFileSync(gatesPath, "utf-8");
|
|
154
|
+
} catch {
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
const candidates = [];
|
|
158
|
+
const seenKeys = /* @__PURE__ */ new Set();
|
|
159
|
+
for (const line of raw.split("\n")) {
|
|
160
|
+
const trimmed = line.trim();
|
|
161
|
+
if (!trimmed) continue;
|
|
162
|
+
let record;
|
|
163
|
+
try {
|
|
164
|
+
record = JSON.parse(trimmed);
|
|
165
|
+
} catch {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const taskId = record.taskId;
|
|
169
|
+
const gate = record.gate ?? "unknown";
|
|
170
|
+
const failCount = record.failCount ?? 0;
|
|
171
|
+
if (typeof taskId !== "string" || failCount <= 0) continue;
|
|
172
|
+
const key = `${taskId}.${gate}`;
|
|
173
|
+
if (seenKeys.has(key)) continue;
|
|
174
|
+
seenKeys.add(key);
|
|
175
|
+
candidates.push({
|
|
176
|
+
source: "test",
|
|
177
|
+
sourceId: key,
|
|
178
|
+
title: `[T2-TEST] Fix flaky gate: ${taskId}.${gate}`,
|
|
179
|
+
rationale: `Gate '${gate}' on task ${taskId} has failed ${failCount} time(s)`,
|
|
180
|
+
weight: TEST_BASE_WEIGHT
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return candidates;
|
|
184
|
+
}
|
|
185
|
+
function runCoverageIngester(projectRoot) {
|
|
186
|
+
const coveragePath = join(projectRoot, COVERAGE_SUMMARY_PATH);
|
|
187
|
+
let summary;
|
|
188
|
+
try {
|
|
189
|
+
const raw = readFileSync(coveragePath, "utf-8");
|
|
190
|
+
summary = JSON.parse(raw);
|
|
191
|
+
} catch {
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
const candidates = [];
|
|
195
|
+
for (const [filePath, entry] of Object.entries(summary)) {
|
|
196
|
+
if (filePath === "total") continue;
|
|
197
|
+
const pct = entry?.lines?.pct;
|
|
198
|
+
if (typeof pct !== "number" || pct >= MIN_LINE_COVERAGE_PCT) continue;
|
|
199
|
+
candidates.push({
|
|
200
|
+
source: "test",
|
|
201
|
+
sourceId: filePath,
|
|
202
|
+
title: `[T2-TEST] Increase coverage: ${filePath} (${pct}% lines)`,
|
|
203
|
+
rationale: `File ${filePath} has ${pct}% line coverage (target: ${MIN_LINE_COVERAGE_PCT}%)`,
|
|
204
|
+
weight: TEST_BASE_WEIGHT
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return candidates;
|
|
208
|
+
}
|
|
209
|
+
function runTestIngester(projectRoot) {
|
|
210
|
+
try {
|
|
211
|
+
const gatesCandidates = runGatesIngester(projectRoot);
|
|
212
|
+
const coverageCandidates = runCoverageIngester(projectRoot);
|
|
213
|
+
const seenSourceIds = /* @__PURE__ */ new Set();
|
|
214
|
+
const merged = [];
|
|
215
|
+
for (const candidate of [...gatesCandidates, ...coverageCandidates]) {
|
|
216
|
+
if (seenSourceIds.has(candidate.sourceId)) continue;
|
|
217
|
+
seenSourceIds.add(candidate.sourceId);
|
|
218
|
+
merged.push(candidate);
|
|
219
|
+
}
|
|
220
|
+
return merged;
|
|
221
|
+
} catch (err) {
|
|
222
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
223
|
+
process.stderr.write(`[sentient/test-ingester] WARNING: ${message}
|
|
224
|
+
`);
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// packages/core/src/sentient/proposal-rate-limiter.ts
|
|
230
|
+
var SENTIENT_TIER2_TAG = "sentient-tier2";
|
|
231
|
+
var DEFAULT_DAILY_PROPOSAL_LIMIT = 3;
|
|
232
|
+
var SQLITE_BUSY_CODE = "SQLITE_BUSY";
|
|
233
|
+
function countTodayProposals(nativeDb) {
|
|
234
|
+
if (!nativeDb) return 0;
|
|
235
|
+
const stmt = nativeDb.prepare(`
|
|
236
|
+
SELECT COUNT(*) as cnt
|
|
237
|
+
FROM tasks
|
|
238
|
+
WHERE labels_json LIKE :labelPattern
|
|
239
|
+
AND date(created_at) = date('now')
|
|
240
|
+
AND status IN ('proposed', 'pending', 'active', 'done')
|
|
241
|
+
`);
|
|
242
|
+
const row = stmt.get({ labelPattern: `%${SENTIENT_TIER2_TAG}%` });
|
|
243
|
+
return row?.cnt ?? 0;
|
|
244
|
+
}
|
|
245
|
+
function isRateLimitExceeded(nativeDb, limit = DEFAULT_DAILY_PROPOSAL_LIMIT) {
|
|
246
|
+
return countTodayProposals(nativeDb) >= limit;
|
|
247
|
+
}
|
|
248
|
+
function transactionalInsertProposal(nativeDb, insertSql, insertParams, limit = DEFAULT_DAILY_PROPOSAL_LIMIT) {
|
|
249
|
+
try {
|
|
250
|
+
nativeDb.exec("BEGIN IMMEDIATE");
|
|
251
|
+
} catch (err) {
|
|
252
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
253
|
+
if (msg.includes(SQLITE_BUSY_CODE)) {
|
|
254
|
+
return { inserted: false, countBeforeInsert: 0, reason: "busy" };
|
|
255
|
+
}
|
|
256
|
+
throw err;
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
const countBeforeInsert = countTodayProposals(nativeDb);
|
|
260
|
+
if (countBeforeInsert >= limit) {
|
|
261
|
+
nativeDb.exec("ROLLBACK");
|
|
262
|
+
return { inserted: false, countBeforeInsert, reason: "rate-limit" };
|
|
263
|
+
}
|
|
264
|
+
const stmt = nativeDb.prepare(insertSql);
|
|
265
|
+
stmt.run(insertParams);
|
|
266
|
+
nativeDb.exec("COMMIT");
|
|
267
|
+
return { inserted: true, countBeforeInsert };
|
|
268
|
+
} catch (err) {
|
|
269
|
+
try {
|
|
270
|
+
nativeDb.exec("ROLLBACK");
|
|
271
|
+
} catch {
|
|
272
|
+
}
|
|
273
|
+
throw err;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// packages/core/src/sentient/state.ts
|
|
278
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
279
|
+
import { dirname, join as join2 } from "node:path";
|
|
280
|
+
var SENTIENT_STATE_SCHEMA_VERSION = "1.0";
|
|
281
|
+
var DEFAULT_SENTIENT_STATE = {
|
|
282
|
+
schemaVersion: SENTIENT_STATE_SCHEMA_VERSION,
|
|
283
|
+
pid: null,
|
|
284
|
+
startedAt: null,
|
|
285
|
+
lastTickAt: null,
|
|
286
|
+
killSwitch: false,
|
|
287
|
+
killSwitchReason: null,
|
|
288
|
+
stats: {
|
|
289
|
+
tasksPicked: 0,
|
|
290
|
+
tasksCompleted: 0,
|
|
291
|
+
tasksFailed: 0,
|
|
292
|
+
ticksExecuted: 0,
|
|
293
|
+
ticksKilled: 0
|
|
294
|
+
},
|
|
295
|
+
stuckTasks: {},
|
|
296
|
+
stuckTimestamps: [],
|
|
297
|
+
activeTaskId: null,
|
|
298
|
+
tier2Enabled: false,
|
|
299
|
+
tier2Stats: {
|
|
300
|
+
proposalsGenerated: 0,
|
|
301
|
+
proposalsAccepted: 0,
|
|
302
|
+
proposalsRejected: 0
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
async function readSentientState(statePath) {
|
|
306
|
+
try {
|
|
307
|
+
const raw = await readFile(statePath, "utf-8");
|
|
308
|
+
const parsed = JSON.parse(raw);
|
|
309
|
+
return {
|
|
310
|
+
...DEFAULT_SENTIENT_STATE,
|
|
311
|
+
...parsed,
|
|
312
|
+
stats: { ...DEFAULT_SENTIENT_STATE.stats, ...parsed.stats ?? {} },
|
|
313
|
+
stuckTasks: parsed.stuckTasks ?? {},
|
|
314
|
+
stuckTimestamps: parsed.stuckTimestamps ?? [],
|
|
315
|
+
tier2Enabled: parsed.tier2Enabled ?? false,
|
|
316
|
+
tier2Stats: { ...DEFAULT_SENTIENT_STATE.tier2Stats, ...parsed.tier2Stats ?? {} }
|
|
317
|
+
};
|
|
318
|
+
} catch {
|
|
319
|
+
return { ...DEFAULT_SENTIENT_STATE };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
async function writeSentientState(statePath, state) {
|
|
323
|
+
const dir = dirname(statePath);
|
|
324
|
+
await mkdir(dir, { recursive: true });
|
|
325
|
+
const tmpPath = join2(dir, `.sentient-state-${process.pid}.tmp`);
|
|
326
|
+
const json = JSON.stringify(state, null, 2);
|
|
327
|
+
await writeFile(tmpPath, json, "utf-8");
|
|
328
|
+
await rename(tmpPath, statePath);
|
|
329
|
+
}
|
|
330
|
+
async function patchSentientState(statePath, patch) {
|
|
331
|
+
const current = await readSentientState(statePath);
|
|
332
|
+
const updated = {
|
|
333
|
+
...current,
|
|
334
|
+
...patch,
|
|
335
|
+
stats: { ...current.stats, ...patch.stats ?? {} }
|
|
336
|
+
};
|
|
337
|
+
await writeSentientState(statePath, updated);
|
|
338
|
+
return updated;
|
|
339
|
+
}
|
|
340
|
+
async function incrementStats(statePath, delta) {
|
|
341
|
+
const current = await readSentientState(statePath);
|
|
342
|
+
const nextStats = {
|
|
343
|
+
tasksPicked: current.stats.tasksPicked + (delta.tasksPicked ?? 0),
|
|
344
|
+
tasksCompleted: current.stats.tasksCompleted + (delta.tasksCompleted ?? 0),
|
|
345
|
+
tasksFailed: current.stats.tasksFailed + (delta.tasksFailed ?? 0),
|
|
346
|
+
ticksExecuted: current.stats.ticksExecuted + (delta.ticksExecuted ?? 0),
|
|
347
|
+
ticksKilled: current.stats.ticksKilled + (delta.ticksKilled ?? 0)
|
|
348
|
+
};
|
|
349
|
+
const updated = { ...current, stats: nextStats };
|
|
350
|
+
await writeSentientState(statePath, updated);
|
|
351
|
+
return updated;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// packages/core/src/sentient/propose-tick.ts
|
|
355
|
+
var PROPOSAL_TITLE_PATTERN = /^\[T2-(BRAIN|NEXUS|TEST)\]/;
|
|
356
|
+
var TIER2_LABEL = "sentient-tier2";
|
|
357
|
+
function fingerprint(candidate) {
|
|
358
|
+
return `${candidate.source}:${candidate.sourceId}`;
|
|
359
|
+
}
|
|
360
|
+
async function killSwitchActive(statePath) {
|
|
361
|
+
const state = await readSentientState(statePath);
|
|
362
|
+
return state.killSwitch === true;
|
|
363
|
+
}
|
|
364
|
+
async function runProposeTick(options) {
|
|
365
|
+
const { projectRoot, statePath } = options;
|
|
366
|
+
if (await killSwitchActive(statePath)) {
|
|
367
|
+
return { kind: "killed", written: 0, count: 0, detail: "killSwitch active before ingest" };
|
|
368
|
+
}
|
|
369
|
+
const state = await readSentientState(statePath);
|
|
370
|
+
if (!state.tier2Enabled) {
|
|
371
|
+
return {
|
|
372
|
+
kind: "disabled",
|
|
373
|
+
written: 0,
|
|
374
|
+
count: 0,
|
|
375
|
+
detail: "tier2Enabled=false; enable via cleo sentient propose enable"
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
let brainDb;
|
|
379
|
+
let nexusDb;
|
|
380
|
+
let tasksNativeDb;
|
|
381
|
+
if (options.brainDb !== void 0) {
|
|
382
|
+
brainDb = options.brainDb;
|
|
383
|
+
} else {
|
|
384
|
+
try {
|
|
385
|
+
const { getBrainDb, getBrainNativeDb } = await import("@cleocode/core/internal");
|
|
386
|
+
await getBrainDb(projectRoot);
|
|
387
|
+
brainDb = getBrainNativeDb();
|
|
388
|
+
} catch {
|
|
389
|
+
brainDb = null;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (options.nexusDb !== void 0) {
|
|
393
|
+
nexusDb = options.nexusDb;
|
|
394
|
+
} else {
|
|
395
|
+
try {
|
|
396
|
+
const { getNexusNativeDb } = await import("@cleocode/core/internal");
|
|
397
|
+
nexusDb = getNexusNativeDb();
|
|
398
|
+
} catch {
|
|
399
|
+
nexusDb = null;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (options.tasksDb !== void 0) {
|
|
403
|
+
tasksNativeDb = options.tasksDb;
|
|
404
|
+
} else {
|
|
405
|
+
const { getNativeDb, getDb } = await import("@cleocode/core/internal");
|
|
406
|
+
await getDb(projectRoot);
|
|
407
|
+
tasksNativeDb = getNativeDb();
|
|
408
|
+
}
|
|
409
|
+
const [brainCandidates, nexusCandidates, testCandidates] = await Promise.all([
|
|
410
|
+
Promise.resolve(runBrainIngester(brainDb)),
|
|
411
|
+
Promise.resolve(runNexusIngester(nexusDb)),
|
|
412
|
+
Promise.resolve(runTestIngester(projectRoot))
|
|
413
|
+
]);
|
|
414
|
+
if (await killSwitchActive(statePath)) {
|
|
415
|
+
return {
|
|
416
|
+
kind: "killed",
|
|
417
|
+
written: 0,
|
|
418
|
+
count: 0,
|
|
419
|
+
detail: "killSwitch active after ingest phase"
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
const seenFingerprints = /* @__PURE__ */ new Set();
|
|
423
|
+
const merged = [];
|
|
424
|
+
for (const candidate of [...brainCandidates, ...nexusCandidates, ...testCandidates]) {
|
|
425
|
+
if (!PROPOSAL_TITLE_PATTERN.test(candidate.title)) {
|
|
426
|
+
process.stderr.write(
|
|
427
|
+
`[sentient/propose-tick] Rejected candidate with invalid title format: "${candidate.title}"
|
|
428
|
+
`
|
|
429
|
+
);
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
const fp = fingerprint(candidate);
|
|
433
|
+
if (seenFingerprints.has(fp)) continue;
|
|
434
|
+
seenFingerprints.add(fp);
|
|
435
|
+
merged.push(candidate);
|
|
436
|
+
}
|
|
437
|
+
if (merged.length === 0) {
|
|
438
|
+
return { kind: "no-candidates", written: 0, count: 0, detail: "no candidates from ingesters" };
|
|
439
|
+
}
|
|
440
|
+
merged.sort((a, b) => b.weight - a.weight);
|
|
441
|
+
const currentCount = tasksNativeDb ? countTodayProposals(tasksNativeDb) : 0;
|
|
442
|
+
const slotsRemaining = Math.max(0, DEFAULT_DAILY_PROPOSAL_LIMIT - currentCount);
|
|
443
|
+
if (slotsRemaining === 0) {
|
|
444
|
+
return {
|
|
445
|
+
kind: "rate-limited",
|
|
446
|
+
written: 0,
|
|
447
|
+
count: currentCount,
|
|
448
|
+
detail: `daily limit reached (${currentCount}/${DEFAULT_DAILY_PROPOSAL_LIMIT})`
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
const toWrite = merged.slice(0, slotsRemaining);
|
|
452
|
+
if (await killSwitchActive(statePath)) {
|
|
453
|
+
return {
|
|
454
|
+
kind: "killed",
|
|
455
|
+
written: 0,
|
|
456
|
+
count: currentCount,
|
|
457
|
+
detail: "killSwitch active before write phase"
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
let written = 0;
|
|
461
|
+
for (const candidate of toWrite) {
|
|
462
|
+
let taskId;
|
|
463
|
+
if (options.allocateTaskId) {
|
|
464
|
+
taskId = await options.allocateTaskId();
|
|
465
|
+
} else {
|
|
466
|
+
const { allocateNextTaskId } = await import("@cleocode/core/internal");
|
|
467
|
+
taskId = await allocateNextTaskId(projectRoot);
|
|
468
|
+
}
|
|
469
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
470
|
+
const labels = JSON.stringify([TIER2_LABEL, `source:${candidate.source}`]);
|
|
471
|
+
if (!tasksNativeDb) {
|
|
472
|
+
process.stderr.write("[sentient/propose-tick] tasks DB not available; skipping write\n");
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
const notesJson = JSON.stringify([
|
|
476
|
+
JSON.stringify({
|
|
477
|
+
kind: "proposal-meta",
|
|
478
|
+
proposedBy: "sentient-tier2",
|
|
479
|
+
source: candidate.source,
|
|
480
|
+
sourceId: candidate.sourceId,
|
|
481
|
+
weight: candidate.weight,
|
|
482
|
+
proposedAt: now
|
|
483
|
+
})
|
|
484
|
+
]);
|
|
485
|
+
const insertSql = `
|
|
486
|
+
INSERT INTO tasks (
|
|
487
|
+
id, title, description, status, priority,
|
|
488
|
+
labels_json, notes_json,
|
|
489
|
+
created_at, updated_at,
|
|
490
|
+
role, scope
|
|
491
|
+
) VALUES (
|
|
492
|
+
:id, :title, :description, :status, :priority,
|
|
493
|
+
:labelsJson, :notesJson,
|
|
494
|
+
:createdAt, :updatedAt,
|
|
495
|
+
:role, :scope
|
|
496
|
+
)
|
|
497
|
+
`;
|
|
498
|
+
const insertParams = {
|
|
499
|
+
id: taskId,
|
|
500
|
+
title: candidate.title,
|
|
501
|
+
description: candidate.rationale,
|
|
502
|
+
status: "proposed",
|
|
503
|
+
priority: "medium",
|
|
504
|
+
labelsJson: labels,
|
|
505
|
+
notesJson,
|
|
506
|
+
createdAt: now,
|
|
507
|
+
updatedAt: now,
|
|
508
|
+
role: "work",
|
|
509
|
+
scope: "feature"
|
|
510
|
+
};
|
|
511
|
+
try {
|
|
512
|
+
const result = transactionalInsertProposal(
|
|
513
|
+
tasksNativeDb,
|
|
514
|
+
insertSql,
|
|
515
|
+
insertParams,
|
|
516
|
+
DEFAULT_DAILY_PROPOSAL_LIMIT
|
|
517
|
+
);
|
|
518
|
+
if (result.inserted) {
|
|
519
|
+
written++;
|
|
520
|
+
} else if (result.reason === "rate-limit") {
|
|
521
|
+
break;
|
|
522
|
+
}
|
|
523
|
+
} catch (err) {
|
|
524
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
525
|
+
process.stderr.write(`[sentient/propose-tick] INSERT failed for ${taskId}: ${message}
|
|
526
|
+
`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (written > 0) {
|
|
530
|
+
const latestState = await readSentientState(statePath);
|
|
531
|
+
await patchSentientState(statePath, {
|
|
532
|
+
tier2Stats: {
|
|
533
|
+
...latestState.tier2Stats,
|
|
534
|
+
proposalsGenerated: latestState.tier2Stats.proposalsGenerated + written
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
const finalCount = tasksNativeDb ? countTodayProposals(tasksNativeDb) : currentCount + written;
|
|
539
|
+
if (written === 0) {
|
|
540
|
+
return {
|
|
541
|
+
kind: "no-candidates",
|
|
542
|
+
written: 0,
|
|
543
|
+
count: finalCount,
|
|
544
|
+
detail: "candidates available but none written (rate limit or DB unavailable)"
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
return {
|
|
548
|
+
kind: "wrote",
|
|
549
|
+
written,
|
|
550
|
+
count: finalCount,
|
|
551
|
+
detail: `wrote ${written} proposal(s) (${finalCount}/${DEFAULT_DAILY_PROPOSAL_LIMIT} today)`
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
async function safeRunProposeTick(options) {
|
|
555
|
+
try {
|
|
556
|
+
return await runProposeTick(options);
|
|
557
|
+
} catch (err) {
|
|
558
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
559
|
+
return {
|
|
560
|
+
kind: "error",
|
|
561
|
+
written: 0,
|
|
562
|
+
count: 0,
|
|
563
|
+
detail: `propose tick threw: ${message}`
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// packages/core/src/sentient/tick.ts
|
|
569
|
+
import { spawn } from "node:child_process";
|
|
570
|
+
var DREAM_VOLUME_THRESHOLD_DEFAULT = 50;
|
|
571
|
+
var DREAM_IDLE_TICKS_DEFAULT = 5;
|
|
572
|
+
var DEFAULT_ADAPTER = "claude-code";
|
|
573
|
+
var RETRY_BACKOFF_MS = [3e4, 3e5, 18e5];
|
|
574
|
+
var MAX_TASK_ATTEMPTS = RETRY_BACKOFF_MS.length;
|
|
575
|
+
var SELF_PAUSE_STUCK_THRESHOLD = 5;
|
|
576
|
+
var SELF_PAUSE_WINDOW_MS = 60 * 60 * 1e3;
|
|
577
|
+
var SELF_PAUSE_REASON = "self-pause: 5 stuck tasks in 1 hour";
|
|
578
|
+
var SPAWN_TIMEOUT_MS = 30 * 60 * 1e3;
|
|
579
|
+
async function killSwitchActive2(statePath) {
|
|
580
|
+
const state = await readSentientState(statePath);
|
|
581
|
+
return state.killSwitch === true;
|
|
582
|
+
}
|
|
583
|
+
function pruneStuckWindow(timestamps, now) {
|
|
584
|
+
const cutoff = now - SELF_PAUSE_WINDOW_MS;
|
|
585
|
+
return timestamps.filter((t) => t >= cutoff);
|
|
586
|
+
}
|
|
587
|
+
async function defaultPickTask(projectRoot) {
|
|
588
|
+
const { Cleo } = await import("@cleocode/core/sdk");
|
|
589
|
+
const { getReadyTasks } = await import("@cleocode/core/tasks");
|
|
590
|
+
const cleo = await Cleo.init(projectRoot);
|
|
591
|
+
const pending = await cleo.tasks.find({ status: "pending", limit: 500 });
|
|
592
|
+
const candidates = Array.isArray(pending?.data?.tasks) ? pending.data.tasks : [];
|
|
593
|
+
if (candidates.length === 0) return null;
|
|
594
|
+
const ready = getReadyTasks(candidates);
|
|
595
|
+
if (ready.length === 0) return null;
|
|
596
|
+
ready.sort((a, b) => a.id.localeCompare(b.id));
|
|
597
|
+
return ready[0];
|
|
598
|
+
}
|
|
599
|
+
function defaultSpawn(taskId, adapter, projectRoot) {
|
|
600
|
+
return new Promise((resolve) => {
|
|
601
|
+
const args = ["orchestrate", "spawn", taskId, "--adapter", adapter];
|
|
602
|
+
const child = spawn("cleo", args, {
|
|
603
|
+
cwd: projectRoot,
|
|
604
|
+
env: { ...process.env, CLEO_SENTIENT_SPAWN: "1" },
|
|
605
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
606
|
+
});
|
|
607
|
+
let stdout = "";
|
|
608
|
+
let stderr = "";
|
|
609
|
+
child.stdout?.on("data", (chunk) => {
|
|
610
|
+
stdout += chunk.toString("utf-8");
|
|
611
|
+
});
|
|
612
|
+
child.stderr?.on("data", (chunk) => {
|
|
613
|
+
stderr += chunk.toString("utf-8");
|
|
614
|
+
});
|
|
615
|
+
const timer = setTimeout(() => {
|
|
616
|
+
child.kill("SIGTERM");
|
|
617
|
+
}, SPAWN_TIMEOUT_MS);
|
|
618
|
+
child.on("error", (err) => {
|
|
619
|
+
clearTimeout(timer);
|
|
620
|
+
resolve({
|
|
621
|
+
exitCode: 1,
|
|
622
|
+
stdout,
|
|
623
|
+
stderr: stderr + `
|
|
624
|
+
[sentient] spawn error: ${err.message}`
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
child.on("exit", (code) => {
|
|
628
|
+
clearTimeout(timer);
|
|
629
|
+
resolve({
|
|
630
|
+
exitCode: code ?? 1,
|
|
631
|
+
stdout: stdout.slice(-4e3),
|
|
632
|
+
stderr: stderr.slice(-4e3)
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
async function writeSuccessReceipt(projectRoot, taskId, exitCode) {
|
|
638
|
+
try {
|
|
639
|
+
const { Cleo } = await import("@cleocode/core/sdk");
|
|
640
|
+
const cleo = await Cleo.init(projectRoot);
|
|
641
|
+
await cleo.memory.observe({
|
|
642
|
+
text: `sentient-tier1: task ${taskId} completed successfully (exit=${exitCode})`,
|
|
643
|
+
title: `sentient-receipt: ${taskId}`
|
|
644
|
+
});
|
|
645
|
+
} catch {
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
var consecutiveIdleTicks = 0;
|
|
649
|
+
async function maybeTriggerDream(projectRoot, opts, pickedTask) {
|
|
650
|
+
const volumeThreshold = opts.dreamVolumeThreshold ?? DREAM_VOLUME_THRESHOLD_DEFAULT;
|
|
651
|
+
const idleTicksThreshold = opts.dreamIdleTicks ?? DREAM_IDLE_TICKS_DEFAULT;
|
|
652
|
+
if (volumeThreshold <= 0 && idleTicksThreshold <= 0) return;
|
|
653
|
+
if (pickedTask) {
|
|
654
|
+
consecutiveIdleTicks = 0;
|
|
655
|
+
} else {
|
|
656
|
+
consecutiveIdleTicks += 1;
|
|
657
|
+
}
|
|
658
|
+
const dreamer = opts.checkAndDream ?? (async (root, dreamerOpts) => {
|
|
659
|
+
const { checkAndDream } = await import("@cleocode/core/internal");
|
|
660
|
+
return checkAndDream(root, dreamerOpts);
|
|
661
|
+
});
|
|
662
|
+
try {
|
|
663
|
+
await dreamer(projectRoot, {
|
|
664
|
+
volumeThreshold: volumeThreshold > 0 ? volumeThreshold : void 0,
|
|
665
|
+
inline: false
|
|
666
|
+
}).catch((err) => {
|
|
667
|
+
console.warn("[sentient/tick] dream trigger error:", err);
|
|
668
|
+
});
|
|
669
|
+
} catch (err) {
|
|
670
|
+
console.warn("[sentient/tick] dream trigger threw:", err);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
async function writeFailureReceipt(projectRoot, taskId, attempt, exitCode, reason) {
|
|
674
|
+
try {
|
|
675
|
+
const { Cleo } = await import("@cleocode/core/sdk");
|
|
676
|
+
const cleo = await Cleo.init(projectRoot);
|
|
677
|
+
await cleo.memory.observe({
|
|
678
|
+
text: `sentient-tier1: task ${taskId} failed (attempt=${attempt}/${MAX_TASK_ATTEMPTS}, exit=${exitCode}). reason=${reason.slice(0, 500)}`,
|
|
679
|
+
title: `sentient-failure: ${taskId}`
|
|
680
|
+
});
|
|
681
|
+
} catch {
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
async function runTick(options) {
|
|
685
|
+
const { projectRoot, statePath } = options;
|
|
686
|
+
const adapter = options.adapter ?? DEFAULT_ADAPTER;
|
|
687
|
+
const now = Date.now();
|
|
688
|
+
if (await killSwitchActive2(statePath)) {
|
|
689
|
+
await incrementStats(statePath, { ticksKilled: 1 });
|
|
690
|
+
await patchSentientState(statePath, { lastTickAt: new Date(now).toISOString() });
|
|
691
|
+
return { kind: "killed", taskId: null, detail: "killSwitch active before pick" };
|
|
692
|
+
}
|
|
693
|
+
const picker = options.pickTask ?? defaultPickTask;
|
|
694
|
+
let task;
|
|
695
|
+
try {
|
|
696
|
+
task = await picker(projectRoot);
|
|
697
|
+
} catch (err) {
|
|
698
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
699
|
+
await incrementStats(statePath, { ticksExecuted: 1 });
|
|
700
|
+
await patchSentientState(statePath, { lastTickAt: new Date(now).toISOString() });
|
|
701
|
+
return { kind: "error", taskId: null, detail: `picker threw: ${message}` };
|
|
702
|
+
}
|
|
703
|
+
if (task === null) {
|
|
704
|
+
await incrementStats(statePath, { ticksExecuted: 1 });
|
|
705
|
+
await patchSentientState(statePath, { lastTickAt: new Date(now).toISOString() });
|
|
706
|
+
return { kind: "no-task", taskId: null, detail: "no unblocked tasks available" };
|
|
707
|
+
}
|
|
708
|
+
const preSpawnState = await readSentientState(statePath);
|
|
709
|
+
const existingStuck = preSpawnState.stuckTasks[task.id];
|
|
710
|
+
if (existingStuck && existingStuck.nextRetryAt > now) {
|
|
711
|
+
await incrementStats(statePath, { ticksExecuted: 1 });
|
|
712
|
+
await patchSentientState(statePath, { lastTickAt: new Date(now).toISOString() });
|
|
713
|
+
return {
|
|
714
|
+
kind: "backoff",
|
|
715
|
+
taskId: task.id,
|
|
716
|
+
detail: `task ${task.id} in backoff until ${new Date(existingStuck.nextRetryAt).toISOString()}`
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
if (await killSwitchActive2(statePath)) {
|
|
720
|
+
await incrementStats(statePath, { ticksKilled: 1 });
|
|
721
|
+
await patchSentientState(statePath, { lastTickAt: new Date(now).toISOString() });
|
|
722
|
+
return { kind: "killed", taskId: task.id, detail: "killSwitch active before spawn" };
|
|
723
|
+
}
|
|
724
|
+
await incrementStats(statePath, { tasksPicked: 1 });
|
|
725
|
+
await patchSentientState(statePath, { activeTaskId: task.id });
|
|
726
|
+
let spawnResult;
|
|
727
|
+
if (options.dryRun === true) {
|
|
728
|
+
spawnResult = {
|
|
729
|
+
exitCode: 0,
|
|
730
|
+
stdout: "[dry-run] spawn skipped",
|
|
731
|
+
stderr: ""
|
|
732
|
+
};
|
|
733
|
+
} else {
|
|
734
|
+
try {
|
|
735
|
+
const spawner = options.spawn ?? ((tid, adp) => defaultSpawn(tid, adp, projectRoot));
|
|
736
|
+
spawnResult = await spawner(task.id, adapter);
|
|
737
|
+
} catch (err) {
|
|
738
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
739
|
+
spawnResult = { exitCode: 1, stdout: "", stderr: `spawn threw: ${message}` };
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
if (await killSwitchActive2(statePath)) {
|
|
743
|
+
await incrementStats(statePath, { ticksKilled: 1 });
|
|
744
|
+
await patchSentientState(statePath, {
|
|
745
|
+
lastTickAt: new Date(Date.now()).toISOString(),
|
|
746
|
+
activeTaskId: null
|
|
747
|
+
});
|
|
748
|
+
return {
|
|
749
|
+
kind: "killed",
|
|
750
|
+
taskId: task.id,
|
|
751
|
+
detail: "killSwitch active after spawn; result not recorded"
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
if (spawnResult.exitCode === 0) {
|
|
755
|
+
await writeSuccessReceipt(projectRoot, task.id, spawnResult.exitCode);
|
|
756
|
+
const post2 = await readSentientState(statePath);
|
|
757
|
+
const { [task.id]: _removed, ...rest } = post2.stuckTasks;
|
|
758
|
+
void _removed;
|
|
759
|
+
await patchSentientState(statePath, {
|
|
760
|
+
stuckTasks: rest,
|
|
761
|
+
activeTaskId: null,
|
|
762
|
+
lastTickAt: new Date(Date.now()).toISOString()
|
|
763
|
+
});
|
|
764
|
+
await incrementStats(statePath, { tasksCompleted: 1, ticksExecuted: 1 });
|
|
765
|
+
return {
|
|
766
|
+
kind: "success",
|
|
767
|
+
taskId: task.id,
|
|
768
|
+
detail: `task ${task.id} completed (exit=0)`
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
const currentAttempts = existingStuck?.attempts ?? 0;
|
|
772
|
+
const nextAttempts = currentAttempts + 1;
|
|
773
|
+
const failureReason = spawnResult.stderr.slice(-500) || `exit=${spawnResult.exitCode}`;
|
|
774
|
+
await writeFailureReceipt(
|
|
775
|
+
projectRoot,
|
|
776
|
+
task.id,
|
|
777
|
+
nextAttempts,
|
|
778
|
+
spawnResult.exitCode,
|
|
779
|
+
failureReason
|
|
780
|
+
);
|
|
781
|
+
await incrementStats(statePath, { tasksFailed: 1, ticksExecuted: 1 });
|
|
782
|
+
if (nextAttempts >= MAX_TASK_ATTEMPTS) {
|
|
783
|
+
const windowed = pruneStuckWindow(preSpawnState.stuckTimestamps, now);
|
|
784
|
+
windowed.push(now);
|
|
785
|
+
const stuckRecord2 = {
|
|
786
|
+
attempts: nextAttempts,
|
|
787
|
+
lastFailureAt: new Date(now).toISOString(),
|
|
788
|
+
nextRetryAt: Number.MAX_SAFE_INTEGER,
|
|
789
|
+
// owner-only release
|
|
790
|
+
lastReason: failureReason
|
|
791
|
+
};
|
|
792
|
+
const post2 = await readSentientState(statePath);
|
|
793
|
+
const updatedStuckTasks = {
|
|
794
|
+
...post2.stuckTasks,
|
|
795
|
+
[task.id]: stuckRecord2
|
|
796
|
+
};
|
|
797
|
+
const shouldSelfPause = windowed.length >= SELF_PAUSE_STUCK_THRESHOLD;
|
|
798
|
+
await patchSentientState(statePath, {
|
|
799
|
+
stuckTasks: updatedStuckTasks,
|
|
800
|
+
stuckTimestamps: windowed,
|
|
801
|
+
activeTaskId: null,
|
|
802
|
+
lastTickAt: new Date(now).toISOString(),
|
|
803
|
+
...shouldSelfPause ? { killSwitch: true, killSwitchReason: SELF_PAUSE_REASON } : {}
|
|
804
|
+
});
|
|
805
|
+
if (shouldSelfPause) {
|
|
806
|
+
return {
|
|
807
|
+
kind: "self-paused",
|
|
808
|
+
taskId: task.id,
|
|
809
|
+
detail: `task ${task.id} is stuck; self-pause fired (${windowed.length}/${SELF_PAUSE_STUCK_THRESHOLD} stucks in window)`
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
return {
|
|
813
|
+
kind: "stuck",
|
|
814
|
+
taskId: task.id,
|
|
815
|
+
detail: `task ${task.id} stuck after ${nextAttempts} attempts; owner must re-enable via \`cleo sentient resume\``
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
const backoff = RETRY_BACKOFF_MS[nextAttempts - 1] ?? RETRY_BACKOFF_MS[RETRY_BACKOFF_MS.length - 1];
|
|
819
|
+
const stuckRecord = {
|
|
820
|
+
attempts: nextAttempts,
|
|
821
|
+
lastFailureAt: new Date(now).toISOString(),
|
|
822
|
+
nextRetryAt: now + backoff,
|
|
823
|
+
lastReason: failureReason
|
|
824
|
+
};
|
|
825
|
+
const post = await readSentientState(statePath);
|
|
826
|
+
await patchSentientState(statePath, {
|
|
827
|
+
stuckTasks: { ...post.stuckTasks, [task.id]: stuckRecord },
|
|
828
|
+
activeTaskId: null,
|
|
829
|
+
lastTickAt: new Date(now).toISOString()
|
|
830
|
+
});
|
|
831
|
+
return {
|
|
832
|
+
kind: "failure",
|
|
833
|
+
taskId: task.id,
|
|
834
|
+
detail: `task ${task.id} failed (attempt=${nextAttempts}/${MAX_TASK_ATTEMPTS}); retry scheduled at ${new Date(now + backoff).toISOString()}`
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
async function safeRunTick(options) {
|
|
838
|
+
let outcome;
|
|
839
|
+
try {
|
|
840
|
+
outcome = await runTick(options);
|
|
841
|
+
} catch (err) {
|
|
842
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
843
|
+
try {
|
|
844
|
+
await incrementStats(options.statePath, { ticksExecuted: 1 });
|
|
845
|
+
} catch {
|
|
846
|
+
}
|
|
847
|
+
outcome = { kind: "error", taskId: null, detail: `tick threw: ${message}` };
|
|
848
|
+
}
|
|
849
|
+
const pickedTask = outcome.kind !== "no-task" && outcome.kind !== "killed" && outcome.kind !== "error" && outcome.taskId !== null;
|
|
850
|
+
await maybeTriggerDream(options.projectRoot, options, pickedTask).catch(() => {
|
|
851
|
+
});
|
|
852
|
+
return outcome;
|
|
853
|
+
}
|
|
854
|
+
function isFailureOutcome(outcome) {
|
|
855
|
+
return outcome.kind === "failure" || outcome.kind === "stuck" || outcome.kind === "self-paused";
|
|
856
|
+
}
|
|
857
|
+
async function getKillStatus(statePath) {
|
|
858
|
+
const state = await readSentientState(statePath);
|
|
859
|
+
return { killSwitch: state.killSwitch, killSwitchReason: state.killSwitchReason };
|
|
860
|
+
}
|
|
861
|
+
function _resetDreamTickState() {
|
|
862
|
+
consecutiveIdleTicks = 0;
|
|
863
|
+
}
|
|
864
|
+
function _getConsecutiveIdleTicks() {
|
|
865
|
+
return consecutiveIdleTicks;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// packages/core/src/sentient/daemon.ts
|
|
869
|
+
var SENTIENT_STATE_FILE = ".cleo/sentient-state.json";
|
|
870
|
+
var SENTIENT_LOCK_FILE = ".cleo/sentient.lock";
|
|
871
|
+
var SENTIENT_CRON_EXPR = "*/5 * * * *";
|
|
872
|
+
var SENTIENT_PROPOSE_CRON_EXPR = "0 */2 * * *";
|
|
873
|
+
var SENTIENT_LOG_DIR = ".cleo/logs";
|
|
874
|
+
var SENTIENT_LOG = "sentient.log";
|
|
875
|
+
var SENTIENT_ERR = "sentient.err";
|
|
876
|
+
async function acquireLock(lockPath) {
|
|
877
|
+
await mkdir2(join3(lockPath, ".."), { recursive: true });
|
|
878
|
+
try {
|
|
879
|
+
const handle = await fsOpen(
|
|
880
|
+
lockPath,
|
|
881
|
+
fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_RDWR,
|
|
882
|
+
420
|
|
883
|
+
);
|
|
884
|
+
await handle.writeFile(String(process.pid), "utf-8");
|
|
885
|
+
return { path: lockPath, handle };
|
|
886
|
+
} catch (err) {
|
|
887
|
+
const code = err.code;
|
|
888
|
+
if (code !== "EEXIST") throw err;
|
|
889
|
+
}
|
|
890
|
+
let existing = null;
|
|
891
|
+
try {
|
|
892
|
+
existing = await fsOpen(lockPath, fsConstants.O_RDWR);
|
|
893
|
+
const buf = await existing.readFile({ encoding: "utf-8" });
|
|
894
|
+
const recordedPid = Number.parseInt(buf.trim(), 10);
|
|
895
|
+
if (Number.isFinite(recordedPid) && recordedPid > 0) {
|
|
896
|
+
try {
|
|
897
|
+
process.kill(recordedPid, 0);
|
|
898
|
+
await existing.close();
|
|
899
|
+
return null;
|
|
900
|
+
} catch {
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
await existing.truncate(0);
|
|
904
|
+
const pidBytes = Buffer.from(String(process.pid), "utf-8");
|
|
905
|
+
await existing.write(pidBytes, 0, pidBytes.length, 0);
|
|
906
|
+
return { path: lockPath, handle: existing };
|
|
907
|
+
} catch (err) {
|
|
908
|
+
if (existing) {
|
|
909
|
+
try {
|
|
910
|
+
await existing.close();
|
|
911
|
+
} catch {
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
throw err;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
async function releaseLock(lock) {
|
|
918
|
+
try {
|
|
919
|
+
await lock.handle.close();
|
|
920
|
+
} catch {
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
async function bootstrapDaemon(projectRoot) {
|
|
924
|
+
const statePath = join3(projectRoot, SENTIENT_STATE_FILE);
|
|
925
|
+
const lockPath = join3(projectRoot, SENTIENT_LOCK_FILE);
|
|
926
|
+
const lock = await acquireLock(lockPath);
|
|
927
|
+
if (!lock) {
|
|
928
|
+
process.stderr.write(`[CLEO SENTIENT] lock acquisition failed \u2014 another daemon is running
|
|
929
|
+
`);
|
|
930
|
+
process.exit(2);
|
|
931
|
+
}
|
|
932
|
+
await patchSentientState(statePath, {
|
|
933
|
+
pid: process.pid,
|
|
934
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
935
|
+
// Clear killSwitch on boot only if owner did not explicitly leave it set.
|
|
936
|
+
// We preserve it here: re-starting a killed daemon must not silently
|
|
937
|
+
// resume. Owner explicitly clears via `cleo sentient resume`.
|
|
938
|
+
});
|
|
939
|
+
let watcher = null;
|
|
940
|
+
try {
|
|
941
|
+
watcher = watch(statePath, { persistent: false }, () => {
|
|
942
|
+
});
|
|
943
|
+
} catch {
|
|
944
|
+
watcher = null;
|
|
945
|
+
}
|
|
946
|
+
const shutdown = async (reason) => {
|
|
947
|
+
try {
|
|
948
|
+
watcher?.close();
|
|
949
|
+
} catch {
|
|
950
|
+
}
|
|
951
|
+
try {
|
|
952
|
+
await patchSentientState(statePath, {
|
|
953
|
+
pid: null,
|
|
954
|
+
killSwitchReason: reason
|
|
955
|
+
});
|
|
956
|
+
} catch {
|
|
957
|
+
}
|
|
958
|
+
try {
|
|
959
|
+
await releaseLock(lock);
|
|
960
|
+
} catch {
|
|
961
|
+
}
|
|
962
|
+
process.exit(0);
|
|
963
|
+
};
|
|
964
|
+
process.on("SIGTERM", () => {
|
|
965
|
+
void shutdown("SIGTERM");
|
|
966
|
+
});
|
|
967
|
+
process.on("SIGINT", () => {
|
|
968
|
+
void shutdown("SIGINT");
|
|
969
|
+
});
|
|
970
|
+
const tickOptions = { projectRoot, statePath };
|
|
971
|
+
const outcome = await safeRunTick(tickOptions);
|
|
972
|
+
process.stdout.write(
|
|
973
|
+
`[CLEO SENTIENT] boot tick: ${outcome.kind} (task=${outcome.taskId ?? "n/a"}) ${outcome.detail}
|
|
974
|
+
`
|
|
975
|
+
);
|
|
976
|
+
cron.schedule(
|
|
977
|
+
SENTIENT_CRON_EXPR,
|
|
978
|
+
async () => {
|
|
979
|
+
const result = await safeRunTick(tickOptions);
|
|
980
|
+
process.stdout.write(
|
|
981
|
+
`[CLEO SENTIENT] tick: ${result.kind} (task=${result.taskId ?? "n/a"}) ${result.detail}
|
|
982
|
+
`
|
|
983
|
+
);
|
|
984
|
+
},
|
|
985
|
+
{
|
|
986
|
+
timezone: "UTC",
|
|
987
|
+
noOverlap: true,
|
|
988
|
+
name: "cleo-sentient"
|
|
989
|
+
}
|
|
990
|
+
);
|
|
991
|
+
const proposeOptions = { projectRoot, statePath };
|
|
992
|
+
cron.schedule(
|
|
993
|
+
SENTIENT_PROPOSE_CRON_EXPR,
|
|
994
|
+
async () => {
|
|
995
|
+
const state = await readSentientState(statePath);
|
|
996
|
+
if (!state.tier2Enabled) return;
|
|
997
|
+
const result = await safeRunProposeTick(proposeOptions);
|
|
998
|
+
process.stdout.write(
|
|
999
|
+
`[CLEO SENTIENT T2] propose: ${result.kind} (written=${result.written}, count=${result.count}) ${result.detail}
|
|
1000
|
+
`
|
|
1001
|
+
);
|
|
1002
|
+
},
|
|
1003
|
+
{
|
|
1004
|
+
timezone: "UTC",
|
|
1005
|
+
noOverlap: true,
|
|
1006
|
+
name: "cleo-sentient-propose"
|
|
1007
|
+
}
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1010
|
+
async function spawnSentientDaemon(projectRoot) {
|
|
1011
|
+
const logsDir = join3(projectRoot, SENTIENT_LOG_DIR);
|
|
1012
|
+
await mkdir2(logsDir, { recursive: true });
|
|
1013
|
+
const logPath = join3(logsDir, SENTIENT_LOG);
|
|
1014
|
+
const errPath = join3(logsDir, SENTIENT_ERR);
|
|
1015
|
+
const outStream = createWriteStream(logPath, { flags: "a" });
|
|
1016
|
+
const errStream = createWriteStream(errPath, { flags: "a" });
|
|
1017
|
+
const daemonEntry = join3(fileURLToPath(import.meta.url), "..", "daemon-entry.js");
|
|
1018
|
+
const child = spawn2(process.execPath, [daemonEntry, projectRoot], {
|
|
1019
|
+
detached: true,
|
|
1020
|
+
stdio: ["ignore", outStream, errStream],
|
|
1021
|
+
env: { ...process.env, CLEO_SENTIENT_DAEMON: "1" }
|
|
1022
|
+
});
|
|
1023
|
+
child.unref();
|
|
1024
|
+
const pid = child.pid ?? 0;
|
|
1025
|
+
const statePath = join3(projectRoot, SENTIENT_STATE_FILE);
|
|
1026
|
+
await patchSentientState(statePath, {
|
|
1027
|
+
pid,
|
|
1028
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1029
|
+
});
|
|
1030
|
+
return { pid, statePath, logPath };
|
|
1031
|
+
}
|
|
1032
|
+
async function stopSentientDaemon(projectRoot, reason = "cleo sentient stop") {
|
|
1033
|
+
const statePath = join3(projectRoot, SENTIENT_STATE_FILE);
|
|
1034
|
+
const state = await readSentientState(statePath);
|
|
1035
|
+
await patchSentientState(statePath, {
|
|
1036
|
+
killSwitch: true,
|
|
1037
|
+
killSwitchReason: reason
|
|
1038
|
+
});
|
|
1039
|
+
const pid = state.pid;
|
|
1040
|
+
if (!pid) {
|
|
1041
|
+
return {
|
|
1042
|
+
stopped: false,
|
|
1043
|
+
pid: null,
|
|
1044
|
+
reason: "killSwitch set; no daemon pid recorded (no active process to signal)"
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
try {
|
|
1048
|
+
process.kill(pid, 0);
|
|
1049
|
+
} catch {
|
|
1050
|
+
await patchSentientState(statePath, { pid: null });
|
|
1051
|
+
return {
|
|
1052
|
+
stopped: true,
|
|
1053
|
+
pid,
|
|
1054
|
+
reason: `killSwitch set; daemon pid ${pid} was already dead (cleared)`
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
try {
|
|
1058
|
+
process.kill(pid, "SIGTERM");
|
|
1059
|
+
return { stopped: true, pid, reason: `killSwitch set + SIGTERM delivered to pid ${pid}` };
|
|
1060
|
+
} catch (err) {
|
|
1061
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1062
|
+
return {
|
|
1063
|
+
stopped: false,
|
|
1064
|
+
pid,
|
|
1065
|
+
reason: `killSwitch set but SIGTERM failed: ${message}`
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
async function resumeSentientDaemon(projectRoot) {
|
|
1070
|
+
const statePath = join3(projectRoot, SENTIENT_STATE_FILE);
|
|
1071
|
+
return patchSentientState(statePath, {
|
|
1072
|
+
killSwitch: false,
|
|
1073
|
+
killSwitchReason: null
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
async function getSentientDaemonStatus(projectRoot) {
|
|
1077
|
+
const statePath = join3(projectRoot, SENTIENT_STATE_FILE);
|
|
1078
|
+
const state = await readSentientState(statePath);
|
|
1079
|
+
let running = false;
|
|
1080
|
+
if (state.pid) {
|
|
1081
|
+
try {
|
|
1082
|
+
process.kill(state.pid, 0);
|
|
1083
|
+
running = true;
|
|
1084
|
+
} catch {
|
|
1085
|
+
running = false;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
return {
|
|
1089
|
+
running,
|
|
1090
|
+
pid: running ? state.pid : null,
|
|
1091
|
+
startedAt: state.startedAt,
|
|
1092
|
+
lastTickAt: state.lastTickAt,
|
|
1093
|
+
killSwitch: state.killSwitch,
|
|
1094
|
+
killSwitchReason: state.killSwitchReason,
|
|
1095
|
+
stats: state.stats,
|
|
1096
|
+
stuckCount: Object.keys(state.stuckTasks).length,
|
|
1097
|
+
activeTaskId: state.activeTaskId
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
export {
|
|
1101
|
+
BRAIN_INGESTER_LIMIT,
|
|
1102
|
+
BRAIN_LOOKBACK_DAYS,
|
|
1103
|
+
BRAIN_MIN_CITATION_COUNT,
|
|
1104
|
+
BRAIN_MIN_QUALITY_SCORE,
|
|
1105
|
+
COVERAGE_SUMMARY_PATH,
|
|
1106
|
+
DEFAULT_ADAPTER,
|
|
1107
|
+
DEFAULT_DAILY_PROPOSAL_LIMIT,
|
|
1108
|
+
DEFAULT_SENTIENT_STATE,
|
|
1109
|
+
DREAM_IDLE_TICKS_DEFAULT,
|
|
1110
|
+
DREAM_VOLUME_THRESHOLD_DEFAULT,
|
|
1111
|
+
GATES_JSONL_PATH,
|
|
1112
|
+
MAX_TASK_ATTEMPTS,
|
|
1113
|
+
MIN_LINE_COVERAGE_PCT,
|
|
1114
|
+
NEXUS_BASE_WEIGHT,
|
|
1115
|
+
NEXUS_MIN_CALLER_COUNT,
|
|
1116
|
+
NEXUS_MIN_DEGREE,
|
|
1117
|
+
NEXUS_QUERY_LIMIT,
|
|
1118
|
+
PROPOSAL_TITLE_PATTERN,
|
|
1119
|
+
RETRY_BACKOFF_MS,
|
|
1120
|
+
SELF_PAUSE_REASON,
|
|
1121
|
+
SELF_PAUSE_STUCK_THRESHOLD,
|
|
1122
|
+
SELF_PAUSE_WINDOW_MS,
|
|
1123
|
+
SENTIENT_CRON_EXPR,
|
|
1124
|
+
SENTIENT_ERR,
|
|
1125
|
+
SENTIENT_LOCK_FILE,
|
|
1126
|
+
SENTIENT_LOG,
|
|
1127
|
+
SENTIENT_LOG_DIR,
|
|
1128
|
+
SENTIENT_PROPOSE_CRON_EXPR,
|
|
1129
|
+
SENTIENT_STATE_FILE,
|
|
1130
|
+
SENTIENT_STATE_SCHEMA_VERSION,
|
|
1131
|
+
SENTIENT_TIER2_TAG,
|
|
1132
|
+
SPAWN_TIMEOUT_MS,
|
|
1133
|
+
TEST_BASE_WEIGHT,
|
|
1134
|
+
TIER2_LABEL,
|
|
1135
|
+
_getConsecutiveIdleTicks,
|
|
1136
|
+
_resetDreamTickState,
|
|
1137
|
+
acquireLock,
|
|
1138
|
+
bootstrapDaemon,
|
|
1139
|
+
computeBrainWeight,
|
|
1140
|
+
countTodayProposals,
|
|
1141
|
+
getKillStatus,
|
|
1142
|
+
getSentientDaemonStatus,
|
|
1143
|
+
incrementStats,
|
|
1144
|
+
isFailureOutcome,
|
|
1145
|
+
isRateLimitExceeded,
|
|
1146
|
+
patchSentientState,
|
|
1147
|
+
readSentientState,
|
|
1148
|
+
releaseLock,
|
|
1149
|
+
resumeSentientDaemon,
|
|
1150
|
+
runBrainIngester,
|
|
1151
|
+
runNexusIngester,
|
|
1152
|
+
runProposeTick,
|
|
1153
|
+
runTestIngester,
|
|
1154
|
+
runTick,
|
|
1155
|
+
safeRunProposeTick,
|
|
1156
|
+
safeRunTick,
|
|
1157
|
+
spawnSentientDaemon,
|
|
1158
|
+
stopSentientDaemon,
|
|
1159
|
+
transactionalInsertProposal,
|
|
1160
|
+
writeSentientState
|
|
1161
|
+
};
|
|
1162
|
+
//# sourceMappingURL=index.js.map
|