@team-agent/installer 0.2.4 → 0.2.6
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/README.md +22 -0
- package/npm/bincheck.mjs +70 -0
- package/package.json +2 -1
- package/skills/team-agent/references/bug-as-artifact-flow.md +82 -0
- package/src/team_agent/_legacy_pane_discovery.py +189 -0
- package/src/team_agent/cli/commands.py +17 -1
- package/src/team_agent/cli/helpers.py +89 -0
- package/src/team_agent/cli/parser.py +7 -2
- package/src/team_agent/diagnose/quick_start.py +1 -1
- package/src/team_agent/leader_binding.py +205 -0
- package/src/team_agent/mcp_server/tools.py +211 -64
- package/src/team_agent/message_store/agent_health.py +6 -2
- package/src/team_agent/message_store/core.py +22 -15
- package/src/team_agent/message_store/leader_notification_log.py +16 -12
- package/src/team_agent/message_store/result_watchers.py +17 -11
- package/src/team_agent/message_store/schema.py +20 -10
- package/src/team_agent/message_store/schema_migration.py +446 -0
- package/src/team_agent/messaging/deps.py +1 -17
- package/src/team_agent/messaging/leader.py +2 -3
- package/src/team_agent/messaging/leader_panes.py +43 -166
- package/src/team_agent/messaging/scheduler.py +1 -1
- package/src/team_agent/provider_cli/adapter.py +10 -5
- package/src/team_agent/provider_cli/codex.py +26 -9
- package/src/team_agent/runtime.py +246 -79
- package/src/team_agent/state.py +105 -30
|
@@ -70,7 +70,7 @@ from team_agent.leader import (
|
|
|
70
70
|
LEADER_OWNERSHIP_LOCK,
|
|
71
71
|
attach_leader,
|
|
72
72
|
attach_leader_to_state as _attach_leader_to_state,
|
|
73
|
-
claim_leader,
|
|
73
|
+
claim_leader as _legacy_claim_leader,
|
|
74
74
|
leader_identity,
|
|
75
75
|
leader_session_name as _leader_session_name,
|
|
76
76
|
leader_start_plan,
|
|
@@ -137,7 +137,7 @@ from team_agent.diagnose import (
|
|
|
137
137
|
preflight_next_actions as _preflight_next_actions,
|
|
138
138
|
profile_checks_for_agents as _profile_checks_for_agents,
|
|
139
139
|
profile_smoke_checks_for_agents as _profile_smoke_checks_for_agents,
|
|
140
|
-
quick_start,
|
|
140
|
+
quick_start as _legacy_quick_start,
|
|
141
141
|
repair_state,
|
|
142
142
|
settle,
|
|
143
143
|
start,
|
|
@@ -491,6 +491,11 @@ def shutdown(workspace: Path, keep_logs: bool = True, team: str | None = None) -
|
|
|
491
491
|
if gate:
|
|
492
492
|
return gate
|
|
493
493
|
session_name = state.get("session_name")
|
|
494
|
+
resolved_team_id = (
|
|
495
|
+
team
|
|
496
|
+
or state.get("active_team_key")
|
|
497
|
+
or (team_state_key(state) if state.get("session_name") else None)
|
|
498
|
+
)
|
|
494
499
|
event_log = EventLog(workspace)
|
|
495
500
|
captured: list[str] = []
|
|
496
501
|
closed_displays: set[str] = set()
|
|
@@ -561,7 +566,72 @@ def shutdown(workspace: Path, keep_logs: bool = True, team: str | None = None) -
|
|
|
561
566
|
agent_state["status"] = "stopped"
|
|
562
567
|
save_team_scoped_state(workspace, state)
|
|
563
568
|
_save_team_runtime_snapshot(workspace, state)
|
|
564
|
-
|
|
569
|
+
# 0.2.6 Family B (C10/C11/C12): atomically unregister the team and
|
|
570
|
+
# archive its runtime snapshot directory. Both branches of --keep-logs
|
|
571
|
+
# still drop the team from state.teams; logs survive inside the
|
|
572
|
+
# archived directory.
|
|
573
|
+
archive_path, teams_remaining, new_active = _commit_shutdown_cleanup(
|
|
574
|
+
workspace, str(resolved_team_id or ""), session_name, event_log
|
|
575
|
+
)
|
|
576
|
+
return {
|
|
577
|
+
"ok": True,
|
|
578
|
+
"session_name": session_name,
|
|
579
|
+
"team": resolved_team_id,
|
|
580
|
+
"logs": captured,
|
|
581
|
+
"coordinator": coordinator,
|
|
582
|
+
"archive_path": archive_path,
|
|
583
|
+
"teams_remaining": teams_remaining,
|
|
584
|
+
"new_active_team_key": new_active,
|
|
585
|
+
"cleanup_mode": "synchronous_committed",
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _commit_shutdown_cleanup(
|
|
590
|
+
workspace: Path,
|
|
591
|
+
team_key: str,
|
|
592
|
+
session_name: str | None,
|
|
593
|
+
event_log: EventLog,
|
|
594
|
+
) -> tuple[str | None, list[str], str | None]:
|
|
595
|
+
import shutil as _shutil
|
|
596
|
+
from datetime import datetime as _dt, timezone as _tz
|
|
597
|
+
workspace_state = load_runtime_state(workspace)
|
|
598
|
+
teams = workspace_state.get("teams") if isinstance(workspace_state.get("teams"), dict) else {}
|
|
599
|
+
if team_key and team_key in teams:
|
|
600
|
+
teams.pop(team_key, None)
|
|
601
|
+
if workspace_state.get("active_team_key") == team_key:
|
|
602
|
+
workspace_state["active_team_key"] = None
|
|
603
|
+
workspace_state["teams"] = teams
|
|
604
|
+
archive_dest: Path | None = None
|
|
605
|
+
if session_name:
|
|
606
|
+
runtime_teams_dir = runtime_dir(workspace) / "teams"
|
|
607
|
+
from team_agent.restart.snapshot import safe_snapshot_name as _safe
|
|
608
|
+
snapshot_name = _safe(str(session_name))
|
|
609
|
+
snapshot_dir = runtime_teams_dir / snapshot_name
|
|
610
|
+
if snapshot_dir.exists():
|
|
611
|
+
ts = _dt.now(_tz.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
612
|
+
archive_dest = runtime_teams_dir / f".archived-{snapshot_name}-{ts}"
|
|
613
|
+
try:
|
|
614
|
+
_shutil.move(str(snapshot_dir), str(archive_dest))
|
|
615
|
+
except OSError as exc:
|
|
616
|
+
event_log.write(
|
|
617
|
+
"team.shutdown_blocked",
|
|
618
|
+
reason="archive_move_failed",
|
|
619
|
+
team_key=team_key,
|
|
620
|
+
error=str(exc),
|
|
621
|
+
hint="check filesystem permissions on .team/runtime/teams and rerun shutdown",
|
|
622
|
+
)
|
|
623
|
+
return None, sorted(teams.keys()), workspace_state.get("active_team_key")
|
|
624
|
+
save_runtime_state(workspace, workspace_state)
|
|
625
|
+
archive_path_str = str(archive_dest) if archive_dest is not None else None
|
|
626
|
+
new_active = workspace_state.get("active_team_key")
|
|
627
|
+
event_log.write(
|
|
628
|
+
"team.shutdown_completed",
|
|
629
|
+
team_key=team_key,
|
|
630
|
+
archive_path=archive_path_str,
|
|
631
|
+
teams_remaining=sorted(teams.keys()),
|
|
632
|
+
new_active_team_key=new_active,
|
|
633
|
+
)
|
|
634
|
+
return archive_path_str, sorted(teams.keys()), new_active
|
|
565
635
|
|
|
566
636
|
|
|
567
637
|
|
|
@@ -602,7 +672,44 @@ def acknowledge_idle(workspace: Path, agent_id: str | None = None, *, team: str
|
|
|
602
672
|
EventLog(workspace).write("coordinator.idle_acknowledged", agent_id=agent_id, team=owner_team_id, acknowledged_at=now, expires_at=expires_at, ttl_seconds=ttl_seconds)
|
|
603
673
|
return {"ok": True, "team": owner_team_id, "agent_id": agent_id, "acknowledged_at": now, "expires_at": expires_at, "ttl_seconds": ttl_seconds}
|
|
604
674
|
|
|
675
|
+
_OWNER_IDENTITY_FIELDS = (
|
|
676
|
+
"pane_id",
|
|
677
|
+
"leader_session_uuid",
|
|
678
|
+
"machine_fingerprint",
|
|
679
|
+
"provider",
|
|
680
|
+
"os_user",
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _owner_identity_matches(existing: dict[str, Any], candidate: dict[str, Any]) -> bool:
|
|
685
|
+
for field in _OWNER_IDENTITY_FIELDS:
|
|
686
|
+
if str(existing.get(field) or "") != str(candidate.get(field) or ""):
|
|
687
|
+
return False
|
|
688
|
+
return True
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _resolve_owner_team_id(state: dict[str, Any], team: str | None) -> str | None:
|
|
692
|
+
if team:
|
|
693
|
+
return str(team)
|
|
694
|
+
active = state.get("active_team_key")
|
|
695
|
+
if active:
|
|
696
|
+
return str(active)
|
|
697
|
+
teams = state.get("teams") or {}
|
|
698
|
+
if isinstance(teams, dict) and len(teams) == 1:
|
|
699
|
+
return next(iter(teams))
|
|
700
|
+
return None
|
|
701
|
+
|
|
702
|
+
|
|
605
703
|
def takeover(workspace: Path, team: str | None = None, confirm: bool = False) -> dict[str, Any]:
|
|
704
|
+
"""0.2.6 Family A: positive-source ownership rebind.
|
|
705
|
+
|
|
706
|
+
Identity is sourced exclusively from ``bind_owner_from_caller_pane``
|
|
707
|
+
(``$TMUX_PANE`` + one targeted ``tmux display-message``). The new
|
|
708
|
+
owner record force-writes every identity field into
|
|
709
|
+
``state.teams[<team_id>].team_owner``; old fields are not merged,
|
|
710
|
+
migrated, or setdefaulted. Idempotent: re-running with the same
|
|
711
|
+
caller identity returns success without mutating state.
|
|
712
|
+
"""
|
|
606
713
|
if not confirm:
|
|
607
714
|
return {
|
|
608
715
|
"ok": False,
|
|
@@ -610,91 +717,151 @@ def takeover(workspace: Path, team: str | None = None, confirm: bool = False) ->
|
|
|
610
717
|
"reason": "confirm_required",
|
|
611
718
|
"action": "rerun with --confirm to claim ownership of this team",
|
|
612
719
|
}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
"status": "refused",
|
|
618
|
-
"reason": "no_caller_identity",
|
|
619
|
-
"action": "set TEAM_AGENT_LEADER_PANE_ID/PROVIDER/MACHINE_FINGERPRINT or run from a tmux pane",
|
|
620
|
-
}
|
|
720
|
+
from team_agent.leader_binding import (
|
|
721
|
+
bind_owner_from_caller_pane,
|
|
722
|
+
emit_owner_bound_event,
|
|
723
|
+
)
|
|
621
724
|
with _runtime_lock(workspace, LEADER_OWNERSHIP_LOCK):
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
725
|
+
state = load_runtime_state(workspace)
|
|
726
|
+
team_id = _resolve_owner_team_id(state, team)
|
|
727
|
+
if not team_id:
|
|
625
728
|
return {
|
|
626
729
|
"ok": False,
|
|
627
730
|
"status": "refused",
|
|
628
731
|
"reason": "team_target_unresolved",
|
|
629
732
|
"team": team,
|
|
630
|
-
"
|
|
733
|
+
"hint": "pass --team <name> or run quick-start first to register an active team.",
|
|
734
|
+
}
|
|
735
|
+
bind = bind_owner_from_caller_pane(workspace, team_id)
|
|
736
|
+
if not bind.get("ok"):
|
|
737
|
+
return {"ok": False, "status": "refused", **bind}
|
|
738
|
+
new_owner = bind["owner"]
|
|
739
|
+
teams = state.setdefault("teams", {})
|
|
740
|
+
team_entry = teams.get(team_id) or {}
|
|
741
|
+
existing_owner = team_entry.get("team_owner") if isinstance(team_entry.get("team_owner"), dict) else {}
|
|
742
|
+
if existing_owner and _owner_identity_matches(existing_owner, new_owner):
|
|
743
|
+
return {
|
|
744
|
+
"ok": True,
|
|
745
|
+
"status": "claimed",
|
|
746
|
+
"team": team_id,
|
|
747
|
+
"team_owner": existing_owner,
|
|
748
|
+
"idempotent": True,
|
|
631
749
|
}
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
"
|
|
639
|
-
"
|
|
640
|
-
|
|
641
|
-
"
|
|
642
|
-
|
|
643
|
-
|
|
750
|
+
team_entry["team_owner"] = new_owner
|
|
751
|
+
teams[team_id] = team_entry
|
|
752
|
+
save_runtime_state(workspace, state)
|
|
753
|
+
emit_owner_bound_event(
|
|
754
|
+
workspace,
|
|
755
|
+
caller_pane_id=bind.get("caller_pane_id", ""),
|
|
756
|
+
caller_current_command=bind.get("caller_current_command", ""),
|
|
757
|
+
derived_leader_session_uuid=new_owner["leader_session_uuid"],
|
|
758
|
+
team_id=team_id,
|
|
759
|
+
old_leader_session_uuid=str(existing_owner.get("leader_session_uuid") or ""),
|
|
760
|
+
)
|
|
761
|
+
return {
|
|
762
|
+
"ok": True,
|
|
763
|
+
"status": "claimed",
|
|
764
|
+
"team": team_id,
|
|
765
|
+
"team_owner": new_owner,
|
|
766
|
+
"previous_owner": existing_owner or None,
|
|
644
767
|
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def claim_leader(workspace: Path, team: str | None = None, confirm: bool = False) -> dict[str, Any]:
|
|
771
|
+
"""0.2.6 Family A: positive-source claim-leader.
|
|
772
|
+
|
|
773
|
+
Calls :func:`bind_owner_from_caller_pane` to confirm the caller is in
|
|
774
|
+
a leader-shaped tmux pane, then delegates to the legacy multi-
|
|
775
|
+
candidate lease arbiter for residual handling. The bind step is the
|
|
776
|
+
only source of caller identity; the legacy lease path no longer
|
|
777
|
+
re-derives it.
|
|
778
|
+
"""
|
|
779
|
+
from team_agent.leader_binding import bind_owner_from_caller_pane
|
|
780
|
+
state = load_runtime_state(workspace)
|
|
781
|
+
team_id = _resolve_owner_team_id(state, team) or team_state_key(state)
|
|
782
|
+
bind = bind_owner_from_caller_pane(workspace, team_id)
|
|
783
|
+
if not bind.get("ok"):
|
|
784
|
+
return {"ok": False, "status": "refused", **bind}
|
|
785
|
+
return _legacy_claim_leader(workspace, team=team, confirm=confirm)
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def quick_start(
|
|
789
|
+
agents_dir: Path,
|
|
790
|
+
name: str | None = None,
|
|
791
|
+
yes: bool = False,
|
|
792
|
+
fresh: bool = False,
|
|
793
|
+
team_id: str | None = None,
|
|
794
|
+
) -> dict[str, Any]:
|
|
795
|
+
"""0.2.6 Family A: positive-source quick-start.
|
|
796
|
+
|
|
797
|
+
The caller-pane shape gate is owned by
|
|
798
|
+
:func:`bind_owner_from_caller_pane`. Quick-start binds the caller
|
|
799
|
+
pane BEFORE any team setup runs; ``$TMUX_PANE`` missing or the
|
|
800
|
+
caller pane not running a leader host short-circuits to a refusal
|
|
801
|
+
(no fallback to legacy reverse-scan). On success, the legacy
|
|
802
|
+
bootstrap brings up the workspace and the bind-derived
|
|
803
|
+
``team_owner`` is force-written into
|
|
804
|
+
``state.teams[team_id].team_owner`` so the runtime owner identity
|
|
805
|
+
matches the caller pane verbatim.
|
|
806
|
+
"""
|
|
807
|
+
from team_agent.leader_binding import (
|
|
808
|
+
bind_owner_from_caller_pane,
|
|
809
|
+
emit_owner_bound_event,
|
|
810
|
+
)
|
|
811
|
+
from team_agent.diagnose.quick_start import prepare_quick_start_team
|
|
812
|
+
|
|
813
|
+
# Pre-resolve team_dir + workspace so the caller-pane bind can write
|
|
814
|
+
# its audit event before any worker is spawned. ``prepare_quick_start_team``
|
|
815
|
+
# is idempotent (mkdir + shutil.copy2 of role docs) and used inside
|
|
816
|
+
# ``_legacy_quick_start`` as the very first step anyway.
|
|
817
|
+
team_dir = prepare_quick_start_team(
|
|
818
|
+
Path(agents_dir).resolve(), Path.cwd().resolve(), name, team_id=team_id
|
|
819
|
+
)
|
|
820
|
+
workspace = team_workspace(team_dir)
|
|
821
|
+
ensure_workspace_dirs(workspace)
|
|
822
|
+
# Spark MED 1 (b1b17b1 review): the on-disk team_dir already passed
|
|
823
|
+
# through ``_safe_snapshot_name``; reusing ``team_dir.name`` here
|
|
824
|
+
# keeps the on-disk path and the state.teams key aligned. Using the
|
|
825
|
+
# raw ``team_id`` would have split the two writes whenever the
|
|
826
|
+
# caller-supplied id contained spaces or shell-unsafe characters.
|
|
827
|
+
resolved_team_id = team_dir.name or "current"
|
|
828
|
+
bind = bind_owner_from_caller_pane(workspace, resolved_team_id)
|
|
829
|
+
if not bind.get("ok"):
|
|
830
|
+
return {"ok": False, "status": "refused", **bind}
|
|
831
|
+
new_owner = bind["owner"]
|
|
832
|
+
result = _legacy_quick_start(
|
|
833
|
+
Path(agents_dir).resolve(), name=name, yes=yes, fresh=fresh, team_id=team_id
|
|
834
|
+
)
|
|
835
|
+
# Spark MED 2 (b1b17b1 review): only commit the owner force-write
|
|
836
|
+
# and emit ``owner.bound_from_caller_pane`` when the legacy bootstrap
|
|
837
|
+
# actually succeeded. Otherwise pass the refusal envelope back
|
|
838
|
+
# verbatim — ``existing_runtime_state`` / ``preflight`` failures
|
|
839
|
+
# must not leave a "team owner claimed" side effect behind.
|
|
840
|
+
if not result.get("ok"):
|
|
841
|
+
return result
|
|
842
|
+
state = load_runtime_state(workspace)
|
|
843
|
+
teams = state.setdefault("teams", {})
|
|
844
|
+
team_entry = teams.get(resolved_team_id) or {}
|
|
845
|
+
existing_owner = (
|
|
846
|
+
team_entry.get("team_owner")
|
|
847
|
+
if isinstance(team_entry.get("team_owner"), dict)
|
|
848
|
+
else {}
|
|
849
|
+
)
|
|
850
|
+
if not (existing_owner and _owner_identity_matches(existing_owner, new_owner)):
|
|
851
|
+
team_entry["team_owner"] = new_owner
|
|
852
|
+
teams[resolved_team_id] = team_entry
|
|
853
|
+
if not state.get("active_team_key"):
|
|
854
|
+
state["active_team_key"] = resolved_team_id
|
|
855
|
+
save_runtime_state(workspace, state)
|
|
856
|
+
emit_owner_bound_event(
|
|
857
|
+
workspace,
|
|
858
|
+
caller_pane_id=bind.get("caller_pane_id", ""),
|
|
859
|
+
caller_current_command=bind.get("caller_current_command", ""),
|
|
860
|
+
derived_leader_session_uuid=new_owner["leader_session_uuid"],
|
|
861
|
+
team_id=resolved_team_id,
|
|
862
|
+
old_leader_session_uuid=str(existing_owner.get("leader_session_uuid") or ""),
|
|
693
863
|
)
|
|
694
|
-
|
|
695
|
-
if new_receiver is not None:
|
|
696
|
-
response["leader_receiver"] = new_receiver
|
|
697
|
-
return response
|
|
864
|
+
return result
|
|
698
865
|
|
|
699
866
|
|
|
700
867
|
def _running_agent_state(workspace: Path, agent: dict[str, Any], previous: dict[str, Any]) -> dict[str, Any]:
|
package/src/team_agent/state.py
CHANGED
|
@@ -51,14 +51,37 @@ def normalize_agent_session_state(state: dict[str, Any]) -> None:
|
|
|
51
51
|
def load_runtime_state(workspace: Path) -> dict[str, Any]:
|
|
52
52
|
path = runtime_state_path(workspace)
|
|
53
53
|
if not path.exists():
|
|
54
|
-
return {"agents": {}, "tasks": [], "session_name": None}
|
|
54
|
+
return {"agents": {}, "tasks": [], "session_name": None, "active_team_key": None}
|
|
55
55
|
state = json.loads(path.read_text(encoding="utf-8"))
|
|
56
56
|
normalize_agent_session_state(state)
|
|
57
|
-
|
|
57
|
+
changed = _migrate_state_identity(state, workspace)
|
|
58
|
+
if _migrate_active_team_key(state):
|
|
59
|
+
changed = True
|
|
60
|
+
if changed:
|
|
58
61
|
save_runtime_state(workspace, state)
|
|
59
62
|
return state
|
|
60
63
|
|
|
61
64
|
|
|
65
|
+
def _migrate_active_team_key(state: dict[str, Any]) -> bool:
|
|
66
|
+
"""0.2.6 Family B (C6): legacy states with a top-level ``session_name``
|
|
67
|
+
but no ``active_team_key`` get the active pointer seeded once. After
|
|
68
|
+
this, ``active_team_key`` is the single explicit source of truth and
|
|
69
|
+
callers mutate it through CLI verbs (claim-leader / takeover /
|
|
70
|
+
shutdown / restart)."""
|
|
71
|
+
if "active_team_key" in state:
|
|
72
|
+
return False
|
|
73
|
+
teams = state.get("teams") if isinstance(state.get("teams"), dict) else {}
|
|
74
|
+
if state.get("session_name"):
|
|
75
|
+
seed = team_state_key(state)
|
|
76
|
+
state["active_team_key"] = seed if seed in teams or not teams else seed
|
|
77
|
+
return True
|
|
78
|
+
if isinstance(teams, dict) and len(teams) == 1:
|
|
79
|
+
state["active_team_key"] = next(iter(teams))
|
|
80
|
+
return True
|
|
81
|
+
state["active_team_key"] = None
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
|
|
62
85
|
def team_state_key(state: dict[str, Any]) -> str:
|
|
63
86
|
for field in ("team_dir", "spec_path"):
|
|
64
87
|
value = state.get(field)
|
|
@@ -98,58 +121,110 @@ def merge_workspace_team_state(existing: dict[str, Any], launched: dict[str, Any
|
|
|
98
121
|
|
|
99
122
|
|
|
100
123
|
def team_state_candidates(state: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if state.get("
|
|
108
|
-
|
|
109
|
-
|
|
124
|
+
"""0.2.6 Family B (C7): the only candidate source is ``state.teams``
|
|
125
|
+
filtered by ``status == "alive"``. Top-level ``session_name`` /
|
|
126
|
+
``team_dir`` are a derived view of the active team and never count as
|
|
127
|
+
an independent candidate. Shutdown/legacy entries with non-alive
|
|
128
|
+
status are excluded."""
|
|
129
|
+
out: dict[str, dict[str, Any]] = {}
|
|
130
|
+
teams = state.get("teams") if isinstance(state.get("teams"), dict) else {}
|
|
131
|
+
for key, value in teams.items():
|
|
132
|
+
if not isinstance(value, dict):
|
|
133
|
+
continue
|
|
134
|
+
if str(value.get("status") or "alive").lower() != "alive":
|
|
135
|
+
continue
|
|
136
|
+
out[str(key)] = value
|
|
137
|
+
return out
|
|
110
138
|
|
|
111
139
|
|
|
112
|
-
def format_team_candidates(
|
|
113
|
-
if not
|
|
140
|
+
def format_team_candidates(team_states: dict[str, dict[str, Any]]) -> str:
|
|
141
|
+
if not team_states:
|
|
114
142
|
return "No team state was found."
|
|
115
143
|
parts = []
|
|
116
|
-
for key
|
|
117
|
-
|
|
118
|
-
|
|
144
|
+
for key in sorted(team_states):
|
|
145
|
+
st = team_states[key]
|
|
146
|
+
agents = ",".join(sorted(st.get("agents", {}).keys())) or "-"
|
|
147
|
+
parts.append(f"{key} session={st.get('session_name') or '-'} agents={agents}")
|
|
119
148
|
return "Candidates: " + "; ".join(parts)
|
|
120
149
|
|
|
121
150
|
|
|
151
|
+
def _team_entry_from_state(state: dict[str, Any], team_key: str) -> dict[str, Any] | None:
|
|
152
|
+
teams = state.get("teams") if isinstance(state.get("teams"), dict) else {}
|
|
153
|
+
entry = teams.get(team_key)
|
|
154
|
+
if not isinstance(entry, dict):
|
|
155
|
+
return None
|
|
156
|
+
return entry
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _project_top_level_view(state: dict[str, Any], team_key: str) -> dict[str, Any]:
|
|
160
|
+
"""0.2.6 Family B (C8): when picking a team for use, the top-level
|
|
161
|
+
keys (``session_name`` / ``team_dir`` / ``agents`` / ``tasks``) are a
|
|
162
|
+
derived view of ``teams[team_key]``. We copy the team entry into a
|
|
163
|
+
flat dict and preserve any auxiliary state (``team_owner`` /
|
|
164
|
+
``leader_receiver`` / ``coordinator`` already pinned to the team)."""
|
|
165
|
+
entry = _team_entry_from_state(state, team_key) or {}
|
|
166
|
+
projection = copy.deepcopy(entry)
|
|
167
|
+
projection.setdefault("session_name", entry.get("session_name"))
|
|
168
|
+
projection.setdefault("team_dir", entry.get("team_dir"))
|
|
169
|
+
projection["active_team_key"] = team_key
|
|
170
|
+
# Preserve the full teams dict so consumers can introspect siblings.
|
|
171
|
+
projection["teams"] = copy.deepcopy(state.get("teams") or {})
|
|
172
|
+
if "team_owner" in entry:
|
|
173
|
+
projection["team_owner"] = copy.deepcopy(entry["team_owner"])
|
|
174
|
+
elif state.get("team_owner") is not None:
|
|
175
|
+
projection["team_owner"] = copy.deepcopy(state["team_owner"])
|
|
176
|
+
if "leader_receiver" in entry:
|
|
177
|
+
projection["leader_receiver"] = copy.deepcopy(entry["leader_receiver"])
|
|
178
|
+
elif state.get("leader_receiver") is not None:
|
|
179
|
+
projection["leader_receiver"] = copy.deepcopy(state["leader_receiver"])
|
|
180
|
+
if "coordinator" in state:
|
|
181
|
+
projection.setdefault("coordinator", copy.deepcopy(state["coordinator"]))
|
|
182
|
+
return projection
|
|
183
|
+
|
|
184
|
+
|
|
122
185
|
def select_runtime_state(workspace: Path, team: str | None = None) -> dict[str, Any]:
|
|
123
186
|
state = load_runtime_state(workspace)
|
|
124
|
-
|
|
187
|
+
alive = team_state_candidates(state)
|
|
125
188
|
if team:
|
|
126
189
|
matches = [
|
|
127
|
-
value
|
|
128
|
-
for key, value in
|
|
190
|
+
(key, value)
|
|
191
|
+
for key, value in alive.items()
|
|
129
192
|
if team in {key, str(value.get("session_name") or ""), str(value.get("team_dir") or "")}
|
|
130
193
|
]
|
|
131
194
|
if len(matches) == 1:
|
|
132
|
-
return
|
|
195
|
+
return _project_top_level_view(state, matches[0][0])
|
|
133
196
|
from team_agent.errors import RuntimeError
|
|
134
197
|
if len(matches) > 1:
|
|
135
|
-
raise RuntimeError("team selector is ambiguous. " + format_team_candidates(
|
|
136
|
-
raise RuntimeError(f"team {team!r} not found. " + format_team_candidates(
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
198
|
+
raise RuntimeError("team selector is ambiguous. " + format_team_candidates(alive))
|
|
199
|
+
raise RuntimeError(f"team {team!r} not found. " + format_team_candidates(alive))
|
|
200
|
+
active = state.get("active_team_key")
|
|
201
|
+
if active and active in alive:
|
|
202
|
+
return _project_top_level_view(state, str(active))
|
|
203
|
+
if len(alive) == 1:
|
|
204
|
+
return _project_top_level_view(state, next(iter(alive)))
|
|
205
|
+
if not alive:
|
|
206
|
+
return copy.deepcopy(state)
|
|
207
|
+
from team_agent.errors import RuntimeError
|
|
208
|
+
raise RuntimeError(
|
|
209
|
+
"multiple teams found in this workspace; pass --team <team> to choose. "
|
|
210
|
+
+ format_team_candidates(alive)
|
|
211
|
+
)
|
|
141
212
|
|
|
142
213
|
|
|
143
214
|
def ambiguous_team_target_result(state: dict[str, Any]) -> dict[str, Any] | None:
|
|
144
|
-
|
|
145
|
-
|
|
215
|
+
alive = team_state_candidates(state)
|
|
216
|
+
active = state.get("active_team_key")
|
|
217
|
+
if active and active in alive:
|
|
218
|
+
return None
|
|
219
|
+
if len(alive) <= 1:
|
|
146
220
|
return None
|
|
147
221
|
return {
|
|
148
222
|
"ok": False,
|
|
149
223
|
"status": "refused",
|
|
150
224
|
"reason": "team_target_ambiguous",
|
|
151
|
-
"candidates": sorted(
|
|
152
|
-
"message": "multiple teams found in this workspace; pass --team <team> to choose. "
|
|
225
|
+
"candidates": sorted(alive.keys()),
|
|
226
|
+
"message": "multiple teams found in this workspace; pass --team <team> to choose. "
|
|
227
|
+
+ format_team_candidates(alive),
|
|
153
228
|
}
|
|
154
229
|
|
|
155
230
|
|