Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,12 @@ TRACECAT__RESULT_EXTERNALIZATION_THRESHOLD_BYTES=128000
# --- Agent filesystem persistence ---
# Maximum compressed bytes retained in each worker's local agent snapshot archive cache.
TRACECAT__AGENT_FS_ARCHIVE_CACHE_MAX_BYTES=10737418240
# Per-sandbox cgroup memory limit and executor memory reserved outside activity slots.
TRACECAT__AGENT_SANDBOX_MEMORY_MB=4096
TRACECAT__AGENT_SANDBOX_CGROUP_ENABLED=true
TRACECAT__AGENT_EXECUTOR_MEMORY_RESERVE_MB=4096
# Readiness sentinel path shared by the worker and the compose healthcheck.
TRACECAT__AGENT_EXECUTOR_READY_FILE=/var/run/tracecat/agent-executor-ready

# --- Local registry ---
# Enable this only for local-registry development. Leave disabled for normal remote/builtin registry use.
Expand Down
9 changes: 9 additions & 0 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,10 @@ services:
TRACECAT__AGENT_EXECUTOR_QUEUE: ${TRACECAT__AGENT_EXECUTOR_QUEUE:-shared-agent-executor-queue}
TRACECAT__EXECUTOR_QUEUE: ${TRACECAT__EXECUTOR_QUEUE:-shared-action-queue}
TRACECAT__AGENT_EXECUTOR_MAX_CONCURRENT_ACTIVITIES: ${TRACECAT__AGENT_EXECUTOR_MAX_CONCURRENT_ACTIVITIES:-1}
TRACECAT__AGENT_SANDBOX_MEMORY_MB: ${TRACECAT__AGENT_SANDBOX_MEMORY_MB:-4096}
TRACECAT__AGENT_SANDBOX_CGROUP_ENABLED: ${TRACECAT__AGENT_SANDBOX_CGROUP_ENABLED:-true}
TRACECAT__AGENT_EXECUTOR_MEMORY_RESERVE_MB: ${TRACECAT__AGENT_EXECUTOR_MEMORY_RESERVE_MB:-4096}
TRACECAT__AGENT_EXECUTOR_READY_FILE: ${TRACECAT__AGENT_EXECUTOR_READY_FILE:-/var/run/tracecat/agent-executor-ready}
TRACECAT__LLM_PROXY_READ_TIMEOUT: ${TRACECAT__LLM_PROXY_READ_TIMEOUT:-600}
TRACECAT__LLM_GATEWAY_CREDENTIAL_CACHE_TTL_SECONDS: ${TRACECAT__LLM_GATEWAY_CREDENTIAL_CACHE_TTL_SECONDS:-60}
TRACECAT__LLM_GATEWAY_HEALTHCHECK_INTERVAL_SECONDS: ${TRACECAT__LLM_GATEWAY_HEALTHCHECK_INTERVAL_SECONDS:-30}
Expand All @@ -418,6 +422,11 @@ services:
- ${TRACECAT__LOCAL_REPOSITORY_PATH}:/app/local_registry
- sandbox-cache:/var/lib/tracecat/sandbox-cache
command: ["python", "-m", "tracecat.agent.executor_worker"]
healthcheck:
test: ["CMD", "test", "-f", "${TRACECAT__AGENT_EXECUTOR_READY_FILE:-/var/run/tracecat/agent-executor-ready}"]
interval: 30s
retries: 3
start_period: 120s
depends_on:
litellm:
condition: service_healthy
Expand Down
9 changes: 9 additions & 0 deletions docker-compose.local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,10 @@ services:
TRACECAT__AGENT_EXECUTOR_QUEUE: ${TRACECAT__AGENT_EXECUTOR_QUEUE:-shared-agent-executor-queue}
TRACECAT__EXECUTOR_QUEUE: ${TRACECAT__EXECUTOR_QUEUE:-shared-action-queue}
TRACECAT__AGENT_EXECUTOR_MAX_CONCURRENT_ACTIVITIES: ${TRACECAT__AGENT_EXECUTOR_MAX_CONCURRENT_ACTIVITIES:-1}
TRACECAT__AGENT_SANDBOX_MEMORY_MB: ${TRACECAT__AGENT_SANDBOX_MEMORY_MB:-4096}
TRACECAT__AGENT_SANDBOX_CGROUP_ENABLED: ${TRACECAT__AGENT_SANDBOX_CGROUP_ENABLED:-true}
TRACECAT__AGENT_EXECUTOR_MEMORY_RESERVE_MB: ${TRACECAT__AGENT_EXECUTOR_MEMORY_RESERVE_MB:-4096}
TRACECAT__AGENT_EXECUTOR_READY_FILE: ${TRACECAT__AGENT_EXECUTOR_READY_FILE:-/var/run/tracecat/agent-executor-ready}
TRACECAT__LLM_PROXY_READ_TIMEOUT: ${TRACECAT__LLM_PROXY_READ_TIMEOUT:-600}
TRACECAT__LLM_GATEWAY_CREDENTIAL_CACHE_TTL_SECONDS: ${TRACECAT__LLM_GATEWAY_CREDENTIAL_CACHE_TTL_SECONDS:-60}
TRACECAT__LLM_GATEWAY_HEALTHCHECK_INTERVAL_SECONDS: ${TRACECAT__LLM_GATEWAY_HEALTHCHECK_INTERVAL_SECONDS:-30}
Expand All @@ -438,6 +442,11 @@ services:
- ${TRACECAT__LOCAL_REPOSITORY_PATH}:/app/local_registry
- sandbox-cache:/var/lib/tracecat/sandbox-cache
command: ["python", "-m", "tracecat.agent.executor_worker"]
healthcheck:
test: ["CMD", "test", "-f", "${TRACECAT__AGENT_EXECUTOR_READY_FILE:-/var/run/tracecat/agent-executor-ready}"]
interval: 30s
retries: 3
start_period: 120s
depends_on:
litellm:
condition: service_healthy
Expand Down
10 changes: 10 additions & 0 deletions docker-compose.sandbox.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,18 @@ services:
# Required for nsjail sandbox execution.
cap_add:
- SYS_ADMIN
environment:
TRACECAT__AGENT_SANDBOX_MEMORY_MB: ${TRACECAT__AGENT_SANDBOX_MEMORY_MB:-4096}
TRACECAT__AGENT_SANDBOX_CGROUP_ENABLED: ${TRACECAT__AGENT_SANDBOX_CGROUP_ENABLED:-true}
TRACECAT__AGENT_EXECUTOR_MEMORY_RESERVE_MB: ${TRACECAT__AGENT_EXECUTOR_MEMORY_RESERVE_MB:-4096}
TRACECAT__AGENT_EXECUTOR_READY_FILE: ${TRACECAT__AGENT_EXECUTOR_READY_FILE:-/var/run/tracecat/agent-executor-ready}
security_opt:
- seccomp:unconfined
- systempaths=unconfined
devices:
- /dev/net/tun:/dev/net/tun
healthcheck:
test: ["CMD", "test", "-f", "${TRACECAT__AGENT_EXECUTOR_READY_FILE:-/var/run/tracecat/agent-executor-ready}"]
Comment thread
daryllimyt marked this conversation as resolved.
interval: 30s
retries: 3
start_period: 120s
9 changes: 9 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,10 @@ services:
TRACECAT__AGENT_EXECUTOR_QUEUE: ${TRACECAT__AGENT_EXECUTOR_QUEUE:-shared-agent-executor-queue}
TRACECAT__EXECUTOR_QUEUE: ${TRACECAT__EXECUTOR_QUEUE:-shared-action-queue}
TRACECAT__AGENT_EXECUTOR_MAX_CONCURRENT_ACTIVITIES: ${TRACECAT__AGENT_EXECUTOR_MAX_CONCURRENT_ACTIVITIES:-1}
TRACECAT__AGENT_SANDBOX_MEMORY_MB: ${TRACECAT__AGENT_SANDBOX_MEMORY_MB:-4096}
TRACECAT__AGENT_SANDBOX_CGROUP_ENABLED: ${TRACECAT__AGENT_SANDBOX_CGROUP_ENABLED:-true}
Comment thread
daryllimyt marked this conversation as resolved.
TRACECAT__AGENT_EXECUTOR_MEMORY_RESERVE_MB: ${TRACECAT__AGENT_EXECUTOR_MEMORY_RESERVE_MB:-4096}
TRACECAT__AGENT_EXECUTOR_READY_FILE: ${TRACECAT__AGENT_EXECUTOR_READY_FILE:-/var/run/tracecat/agent-executor-ready}
TRACECAT__LLM_PROXY_READ_TIMEOUT: ${TRACECAT__LLM_PROXY_READ_TIMEOUT:-600}
TRACECAT__LLM_GATEWAY_CREDENTIAL_CACHE_TTL_SECONDS: ${TRACECAT__LLM_GATEWAY_CREDENTIAL_CACHE_TTL_SECONDS:-60}
TRACECAT__LLM_GATEWAY_HEALTHCHECK_INTERVAL_SECONDS: ${TRACECAT__LLM_GATEWAY_HEALTHCHECK_INTERVAL_SECONDS:-30}
Expand All @@ -433,6 +437,11 @@ services:
- ${TRACECAT__LOCAL_REPOSITORY_PATH}:/app/local_registry
- sandbox-cache:/var/lib/tracecat/sandbox-cache
command: ["python", "-m", "tracecat.agent.executor_worker"]
healthcheck:
test: ["CMD", "test", "-f", "${TRACECAT__AGENT_EXECUTOR_READY_FILE:-/var/run/tracecat/agent-executor-ready}"]
interval: 30s
retries: 3
start_period: 120s
depends_on:
litellm:
condition: service_healthy
Expand Down
294 changes: 294 additions & 0 deletions tests/unit/test_agent_executor_worker_resources.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
from __future__ import annotations

import asyncio
import errno
from datetime import UTC, datetime
from pathlib import Path
from unittest.mock import AsyncMock, Mock

import pytest

from tracecat.agent.sandbox.cgroup import (
AgentExecutorMemoryBudgetError,
CgroupAvailability,
PreparedCgroup,
)


@pytest.mark.anyio
async def test_agent_executor_readiness_sentinel_exists_only_while_running(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
from tracecat.agent import executor_worker

ready_file = tmp_path / "run" / "agent-executor-ready"
shutdown_event = asyncio.Event()
observed_contents: list[str] = []

class _FakeWorker:
def __init__(self, *args: object, **kwargs: object) -> None:
del args, kwargs

async def __aenter__(self) -> _FakeWorker:
return self

async def __aexit__(
self,
exc_type: object,
exc: object,
tb: object,
) -> None:
del exc_type, exc, tb

def keep_concurrency(
max_concurrent: int,
prepared_cgroup: PreparedCgroup,
*,
reserve_mb: int,
sandbox_memory_mb: int,
) -> int:
del prepared_cgroup, reserve_mb, sandbox_memory_mb
return max_concurrent

async def observe_readiness() -> None:
try:
for _ in range(1000):
if ready_file.exists():
observed_contents.append(ready_file.read_text().strip())
return
await asyncio.sleep(0)
pytest.fail("readiness sentinel was not created")
finally:
shutdown_event.set()

monkeypatch.setenv("TRACECAT__AGENT_EXECUTOR_MAX_CONCURRENT_ACTIVITIES", "2")
monkeypatch.setattr(
executor_worker,
"prepare_agent_sandbox_cgroup",
lambda: PreparedCgroup(CgroupAvailability.DISABLED, None),
)
monkeypatch.setattr(
executor_worker,
"clamp_agent_executor_concurrency",
keep_concurrency,
)
monkeypatch.setattr(
executor_worker,
"_start_runtime_services",
AsyncMock(return_value=object()),
)
monkeypatch.setattr(executor_worker, "_stop_runtime_services", AsyncMock())
monkeypatch.setattr(executor_worker, "close_storage_client_cache", AsyncMock())
monkeypatch.setattr(executor_worker, "Worker", _FakeWorker)
monkeypatch.setattr(executor_worker, "new_sandbox_runner", lambda: object())
monkeypatch.setattr(
executor_worker.config,
"TRACECAT__AGENT_EXECUTOR_READY_FILE",
str(ready_file),
)

observer = asyncio.create_task(observe_readiness())
await asyncio.wait_for(
executor_worker.main(shutdown_event=shutdown_event),
timeout=2,
)
await observer

assert not ready_file.exists()
assert len(observed_contents) == 1
started_at = datetime.fromisoformat(observed_contents[0])
assert started_at.tzinfo is UTC


def test_agent_executor_readiness_sentinel_is_best_effort_on_read_only_fs(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
from tracecat.agent import executor_worker

mock_logger = Mock()
monkeypatch.setattr(executor_worker, "logger", mock_logger)

def deny_mkdir(
self: Path,
mode: int = 0o777,
parents: bool = False,
exist_ok: bool = False,
) -> None:
del self, mode, parents, exist_ok
raise PermissionError(errno.EROFS, "read-only filesystem")

monkeypatch.setattr(Path, "mkdir", deny_mkdir)

created = executor_worker._write_readiness_file(
tmp_path / "run" / "agent-executor-ready",
datetime(2026, 7, 27, tzinfo=UTC),
)

assert created is False
mock_logger.warning.assert_called_once()
assert mock_logger.warning.call_args.kwargs["errno"] == errno.EROFS


@pytest.mark.anyio
async def test_agent_executor_removes_stale_readiness_sentinel_on_startup(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
from tracecat.agent import executor_worker

ready_file = tmp_path / "run" / "agent-executor-ready"
ready_file.parent.mkdir(parents=True)
ready_file.write_text("stale\n")

def keep_concurrency(
max_concurrent: int,
prepared_cgroup: PreparedCgroup,
*,
reserve_mb: int,
sandbox_memory_mb: int,
) -> int:
del prepared_cgroup, reserve_mb, sandbox_memory_mb
return max_concurrent

monkeypatch.setenv("TRACECAT__AGENT_EXECUTOR_MAX_CONCURRENT_ACTIVITIES", "1")
monkeypatch.setattr(
executor_worker,
"prepare_agent_sandbox_cgroup",
lambda: PreparedCgroup(CgroupAvailability.DISABLED, None),
)
monkeypatch.setattr(
executor_worker,
"clamp_agent_executor_concurrency",
keep_concurrency,
)
monkeypatch.setattr(
executor_worker,
"_start_runtime_services",
AsyncMock(side_effect=RuntimeError("startup failed")),
)
monkeypatch.setattr(executor_worker, "_stop_runtime_services", AsyncMock())
monkeypatch.setattr(executor_worker, "close_storage_client_cache", AsyncMock())
monkeypatch.setattr(
executor_worker.config,
"TRACECAT__AGENT_EXECUTOR_READY_FILE",
str(ready_file),
)

with pytest.raises(RuntimeError, match="startup failed"):
await executor_worker.main(shutdown_event=asyncio.Event())

assert not ready_file.exists()


@pytest.mark.anyio
async def test_agent_executor_memory_budget_error_propagates_from_main(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
from tracecat.agent import executor_worker

cgroup_root = tmp_path / "cgroup"
cgroup_root.mkdir()
(cgroup_root / "memory.max").write_text(f"{4096 * 1024 * 1024}\n")
start_runtime_services = AsyncMock()
monkeypatch.setenv("TRACECAT__AGENT_EXECUTOR_MAX_CONCURRENT_ACTIVITIES", "1")
monkeypatch.setattr(
executor_worker.config,
"TRACECAT__AGENT_EXECUTOR_MEMORY_RESERVE_MB",
4096,
)
monkeypatch.setattr(
executor_worker.config,
"TRACECAT__AGENT_SANDBOX_MEMORY_MB",
4096,
)
monkeypatch.setattr(
executor_worker,
"prepare_agent_sandbox_cgroup",
lambda: PreparedCgroup(CgroupAvailability.UNAVAILABLE, cgroup_root),
)
monkeypatch.setattr(
executor_worker,
"_start_runtime_services",
start_runtime_services,
)

with pytest.raises(
AgentExecutorMemoryBudgetError,
match="container_limit_mb=4096",
):
await executor_worker.main(shutdown_event=asyncio.Event())

start_runtime_services.assert_not_awaited()


@pytest.mark.anyio
async def test_agent_executor_clears_stale_sentinel_before_budget_validation(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
from tracecat.agent import executor_worker

ready_file = tmp_path / "run" / "agent-executor-ready"
ready_file.parent.mkdir(parents=True)
ready_file.write_text("stale\n")

cgroup_root = tmp_path / "cgroup"
cgroup_root.mkdir()
(cgroup_root / "memory.max").write_text(f"{4096 * 1024 * 1024}\n")
monkeypatch.setenv("TRACECAT__AGENT_EXECUTOR_MAX_CONCURRENT_ACTIVITIES", "1")
monkeypatch.setattr(
executor_worker.config,
"TRACECAT__AGENT_EXECUTOR_MEMORY_RESERVE_MB",
4096,
)
monkeypatch.setattr(
executor_worker.config,
"TRACECAT__AGENT_SANDBOX_MEMORY_MB",
4096,
)
monkeypatch.setattr(
executor_worker,
"prepare_agent_sandbox_cgroup",
lambda: PreparedCgroup(CgroupAvailability.UNAVAILABLE, cgroup_root),
)
monkeypatch.setattr(
executor_worker.config,
"TRACECAT__AGENT_EXECUTOR_READY_FILE",
str(ready_file),
)

with pytest.raises(AgentExecutorMemoryBudgetError):
await executor_worker.main(shutdown_event=asyncio.Event())

assert not ready_file.exists()


@pytest.mark.anyio
@pytest.mark.parametrize("max_concurrent", ["0", "-1"])
async def test_agent_executor_rejects_nonpositive_concurrency(
monkeypatch: pytest.MonkeyPatch,
max_concurrent: str,
) -> None:
from tracecat.agent import executor_worker

prepare_cgroup = Mock()
monkeypatch.setenv(
"TRACECAT__AGENT_EXECUTOR_MAX_CONCURRENT_ACTIVITIES",
max_concurrent,
)
monkeypatch.setattr(
executor_worker,
"prepare_agent_sandbox_cgroup",
prepare_cgroup,
)

with pytest.raises(
ValueError,
match="TRACECAT__AGENT_EXECUTOR_MAX_CONCURRENT_ACTIVITIES",
):
await executor_worker.main(shutdown_event=asyncio.Event())

prepare_cgroup.assert_not_called()
Loading
Loading