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
|
@@ -0,0 +1,1355 @@
|
|
|
1
|
+
"""The lead's side of a team — the current team, its members (in-process or in panes) and the lead's mailbox."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from ..messages import ProviderError, UsageTotals
|
|
16
|
+
from ..permissions import ApprovalRequest
|
|
17
|
+
from ..worktree.meta import process_alive
|
|
18
|
+
from . import LEAD, Member, TeamError, TeamStore, list_teams, teams_root, validate_member_name
|
|
19
|
+
from .backends import (
|
|
20
|
+
IN_PROCESS,
|
|
21
|
+
IN_PROCESS_REASON,
|
|
22
|
+
LABELS,
|
|
23
|
+
START_TIMEOUT,
|
|
24
|
+
WINDOWS_TERMINAL,
|
|
25
|
+
Choice,
|
|
26
|
+
PaneBackend,
|
|
27
|
+
detect_backend,
|
|
28
|
+
member_alive,
|
|
29
|
+
member_arguments,
|
|
30
|
+
pane_backend,
|
|
31
|
+
team_window,
|
|
32
|
+
)
|
|
33
|
+
from .board import STATUS_LABELS
|
|
34
|
+
from .lock import now
|
|
35
|
+
from .mailbox import Message
|
|
36
|
+
from .member import POLL, MemberPolicy, MemberRunner, TeamAccess, addendum, approval_from_record, task_listing
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from ..conversation import Conversation
|
|
40
|
+
|
|
41
|
+
MAX_MEMBERS = 4
|
|
42
|
+
BODY_PREVIEW = 4000 # Characters of a message body in a team update.
|
|
43
|
+
REASON = IN_PROCESS_REASON
|
|
44
|
+
BACKEND_LABELS = LABELS
|
|
45
|
+
STATUS_NAMES = {"running": "运行中", "idle": "空闲", "stopped": "已停止"}
|
|
46
|
+
STOP_WAIT = 10.0 # Seconds a member has to stop after a shutdown_request.
|
|
47
|
+
IDLE_STATES = {"failed": "失败", "interrupted": "被用户中断"} # idle_notification reasons.
|
|
48
|
+
LEAD_GUIDE = """## Agent 团队
|
|
49
|
+
|
|
50
|
+
你可以作为 lead 组建团队:成员是长期存在的 Agent,各自在独立的 git 工作树中工作,通过消息和共享任务协作。
|
|
51
|
+
- 何时使用:用户的任务能拆成几块可以并行、彼此独立的多文件改动时。一处小改动、一个问题或只需要调查时不要建团队,直接做或用子 Agent。
|
|
52
|
+
- 做法:team_create 建团队;先用 team_task_create 建好任务并写明依赖(blocked_by),再用 team_spawn 创建成员(至多 {max_members} 个),在 prompt 中写清它负责的任务、背景、范围和完成后要提交改动。
|
|
53
|
+
- 创建后不要轮询、不要反复查看任务列表:成员的消息、空闲通知和新就绪的任务会作为 <team-update> 自动送达。需要时用 send_message 回复或指派。
|
|
54
|
+
- 全部成员完成后,用 team_merge 按任务依赖的顺序逐个合并它们的分支;每次合并都要用户确认。
|
|
55
|
+
- 合并冲突时:让该成员在它的工作树中合入主分支、解决冲突并提交,再 team_merge。主目录有未提交的改动、或用户拒绝合并时,停下来问用户,不要自己清理或重试。
|
|
56
|
+
- 最后向用户汇报:每个成员做了什么、合并了哪些分支、还剩什么问题。"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def remove_tree(path: Path) -> None:
|
|
60
|
+
"""Windows: a reader may hold a file for a moment; retry briefly."""
|
|
61
|
+
for attempt in range(20):
|
|
62
|
+
try:
|
|
63
|
+
shutil.rmtree(path)
|
|
64
|
+
return
|
|
65
|
+
except FileNotFoundError:
|
|
66
|
+
return
|
|
67
|
+
except PermissionError:
|
|
68
|
+
if attempt == 19:
|
|
69
|
+
raise
|
|
70
|
+
time.sleep(0.05)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def render_update(team: str, messages: list[Message], ready: list[str]) -> str:
|
|
74
|
+
"""One notification for everything that arrived in the lead's mailbox."""
|
|
75
|
+
lines = [f"<team-update>\n团队 {team}:"]
|
|
76
|
+
for message in messages:
|
|
77
|
+
extra, sender = message.extra, message.sender
|
|
78
|
+
if message.type == "text":
|
|
79
|
+
body = message.body
|
|
80
|
+
if len(body) > BODY_PREVIEW:
|
|
81
|
+
body = body[:BODY_PREVIEW] + f"\n(正文共 {len(message.body)} 字,已截断)"
|
|
82
|
+
lines.append(f"[{sender} 的消息] 摘要:{message.summary}\n{body}")
|
|
83
|
+
elif message.type == "plan_approval_request":
|
|
84
|
+
lines.append(
|
|
85
|
+
f"[{sender} 提交了计划,request_id={extra.get('request_id')}]\n{extra.get('plan', '')}\n"
|
|
86
|
+
f"用 send_message(to: {sender},type: plan_approval_response,request_id,approve,feedback)答复。"
|
|
87
|
+
)
|
|
88
|
+
elif message.type == "idle_notification":
|
|
89
|
+
tasks = "、".join(extra.get("completed_tasks") or [])
|
|
90
|
+
state = IDLE_STATES.get(extra.get("reason"), "空闲")
|
|
91
|
+
lines.append(
|
|
92
|
+
f"{sender} {state}"
|
|
93
|
+
+ (f",完成任务 {tasks}" if tasks else "")
|
|
94
|
+
+ (f"。最后回复:{message.summary}" if message.summary else "。")
|
|
95
|
+
)
|
|
96
|
+
elif message.type == "shutdown_response":
|
|
97
|
+
verdict = "同意停止" if extra.get("approve") else f"拒绝停止:{extra.get('reason') or '未说明'}"
|
|
98
|
+
lines.append(f"{sender} {verdict}。")
|
|
99
|
+
else:
|
|
100
|
+
lines.append(f"[{sender} · {message.type}] {message.body}")
|
|
101
|
+
if ready:
|
|
102
|
+
lines.append("现在就绪的任务:" + "、".join(ready) + "。")
|
|
103
|
+
return "\n".join(lines) + "\n</team-update>\n以上是团队的最新消息;需要时用 send_message 回复成员,不要轮询。"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def note_for(message: Message) -> str:
|
|
107
|
+
"""One line in the chat for each update."""
|
|
108
|
+
sender, extra = message.sender, message.extra
|
|
109
|
+
if message.type == "text":
|
|
110
|
+
return f"{sender} → lead:{message.summary}"
|
|
111
|
+
if message.type == "plan_approval_request":
|
|
112
|
+
return f"成员 {sender} 提交了计划,等待 Lead 审批"
|
|
113
|
+
if message.type == "idle_notification":
|
|
114
|
+
tasks = "、".join(extra.get("completed_tasks") or [])
|
|
115
|
+
state = IDLE_STATES.get(extra.get("reason"), "空闲")
|
|
116
|
+
return f"{sender} {state}" + (f":完成任务 {tasks}" if tasks else "")
|
|
117
|
+
if message.type == "shutdown_response":
|
|
118
|
+
return f"{sender} {'已停止' if extra.get('approve') else '拒绝停止'}"
|
|
119
|
+
return f"{sender}:{message.type}"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def label_of(runner) -> str:
|
|
123
|
+
return BACKEND_LABELS.get(getattr(runner, "backend_name", IN_PROCESS), IN_PROCESS)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class MemberRow:
|
|
128
|
+
"""One line of the member panel. Waiting approvals are counted by the interface."""
|
|
129
|
+
|
|
130
|
+
name: str
|
|
131
|
+
status: str
|
|
132
|
+
pane: bool
|
|
133
|
+
since: float | None = None # time.monotonic() when it entered its status; None when stopped.
|
|
134
|
+
last_tool: str = ""
|
|
135
|
+
task: str = "" # The subject of its first in_progress task.
|
|
136
|
+
window: str = ""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class PaneRunner:
|
|
140
|
+
"""The lead's view of a member in a pane of its own. It polls its mailbox itself; its
|
|
141
|
+
record in config.json says how it is doing, and its process ending is the member stopping."""
|
|
142
|
+
|
|
143
|
+
def __init__(
|
|
144
|
+
self,
|
|
145
|
+
store: TeamStore,
|
|
146
|
+
name: str,
|
|
147
|
+
backend: PaneBackend,
|
|
148
|
+
pid: int,
|
|
149
|
+
pane: str | None,
|
|
150
|
+
*,
|
|
151
|
+
status: str = "idle",
|
|
152
|
+
poll: float = POLL,
|
|
153
|
+
):
|
|
154
|
+
self.store, self.name, self.backend = store, name, backend
|
|
155
|
+
self.backend_name = backend.name
|
|
156
|
+
self.pid, self.pane, self.poll = pid, pane, poll
|
|
157
|
+
self.status, self.last_tool = status, ""
|
|
158
|
+
self.since = time.monotonic() # When the lead saw it enter its status.
|
|
159
|
+
self.on_status: Callable[[str, dict], None] | None = None
|
|
160
|
+
# A request it published in its record, and one that left the record (or the member
|
|
161
|
+
# stopped with it still there).
|
|
162
|
+
self.on_request: Callable[[dict], None] | None = None
|
|
163
|
+
self.on_settled: Callable[[str, bool], None] | None = None # (request id, the member ended)
|
|
164
|
+
self.asked: set[str] = set()
|
|
165
|
+
self.job: asyncio.Task | None = None
|
|
166
|
+
|
|
167
|
+
def wake(self) -> None:
|
|
168
|
+
"""Nothing to do; it reads its mailbox every POLL seconds."""
|
|
169
|
+
|
|
170
|
+
def start(self) -> asyncio.Task:
|
|
171
|
+
self.job = asyncio.get_running_loop().create_task(self.watch())
|
|
172
|
+
return self.job
|
|
173
|
+
|
|
174
|
+
def _report(self, status: str) -> None:
|
|
175
|
+
if status != self.status:
|
|
176
|
+
self.since = time.monotonic()
|
|
177
|
+
self.status = status
|
|
178
|
+
if self.on_status is not None:
|
|
179
|
+
self.on_status(status, {})
|
|
180
|
+
|
|
181
|
+
async def watch(self) -> None:
|
|
182
|
+
try:
|
|
183
|
+
while process_alive(self.pid):
|
|
184
|
+
await asyncio.sleep(self.poll)
|
|
185
|
+
try:
|
|
186
|
+
team = await asyncio.to_thread(self.store.load)
|
|
187
|
+
except TeamError:
|
|
188
|
+
continue
|
|
189
|
+
member = team.member(self.name)
|
|
190
|
+
if member is None or member.status == "stopped" or (member.pid and member.pid != self.pid):
|
|
191
|
+
return
|
|
192
|
+
self.last_tool = member.last_tool if member.status == "running" else ""
|
|
193
|
+
self._approvals(member.approvals)
|
|
194
|
+
if member.status != self.status:
|
|
195
|
+
self._report(member.status)
|
|
196
|
+
finally:
|
|
197
|
+
self._approvals([], ended=True)
|
|
198
|
+
self._report("stopped")
|
|
199
|
+
|
|
200
|
+
def _approvals(self, items: list, *, ended: bool = False) -> None:
|
|
201
|
+
"""New requests in the record go to the lead; the ones gone from it were answered or dropped."""
|
|
202
|
+
listed = {item.get("id") for item in items if isinstance(item, dict) and item.get("id")}
|
|
203
|
+
for item in items:
|
|
204
|
+
if isinstance(item, dict) and item.get("id") and item["id"] not in self.asked:
|
|
205
|
+
self.asked.add(item["id"])
|
|
206
|
+
if self.on_request is not None:
|
|
207
|
+
self.on_request(item)
|
|
208
|
+
for request_id in self.asked - listed:
|
|
209
|
+
self.asked.discard(request_id)
|
|
210
|
+
if self.on_settled is not None:
|
|
211
|
+
self.on_settled(request_id, ended)
|
|
212
|
+
|
|
213
|
+
async def stop(self) -> None:
|
|
214
|
+
"""End its process tree (and its tmux pane)."""
|
|
215
|
+
if self.job is not None and not self.job.done():
|
|
216
|
+
self.job.cancel()
|
|
217
|
+
await asyncio.gather(self.job, return_exceptions=True)
|
|
218
|
+
await asyncio.to_thread(self.backend.kill, self.pid if process_alive(self.pid) else None, self.pane)
|
|
219
|
+
|
|
220
|
+
async def close(self) -> None:
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
async def build_runner(
|
|
225
|
+
conversation: Conversation,
|
|
226
|
+
store: TeamStore,
|
|
227
|
+
member: Member,
|
|
228
|
+
agent,
|
|
229
|
+
root: Path | None,
|
|
230
|
+
relative: str,
|
|
231
|
+
*,
|
|
232
|
+
wake: Callable[[str], None] | None = None,
|
|
233
|
+
on_approval: Callable[[ApprovalRequest], None] | None = None,
|
|
234
|
+
poll: float = POLL,
|
|
235
|
+
session_mode: Callable[[], str] | None = None,
|
|
236
|
+
) -> MemberRunner:
|
|
237
|
+
"""The definition's prompt plus the team addendum, tools rooted in its worktree. The lead builds
|
|
238
|
+
in-process members with it; a pane member's process builds itself with it (session_mode from its record)."""
|
|
239
|
+
from ..agent import AgentLoop, add_usage
|
|
240
|
+
from ..agents.runtime import GLOBAL_BAN, SubagentLog, effective_mode
|
|
241
|
+
from ..prompts import git_snapshot
|
|
242
|
+
from ..tools.team import member_tools
|
|
243
|
+
|
|
244
|
+
main = conversation._agent
|
|
245
|
+
parent = main.registry.permissions
|
|
246
|
+
access = TeamAccess(store, member.name, wake)
|
|
247
|
+
session_mode = session_mode or (lambda: parent.mode)
|
|
248
|
+
runner = MemberRunner(
|
|
249
|
+
access,
|
|
250
|
+
member.name,
|
|
251
|
+
agent_mode=lambda mode: effective_mode(agent, mode),
|
|
252
|
+
session_mode=session_mode,
|
|
253
|
+
require_plan_approval=member.require_plan_approval,
|
|
254
|
+
on_approval=on_approval,
|
|
255
|
+
poll=poll,
|
|
256
|
+
)
|
|
257
|
+
provider, budget, owned = conversation.subagents._provider(agent.model, f"成员 {member.name} ")
|
|
258
|
+
mode = "plan" if member.require_plan_approval else effective_mode(agent, session_mode())
|
|
259
|
+
policy = MemberPolicy(parent, mode, interactive=parent.interactive, label=f"成员 {member.name}", root=root)
|
|
260
|
+
base = root or parent.root
|
|
261
|
+
extra = member_tools(base, access, runner)
|
|
262
|
+
keep = lambda name: name not in GLOBAL_BAN and agent.allows(name) # noqa: E731
|
|
263
|
+
if root is None:
|
|
264
|
+
registry = main.registry.for_agent(
|
|
265
|
+
agent="teammate", agent_type=agent.name, permissions=policy, keep=keep, extra=extra
|
|
266
|
+
)
|
|
267
|
+
git = main.git
|
|
268
|
+
else:
|
|
269
|
+
from ..tools import create_default_registry
|
|
270
|
+
|
|
271
|
+
shell = getattr(main.registry.get("execute_command"), "shell", None)
|
|
272
|
+
fresh = create_default_registry(root, permissions=policy, shell=shell if shell is not None else "auto")
|
|
273
|
+
registry = fresh.for_agent(agent="teammate", agent_type=agent.name, permissions=policy, keep=keep, extra=extra)
|
|
274
|
+
registry.session, registry.hooks, registry.worktree = main.registry.session, main.registry.hooks, relative
|
|
275
|
+
git = await asyncio.to_thread(git_snapshot, root)
|
|
276
|
+
system = agent.prompt + "\n\n" + addendum(store.name, member.name, member.worktree)
|
|
277
|
+
turns = min(agent.max_turns or main.max_iterations, main.max_iterations)
|
|
278
|
+
loop = AgentLoop(provider, system, registry, max_iterations=turns, unknown_limit=main.unknown_limit)
|
|
279
|
+
loop.instructions, loop.git = conversation.project_instructions, git
|
|
280
|
+
loop.budget, loop.auto_compact, loop.session = budget, main.auto_compact, main.session
|
|
281
|
+
|
|
282
|
+
def counted(usage) -> None: # The session total includes in-process members.
|
|
283
|
+
main.total_usage = add_usage(main.total_usage, usage)
|
|
284
|
+
|
|
285
|
+
loop.on_usage = counted
|
|
286
|
+
log = SubagentLog(
|
|
287
|
+
store.directory / member.transcript,
|
|
288
|
+
{
|
|
289
|
+
"type": "teammate",
|
|
290
|
+
"v": 1,
|
|
291
|
+
"team": store.name,
|
|
292
|
+
"name": member.name,
|
|
293
|
+
"agent_type": agent.name,
|
|
294
|
+
"created": now(),
|
|
295
|
+
},
|
|
296
|
+
)
|
|
297
|
+
loop.on_commit = log.append
|
|
298
|
+
runner.loop, runner.owned = loop, owned
|
|
299
|
+
return runner
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def load_history(store: TeamStore, member: Member) -> list:
|
|
303
|
+
"""The member's conversation from its transcript; revive_failed when it cannot be trusted. Blocking."""
|
|
304
|
+
from ..session import load_session
|
|
305
|
+
|
|
306
|
+
path = store.directory / member.transcript if member.transcript else None
|
|
307
|
+
if path is None or not path.exists():
|
|
308
|
+
return []
|
|
309
|
+
try:
|
|
310
|
+
restored = load_session(path)
|
|
311
|
+
except (OSError, UnicodeError) as exc:
|
|
312
|
+
raise TeamError("revive_failed", f"无法读取对话记录 {path.name}:{exc}") from None
|
|
313
|
+
if restored.skipped:
|
|
314
|
+
raise TeamError("revive_failed", f"对话记录 {path.name} 已损坏({restored.skipped} 行无法读取)。")
|
|
315
|
+
return restored.history
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def restore_member(runner: MemberRunner, member: Member, history: list) -> None:
|
|
319
|
+
"""The old conversation, its token total and its plan approval."""
|
|
320
|
+
runner.loop.restore(history)
|
|
321
|
+
usage = member.usage or {} # The record keeps the member's total across revivals.
|
|
322
|
+
runner.loop.total_usage = UsageTotals(
|
|
323
|
+
**{key: int(usage.get(key, 0) or 0) for key in ("input_tokens", "output_tokens", "cached_input_tokens")}
|
|
324
|
+
)
|
|
325
|
+
runner.approved = member.approved
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class TeamRuntime:
|
|
329
|
+
def __init__(
|
|
330
|
+
self,
|
|
331
|
+
conversation: Conversation,
|
|
332
|
+
*,
|
|
333
|
+
home: Path | None = None,
|
|
334
|
+
max_members: int = MAX_MEMBERS,
|
|
335
|
+
poll: float = POLL,
|
|
336
|
+
teammate_mode: str = IN_PROCESS,
|
|
337
|
+
launch: dict | None = None,
|
|
338
|
+
):
|
|
339
|
+
self.conversation = conversation
|
|
340
|
+
self.home = home
|
|
341
|
+
self.max_members = max_members
|
|
342
|
+
self.poll = poll
|
|
343
|
+
# Where members run, and what a pane member needs to load the same configuration.
|
|
344
|
+
self.teammate_mode = teammate_mode
|
|
345
|
+
self.launch = dict(launch or {}) # {"config": path, "provider": name}
|
|
346
|
+
self.env: dict | None = None # Tests replace these; None/defaults use the real environment.
|
|
347
|
+
self.which = shutil.which
|
|
348
|
+
self.run = subprocess.run
|
|
349
|
+
self.python: str | None = None
|
|
350
|
+
self.start_timeout = START_TIMEOUT
|
|
351
|
+
self._synced_mode = "" # The session mode last written to pane members' records.
|
|
352
|
+
self._revived: dict[str, str] = {}
|
|
353
|
+
self.current: TeamStore | None = None
|
|
354
|
+
self.runners: dict[str, MemberRunner | PaneRunner] = {}
|
|
355
|
+
self.plan_requests: dict[str, str] = {} # Member -> request_id the lead has not answered.
|
|
356
|
+
self.on_approval: Callable[[ApprovalRequest], None] | None = None # The TUI shows it.
|
|
357
|
+
self.on_note: Callable[[str], None] | None = None
|
|
358
|
+
self.on_change: Callable[[], None] | None = None # A member's status changed.
|
|
359
|
+
self.on_withdraw: Callable[[str], None] | None = None # A card to take down.
|
|
360
|
+
self.on_commit: Callable[[str], None] | None = None # An in-process member's transcript grew.
|
|
361
|
+
self.askers: dict[str, str] = {} # Approval request id -> the member asking.
|
|
362
|
+
self.forwarded: dict[str, tuple[ApprovalRequest, asyncio.Future]] = {} # Pane members' requests.
|
|
363
|
+
self._relays: set[asyncio.Task] = set()
|
|
364
|
+
self.stop_wait = STOP_WAIT
|
|
365
|
+
self._stops: set[asyncio.Task] = set()
|
|
366
|
+
self.notices: list[str] = [] # Corrupt files kept aside and the like.
|
|
367
|
+
self._lead_wake = asyncio.Event()
|
|
368
|
+
self._watcher: asyncio.Task | None = None
|
|
369
|
+
self._writes: set[asyncio.Task] = set()
|
|
370
|
+
self._write_lock = asyncio.Lock() # Status writes land in the order they happened.
|
|
371
|
+
self._merge_lock = asyncio.Lock() # One team_merge at a time, from the first check to the merge.
|
|
372
|
+
|
|
373
|
+
def guide(self) -> str:
|
|
374
|
+
"""The fixed team section of the lead's system prompt."""
|
|
375
|
+
return LEAD_GUIDE.format(max_members=self.max_members)
|
|
376
|
+
|
|
377
|
+
# Places ---------------------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
@property
|
|
380
|
+
def project(self) -> Path:
|
|
381
|
+
conversation = self.conversation
|
|
382
|
+
return Path(conversation.context_root or conversation.registry.permissions.root)
|
|
383
|
+
|
|
384
|
+
@property
|
|
385
|
+
def root(self) -> Path:
|
|
386
|
+
return teams_root(self.project, self.home)
|
|
387
|
+
|
|
388
|
+
def teams(self) -> list[TeamStore]:
|
|
389
|
+
return list_teams(self.root)
|
|
390
|
+
|
|
391
|
+
def _note(self, text: str) -> None:
|
|
392
|
+
if self.on_note is not None:
|
|
393
|
+
try:
|
|
394
|
+
self.on_note(text)
|
|
395
|
+
except Exception:
|
|
396
|
+
pass
|
|
397
|
+
|
|
398
|
+
def access(self) -> TeamAccess:
|
|
399
|
+
if self.current is None:
|
|
400
|
+
raise TeamError("no_team", "还没有当前团队;先用 team_create 新建或打开一个团队。")
|
|
401
|
+
return TeamAccess(self.current, LEAD, self.wake)
|
|
402
|
+
|
|
403
|
+
def wake(self, name: str) -> None:
|
|
404
|
+
if name == LEAD:
|
|
405
|
+
self._lead_wake.set()
|
|
406
|
+
elif name in self.runners:
|
|
407
|
+
self.runners[name].wake()
|
|
408
|
+
|
|
409
|
+
# Teams ----------------------------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
async def create(self, name: str, description: str = "") -> tuple[str, dict]:
|
|
412
|
+
"""New or reopened, then current."""
|
|
413
|
+
store = TeamStore.at(self.root, name, self.notices.append)
|
|
414
|
+
if self.current is not None and self.current.name == name:
|
|
415
|
+
return f"团队 {name} 已经是当前团队。", {"team": name, "opened": True}
|
|
416
|
+
busy = [runner.name for runner in self.runners.values() if runner.status == "running"]
|
|
417
|
+
if busy:
|
|
418
|
+
raise TeamError(
|
|
419
|
+
"team_busy",
|
|
420
|
+
f"当前团队 {self.current.name} 还有成员在运行({'、'.join(busy)});等它们空闲后再切换团队。",
|
|
421
|
+
)
|
|
422
|
+
opened = await asyncio.to_thread(store.exists)
|
|
423
|
+
if not opened:
|
|
424
|
+
await asyncio.to_thread(store.create, description)
|
|
425
|
+
session = self.conversation.session
|
|
426
|
+
team = await asyncio.to_thread(store.acquire, os.getpid(), getattr(session, "id", "") or "", str(self.project))
|
|
427
|
+
await self._leave(keep_panes=True)
|
|
428
|
+
live: list[Member] = []
|
|
429
|
+
if opened: # A pane member that still runs and reports keeps going; everything else is stopped.
|
|
430
|
+
|
|
431
|
+
def refresh(team):
|
|
432
|
+
for member in team.members:
|
|
433
|
+
if (
|
|
434
|
+
member.backend != IN_PROCESS
|
|
435
|
+
and member.status != "stopped"
|
|
436
|
+
and member_alive(member.pid, member.heartbeat)
|
|
437
|
+
):
|
|
438
|
+
live.append(member)
|
|
439
|
+
else:
|
|
440
|
+
member.status, member.pid = "stopped", None
|
|
441
|
+
|
|
442
|
+
team = await asyncio.to_thread(store.modify, refresh)
|
|
443
|
+
self.current = store
|
|
444
|
+
for member in live:
|
|
445
|
+
self._attach(store, member)
|
|
446
|
+
self._start_watching()
|
|
447
|
+
self._changed()
|
|
448
|
+
if opened:
|
|
449
|
+
states = "、".join(
|
|
450
|
+
f"{m.name}({m.agent_type},{STATUS_NAMES.get(m.status, m.status)})" for m in team.members if m in live
|
|
451
|
+
)
|
|
452
|
+
stopped = "、".join(f"{m.name}({m.agent_type})" for m in team.members if m not in live)
|
|
453
|
+
text = f"已打开已有团队 {name}:"
|
|
454
|
+
if states:
|
|
455
|
+
text += f"仍在窗格中运行的成员 {states}"
|
|
456
|
+
text += f";已停止的成员 {stopped}。" if stopped else "。"
|
|
457
|
+
else:
|
|
458
|
+
text += f"{stopped or '无成员'},成员都处于停止状态。"
|
|
459
|
+
return text, {"team": name, "opened": True, "live": [m.name for m in live]}
|
|
460
|
+
return (
|
|
461
|
+
f"已创建团队 {name} 并设为当前团队。接下来用 team_task_create 拆分任务(写好依赖),"
|
|
462
|
+
"再用 team_spawn 创建成员;owner 只能填已创建的成员,可以先留空,创建成员后再用 team_task_update 指派。"
|
|
463
|
+
), {"team": name, "opened": False}
|
|
464
|
+
|
|
465
|
+
async def _leave(self, *, keep_panes: bool = False) -> None:
|
|
466
|
+
"""Stop this process's members of the current team and let it go; pane members switched
|
|
467
|
+
away from keep running (keep_panes) and are found again when the team is reopened."""
|
|
468
|
+
if self.current is None:
|
|
469
|
+
return
|
|
470
|
+
await self._stop_members(keep_panes=keep_panes)
|
|
471
|
+
try:
|
|
472
|
+
await asyncio.to_thread(self.current.release, os.getpid())
|
|
473
|
+
except TeamError:
|
|
474
|
+
pass
|
|
475
|
+
self.current = None
|
|
476
|
+
self.plan_requests.clear()
|
|
477
|
+
|
|
478
|
+
async def _stop_members(self, *, keep_panes: bool = False) -> None:
|
|
479
|
+
runners, self.runners = list(self.runners.values()), {}
|
|
480
|
+
if keep_panes:
|
|
481
|
+
for runner in [runner for runner in runners if isinstance(runner, PaneRunner)]:
|
|
482
|
+
runners.remove(runner)
|
|
483
|
+
runner.on_status, runner.on_request = None, None
|
|
484
|
+
runner.on_settled = lambda request_id, ended, name=runner.name: self._withdraw(
|
|
485
|
+
name, request_id, ended, note=False
|
|
486
|
+
)
|
|
487
|
+
if runner.job is not None:
|
|
488
|
+
runner.job.cancel()
|
|
489
|
+
await asyncio.gather(runner.job, return_exceptions=True)
|
|
490
|
+
for runner in runners:
|
|
491
|
+
await runner.stop()
|
|
492
|
+
await self.settle()
|
|
493
|
+
if runners and self.current is not None:
|
|
494
|
+
names = {runner.name for runner in runners}
|
|
495
|
+
|
|
496
|
+
def stopped(team):
|
|
497
|
+
for member in team.members:
|
|
498
|
+
if member.name in names:
|
|
499
|
+
member.status, member.pid = "stopped", None
|
|
500
|
+
|
|
501
|
+
try:
|
|
502
|
+
await asyncio.to_thread(self.current.modify, stopped)
|
|
503
|
+
except TeamError:
|
|
504
|
+
pass
|
|
505
|
+
await self._release(self.current, names)
|
|
506
|
+
self._changed()
|
|
507
|
+
|
|
508
|
+
def _start_watching(self) -> None:
|
|
509
|
+
if self._watcher is None or self._watcher.done():
|
|
510
|
+
self._watcher = asyncio.get_running_loop().create_task(self._watch())
|
|
511
|
+
|
|
512
|
+
async def close(self) -> None:
|
|
513
|
+
"""Ask every member to stop, wait up to stop_wait in all, cancel the rest; the team stays."""
|
|
514
|
+
if self._watcher is not None:
|
|
515
|
+
self._watcher.cancel()
|
|
516
|
+
await asyncio.gather(self._watcher, return_exceptions=True)
|
|
517
|
+
self._watcher = None
|
|
518
|
+
for job in list(self._stops):
|
|
519
|
+
job.cancel()
|
|
520
|
+
await asyncio.gather(*self._stops, return_exceptions=True)
|
|
521
|
+
store, runners = self.current, list(self.runners.values())
|
|
522
|
+
if store is not None and runners:
|
|
523
|
+
access = TeamAccess(store, LEAD)
|
|
524
|
+
for runner in runners:
|
|
525
|
+
try:
|
|
526
|
+
await asyncio.to_thread(access.send, runner.name, "shutdown_request", reason="Lead 正在退出")
|
|
527
|
+
runner.wake()
|
|
528
|
+
except TeamError:
|
|
529
|
+
pass
|
|
530
|
+
jobs = [runner.job for runner in runners if runner.job is not None]
|
|
531
|
+
if jobs:
|
|
532
|
+
await asyncio.wait(jobs, timeout=self.stop_wait)
|
|
533
|
+
await self.settle()
|
|
534
|
+
try: # Answers nobody will read; a later lead should not see them as news.
|
|
535
|
+
await asyncio.to_thread(store.mailbox.discard_unread, LEAD, {"shutdown_response"})
|
|
536
|
+
except TeamError:
|
|
537
|
+
pass
|
|
538
|
+
await self._leave()
|
|
539
|
+
for job in list(self._relays):
|
|
540
|
+
job.cancel()
|
|
541
|
+
await asyncio.gather(*self._relays, return_exceptions=True)
|
|
542
|
+
await self.settle()
|
|
543
|
+
|
|
544
|
+
async def rows(self) -> list[MemberRow]:
|
|
545
|
+
"""Every member of the current team in its record's order; files are read off the loop."""
|
|
546
|
+
store = self.current
|
|
547
|
+
if store is None:
|
|
548
|
+
return []
|
|
549
|
+
|
|
550
|
+
def read():
|
|
551
|
+
team = store.load()
|
|
552
|
+
try:
|
|
553
|
+
tasks = store.board.list()
|
|
554
|
+
except TeamError:
|
|
555
|
+
tasks = []
|
|
556
|
+
return team, tasks
|
|
557
|
+
|
|
558
|
+
try:
|
|
559
|
+
team, tasks = await asyncio.to_thread(read)
|
|
560
|
+
except TeamError:
|
|
561
|
+
return []
|
|
562
|
+
if self.current is not store:
|
|
563
|
+
return []
|
|
564
|
+
rows = []
|
|
565
|
+
for member in team.members:
|
|
566
|
+
runner = self.runners.get(member.name)
|
|
567
|
+
status = runner.status if runner is not None else "stopped"
|
|
568
|
+
task = next(
|
|
569
|
+
(task.subject for task in tasks if task.owner == member.name and task.status == "in_progress"), ""
|
|
570
|
+
)
|
|
571
|
+
rows.append(
|
|
572
|
+
MemberRow(
|
|
573
|
+
member.name,
|
|
574
|
+
status,
|
|
575
|
+
member.backend != IN_PROCESS,
|
|
576
|
+
since=runner.since if runner is not None else None,
|
|
577
|
+
last_tool=runner.last_tool if runner is not None and status == "running" else "",
|
|
578
|
+
task=task,
|
|
579
|
+
window=member.window,
|
|
580
|
+
)
|
|
581
|
+
)
|
|
582
|
+
return rows
|
|
583
|
+
|
|
584
|
+
def transcript(self, name: str) -> Path | None:
|
|
585
|
+
"""Where the member view reads; None without a current team."""
|
|
586
|
+
if self.current is None:
|
|
587
|
+
return None
|
|
588
|
+
return self.current.transcripts / f"{name}.jsonl"
|
|
589
|
+
|
|
590
|
+
def counts(self) -> tuple[int, int]:
|
|
591
|
+
"""(running, idle) members of this process."""
|
|
592
|
+
states = [runner.status for runner in self.runners.values()]
|
|
593
|
+
return states.count("running"), states.count("idle")
|
|
594
|
+
|
|
595
|
+
def _changed(self) -> None:
|
|
596
|
+
if self.on_change is not None:
|
|
597
|
+
try:
|
|
598
|
+
self.on_change()
|
|
599
|
+
except Exception:
|
|
600
|
+
pass
|
|
601
|
+
|
|
602
|
+
async def _ended(self, store: TeamStore, runner: MemberRunner | PaneRunner) -> None:
|
|
603
|
+
"""A member stopped by itself after its shutdown_response (a pane member: its process ended)."""
|
|
604
|
+
if self.runners.get(runner.name) is not runner:
|
|
605
|
+
return # Stopped by the runtime, which cleans up itself.
|
|
606
|
+
del self.runners[runner.name]
|
|
607
|
+
self.plan_requests.pop(runner.name, None)
|
|
608
|
+
await runner.close()
|
|
609
|
+
if isinstance(runner, PaneRunner): # It may have died without saying so.
|
|
610
|
+
await self._mark_stopped(store, runner.name, pid=runner.pid)
|
|
611
|
+
await self._release(store, [runner.name])
|
|
612
|
+
self._note(f"成员 {runner.name} 已停止({label_of(runner)})")
|
|
613
|
+
self._changed()
|
|
614
|
+
|
|
615
|
+
async def _mark_stopped(self, store: TeamStore, name: str, *, pid: int | None = None, error: str = "") -> None:
|
|
616
|
+
def stopped(team):
|
|
617
|
+
member = team.member(name)
|
|
618
|
+
if member is not None and (pid is None or member.pid in {pid, None}):
|
|
619
|
+
member.status, member.pid, member.last_tool = "stopped", None, ""
|
|
620
|
+
if error:
|
|
621
|
+
member.error = error
|
|
622
|
+
|
|
623
|
+
try:
|
|
624
|
+
await asyncio.to_thread(store.modify, stopped)
|
|
625
|
+
except TeamError:
|
|
626
|
+
pass
|
|
627
|
+
|
|
628
|
+
async def _release(self, store: TeamStore, names) -> None:
|
|
629
|
+
"""The worktrees of stopped members are no longer in use."""
|
|
630
|
+
try:
|
|
631
|
+
team = await asyncio.to_thread(store.load)
|
|
632
|
+
except TeamError:
|
|
633
|
+
return
|
|
634
|
+
for name in names:
|
|
635
|
+
member = team.member(name)
|
|
636
|
+
if member is None or not member.worktree:
|
|
637
|
+
continue
|
|
638
|
+
try:
|
|
639
|
+
manager = self.conversation.worktree_manager()
|
|
640
|
+
await manager.mark_in_use(member.worktree["flat"], "")
|
|
641
|
+
except Exception:
|
|
642
|
+
pass
|
|
643
|
+
|
|
644
|
+
# Stopping one member ---------------------------------------------------------------
|
|
645
|
+
|
|
646
|
+
async def stop_member(self, name: str) -> str:
|
|
647
|
+
access = self.access()
|
|
648
|
+
store = self.current
|
|
649
|
+
team = await asyncio.to_thread(store.load)
|
|
650
|
+
if team.member(name) is None:
|
|
651
|
+
raise TeamError("unknown_member", f"团队 {team.name} 中没有成员 {name}。")
|
|
652
|
+
runner = self.runners.get(name)
|
|
653
|
+
if runner is None:
|
|
654
|
+
return f"成员 {name} 已经停止。"
|
|
655
|
+
await access.asend(name, "shutdown_request", reason="用户用 /team stop 停止")
|
|
656
|
+
job = asyncio.get_running_loop().create_task(self._stop_later(store, runner))
|
|
657
|
+
self._stops.add(job)
|
|
658
|
+
job.add_done_callback(self._stops.discard)
|
|
659
|
+
return f"已请求成员 {name} 停止:它在这一轮结束后停止;{self.stop_wait:g} 秒内没有停止时强制停止。"
|
|
660
|
+
|
|
661
|
+
async def _stop_later(self, store: TeamStore, runner: MemberRunner | PaneRunner) -> None:
|
|
662
|
+
"""Cancel an in-process member, end a pane member's processes, when it has not stopped in time."""
|
|
663
|
+
if runner.job is not None:
|
|
664
|
+
await asyncio.wait([runner.job], timeout=self.stop_wait)
|
|
665
|
+
if self.runners.get(runner.name) is not runner or (runner.job is not None and runner.job.done()):
|
|
666
|
+
return
|
|
667
|
+
del self.runners[runner.name]
|
|
668
|
+
self.plan_requests.pop(runner.name, None)
|
|
669
|
+
await runner.stop()
|
|
670
|
+
await self.settle()
|
|
671
|
+
|
|
672
|
+
await self._mark_stopped(store, runner.name)
|
|
673
|
+
await self._release(store, [runner.name])
|
|
674
|
+
self._note(f"成员 {runner.name} 没有在 {self.stop_wait:g} 秒内停止,已强制停止({label_of(runner)})")
|
|
675
|
+
self._changed()
|
|
676
|
+
|
|
677
|
+
async def delete(self, name: str | None = None, *, force: bool = False) -> tuple[str, dict]:
|
|
678
|
+
"""Stop the members, delete clean and merged worktrees, then the team. name: any team of
|
|
679
|
+
this project (/team delete), the current one by default (team_delete)."""
|
|
680
|
+
if name is None or (self.current is not None and self.current.name == name):
|
|
681
|
+
self.access()
|
|
682
|
+
store = self.current
|
|
683
|
+
busy = [runner.name for runner in self.runners.values() if runner.status == "running"]
|
|
684
|
+
if busy and not force:
|
|
685
|
+
raise TeamError(
|
|
686
|
+
"team_busy", f"成员 {'、'.join(busy)} 正在运行;等它们空闲,或用 force: true 停止后删除。"
|
|
687
|
+
)
|
|
688
|
+
for job in list(self._stops):
|
|
689
|
+
job.cancel()
|
|
690
|
+
await self._stop_members()
|
|
691
|
+
if self._watcher is not None:
|
|
692
|
+
self._watcher.cancel()
|
|
693
|
+
await asyncio.gather(self._watcher, return_exceptions=True)
|
|
694
|
+
self._watcher = None
|
|
695
|
+
self.current = None
|
|
696
|
+
self.plan_requests.clear()
|
|
697
|
+
else:
|
|
698
|
+
store = TeamStore.at(self.root, name, self.notices.append)
|
|
699
|
+
if not await asyncio.to_thread(store.exists):
|
|
700
|
+
raise TeamError("unknown_team", f"本项目没有名为 {name} 的团队。")
|
|
701
|
+
await asyncio.to_thread(store.acquire, os.getpid()) # team_held while another lead has it.
|
|
702
|
+
team = await asyncio.to_thread(store.load)
|
|
703
|
+
removed, kept = [], []
|
|
704
|
+
for member in team.members:
|
|
705
|
+
if not member.worktree:
|
|
706
|
+
continue
|
|
707
|
+
worktree = member.worktree.get("name", "")
|
|
708
|
+
try:
|
|
709
|
+
manager = self.conversation.worktree_manager()
|
|
710
|
+
await manager.remove(worktree)
|
|
711
|
+
removed.append(worktree)
|
|
712
|
+
except Exception as exc: # Changes, unmerged commits or a failed deletion: keep it.
|
|
713
|
+
kept.append(f"{worktree}({getattr(exc, 'changes', None) and exc.changes.describe() or exc})")
|
|
714
|
+
await asyncio.to_thread(remove_tree, store.directory)
|
|
715
|
+
text = f"已删除团队 {team.name}。"
|
|
716
|
+
if removed:
|
|
717
|
+
text += "已删除工作树:" + "、".join(removed) + "。"
|
|
718
|
+
if kept:
|
|
719
|
+
text += "保留的工作树(有未合并的提交或改动):" + ";".join(kept) + "。用 /worktree 查看。"
|
|
720
|
+
self._note(f"团队 {team.name} 已删除")
|
|
721
|
+
self._changed()
|
|
722
|
+
return text, {"team": team.name, "removed": removed, "kept": kept}
|
|
723
|
+
|
|
724
|
+
# Reports ----------------------------------------------------------------------
|
|
725
|
+
|
|
726
|
+
def _holder(self, team) -> str:
|
|
727
|
+
pid = int((team.lead or {}).get("pid") or 0)
|
|
728
|
+
if not pid:
|
|
729
|
+
return "无"
|
|
730
|
+
if pid == os.getpid():
|
|
731
|
+
return "本进程"
|
|
732
|
+
return f"PID {pid}({'运行中' if process_alive(pid) else '已退出'})"
|
|
733
|
+
|
|
734
|
+
def _state(self, store: TeamStore, team, member: Member) -> str:
|
|
735
|
+
"""In this process the runner knows best; a team no live lead holds has nothing running."""
|
|
736
|
+
if self.current is not None and self.current.name == store.name:
|
|
737
|
+
runner = self.runners.get(member.name)
|
|
738
|
+
return runner.status if runner is not None else "stopped"
|
|
739
|
+
pid = int((team.lead or {}).get("pid") or 0)
|
|
740
|
+
return member.status if pid and process_alive(pid) else "stopped"
|
|
741
|
+
|
|
742
|
+
def overview(self) -> str:
|
|
743
|
+
"""Blocking."""
|
|
744
|
+
stores = self.teams()
|
|
745
|
+
if not stores:
|
|
746
|
+
return "本项目还没有团队。让模型用 team_create 建一个。"
|
|
747
|
+
lines = [f"团队({len(stores)})"]
|
|
748
|
+
for store in stores:
|
|
749
|
+
try:
|
|
750
|
+
team = store.load()
|
|
751
|
+
except TeamError as exc:
|
|
752
|
+
lines.append(f" {store.name} ! {exc.message}")
|
|
753
|
+
continue
|
|
754
|
+
states = [self._state(store, team, member) for member in team.members]
|
|
755
|
+
counts = "、".join(
|
|
756
|
+
f"{STATUS_NAMES[state]} {states.count(state)}" for state in STATUS_NAMES if states.count(state)
|
|
757
|
+
)
|
|
758
|
+
current = " 当前" if self.current is not None and self.current.name == store.name else ""
|
|
759
|
+
lines.append(
|
|
760
|
+
f" {store.name}{current} 成员 {len(team.members)}"
|
|
761
|
+
+ (f"({counts})" if counts else "")
|
|
762
|
+
+ f" 持有者 {self._holder(team)}"
|
|
763
|
+
)
|
|
764
|
+
lines.append("/team <名称> 查看详情;/team stop <成员> 停止当前团队的成员;/team delete <名称> 删除团队。")
|
|
765
|
+
return "\n".join(lines)
|
|
766
|
+
|
|
767
|
+
def detail(self, name: str) -> str:
|
|
768
|
+
"""Blocking."""
|
|
769
|
+
store = TeamStore.at(self.root, name)
|
|
770
|
+
if not store.exists():
|
|
771
|
+
raise TeamError("unknown_team", f"本项目没有名为 {name} 的团队。")
|
|
772
|
+
team = store.load()
|
|
773
|
+
lines = [
|
|
774
|
+
f"团队 {team.name}" + (f":{team.description}" if team.description else ""),
|
|
775
|
+
f"创建于 {team.created};持有者 {self._holder(team)}",
|
|
776
|
+
f"成员({len(team.members)})",
|
|
777
|
+
]
|
|
778
|
+
for member in team.members:
|
|
779
|
+
state = self._state(store, team, member)
|
|
780
|
+
backend = BACKEND_LABELS.get(member.backend, member.backend)
|
|
781
|
+
lines.append(
|
|
782
|
+
f" {member.name} {STATUS_NAMES.get(state, state)} {member.agent_type} "
|
|
783
|
+
f"后端 {backend}({member.backend_reason or '—'})"
|
|
784
|
+
)
|
|
785
|
+
if member.window and member.backend == WINDOWS_TERMINAL:
|
|
786
|
+
lines.append(f" 在团队窗口 {member.window} 中")
|
|
787
|
+
if member.worktree:
|
|
788
|
+
lines.append(f" 工作树 {member.worktree.get('name', '')},分支 {member.worktree.get('branch', '')}")
|
|
789
|
+
else:
|
|
790
|
+
lines.append(" 在主项目目录中工作")
|
|
791
|
+
runner = self.runners.get(member.name) if self.current is not None and self.current.name == name else None
|
|
792
|
+
if runner is not None and runner.last_tool:
|
|
793
|
+
lines.append(f" 最近的工具调用:{runner.last_tool}")
|
|
794
|
+
usage = member.usage or {}
|
|
795
|
+
try:
|
|
796
|
+
unread = store.mailbox.unread_count(member.name)
|
|
797
|
+
except TeamError:
|
|
798
|
+
unread = 0
|
|
799
|
+
lines.append(
|
|
800
|
+
f" Token 入 {usage.get('input_tokens', 0)} / 出 {usage.get('output_tokens', 0)};未读消息 {unread}"
|
|
801
|
+
)
|
|
802
|
+
tasks = store.board.list()
|
|
803
|
+
lines += [f"任务({len(tasks)})", *(" " + line for line in task_listing(tasks).splitlines())]
|
|
804
|
+
return "\n".join(lines)
|
|
805
|
+
|
|
806
|
+
def context(self) -> str:
|
|
807
|
+
"""What the lead needs to know again after /clear or /resume; blocking."""
|
|
808
|
+
store = self.current
|
|
809
|
+
if store is None:
|
|
810
|
+
return ""
|
|
811
|
+
team = store.load()
|
|
812
|
+
members = (
|
|
813
|
+
";".join(
|
|
814
|
+
f"{member.name}({member.agent_type},{STATUS_NAMES.get(self._state(store, team, member))}"
|
|
815
|
+
+ (f",分支 {member.worktree['branch']}" if member.worktree else "")
|
|
816
|
+
+ ")"
|
|
817
|
+
for member in team.members
|
|
818
|
+
)
|
|
819
|
+
or "无"
|
|
820
|
+
)
|
|
821
|
+
tasks = store.board.list()
|
|
822
|
+
counts = "、".join(
|
|
823
|
+
f"{label} {sum(1 for task in tasks if task.status == key)}"
|
|
824
|
+
for key, label in STATUS_LABELS.items()
|
|
825
|
+
if any(task.status == key for task in tasks)
|
|
826
|
+
)
|
|
827
|
+
return (
|
|
828
|
+
f"<team-context>\n你仍是团队 {team.name} 的 Lead(会话已清空或恢复,之前的对话不在上下文中)。\n"
|
|
829
|
+
f"成员:{members}。\n任务:共 {len(tasks)} 个" + (f"({counts})" if counts else "") + ";"
|
|
830
|
+
"用 team_task_list 查看详情。团队更新会自动送达,不要轮询。\n</team-context>"
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
# Merging --------------------------------------------------------------------------------------
|
|
834
|
+
|
|
835
|
+
async def merge(self, name: str) -> tuple[str, dict]:
|
|
836
|
+
"""Checks, the user's confirmation, the checks again, then git merge."""
|
|
837
|
+
from ..permissions import MERGE_CHOICES, Operation
|
|
838
|
+
from ..worktree import WorktreeError
|
|
839
|
+
from . import merge
|
|
840
|
+
|
|
841
|
+
self.access()
|
|
842
|
+
store = self.current
|
|
843
|
+
try:
|
|
844
|
+
manager = self.conversation.worktree_manager()
|
|
845
|
+
except WorktreeError as exc:
|
|
846
|
+
raise TeamError("main_not_ready", str(exc)) from None
|
|
847
|
+
busy = lambda: name in self.runners and self.runners[name].status == "running" # noqa: E731
|
|
848
|
+
async with self._merge_lock:
|
|
849
|
+
plan = await asyncio.to_thread(merge.check, manager, store, name, busy())
|
|
850
|
+
policy = self.conversation._agent.registry.permissions
|
|
851
|
+
operation = Operation("team_merge", name, preview=merge.preview(plan))
|
|
852
|
+
choice = await policy.confirm(
|
|
853
|
+
"", operation, f"把成员 {name} 的分支 {plan.branch} 合并到 {plan.target}?", MERGE_CHOICES
|
|
854
|
+
)
|
|
855
|
+
if choice is None:
|
|
856
|
+
raise TeamError("merge_not_confirmed", "合并需要用户在界面中确认;当前无法请求确认,没有合并。")
|
|
857
|
+
if choice != "merge":
|
|
858
|
+
raise TeamError("merge_declined", "用户拒绝了这次合并,没有合并。停下来问用户想怎么处理,不要重试。")
|
|
859
|
+
plan = await asyncio.to_thread(merge.check, manager, store, name, busy()) # It may have changed meanwhile.
|
|
860
|
+
text, details = await asyncio.to_thread(merge.merge, plan)
|
|
861
|
+
self._note(f"已合并成员 {name} 的分支 {plan.branch}")
|
|
862
|
+
return text, {"team": store.name, **details}
|
|
863
|
+
|
|
864
|
+
# Members --------------------------------------------------------------------------------------
|
|
865
|
+
|
|
866
|
+
def _choose(self) -> Choice:
|
|
867
|
+
"""From the current teammate_mode, every time a member starts."""
|
|
868
|
+
return detect_backend(self.teammate_mode, self.env, self.which)
|
|
869
|
+
|
|
870
|
+
async def spawn(
|
|
871
|
+
self,
|
|
872
|
+
name: str,
|
|
873
|
+
agent_type: str,
|
|
874
|
+
prompt: str,
|
|
875
|
+
*,
|
|
876
|
+
require_plan_approval: bool = False,
|
|
877
|
+
isolation: str = "worktree",
|
|
878
|
+
) -> tuple[str, dict]:
|
|
879
|
+
from ..agents import FORK
|
|
880
|
+
|
|
881
|
+
self.access()
|
|
882
|
+
store = self.current
|
|
883
|
+
problem = validate_member_name(name)
|
|
884
|
+
if problem:
|
|
885
|
+
raise TeamError("invalid_name", f"成员名 {name} 不可用:{problem}。")
|
|
886
|
+
team = await asyncio.to_thread(store.load)
|
|
887
|
+
if team.member(name) is not None:
|
|
888
|
+
raise TeamError("member_exists", f"团队 {team.name} 已有成员 {name}。")
|
|
889
|
+
if len(team.members) >= self.max_members:
|
|
890
|
+
raise TeamError("too_many_members", f"一个团队最多 {self.max_members} 个成员(team_max_members)。")
|
|
891
|
+
catalog = self.conversation.subagents.catalog
|
|
892
|
+
kind = agent_type.strip() or "general"
|
|
893
|
+
agent = catalog.get(kind) if kind != FORK else None
|
|
894
|
+
if agent is None:
|
|
895
|
+
names = "、".join(catalog.names()) or "无"
|
|
896
|
+
raise TeamError("unknown_agent_type", f"没有名为 {kind} 的 Agent 定义(成员不能是 Fork)。可用:{names}。")
|
|
897
|
+
subagents = self.conversation.subagents
|
|
898
|
+
try:
|
|
899
|
+
subagents._check_provider(agent.model, f"成员 {name} ")
|
|
900
|
+
except ProviderError as exc:
|
|
901
|
+
raise TeamError("agent_unavailable", str(exc)) from None
|
|
902
|
+
choice = self._choose() # Before anything is created.
|
|
903
|
+
worktree, root, relative = None, None, ""
|
|
904
|
+
if isolation == "worktree":
|
|
905
|
+
from ..worktree import WorktreeError
|
|
906
|
+
from ..worktree.manager import WORKTREES
|
|
907
|
+
|
|
908
|
+
try:
|
|
909
|
+
manager = self.conversation.worktree_manager()
|
|
910
|
+
created = await manager.create(
|
|
911
|
+
f"team-{team.name}/{name}", manual=True, task=f"团队 {team.name} 成员 {name}"
|
|
912
|
+
)
|
|
913
|
+
except WorktreeError as exc:
|
|
914
|
+
raise TeamError(getattr(exc, "code", "") or "worktree_unavailable", str(exc)) from None
|
|
915
|
+
meta = created.meta
|
|
916
|
+
await manager.mark_in_use(meta.flat, f"团队 {team.name} 成员 {name}") # Also when resumed.
|
|
917
|
+
root = manager.root_in(meta)
|
|
918
|
+
relative = (WORKTREES / meta.flat).as_posix()
|
|
919
|
+
from .merge import current_branch
|
|
920
|
+
|
|
921
|
+
worktree = {
|
|
922
|
+
"name": meta.name,
|
|
923
|
+
"flat": meta.flat,
|
|
924
|
+
"path": str(meta.path),
|
|
925
|
+
"branch": meta.branch,
|
|
926
|
+
"relative": relative,
|
|
927
|
+
"main": await asyncio.to_thread(current_branch, manager.top),
|
|
928
|
+
}
|
|
929
|
+
pane = choice.backend != IN_PROCESS
|
|
930
|
+
member = Member(
|
|
931
|
+
name,
|
|
932
|
+
agent.name,
|
|
933
|
+
choice.backend,
|
|
934
|
+
choice.reason,
|
|
935
|
+
"stopped" if pane else "idle",
|
|
936
|
+
require_plan_approval,
|
|
937
|
+
model=agent.model or "",
|
|
938
|
+
worktree=worktree,
|
|
939
|
+
transcript=f"transcripts/{name}.jsonl",
|
|
940
|
+
prompt=prompt,
|
|
941
|
+
)
|
|
942
|
+
if not pane:
|
|
943
|
+
runner = await self._runner(store, member, agent, root, relative)
|
|
944
|
+
await asyncio.to_thread(store.modify, lambda team: team.members.append(member))
|
|
945
|
+
await asyncio.to_thread(store.mailbox.send, name, Message(LEAD, "text", prompt, "初始任务"))
|
|
946
|
+
if pane:
|
|
947
|
+
runner = await self._launch(store, member, choice, root)
|
|
948
|
+
self._own(store, runner)
|
|
949
|
+
self.runners[name] = runner
|
|
950
|
+
runner.start()
|
|
951
|
+
runner.wake()
|
|
952
|
+
place = f"工作树 {worktree['path']},分支 {worktree['branch']}" if worktree else "在主项目目录中工作"
|
|
953
|
+
plan = ";以计划模式开始,提交的计划会出现在团队更新中,由你审批" if require_plan_approval else ""
|
|
954
|
+
self._note(f"成员 {name} 已创建({agent.name},{BACKEND_LABELS[choice.backend]}{self._window(store, choice)})")
|
|
955
|
+
self._changed()
|
|
956
|
+
return (
|
|
957
|
+
f"已创建成员 {name}({agent.name},后端:{choice.backend}({choice.reason})),{place}{plan}。"
|
|
958
|
+
"它完成一轮后你会收到 <team-update>;在此之前不要轮询。"
|
|
959
|
+
), {
|
|
960
|
+
"team": team.name,
|
|
961
|
+
"member": name,
|
|
962
|
+
"backend": choice.backend,
|
|
963
|
+
"backend_reason": choice.reason,
|
|
964
|
+
"worktree": worktree["branch"] if worktree else None,
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
async def _launch(self, store: TeamStore, member: Member, choice: Choice, root: Path | None) -> PaneRunner:
|
|
968
|
+
"""Open the pane, wait for the member to write its pid. backend_failed leaves the member
|
|
969
|
+
stopped and its worktree kept; nothing falls back to in-process."""
|
|
970
|
+
backend = pane_backend(choice.backend, self.run, self.which, self.python)
|
|
971
|
+
mode = self.conversation._agent.registry.permissions.mode
|
|
972
|
+
name = member.name
|
|
973
|
+
|
|
974
|
+
def reset(team):
|
|
975
|
+
record = team.member(name)
|
|
976
|
+
if record is not None:
|
|
977
|
+
record.backend, record.backend_reason = choice.backend, choice.reason
|
|
978
|
+
record.status, record.pid, record.heartbeat, record.pane, record.error = "stopped", None, None, None, ""
|
|
979
|
+
record.window, record.approvals = "", []
|
|
980
|
+
record.mode, record.last_tool = mode, ""
|
|
981
|
+
|
|
982
|
+
await asyncio.to_thread(store.modify, reset)
|
|
983
|
+
self._synced_mode = mode
|
|
984
|
+
cwd = root or self.project
|
|
985
|
+
arguments = member_arguments(store.directory, name, self.launch.get("config"), self.launch.get("provider"))
|
|
986
|
+
window = team_window(store.directory) if choice.backend == WINDOWS_TERMINAL else ""
|
|
987
|
+
try:
|
|
988
|
+
pane = await asyncio.to_thread(backend.launch, cwd, arguments, member=name, team=store.name, window=window)
|
|
989
|
+
except TeamError as exc:
|
|
990
|
+
await self._mark_stopped(store, name, error=exc.message)
|
|
991
|
+
await self._release(store, [name])
|
|
992
|
+
raise
|
|
993
|
+
|
|
994
|
+
def remember(team):
|
|
995
|
+
record = team.member(name)
|
|
996
|
+
if record is not None:
|
|
997
|
+
record.pane, record.window = pane, window
|
|
998
|
+
|
|
999
|
+
await asyncio.to_thread(store.modify, remember)
|
|
1000
|
+
end = asyncio.get_running_loop().time() + self.start_timeout
|
|
1001
|
+
while True:
|
|
1002
|
+
try:
|
|
1003
|
+
record = (await asyncio.to_thread(store.load)).member(name)
|
|
1004
|
+
except TeamError:
|
|
1005
|
+
record = None
|
|
1006
|
+
if record is not None and record.error:
|
|
1007
|
+
failure = f"成员 {name} 的{BACKEND_LABELS[choice.backend]}启动失败:{record.error}"
|
|
1008
|
+
break
|
|
1009
|
+
if record is not None and record.pid:
|
|
1010
|
+
status = record.status if record.status != "stopped" else "idle"
|
|
1011
|
+
return PaneRunner(store, name, backend, int(record.pid), pane, status=status, poll=self.poll)
|
|
1012
|
+
if asyncio.get_running_loop().time() >= end:
|
|
1013
|
+
failure = (
|
|
1014
|
+
f"成员 {name} 的{BACKEND_LABELS[choice.backend]}在 {self.start_timeout:g} 秒内没有启动"
|
|
1015
|
+
"(没有写入进程号)。"
|
|
1016
|
+
)
|
|
1017
|
+
break
|
|
1018
|
+
await asyncio.sleep(min(0.1, self.poll))
|
|
1019
|
+
await asyncio.to_thread(backend.kill, None, pane) # tmux: the pane; Windows Terminal closes its own.
|
|
1020
|
+
await self._mark_stopped(store, name, error=failure)
|
|
1021
|
+
await self._release(store, [name])
|
|
1022
|
+
self._note(failure)
|
|
1023
|
+
raise TeamError("backend_failed", failure + "成员已标为停止,工作树保留;没有改用其他后端。")
|
|
1024
|
+
|
|
1025
|
+
@staticmethod
|
|
1026
|
+
def _window(store: TeamStore, choice: Choice) -> str:
|
|
1027
|
+
"""Where to find a Windows Terminal member."""
|
|
1028
|
+
return f",在团队窗口 {team_window(store.directory)} 中" if choice.backend == WINDOWS_TERMINAL else ""
|
|
1029
|
+
|
|
1030
|
+
def _attach(self, store: TeamStore, member: Member) -> PaneRunner:
|
|
1031
|
+
"""A pane member that kept running while no lead held the team."""
|
|
1032
|
+
backend = pane_backend(member.backend, self.run, self.which, self.python)
|
|
1033
|
+
runner = PaneRunner(
|
|
1034
|
+
store, member.name, backend, int(member.pid), member.pane, status=member.status, poll=self.poll
|
|
1035
|
+
)
|
|
1036
|
+
self._own(store, runner)
|
|
1037
|
+
self.runners[member.name] = runner
|
|
1038
|
+
runner.start()
|
|
1039
|
+
return runner
|
|
1040
|
+
|
|
1041
|
+
def _own(self, store: TeamStore, runner: MemberRunner | PaneRunner) -> None:
|
|
1042
|
+
"""Status changes: an in-process member's go into its record; a pane member writes its own."""
|
|
1043
|
+
|
|
1044
|
+
def status(state: str, fields: dict) -> None:
|
|
1045
|
+
if not isinstance(runner, PaneRunner) and "last_tool" not in fields: # The status line reads the runner.
|
|
1046
|
+
self._member_status(store, runner.name, state, fields)
|
|
1047
|
+
if state == "stopped" and self.runners.get(runner.name) is runner:
|
|
1048
|
+
self._track(self._ended(store, runner))
|
|
1049
|
+
self._changed()
|
|
1050
|
+
|
|
1051
|
+
runner.on_status = status
|
|
1052
|
+
if isinstance(runner, PaneRunner):
|
|
1053
|
+
runner.on_request = lambda item: self._forward(store, runner.name, item)
|
|
1054
|
+
runner.on_settled = lambda request_id, ended: self._withdraw(runner.name, request_id, ended)
|
|
1055
|
+
|
|
1056
|
+
async def _runner(self, store: TeamStore, member: Member, agent, root: Path | None, relative: str) -> MemberRunner:
|
|
1057
|
+
runner = await build_runner(
|
|
1058
|
+
self.conversation,
|
|
1059
|
+
store,
|
|
1060
|
+
member,
|
|
1061
|
+
agent,
|
|
1062
|
+
root,
|
|
1063
|
+
relative,
|
|
1064
|
+
wake=self.wake,
|
|
1065
|
+
on_approval=lambda request: self._approval(request, member.name),
|
|
1066
|
+
poll=self.poll,
|
|
1067
|
+
)
|
|
1068
|
+
append = runner.loop.on_commit
|
|
1069
|
+
|
|
1070
|
+
def commit(message) -> None: # The member view reads the new lines at once.
|
|
1071
|
+
append(message)
|
|
1072
|
+
if self.on_commit is not None:
|
|
1073
|
+
try:
|
|
1074
|
+
self.on_commit(member.name)
|
|
1075
|
+
except Exception:
|
|
1076
|
+
pass
|
|
1077
|
+
|
|
1078
|
+
runner.loop.on_commit = commit
|
|
1079
|
+
return runner
|
|
1080
|
+
|
|
1081
|
+
def _approval(self, request: ApprovalRequest, name: str = "") -> None:
|
|
1082
|
+
pending = self.conversation._agent.registry.permissions.pending
|
|
1083
|
+
for request_id in [key for key in self.askers if key not in pending]:
|
|
1084
|
+
del self.askers[request_id]
|
|
1085
|
+
if name:
|
|
1086
|
+
self.askers[request.id] = name
|
|
1087
|
+
if self.on_approval is not None:
|
|
1088
|
+
self.on_approval(request)
|
|
1089
|
+
|
|
1090
|
+
def asker(self, request_id: str) -> str:
|
|
1091
|
+
"""The member whose approval this is; '' for the lead's own."""
|
|
1092
|
+
return self.askers.get(request_id, "")
|
|
1093
|
+
|
|
1094
|
+
# Pane members' approvals ------------------------------------------------
|
|
1095
|
+
|
|
1096
|
+
def _forward(self, store: TeamStore, name: str, item: dict) -> None:
|
|
1097
|
+
"""A pane member's request, shown in the lead's window and answered through the lead's policy."""
|
|
1098
|
+
request = approval_from_record(item, f"成员 {name}(窗格)")
|
|
1099
|
+
if request is None or request.id in self.forwarded:
|
|
1100
|
+
return
|
|
1101
|
+
policy = self.conversation._agent.registry.permissions
|
|
1102
|
+
future = policy.hold(request)
|
|
1103
|
+
self.forwarded[request.id] = (request, future)
|
|
1104
|
+
job = asyncio.get_running_loop().create_task(self._relay(store, name, request, future))
|
|
1105
|
+
self._relays.add(job)
|
|
1106
|
+
job.add_done_callback(self._relays.discard)
|
|
1107
|
+
self._approval(request, name)
|
|
1108
|
+
|
|
1109
|
+
async def _relay(self, store: TeamStore, name: str, request: ApprovalRequest, future: asyncio.Future) -> None:
|
|
1110
|
+
"""The answer given here goes to the member as a permission_response."""
|
|
1111
|
+
try:
|
|
1112
|
+
choice = await future
|
|
1113
|
+
except asyncio.CancelledError:
|
|
1114
|
+
return # Withdrawn: answered in the member's window, or the member stopped.
|
|
1115
|
+
finally:
|
|
1116
|
+
self.conversation._agent.registry.permissions.release(request)
|
|
1117
|
+
self.forwarded.pop(request.id, None)
|
|
1118
|
+
try:
|
|
1119
|
+
await TeamAccess(store, LEAD).asend(
|
|
1120
|
+
name, "permission_response", runtime=True, request_id=request.id, choice=choice
|
|
1121
|
+
)
|
|
1122
|
+
except TeamError as exc:
|
|
1123
|
+
self._note(f"无法把审批答复发给成员 {name}:{exc.message}")
|
|
1124
|
+
|
|
1125
|
+
def _withdraw(self, name: str, request_id: str, ended: bool, *, note: bool = True) -> None:
|
|
1126
|
+
"""The request left the member's record (answered there) or the member stopped."""
|
|
1127
|
+
entry = self.forwarded.get(request_id)
|
|
1128
|
+
if entry is None or entry[1].done():
|
|
1129
|
+
return # Answered here; the record has caught up.
|
|
1130
|
+
entry[1].cancel()
|
|
1131
|
+
if self.on_withdraw is not None:
|
|
1132
|
+
try:
|
|
1133
|
+
self.on_withdraw(request_id)
|
|
1134
|
+
except Exception:
|
|
1135
|
+
pass
|
|
1136
|
+
if note:
|
|
1137
|
+
self._note(f"成员 {name} 已停止,它的审批已撤下" if ended else f"成员 {name} 的审批已在它的窗口中回答")
|
|
1138
|
+
|
|
1139
|
+
def _member_status(self, store: TeamStore, name: str, state: str, fields: dict) -> None:
|
|
1140
|
+
def change(team):
|
|
1141
|
+
member = team.member(name)
|
|
1142
|
+
if member is not None:
|
|
1143
|
+
member.status = state
|
|
1144
|
+
if "usage" in fields:
|
|
1145
|
+
member.usage = fields["usage"]
|
|
1146
|
+
if "approved" in fields:
|
|
1147
|
+
member.approved = fields["approved"]
|
|
1148
|
+
|
|
1149
|
+
async def write():
|
|
1150
|
+
async with self._write_lock:
|
|
1151
|
+
try:
|
|
1152
|
+
await asyncio.to_thread(store.modify, change)
|
|
1153
|
+
except TeamError:
|
|
1154
|
+
pass
|
|
1155
|
+
|
|
1156
|
+
self._track(write())
|
|
1157
|
+
|
|
1158
|
+
def _track(self, work) -> None:
|
|
1159
|
+
"""Background bookkeeping that settle() waits for."""
|
|
1160
|
+
try:
|
|
1161
|
+
job = asyncio.get_running_loop().create_task(work)
|
|
1162
|
+
except RuntimeError:
|
|
1163
|
+
work.close()
|
|
1164
|
+
return
|
|
1165
|
+
self._writes.add(job)
|
|
1166
|
+
job.add_done_callback(self._writes.discard)
|
|
1167
|
+
|
|
1168
|
+
async def settle(self) -> None:
|
|
1169
|
+
"""Wait for the status writes already started.
|
|
1170
|
+
|
|
1171
|
+
Only unfinished jobs are awaited: a finished one leaves the set in a callback on the next loop
|
|
1172
|
+
iteration, and gathering finished jobs alone never yields to it, so the loop would spin forever.
|
|
1173
|
+
"""
|
|
1174
|
+
while pending := [job for job in self._writes if not job.done()]:
|
|
1175
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
1176
|
+
|
|
1177
|
+
# Messages -------------------------------------------------------------------------------------
|
|
1178
|
+
|
|
1179
|
+
async def send(self, to: str, kind: str, body: str = "", summary: str = "", **extra) -> list[str]:
|
|
1180
|
+
return (await self.message(to, kind, body, summary, **extra))[0]
|
|
1181
|
+
|
|
1182
|
+
async def message(
|
|
1183
|
+
self, to: str, kind: str, body: str = "", summary: str = "", **extra
|
|
1184
|
+
) -> tuple[list[str], list[str]]:
|
|
1185
|
+
"""For the lead, a plan answer must name the member's open request. A stopped
|
|
1186
|
+
recipient is revived after the message is in its mailbox. (targets, revived)."""
|
|
1187
|
+
access = self.access()
|
|
1188
|
+
if kind == "plan_approval_response":
|
|
1189
|
+
open_request = self.plan_requests.get(to)
|
|
1190
|
+
if open_request is None or open_request != extra.get("request_id"):
|
|
1191
|
+
raise TeamError(
|
|
1192
|
+
"unknown_request",
|
|
1193
|
+
f"{to} 没有 request_id 为 {extra.get('request_id')} 的待审批计划"
|
|
1194
|
+
+ (f"(待审批的是 {open_request})。" if open_request else "。"),
|
|
1195
|
+
)
|
|
1196
|
+
if kind == "shutdown_request" and to not in self.runners:
|
|
1197
|
+
await asyncio.to_thread(access.recipients, to, kind) # unknown_member first.
|
|
1198
|
+
raise TeamError("member_stopped", f"成员 {to} 已经停止,不需要停止请求。")
|
|
1199
|
+
store = self.current
|
|
1200
|
+
targets = await access.asend(to, kind, body, summary, **extra)
|
|
1201
|
+
if kind == "plan_approval_response":
|
|
1202
|
+
self.plan_requests.pop(to, None)
|
|
1203
|
+
verdict = "批准" if extra.get("approve") else "拒绝"
|
|
1204
|
+
self._note(f"Lead {verdict}了 {to} 的计划")
|
|
1205
|
+
revived, failures = [], []
|
|
1206
|
+
for name in targets:
|
|
1207
|
+
if name == LEAD or name in self.runners:
|
|
1208
|
+
continue
|
|
1209
|
+
try:
|
|
1210
|
+
await self._revive(store, name)
|
|
1211
|
+
revived.append(name)
|
|
1212
|
+
except TeamError as exc:
|
|
1213
|
+
failures.append(f"{name}:{exc.message}")
|
|
1214
|
+
if failures:
|
|
1215
|
+
raise TeamError("revive_failed", "消息已写入邮箱,下次恢复时仍会读到;恢复失败——" + ";".join(failures))
|
|
1216
|
+
return targets, revived
|
|
1217
|
+
|
|
1218
|
+
async def _revive(self, store: TeamStore, name: str) -> None:
|
|
1219
|
+
"""The backend from the current teammate_mode, the conversation from its transcript, the same
|
|
1220
|
+
worktree, then a turn for the waiting messages. A pane member restores its conversation itself."""
|
|
1221
|
+
from ..agents import FORK
|
|
1222
|
+
|
|
1223
|
+
team = await asyncio.to_thread(store.load)
|
|
1224
|
+
member = team.member(name)
|
|
1225
|
+
if member is None:
|
|
1226
|
+
raise TeamError("revive_failed", "没有这个成员的记录。")
|
|
1227
|
+
agent = self.conversation.subagents.catalog.get(member.agent_type) if member.agent_type != FORK else None
|
|
1228
|
+
if agent is None:
|
|
1229
|
+
raise TeamError("revive_failed", f"Agent 定义 {member.agent_type} 已不存在。")
|
|
1230
|
+
try:
|
|
1231
|
+
self.conversation.subagents._check_provider(agent.model, f"成员 {name} ")
|
|
1232
|
+
except ProviderError as exc:
|
|
1233
|
+
raise TeamError("revive_failed", str(exc)) from None
|
|
1234
|
+
try:
|
|
1235
|
+
choice = self._choose()
|
|
1236
|
+
except TeamError as exc:
|
|
1237
|
+
raise TeamError("revive_failed", exc.message) from None
|
|
1238
|
+
history = await asyncio.to_thread(load_history, store, member)
|
|
1239
|
+
root, relative, manager = None, "", None
|
|
1240
|
+
if member.worktree:
|
|
1241
|
+
from ..worktree.meta import load_meta
|
|
1242
|
+
|
|
1243
|
+
try:
|
|
1244
|
+
manager = self.conversation.worktree_manager()
|
|
1245
|
+
meta = await asyncio.to_thread(load_meta, manager.meta_path(member.worktree["flat"]))
|
|
1246
|
+
except Exception as exc:
|
|
1247
|
+
raise TeamError("revive_failed", f"工作树不可用:{exc}") from None
|
|
1248
|
+
if meta is None or not Path(meta.path).is_dir():
|
|
1249
|
+
raise TeamError("revive_failed", f"工作树 {member.worktree.get('name', '')} 已不存在。")
|
|
1250
|
+
await manager.mark_in_use(meta.flat, f"团队 {store.name} 成员 {name}")
|
|
1251
|
+
root, relative = manager.root_in(meta), member.worktree.get("relative", "")
|
|
1252
|
+
try: # A request left from an earlier stop would stop it again at once.
|
|
1253
|
+
await asyncio.to_thread(store.mailbox.discard_unread, name, {"shutdown_request"})
|
|
1254
|
+
except TeamError:
|
|
1255
|
+
pass
|
|
1256
|
+
if choice.backend == IN_PROCESS:
|
|
1257
|
+
try:
|
|
1258
|
+
runner = await self._runner(store, member, agent, root, relative)
|
|
1259
|
+
except Exception as exc:
|
|
1260
|
+
if manager is not None:
|
|
1261
|
+
await manager.mark_in_use(member.worktree["flat"], "")
|
|
1262
|
+
raise TeamError("revive_failed", str(exc)) from None
|
|
1263
|
+
restore_member(runner, member, history)
|
|
1264
|
+
if member.backend != IN_PROCESS:
|
|
1265
|
+
|
|
1266
|
+
def moved(team):
|
|
1267
|
+
record = team.member(name)
|
|
1268
|
+
if record is not None:
|
|
1269
|
+
record.backend, record.backend_reason, record.pane = choice.backend, choice.reason, None
|
|
1270
|
+
record.window, record.approvals = "", []
|
|
1271
|
+
|
|
1272
|
+
await asyncio.to_thread(store.modify, moved)
|
|
1273
|
+
else:
|
|
1274
|
+
try:
|
|
1275
|
+
runner = await self._launch(store, member, choice, root)
|
|
1276
|
+
except TeamError as exc:
|
|
1277
|
+
raise TeamError("revive_failed", exc.message) from None
|
|
1278
|
+
changed = (
|
|
1279
|
+
"" if choice.backend == member.backend else f",上次是{BACKEND_LABELS.get(member.backend, member.backend)}"
|
|
1280
|
+
)
|
|
1281
|
+
self._revived[name] = f"后端:{choice.backend},{choice.reason}{changed}" # Said in the result.
|
|
1282
|
+
self._own(store, runner)
|
|
1283
|
+
self.runners[name] = runner
|
|
1284
|
+
runner.start()
|
|
1285
|
+
runner.wake()
|
|
1286
|
+
self._note(f"成员 {name} 已恢复({BACKEND_LABELS[choice.backend]}{self._window(store, choice)}{changed})")
|
|
1287
|
+
self._changed()
|
|
1288
|
+
|
|
1289
|
+
async def _watch(self) -> None:
|
|
1290
|
+
"""Read the lead's mailbox when woken or every poll; hand it to the conversation."""
|
|
1291
|
+
while True:
|
|
1292
|
+
try:
|
|
1293
|
+
await asyncio.wait_for(self._lead_wake.wait(), self.poll)
|
|
1294
|
+
except TimeoutError:
|
|
1295
|
+
pass
|
|
1296
|
+
self._lead_wake.clear()
|
|
1297
|
+
store = self.current
|
|
1298
|
+
if store is None:
|
|
1299
|
+
continue
|
|
1300
|
+
await self._sync_mode(store)
|
|
1301
|
+
try:
|
|
1302
|
+
messages = await asyncio.to_thread(store.mailbox.read_unread, LEAD)
|
|
1303
|
+
except TeamError:
|
|
1304
|
+
continue
|
|
1305
|
+
if messages:
|
|
1306
|
+
await self._deliver(store, messages)
|
|
1307
|
+
|
|
1308
|
+
def revived_with(self, name: str) -> str:
|
|
1309
|
+
return self._revived.get(name, "")
|
|
1310
|
+
|
|
1311
|
+
async def _sync_mode(self, store: TeamStore) -> None:
|
|
1312
|
+
"""A pane member reads the session mode from its record at the start of each turn."""
|
|
1313
|
+
mode = self.conversation._agent.registry.permissions.mode
|
|
1314
|
+
if mode == self._synced_mode or not any(isinstance(r, PaneRunner) for r in self.runners.values()):
|
|
1315
|
+
return
|
|
1316
|
+
|
|
1317
|
+
def write(team):
|
|
1318
|
+
for member in team.members:
|
|
1319
|
+
if member.backend != IN_PROCESS:
|
|
1320
|
+
member.mode = mode
|
|
1321
|
+
|
|
1322
|
+
try:
|
|
1323
|
+
await asyncio.to_thread(store.modify, write)
|
|
1324
|
+
self._synced_mode = mode
|
|
1325
|
+
except TeamError:
|
|
1326
|
+
pass
|
|
1327
|
+
|
|
1328
|
+
async def _deliver(self, store: TeamStore, messages: list[Message]) -> None:
|
|
1329
|
+
ready = []
|
|
1330
|
+
if any(m.type == "idle_notification" and m.extra.get("completed_tasks") for m in messages):
|
|
1331
|
+
from .board import is_ready
|
|
1332
|
+
|
|
1333
|
+
try:
|
|
1334
|
+
tasks = await asyncio.to_thread(store.board.list)
|
|
1335
|
+
except TeamError:
|
|
1336
|
+
tasks = []
|
|
1337
|
+
index = {task.id: task for task in tasks}
|
|
1338
|
+
done = {
|
|
1339
|
+
key for m in messages if m.type == "idle_notification" for key in m.extra.get("completed_tasks") or []
|
|
1340
|
+
}
|
|
1341
|
+
ready = [
|
|
1342
|
+
f"{task.id}({task.owner or '未指派'})"
|
|
1343
|
+
for task in tasks # Made ready by these.
|
|
1344
|
+
if task.status == "pending" and done & set(task.blocked_by) and is_ready(task, index)
|
|
1345
|
+
]
|
|
1346
|
+
for message in messages:
|
|
1347
|
+
if message.type == "plan_approval_request":
|
|
1348
|
+
self.plan_requests[message.sender] = str(message.extra.get("request_id", ""))
|
|
1349
|
+
note = note_for(message)
|
|
1350
|
+
if message.type == "idle_notification": # With its backend.
|
|
1351
|
+
note += f"({label_of(self.runners.get(message.sender))})"
|
|
1352
|
+
self._note(f"团队 {store.name}:" + note)
|
|
1353
|
+
conversation = self.conversation
|
|
1354
|
+
conversation._agent.notifications.append(render_update(store.name, messages, ready))
|
|
1355
|
+
conversation._tasks_ready()
|