Files
claudetools/api/services/coord_lock_service.py
Mike Swanson 63975284f4 feat: agent coordination system (workflows, locks, components, messages)
Adds /api/coord/* endpoints for real-time cross-session coordination:
- coord_workflows: named units of work per project
- coord_work_items: tasks within workflows with dependency chains
- coord_session_locks: exclusive resource locks with auto-expiry (TTL)
- coord_component_states: live component state per project (upsert)
- coord_messages: cross-session messaging and broadcasts
- /api/coord/status: cross-project snapshot endpoint

Replaces PROJECT_STATE.md as the coordination layer for Claude sessions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 08:25:33 -07:00

129 lines
4.3 KiB
Python

"""Service layer for CoordSessionLock."""
from datetime import datetime, timedelta, timezone
from typing import Optional
from uuid import UUID
from fastapi import HTTPException, status
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session
from api.models.coord_session_lock import CoordSessionLock
from api.schemas.coord_session_lock import CoordSessionLockCreate
def _active_filter(q):
"""Apply the 'lock is currently active' predicate to a query."""
now = datetime.now(timezone.utc).replace(tzinfo=None)
return q.filter(
CoordSessionLock.released_at.is_(None),
or_(
CoordSessionLock.expires_at.is_(None),
CoordSessionLock.expires_at > now,
),
)
def get_active_locks(
db: Session,
project_key: Optional[str] = None,
session_id: Optional[str] = None,
skip: int = 0,
limit: int = 100,
) -> tuple[list[CoordSessionLock], int]:
"""Return currently active locks with optional filters."""
q = db.query(CoordSessionLock)
if project_key:
q = q.filter(CoordSessionLock.project_key == project_key)
if session_id:
q = q.filter(CoordSessionLock.session_id == session_id)
q = _active_filter(q)
total = q.count()
locks = q.order_by(CoordSessionLock.acquired_at.desc()).offset(skip).limit(limit).all()
return locks, total
def check_resource_locked(
db: Session, project_key: str, resource: str
) -> Optional[CoordSessionLock]:
"""Return the active lock on a resource, or None if unlocked."""
q = db.query(CoordSessionLock).filter(
CoordSessionLock.project_key == project_key,
CoordSessionLock.resource == resource,
)
return _active_filter(q).first()
def claim_lock(db: Session, data: CoordSessionLockCreate) -> CoordSessionLock:
"""Claim a resource lock, computing expires_at from ttl_hours."""
expires_at: Optional[datetime] = None
if data.ttl_hours > 0:
expires_at = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(hours=data.ttl_hours)
try:
lock = CoordSessionLock(
project_key=data.project_key,
session_id=data.session_id,
resource=data.resource,
description=data.description,
acquired_at=datetime.now(timezone.utc).replace(tzinfo=None),
expires_at=expires_at,
released_at=None,
)
db.add(lock)
db.commit()
db.refresh(lock)
return lock
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to claim lock: {e}"
)
def release_lock(db: Session, lock_id: UUID, session_id: str) -> CoordSessionLock:
"""Release a specific lock; only the owning session may release it."""
lock = db.query(CoordSessionLock).filter(CoordSessionLock.id == str(lock_id)).first()
if not lock:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Lock {lock_id} not found"
)
if lock.session_id != session_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the session that claimed this lock may release it"
)
try:
lock.released_at = datetime.now(timezone.utc).replace(tzinfo=None)
db.commit()
db.refresh(lock)
return lock
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to release lock: {e}"
)
def release_all_session_locks(db: Session, session_id: str) -> dict:
"""Release all active locks held by a session (cleanup on session end)."""
now = datetime.now(timezone.utc).replace(tzinfo=None)
try:
q = db.query(CoordSessionLock).filter(
CoordSessionLock.session_id == session_id,
CoordSessionLock.released_at.is_(None),
)
count = q.count()
q.update({"released_at": now}, synchronize_session=False)
db.commit()
return {"message": f"Released {count} lock(s) for session '{session_id}'", "count": count}
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to release session locks: {e}"
)