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.
Files changed (144) hide show
  1. cli/__init__.py +0 -0
  2. cli/action_item_escalation.py +177 -0
  3. cli/agent_client.py +554 -0
  4. cli/bridge_state.py +43 -0
  5. cli/commands/__init__.py +13 -0
  6. cli/commands/action.py +142 -0
  7. cli/commands/agent.py +117 -0
  8. cli/commands/audit.py +68 -0
  9. cli/commands/docs.py +179 -0
  10. cli/commands/experiment.py +755 -0
  11. cli/commands/feedback.py +106 -0
  12. cli/commands/notification.py +213 -0
  13. cli/commands/persona.py +63 -0
  14. cli/commands/project.py +87 -0
  15. cli/commands/runtime.py +105 -0
  16. cli/commands/topic.py +361 -0
  17. cli/e2e_collab.py +602 -0
  18. cli/git_checkpoint.py +68 -0
  19. cli/host_worker_types.py +151 -0
  20. cli/main.py +1553 -0
  21. cli/map_command_client.py +497 -0
  22. cli/participant_worker.py +255 -0
  23. cli/reviewer_worker.py +263 -0
  24. cli/runtime/__init__.py +5 -0
  25. cli/runtime/run_lock.py +497 -0
  26. cli/runtime_chat.py +317 -0
  27. cli/session_wake_log.py +235 -0
  28. cli/simple_waker.py +950 -0
  29. cli/table_render.py +113 -0
  30. cli/wake_backend.py +236 -0
  31. cli/worker_cycle_log.py +36 -0
  32. map_client/__init__.py +37 -0
  33. map_client/bootstrap.py +193 -0
  34. map_client/client.py +1045 -0
  35. map_client/config.py +21 -0
  36. map_client/errors.py +283 -0
  37. map_client/exceptions.py +130 -0
  38. map_client/plan_evidence.py +159 -0
  39. map_client/project_config.py +153 -0
  40. map_client/result_template.py +167 -0
  41. map_client/testing.py +27 -0
  42. map_mcp/__init__.py +4 -0
  43. map_mcp/_utils.py +28 -0
  44. map_mcp/auth.py +34 -0
  45. map_mcp/config.py +50 -0
  46. map_mcp/context.py +39 -0
  47. map_mcp/main.py +75 -0
  48. map_mcp/server.py +573 -0
  49. map_mcp/session.py +79 -0
  50. map_sdk/__init__.py +29 -0
  51. map_sdk/evidence.py +68 -0
  52. map_types/__init__.py +203 -0
  53. map_types/enums.py +199 -0
  54. map_types/schemas.py +1351 -0
  55. multi_agent_platform-0.1.0.dist-info/METADATA +298 -0
  56. multi_agent_platform-0.1.0.dist-info/RECORD +144 -0
  57. multi_agent_platform-0.1.0.dist-info/WHEEL +5 -0
  58. multi_agent_platform-0.1.0.dist-info/entry_points.txt +6 -0
  59. multi_agent_platform-0.1.0.dist-info/licenses/LICENSE +21 -0
  60. multi_agent_platform-0.1.0.dist-info/top_level.txt +6 -0
  61. server/__init__.py +0 -0
  62. server/__version__.py +14 -0
  63. server/api/__init__.py +0 -0
  64. server/api/action_items.py +138 -0
  65. server/api/agents.py +412 -0
  66. server/api/audit.py +54 -0
  67. server/api/background_tasks.py +18 -0
  68. server/api/common.py +117 -0
  69. server/api/deps.py +30 -0
  70. server/api/experiments.py +858 -0
  71. server/api/feedback.py +75 -0
  72. server/api/notifications.py +22 -0
  73. server/api/projects.py +209 -0
  74. server/api/router.py +25 -0
  75. server/api/status.py +33 -0
  76. server/api/topics.py +302 -0
  77. server/api/webhooks.py +74 -0
  78. server/auth/__init__.py +8 -0
  79. server/auth/experiment_access.py +66 -0
  80. server/config.py +38 -0
  81. server/db/__init__.py +3 -0
  82. server/db/base.py +5 -0
  83. server/db/deadlock_retry.py +146 -0
  84. server/db/session.py +41 -0
  85. server/domain/__init__.py +3 -0
  86. server/domain/encrypted_types.py +63 -0
  87. server/domain/models.py +713 -0
  88. server/domain/schemas.py +3 -0
  89. server/domain/state_machine.py +79 -0
  90. server/domain/topic_ack_constants.py +9 -0
  91. server/main.py +148 -0
  92. server/scripts/__init__.py +0 -0
  93. server/scripts/migrate_notification_unique.py +231 -0
  94. server/scripts/purge_audit_pollution.py +116 -0
  95. server/services/__init__.py +0 -0
  96. server/services/_lookups.py +26 -0
  97. server/services/acceptance_service.py +90 -0
  98. server/services/action_item_migration_service.py +190 -0
  99. server/services/action_item_service.py +200 -0
  100. server/services/agent_work_service.py +405 -0
  101. server/services/archive_lint_service.py +156 -0
  102. server/services/audit_service.py +457 -0
  103. server/services/auth.py +66 -0
  104. server/services/comment_service.py +173 -0
  105. server/services/errors.py +65 -0
  106. server/services/escalation_resolver.py +248 -0
  107. server/services/evidence_service.py +88 -0
  108. server/services/experiment_capabilities_service.py +277 -0
  109. server/services/inbound_event_service.py +111 -0
  110. server/services/lock_service.py +273 -0
  111. server/services/log_service.py +202 -0
  112. server/services/mention_service.py +730 -0
  113. server/services/notification_service.py +939 -0
  114. server/services/notification_stream.py +138 -0
  115. server/services/permissions.py +147 -0
  116. server/services/persona_activity_service.py +108 -0
  117. server/services/phase_owner_resolver.py +95 -0
  118. server/services/phase_service.py +381 -0
  119. server/services/plan_marker_service.py +235 -0
  120. server/services/plan_service.py +186 -0
  121. server/services/platform_feedback_service.py +114 -0
  122. server/services/project_service.py +534 -0
  123. server/services/project_status_service.py +132 -0
  124. server/services/review_service.py +707 -0
  125. server/services/secret_encryption.py +97 -0
  126. server/services/similarity_service.py +119 -0
  127. server/services/sse_event_schemas.py +17 -0
  128. server/services/status_service.py +68 -0
  129. server/services/template_service.py +134 -0
  130. server/services/text_utils.py +19 -0
  131. server/services/thread_activity.py +180 -0
  132. server/services/todo_persona_filter.py +73 -0
  133. server/services/todo_service.py +604 -0
  134. server/services/topic_ack_service.py +312 -0
  135. server/services/topic_action_item_ops.py +538 -0
  136. server/services/topic_comment_kind.py +14 -0
  137. server/services/topic_comment_service.py +237 -0
  138. server/services/topic_helpers.py +32 -0
  139. server/services/topic_lifecycle_service.py +478 -0
  140. server/services/topic_progress_service.py +40 -0
  141. server/services/topic_resolve_service.py +234 -0
  142. server/services/topic_service.py +102 -0
  143. server/services/topic_work_item_service.py +570 -0
  144. server/services/webhook_service.py +273 -0
@@ -0,0 +1,478 @@
1
+ """Topic CRUD / summary / round lifecycle helpers.
2
+
3
+ 拆分自 ``topic_service.py``。本模块负责:
4
+ - ``topic_summary`` / ``topic_summaries_for_topics`` 及评论聚合
5
+ - ``create_topic`` / ``list_topics`` / ``get_topic_detail``
6
+ - ``update_topic`` / ``soft_delete_topic`` / ``set_topic_status``
7
+ - ``advance_topic_round`` / ``record_participant_round_ack``
8
+ - ``dismiss_topic`` / ``mark_topic_read``
9
+
10
+ ``get_topic_detail`` 组装 comments + decision,委托
11
+ ``topic_comment_service`` 与 ``topic_resolve_service``。
12
+ ``topic_service`` re-exports 保持 import 兼容。
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import uuid
17
+ from datetime import UTC, datetime
18
+
19
+ from map_types.enums import ExperimentPhase, TopicDiscussionRound
20
+ from sqlalchemy import func, select
21
+ from sqlalchemy.orm import Session
22
+
23
+ from server.domain.models import (
24
+ Agent,
25
+ Experiment,
26
+ Topic,
27
+ TopicComment,
28
+ TopicReadCursor,
29
+ TopicStatus,
30
+ )
31
+ from server.domain.schemas import (
32
+ ExperimentSummaryRead,
33
+ TopicCommentCreate,
34
+ TopicCreate,
35
+ TopicRead,
36
+ TopicReadCursorRead,
37
+ TopicSummaryRead,
38
+ TopicUpdate,
39
+ )
40
+ from server.services import mention_service, topic_ack_service, topic_comment_service
41
+ from server.services._lookups import get_project
42
+ from server.services.errors import ConflictError, NotFoundError, StateTransitionError
43
+ from server.services.text_utils import excerpt
44
+ from server.services.thread_activity import topic_comment_order_clauses, topic_comment_order_clauses_desc
45
+ from server.services.topic_helpers import _agent_names_by_ids, _get_topic
46
+ from server.services.topic_resolve_service import _load_decision, topic_decision_read
47
+
48
+ _TOPIC_CLOSE_BLOCKING_EXPERIMENT_PHASES = (
49
+ ExperimentPhase.draft,
50
+ ExperimentPhase.review,
51
+ ExperimentPhase.approved,
52
+ ExperimentPhase.running,
53
+ ExperimentPhase.result_review,
54
+ )
55
+
56
+ def topic_summary(db: Session, topic: Topic, *, viewer_agent_id: uuid.UUID | None = None) -> TopicSummaryRead:
57
+ return topic_summaries_for_topics(db, [topic], viewer_agent_id=viewer_agent_id)[0]
58
+
59
+
60
+ def dismiss_topic(db: Session, *, agent: Agent, topic_id: uuid.UUID) -> Topic | None:
61
+ """Host-only: hide an open topic from the creator's /todos.
62
+
63
+ Re-surfaces automatically when the topic receives new activity
64
+ (`updated_at` is bumped on new comments), so the dismiss is a soft
65
+ "I've seen the current state" rather than a permanent archive.
66
+ """
67
+ topic = db.get(Topic, topic_id)
68
+ if topic is None or topic.creator_agent_id != agent.id:
69
+ return None
70
+ if topic.dismissed_at is None:
71
+ topic.dismissed_at = datetime.now(UTC)
72
+ topic.dismissed_by_agent_id = agent.id
73
+ db.commit()
74
+ db.refresh(topic)
75
+ return topic
76
+
77
+
78
+ def mark_topic_read(
79
+ db: Session,
80
+ *,
81
+ agent: Agent,
82
+ topic_id: uuid.UUID,
83
+ ) -> TopicReadCursorRead:
84
+ """Advance per-agent read cursor to the latest comment_seq (T3 D4).
85
+
86
+ Independent transaction from comment INSERT (T3-1). Does not clear
87
+ obligation work items (reply / mention / round_ack).
88
+ """
89
+ from server.services.permissions import ensure_topic_access
90
+
91
+ topic = ensure_topic_access(db, agent, topic_id)
92
+ max_seq = int(
93
+ db.scalar(
94
+ select(func.coalesce(func.max(TopicComment.comment_seq), 0)).where(
95
+ TopicComment.topic_id == topic.id
96
+ )
97
+ )
98
+ or 0
99
+ )
100
+ now = datetime.now(UTC)
101
+ cursor = db.scalar(
102
+ select(TopicReadCursor).where(
103
+ TopicReadCursor.topic_id == topic.id,
104
+ TopicReadCursor.agent_id == agent.id,
105
+ )
106
+ )
107
+ if cursor is None:
108
+ cursor = TopicReadCursor(
109
+ topic_id=topic.id,
110
+ agent_id=agent.id,
111
+ last_read_comment_seq=max_seq,
112
+ )
113
+ db.add(cursor)
114
+ else:
115
+ cursor.last_read_comment_seq = max_seq
116
+ cursor.updated_at = now
117
+ db.commit()
118
+ db.refresh(cursor)
119
+ return TopicReadCursorRead(
120
+ topic_id=cursor.topic_id,
121
+ agent_id=cursor.agent_id,
122
+ last_read_comment_seq=cursor.last_read_comment_seq,
123
+ updated_at=cursor.updated_at,
124
+ )
125
+
126
+
127
+ def topic_summaries_for_topics(
128
+ db: Session,
129
+ topics: list[Topic],
130
+ *,
131
+ viewer_agent_id: uuid.UUID | None = None,
132
+ ) -> list[TopicSummaryRead]:
133
+ if not topics:
134
+ return []
135
+
136
+ topic_ids = [topic.id for topic in topics]
137
+ comment_counts = {
138
+ topic_id: count
139
+ for topic_id, count in db.execute(
140
+ select(TopicComment.topic_id, func.count())
141
+ .where(TopicComment.topic_id.in_(topic_ids))
142
+ .group_by(TopicComment.topic_id)
143
+ )
144
+ }
145
+ experiment_counts = {
146
+ topic_id: count
147
+ for topic_id, count in db.execute(
148
+ select(Experiment.topic_id, func.count())
149
+ .where(
150
+ Experiment.topic_id.in_(topic_ids),
151
+ Experiment.deleted_at.is_(None),
152
+ Experiment.phase != ExperimentPhase.cancelled,
153
+ )
154
+ .group_by(Experiment.topic_id)
155
+ )
156
+ }
157
+ creator_names = _agent_names_by_ids(db, {topic.creator_agent_id for topic in topics})
158
+ latest_comments = _latest_topic_comments_by_topic(db, topic_ids)
159
+ latest_author_names = _agent_names_by_ids(
160
+ db, {comment.author_agent_id for comment in latest_comments.values()}
161
+ )
162
+ my_comment_counts: dict[uuid.UUID, int] = {}
163
+ if viewer_agent_id is not None:
164
+ my_comment_counts = _my_comment_counts_by_topic(db, topic_ids, viewer_agent_id)
165
+ return [
166
+ TopicSummaryRead(
167
+ id=topic.id,
168
+ project_id=topic.project_id,
169
+ creator_agent_id=topic.creator_agent_id,
170
+ creator_name=creator_names.get(topic.creator_agent_id),
171
+ title=topic.title,
172
+ description=topic.description,
173
+ status=topic.status,
174
+ pinned=topic.pinned,
175
+ discussion_round=topic.discussion_round,
176
+ round_summary_count=topic.round_summary_count,
177
+ comment_count=comment_counts.get(topic.id, 0),
178
+ experiment_count=experiment_counts.get(topic.id, 0),
179
+ last_comment_id=latest_comments[topic.id].id if topic.id in latest_comments else None,
180
+ last_comment_author_agent_id=(
181
+ latest_comments[topic.id].author_agent_id if topic.id in latest_comments else None
182
+ ),
183
+ last_comment_author_name=(
184
+ latest_author_names.get(latest_comments[topic.id].author_agent_id)
185
+ if topic.id in latest_comments
186
+ else None
187
+ ),
188
+ last_comment_excerpt=(
189
+ excerpt(latest_comments[topic.id].body)
190
+ if topic.id in latest_comments
191
+ else None
192
+ ),
193
+ my_comment_count=my_comment_counts.get(topic.id, 0) if viewer_agent_id is not None else None,
194
+ created_at=topic.created_at,
195
+ updated_at=topic.updated_at,
196
+ archived_at=topic.archived_at,
197
+ dismissed_at=topic.dismissed_at,
198
+ stale_since=topic.advance_round_pending_since,
199
+ )
200
+ for topic in topics
201
+ ]
202
+
203
+
204
+ def _latest_topic_comments_by_topic(
205
+ db: Session, topic_ids: list[uuid.UUID]
206
+ ) -> dict[uuid.UUID, TopicComment]:
207
+ """Return the latest TopicComment per topic in a single query.
208
+
209
+ Previously this was an N+1 loop firing ``SELECT ... ORDER BY ... LIMIT 1``
210
+ per topic. Topic list pages scale linearly with the number of topics, and
211
+ the cost showed up on projects with many open topics. The replacement uses
212
+ ``ROW_NUMBER() OVER (PARTITION BY topic_id ORDER BY <sort>)`` so each
213
+ topic's winning row is selected in one round-trip.
214
+
215
+ The ORDER BY mirrors :func:`thread_activity.topic_comment_order_clauses_desc`
216
+ so the result is the same comment the old per-topic query would have picked.
217
+ """
218
+ if not topic_ids:
219
+ return {}
220
+ rn = (
221
+ func.row_number()
222
+ .over(
223
+ partition_by=TopicComment.topic_id,
224
+ order_by=topic_comment_order_clauses_desc(),
225
+ )
226
+ .label("rn")
227
+ )
228
+ subq = (
229
+ select(TopicComment.id.label("cid"), rn)
230
+ .where(TopicComment.topic_id.in_(topic_ids))
231
+ .subquery()
232
+ )
233
+ winning_ids = db.scalars(
234
+ select(subq.c.cid).where(subq.c.rn == 1)
235
+ ).all()
236
+ if not winning_ids:
237
+ return {}
238
+ comments = db.scalars(
239
+ select(TopicComment).where(TopicComment.id.in_(winning_ids))
240
+ ).all()
241
+ return {comment.topic_id: comment for comment in comments}
242
+
243
+
244
+ def _my_comment_counts_by_topic(
245
+ db: Session, topic_ids: list[uuid.UUID], agent_id: uuid.UUID
246
+ ) -> dict[uuid.UUID, int]:
247
+ if not topic_ids:
248
+ return {}
249
+ return {
250
+ topic_id: count
251
+ for topic_id, count in db.execute(
252
+ select(TopicComment.topic_id, func.count())
253
+ .where(
254
+ TopicComment.topic_id.in_(topic_ids),
255
+ TopicComment.author_agent_id == agent_id,
256
+ )
257
+ .group_by(TopicComment.topic_id)
258
+ )
259
+ }
260
+
261
+
262
+ def _latest_comment_authors_by_topic(
263
+ db: Session, topic_ids: list[uuid.UUID]
264
+ ) -> dict[uuid.UUID, uuid.UUID]:
265
+ return {
266
+ topic_id: comment.author_agent_id
267
+ for topic_id, comment in _latest_topic_comments_by_topic(db, topic_ids).items()
268
+ }
269
+
270
+ def create_topic(
271
+ db: Session,
272
+ project_id: uuid.UUID,
273
+ creator_agent_id: uuid.UUID,
274
+ payload: TopicCreate,
275
+ ) -> Topic:
276
+ get_project(db, project_id)
277
+ creator = db.get(Agent, creator_agent_id)
278
+ if creator is None:
279
+ raise NotFoundError("Creator agent not found")
280
+ topic = Topic(
281
+ project_id=project_id,
282
+ creator_agent_id=creator_agent_id,
283
+ title=payload.title,
284
+ description=payload.description,
285
+ status=TopicStatus.open,
286
+ )
287
+ db.add(topic)
288
+ db.flush()
289
+ mention_service.process_topic_mentions(db, topic=topic, author=creator, commit=False)
290
+ db.commit()
291
+ db.refresh(topic)
292
+ return topic
293
+
294
+
295
+ def list_topics(
296
+ db: Session,
297
+ project_id: uuid.UUID,
298
+ *,
299
+ status: TopicStatus | None = None,
300
+ creator_agent_id: uuid.UUID | None = None,
301
+ q: str | None = None,
302
+ page: int = 1,
303
+ page_size: int = 100,
304
+ include_archived: bool = False,
305
+ viewer_agent_id: uuid.UUID | None = None,
306
+ ) -> tuple[list[TopicSummaryRead], int]:
307
+ get_project(db, project_id)
308
+ stmt = select(Topic).where(Topic.project_id == project_id, Topic.deleted_at.is_(None))
309
+ if not include_archived:
310
+ stmt = stmt.where(Topic.archived_at.is_(None))
311
+ if status is not None:
312
+ stmt = stmt.where(Topic.status == status)
313
+ if creator_agent_id is not None:
314
+ stmt = stmt.where(Topic.creator_agent_id == creator_agent_id)
315
+ if q:
316
+ pattern = f"%{q}%"
317
+ stmt = stmt.where(Topic.title.ilike(pattern) | Topic.description.ilike(pattern))
318
+ total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
319
+ page = max(1, page)
320
+ page_size = max(1, min(page_size, 100))
321
+ stmt = stmt.order_by(Topic.pinned.desc(), Topic.updated_at.desc()).offset((page - 1) * page_size).limit(page_size)
322
+ topics = list(db.scalars(stmt))
323
+ return topic_summaries_for_topics(db, topics, viewer_agent_id=viewer_agent_id), total
324
+
325
+
326
+ def get_topic_detail(db: Session, topic_id: uuid.UUID) -> TopicRead:
327
+ topic = _get_topic(db, topic_id)
328
+ summary = topic_summary(db, topic)
329
+
330
+ exp_stmt = (
331
+ select(Experiment)
332
+ .where(
333
+ Experiment.topic_id == topic.id,
334
+ Experiment.deleted_at.is_(None),
335
+ Experiment.phase != ExperimentPhase.cancelled,
336
+ )
337
+ .order_by(Experiment.created_at.desc())
338
+ )
339
+ experiments = [ExperimentSummaryRead.model_validate(e) for e in db.scalars(exp_stmt)]
340
+ comments = list(db.scalars(
341
+ select(TopicComment)
342
+ .where(TopicComment.topic_id == topic.id)
343
+ .order_by(*topic_comment_order_clauses())
344
+ ))
345
+ comments_tree = topic_comment_service._build_comment_tree(comments, _agent_names_by_ids(
346
+ db, {comment.author_agent_id for comment in comments}
347
+ ))
348
+
349
+ decision = _load_decision(db, topic.id)
350
+ return TopicRead(
351
+ **summary.model_dump(),
352
+ experiments=experiments,
353
+ comments=comments_tree,
354
+ decision=topic_decision_read(db, decision) if decision is not None else None,
355
+ )
356
+
357
+ def update_topic(db: Session, topic_id: uuid.UUID, payload: TopicUpdate) -> Topic:
358
+ topic = _get_topic(db, topic_id)
359
+ data = payload.model_dump(exclude_unset=True)
360
+ archived = data.pop("archived", None)
361
+ for key, value in data.items():
362
+ setattr(topic, key, value)
363
+ if archived is not None:
364
+ topic.archived_at = datetime.now(UTC) if archived else None
365
+ db.commit()
366
+ db.refresh(topic)
367
+ return topic
368
+
369
+
370
+ def soft_delete_topic(db: Session, topic_id: uuid.UUID) -> None:
371
+ topic = _get_topic(db, topic_id)
372
+ topic.deleted_at = datetime.now(UTC)
373
+ db.commit()
374
+
375
+
376
+ def set_topic_status(db: Session, topic_id: uuid.UUID, target: TopicStatus) -> Topic:
377
+ topic = _get_topic(db, topic_id)
378
+ if topic.status == target:
379
+ return topic
380
+ valid = (
381
+ (target == TopicStatus.closed and topic.status == TopicStatus.open)
382
+ or (target == TopicStatus.open and topic.status == TopicStatus.closed)
383
+ )
384
+ if not valid:
385
+ raise StateTransitionError(f"Topic cannot move from {topic.status.value} to {target.value}")
386
+ if target == TopicStatus.closed:
387
+ blocking = db.scalar(
388
+ select(Experiment).where(
389
+ Experiment.topic_id == topic.id,
390
+ Experiment.deleted_at.is_(None),
391
+ Experiment.archived_at.is_(None),
392
+ Experiment.phase.in_(_TOPIC_CLOSE_BLOCKING_EXPERIMENT_PHASES),
393
+ )
394
+ )
395
+ if blocking is not None:
396
+ raise ConflictError(
397
+ "Cannot close topic while linked experiment "
398
+ f"{blocking.id} is {blocking.phase.value}; complete or cancel it first"
399
+ )
400
+ topic.status = target
401
+ db.commit()
402
+ db.refresh(topic)
403
+ return topic
404
+
405
+
406
+ def advance_topic_round(
407
+ db: Session,
408
+ topic_id: uuid.UUID,
409
+ *,
410
+ increment_summary: bool = True,
411
+ acknowledged_by: list[uuid.UUID] | None = None,
412
+ ) -> Topic:
413
+ topic = _get_topic(db, topic_id)
414
+ if topic.status != TopicStatus.open:
415
+ raise ConflictError("Cannot advance a closed topic")
416
+ if topic.discussion_round == TopicDiscussionRound.ready:
417
+ raise ConflictError("Topic discussion round is already ready")
418
+
419
+ topic_ack_service.validate_advance_ack(
420
+ db,
421
+ topic,
422
+ acknowledged_by=acknowledged_by or [],
423
+ )
424
+
425
+ current_count = topic.round_summary_count or 0
426
+ next_count = current_count + 1 if increment_summary else current_count
427
+
428
+ if topic.discussion_round == TopicDiscussionRound.round1:
429
+ if next_count < 1:
430
+ raise ConflictError("Cannot advance round1 before at least one round summary")
431
+ topic.discussion_round = TopicDiscussionRound.round2
432
+ elif topic.discussion_round == TopicDiscussionRound.round2:
433
+ if next_count < 2:
434
+ raise ConflictError("Cannot advance round2 before two round summaries")
435
+ topic.discussion_round = TopicDiscussionRound.ready
436
+ else:
437
+ raise ConflictError(f"Unknown topic discussion round: {topic.discussion_round}")
438
+
439
+ topic.round_summary_count = next_count
440
+ topic.advance_round_pending_since = None
441
+ db.commit()
442
+ db.refresh(topic)
443
+ return topic
444
+
445
+
446
+ def record_participant_round_ack(
447
+ db: Session,
448
+ topic_id: uuid.UUID,
449
+ agent: Agent,
450
+ kind: str,
451
+ ) -> Topic:
452
+ topic = _get_topic(db, topic_id)
453
+ if topic.status != TopicStatus.open:
454
+ raise ConflictError("Cannot ack a closed topic")
455
+ if topic.archived_at is not None:
456
+ raise ConflictError("Cannot ack an archived topic")
457
+ if agent.id == topic.creator_agent_id:
458
+ raise ConflictError("Host cannot post participant ack; use acknowledged_by when advancing")
459
+
460
+ # ack 评论 + mention dismiss 写在同一事务内,单次 commit;任一步失败全部
461
+ # 回滚,避免「ack 评论已写但 mention 未 dismiss」或重试时重复写多条 ack。
462
+ topic_comment_service.create_topic_comment(
463
+ db,
464
+ topic_id,
465
+ agent,
466
+ TopicCommentCreate(body=topic_ack_service.participant_ack_body(kind)),
467
+ commit=False,
468
+ )
469
+ if kind in {"accept", "dismiss"}:
470
+ from server.services import mention_service
471
+
472
+ mention_service.auto_dismiss_mentions_for_round_ack(
473
+ db, topic=topic, agent=agent, ack_kind=kind, commit=False
474
+ )
475
+ db.commit()
476
+ db.refresh(topic)
477
+ return topic
478
+
@@ -0,0 +1,40 @@
1
+ """Per-agent unread topic activity for open discussions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from sqlalchemy.orm import Session
6
+
7
+ from server.domain.models import Agent
8
+ from server.domain.schemas import TopicProgressItemRead, TopicProgressListRead
9
+ from server.services import topic_work_item_service as work_items
10
+
11
+
12
+ def list_topic_progress_for_agent(
13
+ db: Session,
14
+ agent: Agent,
15
+ *,
16
+ bundle: work_items.AgentTopicWorkItems | None = None,
17
+ ) -> TopicProgressListRead:
18
+ """List open topics with work items for ``agent`` (unified diff view)."""
19
+ if agent.project_id is None:
20
+ return TopicProgressListRead(items=[], total=0)
21
+
22
+ if bundle is None:
23
+ bundle = work_items.topic_work_items_bundle_for_agent(db, agent)
24
+ all_items = bundle.items
25
+ items: list[TopicProgressItemRead] = []
26
+ for topic in bundle.open_topics:
27
+ topic_items = [i for i in all_items if i.topic_id == topic.id]
28
+ if not topic_items:
29
+ continue
30
+ comments = bundle.comments_by_topic.get(topic.id)
31
+ item = work_items.topic_progress_item_from_work_items(
32
+ db,
33
+ topic,
34
+ agent,
35
+ topic_items,
36
+ comments=comments,
37
+ )
38
+ if item is not None:
39
+ items.append(item)
40
+ return TopicProgressListRead(items=items, total=len(items))