multi-agent-platform 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli/__init__.py +0 -0
- cli/action_item_escalation.py +177 -0
- cli/agent_client.py +554 -0
- cli/bridge_state.py +43 -0
- cli/commands/__init__.py +13 -0
- cli/commands/action.py +142 -0
- cli/commands/agent.py +117 -0
- cli/commands/audit.py +68 -0
- cli/commands/docs.py +179 -0
- cli/commands/experiment.py +755 -0
- cli/commands/feedback.py +106 -0
- cli/commands/notification.py +213 -0
- cli/commands/persona.py +63 -0
- cli/commands/project.py +87 -0
- cli/commands/runtime.py +105 -0
- cli/commands/topic.py +361 -0
- cli/e2e_collab.py +602 -0
- cli/git_checkpoint.py +68 -0
- cli/host_worker_types.py +151 -0
- cli/main.py +1553 -0
- cli/map_command_client.py +497 -0
- cli/participant_worker.py +255 -0
- cli/reviewer_worker.py +263 -0
- cli/runtime/__init__.py +5 -0
- cli/runtime/run_lock.py +497 -0
- cli/runtime_chat.py +317 -0
- cli/session_wake_log.py +235 -0
- cli/simple_waker.py +950 -0
- cli/table_render.py +113 -0
- cli/wake_backend.py +236 -0
- cli/worker_cycle_log.py +36 -0
- map_client/__init__.py +37 -0
- map_client/bootstrap.py +193 -0
- map_client/client.py +1045 -0
- map_client/config.py +21 -0
- map_client/errors.py +283 -0
- map_client/exceptions.py +130 -0
- map_client/plan_evidence.py +159 -0
- map_client/project_config.py +153 -0
- map_client/result_template.py +167 -0
- map_client/testing.py +27 -0
- map_mcp/__init__.py +4 -0
- map_mcp/_utils.py +28 -0
- map_mcp/auth.py +34 -0
- map_mcp/config.py +50 -0
- map_mcp/context.py +39 -0
- map_mcp/main.py +75 -0
- map_mcp/server.py +573 -0
- map_mcp/session.py +79 -0
- map_sdk/__init__.py +29 -0
- map_sdk/evidence.py +68 -0
- map_types/__init__.py +203 -0
- map_types/enums.py +199 -0
- map_types/schemas.py +1351 -0
- multi_agent_platform-0.1.0.dist-info/METADATA +298 -0
- multi_agent_platform-0.1.0.dist-info/RECORD +144 -0
- multi_agent_platform-0.1.0.dist-info/WHEEL +5 -0
- multi_agent_platform-0.1.0.dist-info/entry_points.txt +6 -0
- multi_agent_platform-0.1.0.dist-info/licenses/LICENSE +21 -0
- multi_agent_platform-0.1.0.dist-info/top_level.txt +6 -0
- server/__init__.py +0 -0
- server/__version__.py +14 -0
- server/api/__init__.py +0 -0
- server/api/action_items.py +138 -0
- server/api/agents.py +412 -0
- server/api/audit.py +54 -0
- server/api/background_tasks.py +18 -0
- server/api/common.py +117 -0
- server/api/deps.py +30 -0
- server/api/experiments.py +858 -0
- server/api/feedback.py +75 -0
- server/api/notifications.py +22 -0
- server/api/projects.py +209 -0
- server/api/router.py +25 -0
- server/api/status.py +33 -0
- server/api/topics.py +302 -0
- server/api/webhooks.py +74 -0
- server/auth/__init__.py +8 -0
- server/auth/experiment_access.py +66 -0
- server/config.py +38 -0
- server/db/__init__.py +3 -0
- server/db/base.py +5 -0
- server/db/deadlock_retry.py +146 -0
- server/db/session.py +41 -0
- server/domain/__init__.py +3 -0
- server/domain/encrypted_types.py +63 -0
- server/domain/models.py +713 -0
- server/domain/schemas.py +3 -0
- server/domain/state_machine.py +79 -0
- server/domain/topic_ack_constants.py +9 -0
- server/main.py +148 -0
- server/scripts/__init__.py +0 -0
- server/scripts/migrate_notification_unique.py +231 -0
- server/scripts/purge_audit_pollution.py +116 -0
- server/services/__init__.py +0 -0
- server/services/_lookups.py +26 -0
- server/services/acceptance_service.py +90 -0
- server/services/action_item_migration_service.py +190 -0
- server/services/action_item_service.py +200 -0
- server/services/agent_work_service.py +405 -0
- server/services/archive_lint_service.py +156 -0
- server/services/audit_service.py +457 -0
- server/services/auth.py +66 -0
- server/services/comment_service.py +173 -0
- server/services/errors.py +65 -0
- server/services/escalation_resolver.py +248 -0
- server/services/evidence_service.py +88 -0
- server/services/experiment_capabilities_service.py +277 -0
- server/services/inbound_event_service.py +111 -0
- server/services/lock_service.py +273 -0
- server/services/log_service.py +202 -0
- server/services/mention_service.py +730 -0
- server/services/notification_service.py +939 -0
- server/services/notification_stream.py +138 -0
- server/services/permissions.py +147 -0
- server/services/persona_activity_service.py +108 -0
- server/services/phase_owner_resolver.py +95 -0
- server/services/phase_service.py +381 -0
- server/services/plan_marker_service.py +235 -0
- server/services/plan_service.py +186 -0
- server/services/platform_feedback_service.py +114 -0
- server/services/project_service.py +534 -0
- server/services/project_status_service.py +132 -0
- server/services/review_service.py +707 -0
- server/services/secret_encryption.py +97 -0
- server/services/similarity_service.py +119 -0
- server/services/sse_event_schemas.py +17 -0
- server/services/status_service.py +68 -0
- server/services/template_service.py +134 -0
- server/services/text_utils.py +19 -0
- server/services/thread_activity.py +180 -0
- server/services/todo_persona_filter.py +73 -0
- server/services/todo_service.py +604 -0
- server/services/topic_ack_service.py +312 -0
- server/services/topic_action_item_ops.py +538 -0
- server/services/topic_comment_kind.py +14 -0
- server/services/topic_comment_service.py +237 -0
- server/services/topic_helpers.py +32 -0
- server/services/topic_lifecycle_service.py +478 -0
- server/services/topic_progress_service.py +40 -0
- server/services/topic_resolve_service.py +234 -0
- server/services/topic_service.py +102 -0
- server/services/topic_work_item_service.py +570 -0
- server/services/webhook_service.py +273 -0
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()
|