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.
Files changed (91) hide show
  1. package/dashboard/dist/App.js +229 -35
  2. package/dashboard/dist/components/AgentRegistryPane.d.ts +35 -0
  3. package/dashboard/dist/components/AgentRegistryPane.js +89 -0
  4. package/dashboard/dist/components/AgentRegistryPane.test.d.ts +1 -0
  5. package/dashboard/dist/components/AgentRegistryPane.test.js +200 -0
  6. package/dashboard/dist/components/RouterPane.d.ts +5 -0
  7. package/dashboard/dist/components/RouterPane.js +65 -0
  8. package/dashboard/dist/components/RouterPane.test.d.ts +1 -0
  9. package/dashboard/dist/components/RouterPane.test.js +176 -0
  10. package/dashboard/dist/components/accessibility.test.d.ts +1 -0
  11. package/dashboard/dist/components/accessibility.test.js +116 -0
  12. package/dashboard/dist/components/layout/MobileNav.d.ts +16 -0
  13. package/dashboard/dist/components/layout/MobileNav.js +31 -0
  14. package/dashboard/dist/components/layout/MobileNav.test.d.ts +1 -0
  15. package/dashboard/dist/components/layout/MobileNav.test.js +111 -0
  16. package/dashboard/dist/components/performance.test.d.ts +1 -0
  17. package/dashboard/dist/components/performance.test.js +114 -0
  18. package/dashboard/dist/components/responsive.test.d.ts +1 -0
  19. package/dashboard/dist/components/responsive.test.js +114 -0
  20. package/dashboard/dist/components/ui/Dropdown.d.ts +22 -0
  21. package/dashboard/dist/components/ui/Dropdown.js +109 -0
  22. package/dashboard/dist/components/ui/Dropdown.test.d.ts +1 -0
  23. package/dashboard/dist/components/ui/Dropdown.test.js +105 -0
  24. package/dashboard/dist/components/ui/Modal.d.ts +13 -0
  25. package/dashboard/dist/components/ui/Modal.js +25 -0
  26. package/dashboard/dist/components/ui/Modal.test.d.ts +1 -0
  27. package/dashboard/dist/components/ui/Modal.test.js +91 -0
  28. package/dashboard/dist/components/ui/Skeleton.d.ts +32 -0
  29. package/dashboard/dist/components/ui/Skeleton.js +48 -0
  30. package/dashboard/dist/components/ui/Skeleton.test.d.ts +1 -0
  31. package/dashboard/dist/components/ui/Skeleton.test.js +125 -0
  32. package/dashboard/dist/components/ui/Toast.d.ts +32 -0
  33. package/dashboard/dist/components/ui/Toast.js +21 -0
  34. package/dashboard/dist/components/ui/Toast.test.d.ts +1 -0
  35. package/dashboard/dist/components/ui/Toast.test.js +118 -0
  36. package/dashboard/dist/hooks/useTheme.d.ts +37 -0
  37. package/dashboard/dist/hooks/useTheme.js +96 -0
  38. package/dashboard/dist/hooks/useTheme.test.d.ts +1 -0
  39. package/dashboard/dist/hooks/useTheme.test.js +94 -0
  40. package/dashboard/dist/hooks/useWebSocket.d.ts +17 -0
  41. package/dashboard/dist/hooks/useWebSocket.js +100 -0
  42. package/dashboard/dist/hooks/useWebSocket.test.d.ts +1 -0
  43. package/dashboard/dist/hooks/useWebSocket.test.js +115 -0
  44. package/dashboard/dist/stores/projectStore.d.ts +44 -0
  45. package/dashboard/dist/stores/projectStore.js +76 -0
  46. package/dashboard/dist/stores/projectStore.test.d.ts +1 -0
  47. package/dashboard/dist/stores/projectStore.test.js +114 -0
  48. package/dashboard/dist/stores/uiStore.d.ts +29 -0
  49. package/dashboard/dist/stores/uiStore.js +72 -0
  50. package/dashboard/dist/stores/uiStore.test.d.ts +1 -0
  51. package/dashboard/dist/stores/uiStore.test.js +93 -0
  52. package/dashboard/package.json +3 -3
  53. package/docker-compose.dev.yml +6 -1
  54. package/package.json +5 -2
  55. package/server/dashboard/index.html +1336 -779
  56. package/server/index.js +178 -0
  57. package/server/lib/agent-cleanup.js +177 -0
  58. package/server/lib/agent-cleanup.test.js +359 -0
  59. package/server/lib/agent-hooks.js +126 -0
  60. package/server/lib/agent-hooks.test.js +303 -0
  61. package/server/lib/agent-metadata.js +179 -0
  62. package/server/lib/agent-metadata.test.js +383 -0
  63. package/server/lib/agent-persistence.js +191 -0
  64. package/server/lib/agent-persistence.test.js +475 -0
  65. package/server/lib/agent-registry-command.js +340 -0
  66. package/server/lib/agent-registry-command.test.js +334 -0
  67. package/server/lib/agent-registry.js +155 -0
  68. package/server/lib/agent-registry.test.js +239 -0
  69. package/server/lib/agent-state.js +236 -0
  70. package/server/lib/agent-state.test.js +375 -0
  71. package/server/lib/api-provider.js +186 -0
  72. package/server/lib/api-provider.test.js +336 -0
  73. package/server/lib/cli-detector.js +166 -0
  74. package/server/lib/cli-detector.test.js +269 -0
  75. package/server/lib/cli-provider.js +212 -0
  76. package/server/lib/cli-provider.test.js +349 -0
  77. package/server/lib/debug.test.js +62 -0
  78. package/server/lib/devserver-router-api.js +249 -0
  79. package/server/lib/devserver-router-api.test.js +426 -0
  80. package/server/lib/model-router.js +245 -0
  81. package/server/lib/model-router.test.js +313 -0
  82. package/server/lib/output-schemas.js +269 -0
  83. package/server/lib/output-schemas.test.js +307 -0
  84. package/server/lib/provider-interface.js +153 -0
  85. package/server/lib/provider-interface.test.js +394 -0
  86. package/server/lib/provider-queue.js +158 -0
  87. package/server/lib/provider-queue.test.js +315 -0
  88. package/server/lib/router-config.js +221 -0
  89. package/server/lib/router-config.test.js +237 -0
  90. package/server/lib/router-setup-command.js +419 -0
  91. 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
+ });