Skip to content

Commit 344e5bf

Browse files
committed
fix(agent): clear running state on workflow failures
1 parent 3a19619 commit 344e5bf

6 files changed

Lines changed: 107 additions & 8 deletions

File tree

packages/tracecat-ee/tracecat_ee/agent/workflows/durable.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ def _resolve_agent_output(
337337
LOAD_TERMINAL_MESSAGE_HISTORY_PATCH = "durable-agent-load-terminal-message-history-v1"
338338
AGENT_ACTIVITY_GRACEFUL_CANCEL_PATCH = "durable-agent-activity-graceful-cancel-v1"
339339
AGENT_SESSION_STATUS_PATCH = "durable-agent-session-status-v1"
340+
AGENT_SESSION_FAILURE_STATUS_PATCH = "durable-agent-session-failure-status-v1"
340341
AGENT_ACTIVITY_GRACEFUL_CANCEL_HEARTBEAT_TIMEOUT_SECONDS = 10
341342

342343

@@ -718,6 +719,11 @@ async def run(self, args: AgentWorkflowArgs) -> AgentOutput:
718719
error=str(emit_error),
719720
)
720721
raise
722+
except TemporalCancelledError:
723+
raise
724+
except Exception:
725+
await self._set_agent_session_failed_for_unhandled_failure()
726+
raise
721727

722728
@workflow.update
723729
def request_cancel(self, request: WorkflowCancelRequest) -> None:
@@ -799,6 +805,16 @@ async def _set_agent_session_status(
799805
error=str(e),
800806
)
801807

808+
async def _set_agent_session_failed_for_unhandled_failure(self) -> None:
809+
"""Mark failed for post-deploy unhandled workflow failures."""
810+
if not workflow.patched(AGENT_SESSION_FAILURE_STATUS_PATCH):
811+
return
812+
813+
await self._set_agent_session_status(
814+
AgentSessionStatus.FAILED,
815+
clear_curr_run_id=True,
816+
)
817+
802818
async def _run_agent_activity_turn(
803819
self,
804820
executor_input: AgentExecutorInput,

tests/temporal/test_durable_agent_workflow.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,49 @@ async def mock_run_agent_activity(
667667
assert status_update_inputs[-1].clear_curr_run_id is True
668668

669669

670+
@pytest.mark.anyio
671+
@pytest.mark.integration
672+
async def test_agent_workflow_marks_failed_when_executor_activity_errors(
673+
temporal_client: Client,
674+
agent_worker_factory,
675+
agent_workflow_args: AgentWorkflowArgs,
676+
mock_session_id: uuid.UUID,
677+
) -> None:
678+
"""Executor ActivityError failures should release the running session gate."""
679+
queue = f"test-agent-queue-{mock_session_id}"
680+
status_update_inputs: list[UpdateSessionStatusInput] = []
681+
682+
def mock_executor(
683+
_call_count: int,
684+
_input: AgentExecutorInput,
685+
) -> AgentExecutorResult:
686+
raise ApplicationError("sandbox worker timed out")
687+
688+
activities = create_activities_with_mock_executor(
689+
mock_executor,
690+
status_update_inputs=status_update_inputs,
691+
)
692+
693+
async with agent_worker_factory(
694+
temporal_client, task_queue=queue, custom_activities=activities
695+
):
696+
with pytest.raises(WorkflowFailureError):
697+
await temporal_client.execute_workflow(
698+
DurableAgentWorkflow.run,
699+
agent_workflow_args,
700+
id=AgentWorkflowID(mock_session_id),
701+
task_queue=queue,
702+
retry_policy=RETRY_POLICIES["workflow:fail_fast"],
703+
execution_timeout=timedelta(seconds=30),
704+
)
705+
706+
assert [input.status for input in status_update_inputs] == [
707+
AgentSessionStatus.RUNNING,
708+
AgentSessionStatus.FAILED,
709+
]
710+
assert status_update_inputs[-1].clear_curr_run_id is True
711+
712+
670713
@pytest.mark.anyio
671714
@pytest.mark.integration
672715
async def test_agent_workflow_preserves_legacy_activity_message_history(

tests/unit/test_agent_activities.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
load_session_activity,
4848
load_session_messages_activity,
4949
)
50-
from tracecat.agent.session.types import AgentSessionEntity
50+
from tracecat.agent.session.types import AgentSessionEntity, AgentSessionStatus
5151
from tracecat.agent.skill.types import ResolvedSkillRef
5252
from tracecat.agent.subagents import ResolvedAgentsConfig
5353
from tracecat.agent.tools import BuildToolsResult
@@ -394,6 +394,41 @@ async def test_idempotent_for_existing_session(
394394
assert result.success is True
395395
assert result.session_id == mock_session_id
396396

397+
@pytest.mark.anyio
398+
@patch("tracecat.agent.session.activities.AgentSessionService.with_session")
399+
async def test_curr_run_id_does_not_mark_session_running(
400+
self, mock_with_session, mock_role: Role, mock_session_id: uuid.UUID
401+
):
402+
"""Create setup records the run token without taking the running lock."""
403+
curr_run_id = uuid.uuid4()
404+
input = CreateSessionInput(
405+
role=mock_role,
406+
session_id=mock_session_id,
407+
entity_type=AgentSessionEntity.WORKFLOW,
408+
entity_id=uuid.uuid4(),
409+
curr_run_id=curr_run_id,
410+
)
411+
412+
mock_agent_session = MagicMock()
413+
mock_agent_session.agents_binding = None
414+
mock_agent_session.status = AgentSessionStatus.IDLE.value
415+
mock_service = AsyncMock()
416+
mock_service.get_or_create_session.return_value = (mock_agent_session, False)
417+
mock_service.session = MagicMock()
418+
mock_service.session.commit = AsyncMock()
419+
420+
mock_ctx = AsyncMock()
421+
mock_ctx.__aenter__.return_value = mock_service
422+
mock_with_session.return_value = mock_ctx
423+
424+
result = await create_session_activity(input)
425+
426+
assert result.success is True
427+
assert mock_agent_session.curr_run_id == curr_run_id
428+
assert mock_agent_session.status == AgentSessionStatus.IDLE.value
429+
mock_service.session.add.assert_called_once_with(mock_agent_session)
430+
mock_service.session.commit.assert_awaited_once()
431+
397432
@pytest.mark.anyio
398433
@patch("tracecat.agent.session.activities.AgentSessionService.with_session")
399434
async def test_backfills_disabled_agents_binding_for_legacy_existing_session(

tests/unit/test_agent_session_router.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
send_message,
2121
stream_session_events,
2222
)
23-
from tracecat.agent.session.types import AgentSessionEntity
23+
from tracecat.agent.session.types import AgentSessionEntity, AgentSessionStatus
2424
from tracecat.auth.types import Role
2525
from tracecat.chat.schemas import (
2626
ApprovalDecision,
@@ -87,7 +87,7 @@ async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
8787

8888
@pytest.mark.anyio
8989
async def test_get_session_includes_agents_binding() -> None:
90-
session_stub = _agent_session_stub()
90+
session_stub = _agent_session_stub(status=AgentSessionStatus.RUNNING.value)
9191
fake_svc = SimpleNamespace(
9292
get_session=AsyncMock(return_value=session_stub),
9393
list_messages=AsyncMock(return_value=[]),
@@ -107,11 +107,14 @@ async def test_get_session_includes_agents_binding() -> None:
107107
"enabled": False,
108108
"subagents": [],
109109
}
110+
assert response.turn_status is AgentSessionStatus.RUNNING
110111

111112

112113
@pytest.mark.anyio
113114
async def test_get_session_vercel_includes_agents_binding() -> None:
114-
session_stub = _agent_session_stub()
115+
session_stub = _agent_session_stub(
116+
status=AgentSessionStatus.WAITING_FOR_APPROVAL.value
117+
)
115118
fake_svc = SimpleNamespace(
116119
get_session=AsyncMock(return_value=session_stub),
117120
list_messages=AsyncMock(return_value=[]),
@@ -131,6 +134,7 @@ async def test_get_session_vercel_includes_agents_binding() -> None:
131134
"enabled": False,
132135
"subagents": [],
133136
}
137+
assert response.turn_status is AgentSessionStatus.WAITING_FOR_APPROVAL
134138

135139

136140
@pytest.mark.anyio

tracecat/agent/session/activities.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,8 +204,9 @@ async def create_session_activity(input: CreateSessionInput) -> CreateSessionRes
204204

205205
# Set curr_run_id if provided (for workflow-initiated sessions)
206206
if input.curr_run_id is not None:
207+
# The workflow marks RUNNING only after setup completes, just
208+
# before handing control to the executor activity.
207209
agent_session.curr_run_id = input.curr_run_id
208-
agent_session.status = AgentSessionStatus.RUNNING.value
209210
service.session.add(agent_session)
210211
await service.session.commit()
211212

tracecat/agent/session/schemas.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from datetime import datetime
77
from typing import Any
88

9-
from pydantic import BaseModel, Field
9+
from pydantic import BaseModel, ConfigDict, Field
1010

1111
from tracecat.agent.adapter.vercel import UIMessage
1212
from tracecat.agent.common.stream_types import HarnessType
@@ -103,7 +103,7 @@ class AgentSessionHistoryRead(BaseModel):
103103
created_at: datetime
104104
updated_at: datetime
105105

106-
model_config = {"from_attributes": True}
106+
model_config = ConfigDict(from_attributes=True)
107107

108108

109109
class AgentSessionRead(BaseModel):
@@ -136,7 +136,7 @@ class AgentSessionRead(BaseModel):
136136
created_at: datetime
137137
updated_at: datetime
138138

139-
model_config = {"from_attributes": True}
139+
model_config = ConfigDict(from_attributes=True, validate_by_name=True)
140140

141141

142142
class AgentSessionReadWithMessages(AgentSessionRead):

0 commit comments

Comments
 (0)