coder-config 0.50.3 → 0.50.4-beta
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/lib/constants.js +1 -1
- package/lib/heartbeat.js +169 -0
- package/package.json +1 -1
package/lib/constants.js
CHANGED
package/lib/heartbeat.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heartbeat module - monitors Ralph Loop health
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { loadLoops, loadLoopState } = require('./loops.js');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Get default heartbeat configuration
|
|
11
|
+
*/
|
|
12
|
+
function getDefaultHeartbeatConfig() {
|
|
13
|
+
return {
|
|
14
|
+
staleThresholdMinutes: 30,
|
|
15
|
+
iterationLimitPercent: 80,
|
|
16
|
+
cooldownMinutes: 15,
|
|
17
|
+
notifications: {
|
|
18
|
+
macos: { enabled: true },
|
|
19
|
+
slack: { enabled: false }
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Evaluate heartbeat for all active loops
|
|
26
|
+
* @param {string} installDir - path to coder-config install dir
|
|
27
|
+
* @param {object} [config] - optional heartbeat config overrides
|
|
28
|
+
* @returns {object} report with alerts and healthy loops
|
|
29
|
+
*/
|
|
30
|
+
function heartbeat(installDir, config) {
|
|
31
|
+
const hbConfig = Object.assign({}, getDefaultHeartbeatConfig(), config || {});
|
|
32
|
+
|
|
33
|
+
const registry = loadLoops(installDir);
|
|
34
|
+
const loops = registry.loops || [];
|
|
35
|
+
|
|
36
|
+
const alerts = [];
|
|
37
|
+
const healthy = [];
|
|
38
|
+
const terminalStates = new Set(['completed', 'cancelled']);
|
|
39
|
+
|
|
40
|
+
for (const entry of loops) {
|
|
41
|
+
const state = loadLoopState(installDir, entry.id);
|
|
42
|
+
if (!state) continue;
|
|
43
|
+
|
|
44
|
+
const status = state.status || 'unknown';
|
|
45
|
+
if (terminalStates.has(status)) continue;
|
|
46
|
+
|
|
47
|
+
const name = state.name || entry.name || entry.id;
|
|
48
|
+
|
|
49
|
+
// Check: failed
|
|
50
|
+
if (status === 'failed') {
|
|
51
|
+
alerts.push({
|
|
52
|
+
loopId: entry.id,
|
|
53
|
+
name,
|
|
54
|
+
type: 'failed',
|
|
55
|
+
message: `Loop "${name}" has failed`,
|
|
56
|
+
severity: 'critical'
|
|
57
|
+
});
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let hasAlert = false;
|
|
62
|
+
|
|
63
|
+
// Check: stale
|
|
64
|
+
const perLoopThreshold = state.heartbeat && state.heartbeat.staleThresholdMinutes != null
|
|
65
|
+
? state.heartbeat.staleThresholdMinutes
|
|
66
|
+
: hbConfig.staleThresholdMinutes;
|
|
67
|
+
|
|
68
|
+
if (state.updatedAt) {
|
|
69
|
+
const updatedAt = new Date(state.updatedAt);
|
|
70
|
+
const ageMinutes = (Date.now() - updatedAt.getTime()) / 60000;
|
|
71
|
+
if (ageMinutes >= perLoopThreshold) {
|
|
72
|
+
alerts.push({
|
|
73
|
+
loopId: entry.id,
|
|
74
|
+
name,
|
|
75
|
+
type: 'stale',
|
|
76
|
+
message: `Loop "${name}" has not updated in ${Math.floor(ageMinutes)} minutes`,
|
|
77
|
+
severity: 'warning'
|
|
78
|
+
});
|
|
79
|
+
hasAlert = true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Check: iteration_limit
|
|
84
|
+
const iterations = state.iterations || {};
|
|
85
|
+
const current = iterations.current != null ? iterations.current : 0;
|
|
86
|
+
const max = iterations.max != null ? iterations.max : 0;
|
|
87
|
+
if (max > 0) {
|
|
88
|
+
const pct = (current / max) * 100;
|
|
89
|
+
if (pct >= hbConfig.iterationLimitPercent) {
|
|
90
|
+
alerts.push({
|
|
91
|
+
loopId: entry.id,
|
|
92
|
+
name,
|
|
93
|
+
type: 'iteration_limit',
|
|
94
|
+
message: `Loop "${name}" is at ${Math.round(pct)}% of iteration limit (${current}/${max})`,
|
|
95
|
+
severity: 'warning'
|
|
96
|
+
});
|
|
97
|
+
hasAlert = true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Check: phase_gate (plan phase + running)
|
|
102
|
+
if (state.phase === 'plan' && status === 'running') {
|
|
103
|
+
alerts.push({
|
|
104
|
+
loopId: entry.id,
|
|
105
|
+
name,
|
|
106
|
+
type: 'phase_gate',
|
|
107
|
+
message: `Loop "${name}" is waiting at plan phase gate`,
|
|
108
|
+
severity: 'info'
|
|
109
|
+
});
|
|
110
|
+
hasAlert = true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Check: blocked (paused with pauseReason)
|
|
114
|
+
if (status === 'paused' && state.pauseReason) {
|
|
115
|
+
alerts.push({
|
|
116
|
+
loopId: entry.id,
|
|
117
|
+
name,
|
|
118
|
+
type: 'blocked',
|
|
119
|
+
message: `Loop "${name}" is blocked: ${state.pauseReason}`,
|
|
120
|
+
severity: 'info'
|
|
121
|
+
});
|
|
122
|
+
hasAlert = true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!hasAlert) {
|
|
126
|
+
healthy.push({
|
|
127
|
+
loopId: entry.id,
|
|
128
|
+
name,
|
|
129
|
+
phase: state.phase || null,
|
|
130
|
+
iteration: current
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const activeLoops = loops.filter(e => {
|
|
136
|
+
const state = loadLoopState(installDir, e.id);
|
|
137
|
+
if (!state) return false;
|
|
138
|
+
const status = state.status || 'unknown';
|
|
139
|
+
return !terminalStates.has(status);
|
|
140
|
+
}).length;
|
|
141
|
+
|
|
142
|
+
const criticalCount = alerts.filter(a => a.severity === 'critical').length;
|
|
143
|
+
const warningCount = alerts.filter(a => a.severity === 'warning').length;
|
|
144
|
+
|
|
145
|
+
let summary;
|
|
146
|
+
if (alerts.length === 0) {
|
|
147
|
+
summary = `${activeLoops} active loop${activeLoops !== 1 ? 's' : ''}, all healthy`;
|
|
148
|
+
} else {
|
|
149
|
+
const parts = [];
|
|
150
|
+
if (criticalCount > 0) parts.push(`${criticalCount} critical`);
|
|
151
|
+
if (warningCount > 0) parts.push(`${warningCount} warning${warningCount !== 1 ? 's' : ''}`);
|
|
152
|
+
const infoCount = alerts.filter(a => a.severity === 'info').length;
|
|
153
|
+
if (infoCount > 0) parts.push(`${infoCount} info`);
|
|
154
|
+
summary = `${activeLoops} active loop${activeLoops !== 1 ? 's' : ''}, ${parts.join(', ')}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
timestamp: new Date().toISOString(),
|
|
159
|
+
activeLoops,
|
|
160
|
+
alerts,
|
|
161
|
+
healthy,
|
|
162
|
+
summary
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = {
|
|
167
|
+
getDefaultHeartbeatConfig,
|
|
168
|
+
heartbeat
|
|
169
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coder-config",
|
|
3
|
-
"version": "0.50.
|
|
3
|
+
"version": "0.50.4-beta",
|
|
4
4
|
"description": "Configuration manager for AI coding tools - Claude Code, Gemini CLI, Codex CLI, Antigravity. Manage MCPs, rules, permissions, memory, and workstreams.",
|
|
5
5
|
"author": "regression.io",
|
|
6
6
|
"main": "config-loader.js",
|