@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,500 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from team_agent.messaging.deps import (
|
|
4
|
+
EventLog,
|
|
5
|
+
MessageStore,
|
|
6
|
+
RuntimeError,
|
|
7
|
+
_capture_missing_sessions,
|
|
8
|
+
_current_task_for_agent,
|
|
9
|
+
_deliver_pending_message,
|
|
10
|
+
_find_agent,
|
|
11
|
+
_find_task,
|
|
12
|
+
_is_leader_sender,
|
|
13
|
+
_is_leader_target,
|
|
14
|
+
_is_runtime_team_agent,
|
|
15
|
+
_leader_id,
|
|
16
|
+
_message_by_id,
|
|
17
|
+
_mirror_peer_message_to_leader,
|
|
18
|
+
_runtime_lock,
|
|
19
|
+
_runtime_team_agent_ids,
|
|
20
|
+
_send_to_leader_receiver,
|
|
21
|
+
check_team_owner,
|
|
22
|
+
load_runtime_state,
|
|
23
|
+
load_spec,
|
|
24
|
+
missing_tools,
|
|
25
|
+
route_task,
|
|
26
|
+
save_team_scoped_state,
|
|
27
|
+
select_runtime_state,
|
|
28
|
+
ambiguous_team_target_result,
|
|
29
|
+
team_state_key,
|
|
30
|
+
update_task_status,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
def send_message(
|
|
37
|
+
workspace: Path,
|
|
38
|
+
target: str | list[str] | None,
|
|
39
|
+
content: str,
|
|
40
|
+
task_id: str | None = None,
|
|
41
|
+
sender: str = "leader",
|
|
42
|
+
requires_ack: bool = True,
|
|
43
|
+
confirm_human: bool = False,
|
|
44
|
+
wait_visible: bool = True,
|
|
45
|
+
timeout: float = 30.0,
|
|
46
|
+
lock_timeout: float = 5.0,
|
|
47
|
+
watch_result: bool = False,
|
|
48
|
+
block_until_delivered: bool = True,
|
|
49
|
+
team: str | None = None,
|
|
50
|
+
) -> dict[str, Any]:
|
|
51
|
+
with _runtime_lock(workspace, "send", timeout=lock_timeout):
|
|
52
|
+
return _send_message_unlocked(
|
|
53
|
+
workspace,
|
|
54
|
+
target,
|
|
55
|
+
content,
|
|
56
|
+
task_id=task_id,
|
|
57
|
+
sender=sender,
|
|
58
|
+
requires_ack=requires_ack,
|
|
59
|
+
confirm_human=confirm_human,
|
|
60
|
+
wait_visible=wait_visible,
|
|
61
|
+
timeout=timeout,
|
|
62
|
+
watch_result=watch_result,
|
|
63
|
+
block_until_delivered=block_until_delivered,
|
|
64
|
+
team=team,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _send_message_unlocked(
|
|
69
|
+
workspace: Path,
|
|
70
|
+
target: str | list[str] | None,
|
|
71
|
+
content: str,
|
|
72
|
+
task_id: str | None = None,
|
|
73
|
+
sender: str = "leader",
|
|
74
|
+
requires_ack: bool = True,
|
|
75
|
+
confirm_human: bool = False,
|
|
76
|
+
wait_visible: bool = True,
|
|
77
|
+
timeout: float = 30.0,
|
|
78
|
+
watch_result: bool = False,
|
|
79
|
+
block_until_delivered: bool = True,
|
|
80
|
+
team: str | None = None,
|
|
81
|
+
) -> dict[str, Any]:
|
|
82
|
+
if team is None:
|
|
83
|
+
ambiguous = ambiguous_team_target_result(load_runtime_state(workspace))
|
|
84
|
+
if ambiguous:
|
|
85
|
+
return ambiguous
|
|
86
|
+
state = select_runtime_state(workspace, team)
|
|
87
|
+
gate = check_team_owner(state)
|
|
88
|
+
spec_path = Path(state.get("spec_path", workspace / "team.spec.yaml"))
|
|
89
|
+
spec = load_spec(spec_path)
|
|
90
|
+
event_log = EventLog(workspace)
|
|
91
|
+
if gate:
|
|
92
|
+
from team_agent.messaging.owner_bypass import apply_worker_sender_bypass
|
|
93
|
+
if not apply_worker_sender_bypass(state, sender, target, task_id, event_log):
|
|
94
|
+
return gate
|
|
95
|
+
owner_team_id = team_state_key(state)
|
|
96
|
+
leader_id = _leader_id(state, spec)
|
|
97
|
+
|
|
98
|
+
if isinstance(target, list):
|
|
99
|
+
if watch_result:
|
|
100
|
+
return {"ok": False, "status": "failed", "reason": "watch_result_not_supported_for_fanout", "to": target}
|
|
101
|
+
return _fanout_message_unlocked(
|
|
102
|
+
workspace,
|
|
103
|
+
state,
|
|
104
|
+
spec,
|
|
105
|
+
event_log,
|
|
106
|
+
target,
|
|
107
|
+
content,
|
|
108
|
+
task_id=task_id,
|
|
109
|
+
sender=sender,
|
|
110
|
+
requires_ack=requires_ack,
|
|
111
|
+
wait_visible=wait_visible,
|
|
112
|
+
timeout=timeout,
|
|
113
|
+
block_until_delivered=block_until_delivered,
|
|
114
|
+
owner_team_id=owner_team_id,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if target == "*":
|
|
118
|
+
if watch_result:
|
|
119
|
+
return {"ok": False, "status": "failed", "reason": "watch_result_not_supported_for_broadcast", "to": target}
|
|
120
|
+
return _broadcast_message_unlocked(
|
|
121
|
+
workspace,
|
|
122
|
+
state,
|
|
123
|
+
spec,
|
|
124
|
+
event_log,
|
|
125
|
+
content,
|
|
126
|
+
task_id=task_id,
|
|
127
|
+
sender=sender,
|
|
128
|
+
requires_ack=requires_ack,
|
|
129
|
+
wait_visible=wait_visible,
|
|
130
|
+
timeout=timeout,
|
|
131
|
+
block_until_delivered=block_until_delivered,
|
|
132
|
+
owner_team_id=owner_team_id,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return _send_single_message_unlocked(
|
|
136
|
+
workspace,
|
|
137
|
+
state,
|
|
138
|
+
spec,
|
|
139
|
+
event_log,
|
|
140
|
+
target,
|
|
141
|
+
content,
|
|
142
|
+
task_id=task_id,
|
|
143
|
+
sender=sender,
|
|
144
|
+
requires_ack=requires_ack,
|
|
145
|
+
confirm_human=confirm_human,
|
|
146
|
+
wait_visible=wait_visible,
|
|
147
|
+
timeout=timeout,
|
|
148
|
+
watch_result=watch_result,
|
|
149
|
+
block_until_delivered=block_until_delivered,
|
|
150
|
+
owner_team_id=owner_team_id,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _send_single_message_unlocked(
|
|
155
|
+
workspace: Path,
|
|
156
|
+
state: dict[str, Any],
|
|
157
|
+
spec: dict[str, Any],
|
|
158
|
+
event_log: EventLog,
|
|
159
|
+
target: str | None,
|
|
160
|
+
content: str,
|
|
161
|
+
*,
|
|
162
|
+
task_id: str | None = None,
|
|
163
|
+
sender: str = "leader",
|
|
164
|
+
requires_ack: bool = True,
|
|
165
|
+
confirm_human: bool = False,
|
|
166
|
+
wait_visible: bool = True,
|
|
167
|
+
timeout: float = 30.0,
|
|
168
|
+
watch_result: bool = False,
|
|
169
|
+
mirror_peer: bool = True,
|
|
170
|
+
route_task_id: bool = True,
|
|
171
|
+
block_until_delivered: bool = True,
|
|
172
|
+
owner_team_id: str | None = None,
|
|
173
|
+
) -> dict[str, Any]:
|
|
174
|
+
leader_id = _leader_id(state, spec)
|
|
175
|
+
|
|
176
|
+
if _is_leader_target(target, leader_id) and not _is_leader_sender(sender, leader_id):
|
|
177
|
+
return _send_to_leader_receiver(workspace, state, leader_id, content, task_id, sender, requires_ack, event_log)
|
|
178
|
+
|
|
179
|
+
from team_agent.messaging.session_drift import session_drift_refusal
|
|
180
|
+
drift = session_drift_refusal(state, target, leader_id, sender, task_id, event_log)
|
|
181
|
+
if drift:
|
|
182
|
+
return drift
|
|
183
|
+
|
|
184
|
+
if task_id and route_task_id:
|
|
185
|
+
task = _find_task(state.get("tasks", []), task_id)
|
|
186
|
+
if task.get("human_confirmation") and not task.get("human_confirmed"):
|
|
187
|
+
if not confirm_human:
|
|
188
|
+
update_task_status(state["tasks"], task_id, "blocked", "human confirmation required before dispatch")
|
|
189
|
+
save_team_scoped_state(workspace, state)
|
|
190
|
+
event_log.write(
|
|
191
|
+
"send.human_confirmation_required",
|
|
192
|
+
task_id=task_id,
|
|
193
|
+
requested_target=target,
|
|
194
|
+
)
|
|
195
|
+
return {
|
|
196
|
+
"ok": False,
|
|
197
|
+
"status": "blocked",
|
|
198
|
+
"reason": "human_confirmation_required",
|
|
199
|
+
"task_id": task_id,
|
|
200
|
+
}
|
|
201
|
+
task["human_confirmed"] = True
|
|
202
|
+
event_log.write("send.human_confirmation_granted", task_id=task_id, confirmed_by=sender)
|
|
203
|
+
route = route_task(spec, task)
|
|
204
|
+
routed_target = route["agent_id"]
|
|
205
|
+
requested_target = target
|
|
206
|
+
target = target or routed_target
|
|
207
|
+
task["assignee"] = target
|
|
208
|
+
event_log.write(
|
|
209
|
+
"routing.decision",
|
|
210
|
+
source="send",
|
|
211
|
+
task_id=task_id,
|
|
212
|
+
route_agent=routed_target,
|
|
213
|
+
selected_agent=target,
|
|
214
|
+
reason=route["reason"],
|
|
215
|
+
manual_override=bool(requested_target and requested_target != routed_target),
|
|
216
|
+
)
|
|
217
|
+
agent = _find_agent(spec, target)
|
|
218
|
+
if agent:
|
|
219
|
+
missing = missing_tools(agent, task)
|
|
220
|
+
if missing:
|
|
221
|
+
update_task_status(state["tasks"], task_id, "blocked", f"missing permissions: {', '.join(missing)}")
|
|
222
|
+
save_team_scoped_state(workspace, state)
|
|
223
|
+
event_log.write(
|
|
224
|
+
"send.blocked_missing_permissions",
|
|
225
|
+
task_id=task_id,
|
|
226
|
+
agent_id=target,
|
|
227
|
+
missing_tools=missing,
|
|
228
|
+
)
|
|
229
|
+
return {
|
|
230
|
+
"ok": False,
|
|
231
|
+
"status": "blocked",
|
|
232
|
+
"task_id": task_id,
|
|
233
|
+
"agent_id": target,
|
|
234
|
+
"missing_tools": missing,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if not target:
|
|
238
|
+
raise RuntimeError("send requires target or --task")
|
|
239
|
+
if not _is_leader_target(target, leader_id) and not _is_runtime_team_agent(target, state, spec):
|
|
240
|
+
event_log.write("send.target_rejected", sender=sender, target=target, reason="target_not_in_team")
|
|
241
|
+
return {"ok": False, "status": "refused", "reason": "target_not_in_team", "from": sender, "to": target}
|
|
242
|
+
store = MessageStore(workspace)
|
|
243
|
+
message_id = store.create_message(task_id, sender, target, content, requires_ack=requires_ack, owner_team_id=owner_team_id)
|
|
244
|
+
if not block_until_delivered:
|
|
245
|
+
watch: dict[str, Any] | None = None
|
|
246
|
+
if watch_result:
|
|
247
|
+
watch_task_id = task_id or _current_task_for_agent(state.get("tasks", []), str(target))
|
|
248
|
+
watcher_id = store.create_result_watcher(watch_task_id, str(target), message_id, leader_id, owner_team_id=owner_team_id)
|
|
249
|
+
watch = {
|
|
250
|
+
"status": "registered",
|
|
251
|
+
"watcher_id": watcher_id,
|
|
252
|
+
"task_id": watch_task_id,
|
|
253
|
+
"agent_id": target,
|
|
254
|
+
"notice": (
|
|
255
|
+
"Team Agent will deliver this message when the worker is available, "
|
|
256
|
+
"then collect the result and notify the leader when this task reports completion."
|
|
257
|
+
),
|
|
258
|
+
}
|
|
259
|
+
event_log.write(
|
|
260
|
+
"result_watcher.created",
|
|
261
|
+
watcher_id=watcher_id,
|
|
262
|
+
task_id=watch_task_id,
|
|
263
|
+
agent_id=target,
|
|
264
|
+
message_id=message_id,
|
|
265
|
+
)
|
|
266
|
+
_capture_missing_sessions(workspace, state, event_log, timeout_s=0.0, log_miss=False)
|
|
267
|
+
save_team_scoped_state(workspace, state)
|
|
268
|
+
event_log.write(
|
|
269
|
+
"send.durably_stored",
|
|
270
|
+
message_id=message_id,
|
|
271
|
+
target=target,
|
|
272
|
+
sender=sender,
|
|
273
|
+
task_id=task_id,
|
|
274
|
+
)
|
|
275
|
+
result = {
|
|
276
|
+
"ok": True,
|
|
277
|
+
"message_id": message_id,
|
|
278
|
+
"status": "queued",
|
|
279
|
+
"message_status": "accepted",
|
|
280
|
+
"to": target,
|
|
281
|
+
"queued": True,
|
|
282
|
+
"durably_stored": True,
|
|
283
|
+
"reason": "deferred_to_coordinator",
|
|
284
|
+
"visible": False,
|
|
285
|
+
"submitted": False,
|
|
286
|
+
}
|
|
287
|
+
if watch is not None:
|
|
288
|
+
result["watch_result"] = True
|
|
289
|
+
result["watch"] = watch
|
|
290
|
+
return result
|
|
291
|
+
delivered_result = _deliver_pending_message(workspace, state, message_id, wait_visible=wait_visible, timeout=timeout)
|
|
292
|
+
row = _message_by_id(store, message_id)
|
|
293
|
+
message_status = row["status"] if row else delivered_result.get("message_status", delivered_result.get("status", "accepted"))
|
|
294
|
+
if (
|
|
295
|
+
mirror_peer
|
|
296
|
+
and not _is_leader_sender(sender, leader_id)
|
|
297
|
+
and not _is_leader_target(target, leader_id)
|
|
298
|
+
and delivered_result.get("ok")
|
|
299
|
+
and not delivered_result.get("queued")
|
|
300
|
+
):
|
|
301
|
+
_mirror_peer_message_to_leader(workspace, state, sender, target, content, task_id, event_log)
|
|
302
|
+
watch: dict[str, Any] | None = None
|
|
303
|
+
if watch_result and delivered_result.get("ok"):
|
|
304
|
+
watch_task_id = task_id or _current_task_for_agent(state.get("tasks", []), str(target))
|
|
305
|
+
watcher_id = store.create_result_watcher(watch_task_id, str(target), message_id, leader_id, owner_team_id=owner_team_id)
|
|
306
|
+
watch = {
|
|
307
|
+
"status": "registered",
|
|
308
|
+
"watcher_id": watcher_id,
|
|
309
|
+
"task_id": watch_task_id,
|
|
310
|
+
"agent_id": target,
|
|
311
|
+
"notice": (
|
|
312
|
+
"Team Agent will deliver this message when the worker is available, "
|
|
313
|
+
"then collect the result and notify the leader when this task reports completion."
|
|
314
|
+
if delivered_result.get("queued")
|
|
315
|
+
else "Team Agent will collect the result and notify the leader when this task reports completion."
|
|
316
|
+
),
|
|
317
|
+
}
|
|
318
|
+
event_log.write(
|
|
319
|
+
"result_watcher.created",
|
|
320
|
+
watcher_id=watcher_id,
|
|
321
|
+
task_id=watch_task_id,
|
|
322
|
+
agent_id=target,
|
|
323
|
+
message_id=message_id,
|
|
324
|
+
)
|
|
325
|
+
_capture_missing_sessions(workspace, state, event_log, timeout_s=0.0, log_miss=False)
|
|
326
|
+
save_team_scoped_state(workspace, state)
|
|
327
|
+
result = {
|
|
328
|
+
"ok": bool(delivered_result.get("ok")),
|
|
329
|
+
"message_id": message_id,
|
|
330
|
+
"status": delivered_result.get("status", message_status),
|
|
331
|
+
"message_status": message_status,
|
|
332
|
+
"to": target,
|
|
333
|
+
"visible": message_status in {"visible", "submitted"},
|
|
334
|
+
"submitted": message_status in {"visible", "submitted", "submitted_unverified", "delivered", "acknowledged"},
|
|
335
|
+
"verification": delivered_result.get("verification"),
|
|
336
|
+
"submit_verification": delivered_result.get("submit_verification"),
|
|
337
|
+
"turn_verification": delivered_result.get("turn_verification"),
|
|
338
|
+
}
|
|
339
|
+
if delivered_result.get("queued"):
|
|
340
|
+
result["queued"] = True
|
|
341
|
+
result["reason"] = delivered_result.get("reason")
|
|
342
|
+
if delivered_result.get("warning"):
|
|
343
|
+
result["warning"] = delivered_result["warning"]
|
|
344
|
+
for key in ("paste_attempts", "submit_attempts"):
|
|
345
|
+
if key in delivered_result:
|
|
346
|
+
result[key] = delivered_result[key]
|
|
347
|
+
if watch is not None:
|
|
348
|
+
result["watch_result"] = True
|
|
349
|
+
result["watch"] = watch
|
|
350
|
+
return result
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _broadcast_message_unlocked(
|
|
354
|
+
workspace: Path,
|
|
355
|
+
state: dict[str, Any],
|
|
356
|
+
spec: dict[str, Any],
|
|
357
|
+
event_log: EventLog,
|
|
358
|
+
content: str,
|
|
359
|
+
*,
|
|
360
|
+
task_id: str | None,
|
|
361
|
+
sender: str,
|
|
362
|
+
requires_ack: bool,
|
|
363
|
+
wait_visible: bool,
|
|
364
|
+
timeout: float,
|
|
365
|
+
block_until_delivered: bool = True,
|
|
366
|
+
owner_team_id: str | None = None,
|
|
367
|
+
) -> dict[str, Any]:
|
|
368
|
+
targets = _broadcast_targets(state, spec, sender)
|
|
369
|
+
if not targets:
|
|
370
|
+
event_log.write("send.broadcast_skipped", sender=sender, reason="no_team_recipients")
|
|
371
|
+
return {"ok": False, "status": "failed", "reason": "no_team_recipients", "to": "*", "targets": []}
|
|
372
|
+
event_log.write("send.broadcast_start", sender=sender, targets=targets, task_id=task_id)
|
|
373
|
+
deliveries: list[dict[str, Any]] = []
|
|
374
|
+
for recipient in targets:
|
|
375
|
+
result = _send_single_message_unlocked(
|
|
376
|
+
workspace,
|
|
377
|
+
state,
|
|
378
|
+
spec,
|
|
379
|
+
event_log,
|
|
380
|
+
recipient,
|
|
381
|
+
content,
|
|
382
|
+
task_id=task_id,
|
|
383
|
+
sender=sender,
|
|
384
|
+
requires_ack=requires_ack,
|
|
385
|
+
confirm_human=False,
|
|
386
|
+
wait_visible=wait_visible,
|
|
387
|
+
timeout=timeout,
|
|
388
|
+
watch_result=False,
|
|
389
|
+
mirror_peer=False,
|
|
390
|
+
route_task_id=False,
|
|
391
|
+
block_until_delivered=block_until_delivered,
|
|
392
|
+
owner_team_id=owner_team_id,
|
|
393
|
+
)
|
|
394
|
+
deliveries.append(_compact_broadcast_delivery(result))
|
|
395
|
+
failed = [item for item in deliveries if not item.get("ok")]
|
|
396
|
+
status = "broadcast_delivered" if not failed else "broadcast_partial"
|
|
397
|
+
event_log.write(
|
|
398
|
+
"send.broadcast_complete",
|
|
399
|
+
sender=sender,
|
|
400
|
+
targets=targets,
|
|
401
|
+
status=status,
|
|
402
|
+
delivered_count=len(deliveries) - len(failed),
|
|
403
|
+
failed_count=len(failed),
|
|
404
|
+
)
|
|
405
|
+
return {
|
|
406
|
+
"ok": not failed,
|
|
407
|
+
"status": status,
|
|
408
|
+
"to": "*",
|
|
409
|
+
"targets": targets,
|
|
410
|
+
"delivered_count": len(deliveries) - len(failed),
|
|
411
|
+
"failed_count": len(failed),
|
|
412
|
+
"deliveries": deliveries,
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _fanout_message_unlocked(
|
|
417
|
+
workspace: Path,
|
|
418
|
+
state: dict[str, Any],
|
|
419
|
+
spec: dict[str, Any],
|
|
420
|
+
event_log: EventLog,
|
|
421
|
+
targets: list[str],
|
|
422
|
+
content: str,
|
|
423
|
+
*,
|
|
424
|
+
task_id: str | None,
|
|
425
|
+
sender: str,
|
|
426
|
+
requires_ack: bool,
|
|
427
|
+
wait_visible: bool,
|
|
428
|
+
timeout: float,
|
|
429
|
+
block_until_delivered: bool = True,
|
|
430
|
+
owner_team_id: str | None = None,
|
|
431
|
+
) -> dict[str, Any]:
|
|
432
|
+
raw_recipients = [target for target in targets if target]
|
|
433
|
+
recipients = list(dict.fromkeys(raw_recipients))
|
|
434
|
+
if not recipients:
|
|
435
|
+
return {"ok": False, "status": "failed", "reason": "no_fanout_recipients", "to": targets, "targets": []}
|
|
436
|
+
if len(raw_recipients) != len(recipients):
|
|
437
|
+
event_log.write("send.fanout_dedupe", sender=sender, task_id=task_id,
|
|
438
|
+
raw_targets=raw_recipients, deduped_targets=recipients,
|
|
439
|
+
duplicate_count=len(raw_recipients) - len(recipients))
|
|
440
|
+
event_log.write("send.fanout_start", sender=sender, targets=recipients, task_id=task_id)
|
|
441
|
+
deliveries: list[dict[str, Any]] = []
|
|
442
|
+
by_recipient: dict[str, dict[str, Any]] = {}
|
|
443
|
+
for recipient in recipients:
|
|
444
|
+
result = _send_single_message_unlocked(
|
|
445
|
+
workspace,
|
|
446
|
+
state,
|
|
447
|
+
spec,
|
|
448
|
+
event_log,
|
|
449
|
+
recipient,
|
|
450
|
+
content,
|
|
451
|
+
task_id=task_id,
|
|
452
|
+
sender=sender,
|
|
453
|
+
requires_ack=requires_ack,
|
|
454
|
+
confirm_human=False,
|
|
455
|
+
wait_visible=wait_visible,
|
|
456
|
+
timeout=timeout,
|
|
457
|
+
watch_result=False,
|
|
458
|
+
mirror_peer=False,
|
|
459
|
+
route_task_id=False,
|
|
460
|
+
block_until_delivered=block_until_delivered,
|
|
461
|
+
owner_team_id=owner_team_id,
|
|
462
|
+
)
|
|
463
|
+
compact = _compact_fanout_delivery(result)
|
|
464
|
+
deliveries.append(compact)
|
|
465
|
+
by_recipient[recipient] = compact
|
|
466
|
+
failed = [item for item in deliveries if not item.get("delivered")]
|
|
467
|
+
status = "fanout_delivered" if not failed else "fanout_partial"
|
|
468
|
+
aggregate = {
|
|
469
|
+
"ok": True,
|
|
470
|
+
"status": status,
|
|
471
|
+
"to": recipients,
|
|
472
|
+
"targets": recipients,
|
|
473
|
+
"delivered_count": len(deliveries) - len(failed),
|
|
474
|
+
"failed_count": len(failed),
|
|
475
|
+
"deliveries": deliveries,
|
|
476
|
+
"recipients": by_recipient,
|
|
477
|
+
}
|
|
478
|
+
event_log.write("send.fanout_status", **aggregate)
|
|
479
|
+
return aggregate
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _broadcast_targets(state: dict[str, Any], spec: dict[str, Any], sender: str) -> list[str]:
|
|
483
|
+
leader_id = _leader_id(state, spec)
|
|
484
|
+
targets = [leader_id, *_runtime_team_agent_ids(state, spec)]
|
|
485
|
+
if _is_leader_sender(sender, leader_id):
|
|
486
|
+
excluded = {leader_id}
|
|
487
|
+
else:
|
|
488
|
+
excluded = {sender}
|
|
489
|
+
return [target for target in targets if target not in excluded]
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _compact_broadcast_delivery(result: dict[str, Any]) -> dict[str, Any]:
|
|
493
|
+
keys = ["ok", "status", "message_id", "to", "reason", "channel"]
|
|
494
|
+
return {key: result[key] for key in keys if key in result}
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _compact_fanout_delivery(result: dict[str, Any]) -> dict[str, Any]:
|
|
498
|
+
compact = _compact_broadcast_delivery(result)
|
|
499
|
+
compact["delivered"] = bool(result.get("submitted") or result.get("visible") or result.get("status") in {"submitted", "visible", "delivered", "acknowledged"})
|
|
500
|
+
return compact
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from team_agent.events import EventLog
|
|
9
|
+
|
|
10
|
+
_UUID = r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
|
|
11
|
+
_RESUME_THREAD_RE = re.compile(
|
|
12
|
+
rf"(?:Switched to thread|resume|thread)\s+({_UUID})",
|
|
13
|
+
re.IGNORECASE,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def extract_thread_id_from_scrollback(scrollback: str) -> str | None:
|
|
18
|
+
if not scrollback:
|
|
19
|
+
return None
|
|
20
|
+
matches = _RESUME_THREAD_RE.findall(scrollback)
|
|
21
|
+
if not matches:
|
|
22
|
+
return None
|
|
23
|
+
return matches[-1].lower()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def detect_session_drift(
|
|
27
|
+
workspace: Path,
|
|
28
|
+
state: dict[str, Any],
|
|
29
|
+
event_log: EventLog,
|
|
30
|
+
*,
|
|
31
|
+
agent_id: str,
|
|
32
|
+
agent_state: dict[str, Any],
|
|
33
|
+
scrollback: str,
|
|
34
|
+
) -> dict[str, Any] | None:
|
|
35
|
+
provider = str(agent_state.get("provider") or "").lower()
|
|
36
|
+
if provider != "codex":
|
|
37
|
+
return None
|
|
38
|
+
stored = str(agent_state.get("session_id") or "").strip()
|
|
39
|
+
if not stored:
|
|
40
|
+
return None
|
|
41
|
+
if str(agent_state.get("status") or "").lower() == "session_drift":
|
|
42
|
+
return None
|
|
43
|
+
actual = extract_thread_id_from_scrollback(scrollback)
|
|
44
|
+
if not actual:
|
|
45
|
+
return None
|
|
46
|
+
if actual.lower() == stored.lower():
|
|
47
|
+
return None
|
|
48
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
49
|
+
event = event_log.write(
|
|
50
|
+
"coordinator.session_drift_detected",
|
|
51
|
+
agent_id=agent_id,
|
|
52
|
+
stored_session_id=stored,
|
|
53
|
+
actual_thread_id=actual,
|
|
54
|
+
status="session_drift",
|
|
55
|
+
provider=provider,
|
|
56
|
+
ts=now,
|
|
57
|
+
remediation="team-agent reset-agent --discard-session <agent>",
|
|
58
|
+
)
|
|
59
|
+
agent_state["status"] = "session_drift"
|
|
60
|
+
agent_state["session_drift"] = {
|
|
61
|
+
"stored_session_id": stored,
|
|
62
|
+
"actual_thread_id": actual,
|
|
63
|
+
"detected_at": now,
|
|
64
|
+
"remediation": "team-agent reset-agent --discard-session <agent>",
|
|
65
|
+
}
|
|
66
|
+
return event
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def session_drift_refusal(state, target, leader_id, sender, task_id, event_log):
|
|
70
|
+
if not target or target == leader_id or target == "*":
|
|
71
|
+
return None
|
|
72
|
+
rs = (state.get("agents") or {}).get(target) or {}
|
|
73
|
+
if str(rs.get("status") or "").lower() != "session_drift":
|
|
74
|
+
return None
|
|
75
|
+
info = rs.get("session_drift") or {}
|
|
76
|
+
event_log.write(
|
|
77
|
+
"send.refused_session_drift",
|
|
78
|
+
target=target,
|
|
79
|
+
sender=sender,
|
|
80
|
+
task_id=task_id,
|
|
81
|
+
stored_session_id=info.get("stored_session_id"),
|
|
82
|
+
actual_thread_id=info.get("actual_thread_id"),
|
|
83
|
+
)
|
|
84
|
+
return {
|
|
85
|
+
"ok": False,
|
|
86
|
+
"status": "refused",
|
|
87
|
+
"reason": "session_drift",
|
|
88
|
+
"to": target,
|
|
89
|
+
"action": f"team-agent reset-agent --discard-session {target}",
|
|
90
|
+
"session_drift": info,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
__all__ = ["detect_session_drift", "extract_thread_id_from_scrollback", "session_drift_refusal"]
|