multi-agent-platform 0.1.0__py3-none-any.whl
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.
- cli/__init__.py +0 -0
- cli/action_item_escalation.py +177 -0
- cli/agent_client.py +554 -0
- cli/bridge_state.py +43 -0
- cli/commands/__init__.py +13 -0
- cli/commands/action.py +142 -0
- cli/commands/agent.py +117 -0
- cli/commands/audit.py +68 -0
- cli/commands/docs.py +179 -0
- cli/commands/experiment.py +755 -0
- cli/commands/feedback.py +106 -0
- cli/commands/notification.py +213 -0
- cli/commands/persona.py +63 -0
- cli/commands/project.py +87 -0
- cli/commands/runtime.py +105 -0
- cli/commands/topic.py +361 -0
- cli/e2e_collab.py +602 -0
- cli/git_checkpoint.py +68 -0
- cli/host_worker_types.py +151 -0
- cli/main.py +1553 -0
- cli/map_command_client.py +497 -0
- cli/participant_worker.py +255 -0
- cli/reviewer_worker.py +263 -0
- cli/runtime/__init__.py +5 -0
- cli/runtime/run_lock.py +497 -0
- cli/runtime_chat.py +317 -0
- cli/session_wake_log.py +235 -0
- cli/simple_waker.py +950 -0
- cli/table_render.py +113 -0
- cli/wake_backend.py +236 -0
- cli/worker_cycle_log.py +36 -0
- map_client/__init__.py +37 -0
- map_client/bootstrap.py +193 -0
- map_client/client.py +1045 -0
- map_client/config.py +21 -0
- map_client/errors.py +283 -0
- map_client/exceptions.py +130 -0
- map_client/plan_evidence.py +159 -0
- map_client/project_config.py +153 -0
- map_client/result_template.py +167 -0
- map_client/testing.py +27 -0
- map_mcp/__init__.py +4 -0
- map_mcp/_utils.py +28 -0
- map_mcp/auth.py +34 -0
- map_mcp/config.py +50 -0
- map_mcp/context.py +39 -0
- map_mcp/main.py +75 -0
- map_mcp/server.py +573 -0
- map_mcp/session.py +79 -0
- map_sdk/__init__.py +29 -0
- map_sdk/evidence.py +68 -0
- map_types/__init__.py +203 -0
- map_types/enums.py +199 -0
- map_types/schemas.py +1351 -0
- multi_agent_platform-0.1.0.dist-info/METADATA +298 -0
- multi_agent_platform-0.1.0.dist-info/RECORD +144 -0
- multi_agent_platform-0.1.0.dist-info/WHEEL +5 -0
- multi_agent_platform-0.1.0.dist-info/entry_points.txt +6 -0
- multi_agent_platform-0.1.0.dist-info/licenses/LICENSE +21 -0
- multi_agent_platform-0.1.0.dist-info/top_level.txt +6 -0
- server/__init__.py +0 -0
- server/__version__.py +14 -0
- server/api/__init__.py +0 -0
- server/api/action_items.py +138 -0
- server/api/agents.py +412 -0
- server/api/audit.py +54 -0
- server/api/background_tasks.py +18 -0
- server/api/common.py +117 -0
- server/api/deps.py +30 -0
- server/api/experiments.py +858 -0
- server/api/feedback.py +75 -0
- server/api/notifications.py +22 -0
- server/api/projects.py +209 -0
- server/api/router.py +25 -0
- server/api/status.py +33 -0
- server/api/topics.py +302 -0
- server/api/webhooks.py +74 -0
- server/auth/__init__.py +8 -0
- server/auth/experiment_access.py +66 -0
- server/config.py +38 -0
- server/db/__init__.py +3 -0
- server/db/base.py +5 -0
- server/db/deadlock_retry.py +146 -0
- server/db/session.py +41 -0
- server/domain/__init__.py +3 -0
- server/domain/encrypted_types.py +63 -0
- server/domain/models.py +713 -0
- server/domain/schemas.py +3 -0
- server/domain/state_machine.py +79 -0
- server/domain/topic_ack_constants.py +9 -0
- server/main.py +148 -0
- server/scripts/__init__.py +0 -0
- server/scripts/migrate_notification_unique.py +231 -0
- server/scripts/purge_audit_pollution.py +116 -0
- server/services/__init__.py +0 -0
- server/services/_lookups.py +26 -0
- server/services/acceptance_service.py +90 -0
- server/services/action_item_migration_service.py +190 -0
- server/services/action_item_service.py +200 -0
- server/services/agent_work_service.py +405 -0
- server/services/archive_lint_service.py +156 -0
- server/services/audit_service.py +457 -0
- server/services/auth.py +66 -0
- server/services/comment_service.py +173 -0
- server/services/errors.py +65 -0
- server/services/escalation_resolver.py +248 -0
- server/services/evidence_service.py +88 -0
- server/services/experiment_capabilities_service.py +277 -0
- server/services/inbound_event_service.py +111 -0
- server/services/lock_service.py +273 -0
- server/services/log_service.py +202 -0
- server/services/mention_service.py +730 -0
- server/services/notification_service.py +939 -0
- server/services/notification_stream.py +138 -0
- server/services/permissions.py +147 -0
- server/services/persona_activity_service.py +108 -0
- server/services/phase_owner_resolver.py +95 -0
- server/services/phase_service.py +381 -0
- server/services/plan_marker_service.py +235 -0
- server/services/plan_service.py +186 -0
- server/services/platform_feedback_service.py +114 -0
- server/services/project_service.py +534 -0
- server/services/project_status_service.py +132 -0
- server/services/review_service.py +707 -0
- server/services/secret_encryption.py +97 -0
- server/services/similarity_service.py +119 -0
- server/services/sse_event_schemas.py +17 -0
- server/services/status_service.py +68 -0
- server/services/template_service.py +134 -0
- server/services/text_utils.py +19 -0
- server/services/thread_activity.py +180 -0
- server/services/todo_persona_filter.py +73 -0
- server/services/todo_service.py +604 -0
- server/services/topic_ack_service.py +312 -0
- server/services/topic_action_item_ops.py +538 -0
- server/services/topic_comment_kind.py +14 -0
- server/services/topic_comment_service.py +237 -0
- server/services/topic_helpers.py +32 -0
- server/services/topic_lifecycle_service.py +478 -0
- server/services/topic_progress_service.py +40 -0
- server/services/topic_resolve_service.py +234 -0
- server/services/topic_service.py +102 -0
- server/services/topic_work_item_service.py +570 -0
- server/services/webhook_service.py +273 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""In-process pub/sub for agent notification SSE streams.
|
|
2
|
+
|
|
3
|
+
支持 ``Last-Event-ID`` 重连(P2 #9):
|
|
4
|
+
|
|
5
|
+
- 每个 agent 维护一个单调递增的 ``event_id``(从 1 开始),每个发布的
|
|
6
|
+
event 都带一个 id。
|
|
7
|
+
- SSE frame 形如 ``id: {event_id}\\ndata: {json}\\n\\n``,浏览器
|
|
8
|
+
``EventSource`` 会自动记录最后接收的 id,断线重连时通过
|
|
9
|
+
``Last-Event-ID`` 请求头传回。
|
|
10
|
+
- 服务端读取该 header,从 per-agent ring buffer 中 replay id 大于它的
|
|
11
|
+
事件,再进入正常的 pub/sub 循环。
|
|
12
|
+
|
|
13
|
+
注意:本实现是 in-process(重启即丢),ring buffer 容量 200 条,覆盖
|
|
14
|
+
常见的网络抖动断线重连场景。跨进程或重启场景需要持久化事件历史,
|
|
15
|
+
那是后续 P3 的范围。
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import contextlib
|
|
22
|
+
import json
|
|
23
|
+
import threading
|
|
24
|
+
import uuid
|
|
25
|
+
from collections import deque
|
|
26
|
+
from queue import Empty, Queue
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from fastapi.responses import StreamingResponse
|
|
30
|
+
|
|
31
|
+
HEARTBEAT_SECONDS = 25
|
|
32
|
+
# 每个 agent 保留最近多少条事件用于 Last-Event-ID replay。
|
|
33
|
+
# 200 条对 wakeable 通知足够(远多于一次 remind 周期内的量),
|
|
34
|
+
# 内存开销可控(每条 ~1KB JSON × 200 × N agents)。
|
|
35
|
+
REPLAY_BUFFER_SIZE = 200
|
|
36
|
+
|
|
37
|
+
_subscribers: dict[uuid.UUID, list[Queue[str]]] = {}
|
|
38
|
+
# per-agent 单调事件 id 计数器 + ring buffer。
|
|
39
|
+
# (next_id, [(event_id, payload_json), ...])
|
|
40
|
+
_event_seqs: dict[uuid.UUID, int] = {}
|
|
41
|
+
_replay_buffers: dict[uuid.UUID, deque[tuple[int, str]]] = {}
|
|
42
|
+
_lock = threading.Lock()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def subscribe(agent_id: uuid.UUID) -> Queue[str]:
|
|
46
|
+
queue: Queue[str] = Queue(maxsize=100)
|
|
47
|
+
with _lock:
|
|
48
|
+
_subscribers.setdefault(agent_id, []).append(queue)
|
|
49
|
+
return queue
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def unsubscribe(agent_id: uuid.UUID, queue: Queue[str]) -> None:
|
|
53
|
+
with _lock:
|
|
54
|
+
queues = _subscribers.get(agent_id)
|
|
55
|
+
if not queues:
|
|
56
|
+
return
|
|
57
|
+
try:
|
|
58
|
+
queues.remove(queue)
|
|
59
|
+
except ValueError:
|
|
60
|
+
return
|
|
61
|
+
if not queues:
|
|
62
|
+
_subscribers.pop(agent_id, None)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def publish(agent_id: uuid.UUID, event: dict[str, Any]) -> None:
|
|
66
|
+
"""发布一个事件,分配单调 id 并写入 per-agent ring buffer。
|
|
67
|
+
|
|
68
|
+
event dict 会被原样序列化(caller 看到的字段不变),SSE 层在 frame
|
|
69
|
+
外面套 ``id:`` 行;客户端解析时 ``event.id`` 即此 id。
|
|
70
|
+
"""
|
|
71
|
+
payload = json.dumps(event, ensure_ascii=False)
|
|
72
|
+
with _lock:
|
|
73
|
+
_event_seqs[agent_id] = _event_seqs.get(agent_id, 0) + 1
|
|
74
|
+
event_id = _event_seqs[agent_id]
|
|
75
|
+
buf = _replay_buffers.setdefault(agent_id, deque(maxlen=REPLAY_BUFFER_SIZE))
|
|
76
|
+
buf.append((event_id, payload))
|
|
77
|
+
queues = list(_subscribers.get(agent_id, []))
|
|
78
|
+
frame = f"id: {event_id}\ndata: {payload}\n\n"
|
|
79
|
+
for queue in queues:
|
|
80
|
+
with contextlib.suppress(Exception):
|
|
81
|
+
queue.put_nowait(frame)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def publish_many(agent_ids: list[uuid.UUID], event: dict[str, Any]) -> None:
|
|
85
|
+
for agent_id in agent_ids:
|
|
86
|
+
publish(agent_id, event)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _drain_replay(agent_id: uuid.UUID, last_event_id: int) -> list[str]:
|
|
90
|
+
"""返回 agent 的 ring buffer 中 id > last_event_id 的事件 frames。"""
|
|
91
|
+
with _lock:
|
|
92
|
+
buf = _replay_buffers.get(agent_id)
|
|
93
|
+
if not buf:
|
|
94
|
+
return []
|
|
95
|
+
return [f"id: {eid}\ndata: {payload}\n\n" for eid, payload in buf if eid > last_event_id]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _parse_last_event_id(header_value: str | None) -> int:
|
|
99
|
+
"""解析 ``Last-Event-ID`` header,非法或缺失返回 0。"""
|
|
100
|
+
if not header_value:
|
|
101
|
+
return 0
|
|
102
|
+
try:
|
|
103
|
+
return max(0, int(header_value.strip()))
|
|
104
|
+
except (ValueError, TypeError):
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def _sse_generator(agent_id: uuid.UUID, last_event_id: int = 0):
|
|
109
|
+
# 1. 先 replay 历史事件(id > last_event_id)
|
|
110
|
+
for frame in _drain_replay(agent_id, last_event_id):
|
|
111
|
+
yield frame
|
|
112
|
+
# 2. 再进入正常 pub/sub 循环
|
|
113
|
+
queue = subscribe(agent_id)
|
|
114
|
+
try:
|
|
115
|
+
while True:
|
|
116
|
+
try:
|
|
117
|
+
data = await asyncio.to_thread(queue.get, True, HEARTBEAT_SECONDS)
|
|
118
|
+
yield data
|
|
119
|
+
except Empty:
|
|
120
|
+
yield ": heartbeat\n\n"
|
|
121
|
+
finally:
|
|
122
|
+
unsubscribe(agent_id, queue)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def notification_sse_response(agent_id: uuid.UUID, last_event_id_header: str | None = None) -> StreamingResponse:
|
|
126
|
+
last_event_id = _parse_last_event_id(last_event_id_header)
|
|
127
|
+
return StreamingResponse(
|
|
128
|
+
_sse_generator(agent_id, last_event_id),
|
|
129
|
+
media_type="text/event-stream",
|
|
130
|
+
headers={
|
|
131
|
+
"Cache-Control": "no-cache",
|
|
132
|
+
"Connection": "keep-alive",
|
|
133
|
+
"X-Accel-Buffering": "no",
|
|
134
|
+
# 告知客户端支持 Last-Event-ID 重连(虽然 EventSource 默认就支持,
|
|
135
|
+
# 显式声明便于其他客户端实现识别)。
|
|
136
|
+
"X-Accel-Allow-Last-Event-ID": "true",
|
|
137
|
+
},
|
|
138
|
+
)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import select
|
|
6
|
+
from sqlalchemy.orm import Session, joinedload
|
|
7
|
+
|
|
8
|
+
from server.domain.models import (
|
|
9
|
+
Agent,
|
|
10
|
+
AgentRole,
|
|
11
|
+
Comment,
|
|
12
|
+
Experiment,
|
|
13
|
+
PlanVersion,
|
|
14
|
+
ProjectStatusVersion,
|
|
15
|
+
Review,
|
|
16
|
+
ReviewItem,
|
|
17
|
+
Topic,
|
|
18
|
+
TopicActionItem,
|
|
19
|
+
TopicComment,
|
|
20
|
+
)
|
|
21
|
+
from server.services.errors import ForbiddenError, NotFoundError, UnauthorizedError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_admin(agent: Agent) -> bool:
|
|
25
|
+
return agent.role == AgentRole.admin
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def require_admin(agent: Agent) -> None:
|
|
29
|
+
if not is_admin(agent):
|
|
30
|
+
raise ForbiddenError("Admin role required")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def ensure_project_access(agent: Agent, project_id: uuid.UUID) -> None:
|
|
34
|
+
if is_admin(agent):
|
|
35
|
+
return
|
|
36
|
+
if agent.project_id != project_id:
|
|
37
|
+
raise ForbiddenError("Access denied to this project")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def ensure_can_revise_project_status(agent: Agent, project_id: uuid.UUID) -> None:
|
|
41
|
+
"""Admin or project-bound agent (e.g. host persona) may revise Current Status MD."""
|
|
42
|
+
ensure_project_access(agent, project_id)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def ensure_experiment_access(db: Session, agent: Agent, experiment_id: uuid.UUID) -> Experiment:
|
|
46
|
+
experiment = db.get(Experiment, experiment_id)
|
|
47
|
+
if experiment is None or experiment.deleted_at is not None:
|
|
48
|
+
raise NotFoundError("Experiment not found")
|
|
49
|
+
ensure_project_access(agent, experiment.project_id)
|
|
50
|
+
return experiment
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def ensure_review_item_access(db: Session, agent: Agent, item_id: uuid.UUID) -> ReviewItem:
|
|
54
|
+
stmt = select(ReviewItem).where(ReviewItem.id == item_id).options(joinedload(ReviewItem.review))
|
|
55
|
+
item = db.scalar(stmt)
|
|
56
|
+
if item is None:
|
|
57
|
+
raise NotFoundError("Review item not found")
|
|
58
|
+
ensure_experiment_access(db, agent, item.review.experiment_id)
|
|
59
|
+
return item
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def ensure_topic_access(db: Session, agent: Agent, topic_id: uuid.UUID) -> Topic:
|
|
63
|
+
topic = db.get(Topic, topic_id)
|
|
64
|
+
if topic is None or topic.deleted_at is not None:
|
|
65
|
+
raise NotFoundError("Topic not found")
|
|
66
|
+
ensure_project_access(agent, topic.project_id)
|
|
67
|
+
return topic
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def ensure_topic_host_or_admin(db: Session, agent: Agent, topic_id: uuid.UUID) -> Topic:
|
|
71
|
+
topic = ensure_topic_access(db, agent, topic_id)
|
|
72
|
+
if topic.creator_agent_id != agent.id and not is_admin(agent):
|
|
73
|
+
raise ForbiddenError("Only the topic host or admin can manage the topic")
|
|
74
|
+
return topic
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def ensure_topic_comment_access(db: Session, agent: Agent, comment_id: uuid.UUID) -> TopicComment:
|
|
78
|
+
comment = db.get(TopicComment, comment_id)
|
|
79
|
+
if comment is None:
|
|
80
|
+
raise NotFoundError("Topic comment not found")
|
|
81
|
+
ensure_topic_access(db, agent, comment.topic_id)
|
|
82
|
+
return comment
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def ensure_audit_target_access(
|
|
86
|
+
db: Session,
|
|
87
|
+
agent: Agent,
|
|
88
|
+
target_type: str,
|
|
89
|
+
target_id: uuid.UUID,
|
|
90
|
+
) -> None:
|
|
91
|
+
if target_type == "experiment":
|
|
92
|
+
ensure_experiment_access(db, agent, target_id)
|
|
93
|
+
elif target_type == "topic":
|
|
94
|
+
ensure_topic_access(db, agent, target_id)
|
|
95
|
+
elif target_type == "plan_version":
|
|
96
|
+
plan = db.get(PlanVersion, target_id)
|
|
97
|
+
if plan is None:
|
|
98
|
+
raise NotFoundError("Plan version not found")
|
|
99
|
+
ensure_experiment_access(db, agent, plan.experiment_id)
|
|
100
|
+
elif target_type == "review":
|
|
101
|
+
review = db.get(Review, target_id)
|
|
102
|
+
if review is None:
|
|
103
|
+
raise NotFoundError("Review not found")
|
|
104
|
+
ensure_experiment_access(db, agent, review.experiment_id)
|
|
105
|
+
elif target_type == "comment":
|
|
106
|
+
comment = db.get(Comment, target_id)
|
|
107
|
+
if comment is None:
|
|
108
|
+
raise NotFoundError("Comment not found")
|
|
109
|
+
ensure_experiment_access(db, agent, comment.experiment_id)
|
|
110
|
+
elif target_type == "project_status_version":
|
|
111
|
+
version = db.get(ProjectStatusVersion, target_id)
|
|
112
|
+
if version is None:
|
|
113
|
+
raise NotFoundError("Project status version not found")
|
|
114
|
+
ensure_project_access(agent, version.project_id)
|
|
115
|
+
elif target_type == "topic_action_item":
|
|
116
|
+
item = db.get(TopicActionItem, target_id)
|
|
117
|
+
if item is None:
|
|
118
|
+
raise NotFoundError("Action item not found")
|
|
119
|
+
ensure_project_access(agent, item.project_id)
|
|
120
|
+
else:
|
|
121
|
+
raise NotFoundError(f"Unknown audit target type: {target_type}")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def resolve_project_id_for_agent(agent: Agent, project_id: uuid.UUID | None) -> uuid.UUID:
|
|
125
|
+
if is_admin(agent):
|
|
126
|
+
if project_id is None:
|
|
127
|
+
raise ForbiddenError("Admin must specify project_id")
|
|
128
|
+
return project_id
|
|
129
|
+
if agent.project_id is None:
|
|
130
|
+
raise ForbiddenError("Agent is not bound to a project")
|
|
131
|
+
if project_id is not None and project_id != agent.project_id:
|
|
132
|
+
raise ForbiddenError("Access denied to this project")
|
|
133
|
+
return agent.project_id
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def ensure_can_register_agent(db: Session, actor: Agent | None, role: AgentRole) -> None:
|
|
137
|
+
"""Allow unauthenticated bootstrap of the first admin; thereafter require admin."""
|
|
138
|
+
from sqlalchemy import func, select
|
|
139
|
+
|
|
140
|
+
count = db.scalar(select(func.count()).select_from(Agent)) or 0
|
|
141
|
+
if count == 0:
|
|
142
|
+
if role != AgentRole.admin:
|
|
143
|
+
raise ForbiddenError("Bootstrap: first agent must have role=admin")
|
|
144
|
+
return
|
|
145
|
+
if actor is None:
|
|
146
|
+
raise UnauthorizedError("Authentication required")
|
|
147
|
+
require_admin(actor)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""0db51e10 I3(5f): 默认选人逻辑(活跃 agent 时间窗 N=7)服务端 helper.
|
|
2
|
+
|
|
3
|
+
承接 plan v2 (5c) / acceptance (f):
|
|
4
|
+
|
|
5
|
+
> 默认选人逻辑单元测试(覆盖 project_id 边界 + agent 状态过滤 +
|
|
6
|
+
> N=7 时间窗边界:恰好 7 天 / 8 天动作 / 7 天内无动作三种边界 case)
|
|
7
|
+
|
|
8
|
+
判定: "活跃 agent" = **最近 7 天(N=7)内有 `log` / `topic` /
|
|
9
|
+
`review` 任一动作的同 project agent**。N=7 可配置(默认 7),过期 agent
|
|
10
|
+
自动排除。
|
|
11
|
+
|
|
12
|
+
沿用 ``escalation_resolver._recent_same_role_agent`` 的判定来源
|
|
13
|
+
(experiment_logs / comments / reviews 三个 source table),但
|
|
14
|
+
本函数返回**所有** role 的活跃 agent 集合(用于 CLI
|
|
15
|
+
``--persona-compare`` 默认选人),不做 single-role 过滤与
|
|
16
|
+
exclude_agent_id。
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import uuid
|
|
22
|
+
from datetime import UTC, datetime, timedelta
|
|
23
|
+
|
|
24
|
+
from sqlalchemy import select
|
|
25
|
+
from sqlalchemy.orm import Session
|
|
26
|
+
|
|
27
|
+
from server.domain.models import Agent, ExperimentLog, Review, TopicComment
|
|
28
|
+
|
|
29
|
+
# 7 天窗口作为 plan v2 (5c) 默认;CLI/SDK 调用方可通过参数覆盖。
|
|
30
|
+
DEFAULT_ACTIVE_WINDOW_DAYS = 7
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def list_active_personas(
|
|
34
|
+
db: Session,
|
|
35
|
+
*,
|
|
36
|
+
project_id: uuid.UUID,
|
|
37
|
+
window_days: int = DEFAULT_ACTIVE_WINDOW_DAYS,
|
|
38
|
+
now: datetime | None = None,
|
|
39
|
+
) -> list[uuid.UUID]:
|
|
40
|
+
"""Return agent UUIDs active in ``project_id`` within the time window.
|
|
41
|
+
|
|
42
|
+
"Active" = any row in ``experiment_logs`` / ``comments`` / ``reviews``
|
|
43
|
+
with ``created_at >= now - window_days`` AND the author /
|
|
44
|
+
reviewer agent belongs to ``project_id``. The function returns the
|
|
45
|
+
**distinct set** of agent UUIDs that satisfy the predicate; no
|
|
46
|
+
ordering guarantee beyond stable iteration order.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
db: SQLAlchemy session.
|
|
50
|
+
project_id: Project scope for the lookup.
|
|
51
|
+
window_days: Lookback window in days (default 7, matches plan
|
|
52
|
+
v2 (5c) / (f) acceptance). Must be >= 1; ``window_days=0``
|
|
53
|
+
is rejected because it would always return zero rows and
|
|
54
|
+
provide a confusing default.
|
|
55
|
+
now: Reference "now" timestamp. Defaults to ``datetime.now(UTC)``.
|
|
56
|
+
Tests inject a fixed value to pin the boundary at exactly
|
|
57
|
+
7 / 8 / 0 days.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
List of distinct agent UUIDs active in the window. May be empty
|
|
61
|
+
when no agent has any recent action. Stable order across
|
|
62
|
+
invocations given the same DB state.
|
|
63
|
+
"""
|
|
64
|
+
if window_days < 1:
|
|
65
|
+
raise ValueError(
|
|
66
|
+
f"window_days must be >= 1 (got {window_days}); "
|
|
67
|
+
"use the default 7 (plan v2 (5c)) or larger."
|
|
68
|
+
)
|
|
69
|
+
if now is None:
|
|
70
|
+
now = datetime.now(UTC)
|
|
71
|
+
cutoff = now - timedelta(days=window_days)
|
|
72
|
+
|
|
73
|
+
project_agents = set(
|
|
74
|
+
db.scalars(select(Agent.id).where(Agent.project_id == project_id)).all()
|
|
75
|
+
)
|
|
76
|
+
if not project_agents:
|
|
77
|
+
return []
|
|
78
|
+
|
|
79
|
+
active: set[uuid.UUID] = set()
|
|
80
|
+
|
|
81
|
+
log_authors = db.execute(
|
|
82
|
+
select(ExperimentLog.author_agent_id).where(
|
|
83
|
+
ExperimentLog.created_at >= cutoff,
|
|
84
|
+
ExperimentLog.author_agent_id.in_(project_agents),
|
|
85
|
+
)
|
|
86
|
+
).all()
|
|
87
|
+
for (agent_id,) in log_authors:
|
|
88
|
+
active.add(agent_id)
|
|
89
|
+
|
|
90
|
+
comment_authors = db.execute(
|
|
91
|
+
select(TopicComment.author_agent_id).where(
|
|
92
|
+
TopicComment.created_at >= cutoff,
|
|
93
|
+
TopicComment.author_agent_id.in_(project_agents),
|
|
94
|
+
)
|
|
95
|
+
).all()
|
|
96
|
+
for (agent_id,) in comment_authors:
|
|
97
|
+
active.add(agent_id)
|
|
98
|
+
|
|
99
|
+
reviewer_ids = db.execute(
|
|
100
|
+
select(Review.reviewer_agent_id).where(
|
|
101
|
+
Review.created_at >= cutoff,
|
|
102
|
+
Review.reviewer_agent_id.in_(project_agents),
|
|
103
|
+
)
|
|
104
|
+
).all()
|
|
105
|
+
for (agent_id,) in reviewer_ids:
|
|
106
|
+
active.add(agent_id)
|
|
107
|
+
|
|
108
|
+
return list(active)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Phase → owner resolution (experiment f873c287 I1(b)).
|
|
2
|
+
|
|
3
|
+
A single source of truth that maps each ``ExperimentPhase`` value to the
|
|
4
|
+
persona who currently holds decision authority to *advance* that phase.
|
|
5
|
+
|
|
6
|
+
This module is intentionally pure (no DB, no side effects) so the
|
|
7
|
+
``my_open_experiments.partition`` layer, the UI copy helper, and the
|
|
8
|
+
``experiments_needing_attention`` filter can all import the same table
|
|
9
|
+
without a circular dependency.
|
|
10
|
+
|
|
11
|
+
Schema: ``PhaseOwner`` enum lives in ``sdk/python/map_types/enums.py`` and
|
|
12
|
+
is the public contract; the string values here MUST match it. Keeping a
|
|
13
|
+
local mirror avoids importing the SDK inside Alembic / CLI layers that
|
|
14
|
+
should not pull pydantic.
|
|
15
|
+
|
|
16
|
+
Why per phase? The decision-owner semantics are stable across runs (see
|
|
17
|
+
experiment plan v2 I1(b) acceptance), but the resolver makes it easy to
|
|
18
|
+
re-route by changing one mapping instead of grepping every consumer.
|
|
19
|
+
|
|
20
|
+
Caveats
|
|
21
|
+
-------
|
|
22
|
+
- ``revise`` is not an ``ExperimentPhase`` value (revising happens inside
|
|
23
|
+
the ``review`` phase via ``plan_revise``); we keep it in the table as a
|
|
24
|
+
"logical phase" so callers can ask ``owner_for("revise")`` when
|
|
25
|
+
computing informational_only / UI copy during plan-revise windows.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from typing import Final
|
|
31
|
+
|
|
32
|
+
# Reuse the SDK enum so the public contract stays single-source. If the
|
|
33
|
+
# enum is ever renamed, this import will surface that immediately at the
|
|
34
|
+
# service layer.
|
|
35
|
+
from map_types.enums import ExperimentPhase, PhaseOwner
|
|
36
|
+
|
|
37
|
+
# Mirror the enum values verbatim so Alembic / non-SDK callers don't need
|
|
38
|
+
# to import pydantic. KEPT IN SYNC with PhaseOwner — assertion below.
|
|
39
|
+
_OWNERS: Final[dict[str, str]] = {
|
|
40
|
+
ExperimentPhase.draft.value: PhaseOwner.host.value,
|
|
41
|
+
ExperimentPhase.review.value: PhaseOwner.reviewer.value,
|
|
42
|
+
# "revise" is a logical sub-phase of review; ``plan_revise`` action
|
|
43
|
+
# is a host decision so the host owns advancement.
|
|
44
|
+
"revise": PhaseOwner.host.value,
|
|
45
|
+
ExperimentPhase.approved.value: PhaseOwner.host.value,
|
|
46
|
+
ExperimentPhase.running.value: PhaseOwner.host.value,
|
|
47
|
+
ExperimentPhase.result_review.value: PhaseOwner.reviewer.value,
|
|
48
|
+
ExperimentPhase.done.value: PhaseOwner.host.value,
|
|
49
|
+
ExperimentPhase.cancelled.value: PhaseOwner.host.value,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
assert set(_OWNERS) == {*{p.value for p in ExperimentPhase}, "revise"}, (
|
|
53
|
+
f"phase_owner_resolver: phase coverage drifted. "
|
|
54
|
+
f"missing={set(p.value for p in ExperimentPhase) - _OWNERS.keys() - {'revise'}} "
|
|
55
|
+
f"extra={set(_OWNERS) - {p.value for p in ExperimentPhase} - {'revise'}}"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def owner_for(phase: str | ExperimentPhase) -> PhaseOwner:
|
|
60
|
+
"""Return the persona who currently holds decision authority for ``phase``.
|
|
61
|
+
|
|
62
|
+
Accepts either an ``ExperimentPhase`` enum member or its raw string
|
|
63
|
+
value. Unknown phases fall back to ``host`` — the conservative default
|
|
64
|
+
that keeps ``informational_only`` false (which means the experiment
|
|
65
|
+
stays in the host's actionable obligation list).
|
|
66
|
+
"""
|
|
67
|
+
key = phase.value if isinstance(phase, ExperimentPhase) else phase
|
|
68
|
+
return PhaseOwner(_OWNERS.get(key, PhaseOwner.host.value))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def is_informational_only(
|
|
72
|
+
phase: str | ExperimentPhase,
|
|
73
|
+
*,
|
|
74
|
+
actions: list[str],
|
|
75
|
+
blocked_on: str | None,
|
|
76
|
+
) -> bool:
|
|
77
|
+
"""I1(a) auto-classification predicate.
|
|
78
|
+
|
|
79
|
+
Returns True iff all three preconditions hold:
|
|
80
|
+
|
|
81
|
+
1. ``actions`` is empty (host has no actionable next step).
|
|
82
|
+
2. ``blocked_on`` is non-empty (a state-machine gate is engaged).
|
|
83
|
+
3. ``phase_owner`` is **not** the host (decision authority is held by
|
|
84
|
+
another persona, so the host can only wait).
|
|
85
|
+
|
|
86
|
+
This predicate is the single source of truth for the
|
|
87
|
+
``my_open_experiments.partition[].informational_only`` field; it is
|
|
88
|
+
re-used by the waker exclusion rule (I1(f)) and by the participant
|
|
89
|
+
filter (I1(e)).
|
|
90
|
+
"""
|
|
91
|
+
if actions:
|
|
92
|
+
return False
|
|
93
|
+
if not blocked_on:
|
|
94
|
+
return False
|
|
95
|
+
return owner_for(phase) is not PhaseOwner.host
|