clementine-agent 1.0.76 → 1.0.78
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/insight-engine.js +3 -2
- package/dist/agent/proactive-engine.d.ts +50 -0
- package/dist/agent/proactive-engine.js +256 -0
- package/dist/agent/proactive-ledger.d.ts +44 -0
- package/dist/agent/proactive-ledger.js +176 -0
- package/dist/agent/self-improve.js +3 -5
- package/dist/gateway/agent-heartbeat-manager.d.ts +42 -0
- package/dist/gateway/agent-heartbeat-manager.js +123 -0
- package/dist/gateway/agent-heartbeat-scheduler.d.ts +48 -0
- package/dist/gateway/agent-heartbeat-scheduler.js +223 -0
- package/dist/gateway/cron-scheduler.d.ts +1 -0
- package/dist/gateway/cron-scheduler.js +41 -6
- package/dist/gateway/heartbeat-scheduler.d.ts +2 -0
- package/dist/gateway/heartbeat-scheduler.js +239 -76
- package/dist/index.js +34 -20
- package/dist/tools/session-tools.js +63 -6
- package/dist/types.d.ts +14 -0
- package/package.json +1 -1
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owns the lifecycle of all per-agent heartbeat schedulers.
|
|
3
|
+
*
|
|
4
|
+
* Boots at daemon start, scans the AgentManager's active list, spawns one
|
|
5
|
+
* AgentHeartbeatScheduler per agent. An outer 60s interval iterates the
|
|
6
|
+
* registry and fires `tick()` on any agent whose nextCheckAt is due.
|
|
7
|
+
*
|
|
8
|
+
* Reconciliation runs each outer tick: agents added to AGENTS_DIR start
|
|
9
|
+
* heartbeats automatically; agents removed (or paused/terminated) drop
|
|
10
|
+
* out. Per-agent failures are caught so one buggy agent can't crash the
|
|
11
|
+
* daemon or stall others.
|
|
12
|
+
*/
|
|
13
|
+
import pino from 'pino';
|
|
14
|
+
import { AgentHeartbeatScheduler } from './agent-heartbeat-scheduler.js';
|
|
15
|
+
const logger = pino({ name: 'clementine.agent-heartbeat-manager' });
|
|
16
|
+
const OUTER_TICK_MS = 60_000;
|
|
17
|
+
export class AgentHeartbeatManager {
|
|
18
|
+
agentManager;
|
|
19
|
+
schedulers = new Map();
|
|
20
|
+
timer = null;
|
|
21
|
+
running = false;
|
|
22
|
+
ticking = false;
|
|
23
|
+
constructor(agentManager) {
|
|
24
|
+
this.agentManager = agentManager;
|
|
25
|
+
}
|
|
26
|
+
start() {
|
|
27
|
+
if (this.running)
|
|
28
|
+
return;
|
|
29
|
+
this.running = true;
|
|
30
|
+
this.reconcile();
|
|
31
|
+
// Run an immediate tick so schedulers boot up without a 60s delay.
|
|
32
|
+
this.outerTick().catch((err) => logger.error({ err }, 'Initial agent heartbeat tick failed'));
|
|
33
|
+
this.timer = setInterval(() => {
|
|
34
|
+
this.outerTick().catch((err) => logger.error({ err }, 'Agent heartbeat outer tick failed'));
|
|
35
|
+
}, OUTER_TICK_MS);
|
|
36
|
+
logger.info({ agents: this.schedulers.size }, 'Agent heartbeat manager started');
|
|
37
|
+
}
|
|
38
|
+
stop() {
|
|
39
|
+
if (!this.running)
|
|
40
|
+
return;
|
|
41
|
+
this.running = false;
|
|
42
|
+
if (this.timer) {
|
|
43
|
+
clearInterval(this.timer);
|
|
44
|
+
this.timer = null;
|
|
45
|
+
}
|
|
46
|
+
this.schedulers.clear();
|
|
47
|
+
logger.info('Agent heartbeat manager stopped');
|
|
48
|
+
}
|
|
49
|
+
/** Add/remove schedulers to match the current AgentManager listing. */
|
|
50
|
+
reconcile() {
|
|
51
|
+
let active = [];
|
|
52
|
+
try {
|
|
53
|
+
active = this.agentManager
|
|
54
|
+
.listAll()
|
|
55
|
+
.filter((p) => p.slug !== 'clementine' && this.agentManager.isRunnable(p.slug))
|
|
56
|
+
.map((p) => p.slug);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
logger.warn({ err }, 'Failed to list agents during reconcile — keeping current set');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const activeSet = new Set(active);
|
|
63
|
+
// Add new
|
|
64
|
+
for (const slug of active) {
|
|
65
|
+
if (!this.schedulers.has(slug)) {
|
|
66
|
+
this.schedulers.set(slug, new AgentHeartbeatScheduler(slug, this.agentManager));
|
|
67
|
+
logger.info({ slug }, 'Agent heartbeat: registered scheduler');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// Remove gone-or-paused
|
|
71
|
+
for (const slug of [...this.schedulers.keys()]) {
|
|
72
|
+
if (!activeSet.has(slug)) {
|
|
73
|
+
this.schedulers.delete(slug);
|
|
74
|
+
logger.info({ slug }, 'Agent heartbeat: deregistered scheduler');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* One outer-loop tick. Reconcile the registry, then fire agents whose
|
|
80
|
+
* nextCheckAt has come due. Runs serially to avoid races on shared
|
|
81
|
+
* state (goals dir, cron runs dir).
|
|
82
|
+
*/
|
|
83
|
+
async outerTick(now = new Date()) {
|
|
84
|
+
if (this.ticking)
|
|
85
|
+
return; // prior outer tick still in flight — skip
|
|
86
|
+
this.ticking = true;
|
|
87
|
+
try {
|
|
88
|
+
this.reconcile();
|
|
89
|
+
for (const [slug, scheduler] of this.schedulers) {
|
|
90
|
+
try {
|
|
91
|
+
if (!scheduler.isDue(now))
|
|
92
|
+
continue;
|
|
93
|
+
await scheduler.tick(now);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
logger.warn({ err, slug }, 'Agent heartbeat tick failed — continuing');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
this.ticking = false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Diagnostic helper for the dashboard / CLI. */
|
|
105
|
+
getStatus() {
|
|
106
|
+
const out = [];
|
|
107
|
+
for (const [slug, scheduler] of this.schedulers) {
|
|
108
|
+
const state = scheduler.loadState();
|
|
109
|
+
out.push({
|
|
110
|
+
slug,
|
|
111
|
+
nextCheckAt: state.nextCheckAt,
|
|
112
|
+
lastTickAt: state.lastTickAt,
|
|
113
|
+
silentTickCount: state.silentTickCount,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
/** Look up a scheduler — useful for CLI commands like "tick this agent now." */
|
|
119
|
+
getScheduler(slug) {
|
|
120
|
+
return this.schedulers.get(slug) ?? null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
//# sourceMappingURL=agent-heartbeat-manager.js.map
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-agent heartbeat scheduler — one instance per specialist agent
|
|
3
|
+
* (Ross, Sasha, Nora, etc.). Runs autonomously alongside Clementine's
|
|
4
|
+
* own HeartbeatScheduler.
|
|
5
|
+
*
|
|
6
|
+
* Phase 2 — cheap path only. No LLM call. The tick loads state, scans
|
|
7
|
+
* three signals (pending delegated tasks, recent goal updates, recent
|
|
8
|
+
* cron completions), updates fingerprint, and persists state.
|
|
9
|
+
*
|
|
10
|
+
* Phase 3 will add the LLM-path tick (assistant.heartbeat() with the
|
|
11
|
+
* agent's profile) when the fingerprint indicates a real signal change.
|
|
12
|
+
*/
|
|
13
|
+
import type { AgentHeartbeatState } from '../types.js';
|
|
14
|
+
import type { AgentManager } from '../agent/agent-manager.js';
|
|
15
|
+
export interface AgentHeartbeatOptions {
|
|
16
|
+
/** Override the base directory for test isolation. Defaults to config.BASE_DIR. */
|
|
17
|
+
baseDir?: string;
|
|
18
|
+
/** Override the agents directory for test isolation. Defaults to config.AGENTS_DIR. */
|
|
19
|
+
agentsDir?: string;
|
|
20
|
+
}
|
|
21
|
+
export declare class AgentHeartbeatScheduler {
|
|
22
|
+
private readonly slug;
|
|
23
|
+
private readonly agentManager;
|
|
24
|
+
private readonly baseDir;
|
|
25
|
+
private readonly agentsDir;
|
|
26
|
+
private readonly stateFile;
|
|
27
|
+
constructor(slug: string, agentManager: AgentManager, opts?: AgentHeartbeatOptions);
|
|
28
|
+
/** Read persisted state, or return a fresh state ready to tick now. */
|
|
29
|
+
loadState(): AgentHeartbeatState;
|
|
30
|
+
saveState(state: AgentHeartbeatState): void;
|
|
31
|
+
/** True if the agent is due for a tick. */
|
|
32
|
+
isDue(now?: Date): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Compute a cheap fingerprint of "anything material to this agent."
|
|
35
|
+
* Three signals: pending delegated tasks, latest goal update, latest
|
|
36
|
+
* cron run timestamp. Sync filesystem reads — bounded and small.
|
|
37
|
+
*/
|
|
38
|
+
private buildFingerprint;
|
|
39
|
+
/**
|
|
40
|
+
* Cheap-path tick. Returns the new state. P3 will branch into an LLM
|
|
41
|
+
* call when the fingerprint changed; for now we just observe and log.
|
|
42
|
+
*/
|
|
43
|
+
tick(now?: Date): Promise<AgentHeartbeatState>;
|
|
44
|
+
/** Schedule the next check explicitly. Clamped to [MIN, MAX] minutes. */
|
|
45
|
+
setNextCheckIn(minutes: number, now?: Date): void;
|
|
46
|
+
getSlug(): string;
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=agent-heartbeat-scheduler.d.ts.map
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-agent heartbeat scheduler — one instance per specialist agent
|
|
3
|
+
* (Ross, Sasha, Nora, etc.). Runs autonomously alongside Clementine's
|
|
4
|
+
* own HeartbeatScheduler.
|
|
5
|
+
*
|
|
6
|
+
* Phase 2 — cheap path only. No LLM call. The tick loads state, scans
|
|
7
|
+
* three signals (pending delegated tasks, recent goal updates, recent
|
|
8
|
+
* cron completions), updates fingerprint, and persists state.
|
|
9
|
+
*
|
|
10
|
+
* Phase 3 will add the LLM-path tick (assistant.heartbeat() with the
|
|
11
|
+
* agent's profile) when the fingerprint indicates a real signal change.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, } from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import pino from 'pino';
|
|
17
|
+
import { AGENTS_DIR, BASE_DIR } from '../config.js';
|
|
18
|
+
import { listAllGoals } from '../tools/shared.js';
|
|
19
|
+
const logger = pino({ name: 'clementine.agent-heartbeat' });
|
|
20
|
+
const DEFAULT_INTERVAL_MIN = 30;
|
|
21
|
+
const MIN_INTERVAL_MIN = 5;
|
|
22
|
+
const MAX_INTERVAL_MIN = 12 * 60;
|
|
23
|
+
export class AgentHeartbeatScheduler {
|
|
24
|
+
slug;
|
|
25
|
+
agentManager;
|
|
26
|
+
baseDir;
|
|
27
|
+
agentsDir;
|
|
28
|
+
stateFile;
|
|
29
|
+
constructor(slug, agentManager, opts = {}) {
|
|
30
|
+
this.slug = slug;
|
|
31
|
+
this.agentManager = agentManager;
|
|
32
|
+
this.baseDir = opts.baseDir ?? BASE_DIR;
|
|
33
|
+
this.agentsDir = opts.agentsDir ?? AGENTS_DIR;
|
|
34
|
+
this.stateFile = path.join(this.baseDir, 'heartbeat', 'agents', slug, 'state.json');
|
|
35
|
+
}
|
|
36
|
+
/** Read persisted state, or return a fresh state ready to tick now. */
|
|
37
|
+
loadState() {
|
|
38
|
+
try {
|
|
39
|
+
if (existsSync(this.stateFile)) {
|
|
40
|
+
const raw = JSON.parse(readFileSync(this.stateFile, 'utf-8'));
|
|
41
|
+
return {
|
|
42
|
+
slug: this.slug,
|
|
43
|
+
lastTickAt: String(raw.lastTickAt ?? ''),
|
|
44
|
+
nextCheckAt: String(raw.nextCheckAt ?? new Date().toISOString()),
|
|
45
|
+
silentTickCount: Number(raw.silentTickCount ?? 0),
|
|
46
|
+
fingerprint: String(raw.fingerprint ?? ''),
|
|
47
|
+
...(raw.lastSignalSummary ? { lastSignalSummary: raw.lastSignalSummary } : {}),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
logger.warn({ err, slug: this.slug }, 'Failed to load agent heartbeat state — starting fresh');
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
slug: this.slug,
|
|
56
|
+
lastTickAt: '',
|
|
57
|
+
nextCheckAt: new Date().toISOString(),
|
|
58
|
+
silentTickCount: 0,
|
|
59
|
+
fingerprint: '',
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
saveState(state) {
|
|
63
|
+
try {
|
|
64
|
+
mkdirSync(path.dirname(this.stateFile), { recursive: true });
|
|
65
|
+
writeFileSync(this.stateFile, JSON.stringify(state, null, 2));
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
logger.warn({ err, slug: this.slug }, 'Failed to save agent heartbeat state — non-fatal');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** True if the agent is due for a tick. */
|
|
72
|
+
isDue(now = new Date()) {
|
|
73
|
+
const state = this.loadState();
|
|
74
|
+
if (!state.nextCheckAt)
|
|
75
|
+
return true;
|
|
76
|
+
return new Date(state.nextCheckAt).getTime() <= now.getTime();
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Compute a cheap fingerprint of "anything material to this agent."
|
|
80
|
+
* Three signals: pending delegated tasks, latest goal update, latest
|
|
81
|
+
* cron run timestamp. Sync filesystem reads — bounded and small.
|
|
82
|
+
*/
|
|
83
|
+
buildFingerprint() {
|
|
84
|
+
const signals = { slug: this.slug };
|
|
85
|
+
// 1. Pending delegated task count
|
|
86
|
+
try {
|
|
87
|
+
const tasksDir = path.join(this.agentsDir, this.slug, 'tasks');
|
|
88
|
+
if (existsSync(tasksDir)) {
|
|
89
|
+
const files = readdirSync(tasksDir).filter((f) => f.endsWith('.json'));
|
|
90
|
+
let pendingCount = 0;
|
|
91
|
+
for (const file of files) {
|
|
92
|
+
try {
|
|
93
|
+
const task = JSON.parse(readFileSync(path.join(tasksDir, file), 'utf-8'));
|
|
94
|
+
if (task && task.status === 'pending')
|
|
95
|
+
pendingCount++;
|
|
96
|
+
}
|
|
97
|
+
catch { /* skip malformed */ }
|
|
98
|
+
}
|
|
99
|
+
signals.pendingTasks = pendingCount;
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
signals.pendingTasks = 0;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
signals.pendingTasks = 0;
|
|
107
|
+
}
|
|
108
|
+
// 2. Latest goal updatedAt for this agent's goals
|
|
109
|
+
try {
|
|
110
|
+
let latest = '';
|
|
111
|
+
for (const { goal, owner } of listAllGoals()) {
|
|
112
|
+
if (owner !== this.slug)
|
|
113
|
+
continue;
|
|
114
|
+
const updatedAt = goal.updatedAt ?? '';
|
|
115
|
+
if (updatedAt > latest)
|
|
116
|
+
latest = updatedAt;
|
|
117
|
+
}
|
|
118
|
+
signals.latestGoalUpdate = latest;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
signals.latestGoalUpdate = '';
|
|
122
|
+
}
|
|
123
|
+
// 3. Latest cron run for any of this agent's crons (file mtime is enough)
|
|
124
|
+
try {
|
|
125
|
+
const runsDir = path.join(this.baseDir, 'cron', 'runs');
|
|
126
|
+
let latestMs = 0;
|
|
127
|
+
if (existsSync(runsDir)) {
|
|
128
|
+
const prefix = `${this.slug}:`;
|
|
129
|
+
for (const file of readdirSync(runsDir)) {
|
|
130
|
+
if (!file.endsWith('.jsonl'))
|
|
131
|
+
continue;
|
|
132
|
+
if (!file.startsWith(prefix) && !file.startsWith(this.slug + '_'))
|
|
133
|
+
continue;
|
|
134
|
+
try {
|
|
135
|
+
const mtime = statSync(path.join(runsDir, file)).mtimeMs;
|
|
136
|
+
if (mtime > latestMs)
|
|
137
|
+
latestMs = mtime;
|
|
138
|
+
}
|
|
139
|
+
catch { /* skip */ }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
signals.latestCronRunMs = latestMs;
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
signals.latestCronRunMs = 0;
|
|
146
|
+
}
|
|
147
|
+
const fingerprint = createHash('sha1')
|
|
148
|
+
.update(JSON.stringify(signals))
|
|
149
|
+
.digest('hex')
|
|
150
|
+
.slice(0, 16);
|
|
151
|
+
return { fingerprint, signals };
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Cheap-path tick. Returns the new state. P3 will branch into an LLM
|
|
155
|
+
* call when the fingerprint changed; for now we just observe and log.
|
|
156
|
+
*/
|
|
157
|
+
async tick(now = new Date()) {
|
|
158
|
+
const profile = this.agentManager.get(this.slug);
|
|
159
|
+
if (!profile) {
|
|
160
|
+
// Agent was removed mid-flight — return a state that won't tick again soon.
|
|
161
|
+
return {
|
|
162
|
+
slug: this.slug,
|
|
163
|
+
lastTickAt: now.toISOString(),
|
|
164
|
+
nextCheckAt: new Date(now.getTime() + MAX_INTERVAL_MIN * 60_000).toISOString(),
|
|
165
|
+
silentTickCount: 0,
|
|
166
|
+
fingerprint: '',
|
|
167
|
+
lastSignalSummary: 'agent profile not found',
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
if (!this.agentManager.isRunnable(this.slug)) {
|
|
171
|
+
logger.debug({ slug: this.slug, status: profile.status }, 'Agent not runnable — skipping tick');
|
|
172
|
+
const next = new Date(now.getTime() + DEFAULT_INTERVAL_MIN * 60_000);
|
|
173
|
+
const prior = this.loadState();
|
|
174
|
+
const state = {
|
|
175
|
+
...prior,
|
|
176
|
+
slug: this.slug,
|
|
177
|
+
lastTickAt: now.toISOString(),
|
|
178
|
+
nextCheckAt: next.toISOString(),
|
|
179
|
+
};
|
|
180
|
+
this.saveState(state);
|
|
181
|
+
return state;
|
|
182
|
+
}
|
|
183
|
+
const prior = this.loadState();
|
|
184
|
+
const { fingerprint, signals } = this.buildFingerprint();
|
|
185
|
+
const changed = fingerprint !== prior.fingerprint;
|
|
186
|
+
const next = new Date(now.getTime() + DEFAULT_INTERVAL_MIN * 60_000);
|
|
187
|
+
const state = {
|
|
188
|
+
slug: this.slug,
|
|
189
|
+
lastTickAt: now.toISOString(),
|
|
190
|
+
nextCheckAt: next.toISOString(),
|
|
191
|
+
silentTickCount: changed ? 0 : prior.silentTickCount + 1,
|
|
192
|
+
fingerprint,
|
|
193
|
+
...(changed
|
|
194
|
+
? { lastSignalSummary: `signal change: ${JSON.stringify(signals)}`.slice(0, 240) }
|
|
195
|
+
: prior.lastSignalSummary
|
|
196
|
+
? { lastSignalSummary: prior.lastSignalSummary }
|
|
197
|
+
: {}),
|
|
198
|
+
};
|
|
199
|
+
this.saveState(state);
|
|
200
|
+
if (changed) {
|
|
201
|
+
logger.info({ slug: this.slug, signals, fingerprint }, 'Agent heartbeat: signal change detected (LLM path is P3)');
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
logger.debug({ slug: this.slug, silentTicks: state.silentTickCount }, 'Agent heartbeat: silent tick');
|
|
205
|
+
}
|
|
206
|
+
return state;
|
|
207
|
+
}
|
|
208
|
+
/** Schedule the next check explicitly. Clamped to [MIN, MAX] minutes. */
|
|
209
|
+
setNextCheckIn(minutes, now = new Date()) {
|
|
210
|
+
const clamped = Math.max(MIN_INTERVAL_MIN, Math.min(MAX_INTERVAL_MIN, Math.floor(minutes)));
|
|
211
|
+
const prior = this.loadState();
|
|
212
|
+
const state = {
|
|
213
|
+
...prior,
|
|
214
|
+
slug: this.slug,
|
|
215
|
+
nextCheckAt: new Date(now.getTime() + clamped * 60_000).toISOString(),
|
|
216
|
+
};
|
|
217
|
+
this.saveState(state);
|
|
218
|
+
}
|
|
219
|
+
getSlug() {
|
|
220
|
+
return this.slug;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
//# sourceMappingURL=agent-heartbeat-scheduler.js.map
|
|
@@ -159,6 +159,7 @@ export declare class CronScheduler {
|
|
|
159
159
|
* Creates the causal link: "action X was attempted for goal Y, result was Z."
|
|
160
160
|
*/
|
|
161
161
|
private logGoalOutcome;
|
|
162
|
+
private logProactiveDecisionOutcome;
|
|
162
163
|
/**
|
|
163
164
|
* Apply non-destructive cron changes suggested by the daily planner.
|
|
164
165
|
* Only auto-applies suggestions that reference a high-priority goal with autoSchedule=true.
|
|
@@ -18,6 +18,7 @@ import { scanner } from '../security/scanner.js';
|
|
|
18
18
|
import { parseAllWorkflows as parseAllWorkflowsSync } from '../agent/workflow-runner.js';
|
|
19
19
|
import { SelfImproveLoop } from '../agent/self-improve.js';
|
|
20
20
|
import { logAuditJsonl } from '../agent/hooks.js';
|
|
21
|
+
import { outcomeStatusFromGoalDisposition, recentDecisions, recordDecisionOutcome, } from '../agent/proactive-ledger.js';
|
|
21
22
|
const logger = pino({ name: 'clementine.cron' });
|
|
22
23
|
/** Default timeout for standard cron jobs (10 minutes). */
|
|
23
24
|
const CRON_STANDARD_TIMEOUT_MS = 10 * 60 * 1000;
|
|
@@ -1643,7 +1644,7 @@ export class CronScheduler {
|
|
|
1643
1644
|
const advice = getExecutionAdvice(jobName, syntheticJob);
|
|
1644
1645
|
if (advice.shouldSkip) {
|
|
1645
1646
|
logger.info({ goalId: trigger.goalId, reason: advice.skipReason }, 'Goal work skipped by advisor (circuit breaker)');
|
|
1646
|
-
this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source, null, `Skipped: ${advice.skipReason}
|
|
1647
|
+
this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source ?? 'unknown', null, `Skipped: ${advice.skipReason}`, trigger.decision);
|
|
1647
1648
|
return;
|
|
1648
1649
|
}
|
|
1649
1650
|
const effectiveMaxTurns = advice.adjustedMaxTurns ?? syntheticJob.maxTurns ?? 15;
|
|
@@ -1667,10 +1668,10 @@ export class CronScheduler {
|
|
|
1667
1668
|
this.dispatcher.send(`🎯 **Goal work: ${goal.title}**\n\n${result.slice(0, 1500)}`, dispatchOpts).catch(err => logger.debug({ err }, 'Failed to send goal work notification'));
|
|
1668
1669
|
}
|
|
1669
1670
|
logToDailyNote(`**Goal work: ${goal.title}** — ${(result || 'completed').slice(0, 100).replace(/\n/g, ' ')}`);
|
|
1670
|
-
this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source, result);
|
|
1671
|
+
this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source ?? 'unknown', result, undefined, trigger.decision);
|
|
1671
1672
|
}).catch((err) => {
|
|
1672
1673
|
logger.error({ err, goalId: trigger.goalId }, 'Goal work session failed');
|
|
1673
|
-
this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source, null, String(err));
|
|
1674
|
+
this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source ?? 'unknown', null, String(err), trigger.decision);
|
|
1674
1675
|
});
|
|
1675
1676
|
}).catch((err) => {
|
|
1676
1677
|
// Advisor import failed — fall back to basic execution
|
|
@@ -1686,10 +1687,10 @@ export class CronScheduler {
|
|
|
1686
1687
|
this.dispatcher.send(`🎯 **Goal work: ${goal.title}**\n\n${result.slice(0, 1500)}`, dispatchOpts).catch(err => logger.debug({ err }, 'Failed to send goal work notification'));
|
|
1687
1688
|
}
|
|
1688
1689
|
logToDailyNote(`**Goal work: ${goal.title}** — ${(result || 'completed').slice(0, 100).replace(/\n/g, ' ')}`);
|
|
1689
|
-
this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source, result);
|
|
1690
|
+
this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source ?? 'unknown', result, undefined, trigger.decision);
|
|
1690
1691
|
}).catch((goalErr) => {
|
|
1691
1692
|
logger.error({ err: goalErr, goalId: trigger.goalId }, 'Goal work session failed');
|
|
1692
|
-
this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source, null, String(goalErr));
|
|
1693
|
+
this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source ?? 'unknown', null, String(goalErr), trigger.decision);
|
|
1693
1694
|
});
|
|
1694
1695
|
});
|
|
1695
1696
|
}
|
|
@@ -1706,7 +1707,7 @@ export class CronScheduler {
|
|
|
1706
1707
|
* Log goal work session outcome to a per-goal progress JSONL file.
|
|
1707
1708
|
* Creates the causal link: "action X was attempted for goal Y, result was Z."
|
|
1708
1709
|
*/
|
|
1709
|
-
logGoalOutcome(goalId, goalPath, prevUpdatedAt, prevNotesCount, focus, source, result, error) {
|
|
1710
|
+
logGoalOutcome(goalId, goalPath, prevUpdatedAt, prevNotesCount, focus, source, result, error, proactiveDecision) {
|
|
1710
1711
|
try {
|
|
1711
1712
|
let madeProgress = false;
|
|
1712
1713
|
let newNotesCount = prevNotesCount;
|
|
@@ -1738,12 +1739,46 @@ export class CronScheduler {
|
|
|
1738
1739
|
mkdirSync(progressDir, { recursive: true });
|
|
1739
1740
|
const progressFile = path.join(progressDir, `${goalId}.progress.jsonl`);
|
|
1740
1741
|
appendFileSync(progressFile, JSON.stringify(entry) + '\n');
|
|
1742
|
+
if (proactiveDecision) {
|
|
1743
|
+
this.logProactiveDecisionOutcome(proactiveDecision, {
|
|
1744
|
+
goalId,
|
|
1745
|
+
focus,
|
|
1746
|
+
source,
|
|
1747
|
+
disposition,
|
|
1748
|
+
madeProgress,
|
|
1749
|
+
resultSnippet: entry.resultSnippet,
|
|
1750
|
+
newProgressNotes: entry.newProgressNotes,
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1741
1753
|
logger.info({ goalId, madeProgress, disposition, status: entry.status }, 'Goal outcome logged');
|
|
1742
1754
|
}
|
|
1743
1755
|
catch (err) {
|
|
1744
1756
|
logger.debug({ err, goalId }, 'Failed to log goal outcome (non-fatal)');
|
|
1745
1757
|
}
|
|
1746
1758
|
}
|
|
1759
|
+
logProactiveDecisionOutcome(decision, details) {
|
|
1760
|
+
try {
|
|
1761
|
+
const existing = recentDecisions({ idempotencyKey: decision.idempotencyKey }, undefined)[0];
|
|
1762
|
+
const decisionId = existing?.id ?? decision.idempotencyKey;
|
|
1763
|
+
recordDecisionOutcome(decisionId, decision, {
|
|
1764
|
+
signalType: 'goal-work-outcome',
|
|
1765
|
+
description: details.focus,
|
|
1766
|
+
goalId: details.goalId,
|
|
1767
|
+
metadata: {
|
|
1768
|
+
source: details.source,
|
|
1769
|
+
disposition: details.disposition,
|
|
1770
|
+
madeProgress: details.madeProgress,
|
|
1771
|
+
newProgressNotes: details.newProgressNotes,
|
|
1772
|
+
},
|
|
1773
|
+
}, {
|
|
1774
|
+
status: outcomeStatusFromGoalDisposition(details.disposition),
|
|
1775
|
+
summary: details.resultSnippet || details.disposition,
|
|
1776
|
+
});
|
|
1777
|
+
}
|
|
1778
|
+
catch (err) {
|
|
1779
|
+
logger.debug({ err, goalId: details.goalId }, 'Failed to log proactive decision outcome (non-fatal)');
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1747
1782
|
/**
|
|
1748
1783
|
* Apply non-destructive cron changes suggested by the daily planner.
|
|
1749
1784
|
* Only auto-applies suggestions that reference a high-priority goal with autoSchedule=true.
|
|
@@ -93,10 +93,12 @@ export declare class HeartbeatScheduler {
|
|
|
93
93
|
private claimNextItem;
|
|
94
94
|
private completeItem;
|
|
95
95
|
private failItem;
|
|
96
|
+
private recordWorkItemOutcome;
|
|
96
97
|
static enqueueWork(opts: {
|
|
97
98
|
description: string;
|
|
98
99
|
prompt: string;
|
|
99
100
|
source: string;
|
|
101
|
+
idempotencyKey?: string;
|
|
100
102
|
priority?: 'high' | 'normal';
|
|
101
103
|
maxTurns?: number;
|
|
102
104
|
tier?: number;
|