@teamvibe/poller 0.2.5 → 0.4.0
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/cron-telemetry.d.ts +32 -0
- package/dist/cron-telemetry.js +58 -0
- package/dist/heartbeat-manager.d.ts +18 -0
- package/dist/heartbeat-manager.js +55 -2
- package/dist/metrics/collector.d.ts +34 -0
- package/dist/metrics/collector.js +238 -0
- package/dist/metrics/scheduler.d.ts +52 -0
- package/dist/metrics/scheduler.js +127 -0
- package/dist/metrics/sender.d.ts +10 -0
- package/dist/metrics/sender.js +58 -0
- package/dist/poller.d.ts +10 -0
- package/dist/poller.js +29 -11
- package/package.json +1 -1
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module-scoped state for poller-level cron-job telemetry.
|
|
3
|
+
*
|
|
4
|
+
* Per teamvibeai/teamvibe.ai#119, the metrics collector reports six measures:
|
|
5
|
+
* last{Heartbeat,Maintenance,AutoUpdate}Job{,Success}Ms
|
|
6
|
+
* each computed as `Date.now() - storedTs` at collection time, with the
|
|
7
|
+
* measure omitted entirely when the cron has never fired (storedTs null).
|
|
8
|
+
*
|
|
9
|
+
* Pulled out of heartbeat-manager.ts / poller.ts into a dedicated module to
|
|
10
|
+
* avoid a circular import — the metrics collector reads from these getters,
|
|
11
|
+
* and heartbeat-manager.ts already imports from the collector.
|
|
12
|
+
*/
|
|
13
|
+
export declare function markHeartbeatJobFired(at?: number): void;
|
|
14
|
+
export declare function markHeartbeatJobSuccess(at?: number): void;
|
|
15
|
+
export declare function markMaintenanceJobFired(at?: number): void;
|
|
16
|
+
export declare function markMaintenanceJobSuccess(at?: number): void;
|
|
17
|
+
export declare function markAutoUpdateJobFired(at?: number): void;
|
|
18
|
+
export declare function markAutoUpdateJobSuccess(at?: number): void;
|
|
19
|
+
export interface CronJobTimestamps {
|
|
20
|
+
lastHeartbeatJobFiredAt: number | null;
|
|
21
|
+
lastHeartbeatJobSuccessAt: number | null;
|
|
22
|
+
lastMaintenanceJobFiredAt: number | null;
|
|
23
|
+
lastMaintenanceJobSuccessAt: number | null;
|
|
24
|
+
lastAutoUpdateJobFiredAt: number | null;
|
|
25
|
+
lastAutoUpdateJobSuccessAt: number | null;
|
|
26
|
+
}
|
|
27
|
+
export declare function getCronJobTimestamps(): CronJobTimestamps;
|
|
28
|
+
/**
|
|
29
|
+
* Test-only helper to clear telemetry between tests.
|
|
30
|
+
* @internal
|
|
31
|
+
*/
|
|
32
|
+
export declare function __resetCronTelemetryForTest(): void;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module-scoped state for poller-level cron-job telemetry.
|
|
3
|
+
*
|
|
4
|
+
* Per teamvibeai/teamvibe.ai#119, the metrics collector reports six measures:
|
|
5
|
+
* last{Heartbeat,Maintenance,AutoUpdate}Job{,Success}Ms
|
|
6
|
+
* each computed as `Date.now() - storedTs` at collection time, with the
|
|
7
|
+
* measure omitted entirely when the cron has never fired (storedTs null).
|
|
8
|
+
*
|
|
9
|
+
* Pulled out of heartbeat-manager.ts / poller.ts into a dedicated module to
|
|
10
|
+
* avoid a circular import — the metrics collector reads from these getters,
|
|
11
|
+
* and heartbeat-manager.ts already imports from the collector.
|
|
12
|
+
*/
|
|
13
|
+
let lastHeartbeatJobFiredAt = null;
|
|
14
|
+
let lastHeartbeatJobSuccessAt = null;
|
|
15
|
+
let lastMaintenanceJobFiredAt = null;
|
|
16
|
+
let lastMaintenanceJobSuccessAt = null;
|
|
17
|
+
let lastAutoUpdateJobFiredAt = null;
|
|
18
|
+
let lastAutoUpdateJobSuccessAt = null;
|
|
19
|
+
export function markHeartbeatJobFired(at = Date.now()) {
|
|
20
|
+
lastHeartbeatJobFiredAt = at;
|
|
21
|
+
}
|
|
22
|
+
export function markHeartbeatJobSuccess(at = Date.now()) {
|
|
23
|
+
lastHeartbeatJobSuccessAt = at;
|
|
24
|
+
}
|
|
25
|
+
export function markMaintenanceJobFired(at = Date.now()) {
|
|
26
|
+
lastMaintenanceJobFiredAt = at;
|
|
27
|
+
}
|
|
28
|
+
export function markMaintenanceJobSuccess(at = Date.now()) {
|
|
29
|
+
lastMaintenanceJobSuccessAt = at;
|
|
30
|
+
}
|
|
31
|
+
export function markAutoUpdateJobFired(at = Date.now()) {
|
|
32
|
+
lastAutoUpdateJobFiredAt = at;
|
|
33
|
+
}
|
|
34
|
+
export function markAutoUpdateJobSuccess(at = Date.now()) {
|
|
35
|
+
lastAutoUpdateJobSuccessAt = at;
|
|
36
|
+
}
|
|
37
|
+
export function getCronJobTimestamps() {
|
|
38
|
+
return {
|
|
39
|
+
lastHeartbeatJobFiredAt,
|
|
40
|
+
lastHeartbeatJobSuccessAt,
|
|
41
|
+
lastMaintenanceJobFiredAt,
|
|
42
|
+
lastMaintenanceJobSuccessAt,
|
|
43
|
+
lastAutoUpdateJobFiredAt,
|
|
44
|
+
lastAutoUpdateJobSuccessAt,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Test-only helper to clear telemetry between tests.
|
|
49
|
+
* @internal
|
|
50
|
+
*/
|
|
51
|
+
export function __resetCronTelemetryForTest() {
|
|
52
|
+
lastHeartbeatJobFiredAt = null;
|
|
53
|
+
lastHeartbeatJobSuccessAt = null;
|
|
54
|
+
lastMaintenanceJobFiredAt = null;
|
|
55
|
+
lastMaintenanceJobSuccessAt = null;
|
|
56
|
+
lastAutoUpdateJobFiredAt = null;
|
|
57
|
+
lastAutoUpdateJobSuccessAt = null;
|
|
58
|
+
}
|
|
@@ -15,6 +15,24 @@ export declare function startHeartbeatLoop(processMessageFn: (msg: {
|
|
|
15
15
|
receiptHandle: string;
|
|
16
16
|
messageId: string;
|
|
17
17
|
}) => Promise<void>): void;
|
|
18
|
+
/**
|
|
19
|
+
* Run the heartbeat cron handler with telemetry instrumentation (#119).
|
|
20
|
+
* Records last-fire timestamp on entry and last-success timestamp on completion.
|
|
21
|
+
* Exported for use by the cron registration above and tests that fire it manually.
|
|
22
|
+
*/
|
|
23
|
+
export declare function runHeartbeatJob(processMessageFn: (msg: {
|
|
24
|
+
queueMessage: TeamVibeQueueMessage;
|
|
25
|
+
receiptHandle: string;
|
|
26
|
+
messageId: string;
|
|
27
|
+
}) => Promise<void>): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Run the maintenance cron handler with telemetry instrumentation (#119).
|
|
30
|
+
*/
|
|
31
|
+
export declare function runMaintenanceJob(processMessageFn: (msg: {
|
|
32
|
+
queueMessage: TeamVibeQueueMessage;
|
|
33
|
+
receiptHandle: string;
|
|
34
|
+
messageId: string;
|
|
35
|
+
}) => Promise<void>): Promise<void>;
|
|
18
36
|
/**
|
|
19
37
|
* Stop the heartbeat and maintenance crons (for shutdown).
|
|
20
38
|
*/
|
|
@@ -3,6 +3,11 @@ import { config } from './config.js';
|
|
|
3
3
|
import { logger } from './logger.js';
|
|
4
4
|
import { isAtCapacity } from './claude-spawner.js';
|
|
5
5
|
import { emitError } from './event-queue.js';
|
|
6
|
+
import { registerBrainForMetrics } from './metrics/scheduler.js';
|
|
7
|
+
import { collectMetrics } from './metrics/collector.js';
|
|
8
|
+
import { sendMetrics } from './metrics/sender.js';
|
|
9
|
+
import { markHeartbeatJobFired, markHeartbeatJobSuccess, markMaintenanceJobFired, markMaintenanceJobSuccess, } from './cron-telemetry.js';
|
|
10
|
+
import { join } from 'path';
|
|
6
11
|
const trackedBrains = new Map();
|
|
7
12
|
let heartbeatJob = null;
|
|
8
13
|
let maintenanceJob = null;
|
|
@@ -17,6 +22,7 @@ export function registerBrain(msg) {
|
|
|
17
22
|
// Don't register from heartbeat/maintenance messages (avoid self-referential loop)
|
|
18
23
|
if (msg.source === 'heartbeat' || msg.source === 'maintenance')
|
|
19
24
|
return;
|
|
25
|
+
const isNew = !trackedBrains.has(brain.brainId);
|
|
20
26
|
trackedBrains.set(brain.brainId, {
|
|
21
27
|
brainId: brain.brainId,
|
|
22
28
|
gitRepoUrl: brain.gitRepoUrl,
|
|
@@ -27,6 +33,23 @@ export function registerBrain(msg) {
|
|
|
27
33
|
channelId: msg.teamvibe.channelId,
|
|
28
34
|
});
|
|
29
35
|
logger.debug(`[Heartbeat] Registered brain: ${brain.brainId}`);
|
|
36
|
+
// Metrics pipeline (teamvibe.ai#110): register for hourly collection. On the
|
|
37
|
+
// first sighting of this brain, fire one immediate collect+send so we don't
|
|
38
|
+
// wait up to an hour for the first sample.
|
|
39
|
+
registerBrainForMetrics(brain.brainId);
|
|
40
|
+
if (isNew) {
|
|
41
|
+
const brainId = brain.brainId;
|
|
42
|
+
void (async () => {
|
|
43
|
+
try {
|
|
44
|
+
const brainPath = join(config.BRAINS_PATH, brainId);
|
|
45
|
+
const payload = await collectMetrics(brainPath, brainId);
|
|
46
|
+
await sendMetrics(payload);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
logger.warn(`[Metrics] Initial send failed for ${brainId}: ${err instanceof Error ? err.message : err}`);
|
|
50
|
+
}
|
|
51
|
+
})();
|
|
52
|
+
}
|
|
30
53
|
}
|
|
31
54
|
/**
|
|
32
55
|
* Send a synthetic message to all tracked brains.
|
|
@@ -93,14 +116,44 @@ export function startHeartbeatLoop(processMessageFn) {
|
|
|
93
116
|
// Heartbeat cron — frequent, lightweight
|
|
94
117
|
logger.info(`[Heartbeat] Starting heartbeat cron: "${config.HEARTBEAT_CRON}"`);
|
|
95
118
|
heartbeatJob = new Cron(config.HEARTBEAT_CRON, { timezone: 'UTC', protect: true }, async () => {
|
|
96
|
-
await
|
|
119
|
+
await runHeartbeatJob(processMessageFn);
|
|
97
120
|
});
|
|
98
121
|
// Maintenance cron — daily, heavy (consolidation, memory cleanup)
|
|
99
122
|
logger.info(`[Maintenance] Starting maintenance cron: "${config.MAINTENANCE_CRON}"`);
|
|
100
123
|
maintenanceJob = new Cron(config.MAINTENANCE_CRON, { timezone: 'UTC', protect: true }, async () => {
|
|
101
|
-
await
|
|
124
|
+
await runMaintenanceJob(processMessageFn);
|
|
102
125
|
});
|
|
103
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Run the heartbeat cron handler with telemetry instrumentation (#119).
|
|
129
|
+
* Records last-fire timestamp on entry and last-success timestamp on completion.
|
|
130
|
+
* Exported for use by the cron registration above and tests that fire it manually.
|
|
131
|
+
*/
|
|
132
|
+
export async function runHeartbeatJob(processMessageFn) {
|
|
133
|
+
markHeartbeatJobFired();
|
|
134
|
+
try {
|
|
135
|
+
await triggerAllBrains('heartbeat', processMessageFn);
|
|
136
|
+
markHeartbeatJobSuccess();
|
|
137
|
+
}
|
|
138
|
+
catch (e) {
|
|
139
|
+
logger.error(`[Heartbeat] heartbeatJob handler threw: ${e instanceof Error ? e.message : e}`);
|
|
140
|
+
throw e; // preserve existing behavior; croner protect handles overlap
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Run the maintenance cron handler with telemetry instrumentation (#119).
|
|
145
|
+
*/
|
|
146
|
+
export async function runMaintenanceJob(processMessageFn) {
|
|
147
|
+
markMaintenanceJobFired();
|
|
148
|
+
try {
|
|
149
|
+
await triggerAllBrains('maintenance', processMessageFn);
|
|
150
|
+
markMaintenanceJobSuccess();
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
logger.error(`[Maintenance] maintenanceJob handler threw: ${e instanceof Error ? e.message : e}`);
|
|
154
|
+
throw e;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
104
157
|
/**
|
|
105
158
|
* Stop the heartbeat and maintenance crons (for shutdown).
|
|
106
159
|
*/
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v1 metrics payload — see teamvibeai/teamvibe.ai#110 (revised post-review 2026-05-21).
|
|
3
|
+
*
|
|
4
|
+
* All measures are OPTIONAL: a missing field means "this measure could not be
|
|
5
|
+
* collected" (file absent, glob failed, command unavailable, etc.). Timestream
|
|
6
|
+
* handles sparse measures natively — never send a zero in place of a real omission.
|
|
7
|
+
*/
|
|
8
|
+
export interface MetricsPayload {
|
|
9
|
+
brainId: string;
|
|
10
|
+
todayMdBytes?: number;
|
|
11
|
+
todayMdLines?: number;
|
|
12
|
+
summaryMdBytes?: number;
|
|
13
|
+
summaryMdLines?: number;
|
|
14
|
+
brainDirBytes?: number;
|
|
15
|
+
vmDiskFreeBytes?: number;
|
|
16
|
+
vmDiskUsedBytes?: number;
|
|
17
|
+
inboxUnreadFiles?: number;
|
|
18
|
+
inboxTotalFiles?: number;
|
|
19
|
+
oldestInboxMessageAgeSeconds?: number;
|
|
20
|
+
uncommittedChangeCount?: number;
|
|
21
|
+
daysSinceMaintenance?: number;
|
|
22
|
+
lastHeartbeatJobMs?: number;
|
|
23
|
+
lastHeartbeatJobSuccessMs?: number;
|
|
24
|
+
lastMaintenanceJobMs?: number;
|
|
25
|
+
lastMaintenanceJobSuccessMs?: number;
|
|
26
|
+
lastAutoUpdateJobMs?: number;
|
|
27
|
+
lastAutoUpdateJobSuccessMs?: number;
|
|
28
|
+
timestamp?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Collect all v1 metrics for a brain. Never throws — individual collector
|
|
32
|
+
* failures cause the corresponding measure to be omitted from the result.
|
|
33
|
+
*/
|
|
34
|
+
export declare function collectMetrics(brainPath: string, brainId: string): Promise<MetricsPayload>;
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { stat, readdir, readFile } from 'fs/promises';
|
|
2
|
+
import { statfs } from 'fs';
|
|
3
|
+
import { promisify } from 'util';
|
|
4
|
+
import { execFile } from 'child_process';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { getCronJobTimestamps } from '../cron-telemetry.js';
|
|
7
|
+
const statfsAsync = promisify(statfs);
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
const MS_PER_DAY = 86_400_000;
|
|
10
|
+
/**
|
|
11
|
+
* Recursively sum file sizes under a directory.
|
|
12
|
+
* Skips entries we cannot stat (permission errors, broken symlinks).
|
|
13
|
+
*/
|
|
14
|
+
async function dirSizeBytes(path) {
|
|
15
|
+
let total = 0;
|
|
16
|
+
let entries;
|
|
17
|
+
try {
|
|
18
|
+
entries = await readdir(path, { withFileTypes: true });
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
const full = join(path, entry.name);
|
|
25
|
+
try {
|
|
26
|
+
if (entry.isDirectory()) {
|
|
27
|
+
total += await dirSizeBytes(full);
|
|
28
|
+
}
|
|
29
|
+
else if (entry.isFile()) {
|
|
30
|
+
const s = await stat(full);
|
|
31
|
+
total += s.size;
|
|
32
|
+
}
|
|
33
|
+
// Skip symlinks/sockets/etc.
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Single broken entry shouldn't tank the whole walk.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return total;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Count newline characters in a file. Returns line count using newline-count
|
|
43
|
+
* semantics (a trailing newline = N lines, no trailing newline = N-1 lines —
|
|
44
|
+
* matches what `wc -l` would have reported).
|
|
45
|
+
*/
|
|
46
|
+
async function countLines(path) {
|
|
47
|
+
const content = await readFile(path, 'utf-8');
|
|
48
|
+
let count = 0;
|
|
49
|
+
for (let i = 0; i < content.length; i++) {
|
|
50
|
+
if (content.charCodeAt(i) === 10 /* \n */)
|
|
51
|
+
count++;
|
|
52
|
+
}
|
|
53
|
+
return count;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Find files matching .inbox/<thread>/new/* under brainPath.
|
|
57
|
+
* Returns absolute paths. Throws if the .inbox directory itself is unreadable —
|
|
58
|
+
* caller wraps in try/catch and omits the measure on any failure.
|
|
59
|
+
*/
|
|
60
|
+
async function listInboxUnreadFiles(brainPath) {
|
|
61
|
+
const inboxRoot = join(brainPath, '.inbox');
|
|
62
|
+
const threads = (await readdir(inboxRoot, { withFileTypes: true }));
|
|
63
|
+
const files = [];
|
|
64
|
+
for (const t of threads) {
|
|
65
|
+
if (!t.isDirectory())
|
|
66
|
+
continue;
|
|
67
|
+
const newDir = join(inboxRoot, t.name, 'new');
|
|
68
|
+
let entries;
|
|
69
|
+
try {
|
|
70
|
+
entries = (await readdir(newDir, { withFileTypes: true }));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
for (const e of entries) {
|
|
76
|
+
if (e.isFile())
|
|
77
|
+
files.push(join(newDir, e.name));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return files;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Count regular files anywhere under brainPath/.inbox.
|
|
84
|
+
*/
|
|
85
|
+
async function countInboxTotalFiles(brainPath) {
|
|
86
|
+
const inboxRoot = join(brainPath, '.inbox');
|
|
87
|
+
let count = 0;
|
|
88
|
+
async function walk(dir) {
|
|
89
|
+
let entries;
|
|
90
|
+
try {
|
|
91
|
+
entries = (await readdir(dir, { withFileTypes: true }));
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
for (const e of entries) {
|
|
97
|
+
const full = join(dir, e.name);
|
|
98
|
+
if (e.isDirectory()) {
|
|
99
|
+
await walk(full);
|
|
100
|
+
}
|
|
101
|
+
else if (e.isFile()) {
|
|
102
|
+
count++;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
await walk(inboxRoot);
|
|
107
|
+
return count;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Collect all v1 metrics for a brain. Never throws — individual collector
|
|
111
|
+
* failures cause the corresponding measure to be omitted from the result.
|
|
112
|
+
*/
|
|
113
|
+
export async function collectMetrics(brainPath, brainId) {
|
|
114
|
+
const payload = {
|
|
115
|
+
brainId,
|
|
116
|
+
timestamp: new Date().toISOString(),
|
|
117
|
+
};
|
|
118
|
+
// memory/TODAY.md size + line count
|
|
119
|
+
try {
|
|
120
|
+
const todayPath = join(brainPath, 'memory/TODAY.md');
|
|
121
|
+
const s = await stat(todayPath);
|
|
122
|
+
payload.todayMdBytes = s.size;
|
|
123
|
+
payload.todayMdLines = await countLines(todayPath);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// File missing or unreadable — omit both measures.
|
|
127
|
+
}
|
|
128
|
+
// memory/SUMMARY.md size + line count
|
|
129
|
+
try {
|
|
130
|
+
const summaryPath = join(brainPath, 'memory/SUMMARY.md');
|
|
131
|
+
const s = await stat(summaryPath);
|
|
132
|
+
payload.summaryMdBytes = s.size;
|
|
133
|
+
payload.summaryMdLines = await countLines(summaryPath);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// omit
|
|
137
|
+
}
|
|
138
|
+
// brain dir total size
|
|
139
|
+
try {
|
|
140
|
+
payload.brainDirBytes = await dirSizeBytes(brainPath);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// omit
|
|
144
|
+
}
|
|
145
|
+
// VM disk usage (root filesystem)
|
|
146
|
+
try {
|
|
147
|
+
const fs = await statfsAsync('/');
|
|
148
|
+
// fs.bsize × fs.bfree = free bytes
|
|
149
|
+
// fs.bsize × (fs.blocks - fs.bfree) = used bytes
|
|
150
|
+
payload.vmDiskFreeBytes = fs.bsize * fs.bfree;
|
|
151
|
+
payload.vmDiskUsedBytes = fs.bsize * (fs.blocks - fs.bfree);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// omit
|
|
155
|
+
}
|
|
156
|
+
// days since maintenance — fractional, from .last_consolidation mtime
|
|
157
|
+
try {
|
|
158
|
+
const guardPath = join(brainPath, 'memory/.last_consolidation');
|
|
159
|
+
const s = await stat(guardPath);
|
|
160
|
+
payload.daysSinceMaintenance = (Date.now() - s.mtimeMs) / MS_PER_DAY;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// Guard file may legitimately not exist (new brain, never consolidated).
|
|
164
|
+
// Omit rather than send a misleading 0.
|
|
165
|
+
}
|
|
166
|
+
// Inbox: unread files (.inbox/<thread>/new/*)
|
|
167
|
+
try {
|
|
168
|
+
const unread = await listInboxUnreadFiles(brainPath);
|
|
169
|
+
payload.inboxUnreadFiles = unread.length;
|
|
170
|
+
// Oldest unread message age — same source, single walk would be cheaper
|
|
171
|
+
// but we already have the paths and the volume is small.
|
|
172
|
+
if (unread.length === 0) {
|
|
173
|
+
payload.oldestInboxMessageAgeSeconds = 0;
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
let minMtime = Infinity;
|
|
177
|
+
for (const f of unread) {
|
|
178
|
+
try {
|
|
179
|
+
const s = await stat(f);
|
|
180
|
+
if (s.mtimeMs < minMtime)
|
|
181
|
+
minMtime = s.mtimeMs;
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// skip
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (minMtime !== Infinity) {
|
|
188
|
+
payload.oldestInboxMessageAgeSeconds = Math.floor((Date.now() - minMtime) / 1000);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// Per Jarvis's review: different poller versions may lay out .inbox/ differently.
|
|
194
|
+
// Omit measures rather than fail the batch.
|
|
195
|
+
}
|
|
196
|
+
// Inbox: total files (anywhere under .inbox)
|
|
197
|
+
try {
|
|
198
|
+
payload.inboxTotalFiles = await countInboxTotalFiles(brainPath);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// omit
|
|
202
|
+
}
|
|
203
|
+
// Uncommitted change count — git status --porcelain
|
|
204
|
+
try {
|
|
205
|
+
const { stdout } = await execFileAsync('git', ['-C', brainPath, 'status', '--porcelain'], {
|
|
206
|
+
timeout: 5000,
|
|
207
|
+
});
|
|
208
|
+
// Each non-empty line = one tracked change.
|
|
209
|
+
payload.uncommittedChangeCount = stdout.split('\n').filter((l) => l.length > 0).length;
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
// Non-git brain, missing git binary, timeout — omit.
|
|
213
|
+
}
|
|
214
|
+
// Poller-level cron-job telemetry (#119). Same six values for every brain on
|
|
215
|
+
// the poller — see issue body for storage cost rationale. Each measure is
|
|
216
|
+
// `now - storedTs`; if the cron has never fired (storedTs null), omit it.
|
|
217
|
+
const now = Date.now();
|
|
218
|
+
const cron = getCronJobTimestamps();
|
|
219
|
+
if (cron.lastHeartbeatJobFiredAt !== null) {
|
|
220
|
+
payload.lastHeartbeatJobMs = now - cron.lastHeartbeatJobFiredAt;
|
|
221
|
+
}
|
|
222
|
+
if (cron.lastHeartbeatJobSuccessAt !== null) {
|
|
223
|
+
payload.lastHeartbeatJobSuccessMs = now - cron.lastHeartbeatJobSuccessAt;
|
|
224
|
+
}
|
|
225
|
+
if (cron.lastMaintenanceJobFiredAt !== null) {
|
|
226
|
+
payload.lastMaintenanceJobMs = now - cron.lastMaintenanceJobFiredAt;
|
|
227
|
+
}
|
|
228
|
+
if (cron.lastMaintenanceJobSuccessAt !== null) {
|
|
229
|
+
payload.lastMaintenanceJobSuccessMs = now - cron.lastMaintenanceJobSuccessAt;
|
|
230
|
+
}
|
|
231
|
+
if (cron.lastAutoUpdateJobFiredAt !== null) {
|
|
232
|
+
payload.lastAutoUpdateJobMs = now - cron.lastAutoUpdateJobFiredAt;
|
|
233
|
+
}
|
|
234
|
+
if (cron.lastAutoUpdateJobSuccessAt !== null) {
|
|
235
|
+
payload.lastAutoUpdateJobSuccessMs = now - cron.lastAutoUpdateJobSuccessAt;
|
|
236
|
+
}
|
|
237
|
+
return payload;
|
|
238
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
interface RegisteredBrain {
|
|
2
|
+
brainId: string;
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Register (or update) a brain for hourly metrics collection.
|
|
6
|
+
* Safe to call repeatedly with the same brainId.
|
|
7
|
+
*/
|
|
8
|
+
export declare function registerBrainForMetrics(brainId: string): void;
|
|
9
|
+
/**
|
|
10
|
+
* Deterministic 32-bit FNV-1a hash of a string. Used to derive a stable
|
|
11
|
+
* per-brain jitter offset for hourly cron alignment (see #110 review):
|
|
12
|
+
* at fleet scale (100+ brains) we don't want every brain to fire at exactly
|
|
13
|
+
* UTC :00:00 — Lambda cold-start storm + Timestream write throttle risk.
|
|
14
|
+
*
|
|
15
|
+
* No crypto needed; we only need a stable, well-distributed integer.
|
|
16
|
+
*/
|
|
17
|
+
export declare function hashBrainId(s: string): number;
|
|
18
|
+
/**
|
|
19
|
+
* Per-brain deterministic jitter offset in [0, JITTER_WINDOW_MS).
|
|
20
|
+
* Two brains with different ids should normally land on different offsets;
|
|
21
|
+
* the same brain id always lands on the same offset.
|
|
22
|
+
*/
|
|
23
|
+
export declare function jitterMsFor(brainId: string): number;
|
|
24
|
+
/**
|
|
25
|
+
* Cron handler: fan out to every registered brain on its deterministic
|
|
26
|
+
* per-brain jitter offset (0–10 min after UTC :00). This avoids a thundering
|
|
27
|
+
* herd of metric writes at the exact hour boundary while keeping the
|
|
28
|
+
* per-brain offset stable (= no clock drift across hours).
|
|
29
|
+
*
|
|
30
|
+
* Exported for tests so they can fire the handler manually.
|
|
31
|
+
*/
|
|
32
|
+
export declare function collectAndSendAll(): void;
|
|
33
|
+
/**
|
|
34
|
+
* Start the hourly metrics cron. Fires once per UTC hour boundary; each tick
|
|
35
|
+
* fans out per-brain on a deterministic jitter offset (0–10min).
|
|
36
|
+
*
|
|
37
|
+
* Idempotent: re-invocation while already running is a no-op.
|
|
38
|
+
*/
|
|
39
|
+
export declare function startMetricsCron(): void;
|
|
40
|
+
/**
|
|
41
|
+
* Stop the metrics cron. Safe to call multiple times.
|
|
42
|
+
*/
|
|
43
|
+
export declare function stopMetricsCron(): void;
|
|
44
|
+
/**
|
|
45
|
+
* Test-only: clear registered brain list.
|
|
46
|
+
*/
|
|
47
|
+
export declare function _resetForTests(): void;
|
|
48
|
+
/**
|
|
49
|
+
* Test-only: read registered brains (returns a snapshot copy).
|
|
50
|
+
*/
|
|
51
|
+
export declare function _getRegisteredForTests(): RegisteredBrain[];
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { Cron } from 'croner';
|
|
3
|
+
import { config } from '../config.js';
|
|
4
|
+
import { logger } from '../logger.js';
|
|
5
|
+
import { collectMetrics } from './collector.js';
|
|
6
|
+
import { sendMetrics } from './sender.js';
|
|
7
|
+
const JITTER_WINDOW_MS = 10 * 60 * 1000; // 10 minutes spread per #110 review
|
|
8
|
+
const registered = new Map();
|
|
9
|
+
const pendingJitterTimers = new Set();
|
|
10
|
+
let metricsJob = null;
|
|
11
|
+
/**
|
|
12
|
+
* Register (or update) a brain for hourly metrics collection.
|
|
13
|
+
* Safe to call repeatedly with the same brainId.
|
|
14
|
+
*/
|
|
15
|
+
export function registerBrainForMetrics(brainId) {
|
|
16
|
+
if (!brainId)
|
|
17
|
+
return;
|
|
18
|
+
registered.set(brainId, { brainId });
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Collect + send metrics for a single brain. Never throws.
|
|
22
|
+
*/
|
|
23
|
+
async function collectAndSendOne(brain) {
|
|
24
|
+
try {
|
|
25
|
+
const brainPath = join(config.BRAINS_PATH, brain.brainId);
|
|
26
|
+
const payload = await collectMetrics(brainPath, brain.brainId);
|
|
27
|
+
await sendMetrics(payload);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
// Defensive: collector/sender already swallow their own errors, but if
|
|
31
|
+
// anything escapes (e.g. path resolution, unexpected runtime fault) we
|
|
32
|
+
// log and move on rather than crashing the cron tick.
|
|
33
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
34
|
+
logger.warn(`[Metrics] Unexpected failure for brain ${brain.brainId}: ${msg}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Deterministic 32-bit FNV-1a hash of a string. Used to derive a stable
|
|
39
|
+
* per-brain jitter offset for hourly cron alignment (see #110 review):
|
|
40
|
+
* at fleet scale (100+ brains) we don't want every brain to fire at exactly
|
|
41
|
+
* UTC :00:00 — Lambda cold-start storm + Timestream write throttle risk.
|
|
42
|
+
*
|
|
43
|
+
* No crypto needed; we only need a stable, well-distributed integer.
|
|
44
|
+
*/
|
|
45
|
+
export function hashBrainId(s) {
|
|
46
|
+
let h = 0x811c9dc5; // FNV offset basis
|
|
47
|
+
for (let i = 0; i < s.length; i++) {
|
|
48
|
+
h ^= s.charCodeAt(i);
|
|
49
|
+
// FNV prime is 0x01000193; use Math.imul for 32-bit overflow semantics.
|
|
50
|
+
h = Math.imul(h, 0x01000193);
|
|
51
|
+
}
|
|
52
|
+
// Coerce to unsigned 32-bit.
|
|
53
|
+
return h >>> 0;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Per-brain deterministic jitter offset in [0, JITTER_WINDOW_MS).
|
|
57
|
+
* Two brains with different ids should normally land on different offsets;
|
|
58
|
+
* the same brain id always lands on the same offset.
|
|
59
|
+
*/
|
|
60
|
+
export function jitterMsFor(brainId) {
|
|
61
|
+
return hashBrainId(brainId) % JITTER_WINDOW_MS;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Cron handler: fan out to every registered brain on its deterministic
|
|
65
|
+
* per-brain jitter offset (0–10 min after UTC :00). This avoids a thundering
|
|
66
|
+
* herd of metric writes at the exact hour boundary while keeping the
|
|
67
|
+
* per-brain offset stable (= no clock drift across hours).
|
|
68
|
+
*
|
|
69
|
+
* Exported for tests so they can fire the handler manually.
|
|
70
|
+
*/
|
|
71
|
+
export function collectAndSendAll() {
|
|
72
|
+
if (registered.size === 0)
|
|
73
|
+
return;
|
|
74
|
+
const brains = Array.from(registered.values());
|
|
75
|
+
for (const brain of brains) {
|
|
76
|
+
const delay = jitterMsFor(brain.brainId);
|
|
77
|
+
const timer = setTimeout(() => {
|
|
78
|
+
pendingJitterTimers.delete(timer);
|
|
79
|
+
void collectAndSendOne(brain);
|
|
80
|
+
}, delay);
|
|
81
|
+
pendingJitterTimers.add(timer);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Start the hourly metrics cron. Fires once per UTC hour boundary; each tick
|
|
86
|
+
* fans out per-brain on a deterministic jitter offset (0–10min).
|
|
87
|
+
*
|
|
88
|
+
* Idempotent: re-invocation while already running is a no-op.
|
|
89
|
+
*/
|
|
90
|
+
export function startMetricsCron() {
|
|
91
|
+
if (metricsJob)
|
|
92
|
+
return;
|
|
93
|
+
if (!config.TEAMVIBE_API_URL || !config.TEAMVIBE_POLLER_TOKEN) {
|
|
94
|
+
// No-op in direct (non-token) mode.
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
logger.info('[Metrics] Starting hourly cron: "0 * * * *" UTC (per-brain jitter 0–10min)');
|
|
98
|
+
metricsJob = new Cron('0 * * * *', { timezone: 'UTC', protect: true }, () => {
|
|
99
|
+
collectAndSendAll();
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Stop the metrics cron. Safe to call multiple times.
|
|
104
|
+
*/
|
|
105
|
+
export function stopMetricsCron() {
|
|
106
|
+
if (metricsJob) {
|
|
107
|
+
metricsJob.stop();
|
|
108
|
+
metricsJob = null;
|
|
109
|
+
}
|
|
110
|
+
for (const timer of pendingJitterTimers) {
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
}
|
|
113
|
+
pendingJitterTimers.clear();
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Test-only: clear registered brain list.
|
|
117
|
+
*/
|
|
118
|
+
export function _resetForTests() {
|
|
119
|
+
registered.clear();
|
|
120
|
+
stopMetricsCron();
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Test-only: read registered brains (returns a snapshot copy).
|
|
124
|
+
*/
|
|
125
|
+
export function _getRegisteredForTests() {
|
|
126
|
+
return Array.from(registered.values());
|
|
127
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { MetricsPayload } from './collector.js';
|
|
2
|
+
/**
|
|
3
|
+
* Send a metrics payload to the platform. Fail-safe:
|
|
4
|
+
* - 5s timeout per attempt
|
|
5
|
+
* - 1 retry after 30s on non-2xx or network failure
|
|
6
|
+
* - Never throws
|
|
7
|
+
*
|
|
8
|
+
* Returns true on success, false if both attempts failed (caller can log/ignore).
|
|
9
|
+
*/
|
|
10
|
+
export declare function sendMetrics(payload: MetricsPayload): Promise<boolean>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { config } from '../config.js';
|
|
2
|
+
import { logger } from '../logger.js';
|
|
3
|
+
const SEND_TIMEOUT_MS = 5_000;
|
|
4
|
+
const RETRY_DELAY_MS = 30_000;
|
|
5
|
+
function sleep(ms) {
|
|
6
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
7
|
+
}
|
|
8
|
+
async function postOnce(payload) {
|
|
9
|
+
const controller = new AbortController();
|
|
10
|
+
const timer = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS);
|
|
11
|
+
try {
|
|
12
|
+
const response = await fetch(`${config.TEAMVIBE_API_URL}/metrics`, {
|
|
13
|
+
method: 'POST',
|
|
14
|
+
headers: {
|
|
15
|
+
'Content-Type': 'application/json',
|
|
16
|
+
Authorization: `Bearer ${config.TEAMVIBE_POLLER_TOKEN}`,
|
|
17
|
+
},
|
|
18
|
+
body: JSON.stringify(payload),
|
|
19
|
+
signal: controller.signal,
|
|
20
|
+
});
|
|
21
|
+
if (!response.ok) {
|
|
22
|
+
logger.warn(`[Metrics] Send failed: ${response.status} for brain ${payload.brainId}`);
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
29
|
+
logger.warn(`[Metrics] Send error for brain ${payload.brainId}: ${msg}`);
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Send a metrics payload to the platform. Fail-safe:
|
|
38
|
+
* - 5s timeout per attempt
|
|
39
|
+
* - 1 retry after 30s on non-2xx or network failure
|
|
40
|
+
* - Never throws
|
|
41
|
+
*
|
|
42
|
+
* Returns true on success, false if both attempts failed (caller can log/ignore).
|
|
43
|
+
*/
|
|
44
|
+
export async function sendMetrics(payload) {
|
|
45
|
+
if (!config.TEAMVIBE_API_URL || !config.TEAMVIBE_POLLER_TOKEN) {
|
|
46
|
+
// Direct (non-token) mode — metrics endpoint isn't reachable. No-op.
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
const ok = await postOnce(payload);
|
|
50
|
+
if (ok)
|
|
51
|
+
return true;
|
|
52
|
+
await sleep(RETRY_DELAY_MS);
|
|
53
|
+
const retried = await postOnce(payload);
|
|
54
|
+
if (!retried) {
|
|
55
|
+
logger.warn(`[Metrics] Dropping payload for brain ${payload.brainId} after 1 retry`);
|
|
56
|
+
}
|
|
57
|
+
return retried;
|
|
58
|
+
}
|
package/dist/poller.d.ts
CHANGED
|
@@ -1 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run the auto-update cron handler with telemetry instrumentation (#119).
|
|
3
|
+
* Exported for tests that fire the handler manually.
|
|
4
|
+
*
|
|
5
|
+
* `checkForUpdateFn` is injected (default = real checkForUpdate) so tests
|
|
6
|
+
* can lock in the "swallow but still mark fired" contract that
|
|
7
|
+
* distinguishes scenario (A) cron-doesn't-fire from (B/C) cron-fires-but-fails
|
|
8
|
+
* in morning-check telemetry. Same DI shape as runHeartbeatJob/runMaintenanceJob.
|
|
9
|
+
*/
|
|
10
|
+
export declare function runAutoUpdateJob(checkForUpdateFn?: () => Promise<boolean>): Promise<void>;
|
|
1
11
|
export declare function startPoller(): Promise<void>;
|
package/dist/poller.js
CHANGED
|
@@ -9,10 +9,12 @@ import { postMaintenanceReports } from './maintenance-reporter.js';
|
|
|
9
9
|
import { initAuth, stopRefresh } from './auth-provider.js';
|
|
10
10
|
import { registerBrain, startHeartbeatLoop, stopHeartbeatLoop } from './heartbeat-manager.js';
|
|
11
11
|
import { startEventQueue, stopEventQueue, flush as flushEventQueue, emitEvent } from './event-queue.js';
|
|
12
|
+
import { startMetricsCron, stopMetricsCron } from './metrics/scheduler.js';
|
|
12
13
|
import { writeMessage, pickNextMessage, markProcessed, markFailed, hasUnprocessed } from './inbox-manager.js';
|
|
13
14
|
import { Cron } from 'croner';
|
|
14
15
|
import { isControlMessage, handleControlMessage } from './control-handler.js';
|
|
15
16
|
import { checkForUpdate } from './auto-update.js';
|
|
17
|
+
import { markAutoUpdateJobFired, markAutoUpdateJobSuccess } from './cron-telemetry.js';
|
|
16
18
|
import { exec } from 'child_process';
|
|
17
19
|
import { promisify } from 'util';
|
|
18
20
|
import { platform } from 'os';
|
|
@@ -21,6 +23,30 @@ const execAsync = promisify(exec);
|
|
|
21
23
|
const processingMessages = new Set();
|
|
22
24
|
// Poller auto-update cron (separate from brain heartbeats)
|
|
23
25
|
let autoUpdateJob = null;
|
|
26
|
+
/**
|
|
27
|
+
* Run the auto-update cron handler with telemetry instrumentation (#119).
|
|
28
|
+
* Exported for tests that fire the handler manually.
|
|
29
|
+
*
|
|
30
|
+
* `checkForUpdateFn` is injected (default = real checkForUpdate) so tests
|
|
31
|
+
* can lock in the "swallow but still mark fired" contract that
|
|
32
|
+
* distinguishes scenario (A) cron-doesn't-fire from (B/C) cron-fires-but-fails
|
|
33
|
+
* in morning-check telemetry. Same DI shape as runHeartbeatJob/runMaintenanceJob.
|
|
34
|
+
*/
|
|
35
|
+
export async function runAutoUpdateJob(checkForUpdateFn = checkForUpdate) {
|
|
36
|
+
markAutoUpdateJobFired();
|
|
37
|
+
try {
|
|
38
|
+
const shouldRestart = await checkForUpdateFn();
|
|
39
|
+
markAutoUpdateJobSuccess();
|
|
40
|
+
if (shouldRestart) {
|
|
41
|
+
logger.info('[AutoUpdate] Update applied, restarting...');
|
|
42
|
+
await flushEventQueue();
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
logger.error(`[AutoUpdate] Check error: ${error instanceof Error ? error.message : error}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
24
50
|
function startAutoUpdateCron() {
|
|
25
51
|
const schedule = config.AUTO_UPDATE_SCHEDULE;
|
|
26
52
|
const match = schedule.match(/^(\d{2}):(\d{2})$/);
|
|
@@ -34,17 +60,7 @@ function startAutoUpdateCron() {
|
|
|
34
60
|
const cronExpr = `${match[2]} ${match[1]} * * *`;
|
|
35
61
|
logger.info(`[AutoUpdate] Scheduled daily at ${schedule} UTC (cron: ${cronExpr}), policy: ${config.AUTO_UPDATE_POLICY}`);
|
|
36
62
|
autoUpdateJob = new Cron(cronExpr, { timezone: 'UTC' }, async () => {
|
|
37
|
-
|
|
38
|
-
const shouldRestart = await checkForUpdate();
|
|
39
|
-
if (shouldRestart) {
|
|
40
|
-
logger.info('[AutoUpdate] Update applied, restarting...');
|
|
41
|
-
await flushEventQueue();
|
|
42
|
-
process.exit(0);
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
catch (error) {
|
|
46
|
-
logger.error(`[AutoUpdate] Check error: ${error instanceof Error ? error.message : error}`);
|
|
47
|
-
}
|
|
63
|
+
await runAutoUpdateJob();
|
|
48
64
|
});
|
|
49
65
|
}
|
|
50
66
|
function stopAutoUpdateCron() {
|
|
@@ -464,6 +480,7 @@ async function shutdown(signal, exitCode = 0) {
|
|
|
464
480
|
stopRefresh();
|
|
465
481
|
stopHeartbeatLoop();
|
|
466
482
|
stopAutoUpdateCron();
|
|
483
|
+
stopMetricsCron();
|
|
467
484
|
stopEventQueue();
|
|
468
485
|
// Kill all active Claude child processes
|
|
469
486
|
await killAllActiveProcesses();
|
|
@@ -502,5 +519,6 @@ export async function startPoller() {
|
|
|
502
519
|
await ensureBaseBrain();
|
|
503
520
|
startHeartbeatLoop(processMessage);
|
|
504
521
|
startAutoUpdateCron();
|
|
522
|
+
startMetricsCron();
|
|
505
523
|
await pollLoop();
|
|
506
524
|
}
|