jarvis-agent-factory 3.1.0 → 3.2.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/package.json +1 -1
- package/src/engine/server.js +109 -8
package/package.json
CHANGED
package/src/engine/server.js
CHANGED
|
@@ -38,6 +38,43 @@ export async function startEngine({ port = DEFAULT_PORT, dashboard = false, proj
|
|
|
38
38
|
|
|
39
39
|
const server = new McpServer({ name: 'jarvis-engine', version: readPkgVersion() });
|
|
40
40
|
|
|
41
|
+
// ---- Session Manager (multi-session safety) ----
|
|
42
|
+
const sessions = new Map(); // sessionId → { id, platform, created_at, last_heartbeat, role }
|
|
43
|
+
const SESSION_TIMEOUT = 120_000; // 2 min heartbeat timeout
|
|
44
|
+
let leaderSessionId = null;
|
|
45
|
+
|
|
46
|
+
function cleanupStaleSessions() {
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
for (const [sid, s] of sessions) {
|
|
49
|
+
if (now - s.last_heartbeat > SESSION_TIMEOUT) {
|
|
50
|
+
sessions.delete(sid);
|
|
51
|
+
if (sid === leaderSessionId) {
|
|
52
|
+
leaderSessionId = null;
|
|
53
|
+
// Elect new leader
|
|
54
|
+
const oldest = [...sessions.values()].sort((a, b) => a.created_at - b.created_at)[0];
|
|
55
|
+
if (oldest) { leaderSessionId = oldest.id; oldest.role = 'leader'; }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// If no leader but sessions exist, elect oldest
|
|
60
|
+
if (!leaderSessionId && sessions.size > 0) {
|
|
61
|
+
const oldest = [...sessions.values()].sort((a, b) => a.created_at - b.created_at)[0];
|
|
62
|
+
leaderSessionId = oldest.id;
|
|
63
|
+
oldest.role = 'leader';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function requireLeader(sessionId) {
|
|
68
|
+
cleanupStaleSessions();
|
|
69
|
+
const s = sessions.get(sessionId);
|
|
70
|
+
if (!s) return { error: 'Session not registered. Call session_join first.' };
|
|
71
|
+
if (s.role !== 'leader') return { error: `Write lock held by session ${leaderSessionId}. You are observer (read-only).` };
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Heartbeat cleanup
|
|
76
|
+
setInterval(cleanupStaleSessions, 30_000);
|
|
77
|
+
|
|
41
78
|
// ---- Pipeline state machine (hard constraints) ----
|
|
42
79
|
const pipelinePath = join(root, '.jarvis', 'pipeline.json');
|
|
43
80
|
|
|
@@ -54,17 +91,78 @@ export async function startEngine({ port = DEFAULT_PORT, dashboard = false, proj
|
|
|
54
91
|
// TOOLS
|
|
55
92
|
// ==============================
|
|
56
93
|
|
|
94
|
+
// --- Session management ---
|
|
95
|
+
server.tool(
|
|
96
|
+
'session_join',
|
|
97
|
+
'【多会话安全】注册当前会话。第一个注册的会话获得 leader 写锁,后续会话为 observer(只读)。返回 session_id 和角色。',
|
|
98
|
+
{ platform: z.enum(['claude','opencode','codex','other']).optional().describe('平台名称') },
|
|
99
|
+
async ({ platform }, extra) => {
|
|
100
|
+
const sessionId = extra?.sessionId || `s${Date.now()}`;
|
|
101
|
+
cleanupStaleSessions();
|
|
102
|
+
const existing = sessions.get(sessionId);
|
|
103
|
+
if (existing) { existing.last_heartbeat = Date.now(); return { content: [{ type: 'text', text: JSON.stringify({ session_id: sessionId, role: existing.role, leader: leaderSessionId, active_sessions: sessions.size }) }] }; }
|
|
104
|
+
|
|
105
|
+
const role = sessions.size === 0 ? 'leader' : 'observer';
|
|
106
|
+
const s = { id: sessionId, platform: platform || 'unknown', created_at: Date.now(), last_heartbeat: Date.now(), role };
|
|
107
|
+
sessions.set(sessionId, s);
|
|
108
|
+
if (role === 'leader') leaderSessionId = sessionId;
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
112
|
+
session_id: sessionId, role, leader: leaderSessionId,
|
|
113
|
+
active_sessions: sessions.size,
|
|
114
|
+
message: role === 'leader' ? '🔑 You are leader — write access granted.' : '👁 You are observer — read-only. Leader holds write lock.',
|
|
115
|
+
}, null, 2) }],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
server.tool('session_heartbeat', '【多会话安全】发送心跳,保持会话活跃。每 60 秒至少发一次,否则会话超时被清理。', {},
|
|
121
|
+
async (_args, extra) => {
|
|
122
|
+
const sid = extra?.sessionId;
|
|
123
|
+
if (!sid || !sessions.has(sid)) return { content: [{ type: 'text', text: JSON.stringify({ error: 'Session not found. Call session_join first.' }) }] };
|
|
124
|
+
sessions.get(sid).last_heartbeat = Date.now();
|
|
125
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, session_id: sid, role: sessions.get(sid).role, leader: leaderSessionId }) }] };
|
|
126
|
+
}
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
server.tool('session_list', '【多会话安全】列出所有活跃会话及其角色。', {},
|
|
130
|
+
async () => {
|
|
131
|
+
cleanupStaleSessions();
|
|
132
|
+
const list = [...sessions.values()].map(s => ({ session_id: s.id, platform: s.platform, role: s.role, leader: s.id === leaderSessionId, last_heartbeat_ago: `${Math.round((Date.now() - s.last_heartbeat) / 1000)}s` }));
|
|
133
|
+
return { content: [{ type: 'text', text: JSON.stringify({ active_sessions: sessions.size, leader_session: leaderSessionId, sessions: list }) }] };
|
|
134
|
+
}
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
server.tool('session_leave', '【多会话安全】主动离开。如果是 leader,锁自动移交给最老的 observer。', {},
|
|
138
|
+
async (_args, extra) => {
|
|
139
|
+
const sid = extra?.sessionId;
|
|
140
|
+
if (!sid || !sessions.has(sid)) return { content: [{ type: 'text', text: JSON.stringify({ ok: true, message: 'Session not registered.' }) }] };
|
|
141
|
+
const wasLeader = sessions.get(sid).role === 'leader';
|
|
142
|
+
sessions.delete(sid);
|
|
143
|
+
if (wasLeader) {
|
|
144
|
+
leaderSessionId = null;
|
|
145
|
+
const oldest = [...sessions.values()].sort((a, b) => a.created_at - b.created_at)[0];
|
|
146
|
+
if (oldest) { leaderSessionId = oldest.id; oldest.role = 'leader'; }
|
|
147
|
+
}
|
|
148
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, message: 'Session left.', lock_transferred: wasLeader && leaderSessionId ? `Lock → ${leaderSessionId}` : 'No active sessions' }) }] };
|
|
149
|
+
}
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// --- Pipeline management ---
|
|
153
|
+
|
|
57
154
|
// Tool: pipeline_init — hard state bootstrap
|
|
58
155
|
server.tool(
|
|
59
156
|
'pipeline_init',
|
|
60
|
-
'
|
|
157
|
+
'【硬约束·需Leader】初始化流水线状态机。只有 leader 会话可调用。observer 会被拒绝。',
|
|
61
158
|
{ project_name: z.string().optional().describe('项目名称(可选)') },
|
|
62
|
-
async ({ project_name }) => {
|
|
159
|
+
async ({ project_name }, extra) => {
|
|
160
|
+
const lockErr = requireLeader(extra?.sessionId); if (lockErr) return { content: [{ type: 'text', text: JSON.stringify(lockErr) }] };
|
|
63
161
|
const existing = readPipeline();
|
|
64
|
-
if (existing) return { content: [{ type: 'text', text: JSON.stringify({ ok: true, message: 'Pipeline already initialized', state: existing }, null, 2) }] };
|
|
65
|
-
const state = { project: project_name || root, current_gate: 'Gate A', started_at: new Date().toISOString(), gates_passed: [], mode: 'strict' };
|
|
162
|
+
if (existing) return { content: [{ type: 'text', text: JSON.stringify({ ok: true, message: 'Pipeline already initialized', state: existing, session: extra?.sessionId, role: 'leader' }, null, 2) }] };
|
|
163
|
+
const state = { project: project_name || root, current_gate: 'Gate A', started_at: new Date().toISOString(), gates_passed: [], mode: 'strict', initialized_by: extra?.sessionId };
|
|
66
164
|
writePipeline(state);
|
|
67
|
-
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, message: 'Pipeline initialized — hard state machine active. Next: Gate A', state }, null, 2) }] };
|
|
165
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, message: 'Pipeline initialized — hard state machine active. Next: Gate A', state, session: extra?.sessionId, role: 'leader' }, null, 2) }] };
|
|
68
166
|
}
|
|
69
167
|
);
|
|
70
168
|
|
|
@@ -80,6 +178,7 @@ export async function startEngine({ port = DEFAULT_PORT, dashboard = false, proj
|
|
|
80
178
|
return { gate, passed: checkpoints.length > 0, checkpoints, artifacts: findGateArtifacts(join(root, 'docs'), gate), requirement: GATE_CHECKS[gate]?.check || '' };
|
|
81
179
|
});
|
|
82
180
|
const current = pstate?.current_gate || (gates.find(g => !g.passed)?.gate || 'Gate A');
|
|
181
|
+
const sessionInfo = { active_sessions: sessions.size, leader: leaderSessionId, sessions: [...sessions.values()].map(s => ({ id: s.id, role: s.role, platform: s.platform, alive_s: Math.round((Date.now() - s.last_heartbeat)/1000) })) };
|
|
83
182
|
return {
|
|
84
183
|
content: [{ type: 'text', text: JSON.stringify({
|
|
85
184
|
project: root,
|
|
@@ -87,6 +186,7 @@ export async function startEngine({ port = DEFAULT_PORT, dashboard = false, proj
|
|
|
87
186
|
current_gate: current,
|
|
88
187
|
completed: gates.filter(g => g.passed).map(g => g.gate),
|
|
89
188
|
gates,
|
|
189
|
+
sessions: sessionInfo,
|
|
90
190
|
_display: formatGateDisplay(gates, current),
|
|
91
191
|
}, null, 2) }],
|
|
92
192
|
};
|
|
@@ -127,12 +227,13 @@ export async function startEngine({ port = DEFAULT_PORT, dashboard = false, proj
|
|
|
127
227
|
}
|
|
128
228
|
);
|
|
129
229
|
|
|
130
|
-
// Tool: advance_gate — FSM enforced
|
|
230
|
+
// Tool: advance_gate — FSM enforced + leader check
|
|
131
231
|
server.tool(
|
|
132
232
|
'advance_gate',
|
|
133
|
-
'
|
|
233
|
+
'【硬约束·需Leader】推进到下一个 Gate。仅 leader 会话可调用,observer 被拒绝。非顺序推进被 FSM 拒绝。',
|
|
134
234
|
{ gate: z.enum(GATES).describe('要推进到的 Gate 名称') },
|
|
135
|
-
async ({ gate }) => {
|
|
235
|
+
async ({ gate }, extra) => {
|
|
236
|
+
const lockErr = requireLeader(extra?.sessionId); if (lockErr) return { content: [{ type: 'text', text: JSON.stringify(lockErr) }] };
|
|
136
237
|
const pstate = readPipeline();
|
|
137
238
|
const currentGate = pstate?.current_gate || 'Gate A';
|
|
138
239
|
const currentIdx = GATES.indexOf(currentGate);
|