feat(core): consolidate config hub, unify daemon scheduling across all entrypoints, and fix step replay
- Unified configuration and paths under artemis.config as single source of truth - Consolidated execution across CLI, SDK, MCP, and Admin Console to default to Artemis Daemon - Fixed frontend step replay to preserve failure steps with proper sequential numbering - Implemented task deduplication, debouncing, and multi-device lock isolation - Added comprehensive end-to-end multi-port integration tests for live phone and emulator
This commit is contained in:
1 parent
e984d17635
commit
5f1ee18bc1
38 files changed
+1426
-203
No files matched your search
@@ -52,9 +52,26 @@ def clean_state(tmp_path, monkeypatch):
|
||||
state.queue_items.clear()
|
||||
state.active_connections.clear()
|
||||
state.cancelled_session_ids.clear()
|
||||
state.active_session_id = None
|
||||
state.current_process = None
|
||||
state.current_goal = None
|
||||
state.current_profile = None
|
||||
state.was_stopped_manually = False
|
||||
if hasattr(state, "recent_submissions"):
|
||||
state.recent_submissions.clear()
|
||||
if state.worker_task and not state.worker_task.done():
|
||||
state.worker_task.cancel()
|
||||
state.worker_task = None
|
||||
from artemis.config.paths import get_temp_dir
|
||||
from artemis.runtime.device_lock import DeviceExecutionLock
|
||||
DeviceExecutionLock.cleanup_stale_locks()
|
||||
queue_dir = get_temp_dir("device-locks") / "artemis-global-device.queue"
|
||||
if queue_dir.exists():
|
||||
for p in queue_dir.glob("*.wait"):
|
||||
try:
|
||||
p.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def test_paused_error_reads_persisted_reason(tmp_path):
|
||||
@@ -612,7 +629,10 @@ def test_darwin_terminate_process_tree_preserves_direct_child_for_asyncio():
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_tasks_unified_ingress():
|
||||
"""Verify enqueue_tasks correctly propagates ingress, custom session_id, and conversation_id."""
|
||||
with patch.object(TaskQueueService, "ensure_worker_running"):
|
||||
with (
|
||||
patch.object(TaskQueueService, "ensure_worker_running"),
|
||||
patch("apps.admin_console.services.task_queue_service.DeviceExecutionLock.reserve", return_value="mock-ticket-unified"),
|
||||
):
|
||||
res = await task_queue_service.enqueue_tasks(
|
||||
["Test unified goal"],
|
||||
profile="flash",
|
||||
@@ -801,4 +821,44 @@ def test_stop_tasks_queued_item_by_session_id():
|
||||
assert update_status.call_args[0][1] == "cancelled"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_tasks_deduplicates_by_session_id():
|
||||
state.queue_items = []
|
||||
state.active_session_id = None
|
||||
|
||||
with (
|
||||
patch("apps.admin_console.services.task_queue_service.session_repo"),
|
||||
patch("apps.admin_console.services.task_queue_service.DeviceExecutionLock.reserve", return_value="ticket-123"),
|
||||
):
|
||||
res1 = await task_queue_service.enqueue_tasks(["Task goal"], session_id="sid-dedup-1")
|
||||
assert len(state.queue_items) == 1
|
||||
assert res1["enqueued_count"] == 1
|
||||
|
||||
# Second submission with same session_id must not enqueue a duplicate
|
||||
res2 = await task_queue_service.enqueue_tasks(["Task goal again"], session_id="sid-dedup-1")
|
||||
assert len(state.queue_items) == 1
|
||||
assert res2["enqueued_count"] == 0
|
||||
assert res2["tasks"][0]["session_id"] == "sid-dedup-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_tasks_debounces_rapid_identical_submissions():
|
||||
state.queue_items = []
|
||||
state.active_session_id = None
|
||||
|
||||
with (
|
||||
patch("apps.admin_console.services.task_queue_service.session_repo"),
|
||||
patch("apps.admin_console.services.task_queue_service.DeviceExecutionLock.reserve", return_value="ticket-456"),
|
||||
):
|
||||
res1 = await task_queue_service.enqueue_tasks(["Rapid duplicate goal"], device_serial="dev-1")
|
||||
assert len(state.queue_items) == 1
|
||||
assert res1["enqueued_count"] == 1
|
||||
|
||||
# Immediate second submission (within 1s) must debounce
|
||||
res2 = await task_queue_service.enqueue_tasks(["Rapid duplicate goal"], device_serial="dev-1")
|
||||
assert len(state.queue_items) == 1
|
||||
assert res2["enqueued_count"] == 0
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -270,6 +270,8 @@ def test_update_step_execution_result_suppresses_identical_post_screenshot(tmp_p
|
||||
pre_screenshot_bytes=image_bytes,
|
||||
summary="Initial step",
|
||||
)
|
||||
for t in list(engine._pending_threads):
|
||||
t.join()
|
||||
|
||||
step_record = engine.storage.get_steps(engine.current_session_id)[0]
|
||||
assert step_record.post_image_name is None
|
||||
@@ -293,3 +295,34 @@ def test_update_step_execution_result_suppresses_identical_post_screenshot(tmp_p
|
||||
|
||||
updated_step_distinct = engine.storage.get_steps(engine.current_session_id)[0]
|
||||
assert updated_step_distinct.post_image_name == "distinct_new_hash_789"
|
||||
|
||||
|
||||
def test_resumed_session_monotonic_step_numbering(tmp_path):
|
||||
mock_ctx = MagicMock(spec=ArtemisContext)
|
||||
mock_execution_setup = MagicMock()
|
||||
mock_execution_setup.traces_path = str(tmp_path)
|
||||
mock_ctx.execution_setup = mock_execution_setup
|
||||
mock_ctx.device = None
|
||||
|
||||
# First runner records Step 1 and Step 2
|
||||
engine1 = DataEngine(mock_ctx)
|
||||
sid = engine1.start_session("Monotonic test")
|
||||
engine1.record_step(summary="Step 1 action")
|
||||
engine1.record_step(summary="Step 2 action")
|
||||
for t in list(engine1._pending_threads):
|
||||
t.join()
|
||||
|
||||
# Second runner starts or resumes the same session
|
||||
engine2 = DataEngine(mock_ctx)
|
||||
engine2.start_session("Resumed goal", session_id=sid)
|
||||
assert engine2.current_step_number == 2
|
||||
|
||||
# Recording a new step must be Step 3, strictly monotonic
|
||||
engine2.record_step(summary="Step 3 action")
|
||||
for t in list(engine2._pending_threads):
|
||||
t.join()
|
||||
|
||||
steps = engine2.storage.get_steps(sid)
|
||||
assert len(steps) == 3
|
||||
assert [s.step_number for s in steps] == [1, 2, 3]
|
||||
|
||||
@@ -38,6 +38,7 @@ from mcp_server.utils import trace_store
|
||||
def temp_trace_env(monkeypatch):
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
monkeypatch.setattr(trace_store, "TRACES_DIR", temp_dir)
|
||||
monkeypatch.setenv("ARTEMIS_STANDALONE", "1")
|
||||
yield temp_dir
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
@@ -151,6 +152,33 @@ def test_mobile_run_task_with_device_serial(temp_trace_env):
|
||||
assert status["device_serial"] == "pixel-11-pro-001"
|
||||
|
||||
|
||||
def test_mobile_run_task_dispatched_to_daemon(temp_trace_env, monkeypatch):
|
||||
monkeypatch.delenv("ARTEMIS_STANDALONE", raising=False)
|
||||
with (
|
||||
patch(
|
||||
"artemis.runtime.ensure_daemon_running",
|
||||
return_value=(True, "http://127.0.0.1:8000"),
|
||||
),
|
||||
patch(
|
||||
"artemis.runtime.submit_task_to_daemon",
|
||||
return_value={"status": "started", "tasks": [{"session_id": "daemon-sid-1"}]},
|
||||
),
|
||||
patch("mcp_server.tools.task_runner.subprocess.Popen") as popen,
|
||||
):
|
||||
result = mobile_run_task(
|
||||
task_desc="Open Settings via Daemon",
|
||||
conversation_id="conv-daemon",
|
||||
model="Flash",
|
||||
)
|
||||
|
||||
popen.assert_not_called()
|
||||
assert result["trace_id"] == "daemon-sid-1"
|
||||
assert result["status"] == "running"
|
||||
assert "enqueued via unified Artemis Daemon" in result["message"]
|
||||
assert "stdout_log" in result
|
||||
assert "stderr_log" in result
|
||||
|
||||
|
||||
def test_mobile_manage_task_unknown_trace(temp_trace_env):
|
||||
res = mobile_manage_task(action="status", trace_id="non-existent-trace")
|
||||
assert res["status"] == "unknown"
|
||||
@@ -325,3 +353,52 @@ async def test_mcp_server_auto_registers_tools():
|
||||
"mobile_get_device_state",
|
||||
"mobile_inspect_trace",
|
||||
}.issubset(tool_names)
|
||||
|
||||
|
||||
def test_mobile_manage_task_syncs_terminal_status_from_db(temp_trace_env):
|
||||
import sqlite3
|
||||
trace_id = str(uuid.uuid4())
|
||||
trace_store.init_trace(trace_id, "Running task to sync", "Flash", "conv-sync")
|
||||
|
||||
db_path = os.path.join(temp_trace_env, "data_engine.db")
|
||||
conn = sqlite3.connect(db_path)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
status TEXT,
|
||||
pid INTEGER,
|
||||
start_time REAL,
|
||||
end_time REAL,
|
||||
device_info TEXT
|
||||
)"""
|
||||
)
|
||||
cur.execute(
|
||||
"INSERT INTO sessions (session_id, status, pid, start_time) VALUES (?, ?, ?, ?)",
|
||||
(trace_id, "completed", 54321, 1000.0),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
res = mobile_manage_task(action="status", trace_id=trace_id)
|
||||
assert res["status"] == "completed"
|
||||
# Ensure status.json was persisted
|
||||
saved_st = trace_store.read_status(trace_id)
|
||||
assert saved_st["status"] == "completed"
|
||||
assert saved_st["pid"] == 54321
|
||||
|
||||
|
||||
def test_mobile_manage_task_stop_via_daemon(temp_trace_env, monkeypatch):
|
||||
monkeypatch.delenv("ARTEMIS_STANDALONE", raising=False)
|
||||
trace_id = str(uuid.uuid4())
|
||||
trace_store.init_trace(trace_id, "Daemon task to stop", "Flash", "conv-stop")
|
||||
|
||||
with (
|
||||
patch("artemis.runtime.is_daemon_running", return_value=True),
|
||||
patch("artemis.runtime.stop_task_on_daemon", return_value=True) as mock_stop,
|
||||
):
|
||||
res = mobile_manage_task(action="stop", trace_id=trace_id)
|
||||
mock_stop.assert_called_once_with(trace_id)
|
||||
assert res["status"] == "cancelled"
|
||||
assert trace_store.read_status(trace_id)["status"] == "cancelled"
|
||||
|
||||
@@ -75,3 +75,11 @@ def test_update_trace_status(temp_trace_env):
|
||||
def test_read_nonexistent_status(temp_trace_env):
|
||||
non_existent = str(uuid.uuid4())
|
||||
assert trace_store.read_status(non_existent) is None
|
||||
|
||||
|
||||
def test_trace_paths(temp_trace_env):
|
||||
trace_id = "test-paths-id"
|
||||
trace_dir = trace_store.get_trace_dir(trace_id)
|
||||
assert trace_store.get_trace_notes_dir(trace_id) == f"{trace_dir}/notes"
|
||||
assert trace_store.get_trace_stdout_log_path(trace_id) == f"{trace_dir}/stdout.log"
|
||||
assert trace_store.get_trace_stderr_log_path(trace_id) == f"{trace_dir}/stderr.log"
|
||||
@@ -116,3 +116,22 @@ def test_submit_task_to_daemon():
|
||||
assert data["ingress"] == "mcp"
|
||||
assert data["conversation_id"] == "conv-123"
|
||||
|
||||
|
||||
def test_stop_task_on_daemon():
|
||||
from artemis.runtime.daemon_client import stop_task_on_daemon
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.read.return_value = b'{"status": "stopped", "session_id": "test-sess-stop"}'
|
||||
mock_resp.__enter__.return_value = mock_resp
|
||||
|
||||
with patch("urllib.request.urlopen", return_value=mock_resp) as mock_urlopen:
|
||||
assert stop_task_on_daemon("test-sess-stop") is True
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
data = json.loads(req.data.decode("utf-8"))
|
||||
assert data["session_id"] == "test-sess-stop"
|
||||
|
||||
mock_resp.read.return_value = b'{"status": "no_running_task"}'
|
||||
with patch("urllib.request.urlopen", return_value=mock_resp):
|
||||
assert stop_task_on_daemon("non-existent-sess") is False
|
||||
|
||||
@@ -588,3 +588,60 @@ def test_agent_config_environment_variable_overrides(monkeypatch):
|
||||
assert cfg.outputter.enabled is False
|
||||
assert cfg.video_analyzer.enable_ledger is False
|
||||
assert cfg.pro.video_analyzer.enable_ledger is False
|
||||
|
||||
|
||||
def test_consolidated_workspace_and_admin_paths():
|
||||
"""Verify consolidated workspace and admin paths exported from artemis.config."""
|
||||
from artemis.config import (
|
||||
DB_PATH,
|
||||
IMAGES_DIR,
|
||||
PAUSE_FILE,
|
||||
REPLAY_BASE_DIR,
|
||||
ROOT_DIR,
|
||||
TEST_DATA_DIR,
|
||||
TEST_OUTPUTS_DIR,
|
||||
TRACES_PATH,
|
||||
WORKSPACE_ROOT,
|
||||
get_images_dir,
|
||||
get_pause_file,
|
||||
get_replay_dir,
|
||||
get_test_data_dir,
|
||||
get_test_outputs_dir,
|
||||
)
|
||||
|
||||
assert WORKSPACE_ROOT == ROOT_DIR
|
||||
assert PAUSE_FILE == get_pause_file()
|
||||
assert PAUSE_FILE.name == ".artemis_paused"
|
||||
assert REPLAY_BASE_DIR == get_replay_dir()
|
||||
assert TEST_DATA_DIR == get_test_data_dir()
|
||||
assert TEST_OUTPUTS_DIR == get_test_outputs_dir()
|
||||
assert IMAGES_DIR == get_images_dir()
|
||||
assert DB_PATH.name == "data_engine.db"
|
||||
assert TRACES_PATH.name == "traces"
|
||||
|
||||
|
||||
def test_admin_console_config_facade_backward_compatibility():
|
||||
"""Verify apps.admin_console.core.config continues to re-export consolidated paths."""
|
||||
from apps.admin_console.core.config import (
|
||||
DB_PATH,
|
||||
IMAGES_DIR,
|
||||
PAUSE_FILE,
|
||||
REPLAY_BASE_DIR,
|
||||
TEST_DATA_DIR,
|
||||
TEST_OUTPUTS_DIR,
|
||||
TRACES_PATH,
|
||||
WORKSPACE_ROOT,
|
||||
init_ls_address,
|
||||
)
|
||||
import artemis.config as ac
|
||||
|
||||
assert WORKSPACE_ROOT == ac.WORKSPACE_ROOT
|
||||
assert PAUSE_FILE == ac.PAUSE_FILE
|
||||
assert REPLAY_BASE_DIR == ac.REPLAY_BASE_DIR
|
||||
assert TEST_DATA_DIR == ac.TEST_DATA_DIR
|
||||
assert TEST_OUTPUTS_DIR == ac.TEST_OUTPUTS_DIR
|
||||
assert IMAGES_DIR == ac.IMAGES_DIR
|
||||
assert DB_PATH == ac.DB_PATH
|
||||
assert TRACES_PATH == ac.TRACES_PATH
|
||||
assert callable(init_ls_address)
|
||||
|
||||
Reference in new issue
Block a user