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/runtime/run_lock.py
ADDED
|
@@ -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
|
+
]
|