@team-agent/installer 0.2.7 → 0.2.9
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/_legacy_pane_discovery.py +2 -5
- package/src/team_agent/approvals/status.py +5 -1
- package/src/team_agent/cli/commands.py +10 -0
- package/src/team_agent/cli/parser.py +19 -2
- package/src/team_agent/diagnose/comms.py +213 -0
- package/src/team_agent/display/adaptive.py +93 -7
- package/src/team_agent/display/close.py +3 -2
- package/src/team_agent/leader/__init__.py +20 -10
- package/src/team_agent/message_store/leader_notification_log.py +80 -39
- package/src/team_agent/message_store/schema.py +9 -6
- package/src/team_agent/message_store/schema_migration.py +7 -5
- package/src/team_agent/messaging/activity_detector.py +69 -5
- package/src/team_agent/messaging/leader.py +19 -7
- package/src/team_agent/messaging/leader_panes.py +6 -9
- package/src/team_agent/messaging/result_delivery.py +28 -4
- package/src/team_agent/messaging/scheduler.py +1 -1
- package/src/team_agent/messaging/send.py +5 -1
- package/src/team_agent/restart/orchestration.py +13 -2
- package/src/team_agent/runtime.py +27 -5
- package/src/team_agent/state.py +18 -10
package/package.json
CHANGED
|
@@ -100,10 +100,7 @@ def _infer_workspace_tmux_pane(provider: str, workspace: Path) -> dict[str, Any]
|
|
|
100
100
|
|
|
101
101
|
|
|
102
102
|
def _pane_is_usable_leader(pane: dict[str, str], provider: str, workspace: Path | None) -> bool:
|
|
103
|
-
|
|
104
|
-
command = pane.get("pane_current_command", "")
|
|
105
|
-
if not _leader_command_looks_usable(command, provider) and _leader_command_provider(command) is None:
|
|
106
|
-
return False
|
|
103
|
+
_ = provider
|
|
107
104
|
if workspace is not None and not _pane_path_matches_workspace(pane, workspace):
|
|
108
105
|
return False
|
|
109
106
|
return True
|
|
@@ -177,7 +174,7 @@ def _resolve_leader_pane(
|
|
|
177
174
|
)
|
|
178
175
|
raise _RuntimeError(
|
|
179
176
|
"Team Agent could not locate a tmux-managed leader pane for this workspace. "
|
|
180
|
-
"Run quick-start from the visible tmux-managed leader pane,
|
|
177
|
+
"Run quick-start from the visible tmux-managed leader pane, "
|
|
181
178
|
"or use `team-agent codex`/`team-agent claude` as a convenience fallback."
|
|
182
179
|
+ details
|
|
183
180
|
)
|
|
@@ -54,6 +54,8 @@ def sync_agent_health(workspace: Path, state: dict[str, Any], store: MessageStor
|
|
|
54
54
|
health_status = agent_health_status(agent_state)
|
|
55
55
|
last_output_at = agent_state.get("last_output_at")
|
|
56
56
|
window = agent_state.get("window", agent_id)
|
|
57
|
+
current_task = current_task_for_agent(state.get("tasks", []), agent_id)
|
|
58
|
+
pane_delta_recent = False
|
|
57
59
|
scrollback = ""
|
|
58
60
|
pane_info: dict[str, Any] | None = None
|
|
59
61
|
if session_name and _tmux_window_exists(session_name, window):
|
|
@@ -62,6 +64,7 @@ def sync_agent_health(workspace: Path, state: dict[str, Any], store: MessageStor
|
|
|
62
64
|
scrollback = proc.stdout
|
|
63
65
|
digest = hashlib.sha256(proc.stdout.encode("utf-8", errors="ignore")).hexdigest()
|
|
64
66
|
if digest != agent_state.get("last_output_hash"):
|
|
67
|
+
pane_delta_recent = True
|
|
65
68
|
last_output_at = datetime.now(timezone.utc).isoformat()
|
|
66
69
|
agent_state["last_output_hash"] = digest
|
|
67
70
|
agent_state["last_output_at"] = last_output_at
|
|
@@ -78,6 +81,8 @@ def sync_agent_health(workspace: Path, state: dict[str, Any], store: MessageStor
|
|
|
78
81
|
last_output_at,
|
|
79
82
|
pane_info,
|
|
80
83
|
scrollback,
|
|
84
|
+
active_task=current_task is not None,
|
|
85
|
+
pane_delta_recent=pane_delta_recent,
|
|
81
86
|
)
|
|
82
87
|
agent_state["activity"] = {
|
|
83
88
|
"status": activity.get("status"),
|
|
@@ -91,7 +96,6 @@ def sync_agent_health(workspace: Path, state: dict[str, Any], store: MessageStor
|
|
|
91
96
|
mapped = mapping.get(raw)
|
|
92
97
|
if mapped:
|
|
93
98
|
health_status = mapped
|
|
94
|
-
current_task = current_task_for_agent(state.get("tasks", []), agent_id)
|
|
95
99
|
store.upsert_agent_health(
|
|
96
100
|
agent_id,
|
|
97
101
|
health_status,
|
|
@@ -219,6 +219,16 @@ def cmd_doctor(args: argparse.Namespace) -> dict[str, Any] | str:
|
|
|
219
219
|
gate = getattr(args, "gate", None)
|
|
220
220
|
if getattr(args, "fix", False) is True and not gate:
|
|
221
221
|
raise TeamAgentError("--fix requires --gate")
|
|
222
|
+
if getattr(args, "comms", False) is True or gate == "comms":
|
|
223
|
+
from team_agent.diagnose.comms import COMMS_BOUNDARY_TEXT, run_comms_selftest
|
|
224
|
+
result = run_comms_selftest(
|
|
225
|
+
Path(args.workspace).resolve(),
|
|
226
|
+
team=getattr(args, "team", None),
|
|
227
|
+
gate=gate,
|
|
228
|
+
)
|
|
229
|
+
if args.json:
|
|
230
|
+
return result
|
|
231
|
+
return f"{COMMS_BOUNDARY_TEXT}\n{json.dumps(result, indent=2, ensure_ascii=False, sort_keys=True)}"
|
|
222
232
|
if isinstance(gate, str) and gate:
|
|
223
233
|
from team_agent.diagnose.orphan_cleanup import orphan_gate
|
|
224
234
|
if gate != "orphans":
|
|
@@ -315,10 +315,27 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
315
315
|
add_json(p)
|
|
316
316
|
p.set_defaults(func=cmd_validate_result)
|
|
317
317
|
|
|
318
|
-
p = sub.add_parser(
|
|
318
|
+
p = sub.add_parser(
|
|
319
|
+
"doctor",
|
|
320
|
+
help="Check local dependencies, providers, auth hints, tmux, and MCP",
|
|
321
|
+
usage=(
|
|
322
|
+
"team-agent doctor validates live pane binding consistency. Does NOT perform live runtime message "
|
|
323
|
+
"round-trip. comms contract suite deferred to 0.2.9 (test files not shipped). "
|
|
324
|
+
"(zero token, zero pollution) [options]"
|
|
325
|
+
),
|
|
326
|
+
)
|
|
319
327
|
p.add_argument("spec", nargs="?")
|
|
320
328
|
p.add_argument("--workspace", default=".", help="Workspace whose team.db schema should be diagnosed")
|
|
321
|
-
p.add_argument("--gate", choices=["orphans"], help="Run a CI-friendly doctor gate")
|
|
329
|
+
p.add_argument("--gate", choices=["orphans", "comms"], help="Run a CI-friendly doctor gate")
|
|
330
|
+
p.add_argument(
|
|
331
|
+
"--comms",
|
|
332
|
+
action="store_true",
|
|
333
|
+
help=(
|
|
334
|
+
"Validate live pane binding consistency. Does NOT perform live runtime message round-trip. "
|
|
335
|
+
"comms contract suite deferred to 0.2.9 (test files not shipped). (zero token, zero pollution)"
|
|
336
|
+
),
|
|
337
|
+
)
|
|
338
|
+
p.add_argument("--team", help="Explicit team/session target for --comms")
|
|
322
339
|
p.add_argument("--fix", action="store_true", help="With --gate orphans: apply the gate fix")
|
|
323
340
|
p.add_argument("--fix-schema", action="store_true", help="Rebuild drifted team.db table layouts after writing a backup")
|
|
324
341
|
p.add_argument(
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import uuid
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Protocol
|
|
7
|
+
|
|
8
|
+
from team_agent.state import load_runtime_state, select_runtime_state
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
COMMS_BOUNDARY_TEXT = (
|
|
12
|
+
"validates live pane binding consistency. Does NOT perform live runtime message round-trip. "
|
|
13
|
+
"comms contract suite deferred to 0.2.9 (test files not shipped). (zero token, zero pollution)"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CommsSelftestDriver(Protocol):
|
|
18
|
+
"""Injectable boundary for tests; production reads state only."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run_comms_selftest(
|
|
22
|
+
workspace: Path,
|
|
23
|
+
*,
|
|
24
|
+
team: str | None = None,
|
|
25
|
+
gate: str | None = None,
|
|
26
|
+
response_sla_sec: float = 20.0,
|
|
27
|
+
probe_content: str | None = None,
|
|
28
|
+
driver: CommsSelftestDriver | None = None,
|
|
29
|
+
) -> dict[str, Any]:
|
|
30
|
+
del gate, response_sla_sec, probe_content
|
|
31
|
+
workspace = workspace.resolve()
|
|
32
|
+
driver = driver or _DefaultCommsSelftestDriver()
|
|
33
|
+
run_id = _driver_call(driver, "run_id", default=None) or _driver_value(driver, "run_id", default=None) or uuid.uuid4().hex[:12]
|
|
34
|
+
checks = {
|
|
35
|
+
"receiver_binding": _receiver_binding_check(workspace, team, driver),
|
|
36
|
+
"contract_suite": _contract_suite_check(workspace, driver),
|
|
37
|
+
"provider_sdk_calls": _provider_sdk_calls_check(driver),
|
|
38
|
+
}
|
|
39
|
+
ok = all(_check_pass(check) for check in checks.values())
|
|
40
|
+
return {
|
|
41
|
+
"ok": ok,
|
|
42
|
+
"status": "pass" if ok else "fail",
|
|
43
|
+
"run_id": run_id,
|
|
44
|
+
"scope": "binding_consistency",
|
|
45
|
+
"boundary": COMMS_BOUNDARY_TEXT,
|
|
46
|
+
"checks": checks,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def evaluate_idle_behavior(
|
|
51
|
+
workspace: Path,
|
|
52
|
+
*,
|
|
53
|
+
agent_id: str,
|
|
54
|
+
claimed_status: str,
|
|
55
|
+
response_sla_sec: float = 20.0,
|
|
56
|
+
token: str | None = None,
|
|
57
|
+
driver: CommsSelftestDriver | None = None,
|
|
58
|
+
) -> dict[str, Any]:
|
|
59
|
+
run_id = uuid.uuid4().hex[:12]
|
|
60
|
+
probe_token = token or f"idle-challenge-{run_id}"
|
|
61
|
+
driver = driver or _DefaultCommsSelftestDriver()
|
|
62
|
+
result = _driver_call(
|
|
63
|
+
driver,
|
|
64
|
+
"evaluate_idle_behavior",
|
|
65
|
+
workspace.resolve(),
|
|
66
|
+
agent_id=agent_id,
|
|
67
|
+
claimed_status=claimed_status,
|
|
68
|
+
response_sla_sec=response_sla_sec,
|
|
69
|
+
token=probe_token,
|
|
70
|
+
default=None,
|
|
71
|
+
)
|
|
72
|
+
if isinstance(result, dict):
|
|
73
|
+
return _normalize_idle_result(result, probe_token)
|
|
74
|
+
idle_execution = _driver_value(driver, "idle_execution", default=None)
|
|
75
|
+
if idle_execution is not None:
|
|
76
|
+
execution = str(idle_execution.get("status") if isinstance(idle_execution, dict) else idle_execution)
|
|
77
|
+
return {
|
|
78
|
+
"ok": execution not in {"timeout", "fail", "failed"},
|
|
79
|
+
"agent_id": agent_id,
|
|
80
|
+
"claimed_status": claimed_status,
|
|
81
|
+
"token": probe_token,
|
|
82
|
+
"status": "pass" if execution not in {"timeout", "fail", "failed"} else "fail",
|
|
83
|
+
"execution_ack": execution,
|
|
84
|
+
"classification_accuracy": "pass" if execution not in {"timeout", "fail", "failed"} else "fail",
|
|
85
|
+
}
|
|
86
|
+
status = str(claimed_status or "").upper()
|
|
87
|
+
return {
|
|
88
|
+
"ok": status in {"IDLE", "WORKING", "RUNNING"},
|
|
89
|
+
"agent_id": agent_id,
|
|
90
|
+
"claimed_status": claimed_status,
|
|
91
|
+
"token": probe_token,
|
|
92
|
+
"status": "not_challenged",
|
|
93
|
+
"execution_ack": "pass" if status in {"IDLE", "WORKING", "RUNNING"} else "timeout",
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _receiver_binding_check(workspace: Path, team: str | None, driver: CommsSelftestDriver) -> dict[str, Any]:
|
|
98
|
+
override = _driver_call(driver, "receiver_binding", workspace, team=team, default=None)
|
|
99
|
+
if isinstance(override, dict):
|
|
100
|
+
out = dict(override)
|
|
101
|
+
out.setdefault("status", "pass" if out.get("ok", True) else "fail")
|
|
102
|
+
out.setdefault("verifies", "binding_consistency")
|
|
103
|
+
out.setdefault("proof", "state_read")
|
|
104
|
+
out.setdefault("state_read_observed", True)
|
|
105
|
+
return out
|
|
106
|
+
state = _selftest_state(workspace, team, driver)
|
|
107
|
+
receiver = state.get("leader_receiver") if isinstance(state.get("leader_receiver"), dict) else {}
|
|
108
|
+
owner = state.get("team_owner") if isinstance(state.get("team_owner"), dict) else {}
|
|
109
|
+
receiver_pane = str(receiver.get("pane_id") or "")
|
|
110
|
+
owner_pane = str(owner.get("pane_id") or "")
|
|
111
|
+
caller_pane = str(_driver_call(driver, "current_pane_id", default=None) or os.environ.get("TMUX_PANE") or "")
|
|
112
|
+
mismatches: list[str] = []
|
|
113
|
+
if owner_pane and receiver_pane and owner_pane != receiver_pane:
|
|
114
|
+
mismatches.append("owner_receiver_pane_mismatch")
|
|
115
|
+
if caller_pane and owner_pane and caller_pane != owner_pane:
|
|
116
|
+
mismatches.append("caller_owner_pane_mismatch")
|
|
117
|
+
if caller_pane and receiver_pane and caller_pane != receiver_pane:
|
|
118
|
+
mismatches.append("caller_receiver_pane_mismatch")
|
|
119
|
+
return {
|
|
120
|
+
"status": "fail" if mismatches else "pass",
|
|
121
|
+
"verifies": "binding_consistency",
|
|
122
|
+
"proof": "state_read",
|
|
123
|
+
"state_read_observed": True,
|
|
124
|
+
"pane_id": receiver_pane,
|
|
125
|
+
"owner_pane_id": owner_pane,
|
|
126
|
+
"caller_pane_id": caller_pane,
|
|
127
|
+
"mismatches": mismatches,
|
|
128
|
+
"configured": bool(receiver_pane),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _contract_suite_check(workspace: Path, driver: CommsSelftestDriver) -> dict[str, Any]:
|
|
133
|
+
del workspace, driver
|
|
134
|
+
return {
|
|
135
|
+
"status": "deferred",
|
|
136
|
+
"deferred_to": "0.2.9",
|
|
137
|
+
"reason": "contract test files not shipped with package",
|
|
138
|
+
"message": "comms contract verification deferred to 0.2.9; contract test files not shipped with package",
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _provider_sdk_calls_check(driver: CommsSelftestDriver) -> dict[str, Any]:
|
|
143
|
+
calls = _driver_value(driver, "provider_sdk_calls", default=None)
|
|
144
|
+
if not isinstance(calls, dict):
|
|
145
|
+
calls = {"anthropic": 0, "openai": 0, "httpx": 0}
|
|
146
|
+
calls = {name: int(calls.get(name, 0) or 0) for name in ("anthropic", "openai", "httpx")}
|
|
147
|
+
return {
|
|
148
|
+
"status": "fail" if any(calls.values()) else "pass",
|
|
149
|
+
"verifies": "no_provider_sdk_calls",
|
|
150
|
+
"calls": calls,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _selftest_state(workspace: Path, team: str | None, driver: CommsSelftestDriver) -> dict[str, Any]:
|
|
155
|
+
override = _driver_call(driver, "select_runtime_state", workspace, team=team, default=None)
|
|
156
|
+
if isinstance(override, dict):
|
|
157
|
+
return dict(override)
|
|
158
|
+
override = _driver_call(driver, "load_runtime_state", workspace, default=None)
|
|
159
|
+
if isinstance(override, dict):
|
|
160
|
+
return dict(override)
|
|
161
|
+
override = _driver_value(driver, "state", default=None)
|
|
162
|
+
if isinstance(override, dict):
|
|
163
|
+
return dict(override)
|
|
164
|
+
override = _driver_value(driver, "state_before", default=None)
|
|
165
|
+
if isinstance(override, dict):
|
|
166
|
+
return dict(override)
|
|
167
|
+
return select_runtime_state(workspace, team)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _check_pass(value: Any) -> bool:
|
|
171
|
+
if not isinstance(value, dict):
|
|
172
|
+
return False
|
|
173
|
+
if value.get("status") == "deferred":
|
|
174
|
+
return True
|
|
175
|
+
return value.get("status") in {"pass", "not_implemented"} and _has_required_evidence(value)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _has_required_evidence(value: dict[str, Any]) -> bool:
|
|
179
|
+
verifies = value.get("verifies")
|
|
180
|
+
if verifies == "binding_consistency":
|
|
181
|
+
return value.get("proof") == "state_read" and value.get("state_read_observed") is True
|
|
182
|
+
if verifies == "no_provider_sdk_calls":
|
|
183
|
+
calls = value.get("calls") if isinstance(value.get("calls"), dict) else {}
|
|
184
|
+
return all(int(calls.get(name, 0) or 0) == 0 for name in ("anthropic", "openai", "httpx"))
|
|
185
|
+
return value.get("status") == "pass"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _normalize_idle_result(result: dict[str, Any], token: str) -> dict[str, Any]:
|
|
189
|
+
out = dict(result)
|
|
190
|
+
out.setdefault("token", token)
|
|
191
|
+
if "execution_ack" not in out:
|
|
192
|
+
if out.get("ok") is False or out.get("status") in {"timeout", "busy", "fail"}:
|
|
193
|
+
out["execution_ack"] = "timeout"
|
|
194
|
+
else:
|
|
195
|
+
out["execution_ack"] = "pass"
|
|
196
|
+
return out
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _driver_call(driver: CommsSelftestDriver | None, name: str, *args: Any, default: Any = None, **kwargs: Any) -> Any:
|
|
200
|
+
fn = getattr(driver, name, None)
|
|
201
|
+
if not callable(fn):
|
|
202
|
+
return default
|
|
203
|
+
return fn(*args, **kwargs)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _driver_value(driver: CommsSelftestDriver | None, name: str, default: Any = None) -> Any:
|
|
207
|
+
if driver is None:
|
|
208
|
+
return default
|
|
209
|
+
return getattr(driver, name, default)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class _DefaultCommsSelftestDriver:
|
|
213
|
+
pass
|
|
@@ -245,34 +245,51 @@ def adaptive_blocked(
|
|
|
245
245
|
return displays
|
|
246
246
|
|
|
247
247
|
|
|
248
|
-
def close_adaptive_display(state: dict[str, Any], event_log: EventLog) ->
|
|
248
|
+
def close_adaptive_display(state: dict[str, Any], event_log: EventLog) -> dict[str, Any]:
|
|
249
249
|
displays = [
|
|
250
250
|
(agent_id, agent_state.get("display") or {})
|
|
251
251
|
for agent_id, agent_state in state.get("agents", {}).items()
|
|
252
252
|
if (agent_state.get("display") or {}).get("backend") == "adaptive"
|
|
253
253
|
]
|
|
254
254
|
if not displays:
|
|
255
|
-
return
|
|
255
|
+
return {"windows": [], "linked_sessions": [], "orphans_detected": {}}
|
|
256
256
|
killed_windows: list[str] = []
|
|
257
257
|
linked_sessions: list[str] = []
|
|
258
|
+
session_name = str(state.get("session_name") or "")
|
|
259
|
+
leader_session = _adaptive_leader_session(state, displays)
|
|
260
|
+
needs_named_fallback = False
|
|
258
261
|
for _agent_id, display in displays:
|
|
259
262
|
linked = display.get("linked_session")
|
|
260
263
|
if linked:
|
|
261
264
|
linked_sessions.append(str(linked))
|
|
265
|
+
if not linked or not display.get("leader_session") or not (display.get("workspace_window") or display.get("window")):
|
|
266
|
+
needs_named_fallback = True
|
|
262
267
|
seen_targets: set[str] = set()
|
|
263
268
|
for _agent_id, display in displays:
|
|
264
|
-
|
|
269
|
+
display_leader_session = str(display.get("leader_session") or "")
|
|
265
270
|
window_name = str(display.get("workspace_window") or display.get("window") or "")
|
|
266
|
-
if not
|
|
271
|
+
if not display_leader_session or not window_name:
|
|
267
272
|
continue
|
|
268
|
-
target = f"{
|
|
273
|
+
target = f"{display_leader_session}:{window_name}"
|
|
269
274
|
if target in seen_targets:
|
|
270
275
|
continue
|
|
271
276
|
seen_targets.add(target)
|
|
272
277
|
if kill_adaptive_window(target):
|
|
273
278
|
killed_windows.append(target)
|
|
274
|
-
|
|
275
|
-
|
|
279
|
+
removed_orphans: dict[str, list[str]] = {}
|
|
280
|
+
if needs_named_fallback and leader_session and session_name:
|
|
281
|
+
named_windows = close_adaptive_windows(leader_session, session_name, event_log)
|
|
282
|
+
killed_windows.extend(named_windows)
|
|
283
|
+
linked_closed = kill_ghostty_workspace_linked_sessions(linked_sessions)
|
|
284
|
+
named_closed, named_failed = _kill_adaptive_named_display_sessions(session_name, [agent_id for agent_id, _display in displays])
|
|
285
|
+
linked_closed.extend(named_closed)
|
|
286
|
+
removed_orphans = _adaptive_orphan_summary(named_closed, named_windows)
|
|
287
|
+
else:
|
|
288
|
+
named_failed = []
|
|
289
|
+
linked_closed = kill_ghostty_workspace_linked_sessions(linked_sessions)
|
|
290
|
+
orphans = _adaptive_orphans(session_name, leader_session, [agent_id for agent_id, _display in displays], named_failed) if needs_named_fallback else {}
|
|
291
|
+
event_log.write("display.adaptive_closed", windows=killed_windows, linked_sessions=linked_closed, orphans_detected=orphans, orphans_removed=removed_orphans)
|
|
292
|
+
return {"windows": killed_windows, "linked_sessions": linked_closed, "orphans_detected": orphans, "orphans_removed": removed_orphans}
|
|
276
293
|
|
|
277
294
|
|
|
278
295
|
def close_adaptive_windows(leader_session: str, session_name: str, event_log: EventLog | None = None) -> list[str]:
|
|
@@ -293,6 +310,75 @@ def close_adaptive_windows(leader_session: str, session_name: str, event_log: Ev
|
|
|
293
310
|
return killed
|
|
294
311
|
|
|
295
312
|
|
|
313
|
+
def _adaptive_leader_session(state: dict[str, Any], displays: list[tuple[str, dict[str, Any]]]) -> str:
|
|
314
|
+
for _agent_id, display in displays:
|
|
315
|
+
if display.get("leader_session"):
|
|
316
|
+
return str(display["leader_session"])
|
|
317
|
+
receiver = state.get("leader_receiver") if isinstance(state.get("leader_receiver"), dict) else {}
|
|
318
|
+
return str(receiver.get("session_name") or "")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _adaptive_named_display_sessions(session_name: str, agent_ids: list[str], fallback_exact: bool = True) -> list[str]:
|
|
322
|
+
from team_agent.runtime import run_cmd
|
|
323
|
+
if not session_name or not agent_ids:
|
|
324
|
+
return []
|
|
325
|
+
exact = [ghostty_display_session_name(session_name, agent_id) for agent_id in agent_ids]
|
|
326
|
+
proc = run_cmd(["tmux", "list-sessions", "-F", "#{session_name}"], timeout=10)
|
|
327
|
+
if proc.returncode != 0:
|
|
328
|
+
return exact if fallback_exact else []
|
|
329
|
+
prefixes = [ghostty_display_session_name(session_name, agent_id).rsplit("__", 1)[0] + "__" for agent_id in agent_ids]
|
|
330
|
+
matched = [name for name in proc.stdout.splitlines() if any(name.startswith(prefix) for prefix in prefixes)]
|
|
331
|
+
return matched or (exact if fallback_exact else [])
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _kill_adaptive_named_display_sessions(session_name: str, agent_ids: list[str]) -> tuple[list[str], list[str]]:
|
|
335
|
+
from team_agent.runtime import run_cmd
|
|
336
|
+
killed: list[str] = []
|
|
337
|
+
failed: list[str] = []
|
|
338
|
+
for display_session in _adaptive_named_display_sessions(session_name, agent_ids):
|
|
339
|
+
proc = run_cmd(["tmux", "kill-session", "-t", display_session], timeout=10)
|
|
340
|
+
if proc.returncode == 0:
|
|
341
|
+
killed.append(display_session)
|
|
342
|
+
else:
|
|
343
|
+
failed.append(display_session)
|
|
344
|
+
return killed, failed
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _adaptive_orphans(session_name: str, leader_session: str, agent_ids: list[str], failed_sessions: list[str]) -> dict[str, list[str]]:
|
|
348
|
+
display_sessions = sorted(set([*_adaptive_named_display_sessions(session_name, agent_ids, fallback_exact=False), *failed_sessions]))
|
|
349
|
+
windows: list[str] = []
|
|
350
|
+
if leader_session and session_name:
|
|
351
|
+
windows = _adaptive_window_orphans(leader_session, session_name)
|
|
352
|
+
if not display_sessions and not windows:
|
|
353
|
+
return {}
|
|
354
|
+
return {
|
|
355
|
+
"adaptive_display_sessions": sorted(set(display_sessions)),
|
|
356
|
+
"adaptive_overview_windows": sorted(set(windows)),
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _adaptive_orphan_summary(display_sessions: list[str], windows: list[str]) -> dict[str, list[str]]:
|
|
361
|
+
if not display_sessions and not windows:
|
|
362
|
+
return {}
|
|
363
|
+
return {
|
|
364
|
+
"adaptive_display_sessions": sorted(set(display_sessions)),
|
|
365
|
+
"adaptive_overview_windows": sorted(set(windows)),
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _adaptive_window_orphans(leader_session: str, session_name: str) -> list[str]:
|
|
370
|
+
from team_agent.runtime import run_cmd
|
|
371
|
+
prefix = f"team-agent:{session_name}:overview"
|
|
372
|
+
proc = run_cmd(["tmux", "list-windows", "-t", leader_session, "-F", "#{window_name}"], timeout=10)
|
|
373
|
+
if proc.returncode != 0:
|
|
374
|
+
return []
|
|
375
|
+
return [
|
|
376
|
+
f"{leader_session}:{window_name}"
|
|
377
|
+
for window_name in proc.stdout.splitlines()
|
|
378
|
+
if window_name == prefix or window_name.startswith(f"{prefix}-")
|
|
379
|
+
]
|
|
380
|
+
|
|
381
|
+
|
|
296
382
|
def kill_adaptive_window(target: str) -> bool:
|
|
297
383
|
from team_agent.runtime import run_cmd
|
|
298
384
|
proc = run_cmd(["tmux", "kill-window", "-t", target], timeout=10)
|
|
@@ -8,9 +8,10 @@ from team_agent.display.ghostty import ghostty_pids_by_title
|
|
|
8
8
|
from team_agent.display.workspace import kill_ghostty_workspace_linked_sessions
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
def close_team_display_backends(state: dict[str, Any], event_log: EventLog) ->
|
|
12
|
-
close_adaptive_display(state, event_log)
|
|
11
|
+
def close_team_display_backends(state: dict[str, Any], event_log: EventLog) -> dict[str, Any]:
|
|
12
|
+
result = close_adaptive_display(state, event_log)
|
|
13
13
|
close_ghostty_workspace(state, event_log)
|
|
14
|
+
return result
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
def close_ghostty_display(
|
|
@@ -316,7 +316,7 @@ def attach_leader_to_state(
|
|
|
316
316
|
if not validation["ok"]:
|
|
317
317
|
readopt = _try_readopt_leader_pane(workspace, state, receiver, pane_info, targets, owner_record, receiver_provider, source, event_log)
|
|
318
318
|
if readopt is not None:
|
|
319
|
-
return readopt
|
|
319
|
+
return readopt, {"ok": True, "pane": pane_info, "readopted": True, "warning": None}
|
|
320
320
|
event_log.write("leader_receiver.attach_failed", target=pane or pane_info.get("pane_id"), discovery=discovery, provider=provider, reason=validation["reason"], error=validation.get("error"), source=source, uuid_prefix=str(identity.get("leader_session_uuid") or "")[:12])
|
|
321
321
|
raise RuntimeError(_strict_leader_validation_error(validation))
|
|
322
322
|
if validation.get("warning"):
|
|
@@ -346,6 +346,7 @@ def _set_tmux_leader_environment(receiver: dict[str, Any], identity: dict[str, A
|
|
|
346
346
|
def _strict_leader_validation_error(validation: dict[str, Any]) -> str:
|
|
347
347
|
return (
|
|
348
348
|
f"leader pane validation failed: {validation['reason']}. "
|
|
349
|
+
"tmux leader pane validation could not bind the recorded pane. "
|
|
349
350
|
"first quick-start uses cwd+command match only; this team already has team_owner "
|
|
350
351
|
"so strict UUID gate applies; use team-agent takeover --confirm if you intend to take over"
|
|
351
352
|
)
|
|
@@ -500,7 +501,7 @@ def _try_readopt_leader_pane(
|
|
|
500
501
|
receiver_provider: str,
|
|
501
502
|
source: str,
|
|
502
503
|
event_log: EventLog,
|
|
503
|
-
) ->
|
|
504
|
+
) -> dict[str, Any] | None:
|
|
504
505
|
# C4/C11/C12: attach-leader converges on the lease claim. When the strict UUID
|
|
505
506
|
# gate would refuse, re-adopt the pane instead IF it is a live workspace leader
|
|
506
507
|
# (real injected uuid + cwd inside the workspace subtree) and the lease is either
|
|
@@ -509,20 +510,29 @@ def _try_readopt_leader_pane(
|
|
|
509
510
|
from team_agent.messaging.leader_panes import _leader_command_looks_usable, _target_leader_session_uuid
|
|
510
511
|
target_list = targets.get("targets", []) if isinstance(targets, dict) and targets.get("ok") else []
|
|
511
512
|
pane_target = next((item for item in target_list if isinstance(item, dict) and str(item.get("pane_id")) == str(pane_info.get("pane_id"))), None)
|
|
512
|
-
pane_uuid = _target_leader_session_uuid(pane_target or {}) or _target_leader_session_uuid(pane_info)
|
|
513
|
-
if not pane_uuid:
|
|
514
|
-
return None
|
|
513
|
+
pane_uuid = _target_leader_session_uuid(pane_target or {}) or _target_leader_session_uuid(pane_info) or str(owner_record.get("leader_session_uuid") or receiver.get("leader_session_uuid") or "")
|
|
515
514
|
if not _cwd_inside_workspace(pane_info.get("pane_current_path"), workspace):
|
|
516
515
|
return None
|
|
517
516
|
if not _leader_command_looks_usable(str(pane_info.get("pane_current_command", "")), receiver_provider):
|
|
518
517
|
return None
|
|
518
|
+
owner_pane = str(owner_record.get("pane_id") or "")
|
|
519
519
|
owner_uuid = str(owner_record.get("leader_session_uuid") or "")
|
|
520
|
-
|
|
520
|
+
target_uuid = _target_leader_session_uuid(pane_target or {})
|
|
521
|
+
if owner_pane and owner_pane != str(pane_info.get("pane_id") or "") and (not owner_uuid or target_uuid != owner_uuid):
|
|
521
522
|
return None
|
|
522
523
|
epoch = _lease_epoch(owner_record, receiver) + (1 if owner_record else 0)
|
|
523
|
-
receiver
|
|
524
|
-
|
|
525
|
-
|
|
524
|
+
receiver.update({
|
|
525
|
+
"pane_id": pane_info["pane_id"],
|
|
526
|
+
"session_name": pane_info.get("session_name"),
|
|
527
|
+
"window_index": pane_info.get("window_index"),
|
|
528
|
+
"window_name": pane_info.get("window_name"),
|
|
529
|
+
"pane_index": pane_info.get("pane_index"),
|
|
530
|
+
"pane_tty": pane_info.get("pane_tty"),
|
|
531
|
+
"pane_current_command": pane_info.get("pane_current_command"),
|
|
532
|
+
"leader_session_uuid": pane_uuid,
|
|
533
|
+
"owner_epoch": epoch,
|
|
534
|
+
"discovery": "attach_readopt",
|
|
535
|
+
})
|
|
526
536
|
receiver.pop("warning", None)
|
|
527
537
|
old_pane = owner_record.get("pane_id") or (state.get("leader_receiver") or {}).get("pane_id")
|
|
528
538
|
state["team_owner"] = {
|
|
@@ -540,7 +550,7 @@ def _try_readopt_leader_pane(
|
|
|
540
550
|
event_log.write("owner.adopted_on_restart", reason="attach_readopt", old_pane_id=old_pane, new_pane_id=pane_info["pane_id"], owner_epoch=epoch, uuid_prefix=pane_uuid[:8], team_id=team_state_key(state))
|
|
541
551
|
event_log.write("leader_receiver.rebind_applied", reason="attach_readopt", old_pane_id=old_pane, new_pane_id=pane_info["pane_id"], owner_epoch=epoch, uuid_prefix=pane_uuid[:8], team_id=team_state_key(state))
|
|
542
552
|
event_log.write("leader_receiver.attached", target=pane_info["pane_id"], session_name=pane_info.get("session_name"), provider=receiver.get("provider"), discovery="attach_readopt", source=source, owner_epoch=epoch, uuid_prefix=pane_uuid[:8])
|
|
543
|
-
return receiver
|
|
553
|
+
return receiver
|
|
544
554
|
|
|
545
555
|
|
|
546
556
|
def _detect_dual_state_divergence(workspace: Path, state: dict[str, Any]) -> dict[str, Any] | None:
|