@team-agent/installer 0.2.3 → 0.2.5
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/abnormal_track.py +253 -0
- package/src/team_agent/cli/commands.py +17 -1
- package/src/team_agent/cli/parser.py +2 -2
- package/src/team_agent/compiler.py +1 -1
- package/src/team_agent/coordinator/lifecycle.py +20 -2
- package/src/team_agent/display/__init__.py +31 -0
- package/src/team_agent/display/adaptive.py +425 -0
- package/src/team_agent/display/backend.py +46 -0
- package/src/team_agent/display/close.py +6 -0
- package/src/team_agent/display/rebuild.py +102 -0
- package/src/team_agent/display/tiling.py +156 -0
- package/src/team_agent/display/worker_window.py +4 -0
- package/src/team_agent/display/workspace.py +36 -127
- package/src/team_agent/idle_predicate.py +200 -0
- package/src/team_agent/idle_takeover.py +59 -0
- package/src/team_agent/idle_takeover_wiring.py +111 -0
- package/src/team_agent/launch/core.py +13 -4
- package/src/team_agent/leader/__init__.py +444 -61
- package/src/team_agent/message_store/agent_health.py +6 -2
- package/src/team_agent/message_store/core.py +51 -18
- package/src/team_agent/message_store/leader_notification_log.py +63 -38
- package/src/team_agent/message_store/result_watchers.py +17 -11
- package/src/team_agent/message_store/schema.py +19 -2
- package/src/team_agent/message_store/schema_migration.py +386 -0
- package/src/team_agent/messaging/delivery.py +45 -2
- package/src/team_agent/messaging/leader_panes.py +115 -21
- package/src/team_agent/messaging/send.py +33 -0
- package/src/team_agent/messaging/tmux_io.py +49 -10
- package/src/team_agent/messaging/trust_auto_answer.py +11 -3
- package/src/team_agent/provider_state/README.md +78 -0
- package/src/team_agent/provider_state/__init__.py +86 -0
- package/src/team_agent/provider_state/claude.py +86 -0
- package/src/team_agent/provider_state/codex.py +84 -0
- package/src/team_agent/provider_state/common.py +207 -0
- package/src/team_agent/provider_state/registry.py +118 -0
- package/src/team_agent/restart/orchestration.py +9 -9
- package/src/team_agent/runtime.py +62 -12
- package/src/team_agent/spec.py +4 -3
- package/src/team_agent/wake.py +58 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shlex
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
DISPLAY_PANES_PER_WINDOW = 3
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def display_window_name(index: int) -> str:
|
|
11
|
+
return "overview" if index == 0 else f"overview-{index + 1}"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def grouped_display_jobs(
|
|
15
|
+
jobs: list[tuple[str, dict[str, Any], str]],
|
|
16
|
+
panes_per_window: int = DISPLAY_PANES_PER_WINDOW,
|
|
17
|
+
) -> list[tuple[int, str, list[tuple[str, dict[str, Any], str]]]]:
|
|
18
|
+
groups: list[tuple[int, str, list[tuple[str, dict[str, Any], str]]]] = []
|
|
19
|
+
for window_index, start in enumerate(range(0, len(jobs), panes_per_window)):
|
|
20
|
+
groups.append((window_index, display_window_name(window_index), jobs[start : start + panes_per_window]))
|
|
21
|
+
return groups
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def team_scoped_display_window_name(session_name: str, index: int) -> str:
|
|
25
|
+
return f"team-agent:{session_name}:{display_window_name(index)}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def tmux_stdout_last_line(stdout: str) -> str | None:
|
|
29
|
+
lines = [line.strip() for line in stdout.splitlines() if line.strip()]
|
|
30
|
+
return lines[-1] if lines else None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def tmux_attach_pane_command(linked_session: str) -> str:
|
|
34
|
+
return f"TMUX= tmux attach-session -t {shlex.quote(linked_session)}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def display_pane_title(agent: dict[str, Any]) -> str:
|
|
38
|
+
return f"team-agent:{agent['id']}:{agent.get('role', '')}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def set_tmux_display_pane_title(pane_id: str, title: str, reason: str) -> dict[str, Any]:
|
|
42
|
+
from team_agent.runtime import run_cmd
|
|
43
|
+
proc = run_cmd(["tmux", "select-pane", "-t", pane_id, "-T", title], timeout=10)
|
|
44
|
+
if proc.returncode != 0:
|
|
45
|
+
return {"ok": False, "reason": reason, "error": proc.stderr.strip()}
|
|
46
|
+
return {"ok": True}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def prepare_tmux_attached_panes(
|
|
50
|
+
host_session: str,
|
|
51
|
+
linked_jobs: list[tuple[str, dict[str, Any], str]],
|
|
52
|
+
*,
|
|
53
|
+
window_name_for_index: Callable[[int], str],
|
|
54
|
+
create_first_as_session: bool,
|
|
55
|
+
panes_per_window: int = DISPLAY_PANES_PER_WINDOW,
|
|
56
|
+
reason_map: dict[str, str] | None = None,
|
|
57
|
+
stderr_reason_allowlist: set[str] | None = None,
|
|
58
|
+
cleanup_session: str | None = None,
|
|
59
|
+
enable_mouse: bool = False,
|
|
60
|
+
select_first_window: bool = False,
|
|
61
|
+
) -> dict[str, Any]:
|
|
62
|
+
from team_agent.runtime import run_cmd
|
|
63
|
+
|
|
64
|
+
reasons = reason_map or {}
|
|
65
|
+
|
|
66
|
+
def reason(key: str) -> str:
|
|
67
|
+
return reasons.get(key, key)
|
|
68
|
+
|
|
69
|
+
def fail(key: str, proc: Any | None = None, target: str | None = None) -> dict[str, Any]:
|
|
70
|
+
if cleanup_session:
|
|
71
|
+
run_cmd(["tmux", "kill-session", "-t", cleanup_session], timeout=10)
|
|
72
|
+
result = {"ok": False, "reason": reason(key)}
|
|
73
|
+
if proc is not None:
|
|
74
|
+
detail = (proc.stderr or proc.stdout or "").strip()
|
|
75
|
+
if stderr_reason_allowlist and detail in stderr_reason_allowlist:
|
|
76
|
+
result["reason"] = detail
|
|
77
|
+
result["error"] = detail
|
|
78
|
+
if target:
|
|
79
|
+
result["target"] = target
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
panes: list[dict[str, Any]] = []
|
|
83
|
+
for window_index, _base_window_name, window_jobs in grouped_display_jobs(linked_jobs, panes_per_window):
|
|
84
|
+
window_name = window_name_for_index(window_index)
|
|
85
|
+
first_agent_id, first_agent, first_linked_session = window_jobs[0]
|
|
86
|
+
if create_first_as_session and window_index == 0:
|
|
87
|
+
command = [
|
|
88
|
+
"tmux", "new-session", "-d", "-P", "-F", "#{pane_id}",
|
|
89
|
+
"-s", host_session, "-n", window_name, tmux_attach_pane_command(first_linked_session),
|
|
90
|
+
]
|
|
91
|
+
fail_key = "create_session"
|
|
92
|
+
else:
|
|
93
|
+
command = [
|
|
94
|
+
"tmux", "new-window", "-t", host_session, "-n", window_name,
|
|
95
|
+
"-P", "-F", "#{pane_id}", tmux_attach_pane_command(first_linked_session),
|
|
96
|
+
]
|
|
97
|
+
fail_key = "create_window"
|
|
98
|
+
proc = run_cmd(command, timeout=10)
|
|
99
|
+
if proc.returncode != 0:
|
|
100
|
+
return fail(fail_key, proc, f"{host_session}:{window_name}")
|
|
101
|
+
|
|
102
|
+
first_pane_id = tmux_stdout_last_line(proc.stdout) or f"{host_session}:{window_name}.0"
|
|
103
|
+
title = display_pane_title(first_agent)
|
|
104
|
+
title_result = set_tmux_display_pane_title(first_pane_id, title, reason("title"))
|
|
105
|
+
if not title_result["ok"]:
|
|
106
|
+
return fail(title_result["reason"], target=first_pane_id)
|
|
107
|
+
panes.append(
|
|
108
|
+
{
|
|
109
|
+
"agent_id": first_agent_id,
|
|
110
|
+
"pane_id": first_pane_id,
|
|
111
|
+
"title": title,
|
|
112
|
+
"linked_session": first_linked_session,
|
|
113
|
+
"window_name": window_name,
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
proc = run_cmd(["tmux", "set-window-option", "-t", f"{host_session}:{window_name}", "remain-on-exit", "on"], timeout=10)
|
|
118
|
+
if proc.returncode != 0:
|
|
119
|
+
return fail("remain", proc, f"{host_session}:{window_name}")
|
|
120
|
+
|
|
121
|
+
for index, (agent_id, agent, linked_session) in enumerate(window_jobs[1:], start=1):
|
|
122
|
+
proc = run_cmd(
|
|
123
|
+
[
|
|
124
|
+
"tmux", "split-window", "-t", f"{host_session}:{window_name}",
|
|
125
|
+
"-h", "-P", "-F", "#{pane_id}", tmux_attach_pane_command(linked_session),
|
|
126
|
+
],
|
|
127
|
+
timeout=10,
|
|
128
|
+
)
|
|
129
|
+
if proc.returncode != 0:
|
|
130
|
+
return fail("split", proc, f"{host_session}:{window_name}")
|
|
131
|
+
pane_id = tmux_stdout_last_line(proc.stdout) or f"{host_session}:{window_name}.{index}"
|
|
132
|
+
title = display_pane_title(agent)
|
|
133
|
+
title_result = set_tmux_display_pane_title(pane_id, title, reason("title"))
|
|
134
|
+
if not title_result["ok"]:
|
|
135
|
+
return fail(title_result["reason"], target=pane_id)
|
|
136
|
+
panes.append(
|
|
137
|
+
{
|
|
138
|
+
"agent_id": agent_id,
|
|
139
|
+
"pane_id": pane_id,
|
|
140
|
+
"title": title,
|
|
141
|
+
"linked_session": linked_session,
|
|
142
|
+
"window_name": window_name,
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
proc = run_cmd(["tmux", "select-layout", "-t", f"{host_session}:{window_name}", "even-horizontal"], timeout=10)
|
|
147
|
+
if proc.returncode != 0:
|
|
148
|
+
return fail("layout", proc, f"{host_session}:{window_name}")
|
|
149
|
+
|
|
150
|
+
if enable_mouse:
|
|
151
|
+
proc = run_cmd(["tmux", "set-option", "-t", host_session, "mouse", "on"], timeout=10)
|
|
152
|
+
if proc.returncode != 0:
|
|
153
|
+
return fail("mouse", proc)
|
|
154
|
+
if select_first_window:
|
|
155
|
+
run_cmd(["tmux", "select-window", "-t", f"{host_session}:{window_name_for_index(0)}"], timeout=10)
|
|
156
|
+
return {"ok": True, "host_session": host_session, "panes": panes}
|
|
@@ -5,6 +5,7 @@ from pathlib import Path
|
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
7
|
from team_agent.events import EventLog
|
|
8
|
+
from team_agent.display.adaptive import open_adaptive_display
|
|
8
9
|
from team_agent.display.ghostty import (
|
|
9
10
|
ghostty_app_exists,
|
|
10
11
|
ghostty_attach_args,
|
|
@@ -21,9 +22,12 @@ def open_worker_displays(
|
|
|
21
22
|
jobs: list[tuple[str, dict[str, Any]]],
|
|
22
23
|
event_log: EventLog,
|
|
23
24
|
display_backend: str = "ghostty_window",
|
|
25
|
+
capability_probe: dict[str, Any] | None = None,
|
|
24
26
|
) -> dict[str, dict[str, Any]]:
|
|
25
27
|
if not jobs:
|
|
26
28
|
return {}
|
|
29
|
+
if display_backend == "adaptive":
|
|
30
|
+
return open_adaptive_display(workspace, session_name, jobs, event_log, capability_probe=capability_probe)
|
|
27
31
|
if display_backend == "ghostty_workspace":
|
|
28
32
|
return open_ghostty_workspace(workspace, session_name, jobs, event_log)
|
|
29
33
|
if len(jobs) == 1:
|
|
@@ -2,7 +2,6 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import hashlib
|
|
4
4
|
import re
|
|
5
|
-
import shlex
|
|
6
5
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
7
6
|
from typing import Any
|
|
8
7
|
|
|
@@ -14,14 +13,18 @@ from team_agent.display.ghostty import (
|
|
|
14
13
|
ghostty_pids_by_title,
|
|
15
14
|
prepare_ghostty_display_session,
|
|
16
15
|
)
|
|
16
|
+
from team_agent.display.tiling import (
|
|
17
|
+
DISPLAY_PANES_PER_WINDOW,
|
|
18
|
+
display_pane_title,
|
|
19
|
+
display_window_name,
|
|
20
|
+
prepare_tmux_attached_panes,
|
|
21
|
+
set_tmux_display_pane_title,
|
|
22
|
+
tmux_attach_pane_command,
|
|
23
|
+
tmux_stdout_last_line as _tmux_stdout_last_line,
|
|
24
|
+
)
|
|
17
25
|
|
|
18
26
|
|
|
19
|
-
GHOSTTY_WORKSPACE_PANES_PER_WINDOW =
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def _tmux_stdout_last_line(stdout: str) -> str | None:
|
|
23
|
-
lines = [line.strip() for line in stdout.splitlines() if line.strip()]
|
|
24
|
-
return lines[-1] if lines else None
|
|
27
|
+
GHOSTTY_WORKSPACE_PANES_PER_WINDOW = DISPLAY_PANES_PER_WINDOW
|
|
25
28
|
|
|
26
29
|
|
|
27
30
|
def open_ghostty_workspace(
|
|
@@ -152,15 +155,15 @@ def ghostty_workspace_aggregator_name(session_name: str) -> str:
|
|
|
152
155
|
|
|
153
156
|
|
|
154
157
|
def ghostty_workspace_window_name(index: int) -> str:
|
|
155
|
-
return
|
|
158
|
+
return display_window_name(index)
|
|
156
159
|
|
|
157
160
|
|
|
158
161
|
def ghostty_workspace_pane_command(linked_session: str) -> str:
|
|
159
|
-
return
|
|
162
|
+
return tmux_attach_pane_command(linked_session)
|
|
160
163
|
|
|
161
164
|
|
|
162
165
|
def ghostty_workspace_pane_title(agent: dict[str, Any]) -> str:
|
|
163
|
-
return
|
|
166
|
+
return display_pane_title(agent)
|
|
164
167
|
|
|
165
168
|
|
|
166
169
|
def prepare_ghostty_workspace_linked_sessions(
|
|
@@ -203,126 +206,32 @@ def prepare_ghostty_workspace_aggregator(
|
|
|
203
206
|
proc = run_cmd(["tmux", "kill-session", "-t", aggregator_session], timeout=10)
|
|
204
207
|
if proc.returncode != 0:
|
|
205
208
|
return {"ok": False, "reason": "display_session_cleanup_failed", "error": proc.stderr.strip()}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
"-F",
|
|
229
|
-
"#{pane_id}",
|
|
230
|
-
"-s",
|
|
231
|
-
aggregator_session,
|
|
232
|
-
"-n",
|
|
233
|
-
window_name,
|
|
234
|
-
ghostty_workspace_pane_command(first_linked_session),
|
|
235
|
-
],
|
|
236
|
-
timeout=10,
|
|
237
|
-
)
|
|
238
|
-
if proc.returncode != 0:
|
|
239
|
-
return {"ok": False, "reason": "display_session_create_failed", "error": proc.stderr.strip()}
|
|
240
|
-
else:
|
|
241
|
-
proc = run_cmd(
|
|
242
|
-
[
|
|
243
|
-
"tmux",
|
|
244
|
-
"new-window",
|
|
245
|
-
"-t",
|
|
246
|
-
aggregator_session,
|
|
247
|
-
"-n",
|
|
248
|
-
window_name,
|
|
249
|
-
"-P",
|
|
250
|
-
"-F",
|
|
251
|
-
"#{pane_id}",
|
|
252
|
-
ghostty_workspace_pane_command(first_linked_session),
|
|
253
|
-
],
|
|
254
|
-
timeout=10,
|
|
255
|
-
)
|
|
256
|
-
if proc.returncode != 0:
|
|
257
|
-
return fail("display_session_window_create_failed", proc, first_linked_session)
|
|
258
|
-
first_pane_id = _tmux_stdout_last_line(proc.stdout) or f"{aggregator_session}:{window_name}.0"
|
|
259
|
-
first_title = ghostty_workspace_pane_title(first_agent)
|
|
260
|
-
title_result = set_ghostty_workspace_pane_title(first_pane_id, first_title)
|
|
261
|
-
if not title_result["ok"]:
|
|
262
|
-
return fail(title_result["reason"], target=first_pane_id)
|
|
263
|
-
panes.append(
|
|
264
|
-
{
|
|
265
|
-
"agent_id": first_agent_id,
|
|
266
|
-
"pane_id": first_pane_id,
|
|
267
|
-
"title": first_title,
|
|
268
|
-
"linked_session": first_linked_session,
|
|
269
|
-
"window_name": window_name,
|
|
270
|
-
}
|
|
271
|
-
)
|
|
272
|
-
|
|
273
|
-
proc = run_cmd(["tmux", "set-window-option", "-t", f"{aggregator_session}:{window_name}", "remain-on-exit", "on"], timeout=10)
|
|
274
|
-
if proc.returncode != 0:
|
|
275
|
-
return fail("display_session_remain_on_exit_failed", proc)
|
|
276
|
-
|
|
277
|
-
for index, (agent_id, agent, linked_session) in enumerate(window_jobs[1:], start=1):
|
|
278
|
-
proc = run_cmd(
|
|
279
|
-
[
|
|
280
|
-
"tmux",
|
|
281
|
-
"split-window",
|
|
282
|
-
"-t",
|
|
283
|
-
f"{aggregator_session}:{window_name}",
|
|
284
|
-
"-h",
|
|
285
|
-
"-P",
|
|
286
|
-
"-F",
|
|
287
|
-
"#{pane_id}",
|
|
288
|
-
ghostty_workspace_pane_command(linked_session),
|
|
289
|
-
],
|
|
290
|
-
timeout=10,
|
|
291
|
-
)
|
|
292
|
-
if proc.returncode != 0:
|
|
293
|
-
return fail("display_session_split_failed", proc, linked_session)
|
|
294
|
-
pane_id = _tmux_stdout_last_line(proc.stdout) or f"{aggregator_session}:{window_name}.{index}"
|
|
295
|
-
title = ghostty_workspace_pane_title(agent)
|
|
296
|
-
title_result = set_ghostty_workspace_pane_title(pane_id, title)
|
|
297
|
-
if not title_result["ok"]:
|
|
298
|
-
return fail(title_result["reason"], target=pane_id)
|
|
299
|
-
panes.append(
|
|
300
|
-
{
|
|
301
|
-
"agent_id": agent_id,
|
|
302
|
-
"pane_id": pane_id,
|
|
303
|
-
"title": title,
|
|
304
|
-
"linked_session": linked_session,
|
|
305
|
-
"window_name": window_name,
|
|
306
|
-
}
|
|
307
|
-
)
|
|
308
|
-
|
|
309
|
-
proc = run_cmd(["tmux", "select-layout", "-t", f"{aggregator_session}:{window_name}", "even-horizontal"], timeout=10)
|
|
310
|
-
if proc.returncode != 0:
|
|
311
|
-
return fail("display_session_layout_failed", proc)
|
|
312
|
-
|
|
313
|
-
proc = run_cmd(["tmux", "set-option", "-t", aggregator_session, "mouse", "on"], timeout=10)
|
|
314
|
-
if proc.returncode != 0:
|
|
315
|
-
return fail("display_session_mouse_failed", proc)
|
|
316
|
-
run_cmd(["tmux", "select-window", "-t", f"{aggregator_session}:{ghostty_workspace_window_name(0)}"], timeout=10)
|
|
317
|
-
return {"ok": True, "aggregator_session": aggregator_session, "panes": panes}
|
|
209
|
+
prepared = prepare_tmux_attached_panes(
|
|
210
|
+
aggregator_session,
|
|
211
|
+
linked_jobs,
|
|
212
|
+
window_name_for_index=ghostty_workspace_window_name,
|
|
213
|
+
create_first_as_session=True,
|
|
214
|
+
panes_per_window=GHOSTTY_WORKSPACE_PANES_PER_WINDOW,
|
|
215
|
+
cleanup_session=aggregator_session,
|
|
216
|
+
enable_mouse=True,
|
|
217
|
+
select_first_window=True,
|
|
218
|
+
reason_map={
|
|
219
|
+
"create_session": "display_session_create_failed",
|
|
220
|
+
"create_window": "display_session_window_create_failed",
|
|
221
|
+
"title": "display_session_pane_title_failed",
|
|
222
|
+
"remain": "display_session_remain_on_exit_failed",
|
|
223
|
+
"split": "display_session_split_failed",
|
|
224
|
+
"layout": "display_session_layout_failed",
|
|
225
|
+
"mouse": "display_session_mouse_failed",
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
if prepared.get("ok"):
|
|
229
|
+
prepared["aggregator_session"] = aggregator_session
|
|
230
|
+
return prepared
|
|
318
231
|
|
|
319
232
|
|
|
320
233
|
def set_ghostty_workspace_pane_title(pane_id: str, title: str) -> dict[str, Any]:
|
|
321
|
-
|
|
322
|
-
proc = run_cmd(["tmux", "select-pane", "-t", pane_id, "-T", title], timeout=10)
|
|
323
|
-
if proc.returncode != 0:
|
|
324
|
-
return {"ok": False, "reason": "display_session_pane_title_failed", "error": proc.stderr.strip()}
|
|
325
|
-
return {"ok": True}
|
|
234
|
+
return set_tmux_display_pane_title(pane_id, title, "display_session_pane_title_failed")
|
|
326
235
|
|
|
327
236
|
|
|
328
237
|
def open_ghostty_workspace_agent_display(
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Provider-neutral take-over reminder predicate (Gap 32 §3).
|
|
2
|
+
|
|
3
|
+
Consumes already-classified node states only. Contains no provider knowledge.
|
|
4
|
+
Rules: arm only after a worker has opened a turn (C1); fire one neutral ping when
|
|
5
|
+
every node is idle for a monotonic debounce window (C2/C11); idle_interrupted
|
|
6
|
+
counts as idle but is annotated (C12); re-arm on a real turn-open edge (C3).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
_IDLE_STATES = {"idle", "idle_interrupted"}
|
|
14
|
+
_DELEGATED_STATES = {"working", "blocked_on_human", "idle_interrupted", "abnormal"}
|
|
15
|
+
|
|
16
|
+
_ARM_KEY = "opened_worker_turn_since_ack"
|
|
17
|
+
_SUPPRESS_KEY = "suppressed"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def evaluate_takeover_reminder(
|
|
21
|
+
nodes: list[dict[str, Any]],
|
|
22
|
+
*,
|
|
23
|
+
monitor_state: dict[str, Any] | None,
|
|
24
|
+
now_monotonic: float,
|
|
25
|
+
debounce_seconds: float,
|
|
26
|
+
suspend_intervals: list[tuple[float, float]] | None = None,
|
|
27
|
+
event_sink: Any = None,
|
|
28
|
+
) -> dict[str, Any]:
|
|
29
|
+
state = dict(monitor_state or {})
|
|
30
|
+
state.setdefault(_ARM_KEY, False)
|
|
31
|
+
state.setdefault(_SUPPRESS_KEY, False)
|
|
32
|
+
state.setdefault("all_idle_since", None)
|
|
33
|
+
state.setdefault("pinged_for_episode", None)
|
|
34
|
+
|
|
35
|
+
# C1: a WORKER turn-open (working / blocked / interrupted / faulted) is the
|
|
36
|
+
# only thing that arms the watch. Leader-only activity never arms it.
|
|
37
|
+
for node in nodes:
|
|
38
|
+
if _role(node) == "leader":
|
|
39
|
+
continue
|
|
40
|
+
if node.get("state") in _DELEGATED_STATES:
|
|
41
|
+
state[_ARM_KEY] = True
|
|
42
|
+
|
|
43
|
+
# Any non-idle node blocks the ping; report which kind (C5 unknown / C14 working).
|
|
44
|
+
for node in nodes:
|
|
45
|
+
node_state = node.get("state")
|
|
46
|
+
if node_state not in _IDLE_STATES:
|
|
47
|
+
state["all_idle_since"] = None
|
|
48
|
+
state["pinged_for_episode"] = None
|
|
49
|
+
return _result(False, None, f"node_{node_state or 'unknown'}", _interrupted(nodes), state)
|
|
50
|
+
|
|
51
|
+
if not nodes:
|
|
52
|
+
return _result(False, None, "no_nodes", [], state)
|
|
53
|
+
|
|
54
|
+
if state.get("all_idle_since") is None:
|
|
55
|
+
state["all_idle_since"] = now_monotonic
|
|
56
|
+
state["pinged_for_episode"] = None
|
|
57
|
+
elapsed = _active_elapsed(state["all_idle_since"], now_monotonic, suspend_intervals)
|
|
58
|
+
interrupted = _interrupted(nodes)
|
|
59
|
+
|
|
60
|
+
if not state.get(_ARM_KEY):
|
|
61
|
+
return _result(False, None, "not_armed_no_worker_turn", interrupted, state)
|
|
62
|
+
if state.get(_SUPPRESS_KEY):
|
|
63
|
+
return _result(False, None, "acknowledged", interrupted, state)
|
|
64
|
+
if elapsed < debounce_seconds:
|
|
65
|
+
return _result(False, None, "debounce_active", interrupted, state)
|
|
66
|
+
if state.get("pinged_for_episode") == state.get("all_idle_since"):
|
|
67
|
+
return _result(False, None, "already_pinged_this_episode", interrupted, state)
|
|
68
|
+
|
|
69
|
+
state["pinged_for_episode"] = state["all_idle_since"]
|
|
70
|
+
message = _neutral_message(len(nodes), elapsed, interrupted)
|
|
71
|
+
_emit(event_sink, "idle_takeover.ping", nodes=len(nodes), elapsed_seconds=int(elapsed), interrupted=[i["node_id"] for i in interrupted])
|
|
72
|
+
return _result(True, message, "all_idle_debounce_elapsed", interrupted, state)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def record_turn_open_after_delivery(
|
|
76
|
+
monitor_state: dict[str, Any] | None,
|
|
77
|
+
*,
|
|
78
|
+
node_id: str,
|
|
79
|
+
turn_id: str | None,
|
|
80
|
+
delivered_message_id: str | None,
|
|
81
|
+
now_monotonic: float,
|
|
82
|
+
event_sink: Any = None,
|
|
83
|
+
) -> dict[str, Any]:
|
|
84
|
+
"""A delivered inbound message produced a real turn-open edge (C3).
|
|
85
|
+
|
|
86
|
+
Re-arms a previously acknowledged watch so delivered-but-unprocessed work
|
|
87
|
+
can never leave it permanently suppressed. Returns the updated monitor_state
|
|
88
|
+
directly (with the re-arm flags set).
|
|
89
|
+
"""
|
|
90
|
+
state = dict(monitor_state or {})
|
|
91
|
+
state[_ARM_KEY] = True
|
|
92
|
+
state[_SUPPRESS_KEY] = False
|
|
93
|
+
state["all_idle_since"] = None
|
|
94
|
+
state["pinged_for_episode"] = None
|
|
95
|
+
state["last_turn_open"] = {
|
|
96
|
+
"node_id": node_id,
|
|
97
|
+
"turn_id": turn_id,
|
|
98
|
+
"delivered_message_id": delivered_message_id,
|
|
99
|
+
"at": now_monotonic,
|
|
100
|
+
}
|
|
101
|
+
state["ok"] = True
|
|
102
|
+
state["rearmed"] = True
|
|
103
|
+
_emit(event_sink, "idle_takeover.turn_open_rearmed", node_id=node_id, turn_id=turn_id, delivered_message_id=delivered_message_id)
|
|
104
|
+
return state
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _role(node: dict[str, Any]) -> str:
|
|
108
|
+
return str(node.get("role") or ("leader" if node.get("is_leader") else "worker"))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _interrupted(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
112
|
+
return [
|
|
113
|
+
{
|
|
114
|
+
"node_id": n.get("node_id"),
|
|
115
|
+
"node": n.get("node_id"),
|
|
116
|
+
"state": "idle_interrupted",
|
|
117
|
+
"reason": "interrupted",
|
|
118
|
+
"interrupted": True,
|
|
119
|
+
"kind": "interrupted",
|
|
120
|
+
"type": "interrupted",
|
|
121
|
+
"annotation": "interrupted",
|
|
122
|
+
}
|
|
123
|
+
for n in nodes
|
|
124
|
+
if n.get("state") == "idle_interrupted"
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _active_elapsed(start: float, now: float, suspend_intervals: list[tuple[float, float]] | None) -> float:
|
|
129
|
+
elapsed = max(0.0, now - start)
|
|
130
|
+
if not suspend_intervals:
|
|
131
|
+
return elapsed
|
|
132
|
+
# C11: clip each window to [start, now], then MERGE overlapping/duplicate
|
|
133
|
+
# windows before subtracting, so an overlap is never counted twice.
|
|
134
|
+
clipped: list[tuple[float, float]] = []
|
|
135
|
+
for interval in suspend_intervals:
|
|
136
|
+
try:
|
|
137
|
+
s, e = float(interval[0]), float(interval[1])
|
|
138
|
+
except (TypeError, ValueError, IndexError):
|
|
139
|
+
continue
|
|
140
|
+
lo = max(s, start)
|
|
141
|
+
hi = min(e, now)
|
|
142
|
+
if hi > lo:
|
|
143
|
+
clipped.append((lo, hi))
|
|
144
|
+
suspended = 0.0
|
|
145
|
+
for lo, hi in _merge_intervals(clipped):
|
|
146
|
+
suspended += hi - lo
|
|
147
|
+
return max(0.0, elapsed - suspended)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _merge_intervals(intervals: list[tuple[float, float]]) -> list[tuple[float, float]]:
|
|
151
|
+
if not intervals:
|
|
152
|
+
return []
|
|
153
|
+
ordered = sorted(intervals)
|
|
154
|
+
merged: list[tuple[float, float]] = [ordered[0]]
|
|
155
|
+
for lo, hi in ordered[1:]:
|
|
156
|
+
last_lo, last_hi = merged[-1]
|
|
157
|
+
if lo <= last_hi: # overlapping or touching → merge
|
|
158
|
+
merged[-1] = (last_lo, max(last_hi, hi))
|
|
159
|
+
else:
|
|
160
|
+
merged.append((lo, hi))
|
|
161
|
+
return merged
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _neutral_message(node_count: int, elapsed: float, interrupted: list[dict[str, Any]]) -> str:
|
|
165
|
+
minutes = max(1, int(round(elapsed / 60.0)))
|
|
166
|
+
base = (
|
|
167
|
+
f"All nodes idle: {node_count} team nodes have had every turn closed for "
|
|
168
|
+
f"about {minutes} min. If this idle state is intentional, run "
|
|
169
|
+
f"team-agent acknowledge-idle to confirm it."
|
|
170
|
+
)
|
|
171
|
+
if interrupted:
|
|
172
|
+
ids = ", ".join(str(i["node_id"]) for i in interrupted)
|
|
173
|
+
base += f" Interrupted nodes: {ids}."
|
|
174
|
+
return base
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _result(should_ping: bool, message: str | None, reason: str, annotations: list[dict[str, Any]], state: dict[str, Any]) -> dict[str, Any]:
|
|
178
|
+
return {
|
|
179
|
+
"should_ping": should_ping,
|
|
180
|
+
"message": message,
|
|
181
|
+
"reason": reason,
|
|
182
|
+
"annotations": list(annotations),
|
|
183
|
+
"interrupted_nodes": [a.get("node_id") for a in annotations],
|
|
184
|
+
"interrupted": [a.get("node_id") for a in annotations],
|
|
185
|
+
"monitor_state": state,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _emit(event_sink: Any, name: str, **fields: Any) -> None:
|
|
190
|
+
if event_sink is None:
|
|
191
|
+
return
|
|
192
|
+
try:
|
|
193
|
+
event_sink(name, fields)
|
|
194
|
+
except TypeError:
|
|
195
|
+
try:
|
|
196
|
+
event_sink({"event": name, **fields})
|
|
197
|
+
except Exception:
|
|
198
|
+
pass
|
|
199
|
+
except Exception:
|
|
200
|
+
pass
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Gap 32 idle/take-over public facade.
|
|
2
|
+
|
|
3
|
+
Thin surface that the runtime + acceptance contract import. Provider dispatch
|
|
4
|
+
lives in ``provider_state``; the predicate / abnormal / wake logic lives in the
|
|
5
|
+
provider-neutral modules. This module only wires them together.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from team_agent.abnormal_track import detect_whole_team_gone, process_abnormal_records
|
|
13
|
+
from team_agent.idle_predicate import evaluate_takeover_reminder, record_turn_open_after_delivery
|
|
14
|
+
from team_agent.provider_state import read_turn_state
|
|
15
|
+
from team_agent.provider_state.registry import get_provider_registry
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def classify_provider_turn_state(
|
|
19
|
+
provider: str,
|
|
20
|
+
session_log_text: str,
|
|
21
|
+
*,
|
|
22
|
+
process: Any = None,
|
|
23
|
+
file_silence_seconds: float = 0,
|
|
24
|
+
registry: Any = None,
|
|
25
|
+
event_sink: Any = None,
|
|
26
|
+
) -> dict[str, Any]:
|
|
27
|
+
"""Classify one node's turn state from its provider session-log text."""
|
|
28
|
+
result = read_turn_state(
|
|
29
|
+
provider,
|
|
30
|
+
session_log_text,
|
|
31
|
+
process=process,
|
|
32
|
+
file_silence_seconds=file_silence_seconds,
|
|
33
|
+
registry=registry,
|
|
34
|
+
)
|
|
35
|
+
if event_sink is not None and result.get("state") in {"unknown", "abnormal"}:
|
|
36
|
+
_emit(event_sink, "idle_takeover.classify", provider=provider, state=result.get("state"), reason=result.get("reason"))
|
|
37
|
+
return result
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"classify_provider_turn_state",
|
|
42
|
+
"evaluate_takeover_reminder",
|
|
43
|
+
"record_turn_open_after_delivery",
|
|
44
|
+
"process_abnormal_records",
|
|
45
|
+
"detect_whole_team_gone",
|
|
46
|
+
"get_provider_registry",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _emit(event_sink: Any, name: str, **fields: Any) -> None:
|
|
51
|
+
try:
|
|
52
|
+
event_sink(name, fields)
|
|
53
|
+
except TypeError:
|
|
54
|
+
try:
|
|
55
|
+
event_sink({"event": name, **fields})
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|