@team-agent/installer 0.1.11 → 0.2.1
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.
- package/crates/team-agent-core/src/lib.rs +50 -5
- package/package.json +1 -1
- package/schemas/team.schema.json +1 -0
- package/src/team_agent/approvals/__init__.py +65 -0
- package/src/team_agent/approvals/constants.py +6 -0
- package/src/team_agent/approvals/parsing.py +176 -0
- package/src/team_agent/approvals/runtime_prompts.py +171 -0
- package/src/team_agent/approvals/status.py +165 -0
- package/src/team_agent/cli/__init__.py +137 -0
- package/src/team_agent/cli/commands.py +339 -0
- package/src/team_agent/cli/e2e.py +202 -0
- package/src/team_agent/cli/helpers.py +137 -0
- package/src/team_agent/cli/parser.py +477 -0
- package/src/team_agent/compiler.py +98 -33
- package/src/team_agent/coordinator/__init__.py +53 -0
- package/src/team_agent/{coordinator.py → coordinator/__main__.py} +3 -1
- package/src/team_agent/coordinator/lifecycle.py +334 -0
- package/src/team_agent/coordinator/metadata.py +61 -0
- package/src/team_agent/coordinator/paths.py +17 -0
- package/src/team_agent/diagnose/__init__.py +48 -0
- package/src/team_agent/diagnose/checks.py +101 -0
- package/src/team_agent/diagnose/health.py +241 -0
- package/src/team_agent/diagnose/preflight.py +194 -0
- package/src/team_agent/diagnose/quick_start.py +233 -0
- package/src/team_agent/display/__init__.py +61 -0
- package/src/team_agent/display/close.py +147 -0
- package/src/team_agent/display/ghostty.py +77 -0
- package/src/team_agent/display/worker_window.py +110 -0
- package/src/team_agent/display/workspace.py +473 -0
- package/src/team_agent/launch/__init__.py +41 -0
- package/src/team_agent/launch/bootstrap.py +85 -0
- package/src/team_agent/launch/config.py +106 -0
- package/src/team_agent/launch/core.py +291 -0
- package/src/team_agent/launch/requirements.py +57 -0
- package/src/team_agent/leader/__init__.py +320 -0
- package/src/team_agent/lifecycle/__init__.py +5 -0
- package/src/team_agent/lifecycle/agents.py +226 -0
- package/src/team_agent/lifecycle/operations.py +321 -0
- package/src/team_agent/lifecycle/paste_buffer_hygiene.py +39 -0
- package/src/team_agent/lifecycle/start.py +363 -0
- package/src/team_agent/mcp_server/__init__.py +42 -0
- package/src/team_agent/mcp_server/__main__.py +7 -0
- package/src/team_agent/mcp_server/contracts.py +148 -0
- package/src/team_agent/mcp_server/normalize.py +257 -0
- package/src/team_agent/mcp_server/server.py +150 -0
- package/src/team_agent/mcp_server/tools.py +205 -0
- package/src/team_agent/message_store/__init__.py +23 -0
- package/src/team_agent/message_store/agent_health.py +109 -0
- package/src/team_agent/{message_store.py → message_store/core.py} +188 -245
- package/src/team_agent/message_store/result_watchers.py +102 -0
- package/src/team_agent/message_store/schema.py +266 -0
- package/src/team_agent/messaging/__init__.py +1 -0
- package/src/team_agent/messaging/activity_detector.py +190 -0
- package/src/team_agent/messaging/delivery.py +138 -0
- package/src/team_agent/messaging/deps.py +263 -0
- package/src/team_agent/messaging/idle_alerts.py +323 -0
- package/src/team_agent/messaging/internal_delivery.py +46 -0
- package/src/team_agent/messaging/leader.py +317 -0
- package/src/team_agent/messaging/leader_panes.py +343 -0
- package/src/team_agent/messaging/owner_bypass.py +29 -0
- package/src/team_agent/messaging/result_delivery.py +300 -0
- package/src/team_agent/messaging/results.py +456 -0
- package/src/team_agent/messaging/scheduler.py +428 -0
- package/src/team_agent/messaging/send.py +500 -0
- package/src/team_agent/messaging/session_drift.py +94 -0
- package/src/team_agent/messaging/tmux_io.py +337 -0
- package/src/team_agent/messaging/tmux_prompt.py +229 -0
- package/src/team_agent/orchestrator/__init__.py +376 -0
- package/src/team_agent/orchestrator/plan.py +122 -0
- package/src/team_agent/orchestrator/state.py +128 -0
- package/src/team_agent/profiles/__init__.py +82 -0
- package/src/team_agent/profiles/constants.py +19 -0
- package/src/team_agent/profiles/core.py +407 -0
- package/src/team_agent/profiles/helpers.py +69 -0
- package/src/team_agent/profiles/provider_env.py +188 -0
- package/src/team_agent/profiles/smoke.py +201 -0
- package/src/team_agent/provider_cli/__init__.py +43 -0
- package/src/team_agent/provider_cli/adapter.py +167 -0
- package/src/team_agent/provider_cli/base.py +48 -0
- package/src/team_agent/provider_cli/claude.py +457 -0
- package/src/team_agent/provider_cli/codex.py +319 -0
- package/src/team_agent/provider_cli/copilot.py +8 -0
- package/src/team_agent/provider_cli/fake.py +39 -0
- package/src/team_agent/provider_cli/gemini.py +95 -0
- package/src/team_agent/provider_cli/opencode.py +8 -0
- package/src/team_agent/provider_cli/prompt.py +62 -0
- package/src/team_agent/provider_cli/registry.py +18 -0
- package/src/team_agent/provider_cli/unsupported.py +32 -0
- package/src/team_agent/providers.py +67 -949
- package/src/team_agent/quality_gates.py +104 -0
- package/src/team_agent/restart/__init__.py +34 -0
- package/src/team_agent/restart/orchestration.py +328 -0
- package/src/team_agent/restart/selection.py +89 -0
- package/src/team_agent/restart/snapshot.py +70 -0
- package/src/team_agent/runtime.py +809 -5892
- package/src/team_agent/rust_core.py +22 -5
- package/src/team_agent/sessions/__init__.py +25 -0
- package/src/team_agent/sessions/capture.py +93 -0
- package/src/team_agent/sessions/inventory.py +44 -0
- package/src/team_agent/sessions/resume.py +135 -0
- package/src/team_agent/spec.py +3 -1
- package/src/team_agent/state.py +218 -4
- package/src/team_agent/status/__init__.py +63 -0
- package/src/team_agent/status/approvals.py +52 -0
- package/src/team_agent/status/compact.py +158 -0
- package/src/team_agent/status/constants.py +18 -0
- package/src/team_agent/status/inbox.py +28 -0
- package/src/team_agent/status/peek.py +117 -0
- package/src/team_agent/status/queries.py +168 -0
- package/src/team_agent/terminal.py +57 -0
- package/src/team_agent/cli.py +0 -858
- package/src/team_agent/mcp_server.py +0 -579
- package/src/team_agent/profiles.py +0 -882
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import signal
|
|
7
|
+
import shlex
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from team_agent.events import EventLog
|
|
16
|
+
from team_agent.state import load_runtime_state, save_runtime_state
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def attach_leader(workspace: Path, pane: str | None = None, provider: str = "codex") -> dict[str, Any]:
|
|
20
|
+
from team_agent.message_store import MessageStore
|
|
21
|
+
from team_agent.runtime import _attach_leader_to_state, ensure_workspace_dirs
|
|
22
|
+
ensure_workspace_dirs(workspace)
|
|
23
|
+
state = load_runtime_state(workspace)
|
|
24
|
+
event_log = EventLog(workspace)
|
|
25
|
+
receiver, validation = _attach_leader_to_state(
|
|
26
|
+
workspace,
|
|
27
|
+
state,
|
|
28
|
+
pane=pane,
|
|
29
|
+
provider=provider,
|
|
30
|
+
event_log=event_log,
|
|
31
|
+
source="manual",
|
|
32
|
+
)
|
|
33
|
+
save_runtime_state(workspace, state)
|
|
34
|
+
requeued = MessageStore(workspace).requeue_delivery_exhausted_watchers()
|
|
35
|
+
if requeued:
|
|
36
|
+
event_log.write(
|
|
37
|
+
"leader_receiver.requeued_exhausted_watchers",
|
|
38
|
+
watcher_ids=requeued,
|
|
39
|
+
count=len(requeued),
|
|
40
|
+
trigger="attach_leader",
|
|
41
|
+
)
|
|
42
|
+
for watcher_id in requeued:
|
|
43
|
+
event_log.write(
|
|
44
|
+
"result_watcher.requeued",
|
|
45
|
+
watcher_id=watcher_id,
|
|
46
|
+
trigger="attach_leader",
|
|
47
|
+
new_pane_id=receiver.get("pane_id"),
|
|
48
|
+
)
|
|
49
|
+
return {
|
|
50
|
+
"ok": True,
|
|
51
|
+
"leader_receiver": receiver,
|
|
52
|
+
"validation": validation,
|
|
53
|
+
"requeued_exhausted_watchers": requeued,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def start_leader(
|
|
58
|
+
provider: str,
|
|
59
|
+
provider_args: list[str],
|
|
60
|
+
workspace: Path,
|
|
61
|
+
*,
|
|
62
|
+
attach_existing: bool = False,
|
|
63
|
+
confirm_attach: bool = False,
|
|
64
|
+
attach_session: str | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
plan = leader_start_plan(
|
|
67
|
+
provider,
|
|
68
|
+
provider_args,
|
|
69
|
+
workspace,
|
|
70
|
+
attach_existing=attach_existing,
|
|
71
|
+
confirm_attach=confirm_attach,
|
|
72
|
+
attach_session=attach_session,
|
|
73
|
+
)
|
|
74
|
+
if plan["mode"] == "new_tmux_session" and not sys.stdin.isatty():
|
|
75
|
+
plan = dict(plan)
|
|
76
|
+
argv = list(plan["argv"])
|
|
77
|
+
argv.insert(2, "-d")
|
|
78
|
+
plan["argv"] = argv
|
|
79
|
+
plan["detached"] = True
|
|
80
|
+
EventLog(workspace).write(
|
|
81
|
+
"leader.start",
|
|
82
|
+
provider=provider,
|
|
83
|
+
workspace=str(workspace),
|
|
84
|
+
mode=plan["mode"],
|
|
85
|
+
session_name=plan.get("session_name"),
|
|
86
|
+
argv=plan["argv"],
|
|
87
|
+
)
|
|
88
|
+
_run_leader_plan(plan, workspace)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def leader_start_plan(
|
|
92
|
+
provider: str,
|
|
93
|
+
provider_args: list[str],
|
|
94
|
+
workspace: Path,
|
|
95
|
+
*,
|
|
96
|
+
attach_existing: bool = False,
|
|
97
|
+
confirm_attach: bool = False,
|
|
98
|
+
attach_session: str | None = None,
|
|
99
|
+
) -> dict[str, Any]:
|
|
100
|
+
from team_agent.runtime import (
|
|
101
|
+
RuntimeError,
|
|
102
|
+
_tmux_session_exists,
|
|
103
|
+
ensure_workspace_dirs,
|
|
104
|
+
get_adapter,
|
|
105
|
+
shutil_which,
|
|
106
|
+
)
|
|
107
|
+
workspace = workspace.resolve()
|
|
108
|
+
ensure_workspace_dirs(workspace)
|
|
109
|
+
adapter = get_adapter(provider)
|
|
110
|
+
if not adapter.is_installed():
|
|
111
|
+
raise RuntimeError(f"Provider {provider} command {adapter.command_name!r} not found")
|
|
112
|
+
argv = [adapter.command_name, *provider_args]
|
|
113
|
+
if attach_session:
|
|
114
|
+
if not confirm_attach:
|
|
115
|
+
raise RuntimeError("--attach-session requires --confirm")
|
|
116
|
+
return {
|
|
117
|
+
"mode": "attach_existing",
|
|
118
|
+
"provider": provider,
|
|
119
|
+
"workspace": str(workspace),
|
|
120
|
+
"session_name": attach_session,
|
|
121
|
+
"argv": ["tmux", "attach-session", "-t", attach_session],
|
|
122
|
+
}
|
|
123
|
+
if os.environ.get("TMUX"):
|
|
124
|
+
return {"mode": "exec_provider", "provider": provider, "workspace": str(workspace), "argv": argv}
|
|
125
|
+
if not shutil_which("tmux"):
|
|
126
|
+
raise RuntimeError("tmux is not installed; install tmux 3.3+ or start the leader from an existing tmux pane")
|
|
127
|
+
session_name = leader_session_name(provider, workspace)
|
|
128
|
+
if _tmux_session_exists(session_name):
|
|
129
|
+
return {
|
|
130
|
+
"mode": "attach_existing",
|
|
131
|
+
"provider": provider,
|
|
132
|
+
"workspace": str(workspace),
|
|
133
|
+
"session_name": session_name,
|
|
134
|
+
"argv": ["tmux", "attach-session", "-t", session_name],
|
|
135
|
+
}
|
|
136
|
+
exports = ""
|
|
137
|
+
if os.environ.get("PATH"):
|
|
138
|
+
exports = f"PATH={shlex.quote(os.environ['PATH'])} "
|
|
139
|
+
shell = f"cd {shlex.quote(str(workspace))} && {exports}exec {shlex.join(argv)}"
|
|
140
|
+
tmux_args = ["tmux", "new-session", "-s", session_name, "-n", provider, "-c", str(workspace)]
|
|
141
|
+
return {
|
|
142
|
+
"mode": "new_tmux_session",
|
|
143
|
+
"provider": provider,
|
|
144
|
+
"workspace": str(workspace),
|
|
145
|
+
"session_name": session_name,
|
|
146
|
+
"argv": [*tmux_args, "sh", "-lc", shell],
|
|
147
|
+
"detached": False,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _run_leader_plan(plan: dict[str, Any], workspace: Path) -> None:
|
|
152
|
+
session_name = plan.get("session_name")
|
|
153
|
+
proc: subprocess.Popen[Any] | None = None
|
|
154
|
+
sigints = 0
|
|
155
|
+
|
|
156
|
+
def stop_process_tree() -> None:
|
|
157
|
+
if session_name and plan["mode"] == "new_tmux_session":
|
|
158
|
+
subprocess.run(["tmux", "kill-session", "-t", str(session_name)], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
159
|
+
if proc and proc.poll() is None:
|
|
160
|
+
proc.terminate()
|
|
161
|
+
|
|
162
|
+
def handle_sigint(signum: int, _frame: Any) -> None:
|
|
163
|
+
nonlocal sigints
|
|
164
|
+
sigints += 1
|
|
165
|
+
if proc and proc.poll() is None:
|
|
166
|
+
try:
|
|
167
|
+
proc.send_signal(signum)
|
|
168
|
+
except ProcessLookupError:
|
|
169
|
+
pass
|
|
170
|
+
if sigints >= 2:
|
|
171
|
+
stop_process_tree()
|
|
172
|
+
|
|
173
|
+
old_sigint = signal.signal(signal.SIGINT, handle_sigint)
|
|
174
|
+
try:
|
|
175
|
+
if plan["mode"] == "exec_provider":
|
|
176
|
+
os.chdir(workspace)
|
|
177
|
+
proc = subprocess.Popen(plan["argv"])
|
|
178
|
+
if plan.get("detached") and session_name:
|
|
179
|
+
proc.wait()
|
|
180
|
+
while _tmux_session_exists_local(str(session_name)):
|
|
181
|
+
time.sleep(0.2)
|
|
182
|
+
else:
|
|
183
|
+
proc.wait()
|
|
184
|
+
finally:
|
|
185
|
+
signal.signal(signal.SIGINT, old_sigint)
|
|
186
|
+
_print_team_running_reminder(workspace)
|
|
187
|
+
raise SystemExit(proc.returncode if proc else 1)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _tmux_session_exists_local(session_name: str) -> bool:
|
|
191
|
+
proc = subprocess.run(["tmux", "has-session", "-t", session_name], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
192
|
+
return proc.returncode == 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _print_team_running_reminder(workspace: Path) -> None:
|
|
196
|
+
state = load_runtime_state(workspace)
|
|
197
|
+
team_name = state.get("session_name")
|
|
198
|
+
if not team_name or not _tmux_session_exists_local(str(team_name)):
|
|
199
|
+
return
|
|
200
|
+
print(f"team {team_name} is still running; run team-agent shutdown to close it OR team-agent attach-leader to reconnect.")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def leader_session_name(provider: str, workspace: Path) -> str:
|
|
204
|
+
digest = hashlib.sha1(str(workspace.resolve()).encode("utf-8")).hexdigest()[:8]
|
|
205
|
+
folder = re.sub(r"[^A-Za-z0-9_.-]", "_", workspace.name)[:48].strip("._-") or "workspace"
|
|
206
|
+
return f"team-agent-leader-{provider}-{folder}-{digest}"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def attach_leader_to_state(
|
|
210
|
+
workspace: Path,
|
|
211
|
+
state: dict[str, Any],
|
|
212
|
+
pane: str | None,
|
|
213
|
+
provider: str,
|
|
214
|
+
event_log: EventLog,
|
|
215
|
+
source: str,
|
|
216
|
+
require_current: bool = False,
|
|
217
|
+
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
218
|
+
from team_agent.runtime import (
|
|
219
|
+
RuntimeError,
|
|
220
|
+
_leader_command_provider,
|
|
221
|
+
_resolve_leader_pane,
|
|
222
|
+
_target_fingerprint,
|
|
223
|
+
_validate_leader_receiver,
|
|
224
|
+
get_adapter,
|
|
225
|
+
)
|
|
226
|
+
get_adapter(provider)
|
|
227
|
+
pane_info, discovery = _resolve_leader_pane(pane, provider, workspace=workspace, require_current=require_current)
|
|
228
|
+
inferred_provider = _leader_command_provider(pane_info.get("pane_current_command", ""))
|
|
229
|
+
receiver_provider = inferred_provider or provider
|
|
230
|
+
receiver = {
|
|
231
|
+
"mode": "direct_tmux",
|
|
232
|
+
"status": "attached",
|
|
233
|
+
"provider": receiver_provider,
|
|
234
|
+
"pane_id": pane_info["pane_id"],
|
|
235
|
+
"session_name": pane_info["session_name"],
|
|
236
|
+
"window_index": pane_info["window_index"],
|
|
237
|
+
"window_name": pane_info["window_name"],
|
|
238
|
+
"pane_index": pane_info["pane_index"],
|
|
239
|
+
"pane_tty": pane_info["pane_tty"],
|
|
240
|
+
"pane_current_command": pane_info["pane_current_command"],
|
|
241
|
+
"fingerprint": _target_fingerprint(pane_info),
|
|
242
|
+
"attached_at": datetime.now(timezone.utc).isoformat(),
|
|
243
|
+
"discovery": discovery,
|
|
244
|
+
}
|
|
245
|
+
if receiver_provider != provider:
|
|
246
|
+
receiver["requested_provider"] = provider
|
|
247
|
+
validation = _validate_leader_receiver(receiver)
|
|
248
|
+
if not validation["ok"]:
|
|
249
|
+
event_log.write(
|
|
250
|
+
"leader_receiver.attach_failed",
|
|
251
|
+
target=pane or pane_info.get("pane_id"),
|
|
252
|
+
discovery=discovery,
|
|
253
|
+
provider=provider,
|
|
254
|
+
reason=validation["reason"],
|
|
255
|
+
error=validation.get("error"),
|
|
256
|
+
source=source,
|
|
257
|
+
)
|
|
258
|
+
raise RuntimeError(f"leader pane validation failed: {validation['reason']}")
|
|
259
|
+
if validation.get("warning"):
|
|
260
|
+
receiver["warning"] = validation["warning"]
|
|
261
|
+
state["leader_receiver"] = receiver
|
|
262
|
+
event_log.write(
|
|
263
|
+
"leader_receiver.attached",
|
|
264
|
+
target=receiver["pane_id"],
|
|
265
|
+
session_name=receiver["session_name"],
|
|
266
|
+
window_index=receiver["window_index"],
|
|
267
|
+
window_name=receiver["window_name"],
|
|
268
|
+
pane_index=receiver["pane_index"],
|
|
269
|
+
pane_tty=receiver["pane_tty"],
|
|
270
|
+
pane_current_command=receiver["pane_current_command"],
|
|
271
|
+
provider=receiver_provider,
|
|
272
|
+
requested_provider=provider if receiver_provider != provider else None,
|
|
273
|
+
discovery=discovery,
|
|
274
|
+
source=source,
|
|
275
|
+
)
|
|
276
|
+
return receiver, validation
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def autobind_leader_receiver_from_env(
|
|
280
|
+
workspace: Path,
|
|
281
|
+
provider: str,
|
|
282
|
+
source: str,
|
|
283
|
+
) -> dict[str, Any] | None:
|
|
284
|
+
tmux_pane = os.environ.get("TMUX_PANE")
|
|
285
|
+
if not tmux_pane:
|
|
286
|
+
return None
|
|
287
|
+
from team_agent.runtime import ensure_workspace_dirs
|
|
288
|
+
ensure_workspace_dirs(workspace)
|
|
289
|
+
state = load_runtime_state(workspace)
|
|
290
|
+
event_log = EventLog(workspace)
|
|
291
|
+
try:
|
|
292
|
+
receiver, _validation = attach_leader_to_state(
|
|
293
|
+
workspace,
|
|
294
|
+
state,
|
|
295
|
+
pane=tmux_pane,
|
|
296
|
+
provider=provider,
|
|
297
|
+
event_log=event_log,
|
|
298
|
+
source=source,
|
|
299
|
+
)
|
|
300
|
+
except Exception as exc:
|
|
301
|
+
event_log.write(
|
|
302
|
+
"leader_receiver.autobind_skipped",
|
|
303
|
+
pane=tmux_pane,
|
|
304
|
+
provider=provider,
|
|
305
|
+
source=source,
|
|
306
|
+
error=str(exc),
|
|
307
|
+
)
|
|
308
|
+
return None
|
|
309
|
+
save_runtime_state(workspace, state)
|
|
310
|
+
return receiver
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
__all__ = [
|
|
314
|
+
"attach_leader",
|
|
315
|
+
"attach_leader_to_state",
|
|
316
|
+
"autobind_leader_receiver_from_env",
|
|
317
|
+
"leader_session_name",
|
|
318
|
+
"leader_start_plan",
|
|
319
|
+
"start_leader",
|
|
320
|
+
]
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from team_agent.errors import RuntimeError
|
|
8
|
+
from team_agent.events import EventLog
|
|
9
|
+
from team_agent.message_store import MessageStore
|
|
10
|
+
from team_agent.spec import load_spec, validate_spec
|
|
11
|
+
from team_agent.state import (
|
|
12
|
+
check_team_owner,
|
|
13
|
+
load_runtime_state,
|
|
14
|
+
resolve_team_scoped_state,
|
|
15
|
+
save_runtime_state,
|
|
16
|
+
save_team_scoped_state,
|
|
17
|
+
write_spec,
|
|
18
|
+
write_team_state,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def remove_agent(
|
|
23
|
+
workspace: Path,
|
|
24
|
+
agent_id: str,
|
|
25
|
+
*,
|
|
26
|
+
from_spec: bool = False,
|
|
27
|
+
confirm: bool = False,
|
|
28
|
+
force: bool = False,
|
|
29
|
+
team: str | None = None,
|
|
30
|
+
) -> dict[str, Any]:
|
|
31
|
+
import team_agent.runtime as runtime
|
|
32
|
+
|
|
33
|
+
workspace = workspace.resolve()
|
|
34
|
+
state, refusal = resolve_team_scoped_state(workspace, team)
|
|
35
|
+
if refusal:
|
|
36
|
+
return refusal
|
|
37
|
+
gate = check_team_owner(state)
|
|
38
|
+
if gate:
|
|
39
|
+
return gate
|
|
40
|
+
spec_path = Path(state.get("spec_path", workspace / "team.spec.yaml"))
|
|
41
|
+
spec = load_spec(spec_path)
|
|
42
|
+
agent = _find_worker(spec, agent_id)
|
|
43
|
+
if not agent:
|
|
44
|
+
raise RuntimeError(f"unknown worker agent id: {agent_id}")
|
|
45
|
+
|
|
46
|
+
event_log = EventLog(workspace)
|
|
47
|
+
store = MessageStore(workspace)
|
|
48
|
+
agent_state = state.get("agents", {}).get(agent_id) or {}
|
|
49
|
+
dynamic_role_file = _dynamic_role_file(workspace, agent_id, agent_state)
|
|
50
|
+
dynamic_agent = bool(agent_state.get("dynamic_role_file") or agent.get("forked_from"))
|
|
51
|
+
running = _is_running(runtime, state, agent_id, agent_state)
|
|
52
|
+
|
|
53
|
+
if not dynamic_agent and not (from_spec and confirm):
|
|
54
|
+
return {"ok": False, "agent_id": agent_id, "status": "refused", "reason": "from_spec_confirm_required"}
|
|
55
|
+
if running and not force:
|
|
56
|
+
return {"ok": False, "agent_id": agent_id, "status": "refused", "reason": "force_required"}
|
|
57
|
+
|
|
58
|
+
rollback = _RemoveRollback(workspace, spec_path, spec, state, dynamic_role_file, store, agent_id, False)
|
|
59
|
+
stopped: dict[str, Any] | None = None
|
|
60
|
+
try:
|
|
61
|
+
if running and force:
|
|
62
|
+
stopped = runtime.stop_agent(workspace, agent_id, team=team)
|
|
63
|
+
rollback.restore_running = True
|
|
64
|
+
state, _refusal_after = resolve_team_scoped_state(workspace, team)
|
|
65
|
+
removed_state = copy.deepcopy(state)
|
|
66
|
+
removed_state.get("agents", {}).pop(agent_id, None)
|
|
67
|
+
save_team_scoped_state(workspace, removed_state)
|
|
68
|
+
|
|
69
|
+
removed_spec = copy.deepcopy(spec)
|
|
70
|
+
removed_spec["agents"] = [item for item in removed_spec.get("agents", []) if item.get("id") != agent_id]
|
|
71
|
+
startup_order = removed_spec.get("runtime", {}).get("startup_order")
|
|
72
|
+
if isinstance(startup_order, list):
|
|
73
|
+
removed_spec["runtime"]["startup_order"] = [item for item in startup_order if item != agent_id]
|
|
74
|
+
validate_spec(removed_spec, base_dir=spec_path.parent)
|
|
75
|
+
team_state_path = write_team_state(workspace, removed_spec, removed_state)
|
|
76
|
+
write_spec(spec_path, removed_spec)
|
|
77
|
+
|
|
78
|
+
role_file_removed = _remove_dynamic_role_file(dynamic_role_file, bool(agent_state.get("dynamic_role_file")))
|
|
79
|
+
_delete_agent_health(store, agent_id)
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
rollback_result = rollback.restore(runtime, event_log)
|
|
82
|
+
event_log.write("remove_agent.rollback", agent_id=agent_id, ok=rollback_result["ok"], error=str(exc), rollback=rollback_result)
|
|
83
|
+
raise RuntimeError(f"remove-agent failed for {agent_id}: {exc}; rollback_ok={rollback_result['ok']}") from exc
|
|
84
|
+
|
|
85
|
+
runtime._save_team_runtime_snapshot(workspace, removed_state)
|
|
86
|
+
warning = None
|
|
87
|
+
try:
|
|
88
|
+
# Storage commit is authoritative; final success event logging is best-effort.
|
|
89
|
+
event_log.write(
|
|
90
|
+
"remove_agent.complete",
|
|
91
|
+
agent_id=agent_id,
|
|
92
|
+
from_spec=from_spec,
|
|
93
|
+
force=force,
|
|
94
|
+
stopped=stopped,
|
|
95
|
+
role_file_removed=role_file_removed,
|
|
96
|
+
)
|
|
97
|
+
except Exception as exc:
|
|
98
|
+
warning = f"remove-agent completed but success event logging failed: {exc}"
|
|
99
|
+
return {
|
|
100
|
+
"ok": True,
|
|
101
|
+
"agent_id": agent_id,
|
|
102
|
+
"status": "removed",
|
|
103
|
+
"from_spec": from_spec,
|
|
104
|
+
"force": force,
|
|
105
|
+
"stopped": stopped,
|
|
106
|
+
"state_file": str(team_state_path),
|
|
107
|
+
"role_file_removed": role_file_removed,
|
|
108
|
+
**({"warning": warning} if warning else {}),
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class _RemoveRollback:
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
workspace: Path,
|
|
116
|
+
spec_path: Path,
|
|
117
|
+
spec: dict[str, Any],
|
|
118
|
+
state: dict[str, Any],
|
|
119
|
+
dynamic_role_file: Path,
|
|
120
|
+
store: MessageStore,
|
|
121
|
+
agent_id: str,
|
|
122
|
+
restore_running: bool,
|
|
123
|
+
) -> None:
|
|
124
|
+
self.workspace = workspace
|
|
125
|
+
self.spec_path = spec_path
|
|
126
|
+
self.spec_text = spec_path.read_text(encoding="utf-8")
|
|
127
|
+
self.spec = copy.deepcopy(spec)
|
|
128
|
+
self.state = copy.deepcopy(state)
|
|
129
|
+
self.team_state_path = workspace / spec.get("context", {}).get("state_file", "team_state.md")
|
|
130
|
+
self.team_state_text = self.team_state_path.read_text(encoding="utf-8") if self.team_state_path.exists() else None
|
|
131
|
+
self.dynamic_role_file = dynamic_role_file
|
|
132
|
+
self.dynamic_role_bytes = dynamic_role_file.read_bytes() if dynamic_role_file.exists() else None
|
|
133
|
+
self.health = copy.deepcopy(store.agent_health().get(agent_id))
|
|
134
|
+
self.agent_id = agent_id
|
|
135
|
+
self.restore_running = restore_running
|
|
136
|
+
|
|
137
|
+
def restore(self, runtime: Any, event_log: EventLog) -> dict[str, Any]:
|
|
138
|
+
errors: list[str] = []
|
|
139
|
+
try:
|
|
140
|
+
self.spec_path.write_text(self.spec_text, encoding="utf-8")
|
|
141
|
+
except Exception as exc:
|
|
142
|
+
errors.append(f"spec:{exc}")
|
|
143
|
+
try:
|
|
144
|
+
save_team_scoped_state(self.workspace, self.state)
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
errors.append(f"workspace_state:{exc}")
|
|
147
|
+
try:
|
|
148
|
+
if self.team_state_text is None:
|
|
149
|
+
self.team_state_path.unlink(missing_ok=True)
|
|
150
|
+
else:
|
|
151
|
+
self.team_state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
self.team_state_path.write_text(self.team_state_text, encoding="utf-8")
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
errors.append(f"team_state:{exc}")
|
|
155
|
+
try:
|
|
156
|
+
if self.dynamic_role_bytes is None:
|
|
157
|
+
self.dynamic_role_file.unlink(missing_ok=True)
|
|
158
|
+
else:
|
|
159
|
+
self.dynamic_role_file.parent.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
self.dynamic_role_file.write_bytes(self.dynamic_role_bytes)
|
|
161
|
+
except Exception as exc:
|
|
162
|
+
errors.append(f"role_file:{exc}")
|
|
163
|
+
try:
|
|
164
|
+
_restore_agent_health(MessageStore(self.workspace), self.agent_id, self.health)
|
|
165
|
+
except Exception as exc:
|
|
166
|
+
errors.append(f"agent_health:{exc}")
|
|
167
|
+
if self.restore_running and not errors:
|
|
168
|
+
try:
|
|
169
|
+
runtime.start_agent(self.workspace, self.agent_id, force=True, allow_fresh=True)
|
|
170
|
+
except Exception as exc:
|
|
171
|
+
errors.append(f"worker_restore:{exc}")
|
|
172
|
+
result = {"ok": not errors, "errors": errors}
|
|
173
|
+
if errors:
|
|
174
|
+
event_log.write("remove_agent.rollback_failed", agent_id=self.agent_id, errors=errors)
|
|
175
|
+
return result
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _find_worker(spec: dict[str, Any], agent_id: str) -> dict[str, Any] | None:
|
|
179
|
+
if spec.get("leader", {}).get("id") == agent_id:
|
|
180
|
+
return None
|
|
181
|
+
for agent in spec.get("agents", []):
|
|
182
|
+
if agent.get("id") == agent_id:
|
|
183
|
+
return agent
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _dynamic_role_file(workspace: Path, agent_id: str, agent_state: dict[str, Any]) -> Path:
|
|
188
|
+
raw = agent_state.get("dynamic_role_file")
|
|
189
|
+
if raw:
|
|
190
|
+
path = Path(str(raw))
|
|
191
|
+
return path if path.is_absolute() else workspace / path
|
|
192
|
+
return workspace / ".team" / "dynamic-role-files" / f"{agent_id}.md"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _is_running(runtime: Any, state: dict[str, Any], agent_id: str, agent_state: dict[str, Any]) -> bool:
|
|
196
|
+
if str(agent_state.get("status") or "").lower() in {"running", "busy"}:
|
|
197
|
+
return True
|
|
198
|
+
session_name = state.get("session_name")
|
|
199
|
+
window = agent_state.get("window") or agent_id
|
|
200
|
+
return bool(session_name and runtime._tmux_window_exists(str(session_name), str(window)))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _remove_dynamic_role_file(path: Path, required: bool) -> bool:
|
|
204
|
+
if path.exists():
|
|
205
|
+
path.unlink()
|
|
206
|
+
return True
|
|
207
|
+
if required:
|
|
208
|
+
raise RuntimeError(f"dynamic role file missing: {path}")
|
|
209
|
+
return False
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _delete_agent_health(store: MessageStore, agent_id: str) -> None:
|
|
213
|
+
store.delete_agent_health(agent_id)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _restore_agent_health(store: MessageStore, agent_id: str, row: dict[str, Any] | None) -> None:
|
|
217
|
+
if not row:
|
|
218
|
+
_delete_agent_health(store, agent_id)
|
|
219
|
+
return
|
|
220
|
+
store.upsert_agent_health(
|
|
221
|
+
agent_id,
|
|
222
|
+
row.get("status") or "IDLE",
|
|
223
|
+
last_output_at=row.get("last_output_at"),
|
|
224
|
+
context_usage_pct=row.get("context_usage_pct"),
|
|
225
|
+
current_task_id=row.get("current_task_id"),
|
|
226
|
+
)
|