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
+ """Per-project experiment execution lock (CLI side).
2
+
3
+ Implements CP-1 / CP-2 / CP-2.5 / CP-3 / CP-3.5 from the v2 plan:
4
+
5
+ * `acquire / release / is_held / force_release` for `experiments.status` semantics.
6
+ * Local ``fcntl.flock`` guard (best-effort, processes per host) **plus** server-side
7
+ authoritative state stored on the ``Experiment`` row.
8
+ * TTL self-healing (server side is the source of truth).
9
+ * ``MAP_HOST_NO_LOCK`` / ``MAP_HOST_LOCK_DRY_RUN`` toggles for ops rollback.
10
+
11
+ The lock intentionally operates at the *project* level: the same ``project_id``
12
+ may only have **one** experiment whose ``phase == running`` AND
13
+ ``lock_holder_experiment_id`` set at a time. Other host workers running
14
+ ``execute_experiment`` against the same project will receive a ``lock_busy``
15
+ result and follow the skip-backoff policy (host execute_experiment callers).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import contextlib
21
+ import errno
22
+ import fcntl
23
+ import json
24
+ import logging
25
+ import os
26
+ import uuid
27
+ from collections.abc import Iterator
28
+ from dataclasses import dataclass, field
29
+ from datetime import UTC, datetime, timedelta
30
+ from pathlib import Path
31
+ from typing import Any, Protocol
32
+
33
+ logger = logging.getLogger("map.experiment_lock")
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Constants (must satisfy lock_ttl_seconds >= max(lock_timeout, SLA) * 3)
37
+ # ---------------------------------------------------------------------------
38
+
39
+ DEFAULT_LOCK_TIMEOUT_SECONDS = 600
40
+ DEFAULT_LOCK_TTL_SECONDS = 1800
41
+ DEFAULT_SLA_SECONDS = 600 # ceiling for one execute_experiment cycle
42
+
43
+ # Skip-backoff (closed loop): exponential, capped at 30 minutes.
44
+ SKIP_BACKOFF_CAP_SECONDS = 1800
45
+ SKIP_BACKOFF_BASE_SECONDS = 60
46
+ SKIP_STUCK_THRESHOLD = 10 # when lock_skip_count >= N -> emit lock_stuck
47
+
48
+ # Log keywords (consumed by tests + ops dashboards).
49
+ LOG_ACQUIRE = "acquire_lock"
50
+ LOG_RELEASE = "release_lock"
51
+ LOG_TIMEOUT = "lock_timeout"
52
+ LOG_STUCK = "lock_stuck"
53
+ LOG_FORCE = "force_release_lock"
54
+ LOG_TTL_MISCONFIG = "lock_ttl_misconfig"
55
+ LOG_BUSY = "lock_busy"
56
+ LOG_DRY_RUN = "lock_dry_run"
57
+
58
+
59
+ class ExperimentLockError(RuntimeError):
60
+ """Raised when the lock module is misconfigured."""
61
+
62
+
63
+ class LockBusy(Exception):
64
+ """Raised when an experiment cannot acquire the lock for this project."""
65
+
66
+ def __init__(self, project_id: str, holder: str | None, message: str = "lock busy") -> None:
67
+ super().__init__(message)
68
+ self.project_id = project_id
69
+ self.holder = holder
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class LockConfig:
74
+ """Runtime configuration for the lock module.
75
+
76
+ The TTL/timeout/SLA coupling rule is enforced at construction time:
77
+
78
+ lock_ttl_seconds >= max(lock_timeout_seconds, sla_seconds) * 3
79
+ """
80
+
81
+ lock_timeout_seconds: int = DEFAULT_LOCK_TIMEOUT_SECONDS
82
+ lock_ttl_seconds: int = DEFAULT_LOCK_TTL_SECONDS
83
+ sla_seconds: int = DEFAULT_SLA_SECONDS
84
+ skip_backoff_base_seconds: int = SKIP_BACKOFF_BASE_SECONDS
85
+ skip_backoff_cap_seconds: int = SKIP_BACKOFF_CAP_SECONDS
86
+ skip_stuck_threshold: int = SKIP_STUCK_THRESHOLD
87
+
88
+ def __post_init__(self) -> None:
89
+ required = max(self.lock_timeout_seconds, self.sla_seconds) * 3
90
+ if self.lock_ttl_seconds < required:
91
+ raise ExperimentLockError(
92
+ f"lock_ttl_misconfig: lock_ttl_seconds={self.lock_ttl_seconds} "
93
+ f"must be >= max(lock_timeout_seconds, sla_seconds) * 3 = {required}"
94
+ )
95
+
96
+
97
+ @dataclass
98
+ class LockState:
99
+ """Server-side authoritative lock snapshot for a single experiment."""
100
+
101
+ project_id: str
102
+ lock_holder_experiment_id: str | None = None
103
+ lock_acquired_at: str | None = None
104
+ lock_ttl_seconds: int = DEFAULT_LOCK_TTL_SECONDS
105
+ next_attempt_at: str | None = None
106
+ lock_skip_count: int = 0
107
+
108
+ @property
109
+ def is_held(self) -> bool:
110
+ return bool(self.lock_holder_experiment_id)
111
+
112
+ def is_expired(self, *, now: datetime | None = None) -> bool:
113
+ if not self.lock_acquired_at:
114
+ return True
115
+ try:
116
+ acquired = datetime.fromisoformat(self.lock_acquired_at.replace("Z", "+00:00"))
117
+ except ValueError:
118
+ return True
119
+ reference = now or datetime.now(UTC)
120
+ if acquired.tzinfo is None:
121
+ acquired = acquired.replace(tzinfo=UTC)
122
+ return reference >= acquired + timedelta(seconds=self.lock_ttl_seconds)
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # Backend protocol — keeps the module testable.
127
+ # ---------------------------------------------------------------------------
128
+
129
+
130
+ class LockBackend(Protocol):
131
+ """Abstract server-side storage for lock state.
132
+
133
+ The real backend is the ``experiments`` table (see
134
+ ``cli.map_command_client.MapCommandClient``); tests can inject an
135
+ in-memory implementation.
136
+ """
137
+
138
+ def get_lock_state(self, project_id: str) -> LockState: ...
139
+ def write_lock(self, project_id: str, experiment_id: str, *, ttl: int) -> LockState: ...
140
+ def clear_lock(self, project_id: str, *, experiment_id: str | None) -> bool: ...
141
+ def bump_skip_count(self, project_id: str, experiment_id: str, *, next_attempt_at: str) -> LockState: ...
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # In-memory backend for unit tests
146
+ # ---------------------------------------------------------------------------
147
+
148
+
149
+ @dataclass
150
+ class InMemoryLockBackend:
151
+ """A minimal backend used by unit tests."""
152
+
153
+ states: dict[str, LockState] = field(default_factory=dict)
154
+
155
+ def get_lock_state(self, project_id: str) -> LockState:
156
+ return self.states.setdefault(project_id, LockState(project_id=project_id))
157
+
158
+ def write_lock(self, project_id: str, experiment_id: str, *, ttl: int) -> LockState:
159
+ state = self.get_lock_state(project_id)
160
+ state.lock_holder_experiment_id = experiment_id
161
+ state.lock_acquired_at = datetime.now(UTC).isoformat()
162
+ state.lock_ttl_seconds = ttl
163
+ return state
164
+
165
+ def clear_lock(self, project_id: str, *, experiment_id: str | None = None) -> bool:
166
+ state = self.states.get(project_id)
167
+ if state is None:
168
+ return False
169
+ if experiment_id is not None and state.lock_holder_experiment_id != experiment_id:
170
+ return False
171
+ state.lock_holder_experiment_id = None
172
+ state.lock_acquired_at = None
173
+ return True
174
+
175
+ def bump_skip_count(self, project_id: str, experiment_id: str, *, next_attempt_at: str) -> LockState:
176
+ # In-memory backend keeps skip count on the experiment's *own* state row;
177
+ # tests assert via a separate per-experiment slot.
178
+ state = self.get_lock_state(project_id)
179
+ state.lock_skip_count += 1
180
+ state.next_attempt_at = next_attempt_at
181
+ return state
182
+
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # Local file lock (best-effort, intra-host)
186
+ # ---------------------------------------------------------------------------
187
+
188
+
189
+ def _local_lock_path(project_id: str, *, base_dir: Path | None = None) -> Path:
190
+ base = base_dir or Path(os.environ.get("MAP_HOST_LOCK_DIR", "/tmp/map-host-locks"))
191
+ base.mkdir(parents=True, exist_ok=True)
192
+ safe = "".join(c if c.isalnum() or c in ("-", "_") else "_" for c in project_id)
193
+ return base / f"exp-lock-{safe}.lock"
194
+
195
+
196
+ @contextlib.contextmanager
197
+ def _local_flock(path: Path) -> Iterator[None]:
198
+ """Try to grab an exclusive flock; non-blocking by default.
199
+
200
+ Raises ``LockBusy`` if another host process already holds the local lock.
201
+ """
202
+
203
+ fd = os.open(str(path), os.O_CREAT | os.O_RDWR, 0o644)
204
+ try:
205
+ try:
206
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
207
+ except OSError as exc:
208
+ if exc.errno in (errno.EWOULDBLOCK, errno.EAGAIN):
209
+ raise LockBusy("local", holder="local-process") from exc
210
+ raise
211
+ try:
212
+ yield
213
+ finally:
214
+ with contextlib.suppress(OSError):
215
+ fcntl.flock(fd, fcntl.LOCK_UN)
216
+ finally:
217
+ os.close(fd)
218
+
219
+
220
+ # ---------------------------------------------------------------------------
221
+ # Manager — orchestrates acquire/release/is_held/force_release
222
+ # ---------------------------------------------------------------------------
223
+
224
+
225
+ @dataclass
226
+ class ExperimentLockManager:
227
+ """Coordinates local + server-side lock state for ``execute_experiment``."""
228
+
229
+ backend: LockBackend
230
+ config: LockConfig = field(default_factory=LockConfig)
231
+ local_lock_dir: Path | None = None
232
+ dry_run: bool = False
233
+ disabled: bool = False
234
+
235
+ # ---- high-level API ----
236
+
237
+ def acquire(self, *, project_id: str, experiment_id: str) -> LockState:
238
+ """Try to grab the lock for ``experiment_id``.
239
+
240
+ On success returns the new ``LockState``. On contention or TTL expiry,
241
+ raises ``LockBusy`` and **does not** mutate state.
242
+ """
243
+ if self.disabled:
244
+ return LockState(project_id=project_id, lock_holder_experiment_id=experiment_id)
245
+ if self.dry_run:
246
+ logger.info("%s project=%s experiment=%s", LOG_DRY_RUN, project_id, experiment_id)
247
+ return LockState(project_id=project_id, lock_holder_experiment_id=experiment_id)
248
+
249
+ state = self.backend.get_lock_state(project_id)
250
+ if state.is_held and not state.is_expired() and state.lock_holder_experiment_id != experiment_id:
251
+ logger.info("%s project=%s holder=%s", LOG_BUSY, project_id, state.lock_holder_experiment_id)
252
+ raise LockBusy(project_id, state.lock_holder_experiment_id)
253
+
254
+ lock_path = _local_lock_path(project_id, base_dir=self.local_lock_dir)
255
+ try:
256
+ with _local_flock(lock_path):
257
+ # Re-check server state under local lock to avoid TOCTOU.
258
+ state = self.backend.get_lock_state(project_id)
259
+ if state.is_held and not state.is_expired() and state.lock_holder_experiment_id != experiment_id:
260
+ logger.info("%s project=%s holder=%s", LOG_BUSY, project_id, state.lock_holder_experiment_id)
261
+ raise LockBusy(project_id, state.lock_holder_experiment_id)
262
+ new_state = self.backend.write_lock(project_id, experiment_id, ttl=self.config.lock_ttl_seconds)
263
+ logger.info(
264
+ "%s project=%s experiment=%s ttl=%s",
265
+ LOG_ACQUIRE,
266
+ project_id,
267
+ experiment_id,
268
+ self.config.lock_ttl_seconds,
269
+ )
270
+ return new_state
271
+ except LockBusy:
272
+ raise
273
+ except OSError as exc:
274
+ raise ExperimentLockError(f"local lock failed for project {project_id}: {exc}") from exc
275
+
276
+ def release(self, *, project_id: str, experiment_id: str) -> bool:
277
+ """Release the lock for ``experiment_id``.
278
+
279
+ Safe to call when the caller no longer holds the lock — used in
280
+ ``finally`` blocks.
281
+ """
282
+ if self.disabled:
283
+ return True
284
+ if self.dry_run:
285
+ logger.info("%s project=%s experiment=%s", LOG_DRY_RUN, project_id, experiment_id)
286
+ return True
287
+ ok = self.backend.clear_lock(project_id, experiment_id=experiment_id)
288
+ logger.info("%s project=%s experiment=%s ok=%s", LOG_RELEASE, project_id, experiment_id, ok)
289
+ return ok
290
+
291
+ def is_held(self, *, project_id: str) -> bool:
292
+ state = self.backend.get_lock_state(project_id)
293
+ if not state.is_held:
294
+ return False
295
+ return not state.is_expired()
296
+
297
+ def force_release(self, *, project_id: str, reason: str, actor: str | None = None) -> LockState:
298
+ """Operator override: clear the lock unconditionally and audit-log it."""
299
+ state = self.backend.get_lock_state(project_id)
300
+ previous_holder = state.lock_holder_experiment_id
301
+ self.backend.clear_lock(project_id)
302
+ audit = {
303
+ "action": LOG_FORCE,
304
+ "project_id": project_id,
305
+ "previous_holder": previous_holder,
306
+ "reason": reason,
307
+ "actor": actor,
308
+ "at": datetime.now(UTC).isoformat(),
309
+ }
310
+ logger.warning("%s %s", LOG_FORCE, json.dumps(audit, ensure_ascii=False, sort_keys=True))
311
+ return self.backend.get_lock_state(project_id)
312
+
313
+ # ---- skip closed-loop helpers ----
314
+
315
+ def compute_backoff(self, *, skip_count: int) -> int:
316
+ """Exponential backoff capped at ``skip_backoff_cap_seconds``.
317
+
318
+ ``skip_count`` is the value *after* it has been incremented, so the
319
+ first failure receives ``60 * 2^0 = 60s``.
320
+ """
321
+ if skip_count < 0:
322
+ skip_count = 0
323
+ candidate = self.config.skip_backoff_base_seconds * (2 ** (skip_count - 1))
324
+ return min(int(candidate), int(self.config.skip_backoff_cap_seconds))
325
+
326
+ def record_skip(self, *, project_id: str, experiment_id: str, now: datetime | None = None) -> tuple[int, str]:
327
+ """Increment ``lock_skip_count`` and compute ``next_attempt_at``.
328
+
329
+ Returns ``(new_skip_count, iso_next_attempt_at)``. Emits ``lock_stuck``
330
+ when threshold is crossed.
331
+ """
332
+ reference = now or datetime.now(UTC)
333
+ prior = self.backend.get_lock_state(project_id).lock_skip_count
334
+ new_count = prior + 1
335
+ backoff = self.compute_backoff(skip_count=new_count)
336
+ next_at = (reference + timedelta(seconds=backoff)).isoformat()
337
+ self.backend.bump_skip_count(project_id, experiment_id, next_attempt_at=next_at)
338
+ if new_count >= self.config.skip_stuck_threshold:
339
+ logger.warning(
340
+ "%s project=%s experiment=%s skip_count=%s next_attempt_at=%s",
341
+ LOG_STUCK,
342
+ project_id,
343
+ experiment_id,
344
+ new_count,
345
+ next_at,
346
+ )
347
+ return new_count, next_at
348
+
349
+ # ---- env toggles ----
350
+
351
+ @classmethod
352
+ def from_env(cls, backend: LockBackend) -> ExperimentLockManager:
353
+ disabled = os.environ.get("MAP_HOST_NO_LOCK") == "1"
354
+ dry_run = os.environ.get("MAP_HOST_LOCK_DRY_RUN") == "1"
355
+ if disabled:
356
+ logger.warning("MAP_HOST_NO_LOCK=1 -> experiment lock disabled (rollback mode)")
357
+ if dry_run:
358
+ logger.warning("MAP_HOST_LOCK_DRY_RUN=1 -> experiment lock dry-run mode")
359
+ return cls(backend=backend, dry_run=dry_run, disabled=disabled)
360
+
361
+
362
+ # ---------------------------------------------------------------------------
363
+ # Helpers for env config
364
+ # ---------------------------------------------------------------------------
365
+
366
+
367
+ def env_lock_disabled() -> bool:
368
+ return os.environ.get("MAP_HOST_NO_LOCK") == "1"
369
+
370
+
371
+ def env_lock_dry_run() -> bool:
372
+ return os.environ.get("MAP_HOST_LOCK_DRY_RUN") == "1"
373
+
374
+
375
+ def new_lock_id() -> str:
376
+ """Generate a fresh lock attempt id (UUID4)."""
377
+ return uuid.uuid4().hex
378
+
379
+
380
+ # ---------------------------------------------------------------------------
381
+ # Helper: server-side backend adapter for ``MapCommandClient``
382
+ # ---------------------------------------------------------------------------
383
+
384
+
385
+ class MapClientLockBackend:
386
+ """Adapter that turns a ``MapCommandClient`` into a ``LockBackend``.
387
+
388
+ The adapter reads lock state from ``experiments.status`` and writes through
389
+ the ``force-release-lock`` and ``update-lock`` subcommands. It never raises
390
+ — failed writes are logged and treated as no-ops so the host worker can
391
+ continue.
392
+ """
393
+
394
+ def __init__(self, client: Any, *, dry_run: bool = False) -> None:
395
+ self.client = client
396
+ self.dry_run = dry_run
397
+
398
+ def _project_running_experiment(self, project_id: str) -> dict[str, Any] | None:
399
+ try:
400
+ experiments = self.client.todos().get("my_open_experiments") or []
401
+ except Exception as exc: # pragma: no cover - defensive
402
+ logger.warning("lock backend todos() failed: %s", exc)
403
+ return None
404
+ for summary in experiments:
405
+ if str(summary.get("project_id") or "") != str(project_id):
406
+ continue
407
+ try:
408
+ detail = self.client.experiment_status(str(summary.get("id")))
409
+ except Exception as exc: # pragma: no cover - defensive
410
+ logger.warning("lock backend status() failed: %s", exc)
411
+ continue
412
+ if detail.get("phase") == "running":
413
+ return detail
414
+ return None
415
+
416
+ def get_lock_state(self, project_id: str) -> LockState:
417
+ if self.dry_run:
418
+ return LockState(project_id=project_id)
419
+ detail = self._project_running_experiment(project_id)
420
+ if not detail:
421
+ return LockState(project_id=project_id)
422
+ return LockState(
423
+ project_id=project_id,
424
+ lock_holder_experiment_id=str(detail.get("lock_holder_experiment_id") or detail.get("id")),
425
+ lock_acquired_at=detail.get("lock_acquired_at"),
426
+ lock_ttl_seconds=int(detail.get("lock_ttl_seconds") or DEFAULT_LOCK_TTL_SECONDS),
427
+ )
428
+
429
+ def write_lock(self, project_id: str, experiment_id: str, *, ttl: int) -> LockState:
430
+ if self.dry_run:
431
+ return LockState(
432
+ project_id=project_id,
433
+ lock_holder_experiment_id=experiment_id,
434
+ lock_acquired_at=datetime.now(UTC).isoformat(),
435
+ lock_ttl_seconds=ttl,
436
+ )
437
+ try:
438
+ self.client.experiment_acquire_lock(experiment_id, ttl_seconds=ttl)
439
+ except Exception as exc: # pragma: no cover - defensive
440
+ logger.warning("lock backend acquire failed: %s", exc)
441
+ return self.get_lock_state(project_id)
442
+
443
+ def clear_lock(self, project_id: str, *, experiment_id: str | None = None) -> bool:
444
+ if self.dry_run:
445
+ return True
446
+ state = self.get_lock_state(project_id)
447
+ holder = experiment_id or state.lock_holder_experiment_id
448
+ if not holder:
449
+ return False
450
+ try:
451
+ self.client.experiment_release_lock(holder)
452
+ return True
453
+ except Exception as exc: # pragma: no cover - defensive
454
+ logger.warning("lock backend release failed: %s", exc)
455
+ return False
456
+
457
+ def bump_skip_count(self, project_id: str, experiment_id: str, *, next_attempt_at: str) -> LockState:
458
+ if self.dry_run:
459
+ state = self.get_lock_state(project_id)
460
+ state.lock_skip_count += 1
461
+ state.next_attempt_at = next_attempt_at
462
+ return state
463
+ try:
464
+ self.client.experiment_record_skip(experiment_id, next_attempt_at=next_attempt_at)
465
+ except Exception as exc: # pragma: no cover - defensive
466
+ logger.warning("lock backend bump_skip_count failed: %s", exc)
467
+ return self.get_lock_state(project_id)
468
+
469
+
470
+ # Re-exports
471
+ __all__ = [
472
+ "DEFAULT_LOCK_TIMEOUT_SECONDS",
473
+ "DEFAULT_LOCK_TTL_SECONDS",
474
+ "DEFAULT_SLA_SECONDS",
475
+ "ExperimentLockError",
476
+ "ExperimentLockManager",
477
+ "InMemoryLockBackend",
478
+ "LockBackend",
479
+ "LockBusy",
480
+ "LockConfig",
481
+ "LockState",
482
+ "LOG_ACQUIRE",
483
+ "LOG_BUSY",
484
+ "LOG_DRY_RUN",
485
+ "LOG_FORCE",
486
+ "LOG_RELEASE",
487
+ "LOG_STUCK",
488
+ "LOG_TIMEOUT",
489
+ "LOG_TTL_MISCONFIG",
490
+ "MapClientLockBackend",
491
+ "SKIP_BACKOFF_BASE_SECONDS",
492
+ "SKIP_BACKOFF_CAP_SECONDS",
493
+ "SKIP_STUCK_THRESHOLD",
494
+ "env_lock_disabled",
495
+ "env_lock_dry_run",
496
+ "new_lock_id",
497
+ ]