@cleocode/core 2026.4.98 → 2026.4.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/daemon.js +481 -0
- package/dist/gc/daemon.js.map +7 -0
- package/dist/gc/index.d.ts +14 -0
- package/dist/gc/index.d.ts.map +1 -0
- package/dist/gc/index.js +669 -0
- package/dist/gc/index.js.map +7 -0
- package/dist/gc/runner.d.ts +132 -0
- package/dist/gc/runner.d.ts.map +1 -0
- package/dist/gc/runner.js +360 -0
- package/dist/gc/runner.js.map +7 -0
- package/dist/gc/state.d.ts +94 -0
- package/dist/gc/state.d.ts.map +1 -0
- package/dist/gc/state.js +49 -0
- package/dist/gc/state.js.map +7 -0
- package/dist/gc/transcript.d.ts +130 -0
- package/dist/gc/transcript.d.ts.map +1 -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-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/daemon.js +1100 -0
- package/dist/sentient/daemon.js.map +7 -0
- package/dist/sentient/index.d.ts +18 -0
- package/dist/sentient/index.d.ts.map +1 -0
- package/dist/sentient/index.js +1162 -0
- package/dist/sentient/index.js.map +7 -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/propose-tick.js +549 -0
- package/dist/sentient/propose-tick.js.map +7 -0
- package/dist/sentient/state.d.ts +143 -0
- package/dist/sentient/state.d.ts.map +1 -0
- package/dist/sentient/state.js +85 -0
- package/dist/sentient/state.js.map +7 -0
- package/dist/sentient/tick.d.ts +193 -0
- package/dist/sentient/tick.d.ts.map +1 -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 +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,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentient Loop Propose Tick — Single-pass Tier-2 proposal generator.
|
|
3
|
+
*
|
|
4
|
+
* Runs inside the daemon cron (every 2 hours) or standalone via
|
|
5
|
+
* `cleo sentient propose run`. Orchestrates the three ingesters,
|
|
6
|
+
* deduplicates candidates by fingerprint, applies the DB-enforced
|
|
7
|
+
* rate limit, and writes accepted candidates as tasks with
|
|
8
|
+
* `status='proposed'` and labels including `'sentient-tier2'`.
|
|
9
|
+
*
|
|
10
|
+
* Scoped IN:
|
|
11
|
+
* - Ingest from brain.db, nexus.db, and .cleo/audit/gates.jsonl
|
|
12
|
+
* - Transactional rate-limit check (BEGIN IMMEDIATE + COUNT + INSERT)
|
|
13
|
+
* - Kill-switch re-check at each checkpoint (Round 2 audit §9)
|
|
14
|
+
* - tier2Enabled guard (default false — owner opt-in)
|
|
15
|
+
*
|
|
16
|
+
* Scoped OUT:
|
|
17
|
+
* - LLM calls (NONE — all proposal titles are structured templates)
|
|
18
|
+
* - Tier-3 sandbox/merge (blocked on T992+T993+T995)
|
|
19
|
+
*
|
|
20
|
+
* Title format enforcement:
|
|
21
|
+
* All proposal titles MUST match `/^\[T2-(BRAIN|NEXUS|TEST)\]/`.
|
|
22
|
+
* This is the prompt-injection defence from T1008 §3.6 — no freeform
|
|
23
|
+
* LLM text can enter the task title column from the Tier-2 proposer.
|
|
24
|
+
*
|
|
25
|
+
* @task T1008
|
|
26
|
+
* @see ADR-054 — Sentient Loop Tier-2
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { ProposalCandidate } from '@cleocode/contracts';
|
|
30
|
+
import { runBrainIngester } from './ingesters/brain-ingester.js';
|
|
31
|
+
import { runNexusIngester } from './ingesters/nexus-ingester.js';
|
|
32
|
+
import { runTestIngester } from './ingesters/test-ingester.js';
|
|
33
|
+
import {
|
|
34
|
+
countTodayProposals,
|
|
35
|
+
DEFAULT_DAILY_PROPOSAL_LIMIT,
|
|
36
|
+
transactionalInsertProposal,
|
|
37
|
+
} from './proposal-rate-limiter.js';
|
|
38
|
+
import { patchSentientState, readSentientState } from './state.js';
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Constants
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Regex that ALL proposal titles MUST match.
|
|
46
|
+
* Enforces structured-template-only output (no freeform LLM text).
|
|
47
|
+
*/
|
|
48
|
+
export const PROPOSAL_TITLE_PATTERN = /^\[T2-(BRAIN|NEXUS|TEST)\]/;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Label applied to every Tier-2 proposal task.
|
|
52
|
+
* Used by the rate limiter to identify proposals.
|
|
53
|
+
*/
|
|
54
|
+
export const TIER2_LABEL = 'sentient-tier2';
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Outcome types
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/** Discriminant for the propose-tick outcome. */
|
|
61
|
+
export type ProposalTickOutcomeKind =
|
|
62
|
+
| 'killed' // killSwitch active
|
|
63
|
+
| 'disabled' // tier2Enabled = false
|
|
64
|
+
| 'rate-limited' // daily cap already reached
|
|
65
|
+
| 'no-candidates' // all ingesters returned empty
|
|
66
|
+
| 'wrote' // at least one proposal was written
|
|
67
|
+
| 'error'; // unexpected error
|
|
68
|
+
|
|
69
|
+
/** Structured outcome of a single propose-tick pass. */
|
|
70
|
+
export interface ProposeTickOutcome {
|
|
71
|
+
/** Discriminant describing how the tick ended. */
|
|
72
|
+
kind: ProposalTickOutcomeKind;
|
|
73
|
+
/** Number of proposals written in this pass. */
|
|
74
|
+
written: number;
|
|
75
|
+
/** Current daily proposal count at the end of the pass. */
|
|
76
|
+
count: number;
|
|
77
|
+
/** Human-readable detail (one line). */
|
|
78
|
+
detail: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Options
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
/** Options for {@link runProposeTick}. */
|
|
86
|
+
export interface ProposeTickOptions {
|
|
87
|
+
/** Absolute path to the project root (contains `.cleo/`). */
|
|
88
|
+
projectRoot: string;
|
|
89
|
+
/** Absolute path to sentient-state.json. */
|
|
90
|
+
statePath: string;
|
|
91
|
+
/**
|
|
92
|
+
* Override for the brain DB handle. Injected by tests to avoid
|
|
93
|
+
* opening a real brain.db. When omitted the real getBrainNativeDb() is used.
|
|
94
|
+
*/
|
|
95
|
+
brainDb?: import('node:sqlite').DatabaseSync | null;
|
|
96
|
+
/**
|
|
97
|
+
* Override for the nexus DB handle. Injected by tests.
|
|
98
|
+
* When omitted the real getNexusNativeDb() is used.
|
|
99
|
+
*/
|
|
100
|
+
nexusDb?: import('node:sqlite').DatabaseSync | null;
|
|
101
|
+
/**
|
|
102
|
+
* Override for the tasks DB handle (used by rate limiter + INSERT).
|
|
103
|
+
* Injected by tests. When omitted the real getNativeTasksDb() is used.
|
|
104
|
+
*/
|
|
105
|
+
tasksDb?: import('node:sqlite').DatabaseSync | null;
|
|
106
|
+
/**
|
|
107
|
+
* Override the task ID allocator. Injected by tests.
|
|
108
|
+
* When omitted the real allocateNextTaskId() is used.
|
|
109
|
+
*/
|
|
110
|
+
allocateTaskId?: () => Promise<string>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Helpers
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Compute a deduplication fingerprint for a candidate.
|
|
119
|
+
* Two candidates with the same source + sourceId are considered identical.
|
|
120
|
+
*/
|
|
121
|
+
function fingerprint(candidate: ProposalCandidate): string {
|
|
122
|
+
return `${candidate.source}:${candidate.sourceId}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Check whether the kill switch is currently active.
|
|
127
|
+
*/
|
|
128
|
+
async function killSwitchActive(statePath: string): Promise<boolean> {
|
|
129
|
+
const state = await readSentientState(statePath);
|
|
130
|
+
return state.killSwitch === true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Public API
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Run a single Tier-2 propose pass.
|
|
139
|
+
*
|
|
140
|
+
* Steps:
|
|
141
|
+
* 1. Check killSwitch → abort if true
|
|
142
|
+
* 2. Check tier2Enabled → abort if false
|
|
143
|
+
* 3. Run all three ingesters in parallel
|
|
144
|
+
* 4. Check killSwitch again (post-ingest checkpoint)
|
|
145
|
+
* 5. Merge + deduplicate candidates by fingerprint
|
|
146
|
+
* 6. Validate title format (must match PROPOSAL_TITLE_PATTERN)
|
|
147
|
+
* 7. Score + take top-N candidates (N = limit - countTodayProposals)
|
|
148
|
+
* 8. Check killSwitch again (pre-write checkpoint)
|
|
149
|
+
* 9. For each candidate: transactional INSERT into tasks.db
|
|
150
|
+
* 10. Update tier2Stats in state
|
|
151
|
+
*
|
|
152
|
+
* @param options - Propose tick options (see {@link ProposeTickOptions})
|
|
153
|
+
* @returns Structured outcome describing how the pass ended.
|
|
154
|
+
*/
|
|
155
|
+
export async function runProposeTick(options: ProposeTickOptions): Promise<ProposeTickOutcome> {
|
|
156
|
+
const { projectRoot, statePath } = options;
|
|
157
|
+
|
|
158
|
+
// Checkpoint 1: killSwitch before any work
|
|
159
|
+
if (await killSwitchActive(statePath)) {
|
|
160
|
+
return { kind: 'killed', written: 0, count: 0, detail: 'killSwitch active before ingest' };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Check tier2Enabled guard
|
|
164
|
+
const state = await readSentientState(statePath);
|
|
165
|
+
if (!state.tier2Enabled) {
|
|
166
|
+
return {
|
|
167
|
+
kind: 'disabled',
|
|
168
|
+
written: 0,
|
|
169
|
+
count: 0,
|
|
170
|
+
detail: 'tier2Enabled=false; enable via cleo sentient propose enable',
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Resolve DB handles
|
|
175
|
+
let brainDb: import('node:sqlite').DatabaseSync | null;
|
|
176
|
+
let nexusDb: import('node:sqlite').DatabaseSync | null;
|
|
177
|
+
let tasksNativeDb: import('node:sqlite').DatabaseSync | null;
|
|
178
|
+
|
|
179
|
+
if (options.brainDb !== undefined) {
|
|
180
|
+
brainDb = options.brainDb;
|
|
181
|
+
} else {
|
|
182
|
+
// Ensure brain.db is initialized before calling getBrainNativeDb
|
|
183
|
+
try {
|
|
184
|
+
const { getBrainDb, getBrainNativeDb } = await import('@cleocode/core/internal');
|
|
185
|
+
await getBrainDb(projectRoot);
|
|
186
|
+
brainDb = getBrainNativeDb();
|
|
187
|
+
} catch {
|
|
188
|
+
brainDb = null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (options.nexusDb !== undefined) {
|
|
193
|
+
nexusDb = options.nexusDb;
|
|
194
|
+
} else {
|
|
195
|
+
try {
|
|
196
|
+
const { getNexusNativeDb } = await import('@cleocode/core/internal');
|
|
197
|
+
nexusDb = getNexusNativeDb();
|
|
198
|
+
} catch {
|
|
199
|
+
nexusDb = null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (options.tasksDb !== undefined) {
|
|
204
|
+
tasksNativeDb = options.tasksDb;
|
|
205
|
+
} else {
|
|
206
|
+
const { getNativeDb, getDb } = await import('@cleocode/core/internal');
|
|
207
|
+
// Ensure tasks.db is initialized
|
|
208
|
+
await getDb(projectRoot);
|
|
209
|
+
tasksNativeDb = getNativeDb();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Run all three ingesters in parallel
|
|
213
|
+
const [brainCandidates, nexusCandidates, testCandidates] = await Promise.all([
|
|
214
|
+
Promise.resolve(runBrainIngester(brainDb)),
|
|
215
|
+
Promise.resolve(runNexusIngester(nexusDb)),
|
|
216
|
+
Promise.resolve(runTestIngester(projectRoot)),
|
|
217
|
+
]);
|
|
218
|
+
|
|
219
|
+
// Checkpoint 2: killSwitch after ingest
|
|
220
|
+
if (await killSwitchActive(statePath)) {
|
|
221
|
+
return {
|
|
222
|
+
kind: 'killed',
|
|
223
|
+
written: 0,
|
|
224
|
+
count: 0,
|
|
225
|
+
detail: 'killSwitch active after ingest phase',
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Merge + deduplicate by fingerprint
|
|
230
|
+
const seenFingerprints = new Set<string>();
|
|
231
|
+
const merged: ProposalCandidate[] = [];
|
|
232
|
+
|
|
233
|
+
for (const candidate of [...brainCandidates, ...nexusCandidates, ...testCandidates]) {
|
|
234
|
+
// Validate title format — reject candidates with non-template titles
|
|
235
|
+
if (!PROPOSAL_TITLE_PATTERN.test(candidate.title)) {
|
|
236
|
+
process.stderr.write(
|
|
237
|
+
`[sentient/propose-tick] Rejected candidate with invalid title format: "${candidate.title}"\n`,
|
|
238
|
+
);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const fp = fingerprint(candidate);
|
|
243
|
+
if (seenFingerprints.has(fp)) continue;
|
|
244
|
+
seenFingerprints.add(fp);
|
|
245
|
+
merged.push(candidate);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (merged.length === 0) {
|
|
249
|
+
return { kind: 'no-candidates', written: 0, count: 0, detail: 'no candidates from ingesters' };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Sort by weight descending
|
|
253
|
+
merged.sort((a, b) => b.weight - a.weight);
|
|
254
|
+
|
|
255
|
+
// Determine how many slots remain today
|
|
256
|
+
const currentCount = tasksNativeDb ? countTodayProposals(tasksNativeDb) : 0;
|
|
257
|
+
const slotsRemaining = Math.max(0, DEFAULT_DAILY_PROPOSAL_LIMIT - currentCount);
|
|
258
|
+
|
|
259
|
+
if (slotsRemaining === 0) {
|
|
260
|
+
return {
|
|
261
|
+
kind: 'rate-limited',
|
|
262
|
+
written: 0,
|
|
263
|
+
count: currentCount,
|
|
264
|
+
detail: `daily limit reached (${currentCount}/${DEFAULT_DAILY_PROPOSAL_LIMIT})`,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Take top-N candidates
|
|
269
|
+
const toWrite = merged.slice(0, slotsRemaining);
|
|
270
|
+
|
|
271
|
+
// Checkpoint 3: killSwitch before DB writes
|
|
272
|
+
if (await killSwitchActive(statePath)) {
|
|
273
|
+
return {
|
|
274
|
+
kind: 'killed',
|
|
275
|
+
written: 0,
|
|
276
|
+
count: currentCount,
|
|
277
|
+
detail: 'killSwitch active before write phase',
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Write proposals
|
|
282
|
+
let written = 0;
|
|
283
|
+
|
|
284
|
+
for (const candidate of toWrite) {
|
|
285
|
+
// Allocate task ID
|
|
286
|
+
let taskId: string;
|
|
287
|
+
if (options.allocateTaskId) {
|
|
288
|
+
taskId = await options.allocateTaskId();
|
|
289
|
+
} else {
|
|
290
|
+
const { allocateNextTaskId } = await import('@cleocode/core/internal');
|
|
291
|
+
taskId = await allocateNextTaskId(projectRoot);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const now = new Date().toISOString();
|
|
295
|
+
const labels = JSON.stringify([TIER2_LABEL, `source:${candidate.source}`]);
|
|
296
|
+
|
|
297
|
+
if (!tasksNativeDb) {
|
|
298
|
+
process.stderr.write('[sentient/propose-tick] tasks DB not available; skipping write\n');
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Use the SQL INSERT path (with metadata_json-equivalent stored in notes_json
|
|
303
|
+
// as a structured first element) and labels to mark the proposal.
|
|
304
|
+
// The rate limiter identifies proposals by: labels_json LIKE '%sentient-tier2%'
|
|
305
|
+
// This avoids needing a new column on the tasks table.
|
|
306
|
+
const notesJson = JSON.stringify([
|
|
307
|
+
JSON.stringify({
|
|
308
|
+
kind: 'proposal-meta',
|
|
309
|
+
proposedBy: 'sentient-tier2',
|
|
310
|
+
source: candidate.source,
|
|
311
|
+
sourceId: candidate.sourceId,
|
|
312
|
+
weight: candidate.weight,
|
|
313
|
+
proposedAt: now,
|
|
314
|
+
}),
|
|
315
|
+
]);
|
|
316
|
+
|
|
317
|
+
const insertSql = `
|
|
318
|
+
INSERT INTO tasks (
|
|
319
|
+
id, title, description, status, priority,
|
|
320
|
+
labels_json, notes_json,
|
|
321
|
+
created_at, updated_at,
|
|
322
|
+
role, scope
|
|
323
|
+
) VALUES (
|
|
324
|
+
:id, :title, :description, :status, :priority,
|
|
325
|
+
:labelsJson, :notesJson,
|
|
326
|
+
:createdAt, :updatedAt,
|
|
327
|
+
:role, :scope
|
|
328
|
+
)
|
|
329
|
+
`;
|
|
330
|
+
|
|
331
|
+
const insertParams = {
|
|
332
|
+
id: taskId,
|
|
333
|
+
title: candidate.title,
|
|
334
|
+
description: candidate.rationale,
|
|
335
|
+
status: 'proposed',
|
|
336
|
+
priority: 'medium',
|
|
337
|
+
labelsJson: labels,
|
|
338
|
+
notesJson,
|
|
339
|
+
createdAt: now,
|
|
340
|
+
updatedAt: now,
|
|
341
|
+
role: 'work',
|
|
342
|
+
scope: 'feature',
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
const result = transactionalInsertProposal(
|
|
347
|
+
tasksNativeDb,
|
|
348
|
+
insertSql,
|
|
349
|
+
insertParams,
|
|
350
|
+
DEFAULT_DAILY_PROPOSAL_LIMIT,
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
if (result.inserted) {
|
|
354
|
+
written++;
|
|
355
|
+
} else if (result.reason === 'rate-limit') {
|
|
356
|
+
// Rate limit hit mid-loop — stop writing.
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
// If 'busy', skip this one and continue.
|
|
360
|
+
} catch (err) {
|
|
361
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
362
|
+
process.stderr.write(`[sentient/propose-tick] INSERT failed for ${taskId}: ${message}\n`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Update tier2Stats
|
|
367
|
+
if (written > 0) {
|
|
368
|
+
const latestState = await readSentientState(statePath);
|
|
369
|
+
await patchSentientState(statePath, {
|
|
370
|
+
tier2Stats: {
|
|
371
|
+
...latestState.tier2Stats,
|
|
372
|
+
proposalsGenerated: latestState.tier2Stats.proposalsGenerated + written,
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const finalCount = tasksNativeDb ? countTodayProposals(tasksNativeDb) : currentCount + written;
|
|
378
|
+
|
|
379
|
+
if (written === 0) {
|
|
380
|
+
return {
|
|
381
|
+
kind: 'no-candidates',
|
|
382
|
+
written: 0,
|
|
383
|
+
count: finalCount,
|
|
384
|
+
detail: 'candidates available but none written (rate limit or DB unavailable)',
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
kind: 'wrote',
|
|
390
|
+
written,
|
|
391
|
+
count: finalCount,
|
|
392
|
+
detail: `wrote ${written} proposal(s) (${finalCount}/${DEFAULT_DAILY_PROPOSAL_LIMIT} today)`,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Safe wrapper for {@link runProposeTick} — swallows unexpected exceptions.
|
|
398
|
+
* Used by the daemon cron handler.
|
|
399
|
+
*
|
|
400
|
+
* @param options - Propose tick options
|
|
401
|
+
* @returns The propose tick outcome, or an error outcome if the tick threw.
|
|
402
|
+
*/
|
|
403
|
+
export async function safeRunProposeTick(options: ProposeTickOptions): Promise<ProposeTickOutcome> {
|
|
404
|
+
try {
|
|
405
|
+
return await runProposeTick(options);
|
|
406
|
+
} catch (err) {
|
|
407
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
408
|
+
return {
|
|
409
|
+
kind: 'error',
|
|
410
|
+
written: 0,
|
|
411
|
+
count: 0,
|
|
412
|
+
detail: `propose tick threw: ${message}`,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentient Loop State — Persistent state for the Tier-1 autonomous daemon.
|
|
3
|
+
*
|
|
4
|
+
* Stored in `.cleo/sentient-state.json` (plain JSON, not SQLite) to avoid
|
|
5
|
+
* SQLite WAL conflicts between the long-running daemon process and the
|
|
6
|
+
* main CLEO CLI process. Human-readable for debugging.
|
|
7
|
+
*
|
|
8
|
+
* The file is gitignored (see .gitignore §.cleo/ section) and survives
|
|
9
|
+
* restarts. Only `killSwitch`, `pid`, and `stats` fields are load-bearing
|
|
10
|
+
* across process boundaries.
|
|
11
|
+
*
|
|
12
|
+
* @see ADR-054 — Sentient Loop Tier-1 (autonomous task execution)
|
|
13
|
+
* @task T946
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
import type { Tier2Stats } from '@cleocode/contracts';
|
|
19
|
+
|
|
20
|
+
/** Schema version for sentient-state.json. Bump on breaking field changes. */
|
|
21
|
+
export const SENTIENT_STATE_SCHEMA_VERSION = '1.0' as const;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Per-task failure/backoff tracking for stuck detection.
|
|
25
|
+
* Keyed by task id in {@link SentientState.stuckTasks}.
|
|
26
|
+
*/
|
|
27
|
+
export interface StuckTaskRecord {
|
|
28
|
+
/** Number of consecutive failed spawn attempts for this task. */
|
|
29
|
+
attempts: number;
|
|
30
|
+
/** ISO-8601 timestamp of the most recent failure. */
|
|
31
|
+
lastFailureAt: string;
|
|
32
|
+
/** Unix epoch ms when the next retry becomes eligible. */
|
|
33
|
+
nextRetryAt: number;
|
|
34
|
+
/** Last captured failure reason (truncated to 500 chars). */
|
|
35
|
+
lastReason: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Rolling counters persisted across daemon restarts.
|
|
40
|
+
*/
|
|
41
|
+
export interface SentientStats {
|
|
42
|
+
/** Total tasks picked by the loop since creation. */
|
|
43
|
+
tasksPicked: number;
|
|
44
|
+
/** Total tasks that completed successfully. */
|
|
45
|
+
tasksCompleted: number;
|
|
46
|
+
/** Total tasks whose spawn exited non-zero. */
|
|
47
|
+
tasksFailed: number;
|
|
48
|
+
/** Total ticks executed (including no-op ticks). */
|
|
49
|
+
ticksExecuted: number;
|
|
50
|
+
/** Total ticks aborted early because kill switch was active. */
|
|
51
|
+
ticksKilled: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Persistent sentient daemon state.
|
|
56
|
+
*
|
|
57
|
+
* Design principles:
|
|
58
|
+
* - `killSwitch` is the single load-bearing kill signal — the daemon re-checks
|
|
59
|
+
* it between every step of a tick, not just at tick start (Round 2 audit).
|
|
60
|
+
* - `stuckTasks` keys are task ids; values encode backoff + failure counts.
|
|
61
|
+
* - `stuckTimestamps` is a rolling 1-hour window used for the self-pause rule
|
|
62
|
+
* (5 stucks in 1 hour → killSwitch=true).
|
|
63
|
+
* - `stats` fields are monotonic counters that only ever increase.
|
|
64
|
+
*/
|
|
65
|
+
export interface SentientState {
|
|
66
|
+
/** JSON schema version for forward-compatibility checks. */
|
|
67
|
+
schemaVersion: typeof SENTIENT_STATE_SCHEMA_VERSION;
|
|
68
|
+
/** PID of the currently running daemon process. null = daemon not running. */
|
|
69
|
+
pid: number | null;
|
|
70
|
+
/** ISO-8601 timestamp when the daemon was last started. */
|
|
71
|
+
startedAt: string | null;
|
|
72
|
+
/** ISO-8601 timestamp of the last completed tick (any outcome). */
|
|
73
|
+
lastTickAt: string | null;
|
|
74
|
+
/**
|
|
75
|
+
* Kill-switch flag. When true, the daemon re-checks at every step of a tick
|
|
76
|
+
* and exits cleanly without picking or spawning a task.
|
|
77
|
+
*/
|
|
78
|
+
killSwitch: boolean;
|
|
79
|
+
/** Reason supplied when killSwitch was last set (diagnostic only). */
|
|
80
|
+
killSwitchReason: string | null;
|
|
81
|
+
/** Rolling counters; see {@link SentientStats}. */
|
|
82
|
+
stats: SentientStats;
|
|
83
|
+
/** Per-task backoff + failure metadata for retry/stuck detection. */
|
|
84
|
+
stuckTasks: Record<string, StuckTaskRecord>;
|
|
85
|
+
/**
|
|
86
|
+
* Unix-epoch-ms timestamps of `stuck` events within the last hour.
|
|
87
|
+
* When length ≥ 5 the daemon self-pauses (killSwitch=true).
|
|
88
|
+
*/
|
|
89
|
+
stuckTimestamps: number[];
|
|
90
|
+
/**
|
|
91
|
+
* Currently-active task id (set while a spawn is in-flight, cleared afterward).
|
|
92
|
+
* Enables `status` to show the in-progress task during a long-running tick.
|
|
93
|
+
*/
|
|
94
|
+
activeTaskId: string | null;
|
|
95
|
+
/**
|
|
96
|
+
* Tier-2 proposal queue enabled flag.
|
|
97
|
+
*
|
|
98
|
+
* Default: `false` — Tier 2 is OFF by default to prevent surprise proposal
|
|
99
|
+
* floods on first daemon start. Owner enables via `cleo sentient propose enable`
|
|
100
|
+
* (patches this flag). See ADR-054 §Tier-2.
|
|
101
|
+
*
|
|
102
|
+
* @task T1008
|
|
103
|
+
*/
|
|
104
|
+
tier2Enabled: boolean;
|
|
105
|
+
/**
|
|
106
|
+
* Rolling counters for Tier-2 proposal activity.
|
|
107
|
+
*
|
|
108
|
+
* @task T1008
|
|
109
|
+
*/
|
|
110
|
+
tier2Stats: Tier2Stats;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Default (empty) sentient state for fresh initialisation. */
|
|
114
|
+
export const DEFAULT_SENTIENT_STATE: SentientState = {
|
|
115
|
+
schemaVersion: SENTIENT_STATE_SCHEMA_VERSION,
|
|
116
|
+
pid: null,
|
|
117
|
+
startedAt: null,
|
|
118
|
+
lastTickAt: null,
|
|
119
|
+
killSwitch: false,
|
|
120
|
+
killSwitchReason: null,
|
|
121
|
+
stats: {
|
|
122
|
+
tasksPicked: 0,
|
|
123
|
+
tasksCompleted: 0,
|
|
124
|
+
tasksFailed: 0,
|
|
125
|
+
ticksExecuted: 0,
|
|
126
|
+
ticksKilled: 0,
|
|
127
|
+
},
|
|
128
|
+
stuckTasks: {},
|
|
129
|
+
stuckTimestamps: [],
|
|
130
|
+
activeTaskId: null,
|
|
131
|
+
tier2Enabled: false,
|
|
132
|
+
tier2Stats: {
|
|
133
|
+
proposalsGenerated: 0,
|
|
134
|
+
proposalsAccepted: 0,
|
|
135
|
+
proposalsRejected: 0,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Read the sentient state from disk.
|
|
141
|
+
*
|
|
142
|
+
* Returns the default state if the file does not exist or is malformed.
|
|
143
|
+
* Never throws — absence is not an error.
|
|
144
|
+
*
|
|
145
|
+
* @param statePath - Absolute path to sentient-state.json
|
|
146
|
+
*/
|
|
147
|
+
export async function readSentientState(statePath: string): Promise<SentientState> {
|
|
148
|
+
try {
|
|
149
|
+
const raw = await readFile(statePath, 'utf-8');
|
|
150
|
+
const parsed = JSON.parse(raw) as Partial<SentientState>;
|
|
151
|
+
return {
|
|
152
|
+
...DEFAULT_SENTIENT_STATE,
|
|
153
|
+
...parsed,
|
|
154
|
+
stats: { ...DEFAULT_SENTIENT_STATE.stats, ...(parsed.stats ?? {}) },
|
|
155
|
+
stuckTasks: parsed.stuckTasks ?? {},
|
|
156
|
+
stuckTimestamps: parsed.stuckTimestamps ?? [],
|
|
157
|
+
tier2Enabled: parsed.tier2Enabled ?? false,
|
|
158
|
+
tier2Stats: { ...DEFAULT_SENTIENT_STATE.tier2Stats, ...(parsed.tier2Stats ?? {}) },
|
|
159
|
+
};
|
|
160
|
+
} catch {
|
|
161
|
+
return { ...DEFAULT_SENTIENT_STATE };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Write the sentient state to disk atomically via tmp-then-rename.
|
|
167
|
+
*
|
|
168
|
+
* Atomic write prevents partial reads if the daemon crashes mid-write.
|
|
169
|
+
*
|
|
170
|
+
* @param statePath - Absolute path to sentient-state.json
|
|
171
|
+
* @param state - State to persist
|
|
172
|
+
*/
|
|
173
|
+
export async function writeSentientState(statePath: string, state: SentientState): Promise<void> {
|
|
174
|
+
const dir = dirname(statePath);
|
|
175
|
+
await mkdir(dir, { recursive: true });
|
|
176
|
+
|
|
177
|
+
const tmpPath = join(dir, `.sentient-state-${process.pid}.tmp`);
|
|
178
|
+
const json = JSON.stringify(state, null, 2);
|
|
179
|
+
|
|
180
|
+
await writeFile(tmpPath, json, 'utf-8');
|
|
181
|
+
await rename(tmpPath, statePath);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Patch a subset of fields in the sentient state file.
|
|
186
|
+
*
|
|
187
|
+
* Reads current state, merges patch, writes back. Nested `stats` merges
|
|
188
|
+
* with existing stats (never clobbered wholesale).
|
|
189
|
+
*
|
|
190
|
+
* @param statePath - Absolute path to sentient-state.json
|
|
191
|
+
* @param patch - Partial state to merge over the existing state
|
|
192
|
+
* @returns The merged state that was written to disk.
|
|
193
|
+
*/
|
|
194
|
+
export async function patchSentientState(
|
|
195
|
+
statePath: string,
|
|
196
|
+
patch: Partial<SentientState>,
|
|
197
|
+
): Promise<SentientState> {
|
|
198
|
+
const current = await readSentientState(statePath);
|
|
199
|
+
const updated: SentientState = {
|
|
200
|
+
...current,
|
|
201
|
+
...patch,
|
|
202
|
+
stats: { ...current.stats, ...(patch.stats ?? {}) },
|
|
203
|
+
};
|
|
204
|
+
await writeSentientState(statePath, updated);
|
|
205
|
+
return updated;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Increment stats counters atomically.
|
|
210
|
+
*
|
|
211
|
+
* @param statePath - Absolute path to sentient-state.json
|
|
212
|
+
* @param delta - Partial stats to add to current counters
|
|
213
|
+
*/
|
|
214
|
+
export async function incrementStats(
|
|
215
|
+
statePath: string,
|
|
216
|
+
delta: Partial<SentientStats>,
|
|
217
|
+
): Promise<SentientState> {
|
|
218
|
+
const current = await readSentientState(statePath);
|
|
219
|
+
const nextStats: SentientStats = {
|
|
220
|
+
tasksPicked: current.stats.tasksPicked + (delta.tasksPicked ?? 0),
|
|
221
|
+
tasksCompleted: current.stats.tasksCompleted + (delta.tasksCompleted ?? 0),
|
|
222
|
+
tasksFailed: current.stats.tasksFailed + (delta.tasksFailed ?? 0),
|
|
223
|
+
ticksExecuted: current.stats.ticksExecuted + (delta.ticksExecuted ?? 0),
|
|
224
|
+
ticksKilled: current.stats.ticksKilled + (delta.ticksKilled ?? 0),
|
|
225
|
+
};
|
|
226
|
+
const updated: SentientState = { ...current, stats: nextStats };
|
|
227
|
+
await writeSentientState(statePath, updated);
|
|
228
|
+
return updated;
|
|
229
|
+
}
|