@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.
Files changed (40) hide show
  1. package/package.json +1 -1
  2. package/src/team_agent/abnormal_track.py +253 -0
  3. package/src/team_agent/cli/commands.py +17 -1
  4. package/src/team_agent/cli/parser.py +2 -2
  5. package/src/team_agent/compiler.py +1 -1
  6. package/src/team_agent/coordinator/lifecycle.py +20 -2
  7. package/src/team_agent/display/__init__.py +31 -0
  8. package/src/team_agent/display/adaptive.py +425 -0
  9. package/src/team_agent/display/backend.py +46 -0
  10. package/src/team_agent/display/close.py +6 -0
  11. package/src/team_agent/display/rebuild.py +102 -0
  12. package/src/team_agent/display/tiling.py +156 -0
  13. package/src/team_agent/display/worker_window.py +4 -0
  14. package/src/team_agent/display/workspace.py +36 -127
  15. package/src/team_agent/idle_predicate.py +200 -0
  16. package/src/team_agent/idle_takeover.py +59 -0
  17. package/src/team_agent/idle_takeover_wiring.py +111 -0
  18. package/src/team_agent/launch/core.py +13 -4
  19. package/src/team_agent/leader/__init__.py +444 -61
  20. package/src/team_agent/message_store/agent_health.py +6 -2
  21. package/src/team_agent/message_store/core.py +51 -18
  22. package/src/team_agent/message_store/leader_notification_log.py +63 -38
  23. package/src/team_agent/message_store/result_watchers.py +17 -11
  24. package/src/team_agent/message_store/schema.py +19 -2
  25. package/src/team_agent/message_store/schema_migration.py +386 -0
  26. package/src/team_agent/messaging/delivery.py +45 -2
  27. package/src/team_agent/messaging/leader_panes.py +115 -21
  28. package/src/team_agent/messaging/send.py +33 -0
  29. package/src/team_agent/messaging/tmux_io.py +49 -10
  30. package/src/team_agent/messaging/trust_auto_answer.py +11 -3
  31. package/src/team_agent/provider_state/README.md +78 -0
  32. package/src/team_agent/provider_state/__init__.py +86 -0
  33. package/src/team_agent/provider_state/claude.py +86 -0
  34. package/src/team_agent/provider_state/codex.py +84 -0
  35. package/src/team_agent/provider_state/common.py +207 -0
  36. package/src/team_agent/provider_state/registry.py +118 -0
  37. package/src/team_agent/restart/orchestration.py +9 -9
  38. package/src/team_agent/runtime.py +62 -12
  39. package/src/team_agent/spec.py +4 -3
  40. 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 = 3
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 "overview" if index == 0 else f"overview-{index + 1}"
158
+ return display_window_name(index)
156
159
 
157
160
 
158
161
  def ghostty_workspace_pane_command(linked_session: str) -> str:
159
- return f"TMUX= tmux attach-session -t {shlex.quote(linked_session)}"
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 f"team-agent:{agent['id']}:{agent.get('role', '')}"
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
- def fail(reason: str, proc: Any | None = None, target: str | None = None) -> dict[str, Any]:
208
- run_cmd(["tmux", "kill-session", "-t", aggregator_session], timeout=10)
209
- result = {"ok": False, "reason": reason}
210
- if proc is not None:
211
- result["error"] = proc.stderr.strip()
212
- if target:
213
- result["target"] = target
214
- return result
215
-
216
- panes: list[dict[str, Any]] = []
217
- for window_index, start in enumerate(range(0, len(linked_jobs), GHOSTTY_WORKSPACE_PANES_PER_WINDOW)):
218
- window_name = ghostty_workspace_window_name(window_index)
219
- window_jobs = linked_jobs[start : start + GHOSTTY_WORKSPACE_PANES_PER_WINDOW]
220
- first_agent_id, first_agent, first_linked_session = window_jobs[0]
221
- if window_index == 0:
222
- proc = run_cmd(
223
- [
224
- "tmux",
225
- "new-session",
226
- "-d",
227
- "-P",
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
- from team_agent.runtime import run_cmd
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