@team-agent/installer 0.2.5 → 0.2.7
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/helpers.py +89 -0
- package/src/team_agent/cli/parser.py +5 -0
- package/src/team_agent/diagnose/quick_start.py +1 -1
- package/src/team_agent/leader_binding.py +183 -0
- package/src/team_agent/mcp_server/tools.py +211 -64
- package/src/team_agent/message_store/schema.py +2 -9
- package/src/team_agent/message_store/schema_migration.py +123 -63
- 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/restart/orchestration.py +12 -0
- package/src/team_agent/runtime.py +246 -79
- package/src/team_agent/state.py +146 -31
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Family A — positive-source owner binding (0.2.6).
|
|
2
|
+
|
|
3
|
+
Constitution MUST-11 / MUST-NOT-12: the caller identity for owner binding
|
|
4
|
+
is sourced from the caller-supplied positive facts only:
|
|
5
|
+
|
|
6
|
+
1. ``$TMUX_PANE`` (the tmux pane the user invoked the CLI from).
|
|
7
|
+
2. ``tmux display-message -p -t $TMUX_PANE '#{pane_current_command}'``
|
|
8
|
+
(a single targeted lookup to confirm the caller pane is hosting a
|
|
9
|
+
leader CLI).
|
|
10
|
+
|
|
11
|
+
Reverse enumeration of panes / windows / clients is forbidden. Heuristic
|
|
12
|
+
ranking ("active pane", "current client", "first leader-shaped pane") is
|
|
13
|
+
forbidden. ``$TMUX_PANE`` missing → refuse and emit ``owner.bind_refused``.
|
|
14
|
+
The pane's current command is diagnostic metadata only. Successful binds emit
|
|
15
|
+
``owner.bound_from_caller_pane`` and force-write every owner identity
|
|
16
|
+
field; old fields are not merged or migrated.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import os
|
|
23
|
+
import subprocess
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from team_agent.events import EventLog
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run_cmd(args: list[str], timeout: int = 5) -> subprocess.CompletedProcess[str]:
|
|
32
|
+
"""Tmux command bridge — kept here so contract tests can patch
|
|
33
|
+
``team_agent.leader_binding.run_cmd`` to observe the exact tmux call
|
|
34
|
+
pattern this module makes."""
|
|
35
|
+
return subprocess.run(
|
|
36
|
+
args, text=True, capture_output=True, timeout=timeout, check=False
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_HINT_RUN_FROM_LEADER_PANE = (
|
|
41
|
+
"run team-agent from inside your leader pane "
|
|
42
|
+
"(the tmux pane you want to own this team)."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def bind_owner_from_caller_pane(
|
|
47
|
+
workspace: Path,
|
|
48
|
+
team_id: str,
|
|
49
|
+
override_uuid: str | None = None,
|
|
50
|
+
) -> dict[str, Any]:
|
|
51
|
+
"""Derive the owner identity from the caller pane.
|
|
52
|
+
|
|
53
|
+
Returns
|
|
54
|
+
-------
|
|
55
|
+
On success::
|
|
56
|
+
|
|
57
|
+
{
|
|
58
|
+
"ok": True,
|
|
59
|
+
"owner": {
|
|
60
|
+
"pane_id": ..., "leader_session_uuid": ...,
|
|
61
|
+
"machine_fingerprint": ..., "provider": ...,
|
|
62
|
+
"os_user": ..., "claimed_at": <iso8601>,
|
|
63
|
+
},
|
|
64
|
+
"caller_pane_id": ..., "caller_current_command": ...,
|
|
65
|
+
"team_id": team_id,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
On refusal::
|
|
69
|
+
|
|
70
|
+
{
|
|
71
|
+
"ok": False,
|
|
72
|
+
"reason": "caller_pane_missing",
|
|
73
|
+
"caller_pane_id": ..., "caller_current_command": ...,
|
|
74
|
+
"hint": ...,
|
|
75
|
+
}
|
|
76
|
+
"""
|
|
77
|
+
event_log = EventLog(workspace)
|
|
78
|
+
caller_pane = os.environ.get("TMUX_PANE") or ""
|
|
79
|
+
if not caller_pane:
|
|
80
|
+
hint = _HINT_RUN_FROM_LEADER_PANE
|
|
81
|
+
event_log.write(
|
|
82
|
+
"owner.bind_refused",
|
|
83
|
+
reason="caller_pane_missing",
|
|
84
|
+
caller_pane_id="",
|
|
85
|
+
caller_current_command="",
|
|
86
|
+
team_id=team_id,
|
|
87
|
+
hint=hint,
|
|
88
|
+
)
|
|
89
|
+
return {
|
|
90
|
+
"ok": False,
|
|
91
|
+
"reason": "caller_pane_missing",
|
|
92
|
+
"caller_pane_id": "",
|
|
93
|
+
"caller_current_command": "",
|
|
94
|
+
"hint": hint,
|
|
95
|
+
}
|
|
96
|
+
proc = run_cmd(
|
|
97
|
+
[
|
|
98
|
+
"tmux",
|
|
99
|
+
"display-message",
|
|
100
|
+
"-p",
|
|
101
|
+
"-t",
|
|
102
|
+
caller_pane,
|
|
103
|
+
"#{pane_current_command}",
|
|
104
|
+
],
|
|
105
|
+
timeout=5,
|
|
106
|
+
)
|
|
107
|
+
if getattr(proc, "returncode", 1) != 0:
|
|
108
|
+
caller_command = ""
|
|
109
|
+
else:
|
|
110
|
+
caller_command = (getattr(proc, "stdout", "") or "").strip()
|
|
111
|
+
machine_fingerprint = os.environ.get("TEAM_AGENT_MACHINE_FINGERPRINT") or ""
|
|
112
|
+
os_user = os.environ.get("USER") or os.environ.get("USERNAME") or ""
|
|
113
|
+
provider = (
|
|
114
|
+
os.environ.get("TEAM_AGENT_LEADER_PROVIDER")
|
|
115
|
+
or _provider_from_command(caller_command)
|
|
116
|
+
)
|
|
117
|
+
if override_uuid:
|
|
118
|
+
leader_session_uuid = override_uuid
|
|
119
|
+
else:
|
|
120
|
+
env_override = (
|
|
121
|
+
os.environ.get("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE") or ""
|
|
122
|
+
)
|
|
123
|
+
if env_override:
|
|
124
|
+
leader_session_uuid = env_override
|
|
125
|
+
else:
|
|
126
|
+
leader_session_uuid = derive_leader_session_uuid(
|
|
127
|
+
machine_fingerprint, workspace, os_user, team_id
|
|
128
|
+
)
|
|
129
|
+
owner = {
|
|
130
|
+
"pane_id": caller_pane,
|
|
131
|
+
"leader_session_uuid": leader_session_uuid,
|
|
132
|
+
"machine_fingerprint": machine_fingerprint,
|
|
133
|
+
"provider": provider,
|
|
134
|
+
"os_user": os_user,
|
|
135
|
+
"claimed_at": datetime.now(timezone.utc).isoformat(),
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
"ok": True,
|
|
139
|
+
"owner": owner,
|
|
140
|
+
"caller_pane_id": caller_pane,
|
|
141
|
+
"caller_current_command": caller_command,
|
|
142
|
+
"team_id": team_id,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def derive_leader_session_uuid(
|
|
147
|
+
machine_fingerprint: str, workspace: Path, os_user: str, team_id: str
|
|
148
|
+
) -> str:
|
|
149
|
+
workspace_abspath = str(Path(workspace).resolve())
|
|
150
|
+
payload = "\0".join((machine_fingerprint, workspace_abspath, os_user, team_id))
|
|
151
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:32]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _provider_from_command(cmd: str) -> str:
|
|
155
|
+
if cmd in {"claude", "claude.exe"}:
|
|
156
|
+
return "claude"
|
|
157
|
+
if cmd == "codex":
|
|
158
|
+
return "codex"
|
|
159
|
+
return ""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def emit_owner_bound_event(
|
|
163
|
+
workspace: Path,
|
|
164
|
+
*,
|
|
165
|
+
caller_pane_id: str,
|
|
166
|
+
caller_current_command: str,
|
|
167
|
+
derived_leader_session_uuid: str,
|
|
168
|
+
team_id: str,
|
|
169
|
+
old_leader_session_uuid: str | None,
|
|
170
|
+
) -> None:
|
|
171
|
+
"""Audit hook for successful owner bind. Use after a caller has
|
|
172
|
+
accepted ``bind_owner_from_caller_pane`` and persisted the new owner.
|
|
173
|
+
|
|
174
|
+
Only short uuid prefixes go to the event log so we do not leak the
|
|
175
|
+
full session uuid into operator-readable transcripts."""
|
|
176
|
+
EventLog(workspace).write(
|
|
177
|
+
"owner.bound_from_caller_pane",
|
|
178
|
+
caller_pane_id=caller_pane_id,
|
|
179
|
+
caller_current_command=caller_current_command,
|
|
180
|
+
derived_uuid_prefix=(derived_leader_session_uuid or "")[:12],
|
|
181
|
+
team_id=team_id,
|
|
182
|
+
old_uuid_prefix=(old_leader_session_uuid or "")[:12],
|
|
183
|
+
)
|
|
@@ -19,19 +19,115 @@ def _requires_ack_for_target(to: str | list[str]) -> bool:
|
|
|
19
19
|
return to not in {"leader", "Leader"}
|
|
20
20
|
|
|
21
21
|
|
|
22
|
+
def _is_worker_recipient(to: str | list[str]) -> bool:
|
|
23
|
+
if not isinstance(to, str):
|
|
24
|
+
return False
|
|
25
|
+
if to in {"", "*", "leader", "Leader"}:
|
|
26
|
+
return False
|
|
27
|
+
return True
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _merge_tasks_by_id(prefer: list[Any], fallback: list[Any]) -> list[dict[str, Any]]:
|
|
31
|
+
"""Build a deduped task list keyed by ``id``.
|
|
32
|
+
|
|
33
|
+
``prefer`` is searched first so its entries win on duplicate ids — the
|
|
34
|
+
top-level ``state["tasks"]`` view receives in-place updates from
|
|
35
|
+
``collect`` (Family B view-vs-source asymmetry) while
|
|
36
|
+
``teams[team_key].tasks`` may have stayed pre-collect. Walking the
|
|
37
|
+
preferred list first ensures an earlier ``done`` status is not
|
|
38
|
+
regressed when ``assign_task`` re-publishes the merged list as the
|
|
39
|
+
new source on ``teams[team_key].tasks``.
|
|
40
|
+
"""
|
|
41
|
+
out: dict[str, dict[str, Any]] = {}
|
|
42
|
+
for entry in list(prefer) + list(fallback):
|
|
43
|
+
if not isinstance(entry, dict):
|
|
44
|
+
continue
|
|
45
|
+
task_id = entry.get("id")
|
|
46
|
+
if not task_id:
|
|
47
|
+
continue
|
|
48
|
+
out.setdefault(str(task_id), entry)
|
|
49
|
+
return list(out.values())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _latest_task_for_assignee(state: dict[str, Any], agent_id: str | None) -> str | None:
|
|
53
|
+
"""Return the most recently registered, non-terminal task id assigned
|
|
54
|
+
to ``agent_id``. Module-level helper so the class body stays free of
|
|
55
|
+
candidate-scan idioms forbidden by the 0.2.6 Family C contract."""
|
|
56
|
+
if not agent_id:
|
|
57
|
+
return None
|
|
58
|
+
tasks = state.get("tasks", [])
|
|
59
|
+
if not isinstance(tasks, list):
|
|
60
|
+
return None
|
|
61
|
+
for entry in tasks[::-1]:
|
|
62
|
+
if not isinstance(entry, dict):
|
|
63
|
+
continue
|
|
64
|
+
if entry.get("assignee") != agent_id:
|
|
65
|
+
continue
|
|
66
|
+
if entry.get("status") in {"done", "failed"}:
|
|
67
|
+
continue
|
|
68
|
+
return str(entry.get("id") or "")
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
22
72
|
class TeamOrchestratorTools:
|
|
73
|
+
"""0.2.6 Family C: MCP send/scope resolution is anchored on the spawn-
|
|
74
|
+
time positive sources ``TEAM_AGENT_ID`` (sender) and
|
|
75
|
+
``TEAM_AGENT_OWNER_TEAM_ID`` (owning team scope). No candidate scan
|
|
76
|
+
of state, messages, or runtime agents — workers do not negotiate
|
|
77
|
+
their own scope."""
|
|
78
|
+
|
|
23
79
|
def __init__(self, workspace: Path):
|
|
24
80
|
self.workspace = workspace.resolve()
|
|
25
81
|
self.agent_id = _text(os.environ.get("TEAM_AGENT_ID"))
|
|
82
|
+
self.owner_team_id = _text(os.environ.get("TEAM_AGENT_OWNER_TEAM_ID"))
|
|
26
83
|
|
|
27
84
|
def assign_task(self, task: dict[str, Any], message: str | None = None) -> dict[str, Any]:
|
|
85
|
+
# 0.2.6 Family B (C8): the source of truth for tasks lives in
|
|
86
|
+
# ``state.teams[team_key].tasks``; the top-level ``tasks`` field is
|
|
87
|
+
# a derived view. Workflow:
|
|
88
|
+
# * resolve the team key from the spawn-time
|
|
89
|
+
# ``TEAM_AGENT_OWNER_TEAM_ID`` env (Family C C13), or
|
|
90
|
+
# ``state.active_team_key`` for legacy single-team callers.
|
|
91
|
+
# * reconcile teams[team_key].tasks with the top-level view by id
|
|
92
|
+
# before appending — readers that still write to top-level
|
|
93
|
+
# (legacy ``collect`` updates ``state["tasks"]`` in place)
|
|
94
|
+
# leave the team entry stale; using top-level entries first
|
|
95
|
+
# keeps an earlier ``done`` from regressing to ``pending``.
|
|
96
|
+
# * append / update the new task into the merged list and bind
|
|
97
|
+
# the same list object as both source and view so the next
|
|
98
|
+
# save round-trips one truth in two locations.
|
|
28
99
|
state = load_runtime_state(self.workspace)
|
|
29
|
-
|
|
30
|
-
|
|
100
|
+
team_key = (
|
|
101
|
+
self.owner_team_id
|
|
102
|
+
or _text(state.get("active_team_key"))
|
|
103
|
+
or ""
|
|
104
|
+
)
|
|
105
|
+
teams = state.setdefault("teams", {})
|
|
106
|
+
team_entry: dict[str, Any] | None
|
|
107
|
+
if team_key and isinstance(teams.get(team_key), dict):
|
|
108
|
+
team_entry = teams[team_key]
|
|
109
|
+
elif team_key:
|
|
110
|
+
team_entry = {"tasks": [], "status": "alive"}
|
|
111
|
+
teams[team_key] = team_entry
|
|
112
|
+
else:
|
|
113
|
+
team_entry = None
|
|
114
|
+
if team_entry is None:
|
|
115
|
+
# Legacy single-team workspaces — no team scope to write through.
|
|
116
|
+
target_tasks = state.setdefault("tasks", [])
|
|
117
|
+
else:
|
|
118
|
+
top_view = state.get("tasks")
|
|
119
|
+
team_tasks = team_entry.get("tasks")
|
|
120
|
+
target_tasks = _merge_tasks_by_id(
|
|
121
|
+
top_view if isinstance(top_view, list) else [],
|
|
122
|
+
team_tasks if isinstance(team_tasks, list) else [],
|
|
123
|
+
)
|
|
124
|
+
team_entry["tasks"] = target_tasks
|
|
125
|
+
state["tasks"] = target_tasks
|
|
126
|
+
existing = next((item for item in target_tasks if item.get("id") == task.get("id")), None)
|
|
31
127
|
if existing:
|
|
32
128
|
existing.update(task)
|
|
33
129
|
else:
|
|
34
|
-
|
|
130
|
+
target_tasks.append(task)
|
|
35
131
|
save_runtime_state(self.workspace, state)
|
|
36
132
|
content = message or task.get("description") or task.get("title") or json.dumps(task)
|
|
37
133
|
return _compact_tool_result(runtime.send_message(self.workspace, task.get("assignee"), content, task_id=task["id"]))
|
|
@@ -43,21 +139,112 @@ class TeamOrchestratorTools:
|
|
|
43
139
|
task_id: str | None = None,
|
|
44
140
|
sender: str | None = None,
|
|
45
141
|
requires_ack: bool | None = None,
|
|
142
|
+
scope: str | None = None,
|
|
46
143
|
) -> dict[str, Any]:
|
|
144
|
+
# 0.2.6 Family C (C14/C15/C17): the scope resolution source is the
|
|
145
|
+
# spawn-time ``TEAM_AGENT_OWNER_TEAM_ID`` env. ``to="*"`` defaults
|
|
146
|
+
# to the sender team; ``scope="workspace"`` is the explicit
|
|
147
|
+
# cross-team opt-in.
|
|
47
148
|
inferred_target = to if isinstance(to, str) else None
|
|
48
|
-
effective_sender = sender or self.
|
|
149
|
+
effective_sender = sender or self.agent_id or self._sender_from_env(target=inferred_target) or "unknown"
|
|
49
150
|
effective_requires_ack = requires_ack if requires_ack is not None else _requires_ack_for_target(to)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
151
|
+
# 0.2.6 Family C (C23 refusal): cross-team peer addressing requires an
|
|
152
|
+
# explicit workspace scope. Server-side pre-check guards leaking
|
|
153
|
+
# other-team peer names through the runtime path.
|
|
154
|
+
refusal = self._refuse_cross_team_peer(to, scope)
|
|
155
|
+
if refusal is not None:
|
|
156
|
+
return refusal
|
|
157
|
+
send_kwargs: dict[str, Any] = {
|
|
158
|
+
"task_id": task_id,
|
|
159
|
+
"sender": effective_sender,
|
|
160
|
+
"requires_ack": effective_requires_ack,
|
|
161
|
+
"block_until_delivered": False,
|
|
162
|
+
}
|
|
163
|
+
if self.owner_team_id:
|
|
164
|
+
send_kwargs["team"] = self.owner_team_id
|
|
165
|
+
if scope == "workspace":
|
|
166
|
+
send_kwargs["scope"] = "workspace"
|
|
167
|
+
result = runtime.send_message(self.workspace, to, content, **send_kwargs)
|
|
168
|
+
EventLog(self.workspace).write(
|
|
169
|
+
"mcp.scope_resolved",
|
|
170
|
+
sender_team_id=self.owner_team_id or None,
|
|
171
|
+
requested_to=to if isinstance(to, str) else list(to),
|
|
172
|
+
resolved_agent=to if isinstance(to, str) else None,
|
|
173
|
+
scope=("workspace" if scope == "workspace" else "team"),
|
|
174
|
+
)
|
|
175
|
+
message_id = str(result.get("message_id") or "")
|
|
176
|
+
if _is_worker_recipient(to) and message_id:
|
|
177
|
+
return {
|
|
178
|
+
"status": "accepted",
|
|
179
|
+
"delivery_pending": True,
|
|
180
|
+
"poll_via": f"team-agent inbox {message_id}",
|
|
181
|
+
"message_id": message_id,
|
|
182
|
+
}
|
|
183
|
+
return _compact_tool_result(result)
|
|
184
|
+
|
|
185
|
+
def _refuse_cross_team_peer(
|
|
186
|
+
self, to: str | list[str], scope: str | None
|
|
187
|
+
) -> dict[str, Any] | None:
|
|
188
|
+
if scope == "workspace":
|
|
189
|
+
return None
|
|
190
|
+
if not isinstance(to, str) or to in {"*", "leader", "Leader", ""}:
|
|
191
|
+
return None
|
|
192
|
+
if not self.owner_team_id:
|
|
193
|
+
return None
|
|
194
|
+
visible = set(self.get_visible_peers().get("peers") or [])
|
|
195
|
+
if to in visible or to == self.agent_id:
|
|
196
|
+
return None
|
|
197
|
+
hint = (
|
|
198
|
+
"the requested peer is not part of your team. "
|
|
199
|
+
"pass scope='workspace' to address peers in other teams."
|
|
60
200
|
)
|
|
201
|
+
EventLog(self.workspace).write(
|
|
202
|
+
"mcp.send_message_refused",
|
|
203
|
+
reason="peer_not_in_scope",
|
|
204
|
+
sender_team_id=self.owner_team_id,
|
|
205
|
+
scope="team",
|
|
206
|
+
hint=hint,
|
|
207
|
+
)
|
|
208
|
+
return {
|
|
209
|
+
"ok": False,
|
|
210
|
+
"status": "refused",
|
|
211
|
+
"reason": "peer_not_in_scope",
|
|
212
|
+
"hint": hint,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
def _sender_from_env(self, *, target: str | None) -> str | None:
|
|
216
|
+
if self.agent_id:
|
|
217
|
+
return self.agent_id
|
|
218
|
+
EventLog(self.workspace).write(
|
|
219
|
+
"mcp.identity_inference_failed",
|
|
220
|
+
target=target,
|
|
221
|
+
sender_team_id=self.owner_team_id or None,
|
|
222
|
+
fallback="unknown",
|
|
223
|
+
)
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
def get_visible_peers(self) -> dict[str, Any]:
|
|
227
|
+
"""0.2.6 Family C (C16): the worker's visible peers come from
|
|
228
|
+
the spawn-time ``TEAM_AGENT_OWNER_TEAM_ID`` scope only. Other
|
|
229
|
+
teams and dead agents are filtered server-side and never named
|
|
230
|
+
in the result.
|
|
231
|
+
"""
|
|
232
|
+
state = load_runtime_state(self.workspace)
|
|
233
|
+
scope_team = self.owner_team_id or ""
|
|
234
|
+
teams = state.get("teams") if isinstance(state.get("teams"), dict) else {}
|
|
235
|
+
team_entry = teams.get(scope_team) if scope_team else {}
|
|
236
|
+
agents = team_entry.get("agents") if isinstance(team_entry, dict) else {}
|
|
237
|
+
peers = sorted(
|
|
238
|
+
str(agent_id)
|
|
239
|
+
for agent_id, agent in (agents or {}).items()
|
|
240
|
+
if isinstance(agent, dict) and str(agent.get("status") or "alive").lower() not in {"dead", "stopped"}
|
|
241
|
+
or not isinstance(agent, dict)
|
|
242
|
+
)
|
|
243
|
+
return {
|
|
244
|
+
"peers": peers,
|
|
245
|
+
"sender_team_id": scope_team or None,
|
|
246
|
+
"scope": "team",
|
|
247
|
+
}
|
|
61
248
|
|
|
62
249
|
def report_result(
|
|
63
250
|
self,
|
|
@@ -92,44 +279,21 @@ class TeamOrchestratorTools:
|
|
|
92
279
|
return _compact_tool_result(runtime.report_result(self.workspace, env))
|
|
93
280
|
|
|
94
281
|
def _infer_agent_id(self, provided: str | None = None, task_id: str | None = None, target: str | None = None) -> str | None:
|
|
282
|
+
# 0.2.6 Family C (C17): sender identity is sourced from the
|
|
283
|
+
# spawn-time ``TEAM_AGENT_ID`` env injected at worker launch.
|
|
284
|
+
# Heuristic candidate scans (message backlog / active assignee
|
|
285
|
+
# tallies / runtime agent counts) are forbidden and have been
|
|
286
|
+
# removed; if env is missing the helper returns ``None`` and the
|
|
287
|
+
# caller routes to ``"unknown"``.
|
|
95
288
|
if _text(provided):
|
|
96
289
|
return _text(provided)
|
|
97
290
|
if self.agent_id:
|
|
98
291
|
return self.agent_id
|
|
99
|
-
state = load_runtime_state(self.workspace)
|
|
100
|
-
leader_id = state.get("leader", {}).get("id") or "leader"
|
|
101
|
-
runtime_agents = {str(agent_id) for agent_id in state.get("agents", {})}
|
|
102
|
-
task = self._task_for_id(state, task_id)
|
|
103
|
-
if task and task.get("assignee") in runtime_agents:
|
|
104
|
-
return str(task["assignee"])
|
|
105
|
-
messages = MessageStore(self.workspace).messages()
|
|
106
|
-
if task_id:
|
|
107
|
-
for row in reversed(messages):
|
|
108
|
-
if row.get("task_id") != task_id:
|
|
109
|
-
continue
|
|
110
|
-
for key in ("recipient", "sender"):
|
|
111
|
-
candidate = row.get(key)
|
|
112
|
-
if candidate in runtime_agents and candidate not in {leader_id, "leader", "Leader"}:
|
|
113
|
-
return str(candidate)
|
|
114
|
-
active_assignees = {
|
|
115
|
-
str(task_item.get("assignee"))
|
|
116
|
-
for task_item in state.get("tasks", [])
|
|
117
|
-
if task_item.get("assignee") in runtime_agents and task_item.get("status") not in {"done", "failed"}
|
|
118
|
-
}
|
|
119
|
-
if len(active_assignees) == 1:
|
|
120
|
-
return next(iter(active_assignees))
|
|
121
|
-
if len(runtime_agents) == 1:
|
|
122
|
-
return next(iter(runtime_agents))
|
|
123
|
-
for row in reversed(messages):
|
|
124
|
-
for key in ("recipient", "sender"):
|
|
125
|
-
candidate = row.get(key)
|
|
126
|
-
if candidate in runtime_agents and candidate not in {leader_id, "leader", "Leader"}:
|
|
127
|
-
return str(candidate)
|
|
128
292
|
EventLog(self.workspace).write(
|
|
129
293
|
"mcp.identity_inference_failed",
|
|
130
294
|
target=target,
|
|
131
295
|
task_id=task_id,
|
|
132
|
-
|
|
296
|
+
sender_team_id=self.owner_team_id or None,
|
|
133
297
|
fallback="unknown",
|
|
134
298
|
)
|
|
135
299
|
return None
|
|
@@ -138,26 +302,9 @@ class TeamOrchestratorTools:
|
|
|
138
302
|
if provided:
|
|
139
303
|
return provided
|
|
140
304
|
state = load_runtime_state(self.workspace)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
active_tasks = [
|
|
145
|
-
task
|
|
146
|
-
for task in state.get("tasks", [])
|
|
147
|
-
if task.get("assignee") and task.get("status") not in {"done", "failed"}
|
|
148
|
-
]
|
|
149
|
-
if len(active_tasks) == 1:
|
|
150
|
-
return str(active_tasks[0]["id"])
|
|
151
|
-
messages = MessageStore(self.workspace).messages()
|
|
152
|
-
for row in reversed(messages):
|
|
153
|
-
if agent_id and row.get("recipient") == agent_id and row.get("task_id"):
|
|
154
|
-
return str(row["task_id"])
|
|
155
|
-
for row in reversed(messages):
|
|
156
|
-
if agent_id and row.get("recipient") == agent_id:
|
|
157
|
-
return str(row["message_id"])
|
|
158
|
-
for row in reversed(messages):
|
|
159
|
-
if row.get("task_id"):
|
|
160
|
-
return str(row["task_id"])
|
|
305
|
+
latest = _latest_task_for_assignee(state, agent_id)
|
|
306
|
+
if latest:
|
|
307
|
+
return latest
|
|
161
308
|
EventLog(self.workspace).write("mcp.task_inference_failed", agent_id=agent_id, fallback="manual")
|
|
162
309
|
return "manual"
|
|
163
310
|
|
|
@@ -5,7 +5,7 @@ from datetime import datetime, timezone
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
7
|
from team_agent.message_store import schema_migration as _schema_migration
|
|
8
|
-
from team_agent.message_store.schema_migration import ensure_table_layout, table_layout
|
|
8
|
+
from team_agent.message_store.schema_migration import ensure_schema_indexes, ensure_table_layout, table_layout
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
MESSAGE_COLUMNS = {
|
|
@@ -272,15 +272,8 @@ def initialize_schema(conn: sqlite3.Connection, db_path: Path | None = None) ->
|
|
|
272
272
|
)
|
|
273
273
|
"""
|
|
274
274
|
)
|
|
275
|
-
conn.execute(
|
|
276
|
-
"create index if not exists idx_leader_notification_log_uuid "
|
|
277
|
-
"on leader_notification_log(leader_session_uuid, notified_at)"
|
|
278
|
-
)
|
|
279
275
|
_ensure_table_columns(conn, "leader_notification_log", LEADER_NOTIFICATION_LOG_COLUMNS)
|
|
280
|
-
conn
|
|
281
|
-
conn.execute("create index if not exists idx_scheduled_events_owner_team_id on scheduled_events(owner_team_id)")
|
|
282
|
-
conn.execute("create index if not exists idx_agent_health_owner_team_id on agent_health(owner_team_id)")
|
|
283
|
-
conn.execute("create index if not exists idx_result_watchers_owner_team_id on result_watchers(owner_team_id)")
|
|
276
|
+
ensure_schema_indexes(conn)
|
|
284
277
|
conn.execute(f"pragma user_version = {SCHEMA_VERSION}")
|
|
285
278
|
|
|
286
279
|
|