Commit dae7e90a990bbe98e5565cbedd27ddd768154dea

Authored by yibo
1 parent b1a348b0

fix: prevent concurrent Master sessions from racing on template files

Layer 1 (P0): Per-agent session mutex
- heartbeat-service.js: skip heartbeat if previous session still active (30min safety valve)
- agent-chat.js: reject concurrent /chat requests for same agentId with different convId (429)
  Multi-turn conversations on same convId still work normally

Layer 2 (P1): Advisory file lock utility
- server/utils/file-lock.js: O_EXCL-based .lock file mechanism
  Auto-cleanup stale locks, graceful degradation on timeout

Root cause: HeartbeatService creates new convId per heartbeat, spawning
independent Claude CLI processes that concurrently write config.json and
SKILL.md, causing rollbacks and FRAUDULENT scores.
.gitignore
@@ -94,6 +94,7 @@ system/data/companies/ @@ -94,6 +94,7 @@ system/data/companies/
94 94
95 # Temp/backup files 95 # Temp/backup files
96 *.bak 96 *.bak
  97 +*.lock
97 EOF 98 EOF
98 PYEOF 99 PYEOF
99 100
server/routes/agent-chat.js
@@ -2178,6 +2178,11 @@ router.get('/:agentId/uploads/:convId/:filename', (req, res) => { @@ -2178,6 +2178,11 @@ router.get('/:agentId/uploads/:convId/:filename', (req, res) => {
2178 // POST /api/agent/:agentId/chat — SSE 流式对话 2178 // POST /api/agent/:agentId/chat — SSE 流式对话
2179 // ═══════════════════════════════════════ 2179 // ═══════════════════════════════════════
2180 2180
  2181 +// 🔒 Agent 级并发互斥锁 — 同一 Agent 同时只允许一个活跃 chat session
  2182 +// 同一 convId 的多轮对话不受限制(ACP 进程复用)
  2183 +const agentChatLocks = new Map(); // agentId → { convId, startedAt }
  2184 +const AGENT_LOCK_TIMEOUT = 30 * 60 * 1000; // 30 分钟安全阀
  2185 +
2181 router.post('/:agentId/chat', async (req, res) => { 2186 router.post('/:agentId/chat', async (req, res) => {
2182 const { message, conversationId: reqConvId } = req.body; 2187 const { message, conversationId: reqConvId } = req.body;
2183 if (!message) return res.status(400).json({ error: 'message is required' }); 2188 if (!message) return res.status(400).json({ error: 'message is required' });
@@ -2187,6 +2192,25 @@ router.post('/:agentId/chat', async (req, res) => { @@ -2187,6 +2192,25 @@ router.post('/:agentId/chat', async (req, res) => {
2187 if (!fs.existsSync(agentDir)) return res.status(404).json({ error: 'Agent not found' }); 2192 if (!fs.existsSync(agentDir)) return res.status(404).json({ error: 'Agent not found' });
2188 2193
2189 const convId = reqConvId || `conv-${Date.now().toString(36)}`; 2194 const convId = reqConvId || `conv-${Date.now().toString(36)}`;
  2195 +
  2196 + // 🔒 并发互斥检查:同一 Agent 不同 convId 的并发请求被拒绝
  2197 + const existingLock = agentChatLocks.get(agentId);
  2198 + if (existingLock && existingLock.convId !== convId) {
  2199 + const elapsed = Date.now() - existingLock.startedAt;
  2200 + if (elapsed < AGENT_LOCK_TIMEOUT) {
  2201 + console.log(`[AgentChat] 🔒 拒绝 ${agentId} 并发请求 — 已有活跃会话 ${existingLock.convId} (${(elapsed / 1000).toFixed(0)}s)`);
  2202 + return res.status(429).json({
  2203 + error: `Agent ${agentId} 正在执行另一个会话 (${existingLock.convId}),请等待完成`,
  2204 + activeConvId: existingLock.convId,
  2205 + elapsedMs: elapsed
  2206 + });
  2207 + }
  2208 + // 超时释放
  2209 + console.warn(`[AgentChat] ⚠️ 强制释放超时锁: ${agentId} / ${existingLock.convId}`);
  2210 + agentChatLocks.delete(agentId);
  2211 + }
  2212 + agentChatLocks.set(agentId, { convId, startedAt: Date.now() });
  2213 +
2190 const msgId = `msg-${Date.now()}-${randomUUID().substring(0, 8)}`; 2214 const msgId = `msg-${Date.now()}-${randomUUID().substring(0, 8)}`;
2191 const sendSSE = setupSSE(res); 2215 const sendSSE = setupSSE(res);
2192 const identity = loadIdentity(agentDir); 2216 const identity = loadIdentity(agentDir);
@@ -2492,6 +2516,7 @@ ${resultsText.join(&#39;\n\n&#39;)} @@ -2492,6 +2516,7 @@ ${resultsText.join(&#39;\n\n&#39;)}
2492 sendSSE('error', { message: err.message }); 2516 sendSSE('error', { message: err.message });
2493 console.log(`[SSE-DEBUG] 🚨 sendSSE('error') called:`, err.message.substring(0, 200)); 2517 console.log(`[SSE-DEBUG] 🚨 sendSSE('error') called:`, err.message.substring(0, 200));
2494 } finally { 2518 } finally {
  2519 + agentChatLocks.delete(agentId);
2495 res.end(); 2520 res.end();
2496 } 2521 }
2497 }); 2522 });
server/services/heartbeat-service.js
@@ -14,7 +14,8 @@ function getAgentDir(id) { @@ -14,7 +14,8 @@ function getAgentDir(id) {
14 14
15 class HeartbeatService { 15 class HeartbeatService {
16 constructor() { 16 constructor() {
17 - this.timers = new Map(); // agentId -> NodeJS.Timeout 17 + this.timers = new Map(); // agentId → NodeJS.Timeout
  18 + this.activeSessions = new Map(); // agentId → { convId, startedAt } — 防并发互斥
18 this.port = config.port || 3000; 19 this.port = config.port || 3000;
19 } 20 }
20 21
@@ -97,17 +98,35 @@ class HeartbeatService { @@ -97,17 +98,35 @@ class HeartbeatService {
97 * 内部方法:向 Agent 的 Chat API 发送一次隐式的系统心跳 98 * 内部方法:向 Agent 的 Chat API 发送一次隐式的系统心跳
98 */ 99 */
99 _triggerHeartbeat(agentId) { 100 _triggerHeartbeat(agentId) {
  101 + // 🔒 互斥检查:如果该 Agent 已有活跃心跳会话,跳过本次
  102 + const SESSION_TIMEOUT = 30 * 60 * 1000; // 30 分钟安全阀
  103 + if (this.activeSessions.has(agentId)) {
  104 + const active = this.activeSessions.get(agentId);
  105 + const elapsed = Date.now() - active.startedAt;
  106 + if (elapsed < SESSION_TIMEOUT) {
  107 + console.log(`[Heartbeat] ⏭️ 跳过 ${agentId} — 已有活跃会话 ${active.convId} (${(elapsed / 1000).toFixed(0)}s)`);
  108 + return;
  109 + }
  110 + // 超时强制释放(防死锁)
  111 + console.warn(`[Heartbeat] ⚠️ 强制释放超时会话: ${agentId} / ${active.convId} (${(elapsed / 1000).toFixed(0)}s)`);
  112 + this.activeSessions.delete(agentId);
  113 + }
  114 +
100 const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19); 115 const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19);
101 console.log(`[Heartbeat] 💓 触发 ${agentId} 主动心跳 (${timestamp})...`); 116 console.log(`[Heartbeat] 💓 触发 ${agentId} 主动心跳 (${timestamp})...`);
102 117
103 const convId = `heartbeat-${Date.now().toString(36)}`; 118 const convId = `heartbeat-${Date.now().toString(36)}`;
  119 +
  120 + // 🔒 注册活跃会话
  121 + this.activeSessions.set(agentId, { convId, startedAt: Date.now() });
  122 +
104 const heartbeatMessage = [ 123 const heartbeatMessage = [
105 `[SYSTEM HEARTBEAT]`, 124 `[SYSTEM HEARTBEAT]`,
106 `当前系统时间:${timestamp}`, 125 `当前系统时间:${timestamp}`,
107 `这是一个后台周期的主动心跳触发。`, 126 `这是一个后台周期的主动心跳触发。`,
108 `要求:请根据你的身份和 heartbeat.md 规则,扫描当前最新的全局 OKR 目标状态和节点。`, 127 `要求:请根据你的身份和 heartbeat.md 规则,扫描当前最新的全局 OKR 目标状态和节点。`,
109 `1. 如果发现某个负责人超时、卡点或产出需要仲裁,请主动使用 dispatch 工具给他们发送通知或驳回。`, 128 `1. 如果发现某个负责人超时、卡点或产出需要仲裁,请主动使用 dispatch 工具给他们发送通知或驳回。`,
110 - `2. 如果一切正常,无事发生,请直接回复“[心跳正常]”并且不要使用任何工具。`, 129 + `2. 如果一切正常,无事发生,请直接回复"[心跳正常]"并且不要使用任何工具。`,
111 `无需等待人类确认,请完全自主决策。` 130 `无需等待人类确认,请完全自主决策。`
112 ].join('\n'); 131 ].join('\n');
113 132
@@ -128,12 +147,18 @@ class HeartbeatService { @@ -128,12 +147,18 @@ class HeartbeatService {
128 } 147 }
129 }; 148 };
130 149
  150 + const releaseSession = () => {
  151 + this.activeSessions.delete(agentId);
  152 + console.log(`[Heartbeat] 🔓 释放 ${agentId} 会话锁 (${convId})`);
  153 + };
  154 +
131 const req = http.request(options, (res) => { 155 const req = http.request(options, (res) => {
132 let body = ''; 156 let body = '';
133 res.on('data', (chunk) => body += chunk); 157 res.on('data', (chunk) => body += chunk);
134 res.on('end', () => { 158 res.on('end', () => {
  159 + releaseSession();
135 if (res.statusCode >= 200 && res.statusCode < 300) { 160 if (res.statusCode >= 200 && res.statusCode < 300) {
136 - console.log(`[Heartbeat] 📨 ${agentId} 成功启动心跳推理会话 (${convId})`); 161 + console.log(`[Heartbeat] 📨 ${agentId} 成功完成心跳推理会话 (${convId})`);
137 } else { 162 } else {
138 console.warn(`[Heartbeat] ⚠️ ${agentId} 心跳触发异常: ${res.statusCode} ${body}`); 163 console.warn(`[Heartbeat] ⚠️ ${agentId} 心跳触发异常: ${res.statusCode} ${body}`);
139 } 164 }
@@ -141,6 +166,7 @@ class HeartbeatService { @@ -141,6 +166,7 @@ class HeartbeatService {
141 }); 166 });
142 167
143 req.on('error', (err) => { 168 req.on('error', (err) => {
  169 + releaseSession();
144 console.error(`[Heartbeat] ❌ ${agentId} 心跳请求失败: ${err.message}`); 170 console.error(`[Heartbeat] ❌ ${agentId} 心跳请求失败: ${err.message}`);
145 }); 171 });
146 172
server/utils/file-lock.js 0 → 100644
  1 +/**
  2 + * file-lock.js — 基于 .lock 文件的 Advisory Lock
  3 + *
  4 + * 用于保护模板关键文件(config.json, SKILL.md)的并发写入。
  5 + * 使用 O_EXCL 原子操作创建锁文件,自动检测和清理过期锁。
  6 + *
  7 + * 使用方式:
  8 + * const { withFileLock } = require('./utils/file-lock');
  9 + * await withFileLock('/path/to/config.json', async () => {
  10 + * // 安全写入 config.json
  11 + * });
  12 + */
  13 +
  14 +const fs = require('fs');
  15 +
  16 +const LOCK_TIMEOUT = 30_000; // 30s 锁超期
  17 +const RETRY_INTERVAL = 200; // 200ms 重试间隔
  18 +const MAX_WAIT = 10_000; // 最多等待 10s
  19 +
  20 +/**
  21 + * 在文件锁保护下执行 fn
  22 + * @param {string} filePath - 要保护的文件路径
  23 + * @param {Function} fn - 在锁保护下执行的异步函数
  24 + * @returns {Promise<*>} fn 的返回值
  25 + */
  26 +async function withFileLock(filePath, fn) {
  27 + const lockPath = filePath + '.lock';
  28 + const deadline = Date.now() + MAX_WAIT;
  29 +
  30 + // 自旋等待获取锁
  31 + while (Date.now() < deadline) {
  32 + try {
  33 + // O_EXCL: 如果文件已存在则失败(原子操作)
  34 + fs.writeFileSync(lockPath, JSON.stringify({
  35 + pid: process.pid,
  36 + ts: Date.now(),
  37 + file: filePath,
  38 + }), { flag: 'wx' });
  39 +
  40 + // 获取锁成功
  41 + try {
  42 + return await fn();
  43 + } finally {
  44 + try { fs.unlinkSync(lockPath); } catch {}
  45 + }
  46 + } catch (e) {
  47 + if (e.code === 'EEXIST') {
  48 + // 锁已存在 — 检查是否过期
  49 + try {
  50 + const lockInfo = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
  51 + if (Date.now() - lockInfo.ts > LOCK_TIMEOUT) {
  52 + // 过期锁,强制移除
  53 + console.warn(`[FileLock] ⚠️ 清理过期锁: ${lockPath} (age: ${((Date.now() - lockInfo.ts) / 1000).toFixed(0)}s)`);
  54 + try { fs.unlinkSync(lockPath); } catch {}
  55 + continue;
  56 + }
  57 + } catch {
  58 + // 锁文件被并发删除或损坏,重试
  59 + }
  60 +
  61 + await new Promise(r => setTimeout(r, RETRY_INTERVAL));
  62 + } else {
  63 + throw e;
  64 + }
  65 + }
  66 + }
  67 +
  68 + // 超时 — 强制移除锁文件并执行(降级策略,不死等)
  69 + console.error(`[FileLock] ❌ 获取文件锁超时 (${MAX_WAIT}ms): ${filePath},强制执行`);
  70 + try { fs.unlinkSync(lockPath); } catch {}
  71 + return await fn();
  72 +}
  73 +
  74 +module.exports = { withFileLock };