tlc-claude-code 1.4.1 → 1.4.4
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/dashboard/dist/App.js +229 -35
- package/dashboard/dist/components/AgentRegistryPane.d.ts +35 -0
- package/dashboard/dist/components/AgentRegistryPane.js +89 -0
- package/dashboard/dist/components/AgentRegistryPane.test.d.ts +1 -0
- package/dashboard/dist/components/AgentRegistryPane.test.js +200 -0
- package/dashboard/dist/components/RouterPane.d.ts +5 -0
- package/dashboard/dist/components/RouterPane.js +65 -0
- package/dashboard/dist/components/RouterPane.test.d.ts +1 -0
- package/dashboard/dist/components/RouterPane.test.js +176 -0
- package/dashboard/dist/components/accessibility.test.d.ts +1 -0
- package/dashboard/dist/components/accessibility.test.js +116 -0
- package/dashboard/dist/components/layout/MobileNav.d.ts +16 -0
- package/dashboard/dist/components/layout/MobileNav.js +31 -0
- package/dashboard/dist/components/layout/MobileNav.test.d.ts +1 -0
- package/dashboard/dist/components/layout/MobileNav.test.js +111 -0
- package/dashboard/dist/components/performance.test.d.ts +1 -0
- package/dashboard/dist/components/performance.test.js +114 -0
- package/dashboard/dist/components/responsive.test.d.ts +1 -0
- package/dashboard/dist/components/responsive.test.js +114 -0
- package/dashboard/dist/components/ui/Dropdown.d.ts +22 -0
- package/dashboard/dist/components/ui/Dropdown.js +109 -0
- package/dashboard/dist/components/ui/Dropdown.test.d.ts +1 -0
- package/dashboard/dist/components/ui/Dropdown.test.js +105 -0
- package/dashboard/dist/components/ui/Modal.d.ts +13 -0
- package/dashboard/dist/components/ui/Modal.js +25 -0
- package/dashboard/dist/components/ui/Modal.test.d.ts +1 -0
- package/dashboard/dist/components/ui/Modal.test.js +91 -0
- package/dashboard/dist/components/ui/Skeleton.d.ts +32 -0
- package/dashboard/dist/components/ui/Skeleton.js +48 -0
- package/dashboard/dist/components/ui/Skeleton.test.d.ts +1 -0
- package/dashboard/dist/components/ui/Skeleton.test.js +125 -0
- package/dashboard/dist/components/ui/Toast.d.ts +32 -0
- package/dashboard/dist/components/ui/Toast.js +21 -0
- package/dashboard/dist/components/ui/Toast.test.d.ts +1 -0
- package/dashboard/dist/components/ui/Toast.test.js +118 -0
- package/dashboard/dist/hooks/useTheme.d.ts +37 -0
- package/dashboard/dist/hooks/useTheme.js +96 -0
- package/dashboard/dist/hooks/useTheme.test.d.ts +1 -0
- package/dashboard/dist/hooks/useTheme.test.js +94 -0
- package/dashboard/dist/hooks/useWebSocket.d.ts +17 -0
- package/dashboard/dist/hooks/useWebSocket.js +100 -0
- package/dashboard/dist/hooks/useWebSocket.test.d.ts +1 -0
- package/dashboard/dist/hooks/useWebSocket.test.js +115 -0
- package/dashboard/dist/stores/projectStore.d.ts +44 -0
- package/dashboard/dist/stores/projectStore.js +76 -0
- package/dashboard/dist/stores/projectStore.test.d.ts +1 -0
- package/dashboard/dist/stores/projectStore.test.js +114 -0
- package/dashboard/dist/stores/uiStore.d.ts +29 -0
- package/dashboard/dist/stores/uiStore.js +72 -0
- package/dashboard/dist/stores/uiStore.test.d.ts +1 -0
- package/dashboard/dist/stores/uiStore.test.js +93 -0
- package/dashboard/package.json +3 -3
- package/docker-compose.dev.yml +6 -1
- package/package.json +5 -2
- package/server/dashboard/index.html +1336 -779
- package/server/index.js +178 -0
- package/server/lib/agent-cleanup.js +177 -0
- package/server/lib/agent-cleanup.test.js +359 -0
- package/server/lib/agent-hooks.js +126 -0
- package/server/lib/agent-hooks.test.js +303 -0
- package/server/lib/agent-metadata.js +179 -0
- package/server/lib/agent-metadata.test.js +383 -0
- package/server/lib/agent-persistence.js +191 -0
- package/server/lib/agent-persistence.test.js +475 -0
- package/server/lib/agent-registry-command.js +340 -0
- package/server/lib/agent-registry-command.test.js +334 -0
- package/server/lib/agent-registry.js +155 -0
- package/server/lib/agent-registry.test.js +239 -0
- package/server/lib/agent-state.js +236 -0
- package/server/lib/agent-state.test.js +375 -0
- package/server/lib/api-provider.js +186 -0
- package/server/lib/api-provider.test.js +336 -0
- package/server/lib/cli-detector.js +166 -0
- package/server/lib/cli-detector.test.js +269 -0
- package/server/lib/cli-provider.js +212 -0
- package/server/lib/cli-provider.test.js +349 -0
- package/server/lib/debug.test.js +62 -0
- package/server/lib/devserver-router-api.js +249 -0
- package/server/lib/devserver-router-api.test.js +426 -0
- package/server/lib/model-router.js +245 -0
- package/server/lib/model-router.test.js +313 -0
- package/server/lib/output-schemas.js +269 -0
- package/server/lib/output-schemas.test.js +307 -0
- package/server/lib/provider-interface.js +153 -0
- package/server/lib/provider-interface.test.js +394 -0
- package/server/lib/provider-queue.js +158 -0
- package/server/lib/provider-queue.test.js +315 -0
- package/server/lib/router-config.js +221 -0
- package/server/lib/router-config.test.js +237 -0
- package/server/lib/router-setup-command.js +419 -0
- package/server/lib/router-setup-command.test.js +375 -0
package/server/index.js
CHANGED
|
@@ -471,6 +471,184 @@ app.post('/api/test', (req, res) => {
|
|
|
471
471
|
res.json({ success: true });
|
|
472
472
|
});
|
|
473
473
|
|
|
474
|
+
// ============================================
|
|
475
|
+
// Agent Registry API (Phase 32)
|
|
476
|
+
// ============================================
|
|
477
|
+
const { getAgentRegistry } = require('./lib/agent-registry');
|
|
478
|
+
const { createAgentState } = require('./lib/agent-state');
|
|
479
|
+
const { createMetadata } = require('./lib/agent-metadata');
|
|
480
|
+
|
|
481
|
+
// Helper to format agent for API response
|
|
482
|
+
function formatAgent(agent) {
|
|
483
|
+
return {
|
|
484
|
+
id: agent.id,
|
|
485
|
+
name: agent.name,
|
|
486
|
+
state: {
|
|
487
|
+
current: agent.stateMachine ? agent.stateMachine.getState() : 'pending',
|
|
488
|
+
history: agent.stateMachine ? agent.stateMachine.getHistory() : [],
|
|
489
|
+
},
|
|
490
|
+
metadata: {
|
|
491
|
+
model: agent.model,
|
|
492
|
+
taskType: agent.taskType || 'unknown',
|
|
493
|
+
tokens: agent.metadataObj ? {
|
|
494
|
+
input: agent.metadataObj.inputTokens,
|
|
495
|
+
output: agent.metadataObj.outputTokens,
|
|
496
|
+
total: agent.metadataObj.totalTokens,
|
|
497
|
+
} : { input: 0, output: 0, total: 0 },
|
|
498
|
+
},
|
|
499
|
+
createdAt: agent.createdAt || agent.registeredAt,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// List agents
|
|
504
|
+
app.get('/api/agents', (req, res) => {
|
|
505
|
+
try {
|
|
506
|
+
const registry = getAgentRegistry();
|
|
507
|
+
let agents = registry.listAgents();
|
|
508
|
+
|
|
509
|
+
// Filter by status (state.current)
|
|
510
|
+
if (req.query.status) {
|
|
511
|
+
agents = agents.filter(a =>
|
|
512
|
+
a.stateMachine ? a.stateMachine.getState() === req.query.status : false
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
// Filter by model
|
|
516
|
+
if (req.query.model) {
|
|
517
|
+
agents = agents.filter(a => a.model === req.query.model);
|
|
518
|
+
}
|
|
519
|
+
// Filter by type
|
|
520
|
+
if (req.query.type) {
|
|
521
|
+
agents = agents.filter(a => a.taskType === req.query.type);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
res.json({ success: true, agents: agents.map(formatAgent) });
|
|
525
|
+
} catch (err) {
|
|
526
|
+
res.status(500).json({ success: false, error: err.message });
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// Get single agent
|
|
531
|
+
app.get('/api/agents/:id', (req, res) => {
|
|
532
|
+
try {
|
|
533
|
+
const registry = getAgentRegistry();
|
|
534
|
+
const agent = registry.getAgent(req.params.id);
|
|
535
|
+
if (!agent) {
|
|
536
|
+
return res.status(404).json({ success: false, error: 'Agent not found' });
|
|
537
|
+
}
|
|
538
|
+
res.json({ success: true, agent: formatAgent(agent) });
|
|
539
|
+
} catch (err) {
|
|
540
|
+
res.status(500).json({ success: false, error: err.message });
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// Register new agent
|
|
545
|
+
app.post('/api/agents', (req, res) => {
|
|
546
|
+
try {
|
|
547
|
+
const registry = getAgentRegistry();
|
|
548
|
+
const { id, name, model, taskType } = req.body;
|
|
549
|
+
|
|
550
|
+
if (!name) {
|
|
551
|
+
return res.status(400).json({ success: false, error: 'name is required' });
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Create state machine and metadata
|
|
555
|
+
const stateMachine = createAgentState({ agentId: id });
|
|
556
|
+
const metadataObj = createMetadata({
|
|
557
|
+
model: model || 'unknown',
|
|
558
|
+
taskType: taskType || 'default',
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
// Register with all components
|
|
562
|
+
const agentId = registry.registerAgent({
|
|
563
|
+
id,
|
|
564
|
+
name,
|
|
565
|
+
model: model || 'unknown',
|
|
566
|
+
taskType: taskType || 'default',
|
|
567
|
+
stateMachine,
|
|
568
|
+
metadataObj,
|
|
569
|
+
createdAt: Date.now(),
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
const agent = registry.getAgent(agentId);
|
|
573
|
+
res.status(201).json({ success: true, agent: formatAgent(agent) });
|
|
574
|
+
} catch (err) {
|
|
575
|
+
res.status(500).json({ success: false, error: err.message });
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
// Update agent (state transitions, token updates)
|
|
580
|
+
app.patch('/api/agents/:id', (req, res) => {
|
|
581
|
+
try {
|
|
582
|
+
const registry = getAgentRegistry();
|
|
583
|
+
const agent = registry.getAgent(req.params.id);
|
|
584
|
+
if (!agent) {
|
|
585
|
+
return res.status(404).json({ success: false, error: 'Agent not found' });
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Handle state transition
|
|
589
|
+
if (req.body.state) {
|
|
590
|
+
if (!agent.stateMachine) {
|
|
591
|
+
return res.status(400).json({ success: false, error: 'Agent has no state machine' });
|
|
592
|
+
}
|
|
593
|
+
const result = agent.stateMachine.transition(req.body.state, { reason: req.body.reason });
|
|
594
|
+
if (!result.success) {
|
|
595
|
+
return res.status(400).json({ success: false, error: result.error });
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Handle token updates
|
|
600
|
+
if (req.body.tokens && agent.metadataObj) {
|
|
601
|
+
agent.metadataObj.updateTokens({
|
|
602
|
+
input: req.body.tokens.input || 0,
|
|
603
|
+
output: req.body.tokens.output || 0,
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
res.json({ success: true, agent: formatAgent(agent) });
|
|
608
|
+
} catch (err) {
|
|
609
|
+
res.status(500).json({ success: false, error: err.message });
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
// Delete agent
|
|
614
|
+
app.delete('/api/agents/:id', (req, res) => {
|
|
615
|
+
try {
|
|
616
|
+
const registry = getAgentRegistry();
|
|
617
|
+
const removed = registry.removeAgent(req.params.id);
|
|
618
|
+
if (!removed) {
|
|
619
|
+
return res.status(404).json({ success: false, error: 'Agent not found' });
|
|
620
|
+
}
|
|
621
|
+
res.json({ success: true });
|
|
622
|
+
} catch (err) {
|
|
623
|
+
res.status(500).json({ success: false, error: err.message });
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// Get registry stats
|
|
628
|
+
app.get('/api/agents-stats', (req, res) => {
|
|
629
|
+
try {
|
|
630
|
+
const registry = getAgentRegistry();
|
|
631
|
+
const agents = registry.listAgents();
|
|
632
|
+
|
|
633
|
+
const stats = {
|
|
634
|
+
total: agents.length,
|
|
635
|
+
byStatus: {},
|
|
636
|
+
byModel: {}
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
agents.forEach(agent => {
|
|
640
|
+
const status = agent.stateMachine ? agent.stateMachine.getState() : 'pending';
|
|
641
|
+
const model = agent.model || 'unknown';
|
|
642
|
+
stats.byStatus[status] = (stats.byStatus[status] || 0) + 1;
|
|
643
|
+
stats.byModel[model] = (stats.byModel[model] || 0) + 1;
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
res.json({ success: true, stats });
|
|
647
|
+
} catch (err) {
|
|
648
|
+
res.status(500).json({ success: false, error: err.message });
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
|
|
474
652
|
app.post('/api/restart', (req, res) => {
|
|
475
653
|
addLog('app', '--- Restarting app ---', 'warn');
|
|
476
654
|
broadcast('app-restart', {});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Cleanup - Handles timeouts and orphaned agents
|
|
3
|
+
*
|
|
4
|
+
* Detects stuck agents (running state but no activity for timeout period)
|
|
5
|
+
* and transitions them to cancelled state.
|
|
6
|
+
* Supports periodic cleanup via setInterval with configurable interval.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { getAgentRegistry } from './agent-registry.js';
|
|
10
|
+
import { getAgentHooks } from './agent-hooks.js';
|
|
11
|
+
import { STATES } from './agent-state.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Default timeout for detecting orphaned agents (30 minutes)
|
|
15
|
+
*/
|
|
16
|
+
const DEFAULT_TIMEOUT = 30 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Default interval for periodic cleanup (5 minutes)
|
|
20
|
+
*/
|
|
21
|
+
const DEFAULT_INTERVAL = 5 * 60 * 1000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Internal state for cleanup scheduling and stats
|
|
25
|
+
*/
|
|
26
|
+
let cleanupIntervalId = null;
|
|
27
|
+
let cleanupStats = {
|
|
28
|
+
totalCleaned: 0,
|
|
29
|
+
cleanupRuns: 0,
|
|
30
|
+
lastCleanupAt: null,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Find agents that are orphaned (stuck in running state without activity)
|
|
35
|
+
* @param {Object} [options] - Options
|
|
36
|
+
* @param {number} [options.timeout] - Timeout in ms (default: 30 minutes)
|
|
37
|
+
* @returns {Array} Array of orphaned agent objects
|
|
38
|
+
*/
|
|
39
|
+
function findOrphanedAgents(options = {}) {
|
|
40
|
+
const timeout = options.timeout || DEFAULT_TIMEOUT;
|
|
41
|
+
const registry = getAgentRegistry();
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
|
|
44
|
+
// Get all running agents
|
|
45
|
+
const runningAgents = registry.listAgents({ status: STATES.RUNNING });
|
|
46
|
+
|
|
47
|
+
// Filter to those with no activity for longer than timeout
|
|
48
|
+
const orphans = runningAgents.filter(agent => {
|
|
49
|
+
const lastActivity = agent.lastActivity || agent.registeredAt;
|
|
50
|
+
const inactiveTime = now - lastActivity;
|
|
51
|
+
|
|
52
|
+
// Check if agent has a custom grace period
|
|
53
|
+
if (agent.gracePeriod) {
|
|
54
|
+
return inactiveTime > agent.gracePeriod;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return inactiveTime > timeout;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return orphans;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Clean up orphaned agents by transitioning them to cancelled state
|
|
65
|
+
* @param {Object} [options] - Options
|
|
66
|
+
* @param {number} [options.timeout] - Timeout for finding orphans (default: 30 minutes)
|
|
67
|
+
* @returns {Promise<Object>} Result with cleaned agents and any errors
|
|
68
|
+
*/
|
|
69
|
+
async function cleanupOrphans(options = {}) {
|
|
70
|
+
const orphans = findOrphanedAgents(options);
|
|
71
|
+
const registry = getAgentRegistry();
|
|
72
|
+
const hooks = getAgentHooks();
|
|
73
|
+
|
|
74
|
+
const cleaned = [];
|
|
75
|
+
const errors = [];
|
|
76
|
+
|
|
77
|
+
for (const agent of orphans) {
|
|
78
|
+
try {
|
|
79
|
+
// Update agent status to cancelled
|
|
80
|
+
registry.updateAgent(agent.id, {
|
|
81
|
+
status: STATES.CANCELLED,
|
|
82
|
+
cancelledAt: Date.now(),
|
|
83
|
+
cancelReason: 'orphaned',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Trigger onCancel hook
|
|
87
|
+
try {
|
|
88
|
+
await hooks.triggerHook('onCancel', {
|
|
89
|
+
agentId: agent.id,
|
|
90
|
+
agent: agent,
|
|
91
|
+
reason: 'orphaned',
|
|
92
|
+
timeout: options.timeout || DEFAULT_TIMEOUT,
|
|
93
|
+
});
|
|
94
|
+
} catch (hookError) {
|
|
95
|
+
// Hook errors are logged but don't stop cleanup
|
|
96
|
+
errors.push({ agentId: agent.id, error: hookError.message, type: 'hook' });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
cleaned.push(agent);
|
|
100
|
+
|
|
101
|
+
// Log cleanup action
|
|
102
|
+
console.log(`[agent-cleanup] Cleaned orphaned agent: ${agent.id} (${agent.name})`);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
errors.push({ agentId: agent.id, error: err.message, type: 'cleanup' });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Update stats
|
|
109
|
+
cleanupStats.totalCleaned += cleaned.length;
|
|
110
|
+
cleanupStats.cleanupRuns += 1;
|
|
111
|
+
cleanupStats.lastCleanupAt = Date.now();
|
|
112
|
+
|
|
113
|
+
return { cleaned, errors };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Schedule periodic cleanup
|
|
118
|
+
* @param {Object} [options] - Options
|
|
119
|
+
* @param {number} [options.interval] - Interval in ms (default: 5 minutes)
|
|
120
|
+
* @param {number} [options.timeout] - Timeout for finding orphans (default: 30 minutes)
|
|
121
|
+
*/
|
|
122
|
+
function scheduleCleanup(options = {}) {
|
|
123
|
+
const interval = options.interval || DEFAULT_INTERVAL;
|
|
124
|
+
|
|
125
|
+
// Stop any existing schedule
|
|
126
|
+
stopCleanup();
|
|
127
|
+
|
|
128
|
+
// Schedule periodic cleanup
|
|
129
|
+
cleanupIntervalId = setInterval(async () => {
|
|
130
|
+
try {
|
|
131
|
+
await cleanupOrphans(options);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
console.error('[agent-cleanup] Error during scheduled cleanup:', err);
|
|
134
|
+
}
|
|
135
|
+
}, interval);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Stop the scheduled cleanup
|
|
140
|
+
*/
|
|
141
|
+
function stopCleanup() {
|
|
142
|
+
if (cleanupIntervalId) {
|
|
143
|
+
clearInterval(cleanupIntervalId);
|
|
144
|
+
cleanupIntervalId = null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Get cleanup statistics
|
|
150
|
+
* @returns {Object} Stats including totalCleaned, cleanupRuns, lastCleanupAt
|
|
151
|
+
*/
|
|
152
|
+
function getCleanupStats() {
|
|
153
|
+
return { ...cleanupStats };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Reset cleanup state (for testing)
|
|
158
|
+
*/
|
|
159
|
+
function resetCleanup() {
|
|
160
|
+
stopCleanup();
|
|
161
|
+
cleanupStats = {
|
|
162
|
+
totalCleaned: 0,
|
|
163
|
+
cleanupRuns: 0,
|
|
164
|
+
lastCleanupAt: null,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export {
|
|
169
|
+
findOrphanedAgents,
|
|
170
|
+
cleanupOrphans,
|
|
171
|
+
scheduleCleanup,
|
|
172
|
+
stopCleanup,
|
|
173
|
+
getCleanupStats,
|
|
174
|
+
resetCleanup,
|
|
175
|
+
DEFAULT_TIMEOUT,
|
|
176
|
+
DEFAULT_INTERVAL,
|
|
177
|
+
};
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
findOrphanedAgents,
|
|
4
|
+
cleanupOrphans,
|
|
5
|
+
scheduleCleanup,
|
|
6
|
+
stopCleanup,
|
|
7
|
+
getCleanupStats,
|
|
8
|
+
DEFAULT_TIMEOUT,
|
|
9
|
+
DEFAULT_INTERVAL,
|
|
10
|
+
resetCleanup,
|
|
11
|
+
} from './agent-cleanup.js';
|
|
12
|
+
import { getAgentRegistry, resetRegistry } from './agent-registry.js';
|
|
13
|
+
import { getAgentHooks, resetHooks } from './agent-hooks.js';
|
|
14
|
+
import { STATES } from './agent-state.js';
|
|
15
|
+
|
|
16
|
+
describe('agent-cleanup', () => {
|
|
17
|
+
let registry;
|
|
18
|
+
let hooks;
|
|
19
|
+
const BASE_TIME = new Date('2025-01-01T12:00:00Z').getTime();
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.useFakeTimers();
|
|
23
|
+
vi.setSystemTime(BASE_TIME);
|
|
24
|
+
resetRegistry();
|
|
25
|
+
resetHooks();
|
|
26
|
+
resetCleanup();
|
|
27
|
+
registry = getAgentRegistry();
|
|
28
|
+
hooks = getAgentHooks();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
stopCleanup();
|
|
33
|
+
vi.useRealTimers();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('findOrphanedAgents', () => {
|
|
37
|
+
it('detects stuck agents', () => {
|
|
38
|
+
// Register agent and set it as running with old lastActivity
|
|
39
|
+
const id = registry.registerAgent({
|
|
40
|
+
name: 'stuck-agent',
|
|
41
|
+
model: 'claude-3',
|
|
42
|
+
type: 'worker',
|
|
43
|
+
status: STATES.RUNNING,
|
|
44
|
+
lastActivity: Date.now() - 35 * 60 * 1000, // 35 minutes ago
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const orphans = findOrphanedAgents();
|
|
48
|
+
|
|
49
|
+
expect(orphans).toHaveLength(1);
|
|
50
|
+
expect(orphans[0].id).toBe(id);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('uses configurable timeout', () => {
|
|
54
|
+
// Register agent running for 10 minutes
|
|
55
|
+
const id = registry.registerAgent({
|
|
56
|
+
name: 'agent',
|
|
57
|
+
model: 'claude-3',
|
|
58
|
+
type: 'worker',
|
|
59
|
+
status: STATES.RUNNING,
|
|
60
|
+
lastActivity: Date.now() - 10 * 60 * 1000, // 10 minutes ago
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// With default 30 minute timeout, should not be orphaned
|
|
64
|
+
expect(findOrphanedAgents()).toHaveLength(0);
|
|
65
|
+
|
|
66
|
+
// With 5 minute timeout, should be orphaned
|
|
67
|
+
const orphans = findOrphanedAgents({ timeout: 5 * 60 * 1000 });
|
|
68
|
+
expect(orphans).toHaveLength(1);
|
|
69
|
+
expect(orphans[0].id).toBe(id);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('does not flag recently active agents', () => {
|
|
73
|
+
registry.registerAgent({
|
|
74
|
+
name: 'active-agent',
|
|
75
|
+
model: 'claude-3',
|
|
76
|
+
type: 'worker',
|
|
77
|
+
status: STATES.RUNNING,
|
|
78
|
+
lastActivity: Date.now() - 5 * 60 * 1000, // 5 minutes ago - recent
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const orphans = findOrphanedAgents();
|
|
82
|
+
|
|
83
|
+
expect(orphans).toHaveLength(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('only flags running agents', () => {
|
|
87
|
+
// Pending agent with old activity - should not be orphaned
|
|
88
|
+
registry.registerAgent({
|
|
89
|
+
name: 'pending-agent',
|
|
90
|
+
model: 'claude-3',
|
|
91
|
+
type: 'worker',
|
|
92
|
+
status: STATES.PENDING,
|
|
93
|
+
lastActivity: Date.now() - 35 * 60 * 1000,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Completed agent with old activity - should not be orphaned
|
|
97
|
+
registry.registerAgent({
|
|
98
|
+
name: 'completed-agent',
|
|
99
|
+
model: 'claude-3',
|
|
100
|
+
type: 'worker',
|
|
101
|
+
status: STATES.COMPLETED,
|
|
102
|
+
lastActivity: Date.now() - 35 * 60 * 1000,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const orphans = findOrphanedAgents();
|
|
106
|
+
|
|
107
|
+
expect(orphans).toHaveLength(0);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('cleanupOrphans', () => {
|
|
112
|
+
it('transitions to cancelled', async () => {
|
|
113
|
+
const id = registry.registerAgent({
|
|
114
|
+
name: 'stuck-agent',
|
|
115
|
+
model: 'claude-3',
|
|
116
|
+
type: 'worker',
|
|
117
|
+
status: STATES.RUNNING,
|
|
118
|
+
lastActivity: Date.now() - 35 * 60 * 1000,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const result = await cleanupOrphans();
|
|
122
|
+
|
|
123
|
+
expect(result.cleaned).toHaveLength(1);
|
|
124
|
+
expect(result.cleaned[0].id).toBe(id);
|
|
125
|
+
|
|
126
|
+
const agent = registry.getAgent(id);
|
|
127
|
+
expect(agent.status).toBe(STATES.CANCELLED);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('triggers hooks', async () => {
|
|
131
|
+
const onCancelHandler = vi.fn();
|
|
132
|
+
hooks.registerHook('onCancel', onCancelHandler);
|
|
133
|
+
|
|
134
|
+
registry.registerAgent({
|
|
135
|
+
name: 'stuck-agent',
|
|
136
|
+
model: 'claude-3',
|
|
137
|
+
type: 'worker',
|
|
138
|
+
status: STATES.RUNNING,
|
|
139
|
+
lastActivity: Date.now() - 35 * 60 * 1000,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await cleanupOrphans();
|
|
143
|
+
|
|
144
|
+
expect(onCancelHandler).toHaveBeenCalled();
|
|
145
|
+
expect(onCancelHandler).toHaveBeenCalledWith(
|
|
146
|
+
expect.objectContaining({
|
|
147
|
+
reason: 'orphaned',
|
|
148
|
+
})
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('handles cleanup errors gracefully', async () => {
|
|
153
|
+
// Register an agent that will cause error during hook execution
|
|
154
|
+
registry.registerAgent({
|
|
155
|
+
name: 'error-agent',
|
|
156
|
+
model: 'claude-3',
|
|
157
|
+
type: 'worker',
|
|
158
|
+
status: STATES.RUNNING,
|
|
159
|
+
lastActivity: Date.now() - 35 * 60 * 1000,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Register a hook that throws
|
|
163
|
+
hooks.registerHook('onCancel', () => {
|
|
164
|
+
throw new Error('Hook error');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Should not throw, should handle gracefully
|
|
168
|
+
const result = await cleanupOrphans();
|
|
169
|
+
|
|
170
|
+
expect(result.errors).toBeDefined();
|
|
171
|
+
expect(result.errors.length).toBeGreaterThanOrEqual(0);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('respects agent grace period', async () => {
|
|
175
|
+
// Agent with gracePeriod that hasn't expired
|
|
176
|
+
registry.registerAgent({
|
|
177
|
+
name: 'grace-agent',
|
|
178
|
+
model: 'claude-3',
|
|
179
|
+
type: 'worker',
|
|
180
|
+
status: STATES.RUNNING,
|
|
181
|
+
lastActivity: Date.now() - 35 * 60 * 1000,
|
|
182
|
+
gracePeriod: 60 * 60 * 1000, // 1 hour grace period
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Agent without grace period - should be cleaned
|
|
186
|
+
const id2 = registry.registerAgent({
|
|
187
|
+
name: 'no-grace-agent',
|
|
188
|
+
model: 'claude-3',
|
|
189
|
+
type: 'worker',
|
|
190
|
+
status: STATES.RUNNING,
|
|
191
|
+
lastActivity: Date.now() - 35 * 60 * 1000,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const result = await cleanupOrphans();
|
|
195
|
+
|
|
196
|
+
expect(result.cleaned).toHaveLength(1);
|
|
197
|
+
expect(result.cleaned[0].id).toBe(id2);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('logs cleanup actions', async () => {
|
|
201
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
202
|
+
|
|
203
|
+
registry.registerAgent({
|
|
204
|
+
name: 'stuck-agent',
|
|
205
|
+
model: 'claude-3',
|
|
206
|
+
type: 'worker',
|
|
207
|
+
status: STATES.RUNNING,
|
|
208
|
+
lastActivity: Date.now() - 35 * 60 * 1000,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
await cleanupOrphans();
|
|
212
|
+
|
|
213
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
214
|
+
expect.stringContaining('cleanup')
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
consoleSpy.mockRestore();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('scheduleCleanup', () => {
|
|
222
|
+
it('runs periodically', async () => {
|
|
223
|
+
// Set up an orphan
|
|
224
|
+
registry.registerAgent({
|
|
225
|
+
name: 'stuck-agent',
|
|
226
|
+
model: 'claude-3',
|
|
227
|
+
type: 'worker',
|
|
228
|
+
status: STATES.RUNNING,
|
|
229
|
+
lastActivity: Date.now() - 35 * 60 * 1000,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
scheduleCleanup({ interval: 1000 }); // Every 1 second for testing
|
|
233
|
+
|
|
234
|
+
// Initially no cleanup yet
|
|
235
|
+
let stats = getCleanupStats();
|
|
236
|
+
expect(stats.totalCleaned).toBe(0);
|
|
237
|
+
|
|
238
|
+
// Advance time by 1 second
|
|
239
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
240
|
+
|
|
241
|
+
stats = getCleanupStats();
|
|
242
|
+
expect(stats.totalCleaned).toBe(1);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('uses configurable interval', async () => {
|
|
246
|
+
const customInterval = 5 * 60 * 1000; // 5 minutes
|
|
247
|
+
|
|
248
|
+
scheduleCleanup({ interval: customInterval });
|
|
249
|
+
|
|
250
|
+
// Add an orphan after scheduling
|
|
251
|
+
registry.registerAgent({
|
|
252
|
+
name: 'stuck-agent',
|
|
253
|
+
model: 'claude-3',
|
|
254
|
+
type: 'worker',
|
|
255
|
+
status: STATES.RUNNING,
|
|
256
|
+
lastActivity: Date.now() - 35 * 60 * 1000,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// Advance 1 minute - no cleanup yet
|
|
260
|
+
await vi.advanceTimersByTimeAsync(60 * 1000);
|
|
261
|
+
expect(getCleanupStats().totalCleaned).toBe(0);
|
|
262
|
+
|
|
263
|
+
// Advance to 5 minutes - cleanup should run
|
|
264
|
+
await vi.advanceTimersByTimeAsync(4 * 60 * 1000);
|
|
265
|
+
expect(getCleanupStats().totalCleaned).toBe(1);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe('stopCleanup', () => {
|
|
270
|
+
it('cancels schedule', () => {
|
|
271
|
+
scheduleCleanup({ interval: 1000 });
|
|
272
|
+
|
|
273
|
+
registry.registerAgent({
|
|
274
|
+
name: 'stuck-agent',
|
|
275
|
+
model: 'claude-3',
|
|
276
|
+
type: 'worker',
|
|
277
|
+
status: STATES.RUNNING,
|
|
278
|
+
lastActivity: Date.now() - 35 * 60 * 1000,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
stopCleanup();
|
|
282
|
+
|
|
283
|
+
// Advance time - cleanup should not run since stopped
|
|
284
|
+
vi.advanceTimersByTime(5000);
|
|
285
|
+
|
|
286
|
+
expect(getCleanupStats().totalCleaned).toBe(0);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('is safe to call multiple times', () => {
|
|
290
|
+
stopCleanup();
|
|
291
|
+
stopCleanup();
|
|
292
|
+
stopCleanup();
|
|
293
|
+
|
|
294
|
+
// Should not throw
|
|
295
|
+
expect(true).toBe(true);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe('getCleanupStats', () => {
|
|
300
|
+
it('returns counts', async () => {
|
|
301
|
+
const id = registry.registerAgent({
|
|
302
|
+
name: 'stuck-agent',
|
|
303
|
+
model: 'claude-3',
|
|
304
|
+
type: 'worker',
|
|
305
|
+
status: STATES.RUNNING,
|
|
306
|
+
lastActivity: Date.now() - 35 * 60 * 1000,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const statsBefore = getCleanupStats();
|
|
310
|
+
expect(statsBefore.totalCleaned).toBe(0);
|
|
311
|
+
expect(statsBefore.lastCleanupAt).toBeNull();
|
|
312
|
+
|
|
313
|
+
await cleanupOrphans();
|
|
314
|
+
|
|
315
|
+
const statsAfter = getCleanupStats();
|
|
316
|
+
expect(statsAfter.totalCleaned).toBe(1);
|
|
317
|
+
expect(statsAfter.lastCleanupAt).toBeDefined();
|
|
318
|
+
expect(statsAfter.cleanupRuns).toBe(1);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('accumulates across multiple cleanup runs', async () => {
|
|
322
|
+
// First orphan
|
|
323
|
+
registry.registerAgent({
|
|
324
|
+
name: 'stuck-agent-1',
|
|
325
|
+
model: 'claude-3',
|
|
326
|
+
type: 'worker',
|
|
327
|
+
status: STATES.RUNNING,
|
|
328
|
+
lastActivity: Date.now() - 35 * 60 * 1000,
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
await cleanupOrphans();
|
|
332
|
+
|
|
333
|
+
// Second orphan (new one)
|
|
334
|
+
registry.registerAgent({
|
|
335
|
+
name: 'stuck-agent-2',
|
|
336
|
+
model: 'claude-3',
|
|
337
|
+
type: 'worker',
|
|
338
|
+
status: STATES.RUNNING,
|
|
339
|
+
lastActivity: Date.now() - 35 * 60 * 1000,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
await cleanupOrphans();
|
|
343
|
+
|
|
344
|
+
const stats = getCleanupStats();
|
|
345
|
+
expect(stats.totalCleaned).toBe(2);
|
|
346
|
+
expect(stats.cleanupRuns).toBe(2);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
describe('defaults', () => {
|
|
351
|
+
it('DEFAULT_TIMEOUT is 30 minutes', () => {
|
|
352
|
+
expect(DEFAULT_TIMEOUT).toBe(30 * 60 * 1000);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('DEFAULT_INTERVAL is 5 minutes', () => {
|
|
356
|
+
expect(DEFAULT_INTERVAL).toBe(5 * 60 * 1000);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
});
|