@cleocode/core 2026.3.57 → 2026.3.59
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/agents/agent-registry.d.ts +206 -0
- package/dist/agents/agent-registry.d.ts.map +1 -0
- package/dist/agents/agent-schema.d.ts.map +1 -1
- package/dist/agents/execution-learning.d.ts +223 -0
- package/dist/agents/execution-learning.d.ts.map +1 -0
- package/dist/agents/health-monitor.d.ts +161 -0
- package/dist/agents/health-monitor.d.ts.map +1 -0
- package/dist/agents/index.d.ts +4 -1
- package/dist/agents/index.d.ts.map +1 -1
- package/dist/agents/retry.d.ts +57 -4
- package/dist/agents/retry.d.ts.map +1 -1
- package/dist/backfill/index.d.ts +83 -0
- package/dist/backfill/index.d.ts.map +1 -0
- package/dist/bootstrap.d.ts +1 -1
- package/dist/config.d.ts +47 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6985 -5068
- package/dist/index.js.map +4 -4
- package/dist/intelligence/adaptive-validation.d.ts +151 -0
- package/dist/intelligence/adaptive-validation.d.ts.map +1 -0
- package/dist/intelligence/impact.d.ts +34 -1
- package/dist/intelligence/impact.d.ts.map +1 -1
- package/dist/intelligence/index.d.ts +7 -2
- package/dist/intelligence/index.d.ts.map +1 -1
- package/dist/intelligence/types.d.ts +60 -0
- package/dist/intelligence/types.d.ts.map +1 -1
- package/dist/internal.d.ts +8 -4
- package/dist/internal.d.ts.map +1 -1
- package/dist/lib/index.d.ts +10 -0
- package/dist/lib/index.d.ts.map +1 -0
- package/dist/lib/retry.d.ts +128 -0
- package/dist/lib/retry.d.ts.map +1 -0
- package/dist/nexus/sharing/index.d.ts +48 -2
- package/dist/nexus/sharing/index.d.ts.map +1 -1
- package/dist/sessions/session-enforcement.d.ts.map +1 -1
- package/dist/stats/index.d.ts +1 -0
- package/dist/stats/index.d.ts.map +1 -1
- package/dist/stats/workflow-telemetry.d.ts +89 -0
- package/dist/stats/workflow-telemetry.d.ts.map +1 -0
- package/dist/store/brain-schema.d.ts.map +1 -1
- package/dist/store/converters.d.ts.map +1 -1
- package/dist/store/cross-db-cleanup.d.ts +93 -0
- package/dist/store/cross-db-cleanup.d.ts.map +1 -0
- package/dist/store/db-helpers.d.ts.map +1 -1
- package/dist/store/migration-sqlite.d.ts.map +1 -1
- package/dist/store/sqlite-data-accessor.d.ts.map +1 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/task-store.d.ts.map +1 -1
- package/dist/store/tasks-schema.d.ts +18 -3
- package/dist/store/tasks-schema.d.ts.map +1 -1
- package/dist/store/validation-schemas.d.ts +32 -0
- package/dist/store/validation-schemas.d.ts.map +1 -1
- package/dist/tasks/add.d.ts +10 -1
- package/dist/tasks/add.d.ts.map +1 -1
- package/dist/tasks/complete.d.ts.map +1 -1
- package/dist/tasks/enforcement.d.ts +22 -0
- package/dist/tasks/enforcement.d.ts.map +1 -0
- package/dist/tasks/epic-enforcement.d.ts +199 -0
- package/dist/tasks/epic-enforcement.d.ts.map +1 -0
- package/dist/tasks/index.d.ts +1 -1
- package/dist/tasks/index.d.ts.map +1 -1
- package/dist/tasks/pipeline-stage.d.ts +181 -0
- package/dist/tasks/pipeline-stage.d.ts.map +1 -0
- package/dist/tasks/update.d.ts +2 -0
- package/dist/tasks/update.d.ts.map +1 -1
- package/migrations/drizzle-brain/20260321000001_t033-brain-indexes/migration.sql +12 -0
- package/migrations/drizzle-brain/20260321000001_t033-brain-indexes/snapshot.json +1232 -0
- package/migrations/drizzle-tasks/20260321000000_t033-connection-health/migration.sql +518 -0
- package/migrations/drizzle-tasks/20260321000000_t033-connection-health/snapshot.json +4312 -0
- package/migrations/drizzle-tasks/20260321000002_t060-pipeline-stage-binding/migration.sql +82 -0
- package/migrations/drizzle-tasks/20260321000002_t060-pipeline-stage-binding/snapshot.json +9 -0
- package/package.json +5 -5
- package/schemas/config.schema.json +37 -1547
- package/src/__tests__/sharing.test.ts +24 -0
- package/src/agents/__tests__/agent-registry.test.ts +351 -0
- package/src/agents/__tests__/execution-learning.test.ts +684 -0
- package/src/agents/__tests__/health-monitor.test.ts +332 -0
- package/src/agents/__tests__/registry.test.ts +30 -2
- package/src/agents/agent-registry.ts +394 -0
- package/src/agents/agent-schema.ts +5 -0
- package/src/agents/execution-learning.ts +675 -0
- package/src/agents/health-monitor.ts +279 -0
- package/src/agents/index.ts +37 -1
- package/src/agents/retry.ts +57 -4
- package/src/backfill/index.ts +309 -0
- package/src/bootstrap.ts +1 -1
- package/src/config.ts +126 -0
- package/src/index.ts +8 -1
- package/src/intelligence/__tests__/adaptive-validation.test.ts +694 -0
- package/src/intelligence/__tests__/impact.test.ts +165 -1
- package/src/intelligence/adaptive-validation.ts +764 -0
- package/src/intelligence/impact.ts +203 -0
- package/src/intelligence/index.ts +19 -0
- package/src/intelligence/types.ts +76 -0
- package/src/internal.ts +39 -0
- package/src/lib/__tests__/retry.test.ts +321 -0
- package/src/lib/index.ts +16 -0
- package/src/lib/retry.ts +224 -0
- package/src/lifecycle/__tests__/chain-store.test.ts +7 -0
- package/src/lifecycle/__tests__/tessera-engine.test.ts +52 -0
- package/src/nexus/sharing/index.ts +142 -2
- package/src/sessions/__tests__/session-edge-cases.test.ts +24 -1
- package/src/sessions/session-enforcement.ts +13 -2
- package/src/stats/index.ts +7 -0
- package/src/stats/workflow-telemetry.ts +502 -0
- package/src/store/__tests__/migration-safety.test.ts +3 -0
- package/src/store/__tests__/session-store.test.ts +132 -1
- package/src/store/__tests__/task-store.test.ts +22 -1
- package/src/store/__tests__/test-db-helper.ts +29 -2
- package/src/store/brain-schema.ts +4 -1
- package/src/store/converters.ts +2 -0
- package/src/store/cross-db-cleanup.ts +192 -0
- package/src/store/db-helpers.ts +2 -0
- package/src/store/migration-sqlite.ts +6 -0
- package/src/store/sqlite-data-accessor.ts +20 -28
- package/src/store/sqlite.ts +14 -2
- package/src/store/task-store.ts +6 -0
- package/src/store/tasks-schema.ts +59 -20
- package/src/tasks/__tests__/add.test.ts +16 -0
- package/src/tasks/__tests__/complete-unblocks.test.ts +10 -1
- package/src/tasks/__tests__/complete.test.ts +11 -2
- package/src/tasks/__tests__/epic-enforcement.test.ts +909 -0
- package/src/tasks/__tests__/minimal-test.test.ts +28 -0
- package/src/tasks/__tests__/pipeline-stage.test.ts +403 -0
- package/src/tasks/__tests__/update.test.ts +40 -6
- package/src/tasks/add.ts +128 -2
- package/src/tasks/complete.ts +29 -17
- package/src/tasks/enforcement.ts +127 -0
- package/src/tasks/epic-enforcement.ts +364 -0
- package/src/tasks/index.ts +1 -0
- package/src/tasks/pipeline-stage.ts +293 -0
- package/src/tasks/update.ts +62 -0
- package/templates/config.template.json +34 -111
- package/templates/global-config.template.json +24 -40
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for agent health monitoring: recordHeartbeat, checkAgentHealth,
|
|
3
|
+
* detectStaleAgents, detectCrashedAgents.
|
|
4
|
+
*
|
|
5
|
+
* @module agents/__tests__/health-monitor.test
|
|
6
|
+
* @task T039
|
|
7
|
+
* @epic T038
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
checkAgentHealth,
|
|
17
|
+
detectCrashedAgents,
|
|
18
|
+
detectStaleAgents,
|
|
19
|
+
HEARTBEAT_INTERVAL_MS,
|
|
20
|
+
recordHeartbeat,
|
|
21
|
+
STALE_THRESHOLD_MS,
|
|
22
|
+
} from '../health-monitor.js';
|
|
23
|
+
import {
|
|
24
|
+
deregisterAgent,
|
|
25
|
+
getAgentInstance,
|
|
26
|
+
markCrashed,
|
|
27
|
+
registerAgent,
|
|
28
|
+
updateAgentStatus,
|
|
29
|
+
} from '../registry.js';
|
|
30
|
+
|
|
31
|
+
describe('Agent Health Monitor (T039)', () => {
|
|
32
|
+
let tempDir: string;
|
|
33
|
+
|
|
34
|
+
beforeEach(async () => {
|
|
35
|
+
tempDir = await mkdtemp(join(tmpdir(), 'cleo-health-test-'));
|
|
36
|
+
await mkdir(join(tempDir, '.cleo'), { recursive: true });
|
|
37
|
+
await mkdir(join(tempDir, '.cleo', 'backups', 'operational'), { recursive: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(async () => {
|
|
41
|
+
try {
|
|
42
|
+
const { closeAllDatabases } = await import('../../store/sqlite.js');
|
|
43
|
+
await closeAllDatabases();
|
|
44
|
+
} catch {
|
|
45
|
+
/* module may not be loaded */
|
|
46
|
+
}
|
|
47
|
+
await Promise.race([
|
|
48
|
+
rm(tempDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 300 }).catch(() => {}),
|
|
49
|
+
new Promise<void>((resolve) => setTimeout(resolve, 8_000)),
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ==========================================================================
|
|
54
|
+
// Constants
|
|
55
|
+
// ==========================================================================
|
|
56
|
+
|
|
57
|
+
describe('module constants', () => {
|
|
58
|
+
it('exports HEARTBEAT_INTERVAL_MS as 30000', () => {
|
|
59
|
+
expect(HEARTBEAT_INTERVAL_MS).toBe(30_000);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('exports STALE_THRESHOLD_MS as 180000 (3 minutes)', () => {
|
|
63
|
+
expect(STALE_THRESHOLD_MS).toBe(180_000);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ==========================================================================
|
|
68
|
+
// recordHeartbeat
|
|
69
|
+
// ==========================================================================
|
|
70
|
+
|
|
71
|
+
describe('recordHeartbeat', () => {
|
|
72
|
+
it('updates last_heartbeat and returns current status', async () => {
|
|
73
|
+
const agent = await registerAgent({ agentType: 'executor' }, tempDir);
|
|
74
|
+
await updateAgentStatus(agent.id, { status: 'active' }, tempDir);
|
|
75
|
+
|
|
76
|
+
const status = await recordHeartbeat(agent.id, tempDir);
|
|
77
|
+
expect(status).toBe('active');
|
|
78
|
+
|
|
79
|
+
const updated = await getAgentInstance(agent.id, tempDir);
|
|
80
|
+
expect(updated!.lastHeartbeat).toBeTruthy();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('returns null for non-existent agent', async () => {
|
|
84
|
+
const result = await recordHeartbeat('agt_nonexistent_abc123', tempDir);
|
|
85
|
+
expect(result).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('does not update heartbeat for stopped agents', async () => {
|
|
89
|
+
const agent = await registerAgent({ agentType: 'executor' }, tempDir);
|
|
90
|
+
await deregisterAgent(agent.id, tempDir);
|
|
91
|
+
|
|
92
|
+
const beforeHeartbeat = (await getAgentInstance(agent.id, tempDir))!.lastHeartbeat;
|
|
93
|
+
const status = await recordHeartbeat(agent.id, tempDir);
|
|
94
|
+
|
|
95
|
+
expect(status).toBe('stopped');
|
|
96
|
+
const afterHeartbeat = (await getAgentInstance(agent.id, tempDir))!.lastHeartbeat;
|
|
97
|
+
// Heartbeat should NOT have been updated for terminal agent
|
|
98
|
+
expect(afterHeartbeat).toBe(beforeHeartbeat);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('does not update heartbeat for crashed agents', async () => {
|
|
102
|
+
const agent = await registerAgent({ agentType: 'executor' }, tempDir);
|
|
103
|
+
await markCrashed(agent.id, 'test', tempDir);
|
|
104
|
+
|
|
105
|
+
const status = await recordHeartbeat(agent.id, tempDir);
|
|
106
|
+
expect(status).toBe('crashed');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('works for idle agents', async () => {
|
|
110
|
+
const agent = await registerAgent({ agentType: 'orchestrator' }, tempDir);
|
|
111
|
+
await updateAgentStatus(agent.id, { status: 'idle' }, tempDir);
|
|
112
|
+
|
|
113
|
+
const status = await recordHeartbeat(agent.id, tempDir);
|
|
114
|
+
expect(status).toBe('idle');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ==========================================================================
|
|
119
|
+
// checkAgentHealth
|
|
120
|
+
// ==========================================================================
|
|
121
|
+
|
|
122
|
+
describe('checkAgentHealth', () => {
|
|
123
|
+
it('returns null for a non-existent agent', async () => {
|
|
124
|
+
const result = await checkAgentHealth('agt_nonexistent_abc123', STALE_THRESHOLD_MS, tempDir);
|
|
125
|
+
expect(result).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('returns a health status with correct fields', async () => {
|
|
129
|
+
const agent = await registerAgent({ agentType: 'executor' }, tempDir);
|
|
130
|
+
await updateAgentStatus(agent.id, { status: 'active' }, tempDir);
|
|
131
|
+
|
|
132
|
+
const health = await checkAgentHealth(agent.id, STALE_THRESHOLD_MS, tempDir);
|
|
133
|
+
expect(health).not.toBeNull();
|
|
134
|
+
expect(health!.agentId).toBe(agent.id);
|
|
135
|
+
expect(health!.status).toBe('active');
|
|
136
|
+
expect(health!.lastHeartbeat).toBeTruthy();
|
|
137
|
+
expect(typeof health!.heartbeatAgeMs).toBe('number');
|
|
138
|
+
expect(health!.thresholdMs).toBe(STALE_THRESHOLD_MS);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('reports healthy when heartbeat is recent', async () => {
|
|
142
|
+
const agent = await registerAgent({ agentType: 'executor' }, tempDir);
|
|
143
|
+
await updateAgentStatus(agent.id, { status: 'active' }, tempDir);
|
|
144
|
+
await recordHeartbeat(agent.id, tempDir);
|
|
145
|
+
|
|
146
|
+
const health = await checkAgentHealth(agent.id, 60_000, tempDir);
|
|
147
|
+
expect(health!.healthy).toBe(true);
|
|
148
|
+
expect(health!.stale).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('reports stale when heartbeat exceeds threshold', async () => {
|
|
152
|
+
const agent = await registerAgent({ agentType: 'executor' }, tempDir);
|
|
153
|
+
await updateAgentStatus(agent.id, { status: 'active' }, tempDir);
|
|
154
|
+
|
|
155
|
+
// Use a 0ms threshold — any heartbeat is immediately stale
|
|
156
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
157
|
+
const health = await checkAgentHealth(agent.id, 0, tempDir);
|
|
158
|
+
expect(health!.stale).toBe(true);
|
|
159
|
+
expect(health!.healthy).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('stopped agents are not healthy or stale', async () => {
|
|
163
|
+
const agent = await registerAgent({ agentType: 'executor' }, tempDir);
|
|
164
|
+
await deregisterAgent(agent.id, tempDir);
|
|
165
|
+
|
|
166
|
+
const health = await checkAgentHealth(agent.id, 0, tempDir);
|
|
167
|
+
expect(health!.healthy).toBe(false);
|
|
168
|
+
expect(health!.stale).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('defaults threshold to STALE_THRESHOLD_MS when not provided', async () => {
|
|
172
|
+
const agent = await registerAgent({ agentType: 'executor' }, tempDir);
|
|
173
|
+
await updateAgentStatus(agent.id, { status: 'active' }, tempDir);
|
|
174
|
+
|
|
175
|
+
const health = await checkAgentHealth(agent.id, undefined, tempDir);
|
|
176
|
+
expect(health!.thresholdMs).toBe(STALE_THRESHOLD_MS);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// ==========================================================================
|
|
181
|
+
// detectStaleAgents
|
|
182
|
+
// ==========================================================================
|
|
183
|
+
|
|
184
|
+
describe('detectStaleAgents', () => {
|
|
185
|
+
it('returns empty array when all agents are healthy', async () => {
|
|
186
|
+
const agent = await registerAgent({ agentType: 'executor' }, tempDir);
|
|
187
|
+
await updateAgentStatus(agent.id, { status: 'active' }, tempDir);
|
|
188
|
+
await recordHeartbeat(agent.id, tempDir);
|
|
189
|
+
|
|
190
|
+
const stale = await detectStaleAgents(60_000, tempDir);
|
|
191
|
+
expect(stale.some((s) => s.agentId === agent.id)).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('detects active agents with old heartbeats', async () => {
|
|
195
|
+
const agent = await registerAgent({ agentType: 'executor' }, tempDir);
|
|
196
|
+
await updateAgentStatus(agent.id, { status: 'active' }, tempDir);
|
|
197
|
+
|
|
198
|
+
// 0ms threshold — all are immediately stale
|
|
199
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
200
|
+
const stale = await detectStaleAgents(0, tempDir);
|
|
201
|
+
expect(stale.some((s) => s.agentId === agent.id)).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('detects idle agents with old heartbeats', async () => {
|
|
205
|
+
const agent = await registerAgent({ agentType: 'executor' }, tempDir);
|
|
206
|
+
await updateAgentStatus(agent.id, { status: 'idle' }, tempDir);
|
|
207
|
+
|
|
208
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
209
|
+
const stale = await detectStaleAgents(0, tempDir);
|
|
210
|
+
expect(stale.some((s) => s.agentId === agent.id)).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('does not include stopped or crashed agents', async () => {
|
|
214
|
+
const stopped = await registerAgent({ agentType: 'executor' }, tempDir);
|
|
215
|
+
const crashed = await registerAgent({ agentType: 'researcher' }, tempDir);
|
|
216
|
+
await deregisterAgent(stopped.id, tempDir);
|
|
217
|
+
await markCrashed(crashed.id, 'test', tempDir);
|
|
218
|
+
|
|
219
|
+
const stale = await detectStaleAgents(0, tempDir);
|
|
220
|
+
expect(stale.some((s) => s.agentId === stopped.id)).toBe(false);
|
|
221
|
+
expect(stale.some((s) => s.agentId === crashed.id)).toBe(false);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('returns results sorted by heartbeat age descending (most stale first)', async () => {
|
|
225
|
+
const a1 = await registerAgent({ agentType: 'executor' }, tempDir);
|
|
226
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
227
|
+
const a2 = await registerAgent({ agentType: 'researcher' }, tempDir);
|
|
228
|
+
|
|
229
|
+
await updateAgentStatus(a1.id, { status: 'active' }, tempDir);
|
|
230
|
+
await updateAgentStatus(a2.id, { status: 'active' }, tempDir);
|
|
231
|
+
|
|
232
|
+
const stale = await detectStaleAgents(0, tempDir);
|
|
233
|
+
if (stale.length >= 2) {
|
|
234
|
+
// Most stale (older heartbeat) should be first
|
|
235
|
+
expect(stale[0]!.heartbeatAgeMs).toBeGreaterThanOrEqual(stale[1]!.heartbeatAgeMs);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('uses STALE_THRESHOLD_MS as default', async () => {
|
|
240
|
+
const agent = await registerAgent({ agentType: 'executor' }, tempDir);
|
|
241
|
+
await updateAgentStatus(agent.id, { status: 'active' }, tempDir);
|
|
242
|
+
await recordHeartbeat(agent.id, tempDir);
|
|
243
|
+
|
|
244
|
+
// With default 3min threshold, freshly heartbeated agent should NOT be stale
|
|
245
|
+
const stale = await detectStaleAgents(undefined, tempDir);
|
|
246
|
+
expect(stale.some((s) => s.agentId === agent.id)).toBe(false);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// ==========================================================================
|
|
251
|
+
// detectCrashedAgents
|
|
252
|
+
// ==========================================================================
|
|
253
|
+
|
|
254
|
+
describe('detectCrashedAgents', () => {
|
|
255
|
+
it('returns empty array when no agents have stale heartbeats', async () => {
|
|
256
|
+
const agent = await registerAgent({ agentType: 'executor' }, tempDir);
|
|
257
|
+
await updateAgentStatus(agent.id, { status: 'active' }, tempDir);
|
|
258
|
+
await recordHeartbeat(agent.id, tempDir);
|
|
259
|
+
|
|
260
|
+
const crashed = await detectCrashedAgents(60_000, tempDir);
|
|
261
|
+
expect(crashed.some((a) => a.id === agent.id)).toBe(false);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('marks active agents with stale heartbeats as crashed', async () => {
|
|
265
|
+
const agent = await registerAgent({ agentType: 'executor' }, tempDir);
|
|
266
|
+
await updateAgentStatus(agent.id, { status: 'active' }, tempDir);
|
|
267
|
+
|
|
268
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
269
|
+
const crashed = await detectCrashedAgents(0, tempDir);
|
|
270
|
+
expect(crashed.some((a) => a.id === agent.id)).toBe(true);
|
|
271
|
+
|
|
272
|
+
// Verify DB is updated
|
|
273
|
+
const dbRecord = await getAgentInstance(agent.id, tempDir);
|
|
274
|
+
expect(dbRecord!.status).toBe('crashed');
|
|
275
|
+
expect(dbRecord!.errorCount).toBe(1);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('only targets active agents, not idle or starting', async () => {
|
|
279
|
+
const idle = await registerAgent({ agentType: 'executor' }, tempDir);
|
|
280
|
+
const starting = await registerAgent({ agentType: 'researcher' }, tempDir);
|
|
281
|
+
await updateAgentStatus(idle.id, { status: 'idle' }, tempDir);
|
|
282
|
+
// starting status is already the default
|
|
283
|
+
|
|
284
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
285
|
+
const crashed = await detectCrashedAgents(0, tempDir);
|
|
286
|
+
expect(crashed.some((a) => a.id === idle.id)).toBe(false);
|
|
287
|
+
expect(crashed.some((a) => a.id === starting.id)).toBe(false);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('returns results sorted by heartbeat age ascending (oldest first)', async () => {
|
|
291
|
+
const a1 = await registerAgent({ agentType: 'executor' }, tempDir);
|
|
292
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
293
|
+
const a2 = await registerAgent({ agentType: 'researcher' }, tempDir);
|
|
294
|
+
|
|
295
|
+
await updateAgentStatus(a1.id, { status: 'active' }, tempDir);
|
|
296
|
+
await updateAgentStatus(a2.id, { status: 'active' }, tempDir);
|
|
297
|
+
|
|
298
|
+
const crashed = await detectCrashedAgents(0, tempDir);
|
|
299
|
+
if (crashed.length >= 2) {
|
|
300
|
+
// Oldest heartbeat first (ascending by lastHeartbeat string)
|
|
301
|
+
const idx1 = crashed.findIndex((a) => a.id === a1.id);
|
|
302
|
+
const idx2 = crashed.findIndex((a) => a.id === a2.id);
|
|
303
|
+
if (idx1 !== -1 && idx2 !== -1) {
|
|
304
|
+
expect(idx1).toBeLessThan(idx2);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('is idempotent — already-crashed agents are not re-processed', async () => {
|
|
310
|
+
const agent = await registerAgent({ agentType: 'executor' }, tempDir);
|
|
311
|
+
await updateAgentStatus(agent.id, { status: 'active' }, tempDir);
|
|
312
|
+
|
|
313
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
314
|
+
const first = await detectCrashedAgents(0, tempDir);
|
|
315
|
+
expect(first.some((a) => a.id === agent.id)).toBe(true);
|
|
316
|
+
|
|
317
|
+
// Second call should NOT include the already-crashed agent
|
|
318
|
+
const second = await detectCrashedAgents(0, tempDir);
|
|
319
|
+
expect(second.some((a) => a.id === agent.id)).toBe(false);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('defaults threshold to STALE_THRESHOLD_MS', async () => {
|
|
323
|
+
const agent = await registerAgent({ agentType: 'executor' }, tempDir);
|
|
324
|
+
await updateAgentStatus(agent.id, { status: 'active' }, tempDir);
|
|
325
|
+
await recordHeartbeat(agent.id, tempDir);
|
|
326
|
+
|
|
327
|
+
// With default 3min threshold, freshly heartbeated agent should NOT be detected
|
|
328
|
+
const crashed = await detectCrashedAgents(undefined, tempDir);
|
|
329
|
+
expect(crashed.some((a) => a.id === agent.id)).toBe(false);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
});
|
|
@@ -84,19 +84,47 @@ describe('Agent Registry', () => {
|
|
|
84
84
|
});
|
|
85
85
|
|
|
86
86
|
it('accepts optional session, task, and parent', async () => {
|
|
87
|
+
// Insert FK parent records before creating the agent that references them.
|
|
88
|
+
// FK constraints: session_id -> sessions.id, task_id -> tasks.id,
|
|
89
|
+
// parent_agent_id -> agent_instances.id (all SET NULL on delete).
|
|
90
|
+
const { getDb } = await import('../../store/sqlite.js');
|
|
91
|
+
const { tasks: tasksTable, sessions: sessionsTable } = await import(
|
|
92
|
+
'../../store/tasks-schema.js'
|
|
93
|
+
);
|
|
94
|
+
const db = await getDb(tempDir);
|
|
95
|
+
db.insert(sessionsTable)
|
|
96
|
+
.values({
|
|
97
|
+
id: 'ses_test_123',
|
|
98
|
+
name: 'Test session',
|
|
99
|
+
status: 'active',
|
|
100
|
+
scopeJson: '{"type":"global"}',
|
|
101
|
+
startedAt: new Date().toISOString(),
|
|
102
|
+
})
|
|
103
|
+
.run();
|
|
104
|
+
db.insert(tasksTable)
|
|
105
|
+
.values({
|
|
106
|
+
id: 'T001',
|
|
107
|
+
title: 'Test task',
|
|
108
|
+
status: 'pending',
|
|
109
|
+
priority: 'medium',
|
|
110
|
+
createdAt: new Date().toISOString(),
|
|
111
|
+
})
|
|
112
|
+
.run();
|
|
113
|
+
const parent = await registerAgent({ agentType: 'orchestrator' }, tempDir);
|
|
114
|
+
|
|
87
115
|
const agent = await registerAgent(
|
|
88
116
|
{
|
|
89
117
|
agentType: 'researcher',
|
|
90
118
|
sessionId: 'ses_test_123',
|
|
91
119
|
taskId: 'T001',
|
|
92
|
-
parentAgentId:
|
|
120
|
+
parentAgentId: parent.id,
|
|
93
121
|
},
|
|
94
122
|
tempDir,
|
|
95
123
|
);
|
|
96
124
|
|
|
97
125
|
expect(agent.sessionId).toBe('ses_test_123');
|
|
98
126
|
expect(agent.taskId).toBe('T001');
|
|
99
|
-
expect(agent.parentAgentId).toBe(
|
|
127
|
+
expect(agent.parentAgentId).toBe(parent.id);
|
|
100
128
|
});
|
|
101
129
|
|
|
102
130
|
it('stores metadata as JSON', async () => {
|