@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
|
@@ -0,0 +1,205 @@
|
|
|
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 or the caller pane not running a leader
|
|
14
|
+
host → refuse and emit ``owner.bind_refused``. 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
|
+
LEADER_HOST_COMMANDS = frozenset({"claude", "claude.exe", "codex"})
|
|
41
|
+
|
|
42
|
+
_HINT_RUN_FROM_LEADER_PANE = (
|
|
43
|
+
"run team-agent from inside your leader pane "
|
|
44
|
+
"(the tmux pane currently running claude or codex)."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def bind_owner_from_caller_pane(
|
|
49
|
+
workspace: Path,
|
|
50
|
+
team_id: str,
|
|
51
|
+
override_uuid: str | None = None,
|
|
52
|
+
) -> dict[str, Any]:
|
|
53
|
+
"""Derive the owner identity from the caller pane.
|
|
54
|
+
|
|
55
|
+
Returns
|
|
56
|
+
-------
|
|
57
|
+
On success::
|
|
58
|
+
|
|
59
|
+
{
|
|
60
|
+
"ok": True,
|
|
61
|
+
"owner": {
|
|
62
|
+
"pane_id": ..., "leader_session_uuid": ...,
|
|
63
|
+
"machine_fingerprint": ..., "provider": ...,
|
|
64
|
+
"os_user": ..., "claimed_at": <iso8601>,
|
|
65
|
+
},
|
|
66
|
+
"caller_pane_id": ..., "caller_current_command": ...,
|
|
67
|
+
"team_id": team_id,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
On refusal::
|
|
71
|
+
|
|
72
|
+
{
|
|
73
|
+
"ok": False,
|
|
74
|
+
"reason": "caller_pane_missing" | "caller_not_leader_shaped",
|
|
75
|
+
"caller_pane_id": ..., "caller_current_command": ...,
|
|
76
|
+
"hint": ...,
|
|
77
|
+
}
|
|
78
|
+
"""
|
|
79
|
+
event_log = EventLog(workspace)
|
|
80
|
+
caller_pane = os.environ.get("TMUX_PANE") or ""
|
|
81
|
+
if not caller_pane:
|
|
82
|
+
hint = _HINT_RUN_FROM_LEADER_PANE
|
|
83
|
+
event_log.write(
|
|
84
|
+
"owner.bind_refused",
|
|
85
|
+
reason="caller_pane_missing",
|
|
86
|
+
caller_pane_id="",
|
|
87
|
+
caller_current_command="",
|
|
88
|
+
team_id=team_id,
|
|
89
|
+
hint=hint,
|
|
90
|
+
)
|
|
91
|
+
return {
|
|
92
|
+
"ok": False,
|
|
93
|
+
"reason": "caller_pane_missing",
|
|
94
|
+
"caller_pane_id": "",
|
|
95
|
+
"caller_current_command": "",
|
|
96
|
+
"hint": hint,
|
|
97
|
+
}
|
|
98
|
+
proc = run_cmd(
|
|
99
|
+
[
|
|
100
|
+
"tmux",
|
|
101
|
+
"display-message",
|
|
102
|
+
"-p",
|
|
103
|
+
"-t",
|
|
104
|
+
caller_pane,
|
|
105
|
+
"#{pane_current_command}",
|
|
106
|
+
],
|
|
107
|
+
timeout=5,
|
|
108
|
+
)
|
|
109
|
+
if getattr(proc, "returncode", 1) != 0:
|
|
110
|
+
caller_command = ""
|
|
111
|
+
else:
|
|
112
|
+
caller_command = (getattr(proc, "stdout", "") or "").strip()
|
|
113
|
+
if caller_command not in LEADER_HOST_COMMANDS:
|
|
114
|
+
hint = (
|
|
115
|
+
f"run team-agent from inside your leader pane "
|
|
116
|
+
f"(this pane is running {caller_command or '<unknown>'})."
|
|
117
|
+
)
|
|
118
|
+
event_log.write(
|
|
119
|
+
"owner.bind_refused",
|
|
120
|
+
reason="caller_not_leader_shaped",
|
|
121
|
+
caller_pane_id=caller_pane,
|
|
122
|
+
caller_current_command=caller_command,
|
|
123
|
+
team_id=team_id,
|
|
124
|
+
hint=hint,
|
|
125
|
+
)
|
|
126
|
+
return {
|
|
127
|
+
"ok": False,
|
|
128
|
+
"reason": "caller_not_leader_shaped",
|
|
129
|
+
"caller_pane_id": caller_pane,
|
|
130
|
+
"caller_current_command": caller_command,
|
|
131
|
+
"hint": hint,
|
|
132
|
+
}
|
|
133
|
+
machine_fingerprint = os.environ.get("TEAM_AGENT_MACHINE_FINGERPRINT") or ""
|
|
134
|
+
os_user = os.environ.get("USER") or os.environ.get("USERNAME") or ""
|
|
135
|
+
provider = (
|
|
136
|
+
os.environ.get("TEAM_AGENT_LEADER_PROVIDER")
|
|
137
|
+
or _provider_from_command(caller_command)
|
|
138
|
+
)
|
|
139
|
+
if override_uuid:
|
|
140
|
+
leader_session_uuid = override_uuid
|
|
141
|
+
else:
|
|
142
|
+
env_override = (
|
|
143
|
+
os.environ.get("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE") or ""
|
|
144
|
+
)
|
|
145
|
+
if env_override:
|
|
146
|
+
leader_session_uuid = env_override
|
|
147
|
+
else:
|
|
148
|
+
leader_session_uuid = derive_leader_session_uuid(
|
|
149
|
+
machine_fingerprint, workspace, os_user, team_id
|
|
150
|
+
)
|
|
151
|
+
owner = {
|
|
152
|
+
"pane_id": caller_pane,
|
|
153
|
+
"leader_session_uuid": leader_session_uuid,
|
|
154
|
+
"machine_fingerprint": machine_fingerprint,
|
|
155
|
+
"provider": provider,
|
|
156
|
+
"os_user": os_user,
|
|
157
|
+
"claimed_at": datetime.now(timezone.utc).isoformat(),
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
"ok": True,
|
|
161
|
+
"owner": owner,
|
|
162
|
+
"caller_pane_id": caller_pane,
|
|
163
|
+
"caller_current_command": caller_command,
|
|
164
|
+
"team_id": team_id,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def derive_leader_session_uuid(
|
|
169
|
+
machine_fingerprint: str, workspace: Path, os_user: str, team_id: str
|
|
170
|
+
) -> str:
|
|
171
|
+
workspace_abspath = str(Path(workspace).resolve())
|
|
172
|
+
payload = "\0".join((machine_fingerprint, workspace_abspath, os_user, team_id))
|
|
173
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:32]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _provider_from_command(cmd: str) -> str:
|
|
177
|
+
if cmd in {"claude", "claude.exe"}:
|
|
178
|
+
return "claude"
|
|
179
|
+
if cmd == "codex":
|
|
180
|
+
return "codex"
|
|
181
|
+
return ""
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def emit_owner_bound_event(
|
|
185
|
+
workspace: Path,
|
|
186
|
+
*,
|
|
187
|
+
caller_pane_id: str,
|
|
188
|
+
caller_current_command: str,
|
|
189
|
+
derived_leader_session_uuid: str,
|
|
190
|
+
team_id: str,
|
|
191
|
+
old_leader_session_uuid: str | None,
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Audit hook for successful owner bind. Use after a caller has
|
|
194
|
+
accepted ``bind_owner_from_caller_pane`` and persisted the new owner.
|
|
195
|
+
|
|
196
|
+
Only short uuid prefixes go to the event log so we do not leak the
|
|
197
|
+
full session uuid into operator-readable transcripts."""
|
|
198
|
+
EventLog(workspace).write(
|
|
199
|
+
"owner.bound_from_caller_pane",
|
|
200
|
+
caller_pane_id=caller_pane_id,
|
|
201
|
+
caller_current_command=caller_current_command,
|
|
202
|
+
derived_uuid_prefix=(derived_leader_session_uuid or "")[:12],
|
|
203
|
+
team_id=team_id,
|
|
204
|
+
old_uuid_prefix=(old_leader_session_uuid or "")[:12],
|
|
205
|
+
)
|
|
@@ -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
|
|
|
@@ -3,9 +3,13 @@ from __future__ import annotations
|
|
|
3
3
|
from contextlib import closing
|
|
4
4
|
from typing import Any
|
|
5
5
|
|
|
6
|
+
from team_agent.message_store.schema_migration import MANAGED_TABLE_LAYOUTS
|
|
6
7
|
from team_agent.message_store.schema import utcnow
|
|
7
8
|
|
|
8
9
|
|
|
10
|
+
AGENT_HEALTH_SELECT = ", ".join(MANAGED_TABLE_LAYOUTS["agent_health"])
|
|
11
|
+
|
|
12
|
+
|
|
9
13
|
def upsert_agent_health(
|
|
10
14
|
self,
|
|
11
15
|
agent_id: str,
|
|
@@ -50,10 +54,10 @@ def upsert_agent_health(
|
|
|
50
54
|
def agent_health(self, owner_team_id: str | None = None) -> dict[str, dict[str, Any]]:
|
|
51
55
|
with closing(self.connect()) as conn:
|
|
52
56
|
if owner_team_id is None:
|
|
53
|
-
rows = conn.execute("select
|
|
57
|
+
rows = conn.execute(f"select {AGENT_HEALTH_SELECT} from agent_health order by agent_id").fetchall()
|
|
54
58
|
else:
|
|
55
59
|
rows = conn.execute(
|
|
56
|
-
"select
|
|
60
|
+
f"select {AGENT_HEALTH_SELECT} from agent_health where owner_team_id = ? or owner_team_id is null order by agent_id",
|
|
57
61
|
(owner_team_id,),
|
|
58
62
|
).fetchall()
|
|
59
63
|
return {row["agent_id"]: dict(row) for row in rows}
|
|
@@ -12,10 +12,17 @@ from typing import Any, Callable
|
|
|
12
12
|
from . import agent_health as _agent_health
|
|
13
13
|
from . import result_watchers as _result_watchers
|
|
14
14
|
from .schema import SCHEMA_VERSION, initialize_schema, utcnow
|
|
15
|
+
from .schema_migration import MANAGED_TABLE_LAYOUTS
|
|
15
16
|
from team_agent.paths import runtime_dir
|
|
16
17
|
from team_agent.spec import validate_result_envelope
|
|
17
18
|
|
|
18
19
|
|
|
20
|
+
MESSAGE_SELECT = ", ".join(MANAGED_TABLE_LAYOUTS["messages"])
|
|
21
|
+
RESULT_SELECT = ", ".join(MANAGED_TABLE_LAYOUTS["results"])
|
|
22
|
+
SCHEDULED_EVENT_SELECT = ", ".join(MANAGED_TABLE_LAYOUTS["scheduled_events"])
|
|
23
|
+
DELIVERY_TOKEN_SELECT = ", ".join(MANAGED_TABLE_LAYOUTS["delivery_tokens"])
|
|
24
|
+
|
|
25
|
+
|
|
19
26
|
def _is_sqlite_locked(exc: sqlite3.OperationalError) -> bool:
|
|
20
27
|
message = str(exc).lower()
|
|
21
28
|
return (
|
|
@@ -57,7 +64,7 @@ class MessageStore:
|
|
|
57
64
|
def _init(self) -> None:
|
|
58
65
|
def initialize() -> None:
|
|
59
66
|
with closing(self.connect()) as conn:
|
|
60
|
-
initialize_schema(conn)
|
|
67
|
+
initialize_schema(conn, self.path)
|
|
61
68
|
|
|
62
69
|
_with_sqlite_busy_retry(initialize)
|
|
63
70
|
|
|
@@ -224,10 +231,10 @@ class MessageStore:
|
|
|
224
231
|
def messages(self, owner_team_id: str | None = None) -> list[dict[str, Any]]:
|
|
225
232
|
with closing(self.connect()) as conn:
|
|
226
233
|
if owner_team_id is None:
|
|
227
|
-
rows = conn.execute("select
|
|
234
|
+
rows = conn.execute(f"select {MESSAGE_SELECT} from messages order by created_at").fetchall()
|
|
228
235
|
else:
|
|
229
236
|
rows = conn.execute(
|
|
230
|
-
"select
|
|
237
|
+
f"select {MESSAGE_SELECT} from messages where owner_team_id = ? or owner_team_id is null order by created_at",
|
|
231
238
|
(owner_team_id,),
|
|
232
239
|
).fetchall()
|
|
233
240
|
return [dict(row) for row in rows]
|
|
@@ -236,8 +243,8 @@ class MessageStore:
|
|
|
236
243
|
with closing(self.connect()) as conn:
|
|
237
244
|
if owner_team_id is None:
|
|
238
245
|
rows = conn.execute(
|
|
239
|
-
"""
|
|
240
|
-
select
|
|
246
|
+
f"""
|
|
247
|
+
select {MESSAGE_SELECT} from messages
|
|
241
248
|
where sender = ? or recipient = ?
|
|
242
249
|
order by created_at desc
|
|
243
250
|
limit ?
|
|
@@ -246,8 +253,8 @@ class MessageStore:
|
|
|
246
253
|
).fetchall()
|
|
247
254
|
else:
|
|
248
255
|
rows = conn.execute(
|
|
249
|
-
"""
|
|
250
|
-
select
|
|
256
|
+
f"""
|
|
257
|
+
select {MESSAGE_SELECT} from messages
|
|
251
258
|
where (sender = ? or recipient = ?)
|
|
252
259
|
and (owner_team_id = ? or owner_team_id is null)
|
|
253
260
|
order by created_at desc
|
|
@@ -259,7 +266,7 @@ class MessageStore:
|
|
|
259
266
|
|
|
260
267
|
def delivery_tokens(self) -> list[dict[str, Any]]:
|
|
261
268
|
with closing(self.connect()) as conn:
|
|
262
|
-
rows = conn.execute("select
|
|
269
|
+
rows = conn.execute(f"select {DELIVERY_TOKEN_SELECT} from delivery_tokens order by injected_at").fetchall()
|
|
263
270
|
return [dict(row) for row in rows]
|
|
264
271
|
|
|
265
272
|
def add_scheduled_event(
|
|
@@ -285,8 +292,8 @@ class MessageStore:
|
|
|
285
292
|
with closing(self.connect()) as conn:
|
|
286
293
|
if owner_team_id is None:
|
|
287
294
|
rows = conn.execute(
|
|
288
|
-
"""
|
|
289
|
-
select
|
|
295
|
+
f"""
|
|
296
|
+
select {SCHEDULED_EVENT_SELECT} from scheduled_events
|
|
290
297
|
where status = 'pending' and due_at <= ?
|
|
291
298
|
order by due_at, id
|
|
292
299
|
""",
|
|
@@ -294,8 +301,8 @@ class MessageStore:
|
|
|
294
301
|
).fetchall()
|
|
295
302
|
else:
|
|
296
303
|
rows = conn.execute(
|
|
297
|
-
"""
|
|
298
|
-
select
|
|
304
|
+
f"""
|
|
305
|
+
select {SCHEDULED_EVENT_SELECT} from scheduled_events
|
|
299
306
|
where status = 'pending' and due_at <= ?
|
|
300
307
|
and (owner_team_id = ? or owner_team_id is null)
|
|
301
308
|
order by due_at, id
|
|
@@ -438,14 +445,14 @@ class MessageStore:
|
|
|
438
445
|
if uncollected_only:
|
|
439
446
|
clauses.append("status not in ('collected', 'invalid')")
|
|
440
447
|
where = " where " + " and ".join(clauses) if clauses else ""
|
|
441
|
-
query = f"select
|
|
448
|
+
query = f"select {RESULT_SELECT} from results{where} order by created_at"
|
|
442
449
|
with closing(self.connect()) as conn:
|
|
443
450
|
rows = conn.execute(query, args).fetchall()
|
|
444
451
|
return [dict(row) for row in rows]
|
|
445
452
|
|
|
446
453
|
def result_by_id(self, result_id: str) -> dict[str, Any] | None:
|
|
447
454
|
with closing(self.connect()) as conn:
|
|
448
|
-
row = conn.execute("select
|
|
455
|
+
row = conn.execute(f"select {RESULT_SELECT} from results where result_id = ?", (result_id,)).fetchone()
|
|
449
456
|
return dict(row) if row else None
|
|
450
457
|
|
|
451
458
|
def latest_results(self, limit: int = 5, owner_team_id: str | None = None) -> list[dict[str, Any]]:
|
|
@@ -454,7 +461,7 @@ class MessageStore:
|
|
|
454
461
|
with closing(self.connect()) as conn:
|
|
455
462
|
rows = conn.execute(
|
|
456
463
|
f"""
|
|
457
|
-
select
|
|
464
|
+
select {RESULT_SELECT} from results
|
|
458
465
|
where status != 'invalid' {owner_clause}
|
|
459
466
|
order by created_at desc
|
|
460
467
|
limit ?
|