clementine-agent 1.0.91 → 1.0.93
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/agent/self-improve-loop.d.ts +102 -0
- package/dist/agent/self-improve-loop.js +347 -0
- package/dist/index.js +7 -0
- package/package.json +1 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Self-improve loop (autonomous fix consumer).
|
|
3
|
+
*
|
|
4
|
+
* Closes the gap between "we noticed a job is failing" and "we did
|
|
5
|
+
* something about it." Periodically scans
|
|
6
|
+
* ~/.clementine/self-improve/triggers/*.json (written by cron-scheduler
|
|
7
|
+
* when consecutiveErrors >= 3), classifies the failure pattern from
|
|
8
|
+
* recentErrors, and either:
|
|
9
|
+
*
|
|
10
|
+
* - Auto-applies a safe cron-config fix (mode, max_hours, max_turns)
|
|
11
|
+
* and DMs the OWNING agent via their bot
|
|
12
|
+
* - Writes a proposal to self-improve/pending-changes/ and DMs the
|
|
13
|
+
* owning agent the diagnosis (full audit-inbox button approval is
|
|
14
|
+
* a separate Phase 8b ship)
|
|
15
|
+
*
|
|
16
|
+
* After processing, the trigger file is removed. The existing
|
|
17
|
+
* fix-verification system (cron-scheduler.ts) records preFailureCount
|
|
18
|
+
* when a job's config changes and tracks whether the next run succeeds.
|
|
19
|
+
*
|
|
20
|
+
* Routing rule: notifications go to the owning agent's DM via their
|
|
21
|
+
* own bot using `dispatcher.send(text, { agentSlug })`. Unowned crons
|
|
22
|
+
* (no agentSlug) → Clementine's main bot DMs the owner.
|
|
23
|
+
*
|
|
24
|
+
* Idempotent: re-applying the same fix to an already-fixed job is a
|
|
25
|
+
* no-op; the trigger gets removed regardless.
|
|
26
|
+
*/
|
|
27
|
+
export interface TriggerFile {
|
|
28
|
+
jobName: string;
|
|
29
|
+
consecutiveErrors: number;
|
|
30
|
+
recentErrors: string[];
|
|
31
|
+
triggeredAt: string;
|
|
32
|
+
}
|
|
33
|
+
export type FixCategory = 'safe-cron-config' | 'risky' | 'noop' | 'unknown';
|
|
34
|
+
export interface FixRecipe {
|
|
35
|
+
category: FixCategory;
|
|
36
|
+
/** Description of what this fix does, for DMs. */
|
|
37
|
+
description: string;
|
|
38
|
+
/**
|
|
39
|
+
* For safe-cron-config: a function that mutates the job's frontmatter
|
|
40
|
+
* entry in-place. Returns true if any change was made (false = idempotent
|
|
41
|
+
* no-op because the fix is already applied).
|
|
42
|
+
*/
|
|
43
|
+
apply?: (job: Record<string, unknown>) => boolean;
|
|
44
|
+
}
|
|
45
|
+
export interface SelfImproveDispatcher {
|
|
46
|
+
send(text: string, context?: {
|
|
47
|
+
agentSlug?: string;
|
|
48
|
+
}): Promise<{
|
|
49
|
+
delivered: boolean;
|
|
50
|
+
channelErrors: Record<string, string>;
|
|
51
|
+
}>;
|
|
52
|
+
}
|
|
53
|
+
export interface SelfImproveLoopOptions {
|
|
54
|
+
/**
|
|
55
|
+
* Override the fallback safety-net tick interval. The loop is primarily
|
|
56
|
+
* event-driven; this is just a backstop. Used by tests to disable the
|
|
57
|
+
* timer entirely (set to 0 or a very large number).
|
|
58
|
+
*/
|
|
59
|
+
tickMs?: number;
|
|
60
|
+
/** Override directories for tests. */
|
|
61
|
+
triggersDir?: string;
|
|
62
|
+
pendingDir?: string;
|
|
63
|
+
cronPath?: string;
|
|
64
|
+
/**
|
|
65
|
+
* Disable the fs.watch event-driven path. Tests use this so they can
|
|
66
|
+
* call tick() directly without racing the watcher.
|
|
67
|
+
*/
|
|
68
|
+
disableWatch?: boolean;
|
|
69
|
+
}
|
|
70
|
+
export declare function classifyFailure(recentErrors: string[]): FixRecipe;
|
|
71
|
+
export declare class SelfImproveLoop {
|
|
72
|
+
private readonly tickMs;
|
|
73
|
+
private readonly triggersDir;
|
|
74
|
+
private readonly pendingDir;
|
|
75
|
+
private readonly cronPath;
|
|
76
|
+
private readonly dispatcher;
|
|
77
|
+
private readonly watchEnabled;
|
|
78
|
+
private timer;
|
|
79
|
+
private watcher;
|
|
80
|
+
private debounceTimer;
|
|
81
|
+
private running;
|
|
82
|
+
private ticking;
|
|
83
|
+
constructor(dispatcher: SelfImproveDispatcher, opts?: SelfImproveLoopOptions);
|
|
84
|
+
start(): void;
|
|
85
|
+
stop(): void;
|
|
86
|
+
/** Coalesce a burst of fs.watch events (multiple triggers landing in
|
|
87
|
+
* quick succession) into a single tick. */
|
|
88
|
+
private scheduleDebouncedTick;
|
|
89
|
+
/**
|
|
90
|
+
* Process all pending triggers. Public so tests + manual invocations
|
|
91
|
+
* (e.g., a `clementine self-improve tick` CLI command) can call it.
|
|
92
|
+
*/
|
|
93
|
+
tick(): Promise<{
|
|
94
|
+
processed: number;
|
|
95
|
+
applied: number;
|
|
96
|
+
pending: number;
|
|
97
|
+
noop: number;
|
|
98
|
+
}>;
|
|
99
|
+
private processOne;
|
|
100
|
+
private notifyAgent;
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=self-improve-loop.d.ts.map
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Self-improve loop (autonomous fix consumer).
|
|
3
|
+
*
|
|
4
|
+
* Closes the gap between "we noticed a job is failing" and "we did
|
|
5
|
+
* something about it." Periodically scans
|
|
6
|
+
* ~/.clementine/self-improve/triggers/*.json (written by cron-scheduler
|
|
7
|
+
* when consecutiveErrors >= 3), classifies the failure pattern from
|
|
8
|
+
* recentErrors, and either:
|
|
9
|
+
*
|
|
10
|
+
* - Auto-applies a safe cron-config fix (mode, max_hours, max_turns)
|
|
11
|
+
* and DMs the OWNING agent via their bot
|
|
12
|
+
* - Writes a proposal to self-improve/pending-changes/ and DMs the
|
|
13
|
+
* owning agent the diagnosis (full audit-inbox button approval is
|
|
14
|
+
* a separate Phase 8b ship)
|
|
15
|
+
*
|
|
16
|
+
* After processing, the trigger file is removed. The existing
|
|
17
|
+
* fix-verification system (cron-scheduler.ts) records preFailureCount
|
|
18
|
+
* when a job's config changes and tracks whether the next run succeeds.
|
|
19
|
+
*
|
|
20
|
+
* Routing rule: notifications go to the owning agent's DM via their
|
|
21
|
+
* own bot using `dispatcher.send(text, { agentSlug })`. Unowned crons
|
|
22
|
+
* (no agentSlug) → Clementine's main bot DMs the owner.
|
|
23
|
+
*
|
|
24
|
+
* Idempotent: re-applying the same fix to an already-fixed job is a
|
|
25
|
+
* no-op; the trigger gets removed regardless.
|
|
26
|
+
*/
|
|
27
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, watch, writeFileSync, } from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import matter from 'gray-matter';
|
|
30
|
+
import pino from 'pino';
|
|
31
|
+
import { BASE_DIR, SYSTEM_DIR } from '../config.js';
|
|
32
|
+
const logger = pino({ name: 'clementine.self-improve-loop' });
|
|
33
|
+
/**
|
|
34
|
+
* Fallback tick interval. The loop is primarily event-driven via fs.watch
|
|
35
|
+
* on the triggers directory — this is just a slow safety net for cases
|
|
36
|
+
* where fs.watch dropped an event (rare but possible) or where the
|
|
37
|
+
* daemon booted with triggers already in place from before fs.watch was
|
|
38
|
+
* registered. 1h is plenty: the upstream cron scheduler runs at most
|
|
39
|
+
* once per minute, and a job needs 3+ consecutive errors to even produce
|
|
40
|
+
* a trigger, so the situation is already hours-stale by the time we see
|
|
41
|
+
* a trigger.
|
|
42
|
+
*/
|
|
43
|
+
const FALLBACK_TICK_MS = 60 * 60 * 1000;
|
|
44
|
+
/** Coalesce a burst of fs.watch events into a single tick. */
|
|
45
|
+
const WATCH_DEBOUNCE_MS = 2000;
|
|
46
|
+
const TRIGGERS_DIR = path.join(BASE_DIR, 'self-improve', 'triggers');
|
|
47
|
+
const PENDING_CHANGES_DIR = path.join(BASE_DIR, 'self-improve', 'pending-changes');
|
|
48
|
+
const CRON_PATH = path.join(SYSTEM_DIR, 'CRON.md');
|
|
49
|
+
// ── Pattern recognition ──────────────────────────────────────────────
|
|
50
|
+
const PATTERNS = [
|
|
51
|
+
{
|
|
52
|
+
// "Reached maximum number of turns (8)"
|
|
53
|
+
match: /Reached maximum number of turns/i,
|
|
54
|
+
recipe: () => ({
|
|
55
|
+
category: 'safe-cron-config',
|
|
56
|
+
description: 'Hit max-turns ceiling repeatedly. Switching to unleashed mode (multi-phase) so the job can complete its workflow.',
|
|
57
|
+
apply: (job) => {
|
|
58
|
+
let changed = false;
|
|
59
|
+
if (job.mode !== 'unleashed') {
|
|
60
|
+
job.mode = 'unleashed';
|
|
61
|
+
changed = true;
|
|
62
|
+
}
|
|
63
|
+
if (typeof job.max_hours !== 'number' || job.max_hours < 1) {
|
|
64
|
+
job.max_hours = 1;
|
|
65
|
+
changed = true;
|
|
66
|
+
}
|
|
67
|
+
return changed;
|
|
68
|
+
},
|
|
69
|
+
}),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
// "Autocompact is thrashing: the context refilled to the limit within 3 turns"
|
|
73
|
+
match: /Autocompact is thrashing/i,
|
|
74
|
+
recipe: () => ({
|
|
75
|
+
category: 'safe-cron-config',
|
|
76
|
+
description: 'Context window blowing up mid-run. Switching to unleashed mode so each phase starts with a fresh context.',
|
|
77
|
+
apply: (job) => {
|
|
78
|
+
let changed = false;
|
|
79
|
+
if (job.mode !== 'unleashed') {
|
|
80
|
+
job.mode = 'unleashed';
|
|
81
|
+
changed = true;
|
|
82
|
+
}
|
|
83
|
+
if (typeof job.max_hours !== 'number' || job.max_hours < 1) {
|
|
84
|
+
job.max_hours = 1;
|
|
85
|
+
changed = true;
|
|
86
|
+
}
|
|
87
|
+
return changed;
|
|
88
|
+
},
|
|
89
|
+
}),
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
// Already-fixed-in-code patterns
|
|
93
|
+
match: /This model does not support user-configurable task budgets|Budget exceeded for cron job/i,
|
|
94
|
+
recipe: () => ({
|
|
95
|
+
category: 'noop',
|
|
96
|
+
description: 'Old taskBudget rejection — already addressed in v1.0.90 (taskBudget no longer passed to SDK). Trigger cleared without action.',
|
|
97
|
+
}),
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
export function classifyFailure(recentErrors) {
|
|
101
|
+
const blob = recentErrors.join('\n').slice(0, 4000);
|
|
102
|
+
for (const { match, recipe } of PATTERNS) {
|
|
103
|
+
if (match.test(blob))
|
|
104
|
+
return recipe();
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
category: 'unknown',
|
|
108
|
+
description: 'Unrecognized failure pattern. Owner needs to inspect the trigger file.',
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function loadCronJob(jobName, cronPath) {
|
|
112
|
+
if (!existsSync(cronPath))
|
|
113
|
+
return null;
|
|
114
|
+
const raw = readFileSync(cronPath, 'utf-8');
|
|
115
|
+
const parsed = matter(raw);
|
|
116
|
+
const jobs = (parsed.data.jobs ?? []);
|
|
117
|
+
const job = jobs.find((j) => String(j.name ?? '') === jobName);
|
|
118
|
+
if (!job)
|
|
119
|
+
return null;
|
|
120
|
+
const agentSlug = typeof job.agentSlug === 'string' ? job.agentSlug : (typeof job.agent_slug === 'string' ? job.agent_slug : undefined);
|
|
121
|
+
return { agentSlug, job, raw, parsed };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Apply the recipe's mutator to the job's frontmatter and write CRON.md
|
|
125
|
+
* back atomically. Returns true if a change was actually written.
|
|
126
|
+
*/
|
|
127
|
+
function applyCronEdit(jobName, recipe, cronPath) {
|
|
128
|
+
if (!recipe.apply)
|
|
129
|
+
return false;
|
|
130
|
+
const lookup = loadCronJob(jobName, cronPath);
|
|
131
|
+
if (!lookup) {
|
|
132
|
+
logger.warn({ jobName }, 'Job not found in CRON.md — cannot apply fix');
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
const changed = recipe.apply(lookup.job);
|
|
136
|
+
if (!changed)
|
|
137
|
+
return false;
|
|
138
|
+
// Re-stringify with the existing content body preserved.
|
|
139
|
+
const updated = matter.stringify(lookup.parsed.content, lookup.parsed.data);
|
|
140
|
+
writeFileSync(cronPath, updated);
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
function writePendingChange(record, dir) {
|
|
144
|
+
mkdirSync(dir, { recursive: true });
|
|
145
|
+
const file = path.join(dir, `${record.id}.json`);
|
|
146
|
+
writeFileSync(file, JSON.stringify(record, null, 2));
|
|
147
|
+
return file;
|
|
148
|
+
}
|
|
149
|
+
// ── Main loop ────────────────────────────────────────────────────────
|
|
150
|
+
export class SelfImproveLoop {
|
|
151
|
+
tickMs;
|
|
152
|
+
triggersDir;
|
|
153
|
+
pendingDir;
|
|
154
|
+
cronPath;
|
|
155
|
+
dispatcher;
|
|
156
|
+
watchEnabled;
|
|
157
|
+
timer = null;
|
|
158
|
+
watcher = null;
|
|
159
|
+
debounceTimer = null;
|
|
160
|
+
running = false;
|
|
161
|
+
ticking = false;
|
|
162
|
+
constructor(dispatcher, opts = {}) {
|
|
163
|
+
this.dispatcher = dispatcher;
|
|
164
|
+
this.tickMs = opts.tickMs ?? FALLBACK_TICK_MS;
|
|
165
|
+
this.triggersDir = opts.triggersDir ?? TRIGGERS_DIR;
|
|
166
|
+
this.pendingDir = opts.pendingDir ?? PENDING_CHANGES_DIR;
|
|
167
|
+
this.cronPath = opts.cronPath ?? CRON_PATH;
|
|
168
|
+
this.watchEnabled = opts.disableWatch !== true;
|
|
169
|
+
}
|
|
170
|
+
start() {
|
|
171
|
+
if (this.running)
|
|
172
|
+
return;
|
|
173
|
+
this.running = true;
|
|
174
|
+
// Run immediately so any backlog from the prior daemon gets handled
|
|
175
|
+
// without a long wait.
|
|
176
|
+
this.tick().catch((err) => logger.error({ err }, 'Initial self-improve tick failed'));
|
|
177
|
+
// Event-driven primary path: watch the triggers dir. cron-scheduler
|
|
178
|
+
// writes a file when a job hits consErrors >= 3; we react within
|
|
179
|
+
// ~2 seconds (debounce window) instead of polling every 10 minutes
|
|
180
|
+
// for a directory that's empty 99% of the time.
|
|
181
|
+
if (this.watchEnabled) {
|
|
182
|
+
try {
|
|
183
|
+
mkdirSync(this.triggersDir, { recursive: true });
|
|
184
|
+
this.watcher = watch(this.triggersDir, (eventType, filename) => {
|
|
185
|
+
if (eventType !== 'rename' || !filename || !filename.endsWith('.json'))
|
|
186
|
+
return;
|
|
187
|
+
this.scheduleDebouncedTick();
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
logger.warn({ err, dir: this.triggersDir }, 'Failed to watch triggers dir — falling back to polling only');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// Slow fallback safety net — covers fs.watch event drops + boot-with-backlog.
|
|
195
|
+
this.timer = setInterval(() => {
|
|
196
|
+
this.tick().catch((err) => logger.error({ err }, 'Self-improve fallback tick failed'));
|
|
197
|
+
}, this.tickMs);
|
|
198
|
+
logger.info({ fallbackTickMs: this.tickMs, watchEnabled: this.watchEnabled && this.watcher !== null }, 'Self-improve loop started');
|
|
199
|
+
}
|
|
200
|
+
stop() {
|
|
201
|
+
if (!this.running)
|
|
202
|
+
return;
|
|
203
|
+
this.running = false;
|
|
204
|
+
if (this.timer) {
|
|
205
|
+
clearInterval(this.timer);
|
|
206
|
+
this.timer = null;
|
|
207
|
+
}
|
|
208
|
+
if (this.watcher) {
|
|
209
|
+
try {
|
|
210
|
+
this.watcher.close();
|
|
211
|
+
}
|
|
212
|
+
catch { /* ignore */ }
|
|
213
|
+
this.watcher = null;
|
|
214
|
+
}
|
|
215
|
+
if (this.debounceTimer) {
|
|
216
|
+
clearTimeout(this.debounceTimer);
|
|
217
|
+
this.debounceTimer = null;
|
|
218
|
+
}
|
|
219
|
+
logger.info('Self-improve loop stopped');
|
|
220
|
+
}
|
|
221
|
+
/** Coalesce a burst of fs.watch events (multiple triggers landing in
|
|
222
|
+
* quick succession) into a single tick. */
|
|
223
|
+
scheduleDebouncedTick() {
|
|
224
|
+
if (this.debounceTimer)
|
|
225
|
+
clearTimeout(this.debounceTimer);
|
|
226
|
+
this.debounceTimer = setTimeout(() => {
|
|
227
|
+
this.debounceTimer = null;
|
|
228
|
+
this.tick().catch((err) => logger.error({ err }, 'Self-improve event-driven tick failed'));
|
|
229
|
+
}, WATCH_DEBOUNCE_MS);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Process all pending triggers. Public so tests + manual invocations
|
|
233
|
+
* (e.g., a `clementine self-improve tick` CLI command) can call it.
|
|
234
|
+
*/
|
|
235
|
+
async tick() {
|
|
236
|
+
if (this.ticking)
|
|
237
|
+
return { processed: 0, applied: 0, pending: 0, noop: 0 };
|
|
238
|
+
this.ticking = true;
|
|
239
|
+
const counts = { processed: 0, applied: 0, pending: 0, noop: 0 };
|
|
240
|
+
try {
|
|
241
|
+
if (!existsSync(this.triggersDir))
|
|
242
|
+
return counts;
|
|
243
|
+
let files;
|
|
244
|
+
try {
|
|
245
|
+
files = readdirSync(this.triggersDir).filter((f) => f.endsWith('.json'));
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return counts;
|
|
249
|
+
}
|
|
250
|
+
for (const file of files) {
|
|
251
|
+
const triggerPath = path.join(this.triggersDir, file);
|
|
252
|
+
let trigger;
|
|
253
|
+
try {
|
|
254
|
+
trigger = JSON.parse(readFileSync(triggerPath, 'utf-8'));
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
logger.warn({ err, file }, 'Failed to parse trigger — removing');
|
|
258
|
+
try {
|
|
259
|
+
unlinkSync(triggerPath);
|
|
260
|
+
}
|
|
261
|
+
catch { /* ignore */ }
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
await this.processOne(trigger, counts);
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
logger.warn({ err, jobName: trigger.jobName }, 'Failed to process trigger — leaving in place for next tick');
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
// Successfully handled — remove the trigger
|
|
272
|
+
try {
|
|
273
|
+
unlinkSync(triggerPath);
|
|
274
|
+
}
|
|
275
|
+
catch { /* ignore */ }
|
|
276
|
+
counts.processed++;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
this.ticking = false;
|
|
281
|
+
}
|
|
282
|
+
if (counts.processed > 0) {
|
|
283
|
+
logger.info(counts, 'Self-improve loop: processed triggers');
|
|
284
|
+
}
|
|
285
|
+
return counts;
|
|
286
|
+
}
|
|
287
|
+
async processOne(trigger, counts) {
|
|
288
|
+
const recipe = classifyFailure(trigger.recentErrors);
|
|
289
|
+
const lookup = loadCronJob(trigger.jobName, this.cronPath);
|
|
290
|
+
const agentSlug = lookup?.agentSlug;
|
|
291
|
+
if (recipe.category === 'safe-cron-config') {
|
|
292
|
+
const applied = applyCronEdit(trigger.jobName, recipe, this.cronPath);
|
|
293
|
+
if (applied) {
|
|
294
|
+
counts.applied++;
|
|
295
|
+
await this.notifyAgent(agentSlug, [
|
|
296
|
+
`🔧 **Auto-fixed** \`${trigger.jobName}\` after ${trigger.consecutiveErrors} consecutive failures.`,
|
|
297
|
+
'',
|
|
298
|
+
recipe.description,
|
|
299
|
+
'',
|
|
300
|
+
'I\'ll watch the next run to confirm it lands cleanly.',
|
|
301
|
+
].join('\n'));
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
counts.noop++;
|
|
305
|
+
logger.info({ jobName: trigger.jobName }, 'Fix recipe applied is already in place — trigger removed without further action');
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (recipe.category === 'noop') {
|
|
310
|
+
counts.noop++;
|
|
311
|
+
logger.info({ jobName: trigger.jobName, reason: recipe.description }, 'Self-improve: no-op');
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
// risky | unknown → write proposal + DM agent
|
|
315
|
+
const id = `proposal-${Date.now()}-${trigger.jobName.replace(/[^a-z0-9-]/gi, '_')}`;
|
|
316
|
+
const record = {
|
|
317
|
+
id,
|
|
318
|
+
jobName: trigger.jobName,
|
|
319
|
+
...(agentSlug ? { agentSlug } : {}),
|
|
320
|
+
category: recipe.category,
|
|
321
|
+
description: recipe.description,
|
|
322
|
+
recentErrors: trigger.recentErrors,
|
|
323
|
+
consecutiveErrors: trigger.consecutiveErrors,
|
|
324
|
+
proposedAt: new Date().toISOString(),
|
|
325
|
+
};
|
|
326
|
+
const file = writePendingChange(record, this.pendingDir);
|
|
327
|
+
counts.pending++;
|
|
328
|
+
await this.notifyAgent(agentSlug, [
|
|
329
|
+
`⚠️ **${trigger.jobName}** has failed ${trigger.consecutiveErrors} times in a row.`,
|
|
330
|
+
'',
|
|
331
|
+
recipe.description,
|
|
332
|
+
'',
|
|
333
|
+
`Proposal saved to \`${file}\`. Review when convenient.`,
|
|
334
|
+
'',
|
|
335
|
+
'_(approve flow via #audit-inbox buttons coming in P8b)_',
|
|
336
|
+
].join('\n'));
|
|
337
|
+
}
|
|
338
|
+
async notifyAgent(agentSlug, message) {
|
|
339
|
+
try {
|
|
340
|
+
await this.dispatcher.send(message, agentSlug && agentSlug !== 'clementine' ? { agentSlug } : {});
|
|
341
|
+
}
|
|
342
|
+
catch (err) {
|
|
343
|
+
logger.debug({ err, agentSlug }, 'Failed to dispatch self-improve notification (non-fatal)');
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
//# sourceMappingURL=self-improve-loop.js.map
|
package/dist/index.js
CHANGED
|
@@ -665,6 +665,11 @@ async function asyncMain() {
|
|
|
665
665
|
// agent's profile and routes output to their Discord channel.
|
|
666
666
|
const { AgentHeartbeatManager } = await import('./gateway/agent-heartbeat-manager.js');
|
|
667
667
|
const agentHeartbeats = new AgentHeartbeatManager(gateway.getAgentManager(), gateway);
|
|
668
|
+
// Self-improve loop — closes the gap between "trigger written" and
|
|
669
|
+
// "fix applied." Every 10 min, scans self-improve/triggers/, classifies
|
|
670
|
+
// failures, auto-applies safe cron-config fixes, escalates risky ones.
|
|
671
|
+
const { SelfImproveLoop } = await import('./agent/self-improve-loop.js');
|
|
672
|
+
const selfImproveLoop = new SelfImproveLoop(dispatcher);
|
|
668
673
|
// ── Build channel tasks ──────────────────────────────────────────
|
|
669
674
|
const channelTasks = [];
|
|
670
675
|
const activeChannels = [];
|
|
@@ -762,6 +767,7 @@ async function asyncMain() {
|
|
|
762
767
|
heartbeat.start();
|
|
763
768
|
cronScheduler.start();
|
|
764
769
|
agentHeartbeats.start();
|
|
770
|
+
selfImproveLoop.start();
|
|
765
771
|
// Background-task hygiene: any task left in 'running' is from a prior
|
|
766
772
|
// process. Mark them aborted so the lifecycle is honest. (P6b will add
|
|
767
773
|
// resumability; for now fail-fast is clearer than silently re-running.)
|
|
@@ -966,6 +972,7 @@ async function asyncMain() {
|
|
|
966
972
|
heartbeat.stop();
|
|
967
973
|
cronScheduler.stop();
|
|
968
974
|
agentHeartbeats.stop();
|
|
975
|
+
selfImproveLoop.stop();
|
|
969
976
|
// ── Self-restart (enhanced with health check + rollback) ────────
|
|
970
977
|
if (restartRequested) {
|
|
971
978
|
// Clear our PID file BEFORE spawning the child, so ensureSingleton()
|