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,90 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import re
5
+
6
+ from map_types.enums import AcceptanceType
7
+
8
+ from server.domain.schemas import AcceptanceStatusRead
9
+ from server.services.errors import StateTransitionError
10
+ from server.services.evidence_service import metadata_has_completion_evidence
11
+
12
+ _ACCEPTANCE_MARKER_RE = re.compile(
13
+ r"\[acceptance_type:\s*(?P<kind>[a-zA-Z_]+)\]",
14
+ re.IGNORECASE,
15
+ )
16
+ _ACCEPTANCE_ITEM_RE = re.compile(
17
+ r"^\s*(?:[-*]|\d+[.)])\s*"
18
+ r"\[acceptance_type:\s*(?P<kind>[a-zA-Z_]+)\]\s*"
19
+ r"(?P<description>.+?)\s*$",
20
+ re.IGNORECASE,
21
+ )
22
+
23
+
24
+ def parse_acceptance_status(
25
+ content_md: str, *, completion_metadata: dict | None = None
26
+ ) -> list[AcceptanceStatusRead]:
27
+ statuses: list[AcceptanceStatusRead] = []
28
+ has_generic_evidence = metadata_has_completion_evidence(completion_metadata)
29
+ for line in content_md.splitlines():
30
+ match = _ACCEPTANCE_ITEM_RE.match(line)
31
+ if match is None:
32
+ continue
33
+ raw_kind = match.group("kind").lower()
34
+ try:
35
+ acceptance_type = AcceptanceType(raw_kind)
36
+ except ValueError as exc:
37
+ allowed = ", ".join(item.value for item in AcceptanceType)
38
+ raise StateTransitionError(
39
+ f"Unknown acceptance_type {raw_kind!r}; allowed values: {allowed}"
40
+ ) from exc
41
+ description = " ".join(match.group("description").split())
42
+ acceptance_id = _stable_acceptance_id(acceptance_type.value, description)
43
+ statuses.append(
44
+ AcceptanceStatusRead(
45
+ id=acceptance_id,
46
+ description=description,
47
+ acceptance_type=acceptance_type,
48
+ evidence_provided=has_generic_evidence
49
+ or _metadata_has_acceptance_evidence(
50
+ completion_metadata,
51
+ acceptance_id=acceptance_id,
52
+ acceptance_type=acceptance_type.value,
53
+ description=description,
54
+ ),
55
+ reviewer_verdict=None,
56
+ )
57
+ )
58
+ return statuses
59
+
60
+
61
+ def _stable_acceptance_id(acceptance_type: str, description: str) -> str:
62
+ digest = hashlib.sha1(f"{acceptance_type}:{description}".encode()).hexdigest()
63
+ return f"acc-{digest[:12]}"
64
+
65
+
66
+ def _metadata_has_acceptance_evidence(
67
+ metadata: object, *, acceptance_id: str, acceptance_type: str, description: str
68
+ ) -> bool:
69
+ if not isinstance(metadata, dict):
70
+ return False
71
+ acceptance = metadata.get("acceptance")
72
+ if isinstance(acceptance, dict):
73
+ for key in (acceptance_id, acceptance_type, description):
74
+ value = acceptance.get(key)
75
+ if value not in (None, "", [], {}, False):
76
+ return True
77
+ if isinstance(acceptance, list):
78
+ for item in acceptance:
79
+ if not isinstance(item, dict):
80
+ continue
81
+ evidence = item.get("evidence") or item.get("evidence_provided")
82
+ if evidence in (None, "", [], {}, False):
83
+ continue
84
+ if (
85
+ item.get("id") == acceptance_id
86
+ or item.get("acceptance_type") == acceptance_type
87
+ or item.get("description") == description
88
+ ):
89
+ return True
90
+ return False
@@ -0,0 +1,190 @@
1
+ """Migrate or deliver open action items tied to closed/archived topics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from dataclasses import dataclass
7
+
8
+ from sqlalchemy import or_, select
9
+ from sqlalchemy.orm import Session, joinedload
10
+
11
+ from server.domain.models import AuditLog, Topic, TopicActionItem, TopicActionItemStatus, TopicStatus
12
+ from server.services import audit_service, topic_service
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class MigrationResult:
17
+ action_item_id: uuid.UUID
18
+ strategy: str
19
+ from_topic_id: uuid.UUID
20
+ to_topic_id: uuid.UUID | None
21
+ dry_run: bool
22
+ skipped: bool
23
+ reason: str | None = None
24
+
25
+
26
+ def _idempotency_key(item_id: uuid.UUID) -> str:
27
+ return f"action_item_migrate:{item_id}"
28
+
29
+
30
+ def migration_already_applied(db: Session, item_id: uuid.UUID) -> bool:
31
+ key = _idempotency_key(item_id)
32
+ rows = db.scalars(
33
+ select(AuditLog).where(
34
+ AuditLog.action == "action_item.migrated",
35
+ AuditLog.target_id == item_id,
36
+ )
37
+ ).all()
38
+ return any((row.payload_json or {}).get("idempotency_key") == key for row in rows)
39
+
40
+
41
+ def list_stale_open_action_items(db: Session) -> list[TopicActionItem]:
42
+ """Open items whose source topic is closed or archived (not soft-deleted)."""
43
+ return list(
44
+ db.scalars(
45
+ select(TopicActionItem)
46
+ .join(Topic, TopicActionItem.topic_id == Topic.id)
47
+ .options(joinedload(TopicActionItem.topic))
48
+ .where(
49
+ TopicActionItem.status == TopicActionItemStatus.open,
50
+ Topic.deleted_at.is_(None),
51
+ or_(
52
+ Topic.status == TopicStatus.closed,
53
+ Topic.archived_at.isnot(None),
54
+ ),
55
+ )
56
+ .order_by(TopicActionItem.updated_at.desc())
57
+ )
58
+ )
59
+
60
+
61
+ def migrate_action_item(
62
+ db: Session,
63
+ item: TopicActionItem,
64
+ *,
65
+ migrate_to_topic_id: uuid.UUID | None = None,
66
+ agent_id: uuid.UUID | None = None,
67
+ dry_run: bool = False,
68
+ ) -> MigrationResult:
69
+ from_topic_id = item.topic_id
70
+ if migration_already_applied(db, item.id):
71
+ return MigrationResult(
72
+ action_item_id=item.id,
73
+ strategy="skip",
74
+ from_topic_id=from_topic_id,
75
+ to_topic_id=None,
76
+ dry_run=dry_run,
77
+ skipped=True,
78
+ reason="already_migrated",
79
+ )
80
+
81
+ if migrate_to_topic_id is not None:
82
+ target = db.get(Topic, migrate_to_topic_id)
83
+ if target is None or target.deleted_at is not None:
84
+ raise ValueError(f"Target topic {migrate_to_topic_id} not found")
85
+ strategy = "migrate_to"
86
+ to_topic_id = migrate_to_topic_id
87
+ if not dry_run:
88
+ item.topic_id = migrate_to_topic_id
89
+ audit_service.log_no_commit(
90
+ db,
91
+ action="action_item.migrated",
92
+ target_type="topic_action_item",
93
+ target_id=item.id,
94
+ agent_id=agent_id,
95
+ project_id=item.project_id,
96
+ summary=f"迁移行动项「{item.title}」到新话题",
97
+ payload={
98
+ "idempotency_key": _idempotency_key(item.id),
99
+ "from_topic_id": str(from_topic_id),
100
+ "to_topic_id": str(to_topic_id),
101
+ "strategy": strategy,
102
+ },
103
+ )
104
+ db.commit()
105
+ return MigrationResult(
106
+ action_item_id=item.id,
107
+ strategy=strategy,
108
+ from_topic_id=from_topic_id,
109
+ to_topic_id=to_topic_id,
110
+ dry_run=dry_run,
111
+ skipped=False,
112
+ )
113
+
114
+ strategy = "cascade_backlog"
115
+ if not dry_run:
116
+ suggested_id, _ = topic_service._suggest_linked_experiment(db, item)
117
+ if suggested_id is not None:
118
+ item.linked_experiment_id = suggested_id
119
+ topic_service.deliver_action_item_no_commit(
120
+ db,
121
+ item,
122
+ triggered_by="migration:cascade_backlog",
123
+ )
124
+ audit_service.log_no_commit(
125
+ db,
126
+ action="action_item.migrated",
127
+ target_type="topic_action_item",
128
+ target_id=item.id,
129
+ agent_id=agent_id,
130
+ project_id=item.project_id,
131
+ summary=f"迁移关闭行动项「{item.title}」(cascade backlog)",
132
+ payload={
133
+ "idempotency_key": _idempotency_key(item.id),
134
+ "from_topic_id": str(from_topic_id),
135
+ "to_topic_id": None,
136
+ "strategy": strategy,
137
+ "linked_experiment_id": str(item.linked_experiment_id)
138
+ if item.linked_experiment_id
139
+ else None,
140
+ },
141
+ )
142
+ db.commit()
143
+ return MigrationResult(
144
+ action_item_id=item.id,
145
+ strategy=strategy,
146
+ from_topic_id=from_topic_id,
147
+ to_topic_id=None,
148
+ dry_run=dry_run,
149
+ skipped=False,
150
+ )
151
+
152
+
153
+ def migrate_stale_open_action_items(
154
+ db: Session,
155
+ *,
156
+ migrate_to_topic_id: uuid.UUID | None = None,
157
+ agent_id: uuid.UUID | None = None,
158
+ dry_run: bool = False,
159
+ ) -> list[MigrationResult]:
160
+ results: list[MigrationResult] = []
161
+ for item in list_stale_open_action_items(db):
162
+ try:
163
+ results.append(
164
+ migrate_action_item(
165
+ db,
166
+ item,
167
+ migrate_to_topic_id=migrate_to_topic_id,
168
+ agent_id=agent_id,
169
+ dry_run=dry_run,
170
+ )
171
+ )
172
+ except Exception as exc:
173
+ if not dry_run:
174
+ db.rollback()
175
+ audit_service.log(
176
+ db,
177
+ action="action_item.migration_failed",
178
+ target_type="topic_action_item",
179
+ target_id=item.id,
180
+ agent_id=agent_id,
181
+ project_id=item.project_id,
182
+ summary=f"迁移行动项失败「{item.title}」",
183
+ payload={
184
+ "action_item_id": str(item.id),
185
+ "from_topic_id": str(item.topic_id),
186
+ "error": str(exc),
187
+ },
188
+ )
189
+ raise
190
+ return results
@@ -0,0 +1,200 @@
1
+ """Action-item wake / stale lifecycle helpers (experiment B, plan §3).
2
+
3
+ This module owns the *transaction-internal* mutations that the runtime-waker
4
+ needs to advance the three-stage escalation timeline:
5
+
6
+ - ``mark_wake_sent_no_commit``: bump ``wake_count``, stamp ``last_woken_at``,
7
+ emit the ``action_item.wake_sent`` audit row — all in the caller's tx.
8
+ - ``mark_stale_no_commit``: stamp ``stale_at``, emit the ``action_item.stale``
9
+ audit row — all in the caller's tx.
10
+
11
+ Public ``mark_wake_sent`` / ``mark_stale`` wrappers add lookup + commit for
12
+ direct callers (API, dogfood scripts). The ``_no_commit`` variants are what
13
+ the waker should call from its own session.
14
+
15
+ Why a separate service module instead of inlining into ``topic_service.py``:
16
+ the waker has its own import surface (no FastAPI deps, no project_status
17
+ recursion) and the topic module is already 700+ lines. Splitting keeps the
18
+ mutation contract co-located with its audit helpers and the I3 acceptance
19
+ tests.
20
+
21
+ Related plan sections:
22
+ - §1 audit event ``action_item.stale`` payload schema
23
+ - §2 ``first_open_at`` / ``last_woken_at`` / ``stale_at`` lifecycle
24
+ - §3 waker scan / stale trigger
25
+ - §4 clock-skew policy: every timestamp is server time, never client time
26
+ - §8 B-1..B-13 acceptance — these helpers back B-2..B-7, B-11
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import uuid
32
+ from datetime import UTC, datetime
33
+
34
+ from sqlalchemy.orm import Session
35
+
36
+ from server.domain.models import TopicActionItem
37
+ from server.services import audit_service
38
+
39
+ # Wake-history window from plan §3. Mirrored here so service-level callers
40
+ # (admin scripts, dogfood) can introspect the same numbers the waker uses
41
+ # without re-parsing the plan markdown.
42
+ WAKE_STAGE_THRESHOLDS: tuple[tuple[int, int], ...] = (
43
+ # (wake_count_after_increment, minimum_hours_since_first_open)
44
+ (1, 24), # T+24h first wake
45
+ (2, 72), # T+72h second wake
46
+ )
47
+ WAKE_REPEAT_INTERVAL_DAYS = 7 # after the 72h wake, fire every 7d
48
+ WAKE_MAX_COUNT_BEFORE_STALE = 4 # 4th unanswered wake -> stale
49
+
50
+
51
+ def _utcnow() -> datetime:
52
+ """Return the waker-canonical ``datetime.now(UTC)``.
53
+
54
+ Centralised so tests can monkey-patch the service module rather than
55
+ chasing ``datetime.now`` through every call site (plan §4 clock-skew
56
+ requirement: only server time, never client time).
57
+ """
58
+ return datetime.now(UTC)
59
+
60
+
61
+ def _ensure_first_open_at(item: TopicActionItem, now: datetime) -> None:
62
+ """Stamp ``first_open_at`` on the first transition into ``open``.
63
+
64
+ Per plan §2: ``first_open_at`` is written when an item enters the ``open``
65
+ status (creation, or reopen from ``done``/``cancelled``). Re-triggering
66
+ a wake on an already-open item must NOT reset this — the T+24h / T+72h
67
+ window is anchored to the moment the item first became open.
68
+
69
+ The backfill for already-open rows is handled by alembic migration
70
+ ``027_topic_action_item_escalation_fields.py`` — this helper only sets
71
+ the timestamp when it's still null.
72
+ """
73
+ if item.status.value == "open" and item.first_open_at is None:
74
+ item.first_open_at = now
75
+
76
+
77
+ def mark_wake_sent_no_commit(
78
+ db: Session,
79
+ *,
80
+ item: TopicActionItem,
81
+ now: datetime | None = None,
82
+ ) -> int:
83
+ """Bump ``wake_count`` + stamp ``last_woken_at`` + audit row, no commit.
84
+
85
+ Caller is responsible for the final ``db.commit()``. Returns the new
86
+ ``wake_count`` so the waker can decide whether to advance to ``stale``
87
+ on the next cycle (returns 1..4; values beyond ``WAKE_MAX_COUNT_BEFORE_STALE``
88
+ mean the waker is calling this too aggressively and the caller should
89
+ pivot to ``mark_stale_no_commit`` instead).
90
+
91
+ Invariants (plan §3 + §4):
92
+ - Only fires when ``status == open`` and ``owner_agent_id`` is set;
93
+ ``wake_count`` is meaningless on closed items.
94
+ - ``last_woken_at`` and the audit row's ``last_woken_at`` field both
95
+ use the same ``now`` value (caller-supplied or server-time default)
96
+ so grep-side reconstruction matches the DB.
97
+ - First call also writes ``first_open_at`` if it's still null — this
98
+ covers the I1 contract that backfill only handles pre-existing rows.
99
+ """
100
+ if item.status.value != "open":
101
+ raise ValueError(
102
+ f"mark_wake_sent requires status=open, got {item.status.value}"
103
+ )
104
+ if item.owner_agent_id is None:
105
+ raise ValueError(
106
+ "mark_wake_sent requires owner_agent_id — "
107
+ "waker only wakes the assignee"
108
+ )
109
+
110
+ when = now or _utcnow()
111
+ _ensure_first_open_at(item, when)
112
+ item.wake_count = (item.wake_count or 0) + 1
113
+ item.last_woken_at = when
114
+
115
+ audit_service.log_action_item_wake_sent(db, item=item, now=when)
116
+ return item.wake_count
117
+
118
+
119
+ def mark_stale_no_commit(
120
+ db: Session,
121
+ *,
122
+ item: TopicActionItem,
123
+ now: datetime | None = None,
124
+ admin_notified: bool = True,
125
+ creator_audit_only: bool = True,
126
+ ) -> None:
127
+ """Stamp ``stale_at`` + write the diagnostic audit row, no commit.
128
+
129
+ Idempotent: if ``stale_at`` is already set we skip re-writing (the audit
130
+ row is a one-shot diagnostic — repeat wakes after stale must not spam
131
+ audit). Caller still owns the commit boundary.
132
+
133
+ Validation is delegated to ``audit_service._log_action_item_stale_no_commit``
134
+ which rejects unassigned items, items with no prior wake, and
135
+ ``stale_after_attempt < 1``. We pass ``stale_after_attempt=item.wake_count``
136
+ because the plan defines stale as "the 4th unanswered wake" — the count
137
+ after the increment.
138
+ """
139
+ if item.status.value != "open":
140
+ raise ValueError(
141
+ f"mark_stale requires status=open, got {item.status.value}"
142
+ )
143
+
144
+ if item.stale_at is not None:
145
+ return # already stale — plan §5: do not re-write diagnostic
146
+
147
+ when = now or _utcnow()
148
+ item.stale_at = when
149
+
150
+ audit_service._log_action_item_stale_no_commit(
151
+ db,
152
+ item=item,
153
+ stale_after_attempt=item.wake_count,
154
+ admin_notified=admin_notified,
155
+ creator_audit_only=creator_audit_only,
156
+ )
157
+
158
+
159
+ def mark_wake_sent(
160
+ db: Session,
161
+ action_item_id: uuid.UUID,
162
+ *,
163
+ now: datetime | None = None,
164
+ ) -> TopicActionItem:
165
+ """Public wrapper: lookup + ``mark_wake_sent_no_commit`` + commit.
166
+
167
+ Kept for API symmetry with ``complete_action_item`` / ``cancel_action_item``.
168
+ The waker itself should call the ``_no_commit`` variant directly.
169
+ """
170
+ item = db.get(TopicActionItem, action_item_id)
171
+ if item is None:
172
+ raise LookupError(f"action item {action_item_id} not found")
173
+ mark_wake_sent_no_commit(db, item=item, now=now)
174
+ db.commit()
175
+ db.refresh(item)
176
+ return item
177
+
178
+
179
+ def mark_stale(
180
+ db: Session,
181
+ action_item_id: uuid.UUID,
182
+ *,
183
+ now: datetime | None = None,
184
+ admin_notified: bool = True,
185
+ creator_audit_only: bool = True,
186
+ ) -> TopicActionItem:
187
+ """Public wrapper: lookup + ``mark_stale_no_commit`` + commit."""
188
+ item = db.get(TopicActionItem, action_item_id)
189
+ if item is None:
190
+ raise LookupError(f"action item {action_item_id} not found")
191
+ mark_stale_no_commit(
192
+ db,
193
+ item=item,
194
+ now=now,
195
+ admin_notified=admin_notified,
196
+ creator_audit_only=creator_audit_only,
197
+ )
198
+ db.commit()
199
+ db.refresh(item)
200
+ return item