forked from cardosofelipe/pragma-stack
Compare commits
2 Commits
63066c50ba
...
06b2491c1f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06b2491c1f | ||
|
|
b8265783f3 |
@@ -31,7 +31,13 @@ from app.crud.syndarix.agent_instance import agent_instance as agent_instance_cr
|
||||
from app.crud.syndarix.issue import issue as issue_crud
|
||||
from app.crud.syndarix.project import project as project_crud
|
||||
from app.crud.syndarix.sprint import sprint as sprint_crud
|
||||
from app.models.syndarix.enums import IssuePriority, IssueStatus, SyncStatus
|
||||
from app.models.syndarix.enums import (
|
||||
AgentStatus,
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
SprintStatus,
|
||||
SyncStatus,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.schemas.common import (
|
||||
MessageResponse,
|
||||
@@ -200,6 +206,12 @@ async def create_issue(
|
||||
error_code=ErrorCode.VALIDATION_ERROR,
|
||||
field="assigned_agent_id",
|
||||
)
|
||||
if agent.status == AgentStatus.TERMINATED:
|
||||
raise ValidationException(
|
||||
message="Cannot assign issue to a terminated agent",
|
||||
error_code=ErrorCode.VALIDATION_ERROR,
|
||||
field="assigned_agent_id",
|
||||
)
|
||||
|
||||
# Validate sprint if provided (IDOR prevention)
|
||||
if issue_in.sprint_id:
|
||||
@@ -537,8 +549,14 @@ async def update_issue(
|
||||
error_code=ErrorCode.VALIDATION_ERROR,
|
||||
field="assigned_agent_id",
|
||||
)
|
||||
if agent.status == AgentStatus.TERMINATED:
|
||||
raise ValidationException(
|
||||
message="Cannot assign issue to a terminated agent",
|
||||
error_code=ErrorCode.VALIDATION_ERROR,
|
||||
field="assigned_agent_id",
|
||||
)
|
||||
|
||||
# Validate sprint if being updated (IDOR prevention)
|
||||
# Validate sprint if being updated (IDOR prevention and status validation)
|
||||
if issue_in.sprint_id is not None:
|
||||
sprint = await sprint_crud.get(db, id=issue_in.sprint_id)
|
||||
if not sprint:
|
||||
@@ -552,6 +570,13 @@ async def update_issue(
|
||||
error_code=ErrorCode.VALIDATION_ERROR,
|
||||
field="sprint_id",
|
||||
)
|
||||
# Cannot add issues to completed or cancelled sprints
|
||||
if sprint.status in [SprintStatus.COMPLETED, SprintStatus.CANCELLED]:
|
||||
raise ValidationException(
|
||||
message=f"Cannot add issues to sprint with status '{sprint.status.value}'",
|
||||
error_code=ErrorCode.VALIDATION_ERROR,
|
||||
field="sprint_id",
|
||||
)
|
||||
|
||||
try:
|
||||
updated_issue = await issue_crud.update(db, db_obj=issue, obj_in=issue_in)
|
||||
@@ -730,6 +755,12 @@ async def assign_issue(
|
||||
error_code=ErrorCode.VALIDATION_ERROR,
|
||||
field="assigned_agent_id",
|
||||
)
|
||||
if agent.status == AgentStatus.TERMINATED:
|
||||
raise ValidationException(
|
||||
message="Cannot assign issue to a terminated agent",
|
||||
error_code=ErrorCode.VALIDATION_ERROR,
|
||||
field="assigned_agent_id",
|
||||
)
|
||||
|
||||
updated_issue = await issue_crud.assign_to_agent(
|
||||
db, issue_id=issue_id, agent_id=assignment.assigned_agent_id
|
||||
|
||||
@@ -206,7 +206,10 @@ class CRUDAgentInstance(CRUDBase[AgentInstance, AgentInstanceCreate, AgentInstan
|
||||
*,
|
||||
instance_id: UUID,
|
||||
) -> AgentInstance | None:
|
||||
"""Terminate an agent instance."""
|
||||
"""Terminate an agent instance.
|
||||
|
||||
Also unassigns all issues from this agent to prevent orphaned assignments.
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(AgentInstance).where(AgentInstance.id == instance_id)
|
||||
@@ -216,6 +219,13 @@ class CRUDAgentInstance(CRUDBase[AgentInstance, AgentInstanceCreate, AgentInstan
|
||||
if not instance:
|
||||
return None
|
||||
|
||||
# Unassign all issues from this agent before terminating
|
||||
await db.execute(
|
||||
update(Issue)
|
||||
.where(Issue.assigned_agent_id == instance_id)
|
||||
.values(assigned_agent_id=None)
|
||||
)
|
||||
|
||||
instance.status = AgentStatus.TERMINATED
|
||||
instance.terminated_at = datetime.now(UTC)
|
||||
instance.current_task = None
|
||||
@@ -308,8 +318,29 @@ class CRUDAgentInstance(CRUDBase[AgentInstance, AgentInstanceCreate, AgentInstan
|
||||
*,
|
||||
project_id: UUID,
|
||||
) -> int:
|
||||
"""Terminate all active instances in a project."""
|
||||
"""Terminate all active instances in a project.
|
||||
|
||||
Also unassigns all issues from these agents to prevent orphaned assignments.
|
||||
"""
|
||||
try:
|
||||
# First, unassign all issues from agents in this project
|
||||
# Get all agent IDs that will be terminated
|
||||
agents_to_terminate = await db.execute(
|
||||
select(AgentInstance.id).where(
|
||||
AgentInstance.project_id == project_id,
|
||||
AgentInstance.status != AgentStatus.TERMINATED,
|
||||
)
|
||||
)
|
||||
agent_ids = [row[0] for row in agents_to_terminate.fetchall()]
|
||||
|
||||
# Unassign issues from these agents
|
||||
if agent_ids:
|
||||
await db.execute(
|
||||
update(Issue)
|
||||
.where(Issue.assigned_agent_id.in_(agent_ids))
|
||||
.values(assigned_agent_id=None)
|
||||
)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
stmt = (
|
||||
update(AgentInstance)
|
||||
|
||||
@@ -5,13 +5,15 @@ import logging
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import func, or_, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.crud.base import CRUDBase
|
||||
from app.models.syndarix import AgentInstance, Issue, Project, Sprint
|
||||
from app.models.syndarix.enums import ProjectStatus, SprintStatus
|
||||
from app.models.syndarix.enums import AgentStatus, ProjectStatus, SprintStatus
|
||||
from app.schemas.syndarix import ProjectCreate, ProjectUpdate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -283,7 +285,13 @@ class CRUDProject(CRUDBase[Project, ProjectCreate, ProjectUpdate]):
|
||||
*,
|
||||
project_id: UUID,
|
||||
) -> Project | None:
|
||||
"""Archive a project by setting status to ARCHIVED."""
|
||||
"""Archive a project by setting status to ARCHIVED.
|
||||
|
||||
This also performs cascading cleanup:
|
||||
- Terminates all active agent instances
|
||||
- Cancels all planned/active sprints
|
||||
- Unassigns issues from terminated agents
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == project_id)
|
||||
@@ -293,9 +301,63 @@ class CRUDProject(CRUDBase[Project, ProjectCreate, ProjectUpdate]):
|
||||
if not project:
|
||||
return None
|
||||
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# 1. Get all agent IDs that will be terminated
|
||||
agents_to_terminate = await db.execute(
|
||||
select(AgentInstance.id).where(
|
||||
AgentInstance.project_id == project_id,
|
||||
AgentInstance.status != AgentStatus.TERMINATED,
|
||||
)
|
||||
)
|
||||
agent_ids = [row[0] for row in agents_to_terminate.fetchall()]
|
||||
|
||||
# 2. Unassign issues from these agents to prevent orphaned assignments
|
||||
if agent_ids:
|
||||
await db.execute(
|
||||
update(Issue)
|
||||
.where(Issue.assigned_agent_id.in_(agent_ids))
|
||||
.values(assigned_agent_id=None)
|
||||
)
|
||||
|
||||
# 3. Terminate all active agents
|
||||
await db.execute(
|
||||
update(AgentInstance)
|
||||
.where(
|
||||
AgentInstance.project_id == project_id,
|
||||
AgentInstance.status != AgentStatus.TERMINATED,
|
||||
)
|
||||
.values(
|
||||
status=AgentStatus.TERMINATED,
|
||||
terminated_at=now,
|
||||
current_task=None,
|
||||
session_id=None,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
|
||||
# 4. Cancel all planned/active sprints
|
||||
await db.execute(
|
||||
update(Sprint)
|
||||
.where(
|
||||
Sprint.project_id == project_id,
|
||||
Sprint.status.in_([SprintStatus.PLANNED, SprintStatus.ACTIVE]),
|
||||
)
|
||||
.values(
|
||||
status=SprintStatus.CANCELLED,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
|
||||
# 5. Archive the project
|
||||
project.status = ProjectStatus.ARCHIVED
|
||||
await db.commit()
|
||||
await db.refresh(project)
|
||||
|
||||
logger.info(
|
||||
f"Archived project {project_id}: terminated agents={len(agent_ids)}"
|
||||
)
|
||||
|
||||
return project
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
|
||||
1089
backend/tests/api/routes/syndarix/test_edge_cases.py
Normal file
1089
backend/tests/api/routes/syndarix/test_edge_cases.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user