Commit dae7e90a990bbe98e5565cbedd27ddd768154dea
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.
Showing
4 changed files
with
129 additions
and
3 deletions
.gitignore
server/routes/agent-chat.js
| ... | ... | @@ -2178,6 +2178,11 @@ router.get('/:agentId/uploads/:convId/:filename', (req, res) => { |
| 2178 | 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 | 2186 | router.post('/:agentId/chat', async (req, res) => { |
| 2182 | 2187 | const { message, conversationId: reqConvId } = req.body; |
| 2183 | 2188 | if (!message) return res.status(400).json({ error: 'message is required' }); |
| ... | ... | @@ -2187,6 +2192,25 @@ router.post('/:agentId/chat', async (req, res) => { |
| 2187 | 2192 | if (!fs.existsSync(agentDir)) return res.status(404).json({ error: 'Agent not found' }); |
| 2188 | 2193 | |
| 2189 | 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 | 2214 | const msgId = `msg-${Date.now()}-${randomUUID().substring(0, 8)}`; |
| 2191 | 2215 | const sendSSE = setupSSE(res); |
| 2192 | 2216 | const identity = loadIdentity(agentDir); |
| ... | ... | @@ -2492,6 +2516,7 @@ ${resultsText.join('\n\n')} |
| 2492 | 2516 | sendSSE('error', { message: err.message }); |
| 2493 | 2517 | console.log(`[SSE-DEBUG] 🚨 sendSSE('error') called:`, err.message.substring(0, 200)); |
| 2494 | 2518 | } finally { |
| 2519 | + agentChatLocks.delete(agentId); | |
| 2495 | 2520 | res.end(); |
| 2496 | 2521 | } |
| 2497 | 2522 | }); | ... | ... |
server/services/heartbeat-service.js
| ... | ... | @@ -14,7 +14,8 @@ function getAgentDir(id) { |
| 14 | 14 | |
| 15 | 15 | class HeartbeatService { |
| 16 | 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 | 19 | this.port = config.port || 3000; |
| 19 | 20 | } |
| 20 | 21 | |
| ... | ... | @@ -97,17 +98,35 @@ class HeartbeatService { |
| 97 | 98 | * 内部方法:向 Agent 的 Chat API 发送一次隐式的系统心跳 |
| 98 | 99 | */ |
| 99 | 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 | 115 | const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19); |
| 101 | 116 | console.log(`[Heartbeat] 💓 触发 ${agentId} 主动心跳 (${timestamp})...`); |
| 102 | 117 | |
| 103 | 118 | const convId = `heartbeat-${Date.now().toString(36)}`; |
| 119 | + | |
| 120 | + // 🔒 注册活跃会话 | |
| 121 | + this.activeSessions.set(agentId, { convId, startedAt: Date.now() }); | |
| 122 | + | |
| 104 | 123 | const heartbeatMessage = [ |
| 105 | 124 | `[SYSTEM HEARTBEAT]`, |
| 106 | 125 | `当前系统时间:${timestamp}`, |
| 107 | 126 | `这是一个后台周期的主动心跳触发。`, |
| 108 | 127 | `要求:请根据你的身份和 heartbeat.md 规则,扫描当前最新的全局 OKR 目标状态和节点。`, |
| 109 | 128 | `1. 如果发现某个负责人超时、卡点或产出需要仲裁,请主动使用 dispatch 工具给他们发送通知或驳回。`, |
| 110 | - `2. 如果一切正常,无事发生,请直接回复“[心跳正常]”并且不要使用任何工具。`, | |
| 129 | + `2. 如果一切正常,无事发生,请直接回复"[心跳正常]"并且不要使用任何工具。`, | |
| 111 | 130 | `无需等待人类确认,请完全自主决策。` |
| 112 | 131 | ].join('\n'); |
| 113 | 132 | |
| ... | ... | @@ -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 | 155 | const req = http.request(options, (res) => { |
| 132 | 156 | let body = ''; |
| 133 | 157 | res.on('data', (chunk) => body += chunk); |
| 134 | 158 | res.on('end', () => { |
| 159 | + releaseSession(); | |
| 135 | 160 | if (res.statusCode >= 200 && res.statusCode < 300) { |
| 136 | - console.log(`[Heartbeat] 📨 ${agentId} 成功启动心跳推理会话 (${convId})`); | |
| 161 | + console.log(`[Heartbeat] 📨 ${agentId} 成功完成心跳推理会话 (${convId})`); | |
| 137 | 162 | } else { |
| 138 | 163 | console.warn(`[Heartbeat] ⚠️ ${agentId} 心跳触发异常: ${res.statusCode} ${body}`); |
| 139 | 164 | } |
| ... | ... | @@ -141,6 +166,7 @@ class HeartbeatService { |
| 141 | 166 | }); |
| 142 | 167 | |
| 143 | 168 | req.on('error', (err) => { |
| 169 | + releaseSession(); | |
| 144 | 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 }; | ... | ... |