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,939 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from datetime import UTC, datetime
|
|
3
|
+
from typing import Any, cast
|
|
4
|
+
|
|
5
|
+
from map_types.enums import ExperimentPhase, NotificationCategory, NotificationFingerprintVersion
|
|
6
|
+
from sqlalchemy import event, func, or_, select
|
|
7
|
+
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
8
|
+
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
|
9
|
+
from sqlalchemy.orm import Session
|
|
10
|
+
|
|
11
|
+
from server.domain.models import Agent, AgentRole, Experiment, ExperimentLog, Notification, TopicActionItem
|
|
12
|
+
from server.services import notification_stream
|
|
13
|
+
from server.services.errors import ForbiddenError, NotFoundError
|
|
14
|
+
|
|
15
|
+
# Phase 2 D2: persona agent names used by emit_kind to resolve wake-kind
|
|
16
|
+
# recipients. These are the canonical MAP persona agents bound to a project;
|
|
17
|
+
# the wake kinds (pending_review / pending_result_review / topic_lifecycle /
|
|
18
|
+
# etc.) target one or more of these so the waker can differentiate lifecycle
|
|
19
|
+
# events from generic notifications. Falls back to no recipients if a project
|
|
20
|
+
# has not yet bound a given persona (early onboarding is graceful).
|
|
21
|
+
PERSONA_AGENT_NAMES: dict[str, str] = {
|
|
22
|
+
"host": "multi-agent-platform-host",
|
|
23
|
+
"participant": "multi-agent-platform-participant",
|
|
24
|
+
"reviewer": "multi-agent-platform-reviewer",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
"""Wakeable 事件显式白名单——v0.9 用以替代显式 runtime feature flag。
|
|
28
|
+
|
|
29
|
+
等价性论证(与 topic d0df651c resolve decision 一致):
|
|
30
|
+
- ``NotificationCategory.digest`` 是 schema 默认值,等价于「feature flag off」(不触发 waker)
|
|
31
|
+
- 仅本集合显式列出的事件走 ``wakeable``,等价于「feature flag 显式开启」
|
|
32
|
+
- 不启用 ``system.runtime_attention``(schema 占位),等价于「feature flag 不引入新 wakeable 入口」
|
|
33
|
+
|
|
34
|
+
新增 wakeable 事件必须在此集合内显式声明并经过代码评审,避免 schema/runtime config 双源漂移。
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
WAKEABLE_NOTIFICATION_EVENTS: set[str] = {
|
|
38
|
+
"experiment.lifecycle.withdrawn",
|
|
39
|
+
"experiment.lifecycle.cancelled",
|
|
40
|
+
"experiment.lock.no_progress",
|
|
41
|
+
"review_item.status_changed",
|
|
42
|
+
"system.runtime_attention",
|
|
43
|
+
"topic.lifecycle.closed",
|
|
44
|
+
"topic.lifecycle.reopened",
|
|
45
|
+
"action_item.wake_sent",
|
|
46
|
+
"action_item.stale",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _emit_created(
|
|
51
|
+
recipient_ids: list[uuid.UUID],
|
|
52
|
+
notification_ids: list[uuid.UUID],
|
|
53
|
+
*,
|
|
54
|
+
event: str,
|
|
55
|
+
categories: list[NotificationCategory] | None = None,
|
|
56
|
+
wake_versions: list[int] | None = None,
|
|
57
|
+
fingerprint_versions: list[NotificationFingerprintVersion] | None = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Publish ``notification.created`` SSE frames for wakeable notifications.
|
|
60
|
+
|
|
61
|
+
v0.9 PRD §7.2: the waker drops digest notifications at the SSE frame layer,
|
|
62
|
+
so digest rows must NOT trigger a publish here. ``enqueue_for_agents`` and
|
|
63
|
+
``enqueue_from_event`` pre-filter the recipient list to wakeable rows
|
|
64
|
+
before calling us; ``categories`` is kept parallel so SSE subscribers can
|
|
65
|
+
see which category they received without a second round-trip.
|
|
66
|
+
"""
|
|
67
|
+
if categories is None:
|
|
68
|
+
categories = [NotificationCategory.wakeable] * len(notification_ids)
|
|
69
|
+
if wake_versions is None:
|
|
70
|
+
wake_versions = [1] * len(notification_ids)
|
|
71
|
+
if fingerprint_versions is None:
|
|
72
|
+
fingerprint_versions = [NotificationFingerprintVersion.v2] * len(notification_ids)
|
|
73
|
+
for recipient_id, notification_id, category, wake_version, fp_version in zip(
|
|
74
|
+
recipient_ids,
|
|
75
|
+
notification_ids,
|
|
76
|
+
categories,
|
|
77
|
+
wake_versions,
|
|
78
|
+
fingerprint_versions,
|
|
79
|
+
strict=True,
|
|
80
|
+
):
|
|
81
|
+
if category != NotificationCategory.wakeable:
|
|
82
|
+
continue
|
|
83
|
+
notification_stream.publish(
|
|
84
|
+
recipient_id,
|
|
85
|
+
{
|
|
86
|
+
"type": "notification.created",
|
|
87
|
+
"event": event,
|
|
88
|
+
"notification_id": str(notification_id),
|
|
89
|
+
"category": category.value,
|
|
90
|
+
"wake_version": wake_version,
|
|
91
|
+
"fingerprint_version": fp_version.value,
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_PENDING_SSE_KEY = "map_pending_notification_created_sse"
|
|
97
|
+
_PendingSseFrame = tuple[
|
|
98
|
+
list[uuid.UUID],
|
|
99
|
+
list[uuid.UUID],
|
|
100
|
+
str,
|
|
101
|
+
list[NotificationCategory],
|
|
102
|
+
list[int],
|
|
103
|
+
list[NotificationFingerprintVersion],
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _queue_created_after_commit(
|
|
108
|
+
db: Session,
|
|
109
|
+
recipient_ids: list[uuid.UUID],
|
|
110
|
+
notification_ids: list[uuid.UUID],
|
|
111
|
+
*,
|
|
112
|
+
event: str,
|
|
113
|
+
categories: list[NotificationCategory],
|
|
114
|
+
wake_versions: list[int],
|
|
115
|
+
fingerprint_versions: list[NotificationFingerprintVersion],
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Publish notification SSE frames only after the DB transaction commits."""
|
|
118
|
+
if not notification_ids:
|
|
119
|
+
return
|
|
120
|
+
pending = db.info.setdefault(_PENDING_SSE_KEY, [])
|
|
121
|
+
pending.append(
|
|
122
|
+
(
|
|
123
|
+
list(recipient_ids),
|
|
124
|
+
list(notification_ids),
|
|
125
|
+
event,
|
|
126
|
+
list(categories),
|
|
127
|
+
list(wake_versions),
|
|
128
|
+
list(fingerprint_versions),
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@event.listens_for(Session, "after_commit")
|
|
134
|
+
def _publish_pending_created_after_commit(db: Session) -> None:
|
|
135
|
+
pending: list[_PendingSseFrame] = db.info.pop(_PENDING_SSE_KEY, [])
|
|
136
|
+
for (
|
|
137
|
+
recipient_ids,
|
|
138
|
+
notification_ids,
|
|
139
|
+
event_name,
|
|
140
|
+
categories,
|
|
141
|
+
wake_versions,
|
|
142
|
+
fingerprint_versions,
|
|
143
|
+
) in pending:
|
|
144
|
+
_emit_created(
|
|
145
|
+
recipient_ids,
|
|
146
|
+
notification_ids,
|
|
147
|
+
event=event_name,
|
|
148
|
+
categories=categories,
|
|
149
|
+
wake_versions=wake_versions,
|
|
150
|
+
fingerprint_versions=fingerprint_versions,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@event.listens_for(Session, "after_rollback")
|
|
155
|
+
def _discard_pending_created_after_rollback(db: Session) -> None:
|
|
156
|
+
db.info.pop(_PENDING_SSE_KEY, None)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _resolve_persona_agent_ids(
|
|
160
|
+
db: Session, project_id: uuid.UUID, personas: list[str]
|
|
161
|
+
) -> list[uuid.UUID]:
|
|
162
|
+
"""Resolve persona names to Agent.id within a project.
|
|
163
|
+
|
|
164
|
+
Returns an empty list if no persona matches (e.g. project hasn't bound
|
|
165
|
+
that persona yet) so callers can treat it as a no-op rather than a 500.
|
|
166
|
+
"""
|
|
167
|
+
names = [PERSONA_AGENT_NAMES[p] for p in personas if p in PERSONA_AGENT_NAMES]
|
|
168
|
+
if not names:
|
|
169
|
+
return []
|
|
170
|
+
rows = db.scalars(
|
|
171
|
+
select(Agent).where(Agent.name.in_(names), Agent.project_id == project_id)
|
|
172
|
+
).all()
|
|
173
|
+
return [agent.id for agent in rows]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def classify(event: str, *, wakeable: bool | None = None) -> NotificationCategory:
|
|
177
|
+
"""Single public entry point for category decisions.
|
|
178
|
+
|
|
179
|
+
Per topic d0df651c Round 1 (1b) hard rule: API route / waker / Web MUST NOT
|
|
180
|
+
carry any category decision logic — every notification row's ``category``
|
|
181
|
+
value must come through this function. The explicit ``wakeable`` kwarg is
|
|
182
|
+
reserved for the wakeable-only ``action_item.*`` / ``system.runtime_attention``
|
|
183
|
+
paths where the caller has authoritative knowledge (see
|
|
184
|
+
``notify_owner_action_item_wake`` etc.).
|
|
185
|
+
"""
|
|
186
|
+
if wakeable is not None:
|
|
187
|
+
return NotificationCategory.wakeable if wakeable else NotificationCategory.digest
|
|
188
|
+
if event in WAKEABLE_NOTIFICATION_EVENTS:
|
|
189
|
+
return NotificationCategory.wakeable
|
|
190
|
+
return NotificationCategory.digest
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _event_category(event: str, *, wakeable: bool | None = None) -> NotificationCategory:
|
|
194
|
+
"""Backwards-compat alias; delegates to :func:`classify`."""
|
|
195
|
+
return classify(event, wakeable=wakeable)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def v2_fingerprint(persona: str, notification_id: uuid.UUID, wake_version: int) -> str:
|
|
199
|
+
"""Build the canonical v2 fingerprint for a notification wake.
|
|
200
|
+
|
|
201
|
+
Format from topic d0df651c Round 1 §2: ``{persona}:notification:{notification_id}:{wake_version}``.
|
|
202
|
+
The waker's resume gate relies on this exact shape; v1 fingerprints
|
|
203
|
+
(``inbound:<event_id>``) are rejected and counted via
|
|
204
|
+
``InboundEvent.rejection_count`` (see M30A acceptance §4).
|
|
205
|
+
"""
|
|
206
|
+
return f"{persona}:notification:{notification_id}:{wake_version}"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def is_legacy_v1_fingerprint(fingerprint: str) -> bool:
|
|
210
|
+
"""Return True if ``fingerprint`` uses the pre-v0.9 ``inbound:<event_id>``
|
|
211
|
+
shape so the host's resume endpoint can route it to the v1 rejection
|
|
212
|
+
counter instead of resuming a session.
|
|
213
|
+
|
|
214
|
+
v0.9 fingerprints are namespace-prefixed (``{persona}:notification:...``
|
|
215
|
+
or ``{persona}:{todo_bucket}:...``) so they never start with ``inbound:``.
|
|
216
|
+
"""
|
|
217
|
+
return fingerprint.startswith("inbound:")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _group_target(
|
|
221
|
+
*,
|
|
222
|
+
event: str,
|
|
223
|
+
target_type: str,
|
|
224
|
+
target_id: uuid.UUID | None,
|
|
225
|
+
payload: dict[str, Any] | None,
|
|
226
|
+
) -> tuple[str, str]:
|
|
227
|
+
data = payload or {}
|
|
228
|
+
if event == "topic.comment.created" and data.get("topic_id"):
|
|
229
|
+
return "topic", str(data["topic_id"])
|
|
230
|
+
if event == "comment.created" and data.get("experiment_id"):
|
|
231
|
+
return "experiment", str(data["experiment_id"])
|
|
232
|
+
if target_id is not None:
|
|
233
|
+
return target_type, str(target_id)
|
|
234
|
+
for key in ("topic_id", "experiment_id", "id"):
|
|
235
|
+
if data.get(key):
|
|
236
|
+
inferred_type = "topic" if key == "topic_id" else "experiment" if key == "experiment_id" else target_type
|
|
237
|
+
return inferred_type, str(data[key])
|
|
238
|
+
return target_type, "none"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _group_key(
|
|
242
|
+
*,
|
|
243
|
+
recipient_agent_id: uuid.UUID,
|
|
244
|
+
project_id: uuid.UUID | None,
|
|
245
|
+
event: str,
|
|
246
|
+
target_type: str,
|
|
247
|
+
target_id: uuid.UUID | None,
|
|
248
|
+
payload: dict[str, Any] | None,
|
|
249
|
+
) -> str:
|
|
250
|
+
group_target_type, group_target_id = _group_target(
|
|
251
|
+
event=event,
|
|
252
|
+
target_type=target_type,
|
|
253
|
+
target_id=target_id,
|
|
254
|
+
payload=payload,
|
|
255
|
+
)
|
|
256
|
+
project = str(project_id) if project_id is not None else "global"
|
|
257
|
+
return f"recipient:{recipient_agent_id}:project:{project}:{group_target_type}:{group_target_id}:{event}"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _upsert_notification(
|
|
261
|
+
db: Session,
|
|
262
|
+
*,
|
|
263
|
+
recipient_agent_id: uuid.UUID,
|
|
264
|
+
project_id: uuid.UUID | None,
|
|
265
|
+
event: str,
|
|
266
|
+
summary: str,
|
|
267
|
+
target_type: str,
|
|
268
|
+
target_id: uuid.UUID | None,
|
|
269
|
+
payload: dict[str, Any] | None,
|
|
270
|
+
category: NotificationCategory,
|
|
271
|
+
) -> Notification:
|
|
272
|
+
"""Insert-or-merge a notification row.
|
|
273
|
+
|
|
274
|
+
Race experiment (eca0f522) PR2: replaced select-then-update with a
|
|
275
|
+
single ``INSERT ... ON CONFLICT DO UPDATE`` (atomic on both PG and
|
|
276
|
+
SQLite 3.24+). The ``uq_notifications_recipient_group_key``
|
|
277
|
+
constraint is the merge key. PG-only ``event_count``-via-arithmetic
|
|
278
|
+
keeps the counter monotonic without a second round-trip; the wakeable
|
|
279
|
+
``wake_version`` bump is gated by a CASE on the incoming ``category``
|
|
280
|
+
so digest upserts don't push a new waker fingerprint.
|
|
281
|
+
"""
|
|
282
|
+
now = datetime.now(UTC)
|
|
283
|
+
group_key = _group_key(
|
|
284
|
+
recipient_agent_id=recipient_agent_id,
|
|
285
|
+
project_id=project_id,
|
|
286
|
+
event=event,
|
|
287
|
+
target_type=target_type,
|
|
288
|
+
target_id=target_id,
|
|
289
|
+
payload=payload,
|
|
290
|
+
)
|
|
291
|
+
is_wakeable = category == NotificationCategory.wakeable
|
|
292
|
+
|
|
293
|
+
values = {
|
|
294
|
+
"recipient_agent_id": recipient_agent_id,
|
|
295
|
+
"project_id": project_id,
|
|
296
|
+
"event": event,
|
|
297
|
+
"summary": summary,
|
|
298
|
+
"target_type": target_type,
|
|
299
|
+
"target_id": target_id,
|
|
300
|
+
"payload_json": payload,
|
|
301
|
+
"category": category,
|
|
302
|
+
"group_key": group_key,
|
|
303
|
+
"wake_version": 1,
|
|
304
|
+
"fingerprint_version": NotificationFingerprintVersion.v2,
|
|
305
|
+
"event_count": 1,
|
|
306
|
+
"first_event_at": now,
|
|
307
|
+
"last_event_at": now,
|
|
308
|
+
"updated_at": now,
|
|
309
|
+
}
|
|
310
|
+
set_: dict[str, object] = {
|
|
311
|
+
"event": event,
|
|
312
|
+
"summary": summary,
|
|
313
|
+
"target_type": target_type,
|
|
314
|
+
"target_id": target_id,
|
|
315
|
+
"payload_json": payload,
|
|
316
|
+
"category": category,
|
|
317
|
+
"event_count": Notification.event_count + 1,
|
|
318
|
+
"last_event_at": now,
|
|
319
|
+
"updated_at": now,
|
|
320
|
+
"read_at": None,
|
|
321
|
+
# wake_version bump only on wakeable merges; preserves the v0.9
|
|
322
|
+
# monotonicity invariant while keeping digest fingerprints stable.
|
|
323
|
+
"wake_version": Notification.wake_version + 1 if is_wakeable else Notification.wake_version,
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
# SQLite 3.24+ and PG both accept ON CONFLICT DO UPDATE with the same
|
|
327
|
+
# syntax; SQLAlchemy requires the dialect-specific ``Insert`` class.
|
|
328
|
+
dialect_insert: Any = (
|
|
329
|
+
pg_insert
|
|
330
|
+
if db.bind is not None and db.bind.dialect.name == "postgresql"
|
|
331
|
+
else sqlite_insert
|
|
332
|
+
)
|
|
333
|
+
stmt = dialect_insert(Notification).values(**values)
|
|
334
|
+
stmt = stmt.on_conflict_do_update(
|
|
335
|
+
index_elements=["recipient_agent_id", "group_key"],
|
|
336
|
+
set_=set_,
|
|
337
|
+
)
|
|
338
|
+
# ``populate_existing=True`` forces the identity-map cached object to be
|
|
339
|
+
# refreshed from RETURNING. Without it, sessions with
|
|
340
|
+
# ``expire_on_commit=False`` (the test fixture's savepoint pattern) keep
|
|
341
|
+
# stale ``event_count``/``wake_version`` attributes on subsequent merges,
|
|
342
|
+
# even though the DB row is correct — see PR2 tests.
|
|
343
|
+
result = db.execute(
|
|
344
|
+
stmt.returning(Notification),
|
|
345
|
+
execution_options={"populate_existing": True},
|
|
346
|
+
)
|
|
347
|
+
row = result.scalar_one()
|
|
348
|
+
return cast(Notification, row)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def emit_kind(
|
|
352
|
+
db: Session,
|
|
353
|
+
*,
|
|
354
|
+
project_id: uuid.UUID,
|
|
355
|
+
actor_id: uuid.UUID,
|
|
356
|
+
personas: list[str],
|
|
357
|
+
event: str,
|
|
358
|
+
summary: str,
|
|
359
|
+
target_type: str,
|
|
360
|
+
target_id: uuid.UUID,
|
|
361
|
+
payload: dict[str, Any] | None,
|
|
362
|
+
wakeable: bool | None = None,
|
|
363
|
+
commit: bool = True,
|
|
364
|
+
) -> list[uuid.UUID]:
|
|
365
|
+
"""Insert one Notification per persona agent + SSE publish.
|
|
366
|
+
|
|
367
|
+
Phase 2 D2 entry point for kind-specific wake events. Distinct from
|
|
368
|
+
``enqueue_from_event`` (broadcast to all project agents) — here the
|
|
369
|
+
recipient set is narrowed to persona agents (host / participant /
|
|
370
|
+
reviewer) so the waker can differentiate lifecycle wake kinds from
|
|
371
|
+
the generic ``notification`` bucket.
|
|
372
|
+
|
|
373
|
+
Payload is enriched with ``kind`` (derived from the event name's middle
|
|
374
|
+
segment, e.g. ``topic.lifecycle.closed`` → ``lifecycle``) so the waker
|
|
375
|
+
can build a kind-specific fingerprint without re-deriving from the
|
|
376
|
+
Notification row.
|
|
377
|
+
"""
|
|
378
|
+
recipient_ids = _resolve_persona_agent_ids(db, project_id, personas)
|
|
379
|
+
if not recipient_ids:
|
|
380
|
+
return []
|
|
381
|
+
enriched = dict(payload or {})
|
|
382
|
+
parts = event.split(".")
|
|
383
|
+
if len(parts) >= 2 and "kind" not in enriched:
|
|
384
|
+
enriched["kind"] = ".".join(parts[:-1])
|
|
385
|
+
return enqueue_for_agents(
|
|
386
|
+
db,
|
|
387
|
+
recipient_agent_ids=recipient_ids,
|
|
388
|
+
project_id=project_id,
|
|
389
|
+
actor_id=actor_id,
|
|
390
|
+
event=event,
|
|
391
|
+
summary=summary,
|
|
392
|
+
target_type=target_type,
|
|
393
|
+
target_id=target_id,
|
|
394
|
+
payload=enriched,
|
|
395
|
+
wakeable=wakeable,
|
|
396
|
+
commit=commit,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _recipients_for_project(db: Session, project_id: uuid.UUID | None, exclude_agent_id: uuid.UUID) -> list[Agent]:
|
|
401
|
+
if project_id is None:
|
|
402
|
+
return []
|
|
403
|
+
stmt = select(Agent).where(
|
|
404
|
+
or_(
|
|
405
|
+
Agent.project_id == project_id,
|
|
406
|
+
Agent.role == AgentRole.admin,
|
|
407
|
+
)
|
|
408
|
+
)
|
|
409
|
+
return [agent for agent in db.scalars(stmt) if agent.id != exclude_agent_id]
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def enqueue_from_event(
|
|
413
|
+
db: Session,
|
|
414
|
+
*,
|
|
415
|
+
project_id: uuid.UUID | None,
|
|
416
|
+
actor_id: uuid.UUID,
|
|
417
|
+
event: str,
|
|
418
|
+
summary: str,
|
|
419
|
+
target_type: str,
|
|
420
|
+
target_id: uuid.UUID | None,
|
|
421
|
+
payload: dict[str, Any] | None,
|
|
422
|
+
exclude_recipient_ids: set[uuid.UUID] | None = None,
|
|
423
|
+
wakeable: bool | None = None,
|
|
424
|
+
commit: bool = True,
|
|
425
|
+
) -> list[uuid.UUID]:
|
|
426
|
+
"""Write in-app notifications for project agents (and admins), excluding the actor.
|
|
427
|
+
|
|
428
|
+
``commit=False`` 时只 ``flush`` 不 ``commit``——供需要把通知写入与调用方
|
|
429
|
+
自身业务变更绑在同一事务内的场景。调用方负责最终 commit。
|
|
430
|
+
"""
|
|
431
|
+
skip = exclude_recipient_ids or set()
|
|
432
|
+
recipients = [
|
|
433
|
+
agent
|
|
434
|
+
for agent in _recipients_for_project(db, project_id, actor_id)
|
|
435
|
+
if agent.id not in skip
|
|
436
|
+
]
|
|
437
|
+
if not recipients:
|
|
438
|
+
return []
|
|
439
|
+
|
|
440
|
+
notification_ids: list[uuid.UUID] = []
|
|
441
|
+
recipient_ids: list[uuid.UUID] = []
|
|
442
|
+
categories: list[NotificationCategory] = []
|
|
443
|
+
wake_versions: list[int] = []
|
|
444
|
+
fingerprint_versions: list[NotificationFingerprintVersion] = []
|
|
445
|
+
category = classify(event, wakeable=wakeable)
|
|
446
|
+
for recipient in recipients:
|
|
447
|
+
notification = _upsert_notification(
|
|
448
|
+
db,
|
|
449
|
+
recipient_agent_id=recipient.id,
|
|
450
|
+
project_id=project_id,
|
|
451
|
+
summary=summary,
|
|
452
|
+
event=event,
|
|
453
|
+
target_type=target_type,
|
|
454
|
+
target_id=target_id,
|
|
455
|
+
payload=payload,
|
|
456
|
+
category=category,
|
|
457
|
+
)
|
|
458
|
+
notification_ids.append(notification.id)
|
|
459
|
+
recipient_ids.append(recipient.id)
|
|
460
|
+
categories.append(notification.category)
|
|
461
|
+
wake_versions.append(notification.wake_version)
|
|
462
|
+
fingerprint_versions.append(notification.fingerprint_version)
|
|
463
|
+
_queue_created_after_commit(
|
|
464
|
+
db,
|
|
465
|
+
recipient_ids,
|
|
466
|
+
notification_ids,
|
|
467
|
+
event=event,
|
|
468
|
+
categories=categories,
|
|
469
|
+
wake_versions=wake_versions,
|
|
470
|
+
fingerprint_versions=fingerprint_versions,
|
|
471
|
+
)
|
|
472
|
+
if commit:
|
|
473
|
+
db.commit()
|
|
474
|
+
else:
|
|
475
|
+
db.flush()
|
|
476
|
+
return notification_ids
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def notify_topic_comment_created(
|
|
480
|
+
db: Session,
|
|
481
|
+
*,
|
|
482
|
+
project_id: uuid.UUID,
|
|
483
|
+
actor_id: uuid.UUID,
|
|
484
|
+
creator_agent_id: uuid.UUID,
|
|
485
|
+
target_id: uuid.UUID,
|
|
486
|
+
payload: dict[str, Any] | None,
|
|
487
|
+
) -> list[uuid.UUID]:
|
|
488
|
+
"""Broadcast to project agents; host gets a separate directed notification (no duplicate)."""
|
|
489
|
+
event = "topic.comment.created"
|
|
490
|
+
base_payload = dict(payload or {})
|
|
491
|
+
notification_ids = enqueue_from_event(
|
|
492
|
+
db,
|
|
493
|
+
project_id=project_id,
|
|
494
|
+
actor_id=actor_id,
|
|
495
|
+
event=event,
|
|
496
|
+
summary="话题新评论",
|
|
497
|
+
target_type="topic_comment",
|
|
498
|
+
target_id=target_id,
|
|
499
|
+
payload=base_payload,
|
|
500
|
+
exclude_recipient_ids={creator_agent_id} if creator_agent_id != actor_id else None,
|
|
501
|
+
)
|
|
502
|
+
if creator_agent_id != actor_id:
|
|
503
|
+
notification_ids.extend(
|
|
504
|
+
enqueue_for_agents(
|
|
505
|
+
db,
|
|
506
|
+
recipient_agent_ids=[creator_agent_id],
|
|
507
|
+
project_id=project_id,
|
|
508
|
+
actor_id=actor_id,
|
|
509
|
+
event=event,
|
|
510
|
+
summary="【主持】话题新评论待回复",
|
|
511
|
+
target_type="topic_comment",
|
|
512
|
+
target_id=target_id,
|
|
513
|
+
payload={**base_payload, "host_directed": True},
|
|
514
|
+
)
|
|
515
|
+
)
|
|
516
|
+
return notification_ids
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def enqueue_for_agents(
|
|
520
|
+
db: Session,
|
|
521
|
+
*,
|
|
522
|
+
recipient_agent_ids: list[uuid.UUID],
|
|
523
|
+
project_id: uuid.UUID | None,
|
|
524
|
+
actor_id: uuid.UUID,
|
|
525
|
+
event: str,
|
|
526
|
+
summary: str,
|
|
527
|
+
target_type: str,
|
|
528
|
+
target_id: uuid.UUID | None,
|
|
529
|
+
payload: dict[str, Any] | None,
|
|
530
|
+
wakeable: bool | None = None,
|
|
531
|
+
exclude_actor: bool = True,
|
|
532
|
+
commit: bool = True,
|
|
533
|
+
) -> list[uuid.UUID]:
|
|
534
|
+
"""Write in-app notifications for specific agents (e.g. @mentions).
|
|
535
|
+
|
|
536
|
+
``commit=False`` 时只 ``flush``——供需要把通知与调用方业务变更绑在同一
|
|
537
|
+
事务内的场景(如 mention 处理)。调用方负责最终 commit。
|
|
538
|
+
"""
|
|
539
|
+
notification_ids: list[uuid.UUID] = []
|
|
540
|
+
recipient_ids: list[uuid.UUID] = []
|
|
541
|
+
categories: list[NotificationCategory] = []
|
|
542
|
+
wake_versions: list[int] = []
|
|
543
|
+
fingerprint_versions: list[NotificationFingerprintVersion] = []
|
|
544
|
+
category = classify(event, wakeable=wakeable)
|
|
545
|
+
for recipient_id in recipient_agent_ids:
|
|
546
|
+
if exclude_actor and recipient_id == actor_id:
|
|
547
|
+
continue
|
|
548
|
+
notification = _upsert_notification(
|
|
549
|
+
db,
|
|
550
|
+
recipient_agent_id=recipient_id,
|
|
551
|
+
project_id=project_id,
|
|
552
|
+
event=event,
|
|
553
|
+
summary=summary,
|
|
554
|
+
target_type=target_type,
|
|
555
|
+
target_id=target_id,
|
|
556
|
+
payload=payload,
|
|
557
|
+
category=category,
|
|
558
|
+
)
|
|
559
|
+
notification_ids.append(notification.id)
|
|
560
|
+
recipient_ids.append(recipient_id)
|
|
561
|
+
categories.append(notification.category)
|
|
562
|
+
wake_versions.append(notification.wake_version)
|
|
563
|
+
fingerprint_versions.append(notification.fingerprint_version)
|
|
564
|
+
if notification_ids:
|
|
565
|
+
_queue_created_after_commit(
|
|
566
|
+
db,
|
|
567
|
+
recipient_ids,
|
|
568
|
+
notification_ids,
|
|
569
|
+
event=event,
|
|
570
|
+
categories=categories,
|
|
571
|
+
wake_versions=wake_versions,
|
|
572
|
+
fingerprint_versions=fingerprint_versions,
|
|
573
|
+
)
|
|
574
|
+
if commit:
|
|
575
|
+
db.commit()
|
|
576
|
+
else:
|
|
577
|
+
db.flush()
|
|
578
|
+
return notification_ids
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def list_for_agent(
|
|
582
|
+
db: Session,
|
|
583
|
+
agent: Agent,
|
|
584
|
+
*,
|
|
585
|
+
unread_only: bool = False,
|
|
586
|
+
category: NotificationCategory | None = None,
|
|
587
|
+
target_type: str | None = None,
|
|
588
|
+
limit: int = 50,
|
|
589
|
+
offset: int = 0,
|
|
590
|
+
) -> tuple[list[Notification], int]:
|
|
591
|
+
filters = [Notification.recipient_agent_id == agent.id]
|
|
592
|
+
if unread_only:
|
|
593
|
+
filters.append(Notification.read_at.is_(None))
|
|
594
|
+
if category is not None:
|
|
595
|
+
filters.append(Notification.category == category)
|
|
596
|
+
if target_type is not None:
|
|
597
|
+
filters.append(Notification.target_type == target_type)
|
|
598
|
+
total = db.scalar(select(func.count()).select_from(Notification).where(*filters)) or 0
|
|
599
|
+
rows = list(
|
|
600
|
+
db.scalars(
|
|
601
|
+
select(Notification)
|
|
602
|
+
.where(*filters)
|
|
603
|
+
.order_by(Notification.updated_at.desc(), Notification.created_at.desc())
|
|
604
|
+
.offset(offset)
|
|
605
|
+
.limit(min(limit, 200))
|
|
606
|
+
)
|
|
607
|
+
)
|
|
608
|
+
return rows, total
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def count_unread(
|
|
612
|
+
db: Session,
|
|
613
|
+
agent: Agent,
|
|
614
|
+
*,
|
|
615
|
+
category: NotificationCategory | None = None,
|
|
616
|
+
target_type: str | None = None,
|
|
617
|
+
) -> int:
|
|
618
|
+
filters = [Notification.recipient_agent_id == agent.id, Notification.read_at.is_(None)]
|
|
619
|
+
if category is not None:
|
|
620
|
+
filters.append(Notification.category == category)
|
|
621
|
+
if target_type is not None:
|
|
622
|
+
filters.append(Notification.target_type == target_type)
|
|
623
|
+
return (
|
|
624
|
+
db.scalar(
|
|
625
|
+
select(func.count())
|
|
626
|
+
.select_from(Notification)
|
|
627
|
+
.where(*filters)
|
|
628
|
+
)
|
|
629
|
+
or 0
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def mark_agent_mentioned_notifications_read_no_commit(
|
|
634
|
+
db: Session,
|
|
635
|
+
*,
|
|
636
|
+
mentions: list[Any],
|
|
637
|
+
now: datetime | None = None,
|
|
638
|
+
) -> int:
|
|
639
|
+
"""Mark ``agent.mentioned`` notifications read when their mention is dismissed."""
|
|
640
|
+
from server.domain.models import Mention
|
|
641
|
+
|
|
642
|
+
if not mentions:
|
|
643
|
+
return 0
|
|
644
|
+
now = now or datetime.now(UTC)
|
|
645
|
+
touched = 0
|
|
646
|
+
for mention in mentions:
|
|
647
|
+
if not isinstance(mention, Mention):
|
|
648
|
+
continue
|
|
649
|
+
rows = list(
|
|
650
|
+
db.scalars(
|
|
651
|
+
select(Notification).where(
|
|
652
|
+
Notification.recipient_agent_id == mention.mentioned_agent_id,
|
|
653
|
+
Notification.event == "agent.mentioned",
|
|
654
|
+
Notification.read_at.is_(None),
|
|
655
|
+
Notification.target_id == mention.source_id,
|
|
656
|
+
)
|
|
657
|
+
)
|
|
658
|
+
)
|
|
659
|
+
for row in rows:
|
|
660
|
+
row.read_at = now
|
|
661
|
+
touched += 1
|
|
662
|
+
return touched
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def mark_read(db: Session, agent: Agent, notification_id: uuid.UUID) -> Notification:
|
|
666
|
+
notification = db.get(Notification, notification_id)
|
|
667
|
+
if notification is None:
|
|
668
|
+
raise NotFoundError("Notification not found")
|
|
669
|
+
if notification.recipient_agent_id != agent.id:
|
|
670
|
+
raise ForbiddenError("Cannot mark another agent's notification")
|
|
671
|
+
if notification.read_at is None:
|
|
672
|
+
notification.read_at = datetime.now(UTC)
|
|
673
|
+
db.commit()
|
|
674
|
+
db.refresh(notification)
|
|
675
|
+
return notification
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def mark_all_read(db: Session, agent: Agent) -> int:
|
|
679
|
+
now = datetime.now(UTC)
|
|
680
|
+
rows = list(
|
|
681
|
+
db.scalars(
|
|
682
|
+
select(Notification).where(
|
|
683
|
+
Notification.recipient_agent_id == agent.id,
|
|
684
|
+
Notification.read_at.is_(None),
|
|
685
|
+
)
|
|
686
|
+
)
|
|
687
|
+
)
|
|
688
|
+
for row in rows:
|
|
689
|
+
row.read_at = now
|
|
690
|
+
if rows:
|
|
691
|
+
db.commit()
|
|
692
|
+
return len(rows)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
# ---------------------------------------------------------------------------
|
|
696
|
+
# action_item wake / stale notification helpers (experiment B, plan §3 #2 #3)
|
|
697
|
+
# ---------------------------------------------------------------------------
|
|
698
|
+
#
|
|
699
|
+
# The runtime-waker calls ``mark_wake_sent`` / ``mark_stale`` via the API,
|
|
700
|
+
# but those endpoints only mutate the action_item + write the audit row.
|
|
701
|
+
# They do NOT post any in-app notification — by design, the wakeable
|
|
702
|
+
# signals the assignee / admin sees live here, alongside the audit row.
|
|
703
|
+
#
|
|
704
|
+
# Why split this out instead of folding into the endpoint wrappers:
|
|
705
|
+
# - Keeps ``action_item_service`` / ``topic_service`` free of notification
|
|
706
|
+
# service imports (service layering: audit + state in service, fan-out in
|
|
707
|
+
# notification service).
|
|
708
|
+
# - Makes the actor / recipient policy explicit per plan section §3 #2 #3:
|
|
709
|
+
# wake goes to the assignee; stale goes to admins; creator is intentionally
|
|
710
|
+
# skipped (audit-only path, see I6).
|
|
711
|
+
# ---------------------------------------------------------------------------
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _resolve_admin_agent_ids(
|
|
715
|
+
db: Session,
|
|
716
|
+
*,
|
|
717
|
+
exclude_agent_id: uuid.UUID | None,
|
|
718
|
+
) -> list[uuid.UUID]:
|
|
719
|
+
"""Return the list of admin Agent ids in the system.
|
|
720
|
+
|
|
721
|
+
Admin role is global in this codebase (no per-project scoping), so a
|
|
722
|
+
single query against ``Agent.role == AgentRole.admin`` is sufficient.
|
|
723
|
+
``exclude_agent_id`` filters the assignee out so the owner does not get
|
|
724
|
+
a duplicate notification through the admin path when the assignee is
|
|
725
|
+
themselves an admin.
|
|
726
|
+
"""
|
|
727
|
+
rows = db.scalars(select(Agent.id).where(Agent.role == AgentRole.admin)).all()
|
|
728
|
+
if exclude_agent_id is None:
|
|
729
|
+
return list(rows)
|
|
730
|
+
return [row for row in rows if row != exclude_agent_id]
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def notify_owner_action_item_wake(
|
|
734
|
+
db: Session,
|
|
735
|
+
*,
|
|
736
|
+
action_item: TopicActionItem,
|
|
737
|
+
) -> list[uuid.UUID]:
|
|
738
|
+
"""Send a wakeable notification to the assignee after a wake bump.
|
|
739
|
+
|
|
740
|
+
Plan §3 wake path: the runtime-waker wakes the owner at T+24h / T+72h /
|
|
741
|
+
every 7d up to 4 times. Each bump surfaces here as an in-app notification
|
|
742
|
+
so the assignee sees a wakeable signal in their todos (independent of
|
|
743
|
+
whether they happen to be looking at the action_item list right now).
|
|
744
|
+
Falls through silently when the item has no owner (shouldn't happen —
|
|
745
|
+
``mark_wake_sent`` already rejected unassigned items upstream — but the
|
|
746
|
+
guard is here for defence-in-depth).
|
|
747
|
+
"""
|
|
748
|
+
if action_item.owner_agent_id is None:
|
|
749
|
+
return []
|
|
750
|
+
payload: dict[str, object] = {
|
|
751
|
+
"action_item_id": str(action_item.id),
|
|
752
|
+
"topic_id": str(action_item.topic_id),
|
|
753
|
+
"wake_count": action_item.wake_count,
|
|
754
|
+
}
|
|
755
|
+
if action_item.last_woken_at is not None:
|
|
756
|
+
payload["last_woken_at"] = action_item.last_woken_at.isoformat()
|
|
757
|
+
return enqueue_for_agents(
|
|
758
|
+
db,
|
|
759
|
+
recipient_agent_ids=[action_item.owner_agent_id],
|
|
760
|
+
project_id=action_item.project_id,
|
|
761
|
+
actor_id=action_item.owner_agent_id,
|
|
762
|
+
event="action_item.wake_sent",
|
|
763
|
+
summary=f"待办提醒:{action_item.title}(第 {action_item.wake_count} 次)",
|
|
764
|
+
target_type="topic_action_item",
|
|
765
|
+
target_id=action_item.id,
|
|
766
|
+
payload=payload,
|
|
767
|
+
wakeable=True,
|
|
768
|
+
exclude_actor=False,
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def notify_admin_action_item_stale(
|
|
773
|
+
db: Session,
|
|
774
|
+
*,
|
|
775
|
+
action_item: TopicActionItem,
|
|
776
|
+
) -> list[uuid.UUID]:
|
|
777
|
+
"""Send a wakeable notification to every admin after a stale transition.
|
|
778
|
+
|
|
779
|
+
Plan §3 #2 (3c admin 优先): admin is the most stable收口 because the
|
|
780
|
+
creator may have been deactivated / changed roles / left the project.
|
|
781
|
+
The owner is explicitly excluded — if the owner happens to be an admin
|
|
782
|
+
they'd otherwise get a duplicate notification through both paths, and
|
|
783
|
+
the audit row + plan §3 #3 (3c creator audit-only) policy says admin
|
|
784
|
+
notification should not double as the owner's channel.
|
|
785
|
+
|
|
786
|
+
Plan §3 #3 (creator audit-only): the creator is intentionally NOT in
|
|
787
|
+
this recipient set. They can find the stale event via ``action_items``
|
|
788
|
+
or the audit history, but they do NOT get a wake notification (per I6).
|
|
789
|
+
"""
|
|
790
|
+
admin_ids = _resolve_admin_agent_ids(
|
|
791
|
+
db, exclude_agent_id=action_item.owner_agent_id
|
|
792
|
+
)
|
|
793
|
+
if not admin_ids:
|
|
794
|
+
return []
|
|
795
|
+
payload: dict[str, object] = {
|
|
796
|
+
"action_item_id": str(action_item.id),
|
|
797
|
+
"topic_id": str(action_item.topic_id),
|
|
798
|
+
"wake_count": action_item.wake_count,
|
|
799
|
+
}
|
|
800
|
+
if action_item.stale_at is not None:
|
|
801
|
+
payload["stale_at"] = action_item.stale_at.isoformat()
|
|
802
|
+
# actor_id is unused because exclude_actor=False — every admin in the
|
|
803
|
+
# resolved list gets the notification regardless. We still need to pass
|
|
804
|
+
# a UUID-shaped value to satisfy the signature, so use the first admin
|
|
805
|
+
# or the owner as a stable label.
|
|
806
|
+
actor_id = action_item.owner_agent_id or admin_ids[0]
|
|
807
|
+
return enqueue_for_agents(
|
|
808
|
+
db,
|
|
809
|
+
recipient_agent_ids=admin_ids,
|
|
810
|
+
project_id=action_item.project_id,
|
|
811
|
+
actor_id=actor_id,
|
|
812
|
+
event="action_item.stale",
|
|
813
|
+
summary=f"行动项已 stale:{action_item.title}",
|
|
814
|
+
target_type="topic_action_item",
|
|
815
|
+
target_id=action_item.id,
|
|
816
|
+
payload=payload,
|
|
817
|
+
wakeable=True,
|
|
818
|
+
exclude_actor=False,
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _aware(value: datetime | None) -> datetime | None:
|
|
823
|
+
if value is None:
|
|
824
|
+
return None
|
|
825
|
+
if value.tzinfo is None:
|
|
826
|
+
return value.replace(tzinfo=UTC)
|
|
827
|
+
return value
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _latest_experiment_log_at(db: Session, experiment_id: uuid.UUID) -> datetime | None:
|
|
831
|
+
return db.scalar(
|
|
832
|
+
select(func.max(ExperimentLog.created_at)).where(ExperimentLog.experiment_id == experiment_id)
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _project_agent_ids(db: Session, project_id: uuid.UUID, *, exclude: set[uuid.UUID]) -> list[uuid.UUID]:
|
|
837
|
+
stmt = select(Agent.id).where(Agent.project_id == project_id)
|
|
838
|
+
if exclude:
|
|
839
|
+
stmt = stmt.where(Agent.id.notin_(exclude))
|
|
840
|
+
rows = db.scalars(stmt).all()
|
|
841
|
+
return list(rows)
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def notify_stalled_experiment_locks(
|
|
845
|
+
db: Session,
|
|
846
|
+
*,
|
|
847
|
+
project_id: uuid.UUID | None = None,
|
|
848
|
+
now: datetime | None = None,
|
|
849
|
+
progress_threshold: float = 0.5,
|
|
850
|
+
wake_threshold: float = 0.8,
|
|
851
|
+
commit: bool = True,
|
|
852
|
+
) -> list[uuid.UUID]:
|
|
853
|
+
"""Notify when a running experiment holds the execution lock without progress.
|
|
854
|
+
|
|
855
|
+
D experiment slice:
|
|
856
|
+
- holder/host gets a wakeable notification once the lock has consumed at
|
|
857
|
+
least ``wake_threshold`` of its TTL without a new execution log.
|
|
858
|
+
- project members get a digest notification once the lock has consumed at
|
|
859
|
+
least ``progress_threshold`` of its TTL without a new execution log.
|
|
860
|
+
|
|
861
|
+
Notification grouping keeps repeated scans from creating many rows; wakeable
|
|
862
|
+
upserts still bump ``wake_version`` so waker fingerprints can advance.
|
|
863
|
+
"""
|
|
864
|
+
reference = now or datetime.now(UTC)
|
|
865
|
+
if reference.tzinfo is None:
|
|
866
|
+
reference = reference.replace(tzinfo=UTC)
|
|
867
|
+
emitted: list[uuid.UUID] = []
|
|
868
|
+
filters = [
|
|
869
|
+
Experiment.phase == ExperimentPhase.running,
|
|
870
|
+
Experiment.lock_holder_experiment_id.is_not(None),
|
|
871
|
+
Experiment.lock_acquired_at.is_not(None),
|
|
872
|
+
]
|
|
873
|
+
if project_id is not None:
|
|
874
|
+
filters.append(Experiment.project_id == project_id)
|
|
875
|
+
experiments = db.scalars(select(Experiment).where(*filters)).all()
|
|
876
|
+
for experiment in experiments:
|
|
877
|
+
acquired_at = _aware(experiment.lock_acquired_at)
|
|
878
|
+
if acquired_at is None:
|
|
879
|
+
continue
|
|
880
|
+
ttl_seconds = int(experiment.lock_ttl_seconds or 0)
|
|
881
|
+
if ttl_seconds <= 0:
|
|
882
|
+
continue
|
|
883
|
+
elapsed = max(0.0, (reference - acquired_at).total_seconds())
|
|
884
|
+
ratio = elapsed / ttl_seconds
|
|
885
|
+
if ratio < progress_threshold:
|
|
886
|
+
continue
|
|
887
|
+
last_log_at = _aware(_latest_experiment_log_at(db, experiment.id))
|
|
888
|
+
if last_log_at is not None and last_log_at > acquired_at:
|
|
889
|
+
continue
|
|
890
|
+
payload: dict[str, object] = {
|
|
891
|
+
"experiment_id": str(experiment.id),
|
|
892
|
+
"lock_holder_experiment_id": str(experiment.lock_holder_experiment_id),
|
|
893
|
+
"lock_acquired_at": acquired_at.isoformat(),
|
|
894
|
+
"lock_ttl_seconds": ttl_seconds,
|
|
895
|
+
"elapsed_seconds": int(elapsed),
|
|
896
|
+
"threshold_ratio": ratio,
|
|
897
|
+
"last_log_at": last_log_at.isoformat() if last_log_at else None,
|
|
898
|
+
}
|
|
899
|
+
summary = f"实验锁长时间无进展:{experiment.title}"
|
|
900
|
+
holder_ids = [experiment.creator_agent_id]
|
|
901
|
+
if ratio >= wake_threshold:
|
|
902
|
+
emitted.extend(
|
|
903
|
+
enqueue_for_agents(
|
|
904
|
+
db,
|
|
905
|
+
recipient_agent_ids=holder_ids,
|
|
906
|
+
project_id=experiment.project_id,
|
|
907
|
+
actor_id=experiment.creator_agent_id,
|
|
908
|
+
event="experiment.lock.no_progress",
|
|
909
|
+
summary=summary,
|
|
910
|
+
target_type="experiment",
|
|
911
|
+
target_id=experiment.id,
|
|
912
|
+
payload=payload,
|
|
913
|
+
wakeable=True,
|
|
914
|
+
exclude_actor=False,
|
|
915
|
+
commit=False,
|
|
916
|
+
)
|
|
917
|
+
)
|
|
918
|
+
digest_recipients = _project_agent_ids(db, experiment.project_id, exclude=set(holder_ids))
|
|
919
|
+
emitted.extend(
|
|
920
|
+
enqueue_for_agents(
|
|
921
|
+
db,
|
|
922
|
+
recipient_agent_ids=digest_recipients,
|
|
923
|
+
project_id=experiment.project_id,
|
|
924
|
+
actor_id=experiment.creator_agent_id,
|
|
925
|
+
event="experiment.lock.no_progress",
|
|
926
|
+
summary=summary,
|
|
927
|
+
target_type="experiment",
|
|
928
|
+
target_id=experiment.id,
|
|
929
|
+
payload=payload,
|
|
930
|
+
wakeable=False,
|
|
931
|
+
exclude_actor=False,
|
|
932
|
+
commit=False,
|
|
933
|
+
)
|
|
934
|
+
)
|
|
935
|
+
if commit:
|
|
936
|
+
db.commit()
|
|
937
|
+
else:
|
|
938
|
+
db.flush()
|
|
939
|
+
return emitted
|