feat: extend operational investigation workflows
This commit is contained in:
@@ -19,6 +19,7 @@ from app.auth.permissions import (
|
||||
SIMULATION_VIEW,
|
||||
)
|
||||
from app.domain.domain_errors import DomainError, require
|
||||
from app.services import task_questions
|
||||
from app.domain.schemas.operational_task import (
|
||||
ActionDecisionCommand,
|
||||
ActionExecuteCommand,
|
||||
@@ -65,6 +66,8 @@ from app.domain.schemas.operational_task import (
|
||||
TaskPresentationEventCommand,
|
||||
TaskPresentationEventResult,
|
||||
TaskPresentationCursor,
|
||||
TaskPresentationHistoryItem,
|
||||
TaskPresentationHistoryPage,
|
||||
)
|
||||
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
|
||||
from app.services.content_digest import digest
|
||||
@@ -97,6 +100,15 @@ class OperationalTaskService:
|
||||
def _actor(self) -> str:
|
||||
return f"principal:{self.context.user_id}"
|
||||
|
||||
@staticmethod
|
||||
def _require_run_owner(run: dict, command: AgentRunCommand) -> None:
|
||||
require(run.get("agent_session_id") == command.source_session_id,
|
||||
"SESSION_MISMATCH", "Agent session does not own this run", status=409)
|
||||
owner = run.get("agent_instance_id")
|
||||
if owner is not None:
|
||||
require(owner == command.agent_instance_id,
|
||||
"AGENT_INSTANCE_MISMATCH", "Agent instance does not own this run", status=409)
|
||||
|
||||
@property
|
||||
def _supports_runs(self) -> bool:
|
||||
return hasattr(self.repo, "get_active_run") and hasattr(self.repo, "update_run")
|
||||
@@ -138,6 +150,7 @@ class OperationalTaskService:
|
||||
permissions = self.permissions
|
||||
return OperationalTaskPermissions(
|
||||
can_publish_artifacts=OPERATIONAL_TASK_EXECUTE in permissions,
|
||||
can_answer_questions=OPERATIONAL_TASK_WRITE in permissions,
|
||||
can_propose_actions=OPERATIONAL_TASK_WRITE in permissions,
|
||||
can_sync_external_feedback=OPERATIONAL_TASK_EXTERNAL_SYNC in permissions,
|
||||
can_decide_work_orders=OPERATIONAL_ACTION_WORK_ORDER_APPROVE in permissions,
|
||||
@@ -258,6 +271,7 @@ class OperationalTaskService:
|
||||
evidence=evidence,
|
||||
actions=actions,
|
||||
access_requests=access_requests,
|
||||
questions=await task_questions.list_questions(self, task_id),
|
||||
permissions=self._permission_view(),
|
||||
work_order_snapshot=work_order_snapshot,
|
||||
)
|
||||
@@ -483,6 +497,7 @@ class OperationalTaskService:
|
||||
status="running",
|
||||
revision=expected + 1,
|
||||
agent_session_id=command.source_session_id,
|
||||
agent_instance_id=command.agent_instance_id,
|
||||
lease_expires_at=now + LEASE_DURATION,
|
||||
heartbeat_at=now,
|
||||
started_at=run.get("started_at") or now,
|
||||
@@ -527,8 +542,7 @@ class OperationalTaskService:
|
||||
"Run revision changed", status=409)
|
||||
require(run["status"] in {"claimed", "running"}, "INVALID_TRANSITION",
|
||||
"Only an active run has a lease", status=409)
|
||||
require(run.get("agent_session_id") == command.source_session_id,
|
||||
"SESSION_MISMATCH", "Agent session does not own this run", status=409)
|
||||
self._require_run_owner(run, command)
|
||||
now = self._now()
|
||||
self._require_active_run_lease(run, now)
|
||||
expected = run["revision"]
|
||||
@@ -580,6 +594,7 @@ class OperationalTaskService:
|
||||
replay = await self._request_replay(scope, command.request_id, body)
|
||||
if replay:
|
||||
return await self.detail(task_id)
|
||||
await self.repo.lock(f"task:{task_id}")
|
||||
await self.repo.lock(f"run:{run_id}")
|
||||
run = await self._run(task_id, run_id)
|
||||
require(run["revision"] == command.expected_revision, "VERSION_CONFLICT",
|
||||
@@ -587,6 +602,7 @@ class OperationalTaskService:
|
||||
require(run["status"] in {"queued", "claimed", "running"},
|
||||
"INVALID_TRANSITION", "Run is already terminal", status=409)
|
||||
now = self._now()
|
||||
await task_questions.cancel_pending(self, task_id, now)
|
||||
expected = run["revision"]
|
||||
run.update(
|
||||
status="cancelled", revision=expected + 1, finished_at=now,
|
||||
@@ -624,6 +640,7 @@ class OperationalTaskService:
|
||||
require(await self.repo.get_active_run(str(task_id)) is None,
|
||||
"ACTIVE_RUN_EXISTS", "Task already has an active run", status=409)
|
||||
now = self._now()
|
||||
await task_questions.cancel_pending(self, task_id, now)
|
||||
generation = task["execution_generation"] + 1
|
||||
run = self._new_run(task_id, generation, now)
|
||||
await self.repo.insert_run(_json(run))
|
||||
@@ -986,6 +1003,27 @@ class OperationalTaskService:
|
||||
event_cursor=cursor,
|
||||
)
|
||||
|
||||
async def presentation_history(
|
||||
self, task_id: UUID, *, after: int = 0, through: int | None = None, limit: int = 100,
|
||||
) -> TaskPresentationHistoryPage:
|
||||
require(0 <= after and (through is None or through >= 0) and 1 <= limit <= 200,
|
||||
"INVALID_ARGUMENT", "Invalid presentation history page", status=422)
|
||||
async with self.repo.transaction():
|
||||
await self._task(task_id)
|
||||
cursor = await self.repo.current_event_cursor()
|
||||
boundary = min(through, cursor) if through is not None else cursor
|
||||
rows = await self.repo.list_presentation_events(str(task_id), after, boundary, limit + 1)
|
||||
items = [TaskPresentationHistoryItem(
|
||||
event_seq=row["event_seq"], occurred_at=row["occurred_at"],
|
||||
run_id=row["metadata"]["run_id"],
|
||||
execution_generation=row["metadata"]["execution_generation"],
|
||||
command=row["metadata"]["command"],
|
||||
) for row in rows[:limit]]
|
||||
return TaskPresentationHistoryPage(
|
||||
task_id=task_id, event_cursor=boundary, items=items,
|
||||
next_after=items[-1].event_seq if len(rows) > limit else None,
|
||||
)
|
||||
|
||||
async def publish_presentation_event(
|
||||
self, task_id: UUID, command: TaskPresentationEventCommand
|
||||
) -> TaskPresentationEventResult:
|
||||
@@ -1019,7 +1057,16 @@ class OperationalTaskService:
|
||||
evidence = StoredEvidenceRecord.model_validate(stored)
|
||||
require(evidence.data_snapshot is not None, "SNAPSHOT_NOT_FOUND",
|
||||
"Overlay requires a Server-ingested immutable data snapshot", status=422)
|
||||
payload["data"] = resolve_overlay(evidence.data_snapshot, command.command.mode)
|
||||
resolved = resolve_overlay(evidence.data_snapshot, command.command.mode)
|
||||
if command.command.data is not None:
|
||||
supplied = resolve_overlay(
|
||||
evidence.data_snapshot.model_copy(update={
|
||||
"data": command.command.data.model_dump(mode="json", exclude_unset=True),
|
||||
}), command.command.mode,
|
||||
)
|
||||
require(supplied == resolved, "OVERLAY_DATA_MISMATCH",
|
||||
"Overlay data must match the immutable evidence snapshot", status=422)
|
||||
payload["data"] = resolved
|
||||
metadata = {"task_id": str(task_id), "run_id": str(run["run_id"]),
|
||||
"execution_generation": run["generation"], "command": payload}
|
||||
try:
|
||||
@@ -1890,18 +1937,21 @@ class OperationalTaskService:
|
||||
replay = await self._request_replay(scope, command.request_id, body)
|
||||
if replay:
|
||||
return await self.detail(task_id)
|
||||
await self.repo.lock(f"task:{task_id}")
|
||||
await self.repo.lock(f"run:{run_id}")
|
||||
run = await self._run(task_id, run_id)
|
||||
require(run["revision"] == command.expected_revision, "VERSION_CONFLICT",
|
||||
"Run revision changed", status=409)
|
||||
require(run["status"] in {"claimed", "running"}, "INVALID_TRANSITION",
|
||||
"Only an active run can finish", status=409)
|
||||
require(run.get("agent_session_id") == command.source_session_id,
|
||||
"SESSION_MISMATCH", "Agent session does not own this run", status=409)
|
||||
self._require_run_owner(run, command)
|
||||
now = self._now()
|
||||
self._require_active_run_lease(run, now)
|
||||
task = await self._task(task_id)
|
||||
final_outcome = outcome
|
||||
pending_question = any(q["status"] == "pending" and str(q["run_id"]) == str(run_id)
|
||||
for q in await task_questions.list_questions(self, task_id))
|
||||
actions = []
|
||||
if outcome == "succeeded":
|
||||
await self._require_no_pending_calculations(task_id, run)
|
||||
if hasattr(self.repo, "pending_run_messages"):
|
||||
@@ -1929,7 +1979,9 @@ class OperationalTaskService:
|
||||
and plan.source_session_id == command.source_session_id
|
||||
))
|
||||
)
|
||||
if not same_generation or report.report.stage != "final":
|
||||
if pending_question:
|
||||
lifecycle = "waiting_human"
|
||||
elif not same_generation or report.report.stage != "final":
|
||||
final_outcome = "failed"
|
||||
lifecycle = "failed"
|
||||
failure_code = "ARTIFACT_INCOMPLETE"
|
||||
@@ -1974,6 +2026,7 @@ class OperationalTaskService:
|
||||
run_model = OperationalTaskRun.model_validate(run)
|
||||
await self.repo.update_run(_json(run_model), expected)
|
||||
if final_outcome != "succeeded":
|
||||
await task_questions.cancel_pending(self, task_id, now)
|
||||
await self._fail_run_messages(run_id, failure_code or "Run failed", now)
|
||||
task.update(
|
||||
lifecycle_status=lifecycle,
|
||||
@@ -1994,7 +2047,7 @@ class OperationalTaskService:
|
||||
failure_detail or "工况任务执行失败",
|
||||
now,
|
||||
)
|
||||
elif lifecycle == "waiting_human":
|
||||
elif lifecycle == "waiting_human" and not pending_question:
|
||||
pending_actions = any(
|
||||
action.approval_status == "pending" for action in actions
|
||||
)
|
||||
@@ -2207,6 +2260,7 @@ class OperationalTaskService:
|
||||
task["agent_lease_expires_at"] = None
|
||||
|
||||
async def _requeue_task(self, task: dict, now: datetime) -> None:
|
||||
await task_questions.cancel_pending(self, UUID(str(task["task_id"])), now)
|
||||
if self._supports_runs:
|
||||
active_run = await self.repo.get_active_run(str(task["task_id"]))
|
||||
while active_run is not None:
|
||||
|
||||
Reference in New Issue
Block a user