@team-agent/installer 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/team_agent/cli/commands.py +18 -3
- package/src/team_agent/cli/parser.py +33 -1
- package/src/team_agent/coordinator/__main__.py +21 -2
- package/src/team_agent/coordinator/lifecycle.py +8 -0
- package/src/team_agent/diagnose/orphan_cleanup.py +193 -0
- package/src/team_agent/events.py +47 -0
- package/src/team_agent/leader/__init__.py +273 -60
- package/src/team_agent/lifecycle/agents.py +54 -2
- package/src/team_agent/lifecycle/operations.py +86 -9
- package/src/team_agent/message_store/leader_notification_log.py +132 -0
- package/src/team_agent/message_store/result_watchers.py +144 -1
- package/src/team_agent/message_store/schema.py +23 -0
- package/src/team_agent/messaging/idle_alerts.py +109 -9
- package/src/team_agent/messaging/leader.py +166 -6
- package/src/team_agent/messaging/leader_panes.py +193 -23
- package/src/team_agent/messaging/result_delivery.py +219 -4
- package/src/team_agent/messaging/results.py +12 -21
- package/src/team_agent/messaging/scheduler.py +12 -2
- package/src/team_agent/runtime.py +4 -4
- package/src/team_agent/rust_core.py +157 -3
- package/src/team_agent/state.py +153 -10
- package/src/team_agent/status/inbox.py +33 -3
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import hashlib
|
|
4
|
+
|
|
3
5
|
from team_agent.messaging.deps import (
|
|
4
6
|
EventLog,
|
|
5
7
|
MessageStore,
|
|
@@ -10,8 +12,10 @@ from team_agent.messaging.deps import (
|
|
|
10
12
|
_validate_leader_receiver,
|
|
11
13
|
core_render_message,
|
|
12
14
|
json,
|
|
15
|
+
os,
|
|
13
16
|
runtime_dir,
|
|
14
17
|
save_runtime_state,
|
|
18
|
+
team_state_key,
|
|
15
19
|
time,
|
|
16
20
|
)
|
|
17
21
|
|
|
@@ -49,6 +53,19 @@ def _leader_inbox_path(workspace: Path) -> Path:
|
|
|
49
53
|
return runtime_dir(workspace) / "leader-inbox.log"
|
|
50
54
|
|
|
51
55
|
|
|
56
|
+
def _extract_result_id_from_content(content: str) -> str | None:
|
|
57
|
+
"""Stage 12: result-notification messages embed a `Result id: <id>` line; the gate
|
|
58
|
+
parses it from content so callers that did NOT plumb the result_id kwarg through
|
|
59
|
+
still consult the dedupe gate. Format mirrors _format_report_result_notification and
|
|
60
|
+
format_result_watcher_notification."""
|
|
61
|
+
if not content:
|
|
62
|
+
return None
|
|
63
|
+
for line in content.splitlines():
|
|
64
|
+
if line.startswith("Result id: "):
|
|
65
|
+
return line.removeprefix("Result id: ").strip() or None
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
52
69
|
def _send_to_leader_receiver(
|
|
53
70
|
workspace: Path,
|
|
54
71
|
state: dict[str, Any],
|
|
@@ -58,6 +75,8 @@ def _send_to_leader_receiver(
|
|
|
58
75
|
sender: str,
|
|
59
76
|
requires_ack: bool,
|
|
60
77
|
event_log: EventLog,
|
|
78
|
+
*,
|
|
79
|
+
result_id: str | None = None,
|
|
61
80
|
) -> dict[str, Any]:
|
|
62
81
|
store = MessageStore(workspace)
|
|
63
82
|
message_id = store.create_message(task_id, sender, leader_id, content, requires_ack=False)
|
|
@@ -94,10 +113,30 @@ def _send_to_leader_receiver(
|
|
|
94
113
|
error="No direct leader tmux pane is attached. Run team-agent attach-leader.",
|
|
95
114
|
)
|
|
96
115
|
|
|
97
|
-
|
|
116
|
+
owner_identity = state.get("team_owner") or None
|
|
117
|
+
side_pane_refusal = _side_pane_owner_refusal(state, owner_identity)
|
|
118
|
+
if side_pane_refusal:
|
|
119
|
+
event_log.write("leader_receiver.side_pane_refused", **side_pane_refusal)
|
|
120
|
+
return {
|
|
121
|
+
"ok": False,
|
|
122
|
+
"message_id": message_id,
|
|
123
|
+
"status": "refused",
|
|
124
|
+
"to": leader_id,
|
|
125
|
+
"channel": "direct_tmux",
|
|
126
|
+
**side_pane_refusal,
|
|
127
|
+
}
|
|
128
|
+
receiver_for_validation = dict(receiver)
|
|
129
|
+
if owner_identity and owner_identity.get("leader_session_uuid") and not receiver_for_validation.get("leader_session_uuid"):
|
|
130
|
+
receiver_for_validation["leader_session_uuid"] = owner_identity["leader_session_uuid"]
|
|
131
|
+
validation = _validate_leader_receiver(receiver_for_validation)
|
|
98
132
|
if not validation["ok"]:
|
|
99
|
-
|
|
100
|
-
|
|
133
|
+
rediscovery = _rediscover_leader_receiver(
|
|
134
|
+
receiver_for_validation,
|
|
135
|
+
event_log,
|
|
136
|
+
owner_identity,
|
|
137
|
+
invalidation_reason=validation.get("reason"),
|
|
138
|
+
team_id=team_state_key(state),
|
|
139
|
+
)
|
|
101
140
|
if rediscovery.get("status") == "updated":
|
|
102
141
|
state["leader_receiver"].update(rediscovery["receiver"])
|
|
103
142
|
receiver = state["leader_receiver"]
|
|
@@ -111,7 +150,7 @@ def _send_to_leader_receiver(
|
|
|
111
150
|
payload,
|
|
112
151
|
event_log,
|
|
113
152
|
reason="ambiguous",
|
|
114
|
-
error="multiple possible leader panes found;
|
|
153
|
+
error="multiple possible leader panes found; run team-agent claim-leader --confirm from the intended pane",
|
|
115
154
|
message_status="ambiguous",
|
|
116
155
|
)
|
|
117
156
|
if not validation["ok"]:
|
|
@@ -128,6 +167,69 @@ def _send_to_leader_receiver(
|
|
|
128
167
|
state["leader_receiver"].update(validation["pane"])
|
|
129
168
|
submit_key, submit_reason = _choose_leader_submit_key(receiver.get("provider", "codex"), validation.get("capture", ""))
|
|
130
169
|
target = receiver["pane_id"]
|
|
170
|
+
# Stage 12 (Gap 26 ∩ Gap 32 roundtable 2026-05-26) — injection-boundary dedupe gate.
|
|
171
|
+
# Result-notification injections route through claim_leader_notification_delivery; the
|
|
172
|
+
# gate suppresses a second inject for the same (result_id, leader_session_uuid).
|
|
173
|
+
# Non-result messages (peer mirror, idle reminder, ambiguous-prompt) lack a "Result id:"
|
|
174
|
+
# line in their text and bypass the gate.
|
|
175
|
+
effective_result_id = result_id or _extract_result_id_from_content(content)
|
|
176
|
+
leader_uuid_for_gate = str(
|
|
177
|
+
(state.get("team_owner") or {}).get("leader_session_uuid")
|
|
178
|
+
or (state.get("leader_receiver") or {}).get("leader_session_uuid")
|
|
179
|
+
or ""
|
|
180
|
+
)
|
|
181
|
+
if effective_result_id and leader_uuid_for_gate:
|
|
182
|
+
from team_agent.message_store.leader_notification_log import claim_leader_notification_delivery
|
|
183
|
+
envelope_hash = hashlib.sha256(content.encode("utf-8", errors="ignore")).hexdigest()[:16]
|
|
184
|
+
claim = claim_leader_notification_delivery(
|
|
185
|
+
store,
|
|
186
|
+
result_id=effective_result_id,
|
|
187
|
+
leader_session_uuid=leader_uuid_for_gate,
|
|
188
|
+
proposed_message_id=message_id,
|
|
189
|
+
envelope_hash=envelope_hash,
|
|
190
|
+
owner_team_id=team_state_key(state),
|
|
191
|
+
pane_id=target,
|
|
192
|
+
)
|
|
193
|
+
if claim["status"] == "already_notified_by":
|
|
194
|
+
prev_msg = claim.get("notified_message_id")
|
|
195
|
+
prev_hash = claim.get("envelope_content_hash")
|
|
196
|
+
if envelope_hash == prev_hash:
|
|
197
|
+
event_log.write(
|
|
198
|
+
"leader_notification.dedupe_skip",
|
|
199
|
+
result_id=effective_result_id,
|
|
200
|
+
leader_session_uuid=leader_uuid_for_gate,
|
|
201
|
+
prev_message_id=prev_msg,
|
|
202
|
+
this_message_id=message_id,
|
|
203
|
+
prev_ts=claim.get("notified_at"),
|
|
204
|
+
pane_id=target,
|
|
205
|
+
team_id=team_state_key(state),
|
|
206
|
+
)
|
|
207
|
+
else:
|
|
208
|
+
event_log.write(
|
|
209
|
+
"leader_notification.legitimate_duplicate_suspected",
|
|
210
|
+
result_id=effective_result_id,
|
|
211
|
+
leader_session_uuid=leader_uuid_for_gate,
|
|
212
|
+
prev_message_id=prev_msg,
|
|
213
|
+
this_message_id=message_id,
|
|
214
|
+
prev_envelope_hash=prev_hash,
|
|
215
|
+
this_envelope_hash=envelope_hash,
|
|
216
|
+
pane_id=target,
|
|
217
|
+
team_id=team_state_key(state),
|
|
218
|
+
)
|
|
219
|
+
store.mark(message_id, "submitted", "dedupe_suppressed_by_leader_notification_log")
|
|
220
|
+
save_runtime_state(workspace, state)
|
|
221
|
+
return {
|
|
222
|
+
"ok": True,
|
|
223
|
+
"message_id": message_id,
|
|
224
|
+
"status": "submitted",
|
|
225
|
+
"to": leader_id,
|
|
226
|
+
"channel": "direct_tmux",
|
|
227
|
+
"leader_receiver": state["leader_receiver"],
|
|
228
|
+
"visible": False,
|
|
229
|
+
"submitted": False,
|
|
230
|
+
"deduped": True,
|
|
231
|
+
"canonical_message_id": prev_msg,
|
|
232
|
+
}
|
|
131
233
|
event_log.write(
|
|
132
234
|
"leader_receiver.deliver_attempt",
|
|
133
235
|
message_id=message_id,
|
|
@@ -139,6 +241,8 @@ def _send_to_leader_receiver(
|
|
|
139
241
|
visible_token=rendered.get("token"),
|
|
140
242
|
payload=payload,
|
|
141
243
|
warning=validation.get("warning"),
|
|
244
|
+
result_id=effective_result_id,
|
|
245
|
+
leader_session_uuid=leader_uuid_for_gate or None,
|
|
142
246
|
)
|
|
143
247
|
injection = _tmux_inject_text(
|
|
144
248
|
target,
|
|
@@ -201,6 +305,64 @@ def _send_to_leader_receiver(
|
|
|
201
305
|
)
|
|
202
306
|
|
|
203
307
|
|
|
308
|
+
def _side_pane_owner_refusal(state: dict[str, Any], owner_identity: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
309
|
+
owner_uuid = str((owner_identity or {}).get("leader_session_uuid") or "")
|
|
310
|
+
caller_uuid = os.environ.get("TEAM_AGENT_LEADER_SESSION_UUID") or os.environ.get("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE") or ""
|
|
311
|
+
if not owner_uuid or not caller_uuid or caller_uuid == owner_uuid:
|
|
312
|
+
return None
|
|
313
|
+
bound_pane = (state.get("leader_receiver") or {}).get("pane_id") or (owner_identity or {}).get("pane_id")
|
|
314
|
+
team_id = team_state_key(state)
|
|
315
|
+
return {
|
|
316
|
+
"reason": "team_owner_mismatch",
|
|
317
|
+
"error": (
|
|
318
|
+
f"This workspace's team `{team_id}` is already bound to pane `{bound_pane}`. "
|
|
319
|
+
"To work in this window either start a new team with a different team_id, operate through the bound pane, "
|
|
320
|
+
"or run `team-agent claim-leader --confirm` only if you intend to forcibly take over."
|
|
321
|
+
),
|
|
322
|
+
"bound_pane_id": bound_pane,
|
|
323
|
+
"caller_uuid_prefix": caller_uuid[:8],
|
|
324
|
+
"uuid_prefix": owner_uuid[:8],
|
|
325
|
+
"action": "team-agent claim-leader --confirm",
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def claim_leader_receiver(
|
|
330
|
+
workspace: Path,
|
|
331
|
+
state: dict[str, Any],
|
|
332
|
+
candidate: dict[str, Any],
|
|
333
|
+
event_log: EventLog,
|
|
334
|
+
*,
|
|
335
|
+
confirm: bool,
|
|
336
|
+
expected_epoch: int | None = None,
|
|
337
|
+
) -> dict[str, Any]:
|
|
338
|
+
from team_agent.messaging.leader_panes import _leader_command_looks_usable, _receiver_from_target, _target_matches_owner_identity, _uuid_prefix
|
|
339
|
+
if not confirm:
|
|
340
|
+
return {"ok": False, "status": "refused", "reason": "confirm_required", "action": "team-agent claim-leader --confirm"}
|
|
341
|
+
owner = state.setdefault("team_owner", {})
|
|
342
|
+
receiver = state.get("leader_receiver") or {}
|
|
343
|
+
current_epoch = int(owner.get("owner_epoch") or receiver.get("owner_epoch") or 0)
|
|
344
|
+
if expected_epoch is not None and current_epoch != expected_epoch:
|
|
345
|
+
event_log.write("leader_receiver.claim_refused", reason="owner_epoch_advanced", owner_epoch=current_epoch, bound_pane_id=receiver.get("pane_id"))
|
|
346
|
+
return {"ok": False, "status": "refused", "reason": "owner_epoch_advanced", "owner_epoch": current_epoch, "bound_pane_id": receiver.get("pane_id")}
|
|
347
|
+
if receiver.get("pane_id") == candidate.get("pane_id"):
|
|
348
|
+
return {"ok": True, "status": "already_bound", "leader_receiver": receiver, "owner_epoch": current_epoch}
|
|
349
|
+
if not _target_matches_owner_identity(candidate, owner):
|
|
350
|
+
event_log.write("leader_receiver.claim_refused", reason="uuid_mismatch", candidate_pane_id=candidate.get("pane_id"))
|
|
351
|
+
return {"ok": False, "status": "refused", "reason": "uuid_mismatch"}
|
|
352
|
+
provider = str(candidate.get("provider") or receiver.get("provider") or "codex")
|
|
353
|
+
if not _leader_command_looks_usable(str(candidate.get("pane_current_command", "")), provider):
|
|
354
|
+
return {"ok": False, "status": "refused", "reason": "wrong_command", "candidate_pane_id": candidate.get("pane_id")}
|
|
355
|
+
next_epoch = current_epoch + 1
|
|
356
|
+
new_receiver = _receiver_from_target(candidate, provider, owner.get("leader_session_uuid"), next_epoch)
|
|
357
|
+
owner["owner_epoch"] = next_epoch
|
|
358
|
+
state["leader_receiver"] = new_receiver
|
|
359
|
+
from team_agent.runtime import _runtime_lock, save_runtime_state
|
|
360
|
+
with _runtime_lock(workspace, "leader_receiver"):
|
|
361
|
+
save_runtime_state(workspace, state)
|
|
362
|
+
event_log.write("leader_receiver.claimed", pane_id=new_receiver["pane_id"], owner_epoch=next_epoch, uuid_prefix=_uuid_prefix(owner))
|
|
363
|
+
return {"ok": True, "status": "claimed", "leader_receiver": new_receiver, "owner_epoch": next_epoch}
|
|
364
|
+
|
|
365
|
+
|
|
204
366
|
def _fail_leader_delivery(
|
|
205
367
|
workspace: Path,
|
|
206
368
|
state: dict[str, Any],
|
|
@@ -310,8 +472,6 @@ def _format_team_agent_message(payload: dict[str, Any]) -> str:
|
|
|
310
472
|
|
|
311
473
|
|
|
312
474
|
|
|
313
|
-
|
|
314
|
-
|
|
315
475
|
|
|
316
476
|
|
|
317
477
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import hashlib
|
|
4
|
+
|
|
3
5
|
from team_agent.messaging.deps import (
|
|
4
6
|
EventLog,
|
|
5
7
|
RuntimeError,
|
|
@@ -9,6 +11,7 @@ from team_agent.messaging.deps import (
|
|
|
9
11
|
_tmux_current_client_pane_info as _runtime_tmux_current_client_pane_info,
|
|
10
12
|
_tmux_list_panes as _runtime_tmux_list_panes,
|
|
11
13
|
_tmux_pane_info as _runtime_tmux_pane_info,
|
|
14
|
+
_tmux_inject_text,
|
|
12
15
|
core_list_targets,
|
|
13
16
|
datetime,
|
|
14
17
|
os,
|
|
@@ -20,6 +23,8 @@ from team_agent.messaging.deps import (
|
|
|
20
23
|
from pathlib import Path
|
|
21
24
|
from typing import Any
|
|
22
25
|
|
|
26
|
+
_AMBIGUOUS_DEBOUNCE_SECONDS = 60
|
|
27
|
+
|
|
23
28
|
def _resolve_leader_pane(
|
|
24
29
|
pane: str | None,
|
|
25
30
|
provider: str,
|
|
@@ -208,17 +213,40 @@ def _target_fingerprint(pane_info: dict[str, Any]) -> str:
|
|
|
208
213
|
)
|
|
209
214
|
|
|
210
215
|
|
|
216
|
+
def is_bound_pane_still_valid(state: dict[str, Any], store: Any | None = None) -> dict[str, Any]:
|
|
217
|
+
receiver = dict(state.get("leader_receiver") or {})
|
|
218
|
+
owner = state.get("team_owner") if isinstance(state.get("team_owner"), dict) else {}
|
|
219
|
+
if owner and owner.get("leader_session_uuid") and not receiver.get("leader_session_uuid"):
|
|
220
|
+
receiver["leader_session_uuid"] = owner["leader_session_uuid"]
|
|
221
|
+
return _validate_leader_receiver(receiver)
|
|
222
|
+
|
|
223
|
+
|
|
211
224
|
def _rediscover_leader_receiver(
|
|
212
225
|
receiver: dict[str, Any],
|
|
213
226
|
event_log: EventLog,
|
|
214
227
|
owner_identity: dict[str, Any] | None = None,
|
|
228
|
+
invalidation_reason: str | None = None,
|
|
229
|
+
team_id: str | None = None,
|
|
215
230
|
) -> dict[str, Any]:
|
|
216
231
|
provider = str(receiver.get("provider") or "codex")
|
|
217
|
-
if provider
|
|
218
|
-
return {"status": "missing", "reason": "
|
|
232
|
+
if provider == "fake":
|
|
233
|
+
return {"status": "missing", "reason": "rediscovery_not_supported_for_fake"}
|
|
219
234
|
targets = core_list_targets()
|
|
220
235
|
if not targets.get("ok"):
|
|
221
236
|
event_log.write("leader_receiver.rediscover_failed", provider=provider, error=targets.get("error"))
|
|
237
|
+
# Stage 15 CI fix: when the tmux target scan itself fails (no server, no daemon,
|
|
238
|
+
# CI env without tmux), the caller has no way to recover unless we also emit
|
|
239
|
+
# rebind_required. Without this, _refresh_leader_receiver_or_flag_rebind silently
|
|
240
|
+
# returns and report_result queues against the stale pane with zero audit signal.
|
|
241
|
+
event_log.write(
|
|
242
|
+
"leader_receiver.rebind_required",
|
|
243
|
+
old_pane_id=receiver.get("pane_id"),
|
|
244
|
+
reason=invalidation_reason,
|
|
245
|
+
provider=provider,
|
|
246
|
+
team_id=team_id,
|
|
247
|
+
rediscovery_status="failed",
|
|
248
|
+
error=targets.get("error"),
|
|
249
|
+
)
|
|
222
250
|
return {"status": "failed", "error": targets.get("error")}
|
|
223
251
|
candidates = [
|
|
224
252
|
target
|
|
@@ -228,16 +256,26 @@ def _rediscover_leader_receiver(
|
|
|
228
256
|
if owner_identity:
|
|
229
257
|
owner_candidates = [target for target in candidates if _target_matches_owner_identity(target, owner_identity)]
|
|
230
258
|
if len(owner_candidates) == 1:
|
|
231
|
-
return _rediscovered_receiver(receiver, provider, owner_candidates[0], event_log, owner_identity)
|
|
259
|
+
return _rediscovered_receiver(receiver, provider, owner_candidates[0], event_log, owner_identity, invalidation_reason)
|
|
232
260
|
if len(owner_candidates) > 1:
|
|
261
|
+
incident = _broadcast_ambiguous_candidates(
|
|
262
|
+
receiver,
|
|
263
|
+
provider,
|
|
264
|
+
owner_candidates,
|
|
265
|
+
event_log,
|
|
266
|
+
owner_identity,
|
|
267
|
+
team_id,
|
|
268
|
+
)
|
|
233
269
|
event_log.write(
|
|
234
270
|
"leader_receiver.rediscover_ambiguous",
|
|
235
271
|
provider=provider,
|
|
236
272
|
old_target=receiver.get("pane_id"),
|
|
237
273
|
candidates=[target.get("pane_id") for target in owner_candidates],
|
|
238
274
|
owner_identity=owner_identity,
|
|
275
|
+
incident_id=incident.get("incident_id"),
|
|
276
|
+
deduped=incident.get("deduped"),
|
|
239
277
|
)
|
|
240
|
-
return {"status": "ambiguous", "candidates": owner_candidates, "owner_identity": owner_identity}
|
|
278
|
+
return {"status": "ambiguous", "candidates": owner_candidates, "owner_identity": owner_identity, **incident}
|
|
241
279
|
event_log.write(
|
|
242
280
|
"leader_receiver.rediscover_missing",
|
|
243
281
|
provider=provider,
|
|
@@ -245,9 +283,19 @@ def _rediscover_leader_receiver(
|
|
|
245
283
|
owner_identity=owner_identity,
|
|
246
284
|
candidate_count=len(candidates),
|
|
247
285
|
)
|
|
286
|
+
event_log.write(
|
|
287
|
+
"leader_receiver.rebind_required",
|
|
288
|
+
old_pane_id=receiver.get("pane_id"),
|
|
289
|
+
reason=invalidation_reason,
|
|
290
|
+
provider=provider,
|
|
291
|
+
team_id=team_id,
|
|
292
|
+
uuid_prefix=_uuid_prefix(owner_identity),
|
|
293
|
+
owner_identity=owner_identity,
|
|
294
|
+
recovery_action="open the owning leader pane or run team-agent claim-leader --confirm from a matching pane",
|
|
295
|
+
)
|
|
248
296
|
return {"status": "missing", "owner_identity": owner_identity}
|
|
249
297
|
if len(candidates) == 1:
|
|
250
|
-
return _rediscovered_receiver(receiver, provider, candidates[0], event_log, None)
|
|
298
|
+
return _rediscovered_receiver(receiver, provider, candidates[0], event_log, None, invalidation_reason)
|
|
251
299
|
if len(candidates) > 1:
|
|
252
300
|
event_log.write(
|
|
253
301
|
"leader_receiver.rediscover_ambiguous",
|
|
@@ -255,12 +303,19 @@ def _rediscover_leader_receiver(
|
|
|
255
303
|
old_target=receiver.get("pane_id"),
|
|
256
304
|
candidates=[target.get("pane_id") for target in candidates],
|
|
257
305
|
)
|
|
306
|
+
event_log.write("leader_receiver.rebind_required", old_pane_id=receiver.get("pane_id"), reason=invalidation_reason, provider=provider, team_id=team_id, rediscovery_status="ambiguous")
|
|
258
307
|
return {"status": "ambiguous", "candidates": candidates}
|
|
259
308
|
event_log.write("leader_receiver.rediscover_missing", provider=provider, old_target=receiver.get("pane_id"))
|
|
309
|
+
event_log.write("leader_receiver.rebind_required", old_pane_id=receiver.get("pane_id"), reason=invalidation_reason, provider=provider, team_id=team_id, rediscovery_status="missing")
|
|
260
310
|
return {"status": "missing"}
|
|
261
311
|
|
|
262
312
|
|
|
263
313
|
def _target_matches_owner_identity(target: dict[str, Any], owner_identity: dict[str, Any]) -> bool:
|
|
314
|
+
expected_uuid = owner_identity.get("leader_session_uuid")
|
|
315
|
+
if expected_uuid:
|
|
316
|
+
actual_uuid = _target_leader_session_uuid(target)
|
|
317
|
+
if actual_uuid:
|
|
318
|
+
return actual_uuid == expected_uuid
|
|
264
319
|
env = target.get("leader_env") if isinstance(target.get("leader_env"), dict) else {}
|
|
265
320
|
return (
|
|
266
321
|
env.get("TEAM_AGENT_LEADER_PANE_ID") == (owner_identity.get("pane_id") or "")
|
|
@@ -269,14 +324,31 @@ def _target_matches_owner_identity(target: dict[str, Any], owner_identity: dict[
|
|
|
269
324
|
)
|
|
270
325
|
|
|
271
326
|
|
|
272
|
-
def
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
)
|
|
279
|
-
|
|
327
|
+
def _target_leader_session_uuid(target: dict[str, Any]) -> str:
|
|
328
|
+
env = target.get("leader_env") if isinstance(target.get("leader_env"), dict) else {}
|
|
329
|
+
return str(target.get("leader_session_uuid") or env.get("TEAM_AGENT_LEADER_SESSION_UUID") or "")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _leader_uuid_for_bound_pane(receiver: dict[str, Any], pane_info: dict[str, Any]) -> str:
|
|
333
|
+
direct = _target_leader_session_uuid(pane_info) or _target_leader_session_uuid(receiver)
|
|
334
|
+
if direct:
|
|
335
|
+
return direct
|
|
336
|
+
targets = core_list_targets()
|
|
337
|
+
if not targets.get("ok"):
|
|
338
|
+
return ""
|
|
339
|
+
pane_id = pane_info.get("pane_id")
|
|
340
|
+
for target in targets.get("targets", []):
|
|
341
|
+
if target.get("pane_id") == pane_id:
|
|
342
|
+
return _target_leader_session_uuid(target)
|
|
343
|
+
return ""
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _uuid_prefix(owner_identity: dict[str, Any] | None) -> str:
|
|
347
|
+
return str((owner_identity or {}).get("leader_session_uuid") or "")[:8]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _receiver_from_target(target: dict[str, Any], provider: str, leader_uuid: str | None, owner_epoch: int | None = None) -> dict[str, Any]:
|
|
351
|
+
receiver = {
|
|
280
352
|
"mode": "direct_tmux",
|
|
281
353
|
"status": "attached",
|
|
282
354
|
"provider": provider,
|
|
@@ -289,8 +361,83 @@ def _rediscovered_receiver(
|
|
|
289
361
|
"pane_current_command": target["pane_current_command"],
|
|
290
362
|
"fingerprint": target.get("fingerprint") or _target_fingerprint(target),
|
|
291
363
|
"attached_at": datetime.now(timezone.utc).isoformat(),
|
|
292
|
-
"discovery": "stale_rediscovery_owner_identity" if owner_identity else "stale_rediscovery_unique_candidate",
|
|
293
364
|
}
|
|
365
|
+
if leader_uuid:
|
|
366
|
+
receiver["leader_session_uuid"] = leader_uuid
|
|
367
|
+
if owner_epoch is not None:
|
|
368
|
+
receiver["owner_epoch"] = owner_epoch
|
|
369
|
+
return receiver
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _broadcast_ambiguous_candidates(
|
|
373
|
+
receiver: dict[str, Any],
|
|
374
|
+
provider: str,
|
|
375
|
+
candidates: list[dict[str, Any]],
|
|
376
|
+
event_log: EventLog,
|
|
377
|
+
owner_identity: dict[str, Any],
|
|
378
|
+
team_id: str | None,
|
|
379
|
+
) -> dict[str, Any]:
|
|
380
|
+
candidate_ids = sorted(str(candidate.get("pane_id")) for candidate in candidates)
|
|
381
|
+
bucket = _ambiguous_debounce_bucket()
|
|
382
|
+
incident_id = hashlib.sha256("\0".join([str(team_id or ""), *candidate_ids, bucket]).encode("utf-8")).hexdigest()[:16]
|
|
383
|
+
if any(event.get("event") == "leader_receiver.ambiguous_candidates" and event.get("incident_id") == incident_id for event in event_log.tail(200)):
|
|
384
|
+
return {"incident_id": incident_id, "deduped": True}
|
|
385
|
+
prompt = _ambiguous_candidate_prompt(team_id, len(candidates))
|
|
386
|
+
event_log.write(
|
|
387
|
+
"leader_receiver.ambiguous_candidates",
|
|
388
|
+
incident_id=incident_id,
|
|
389
|
+
old_pane_id=receiver.get("pane_id"),
|
|
390
|
+
candidates=candidate_ids,
|
|
391
|
+
provider=provider,
|
|
392
|
+
team_id=team_id,
|
|
393
|
+
uuid_prefix=_uuid_prefix(owner_identity),
|
|
394
|
+
debounce_bucket=bucket,
|
|
395
|
+
)
|
|
396
|
+
for candidate in candidates:
|
|
397
|
+
pane_id = str(candidate.get("pane_id") or "")
|
|
398
|
+
injected = _tmux_inject_text(
|
|
399
|
+
pane_id,
|
|
400
|
+
prompt,
|
|
401
|
+
"Enter",
|
|
402
|
+
f"team-agent-leader-ambiguous-{incident_id}-{pane_id.strip('%')}",
|
|
403
|
+
provider=provider,
|
|
404
|
+
)
|
|
405
|
+
event_log.write(
|
|
406
|
+
"leader_receiver.ambiguous_candidate_queued",
|
|
407
|
+
incident_id=incident_id,
|
|
408
|
+
pane_id=pane_id,
|
|
409
|
+
ok=bool(injected.get("ok")),
|
|
410
|
+
error=injected.get("error"),
|
|
411
|
+
)
|
|
412
|
+
return {"incident_id": incident_id, "deduped": False}
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _ambiguous_debounce_bucket() -> str:
|
|
416
|
+
now = datetime.now(timezone.utc)
|
|
417
|
+
epoch = int(now.timestamp() // _AMBIGUOUS_DEBOUNCE_SECONDS) * _AMBIGUOUS_DEBOUNCE_SECONDS
|
|
418
|
+
return datetime.fromtimestamp(epoch, timezone.utc).isoformat()
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _ambiguous_candidate_prompt(team_id: str | None, candidate_count: int) -> str:
|
|
422
|
+
others = max(candidate_count - 1, 0)
|
|
423
|
+
return (
|
|
424
|
+
f"Team `{team_id or 'current'}` has no bound leader. This window and {others} other window(s) all qualify. "
|
|
425
|
+
"To claim this window as the team leader, run: `team-agent claim-leader --confirm`. "
|
|
426
|
+
"Only the first such call wins; subsequent calls from other windows will be refused."
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _rediscovered_receiver(
|
|
431
|
+
receiver: dict[str, Any],
|
|
432
|
+
provider: str,
|
|
433
|
+
target: dict[str, Any],
|
|
434
|
+
event_log: EventLog,
|
|
435
|
+
owner_identity: dict[str, Any] | None,
|
|
436
|
+
invalidation_reason: str | None = None,
|
|
437
|
+
) -> dict[str, Any]:
|
|
438
|
+
leader_uuid = _target_leader_session_uuid(target) or (owner_identity or {}).get("leader_session_uuid") or receiver.get("leader_session_uuid")
|
|
439
|
+
updated = _receiver_from_target(target, provider, leader_uuid)
|
|
440
|
+
updated["discovery"] = "stale_rediscovery_owner_identity" if owner_identity else "stale_rediscovery_unique_candidate"
|
|
294
441
|
event_log.write(
|
|
295
442
|
"leader_receiver.rediscovered",
|
|
296
443
|
provider=provider,
|
|
@@ -299,6 +446,14 @@ def _rediscovered_receiver(
|
|
|
299
446
|
candidate_count=1,
|
|
300
447
|
owner_identity=owner_identity,
|
|
301
448
|
)
|
|
449
|
+
event_log.write(
|
|
450
|
+
"leader_receiver.rebind_applied",
|
|
451
|
+
old_pane_id=receiver.get("pane_id"),
|
|
452
|
+
new_pane_id=updated["pane_id"],
|
|
453
|
+
reason=invalidation_reason,
|
|
454
|
+
owner_identity=owner_identity,
|
|
455
|
+
uuid_prefix=_uuid_prefix(owner_identity),
|
|
456
|
+
)
|
|
302
457
|
return {"status": "updated", "receiver": updated, "owner_identity": owner_identity}
|
|
303
458
|
|
|
304
459
|
|
|
@@ -306,6 +461,26 @@ def _validate_leader_receiver(receiver: dict[str, Any]) -> dict[str, Any]:
|
|
|
306
461
|
pane_info = _runtime_tmux_pane_info(receiver.get("pane_id"))
|
|
307
462
|
if not pane_info:
|
|
308
463
|
return {"ok": False, "reason": "leader_pane_missing", "error": "tmux pane does not exist"}
|
|
464
|
+
provider = str(receiver.get("provider") or "codex")
|
|
465
|
+
if not _leader_command_looks_usable(pane_info.get("pane_current_command", ""), provider):
|
|
466
|
+
return {
|
|
467
|
+
"ok": False,
|
|
468
|
+
"reason": "leader_pane_wrong_command",
|
|
469
|
+
"error": f"pane command {pane_info.get('pane_current_command')!r} is not a leader host",
|
|
470
|
+
"pane": pane_info,
|
|
471
|
+
}
|
|
472
|
+
expected_uuid = receiver.get("leader_session_uuid")
|
|
473
|
+
if expected_uuid:
|
|
474
|
+
actual_uuid = _leader_uuid_for_bound_pane(receiver, pane_info)
|
|
475
|
+
if not actual_uuid:
|
|
476
|
+
return {"ok": False, "reason": "leader_uuid_missing", "error": "bound pane has no TEAM_AGENT_LEADER_SESSION_UUID", "pane": pane_info}
|
|
477
|
+
if actual_uuid != expected_uuid:
|
|
478
|
+
return {
|
|
479
|
+
"ok": False,
|
|
480
|
+
"reason": "leader_uuid_mismatch",
|
|
481
|
+
"error": "bound pane TEAM_AGENT_LEADER_SESSION_UUID does not match stored team owner",
|
|
482
|
+
"pane": pane_info,
|
|
483
|
+
}
|
|
309
484
|
capture = run_cmd(["tmux", "capture-pane", "-p", "-S", "-40", "-t", pane_info["pane_id"]], timeout=5)
|
|
310
485
|
if capture.returncode != 0:
|
|
311
486
|
return {
|
|
@@ -314,14 +489,7 @@ def _validate_leader_receiver(receiver: dict[str, Any]) -> dict[str, Any]:
|
|
|
314
489
|
"error": capture.stderr.strip() or "tmux capture-pane failed",
|
|
315
490
|
"pane": pane_info,
|
|
316
491
|
}
|
|
317
|
-
warning
|
|
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}
|
|
492
|
+
return {"ok": True, "pane": pane_info, "capture": capture.stdout, "warning": None}
|
|
325
493
|
|
|
326
494
|
|
|
327
495
|
def _leader_command_looks_usable(command: str, provider: str) -> bool:
|
|
@@ -330,7 +498,9 @@ def _leader_command_looks_usable(command: str, provider: str) -> bool:
|
|
|
330
498
|
command_name = Path(command).name
|
|
331
499
|
if provider == "codex":
|
|
332
500
|
return command_name in {"codex", "node", "nodejs"}
|
|
333
|
-
|
|
501
|
+
if provider in {"claude", "claude_code"}:
|
|
502
|
+
return command_name in {"claude", "claude.exe"}
|
|
503
|
+
return command_name in {"codex", "node", "nodejs", "claude", "claude.exe"}
|
|
334
504
|
|
|
335
505
|
|
|
336
506
|
def _choose_leader_submit_key(provider: str, capture_text: str) -> tuple[str, str]:
|