mosscode 0.1.0__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.
- mosscode/__init__.py +10 -0
- mosscode/__main__.py +3 -0
- mosscode/agent.py +486 -0
- mosscode/agents/__init__.py +249 -0
- mosscode/agents/builtin/explore.md +16 -0
- mosscode/agents/builtin/general.md +12 -0
- mosscode/agents/builtin/plan.md +16 -0
- mosscode/agents/runtime.py +685 -0
- mosscode/agents/tasks.py +193 -0
- mosscode/appearance.py +184 -0
- mosscode/builtin_commands.py +1363 -0
- mosscode/cli.py +547 -0
- mosscode/commands.py +254 -0
- mosscode/compaction.py +259 -0
- mosscode/config.py +337 -0
- mosscode/context.py +102 -0
- mosscode/conversation.py +1046 -0
- mosscode/coordinator.py +54 -0
- mosscode/doctor.py +303 -0
- mosscode/hooks/__init__.py +22 -0
- mosscode/hooks/actions.py +151 -0
- mosscode/hooks/conditions.py +170 -0
- mosscode/hooks/config.py +404 -0
- mosscode/hooks/engine.py +401 -0
- mosscode/hooks/events.py +48 -0
- mosscode/launcher.py +177 -0
- mosscode/mcp_client.py +460 -0
- mosscode/mcp_config.py +216 -0
- mosscode/member_ui.py +289 -0
- mosscode/memory.py +291 -0
- mosscode/memory_extract.py +245 -0
- mosscode/messages.py +216 -0
- mosscode/paths.py +31 -0
- mosscode/permissions.py +733 -0
- mosscode/prompts.py +483 -0
- mosscode/providers.py +587 -0
- mosscode/scrollback.py +106 -0
- mosscode/security.py +282 -0
- mosscode/serialization.py +121 -0
- mosscode/session.py +563 -0
- mosscode/setup.py +248 -0
- mosscode/skills/__init__.py +455 -0
- mosscode/skills/builtin/commit/SKILL.md +15 -0
- mosscode/skills/builtin/review/SKILL.md +15 -0
- mosscode/skills/builtin/test/SKILL.md +13 -0
- mosscode/skills/builtin/test/scripts/detect.py +96 -0
- mosscode/skills/builtin/test/tool.json +9 -0
- mosscode/teams/__init__.py +216 -0
- mosscode/teams/backends.py +259 -0
- mosscode/teams/board.py +230 -0
- mosscode/teams/lock.py +173 -0
- mosscode/teams/mailbox.py +172 -0
- mosscode/teams/member.py +427 -0
- mosscode/teams/merge.py +207 -0
- mosscode/teams/pane.py +20 -0
- mosscode/teams/pane_app.py +167 -0
- mosscode/teams/process.py +371 -0
- mosscode/teams/runtime.py +1355 -0
- mosscode/teams/view.py +182 -0
- mosscode/tool_budget.py +86 -0
- mosscode/tools/__init__.py +38 -0
- mosscode/tools/agent.py +127 -0
- mosscode/tools/base.py +384 -0
- mosscode/tools/diffing.py +16 -0
- mosscode/tools/execution.py +99 -0
- mosscode/tools/files.py +287 -0
- mosscode/tools/memory.py +65 -0
- mosscode/tools/plan.py +30 -0
- mosscode/tools/process.py +216 -0
- mosscode/tools/search.py +229 -0
- mosscode/tools/search_worker.py +243 -0
- mosscode/tools/shell.py +121 -0
- mosscode/tools/skill.py +118 -0
- mosscode/tools/team.py +447 -0
- mosscode/tools/windows_job.py +75 -0
- mosscode/tui.py +2325 -0
- mosscode/usage.py +68 -0
- mosscode/windows_inline.py +148 -0
- mosscode/worktree/__init__.py +29 -0
- mosscode/worktree/changes.py +59 -0
- mosscode/worktree/git.py +125 -0
- mosscode/worktree/manager.py +462 -0
- mosscode/worktree/meta.py +82 -0
- mosscode/worktree/names.py +52 -0
- mosscode/worktree/setup.py +119 -0
- mosscode-0.1.0.dist-info/METADATA +92 -0
- mosscode-0.1.0.dist-info/RECORD +91 -0
- mosscode-0.1.0.dist-info/WHEEL +5 -0
- mosscode-0.1.0.dist-info/entry_points.txt +3 -0
- mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
- mosscode-0.1.0.dist-info/top_level.txt +1 -0
mosscode/teams/board.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""The team's shared task list, tasks.json under its lock.
|
|
2
|
+
|
|
3
|
+
Not agents/tasks.py: those are the one-shot sub-agents of a session (/tasks, task_output).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable, Iterable
|
|
9
|
+
from dataclasses import asdict, dataclass, field, fields
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .lock import TeamError, atomic_write, file_lock, load_json, now
|
|
13
|
+
from .mailbox import LEAD
|
|
14
|
+
|
|
15
|
+
STATUSES = ("pending", "in_progress", "completed")
|
|
16
|
+
STATUS_LABELS = {"pending": "待办", "in_progress": "进行中", "completed": "已完成"}
|
|
17
|
+
TASK_LIMIT = 200
|
|
18
|
+
SUBJECT_LIMIT = 120
|
|
19
|
+
DESCRIPTION_LIMIT = 20000
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class TeamTask:
|
|
24
|
+
id: str
|
|
25
|
+
subject: str
|
|
26
|
+
description: str = ""
|
|
27
|
+
status: str = "pending"
|
|
28
|
+
owner: str = ""
|
|
29
|
+
blocked_by: list[str] = field(default_factory=list)
|
|
30
|
+
created: str = field(default_factory=now)
|
|
31
|
+
updated: str = field(default_factory=now)
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_dict(cls, data: dict) -> TeamTask:
|
|
35
|
+
known = {item.name for item in fields(cls)}
|
|
36
|
+
return cls(**{key: value for key, value in data.items() if key in known})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Updated:
|
|
41
|
+
task: TeamTask
|
|
42
|
+
ready: tuple[TeamTask, ...] = () # Pending tasks this update made ready.
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _valid(data) -> bool:
|
|
46
|
+
return (
|
|
47
|
+
isinstance(data, dict)
|
|
48
|
+
and isinstance(data.get("tasks"), list)
|
|
49
|
+
and all(isinstance(item, dict) and isinstance(item.get("id"), str) for item in data["tasks"])
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_ready(task: TeamTask, tasks: dict[str, TeamTask]) -> bool:
|
|
54
|
+
"""Every task it waits for is completed."""
|
|
55
|
+
return all(tasks[dep].status == "completed" for dep in task.blocked_by if dep in tasks)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def blocks(task_id: str, tasks: Iterable[TeamTask]) -> list[str]:
|
|
59
|
+
"""Derived, never stored."""
|
|
60
|
+
return [task.id for task in tasks if task_id in task.blocked_by]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _reaches(start: str, target: str, tasks: dict[str, TeamTask]) -> bool:
|
|
64
|
+
"""Whether start waits, directly or not, for target."""
|
|
65
|
+
seen, stack = set(), [start]
|
|
66
|
+
while stack:
|
|
67
|
+
current = stack.pop()
|
|
68
|
+
if current == target:
|
|
69
|
+
return True
|
|
70
|
+
if current in seen or current not in tasks:
|
|
71
|
+
continue
|
|
72
|
+
seen.add(current)
|
|
73
|
+
stack.extend(tasks[current].blocked_by)
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _text(value: str, name: str, limit: int, *, required: bool = False) -> str:
|
|
78
|
+
value = value.strip()
|
|
79
|
+
if required and not value:
|
|
80
|
+
raise TeamError("invalid_task", f"{name} 不能为空。")
|
|
81
|
+
if len(value) > limit:
|
|
82
|
+
raise TeamError("invalid_task", f"{name} 最多 {limit} 字。")
|
|
83
|
+
return value
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TaskBoard:
|
|
87
|
+
def __init__(self, path: Path, on_notice: Callable[[str], None] | None = None):
|
|
88
|
+
self.path = path
|
|
89
|
+
self.on_notice = on_notice
|
|
90
|
+
|
|
91
|
+
def _load(self) -> dict[str, TeamTask]:
|
|
92
|
+
data = load_json(self.path, lambda: {"v": 1, "tasks": []}, _valid, self.on_notice)
|
|
93
|
+
tasks = {}
|
|
94
|
+
for item in data["tasks"]:
|
|
95
|
+
try:
|
|
96
|
+
task = TeamTask.from_dict(item)
|
|
97
|
+
except TypeError:
|
|
98
|
+
continue
|
|
99
|
+
tasks[task.id] = task
|
|
100
|
+
return tasks
|
|
101
|
+
|
|
102
|
+
def _save(self, tasks: dict[str, TeamTask]) -> None:
|
|
103
|
+
atomic_write(self.path, {"v": 1, "tasks": [asdict(task) for task in tasks.values()]})
|
|
104
|
+
|
|
105
|
+
def list(self) -> list[TeamTask]:
|
|
106
|
+
return list(self._load().values())
|
|
107
|
+
|
|
108
|
+
def get(self, task_id: str) -> TeamTask:
|
|
109
|
+
task = self._load().get(task_id)
|
|
110
|
+
if task is None:
|
|
111
|
+
raise TeamError("unknown_task", f"没有编号为 {task_id} 的任务。")
|
|
112
|
+
return task
|
|
113
|
+
|
|
114
|
+
def create(
|
|
115
|
+
self,
|
|
116
|
+
subject: str,
|
|
117
|
+
description: str = "",
|
|
118
|
+
blocked_by: Iterable[str] = (),
|
|
119
|
+
*,
|
|
120
|
+
owner: str = "",
|
|
121
|
+
actor: str = LEAD,
|
|
122
|
+
participants: Iterable[str] = (),
|
|
123
|
+
) -> TeamTask:
|
|
124
|
+
subject = _text(subject, "subject", SUBJECT_LIMIT, required=True)
|
|
125
|
+
description = _text(description, "description", DESCRIPTION_LIMIT)
|
|
126
|
+
with file_lock(self.path):
|
|
127
|
+
tasks = self._load()
|
|
128
|
+
if len(tasks) >= TASK_LIMIT:
|
|
129
|
+
raise TeamError("too_many_tasks", f"一个团队最多 {TASK_LIMIT} 个任务。")
|
|
130
|
+
deps = self._deps(blocked_by, tasks)
|
|
131
|
+
owner = self._owner(owner, actor, participants)
|
|
132
|
+
number = max((int(key) for key in tasks if key.isdigit()), default=0) + 1
|
|
133
|
+
task = TeamTask(str(number), subject, description, owner=owner, blocked_by=deps)
|
|
134
|
+
tasks[task.id] = task
|
|
135
|
+
self._save(tasks)
|
|
136
|
+
return task
|
|
137
|
+
|
|
138
|
+
def update(
|
|
139
|
+
self,
|
|
140
|
+
task_id: str,
|
|
141
|
+
*,
|
|
142
|
+
actor: str,
|
|
143
|
+
participants: Iterable[str] = (),
|
|
144
|
+
status: str | None = None,
|
|
145
|
+
owner: str | None = None,
|
|
146
|
+
subject: str | None = None,
|
|
147
|
+
description: str | None = None,
|
|
148
|
+
add_blocked_by: Iterable[str] = (),
|
|
149
|
+
remove_blocked_by: Iterable[str] = (),
|
|
150
|
+
) -> Updated:
|
|
151
|
+
"""The result lists the tasks that became ready."""
|
|
152
|
+
add_blocked_by, remove_blocked_by = list(add_blocked_by), list(remove_blocked_by)
|
|
153
|
+
with file_lock(self.path):
|
|
154
|
+
tasks = self._load()
|
|
155
|
+
task = tasks.get(task_id)
|
|
156
|
+
if task is None:
|
|
157
|
+
raise TeamError("unknown_task", f"没有编号为 {task_id} 的任务。")
|
|
158
|
+
lead = actor == LEAD
|
|
159
|
+
if not lead and task.owner not in {"", actor}:
|
|
160
|
+
raise TeamError("not_owner", f"任务 {task_id} 归 {task.owner} 负责,只有负责人或 Lead 能修改。")
|
|
161
|
+
if remove_blocked_by and not lead:
|
|
162
|
+
raise TeamError("lead_only", "只有 Lead 能移除依赖。")
|
|
163
|
+
before = {key: is_ready(item, tasks) for key, item in tasks.items() if item.status == "pending"}
|
|
164
|
+
if subject is not None:
|
|
165
|
+
task.subject = _text(subject, "subject", SUBJECT_LIMIT, required=True)
|
|
166
|
+
if description is not None:
|
|
167
|
+
task.description = _text(description, "description", DESCRIPTION_LIMIT)
|
|
168
|
+
if owner is not None:
|
|
169
|
+
task.owner = self._owner(owner, actor, participants)
|
|
170
|
+
if remove_blocked_by:
|
|
171
|
+
task.blocked_by = [dep for dep in task.blocked_by if dep not in remove_blocked_by]
|
|
172
|
+
for dep in self._deps(add_blocked_by, tasks):
|
|
173
|
+
if dep == task.id or _reaches(dep, task.id, tasks):
|
|
174
|
+
raise TeamError("dependency_cycle", f"任务 {task.id} 依赖 {dep} 会形成循环依赖。")
|
|
175
|
+
if dep not in task.blocked_by:
|
|
176
|
+
task.blocked_by.append(dep)
|
|
177
|
+
if status is not None:
|
|
178
|
+
self._move(task, status, actor, tasks)
|
|
179
|
+
task.updated = now()
|
|
180
|
+
self._save(tasks)
|
|
181
|
+
ready = tuple(
|
|
182
|
+
tasks[key]
|
|
183
|
+
for key, was in before.items()
|
|
184
|
+
if not was and tasks[key].status == "pending" and is_ready(tasks[key], tasks)
|
|
185
|
+
)
|
|
186
|
+
return Updated(task, ready)
|
|
187
|
+
|
|
188
|
+
@staticmethod
|
|
189
|
+
def _deps(blocked_by: Iterable[str], tasks: dict[str, TeamTask]) -> list[str]:
|
|
190
|
+
deps = []
|
|
191
|
+
for dep in blocked_by:
|
|
192
|
+
dep = str(dep).strip()
|
|
193
|
+
if dep not in tasks:
|
|
194
|
+
raise TeamError("unknown_task", f"依赖的任务 {dep} 不存在。")
|
|
195
|
+
if dep not in deps:
|
|
196
|
+
deps.append(dep)
|
|
197
|
+
return deps
|
|
198
|
+
|
|
199
|
+
@staticmethod
|
|
200
|
+
def _owner(owner: str, actor: str, participants: Iterable[str]) -> str:
|
|
201
|
+
"""A member takes tasks for itself only; the lead assigns anyone on the roster."""
|
|
202
|
+
owner = owner.strip()
|
|
203
|
+
if not owner:
|
|
204
|
+
if actor != LEAD:
|
|
205
|
+
raise TeamError("lead_only", "只有 Lead 能清除负责人。")
|
|
206
|
+
return ""
|
|
207
|
+
if actor != LEAD and owner != actor:
|
|
208
|
+
raise TeamError("not_owner", "成员只能把任务领给自己。")
|
|
209
|
+
if owner != LEAD and owner not in set(participants):
|
|
210
|
+
raise TeamError("unknown_member", f"团队中没有成员 {owner};先用 team_spawn 创建它,或先不填负责人。")
|
|
211
|
+
return owner
|
|
212
|
+
|
|
213
|
+
@staticmethod
|
|
214
|
+
def _move(task: TeamTask, status: str, actor: str, tasks: dict[str, TeamTask]) -> None:
|
|
215
|
+
"""Forward only, and only once ready; the lead may send a task back to pending."""
|
|
216
|
+
if status not in STATUSES:
|
|
217
|
+
raise TeamError("invalid_task", f"状态只能是 {'、'.join(STATUSES)}。")
|
|
218
|
+
old, new = STATUSES.index(task.status), STATUSES.index(status)
|
|
219
|
+
if new < old:
|
|
220
|
+
if actor != LEAD or status != "pending":
|
|
221
|
+
raise TeamError("invalid_transition", "状态只能前进;只有 Lead 能把任务改回 pending。")
|
|
222
|
+
elif new > old:
|
|
223
|
+
if not is_ready(task, tasks):
|
|
224
|
+
waiting = [dep for dep in task.blocked_by if dep in tasks and tasks[dep].status != "completed"]
|
|
225
|
+
raise TeamError("task_blocked", f"任务 {task.id} 还在等待任务 {'、'.join(waiting)} 完成。")
|
|
226
|
+
if not task.owner:
|
|
227
|
+
if actor == LEAD:
|
|
228
|
+
raise TeamError("no_owner", f"任务 {task.id} 没有负责人;先指定 owner。")
|
|
229
|
+
task.owner = actor
|
|
230
|
+
task.status = status
|
mosscode/teams/lock.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""A lock file next to each data file, and writes a reader never sees half done.
|
|
2
|
+
|
|
3
|
+
Every team file (config, tasks, mailboxes) is read, changed and written under its lock; the lock is
|
|
4
|
+
held only for the file work, never across a model call.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import random
|
|
12
|
+
import secrets
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from contextlib import contextmanager
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
LOCK_RETRIES = 10
|
|
21
|
+
BACKOFF = (0.005, 0.1) # Seconds, random within.
|
|
22
|
+
STALE_AFTER = 10.0 # Seconds; an older lock file belongs to a process that died holding it.
|
|
23
|
+
DEADLINE = 5.0 # Seconds of waiting in all, however often the lock changed hands.
|
|
24
|
+
REPLACE_RETRIES = 3
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TeamError(Exception):
|
|
28
|
+
"""A team operation that did not happen; code is the tool error code."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, code: str, message: str):
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
self.code = code
|
|
33
|
+
self.message = message
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def now() -> str:
|
|
37
|
+
return datetime.now().astimezone().isoformat(timespec="seconds")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _stale(lock: Path, after: float) -> bool:
|
|
41
|
+
try:
|
|
42
|
+
return time.time() - lock.stat().st_mtime > after
|
|
43
|
+
except OSError:
|
|
44
|
+
return False # Gone already: the next attempt will tell.
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _holder(lock: Path) -> str:
|
|
48
|
+
try:
|
|
49
|
+
return lock.read_text(encoding="utf-8")
|
|
50
|
+
except OSError:
|
|
51
|
+
return ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@contextmanager
|
|
55
|
+
def file_lock(
|
|
56
|
+
path: Path,
|
|
57
|
+
*,
|
|
58
|
+
busy: str = "team_busy",
|
|
59
|
+
retries: int = LOCK_RETRIES,
|
|
60
|
+
stale: float = STALE_AFTER,
|
|
61
|
+
deadline: float = DEADLINE,
|
|
62
|
+
):
|
|
63
|
+
"""O_CREAT|O_EXCL on <file>.lock; retries with random backoff, then TeamError(busy).
|
|
64
|
+
|
|
65
|
+
The count starts again whenever the lock changed hands (others are making progress, it is not stuck),
|
|
66
|
+
within an overall deadline; a lock held by one holder throughout fails after `retries`.
|
|
67
|
+
"""
|
|
68
|
+
lock = path.with_name(path.name + ".lock")
|
|
69
|
+
lock.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
token = f"{os.getpid()} {now()} {secrets.token_hex(4)}"
|
|
71
|
+
attempt, seen, start = 0, None, time.monotonic()
|
|
72
|
+
while True:
|
|
73
|
+
try:
|
|
74
|
+
fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
75
|
+
except (FileExistsError, PermissionError): # Windows: PermissionError while it is being deleted.
|
|
76
|
+
if _stale(lock, stale):
|
|
77
|
+
try:
|
|
78
|
+
os.remove(lock)
|
|
79
|
+
except OSError:
|
|
80
|
+
pass
|
|
81
|
+
holder = _holder(lock)
|
|
82
|
+
if holder and seen is not None and holder != seen:
|
|
83
|
+
attempt = 0
|
|
84
|
+
seen = holder or seen
|
|
85
|
+
if attempt >= retries or time.monotonic() - start > deadline:
|
|
86
|
+
raise TeamError(
|
|
87
|
+
busy, f"{path.name} 正被其他进程使用,重试 {retries} 次后仍拿不到锁;稍后再试。"
|
|
88
|
+
) from None
|
|
89
|
+
attempt += 1
|
|
90
|
+
time.sleep(random.uniform(*BACKOFF))
|
|
91
|
+
continue
|
|
92
|
+
try:
|
|
93
|
+
os.write(fd, token.encode("utf-8"))
|
|
94
|
+
finally:
|
|
95
|
+
os.close(fd)
|
|
96
|
+
break
|
|
97
|
+
try:
|
|
98
|
+
yield
|
|
99
|
+
finally:
|
|
100
|
+
_release(lock)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _release(lock: Path) -> None:
|
|
104
|
+
"""Windows refuses to delete a file another waiter has open for reading its holder: retry briefly,
|
|
105
|
+
or the lock would stay until it is stale."""
|
|
106
|
+
for _ in range(40):
|
|
107
|
+
try:
|
|
108
|
+
os.remove(lock)
|
|
109
|
+
return
|
|
110
|
+
except FileNotFoundError:
|
|
111
|
+
return
|
|
112
|
+
except PermissionError:
|
|
113
|
+
time.sleep(0.005)
|
|
114
|
+
except OSError:
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def atomic_write(path: Path, data: Any) -> None:
|
|
119
|
+
"""A temporary file in the same directory, then os.replace (retried briefly on Windows)."""
|
|
120
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
temporary = path.with_name(f"{path.name}.{os.getpid()}.tmp")
|
|
122
|
+
temporary.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
123
|
+
try:
|
|
124
|
+
for attempt in range(REPLACE_RETRIES + 1):
|
|
125
|
+
try:
|
|
126
|
+
os.replace(temporary, path)
|
|
127
|
+
return
|
|
128
|
+
except PermissionError: # Windows: another process has it open for a moment.
|
|
129
|
+
if attempt == REPLACE_RETRIES:
|
|
130
|
+
raise
|
|
131
|
+
time.sleep(0.02)
|
|
132
|
+
finally:
|
|
133
|
+
if temporary.exists():
|
|
134
|
+
try:
|
|
135
|
+
temporary.unlink()
|
|
136
|
+
except OSError:
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def read_text(path: Path) -> str:
|
|
141
|
+
"""Readers without the lock (/team, peek) can meet os.replace mid-way on Windows: retry briefly."""
|
|
142
|
+
for attempt in range(REPLACE_RETRIES + 1):
|
|
143
|
+
try:
|
|
144
|
+
return path.read_text(encoding="utf-8")
|
|
145
|
+
except PermissionError:
|
|
146
|
+
if attempt == REPLACE_RETRIES:
|
|
147
|
+
raise
|
|
148
|
+
time.sleep(0.02)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def load_json(
|
|
152
|
+
path: Path, default: Callable[[], Any], valid: Callable[[Any], bool], on_notice: Callable[[str], None] | None = None
|
|
153
|
+
) -> Any:
|
|
154
|
+
"""Missing -> default; unreadable or the wrong shape -> kept as <file>.corrupt-<time>, then default."""
|
|
155
|
+
try:
|
|
156
|
+
text = read_text(path)
|
|
157
|
+
except FileNotFoundError:
|
|
158
|
+
return default()
|
|
159
|
+
try:
|
|
160
|
+
data = json.loads(text)
|
|
161
|
+
if valid(data):
|
|
162
|
+
return data
|
|
163
|
+
except ValueError:
|
|
164
|
+
pass
|
|
165
|
+
kept = path.with_name(f"{path.name}.corrupt-{datetime.now().strftime('%Y%m%d-%H%M%S')}")
|
|
166
|
+
try:
|
|
167
|
+
os.replace(path, kept)
|
|
168
|
+
note = f"{path} 已损坏,原文件保留为 {kept.name},按空文件继续。"
|
|
169
|
+
except OSError as exc:
|
|
170
|
+
note = f"{path} 已损坏,且无法改名保留({exc}),按空文件继续。"
|
|
171
|
+
if on_notice is not None:
|
|
172
|
+
on_notice(note)
|
|
173
|
+
return default()
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""One JSON file of messages per participant, changed only under its lock."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import secrets
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .lock import TeamError, atomic_write, file_lock, load_json, now
|
|
11
|
+
|
|
12
|
+
LEAD = "lead"
|
|
13
|
+
BROADCAST = "*"
|
|
14
|
+
TYPES = (
|
|
15
|
+
"text",
|
|
16
|
+
"plan_approval_request",
|
|
17
|
+
"plan_approval_response",
|
|
18
|
+
"shutdown_request",
|
|
19
|
+
"shutdown_response",
|
|
20
|
+
"idle_notification",
|
|
21
|
+
"permission_response",
|
|
22
|
+
)
|
|
23
|
+
# permission_response: the user's answer, given in the lead's window, to a pane member's approval.
|
|
24
|
+
LEAD_ONLY = frozenset({"plan_approval_response", "shutdown_request", "permission_response"})
|
|
25
|
+
MEMBER_ONLY = frozenset({"shutdown_response", "plan_approval_request", "idle_notification"})
|
|
26
|
+
# Sent for the model, never by it.
|
|
27
|
+
RUNTIME_ONLY = frozenset({"plan_approval_request", "idle_notification", "permission_response"})
|
|
28
|
+
EXTRA_FIELDS = {
|
|
29
|
+
"plan_approval_request": ("request_id", "plan"),
|
|
30
|
+
"plan_approval_response": ("request_id", "approve", "feedback"),
|
|
31
|
+
"shutdown_request": ("reason",),
|
|
32
|
+
"shutdown_response": ("request_id", "approve", "reason"),
|
|
33
|
+
"idle_notification": ("reason", "completed_tasks"), # Its summary is the message's own.
|
|
34
|
+
"permission_response": ("request_id", "choice"),
|
|
35
|
+
}
|
|
36
|
+
BODY_LIMIT = 20000
|
|
37
|
+
SUMMARY_LIMIT = 80
|
|
38
|
+
KEEP_READ = 100
|
|
39
|
+
UNREAD_LIMIT = 500
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Message:
|
|
44
|
+
sender: str
|
|
45
|
+
type: str
|
|
46
|
+
body: str = ""
|
|
47
|
+
summary: str = ""
|
|
48
|
+
id: str = field(default_factory=lambda: f"m-{secrets.token_hex(4)}")
|
|
49
|
+
timestamp: str = field(default_factory=now)
|
|
50
|
+
read: bool = False
|
|
51
|
+
extra: dict = field(default_factory=dict) # The type's own fields.
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict:
|
|
54
|
+
return {
|
|
55
|
+
"id": self.id,
|
|
56
|
+
"from": self.sender,
|
|
57
|
+
"type": self.type,
|
|
58
|
+
"body": self.body,
|
|
59
|
+
"summary": self.summary,
|
|
60
|
+
"timestamp": self.timestamp,
|
|
61
|
+
"read": self.read,
|
|
62
|
+
**self.extra,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_dict(cls, data: dict) -> Message:
|
|
67
|
+
known = {"id", "from", "type", "body", "summary", "timestamp", "read"}
|
|
68
|
+
return cls(
|
|
69
|
+
sender=str(data.get("from", "")),
|
|
70
|
+
type=str(data.get("type", "text")),
|
|
71
|
+
body=str(data.get("body", "")),
|
|
72
|
+
summary=str(data.get("summary", "")),
|
|
73
|
+
id=str(data.get("id", "")),
|
|
74
|
+
timestamp=str(data.get("timestamp", "")),
|
|
75
|
+
read=bool(data.get("read", False)),
|
|
76
|
+
extra={key: value for key, value in data.items() if key not in known},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def check(message: Message) -> None:
|
|
81
|
+
"""What any message must satisfy, whoever sends it; TeamError otherwise."""
|
|
82
|
+
if message.type not in TYPES:
|
|
83
|
+
raise TeamError("invalid_message", f"未知的消息类型 {message.type};可用:{'、'.join(TYPES)}。")
|
|
84
|
+
if message.type in LEAD_ONLY and message.sender != LEAD:
|
|
85
|
+
raise TeamError("invalid_message", f"只有 Lead 能发送 {message.type}。")
|
|
86
|
+
if message.type in MEMBER_ONLY and message.sender == LEAD:
|
|
87
|
+
raise TeamError("invalid_message", f"Lead 不能发送 {message.type}。")
|
|
88
|
+
if message.type == "text":
|
|
89
|
+
if not message.summary.strip():
|
|
90
|
+
raise TeamError("invalid_message", "text 消息必须有 summary(一句话摘要)。")
|
|
91
|
+
if len(message.summary) > SUMMARY_LIMIT:
|
|
92
|
+
raise TeamError("invalid_message", f"summary 最多 {SUMMARY_LIMIT} 字。")
|
|
93
|
+
if len(message.body) > BODY_LIMIT:
|
|
94
|
+
raise TeamError("invalid_message", f"正文最多 {BODY_LIMIT} 字,当前 {len(message.body)} 字。")
|
|
95
|
+
missing = [name for name in EXTRA_FIELDS.get(message.type, ()) if name not in message.extra]
|
|
96
|
+
if missing:
|
|
97
|
+
raise TeamError("invalid_message", f"{message.type} 消息缺少字段:{'、'.join(missing)}。")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _valid(data) -> bool:
|
|
101
|
+
return isinstance(data, list) and all(isinstance(item, dict) for item in data)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _trim(messages: list[dict]) -> list[dict]:
|
|
105
|
+
"""Every unread message and the latest KEEP_READ read ones, in order."""
|
|
106
|
+
read = [index for index, item in enumerate(messages) if item.get("read")]
|
|
107
|
+
dropped = set(read[:-KEEP_READ]) if len(read) > KEEP_READ else set()
|
|
108
|
+
return [item for index, item in enumerate(messages) if index not in dropped]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Mailbox:
|
|
112
|
+
"""The mailbox directory of one team: mailbox/<name>.json for each participant, lead included."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, directory: Path, on_notice: Callable[[str], None] | None = None):
|
|
115
|
+
self.directory = directory
|
|
116
|
+
self.on_notice = on_notice
|
|
117
|
+
|
|
118
|
+
def path(self, name: str) -> Path:
|
|
119
|
+
return self.directory / f"{name}.json"
|
|
120
|
+
|
|
121
|
+
def _load(self, name: str) -> list[dict]:
|
|
122
|
+
return load_json(self.path(name), list, _valid, self.on_notice)
|
|
123
|
+
|
|
124
|
+
def send(self, to: str, message: Message) -> None:
|
|
125
|
+
"""Append under the recipient's lock; TeamError when busy or full, never a silent drop."""
|
|
126
|
+
check(message)
|
|
127
|
+
path = self.path(to)
|
|
128
|
+
with file_lock(path, busy="mailbox_busy"):
|
|
129
|
+
messages = self._load(to)
|
|
130
|
+
if sum(1 for item in messages if not item.get("read")) >= UNREAD_LIMIT:
|
|
131
|
+
raise TeamError("mailbox_full", f"{to} 的未读消息已达 {UNREAD_LIMIT} 条,暂不能再发送。")
|
|
132
|
+
messages.append(message.to_dict())
|
|
133
|
+
atomic_write(path, _trim(messages))
|
|
134
|
+
|
|
135
|
+
def read_unread(self, name: str) -> list[Message]:
|
|
136
|
+
"""The unread messages, marked read in the same locked step."""
|
|
137
|
+
path = self.path(name)
|
|
138
|
+
with file_lock(path, busy="mailbox_busy"):
|
|
139
|
+
messages = self._load(name)
|
|
140
|
+
unread = [item for item in messages if not item.get("read")]
|
|
141
|
+
if not unread:
|
|
142
|
+
return []
|
|
143
|
+
for item in unread:
|
|
144
|
+
item["read"] = True
|
|
145
|
+
atomic_write(path, _trim(messages))
|
|
146
|
+
return [Message.from_dict(item) for item in unread]
|
|
147
|
+
|
|
148
|
+
def take(self, name: str, kinds: frozenset[str] | set[str]) -> list[Message]:
|
|
149
|
+
"""The unread messages of these types only, marked read; the rest stay unread (answers mid-turn)."""
|
|
150
|
+
path = self.path(name)
|
|
151
|
+
if not path.exists():
|
|
152
|
+
return []
|
|
153
|
+
with file_lock(path, busy="mailbox_busy"):
|
|
154
|
+
messages = self._load(name)
|
|
155
|
+
taken = [item for item in messages if not item.get("read") and item.get("type") in kinds]
|
|
156
|
+
if not taken:
|
|
157
|
+
return []
|
|
158
|
+
for item in taken:
|
|
159
|
+
item["read"] = True
|
|
160
|
+
atomic_write(path, _trim(messages))
|
|
161
|
+
return [Message.from_dict(item) for item in taken]
|
|
162
|
+
|
|
163
|
+
def discard_unread(self, name: str, kinds: frozenset[str] | set[str]) -> int:
|
|
164
|
+
"""Mark stale unread messages of these types read (an old shutdown request or answer)."""
|
|
165
|
+
return len(self.take(name, kinds))
|
|
166
|
+
|
|
167
|
+
def peek(self, name: str) -> list[Message]:
|
|
168
|
+
"""Every kept message, read or not; for /team. No lock: a reader sees the old file or the new one."""
|
|
169
|
+
return [Message.from_dict(item) for item in self._load(name)]
|
|
170
|
+
|
|
171
|
+
def unread_count(self, name: str) -> int:
|
|
172
|
+
return sum(1 for message in self.peek(name) if not message.read)
|