@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,343 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from team_agent.messaging.deps import (
|
|
4
|
+
EventLog,
|
|
5
|
+
RuntimeError,
|
|
6
|
+
TMUX_PANE_FORMAT,
|
|
7
|
+
_infer_active_tmux_pane as _runtime_infer_active_tmux_pane,
|
|
8
|
+
_infer_workspace_tmux_pane as _runtime_infer_workspace_tmux_pane,
|
|
9
|
+
_tmux_current_client_pane_info as _runtime_tmux_current_client_pane_info,
|
|
10
|
+
_tmux_list_panes as _runtime_tmux_list_panes,
|
|
11
|
+
_tmux_pane_info as _runtime_tmux_pane_info,
|
|
12
|
+
core_list_targets,
|
|
13
|
+
datetime,
|
|
14
|
+
os,
|
|
15
|
+
re,
|
|
16
|
+
run_cmd,
|
|
17
|
+
timezone,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
def _resolve_leader_pane(
|
|
24
|
+
pane: str | None,
|
|
25
|
+
provider: str,
|
|
26
|
+
workspace: Path | None = None,
|
|
27
|
+
require_current: bool = False,
|
|
28
|
+
) -> tuple[dict[str, str], str]:
|
|
29
|
+
if pane:
|
|
30
|
+
pane_info = _tmux_pane_info(pane)
|
|
31
|
+
if not pane_info:
|
|
32
|
+
raise RuntimeError(f"tmux pane not found: {pane}")
|
|
33
|
+
return pane_info, "explicit_pane"
|
|
34
|
+
pane_info = _runtime_tmux_current_client_pane_info()
|
|
35
|
+
if pane_info and _pane_is_usable_leader(pane_info, provider, workspace):
|
|
36
|
+
return pane_info, "current_client"
|
|
37
|
+
if workspace is not None:
|
|
38
|
+
workspace_match = _runtime_infer_workspace_tmux_pane(provider, workspace)
|
|
39
|
+
if workspace_match["status"] == "ok":
|
|
40
|
+
return workspace_match["pane"], "workspace_pane_scan"
|
|
41
|
+
if workspace_match["status"] == "ambiguous":
|
|
42
|
+
raise RuntimeError(
|
|
43
|
+
"multiple tmux leader panes match this workspace; pass --pane explicitly. "
|
|
44
|
+
+ _format_leader_pane_candidates(workspace_match["candidates"])
|
|
45
|
+
)
|
|
46
|
+
if require_current:
|
|
47
|
+
details = ""
|
|
48
|
+
if pane_info:
|
|
49
|
+
details = (
|
|
50
|
+
f" Current tmux client points at pane {pane_info.get('pane_id')} "
|
|
51
|
+
f"command={pane_info.get('pane_current_command')!r} "
|
|
52
|
+
f"cwd={pane_info.get('pane_current_path')!r}, not a usable pane for this workspace."
|
|
53
|
+
)
|
|
54
|
+
raise RuntimeError(
|
|
55
|
+
"Team Agent could not locate a tmux-managed leader pane for this workspace. "
|
|
56
|
+
"Run quick-start from the visible tmux-managed leader pane, pass --pane explicitly, "
|
|
57
|
+
"or use `team-agent codex`/`team-agent claude` as a convenience fallback."
|
|
58
|
+
+ details
|
|
59
|
+
)
|
|
60
|
+
if pane_info and workspace is None:
|
|
61
|
+
return pane_info, "current_client"
|
|
62
|
+
pane_info = _runtime_infer_active_tmux_pane(provider)
|
|
63
|
+
if pane_info:
|
|
64
|
+
return pane_info, "active_pane_scan"
|
|
65
|
+
raise RuntimeError("could not infer a tmux leader pane; pass --pane <pane_id>")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _tmux_current_client_pane_info() -> dict[str, str] | None:
|
|
69
|
+
proc = run_cmd(["tmux", "display-message", "-p", "-F", TMUX_PANE_FORMAT], timeout=5)
|
|
70
|
+
if proc.returncode != 0:
|
|
71
|
+
return None
|
|
72
|
+
return _parse_tmux_pane_info(proc.stdout.strip())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _tmux_list_panes() -> list[dict[str, str]]:
|
|
76
|
+
proc = run_cmd(["tmux", "list-panes", "-a", "-F", TMUX_PANE_FORMAT], timeout=5)
|
|
77
|
+
if proc.returncode != 0:
|
|
78
|
+
return []
|
|
79
|
+
return [pane for line in proc.stdout.splitlines() if (pane := _parse_tmux_pane_info(line))]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _infer_active_tmux_pane(provider: str) -> dict[str, str] | None:
|
|
83
|
+
panes = _runtime_tmux_list_panes()
|
|
84
|
+
active = [pane for pane in panes if pane.get("pane_active") == "1"]
|
|
85
|
+
preferred = [pane for pane in active if _leader_command_looks_usable(pane.get("pane_current_command", ""), provider)]
|
|
86
|
+
if len(preferred) == 1:
|
|
87
|
+
return preferred[0]
|
|
88
|
+
if len(active) == 1:
|
|
89
|
+
return active[0]
|
|
90
|
+
if preferred:
|
|
91
|
+
return preferred[0]
|
|
92
|
+
return active[0] if active else None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _tmux_pane_info(target: str | None) -> dict[str, str] | None:
|
|
96
|
+
if not target:
|
|
97
|
+
return None
|
|
98
|
+
proc = run_cmd(["tmux", "display-message", "-p", "-t", target, "-F", TMUX_PANE_FORMAT], timeout=5)
|
|
99
|
+
if proc.returncode != 0:
|
|
100
|
+
return None
|
|
101
|
+
return _parse_tmux_pane_info(proc.stdout.strip())
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _parse_tmux_pane_info(line: str) -> dict[str, str] | None:
|
|
105
|
+
parts = line.split("\t")
|
|
106
|
+
if len(parts) not in {8, 10, 11}:
|
|
107
|
+
return None
|
|
108
|
+
keys = [
|
|
109
|
+
"pane_id",
|
|
110
|
+
"session_name",
|
|
111
|
+
"window_index",
|
|
112
|
+
"window_name",
|
|
113
|
+
"pane_index",
|
|
114
|
+
"pane_tty",
|
|
115
|
+
"pane_current_command",
|
|
116
|
+
"pane_active",
|
|
117
|
+
]
|
|
118
|
+
if len(parts) >= 10:
|
|
119
|
+
keys.extend(["pane_current_path", "session_attached"])
|
|
120
|
+
if len(parts) == 11:
|
|
121
|
+
keys.append("pane_in_mode")
|
|
122
|
+
return dict(zip(keys, parts))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _infer_workspace_tmux_pane(provider: str, workspace: Path) -> dict[str, Any]:
|
|
126
|
+
panes = _runtime_tmux_list_panes()
|
|
127
|
+
workspace_panes = [pane for pane in panes if _pane_path_matches_workspace(pane, workspace)]
|
|
128
|
+
candidates = [
|
|
129
|
+
pane
|
|
130
|
+
for pane in workspace_panes
|
|
131
|
+
if _leader_command_looks_usable(pane.get("pane_current_command", ""), provider)
|
|
132
|
+
or _leader_command_provider(pane.get("pane_current_command", "")) is not None
|
|
133
|
+
]
|
|
134
|
+
if not candidates:
|
|
135
|
+
return {"status": "missing", "workspace_panes": workspace_panes}
|
|
136
|
+
ranked = sorted(candidates, key=lambda item: _leader_pane_rank(item, provider), reverse=True)
|
|
137
|
+
best_rank = _leader_pane_rank(ranked[0], provider)
|
|
138
|
+
best = [pane for pane in ranked if _leader_pane_rank(pane, provider) == best_rank]
|
|
139
|
+
if len(best) == 1:
|
|
140
|
+
return {"status": "ok", "pane": best[0], "candidates": candidates}
|
|
141
|
+
return {"status": "ambiguous", "candidates": best}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _pane_is_usable_leader(pane: dict[str, str], provider: str, workspace: Path | None) -> bool:
|
|
145
|
+
command = pane.get("pane_current_command", "")
|
|
146
|
+
if not _leader_command_looks_usable(command, provider) and _leader_command_provider(command) is None:
|
|
147
|
+
return False
|
|
148
|
+
if workspace is not None and not _pane_path_matches_workspace(pane, workspace):
|
|
149
|
+
return False
|
|
150
|
+
return True
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _pane_path_matches_workspace(pane: dict[str, str], workspace: Path) -> bool:
|
|
154
|
+
current_path = pane.get("pane_current_path")
|
|
155
|
+
if not current_path:
|
|
156
|
+
return False
|
|
157
|
+
return os.path.realpath(current_path) == os.path.realpath(str(workspace.resolve()))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _leader_pane_rank(pane: dict[str, str], provider: str) -> tuple[int, int, int]:
|
|
161
|
+
return (
|
|
162
|
+
_tmux_truthy(pane.get("session_attached", "")),
|
|
163
|
+
1 if pane.get("pane_active") == "1" else 0,
|
|
164
|
+
1 if _leader_command_is_exact(pane.get("pane_current_command", ""), provider) else 0,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _tmux_truthy(value: str) -> int:
|
|
169
|
+
try:
|
|
170
|
+
return 1 if int(value) > 0 else 0
|
|
171
|
+
except (TypeError, ValueError):
|
|
172
|
+
return 1 if value and value != "0" else 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _leader_command_is_exact(command: str, provider: str) -> bool:
|
|
176
|
+
command_name = Path(command).name
|
|
177
|
+
if provider == "codex":
|
|
178
|
+
return command_name == "codex"
|
|
179
|
+
if provider in {"claude", "claude_code"}:
|
|
180
|
+
return command_name in {"claude", "claude.exe"}
|
|
181
|
+
return provider == "fake"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _leader_command_provider(command: str) -> str | None:
|
|
185
|
+
command_name = Path(command).name
|
|
186
|
+
if command_name in {"codex", "node", "nodejs"}:
|
|
187
|
+
return "codex"
|
|
188
|
+
if command_name in {"claude", "claude.exe"}:
|
|
189
|
+
return "claude_code"
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _format_leader_pane_candidates(candidates: list[dict[str, str]]) -> str:
|
|
194
|
+
compact = []
|
|
195
|
+
for pane in candidates[:5]:
|
|
196
|
+
compact.append(
|
|
197
|
+
"{pane_id} session={session_name} pane={window_index}.{pane_index} "
|
|
198
|
+
"cmd={pane_current_command} cwd={pane_current_path} active={pane_active}".format(**pane)
|
|
199
|
+
)
|
|
200
|
+
suffix = "" if len(candidates) <= 5 else f" ... +{len(candidates) - 5} more"
|
|
201
|
+
return "candidates: " + "; ".join(compact) + suffix
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _target_fingerprint(pane_info: dict[str, Any]) -> str:
|
|
205
|
+
return "|".join(
|
|
206
|
+
str(pane_info.get(key, ""))
|
|
207
|
+
for key in ["session_name", "window_index", "pane_index", "pane_tty"]
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _rediscover_leader_receiver(
|
|
212
|
+
receiver: dict[str, Any],
|
|
213
|
+
event_log: EventLog,
|
|
214
|
+
owner_identity: dict[str, Any] | None = None,
|
|
215
|
+
) -> dict[str, Any]:
|
|
216
|
+
provider = str(receiver.get("provider") or "codex")
|
|
217
|
+
if provider != "codex":
|
|
218
|
+
return {"status": "missing", "reason": "rediscovery_only_for_codex"}
|
|
219
|
+
targets = core_list_targets()
|
|
220
|
+
if not targets.get("ok"):
|
|
221
|
+
event_log.write("leader_receiver.rediscover_failed", provider=provider, error=targets.get("error"))
|
|
222
|
+
return {"status": "failed", "error": targets.get("error")}
|
|
223
|
+
candidates = [
|
|
224
|
+
target
|
|
225
|
+
for target in targets.get("targets", [])
|
|
226
|
+
if _leader_command_looks_usable(str(target.get("pane_current_command", "")), provider)
|
|
227
|
+
]
|
|
228
|
+
if owner_identity:
|
|
229
|
+
owner_candidates = [target for target in candidates if _target_matches_owner_identity(target, owner_identity)]
|
|
230
|
+
if len(owner_candidates) == 1:
|
|
231
|
+
return _rediscovered_receiver(receiver, provider, owner_candidates[0], event_log, owner_identity)
|
|
232
|
+
if len(owner_candidates) > 1:
|
|
233
|
+
event_log.write(
|
|
234
|
+
"leader_receiver.rediscover_ambiguous",
|
|
235
|
+
provider=provider,
|
|
236
|
+
old_target=receiver.get("pane_id"),
|
|
237
|
+
candidates=[target.get("pane_id") for target in owner_candidates],
|
|
238
|
+
owner_identity=owner_identity,
|
|
239
|
+
)
|
|
240
|
+
return {"status": "ambiguous", "candidates": owner_candidates, "owner_identity": owner_identity}
|
|
241
|
+
event_log.write(
|
|
242
|
+
"leader_receiver.rediscover_missing",
|
|
243
|
+
provider=provider,
|
|
244
|
+
old_target=receiver.get("pane_id"),
|
|
245
|
+
owner_identity=owner_identity,
|
|
246
|
+
candidate_count=len(candidates),
|
|
247
|
+
)
|
|
248
|
+
return {"status": "missing", "owner_identity": owner_identity}
|
|
249
|
+
if len(candidates) == 1:
|
|
250
|
+
return _rediscovered_receiver(receiver, provider, candidates[0], event_log, None)
|
|
251
|
+
if len(candidates) > 1:
|
|
252
|
+
event_log.write(
|
|
253
|
+
"leader_receiver.rediscover_ambiguous",
|
|
254
|
+
provider=provider,
|
|
255
|
+
old_target=receiver.get("pane_id"),
|
|
256
|
+
candidates=[target.get("pane_id") for target in candidates],
|
|
257
|
+
)
|
|
258
|
+
return {"status": "ambiguous", "candidates": candidates}
|
|
259
|
+
event_log.write("leader_receiver.rediscover_missing", provider=provider, old_target=receiver.get("pane_id"))
|
|
260
|
+
return {"status": "missing"}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _target_matches_owner_identity(target: dict[str, Any], owner_identity: dict[str, Any]) -> bool:
|
|
264
|
+
env = target.get("leader_env") if isinstance(target.get("leader_env"), dict) else {}
|
|
265
|
+
return (
|
|
266
|
+
env.get("TEAM_AGENT_LEADER_PANE_ID") == (owner_identity.get("pane_id") or "")
|
|
267
|
+
and env.get("TEAM_AGENT_LEADER_PROVIDER") == (owner_identity.get("provider") or "")
|
|
268
|
+
and env.get("TEAM_AGENT_MACHINE_FINGERPRINT") == (owner_identity.get("machine_fingerprint") or "")
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _rediscovered_receiver(
|
|
273
|
+
receiver: dict[str, Any],
|
|
274
|
+
provider: str,
|
|
275
|
+
target: dict[str, Any],
|
|
276
|
+
event_log: EventLog,
|
|
277
|
+
owner_identity: dict[str, Any] | None,
|
|
278
|
+
) -> dict[str, Any]:
|
|
279
|
+
updated = {
|
|
280
|
+
"mode": "direct_tmux",
|
|
281
|
+
"status": "attached",
|
|
282
|
+
"provider": provider,
|
|
283
|
+
"pane_id": target["pane_id"],
|
|
284
|
+
"session_name": target["session_name"],
|
|
285
|
+
"window_index": str(target["window_index"]),
|
|
286
|
+
"window_name": target["window_name"],
|
|
287
|
+
"pane_index": str(target["pane_index"]),
|
|
288
|
+
"pane_tty": target["pane_tty"],
|
|
289
|
+
"pane_current_command": target["pane_current_command"],
|
|
290
|
+
"fingerprint": target.get("fingerprint") or _target_fingerprint(target),
|
|
291
|
+
"attached_at": datetime.now(timezone.utc).isoformat(),
|
|
292
|
+
"discovery": "stale_rediscovery_owner_identity" if owner_identity else "stale_rediscovery_unique_candidate",
|
|
293
|
+
}
|
|
294
|
+
event_log.write(
|
|
295
|
+
"leader_receiver.rediscovered",
|
|
296
|
+
provider=provider,
|
|
297
|
+
old_target=receiver.get("pane_id"),
|
|
298
|
+
new_target=updated["pane_id"],
|
|
299
|
+
candidate_count=1,
|
|
300
|
+
owner_identity=owner_identity,
|
|
301
|
+
)
|
|
302
|
+
return {"status": "updated", "receiver": updated, "owner_identity": owner_identity}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _validate_leader_receiver(receiver: dict[str, Any]) -> dict[str, Any]:
|
|
306
|
+
pane_info = _runtime_tmux_pane_info(receiver.get("pane_id"))
|
|
307
|
+
if not pane_info:
|
|
308
|
+
return {"ok": False, "reason": "leader_pane_missing", "error": "tmux pane does not exist"}
|
|
309
|
+
capture = run_cmd(["tmux", "capture-pane", "-p", "-S", "-40", "-t", pane_info["pane_id"]], timeout=5)
|
|
310
|
+
if capture.returncode != 0:
|
|
311
|
+
return {
|
|
312
|
+
"ok": False,
|
|
313
|
+
"reason": "leader_capture_failed",
|
|
314
|
+
"error": capture.stderr.strip() or "tmux capture-pane failed",
|
|
315
|
+
"pane": pane_info,
|
|
316
|
+
}
|
|
317
|
+
warning = None
|
|
318
|
+
provider = str(receiver.get("provider") or "codex")
|
|
319
|
+
if not _leader_command_looks_usable(pane_info.get("pane_current_command", ""), provider):
|
|
320
|
+
warning = (
|
|
321
|
+
f"pane command {pane_info.get('pane_current_command')!r} is not a typical {provider} host; "
|
|
322
|
+
"continuing because tmux capture works"
|
|
323
|
+
)
|
|
324
|
+
return {"ok": True, "pane": pane_info, "capture": capture.stdout, "warning": warning}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _leader_command_looks_usable(command: str, provider: str) -> bool:
|
|
328
|
+
if provider == "fake":
|
|
329
|
+
return True
|
|
330
|
+
command_name = Path(command).name
|
|
331
|
+
if provider == "codex":
|
|
332
|
+
return command_name in {"codex", "node", "nodejs"}
|
|
333
|
+
return bool(command_name)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _choose_leader_submit_key(provider: str, capture_text: str) -> tuple[str, str]:
|
|
337
|
+
if provider != "codex":
|
|
338
|
+
return "Enter", "non_codex_provider"
|
|
339
|
+
if re.search(r"esc to interrupt|working|running", capture_text, re.IGNORECASE):
|
|
340
|
+
return "Enter", "codex_busy_submit_followup"
|
|
341
|
+
if re.search(r"(›|❯|codex>)", capture_text):
|
|
342
|
+
return "Enter", "codex_idle_prompt"
|
|
343
|
+
return "Enter", "codex_state_unknown_submit"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from team_agent.events import EventLog
|
|
6
|
+
from team_agent.state import worker_sender_bypasses_owner_gate
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def apply_worker_sender_bypass(
|
|
10
|
+
state: dict[str, Any],
|
|
11
|
+
sender: str | None,
|
|
12
|
+
target: Any,
|
|
13
|
+
task_id: str | None,
|
|
14
|
+
event_log: EventLog,
|
|
15
|
+
) -> bool:
|
|
16
|
+
via = worker_sender_bypasses_owner_gate(state, sender)
|
|
17
|
+
if not via:
|
|
18
|
+
return False
|
|
19
|
+
event_log.write(
|
|
20
|
+
"send.bypassed_owner_gate_worker_sender",
|
|
21
|
+
sender=sender,
|
|
22
|
+
env_team_agent_id=via,
|
|
23
|
+
target=target if isinstance(target, str) else None,
|
|
24
|
+
task_id=task_id,
|
|
25
|
+
)
|
|
26
|
+
return True
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
__all__ = ["apply_worker_sender_bypass"]
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from team_agent.events import EventLog
|
|
8
|
+
from team_agent.message_store import MessageStore
|
|
9
|
+
from team_agent.messaging.deps import send_message
|
|
10
|
+
from team_agent.messaging.internal_delivery import deliver_stored_message
|
|
11
|
+
|
|
12
|
+
_RESULT_DELIVERY_MAX_ATTEMPTS = 5
|
|
13
|
+
_DELIVERED_RESULT_MESSAGE_STATUSES = {"visible", "submitted", "submitted_unverified", "delivered", "acknowledged"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def retry_result_deliveries(workspace: Path, event_log: EventLog) -> list[dict[str, Any]]:
|
|
17
|
+
store = MessageStore(workspace)
|
|
18
|
+
notified: list[dict[str, Any]] = []
|
|
19
|
+
for watcher in store.retryable_result_watchers():
|
|
20
|
+
if watcher.get("status") != "notify_failed" or not watcher.get("result_id"):
|
|
21
|
+
continue
|
|
22
|
+
row = store.result_by_id(str(watcher["result_id"]))
|
|
23
|
+
if not row:
|
|
24
|
+
continue
|
|
25
|
+
notified.extend(notify_result_watchers(workspace, _result_entry_from_row(row), event_log, watchers=[watcher]))
|
|
26
|
+
return notified
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def notify_result_watchers(
|
|
30
|
+
workspace: Path,
|
|
31
|
+
result: dict[str, Any],
|
|
32
|
+
event_log: EventLog,
|
|
33
|
+
watchers: list[dict[str, Any]] | None = None,
|
|
34
|
+
) -> list[dict[str, Any]]:
|
|
35
|
+
store = MessageStore(workspace)
|
|
36
|
+
candidates = [
|
|
37
|
+
watcher
|
|
38
|
+
for watcher in (watchers if watchers is not None else store.pending_result_watchers())
|
|
39
|
+
if watcher_matches_result(watcher, result)
|
|
40
|
+
]
|
|
41
|
+
if not candidates:
|
|
42
|
+
return []
|
|
43
|
+
primary, superseded = _dedupe_watchers_for_result(candidates)
|
|
44
|
+
notified: list[dict[str, Any]] = []
|
|
45
|
+
for stale in superseded:
|
|
46
|
+
store.mark_result_watcher(
|
|
47
|
+
stale["watcher_id"],
|
|
48
|
+
"superseded",
|
|
49
|
+
result_id=result.get("result_id"),
|
|
50
|
+
error="superseded by earlier watcher for same (task_id, agent_id, result_id)",
|
|
51
|
+
)
|
|
52
|
+
event_log.write(
|
|
53
|
+
"result_watcher.superseded",
|
|
54
|
+
watcher_id=stale["watcher_id"],
|
|
55
|
+
result_id=result.get("result_id"),
|
|
56
|
+
task_id=result.get("task_id"),
|
|
57
|
+
agent_id=result.get("agent_id"),
|
|
58
|
+
primary_watcher_id=primary["watcher_id"],
|
|
59
|
+
)
|
|
60
|
+
notified.append(
|
|
61
|
+
{
|
|
62
|
+
"watcher_id": stale["watcher_id"],
|
|
63
|
+
"result_id": result.get("result_id"),
|
|
64
|
+
"ok": False,
|
|
65
|
+
"status": "superseded",
|
|
66
|
+
"primary_watcher_id": primary["watcher_id"],
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
attempts = result_delivery_attempts(event_log, primary["watcher_id"], str(result.get("result_id") or ""))
|
|
70
|
+
existing = delivered_result_message(
|
|
71
|
+
store,
|
|
72
|
+
str(result.get("result_id") or ""),
|
|
73
|
+
task_id=result.get("task_id"),
|
|
74
|
+
owner_team_id=primary.get("owner_team_id"),
|
|
75
|
+
)
|
|
76
|
+
if existing:
|
|
77
|
+
notified.append(_mark_watcher_already_delivered(store, event_log, primary, result, attempts, existing))
|
|
78
|
+
return notified
|
|
79
|
+
if attempts >= _RESULT_DELIVERY_MAX_ATTEMPTS:
|
|
80
|
+
notified.append(_mark_delivery_exhausted(store, event_log, primary, result, attempts))
|
|
81
|
+
else:
|
|
82
|
+
notified.append(_deliver_result_to_watcher(workspace, store, event_log, primary, result, attempts))
|
|
83
|
+
return notified
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _dedupe_watchers_for_result(
|
|
87
|
+
watchers: list[dict[str, Any]],
|
|
88
|
+
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
89
|
+
ordered = sorted(watchers, key=lambda w: (str(w.get("created_at") or ""), str(w.get("watcher_id") or "")))
|
|
90
|
+
return ordered[0], ordered[1:]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _deliver_result_to_watcher(
|
|
94
|
+
workspace: Path,
|
|
95
|
+
store: MessageStore,
|
|
96
|
+
event_log: EventLog,
|
|
97
|
+
watcher: dict[str, Any],
|
|
98
|
+
result: dict[str, Any],
|
|
99
|
+
attempts: int,
|
|
100
|
+
) -> dict[str, Any]:
|
|
101
|
+
try:
|
|
102
|
+
deliver = deliver_stored_message if watcher.get("owner_team_id") else send_message
|
|
103
|
+
delivery = deliver(
|
|
104
|
+
workspace,
|
|
105
|
+
watcher.get("leader_id") or "leader",
|
|
106
|
+
format_result_watcher_notification(result),
|
|
107
|
+
task_id=result.get("task_id"),
|
|
108
|
+
sender="coordinator",
|
|
109
|
+
requires_ack=False,
|
|
110
|
+
wait_visible=False,
|
|
111
|
+
team=watcher.get("owner_team_id"),
|
|
112
|
+
)
|
|
113
|
+
except Exception as exc:
|
|
114
|
+
return _mark_delivery_failed(store, event_log, watcher, result, attempts, str(exc))
|
|
115
|
+
status = "notified" if delivery.get("ok") else "notify_failed"
|
|
116
|
+
error = delivery.get("reason") or delivery.get("error")
|
|
117
|
+
store.mark_result_watcher(
|
|
118
|
+
watcher["watcher_id"],
|
|
119
|
+
status,
|
|
120
|
+
result_id=result.get("result_id"),
|
|
121
|
+
notified_message_id=delivery.get("message_id"),
|
|
122
|
+
error=error,
|
|
123
|
+
)
|
|
124
|
+
event_log.write(
|
|
125
|
+
"result_watcher.notified",
|
|
126
|
+
watcher_id=watcher["watcher_id"],
|
|
127
|
+
result_id=result.get("result_id"),
|
|
128
|
+
task_id=result.get("task_id"),
|
|
129
|
+
agent_id=result.get("agent_id"),
|
|
130
|
+
ok=bool(delivery.get("ok")),
|
|
131
|
+
delivery_status=delivery.get("status"),
|
|
132
|
+
message_id=delivery.get("message_id"),
|
|
133
|
+
error=error,
|
|
134
|
+
attempt=attempts + 1,
|
|
135
|
+
)
|
|
136
|
+
return {
|
|
137
|
+
"watcher_id": watcher["watcher_id"],
|
|
138
|
+
"result_id": result.get("result_id"),
|
|
139
|
+
"ok": bool(delivery.get("ok")),
|
|
140
|
+
"message_id": delivery.get("message_id"),
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _mark_delivery_failed(
|
|
145
|
+
store: MessageStore,
|
|
146
|
+
event_log: EventLog,
|
|
147
|
+
watcher: dict[str, Any],
|
|
148
|
+
result: dict[str, Any],
|
|
149
|
+
attempts: int,
|
|
150
|
+
error: str,
|
|
151
|
+
) -> dict[str, Any]:
|
|
152
|
+
store.mark_result_watcher(watcher["watcher_id"], "notify_failed", result_id=result.get("result_id"), error=error)
|
|
153
|
+
event_log.write(
|
|
154
|
+
"result_watcher.notify_failed",
|
|
155
|
+
watcher_id=watcher["watcher_id"],
|
|
156
|
+
result_id=result.get("result_id"),
|
|
157
|
+
attempt=attempts + 1,
|
|
158
|
+
error=error,
|
|
159
|
+
)
|
|
160
|
+
return {"watcher_id": watcher["watcher_id"], "result_id": result.get("result_id"), "ok": False, "error": error}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _mark_watcher_already_delivered(
|
|
164
|
+
store: MessageStore,
|
|
165
|
+
event_log: EventLog,
|
|
166
|
+
watcher: dict[str, Any],
|
|
167
|
+
result: dict[str, Any],
|
|
168
|
+
attempts: int,
|
|
169
|
+
message: dict[str, Any],
|
|
170
|
+
) -> dict[str, Any]:
|
|
171
|
+
store.mark_result_watcher(
|
|
172
|
+
watcher["watcher_id"],
|
|
173
|
+
"notified",
|
|
174
|
+
result_id=result.get("result_id"),
|
|
175
|
+
notified_message_id=message.get("message_id"),
|
|
176
|
+
)
|
|
177
|
+
event_log.write(
|
|
178
|
+
"result_watcher.notified",
|
|
179
|
+
watcher_id=watcher["watcher_id"],
|
|
180
|
+
result_id=result.get("result_id"),
|
|
181
|
+
task_id=result.get("task_id"),
|
|
182
|
+
agent_id=result.get("agent_id"),
|
|
183
|
+
ok=True,
|
|
184
|
+
delivery_status="already_delivered",
|
|
185
|
+
message_id=message.get("message_id"),
|
|
186
|
+
deduped=True,
|
|
187
|
+
attempt=attempts,
|
|
188
|
+
)
|
|
189
|
+
return {
|
|
190
|
+
"watcher_id": watcher["watcher_id"],
|
|
191
|
+
"result_id": result.get("result_id"),
|
|
192
|
+
"ok": True,
|
|
193
|
+
"message_id": message.get("message_id"),
|
|
194
|
+
"deduped": True,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _mark_delivery_exhausted(
|
|
199
|
+
store: MessageStore,
|
|
200
|
+
event_log: EventLog,
|
|
201
|
+
watcher: dict[str, Any],
|
|
202
|
+
result: dict[str, Any],
|
|
203
|
+
attempts: int,
|
|
204
|
+
) -> dict[str, Any]:
|
|
205
|
+
error = "result delivery retry budget exhausted"
|
|
206
|
+
store.mark_result_watcher(watcher["watcher_id"], "delivery_exhausted", result_id=result.get("result_id"), error=error)
|
|
207
|
+
event_log.write(
|
|
208
|
+
"result_delivery_exhausted",
|
|
209
|
+
watcher_id=watcher["watcher_id"],
|
|
210
|
+
result_id=result.get("result_id"),
|
|
211
|
+
task_id=result.get("task_id"),
|
|
212
|
+
agent_id=result.get("agent_id"),
|
|
213
|
+
attempts=attempts,
|
|
214
|
+
last_error=watcher.get("error"),
|
|
215
|
+
)
|
|
216
|
+
return {"watcher_id": watcher["watcher_id"], "result_id": result.get("result_id"), "ok": False, "error": error}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _result_entry_from_row(row: dict[str, Any]) -> dict[str, Any]:
|
|
220
|
+
envelope = json.loads(row["envelope"])
|
|
221
|
+
return {
|
|
222
|
+
"result_id": row["result_id"],
|
|
223
|
+
"task_id": envelope.get("task_id"),
|
|
224
|
+
"agent_id": envelope.get("agent_id"),
|
|
225
|
+
"status": envelope.get("status"),
|
|
226
|
+
"summary": envelope.get("summary"),
|
|
227
|
+
"tests": envelope.get("tests", []),
|
|
228
|
+
"created_at": row.get("created_at"),
|
|
229
|
+
"scope": "task",
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def result_delivery_attempts(event_log: EventLog, watcher_id: str, result_id: str) -> int:
|
|
234
|
+
attempts = 0
|
|
235
|
+
for event in event_log.tail(500):
|
|
236
|
+
if event.get("watcher_id") != watcher_id:
|
|
237
|
+
continue
|
|
238
|
+
if event.get("event") == "result_watcher.requeued":
|
|
239
|
+
attempts = 0
|
|
240
|
+
continue
|
|
241
|
+
if event.get("result_id") != result_id:
|
|
242
|
+
continue
|
|
243
|
+
if event.get("event") in {"result_watcher.notified", "result_watcher.notify_failed"}:
|
|
244
|
+
attempts += 1
|
|
245
|
+
return attempts
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def delivered_result_message(
|
|
249
|
+
store: MessageStore,
|
|
250
|
+
result_id: str,
|
|
251
|
+
*,
|
|
252
|
+
task_id: str | None = None,
|
|
253
|
+
owner_team_id: str | None = None,
|
|
254
|
+
) -> dict[str, Any] | None:
|
|
255
|
+
if not result_id:
|
|
256
|
+
return None
|
|
257
|
+
for message in reversed(store.messages(owner_team_id=owner_team_id)):
|
|
258
|
+
if message.get("recipient") != "leader":
|
|
259
|
+
continue
|
|
260
|
+
if task_id and message.get("task_id") != task_id:
|
|
261
|
+
continue
|
|
262
|
+
if message.get("status") not in _DELIVERED_RESULT_MESSAGE_STATUSES:
|
|
263
|
+
continue
|
|
264
|
+
if f"Result id: {result_id}" in str(message.get("content") or ""):
|
|
265
|
+
return message
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def result_id_from_text(content: str) -> str | None:
|
|
270
|
+
for line in content.splitlines():
|
|
271
|
+
if line.startswith("Result id: "):
|
|
272
|
+
return line.removeprefix("Result id: ").strip() or None
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def watcher_matches_result(watcher: dict[str, Any], result: dict[str, Any]) -> bool:
|
|
277
|
+
task_id = watcher.get("task_id")
|
|
278
|
+
agent_id = watcher.get("agent_id")
|
|
279
|
+
return (not task_id or task_id == result.get("task_id")) and (not agent_id or agent_id == result.get("agent_id"))
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def format_result_watcher_notification(result: dict[str, Any]) -> str:
|
|
283
|
+
task_id = result.get("task_id") or "unknown task"
|
|
284
|
+
agent_id = result.get("agent_id") or "unknown agent"
|
|
285
|
+
status = result.get("status") or "unknown"
|
|
286
|
+
summary = result.get("summary") or "completed"
|
|
287
|
+
lines = [
|
|
288
|
+
f"Task {task_id} reported {status} from {agent_id}: {summary}",
|
|
289
|
+
"Team Agent has collected this result and updated team_state.md. No manual polling is needed.",
|
|
290
|
+
]
|
|
291
|
+
if result.get("result_id"):
|
|
292
|
+
lines.insert(1, f"Result id: {result['result_id']}")
|
|
293
|
+
rendered_tests = [
|
|
294
|
+
f"{test.get('command') or 'test'}={test.get('status') or 'unknown'}"
|
|
295
|
+
for test in (result.get("tests") or [])[:3]
|
|
296
|
+
if isinstance(test, dict)
|
|
297
|
+
]
|
|
298
|
+
if rendered_tests:
|
|
299
|
+
lines.insert(1, "Tests: " + "; ".join(rendered_tests))
|
|
300
|
+
return "\n".join(lines)
|