opencode-collaboration 2.1.0__py3-none-any.whl → 2.2.0.post1__py3-none-any.whl
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.
- opencode_collaboration-2.2.0.post1.dist-info/METADATA +136 -0
- {opencode_collaboration-2.1.0.dist-info → opencode_collaboration-2.2.0.post1.dist-info}/RECORD +10 -5
- src/core/agent_manager.py +553 -0
- src/core/meeting_manager.py +502 -0
- src/core/project_manager.py +549 -0
- src/core/resource_lock.py +468 -0
- src/core/story_manager.py +712 -0
- opencode_collaboration-2.1.0.dist-info/METADATA +0 -99
- {opencode_collaboration-2.1.0.dist-info → opencode_collaboration-2.2.0.post1.dist-info}/WHEEL +0 -0
- {opencode_collaboration-2.1.0.dist-info → opencode_collaboration-2.2.0.post1.dist-info}/entry_points.txt +0 -0
- {opencode_collaboration-2.1.0.dist-info → opencode_collaboration-2.2.0.post1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
"""资源锁模块 - v2.2.0 M2 资源锁管理
|
|
2
|
+
|
|
3
|
+
提供文件锁、目录锁、任务锁,防止并发冲突。
|
|
4
|
+
"""
|
|
5
|
+
import uuid
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
import yaml
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
from datetime import datetime, timedelta
|
|
15
|
+
from threading import Lock as ThreadLock
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LockType(Enum):
|
|
22
|
+
"""锁类型枚举。"""
|
|
23
|
+
FILE = "file"
|
|
24
|
+
DIRECTORY = "directory"
|
|
25
|
+
TASK = "task"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LockStatus(Enum):
|
|
29
|
+
"""锁状态枚举。"""
|
|
30
|
+
ACTIVE = "active"
|
|
31
|
+
EXPIRED = "expired"
|
|
32
|
+
RELEASED = "released"
|
|
33
|
+
FORCE_RELEASED = "force_released"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ResourceLock:
|
|
38
|
+
"""资源锁。"""
|
|
39
|
+
lock_id: str
|
|
40
|
+
lock_type: LockType
|
|
41
|
+
resource_path: str
|
|
42
|
+
holder: str
|
|
43
|
+
status: LockStatus = LockStatus.ACTIVE
|
|
44
|
+
timeout_minutes: int = 30
|
|
45
|
+
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
|
46
|
+
expires_at: str = field(default_factory=lambda: (datetime.now() + timedelta(minutes=30)).isoformat())
|
|
47
|
+
reason: str = ""
|
|
48
|
+
force_release_reason: Optional[str] = None
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
51
|
+
return {
|
|
52
|
+
"lock_id": self.lock_id,
|
|
53
|
+
"lock_type": self.lock_type.value,
|
|
54
|
+
"resource_path": self.resource_path,
|
|
55
|
+
"holder": self.holder,
|
|
56
|
+
"status": self.status.value,
|
|
57
|
+
"timeout_minutes": self.timeout_minutes,
|
|
58
|
+
"created_at": self.created_at,
|
|
59
|
+
"expires_at": self.expires_at,
|
|
60
|
+
"reason": self.reason,
|
|
61
|
+
"force_release_reason": self.force_release_reason
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_dict(cls, data: Dict[str, Any]) -> "ResourceLock":
|
|
66
|
+
return cls(
|
|
67
|
+
lock_id=data["lock_id"],
|
|
68
|
+
lock_type=LockType(data["lock_type"]),
|
|
69
|
+
resource_path=data["resource_path"],
|
|
70
|
+
holder=data["holder"],
|
|
71
|
+
status=LockStatus(data.get("status", "active")),
|
|
72
|
+
timeout_minutes=data.get("timeout_minutes", 30),
|
|
73
|
+
created_at=data.get("created_at", datetime.now().isoformat()),
|
|
74
|
+
expires_at=data.get("expires_at", (datetime.now() + timedelta(minutes=30)).isoformat()),
|
|
75
|
+
reason=data.get("reason", ""),
|
|
76
|
+
force_release_reason=data.get("force_release_reason")
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def is_expired(self) -> bool:
|
|
80
|
+
"""检查是否已过期。"""
|
|
81
|
+
try:
|
|
82
|
+
expires = datetime.fromisoformat(self.expires_at)
|
|
83
|
+
return datetime.now() > expires
|
|
84
|
+
except ValueError:
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
def minutes_remaining(self) -> float:
|
|
88
|
+
"""获取剩余时间(分钟)。"""
|
|
89
|
+
try:
|
|
90
|
+
expires = datetime.fromisoformat(self.expires_at)
|
|
91
|
+
remaining = expires - datetime.now()
|
|
92
|
+
return remaining.total_seconds() / 60
|
|
93
|
+
except ValueError:
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ResourceLockError(Exception):
|
|
98
|
+
"""资源锁异常基类。"""
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class LockNotFoundError(ResourceLockError):
|
|
103
|
+
"""锁未找到异常。"""
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class LockConflictError(ResourceLockError):
|
|
108
|
+
"""锁冲突异常。"""
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class LockTimeoutError(ResourceLockError):
|
|
113
|
+
"""锁超时异常。"""
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class PermissionDeniedError(ResourceLockError):
|
|
118
|
+
"""权限不足异常。"""
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class ResourceLockManager:
|
|
123
|
+
"""资源锁管理器。"""
|
|
124
|
+
|
|
125
|
+
DEFAULT_TIMEOUTS = {
|
|
126
|
+
LockType.FILE: 30,
|
|
127
|
+
LockType.DIRECTORY: 120,
|
|
128
|
+
LockType.TASK: 1440
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
def __init__(
|
|
132
|
+
self,
|
|
133
|
+
project_path: str,
|
|
134
|
+
locks_file: Optional[str] = None,
|
|
135
|
+
warning_before_minutes: int = 5
|
|
136
|
+
):
|
|
137
|
+
"""初始化资源锁管理器。
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
project_path: 项目路径
|
|
141
|
+
locks_file: 锁数据文件
|
|
142
|
+
warning_before_minutes: 超时前警告时间(分钟)
|
|
143
|
+
"""
|
|
144
|
+
self.project_path = Path(project_path)
|
|
145
|
+
self.locks_file = self.project_path / (locks_file or "locks.yaml")
|
|
146
|
+
self.warning_before_minutes = warning_before_minutes
|
|
147
|
+
self.locks: Dict[str, ResourceLock] = {}
|
|
148
|
+
self._lock = ThreadLock()
|
|
149
|
+
self._load_locks()
|
|
150
|
+
|
|
151
|
+
def _load_locks(self) -> None:
|
|
152
|
+
"""加载锁数据。"""
|
|
153
|
+
if self.locks_file.exists():
|
|
154
|
+
try:
|
|
155
|
+
with open(self.locks_file, 'r', encoding='utf-8') as f:
|
|
156
|
+
data = yaml.safe_load(f)
|
|
157
|
+
if data and "locks" in data:
|
|
158
|
+
for lock_data in data.get("locks", []):
|
|
159
|
+
lock = ResourceLock.from_dict(lock_data)
|
|
160
|
+
if lock.status == LockStatus.ACTIVE and not lock.is_expired():
|
|
161
|
+
self.locks[lock.lock_id] = lock
|
|
162
|
+
except Exception as e:
|
|
163
|
+
logger.warning(f"加载锁数据失败: {e}")
|
|
164
|
+
|
|
165
|
+
def _save_locks(self) -> None:
|
|
166
|
+
"""保存锁数据。"""
|
|
167
|
+
data = {
|
|
168
|
+
"locks": [lock.to_dict() for lock in self.locks.values()],
|
|
169
|
+
"updated_at": datetime.now().isoformat()
|
|
170
|
+
}
|
|
171
|
+
with open(self.locks_file, 'w', encoding='utf-8') as f:
|
|
172
|
+
yaml.dump(data, f, allow_unicode=True)
|
|
173
|
+
|
|
174
|
+
def acquire_lock(
|
|
175
|
+
self,
|
|
176
|
+
lock_type: LockType,
|
|
177
|
+
resource_path: str,
|
|
178
|
+
holder: str,
|
|
179
|
+
timeout_minutes: Optional[int] = None,
|
|
180
|
+
reason: str = ""
|
|
181
|
+
) -> ResourceLock:
|
|
182
|
+
"""获取锁。
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
lock_type: 锁类型
|
|
186
|
+
resource_path: 资源路径
|
|
187
|
+
holder: 持有者
|
|
188
|
+
timeout_minutes: 超时时间(分钟)
|
|
189
|
+
reason: 锁定原因
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
创建的锁
|
|
193
|
+
|
|
194
|
+
Raises:
|
|
195
|
+
LockConflictError: 资源已被锁定
|
|
196
|
+
"""
|
|
197
|
+
with self._lock:
|
|
198
|
+
existing_lock = self._find_active_lock(lock_type, resource_path)
|
|
199
|
+
if existing_lock:
|
|
200
|
+
if existing_lock.holder == holder:
|
|
201
|
+
return existing_lock
|
|
202
|
+
raise LockConflictError(
|
|
203
|
+
f"资源已被锁定: {resource_path} (持有者: {existing_lock.holder})"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
timeout = timeout_minutes or self.DEFAULT_TIMEOUTS[lock_type]
|
|
207
|
+
lock_id = f"LOCK-{uuid.uuid4().hex[:8]}"
|
|
208
|
+
|
|
209
|
+
lock = ResourceLock(
|
|
210
|
+
lock_id=lock_id,
|
|
211
|
+
lock_type=lock_type,
|
|
212
|
+
resource_path=resource_path,
|
|
213
|
+
holder=holder,
|
|
214
|
+
timeout_minutes=timeout,
|
|
215
|
+
reason=reason
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
self.locks[lock_id] = lock
|
|
219
|
+
self._save_locks()
|
|
220
|
+
logger.info(f"获取锁: {lock_id} - {resource_path} by {holder}")
|
|
221
|
+
return lock
|
|
222
|
+
|
|
223
|
+
def release_lock(self, lock_id: str, holder: str) -> bool:
|
|
224
|
+
"""释放锁。
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
lock_id: 锁 ID
|
|
228
|
+
holder: 释放者
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
是否成功释放
|
|
232
|
+
|
|
233
|
+
Raises:
|
|
234
|
+
LockNotFoundError: 锁未找到
|
|
235
|
+
PermissionDeniedError: 无权释放
|
|
236
|
+
"""
|
|
237
|
+
with self._lock:
|
|
238
|
+
if lock_id not in self.locks:
|
|
239
|
+
raise LockNotFoundError(f"锁未找到: {lock_id}")
|
|
240
|
+
|
|
241
|
+
lock = self.locks[lock_id]
|
|
242
|
+
if lock.holder != holder:
|
|
243
|
+
raise PermissionDeniedError(
|
|
244
|
+
f"无权释放锁: {lock_id} (持有者: {lock.holder})"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
lock.status = LockStatus.RELEASED
|
|
248
|
+
del self.locks[lock_id]
|
|
249
|
+
self._save_locks()
|
|
250
|
+
logger.info(f"释放锁: {lock_id}")
|
|
251
|
+
return True
|
|
252
|
+
|
|
253
|
+
def force_release_lock(
|
|
254
|
+
self,
|
|
255
|
+
lock_id: str,
|
|
256
|
+
reason: str,
|
|
257
|
+
requester: str
|
|
258
|
+
) -> bool:
|
|
259
|
+
"""强制释放锁(仅 Agent 1 可用)。
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
lock_id: 锁 ID
|
|
263
|
+
reason: 强制释放原因
|
|
264
|
+
requester: 请求者
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
是否成功释放
|
|
268
|
+
|
|
269
|
+
Raises:
|
|
270
|
+
LockNotFoundError: 锁未找到
|
|
271
|
+
"""
|
|
272
|
+
with self._lock:
|
|
273
|
+
if lock_id not in self.locks:
|
|
274
|
+
raise LockNotFoundError(f"锁未找到: {lock_id}")
|
|
275
|
+
|
|
276
|
+
lock = self.locks[lock_id]
|
|
277
|
+
lock.status = LockStatus.FORCE_RELEASED
|
|
278
|
+
lock.force_release_reason = f"{reason} (by {requester})"
|
|
279
|
+
del self.locks[lock_id]
|
|
280
|
+
self._save_locks()
|
|
281
|
+
logger.info(f"强制释放锁: {lock_id} - {reason}")
|
|
282
|
+
return True
|
|
283
|
+
|
|
284
|
+
def get_lock(self, lock_id: str) -> ResourceLock:
|
|
285
|
+
"""获取锁信息。
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
lock_id: 锁 ID
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
锁信息
|
|
292
|
+
|
|
293
|
+
Raises:
|
|
294
|
+
LockNotFoundError: 锁未找到
|
|
295
|
+
"""
|
|
296
|
+
if lock_id not in self.locks:
|
|
297
|
+
raise LockNotFoundError(f"锁未找到: {lock_id}")
|
|
298
|
+
return self.locks[lock_id]
|
|
299
|
+
|
|
300
|
+
def get_lock_by_resource(
|
|
301
|
+
self,
|
|
302
|
+
lock_type: LockType,
|
|
303
|
+
resource_path: str
|
|
304
|
+
) -> Optional[ResourceLock]:
|
|
305
|
+
"""根据资源路径获取锁。
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
lock_type: 锁类型
|
|
309
|
+
resource_path: 资源路径
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
锁信息,未找到返回 None
|
|
313
|
+
"""
|
|
314
|
+
return self._find_active_lock(lock_type, resource_path)
|
|
315
|
+
|
|
316
|
+
def _find_active_lock(
|
|
317
|
+
self,
|
|
318
|
+
lock_type: LockType,
|
|
319
|
+
resource_path: str
|
|
320
|
+
) -> Optional[ResourceLock]:
|
|
321
|
+
"""查找活跃锁。"""
|
|
322
|
+
for lock in self.locks.values():
|
|
323
|
+
if lock.lock_type == lock_type and lock.resource_path == resource_path:
|
|
324
|
+
if not lock.is_expired():
|
|
325
|
+
return lock
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
def list_locks(
|
|
329
|
+
self,
|
|
330
|
+
holder: Optional[str] = None,
|
|
331
|
+
lock_type: Optional[LockType] = None,
|
|
332
|
+
status: Optional[LockStatus] = None
|
|
333
|
+
) -> List[ResourceLock]:
|
|
334
|
+
"""列出锁。
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
holder: 持有者过滤
|
|
338
|
+
lock_type: 锁类型过滤
|
|
339
|
+
status: 状态过滤
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
锁列表
|
|
343
|
+
"""
|
|
344
|
+
locks = list(self.locks.values())
|
|
345
|
+
|
|
346
|
+
if holder:
|
|
347
|
+
locks = [l for l in locks if l.holder == holder]
|
|
348
|
+
if lock_type:
|
|
349
|
+
locks = [l for l in locks if l.lock_type == lock_type]
|
|
350
|
+
if status:
|
|
351
|
+
locks = [l for l in locks if l.status == status]
|
|
352
|
+
|
|
353
|
+
return sorted(locks, key=lambda l: l.created_at, reverse=True)
|
|
354
|
+
|
|
355
|
+
def check_timeouts(self) -> List[ResourceLock]:
|
|
356
|
+
"""检查超时锁。
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
超时的锁列表
|
|
360
|
+
"""
|
|
361
|
+
expired_locks = []
|
|
362
|
+
for lock in self.locks.values():
|
|
363
|
+
if lock.is_expired():
|
|
364
|
+
expired_locks.append(lock)
|
|
365
|
+
return expired_locks
|
|
366
|
+
|
|
367
|
+
def get_warnings(self) -> List[ResourceLock]:
|
|
368
|
+
"""获取即将超时的锁警告。
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
即将超时的锁列表
|
|
372
|
+
"""
|
|
373
|
+
warnings = []
|
|
374
|
+
for lock in self.locks.values():
|
|
375
|
+
remaining = lock.minutes_remaining()
|
|
376
|
+
if 0 < remaining <= self.warning_before_minutes:
|
|
377
|
+
warnings.append(lock)
|
|
378
|
+
return warnings
|
|
379
|
+
|
|
380
|
+
def auto_expire_locks(self) -> List[str]:
|
|
381
|
+
"""自动过期处理超时的锁。
|
|
382
|
+
|
|
383
|
+
Returns:
|
|
384
|
+
过期的锁 ID 列表
|
|
385
|
+
"""
|
|
386
|
+
expired_ids = []
|
|
387
|
+
for lock_id in list(self.locks.keys()):
|
|
388
|
+
lock = self.locks[lock_id]
|
|
389
|
+
if lock.is_expired():
|
|
390
|
+
lock.status = LockStatus.EXPIRED
|
|
391
|
+
expired_ids.append(lock_id)
|
|
392
|
+
logger.warning(f"锁已过期自动释放: {lock_id} - {lock.resource_path}")
|
|
393
|
+
|
|
394
|
+
for lock_id in expired_ids:
|
|
395
|
+
del self.locks[lock_id]
|
|
396
|
+
|
|
397
|
+
if expired_ids:
|
|
398
|
+
self._save_locks()
|
|
399
|
+
|
|
400
|
+
return expired_ids
|
|
401
|
+
|
|
402
|
+
def get_lock_summary(self) -> Dict[str, Any]:
|
|
403
|
+
"""获取锁管理摘要。
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
摘要信息
|
|
407
|
+
"""
|
|
408
|
+
active_locks = self.list_locks(status=LockStatus.ACTIVE)
|
|
409
|
+
warnings = self.get_warnings()
|
|
410
|
+
|
|
411
|
+
by_type = {}
|
|
412
|
+
for lock_type in LockType:
|
|
413
|
+
by_type[lock_type.value] = len([
|
|
414
|
+
l for l in active_locks if l.lock_type == lock_type
|
|
415
|
+
])
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
"total_active_locks": len(active_locks),
|
|
419
|
+
"by_type": by_type,
|
|
420
|
+
"warnings": len(warnings),
|
|
421
|
+
"locks_file": str(self.locks_file)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
def export_locks(self, output_path: Optional[str] = None) -> Dict[str, Any]:
|
|
425
|
+
"""导出锁数据。
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
output_path: 输出路径(可选)
|
|
429
|
+
|
|
430
|
+
Returns:
|
|
431
|
+
锁数据字典
|
|
432
|
+
"""
|
|
433
|
+
data = {
|
|
434
|
+
"active_locks": [lock.to_dict() for lock in self.locks.values()],
|
|
435
|
+
"summary": self.get_lock_summary(),
|
|
436
|
+
"exported_at": datetime.now().isoformat()
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if output_path:
|
|
440
|
+
with open(output_path, 'w', encoding='utf-8') as f:
|
|
441
|
+
yaml.dump(data, f, allow_unicode=True)
|
|
442
|
+
|
|
443
|
+
return data
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
if __name__ == "__main__":
|
|
447
|
+
import tempfile
|
|
448
|
+
|
|
449
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
450
|
+
manager = ResourceLockManager(tmpdir)
|
|
451
|
+
|
|
452
|
+
lock1 = manager.acquire_lock(
|
|
453
|
+
lock_type=LockType.FILE,
|
|
454
|
+
resource_path="src/components/Header.tsx",
|
|
455
|
+
holder="agent_frontend_react",
|
|
456
|
+
timeout_minutes=30,
|
|
457
|
+
reason="实现头部组件"
|
|
458
|
+
)
|
|
459
|
+
print(f"获取锁: {lock1.lock_id}")
|
|
460
|
+
|
|
461
|
+
summary = manager.get_lock_summary()
|
|
462
|
+
print(f"锁摘要: {summary}")
|
|
463
|
+
|
|
464
|
+
remaining = lock1.minutes_remaining()
|
|
465
|
+
print(f"剩余时间: {remaining:.1f} 分钟")
|
|
466
|
+
|
|
467
|
+
manager.release_lock(lock1.lock_id, "agent_frontend_react")
|
|
468
|
+
print(f"释放锁: {lock1.lock_id}")
|