@team-agent/installer 0.2.5 → 0.2.7
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/README.md +22 -0
- package/npm/bincheck.mjs +70 -0
- package/package.json +2 -1
- package/skills/team-agent/references/bug-as-artifact-flow.md +82 -0
- package/src/team_agent/_legacy_pane_discovery.py +189 -0
- package/src/team_agent/cli/helpers.py +89 -0
- package/src/team_agent/cli/parser.py +5 -0
- package/src/team_agent/diagnose/quick_start.py +1 -1
- package/src/team_agent/leader_binding.py +183 -0
- package/src/team_agent/mcp_server/tools.py +211 -64
- package/src/team_agent/message_store/schema.py +2 -9
- package/src/team_agent/message_store/schema_migration.py +123 -63
- package/src/team_agent/messaging/deps.py +1 -17
- package/src/team_agent/messaging/leader.py +2 -3
- package/src/team_agent/messaging/leader_panes.py +43 -166
- package/src/team_agent/messaging/scheduler.py +1 -1
- package/src/team_agent/provider_cli/adapter.py +10 -5
- package/src/team_agent/provider_cli/codex.py +26 -9
- package/src/team_agent/restart/orchestration.py +12 -0
- package/src/team_agent/runtime.py +246 -79
- package/src/team_agent/state.py +146 -31
package/src/team_agent/state.py
CHANGED
|
@@ -4,6 +4,7 @@ import hashlib
|
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
6
|
import copy
|
|
7
|
+
import subprocess
|
|
7
8
|
import uuid
|
|
8
9
|
from datetime import datetime, timezone
|
|
9
10
|
from pathlib import Path
|
|
@@ -51,14 +52,37 @@ def normalize_agent_session_state(state: dict[str, Any]) -> None:
|
|
|
51
52
|
def load_runtime_state(workspace: Path) -> dict[str, Any]:
|
|
52
53
|
path = runtime_state_path(workspace)
|
|
53
54
|
if not path.exists():
|
|
54
|
-
return {"agents": {}, "tasks": [], "session_name": None}
|
|
55
|
+
return {"agents": {}, "tasks": [], "session_name": None, "active_team_key": None}
|
|
55
56
|
state = json.loads(path.read_text(encoding="utf-8"))
|
|
56
57
|
normalize_agent_session_state(state)
|
|
57
|
-
|
|
58
|
+
changed = _migrate_state_identity(state, workspace)
|
|
59
|
+
if _migrate_active_team_key(state):
|
|
60
|
+
changed = True
|
|
61
|
+
if changed:
|
|
58
62
|
save_runtime_state(workspace, state)
|
|
59
63
|
return state
|
|
60
64
|
|
|
61
65
|
|
|
66
|
+
def _migrate_active_team_key(state: dict[str, Any]) -> bool:
|
|
67
|
+
"""0.2.6 Family B (C6): legacy states with a top-level ``session_name``
|
|
68
|
+
but no ``active_team_key`` get the active pointer seeded once. After
|
|
69
|
+
this, ``active_team_key`` is the single explicit source of truth and
|
|
70
|
+
callers mutate it through CLI verbs (claim-leader / takeover /
|
|
71
|
+
shutdown / restart)."""
|
|
72
|
+
if "active_team_key" in state:
|
|
73
|
+
return False
|
|
74
|
+
teams = state.get("teams") if isinstance(state.get("teams"), dict) else {}
|
|
75
|
+
if state.get("session_name"):
|
|
76
|
+
seed = team_state_key(state)
|
|
77
|
+
state["active_team_key"] = seed if seed in teams or not teams else seed
|
|
78
|
+
return True
|
|
79
|
+
if isinstance(teams, dict) and len(teams) == 1:
|
|
80
|
+
state["active_team_key"] = next(iter(teams))
|
|
81
|
+
return True
|
|
82
|
+
state["active_team_key"] = None
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
|
|
62
86
|
def team_state_key(state: dict[str, Any]) -> str:
|
|
63
87
|
for field in ("team_dir", "spec_path"):
|
|
64
88
|
value = state.get(field)
|
|
@@ -98,58 +122,110 @@ def merge_workspace_team_state(existing: dict[str, Any], launched: dict[str, Any
|
|
|
98
122
|
|
|
99
123
|
|
|
100
124
|
def team_state_candidates(state: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if state.get("
|
|
108
|
-
|
|
109
|
-
|
|
125
|
+
"""0.2.6 Family B (C7): the only candidate source is ``state.teams``
|
|
126
|
+
filtered by ``status == "alive"``. Top-level ``session_name`` /
|
|
127
|
+
``team_dir`` are a derived view of the active team and never count as
|
|
128
|
+
an independent candidate. Shutdown/legacy entries with non-alive
|
|
129
|
+
status are excluded."""
|
|
130
|
+
out: dict[str, dict[str, Any]] = {}
|
|
131
|
+
teams = state.get("teams") if isinstance(state.get("teams"), dict) else {}
|
|
132
|
+
for key, value in teams.items():
|
|
133
|
+
if not isinstance(value, dict):
|
|
134
|
+
continue
|
|
135
|
+
if str(value.get("status") or "alive").lower() != "alive":
|
|
136
|
+
continue
|
|
137
|
+
out[str(key)] = value
|
|
138
|
+
return out
|
|
110
139
|
|
|
111
140
|
|
|
112
|
-
def format_team_candidates(
|
|
113
|
-
if not
|
|
141
|
+
def format_team_candidates(team_states: dict[str, dict[str, Any]]) -> str:
|
|
142
|
+
if not team_states:
|
|
114
143
|
return "No team state was found."
|
|
115
144
|
parts = []
|
|
116
|
-
for key
|
|
117
|
-
|
|
118
|
-
|
|
145
|
+
for key in sorted(team_states):
|
|
146
|
+
st = team_states[key]
|
|
147
|
+
agents = ",".join(sorted(st.get("agents", {}).keys())) or "-"
|
|
148
|
+
parts.append(f"{key} session={st.get('session_name') or '-'} agents={agents}")
|
|
119
149
|
return "Candidates: " + "; ".join(parts)
|
|
120
150
|
|
|
121
151
|
|
|
152
|
+
def _team_entry_from_state(state: dict[str, Any], team_key: str) -> dict[str, Any] | None:
|
|
153
|
+
teams = state.get("teams") if isinstance(state.get("teams"), dict) else {}
|
|
154
|
+
entry = teams.get(team_key)
|
|
155
|
+
if not isinstance(entry, dict):
|
|
156
|
+
return None
|
|
157
|
+
return entry
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _project_top_level_view(state: dict[str, Any], team_key: str) -> dict[str, Any]:
|
|
161
|
+
"""0.2.6 Family B (C8): when picking a team for use, the top-level
|
|
162
|
+
keys (``session_name`` / ``team_dir`` / ``agents`` / ``tasks``) are a
|
|
163
|
+
derived view of ``teams[team_key]``. We copy the team entry into a
|
|
164
|
+
flat dict and preserve any auxiliary state (``team_owner`` /
|
|
165
|
+
``leader_receiver`` / ``coordinator`` already pinned to the team)."""
|
|
166
|
+
entry = _team_entry_from_state(state, team_key) or {}
|
|
167
|
+
projection = copy.deepcopy(entry)
|
|
168
|
+
projection.setdefault("session_name", entry.get("session_name"))
|
|
169
|
+
projection.setdefault("team_dir", entry.get("team_dir"))
|
|
170
|
+
projection["active_team_key"] = team_key
|
|
171
|
+
# Preserve the full teams dict so consumers can introspect siblings.
|
|
172
|
+
projection["teams"] = copy.deepcopy(state.get("teams") or {})
|
|
173
|
+
if "team_owner" in entry:
|
|
174
|
+
projection["team_owner"] = copy.deepcopy(entry["team_owner"])
|
|
175
|
+
elif state.get("team_owner") is not None:
|
|
176
|
+
projection["team_owner"] = copy.deepcopy(state["team_owner"])
|
|
177
|
+
if "leader_receiver" in entry:
|
|
178
|
+
projection["leader_receiver"] = copy.deepcopy(entry["leader_receiver"])
|
|
179
|
+
elif state.get("leader_receiver") is not None:
|
|
180
|
+
projection["leader_receiver"] = copy.deepcopy(state["leader_receiver"])
|
|
181
|
+
if "coordinator" in state:
|
|
182
|
+
projection.setdefault("coordinator", copy.deepcopy(state["coordinator"]))
|
|
183
|
+
return projection
|
|
184
|
+
|
|
185
|
+
|
|
122
186
|
def select_runtime_state(workspace: Path, team: str | None = None) -> dict[str, Any]:
|
|
123
187
|
state = load_runtime_state(workspace)
|
|
124
|
-
|
|
188
|
+
alive = team_state_candidates(state)
|
|
125
189
|
if team:
|
|
126
190
|
matches = [
|
|
127
|
-
value
|
|
128
|
-
for key, value in
|
|
191
|
+
(key, value)
|
|
192
|
+
for key, value in alive.items()
|
|
129
193
|
if team in {key, str(value.get("session_name") or ""), str(value.get("team_dir") or "")}
|
|
130
194
|
]
|
|
131
195
|
if len(matches) == 1:
|
|
132
|
-
return
|
|
196
|
+
return _project_top_level_view(state, matches[0][0])
|
|
133
197
|
from team_agent.errors import RuntimeError
|
|
134
198
|
if len(matches) > 1:
|
|
135
|
-
raise RuntimeError("team selector is ambiguous. " + format_team_candidates(
|
|
136
|
-
raise RuntimeError(f"team {team!r} not found. " + format_team_candidates(
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
199
|
+
raise RuntimeError("team selector is ambiguous. " + format_team_candidates(alive))
|
|
200
|
+
raise RuntimeError(f"team {team!r} not found. " + format_team_candidates(alive))
|
|
201
|
+
active = state.get("active_team_key")
|
|
202
|
+
if active and active in alive:
|
|
203
|
+
return _project_top_level_view(state, str(active))
|
|
204
|
+
if len(alive) == 1:
|
|
205
|
+
return _project_top_level_view(state, next(iter(alive)))
|
|
206
|
+
if not alive:
|
|
207
|
+
return copy.deepcopy(state)
|
|
208
|
+
from team_agent.errors import RuntimeError
|
|
209
|
+
raise RuntimeError(
|
|
210
|
+
"multiple teams found in this workspace; pass --team <team> to choose. "
|
|
211
|
+
+ format_team_candidates(alive)
|
|
212
|
+
)
|
|
141
213
|
|
|
142
214
|
|
|
143
215
|
def ambiguous_team_target_result(state: dict[str, Any]) -> dict[str, Any] | None:
|
|
144
|
-
|
|
145
|
-
|
|
216
|
+
alive = team_state_candidates(state)
|
|
217
|
+
active = state.get("active_team_key")
|
|
218
|
+
if active and active in alive:
|
|
219
|
+
return None
|
|
220
|
+
if len(alive) <= 1:
|
|
146
221
|
return None
|
|
147
222
|
return {
|
|
148
223
|
"ok": False,
|
|
149
224
|
"status": "refused",
|
|
150
225
|
"reason": "team_target_ambiguous",
|
|
151
|
-
"candidates": sorted(
|
|
152
|
-
"message": "multiple teams found in this workspace; pass --team <team> to choose. "
|
|
226
|
+
"candidates": sorted(alive.keys()),
|
|
227
|
+
"message": "multiple teams found in this workspace; pass --team <team> to choose. "
|
|
228
|
+
+ format_team_candidates(alive),
|
|
153
229
|
}
|
|
154
230
|
|
|
155
231
|
|
|
@@ -238,7 +314,7 @@ def _caller_identity_from_env(state: dict[str, Any] | None = None, team_id: str
|
|
|
238
314
|
team_id or os.environ.get("TEAM_AGENT_TEAM_ID") or team_state_key(state),
|
|
239
315
|
)
|
|
240
316
|
return {
|
|
241
|
-
"pane_id": os.environ.get("TEAM_AGENT_LEADER_PANE_ID") or "",
|
|
317
|
+
"pane_id": os.environ.get("TEAM_AGENT_LEADER_PANE_ID") or os.environ.get("TMUX_PANE") or "",
|
|
242
318
|
"provider": os.environ.get("TEAM_AGENT_LEADER_PROVIDER") or "",
|
|
243
319
|
"machine_fingerprint": machine_fingerprint,
|
|
244
320
|
"leader_session_uuid": leader_uuid,
|
|
@@ -246,6 +322,36 @@ def _caller_identity_from_env(state: dict[str, Any] | None = None, team_id: str
|
|
|
246
322
|
}
|
|
247
323
|
|
|
248
324
|
|
|
325
|
+
_TMUX_PANE_LIVE = "live"
|
|
326
|
+
_TMUX_PANE_DEAD = "dead"
|
|
327
|
+
_TMUX_PANE_UNKNOWN = "unknown"
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _tmux_pane_liveness(pane_id: str) -> str:
|
|
331
|
+
if not pane_id:
|
|
332
|
+
return _TMUX_PANE_UNKNOWN
|
|
333
|
+
try:
|
|
334
|
+
from team_agent.runtime import run_cmd
|
|
335
|
+
proc = run_cmd(["tmux", "display-message", "-p", "-t", pane_id, "#{pane_id}"], timeout=3)
|
|
336
|
+
except Exception:
|
|
337
|
+
try:
|
|
338
|
+
proc = subprocess.run(
|
|
339
|
+
["tmux", "display-message", "-p", "-t", pane_id, "#{pane_id}"],
|
|
340
|
+
text=True,
|
|
341
|
+
capture_output=True,
|
|
342
|
+
timeout=3,
|
|
343
|
+
check=False,
|
|
344
|
+
)
|
|
345
|
+
except Exception:
|
|
346
|
+
return _TMUX_PANE_UNKNOWN
|
|
347
|
+
if proc.returncode == 0:
|
|
348
|
+
return _TMUX_PANE_LIVE
|
|
349
|
+
stderr = str(getattr(proc, "stderr", "") or "").lower()
|
|
350
|
+
if "can't find pane" in stderr or "can't find window" in stderr or "can't find session" in stderr:
|
|
351
|
+
return _TMUX_PANE_DEAD
|
|
352
|
+
return _TMUX_PANE_UNKNOWN
|
|
353
|
+
|
|
354
|
+
|
|
249
355
|
def check_team_owner(state: dict[str, Any]) -> dict[str, Any] | None:
|
|
250
356
|
owner = state.get("team_owner") or {}
|
|
251
357
|
if not owner:
|
|
@@ -256,6 +362,15 @@ def check_team_owner(state: dict[str, Any]) -> dict[str, Any] | None:
|
|
|
256
362
|
caller_uuid = caller["leader_session_uuid"]
|
|
257
363
|
owner_pane = str(owner.get("pane_id") or "")
|
|
258
364
|
caller_pane = caller.get("pane_id") or ""
|
|
365
|
+
if caller_pane and caller_pane == owner_pane:
|
|
366
|
+
return None
|
|
367
|
+
if (
|
|
368
|
+
caller_pane
|
|
369
|
+
and not os.environ.get("TEAM_AGENT_ID")
|
|
370
|
+
and owner_pane
|
|
371
|
+
and _tmux_pane_liveness(owner_pane) != _TMUX_PANE_LIVE
|
|
372
|
+
):
|
|
373
|
+
return None
|
|
259
374
|
if caller_uuid == owner_uuid and (not caller_pane or caller_pane == owner_pane):
|
|
260
375
|
return None
|
|
261
376
|
same_uuid = caller_uuid == owner_uuid
|