@inixiative/hivemind 0.1.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/LICENSE +21 -0
- package/README.md +115 -0
- package/dist/agents/agents.test.d.ts +1 -0
- package/dist/agents/agents.test.js +167 -0
- package/dist/agents/getActiveAgents.d.ts +6 -0
- package/dist/agents/getActiveAgents.js +11 -0
- package/dist/agents/getAgent.d.ts +6 -0
- package/dist/agents/getAgent.js +7 -0
- package/dist/agents/getAgentBySessionId.d.ts +10 -0
- package/dist/agents/getAgentBySessionId.js +17 -0
- package/dist/agents/index.d.ts +10 -0
- package/dist/agents/index.js +12 -0
- package/dist/agents/markAgentDead.d.ts +6 -0
- package/dist/agents/markAgentDead.js +26 -0
- package/dist/agents/markAgentIdle.d.ts +5 -0
- package/dist/agents/markAgentIdle.js +12 -0
- package/dist/agents/registerAgent.d.ts +6 -0
- package/dist/agents/registerAgent.js +29 -0
- package/dist/agents/types.d.ts +30 -0
- package/dist/agents/types.js +1 -0
- package/dist/agents/unregisterAgent.d.ts +5 -0
- package/dist/agents/unregisterAgent.js +8 -0
- package/dist/agents/updateAgentContext.d.ts +5 -0
- package/dist/agents/updateAgentContext.js +12 -0
- package/dist/agents/updateAgentTask.d.ts +5 -0
- package/dist/agents/updateAgentTask.js +12 -0
- package/dist/agents/updateAgentWorktree.d.ts +5 -0
- package/dist/agents/updateAgentWorktree.js +12 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.js +8 -0
- package/dist/cli/init.d.ts +14 -0
- package/dist/cli/init.js +71 -0
- package/dist/cli/install.d.ts +8 -0
- package/dist/cli/install.js +47 -0
- package/dist/cli/join.d.ts +9 -0
- package/dist/cli/join.js +38 -0
- package/dist/cli/registerMcp.d.ts +28 -0
- package/dist/cli/registerMcp.js +138 -0
- package/dist/cli/status.d.ts +8 -0
- package/dist/cli/status.js +82 -0
- package/dist/cli/watch.d.ts +6 -0
- package/dist/cli/watch.js +68 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +49 -0
- package/dist/coordinator/coordinator.test.d.ts +1 -0
- package/dist/coordinator/coordinator.test.js +171 -0
- package/dist/coordinator/index.d.ts +16 -0
- package/dist/coordinator/index.js +166 -0
- package/dist/coordinator/spawn.d.ts +22 -0
- package/dist/coordinator/spawn.js +66 -0
- package/dist/datetime/datetime.test.d.ts +1 -0
- package/dist/datetime/datetime.test.js +63 -0
- package/dist/datetime/formatDate.d.ts +6 -0
- package/dist/datetime/formatDate.js +11 -0
- package/dist/datetime/formatDatetime.d.ts +6 -0
- package/dist/datetime/formatDatetime.js +12 -0
- package/dist/datetime/formatTime.d.ts +6 -0
- package/dist/datetime/formatTime.js +11 -0
- package/dist/datetime/index.d.ts +4 -0
- package/dist/datetime/index.js +7 -0
- package/dist/datetime/isStale.d.ts +10 -0
- package/dist/datetime/isStale.js +18 -0
- package/dist/datetime/now.d.ts +6 -0
- package/dist/datetime/now.js +9 -0
- package/dist/datetime/parseDatetime.d.ts +7 -0
- package/dist/datetime/parseDatetime.js +28 -0
- package/dist/db/constants.d.ts +4 -0
- package/dist/db/constants.js +6 -0
- package/dist/db/db.test.d.ts +1 -0
- package/dist/db/db.test.js +141 -0
- package/dist/db/ensureProjectDirs.d.ts +4 -0
- package/dist/db/ensureProjectDirs.js +12 -0
- package/dist/db/getConnection.d.ts +19 -0
- package/dist/db/getConnection.js +51 -0
- package/dist/db/getCurrentProject.d.ts +8 -0
- package/dist/db/getCurrentProject.js +14 -0
- package/dist/db/getProjectPaths.d.ts +21 -0
- package/dist/db/getProjectPaths.js +26 -0
- package/dist/db/index.d.ts +10 -0
- package/dist/db/index.js +13 -0
- package/dist/db/initializeDb.d.ts +7 -0
- package/dist/db/initializeDb.js +23 -0
- package/dist/db/nextEventSeq.d.ts +5 -0
- package/dist/db/nextEventSeq.js +13 -0
- package/dist/db/nextSubtaskSeq.d.ts +5 -0
- package/dist/db/nextSubtaskSeq.js +12 -0
- package/dist/db/nextTaskSeq.d.ts +5 -0
- package/dist/db/nextTaskSeq.js +13 -0
- package/dist/db/resetDb.d.ts +10 -0
- package/dist/db/resetDb.js +36 -0
- package/dist/events/emit.d.ts +6 -0
- package/dist/events/emit.js +31 -0
- package/dist/events/events.test.d.ts +1 -0
- package/dist/events/events.test.js +145 -0
- package/dist/events/getEventsByAgent.d.ts +6 -0
- package/dist/events/getEventsByAgent.js +14 -0
- package/dist/events/getEventsByBranch.d.ts +6 -0
- package/dist/events/getEventsByBranch.js +12 -0
- package/dist/events/getEventsByPlan.d.ts +6 -0
- package/dist/events/getEventsByPlan.js +14 -0
- package/dist/events/getEventsByWorktree.d.ts +6 -0
- package/dist/events/getEventsByWorktree.js +12 -0
- package/dist/events/getEventsSince.d.ts +12 -0
- package/dist/events/getEventsSince.js +47 -0
- package/dist/events/getRecentEvents.d.ts +6 -0
- package/dist/events/getRecentEvents.js +12 -0
- package/dist/events/index.d.ts +8 -0
- package/dist/events/index.js +9 -0
- package/dist/events/types.d.ts +34 -0
- package/dist/events/types.js +1 -0
- package/dist/git/getBranch.d.ts +4 -0
- package/dist/git/getBranch.js +14 -0
- package/dist/git/getCurrentWorktree.d.ts +5 -0
- package/dist/git/getCurrentWorktree.js +15 -0
- package/dist/git/getGitInfo.d.ts +10 -0
- package/dist/git/getGitInfo.js +23 -0
- package/dist/git/getRepoName.d.ts +4 -0
- package/dist/git/getRepoName.js +32 -0
- package/dist/git/getRepoRoot.d.ts +4 -0
- package/dist/git/getRepoRoot.js +14 -0
- package/dist/git/getWorktrees.d.ts +10 -0
- package/dist/git/getWorktrees.js +39 -0
- package/dist/git/index.d.ts +9 -0
- package/dist/git/index.js +7 -0
- package/dist/git/isGitRepo.d.ts +4 -0
- package/dist/git/isGitRepo.js +13 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/sessionStart.d.ts +21 -0
- package/dist/hooks/sessionStart.js +93 -0
- package/dist/ids/generateHex.d.ts +4 -0
- package/dist/ids/generateHex.js +7 -0
- package/dist/ids/getParentTaskId.d.ts +7 -0
- package/dist/ids/getParentTaskId.js +15 -0
- package/dist/ids/getPlanHexFromTaskId.d.ts +6 -0
- package/dist/ids/getPlanHexFromTaskId.js +9 -0
- package/dist/ids/ids.test.d.ts +1 -0
- package/dist/ids/ids.test.js +215 -0
- package/dist/ids/index.d.ts +16 -0
- package/dist/ids/index.js +17 -0
- package/dist/ids/isSubtask.d.ts +7 -0
- package/dist/ids/isSubtask.js +11 -0
- package/dist/ids/isValidId.d.ts +9 -0
- package/dist/ids/isValidId.js +22 -0
- package/dist/ids/makeAgentId.d.ts +8 -0
- package/dist/ids/makeAgentId.js +15 -0
- package/dist/ids/makeEventId.d.ts +11 -0
- package/dist/ids/makeEventId.js +12 -0
- package/dist/ids/makePlanId.d.ts +11 -0
- package/dist/ids/makePlanId.js +15 -0
- package/dist/ids/makeSubtaskId.d.ts +8 -0
- package/dist/ids/makeSubtaskId.js +15 -0
- package/dist/ids/makeTaskId.d.ts +8 -0
- package/dist/ids/makeTaskId.js +14 -0
- package/dist/ids/makeWorktreeId.d.ts +5 -0
- package/dist/ids/makeWorktreeId.js +12 -0
- package/dist/ids/parseId.d.ts +11 -0
- package/dist/ids/parseId.js +26 -0
- package/dist/ids/sanitizeLabel.d.ts +7 -0
- package/dist/ids/sanitizeLabel.js +12 -0
- package/dist/ids/typedIds.d.ts +34 -0
- package/dist/ids/typedIds.js +22 -0
- package/dist/ids/types.d.ts +14 -0
- package/dist/ids/types.js +1 -0
- package/dist/init/claudeConfig.d.ts +39 -0
- package/dist/init/claudeConfig.js +161 -0
- package/dist/llm/extractTasks.d.ts +28 -0
- package/dist/llm/extractTasks.js +108 -0
- package/dist/llm/index.d.ts +2 -0
- package/dist/llm/index.js +2 -0
- package/dist/llm/reconcileTasks.d.ts +21 -0
- package/dist/llm/reconcileTasks.js +82 -0
- package/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/server.js +100 -0
- package/dist/mcp/tools/emitEvent.d.ts +62 -0
- package/dist/mcp/tools/emitEvent.js +84 -0
- package/dist/mcp/tools/events.d.ts +55 -0
- package/dist/mcp/tools/events.js +56 -0
- package/dist/mcp/tools/index.d.ts +18 -0
- package/dist/mcp/tools/index.js +13 -0
- package/dist/mcp/tools/query.d.ts +54 -0
- package/dist/mcp/tools/query.js +70 -0
- package/dist/mcp/tools/register.d.ts +47 -0
- package/dist/mcp/tools/register.js +79 -0
- package/dist/mcp/tools/reset.d.ts +38 -0
- package/dist/mcp/tools/reset.js +56 -0
- package/dist/mcp/tools/setup.d.ts +42 -0
- package/dist/mcp/tools/setup.js +75 -0
- package/dist/mcp/tools/status.d.ts +44 -0
- package/dist/mcp/tools/status.js +74 -0
- package/dist/mcp/tools/tasks.d.ts +116 -0
- package/dist/mcp/tools/tasks.js +143 -0
- package/dist/mcp/tools/worktreeCleanup.d.ts +38 -0
- package/dist/mcp/tools/worktreeCleanup.js +67 -0
- package/dist/plans/createPlan.d.ts +6 -0
- package/dist/plans/createPlan.js +29 -0
- package/dist/plans/getActivePlans.d.ts +6 -0
- package/dist/plans/getActivePlans.js +11 -0
- package/dist/plans/getPlan.d.ts +6 -0
- package/dist/plans/getPlan.js +7 -0
- package/dist/plans/index.d.ts +5 -0
- package/dist/plans/index.js +5 -0
- package/dist/plans/plans.test.d.ts +1 -0
- package/dist/plans/plans.test.js +107 -0
- package/dist/plans/types.d.ts +32 -0
- package/dist/plans/types.js +1 -0
- package/dist/plans/updatePlanStatus.d.ts +6 -0
- package/dist/plans/updatePlanStatus.js +8 -0
- package/dist/tasks/assignTask.d.ts +7 -0
- package/dist/tasks/assignTask.js +20 -0
- package/dist/tasks/blockTask.d.ts +5 -0
- package/dist/tasks/blockTask.js +12 -0
- package/dist/tasks/claimTask.d.ts +7 -0
- package/dist/tasks/claimTask.js +21 -0
- package/dist/tasks/completeTask.d.ts +6 -0
- package/dist/tasks/completeTask.js +18 -0
- package/dist/tasks/createTask.d.ts +6 -0
- package/dist/tasks/createTask.js +36 -0
- package/dist/tasks/getPendingTasks.d.ts +6 -0
- package/dist/tasks/getPendingTasks.js +19 -0
- package/dist/tasks/getTask.d.ts +6 -0
- package/dist/tasks/getTask.js +7 -0
- package/dist/tasks/getTasksByPlan.d.ts +6 -0
- package/dist/tasks/getTasksByPlan.js +11 -0
- package/dist/tasks/index.d.ts +11 -0
- package/dist/tasks/index.js +12 -0
- package/dist/tasks/startTask.d.ts +5 -0
- package/dist/tasks/startTask.js +12 -0
- package/dist/tasks/tasks.test.d.ts +1 -0
- package/dist/tasks/tasks.test.js +209 -0
- package/dist/tasks/types.d.ts +36 -0
- package/dist/tasks/types.js +1 -0
- package/dist/tasks/unclaimTask.d.ts +5 -0
- package/dist/tasks/unclaimTask.js +12 -0
- package/dist/test/factories/agentFactory.d.ts +13 -0
- package/dist/test/factories/agentFactory.js +5 -0
- package/dist/test/factories/eventFactory.d.ts +20 -0
- package/dist/test/factories/eventFactory.js +16 -0
- package/dist/test/factories/factories.test.d.ts +1 -0
- package/dist/test/factories/factories.test.js +101 -0
- package/dist/test/factories/index.d.ts +4 -0
- package/dist/test/factories/index.js +4 -0
- package/dist/test/factories/planFactory.d.ts +15 -0
- package/dist/test/factories/planFactory.js +14 -0
- package/dist/test/factories/taskFactory.d.ts +22 -0
- package/dist/test/factories/taskFactory.js +44 -0
- package/dist/test/multiAgentDemo.d.ts +16 -0
- package/dist/test/multiAgentDemo.js +20 -0
- package/dist/test/multiAgentDemo.test.d.ts +1 -0
- package/dist/test/multiAgentDemo.test.js +14 -0
- package/dist/test/setup.d.ts +9 -0
- package/dist/test/setup.js +50 -0
- package/dist/utils/index.d.ts +5 -0
- package/dist/utils/index.js +5 -0
- package/dist/utils/math.d.ts +6 -0
- package/dist/utils/math.js +10 -0
- package/dist/utils/math.test.d.ts +1 -0
- package/dist/utils/math.test.js +26 -0
- package/dist/utils/string.d.ts +11 -0
- package/dist/utils/string.js +17 -0
- package/dist/utils/string.test.d.ts +1 -0
- package/dist/utils/string.test.js +30 -0
- package/dist/watcher/index.d.ts +1 -0
- package/dist/watcher/index.js +1 -0
- package/dist/watcher/planWatcher.d.ts +31 -0
- package/dist/watcher/planWatcher.js +171 -0
- package/dist/worktrees/getAllWorktrees.d.ts +6 -0
- package/dist/worktrees/getAllWorktrees.js +10 -0
- package/dist/worktrees/getWorktreeById.d.ts +6 -0
- package/dist/worktrees/getWorktreeById.js +7 -0
- package/dist/worktrees/getWorktreeByPath.d.ts +6 -0
- package/dist/worktrees/getWorktreeByPath.js +7 -0
- package/dist/worktrees/index.d.ts +9 -0
- package/dist/worktrees/index.js +13 -0
- package/dist/worktrees/registerWorktree.d.ts +6 -0
- package/dist/worktrees/registerWorktree.js +28 -0
- package/dist/worktrees/removeWorktree.d.ts +6 -0
- package/dist/worktrees/removeWorktree.js +9 -0
- package/dist/worktrees/syncWorktreesFromGit.d.ts +7 -0
- package/dist/worktrees/syncWorktreesFromGit.js +51 -0
- package/dist/worktrees/types.d.ts +29 -0
- package/dist/worktrees/types.js +1 -0
- package/dist/worktrees/updateWorktreeCommit.d.ts +5 -0
- package/dist/worktrees/updateWorktreeCommit.js +13 -0
- package/dist/worktrees/updateWorktreeSeen.d.ts +5 -0
- package/dist/worktrees/updateWorktreeSeen.js +13 -0
- package/package.json +58 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, afterAll } from 'bun:test';
|
|
2
|
+
import { createTestDb, cleanupAllTestDbs } from '../test/setup';
|
|
3
|
+
import { buildAgent } from '../test/factories';
|
|
4
|
+
import { getAgent } from '../agents/getAgent';
|
|
5
|
+
import { getActiveAgents } from '../agents/getActiveAgents';
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import { mkdirSync, rmSync, existsSync } from 'fs';
|
|
9
|
+
// Test directory for coordinator files
|
|
10
|
+
const TEST_COORD_DIR = path.join(__dirname, '../../.test-coordinator');
|
|
11
|
+
function createTestCoordinatorDir() {
|
|
12
|
+
const dataDir = path.join(TEST_COORD_DIR, `coord-${Date.now()}`);
|
|
13
|
+
mkdirSync(dataDir, { recursive: true });
|
|
14
|
+
return {
|
|
15
|
+
dataDir,
|
|
16
|
+
cleanup: () => {
|
|
17
|
+
if (existsSync(dataDir)) {
|
|
18
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function cleanupAllCoordinatorDirs() {
|
|
24
|
+
if (existsSync(TEST_COORD_DIR)) {
|
|
25
|
+
rmSync(TEST_COORD_DIR, { recursive: true, force: true });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
describe('Coordinator', () => {
|
|
29
|
+
let testDb;
|
|
30
|
+
let testCoord;
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
testDb = createTestDb();
|
|
33
|
+
testCoord = createTestCoordinatorDir();
|
|
34
|
+
});
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
testDb.cleanup();
|
|
37
|
+
testCoord.cleanup();
|
|
38
|
+
});
|
|
39
|
+
afterAll(() => {
|
|
40
|
+
cleanupAllTestDbs();
|
|
41
|
+
cleanupAllCoordinatorDirs();
|
|
42
|
+
});
|
|
43
|
+
describe('Lock acquisition', () => {
|
|
44
|
+
it('creates pid file when acquiring lock', async () => {
|
|
45
|
+
const pidPath = path.join(testCoord.dataDir, 'coordinator.pid');
|
|
46
|
+
// Simulate acquiring lock
|
|
47
|
+
fs.writeFileSync(pidPath, process.pid.toString());
|
|
48
|
+
expect(existsSync(pidPath)).toBe(true);
|
|
49
|
+
const content = fs.readFileSync(pidPath, 'utf-8');
|
|
50
|
+
expect(parseInt(content, 10)).toBe(process.pid);
|
|
51
|
+
});
|
|
52
|
+
it('detects stale pid file for dead process', () => {
|
|
53
|
+
const pidPath = path.join(testCoord.dataDir, 'coordinator.pid');
|
|
54
|
+
// Write a PID that doesn't exist (use a very high number)
|
|
55
|
+
fs.writeFileSync(pidPath, '999999999');
|
|
56
|
+
// Check if process exists
|
|
57
|
+
let processExists = true;
|
|
58
|
+
try {
|
|
59
|
+
process.kill(999999999, 0);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
processExists = false;
|
|
63
|
+
}
|
|
64
|
+
expect(processExists).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
describe('PID-based agent detection', () => {
|
|
68
|
+
it('can detect if a PID is alive', () => {
|
|
69
|
+
// Current process is alive
|
|
70
|
+
let alive = true;
|
|
71
|
+
try {
|
|
72
|
+
process.kill(process.pid, 0);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
alive = false;
|
|
76
|
+
}
|
|
77
|
+
expect(alive).toBe(true);
|
|
78
|
+
// Non-existent PID is dead
|
|
79
|
+
let deadAlive = true;
|
|
80
|
+
try {
|
|
81
|
+
process.kill(999999999, 0);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
deadAlive = false;
|
|
85
|
+
}
|
|
86
|
+
expect(deadAlive).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
it('agents store PID for lifecycle tracking', () => {
|
|
89
|
+
const { agent } = buildAgent(testDb.db, { pid: process.pid });
|
|
90
|
+
expect(agent.pid).toBe(process.pid);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
describe('Agent cleanup', () => {
|
|
94
|
+
it('marks stale agent as dead and releases tasks', () => {
|
|
95
|
+
const { agent } = buildAgent(testDb.db);
|
|
96
|
+
// Create a claimed task
|
|
97
|
+
testDb.db
|
|
98
|
+
.prepare(`
|
|
99
|
+
INSERT INTO plans (id, hex, title, status, created_at)
|
|
100
|
+
VALUES ('pln_test', 'abcdef', 'Test Plan', 'active', datetime('now'))
|
|
101
|
+
`)
|
|
102
|
+
.run();
|
|
103
|
+
testDb.db
|
|
104
|
+
.prepare(`
|
|
105
|
+
INSERT INTO tasks (id, plan_hex, seq, plan_id, title, status, claimed_by)
|
|
106
|
+
VALUES ('tsk_test', 'abcdef', '001', 'pln_test', 'Test task', 'claimed', ?)
|
|
107
|
+
`)
|
|
108
|
+
.run(agent.id);
|
|
109
|
+
// Mark agent dead (simulating coordinator action)
|
|
110
|
+
testDb.db.prepare(`UPDATE agents SET status = 'dead' WHERE id = ?`).run(agent.id);
|
|
111
|
+
testDb.db
|
|
112
|
+
.prepare(`UPDATE tasks SET status = 'pending', claimed_by = NULL WHERE claimed_by = ?`)
|
|
113
|
+
.run(agent.id);
|
|
114
|
+
// Verify
|
|
115
|
+
const updatedAgent = getAgent(testDb.db, agent.id);
|
|
116
|
+
expect(updatedAgent.status).toBe('dead');
|
|
117
|
+
const task = testDb.db.prepare(`SELECT * FROM tasks WHERE id = 'tsk_test'`).get();
|
|
118
|
+
expect(task.status).toBe('pending');
|
|
119
|
+
expect(task.claimed_by).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
describe('Singleton behavior', () => {
|
|
123
|
+
it('prevents multiple coordinators via pid file', () => {
|
|
124
|
+
const pidPath = path.join(testCoord.dataDir, 'coordinator.pid');
|
|
125
|
+
// First "coordinator" writes its PID
|
|
126
|
+
fs.writeFileSync(pidPath, process.pid.toString());
|
|
127
|
+
// Second coordinator should detect existing process
|
|
128
|
+
const existingPid = parseInt(fs.readFileSync(pidPath, 'utf-8'), 10);
|
|
129
|
+
let canAcquire = false;
|
|
130
|
+
try {
|
|
131
|
+
process.kill(existingPid, 0); // Check if alive
|
|
132
|
+
canAcquire = false; // Process alive, can't acquire
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
canAcquire = true; // Process dead, can acquire
|
|
136
|
+
}
|
|
137
|
+
expect(canAcquire).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
it('allows new coordinator when previous is dead', () => {
|
|
140
|
+
const pidPath = path.join(testCoord.dataDir, 'coordinator.pid');
|
|
141
|
+
// Write a dead PID
|
|
142
|
+
fs.writeFileSync(pidPath, '999999999');
|
|
143
|
+
// Check if can acquire
|
|
144
|
+
const existingPid = parseInt(fs.readFileSync(pidPath, 'utf-8'), 10);
|
|
145
|
+
let canAcquire = false;
|
|
146
|
+
try {
|
|
147
|
+
process.kill(existingPid, 0);
|
|
148
|
+
canAcquire = false;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
canAcquire = true;
|
|
152
|
+
}
|
|
153
|
+
expect(canAcquire).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
describe('Coordinator lifecycle', () => {
|
|
157
|
+
it('coordinator runs indefinitely (no exit on empty)', () => {
|
|
158
|
+
// No agents registered
|
|
159
|
+
const active = getActiveAgents(testDb.db);
|
|
160
|
+
expect(active.length).toBe(0);
|
|
161
|
+
// Coordinator should NOT exit - it runs forever per project
|
|
162
|
+
const shouldExit = false; // Changed from heartbeat-based logic
|
|
163
|
+
expect(shouldExit).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
it('coordinator monitors all active agents', () => {
|
|
166
|
+
buildAgent(testDb.db);
|
|
167
|
+
const active = getActiveAgents(testDb.db);
|
|
168
|
+
expect(active.length).toBe(1);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hivemind Coordinator - singleton background process (one per project)
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - Periodically check if agent PIDs are still alive
|
|
6
|
+
* - Mark dead agents
|
|
7
|
+
* - Clean up resources
|
|
8
|
+
*
|
|
9
|
+
* Runs indefinitely until killed. Started by first agent join.
|
|
10
|
+
*/
|
|
11
|
+
type CoordinatorConfig = {
|
|
12
|
+
project: string;
|
|
13
|
+
dataDir: string;
|
|
14
|
+
};
|
|
15
|
+
export declare function runCoordinator(config: CoordinatorConfig): void;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hivemind Coordinator - singleton background process (one per project)
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - Periodically check if agent PIDs are still alive
|
|
6
|
+
* - Mark dead agents
|
|
7
|
+
* - Clean up resources
|
|
8
|
+
*
|
|
9
|
+
* Runs indefinitely until killed. Started by first agent join.
|
|
10
|
+
*/
|
|
11
|
+
import { getConnection } from '../db/getConnection';
|
|
12
|
+
import { getActiveAgents } from '../agents/getActiveAgents';
|
|
13
|
+
import { markAgentDead } from '../agents/markAgentDead';
|
|
14
|
+
import { startPlanWatcher } from '../watcher/planWatcher';
|
|
15
|
+
import { emit } from '../events/emit';
|
|
16
|
+
import * as fs from 'fs';
|
|
17
|
+
import * as path from 'path';
|
|
18
|
+
import { parseDatetime } from '../datetime/parseDatetime';
|
|
19
|
+
const SWEEP_INTERVAL_MS = 30_000; // 30 seconds
|
|
20
|
+
const MIN_AGE_MS = 60_000; // Only kill agents older than 1 minute (grace period)
|
|
21
|
+
/**
|
|
22
|
+
* Check if a process is alive
|
|
23
|
+
*/
|
|
24
|
+
function isProcessAlive(pid) {
|
|
25
|
+
try {
|
|
26
|
+
process.kill(pid, 0); // Signal 0 = check existence
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Check if an agent is old enough to be considered for cleanup
|
|
35
|
+
*/
|
|
36
|
+
function isOldEnough(createdAt) {
|
|
37
|
+
const created = parseDatetime(createdAt);
|
|
38
|
+
if (!created)
|
|
39
|
+
return false;
|
|
40
|
+
return Date.now() - created.getTime() > MIN_AGE_MS;
|
|
41
|
+
}
|
|
42
|
+
function getLockPath(config) {
|
|
43
|
+
return path.join(config.dataDir, 'coordinator.lock');
|
|
44
|
+
}
|
|
45
|
+
function getPidPath(config) {
|
|
46
|
+
return path.join(config.dataDir, 'coordinator.pid');
|
|
47
|
+
}
|
|
48
|
+
function acquireLock(config) {
|
|
49
|
+
const lockPath = getLockPath(config);
|
|
50
|
+
const pidPath = getPidPath(config);
|
|
51
|
+
try {
|
|
52
|
+
// Check if another coordinator is running
|
|
53
|
+
if (fs.existsSync(pidPath)) {
|
|
54
|
+
const existingPid = parseInt(fs.readFileSync(pidPath, 'utf-8').trim(), 10);
|
|
55
|
+
// Check if process is still alive
|
|
56
|
+
try {
|
|
57
|
+
process.kill(existingPid, 0); // Signal 0 = check if process exists
|
|
58
|
+
// Process exists, can't acquire lock
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Process doesn't exist, stale PID file
|
|
63
|
+
fs.unlinkSync(pidPath);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Write our PID
|
|
67
|
+
fs.writeFileSync(pidPath, process.pid.toString());
|
|
68
|
+
// Create lock file
|
|
69
|
+
fs.writeFileSync(lockPath, new Date().toISOString());
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
console.error('Failed to acquire lock:', error);
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function releaseLock(config) {
|
|
78
|
+
try {
|
|
79
|
+
const lockPath = getLockPath(config);
|
|
80
|
+
const pidPath = getPidPath(config);
|
|
81
|
+
if (fs.existsSync(lockPath))
|
|
82
|
+
fs.unlinkSync(lockPath);
|
|
83
|
+
if (fs.existsSync(pidPath))
|
|
84
|
+
fs.unlinkSync(pidPath);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Ignore cleanup errors
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function sweep(config) {
|
|
91
|
+
const db = getConnection(config.project);
|
|
92
|
+
// Get all active agents and check if their PIDs are alive
|
|
93
|
+
const activeAgents = getActiveAgents(db);
|
|
94
|
+
let marked = 0;
|
|
95
|
+
for (const agent of activeAgents) {
|
|
96
|
+
// Skip agents that are too new (grace period)
|
|
97
|
+
if (!isOldEnough(agent.created_at)) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
// If agent has a PID, check if it's still alive
|
|
101
|
+
if (agent.pid && !isProcessAlive(agent.pid)) {
|
|
102
|
+
markAgentDead(db, agent.id);
|
|
103
|
+
// Emit agent:dead system event
|
|
104
|
+
emit(db, {
|
|
105
|
+
type: 'agent:dead',
|
|
106
|
+
agent_id: agent.id,
|
|
107
|
+
worktree_id: agent.worktree_id ?? undefined,
|
|
108
|
+
content: `Agent ${agent.id} died (PID ${agent.pid} no longer running)`,
|
|
109
|
+
metadata: {
|
|
110
|
+
pid: agent.pid,
|
|
111
|
+
label: agent.label,
|
|
112
|
+
current_task_id: agent.current_task_id,
|
|
113
|
+
current_plan_id: agent.current_plan_id,
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
marked++;
|
|
117
|
+
}
|
|
118
|
+
// Agents without PIDs are left alone (legacy or manual registration)
|
|
119
|
+
}
|
|
120
|
+
return marked;
|
|
121
|
+
}
|
|
122
|
+
export function runCoordinator(config) {
|
|
123
|
+
// Try to acquire lock
|
|
124
|
+
if (!acquireLock(config)) {
|
|
125
|
+
// Another coordinator is running
|
|
126
|
+
process.exit(0);
|
|
127
|
+
}
|
|
128
|
+
console.log(`[coordinator] Started for ${config.project} (pid: ${process.pid})`);
|
|
129
|
+
// Start the plans watcher (syncs Claude's plan mode files to hivemind)
|
|
130
|
+
const watcher = startPlanWatcher({ project: config.project });
|
|
131
|
+
// Handle shutdown gracefully
|
|
132
|
+
const shutdown = () => {
|
|
133
|
+
console.log('[coordinator] Shutting down...');
|
|
134
|
+
watcher.stop();
|
|
135
|
+
releaseLock(config);
|
|
136
|
+
process.exit(0);
|
|
137
|
+
};
|
|
138
|
+
process.on('SIGINT', shutdown);
|
|
139
|
+
process.on('SIGTERM', shutdown);
|
|
140
|
+
// Main loop
|
|
141
|
+
const tick = () => {
|
|
142
|
+
try {
|
|
143
|
+
const marked = sweep(config);
|
|
144
|
+
if (marked > 0) {
|
|
145
|
+
console.log(`[coordinator] Marked ${marked} dead agent(s) (PID gone)`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
console.error('[coordinator] Sweep error:', error);
|
|
150
|
+
}
|
|
151
|
+
// Schedule next tick
|
|
152
|
+
setTimeout(tick, SWEEP_INTERVAL_MS);
|
|
153
|
+
};
|
|
154
|
+
// Start the loop
|
|
155
|
+
tick();
|
|
156
|
+
}
|
|
157
|
+
// CLI entry point
|
|
158
|
+
if (import.meta.main) {
|
|
159
|
+
const project = process.argv[2];
|
|
160
|
+
const dataDir = process.argv[3];
|
|
161
|
+
if (!project || !dataDir) {
|
|
162
|
+
console.error('Usage: coordinator <project> <dataDir>');
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
runCoordinator({ project, dataDir });
|
|
166
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spawn coordinator as a singleton - only starts if not already running
|
|
3
|
+
*/
|
|
4
|
+
type SpawnConfig = {
|
|
5
|
+
project: string;
|
|
6
|
+
dataDir: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Upsert coordinator singleton - spawns if not running, no-op if already running
|
|
10
|
+
*/
|
|
11
|
+
export declare function ensureCoordinator(config: SpawnConfig): {
|
|
12
|
+
spawned: boolean;
|
|
13
|
+
pid?: number;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Get coordinator status
|
|
17
|
+
*/
|
|
18
|
+
export declare function getCoordinatorStatus(config: SpawnConfig): {
|
|
19
|
+
running: boolean;
|
|
20
|
+
pid?: number;
|
|
21
|
+
};
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spawn coordinator as a singleton - only starts if not already running
|
|
3
|
+
*/
|
|
4
|
+
import { spawn } from 'child_process';
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
function isCoordinatorRunning(config) {
|
|
8
|
+
const pidPath = path.join(config.dataDir, 'coordinator.pid');
|
|
9
|
+
if (!fs.existsSync(pidPath)) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
const pid = parseInt(fs.readFileSync(pidPath, 'utf-8').trim(), 10);
|
|
14
|
+
process.kill(pid, 0); // Signal 0 = check existence
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// Process doesn't exist or can't be signaled
|
|
19
|
+
// Clean up stale PID file
|
|
20
|
+
try {
|
|
21
|
+
fs.unlinkSync(pidPath);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Ignore
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Upsert coordinator singleton - spawns if not running, no-op if already running
|
|
31
|
+
*/
|
|
32
|
+
export function ensureCoordinator(config) {
|
|
33
|
+
// Check if already running
|
|
34
|
+
if (isCoordinatorRunning(config)) {
|
|
35
|
+
const pidPath = path.join(config.dataDir, 'coordinator.pid');
|
|
36
|
+
const pid = parseInt(fs.readFileSync(pidPath, 'utf-8').trim(), 10);
|
|
37
|
+
return { spawned: false, pid };
|
|
38
|
+
}
|
|
39
|
+
// Spawn coordinator as detached background process
|
|
40
|
+
const coordinatorPath = path.join(__dirname, 'index.ts');
|
|
41
|
+
const child = spawn('bun', ['run', coordinatorPath, config.project, config.dataDir], {
|
|
42
|
+
detached: true,
|
|
43
|
+
stdio: 'ignore',
|
|
44
|
+
env: { ...process.env },
|
|
45
|
+
});
|
|
46
|
+
// Detach from parent
|
|
47
|
+
child.unref();
|
|
48
|
+
return { spawned: true, pid: child.pid };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Get coordinator status
|
|
52
|
+
*/
|
|
53
|
+
export function getCoordinatorStatus(config) {
|
|
54
|
+
const pidPath = path.join(config.dataDir, 'coordinator.pid');
|
|
55
|
+
if (!fs.existsSync(pidPath)) {
|
|
56
|
+
return { running: false };
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const pid = parseInt(fs.readFileSync(pidPath, 'utf-8').trim(), 10);
|
|
60
|
+
process.kill(pid, 0);
|
|
61
|
+
return { running: true, pid };
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return { running: false };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, it, expect } from 'bun:test';
|
|
2
|
+
import { formatDate } from './formatDate';
|
|
3
|
+
import { formatTime } from './formatTime';
|
|
4
|
+
import { formatDatetime } from './formatDatetime';
|
|
5
|
+
import { now } from './now';
|
|
6
|
+
import { parseDatetime } from './parseDatetime';
|
|
7
|
+
import { isStale } from './isStale';
|
|
8
|
+
describe('formatDate', () => {
|
|
9
|
+
it('formats date as yyyy/mm/dd', () => {
|
|
10
|
+
const date = new Date('2024-03-15T10:30:00Z');
|
|
11
|
+
const formatted = formatDate(date);
|
|
12
|
+
expect(formatted).toMatch(/^\d{4}\/\d{2}\/\d{2}$/);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
describe('formatTime', () => {
|
|
16
|
+
it('formats time as hh:mm:ss', () => {
|
|
17
|
+
const date = new Date('2024-03-15T10:30:45Z');
|
|
18
|
+
const formatted = formatTime(date);
|
|
19
|
+
expect(formatted).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
describe('formatDatetime', () => {
|
|
23
|
+
it('formats datetime with timezone', () => {
|
|
24
|
+
const date = new Date('2024-03-15T10:30:45Z');
|
|
25
|
+
const formatted = formatDatetime(date);
|
|
26
|
+
expect(formatted).toMatch(/^\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2} [A-Z]{2,4}$/);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
describe('now', () => {
|
|
30
|
+
it('returns current datetime string', () => {
|
|
31
|
+
const current = now();
|
|
32
|
+
expect(current).toMatch(/^\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2} [A-Z]{2,4}$/);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
describe('parseDatetime', () => {
|
|
36
|
+
it('parses formatted datetime string', () => {
|
|
37
|
+
const original = new Date();
|
|
38
|
+
const formatted = formatDatetime(original);
|
|
39
|
+
const parsed = parseDatetime(formatted);
|
|
40
|
+
expect(parsed).toBeInstanceOf(Date);
|
|
41
|
+
// Allow 1 second tolerance due to milliseconds being lost
|
|
42
|
+
expect(Math.abs(parsed.getTime() - original.getTime())).toBeLessThan(1000);
|
|
43
|
+
});
|
|
44
|
+
it('returns null for invalid string', () => {
|
|
45
|
+
expect(parseDatetime('invalid')).toBeNull();
|
|
46
|
+
expect(parseDatetime('')).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe('isStale', () => {
|
|
50
|
+
it('returns true for old timestamps', () => {
|
|
51
|
+
const oldDate = new Date(Date.now() - 120000); // 2 minutes ago
|
|
52
|
+
const formatted = formatDatetime(oldDate);
|
|
53
|
+
expect(isStale(formatted, 60000)).toBe(true); // 1 minute threshold
|
|
54
|
+
});
|
|
55
|
+
it('returns false for recent timestamps', () => {
|
|
56
|
+
const recent = now();
|
|
57
|
+
expect(isStale(recent, 60000)).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
it('returns true for null/undefined', () => {
|
|
60
|
+
expect(isStale(null, 60000)).toBe(true);
|
|
61
|
+
expect(isStale(undefined, 60000)).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format a Date as yyyy/mm/dd
|
|
3
|
+
*
|
|
4
|
+
* Example: 2025/01/15
|
|
5
|
+
*/
|
|
6
|
+
export function formatDate(date = new Date()) {
|
|
7
|
+
const year = date.getFullYear();
|
|
8
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
9
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
10
|
+
return `${year}/${month}/${day}`;
|
|
11
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { formatDate } from './formatDate';
|
|
2
|
+
import { formatTime } from './formatTime';
|
|
3
|
+
/**
|
|
4
|
+
* Format a Date as yyyy/mm/dd hh:mm:ss TZ
|
|
5
|
+
*
|
|
6
|
+
* Example: 2025/01/15 09:14:32 PST
|
|
7
|
+
*/
|
|
8
|
+
export function formatDatetime(date = new Date()) {
|
|
9
|
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
10
|
+
const tzAbbrev = date.toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop();
|
|
11
|
+
return `${formatDate(date)} ${formatTime(date)} ${tzAbbrev}`;
|
|
12
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format a Date as hh:mm:ss
|
|
3
|
+
*
|
|
4
|
+
* Example: 09:14:32
|
|
5
|
+
*/
|
|
6
|
+
export function formatTime(date = new Date()) {
|
|
7
|
+
const hours = String(date.getHours()).padStart(2, '0');
|
|
8
|
+
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
9
|
+
const seconds = String(date.getSeconds()).padStart(2, '0');
|
|
10
|
+
return `${hours}:${minutes}:${seconds}`;
|
|
11
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check if a timestamp is older than threshold
|
|
3
|
+
*
|
|
4
|
+
* Used for agent liveness checks
|
|
5
|
+
*
|
|
6
|
+
* @param timestamp - Datetime string (2025/01/15 09:14:32 PST) or null
|
|
7
|
+
* @param thresholdMs - Stale threshold in milliseconds (default 30 seconds)
|
|
8
|
+
* @returns true if stale, including when timestamp is null/undefined
|
|
9
|
+
*/
|
|
10
|
+
export declare function isStale(timestamp: string | null | undefined, thresholdMs?: number): boolean;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { parseDatetime } from './parseDatetime';
|
|
2
|
+
/**
|
|
3
|
+
* Check if a timestamp is older than threshold
|
|
4
|
+
*
|
|
5
|
+
* Used for agent liveness checks
|
|
6
|
+
*
|
|
7
|
+
* @param timestamp - Datetime string (2025/01/15 09:14:32 PST) or null
|
|
8
|
+
* @param thresholdMs - Stale threshold in milliseconds (default 30 seconds)
|
|
9
|
+
* @returns true if stale, including when timestamp is null/undefined
|
|
10
|
+
*/
|
|
11
|
+
export function isStale(timestamp, thresholdMs = 30000) {
|
|
12
|
+
const then = parseDatetime(timestamp);
|
|
13
|
+
if (!then) {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
const now = new Date();
|
|
17
|
+
return now.getTime() - then.getTime() > thresholdMs;
|
|
18
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a datetime string to Date
|
|
3
|
+
*
|
|
4
|
+
* Input: 2025/01/15 09:14:32 PST
|
|
5
|
+
* Returns: Date object or null if invalid
|
|
6
|
+
*/
|
|
7
|
+
export function parseDatetime(datetimeStr) {
|
|
8
|
+
if (!datetimeStr) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
try {
|
|
12
|
+
// Remove timezone abbreviation for parsing
|
|
13
|
+
const withoutTz = datetimeStr.replace(/\s+[A-Z]{3,4}$/, '');
|
|
14
|
+
const [datePart, timePart] = withoutTz.split(' ');
|
|
15
|
+
if (!datePart || !timePart) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
const [year, month, day] = datePart.split('/').map(Number);
|
|
19
|
+
const [hours, minutes, seconds] = timePart.split(':').map(Number);
|
|
20
|
+
if (isNaN(year) || isNaN(month) || isNaN(day)) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return new Date(year, month - 1, day, hours, minutes, seconds);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|