@team-agent/installer 0.2.1 → 0.2.2
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/package.json +1 -1
- package/src/team_agent/cli/commands.py +18 -3
- package/src/team_agent/cli/parser.py +33 -1
- package/src/team_agent/coordinator/__main__.py +21 -2
- package/src/team_agent/coordinator/lifecycle.py +8 -0
- package/src/team_agent/diagnose/orphan_cleanup.py +193 -0
- package/src/team_agent/events.py +47 -0
- package/src/team_agent/leader/__init__.py +273 -60
- package/src/team_agent/lifecycle/agents.py +54 -2
- package/src/team_agent/lifecycle/operations.py +86 -9
- package/src/team_agent/message_store/leader_notification_log.py +132 -0
- package/src/team_agent/message_store/result_watchers.py +144 -1
- package/src/team_agent/message_store/schema.py +23 -0
- package/src/team_agent/messaging/idle_alerts.py +109 -9
- package/src/team_agent/messaging/leader.py +166 -6
- package/src/team_agent/messaging/leader_panes.py +193 -23
- package/src/team_agent/messaging/result_delivery.py +219 -4
- package/src/team_agent/messaging/results.py +12 -21
- package/src/team_agent/messaging/scheduler.py +12 -2
- package/src/team_agent/runtime.py +4 -4
- package/src/team_agent/rust_core.py +157 -3
- package/src/team_agent/state.py +153 -10
- package/src/team_agent/status/inbox.py +33 -3
package/src/team_agent/state.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import hashlib
|
|
3
4
|
import json
|
|
4
5
|
import os
|
|
5
6
|
import copy
|
|
@@ -23,6 +24,14 @@ SESSION_STATE_FIELDS = [
|
|
|
23
24
|
*SESSION_CAPTURE_FIELDS,
|
|
24
25
|
"spawn_cwd",
|
|
25
26
|
]
|
|
27
|
+
_UUID_SEPARATOR = "\0"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def derive_leader_session_uuid(machine_fingerprint: str, workspace_abspath: str, os_user: str, team_id: str) -> str:
|
|
31
|
+
parts = [machine_fingerprint, workspace_abspath, os_user, team_id]
|
|
32
|
+
if any(_UUID_SEPARATOR in part for part in parts):
|
|
33
|
+
raise ValueError("leader_session_uuid inputs must not contain NUL")
|
|
34
|
+
return hashlib.sha256(_UUID_SEPARATOR.join(parts).encode("utf-8")).hexdigest()[:32]
|
|
26
35
|
|
|
27
36
|
|
|
28
37
|
def runtime_state_path(workspace: Path) -> Path:
|
|
@@ -45,6 +54,8 @@ def load_runtime_state(workspace: Path) -> dict[str, Any]:
|
|
|
45
54
|
return {"agents": {}, "tasks": [], "session_name": None}
|
|
46
55
|
state = json.loads(path.read_text(encoding="utf-8"))
|
|
47
56
|
normalize_agent_session_state(state)
|
|
57
|
+
if _migrate_state_identity(state, workspace):
|
|
58
|
+
save_runtime_state(workspace, state)
|
|
48
59
|
return state
|
|
49
60
|
|
|
50
61
|
|
|
@@ -163,11 +174,75 @@ def resolve_team_scoped_state(
|
|
|
163
174
|
}
|
|
164
175
|
|
|
165
176
|
|
|
166
|
-
def
|
|
177
|
+
def _identity_workspace_abspath(state: dict[str, Any], workspace: Path | None = None) -> str:
|
|
178
|
+
if state.get("workspace"):
|
|
179
|
+
return str(Path(str(state["workspace"])).resolve())
|
|
180
|
+
if state.get("team_dir"):
|
|
181
|
+
return str(Path(str(state["team_dir"])).resolve().parent.parent)
|
|
182
|
+
if state.get("spec_path"):
|
|
183
|
+
spec_path = Path(str(state["spec_path"])).resolve()
|
|
184
|
+
return str(spec_path.parent.parent.parent if spec_path.parent.parent.name == ".team" else spec_path.parent)
|
|
185
|
+
return str((workspace or Path(os.environ.get("TEAM_AGENT_WORKSPACE") or os.getcwd())).resolve())
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _identity_os_user() -> str:
|
|
189
|
+
return os.environ.get("USER") or os.environ.get("USERNAME") or ""
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _identity_machine_fingerprint(state: dict[str, Any]) -> str:
|
|
193
|
+
for record in (state.get("team_owner"), state.get("leader_receiver")):
|
|
194
|
+
if isinstance(record, dict) and record.get("machine_fingerprint"):
|
|
195
|
+
return str(record["machine_fingerprint"])
|
|
196
|
+
return os.environ.get("TEAM_AGENT_MACHINE_FINGERPRINT") or ""
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _leader_session_uuid_for_state(state: dict[str, Any], workspace: Path | None = None, team_id: str | None = None) -> str:
|
|
200
|
+
return derive_leader_session_uuid(
|
|
201
|
+
_identity_machine_fingerprint(state),
|
|
202
|
+
_identity_workspace_abspath(state, workspace),
|
|
203
|
+
_identity_os_user(),
|
|
204
|
+
team_id or team_state_key(state),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _migrate_team_identity(state: dict[str, Any], workspace: Path, team_id: str | None = None) -> bool:
|
|
209
|
+
leader_uuid = _leader_session_uuid_for_state(state, workspace, team_id)
|
|
210
|
+
changed = False
|
|
211
|
+
for key in ("team_owner", "leader_receiver"):
|
|
212
|
+
record = state.get(key)
|
|
213
|
+
if isinstance(record, dict) and not record.get("leader_session_uuid"):
|
|
214
|
+
record["leader_session_uuid"] = leader_uuid
|
|
215
|
+
changed = True
|
|
216
|
+
return changed
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _migrate_state_identity(state: dict[str, Any], workspace: Path) -> bool:
|
|
220
|
+
changed = _migrate_team_identity(state, workspace) if state.get("session_name") else False
|
|
221
|
+
teams = state.get("teams")
|
|
222
|
+
if isinstance(teams, dict):
|
|
223
|
+
for team_id, team_state in teams.items():
|
|
224
|
+
if isinstance(team_state, dict):
|
|
225
|
+
changed = _migrate_team_identity(team_state, workspace, str(team_id)) or changed
|
|
226
|
+
return changed
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _caller_identity_from_env(state: dict[str, Any] | None = None, team_id: str | None = None, workspace: Path | None = None) -> dict[str, str]:
|
|
230
|
+
state = state or {}
|
|
231
|
+
machine_fingerprint = os.environ.get("TEAM_AGENT_MACHINE_FINGERPRINT") or ""
|
|
232
|
+
override = os.environ.get("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE") or ""
|
|
233
|
+
env_uuid = os.environ.get("TEAM_AGENT_LEADER_SESSION_UUID") or ""
|
|
234
|
+
leader_uuid = override or env_uuid or derive_leader_session_uuid(
|
|
235
|
+
machine_fingerprint,
|
|
236
|
+
_identity_workspace_abspath(state, workspace),
|
|
237
|
+
_identity_os_user(),
|
|
238
|
+
team_id or os.environ.get("TEAM_AGENT_TEAM_ID") or team_state_key(state),
|
|
239
|
+
)
|
|
167
240
|
return {
|
|
168
241
|
"pane_id": os.environ.get("TEAM_AGENT_LEADER_PANE_ID") or "",
|
|
169
242
|
"provider": os.environ.get("TEAM_AGENT_LEADER_PROVIDER") or "",
|
|
170
|
-
"machine_fingerprint":
|
|
243
|
+
"machine_fingerprint": machine_fingerprint,
|
|
244
|
+
"leader_session_uuid": leader_uuid,
|
|
245
|
+
"leader_session_uuid_source": "explicit-override" if override else ("env" if env_uuid else "derived"),
|
|
171
246
|
}
|
|
172
247
|
|
|
173
248
|
|
|
@@ -175,19 +250,22 @@ def check_team_owner(state: dict[str, Any]) -> dict[str, Any] | None:
|
|
|
175
250
|
owner = state.get("team_owner") or {}
|
|
176
251
|
if not owner:
|
|
177
252
|
return None
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
)
|
|
253
|
+
_migrate_team_identity(state, Path(_identity_workspace_abspath(state)), team_state_key(state))
|
|
254
|
+
caller = _caller_identity_from_env(state, team_state_key(state))
|
|
255
|
+
owner_uuid = str(owner.get("leader_session_uuid") or "")
|
|
256
|
+
caller_uuid = caller["leader_session_uuid"]
|
|
257
|
+
owner_pane = str(owner.get("pane_id") or "")
|
|
258
|
+
caller_pane = caller.get("pane_id") or ""
|
|
259
|
+
if caller_uuid == owner_uuid and (not caller_pane or caller_pane == owner_pane):
|
|
184
260
|
return None
|
|
261
|
+
same_uuid = caller_uuid == owner_uuid
|
|
185
262
|
return {
|
|
186
263
|
"ok": False,
|
|
187
264
|
"status": "refused",
|
|
188
265
|
"reason": "team_owner_mismatch",
|
|
266
|
+
"reason_kind": "sticky_bind_collision" if same_uuid else "owner_takeover_required",
|
|
189
267
|
"error": "not_owner",
|
|
190
|
-
"action": "
|
|
268
|
+
"action": "team-agent claim-leader --confirm" if same_uuid else "team-agent takeover --confirm",
|
|
191
269
|
"team_owner": owner,
|
|
192
270
|
"caller": caller,
|
|
193
271
|
}
|
|
@@ -209,14 +287,16 @@ def worker_sender_bypasses_owner_gate(state: dict[str, Any], sender: str | None)
|
|
|
209
287
|
|
|
210
288
|
def populate_team_owner_from_env(state: dict[str, Any], source: str = "autopopulate") -> dict[str, Any] | None:
|
|
211
289
|
if state.get("team_owner"):
|
|
290
|
+
_migrate_team_identity(state, Path(_identity_workspace_abspath(state)), team_state_key(state))
|
|
212
291
|
return state["team_owner"]
|
|
213
|
-
caller = _caller_identity_from_env()
|
|
292
|
+
caller = _caller_identity_from_env(state, team_state_key(state))
|
|
214
293
|
if not caller["pane_id"]:
|
|
215
294
|
return None
|
|
216
295
|
owner = {
|
|
217
296
|
"pane_id": caller["pane_id"],
|
|
218
297
|
"provider": caller["provider"],
|
|
219
298
|
"machine_fingerprint": caller["machine_fingerprint"],
|
|
299
|
+
"leader_session_uuid": caller["leader_session_uuid"],
|
|
220
300
|
"claimed_at": datetime.now(timezone.utc).isoformat(),
|
|
221
301
|
"claimed_via": source,
|
|
222
302
|
}
|
|
@@ -224,7 +304,70 @@ def populate_team_owner_from_env(state: dict[str, Any], source: str = "autopopul
|
|
|
224
304
|
return owner
|
|
225
305
|
|
|
226
306
|
|
|
307
|
+
def apply_first_time_leader_binding(
|
|
308
|
+
workspace: Path,
|
|
309
|
+
state: dict[str, Any],
|
|
310
|
+
receiver: dict[str, Any],
|
|
311
|
+
pane_info: dict[str, Any],
|
|
312
|
+
identity: dict[str, Any],
|
|
313
|
+
source: str,
|
|
314
|
+
) -> dict[str, Any]:
|
|
315
|
+
from team_agent.messaging.leader_panes import _leader_command_looks_usable
|
|
316
|
+
command = pane_info.get("pane_current_command", "")
|
|
317
|
+
provider = str(receiver.get("provider") or "")
|
|
318
|
+
if not _leader_command_looks_usable(command, provider):
|
|
319
|
+
return {"ok": False, "reason": "leader_pane_wrong_command", "error": f"pane command {command!r} is not a leader host", "pane": pane_info}
|
|
320
|
+
current_path = pane_info.get("pane_current_path")
|
|
321
|
+
if not current_path or os.path.realpath(current_path) != os.path.realpath(str(workspace.resolve())):
|
|
322
|
+
return {"ok": False, "reason": "leader_pane_wrong_workspace", "error": f"pane cwd {current_path!r} does not match workspace {str(workspace.resolve())!r}", "pane": pane_info}
|
|
323
|
+
receiver.update({
|
|
324
|
+
"leader_session_uuid": identity["leader_session_uuid"],
|
|
325
|
+
"machine_fingerprint": identity["machine_fingerprint"],
|
|
326
|
+
"owner_epoch": 0,
|
|
327
|
+
})
|
|
328
|
+
state["team_owner"] = {
|
|
329
|
+
"pane_id": receiver["pane_id"],
|
|
330
|
+
"provider": provider,
|
|
331
|
+
"machine_fingerprint": identity["machine_fingerprint"],
|
|
332
|
+
"leader_session_uuid": identity["leader_session_uuid"],
|
|
333
|
+
"owner_epoch": 0,
|
|
334
|
+
"claimed_at": datetime.now(timezone.utc).isoformat(),
|
|
335
|
+
"claimed_via": source,
|
|
336
|
+
}
|
|
337
|
+
state["leader_receiver"] = receiver
|
|
338
|
+
return {"ok": True, "pane": pane_info, "warning": None, "first_time": True}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def leader_env_exports(receiver: dict[str, Any], identity: dict[str, Any]) -> dict[str, str]:
|
|
342
|
+
return {
|
|
343
|
+
"TEAM_AGENT_LEADER_PANE_ID": str(receiver.get("pane_id") or ""),
|
|
344
|
+
"TEAM_AGENT_LEADER_PROVIDER": str(receiver.get("provider") or ""),
|
|
345
|
+
"TEAM_AGENT_LEADER_SESSION_UUID": str(identity.get("leader_session_uuid") or ""),
|
|
346
|
+
"TEAM_AGENT_MACHINE_FINGERPRINT": str(identity.get("machine_fingerprint") or ""),
|
|
347
|
+
"TEAM_AGENT_WORKSPACE": str(identity.get("workspace_abspath") or ""),
|
|
348
|
+
"TEAM_AGENT_TEAM_ID": str(identity.get("team_id") or ""),
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def validate_leader_uuid_from_targets(receiver: dict[str, Any], targets: dict[str, Any]) -> dict[str, Any]:
|
|
353
|
+
expected_uuid = str(receiver.get("leader_session_uuid") or "")
|
|
354
|
+
if not expected_uuid or receiver.get("provider") == "fake":
|
|
355
|
+
return {"ok": True}
|
|
356
|
+
if not targets.get("ok"):
|
|
357
|
+
return {"ok": False, "reason": "leader_uuid_lookup_failed", "error": targets.get("error") or "tmux target scan failed"}
|
|
358
|
+
pane_id = receiver.get("pane_id")
|
|
359
|
+
target = next((item for item in targets.get("targets", []) if item.get("pane_id") == pane_id), None)
|
|
360
|
+
env = target.get("leader_env") if isinstance((target or {}).get("leader_env"), dict) else {}
|
|
361
|
+
actual_uuid = str((target or {}).get("leader_session_uuid") or env.get("TEAM_AGENT_LEADER_SESSION_UUID") or "")
|
|
362
|
+
if not actual_uuid:
|
|
363
|
+
return {"ok": False, "reason": "leader_uuid_missing", "error": "bound pane has no TEAM_AGENT_LEADER_SESSION_UUID", "pane": target}
|
|
364
|
+
if actual_uuid != expected_uuid:
|
|
365
|
+
return {"ok": False, "reason": "leader_uuid_mismatch", "error": "bound pane TEAM_AGENT_LEADER_SESSION_UUID does not match stored team owner", "pane": target}
|
|
366
|
+
return {"ok": True}
|
|
367
|
+
|
|
368
|
+
|
|
227
369
|
def save_runtime_state(workspace: Path, state: dict[str, Any]) -> None:
|
|
370
|
+
_migrate_state_identity(state, workspace)
|
|
228
371
|
path = runtime_state_path(workspace)
|
|
229
372
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
230
373
|
tmp_path = path.with_name(f"{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
|
|
@@ -1,24 +1,54 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from datetime import datetime, timezone
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
from typing import Any
|
|
5
6
|
|
|
6
7
|
from team_agent.message_store import MessageStore
|
|
7
8
|
|
|
8
9
|
|
|
9
|
-
def
|
|
10
|
+
def _parse_since(since: str | None) -> datetime | None:
|
|
11
|
+
if not since:
|
|
12
|
+
return None
|
|
13
|
+
try:
|
|
14
|
+
dt = datetime.fromisoformat(since.replace("Z", "+00:00"))
|
|
15
|
+
except (ValueError, AttributeError):
|
|
16
|
+
return None
|
|
17
|
+
if dt.tzinfo is None:
|
|
18
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
19
|
+
return dt
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _filter_since(rows: list[dict[str, Any]], since: str | None) -> list[dict[str, Any]]:
|
|
23
|
+
cutoff = _parse_since(since)
|
|
24
|
+
if cutoff is None:
|
|
25
|
+
return rows
|
|
26
|
+
filtered: list[dict[str, Any]] = []
|
|
27
|
+
for row in rows:
|
|
28
|
+
ts_raw = str(row.get("created_at") or "")
|
|
29
|
+
ts = _parse_since(ts_raw)
|
|
30
|
+
if ts and ts >= cutoff:
|
|
31
|
+
filtered.append(row)
|
|
32
|
+
return filtered
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def inbox(workspace: Path, agent_id: str, limit: int = 20, since: str | None = None) -> dict[str, Any]:
|
|
10
36
|
rows = MessageStore(workspace).inbox(agent_id, limit=limit)
|
|
11
|
-
|
|
37
|
+
rows = _filter_since(rows, since)
|
|
38
|
+
return {"ok": True, "agent_id": agent_id, "messages": rows, "since": since}
|
|
12
39
|
|
|
13
40
|
|
|
14
|
-
def format_inbox(workspace: Path, agent_id: str, limit: int = 20) -> str:
|
|
41
|
+
def format_inbox(workspace: Path, agent_id: str, limit: int = 20, since: str | None = None) -> str:
|
|
15
42
|
store = MessageStore(workspace)
|
|
16
43
|
rows = store.inbox(agent_id, limit=limit)
|
|
44
|
+
rows = _filter_since(rows, since)
|
|
17
45
|
result_counts = store.result_counts()
|
|
18
46
|
note = "final results are not in inbox; use team-agent collect"
|
|
19
47
|
if result_counts.get("uncollected", 0):
|
|
20
48
|
note += f" ({result_counts['uncollected']} uncollected result(s) pending)"
|
|
21
49
|
if not rows:
|
|
50
|
+
if since:
|
|
51
|
+
return f"{agent_id}: no messages since {since}\n{note}"
|
|
22
52
|
return f"{agent_id}: no messages\n{note}"
|
|
23
53
|
lines = [
|
|
24
54
|
f"{row['created_at']} {row['sender']} -> {row['recipient']} {row['status']}: {row['content']}"
|