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,111 @@
|
|
|
1
|
+
"""Service layer for the runtime-waker inbound-event access log.
|
|
2
|
+
|
|
3
|
+
This module backs the D6 server-side record endpoint
|
|
4
|
+
(``POST /agents/me/inbound-events``), which is the primary dedup gate for the
|
|
5
|
+
runtime-waker: every waker must call this endpoint *before* resuming a session
|
|
6
|
+
so the server's ``UNIQUE(fingerprint)`` constraint enforces A1 (replay rejection)
|
|
7
|
+
and A2 (concurrent dedup) across processes and restarts.
|
|
8
|
+
|
|
9
|
+
v0.9 (M30A/M31 I2): legacy v1 fingerprints (``inbound:<event_id>``) are
|
|
10
|
+
detected via :func:`notification_service.is_legacy_v1_fingerprint` and routed
|
|
11
|
+
to a separate code path that bumps ``InboundEvent.rejection_count`` instead of
|
|
12
|
+
returning the normal ``UNIQUE(fingerprint)`` 409 — the audit row is preserved
|
|
13
|
+
(matching plan D6 three-layer join) but the waker is told to skip resume.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from sqlalchemy import select
|
|
19
|
+
from sqlalchemy.exc import IntegrityError
|
|
20
|
+
from sqlalchemy.orm import Session
|
|
21
|
+
|
|
22
|
+
from server.domain.models import Agent, InboundEvent
|
|
23
|
+
from server.domain.schemas import InboundEventCreate
|
|
24
|
+
from server.services.notification_service import is_legacy_v1_fingerprint
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def record_inbound_event(
|
|
28
|
+
db: Session, agent: Agent, payload: InboundEventCreate
|
|
29
|
+
) -> tuple[InboundEvent, str]:
|
|
30
|
+
"""Persist a waker's record of an inbound notification.
|
|
31
|
+
|
|
32
|
+
Returns one of:
|
|
33
|
+
|
|
34
|
+
- ``(event, "recorded")`` on first time (status code 201 at the API layer).
|
|
35
|
+
- ``(existing, "duplicate")`` when the ``UNIQUE(fingerprint)`` constraint
|
|
36
|
+
rejects the insert (replay / concurrent claim); API maps to 409 Conflict
|
|
37
|
+
(D6 / A1 gate).
|
|
38
|
+
- ``(event, "rejected_v1")`` when the fingerprint uses the legacy
|
|
39
|
+
``inbound:<event_id>`` shape. The row is upserted with
|
|
40
|
+
``rejection_count`` bumped, so the audit table still records the
|
|
41
|
+
sighting; the waker is told to skip resume (API returns 200 OK with the
|
|
42
|
+
current ``rejection_count`` so the caller can observe it).
|
|
43
|
+
|
|
44
|
+
The fetch-after-rollback is a best-effort lookup: in the rare race where
|
|
45
|
+
the conflicting row is deleted before we look it up we re-raise the
|
|
46
|
+
original IntegrityError so the caller still sees a 5xx rather than silently
|
|
47
|
+
lying.
|
|
48
|
+
"""
|
|
49
|
+
if is_legacy_v1_fingerprint(payload.fingerprint):
|
|
50
|
+
return _record_or_reject_v1(db, agent, payload)
|
|
51
|
+
|
|
52
|
+
event = InboundEvent(
|
|
53
|
+
agent_id=agent.id,
|
|
54
|
+
event_id=payload.event_id,
|
|
55
|
+
event_type=payload.event_type,
|
|
56
|
+
source=payload.source,
|
|
57
|
+
fingerprint=payload.fingerprint,
|
|
58
|
+
payload=payload.payload,
|
|
59
|
+
)
|
|
60
|
+
db.add(event)
|
|
61
|
+
try:
|
|
62
|
+
db.commit()
|
|
63
|
+
except IntegrityError:
|
|
64
|
+
db.rollback()
|
|
65
|
+
existing = (
|
|
66
|
+
db.query(InboundEvent)
|
|
67
|
+
.filter(InboundEvent.fingerprint == payload.fingerprint)
|
|
68
|
+
.one_or_none()
|
|
69
|
+
)
|
|
70
|
+
if existing is None:
|
|
71
|
+
raise
|
|
72
|
+
return existing, "duplicate"
|
|
73
|
+
db.refresh(event)
|
|
74
|
+
return event, "recorded"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _record_or_reject_v1(
|
|
78
|
+
db: Session, agent: Agent, payload: InboundEventCreate
|
|
79
|
+
) -> tuple[InboundEvent, str]:
|
|
80
|
+
"""Insert or upsert a legacy v1 fingerprint with ``rejection_count`` bumped.
|
|
81
|
+
|
|
82
|
+
v1 fingerprints are pre-v0.9 namespaced (``inbound:<event_id>``) and MUST
|
|
83
|
+
NOT trigger a session resume. We still persist an ``InboundEvent`` row so
|
|
84
|
+
the audit table records the sighting — every repeat of the same fingerprint
|
|
85
|
+
increments ``rejection_count`` so an operator can observe how often the
|
|
86
|
+
legacy path fires. The waker distinguishes ``"rejected_v1"`` from
|
|
87
|
+
``"duplicate"`` and uses it to log a ``[wake:skip] v1 fingerprint rejected``
|
|
88
|
+
line rather than treating it as a cross-process race.
|
|
89
|
+
"""
|
|
90
|
+
existing = db.scalar(
|
|
91
|
+
select(InboundEvent).where(InboundEvent.fingerprint == payload.fingerprint)
|
|
92
|
+
)
|
|
93
|
+
if existing is not None:
|
|
94
|
+
existing.rejection_count = (existing.rejection_count or 0) + 1
|
|
95
|
+
db.commit()
|
|
96
|
+
db.refresh(existing)
|
|
97
|
+
return existing, "rejected_v1"
|
|
98
|
+
|
|
99
|
+
event = InboundEvent(
|
|
100
|
+
agent_id=agent.id,
|
|
101
|
+
event_id=payload.event_id,
|
|
102
|
+
event_type=payload.event_type,
|
|
103
|
+
source=payload.source,
|
|
104
|
+
fingerprint=payload.fingerprint,
|
|
105
|
+
payload=payload.payload,
|
|
106
|
+
rejection_count=1,
|
|
107
|
+
)
|
|
108
|
+
db.add(event)
|
|
109
|
+
db.commit()
|
|
110
|
+
db.refresh(event)
|
|
111
|
+
return event, "rejected_v1"
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Per-project experiment execution lock service (CP-3 server side).
|
|
2
|
+
|
|
3
|
+
The lock is **soft**: a row-level lock stored on the ``experiments`` table
|
|
4
|
+
itself (``lock_holder_experiment_id`` + ``lock_acquired_at`` + ``lock_ttl_seconds``)
|
|
5
|
+
is the source of truth. Stale locks self-heal via ``lock_ttl_seconds``. The
|
|
6
|
+
CLI side (``cli/runtime/run_lock.py``) adds a best-effort ``fcntl.flock`` for
|
|
7
|
+
intra-host safety; the server side is authoritative.
|
|
8
|
+
|
|
9
|
+
Endpoints wired in :mod:`server.api.experiments`:
|
|
10
|
+
|
|
11
|
+
* ``POST /experiments/{id}/lock/acquire`` → :func:`acquire_experiment_lock`
|
|
12
|
+
* ``POST /experiments/{id}/lock/release`` → :func:`release_experiment_lock`
|
|
13
|
+
* ``POST /experiments/{id}/lock/force-release`` → :func:`force_release_experiment_lock`
|
|
14
|
+
* ``POST /experiments/{id}/lock/skip`` → :func:`record_experiment_lock_skip`
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import uuid
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from datetime import UTC, datetime
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from sqlalchemy import select
|
|
26
|
+
from sqlalchemy.exc import IntegrityError
|
|
27
|
+
from sqlalchemy.orm import Session
|
|
28
|
+
|
|
29
|
+
from server.db.deadlock_retry import commit_with_retry
|
|
30
|
+
from server.domain.models import Agent, Experiment
|
|
31
|
+
from server.services.errors import ConflictError, ForbiddenError, NotFoundError
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger("map.experiment_lock")
|
|
34
|
+
|
|
35
|
+
DEFAULT_LOCK_TTL_SECONDS = 1800
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class LockResult:
|
|
40
|
+
"""Compact representation of a successful lock operation.
|
|
41
|
+
|
|
42
|
+
Returned to the API caller and also persisted on the experiment row.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
experiment_id: uuid.UUID
|
|
46
|
+
project_id: uuid.UUID
|
|
47
|
+
holder: uuid.UUID | None
|
|
48
|
+
acquired_at: datetime | None
|
|
49
|
+
ttl_seconds: int | None
|
|
50
|
+
next_attempt_at: datetime | None
|
|
51
|
+
skip_count: int
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _now() -> datetime:
|
|
55
|
+
return datetime.now(UTC)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_expired(experiment: Experiment, *, now: datetime | None = None) -> bool:
|
|
59
|
+
if not experiment.lock_holder_experiment_id or not experiment.lock_acquired_at:
|
|
60
|
+
return True
|
|
61
|
+
reference = now or _now()
|
|
62
|
+
acquired = experiment.lock_acquired_at
|
|
63
|
+
if acquired.tzinfo is None:
|
|
64
|
+
acquired = acquired.replace(tzinfo=UTC)
|
|
65
|
+
ttl = experiment.lock_ttl_seconds or DEFAULT_LOCK_TTL_SECONDS
|
|
66
|
+
return reference >= acquired + __import__("datetime").timedelta(seconds=ttl)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _to_result(experiment: Experiment) -> LockResult:
|
|
70
|
+
return LockResult(
|
|
71
|
+
experiment_id=experiment.id,
|
|
72
|
+
project_id=experiment.project_id,
|
|
73
|
+
holder=experiment.lock_holder_experiment_id,
|
|
74
|
+
acquired_at=experiment.lock_acquired_at,
|
|
75
|
+
ttl_seconds=experiment.lock_ttl_seconds,
|
|
76
|
+
next_attempt_at=experiment.next_attempt_at,
|
|
77
|
+
skip_count=int(experiment.lock_skip_count or 0),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _get(db: Session, experiment_id: uuid.UUID) -> Experiment:
|
|
82
|
+
stmt = select(Experiment).where(Experiment.id == experiment_id)
|
|
83
|
+
experiment = db.scalar(stmt)
|
|
84
|
+
if experiment is None or experiment.deleted_at is not None:
|
|
85
|
+
raise NotFoundError(f"experiment {experiment_id} not found")
|
|
86
|
+
return experiment
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _ensure_can_modify_lock(actor: Agent, experiment: Experiment) -> None:
|
|
90
|
+
if experiment.creator_agent_id != actor.id and actor.role.value != "admin":
|
|
91
|
+
raise ForbiddenError("Only the creator or admin can manage the experiment lock")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _find_project_holder(db: Session, project_id: uuid.UUID, *, exclude_id: uuid.UUID | None = None) -> Experiment | None:
|
|
95
|
+
"""Find an experiment in ``project_id`` that currently holds the lock."""
|
|
96
|
+
|
|
97
|
+
stmt = (
|
|
98
|
+
select(Experiment)
|
|
99
|
+
.where(
|
|
100
|
+
Experiment.project_id == project_id,
|
|
101
|
+
Experiment.lock_holder_experiment_id.is_not(None),
|
|
102
|
+
Experiment.lock_acquired_at.is_not(None),
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
if exclude_id is not None:
|
|
106
|
+
stmt = stmt.where(Experiment.id != exclude_id)
|
|
107
|
+
return db.scalar(stmt)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def acquire_experiment_lock(
|
|
111
|
+
db: Session,
|
|
112
|
+
experiment_id: uuid.UUID,
|
|
113
|
+
actor: Agent,
|
|
114
|
+
*,
|
|
115
|
+
ttl_seconds: int = DEFAULT_LOCK_TTL_SECONDS,
|
|
116
|
+
) -> LockResult:
|
|
117
|
+
"""Acquire the per-project execution lock for ``experiment_id``.
|
|
118
|
+
|
|
119
|
+
Raises :class:`ConflictError` when another live experiment in the same
|
|
120
|
+
project holds the lock. A stale lock (TTL expired) is transparently
|
|
121
|
+
reclaimed.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
experiment = _get(db, experiment_id)
|
|
125
|
+
_ensure_can_modify_lock(actor, experiment)
|
|
126
|
+
|
|
127
|
+
other = _find_project_holder(db, experiment.project_id, exclude_id=experiment_id)
|
|
128
|
+
if other is not None and other.lock_holder_experiment_id != experiment.id and not _is_expired(other):
|
|
129
|
+
raise ConflictError(
|
|
130
|
+
f"experiment {other.id} already holds the execution lock for project {experiment.project_id}"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
experiment.lock_holder_experiment_id = experiment.id
|
|
134
|
+
experiment.lock_acquired_at = _now()
|
|
135
|
+
experiment.lock_ttl_seconds = int(ttl_seconds)
|
|
136
|
+
try:
|
|
137
|
+
commit_with_retry(db, op_label="acquire_experiment_lock")
|
|
138
|
+
except IntegrityError:
|
|
139
|
+
# Lost the race against another acquire that committed first; the
|
|
140
|
+
# ``uq_experiment_lock_holder_active`` partial unique index
|
|
141
|
+
# (PG-only; race experiment eca0f522 PR1) caught the conflict at
|
|
142
|
+
# commit time. Surface as ConflictError so callers see the same
|
|
143
|
+
# semantics as the in-process ``_find_project_holder`` guard.
|
|
144
|
+
db.rollback()
|
|
145
|
+
holder = _find_project_holder(db, experiment.project_id, exclude_id=experiment_id)
|
|
146
|
+
holder_id = holder.id if holder is not None else None
|
|
147
|
+
raise ConflictError(
|
|
148
|
+
f"experiment {holder_id} already holds the execution lock for project {experiment.project_id}"
|
|
149
|
+
) from None
|
|
150
|
+
db.refresh(experiment)
|
|
151
|
+
logger.info(
|
|
152
|
+
"acquire_lock experiment=%s project=%s ttl=%s",
|
|
153
|
+
experiment.id,
|
|
154
|
+
experiment.project_id,
|
|
155
|
+
ttl_seconds,
|
|
156
|
+
)
|
|
157
|
+
return _to_result(experiment)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def release_experiment_lock(
|
|
161
|
+
db: Session,
|
|
162
|
+
experiment_id: uuid.UUID,
|
|
163
|
+
actor: Agent,
|
|
164
|
+
) -> LockResult:
|
|
165
|
+
"""Release the lock if ``experiment_id`` is the current holder."""
|
|
166
|
+
|
|
167
|
+
experiment = _get(db, experiment_id)
|
|
168
|
+
_ensure_can_modify_lock(actor, experiment)
|
|
169
|
+
|
|
170
|
+
if experiment.lock_holder_experiment_id == experiment.id:
|
|
171
|
+
experiment.lock_holder_experiment_id = None
|
|
172
|
+
experiment.lock_acquired_at = None
|
|
173
|
+
experiment.lock_ttl_seconds = None
|
|
174
|
+
commit_with_retry(db, op_label="release_experiment_lock")
|
|
175
|
+
db.refresh(experiment)
|
|
176
|
+
logger.info("release_lock experiment=%s", experiment.id)
|
|
177
|
+
else:
|
|
178
|
+
logger.info(
|
|
179
|
+
"release_lock noop experiment=%s current_holder=%s",
|
|
180
|
+
experiment.id,
|
|
181
|
+
experiment.lock_holder_experiment_id,
|
|
182
|
+
)
|
|
183
|
+
return _to_result(experiment)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def force_release_experiment_lock(
|
|
187
|
+
db: Session,
|
|
188
|
+
experiment_id: uuid.UUID,
|
|
189
|
+
actor: Agent,
|
|
190
|
+
*,
|
|
191
|
+
reason: str,
|
|
192
|
+
) -> LockResult:
|
|
193
|
+
"""Operator override: clear the lock regardless of holder.
|
|
194
|
+
|
|
195
|
+
Clears **all** lock holders on the project (only one should exist, but
|
|
196
|
+
defensive).
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
experiment = _get(db, experiment_id)
|
|
200
|
+
if actor.role.value != "admin" and experiment.creator_agent_id != actor.id:
|
|
201
|
+
raise ForbiddenError("Only the creator or admin can force-release a lock")
|
|
202
|
+
|
|
203
|
+
# Clear every holder on the project.
|
|
204
|
+
holders = (
|
|
205
|
+
db.scalars(
|
|
206
|
+
select(Experiment).where(
|
|
207
|
+
Experiment.project_id == experiment.project_id,
|
|
208
|
+
Experiment.lock_holder_experiment_id.is_not(None),
|
|
209
|
+
)
|
|
210
|
+
).all()
|
|
211
|
+
)
|
|
212
|
+
previous_holders = [str(e.id) for e in holders if e.lock_holder_experiment_id is not None]
|
|
213
|
+
for holder in holders:
|
|
214
|
+
holder.lock_holder_experiment_id = None
|
|
215
|
+
holder.lock_acquired_at = None
|
|
216
|
+
holder.lock_ttl_seconds = None
|
|
217
|
+
commit_with_retry(db, op_label="force_release_experiment_lock")
|
|
218
|
+
logger.warning(
|
|
219
|
+
"force_release_lock actor=%s reason=%s project=%s previous_holders=%s",
|
|
220
|
+
actor.id,
|
|
221
|
+
reason,
|
|
222
|
+
experiment.project_id,
|
|
223
|
+
previous_holders,
|
|
224
|
+
)
|
|
225
|
+
db.refresh(experiment)
|
|
226
|
+
return _to_result(experiment)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def record_experiment_lock_skip(
|
|
230
|
+
db: Session,
|
|
231
|
+
experiment_id: uuid.UUID,
|
|
232
|
+
actor: Agent,
|
|
233
|
+
*,
|
|
234
|
+
next_attempt_at: datetime,
|
|
235
|
+
) -> LockResult:
|
|
236
|
+
"""Bump ``lock_skip_count`` and record ``next_attempt_at`` for closed-loop backoff."""
|
|
237
|
+
|
|
238
|
+
experiment = _get(db, experiment_id)
|
|
239
|
+
_ensure_can_modify_lock(actor, experiment)
|
|
240
|
+
experiment.lock_skip_count = int(experiment.lock_skip_count or 0) + 1
|
|
241
|
+
experiment.next_attempt_at = next_attempt_at
|
|
242
|
+
commit_with_retry(db, op_label="record_experiment_lock_skip")
|
|
243
|
+
db.refresh(experiment)
|
|
244
|
+
logger.info(
|
|
245
|
+
"lock_skip experiment=%s skip_count=%s next_attempt_at=%s",
|
|
246
|
+
experiment.id,
|
|
247
|
+
experiment.lock_skip_count,
|
|
248
|
+
next_attempt_at.isoformat(),
|
|
249
|
+
)
|
|
250
|
+
return _to_result(experiment)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def serialize_lock_state(experiment: Experiment) -> dict[str, Any]:
|
|
254
|
+
"""Return a JSON-safe snapshot for inclusion in API responses / logs."""
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
"lock_holder_experiment_id": str(experiment.lock_holder_experiment_id) if experiment.lock_holder_experiment_id else None,
|
|
258
|
+
"lock_acquired_at": experiment.lock_acquired_at.isoformat() if experiment.lock_acquired_at else None,
|
|
259
|
+
"lock_ttl_seconds": experiment.lock_ttl_seconds,
|
|
260
|
+
"next_attempt_at": experiment.next_attempt_at.isoformat() if experiment.next_attempt_at else None,
|
|
261
|
+
"lock_skip_count": int(experiment.lock_skip_count or 0),
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
__all__ = [
|
|
266
|
+
"DEFAULT_LOCK_TTL_SECONDS",
|
|
267
|
+
"LockResult",
|
|
268
|
+
"acquire_experiment_lock",
|
|
269
|
+
"force_release_experiment_lock",
|
|
270
|
+
"record_experiment_lock_skip",
|
|
271
|
+
"release_experiment_lock",
|
|
272
|
+
"serialize_lock_state",
|
|
273
|
+
]
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import func, select
|
|
4
|
+
from sqlalchemy.orm import Session
|
|
5
|
+
|
|
6
|
+
from server.domain.models import (
|
|
7
|
+
Agent,
|
|
8
|
+
ExperimentLog,
|
|
9
|
+
ExperimentPhase,
|
|
10
|
+
PlanVersion,
|
|
11
|
+
)
|
|
12
|
+
from server.domain.schemas import ExperimentLogCreate
|
|
13
|
+
from server.services import audit_service
|
|
14
|
+
from server.services.errors import StateTransitionError
|
|
15
|
+
from server.services.evidence_service import (
|
|
16
|
+
EvidenceValidationResult,
|
|
17
|
+
validate_log_evidence,
|
|
18
|
+
)
|
|
19
|
+
from server.services.project_service import get_experiment
|
|
20
|
+
from server.services.similarity_service import (
|
|
21
|
+
SimilarityValidationResult,
|
|
22
|
+
validate_log_similarity,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _validate_log_phase(phase: ExperimentPhase) -> None:
|
|
27
|
+
if phase not in (
|
|
28
|
+
ExperimentPhase.running,
|
|
29
|
+
ExperimentPhase.result_review,
|
|
30
|
+
ExperimentPhase.done,
|
|
31
|
+
):
|
|
32
|
+
raise StateTransitionError(
|
|
33
|
+
"Logs can only be added when experiment is running, pending result review, or done"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _load_current_plan_md(db: Session, experiment_id: uuid.UUID) -> str | None:
|
|
38
|
+
"""Return ``current_plan.content_md`` for the experiment, or None when
|
|
39
|
+
the experiment has no committed plan version (current_plan_version == 0
|
|
40
|
+
or the row is missing).
|
|
41
|
+
"""
|
|
42
|
+
experiment = get_experiment(db, experiment_id)
|
|
43
|
+
if experiment.current_plan_version <= 0:
|
|
44
|
+
return None
|
|
45
|
+
plan = db.scalar(
|
|
46
|
+
select(PlanVersion).where(
|
|
47
|
+
PlanVersion.experiment_id == experiment_id,
|
|
48
|
+
PlanVersion.version == experiment.current_plan_version,
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
return plan.content_md if plan is not None else None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def append_log(
|
|
55
|
+
db: Session,
|
|
56
|
+
experiment_id: uuid.UUID,
|
|
57
|
+
author: Agent,
|
|
58
|
+
payload: ExperimentLogCreate,
|
|
59
|
+
) -> tuple[ExperimentLog, EvidenceValidationResult, SimilarityValidationResult, bool]:
|
|
60
|
+
experiment = get_experiment(db, experiment_id)
|
|
61
|
+
_validate_log_phase(experiment.phase)
|
|
62
|
+
next_index = db.scalar(
|
|
63
|
+
select(func.coalesce(func.max(ExperimentLog.log_index), 0) + 1).where(
|
|
64
|
+
ExperimentLog.experiment_id == experiment_id
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
log = ExperimentLog(
|
|
68
|
+
experiment_id=experiment_id,
|
|
69
|
+
author_agent_id=author.id,
|
|
70
|
+
summary=payload.summary,
|
|
71
|
+
content_md=payload.content_md,
|
|
72
|
+
metadata_json=payload.metadata,
|
|
73
|
+
log_index=next_index or 1,
|
|
74
|
+
)
|
|
75
|
+
plan_md = _load_current_plan_md(db, experiment_id)
|
|
76
|
+
validation = validate_log_evidence(plan_md=plan_md, metadata=payload.metadata)
|
|
77
|
+
# Compute similarity BEFORE flushing the new log so the comparison
|
|
78
|
+
# sees the prior log, not the log we're about to save (which would
|
|
79
|
+
# trivially score 1.0 against itself).
|
|
80
|
+
similarity = validate_log_similarity(
|
|
81
|
+
db,
|
|
82
|
+
experiment_id=experiment_id,
|
|
83
|
+
content_md=payload.content_md,
|
|
84
|
+
)
|
|
85
|
+
db.add(log)
|
|
86
|
+
db.flush()
|
|
87
|
+
force_skip_applied = False
|
|
88
|
+
if similarity.warnings and payload.force_skip_similarity:
|
|
89
|
+
warning = similarity.warnings[0]
|
|
90
|
+
audit_service.log_force_skip_no_commit(
|
|
91
|
+
db,
|
|
92
|
+
log_id=log.id,
|
|
93
|
+
ref_log_id=warning.ref_log_id,
|
|
94
|
+
experiment_id=experiment_id,
|
|
95
|
+
project_id=experiment.project_id,
|
|
96
|
+
actor_id=author.id,
|
|
97
|
+
similarity_score=warning.score,
|
|
98
|
+
threshold=warning.threshold,
|
|
99
|
+
embedding_model=warning.model,
|
|
100
|
+
)
|
|
101
|
+
force_skip_applied = True
|
|
102
|
+
return log, validation, similarity, force_skip_applied
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def create_log(
|
|
106
|
+
db: Session,
|
|
107
|
+
experiment_id: uuid.UUID,
|
|
108
|
+
author: Agent,
|
|
109
|
+
payload: ExperimentLogCreate,
|
|
110
|
+
) -> tuple[
|
|
111
|
+
ExperimentLog,
|
|
112
|
+
EvidenceValidationResult,
|
|
113
|
+
SimilarityValidationResult,
|
|
114
|
+
bool,
|
|
115
|
+
]:
|
|
116
|
+
log, validation, similarity, force_skip_applied = append_log(
|
|
117
|
+
db, experiment_id, author, payload
|
|
118
|
+
)
|
|
119
|
+
db.commit()
|
|
120
|
+
db.refresh(log)
|
|
121
|
+
return log, validation, similarity, force_skip_applied
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def list_logs(
|
|
125
|
+
db: Session,
|
|
126
|
+
experiment_id: uuid.UUID,
|
|
127
|
+
*,
|
|
128
|
+
limit: int = 50,
|
|
129
|
+
) -> list[ExperimentLog]:
|
|
130
|
+
get_experiment(db, experiment_id)
|
|
131
|
+
stmt = (
|
|
132
|
+
select(ExperimentLog)
|
|
133
|
+
.where(ExperimentLog.experiment_id == experiment_id)
|
|
134
|
+
.order_by(ExperimentLog.log_index.asc())
|
|
135
|
+
.limit(max(1, min(limit, 200)))
|
|
136
|
+
)
|
|
137
|
+
return list(db.scalars(stmt))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_latest_log(db: Session, experiment_id: uuid.UUID) -> ExperimentLog | None:
|
|
141
|
+
stmt = (
|
|
142
|
+
select(ExperimentLog)
|
|
143
|
+
.where(ExperimentLog.experiment_id == experiment_id)
|
|
144
|
+
.order_by(ExperimentLog.log_index.desc())
|
|
145
|
+
.limit(1)
|
|
146
|
+
)
|
|
147
|
+
return db.scalar(stmt)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def log_counts_by_experiment(
|
|
151
|
+
db: Session, experiment_ids: list[uuid.UUID]
|
|
152
|
+
) -> dict[uuid.UUID, int]:
|
|
153
|
+
"""Per-experiment ExperimentLog row counts in a single GROUP BY query.
|
|
154
|
+
|
|
155
|
+
Returns a dict keyed by experiment_id. Experiments with no logs map
|
|
156
|
+
to 0 (caller can safely ``counts[eid]`` without ``.get``). Empty
|
|
157
|
+
input → empty dict without hitting the database.
|
|
158
|
+
"""
|
|
159
|
+
if not experiment_ids:
|
|
160
|
+
return {}
|
|
161
|
+
stmt = (
|
|
162
|
+
select(ExperimentLog.experiment_id, func.count())
|
|
163
|
+
.where(ExperimentLog.experiment_id.in_(experiment_ids))
|
|
164
|
+
.group_by(ExperimentLog.experiment_id)
|
|
165
|
+
)
|
|
166
|
+
found = {eid: int(count) for eid, count in db.execute(stmt).all()}
|
|
167
|
+
# Fill in zeros for experiments that have no logs so the caller can
|
|
168
|
+
# index by experiment_id without a defensive ``.get``/``default``.
|
|
169
|
+
return {eid: found.get(eid, 0) for eid in experiment_ids}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def latest_log_by_experiment(
|
|
173
|
+
db: Session, experiment_ids: list[uuid.UUID]
|
|
174
|
+
) -> dict[uuid.UUID, ExperimentLog]:
|
|
175
|
+
"""Latest ExperimentLog per experiment in a single window/join query.
|
|
176
|
+
|
|
177
|
+
Returns a dict keyed by experiment_id. Experiments with no logs are
|
|
178
|
+
absent from the dict (``latest.get(eid) is None``). Empty input →
|
|
179
|
+
empty dict without hitting the database.
|
|
180
|
+
"""
|
|
181
|
+
if not experiment_ids:
|
|
182
|
+
return {}
|
|
183
|
+
# Max + self-join pattern. (experiment_id, log_index) is not UNIQUE
|
|
184
|
+
# in the schema, but ``append_log`` always allocates via max+1, so
|
|
185
|
+
# in practice the max row is unique. GROUP BY gives one row per
|
|
186
|
+
# experiment with the top log_index; the join back returns the
|
|
187
|
+
# full ExperimentLog rows.
|
|
188
|
+
max_idx_subq = (
|
|
189
|
+
select(
|
|
190
|
+
ExperimentLog.experiment_id.label("eid"),
|
|
191
|
+
func.max(ExperimentLog.log_index).label("max_idx"),
|
|
192
|
+
)
|
|
193
|
+
.where(ExperimentLog.experiment_id.in_(experiment_ids))
|
|
194
|
+
.group_by(ExperimentLog.experiment_id)
|
|
195
|
+
.subquery()
|
|
196
|
+
)
|
|
197
|
+
stmt = select(ExperimentLog).join(
|
|
198
|
+
max_idx_subq,
|
|
199
|
+
(ExperimentLog.experiment_id == max_idx_subq.c.eid)
|
|
200
|
+
& (ExperimentLog.log_index == max_idx_subq.c.max_idx),
|
|
201
|
+
)
|
|
202
|
+
return {log.experiment_id: log for log in db.scalars(stmt)}
|