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
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import tempfile
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from cli.host_worker_types import WorkerError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MapCommandClient:
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
*,
|
|
19
|
+
map_cmd: str = "map",
|
|
20
|
+
persona: str = "host",
|
|
21
|
+
project_root: Path | None = None,
|
|
22
|
+
dry_run: bool = False,
|
|
23
|
+
cmd_timeout: float = 120.0,
|
|
24
|
+
) -> None:
|
|
25
|
+
self.map_cmd = map_cmd
|
|
26
|
+
self.persona = persona
|
|
27
|
+
self.project_root = project_root
|
|
28
|
+
self.dry_run = dry_run
|
|
29
|
+
# 每次 ``map`` 子进程调用的最大秒数。waker 每个 cycle 会发起多次
|
|
30
|
+
# subprocess(whoami / work / action mark-* / inbound-event record …);
|
|
31
|
+
# 一条挂起的命令若没有超时保护,会把整个 waker cycle 永久阻塞
|
|
32
|
+
# (_inflight 永远回不到 False,后续 cycle 全部 skip("busy"))。
|
|
33
|
+
self.cmd_timeout = cmd_timeout
|
|
34
|
+
|
|
35
|
+
def _base_args(self) -> list[str]:
|
|
36
|
+
args = [self.map_cmd, "--persona", self.persona]
|
|
37
|
+
if self.project_root is not None:
|
|
38
|
+
args.extend(["--project-root", str(self.project_root)])
|
|
39
|
+
return args
|
|
40
|
+
|
|
41
|
+
def _run(self, args: list[str], *, parse_yaml: bool = True, retryable: bool = False) -> Any:
|
|
42
|
+
cmd = self._base_args() + args
|
|
43
|
+
if self.dry_run and _is_write_command(args):
|
|
44
|
+
typer.echo("[dry-run] " + " ".join(cmd))
|
|
45
|
+
return None
|
|
46
|
+
attempts = _RETRY_ATTEMPTS if retryable else 1
|
|
47
|
+
result: subprocess.CompletedProcess[str] | None = None
|
|
48
|
+
for attempt in range(attempts):
|
|
49
|
+
try:
|
|
50
|
+
result = subprocess.run(
|
|
51
|
+
cmd,
|
|
52
|
+
text=True,
|
|
53
|
+
capture_output=True,
|
|
54
|
+
check=False,
|
|
55
|
+
timeout=self.cmd_timeout,
|
|
56
|
+
)
|
|
57
|
+
except subprocess.TimeoutExpired as exc:
|
|
58
|
+
if retryable and attempt < attempts - 1:
|
|
59
|
+
self._retry_backoff(attempt, cmd, reason="timeout")
|
|
60
|
+
continue
|
|
61
|
+
raise WorkerError(
|
|
62
|
+
f"Command timed out after {self.cmd_timeout}s: {' '.join(cmd)}"
|
|
63
|
+
) from exc
|
|
64
|
+
if result.returncode == 0:
|
|
65
|
+
break
|
|
66
|
+
detail = result.stderr.strip() or result.stdout.strip()
|
|
67
|
+
# 仅对幂等读命令、且失败特征为瞬时(API 5xx / 网络抖动 / 超时)时退避重试;
|
|
68
|
+
# 401/403/404 等确定性错误或写命令失败立即抛出,避免无谓重试。
|
|
69
|
+
if retryable and attempt < attempts - 1 and _is_transient_failure(detail):
|
|
70
|
+
self._retry_backoff(attempt, cmd, reason=detail)
|
|
71
|
+
continue
|
|
72
|
+
raise WorkerError(
|
|
73
|
+
f"Command failed ({result.returncode}): {' '.join(cmd)}\n{detail}"
|
|
74
|
+
)
|
|
75
|
+
assert result is not None # 循环只在 returncode == 0 时 break
|
|
76
|
+
if not parse_yaml:
|
|
77
|
+
return result.stdout
|
|
78
|
+
if not result.stdout.strip():
|
|
79
|
+
return None
|
|
80
|
+
return yaml.safe_load(result.stdout)
|
|
81
|
+
|
|
82
|
+
def _retry_backoff(self, attempt: int, cmd: list[str], *, reason: str) -> None:
|
|
83
|
+
delay = _RETRY_BACKOFF_BASE * (2**attempt)
|
|
84
|
+
typer.echo(
|
|
85
|
+
f"[map-client] transient failure (attempt {attempt + 1}/{_RETRY_ATTEMPTS}), "
|
|
86
|
+
f"retrying in {delay:.0f}s: {' '.join(cmd)} :: {reason[:200]}",
|
|
87
|
+
err=True,
|
|
88
|
+
)
|
|
89
|
+
time.sleep(delay)
|
|
90
|
+
|
|
91
|
+
def whoami(self) -> dict[str, Any]:
|
|
92
|
+
return self._run(["persona", "whoami"], retryable=True)
|
|
93
|
+
|
|
94
|
+
def todos(self) -> dict[str, Any]:
|
|
95
|
+
return self._run(["todos"], retryable=True)
|
|
96
|
+
|
|
97
|
+
def work(self) -> dict[str, Any]:
|
|
98
|
+
return self._run(["work", "--notification-category", "wakeable"], retryable=True)
|
|
99
|
+
|
|
100
|
+
def topic_progress(self) -> dict[str, Any]:
|
|
101
|
+
return self._run(["topic", "progress"], retryable=True)
|
|
102
|
+
|
|
103
|
+
def notifications_unread(
|
|
104
|
+
self,
|
|
105
|
+
*,
|
|
106
|
+
limit: int = 50,
|
|
107
|
+
category: str | None = "wakeable",
|
|
108
|
+
) -> list[dict[str, Any]]:
|
|
109
|
+
args = ["notification", "list", "--unread-only", "--limit", str(limit)]
|
|
110
|
+
if category is not None:
|
|
111
|
+
args.extend(["--category", category])
|
|
112
|
+
data = self._run(args, retryable=True)
|
|
113
|
+
if not isinstance(data, dict):
|
|
114
|
+
return []
|
|
115
|
+
items = data.get("items")
|
|
116
|
+
return list(items) if isinstance(items, list) else []
|
|
117
|
+
|
|
118
|
+
def mention_dismiss(self, mention_id: str) -> dict[str, Any] | None:
|
|
119
|
+
return self._run(["mention", "dismiss", "--id", mention_id])
|
|
120
|
+
|
|
121
|
+
def mention_dismiss_all(self) -> dict[str, Any] | None:
|
|
122
|
+
return self._run(["mention", "dismiss-all"])
|
|
123
|
+
|
|
124
|
+
def topic_show(self, topic_id: str) -> dict[str, Any]:
|
|
125
|
+
return self._run(["topic", "show", "--id", topic_id], retryable=True)
|
|
126
|
+
|
|
127
|
+
def topic_list_open(self) -> list[dict[str, Any]]:
|
|
128
|
+
page = 1
|
|
129
|
+
page_size = 100
|
|
130
|
+
all_rows: list[dict[str, Any]] = []
|
|
131
|
+
while True:
|
|
132
|
+
rows = self._run(
|
|
133
|
+
[
|
|
134
|
+
"topic",
|
|
135
|
+
"list",
|
|
136
|
+
"--status",
|
|
137
|
+
"open",
|
|
138
|
+
"--page",
|
|
139
|
+
str(page),
|
|
140
|
+
"--page-size",
|
|
141
|
+
str(page_size),
|
|
142
|
+
],
|
|
143
|
+
retryable=True,
|
|
144
|
+
)
|
|
145
|
+
page_rows = list(rows or []) if isinstance(rows, list) else []
|
|
146
|
+
all_rows.extend(r for r in page_rows if isinstance(r, dict))
|
|
147
|
+
if len(page_rows) < page_size:
|
|
148
|
+
break
|
|
149
|
+
page += 1
|
|
150
|
+
return all_rows
|
|
151
|
+
|
|
152
|
+
def topic_comment(self, topic_id: str, body: str, parent_id: str | None = None) -> dict[str, Any] | None:
|
|
153
|
+
args = ["topic", "comment", "--id", topic_id, "--body", body]
|
|
154
|
+
if parent_id is not None:
|
|
155
|
+
args.extend(["--parent", parent_id])
|
|
156
|
+
return self._run(args)
|
|
157
|
+
|
|
158
|
+
def topic_advance_round(self, topic_id: str) -> dict[str, Any] | None:
|
|
159
|
+
return self._run(["topic", "advance-round", "--id", topic_id])
|
|
160
|
+
|
|
161
|
+
def topic_resolve(self, topic_id: str, payload: dict[str, Any]) -> dict[str, Any] | None:
|
|
162
|
+
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".yaml", delete=True) as fh:
|
|
163
|
+
yaml.safe_dump(payload, fh, allow_unicode=True, sort_keys=False)
|
|
164
|
+
fh.flush()
|
|
165
|
+
return self._run(["topic", "resolve", "--id", topic_id, "--file", fh.name])
|
|
166
|
+
|
|
167
|
+
def action_complete(self, action_item_id: str) -> dict[str, Any] | None:
|
|
168
|
+
return self._run(["action", "complete", "--id", action_item_id])
|
|
169
|
+
|
|
170
|
+
def action_cancel(
|
|
171
|
+
self,
|
|
172
|
+
action_item_id: str,
|
|
173
|
+
*,
|
|
174
|
+
reason: str,
|
|
175
|
+
category: str | None = None,
|
|
176
|
+
) -> dict[str, Any] | None:
|
|
177
|
+
args = ["action", "cancel", "--id", action_item_id, "--reason", reason]
|
|
178
|
+
if category is not None:
|
|
179
|
+
args.extend(["--category", category])
|
|
180
|
+
return self._run(args)
|
|
181
|
+
|
|
182
|
+
def action_link(self, action_item_id: str, experiment_id: str) -> dict[str, Any] | None:
|
|
183
|
+
return self._run([
|
|
184
|
+
"action", "link",
|
|
185
|
+
"--id", action_item_id,
|
|
186
|
+
"--experiment-id", experiment_id,
|
|
187
|
+
])
|
|
188
|
+
|
|
189
|
+
def action_mark_wake_sent(self, action_item_id: str) -> dict[str, Any] | None:
|
|
190
|
+
"""Bump wake_count + stamp last_woken_at + audit (experiment B / I4)."""
|
|
191
|
+
return self._run(["action", "mark-wake-sent", "--id", action_item_id])
|
|
192
|
+
|
|
193
|
+
def action_mark_stale(self, action_item_id: str) -> dict[str, Any] | None:
|
|
194
|
+
"""Stamp stale_at + write the action_item.stale audit (experiment B / I4)."""
|
|
195
|
+
return self._run(["action", "mark-stale", "--id", action_item_id])
|
|
196
|
+
|
|
197
|
+
def experiment_create(
|
|
198
|
+
self,
|
|
199
|
+
title: str,
|
|
200
|
+
plan_file: Path,
|
|
201
|
+
*,
|
|
202
|
+
topic_id: str,
|
|
203
|
+
submit_for_review: bool,
|
|
204
|
+
) -> dict[str, Any] | None:
|
|
205
|
+
args = [
|
|
206
|
+
"experiment",
|
|
207
|
+
"create",
|
|
208
|
+
"--title",
|
|
209
|
+
title,
|
|
210
|
+
"--plan-file",
|
|
211
|
+
str(plan_file),
|
|
212
|
+
"--topic-id",
|
|
213
|
+
topic_id,
|
|
214
|
+
]
|
|
215
|
+
if submit_for_review:
|
|
216
|
+
args.append("--submit-for-review")
|
|
217
|
+
return self._run(args)
|
|
218
|
+
|
|
219
|
+
def experiment_status(self, experiment_id: str) -> dict[str, Any]:
|
|
220
|
+
return self._run(["experiment", "status", "--id", experiment_id], retryable=True)
|
|
221
|
+
|
|
222
|
+
def experiment_list(
|
|
223
|
+
self,
|
|
224
|
+
*,
|
|
225
|
+
phase: str | None = None,
|
|
226
|
+
page_size: int = 100,
|
|
227
|
+
) -> list[dict[str, Any]]:
|
|
228
|
+
"""List experiments (read-only). Used by the E2E driver to discover
|
|
229
|
+
experiment_id from topic_id; not used by waker."""
|
|
230
|
+
args = ["experiment", "list", "--page-size", str(page_size)]
|
|
231
|
+
if phase:
|
|
232
|
+
args.extend(["--phase", phase])
|
|
233
|
+
data = self._run(args, retryable=True)
|
|
234
|
+
return list(data or []) if isinstance(data, list) else []
|
|
235
|
+
|
|
236
|
+
def experiment_submit_review(self, experiment_id: str) -> dict[str, Any] | None:
|
|
237
|
+
return self._run(["experiment", "submit-review", "--id", experiment_id])
|
|
238
|
+
|
|
239
|
+
def experiment_reviews_list(self, experiment_id: str) -> list[dict[str, Any]]:
|
|
240
|
+
data = self._run(["experiment", "review", "list", "--id", experiment_id], retryable=True)
|
|
241
|
+
return list(data or [])
|
|
242
|
+
|
|
243
|
+
def plan_revise(
|
|
244
|
+
self,
|
|
245
|
+
experiment_id: str,
|
|
246
|
+
plan_file: Path,
|
|
247
|
+
*,
|
|
248
|
+
note: str | None,
|
|
249
|
+
addressed_item_ids: list[str],
|
|
250
|
+
) -> dict[str, Any] | None:
|
|
251
|
+
args = [
|
|
252
|
+
"experiment",
|
|
253
|
+
"plan",
|
|
254
|
+
"revise",
|
|
255
|
+
"--id",
|
|
256
|
+
experiment_id,
|
|
257
|
+
"--plan-file",
|
|
258
|
+
str(plan_file),
|
|
259
|
+
]
|
|
260
|
+
if note:
|
|
261
|
+
args.extend(["--note", note])
|
|
262
|
+
for item_id in addressed_item_ids:
|
|
263
|
+
args.extend(["--addressed-item", item_id])
|
|
264
|
+
return self._run(args)
|
|
265
|
+
|
|
266
|
+
def experiment_approve(self, experiment_id: str) -> dict[str, Any] | None:
|
|
267
|
+
return self._run(["experiment", "approve", "--id", experiment_id])
|
|
268
|
+
|
|
269
|
+
def experiment_start(self, experiment_id: str) -> dict[str, Any] | None:
|
|
270
|
+
return self._run(["experiment", "start", "--id", experiment_id])
|
|
271
|
+
|
|
272
|
+
def experiment_complete(self, experiment_id: str, *, summary: str, log_file: Path) -> dict[str, Any] | None:
|
|
273
|
+
metadata = {"evidence": {"worker_log": str(log_file)}}
|
|
274
|
+
with tempfile.NamedTemporaryFile("w", suffix=".yaml", encoding="utf-8", delete=False) as fh:
|
|
275
|
+
yaml.safe_dump(metadata, fh, allow_unicode=True, sort_keys=False)
|
|
276
|
+
metadata_file = Path(fh.name)
|
|
277
|
+
try:
|
|
278
|
+
return self._run(
|
|
279
|
+
[
|
|
280
|
+
"experiment",
|
|
281
|
+
"complete",
|
|
282
|
+
"--id",
|
|
283
|
+
experiment_id,
|
|
284
|
+
"--summary",
|
|
285
|
+
summary,
|
|
286
|
+
"--file",
|
|
287
|
+
str(log_file),
|
|
288
|
+
"--metadata",
|
|
289
|
+
str(metadata_file),
|
|
290
|
+
]
|
|
291
|
+
)
|
|
292
|
+
finally:
|
|
293
|
+
metadata_file.unlink(missing_ok=True)
|
|
294
|
+
|
|
295
|
+
# --- experiment execution lock (CP-3) -------------------------------------
|
|
296
|
+
|
|
297
|
+
def experiment_acquire_lock(self, experiment_id: str, *, ttl_seconds: int) -> dict[str, Any] | None:
|
|
298
|
+
return self._run(
|
|
299
|
+
[
|
|
300
|
+
"experiment",
|
|
301
|
+
"lock",
|
|
302
|
+
"acquire",
|
|
303
|
+
"--id",
|
|
304
|
+
experiment_id,
|
|
305
|
+
"--ttl",
|
|
306
|
+
str(ttl_seconds),
|
|
307
|
+
]
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
def experiment_release_lock(self, experiment_id: str) -> dict[str, Any] | None:
|
|
311
|
+
return self._run(["experiment", "lock", "release", "--id", experiment_id])
|
|
312
|
+
|
|
313
|
+
def experiment_force_release_lock(
|
|
314
|
+
self, experiment_id: str, *, reason: str, actor: str | None = None
|
|
315
|
+
) -> dict[str, Any] | None:
|
|
316
|
+
args = [
|
|
317
|
+
"experiment",
|
|
318
|
+
"lock",
|
|
319
|
+
"force-release",
|
|
320
|
+
"--id",
|
|
321
|
+
experiment_id,
|
|
322
|
+
"--reason",
|
|
323
|
+
reason,
|
|
324
|
+
]
|
|
325
|
+
if actor:
|
|
326
|
+
args.extend(["--actor", actor])
|
|
327
|
+
return self._run(args)
|
|
328
|
+
|
|
329
|
+
def experiment_record_skip(
|
|
330
|
+
self, experiment_id: str, *, next_attempt_at: str
|
|
331
|
+
) -> dict[str, Any] | None:
|
|
332
|
+
return self._run(
|
|
333
|
+
[
|
|
334
|
+
"experiment",
|
|
335
|
+
"lock",
|
|
336
|
+
"skip",
|
|
337
|
+
"--id",
|
|
338
|
+
experiment_id,
|
|
339
|
+
"--next-attempt-at",
|
|
340
|
+
next_attempt_at,
|
|
341
|
+
]
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
def experiment_scan_stalled_locks(self) -> dict[str, Any] | None:
|
|
345
|
+
return self._run(["experiment", "lock", "scan-stalled"])
|
|
346
|
+
|
|
347
|
+
# --- inbound event (D6 server gate) ------------------------------------
|
|
348
|
+
|
|
349
|
+
def inbound_event_record(
|
|
350
|
+
self,
|
|
351
|
+
*,
|
|
352
|
+
event_id: str,
|
|
353
|
+
fingerprint: str,
|
|
354
|
+
event_type: str,
|
|
355
|
+
source: str = "polling",
|
|
356
|
+
) -> bool:
|
|
357
|
+
"""Record a waker fingerprint against the D6 server gate.
|
|
358
|
+
|
|
359
|
+
Returns ``True`` on first-time success, ``False`` if the server already
|
|
360
|
+
had the fingerprint (409 → CLI exit 2). Any other non-zero exit raises
|
|
361
|
+
:class:`WorkerError`. The waker treats ``False`` as "already woken by
|
|
362
|
+
another worker" and skips resume.
|
|
363
|
+
"""
|
|
364
|
+
args = [
|
|
365
|
+
"inbound-event",
|
|
366
|
+
"record",
|
|
367
|
+
"--event-id",
|
|
368
|
+
event_id,
|
|
369
|
+
"--fingerprint",
|
|
370
|
+
fingerprint,
|
|
371
|
+
"--event-type",
|
|
372
|
+
event_type,
|
|
373
|
+
"--source",
|
|
374
|
+
source,
|
|
375
|
+
]
|
|
376
|
+
cmd = self._base_args() + args
|
|
377
|
+
if self.dry_run and _is_write_command(args):
|
|
378
|
+
typer.echo("[dry-run] " + " ".join(cmd))
|
|
379
|
+
return True
|
|
380
|
+
try:
|
|
381
|
+
result = subprocess.run(
|
|
382
|
+
cmd,
|
|
383
|
+
text=True,
|
|
384
|
+
capture_output=True,
|
|
385
|
+
check=False,
|
|
386
|
+
timeout=self.cmd_timeout,
|
|
387
|
+
)
|
|
388
|
+
except subprocess.TimeoutExpired as exc:
|
|
389
|
+
raise WorkerError(
|
|
390
|
+
f"Command timed out after {self.cmd_timeout}s: {' '.join(cmd)}"
|
|
391
|
+
) from exc
|
|
392
|
+
if result.returncode == 0:
|
|
393
|
+
return True
|
|
394
|
+
if result.returncode == 2:
|
|
395
|
+
# CLI maps 409 Conflict to exit 2; treat as duplicate.
|
|
396
|
+
return False
|
|
397
|
+
detail = result.stderr.strip() or result.stdout.strip()
|
|
398
|
+
raise WorkerError(
|
|
399
|
+
f"Command failed ({result.returncode}): {' '.join(cmd)}\n{detail}"
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# 会修改 MAP 状态的子命令(``map --dry-run`` 时必须跳过这些,否则会真实
|
|
404
|
+
# 写入)。``_WRITE_COMMANDS_2`` 匹配两段路径 ``[group, command]``,
|
|
405
|
+
# ``_WRITE_COMMANDS_3`` 匹配三段路径 ``[group, subgroup, command]``。
|
|
406
|
+
#
|
|
407
|
+
# 这个清单必须与 ``cli/main.py`` 注册的命令保持一致——
|
|
408
|
+
# ``tests/cli/test_dry_run_write_commands.py`` 用反射扫描 main.py 的全部
|
|
409
|
+
# 命令,强制每个命令要么在此处(写)、要么在测试的 read-only 集合里,
|
|
410
|
+
# 从而防止"新增写命令却忘了登记"导致 dry-run 真实执行。
|
|
411
|
+
_WRITE_COMMANDS_2: set[tuple[str, str]] = {
|
|
412
|
+
# topic
|
|
413
|
+
("topic", "comment"),
|
|
414
|
+
("topic", "create"),
|
|
415
|
+
("topic", "close"),
|
|
416
|
+
("topic", "reopen"),
|
|
417
|
+
("topic", "dismiss"),
|
|
418
|
+
("topic", "advance-round"),
|
|
419
|
+
("topic", "resolve"),
|
|
420
|
+
("topic", "archive"),
|
|
421
|
+
("topic", "read"),
|
|
422
|
+
("topic", "mark-seen"),
|
|
423
|
+
# experiment
|
|
424
|
+
("experiment", "create"),
|
|
425
|
+
("experiment", "submit-review"),
|
|
426
|
+
("experiment", "approve"),
|
|
427
|
+
("experiment", "start"),
|
|
428
|
+
("experiment", "complete"),
|
|
429
|
+
("experiment", "log"),
|
|
430
|
+
("experiment", "comment"),
|
|
431
|
+
("experiment", "archive"),
|
|
432
|
+
("experiment", "accept-result"),
|
|
433
|
+
("experiment", "reject-result"),
|
|
434
|
+
# mention
|
|
435
|
+
("mention", "dismiss"),
|
|
436
|
+
("mention", "dismiss-all"),
|
|
437
|
+
("mention", "reconcile-stale"),
|
|
438
|
+
# notification(标记已读 = 写)
|
|
439
|
+
("notification", "read"),
|
|
440
|
+
("notification", "read-all"),
|
|
441
|
+
# action item
|
|
442
|
+
("action", "complete"),
|
|
443
|
+
("action", "cancel"),
|
|
444
|
+
("action", "deliver"),
|
|
445
|
+
("action", "link"),
|
|
446
|
+
("action", "mark-wake-sent"),
|
|
447
|
+
("action", "mark-stale"),
|
|
448
|
+
# feedback
|
|
449
|
+
("feedback", "submit"),
|
|
450
|
+
("feedback", "update"),
|
|
451
|
+
# project
|
|
452
|
+
("project", "create"),
|
|
453
|
+
# inbound event(waker 审计门禁)
|
|
454
|
+
("inbound-event", "record"),
|
|
455
|
+
# todo 分区清理
|
|
456
|
+
("todo", "clear"),
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
_WRITE_COMMANDS_3: set[tuple[str, str, str]] = {
|
|
460
|
+
("experiment", "plan", "revise"),
|
|
461
|
+
("experiment", "review", "add"),
|
|
462
|
+
("experiment", "review", "resolve-item"),
|
|
463
|
+
("experiment", "review", "withdraw"),
|
|
464
|
+
("experiment", "lock", "acquire"),
|
|
465
|
+
("experiment", "lock", "release"),
|
|
466
|
+
("experiment", "lock", "force-release"),
|
|
467
|
+
("experiment", "lock", "skip"),
|
|
468
|
+
("experiment", "lock", "scan-stalled"),
|
|
469
|
+
# project Current Status MD 修订
|
|
470
|
+
("project", "status", "revise"),
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _is_write_command(args: list[str]) -> bool:
|
|
475
|
+
if not args:
|
|
476
|
+
return False
|
|
477
|
+
if len(args) >= 3 and tuple(args[:3]) in _WRITE_COMMANDS_3:
|
|
478
|
+
return True
|
|
479
|
+
return tuple(args[:2]) in _WRITE_COMMANDS_2
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
# 瞬时失败特征:stderr/stdout 含这些子串时视为可重试(API 5xx / 网络抖动 / 超时)。
|
|
483
|
+
# 401/403/404 等确定性错误不在其中——对幂等读命令重试它们只是浪费几次快速失败。
|
|
484
|
+
_TRANSIENT_FAILURE_MARKERS: tuple[str, ...] = (
|
|
485
|
+
"5xx", "500", "502", "503", "504",
|
|
486
|
+
"timeout", "timed out",
|
|
487
|
+
"connection", "connect", "reset", "refused", "unreachable",
|
|
488
|
+
"temporarily", "retry", "overloaded",
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
_RETRY_ATTEMPTS = 3
|
|
492
|
+
_RETRY_BACKOFF_BASE = 2.0
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _is_transient_failure(detail: str) -> bool:
|
|
496
|
+
lowered = detail.lower()
|
|
497
|
+
return any(marker in lowered for marker in _TRANSIENT_FAILURE_MARKERS)
|