multi-agent-platform 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. cli/__init__.py +0 -0
  2. cli/action_item_escalation.py +177 -0
  3. cli/agent_client.py +554 -0
  4. cli/bridge_state.py +43 -0
  5. cli/commands/__init__.py +13 -0
  6. cli/commands/action.py +142 -0
  7. cli/commands/agent.py +117 -0
  8. cli/commands/audit.py +68 -0
  9. cli/commands/docs.py +179 -0
  10. cli/commands/experiment.py +755 -0
  11. cli/commands/feedback.py +106 -0
  12. cli/commands/notification.py +213 -0
  13. cli/commands/persona.py +63 -0
  14. cli/commands/project.py +87 -0
  15. cli/commands/runtime.py +105 -0
  16. cli/commands/topic.py +361 -0
  17. cli/e2e_collab.py +602 -0
  18. cli/git_checkpoint.py +68 -0
  19. cli/host_worker_types.py +151 -0
  20. cli/main.py +1553 -0
  21. cli/map_command_client.py +497 -0
  22. cli/participant_worker.py +255 -0
  23. cli/reviewer_worker.py +263 -0
  24. cli/runtime/__init__.py +5 -0
  25. cli/runtime/run_lock.py +497 -0
  26. cli/runtime_chat.py +317 -0
  27. cli/session_wake_log.py +235 -0
  28. cli/simple_waker.py +950 -0
  29. cli/table_render.py +113 -0
  30. cli/wake_backend.py +236 -0
  31. cli/worker_cycle_log.py +36 -0
  32. map_client/__init__.py +37 -0
  33. map_client/bootstrap.py +193 -0
  34. map_client/client.py +1045 -0
  35. map_client/config.py +21 -0
  36. map_client/errors.py +283 -0
  37. map_client/exceptions.py +130 -0
  38. map_client/plan_evidence.py +159 -0
  39. map_client/project_config.py +153 -0
  40. map_client/result_template.py +167 -0
  41. map_client/testing.py +27 -0
  42. map_mcp/__init__.py +4 -0
  43. map_mcp/_utils.py +28 -0
  44. map_mcp/auth.py +34 -0
  45. map_mcp/config.py +50 -0
  46. map_mcp/context.py +39 -0
  47. map_mcp/main.py +75 -0
  48. map_mcp/server.py +573 -0
  49. map_mcp/session.py +79 -0
  50. map_sdk/__init__.py +29 -0
  51. map_sdk/evidence.py +68 -0
  52. map_types/__init__.py +203 -0
  53. map_types/enums.py +199 -0
  54. map_types/schemas.py +1351 -0
  55. multi_agent_platform-0.1.0.dist-info/METADATA +298 -0
  56. multi_agent_platform-0.1.0.dist-info/RECORD +144 -0
  57. multi_agent_platform-0.1.0.dist-info/WHEEL +5 -0
  58. multi_agent_platform-0.1.0.dist-info/entry_points.txt +6 -0
  59. multi_agent_platform-0.1.0.dist-info/licenses/LICENSE +21 -0
  60. multi_agent_platform-0.1.0.dist-info/top_level.txt +6 -0
  61. server/__init__.py +0 -0
  62. server/__version__.py +14 -0
  63. server/api/__init__.py +0 -0
  64. server/api/action_items.py +138 -0
  65. server/api/agents.py +412 -0
  66. server/api/audit.py +54 -0
  67. server/api/background_tasks.py +18 -0
  68. server/api/common.py +117 -0
  69. server/api/deps.py +30 -0
  70. server/api/experiments.py +858 -0
  71. server/api/feedback.py +75 -0
  72. server/api/notifications.py +22 -0
  73. server/api/projects.py +209 -0
  74. server/api/router.py +25 -0
  75. server/api/status.py +33 -0
  76. server/api/topics.py +302 -0
  77. server/api/webhooks.py +74 -0
  78. server/auth/__init__.py +8 -0
  79. server/auth/experiment_access.py +66 -0
  80. server/config.py +38 -0
  81. server/db/__init__.py +3 -0
  82. server/db/base.py +5 -0
  83. server/db/deadlock_retry.py +146 -0
  84. server/db/session.py +41 -0
  85. server/domain/__init__.py +3 -0
  86. server/domain/encrypted_types.py +63 -0
  87. server/domain/models.py +713 -0
  88. server/domain/schemas.py +3 -0
  89. server/domain/state_machine.py +79 -0
  90. server/domain/topic_ack_constants.py +9 -0
  91. server/main.py +148 -0
  92. server/scripts/__init__.py +0 -0
  93. server/scripts/migrate_notification_unique.py +231 -0
  94. server/scripts/purge_audit_pollution.py +116 -0
  95. server/services/__init__.py +0 -0
  96. server/services/_lookups.py +26 -0
  97. server/services/acceptance_service.py +90 -0
  98. server/services/action_item_migration_service.py +190 -0
  99. server/services/action_item_service.py +200 -0
  100. server/services/agent_work_service.py +405 -0
  101. server/services/archive_lint_service.py +156 -0
  102. server/services/audit_service.py +457 -0
  103. server/services/auth.py +66 -0
  104. server/services/comment_service.py +173 -0
  105. server/services/errors.py +65 -0
  106. server/services/escalation_resolver.py +248 -0
  107. server/services/evidence_service.py +88 -0
  108. server/services/experiment_capabilities_service.py +277 -0
  109. server/services/inbound_event_service.py +111 -0
  110. server/services/lock_service.py +273 -0
  111. server/services/log_service.py +202 -0
  112. server/services/mention_service.py +730 -0
  113. server/services/notification_service.py +939 -0
  114. server/services/notification_stream.py +138 -0
  115. server/services/permissions.py +147 -0
  116. server/services/persona_activity_service.py +108 -0
  117. server/services/phase_owner_resolver.py +95 -0
  118. server/services/phase_service.py +381 -0
  119. server/services/plan_marker_service.py +235 -0
  120. server/services/plan_service.py +186 -0
  121. server/services/platform_feedback_service.py +114 -0
  122. server/services/project_service.py +534 -0
  123. server/services/project_status_service.py +132 -0
  124. server/services/review_service.py +707 -0
  125. server/services/secret_encryption.py +97 -0
  126. server/services/similarity_service.py +119 -0
  127. server/services/sse_event_schemas.py +17 -0
  128. server/services/status_service.py +68 -0
  129. server/services/template_service.py +134 -0
  130. server/services/text_utils.py +19 -0
  131. server/services/thread_activity.py +180 -0
  132. server/services/todo_persona_filter.py +73 -0
  133. server/services/todo_service.py +604 -0
  134. server/services/topic_ack_service.py +312 -0
  135. server/services/topic_action_item_ops.py +538 -0
  136. server/services/topic_comment_kind.py +14 -0
  137. server/services/topic_comment_service.py +237 -0
  138. server/services/topic_helpers.py +32 -0
  139. server/services/topic_lifecycle_service.py +478 -0
  140. server/services/topic_progress_service.py +40 -0
  141. server/services/topic_resolve_service.py +234 -0
  142. server/services/topic_service.py +102 -0
  143. server/services/topic_work_item_service.py +570 -0
  144. server/services/webhook_service.py +273 -0
@@ -0,0 +1,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)