@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.
Files changed (113) hide show
  1. package/crates/team-agent-core/src/lib.rs +50 -5
  2. package/package.json +1 -1
  3. package/schemas/team.schema.json +1 -0
  4. package/src/team_agent/approvals/__init__.py +65 -0
  5. package/src/team_agent/approvals/constants.py +6 -0
  6. package/src/team_agent/approvals/parsing.py +176 -0
  7. package/src/team_agent/approvals/runtime_prompts.py +171 -0
  8. package/src/team_agent/approvals/status.py +165 -0
  9. package/src/team_agent/cli/__init__.py +137 -0
  10. package/src/team_agent/cli/commands.py +339 -0
  11. package/src/team_agent/cli/e2e.py +202 -0
  12. package/src/team_agent/cli/helpers.py +137 -0
  13. package/src/team_agent/cli/parser.py +477 -0
  14. package/src/team_agent/compiler.py +98 -33
  15. package/src/team_agent/coordinator/__init__.py +53 -0
  16. package/src/team_agent/{coordinator.py → coordinator/__main__.py} +3 -1
  17. package/src/team_agent/coordinator/lifecycle.py +334 -0
  18. package/src/team_agent/coordinator/metadata.py +61 -0
  19. package/src/team_agent/coordinator/paths.py +17 -0
  20. package/src/team_agent/diagnose/__init__.py +48 -0
  21. package/src/team_agent/diagnose/checks.py +101 -0
  22. package/src/team_agent/diagnose/health.py +241 -0
  23. package/src/team_agent/diagnose/preflight.py +194 -0
  24. package/src/team_agent/diagnose/quick_start.py +233 -0
  25. package/src/team_agent/display/__init__.py +61 -0
  26. package/src/team_agent/display/close.py +147 -0
  27. package/src/team_agent/display/ghostty.py +77 -0
  28. package/src/team_agent/display/worker_window.py +110 -0
  29. package/src/team_agent/display/workspace.py +473 -0
  30. package/src/team_agent/launch/__init__.py +41 -0
  31. package/src/team_agent/launch/bootstrap.py +85 -0
  32. package/src/team_agent/launch/config.py +106 -0
  33. package/src/team_agent/launch/core.py +291 -0
  34. package/src/team_agent/launch/requirements.py +57 -0
  35. package/src/team_agent/leader/__init__.py +320 -0
  36. package/src/team_agent/lifecycle/__init__.py +5 -0
  37. package/src/team_agent/lifecycle/agents.py +226 -0
  38. package/src/team_agent/lifecycle/operations.py +321 -0
  39. package/src/team_agent/lifecycle/paste_buffer_hygiene.py +39 -0
  40. package/src/team_agent/lifecycle/start.py +363 -0
  41. package/src/team_agent/mcp_server/__init__.py +42 -0
  42. package/src/team_agent/mcp_server/__main__.py +7 -0
  43. package/src/team_agent/mcp_server/contracts.py +148 -0
  44. package/src/team_agent/mcp_server/normalize.py +257 -0
  45. package/src/team_agent/mcp_server/server.py +150 -0
  46. package/src/team_agent/mcp_server/tools.py +205 -0
  47. package/src/team_agent/message_store/__init__.py +23 -0
  48. package/src/team_agent/message_store/agent_health.py +109 -0
  49. package/src/team_agent/{message_store.py → message_store/core.py} +188 -245
  50. package/src/team_agent/message_store/result_watchers.py +102 -0
  51. package/src/team_agent/message_store/schema.py +266 -0
  52. package/src/team_agent/messaging/__init__.py +1 -0
  53. package/src/team_agent/messaging/activity_detector.py +190 -0
  54. package/src/team_agent/messaging/delivery.py +138 -0
  55. package/src/team_agent/messaging/deps.py +263 -0
  56. package/src/team_agent/messaging/idle_alerts.py +323 -0
  57. package/src/team_agent/messaging/internal_delivery.py +46 -0
  58. package/src/team_agent/messaging/leader.py +317 -0
  59. package/src/team_agent/messaging/leader_panes.py +343 -0
  60. package/src/team_agent/messaging/owner_bypass.py +29 -0
  61. package/src/team_agent/messaging/result_delivery.py +300 -0
  62. package/src/team_agent/messaging/results.py +456 -0
  63. package/src/team_agent/messaging/scheduler.py +428 -0
  64. package/src/team_agent/messaging/send.py +500 -0
  65. package/src/team_agent/messaging/session_drift.py +94 -0
  66. package/src/team_agent/messaging/tmux_io.py +337 -0
  67. package/src/team_agent/messaging/tmux_prompt.py +229 -0
  68. package/src/team_agent/orchestrator/__init__.py +376 -0
  69. package/src/team_agent/orchestrator/plan.py +122 -0
  70. package/src/team_agent/orchestrator/state.py +128 -0
  71. package/src/team_agent/profiles/__init__.py +82 -0
  72. package/src/team_agent/profiles/constants.py +19 -0
  73. package/src/team_agent/profiles/core.py +407 -0
  74. package/src/team_agent/profiles/helpers.py +69 -0
  75. package/src/team_agent/profiles/provider_env.py +188 -0
  76. package/src/team_agent/profiles/smoke.py +201 -0
  77. package/src/team_agent/provider_cli/__init__.py +43 -0
  78. package/src/team_agent/provider_cli/adapter.py +167 -0
  79. package/src/team_agent/provider_cli/base.py +48 -0
  80. package/src/team_agent/provider_cli/claude.py +457 -0
  81. package/src/team_agent/provider_cli/codex.py +319 -0
  82. package/src/team_agent/provider_cli/copilot.py +8 -0
  83. package/src/team_agent/provider_cli/fake.py +39 -0
  84. package/src/team_agent/provider_cli/gemini.py +95 -0
  85. package/src/team_agent/provider_cli/opencode.py +8 -0
  86. package/src/team_agent/provider_cli/prompt.py +62 -0
  87. package/src/team_agent/provider_cli/registry.py +18 -0
  88. package/src/team_agent/provider_cli/unsupported.py +32 -0
  89. package/src/team_agent/providers.py +67 -949
  90. package/src/team_agent/quality_gates.py +104 -0
  91. package/src/team_agent/restart/__init__.py +34 -0
  92. package/src/team_agent/restart/orchestration.py +328 -0
  93. package/src/team_agent/restart/selection.py +89 -0
  94. package/src/team_agent/restart/snapshot.py +70 -0
  95. package/src/team_agent/runtime.py +809 -5892
  96. package/src/team_agent/rust_core.py +22 -5
  97. package/src/team_agent/sessions/__init__.py +25 -0
  98. package/src/team_agent/sessions/capture.py +93 -0
  99. package/src/team_agent/sessions/inventory.py +44 -0
  100. package/src/team_agent/sessions/resume.py +135 -0
  101. package/src/team_agent/spec.py +3 -1
  102. package/src/team_agent/state.py +218 -4
  103. package/src/team_agent/status/__init__.py +63 -0
  104. package/src/team_agent/status/approvals.py +52 -0
  105. package/src/team_agent/status/compact.py +158 -0
  106. package/src/team_agent/status/constants.py +18 -0
  107. package/src/team_agent/status/inbox.py +28 -0
  108. package/src/team_agent/status/peek.py +117 -0
  109. package/src/team_agent/status/queries.py +168 -0
  110. package/src/team_agent/terminal.py +57 -0
  111. package/src/team_agent/cli.py +0 -858
  112. package/src/team_agent/mcp_server.py +0 -579
  113. 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)