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:
somew1nd committed 2026-08-27 10:21:40 -07:00
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
+33
View File
@@ -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]
+77
View File
@@ -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"
+8
View File
@@ -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"
+19
View File
@@ -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
+57
View File
@@ -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)