@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,118 @@
|
|
|
1
|
+
"""Per-CLI idle/turn-state registry — PURE INFRA DATA (Gap 32 C7).
|
|
2
|
+
|
|
3
|
+
This module is data only: session-file locations, turn-lifecycle marker
|
|
4
|
+
descriptions, and per-CLI error white/black lists. It carries no predicate,
|
|
5
|
+
abnormal, or wake logic. Adding a new provider is one entry here plus one
|
|
6
|
+
reader module under ``provider_state/``; the neutral layers never change.
|
|
7
|
+
|
|
8
|
+
The registry is shipped with the runtime as infra data — it is NOT
|
|
9
|
+
user-mandatory configuration and is never loaded from a workspace.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
# Each entry is consumed by the matching provider reader. The neutral
|
|
17
|
+
# idle_predicate / abnormal_track / wake modules never read provider names.
|
|
18
|
+
_PROVIDER_REGISTRY: dict[str, dict[str, Any]] = {
|
|
19
|
+
"claude": {
|
|
20
|
+
"kind": "claude",
|
|
21
|
+
"reader_module": "team_agent.provider_state.claude",
|
|
22
|
+
"source": "infra",
|
|
23
|
+
"file_location": {
|
|
24
|
+
"root": "~/.claude/projects",
|
|
25
|
+
"layout": "<cwd-slug>/<session_id>.jsonl",
|
|
26
|
+
"format": "transcript-jsonl",
|
|
27
|
+
},
|
|
28
|
+
"event_types": {
|
|
29
|
+
"turn_open": "assistant message.stop_reason == tool_use",
|
|
30
|
+
"turn_complete": "assistant message.stop_reason == end_turn",
|
|
31
|
+
"interrupted": "user text == [Request interrupted by user]",
|
|
32
|
+
"tool_error": "user tool_result is_error == true",
|
|
33
|
+
"api_error": "system subtype == api_error and level == error",
|
|
34
|
+
},
|
|
35
|
+
"metadata_ignore": [
|
|
36
|
+
"stop_hook_summary",
|
|
37
|
+
"turn_duration",
|
|
38
|
+
"last-prompt",
|
|
39
|
+
"ai-title",
|
|
40
|
+
"permission-mode",
|
|
41
|
+
"file-history-snapshot",
|
|
42
|
+
"queue-operation",
|
|
43
|
+
],
|
|
44
|
+
"error_whitelist": [],
|
|
45
|
+
"error_blacklist": [
|
|
46
|
+
"api_error",
|
|
47
|
+
"rate limit",
|
|
48
|
+
"overloaded",
|
|
49
|
+
"traceback",
|
|
50
|
+
"panic",
|
|
51
|
+
],
|
|
52
|
+
"error_lists": {
|
|
53
|
+
"whitelist": [],
|
|
54
|
+
"blacklist": ["api_error", "rate limit", "overloaded", "traceback", "panic"],
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
"codex": {
|
|
58
|
+
"kind": "codex",
|
|
59
|
+
"reader_module": "team_agent.provider_state.codex",
|
|
60
|
+
"source": "infra",
|
|
61
|
+
"file_location": {
|
|
62
|
+
"root": "~/.codex/sessions",
|
|
63
|
+
"layout": "<YYYY>/<MM>/<DD>/rollout-<stamp>-<session_id>.jsonl",
|
|
64
|
+
"format": "rollout-jsonl",
|
|
65
|
+
},
|
|
66
|
+
"event_types": {
|
|
67
|
+
"turn_open": "event_msg payload.type == task_started",
|
|
68
|
+
"turn_complete": "event_msg payload.type == task_complete",
|
|
69
|
+
"interrupted": "event_msg payload.type == turn_aborted and reason == interrupted",
|
|
70
|
+
"failed": "app-server turn.status == failed",
|
|
71
|
+
"approval": "app-server method endswith requestApproval",
|
|
72
|
+
},
|
|
73
|
+
"metadata_ignore": [
|
|
74
|
+
"token_count",
|
|
75
|
+
"agent_message",
|
|
76
|
+
"context_compacted",
|
|
77
|
+
"mcp_tool_call_end",
|
|
78
|
+
"patch_apply_end",
|
|
79
|
+
"web_search_end",
|
|
80
|
+
"thread_goal_updated",
|
|
81
|
+
],
|
|
82
|
+
"error_whitelist": [],
|
|
83
|
+
"error_blacklist": [
|
|
84
|
+
"failed",
|
|
85
|
+
"api error",
|
|
86
|
+
"rate limit",
|
|
87
|
+
"overloaded",
|
|
88
|
+
"traceback",
|
|
89
|
+
"panic",
|
|
90
|
+
],
|
|
91
|
+
"error_lists": {
|
|
92
|
+
"whitelist": [],
|
|
93
|
+
"blacklist": ["failed", "api error", "rate limit", "overloaded", "traceback", "panic"],
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_provider_registry(provider: str | None = None) -> Any:
|
|
100
|
+
"""Return the infra registry.
|
|
101
|
+
|
|
102
|
+
With no argument, returns a copy of the whole per-CLI registry mapping.
|
|
103
|
+
With a provider name, returns that provider's entry (or ``None``).
|
|
104
|
+
"""
|
|
105
|
+
if provider is None:
|
|
106
|
+
return {name: _copy_entry(entry) for name, entry in _PROVIDER_REGISTRY.items()}
|
|
107
|
+
entry = _PROVIDER_REGISTRY.get(provider)
|
|
108
|
+
return _copy_entry(entry) if entry is not None else None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def supported_providers() -> list[str]:
|
|
112
|
+
return sorted(_PROVIDER_REGISTRY)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _copy_entry(entry: dict[str, Any]) -> dict[str, Any]:
|
|
116
|
+
import copy
|
|
117
|
+
|
|
118
|
+
return copy.deepcopy(entry)
|
|
@@ -8,18 +8,19 @@ from typing import Any
|
|
|
8
8
|
from team_agent.events import EventLog
|
|
9
9
|
from team_agent.message_store import MessageStore
|
|
10
10
|
from team_agent.permissions import resolve_permissions
|
|
11
|
+
from team_agent.display.backend import display_backend_has_worker_views, display_backend_opens_before_leader_rebind, resolve_restart_display_backend
|
|
12
|
+
from team_agent.display.close import close_team_display_backends
|
|
13
|
+
from team_agent.display.rebuild import rebuild_restart_display_after_rebind
|
|
11
14
|
from team_agent.restart.selection import select_restart_state
|
|
12
15
|
from team_agent.restart.snapshot import save_team_runtime_snapshot
|
|
13
16
|
from team_agent.spec import load_spec
|
|
14
17
|
from team_agent.state import (
|
|
15
18
|
check_team_owner,
|
|
16
|
-
load_runtime_state,
|
|
17
19
|
populate_team_owner_from_env,
|
|
18
20
|
save_runtime_state,
|
|
19
21
|
write_team_state,
|
|
20
22
|
)
|
|
21
23
|
|
|
22
|
-
|
|
23
24
|
def restart(workspace: Path, allow_fresh: bool = False, team: str | None = None) -> dict[str, Any]:
|
|
24
25
|
# Lazy-import everything from team_agent.runtime so existing tests that
|
|
25
26
|
# patch runtime.shell_resume_command_for_agent / runtime.run_cmd /
|
|
@@ -27,7 +28,6 @@ def restart(workspace: Path, allow_fresh: bool = False, team: str | None = None)
|
|
|
27
28
|
# at call time. Runtime re-exports the provider helpers, so this also
|
|
28
29
|
# routes through the providers module without binding it directly.
|
|
29
30
|
from team_agent.runtime import (
|
|
30
|
-
GHOSTTY_DISPLAY_BACKENDS,
|
|
31
31
|
ResumeUnavailable,
|
|
32
32
|
RuntimeError,
|
|
33
33
|
_attach_profile_resume_root,
|
|
@@ -35,7 +35,6 @@ def restart(workspace: Path, allow_fresh: bool = False, team: str | None = None)
|
|
|
35
35
|
_capture_agent_session,
|
|
36
36
|
_clear_session_capture_fields,
|
|
37
37
|
_close_ghostty_display,
|
|
38
|
-
_close_ghostty_workspace,
|
|
39
38
|
_compile_team_dir_spec,
|
|
40
39
|
_effective_runtime_config,
|
|
41
40
|
_ensure_agent_start_requirements,
|
|
@@ -83,7 +82,7 @@ def restart(workspace: Path, allow_fresh: bool = False, team: str | None = None)
|
|
|
83
82
|
)
|
|
84
83
|
raise RuntimeError(_tmux_session_conflict_error(session_name))
|
|
85
84
|
runtime_cfg = _effective_runtime_config(spec.get("runtime", {}))
|
|
86
|
-
display_backend = spec
|
|
85
|
+
display_backend = resolve_restart_display_backend(spec, state, event_log)
|
|
87
86
|
# Stage 7 S5 — Slice 6 lifecycle atomicity contract: compute restart_agents
|
|
88
87
|
# early so we can pre-validate resumability BEFORE any destructive teardown
|
|
89
88
|
# (ghostty close, tmux session creation). Without --allow-fresh, every
|
|
@@ -146,7 +145,7 @@ def restart(workspace: Path, allow_fresh: bool = False, team: str | None = None)
|
|
|
146
145
|
"allow_fresh": bool(allow_fresh),
|
|
147
146
|
"error": _format_atomic_refusal_error(refused),
|
|
148
147
|
}
|
|
149
|
-
|
|
148
|
+
close_team_display_backends(state, event_log)
|
|
150
149
|
for agent_id, agent_state in state.get("agents", {}).items():
|
|
151
150
|
_close_ghostty_display(agent_id, agent_state, event_log)
|
|
152
151
|
state["display_backend"] = display_backend
|
|
@@ -330,7 +329,7 @@ def restart(workspace: Path, allow_fresh: bool = False, team: str | None = None)
|
|
|
330
329
|
exclude_session_ids=known_session_ids,
|
|
331
330
|
raise_on_missed=False,
|
|
332
331
|
)
|
|
333
|
-
if display_backend
|
|
332
|
+
if display_backend_has_worker_views(display_backend):
|
|
334
333
|
display_jobs.append((agent["id"], agent))
|
|
335
334
|
new_agents[agent["id"]] = agent_state
|
|
336
335
|
restarted.append(
|
|
@@ -341,7 +340,7 @@ def restart(workspace: Path, allow_fresh: bool = False, team: str | None = None)
|
|
|
341
340
|
"display_target": None,
|
|
342
341
|
}
|
|
343
342
|
)
|
|
344
|
-
display_results = _open_worker_displays(workspace, session_name, display_jobs, event_log, display_backend)
|
|
343
|
+
display_results = _open_worker_displays(workspace, session_name, display_jobs, event_log, display_backend) if display_backend_opens_before_leader_rebind(display_backend) else {}
|
|
345
344
|
for agent_id, display in display_results.items():
|
|
346
345
|
if agent_id in new_agents:
|
|
347
346
|
new_agents[agent_id]["display"] = display
|
|
@@ -367,7 +366,8 @@ def restart(workspace: Path, allow_fresh: bool = False, team: str | None = None)
|
|
|
367
366
|
write_team_state(workspace, spec, state)
|
|
368
367
|
from team_agent.leader import autobind_leader_receiver_from_env
|
|
369
368
|
leader_provider = str(spec.get("leader", {}).get("provider") or "codex")
|
|
370
|
-
autobind_leader_receiver_from_env(workspace, leader_provider, source="restart")
|
|
369
|
+
rebound_receiver = autobind_leader_receiver_from_env(workspace, leader_provider, source="restart")
|
|
370
|
+
rebuild_restart_display_after_rebind(display_backend, workspace, session_name, spec, event_log, restarted, receiver=rebound_receiver)
|
|
371
371
|
coordinator = start_coordinator(workspace)
|
|
372
372
|
event_log.write("restart.complete", session=session_name, agents=restarted, coordinator=coordinator)
|
|
373
373
|
return {"ok": True, "session_name": session_name, "agents": restarted, "coordinator": coordinator}
|
|
@@ -39,10 +39,12 @@ from team_agent.providers import (
|
|
|
39
39
|
shell_resume_command_for_agent,
|
|
40
40
|
)
|
|
41
41
|
from team_agent.display import (
|
|
42
|
+
GHOSTTY_DISPLAY_BACKENDS,
|
|
42
43
|
GHOSTTY_WORKSPACE_PANES_PER_WINDOW,
|
|
43
44
|
close_ghostty_display as _close_ghostty_display,
|
|
44
45
|
close_ghostty_workspace as _close_ghostty_workspace,
|
|
45
46
|
close_ghostty_workspace_slot as _close_ghostty_workspace_slot,
|
|
47
|
+
close_team_display_backends as _close_team_display_backends,
|
|
46
48
|
ghostty_app_exists as _ghostty_app_exists,
|
|
47
49
|
ghostty_attach_args as _ghostty_attach_args,
|
|
48
50
|
ghostty_command as _ghostty_command,
|
|
@@ -65,6 +67,7 @@ from team_agent.display import (
|
|
|
65
67
|
set_ghostty_workspace_pane_title as _set_ghostty_workspace_pane_title,
|
|
66
68
|
)
|
|
67
69
|
from team_agent.leader import (
|
|
70
|
+
LEADER_OWNERSHIP_LOCK,
|
|
68
71
|
attach_leader,
|
|
69
72
|
attach_leader_to_state as _attach_leader_to_state,
|
|
70
73
|
claim_leader,
|
|
@@ -456,7 +459,6 @@ TMUX_PANE_FORMAT = (
|
|
|
456
459
|
"#{pane_current_path}\t#{session_attached}\t#{pane_in_mode}"
|
|
457
460
|
)
|
|
458
461
|
HEALTH_STATUSES = {"RUNNING", "IDLE", "AWAITING_APPROVAL", "BLOCKED", "ERROR", "DONE"}
|
|
459
|
-
GHOSTTY_DISPLAY_BACKENDS = {"ghostty", "ghostty_window", "ghostty_workspace"}
|
|
460
462
|
DELIVERY_CAPTURE_LINES = 40
|
|
461
463
|
SUBMITTED_DELIVERY_STATUSES = {"injected", "visible", "submitted", "submitted_unverified", "delivered", "acknowledged"}
|
|
462
464
|
TMUX_STDIN_BUFFER_THRESHOLD = 16 * 1024
|
|
@@ -480,7 +482,6 @@ def ensure_workspace_dirs(workspace: Path) -> None:
|
|
|
480
482
|
path.mkdir(parents=True, exist_ok=True)
|
|
481
483
|
|
|
482
484
|
|
|
483
|
-
|
|
484
485
|
def shutdown(workspace: Path, keep_logs: bool = True, team: str | None = None) -> dict[str, Any]:
|
|
485
486
|
from team_agent.state import resolve_team_scoped_state
|
|
486
487
|
state, refusal = resolve_team_scoped_state(workspace, team)
|
|
@@ -521,7 +522,7 @@ def shutdown(workspace: Path, keep_logs: bool = True, team: str | None = None) -
|
|
|
521
522
|
if proc.returncode == 0:
|
|
522
523
|
log_path.write_text(proc.stdout, encoding="utf-8")
|
|
523
524
|
captured.append(str(log_path))
|
|
524
|
-
|
|
525
|
+
_close_team_display_backends(state, event_log)
|
|
525
526
|
for agent_id, agent_state in state.get("agents", {}).items():
|
|
526
527
|
_close_ghostty_display(agent_id, agent_state, event_log)
|
|
527
528
|
closed_displays.add(agent_id)
|
|
@@ -535,7 +536,7 @@ def shutdown(workspace: Path, keep_logs: bool = True, team: str | None = None) -
|
|
|
535
536
|
event_log.write("shutdown.kill_session", session=session_name, keep_logs=keep_logs, captured=captured)
|
|
536
537
|
else:
|
|
537
538
|
event_log.write("shutdown.idempotent", session=session_name, reason="session missing")
|
|
538
|
-
|
|
539
|
+
_close_team_display_backends(state, event_log)
|
|
539
540
|
for agent_id, agent_state in state.get("agents", {}).items():
|
|
540
541
|
if agent_id not in closed_displays:
|
|
541
542
|
_close_ghostty_display(agent_id, agent_state, event_log)
|
|
@@ -617,7 +618,7 @@ def takeover(workspace: Path, team: str | None = None, confirm: bool = False) ->
|
|
|
617
618
|
"reason": "no_caller_identity",
|
|
618
619
|
"action": "set TEAM_AGENT_LEADER_PANE_ID/PROVIDER/MACHINE_FINGERPRINT or run from a tmux pane",
|
|
619
620
|
}
|
|
620
|
-
with _runtime_lock(workspace,
|
|
621
|
+
with _runtime_lock(workspace, LEADER_OWNERSHIP_LOCK):
|
|
621
622
|
try:
|
|
622
623
|
team_state = select_runtime_state(workspace, team)
|
|
623
624
|
except RuntimeError as exc:
|
|
@@ -628,23 +629,72 @@ def takeover(workspace: Path, team: str | None = None, confirm: bool = False) ->
|
|
|
628
629
|
"team": team,
|
|
629
630
|
"error": str(exc),
|
|
630
631
|
}
|
|
631
|
-
previous_owner = team_state.get("team_owner")
|
|
632
|
+
previous_owner = team_state.get("team_owner") if isinstance(team_state.get("team_owner"), dict) else {}
|
|
633
|
+
previous_receiver = team_state.get("leader_receiver") if isinstance(team_state.get("leader_receiver"), dict) else {}
|
|
634
|
+
from team_agent.leader import _lease_epoch, _receiver_from_claim_target
|
|
635
|
+
next_epoch = _lease_epoch(previous_owner, previous_receiver) + 1
|
|
636
|
+
leader_uuid = str(previous_owner.get("leader_session_uuid") or "")
|
|
632
637
|
new_owner = {
|
|
633
638
|
"pane_id": pane_id,
|
|
634
639
|
"provider": os.environ.get("TEAM_AGENT_LEADER_PROVIDER", ""),
|
|
635
640
|
"machine_fingerprint": os.environ.get("TEAM_AGENT_MACHINE_FINGERPRINT", ""),
|
|
641
|
+
"owner_epoch": next_epoch,
|
|
636
642
|
"claimed_at": datetime.now(timezone.utc).isoformat(),
|
|
637
643
|
"claimed_via": "takeover",
|
|
638
644
|
}
|
|
645
|
+
if leader_uuid:
|
|
646
|
+
new_owner["leader_session_uuid"] = leader_uuid
|
|
639
647
|
team_state["team_owner"] = new_owner
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
648
|
+
# C11/C17: takeover converges on the same lease mutation as claim-leader.
|
|
649
|
+
# Rebind the leader receiver to the caller pane and write owner + receiver
|
|
650
|
+
# to both state locations together, so takeover never leaves the receiver
|
|
651
|
+
# pointing at the old (often dead) pane.
|
|
652
|
+
targets_result = core_list_targets()
|
|
653
|
+
targets = targets_result.get("targets", []) if isinstance(targets_result, dict) and targets_result.get("ok") else []
|
|
654
|
+
caller_target = next((item for item in targets if isinstance(item, dict) and str(item.get("pane_id")) == str(pane_id)), None)
|
|
655
|
+
new_receiver = None
|
|
656
|
+
if caller_target:
|
|
657
|
+
new_receiver = _receiver_from_claim_target(
|
|
658
|
+
caller_target,
|
|
659
|
+
previous_receiver,
|
|
660
|
+
leader_uuid or None,
|
|
661
|
+
next_epoch,
|
|
662
|
+
)
|
|
663
|
+
new_receiver["discovery"] = "takeover"
|
|
664
|
+
team_state["leader_receiver"] = new_receiver
|
|
665
|
+
from team_agent.leader import _write_lease_dual_state
|
|
666
|
+
_write_lease_dual_state(workspace, team_state)
|
|
667
|
+
# C11: takeover converges on the same lease audit events as claim-leader
|
|
668
|
+
# instead of a divergent legacy team_owner.takeover record.
|
|
669
|
+
event_log = EventLog(workspace)
|
|
670
|
+
uuid_prefix = leader_uuid[:8]
|
|
671
|
+
old_pane_id = previous_receiver.get("pane_id") or (previous_owner or {}).get("pane_id")
|
|
672
|
+
if new_receiver is not None:
|
|
673
|
+
event_log.write(
|
|
674
|
+
"leader_receiver.rebind_applied",
|
|
675
|
+
reason="takeover_confirmed",
|
|
676
|
+
old_pane_id=old_pane_id,
|
|
677
|
+
new_pane_id=pane_id,
|
|
678
|
+
owner_epoch=next_epoch,
|
|
679
|
+
uuid_prefix=uuid_prefix,
|
|
680
|
+
team_id=team,
|
|
681
|
+
)
|
|
682
|
+
event_log.write(
|
|
683
|
+
"owner_epoch_advanced",
|
|
684
|
+
reason="takeover_confirmed",
|
|
685
|
+
old_pane_id=old_pane_id,
|
|
686
|
+
new_pane_id=pane_id,
|
|
687
|
+
owner_epoch=next_epoch,
|
|
688
|
+
uuid_prefix=uuid_prefix,
|
|
689
|
+
team_id=team,
|
|
690
|
+
previous_owner=previous_owner or None,
|
|
645
691
|
new_owner=new_owner,
|
|
692
|
+
receiver_rebound=bool(new_receiver),
|
|
646
693
|
)
|
|
647
|
-
|
|
694
|
+
response = {"ok": True, "status": "claimed", "team": team, "team_owner": new_owner, "previous_owner": previous_owner or None, "owner_epoch": next_epoch}
|
|
695
|
+
if new_receiver is not None:
|
|
696
|
+
response["leader_receiver"] = new_receiver
|
|
697
|
+
return response
|
|
648
698
|
|
|
649
699
|
|
|
650
700
|
def _running_agent_state(workspace: Path, agent: dict[str, Any], previous: dict[str, Any]) -> dict[str, Any]:
|
package/src/team_agent/spec.py
CHANGED
|
@@ -4,6 +4,7 @@ from pathlib import Path
|
|
|
4
4
|
from typing import Any
|
|
5
5
|
|
|
6
6
|
from team_agent.errors import ValidationError
|
|
7
|
+
from team_agent.display.backend import VALID_DISPLAY_BACKENDS
|
|
7
8
|
from team_agent.permissions import CANONICAL_TOOLS, expand_tools
|
|
8
9
|
from team_agent.profiles import AUTH_MODES
|
|
9
10
|
from team_agent.simple_yaml import loads
|
|
@@ -233,8 +234,8 @@ def _check_communication(comm: Any, errors: list[str]) -> None:
|
|
|
233
234
|
|
|
234
235
|
|
|
235
236
|
def _check_runtime(runtime: Any, errors: list[str]) -> None:
|
|
236
|
-
required = {"backend", "
|
|
237
|
-
allowed = required | {
|
|
237
|
+
required = {"backend", "session_name", "auto_launch", "require_user_approval_before_launch", "max_active_agents", "startup_order"}
|
|
238
|
+
allowed = required | {"display_backend"} | {
|
|
238
239
|
"dangerous_auto_approve",
|
|
239
240
|
"auto_attach_leader",
|
|
240
241
|
"fast",
|
|
@@ -253,7 +254,7 @@ def _check_runtime(runtime: Any, errors: list[str]) -> None:
|
|
|
253
254
|
return
|
|
254
255
|
if runtime.get("backend") not in {"tmux", "pty"}:
|
|
255
256
|
errors.append("/runtime/backend: invalid backend")
|
|
256
|
-
if runtime.get("display_backend") not in
|
|
257
|
+
if "display_backend" in runtime and runtime.get("display_backend") not in VALID_DISPLAY_BACKENDS:
|
|
257
258
|
errors.append("/runtime/display_backend: invalid display backend")
|
|
258
259
|
if "dangerous_auto_approve" in runtime and not isinstance(runtime["dangerous_auto_approve"], bool):
|
|
259
260
|
errors.append("/runtime/dangerous_auto_approve: must be a boolean")
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Provider-neutral wake layer (Gap 32 §2): decide WHEN to re-read a session
|
|
2
|
+
file, never HOW to parse it. No polling loop, no screen, no provider names.
|
|
3
|
+
|
|
4
|
+
Two cheap, passive sources:
|
|
5
|
+
- a file-change watch (the caller wires FSEvents/inotify/kqueue and calls
|
|
6
|
+
``on_file_changed``) — naturally per-session (one file per node);
|
|
7
|
+
- an mtime gate — only re-read the tail when the file changed since the last
|
|
8
|
+
classification, or when it has been quiet past the debounce window.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def should_reread(
|
|
17
|
+
*,
|
|
18
|
+
last_mtime: float | None,
|
|
19
|
+
current_mtime: float | None,
|
|
20
|
+
last_classified_mtime: float | None,
|
|
21
|
+
now: float,
|
|
22
|
+
debounce_seconds: float,
|
|
23
|
+
) -> dict[str, Any]:
|
|
24
|
+
"""Return whether a fresh tail-read is warranted and why.
|
|
25
|
+
|
|
26
|
+
A read is warranted when the file changed since we last classified it, or
|
|
27
|
+
when it has been silent longer than the debounce window and we have not yet
|
|
28
|
+
classified at this mtime (so an idle-close that we already saw is not
|
|
29
|
+
re-read forever).
|
|
30
|
+
"""
|
|
31
|
+
if current_mtime is None:
|
|
32
|
+
return {"reread": False, "reason": "no_file"}
|
|
33
|
+
if last_classified_mtime is None:
|
|
34
|
+
return {"reread": True, "reason": "never_classified"}
|
|
35
|
+
if current_mtime != last_classified_mtime:
|
|
36
|
+
return {"reread": True, "reason": "file_changed"}
|
|
37
|
+
silent_for = max(0.0, now - current_mtime)
|
|
38
|
+
if silent_for >= debounce_seconds:
|
|
39
|
+
return {"reread": False, "reason": "quiescent_already_classified"}
|
|
40
|
+
return {"reread": False, "reason": "unchanged"}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def on_file_changed(watch_state: dict[str, Any] | None, *, node_id: str, mtime: float) -> dict[str, Any]:
|
|
44
|
+
"""Record a file-change wake for a node (the push path)."""
|
|
45
|
+
state = dict(watch_state or {})
|
|
46
|
+
pending = set(state.get("pending") or [])
|
|
47
|
+
pending.add(node_id)
|
|
48
|
+
state["pending"] = sorted(pending)
|
|
49
|
+
state.setdefault("mtimes", {})[node_id] = mtime
|
|
50
|
+
return state
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def take_pending(watch_state: dict[str, Any] | None) -> tuple[list[str], dict[str, Any]]:
|
|
54
|
+
"""Drain the set of nodes whose files changed since the last drain."""
|
|
55
|
+
state = dict(watch_state or {})
|
|
56
|
+
pending = sorted(state.get("pending") or [])
|
|
57
|
+
state["pending"] = []
|
|
58
|
+
return pending, state
|