@team-agent/installer 0.2.1 → 0.2.3
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/schemas/team.schema.json +6 -0
- package/src/team_agent/approvals/runtime_prompts.py +1 -1
- package/src/team_agent/cli/commands.py +122 -6
- package/src/team_agent/cli/parser.py +42 -1
- package/src/team_agent/coordinator/__main__.py +21 -2
- package/src/team_agent/coordinator/lifecycle.py +11 -0
- package/src/team_agent/diagnose/orphan_cleanup.py +364 -0
- package/src/team_agent/events.py +47 -0
- package/src/team_agent/launch/core.py +2 -1
- 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 +87 -9
- package/src/team_agent/lifecycle/start.py +1 -1
- package/src/team_agent/message_store/core.py +8 -7
- 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 +31 -2
- package/src/team_agent/messaging/delivery.py +293 -1
- package/src/team_agent/messaging/idle_alerts.py +109 -9
- package/src/team_agent/messaging/leader.py +179 -10
- package/src/team_agent/messaging/leader_api_errors.py +216 -0
- package/src/team_agent/messaging/leader_panes.py +393 -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 +24 -2
- package/src/team_agent/messaging/send.py +21 -26
- package/src/team_agent/messaging/tmux_io.py +153 -23
- package/src/team_agent/messaging/tmux_prompt.py +87 -0
- package/src/team_agent/messaging/trust_auto_answer.py +44 -0
- package/src/team_agent/restart/orchestration.py +207 -4
- package/src/team_agent/runtime.py +7 -7
- package/src/team_agent/rust_core.py +157 -3
- package/src/team_agent/sessions/capture.py +65 -15
- package/src/team_agent/spec.py +59 -0
- package/src/team_agent/state.py +153 -10
- package/src/team_agent/status/inbox.py +33 -3
- package/src/team_agent/status/queries.py +32 -1
- package/src/team_agent/watch/__init__.py +145 -0
|
@@ -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']}"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
from datetime import datetime
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
@@ -11,6 +12,24 @@ from team_agent.status.compact import compact_status
|
|
|
11
12
|
from team_agent.status.constants import PENDING_DELIVERY_STATUSES
|
|
12
13
|
|
|
13
14
|
|
|
15
|
+
def _interacted_marker(first_send_at: Any) -> str:
|
|
16
|
+
"""C3 (cr verdict, 2026-05-27): render the persisted first_send_at as a
|
|
17
|
+
user-visible status field. Valid ISO 8601 UTC strings pass through; any
|
|
18
|
+
other shape (None, empty string, 0, False, corrupt garbage) renders as
|
|
19
|
+
the literal "never" so the operator sees a consistent classification
|
|
20
|
+
instead of leaking raw garbage into status output. Restart enforces
|
|
21
|
+
strict typing separately (corrupt values fail the operation); status is
|
|
22
|
+
a read-only surface and tolerantly degrades to "never" rather than
|
|
23
|
+
failing the status command."""
|
|
24
|
+
if isinstance(first_send_at, str) and first_send_at:
|
|
25
|
+
try:
|
|
26
|
+
datetime.fromisoformat(first_send_at)
|
|
27
|
+
except (ValueError, TypeError):
|
|
28
|
+
return "never"
|
|
29
|
+
return first_send_at
|
|
30
|
+
return "never"
|
|
31
|
+
|
|
32
|
+
|
|
14
33
|
def status(workspace: Path, as_json: bool = False, *, compact: bool = False) -> dict[str, Any]:
|
|
15
34
|
from team_agent.runtime import (
|
|
16
35
|
_capture_missing_sessions,
|
|
@@ -31,12 +50,24 @@ def status(workspace: Path, as_json: bool = False, *, compact: bool = False) ->
|
|
|
31
50
|
save_runtime_state(workspace, state)
|
|
32
51
|
session_name = state.get("session_name")
|
|
33
52
|
tmux_exists = _tmux_session_exists(session_name) if session_name else False
|
|
53
|
+
# C3 (cr verdict): enrich each worker entry with an explicit `interacted`
|
|
54
|
+
# field derived from the persisted first_send_at. The original entry
|
|
55
|
+
# passes through unchanged so any pre-existing field (including raw
|
|
56
|
+
# first_send_at) stays visible.
|
|
57
|
+
enriched_agents: dict[str, Any] = {}
|
|
58
|
+
for aid, raw in (state.get("agents") or {}).items():
|
|
59
|
+
if isinstance(raw, dict):
|
|
60
|
+
entry = dict(raw)
|
|
61
|
+
entry["interacted"] = _interacted_marker(raw.get("first_send_at"))
|
|
62
|
+
else:
|
|
63
|
+
entry = raw
|
|
64
|
+
enriched_agents[aid] = entry
|
|
34
65
|
result = {
|
|
35
66
|
"team": state.get("leader", {}).get("id", "leader"),
|
|
36
67
|
"session_name": session_name,
|
|
37
68
|
"tmux_session_present": tmux_exists,
|
|
38
69
|
"leader_receiver": state.get("leader_receiver", {}),
|
|
39
|
-
"agents":
|
|
70
|
+
"agents": enriched_agents,
|
|
40
71
|
"agent_health": store.agent_health(),
|
|
41
72
|
"tasks": state.get("tasks", []),
|
|
42
73
|
"messages": store.message_counts(),
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
from team_agent.message_store import MessageStore
|
|
10
|
+
from team_agent.paths import logs_dir, runtime_dir
|
|
11
|
+
from team_agent.status.queries import result_summary_from_row
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class WatchCursor:
|
|
16
|
+
event_offset: int = 0
|
|
17
|
+
seen_result_ids: set[str] = field(default_factory=set)
|
|
18
|
+
initialized: bool = False
|
|
19
|
+
archive_signature: tuple[int, int] | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
ROTATION_MARKER = "[watch] log rotated; archived segment events.jsonl.1 not replayed — historical replay deferred to a future --replay flag"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run_watch(
|
|
26
|
+
workspace: Path,
|
|
27
|
+
*,
|
|
28
|
+
team: str | None = None,
|
|
29
|
+
interval: float = 0.5,
|
|
30
|
+
output: Callable[[str], None] = print,
|
|
31
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
32
|
+
) -> None:
|
|
33
|
+
cursor = WatchCursor()
|
|
34
|
+
while True:
|
|
35
|
+
for line in collect_watch_lines(workspace, cursor, team=team):
|
|
36
|
+
output(line)
|
|
37
|
+
sleep(interval)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def collect_watch_lines(workspace: Path, cursor: WatchCursor, *, team: str | None = None) -> list[str]:
|
|
41
|
+
lines = _collect_event_lines(workspace, cursor, team=team)
|
|
42
|
+
lines.extend(_collect_result_lines(workspace, cursor, team=team))
|
|
43
|
+
return lines
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def render_event_line(event: dict[str, Any]) -> str | None:
|
|
47
|
+
kind = event.get("event")
|
|
48
|
+
if kind == "result_received":
|
|
49
|
+
return _result_line(event.get("agent_id"), event.get("summary"))
|
|
50
|
+
if kind in {"leader_receiver.injected", "leader_receiver.submitted"}:
|
|
51
|
+
return f"leader_receiver.injected: {_message_snippet(event)} -> {_recipient(event)}"
|
|
52
|
+
if kind == "send.failed":
|
|
53
|
+
return f"send.failed: {_recipient(event)} reason={_clean(event.get('reason') or event.get('error') or '-')}"
|
|
54
|
+
if kind == "leader_receiver.rebind_required":
|
|
55
|
+
pane = event.get("old_pane_id") or event.get("pane_id") or event.get("target") or "-"
|
|
56
|
+
reason = event.get("reason") or event.get("rediscovery_status") or "-"
|
|
57
|
+
return f"leader_receiver.rebind_required: pane={pane} reason={_clean(reason)}"
|
|
58
|
+
if kind == "leader.api_error":
|
|
59
|
+
error_class = event.get("error_class") or "Unknown"
|
|
60
|
+
provider = event.get("provider") or "-"
|
|
61
|
+
snippet = _clean(event.get("matched_pattern_snippet") or event.get("snippet") or "-")
|
|
62
|
+
return f"leader.api_error: {error_class} provider={provider} snippet={snippet}"
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _collect_event_lines(workspace: Path, cursor: WatchCursor, *, team: str | None = None) -> list[str]:
|
|
67
|
+
path = logs_dir(workspace) / "events.jsonl"
|
|
68
|
+
archive_signature = _archive_signature(path.with_name("events.jsonl.1"))
|
|
69
|
+
lines: list[str] = []
|
|
70
|
+
if not cursor.initialized:
|
|
71
|
+
cursor.archive_signature = archive_signature
|
|
72
|
+
cursor.initialized = True
|
|
73
|
+
elif archive_signature and archive_signature != cursor.archive_signature:
|
|
74
|
+
lines.append(ROTATION_MARKER)
|
|
75
|
+
cursor.archive_signature = archive_signature
|
|
76
|
+
cursor.event_offset = 0
|
|
77
|
+
if not path.exists():
|
|
78
|
+
return lines
|
|
79
|
+
size = path.stat().st_size
|
|
80
|
+
if cursor.event_offset > size:
|
|
81
|
+
if ROTATION_MARKER not in lines:
|
|
82
|
+
lines.append(ROTATION_MARKER)
|
|
83
|
+
cursor.event_offset = 0
|
|
84
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
85
|
+
handle.seek(cursor.event_offset)
|
|
86
|
+
for raw in handle:
|
|
87
|
+
try:
|
|
88
|
+
event = json.loads(raw)
|
|
89
|
+
except json.JSONDecodeError:
|
|
90
|
+
continue
|
|
91
|
+
if team and _event_team_id(event) != team:
|
|
92
|
+
continue
|
|
93
|
+
rendered = render_event_line(event)
|
|
94
|
+
if rendered:
|
|
95
|
+
lines.append(rendered)
|
|
96
|
+
cursor.event_offset = handle.tell()
|
|
97
|
+
return lines
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _collect_result_lines(workspace: Path, cursor: WatchCursor, *, team: str | None = None) -> list[str]:
|
|
101
|
+
if not (runtime_dir(workspace) / "team.db").exists():
|
|
102
|
+
return []
|
|
103
|
+
store = MessageStore(workspace)
|
|
104
|
+
lines: list[str] = []
|
|
105
|
+
for row in store.latest_results(limit=20, owner_team_id=team):
|
|
106
|
+
result_id = str(row.get("result_id") or "")
|
|
107
|
+
if not result_id or result_id in cursor.seen_result_ids:
|
|
108
|
+
continue
|
|
109
|
+
cursor.seen_result_ids.add(result_id)
|
|
110
|
+
summary = result_summary_from_row(row) or {}
|
|
111
|
+
lines.append(_result_line(summary.get("agent_id"), summary.get("summary")))
|
|
112
|
+
return lines
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _result_line(agent_id: Any, summary: Any) -> str:
|
|
116
|
+
return f"result_received: {agent_id or '-'} -> {_clean(summary or '-')[:80]}"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _message_snippet(event: dict[str, Any]) -> str:
|
|
120
|
+
message_id = str(event.get("message_id") or event.get("msg_id") or "-")
|
|
121
|
+
return message_id[:12] if message_id != "-" else "-"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _recipient(event: dict[str, Any]) -> str:
|
|
125
|
+
return str(event.get("recipient") or event.get("to") or event.get("target") or "-")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _clean(value: Any) -> str:
|
|
129
|
+
return " ".join(str(value).split())
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _event_team_id(event: dict[str, Any]) -> str | None:
|
|
133
|
+
value = event.get("team_id") or event.get("owner_team_id") or event.get("team")
|
|
134
|
+
return str(value) if value else None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _archive_signature(path: Path) -> tuple[int, int] | None:
|
|
138
|
+
try:
|
|
139
|
+
stat = path.stat()
|
|
140
|
+
except FileNotFoundError:
|
|
141
|
+
return None
|
|
142
|
+
return (stat.st_size, stat.st_mtime_ns)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
__all__ = ["ROTATION_MARKER", "WatchCursor", "collect_watch_lines", "render_event_line", "run_watch"]
|