clementine-agent 1.0.77 → 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/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/heartbeat-scheduler.js +60 -4
- package/dist/index.js +34 -20
- package/dist/tools/session-tools.js +5 -0
- package/dist/types.d.ts +13 -0
- package/package.json +1 -1
|
@@ -0,0 +1,42 @@
|
|
|
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 type { AgentManager } from '../agent/agent-manager.js';
|
|
14
|
+
import { AgentHeartbeatScheduler } from './agent-heartbeat-scheduler.js';
|
|
15
|
+
export declare class AgentHeartbeatManager {
|
|
16
|
+
private readonly agentManager;
|
|
17
|
+
private readonly schedulers;
|
|
18
|
+
private timer;
|
|
19
|
+
private running;
|
|
20
|
+
private ticking;
|
|
21
|
+
constructor(agentManager: AgentManager);
|
|
22
|
+
start(): void;
|
|
23
|
+
stop(): void;
|
|
24
|
+
/** Add/remove schedulers to match the current AgentManager listing. */
|
|
25
|
+
private reconcile;
|
|
26
|
+
/**
|
|
27
|
+
* One outer-loop tick. Reconcile the registry, then fire agents whose
|
|
28
|
+
* nextCheckAt has come due. Runs serially to avoid races on shared
|
|
29
|
+
* state (goals dir, cron runs dir).
|
|
30
|
+
*/
|
|
31
|
+
private outerTick;
|
|
32
|
+
/** Diagnostic helper for the dashboard / CLI. */
|
|
33
|
+
getStatus(): Array<{
|
|
34
|
+
slug: string;
|
|
35
|
+
nextCheckAt: string;
|
|
36
|
+
lastTickAt: string;
|
|
37
|
+
silentTickCount: number;
|
|
38
|
+
}>;
|
|
39
|
+
/** Look up a scheduler — useful for CLI commands like "tick this agent now." */
|
|
40
|
+
getScheduler(slug: string): AgentHeartbeatScheduler | null;
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=agent-heartbeat-manager.d.ts.map
|
|
@@ -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
|
|
@@ -9,8 +9,8 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync,
|
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import matter from 'gray-matter';
|
|
11
11
|
import pino from 'pino';
|
|
12
|
-
import { HEARTBEAT_FILE, TASKS_FILE, INBOX_DIR, DAILY_NOTES_DIR, HEARTBEAT_INTERVAL_MINUTES, HEARTBEAT_ACTIVE_START, HEARTBEAT_ACTIVE_END, BASE_DIR, GOALS_DIR, HEARTBEAT_WORK_QUEUE_FILE, DISCORD_OWNER_ID, } from '../config.js';
|
|
13
|
-
import { listAllGoals } from '../tools/shared.js';
|
|
12
|
+
import { HEARTBEAT_FILE, TASKS_FILE, INBOX_DIR, DAILY_NOTES_DIR, HEARTBEAT_INTERVAL_MINUTES, HEARTBEAT_ACTIVE_START, HEARTBEAT_ACTIVE_END, BASE_DIR, GOALS_DIR, AGENTS_DIR, HEARTBEAT_WORK_QUEUE_FILE, DISCORD_OWNER_ID, } from '../config.js';
|
|
13
|
+
import { findGoalPath, listAllGoals } from '../tools/shared.js';
|
|
14
14
|
import { gatherInsightSignals, buildInsightPrompt, parseInsightResponse, canSendInsight, recordInsightSent, recordInsightAcked, maybeIncreaseCooldown, } from '../agent/insight-engine.js';
|
|
15
15
|
import { decideDailyPlanPriority, decideDiscoveredWorkItem, decideGoalAdvancement, decisionShouldCreateGoalTrigger, decisionShouldQueueHeartbeatWork, } from '../agent/proactive-engine.js';
|
|
16
16
|
import { recentDecisions, recordDecision, recordDecisionOutcome, wasRecentlyDecided, } from '../agent/proactive-ledger.js';
|
|
@@ -444,6 +444,35 @@ export class HeartbeatScheduler {
|
|
|
444
444
|
continue;
|
|
445
445
|
if (wasRecentlyDecided(decision.idempotencyKey, PROACTIVE_DECISION_DEDUPE_MS))
|
|
446
446
|
continue;
|
|
447
|
+
// If the goal belongs to a specialist agent, route to them via a
|
|
448
|
+
// goal-trigger file instead of running the work as Clementine.
|
|
449
|
+
// processGoalTriggers in cron-scheduler reads goal.owner and
|
|
450
|
+
// dispatches with the right profile + Discord channel.
|
|
451
|
+
const goalLookup = findGoalPath(priority.id);
|
|
452
|
+
const ownerSlug = goalLookup && goalLookup.owner !== 'clementine' ? goalLookup.owner : null;
|
|
453
|
+
if (ownerSlug) {
|
|
454
|
+
const goalTriggerDir = path.join(BASE_DIR, 'cron', 'goal-triggers');
|
|
455
|
+
mkdirSync(goalTriggerDir, { recursive: true });
|
|
456
|
+
const trigger = {
|
|
457
|
+
goalId: priority.id,
|
|
458
|
+
focus: priority.action,
|
|
459
|
+
maxTurns: 15,
|
|
460
|
+
triggeredAt: new Date().toISOString(),
|
|
461
|
+
source: 'daily-plan',
|
|
462
|
+
decision,
|
|
463
|
+
};
|
|
464
|
+
const triggerPath = path.join(goalTriggerDir, `${decision.idempotencyKey}.trigger.json`);
|
|
465
|
+
writeFileSync(triggerPath, JSON.stringify(trigger, null, 2));
|
|
466
|
+
recordDecision(decision, {
|
|
467
|
+
signalType: 'daily-plan-priority',
|
|
468
|
+
description: priority.action,
|
|
469
|
+
goalId: priority.id,
|
|
470
|
+
owner: ownerSlug,
|
|
471
|
+
metadata: { planDate: todayPlan.date, type: priority.type, routedTo: ownerSlug },
|
|
472
|
+
});
|
|
473
|
+
logger.info({ goalId: priority.id, owner: ownerSlug, action: priority.action }, 'Routed daily-plan goal to owning agent');
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
447
476
|
HeartbeatScheduler.enqueueWork({
|
|
448
477
|
description: priority.action,
|
|
449
478
|
prompt: `Goal progress: ${priority.action}\n\nThis is a high-priority item from today's daily plan (goal: ${priority.id}). ` +
|
|
@@ -1231,18 +1260,45 @@ export class HeartbeatScheduler {
|
|
|
1231
1260
|
// If move fails, skip — will retry next tick
|
|
1232
1261
|
continue;
|
|
1233
1262
|
}
|
|
1263
|
+
// Load active team so Clementine can delegate when an item belongs
|
|
1264
|
+
// to a specialist. Read agent.md frontmatter for slug/name/scope.
|
|
1265
|
+
const teamLines = [];
|
|
1266
|
+
try {
|
|
1267
|
+
if (existsSync(AGENTS_DIR)) {
|
|
1268
|
+
const agentDirs = readdirSync(AGENTS_DIR, { withFileTypes: true })
|
|
1269
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith('_'))
|
|
1270
|
+
.map((d) => d.name);
|
|
1271
|
+
for (const slug of agentDirs) {
|
|
1272
|
+
const agentMd = path.join(AGENTS_DIR, slug, 'agent.md');
|
|
1273
|
+
if (!existsSync(agentMd))
|
|
1274
|
+
continue;
|
|
1275
|
+
try {
|
|
1276
|
+
const fm = matter(readFileSync(agentMd, 'utf-8')).data;
|
|
1277
|
+
const desc = (fm.description ?? '').replace(/\s+/g, ' ').trim().slice(0, 160);
|
|
1278
|
+
teamLines.push(`- \`${slug}\` (${fm.name ?? slug})${desc ? ` — ${desc}` : ''}`);
|
|
1279
|
+
}
|
|
1280
|
+
catch { /* skip malformed agent.md */ }
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
catch { /* non-fatal */ }
|
|
1285
|
+
const teamBlock = teamLines.length > 0
|
|
1286
|
+
? `## Your Team (delegate when work clearly belongs to one of them)\n${teamLines.join('\n')}\n\n`
|
|
1287
|
+
: '';
|
|
1234
1288
|
// Build a prompt for the agent to triage this inbox item
|
|
1235
1289
|
const prompt = `Triage this inbox item and take appropriate action.\n\n` +
|
|
1236
1290
|
`**Title:** ${title}\n` +
|
|
1237
1291
|
`**Content:**\n${content.slice(0, 2000)}\n\n` +
|
|
1292
|
+
teamBlock +
|
|
1238
1293
|
`## Instructions:\n` +
|
|
1239
|
-
`1. Determine the intent: Is this a task, a reference/note, a reminder, or
|
|
1294
|
+
`1. Determine the intent: Is this a task, a reference/note, a reminder, project update, or work for a teammate?\n` +
|
|
1240
1295
|
`2. Take the appropriate action:\n` +
|
|
1241
1296
|
` - **Task**: Use \`task_add\` to create a task with the right priority and due date.\n` +
|
|
1242
1297
|
` - **Reference**: Use \`note_create\` or \`memory_write\` to file it in the vault.\n` +
|
|
1243
1298
|
` - **Reminder**: Add to today's daily note with \`memory_write(action="append_daily")\`.\n` +
|
|
1244
1299
|
` - **Project update**: Update the relevant project note.\n` +
|
|
1245
|
-
`
|
|
1300
|
+
` - **Delegate to a teammate**: If the item is clearly work for a specialist on your team, use \`team_message\` to hand it off with enough context for them to act. Don't try to do their job yourself.\n` +
|
|
1301
|
+
`3. Respond with a one-line summary of what you did (including who you delegated to, if anyone).`;
|
|
1246
1302
|
// Fire-and-forget — run as a lightweight cron job
|
|
1247
1303
|
this.gateway
|
|
1248
1304
|
.handleCronJob(`inbox:${title}`, prompt, 1, 5)
|
package/dist/index.js
CHANGED
|
@@ -453,28 +453,36 @@ function startTimerChecker(dispatcher, gateway) {
|
|
|
453
453
|
// ── Log rotation ─────────────────────────────────────────────────────
|
|
454
454
|
const LOG_MAX_BYTES = 2 * 1024 * 1024; // 2 MB
|
|
455
455
|
const LOG_MAX_BACKUPS = 7;
|
|
456
|
+
function rotateOne(logFile) {
|
|
457
|
+
if (!existsSync(logFile))
|
|
458
|
+
return;
|
|
459
|
+
const size = statSync(logFile).size;
|
|
460
|
+
if (size < LOG_MAX_BYTES)
|
|
461
|
+
return;
|
|
462
|
+
// Rotate: delete .log.7, shift .log.6→.log.7, ... .log→.log.1
|
|
463
|
+
const oldest = `${logFile}.${LOG_MAX_BACKUPS}`;
|
|
464
|
+
if (existsSync(oldest))
|
|
465
|
+
unlinkSync(oldest);
|
|
466
|
+
for (let i = LOG_MAX_BACKUPS - 1; i >= 1; i--) {
|
|
467
|
+
const src = `${logFile}.${i}`;
|
|
468
|
+
if (existsSync(src))
|
|
469
|
+
renameSync(src, `${logFile}.${i + 1}`);
|
|
470
|
+
}
|
|
471
|
+
renameSync(logFile, `${logFile}.1`);
|
|
472
|
+
writeFileSync(logFile, '');
|
|
473
|
+
}
|
|
456
474
|
function rotateLogIfNeeded() {
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
unlinkSync(oldest);
|
|
468
|
-
for (let i = LOG_MAX_BACKUPS - 1; i >= 1; i--) {
|
|
469
|
-
const src = `${logFile}.${i}`;
|
|
470
|
-
if (existsSync(src))
|
|
471
|
-
renameSync(src, `${logFile}.${i + 1}`);
|
|
475
|
+
// cron.log is appended to by launchd-spawned `clementine cron run` invocations
|
|
476
|
+
// — each is a one-shot process that closes the FD after writing, so a
|
|
477
|
+
// rename-rotate at daemon startup is safe.
|
|
478
|
+
const logsDir = path.join(config.BASE_DIR, 'logs');
|
|
479
|
+
for (const name of ['clementine.log', 'cron.log']) {
|
|
480
|
+
try {
|
|
481
|
+
rotateOne(path.join(logsDir, name));
|
|
482
|
+
}
|
|
483
|
+
catch (err) {
|
|
484
|
+
logger.warn({ err, name }, 'Log rotation failed — continuing startup');
|
|
472
485
|
}
|
|
473
|
-
renameSync(logFile, `${logFile}.1`);
|
|
474
|
-
writeFileSync(logFile, '');
|
|
475
|
-
}
|
|
476
|
-
catch (err) {
|
|
477
|
-
logger.warn({ err }, 'Log rotation failed — continuing startup');
|
|
478
486
|
}
|
|
479
487
|
}
|
|
480
488
|
// ── Async main ───────────────────────────────────────────────────────
|
|
@@ -652,6 +660,10 @@ async function asyncMain() {
|
|
|
652
660
|
const heartbeat = new HeartbeatScheduler(gateway, dispatcher);
|
|
653
661
|
const cronScheduler = new CronScheduler(gateway, dispatcher);
|
|
654
662
|
heartbeat.setCronScheduler(cronScheduler);
|
|
663
|
+
// Per-agent heartbeats (Ross / Sasha / Nora / future hires). Cheap-path
|
|
664
|
+
// observation only in P2 — LLM ticks land in P3.
|
|
665
|
+
const { AgentHeartbeatManager } = await import('./gateway/agent-heartbeat-manager.js');
|
|
666
|
+
const agentHeartbeats = new AgentHeartbeatManager(gateway.getAgentManager());
|
|
655
667
|
// ── Build channel tasks ──────────────────────────────────────────
|
|
656
668
|
const channelTasks = [];
|
|
657
669
|
const activeChannels = [];
|
|
@@ -748,6 +760,7 @@ async function asyncMain() {
|
|
|
748
760
|
// Start heartbeat + cron + timers
|
|
749
761
|
heartbeat.start();
|
|
750
762
|
cronScheduler.start();
|
|
763
|
+
agentHeartbeats.start();
|
|
751
764
|
const timerInterval = startTimerChecker(dispatcher, gateway);
|
|
752
765
|
// Start brain ingest scheduler (polls registered REST sources on their cron)
|
|
753
766
|
try {
|
|
@@ -938,6 +951,7 @@ async function asyncMain() {
|
|
|
938
951
|
// Now safe to tear down remaining infrastructure
|
|
939
952
|
heartbeat.stop();
|
|
940
953
|
cronScheduler.stop();
|
|
954
|
+
agentHeartbeats.stop();
|
|
941
955
|
// ── Self-restart (enhanced with health check + rollback) ────────
|
|
942
956
|
if (restartRequested) {
|
|
943
957
|
// Clear our PID file BEFORE spawning the child, so ensureSingleton()
|
|
@@ -141,6 +141,11 @@ export function registerSessionTools(server) {
|
|
|
141
141
|
const topItems = items.slice(0, maxItems);
|
|
142
142
|
if (topItems.length === 0)
|
|
143
143
|
return textResult('No work items discovered. All goals on track, no failures, inbox clear.');
|
|
144
|
+
// Decisions here are advisory — the agent surveys, then chooses whether
|
|
145
|
+
// to act. We deliberately do NOT recordDecision: the autonomous paths
|
|
146
|
+
// (processInbox, daily-plan loop, goal advancement) record their own
|
|
147
|
+
// decisions when they actually act, so the ledger reflects committed
|
|
148
|
+
// decisions and proactive_stats stays signal-rich.
|
|
144
149
|
const lines = topItems.map((i) => {
|
|
145
150
|
const decision = decideDiscoveredWorkItem(i);
|
|
146
151
|
const label = ACTION_LABEL[decision.action];
|
package/dist/types.d.ts
CHANGED
|
@@ -221,6 +221,19 @@ export interface HeartbeatWorkItem {
|
|
|
221
221
|
error?: string;
|
|
222
222
|
agentSlug?: string;
|
|
223
223
|
}
|
|
224
|
+
/**
|
|
225
|
+
* State for one specialist agent's heartbeat scheduler. Persisted at
|
|
226
|
+
* ~/.clementine/heartbeat/agents/<slug>/state.json. Manager reads
|
|
227
|
+
* `nextCheckAt` to decide whether the agent is due for a tick.
|
|
228
|
+
*/
|
|
229
|
+
export interface AgentHeartbeatState {
|
|
230
|
+
slug: string;
|
|
231
|
+
lastTickAt: string;
|
|
232
|
+
nextCheckAt: string;
|
|
233
|
+
silentTickCount: number;
|
|
234
|
+
fingerprint: string;
|
|
235
|
+
lastSignalSummary?: string;
|
|
236
|
+
}
|
|
224
237
|
export interface CronJobDefinition {
|
|
225
238
|
name: string;
|
|
226
239
|
schedule: string;
|