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
cli/simple_waker.py ADDED
@@ -0,0 +1,950 @@
1
+ """Thin MAP waker: poll unified work snapshot, remind agent when there is new work.
2
+
3
+ Waker logic stays minimal: the platform serves ``GET /agents/me/work``
4
+ (whoami + topic-progress + todos + wakeable notifications); agents use
5
+ ``map work`` / ``map topic progress`` in Skills.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import hashlib
12
+ import os
13
+ import uuid
14
+ from dataclasses import dataclass
15
+ from datetime import UTC, datetime
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import typer
20
+
21
+ from cli.action_item_escalation import (
22
+ ActionItemWakeDecision,
23
+ scan_pending_action_items,
24
+ )
25
+ from cli.bridge_state import load_bridge_state, save_bridge_state
26
+ from cli.host_worker_types import WorkerError
27
+ from cli.map_command_client import MapCommandClient
28
+ from cli.wake_backend import (
29
+ TODO_BUCKET_UI_LABELS,
30
+ TODO_WAKE_BUCKETS,
31
+ PersonaAgentWakeBackend,
32
+ sync_runtime_skills,
33
+ )
34
+ from cli.worker_cycle_log import log_cycle_summary
35
+
36
+ APP = typer.Typer(add_completion=False)
37
+
38
+ # Passive inventory — not used alone to wake (topic activity uses topic-progress).
39
+ SIMPLE_WAKER_PASSIVE_BUCKETS: frozenset[str] = frozenset({"my_open_topics"})
40
+
41
+ _EXCERPT_IN_PROMPT = 280
42
+ RUNTIME_CONTRACT_VERSION = "simple-waker-runtime-contract-v3"
43
+ RUNTIME_CONTRACT_FILES: tuple[str, ...] = (
44
+ ".cursor/skills/map-runtime-waker/SKILL.md",
45
+ ".cursor/skills/map-project-collab/SKILL.md",
46
+ ".cursor/skills/topic-host/SKILL.md",
47
+ ".cursor/skills/topic-participant/SKILL.md",
48
+ ".cursor/skills/experiment-host/SKILL.md",
49
+ ".cursor/skills/experiment-reviewer/SKILL.md",
50
+ )
51
+
52
+
53
+ def runtime_contract_hash(project_root: Path) -> str:
54
+ """Hash the runtime-facing prompt/Skill contract that may affect agent behavior."""
55
+ digest = hashlib.sha256()
56
+ digest.update(RUNTIME_CONTRACT_VERSION.encode("utf-8"))
57
+ for relative in RUNTIME_CONTRACT_FILES:
58
+ path = project_root / relative
59
+ digest.update(relative.encode("utf-8"))
60
+ if path.is_file():
61
+ digest.update(path.read_bytes())
62
+ else:
63
+ digest.update(b"<missing>")
64
+ return digest.hexdigest()
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class TopicProgressEntry:
69
+ topic_id: str
70
+ topic_title: str
71
+ discussion_round: str
72
+ last_comment_author_name: str | None
73
+ new_comment_count: int
74
+ new_comments: tuple[dict[str, Any], ...]
75
+ work_item_kinds: tuple[str, ...]
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class PendingBucket:
80
+ kind: str
81
+ count: int
82
+ label: str
83
+ samples: tuple[str, ...] = ()
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class WakeContext:
88
+ topic_progress: tuple[TopicProgressEntry, ...] = ()
89
+ todo_buckets: tuple[PendingBucket, ...] = ()
90
+ notification_count: int = 0
91
+ # race experiment (eca0f522) PR3: per-cycle dedup keeps
92
+ # ``notification_count`` consistent with the DB's UNIQUE(recipient,
93
+ # group_key) constraint, but the raw ``event_count`` from the
94
+ # surviving (highest wake_version) row is preserved here so the
95
+ # audit path can still report how many underlying events fed each
96
+ # wakeable notification. Without this split, dedup would silently
97
+ # compress the event_count in the aggregated stats.
98
+ notification_event_count_sum: int = 0
99
+ notification_dedup_dropped: int = 0
100
+ open_topic_count: int = 0
101
+ open_topic_samples: tuple[dict[str, Any], ...] = ()
102
+ drain_topics: bool = False
103
+
104
+ @property
105
+ def topic_update_count(self) -> int:
106
+ return len(self.topic_progress)
107
+
108
+ @property
109
+ def todo_item_count(self) -> int:
110
+ return sum(bucket.count for bucket in self.todo_buckets)
111
+
112
+ @property
113
+ def total_items(self) -> int:
114
+ return (
115
+ self.topic_update_count
116
+ + self.todo_item_count
117
+ + self.notification_count
118
+ + self.open_topic_count
119
+ )
120
+
121
+ @property
122
+ def has_work(self) -> bool:
123
+ return self.total_items > 0
124
+
125
+
126
+ @dataclass
127
+ class SimpleWakerConfig:
128
+ persona: str = "host"
129
+ project_root: Path = Path(".")
130
+ map_cmd: str = "map"
131
+ active_interval: float = 30.0
132
+ idle_interval: float = 300.0
133
+ once: bool = False
134
+ max_cycles: int | None = None
135
+ dry_run: bool = False
136
+ state_file: Path | None = Path(".map/simple-waker-state.json")
137
+ model: str | None = None
138
+ runtime_home: Path | None = None
139
+ min_remind_seconds: float = 30.0
140
+ drain_topics: bool = False
141
+ # f873c287 I1(g): CLI flag → env var → server settings. When set,
142
+ # the waker exports ``MAP_STALE_OPEN_TOPIC_THRESHOLD_MINUTES`` so
143
+ # subprocess ``map work`` invocations and any in-process server
144
+ # pick up the override. The server's ``Settings`` already reads the
145
+ # env var (W21 I1(c)); this flag is just the waker-side entrypoint.
146
+ stale_threshold_minutes: int | None = None
147
+
148
+
149
+ @dataclass
150
+ class SimpleWakerStats:
151
+ cycles: int = 0
152
+ polls_with_work: int = 0
153
+ polls_idle: int = 0
154
+ reminds_sent: int = 0
155
+ remind_skips_busy: int = 0
156
+ remind_skips_cooldown: int = 0
157
+ remind_errors: int = 0
158
+ dry_run_actions: int = 0
159
+ # v0.10:每次 remind 后写一条聚合 inbound_event(fingerprint=
160
+ # ``simple-remind:{persona}:{cycle_ts}``)作为可观测性审计。
161
+ # ``duplicate`` = 服务端 409(同一 cycle 已写过,理论上不会发生);
162
+ # ``skipped`` = client 没有 inbound_event_record 方法(测试 mock)。
163
+ inbound_events_recorded: int = 0
164
+ inbound_events_duplicate: int = 0
165
+ inbound_events_skipped: int = 0
166
+ # v0.10:action_item escalation(移植自 legacy runtime-waker)。
167
+ # simple-waker 在 remind 前扫描 open + owner=persona 的 action_items:
168
+ # - STALE → 调 ``action mark-stale`` 标记过期(waker 职责,非 Agent)
169
+ # - WAKE → 调 ``action mark-wake-sent`` 推进 wake_count,再 remind
170
+ # - SKIP → 跳过
171
+ action_items_wake: int = 0
172
+ action_items_stale: int = 0
173
+ action_items_skip: int = 0
174
+ action_items_errors: int = 0
175
+ # 单次 cycle 因瞬时错误(API 5xx / 子进程失败 / 身份解析失败)而未能完成。
176
+ # 长驻 waker 兜底跳过该 cycle 并在下一周期重试,此计数仅用于可观测性。
177
+ cycle_errors: int = 0
178
+ stalled_lock_notifications: int = 0
179
+
180
+ def add(self, other: SimpleWakerStats) -> None:
181
+ self.cycles += other.cycles
182
+ self.polls_with_work += other.polls_with_work
183
+ self.polls_idle += other.polls_idle
184
+ self.reminds_sent += other.reminds_sent
185
+ self.remind_skips_busy += other.remind_skips_busy
186
+ self.remind_skips_cooldown += other.remind_skips_cooldown
187
+ self.remind_errors += other.remind_errors
188
+ self.dry_run_actions += other.dry_run_actions
189
+ self.inbound_events_recorded += other.inbound_events_recorded
190
+ self.inbound_events_duplicate += other.inbound_events_duplicate
191
+ self.inbound_events_skipped += other.inbound_events_skipped
192
+ self.action_items_wake += other.action_items_wake
193
+ self.action_items_stale += other.action_items_stale
194
+ self.action_items_skip += other.action_items_skip
195
+ self.action_items_errors += other.action_items_errors
196
+ self.cycle_errors += other.cycle_errors
197
+ self.stalled_lock_notifications += other.stalled_lock_notifications
198
+
199
+
200
+ def parse_topic_progress(data: dict[str, Any] | None) -> tuple[TopicProgressEntry, ...]:
201
+ if not isinstance(data, dict):
202
+ return ()
203
+ items = data.get("items") or []
204
+ if not isinstance(items, list):
205
+ return ()
206
+ entries: list[TopicProgressEntry] = []
207
+ for raw in items:
208
+ if not isinstance(raw, dict):
209
+ continue
210
+ topic_id = raw.get("topic_id")
211
+ if not topic_id:
212
+ continue
213
+ new_comments = raw.get("new_comments") or []
214
+ if not isinstance(new_comments, list):
215
+ new_comments = []
216
+ work_items = raw.get("work_items") or []
217
+ kinds: list[str] = []
218
+ if isinstance(work_items, list):
219
+ for item in work_items:
220
+ if isinstance(item, dict) and item.get("kind"):
221
+ kinds.append(str(item["kind"]))
222
+ entries.append(
223
+ TopicProgressEntry(
224
+ topic_id=str(topic_id),
225
+ topic_title=str(raw.get("topic_title") or ""),
226
+ discussion_round=str(raw.get("discussion_round") or ""),
227
+ last_comment_author_name=(
228
+ str(raw["last_comment_author_name"])
229
+ if raw.get("last_comment_author_name")
230
+ else None
231
+ ),
232
+ new_comment_count=int(raw.get("new_comment_count") or len(new_comments)),
233
+ new_comments=tuple(c for c in new_comments if isinstance(c, dict)),
234
+ work_item_kinds=tuple(kinds),
235
+ )
236
+ )
237
+ return tuple(entries)
238
+
239
+
240
+ def summarize_actionable_todos(todos: dict[str, Any]) -> tuple[PendingBucket, ...]:
241
+ buckets: list[PendingBucket] = []
242
+ for kind in TODO_WAKE_BUCKETS:
243
+ if kind in SIMPLE_WAKER_PASSIVE_BUCKETS:
244
+ continue
245
+ raw_items = todos.get(kind) or []
246
+ if not isinstance(raw_items, list):
247
+ continue
248
+ items = [
249
+ item
250
+ for item in raw_items
251
+ if isinstance(item, dict) and _todo_item_is_actionable(kind, item)
252
+ ]
253
+ count = len(items)
254
+ if count <= 0:
255
+ continue
256
+ buckets.append(
257
+ PendingBucket(
258
+ kind=kind,
259
+ count=count,
260
+ label=TODO_BUCKET_UI_LABELS.get(kind, kind),
261
+ samples=_todo_bucket_samples(kind, items),
262
+ )
263
+ )
264
+ return tuple(buckets)
265
+
266
+
267
+ def _todo_item_is_actionable(kind: str, item: dict[str, Any]) -> bool:
268
+ if kind != "my_open_experiments":
269
+ return True
270
+ if item.get("archived_at"):
271
+ return False
272
+ # f873c287 I1(f): ``informational_only`` experiments are read-only
273
+ # for the host (decision authority held by another persona). They
274
+ # MUST NOT enter the waker obligation bucket even if the legacy
275
+ # ``actions=[]`` filter happens to keep them in the partition.
276
+ if item.get("informational_only"):
277
+ return False
278
+ actions = item.get("actions") or []
279
+ return isinstance(actions, list) and any(str(action).strip() for action in actions)
280
+
281
+
282
+ def _todo_bucket_samples(kind: str, items: list[dict[str, Any]]) -> tuple[str, ...]:
283
+ if kind != "my_open_experiments":
284
+ return ()
285
+ samples: list[str] = []
286
+ for item in items[:3]:
287
+ exp_id = str(item.get("id") or item.get("experiment_id") or "?")
288
+ title = str(item.get("title") or item.get("experiment_title") or "experiment")
289
+ phase = str(item.get("phase") or "?")
290
+ actions = item.get("actions") or []
291
+ action_text = ", ".join(str(action) for action in actions if str(action).strip()) or "?"
292
+ samples.append(f"{title} (`{exp_id}`) · phase={phase} · actions={action_text}")
293
+ if len(items) > len(samples):
294
+ samples.append(f"… 另有 {len(items) - len(samples)} 个实验")
295
+ return tuple(samples)
296
+
297
+
298
+ def build_wake_context(
299
+ *,
300
+ topic_progress_data: dict[str, Any] | None,
301
+ todos: dict[str, Any],
302
+ notifications: list[dict[str, Any]] | None = None,
303
+ persona: str | None = None,
304
+ drain_topics: bool = False,
305
+ open_topics: list[dict[str, Any]] | None = None,
306
+ ) -> WakeContext:
307
+ filtered_progress = _filter_topic_progress_for_persona(
308
+ persona,
309
+ topic_progress_data,
310
+ todos,
311
+ )
312
+ deduped_notifications, event_count_sum, dropped = _dedupe_notifications_by_group_key(
313
+ notifications
314
+ )
315
+ return WakeContext(
316
+ topic_progress=parse_topic_progress(filtered_progress),
317
+ todo_buckets=summarize_actionable_todos(todos),
318
+ notification_count=len(deduped_notifications),
319
+ notification_event_count_sum=event_count_sum,
320
+ notification_dedup_dropped=dropped,
321
+ open_topic_count=len(open_topics or []) if drain_topics and persona == "host" else 0,
322
+ open_topic_samples=tuple((open_topics or [])[:10]) if drain_topics and persona == "host" else (),
323
+ drain_topics=drain_topics,
324
+ )
325
+
326
+
327
+ def _dedupe_notifications_by_group_key(
328
+ notifications: list[dict[str, Any]] | None,
329
+ ) -> tuple[list[dict[str, Any]], int, int]:
330
+ """Per-cycle dedup of wakeable notifications by ``group_key``.
331
+
332
+ race experiment (eca0f522) PR3: PR2's DB-layer
333
+ ``UNIQUE(recipient_agent_id, group_key)`` already prevents duplicate
334
+ rows in steady state, but the simple-waker must not depend on that
335
+ invariant — the work snapshot can still surface multiple rows during
336
+ a brief window if a wakeable merge races with an unrelated read
337
+ cursor refresh, or if a future migration relaxes the constraint.
338
+ We dedupe defensively: for each ``group_key``, keep the row with the
339
+ highest ``wake_version`` (the most recent merge), summing each
340
+ surviving row's ``event_count`` so the audit path sees the raw
341
+ underlying event volume (otherwise dedup would silently compress
342
+ it). Rows with ``group_key=None`` are treated as unique entries
343
+ (UNIQUE constraints ignore NULLs by design).
344
+ """
345
+ if not notifications:
346
+ return [], 0, 0
347
+ survivors_by_key: dict[str, dict[str, Any]] = {}
348
+ raw_event_count = 0
349
+ dropped = 0
350
+ for raw in notifications:
351
+ if not isinstance(raw, dict):
352
+ continue
353
+ group_key = raw.get("group_key")
354
+ event_count = int(raw.get("event_count") or 1)
355
+ if group_key is None:
356
+ survivors_by_key[f"__none_:{id(raw)}"] = raw
357
+ raw_event_count += event_count
358
+ continue
359
+ existing = survivors_by_key.get(group_key)
360
+ if existing is None:
361
+ survivors_by_key[group_key] = raw
362
+ raw_event_count += event_count
363
+ continue
364
+ existing_wake = int(existing.get("wake_version") or 0)
365
+ new_wake = int(raw.get("wake_version") or 0)
366
+ existing_event_count = int(existing.get("event_count") or 1)
367
+ if new_wake > existing_wake:
368
+ # Replace: subtract the dropped row's event_count from the
369
+ # raw total so we don't double-count.
370
+ raw_event_count += event_count - existing_event_count
371
+ survivors_by_key[group_key] = raw
372
+ dropped += 1
373
+ else:
374
+ raw_event_count += event_count
375
+ dropped += 1
376
+ return list(survivors_by_key.values()), raw_event_count, dropped
377
+
378
+
379
+ def _filter_topic_progress_for_persona(
380
+ persona: str | None,
381
+ topic_progress_data: dict[str, Any] | None,
382
+ todos: dict[str, Any],
383
+ ) -> dict[str, Any] | None:
384
+ """Reviewer with experiment review todos: suppress contextual-only topic progress."""
385
+ if persona != "reviewer":
386
+ return topic_progress_data
387
+ if not (todos.get("pending_reviews") or todos.get("pending_result_reviews")):
388
+ return topic_progress_data
389
+ if not isinstance(topic_progress_data, dict):
390
+ return topic_progress_data
391
+ items = topic_progress_data.get("items") or []
392
+ if not isinstance(items, list):
393
+ return topic_progress_data
394
+ filtered: list[dict[str, Any]] = []
395
+ for raw in items:
396
+ if not isinstance(raw, dict):
397
+ continue
398
+ work_items = raw.get("work_items") or []
399
+ if not isinstance(work_items, list) or not work_items:
400
+ filtered.append(raw)
401
+ continue
402
+ if any(isinstance(wi, dict) and wi.get("priority") == "obligation" for wi in work_items):
403
+ filtered.append(raw)
404
+ return {**topic_progress_data, "items": filtered, "total": len(filtered)}
405
+
406
+
407
+ def should_send_remind(
408
+ context: WakeContext,
409
+ *,
410
+ now: datetime,
411
+ last_remind_at: datetime | None,
412
+ inflight: bool,
413
+ min_remind_seconds: float,
414
+ ) -> tuple[bool, str | None]:
415
+ if not context.has_work:
416
+ return False, "idle"
417
+ if inflight:
418
+ return False, "busy"
419
+ if last_remind_at is not None:
420
+ elapsed = (now - last_remind_at).total_seconds()
421
+ if elapsed < min_remind_seconds:
422
+ return False, "cooldown"
423
+ return True, None
424
+
425
+
426
+ def _clip(text: str, limit: int = _EXCERPT_IN_PROMPT) -> str:
427
+ cleaned = " ".join(text.split())
428
+ if len(cleaned) <= limit:
429
+ return cleaned
430
+ return cleaned[: limit - 1] + "…"
431
+
432
+
433
+ def build_remind_prompt(persona: str, context: WakeContext) -> str:
434
+ command = f"map --persona {persona}"
435
+ lines = [
436
+ f"MAP 协作提醒 · {persona}",
437
+ "",
438
+ "平台检测到待处理 work items / todos actions。请先读 map-runtime-waker、map-project-collab 与 persona Skill,然后:",
439
+ f"1. `{command} persona whoami`",
440
+ f"2. `{command} work` 或 `{command} topic progress` — topic work items 统一视图(obligation + contextual;与 todos 话题分区同源)",
441
+ f"3. `{command} todos` — 实验/评审/mention 等待办分区",
442
+ "4. 按 work_items.kind 或 todos.actions 逐项处理(obligation 优先);topic 用 topic-host,pending_reviews 用 experiment-reviewer,my_open_experiments 用 experiment-host",
443
+ "",
444
+ ]
445
+
446
+ if context.drain_topics and persona == "host":
447
+ lines.extend(
448
+ [
449
+ "## Drain topics 模式",
450
+ f"- 当前仍有 {context.open_topic_count} 个 open topic。",
451
+ "- 请按 topic-host Skill 主持这些话题;host 应主动推动话题进展、积极解决问题。",
452
+ "- 先逐个 topic show 复盘上下文,再选择下一步:comment / Round Summary / advance-round / resolve。",
453
+ "- 更细的邀请、收敛、行动项和实验边界判断以 topic-host Skill 为准。",
454
+ ]
455
+ )
456
+ for raw in context.open_topic_samples:
457
+ topic_id = raw.get("id") or raw.get("topic_id")
458
+ title = raw.get("title") or raw.get("topic_title") or ""
459
+ round_name = raw.get("discussion_round") or ""
460
+ comments = raw.get("comment_count")
461
+ lines.append(f"- {title} (`{topic_id}`) · {round_name} · comments={comments}")
462
+ if context.open_topic_count > len(context.open_topic_samples):
463
+ lines.append(f"- … 另有 {context.open_topic_count - len(context.open_topic_samples)} 个 open topic")
464
+ lines.append("")
465
+
466
+ if context.topic_progress:
467
+ lines.append("## 话题 work items(topic-progress)")
468
+ for entry in context.topic_progress:
469
+ kinds = ", ".join(entry.work_item_kinds) if entry.work_item_kinds else "?"
470
+ who = entry.last_comment_author_name or "他人"
471
+ lines.append(
472
+ f"- **{entry.topic_title}** (`{entry.topic_id}`) "
473
+ f"· {entry.discussion_round} · kinds: {kinds} · 末评 {who}"
474
+ )
475
+ if entry.new_comment_count > 0:
476
+ lines.append(f" - unread 摘要 +{entry.new_comment_count} 条:")
477
+ for comment in entry.new_comments[:3]:
478
+ author = comment.get("author_name") or comment.get("author_agent_id") or "?"
479
+ excerpt = comment.get("excerpt") or comment.get("body") or ""
480
+ lines.append(f" - @{author}: {_clip(str(excerpt))}")
481
+ if len(entry.new_comments) > 3:
482
+ lines.append(f" - … 另有 {len(entry.new_comments) - 3} 条,请 `topic show --id {entry.topic_id}`")
483
+ lines.append("")
484
+
485
+ if context.todo_buckets or context.notification_count:
486
+ lines.append("## 其他待办")
487
+ for bucket in context.todo_buckets:
488
+ lines.append(f"- {bucket.kind}: {bucket.count}({bucket.label})")
489
+ for sample in bucket.samples:
490
+ lines.append(f" - {sample}")
491
+ if context.notification_count:
492
+ lines.append(
493
+ f"- notification: {context.notification_count}"
494
+ f"({TODO_BUCKET_UI_LABELS['notification']})"
495
+ )
496
+ lines.append("")
497
+
498
+ lines.append(f"详情:`{command} work` · `{command} topic progress` · `{command} todos`")
499
+ return "\n".join(lines) + "\n"
500
+
501
+
502
+ def next_sleep_seconds(context: WakeContext, config: SimpleWakerConfig) -> float:
503
+ return config.active_interval if context.has_work else config.idle_interval
504
+
505
+
506
+ def _parse_datetime(value: str | None) -> datetime | None:
507
+ if not value:
508
+ return None
509
+ try:
510
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
511
+ except ValueError:
512
+ return None
513
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
514
+
515
+
516
+ # Backward-compatible aliases for tests
517
+ PendingWorkSummary = WakeContext
518
+ summarize_pending_work = lambda todos, notifications=None: build_wake_context( # noqa: E731
519
+ topic_progress_data={"items": []},
520
+ todos=todos,
521
+ notifications=notifications,
522
+ persona=None,
523
+ )
524
+
525
+
526
+ class SimpleWaker:
527
+ def __init__(
528
+ self,
529
+ *,
530
+ client: MapCommandClient,
531
+ config: SimpleWakerConfig | None = None,
532
+ backend: PersonaAgentWakeBackend | None = None,
533
+ ) -> None:
534
+ self.client = client
535
+ self.config = config or SimpleWakerConfig()
536
+ self.state = load_bridge_state(
537
+ self.config.state_file,
538
+ bridge_name="simple-waker",
539
+ default_collections=("personas",),
540
+ )
541
+ self._state_dirty = False
542
+ self._inflight = False
543
+ self.backend = backend or PersonaAgentWakeBackend(
544
+ project_root=self.config.project_root,
545
+ persona=self.config.persona,
546
+ get_agent_state=lambda: self._persona_state(self.config.persona),
547
+ save_state_fn=lambda: self._save_state_if_needed(force=True),
548
+ model=self.config.model,
549
+ runtime_home=self.config.runtime_home,
550
+ )
551
+ self._runtime_contract_hash = runtime_contract_hash(self.config.project_root)
552
+
553
+ def run_forever(self) -> SimpleWakerStats:
554
+ return asyncio.run(self._run_forever_async())
555
+
556
+ async def _run_forever_async(self) -> SimpleWakerStats:
557
+ if not self.config.dry_run:
558
+ await self._reset_runtime_session_if_contract_changed()
559
+ if not self.config.dry_run:
560
+ await self.backend.connect()
561
+ total = SimpleWakerStats()
562
+ try:
563
+ while True:
564
+ try:
565
+ stats, sleep_for = await self._run_once_async()
566
+ except WorkerError as exc:
567
+ # 瞬时错误兜底:API 5xx / 子进程失败 / 身份解析失败等。
568
+ # 长驻 waker 不能因单次 cycle 失败退出——记错到 state,
569
+ # 按 idle 间隔退避后下一 cycle 重试。持续失败会在 state
570
+ # 累积 last_cycle_error 供运维观测。
571
+ typer.echo(f"[simple-waker:cycle-error] {exc}", err=True)
572
+ persona_state = self._persona_state(self.config.persona)
573
+ persona_state["last_cycle_error"] = str(exc)
574
+ persona_state["last_cycle_error_at"] = datetime.now(UTC).isoformat()
575
+ self._state_dirty = True
576
+ self._save_state_if_needed(force=True)
577
+ stats = SimpleWakerStats(cycles=1, cycle_errors=1)
578
+ sleep_for = self.config.idle_interval
579
+ total.add(stats)
580
+ log_cycle_summary(
581
+ "simple-waker",
582
+ total,
583
+ fields=[
584
+ "cycles",
585
+ "polls_with_work",
586
+ "polls_idle",
587
+ "reminds_sent",
588
+ "remind_skips_busy",
589
+ "remind_skips_cooldown",
590
+ "remind_errors",
591
+ "dry_run_actions",
592
+ "inbound_events_recorded",
593
+ "inbound_events_duplicate",
594
+ "inbound_events_skipped",
595
+ "action_items_wake",
596
+ "action_items_stale",
597
+ "action_items_skip",
598
+ "action_items_errors",
599
+ "cycle_errors",
600
+ ],
601
+ )
602
+ if self.config.once:
603
+ break
604
+ if self.config.max_cycles is not None and total.cycles >= self.config.max_cycles:
605
+ break
606
+ await asyncio.sleep(sleep_for)
607
+ return total
608
+ finally:
609
+ if not self.config.dry_run:
610
+ await self.backend.disconnect()
611
+
612
+ def run_once(self) -> SimpleWakerStats:
613
+ return asyncio.run(self._run_once_async())[0]
614
+
615
+ async def _run_once_async(self) -> tuple[SimpleWakerStats, float]:
616
+ self._ensure_identity()
617
+ stats = SimpleWakerStats(cycles=1)
618
+ self._scan_stalled_experiment_locks(stats)
619
+ work = self.client.work() or {}
620
+ topic_progress_data = work.get("topic_progress") or {}
621
+ todos = work.get("todos") or {}
622
+ notifications_payload = work.get("notifications") or {}
623
+ notifications = (
624
+ list(notifications_payload.get("items") or [])
625
+ if isinstance(notifications_payload, dict)
626
+ else []
627
+ )
628
+ open_topics: list[dict[str, Any]] = []
629
+ if self.config.drain_topics and self.config.persona == "host":
630
+ open_topics = self.client.topic_list_open()
631
+ context = build_wake_context(
632
+ topic_progress_data=topic_progress_data,
633
+ todos=todos,
634
+ notifications=notifications,
635
+ persona=self.config.persona,
636
+ drain_topics=self.config.drain_topics,
637
+ open_topics=open_topics,
638
+ )
639
+ if context.has_work:
640
+ stats.polls_with_work = 1
641
+ else:
642
+ stats.polls_idle = 1
643
+
644
+ persona_state = self._persona_state(self.config.persona)
645
+ last_remind_at = _parse_datetime(persona_state.get("last_remind_at"))
646
+ now = datetime.now(UTC)
647
+ should_remind, skip_reason = should_send_remind(
648
+ context,
649
+ now=now,
650
+ last_remind_at=last_remind_at,
651
+ inflight=self._inflight,
652
+ min_remind_seconds=self.config.min_remind_seconds,
653
+ )
654
+ if not should_remind:
655
+ if skip_reason == "busy":
656
+ stats.remind_skips_busy = 1
657
+ elif skip_reason == "cooldown":
658
+ stats.remind_skips_cooldown = 1
659
+ self._save_state_if_needed()
660
+ return stats, next_sleep_seconds(context, self.config)
661
+
662
+ prompt = build_remind_prompt(self.config.persona, context)
663
+ if self.config.dry_run:
664
+ typer.echo(
665
+ f"[dry-run] would remind persona={self.config.persona} "
666
+ f"topics={context.topic_update_count} todos={context.todo_item_count}"
667
+ )
668
+ typer.echo(prompt.rstrip())
669
+ stats.dry_run_actions = 1
670
+ self._save_state_if_needed()
671
+ return stats, next_sleep_seconds(context, self.config)
672
+
673
+ # v0.10:action_item escalation(移植自 legacy runtime-waker)。
674
+ # 在 remind 前扫描 open + owner=persona 的 action_items,推进
675
+ # wake_count(WAKE)或标记过期(STALE)。失败不阻塞 remind。
676
+ self._apply_action_item_escalation(stats, todos=todos, now=now)
677
+
678
+ self._inflight = True
679
+ try:
680
+ await self.backend.wake_async(prompt=prompt, event_source="simple-waker")
681
+ persona_state["last_remind_at"] = now.isoformat()
682
+ persona_state["last_remind_work_count"] = context.total_items
683
+ persona_state["last_remind_topic_count"] = context.topic_update_count
684
+ self._state_dirty = True
685
+ stats.reminds_sent = 1
686
+ # v0.10:写一条聚合 inbound_event 作为可观测性审计。
687
+ # fingerprint 含 cycle 时间戳,确保每个 remind cycle 唯一;
688
+ # 同一 cycle 内 min_remind_seconds 已防重,409 仅作并发兜底。
689
+ self._record_remind_inbound_event(stats, now=now, work_count=context.total_items)
690
+ except WorkerError as exc:
691
+ stats.remind_errors = 1
692
+ persona_state["last_remind_error"] = str(exc)
693
+ persona_state["last_remind_error_at"] = now.isoformat()
694
+ self._state_dirty = True
695
+ typer.echo(f"[simple-waker:error] {exc}", err=True)
696
+ finally:
697
+ self._inflight = False
698
+ self._save_state_if_needed(force=True)
699
+ return stats, next_sleep_seconds(context, self.config)
700
+
701
+ def _scan_stalled_experiment_locks(self, stats: SimpleWakerStats) -> None:
702
+ """Ask the platform to materialize stalled-lock notifications before polling work."""
703
+ if self.config.persona != "host":
704
+ return
705
+ scan_fn = getattr(self.client, "experiment_scan_stalled_locks", None)
706
+ if scan_fn is None or self.config.dry_run:
707
+ return
708
+ try:
709
+ result = scan_fn() or {}
710
+ except WorkerError as exc:
711
+ stats.cycle_errors += 1
712
+ typer.echo(f"[simple-waker:stalled-lock-scan] {exc}", err=True)
713
+ return
714
+ if isinstance(result, dict):
715
+ stats.stalled_lock_notifications += int(result.get("emitted_count") or 0)
716
+
717
+ def _record_remind_inbound_event(
718
+ self,
719
+ stats: SimpleWakerStats,
720
+ *,
721
+ now: datetime,
722
+ work_count: int,
723
+ ) -> None:
724
+ """写一条聚合 inbound_event 审计行(fingerprint=simple-remind:{persona}:{ts})。
725
+
726
+ 服务端 ``POST /agents/me/inbound-events`` 的 ``UNIQUE(fingerprint)``
727
+ 约束保证同一 cycle 不会被并发进程重复记录。失败不阻塞主流程。
728
+ """
729
+ record_fn = getattr(self.client, "inbound_event_record", None)
730
+ if record_fn is None:
731
+ # 测试 mock 可能不实现此方法;统计但不上报。
732
+ stats.inbound_events_skipped = 1
733
+ return
734
+ fingerprint = f"simple-remind:{self.config.persona}:{now.strftime('%Y%m%dT%H%M%S%fZ')}"
735
+ event_id = str(uuid.uuid4())
736
+ try:
737
+ recorded = record_fn(
738
+ event_id=event_id,
739
+ fingerprint=fingerprint,
740
+ event_type="simple-waker.remind",
741
+ source="polling",
742
+ )
743
+ if recorded:
744
+ stats.inbound_events_recorded = 1
745
+ else:
746
+ stats.inbound_events_duplicate = 1
747
+ except WorkerError as exc:
748
+ # 审计写入失败不影响主流程;记录到 state 供运维排查。
749
+ typer.echo(f"[simple-waker:inbound_event] {exc}", err=True)
750
+ stats.inbound_events_skipped = 1
751
+ persona_state = self._persona_state(self.config.persona)
752
+ persona_state["last_inbound_event_error"] = str(exc)
753
+ persona_state["last_inbound_event_error_at"] = now.isoformat()
754
+ self._state_dirty = True
755
+
756
+ def _apply_action_item_escalation(
757
+ self,
758
+ stats: SimpleWakerStats,
759
+ *,
760
+ todos: dict[str, Any],
761
+ now: datetime,
762
+ ) -> None:
763
+ """推进 action_item 升级时间线(WAKE → mark-wake-sent,STALE → mark-stale)。
764
+
765
+ simple-waker 是批量 remind,不像 legacy runtime-waker 逐项 fingerprint
766
+ wake。但 action_item 的 ``wake_count`` 仍需在 remind 时推进,否则
767
+ 永远停在第一阶段。STALE 项标记后从下次 remind 候选里消失(服务端
768
+ ``action_items`` todo 只返回 ``stale_at IS NULL`` 的项)。
769
+ """
770
+ items = todos.get("action_items") if isinstance(todos, dict) else None
771
+ if not isinstance(items, list) or not items:
772
+ return
773
+ # 取 persona agent_id 用于 owner 过滤。whoami 已在 _ensure_identity 调过,
774
+ # 这里复用 client 缓存。测试 mock 可能不实现 whoami,跳过即可。
775
+ me = getattr(self.client, "whoami", lambda: {})() or {}
776
+ persona_agent_id = str(me.get("id") or "") or None
777
+ decisions = scan_pending_action_items(
778
+ items,
779
+ persona_agent_id=persona_agent_id,
780
+ now=now,
781
+ )
782
+ if not decisions:
783
+ return
784
+ mark_wake_fn = getattr(self.client, "action_mark_wake_sent", None)
785
+ mark_stale_fn = getattr(self.client, "action_mark_stale", None)
786
+ for item_id, decision in decisions:
787
+ if decision == ActionItemWakeDecision.SKIP:
788
+ stats.action_items_skip += 1
789
+ continue
790
+ if decision == ActionItemWakeDecision.STALE:
791
+ if self.config.dry_run or mark_stale_fn is None:
792
+ stats.action_items_stale += 1
793
+ continue
794
+ try:
795
+ mark_stale_fn(item_id)
796
+ stats.action_items_stale += 1
797
+ except WorkerError as exc:
798
+ typer.echo(f"[simple-waker:mark-stale] {exc}", err=True)
799
+ stats.action_items_errors += 1
800
+ continue
801
+ # WAKE
802
+ if self.config.dry_run or mark_wake_fn is None:
803
+ stats.action_items_wake += 1
804
+ continue
805
+ try:
806
+ mark_wake_fn(item_id)
807
+ stats.action_items_wake += 1
808
+ except WorkerError as exc:
809
+ typer.echo(f"[simple-waker:mark-wake-sent] {exc}", err=True)
810
+ stats.action_items_errors += 1
811
+
812
+ def _ensure_identity(self) -> None:
813
+ me = self.client.whoami()
814
+ if not me or not me.get("id"):
815
+ raise WorkerError(
816
+ f"Could not resolve {self.config.persona} identity; run "
817
+ f"`map --persona {self.config.persona} persona whoami` first"
818
+ )
819
+
820
+ def _persona_state(self, persona: str) -> dict[str, Any]:
821
+ personas = self.state.setdefault("personas", {})
822
+ if persona not in personas or not isinstance(personas[persona], dict):
823
+ personas[persona] = {}
824
+ return personas[persona]
825
+
826
+ async def _reset_runtime_session_if_contract_changed(self) -> None:
827
+ persona_state = self._persona_state(self.config.persona)
828
+ previous_hash = persona_state.get("runtime_contract_hash")
829
+ if previous_hash == self._runtime_contract_hash:
830
+ return
831
+ has_resume_session = bool(
832
+ persona_state.get("claude_session_id") or persona_state.get("runtime_session_id")
833
+ )
834
+ if previous_hash is not None or has_resume_session:
835
+ typer.echo(
836
+ f"[simple-waker] runtime contract changed for {self.config.persona}; "
837
+ "starting a fresh runtime session",
838
+ err=True,
839
+ )
840
+ await self.backend.reset_session()
841
+ persona_state["runtime_contract_hash"] = self._runtime_contract_hash
842
+ persona_state["runtime_contract_version"] = RUNTIME_CONTRACT_VERSION
843
+ persona_state["runtime_contract_updated_at"] = datetime.now(UTC).isoformat()
844
+ self._state_dirty = True
845
+ self._save_state_if_needed(force=True)
846
+
847
+ def _save_state_if_needed(self, *, force: bool = False) -> None:
848
+ if not force and not self._state_dirty:
849
+ return
850
+
851
+ save_bridge_state(self.config.state_file, self.state)
852
+ self._state_dirty = False
853
+
854
+
855
+ @APP.command()
856
+ def run(
857
+ persona: str = typer.Option("host", "--persona", help="MAP persona to wake."),
858
+ project_root: Path = typer.Option(Path("."), "--project-root", help="Project root containing .map/."),
859
+ map_cmd: str = typer.Option("map", "--map-cmd", help="MAP CLI command."),
860
+ active_interval: float = typer.Option(
861
+ 30.0,
862
+ "--active-interval",
863
+ min=5.0,
864
+ help="Poll/remind cadence while work exists.",
865
+ ),
866
+ idle_interval: float = typer.Option(
867
+ 300.0,
868
+ "--idle-interval",
869
+ min=30.0,
870
+ help="Poll cadence when idle.",
871
+ ),
872
+ min_remind_seconds: float = typer.Option(
873
+ 30.0,
874
+ "--min-remind-seconds",
875
+ min=5.0,
876
+ help="Minimum seconds between remind prompts while work remains.",
877
+ ),
878
+ once: bool = typer.Option(False, "--once", help="Run one cycle and exit."),
879
+ max_cycles: int | None = typer.Option(None, "--max-cycles", min=1, help="Stop after N cycles."),
880
+ dry_run: bool = typer.Option(False, "--dry-run", help="Print remind actions without invoking runtime."),
881
+ drain_topics: bool = typer.Option(
882
+ False,
883
+ "--drain-topics",
884
+ help="Host mode: keep reminding while any open topic exists; dismiss does not count as done.",
885
+ ),
886
+ state_file: Path | None = typer.Option(
887
+ Path(".map/simple-waker-state.json"),
888
+ "--state-file",
889
+ help="Runtime session + remind timestamps.",
890
+ ),
891
+ model: str | None = typer.Option(None, "--model", help="Optional Claude model override."),
892
+ runtime_home: Path | None = typer.Option(
893
+ None,
894
+ "--runtime-home",
895
+ help="Optional HOME for the Claude runtime process.",
896
+ ),
897
+ stale_threshold_minutes: int | None = typer.Option(
898
+ None,
899
+ "--waker-stale-threshold",
900
+ min=0,
901
+ help=(
902
+ "Stale-open-topic threshold (minutes). When set, exports "
903
+ "MAP_STALE_OPEN_TOPIC_THRESHOLD_MINUTES so subprocess "
904
+ "``map work`` invocations and any in-process server pick up "
905
+ "the override via server.config.Settings (f873c287 I1(g))."
906
+ ),
907
+ ),
908
+ ) -> None:
909
+ """Run the simplified MAP waker loop."""
910
+ root = project_root.resolve()
911
+ resolved_runtime_home = runtime_home
912
+ if resolved_runtime_home is not None and not dry_run:
913
+ sync_runtime_skills(project_root=root, runtime_home=resolved_runtime_home)
914
+ # f873c287 I1(g): apply threshold to env BEFORE any Settings read so
915
+ # the ``map work`` subprocess (and any in-process server) sees the
916
+ # override on its first ``get_settings()`` call. We export here even
917
+ # in dry-run so logs reflect what the value would be in production.
918
+ if stale_threshold_minutes is not None:
919
+ os.environ["MAP_STALE_OPEN_TOPIC_THRESHOLD_MINUTES"] = str(stale_threshold_minutes)
920
+ # If the server module is already imported in this process (test
921
+ # fixtures), invalidate the lru_cache so the new value wins.
922
+ try:
923
+ from server.config import get_settings
924
+
925
+ get_settings.cache_clear()
926
+ except Exception:
927
+ pass
928
+ client = MapCommandClient(persona=persona, project_root=root, map_cmd=map_cmd)
929
+ config = SimpleWakerConfig(
930
+ persona=persona,
931
+ project_root=root,
932
+ map_cmd=map_cmd,
933
+ active_interval=active_interval,
934
+ idle_interval=idle_interval,
935
+ once=once,
936
+ max_cycles=max_cycles,
937
+ dry_run=dry_run,
938
+ state_file=state_file,
939
+ model=model,
940
+ runtime_home=resolved_runtime_home,
941
+ min_remind_seconds=min_remind_seconds,
942
+ drain_topics=drain_topics,
943
+ stale_threshold_minutes=stale_threshold_minutes,
944
+ )
945
+ waker = SimpleWaker(client=client, config=config)
946
+ waker.run_forever()
947
+
948
+
949
+ if __name__ == "__main__":
950
+ APP()