@team-agent/installer 0.2.0 → 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/__init__.py +2 -0
- package/src/team_agent/cli/commands.py +22 -3
- package/src/team_agent/cli/parser.py +40 -1
- package/src/team_agent/coordinator/__main__.py +21 -2
- package/src/team_agent/coordinator/lifecycle.py +23 -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/lifecycle/paste_buffer_hygiene.py +39 -0
- package/src/team_agent/lifecycle/start.py +3 -0
- 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/delivery.py +10 -0
- package/src/team_agent/messaging/idle_alerts.py +227 -21
- package/src/team_agent/messaging/leader.py +166 -6
- package/src/team_agent/messaging/leader_panes.py +193 -23
- package/src/team_agent/messaging/owner_bypass.py +29 -0
- 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 +22 -2
- package/src/team_agent/messaging/send.py +9 -2
- package/src/team_agent/messaging/session_drift.py +94 -0
- package/src/team_agent/runtime.py +22 -14
- package/src/team_agent/rust_core.py +157 -3
- package/src/team_agent/state.py +167 -10
- package/src/team_agent/status/inbox.py +33 -3
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from datetime import datetime, timezone
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
7
|
from team_agent.events import EventLog
|
|
8
8
|
from team_agent.message_store import MessageStore
|
|
9
|
-
from team_agent.messaging.deps import load_spec, save_runtime_state, team_state_key
|
|
9
|
+
from team_agent.messaging.deps import load_runtime_state, load_spec, save_runtime_state, team_state_key
|
|
10
10
|
from team_agent.messaging.internal_delivery import deliver_stored_message
|
|
11
11
|
|
|
12
12
|
|
|
@@ -23,33 +23,212 @@ _UNDELIVERED_MESSAGE_STATUSES = {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
STABLE_IDLE_SECONDS = 120
|
|
27
|
+
FIRE_DEBOUNCE_SECONDS = 300
|
|
28
|
+
OBLIGATION_PENDING_MIN_AGE_SECONDS = 60
|
|
29
|
+
|
|
30
|
+
# Event-log progress signal (Gap 32 §"Idle-Detector False Positive Continues Post Phase G hotfix-3"):
|
|
31
|
+
# the team_last_progress_at calculation must also count leader-side sends and worker MCP calls
|
|
32
|
+
# as recent team activity, not only agent_health.last_output_at. Without this, a worker that has
|
|
33
|
+
# called MCP but not yet emitted a visible turn shows up as idle and the idle reminder fires
|
|
34
|
+
# spuriously inside the stable-idle window.
|
|
35
|
+
_PROGRESS_EVENT_TYPES = frozenset({
|
|
36
|
+
"send.deliver_attempt",
|
|
37
|
+
"leader_receiver.deliver_attempt",
|
|
38
|
+
"mcp.report_result",
|
|
39
|
+
"mcp.send_message",
|
|
40
|
+
})
|
|
41
|
+
_PROGRESS_EVENT_PREFIXES = ("mcp.read_",)
|
|
42
|
+
_PROGRESS_EVENT_WINDOW_SECONDS = 300
|
|
43
|
+
_PROGRESS_EVENT_TAIL_LIMIT = 1000
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _parse_iso(text: Any) -> datetime | None:
|
|
47
|
+
if not isinstance(text, str) or not text:
|
|
48
|
+
return None
|
|
49
|
+
try:
|
|
50
|
+
dt = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
51
|
+
except ValueError:
|
|
52
|
+
return None
|
|
53
|
+
if dt.tzinfo is None:
|
|
54
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
55
|
+
return dt
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def record_team_progress(
|
|
59
|
+
state: dict[str, Any],
|
|
60
|
+
now: datetime | None = None,
|
|
61
|
+
*,
|
|
62
|
+
source: str = "",
|
|
63
|
+
owner_team_id: str | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
coordinator = state.setdefault("coordinator", {})
|
|
66
|
+
progress = coordinator.setdefault("team_last_progress_at", {})
|
|
67
|
+
key = owner_team_id or team_state_key(state)
|
|
68
|
+
if not key:
|
|
69
|
+
return
|
|
70
|
+
progress[key] = {
|
|
71
|
+
"at": (now or datetime.now(timezone.utc)).isoformat(),
|
|
72
|
+
"source": source,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _team_last_progress_at(
|
|
77
|
+
state: dict[str, Any],
|
|
78
|
+
store: MessageStore,
|
|
79
|
+
owner_team_id: str,
|
|
80
|
+
event_log: EventLog | None = None,
|
|
81
|
+
now: datetime | None = None,
|
|
82
|
+
workspace: Path | None = None,
|
|
83
|
+
) -> tuple[datetime | None, str | None]:
|
|
84
|
+
sources: list[tuple[datetime, str]] = []
|
|
85
|
+
coordinator = state.get("coordinator") or {}
|
|
86
|
+
explicit = (coordinator.get("team_last_progress_at") or {}).get(owner_team_id)
|
|
87
|
+
if isinstance(explicit, dict):
|
|
88
|
+
ts = _parse_iso(explicit.get("at"))
|
|
89
|
+
if ts:
|
|
90
|
+
sources.append((ts, "explicit_marker"))
|
|
91
|
+
elif isinstance(explicit, str):
|
|
92
|
+
ts = _parse_iso(explicit)
|
|
93
|
+
if ts:
|
|
94
|
+
sources.append((ts, "explicit_marker"))
|
|
95
|
+
health = store.agent_health(owner_team_id=owner_team_id)
|
|
96
|
+
for row in health.values():
|
|
97
|
+
ts = _parse_iso(row.get("last_output_at"))
|
|
98
|
+
if ts:
|
|
99
|
+
sources.append((ts, "agent_health.last_output_at"))
|
|
100
|
+
if event_log is not None:
|
|
101
|
+
# Spark MEDIUM #3 (d9f740d): in multi-team workspaces an unscoped progress event in
|
|
102
|
+
# team A's activity must NOT suppress team B's idle_fallback. require_team_scope=True
|
|
103
|
+
# when the workspace has more than one team so unscoped events are ignored. The
|
|
104
|
+
# team-scoped state passed in here does not carry the workspace-level `teams` dict, so
|
|
105
|
+
# we re-read the workspace state from disk to detect multi-team shape.
|
|
106
|
+
require_team_scope = False
|
|
107
|
+
teams = state.get("teams")
|
|
108
|
+
if isinstance(teams, dict) and len(teams) > 1:
|
|
109
|
+
require_team_scope = True
|
|
110
|
+
elif workspace is not None:
|
|
111
|
+
try:
|
|
112
|
+
ws_teams = (load_runtime_state(workspace).get("teams") or {})
|
|
113
|
+
except Exception:
|
|
114
|
+
ws_teams = {}
|
|
115
|
+
if isinstance(ws_teams, dict) and len(ws_teams) > 1:
|
|
116
|
+
require_team_scope = True
|
|
117
|
+
event_ts = _scan_event_progress_signals(
|
|
118
|
+
event_log, owner_team_id, now or datetime.now(timezone.utc),
|
|
119
|
+
require_team_scope=require_team_scope,
|
|
120
|
+
)
|
|
121
|
+
if event_ts:
|
|
122
|
+
sources.append((event_ts, "event_log"))
|
|
123
|
+
if not sources:
|
|
124
|
+
return None, None
|
|
125
|
+
sources.sort(key=lambda item: item[0], reverse=True)
|
|
126
|
+
return sources[0]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# Stage 14 (Gap 36b) — mtime cache per (workspace_path, owner_team_id, require_team_scope).
|
|
130
|
+
# Mac mini 2026-05-26 evidence: _scan_event_progress_signals was a 22% CPU hot path because
|
|
131
|
+
# every 2-second coordinator tick parsed up to 1000 events from a 28 MB events.jsonl. With
|
|
132
|
+
# the cache, the parse only re-runs when the file changes; quiet workspaces pay zero file
|
|
133
|
+
# I/O between writes.
|
|
134
|
+
_PROGRESS_SCAN_CACHE: dict[tuple[str, str, bool], tuple[float, datetime | None]] = {}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _scan_event_progress_signals(
|
|
138
|
+
event_log: EventLog,
|
|
139
|
+
owner_team_id: str,
|
|
140
|
+
now: datetime,
|
|
141
|
+
*,
|
|
142
|
+
require_team_scope: bool = False,
|
|
143
|
+
) -> datetime | None:
|
|
144
|
+
cache_key = (str(event_log.path), owner_team_id, require_team_scope)
|
|
145
|
+
try:
|
|
146
|
+
current_mtime = event_log.path.stat().st_mtime
|
|
147
|
+
except FileNotFoundError:
|
|
148
|
+
_PROGRESS_SCAN_CACHE.pop(cache_key, None)
|
|
149
|
+
return None
|
|
150
|
+
cached = _PROGRESS_SCAN_CACHE.get(cache_key)
|
|
151
|
+
if cached is not None and cached[0] == current_mtime:
|
|
152
|
+
return cached[1]
|
|
153
|
+
window_start = now - timedelta(seconds=_PROGRESS_EVENT_WINDOW_SECONDS)
|
|
154
|
+
latest: datetime | None = None
|
|
155
|
+
for event in event_log.tail(_PROGRESS_EVENT_TAIL_LIMIT):
|
|
156
|
+
event_type = str(event.get("event") or "")
|
|
157
|
+
if event_type not in _PROGRESS_EVENT_TYPES and not any(
|
|
158
|
+
event_type.startswith(prefix) for prefix in _PROGRESS_EVENT_PREFIXES
|
|
159
|
+
):
|
|
160
|
+
continue
|
|
161
|
+
event_team = event.get("team") or event.get("owner_team_id")
|
|
162
|
+
if event_team is None:
|
|
163
|
+
if require_team_scope:
|
|
164
|
+
continue
|
|
165
|
+
elif event_team != owner_team_id:
|
|
166
|
+
continue
|
|
167
|
+
ts = _parse_iso(event.get("ts"))
|
|
168
|
+
if not ts or ts < window_start:
|
|
169
|
+
continue
|
|
170
|
+
if latest is None or ts > latest:
|
|
171
|
+
latest = ts
|
|
172
|
+
_PROGRESS_SCAN_CACHE[cache_key] = (current_mtime, latest)
|
|
173
|
+
return latest
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _reset_progress_scan_cache() -> None:
|
|
177
|
+
"""Test-only hook to force re-scan."""
|
|
178
|
+
_PROGRESS_SCAN_CACHE.clear()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _team_last_idle_fallback_fire_at(state: dict[str, Any], owner_team_id: str) -> datetime | None:
|
|
182
|
+
coordinator = state.get("coordinator") or {}
|
|
183
|
+
fires = coordinator.get("team_last_idle_fallback_fire_at") or {}
|
|
184
|
+
return _parse_iso(fires.get(owner_team_id))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _record_idle_fallback_fire(state: dict[str, Any], owner_team_id: str, now: datetime) -> None:
|
|
188
|
+
coordinator = state.setdefault("coordinator", {})
|
|
189
|
+
fires = coordinator.setdefault("team_last_idle_fallback_fire_at", {})
|
|
190
|
+
fires[owner_team_id] = now.isoformat()
|
|
191
|
+
|
|
192
|
+
|
|
26
193
|
def _team_undelivered_obligations(
|
|
27
194
|
state: dict[str, Any],
|
|
28
195
|
store: MessageStore,
|
|
29
196
|
owner_team_id: str,
|
|
30
197
|
active_task_statuses: set[str],
|
|
198
|
+
*,
|
|
199
|
+
now: datetime | None = None,
|
|
31
200
|
) -> list[dict[str, Any]]:
|
|
201
|
+
now = now or datetime.now(timezone.utc)
|
|
202
|
+
min_age = timedelta(seconds=OBLIGATION_PENDING_MIN_AGE_SECONDS)
|
|
32
203
|
obligations: list[dict[str, Any]] = []
|
|
33
204
|
for message in store.messages(owner_team_id=owner_team_id):
|
|
34
|
-
if message.get("status") in _UNDELIVERED_MESSAGE_STATUSES:
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
205
|
+
if message.get("status") not in _UNDELIVERED_MESSAGE_STATUSES:
|
|
206
|
+
continue
|
|
207
|
+
created_at = _parse_iso(message.get("created_at"))
|
|
208
|
+
if created_at and (now - created_at) < min_age:
|
|
209
|
+
continue
|
|
210
|
+
obligations.append(
|
|
211
|
+
{
|
|
212
|
+
"kind": "undelivered_message",
|
|
213
|
+
"message_id": message.get("message_id"),
|
|
214
|
+
"recipient": message.get("recipient"),
|
|
215
|
+
"status": message.get("status"),
|
|
216
|
+
}
|
|
217
|
+
)
|
|
43
218
|
for watcher in store.retryable_result_watchers():
|
|
44
|
-
if watcher.get("status") in {"pending", "notify_failed"}:
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
219
|
+
if watcher.get("status") not in {"pending", "notify_failed"}:
|
|
220
|
+
continue
|
|
221
|
+
created_at = _parse_iso(watcher.get("created_at"))
|
|
222
|
+
if created_at and (now - created_at) < min_age:
|
|
223
|
+
continue
|
|
224
|
+
obligations.append(
|
|
225
|
+
{
|
|
226
|
+
"kind": "pending_result_watcher",
|
|
227
|
+
"watcher_id": watcher.get("watcher_id"),
|
|
228
|
+
"task_id": watcher.get("task_id"),
|
|
229
|
+
"agent_id": watcher.get("agent_id"),
|
|
230
|
+
}
|
|
231
|
+
)
|
|
53
232
|
for task in state.get("tasks", []):
|
|
54
233
|
if task.get("status", "pending") in active_task_statuses and task.get("assignee"):
|
|
55
234
|
obligations.append(
|
|
@@ -118,11 +297,37 @@ def detect_idle_fallbacks(
|
|
|
118
297
|
)
|
|
119
298
|
now = now or datetime.now(timezone.utc)
|
|
120
299
|
owner_team_id = team_state_key(state)
|
|
121
|
-
obligations = _team_undelivered_obligations(state, store, owner_team_id, _ACTIVE_TASK_STATUSES)
|
|
300
|
+
obligations = _team_undelivered_obligations(state, store, owner_team_id, _ACTIVE_TASK_STATUSES, now=now)
|
|
122
301
|
if not obligations:
|
|
123
302
|
return []
|
|
124
303
|
all_idle, idle_workers = _all_workers_idle(state, store, owner_team_id)
|
|
125
304
|
if not all_idle:
|
|
305
|
+
record_team_progress(state, now, source="all_workers_idle:false", owner_team_id=owner_team_id)
|
|
306
|
+
save_runtime_state(workspace, state)
|
|
307
|
+
return []
|
|
308
|
+
last_progress, progress_source = _team_last_progress_at(
|
|
309
|
+
state, store, owner_team_id, event_log=event_log, now=now, workspace=workspace,
|
|
310
|
+
)
|
|
311
|
+
if last_progress and (now - last_progress) < timedelta(seconds=STABLE_IDLE_SECONDS):
|
|
312
|
+
reason = "recent_team_progress" if progress_source == "event_log" else "stable_idle_window"
|
|
313
|
+
event_log.write(
|
|
314
|
+
"coordinator.idle_fallback_skipped",
|
|
315
|
+
reason=reason,
|
|
316
|
+
team=owner_team_id,
|
|
317
|
+
stable_idle_seconds=STABLE_IDLE_SECONDS,
|
|
318
|
+
elapsed_seconds=int((now - last_progress).total_seconds()),
|
|
319
|
+
progress_source=progress_source,
|
|
320
|
+
)
|
|
321
|
+
return []
|
|
322
|
+
last_fire = _team_last_idle_fallback_fire_at(state, owner_team_id)
|
|
323
|
+
if last_fire and (now - last_fire) < timedelta(seconds=FIRE_DEBOUNCE_SECONDS):
|
|
324
|
+
event_log.write(
|
|
325
|
+
"coordinator.idle_fallback_skipped",
|
|
326
|
+
reason="fire_debounce",
|
|
327
|
+
team=owner_team_id,
|
|
328
|
+
fire_debounce_seconds=FIRE_DEBOUNCE_SECONDS,
|
|
329
|
+
elapsed_seconds=int((now - last_fire).total_seconds()),
|
|
330
|
+
)
|
|
126
331
|
return []
|
|
127
332
|
spec_path = Path(state.get("spec_path", workspace / "team.spec.yaml"))
|
|
128
333
|
spec = load_spec(spec_path) if spec_path.exists() else {}
|
|
@@ -137,6 +342,7 @@ def detect_idle_fallbacks(
|
|
|
137
342
|
alerts.append({"agent_id": agent_id, "alert_type": "idle_fallback", "obligations": obligations})
|
|
138
343
|
if not alerts:
|
|
139
344
|
return []
|
|
345
|
+
_record_idle_fallback_fire(state, owner_team_id, now)
|
|
140
346
|
save_runtime_state(workspace, state)
|
|
141
347
|
content = (
|
|
142
348
|
"There is still unfinished work. Continue coordinating, deliver a result, "
|
|
@@ -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
|
|