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,538 @@
1
+ """Topic action-item list / mutate / serialize helpers.
2
+
3
+ 拆分自 ``topic_service.py`` 以缓解超大模块可读性问题。
4
+ 本模块负责 action item 的读序列化与生命周期 mutation:
5
+ - ``_action_item_read`` / suggest-link / linked-phase batch helpers
6
+ - ``list_action_items``
7
+ - ``deliver`` / ``complete`` / ``cancel`` / ``link``
8
+ - ``mark_wake_sent_action_item`` / ``mark_stale_action_item``
9
+
10
+ ``topic_service`` re-exports 公开与私有 API,保持现有 import 不破。
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import uuid
15
+ from datetime import UTC, datetime, timedelta
16
+ from typing import Any
17
+
18
+ from map_types.enums import ActionItemCategory, ExperimentPhase, TopicActionItemStatus
19
+ from map_types.schemas import ActionItemCancel
20
+ from sqlalchemy import select
21
+ from sqlalchemy.orm import Session, joinedload
22
+
23
+ from server.domain.models import Agent, Experiment, Topic, TopicActionItem
24
+ from server.domain.schemas import TopicActionItemRead
25
+ from server.services import action_item_service, audit_service, notification_service
26
+ from server.services._lookups import get_project
27
+ from server.services.errors import ConflictError, ForbiddenError, NotFoundError
28
+ from server.services.permissions import is_admin
29
+
30
+
31
+ def _suggest_linked_experiment(db: Session, item: TopicActionItem) -> tuple[uuid.UUID | None, str | None]:
32
+ """Find a recently-done experiment in the same project whose owner matches
33
+ the action item's owner and whose title is a substring match (case-insensitive,
34
+ space-insensitive) of the action item title. Returns ``(None, None)`` when
35
+ the item is not eligible (closed, already linked, no owner) or no match.
36
+ """
37
+ if item.status != TopicActionItemStatus.open:
38
+ return None, None
39
+ if item.linked_experiment_id is not None:
40
+ return None, None
41
+ if item.owner_agent_id is None:
42
+ return None, None
43
+
44
+ cutoff = datetime.now(UTC) - timedelta(days=30)
45
+ item_norm = item.title.lower().replace(" ", "")
46
+ candidates = db.scalars(
47
+ select(Experiment)
48
+ .where(
49
+ Experiment.project_id == item.project_id,
50
+ Experiment.creator_agent_id == item.owner_agent_id,
51
+ Experiment.phase == ExperimentPhase.done,
52
+ Experiment.deleted_at.is_(None),
53
+ Experiment.archived_at.is_(None),
54
+ Experiment.updated_at >= cutoff,
55
+ )
56
+ .order_by(Experiment.updated_at.desc())
57
+ .limit(50)
58
+ ).all()
59
+ for exp in candidates:
60
+ exp_norm = exp.title.lower().replace(" ", "")
61
+ if not exp_norm or not item_norm:
62
+ continue
63
+ if exp_norm in item_norm or item_norm in exp_norm:
64
+ return exp.id, exp.title
65
+ return None, None
66
+
67
+
68
+ def _suggest_linked_experiments_batch(
69
+ db: Session, items: list[TopicActionItem]
70
+ ) -> dict[uuid.UUID, tuple[uuid.UUID | None, str | None]]:
71
+ """Batch counterpart of :func:`_suggest_linked_experiment`.
72
+
73
+ List / decision-read 路径会对多个 action_item 逐条调用 read,若每条都
74
+ 单独查候选实验会产生 N+1(每条一次 SQL + 50 行内存匹配)。本函数按
75
+ ``(project_id, owner_agent_id)`` 分桶,每桶只查一次候选实验,再在 Python
76
+ 内做子串匹配。不合格(非 open / 已 link / 无 owner)的 item 直接映射到
77
+ ``(None, None)``。
78
+ """
79
+ result: dict[uuid.UUID, tuple[uuid.UUID | None, str | None]] = {}
80
+ eligible: list[TopicActionItem] = []
81
+ for item in items:
82
+ if (
83
+ item.status == TopicActionItemStatus.open
84
+ and item.linked_experiment_id is None
85
+ and item.owner_agent_id is not None
86
+ ):
87
+ eligible.append(item)
88
+ else:
89
+ result[item.id] = (None, None)
90
+ if not eligible:
91
+ return result
92
+
93
+ cutoff = datetime.now(UTC) - timedelta(days=30)
94
+ groups: dict[tuple[uuid.UUID, uuid.UUID | None], list[TopicActionItem]] = {}
95
+ for item in eligible:
96
+ groups.setdefault((item.project_id, item.owner_agent_id), []).append(item)
97
+
98
+ for (project_id, owner_id), group_items in groups.items():
99
+ candidates = db.scalars(
100
+ select(Experiment)
101
+ .where(
102
+ Experiment.project_id == project_id,
103
+ Experiment.creator_agent_id == owner_id,
104
+ Experiment.phase == ExperimentPhase.done,
105
+ Experiment.deleted_at.is_(None),
106
+ Experiment.archived_at.is_(None),
107
+ Experiment.updated_at >= cutoff,
108
+ )
109
+ .order_by(Experiment.updated_at.desc())
110
+ .limit(50)
111
+ ).all()
112
+ for item in group_items:
113
+ item_norm = item.title.lower().replace(" ", "")
114
+ match: tuple[uuid.UUID | None, str | None] = (None, None)
115
+ if item_norm:
116
+ for exp in candidates:
117
+ exp_norm = exp.title.lower().replace(" ", "")
118
+ if exp_norm and (exp_norm in item_norm or item_norm in exp_norm):
119
+ match = (exp.id, exp.title)
120
+ break
121
+ result[item.id] = match
122
+ return result
123
+
124
+
125
+ def _linked_experiment_phases_batch(
126
+ db: Session, items: list[TopicActionItem]
127
+ ) -> dict[uuid.UUID, ExperimentPhase | None]:
128
+ """Batch-fetch ``linked_experiment_phase`` for a list of action items.
129
+
130
+ Returns a mapping ``{item.id: phase | None}``. Items without a linked
131
+ experiment map to ``None`` without hitting the DB. Used by list / todos
132
+ read paths to avoid an N+1 when serializing many action items.
133
+ """
134
+ result: dict[uuid.UUID, ExperimentPhase | None] = {}
135
+ linked_ids: list[uuid.UUID] = []
136
+ for item in items:
137
+ if item.linked_experiment_id is not None:
138
+ linked_ids.append(item.linked_experiment_id)
139
+ else:
140
+ result[item.id] = None
141
+ if not linked_ids:
142
+ return result
143
+ rows = db.execute(
144
+ select(Experiment.id, Experiment.phase).where(Experiment.id.in_(linked_ids))
145
+ ).all()
146
+ phase_by_id: dict[uuid.UUID, ExperimentPhase] = {row[0]: row[1] for row in rows}
147
+ for item in items:
148
+ if item.linked_experiment_id is not None:
149
+ result[item.id] = phase_by_id.get(item.linked_experiment_id)
150
+ return result
151
+
152
+
153
+ def _action_item_read(
154
+ db: Session,
155
+ item: TopicActionItem,
156
+ *,
157
+ suggested: tuple[uuid.UUID | None, str | None] | None = None,
158
+ linked_experiment_phase: ExperimentPhase | None = None,
159
+ linked_experiment_phase_provided: bool = False,
160
+ ) -> TopicActionItemRead:
161
+ owner_name = None
162
+ if item.owner_agent_id is not None:
163
+ owner = getattr(item, "owner", None)
164
+ if owner is None:
165
+ owner = db.get(Agent, item.owner_agent_id)
166
+ owner_name = owner.name if owner else None
167
+ # 列表 / 决策读路径已批量预取 suggested,传入时跳过逐条 SQL(消除 N+1)。
168
+ suggested_id, suggested_title = (
169
+ suggested if suggested is not None else _suggest_linked_experiment(db, item)
170
+ )
171
+ # 同上:批量预取 phase 时直接用;否则单条 fallback 查询。
172
+ if not linked_experiment_phase_provided:
173
+ if item.linked_experiment_id is not None:
174
+ exp = db.get(Experiment, item.linked_experiment_id)
175
+ linked_experiment_phase = exp.phase if exp else None
176
+ else:
177
+ linked_experiment_phase = None
178
+ return TopicActionItemRead(
179
+ id=item.id,
180
+ decision_id=item.decision_id,
181
+ project_id=item.project_id,
182
+ topic_id=item.topic_id,
183
+ title=item.title,
184
+ description=item.description,
185
+ owner_agent_id=item.owner_agent_id,
186
+ owner_name=owner_name,
187
+ status=item.status,
188
+ due_at=item.due_at,
189
+ linked_experiment_id=item.linked_experiment_id,
190
+ linked_experiment_phase=linked_experiment_phase,
191
+ category=ActionItemCategory(item.category) if item.category else None,
192
+ cancel_reason=item.cancel_reason,
193
+ suggested_linked_experiment_id=suggested_id,
194
+ suggested_linked_experiment_title=suggested_title,
195
+ wake_count=item.wake_count,
196
+ first_open_at=item.first_open_at,
197
+ last_woken_at=item.last_woken_at,
198
+ stale_at=item.stale_at,
199
+ created_at=item.created_at,
200
+ updated_at=item.updated_at,
201
+ )
202
+
203
+ def list_action_items(
204
+ db: Session,
205
+ project_id: uuid.UUID,
206
+ *,
207
+ owner_agent_id: uuid.UUID | None = None,
208
+ status: TopicActionItemStatus | None = None,
209
+ limit: int = 100,
210
+ ) -> list[TopicActionItemRead]:
211
+ get_project(db, project_id)
212
+ stmt = (
213
+ select(TopicActionItem)
214
+ .where(TopicActionItem.project_id == project_id)
215
+ .options(joinedload(TopicActionItem.owner))
216
+ .order_by(TopicActionItem.updated_at.desc())
217
+ .limit(max(1, min(limit, 200)))
218
+ )
219
+ if owner_agent_id is not None:
220
+ stmt = stmt.where(TopicActionItem.owner_agent_id == owner_agent_id)
221
+ if status is not None:
222
+ stmt = stmt.where(TopicActionItem.status == status)
223
+ items = list(db.scalars(stmt))
224
+ suggested_map = _suggest_linked_experiments_batch(db, items)
225
+ phase_map = _linked_experiment_phases_batch(db, items)
226
+ return [
227
+ _action_item_read(
228
+ db,
229
+ item,
230
+ suggested=suggested_map.get(item.id),
231
+ linked_experiment_phase=phase_map.get(item.id),
232
+ linked_experiment_phase_provided=True,
233
+ )
234
+ for item in items
235
+ ]
236
+
237
+
238
+ def _ensure_action_item_accessor(item: TopicActionItem, agent: Agent) -> None:
239
+ """Caller must be the owner or an admin. Topic creator may be added later."""
240
+ if item.owner_agent_id is None:
241
+ # No owner assigned: admin-only is the safe default.
242
+ if not is_admin(agent):
243
+ raise ForbiddenError("Only admin can close an unassigned action item")
244
+ return
245
+ if item.owner_agent_id != agent.id and not is_admin(agent):
246
+ raise ForbiddenError("Only the owner or admin can complete/cancel this action item")
247
+
248
+
249
+ def _complete_action_item_no_commit(
250
+ db: Session,
251
+ item: TopicActionItem,
252
+ *,
253
+ triggered_by: str = "manual",
254
+ ) -> dict[str, Any]:
255
+ """Move an open action item to ``done`` within the caller's transaction.
256
+
257
+ Caller is responsible for:
258
+ - 404 lookup (db.get on TopicActionItem)
259
+ - Owner / admin access check (``_ensure_action_item_accessor``)
260
+ - Final ``db.commit()``
261
+
262
+ Returns a dict the caller can splice into a higher-level audit payload
263
+ (e.g. ``cascaded_action_items`` on ``experiment.completed``).
264
+ """
265
+ if item.status != TopicActionItemStatus.open:
266
+ raise ConflictError(f"Action item already {item.status.value}")
267
+ prev_status = item.status
268
+ item.status = TopicActionItemStatus.done
269
+ audit_service.log_no_commit(
270
+ db,
271
+ action="action_item.completed",
272
+ target_type="topic_action_item",
273
+ target_id=item.id,
274
+ project_id=item.project_id,
275
+ summary=f"完成行动项「{item.title}」",
276
+ payload={
277
+ "action_item_id": str(item.id),
278
+ "prev_status": prev_status.value,
279
+ "new_status": item.status.value,
280
+ "triggered_by": triggered_by,
281
+ },
282
+ )
283
+ return {
284
+ "action_item_id": str(item.id),
285
+ "prev_status": prev_status.value,
286
+ "new_status": item.status.value,
287
+ }
288
+
289
+
290
+ def _action_item_source_topic(db: Session, item: TopicActionItem) -> Topic:
291
+ topic = db.get(Topic, item.topic_id)
292
+ if topic is None or topic.deleted_at is not None:
293
+ raise NotFoundError("Action item source topic not found")
294
+ return topic
295
+
296
+
297
+ def deliver_action_item_no_commit(
298
+ db: Session,
299
+ item: TopicActionItem,
300
+ *,
301
+ triggered_by: str = "deliver",
302
+ agent_id: uuid.UUID | None = None,
303
+ ) -> dict[str, Any]:
304
+ """Mark an open action item done when its source topic may be closed/archived."""
305
+ if item.status != TopicActionItemStatus.open:
306
+ raise ConflictError(f"Action item already {item.status.value}")
307
+ topic = _action_item_source_topic(db, item)
308
+ audit_service.log_no_commit(
309
+ db,
310
+ action="action_item.delivered",
311
+ target_type="topic_action_item",
312
+ target_id=item.id,
313
+ agent_id=agent_id,
314
+ project_id=item.project_id,
315
+ summary=f"交付行动项「{item.title}」",
316
+ payload={
317
+ "action_item_id": str(item.id),
318
+ "topic_id": str(topic.id),
319
+ "topic_status": topic.status.value,
320
+ "topic_archived": topic.archived_at is not None,
321
+ "triggered_by": triggered_by,
322
+ },
323
+ )
324
+ return _complete_action_item_no_commit(db, item, triggered_by=triggered_by)
325
+
326
+
327
+ def deliver_action_item(
328
+ db: Session,
329
+ action_item_id: uuid.UUID,
330
+ agent: Agent,
331
+ ) -> TopicActionItemRead:
332
+ item = db.get(TopicActionItem, action_item_id)
333
+ if item is None:
334
+ raise NotFoundError("Action item not found")
335
+ _ensure_action_item_accessor(item, agent)
336
+ deliver_action_item_no_commit(db, item, triggered_by="deliver", agent_id=agent.id)
337
+ db.commit()
338
+ db.refresh(item)
339
+ return _action_item_read(db, item)
340
+
341
+
342
+ def complete_action_item(
343
+ db: Session,
344
+ action_item_id: uuid.UUID,
345
+ agent: Agent,
346
+ *,
347
+ triggered_by: str = "manual",
348
+ ) -> TopicActionItemRead:
349
+ """Move an open action item to ``done``. Idempotent against non-open statuses (409)."""
350
+ item = db.get(TopicActionItem, action_item_id)
351
+ if item is None:
352
+ raise NotFoundError("Action item not found")
353
+ _ensure_action_item_accessor(item, agent)
354
+ _complete_action_item_no_commit(db, item, triggered_by=triggered_by)
355
+ db.commit()
356
+ db.refresh(item)
357
+ return _action_item_read(db, item)
358
+
359
+
360
+ def mark_wake_sent_action_item(
361
+ db: Session,
362
+ action_item_id: uuid.UUID,
363
+ agent: Agent,
364
+ ) -> TopicActionItemRead:
365
+ """Bump wake_count + stamp last_woken_at + audit row (experiment B, plan §3).
366
+
367
+ Access: owner or admin — same gate as ``complete`` / ``cancel`` so the
368
+ runtime-waker CLI (driven by an admin agent) can escalate forgotten
369
+ action_items whose owner is unresponsive. The transaction-internal
370
+ helper does the mutation; this wrapper adds the access check + commit
371
+ + read-back serialization.
372
+
373
+ I5: also posts a wakeable notification to the assignee so the bump
374
+ shows up in the owner's todos (B-1 / B-2 acceptance — wake is visible
375
+ to the assignee independent of the audit log).
376
+ """
377
+ item = db.get(TopicActionItem, action_item_id)
378
+ if item is None:
379
+ raise NotFoundError("Action item not found")
380
+ _ensure_action_item_accessor(item, agent)
381
+ try:
382
+ action_item_service.mark_wake_sent(db, action_item_id)
383
+ except ValueError as exc:
384
+ # The I3 helper raises ValueError on non-open / unassigned items —
385
+ # surface as ConflictError so the API returns 409 instead of 500.
386
+ raise ConflictError(str(exc)) from exc
387
+ # I5: notify the assignee. ``mark_wake_sent`` refreshes the identity-mapped
388
+ # item in place, so ``item`` here reflects the new wake_count + last_woken_at
389
+ # when we hand it to the notification helper.
390
+ notification_service.notify_owner_action_item_wake(db, action_item=item)
391
+ return _action_item_read(db, item)
392
+
393
+
394
+ def mark_stale_action_item(
395
+ db: Session,
396
+ action_item_id: uuid.UUID,
397
+ agent: Agent,
398
+ ) -> TopicActionItemRead:
399
+ """Stamp stale_at + write the ``action_item.stale`` audit row (plan §3).
400
+
401
+ Access: admin only — the stale transition is a system escalation that
402
+ should not be triggerable by the assignee themselves, since the whole
403
+ point is that they have not responded to 4 wakes. ``admin_notified=True``
404
+ and ``creator_audit_only=True`` are passed per plan §3 §6 (3c admin 优先
405
+ + 6 creator 不 wake).
406
+
407
+ I5: also posts a wakeable notification to every admin agent (excluding
408
+ the owner) — plan §3 #2 (3c admin 优先). The creator is intentionally
409
+ NOT in the recipient set (I6 audit-only policy): they can find the
410
+ stale event via ``action_items`` listing or the audit history.
411
+ """
412
+ if not is_admin(agent):
413
+ raise ForbiddenError("Only admin can mark an action item stale")
414
+ item = db.get(TopicActionItem, action_item_id)
415
+ if item is None:
416
+ raise NotFoundError("Action item not found")
417
+ try:
418
+ action_item_service.mark_stale(db, action_item_id)
419
+ except ValueError as exc:
420
+ # The audit-service helper refuses stale without a prior wake, and
421
+ # refuses non-open items — both surface as ConflictError (409) so
422
+ # the waker CLI can distinguish "retry later" from "5xx bug".
423
+ raise ConflictError(str(exc)) from exc
424
+ # I5: fan out to admin agents. No-op when the project has no admin
425
+ # configured (per plan risk #3 this is a degraded state — the audit
426
+ # row is still written, but no notification reaches a human).
427
+ notification_service.notify_admin_action_item_stale(db, action_item=item)
428
+ return _action_item_read(db, item)
429
+
430
+
431
+ def cancel_action_item(
432
+ db: Session,
433
+ action_item_id: uuid.UUID,
434
+ agent: Agent,
435
+ payload: ActionItemCancel,
436
+ *,
437
+ triggered_by: str = "manual",
438
+ ) -> TopicActionItemRead:
439
+ """Move an open action item to ``cancelled``. Reason + category validated by schema."""
440
+ item = db.get(TopicActionItem, action_item_id)
441
+ if item is None:
442
+ raise NotFoundError("Action item not found")
443
+ _ensure_action_item_accessor(item, agent)
444
+ if item.status != TopicActionItemStatus.open:
445
+ raise ConflictError(f"Action item already {item.status.value}")
446
+ prev_status = item.status
447
+ item.status = TopicActionItemStatus.cancelled
448
+ item.cancel_reason = payload.reason
449
+ if payload.category is not None:
450
+ item.category = payload.category.value
451
+ # 状态变更与 audit 行写在同一事务内(log_no_commit 只 flush),单次 commit;
452
+ # audit 失败会连同状态变更一起回滚,避免「已取消但无审计」的脱钩。
453
+ audit_service.log_no_commit(
454
+ db,
455
+ action="action_item.cancelled",
456
+ target_type="topic_action_item",
457
+ target_id=item.id,
458
+ agent_id=agent.id,
459
+ project_id=item.project_id,
460
+ summary=f"取消行动项「{item.title}」",
461
+ payload={
462
+ "action_item_id": str(item.id),
463
+ "prev_status": prev_status.value,
464
+ "new_status": item.status.value,
465
+ "cancel_reason": payload.reason,
466
+ "category": (payload.category.value if payload.category else item.category),
467
+ "triggered_by": triggered_by,
468
+ },
469
+ )
470
+ db.commit()
471
+ db.refresh(item)
472
+ return _action_item_read(db, item)
473
+
474
+
475
+ def link_action_item(
476
+ db: Session,
477
+ action_item_id: uuid.UUID,
478
+ experiment_id: uuid.UUID,
479
+ agent: Agent,
480
+ ) -> TopicActionItemRead:
481
+ """Attach an experiment to an open action item (``linked_experiment_id``).
482
+
483
+ Idempotency: re-linking the same experiment is a no-op (returns the item).
484
+ Relinking to a *different* experiment is rejected with 409 — to change link
485
+ target, first unlink via service-internal flow (not currently exposed).
486
+
487
+ Permissions: owner or admin (same as ``complete`` / ``cancel``).
488
+ """
489
+ from server.services.project_service import get_experiment
490
+
491
+ item = db.get(TopicActionItem, action_item_id)
492
+ if item is None:
493
+ raise NotFoundError("Action item not found")
494
+ _ensure_action_item_accessor(item, agent)
495
+ if item.status != TopicActionItemStatus.open:
496
+ raise ConflictError(f"Cannot link a {item.status.value} action item")
497
+
498
+ experiment = get_experiment(db, experiment_id)
499
+ if experiment.project_id != item.project_id:
500
+ raise ConflictError("Action item and experiment belong to different projects")
501
+ allowed_phases = {
502
+ ExperimentPhase.running,
503
+ ExperimentPhase.result_review,
504
+ ExperimentPhase.done,
505
+ }
506
+ if experiment.phase not in allowed_phases:
507
+ raise ConflictError(
508
+ f"Cannot link experiment in phase {experiment.phase.value}; "
509
+ "must be running, result_review, or done"
510
+ )
511
+
512
+ if item.linked_experiment_id == experiment_id:
513
+ return _action_item_read(db, item)
514
+ if item.linked_experiment_id is not None:
515
+ raise ConflictError(
516
+ "Action item is already linked to a different experiment; "
517
+ "unlink first (not yet supported via CLI)"
518
+ )
519
+
520
+ item.linked_experiment_id = experiment_id
521
+ db.commit()
522
+ db.refresh(item)
523
+ audit_service.log(
524
+ db,
525
+ action="action_item.linked",
526
+ target_type="topic_action_item",
527
+ target_id=item.id,
528
+ agent_id=agent.id,
529
+ project_id=item.project_id,
530
+ summary=f"关联行动项「{item.title}」到实验「{experiment.title}」",
531
+ payload={
532
+ "action_item_id": str(item.id),
533
+ "experiment_id": str(experiment_id),
534
+ "triggered_by": "manual",
535
+ },
536
+ )
537
+ return _action_item_read(db, item)
538
+
@@ -0,0 +1,14 @@
1
+ """Topic comment kind classification (user vs system/informational)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from map_types.enums import TopicCommentKind
6
+
7
+ from server.services import topic_ack_service
8
+
9
+
10
+ def resolve_topic_comment_kind(body: str) -> TopicCommentKind:
11
+ """Classify comment body; ack markers map to ``system`` for unread_change filtering."""
12
+ if topic_ack_service._ack_kind(body) is not None:
13
+ return TopicCommentKind.system
14
+ return TopicCommentKind.user