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

+26 -57
View File
@@ -12,63 +12,32 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import os
from pathlib import Path
import sys
"""Admin Console configuration facade.
All configuration and path management is centralized in `artemis.config`.
This module re-exports common constants for full backward compatibility.
"""
# Automatically write ANTIGRAVITY_LS_ADDRESS to shared synchronization file on startup
# to allow decoupled background processes to communicate with Jetski
def init_ls_address():
ls_addr = os.environ.get("ANTIGRAVITY_LS_ADDRESS")
if ls_addr:
try:
from artemis.config import write_ls_address
from artemis.config import (
DB_PATH,
IMAGES_DIR,
PAUSE_FILE,
REPLAY_BASE_DIR,
TEST_DATA_DIR,
TEST_OUTPUTS_DIR,
TRACES_PATH,
WORKSPACE_ROOT,
init_ls_address,
)
write_ls_address(ls_addr)
except Exception as e:
sys.stderr.write(f"Failed to write .jetski_ls_address on startup: {e}\n")
# Compute real WORKSPACE_ROOT (find repo root containing pyproject.toml or artemis)
_current_p = Path(__file__).resolve().parent
while _current_p != _current_p.parent:
if (_current_p / "pyproject.toml").exists() or (_current_p / "artemis").is_dir():
WORKSPACE_ROOT = _current_p
break
_current_p = _current_p.parent
else:
WORKSPACE_ROOT = Path(__file__).resolve().parent.parent.parent.parent
_apps_dir = WORKSPACE_ROOT / "apps"
_admin_console_dir = _apps_dir / "admin_console"
_cloud_service_dir = _apps_dir / "cloud_service"
for _p in (str(WORKSPACE_ROOT), str(_apps_dir), str(_admin_console_dir), str(_cloud_service_dir)):
if _p not in sys.path:
sys.path.insert(0, _p)
# Try to import settings from artemis
try:
from artemis.config import settings
TRACES_PATH = Path(settings.TRACES_PATH)
DB_PATH = Path(settings.DATA_ENGINE_DB_PATH)
except ImportError:
# Fallback to default path if run outside project environment or imports fail
TRACES_PATH = WORKSPACE_ROOT / "traces"
DB_PATH = TRACES_PATH / "data_engine.db"
# Step Replay Directories (Stored within traces/replay to avoid workspace clutter)
REPLAY_BASE_DIR = TRACES_PATH / "replay"
TEST_DATA_DIR = REPLAY_BASE_DIR / "data"
TEST_OUTPUTS_DIR = REPLAY_BASE_DIR / "outputs"
IMAGES_DIR = TRACES_PATH / "images"
PAUSE_FILE = WORKSPACE_ROOT / ".artemis_paused"
# Ensure all essential runtime directories exist automatically
for _d in (TRACES_PATH, REPLAY_BASE_DIR, TEST_DATA_DIR, TEST_OUTPUTS_DIR, IMAGES_DIR):
try:
_d.mkdir(parents=True, exist_ok=True)
except Exception:
pass
__all__ = [
"DB_PATH",
"IMAGES_DIR",
"PAUSE_FILE",
"REPLAY_BASE_DIR",
"TEST_DATA_DIR",
"TEST_OUTPUTS_DIR",
"TRACES_PATH",
"WORKSPACE_ROOT",
"init_ls_address",
]
+1 -4
View File
@@ -16,10 +16,7 @@ import asyncio
from collections.abc import Callable
from typing import Any
try:
from admin_console.core.config import PAUSE_FILE
except ImportError:
from apps.admin_console.core.config import PAUSE_FILE
from artemis.config import PAUSE_FILE
class ServerState:
+1 -4
View File
@@ -16,10 +16,7 @@ from contextlib import contextmanager
from pathlib import Path
import sqlite3
try:
from admin_console.core.config import DB_PATH
except ImportError:
from apps.admin_console.core.config import DB_PATH
from artemis.config import DB_PATH
_initialized_dbs = set()
@@ -285,6 +285,17 @@ class SessionRepository:
("failed", now, row["session_id"], "running"),
)
count += cursor.rowcount
try:
from mcp_server.utils import trace_store
if trace_store.read_status(str(row["session_id"])):
trace_store.update_trace_status(
str(row["session_id"]),
"failed",
error="Process terminated prior to server startup.",
)
except Exception:
pass
conn.commit()
return count
except Exception:
+2 -2
View File
@@ -16,12 +16,12 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse, HTMLResponse
from artemis.config import IMAGES_DIR, WORKSPACE_ROOT
try:
from admin_console.core.config import IMAGES_DIR, WORKSPACE_ROOT
from admin_console.database.repositories.session_repository import session_repo
from admin_console.services.media_service import media_service
except ImportError:
from apps.admin_console.core.config import IMAGES_DIR, WORKSPACE_ROOT
from apps.admin_console.database.repositories.session_repository import session_repo
from apps.admin_console.services.media_service import media_service
+2 -2
View File
@@ -15,12 +15,12 @@
import traceback
from fastapi import APIRouter, HTTPException
from artemis.config import WORKSPACE_ROOT
try:
from admin_console.core.config import WORKSPACE_ROOT
from admin_console.schemas.task_schema import ReplayRequest
from admin_console.replay_manager import ReplayManager
except ImportError:
from apps.admin_console.core.config import WORKSPACE_ROOT
from apps.admin_console.schemas.task_schema import ReplayRequest
from apps.admin_console.replay_manager import ReplayManager
+23 -2
View File
@@ -17,15 +17,15 @@ import time
from uuid import UUID
from fastapi import APIRouter, HTTPException
from artemis.config import DB_PATH, TRACES_PATH
try:
from admin_console.core.config import DB_PATH, TRACES_PATH
from admin_console.core.state import state
from admin_console.database.repositories.session_repository import session_repo
from admin_console.database.repositories.trace_repository import trace_repo
from admin_console.services.media_service import media_service
from admin_console.services.model_service import model_service
except ImportError:
from apps.admin_console.core.config import DB_PATH, TRACES_PATH
from apps.admin_console.core.state import state
from apps.admin_console.database.repositories.session_repository import session_repo
from apps.admin_console.database.repositories.trace_repository import trace_repo
@@ -137,6 +137,18 @@ async def list_sessions():
try:
harvested = session_repo.harvest_orphaned_sessions(orphaned_ids)
print(f"[list_sessions] Auto-harvested {harvested} orphaned running session(s).")
try:
from mcp_server.utils import trace_store
for o_id in orphaned_ids:
if trace_store.read_status(str(o_id)):
trace_store.update_trace_status(
str(o_id),
"failed",
error="Process terminated unexpectedly (auto-harvested).",
)
except Exception:
pass
except Exception as e:
print(f"[list_sessions] Failed to update orphaned sessions: {e}")
@@ -145,6 +157,15 @@ async def list_sessions():
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/sessions/{session_id}")
async def get_session_details(session_id: str):
"""Retrieve details for a single automation session."""
row = session_repo.get_session_by_id(session_id)
if not row:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
return dict(row)
@router.get("/api/sessions/{session_id}/tree")
async def get_tree(session_id: str):
try:
+2 -2
View File
@@ -17,13 +17,13 @@ import traceback
from fastapi import APIRouter, HTTPException
from fastapi.responses import PlainTextResponse
from artemis.config import TEST_OUTPUTS_DIR
try:
from admin_console.core.config import TEST_OUTPUTS_DIR
from admin_console.database.repositories.step_repository import step_repo
from admin_console.database.repositories.trace_repository import trace_repo
from admin_console.services.media_service import media_service
except ImportError:
from apps.admin_console.core.config import TEST_OUTPUTS_DIR
from apps.admin_console.database.repositories.step_repository import step_repo
from apps.admin_console.database.repositories.trace_repository import trace_repo
from apps.admin_console.services.media_service import media_service
+1
View File
@@ -298,6 +298,7 @@ async def get_status():
"background_tasks": bg_tasks,
"queue": queue_data,
"active_tasks": active_tasks,
"model_info": model_info,
"ipc_port": state.ipc_port,
}
+11 -13
View File
@@ -55,17 +55,18 @@ from artemis.runtime import (
write_server_info,
)
from artemis.config import (
DB_PATH,
IMAGES_DIR,
REPLAY_BASE_DIR,
TEST_DATA_DIR,
TEST_OUTPUTS_DIR,
TRACES_PATH,
WORKSPACE_ROOT,
init_ls_address,
)
try:
from admin_console.core.config import (
DB_PATH,
IMAGES_DIR,
REPLAY_BASE_DIR,
TEST_DATA_DIR,
TEST_OUTPUTS_DIR,
TRACES_PATH,
WORKSPACE_ROOT,
init_ls_address,
)
from admin_console.core.state import ServerState, state
from admin_console.database.connection import db_session, get_db
from admin_console.database.repositories.session_repository import session_repo
@@ -78,9 +79,6 @@ try:
from admin_console.services.model_service import model_service
from admin_console.services.task_queue_service import task_queue_service
except ImportError:
from apps.admin_console.core.config import (
init_ls_address,
)
from apps.admin_console.core.state import state
from apps.admin_console.database.repositories.session_repository import session_repo
from apps.admin_console.routers import media, replay, sessions, steps, stream, system, tasks
+1 -4
View File
@@ -22,10 +22,7 @@ from typing import Any
import urllib.parse
from fastapi import HTTPException
try:
from admin_console.core.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
except ImportError:
from apps.admin_console.core.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
class MediaService:
+101 -27
View File
@@ -24,27 +24,21 @@ from typing import Any
import uuid
try:
from admin_console.core.config import (
PAUSE_FILE,
TEST_DATA_DIR,
TEST_OUTPUTS_DIR,
WORKSPACE_ROOT,
)
from admin_console.core.state import state
from admin_console.database.repositories.session_repository import session_repo
from admin_console.services.media_service import media_service
from artemis.runtime import DeviceExecutionLock, process_supervisor
except ImportError:
from apps.admin_console.core.config import (
PAUSE_FILE,
TEST_DATA_DIR,
TEST_OUTPUTS_DIR,
WORKSPACE_ROOT,
)
from apps.admin_console.core.state import state
from apps.admin_console.database.repositories.session_repository import session_repo
from apps.admin_console.services.media_service import media_service
from artemis.runtime import DeviceExecutionLock, process_supervisor
from artemis.config import (
PAUSE_FILE,
TEST_DATA_DIR,
TEST_OUTPUTS_DIR,
WORKSPACE_ROOT,
)
from artemis.runtime import DeviceExecutionLock, process_supervisor
class TaskQueueService:
@@ -314,6 +308,7 @@ class TaskQueueService:
if sess_id:
env["ARTEMIS_SESSION_ID"] = str(sess_id)
env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend"))
env["ARTEMIS_TASK_WORKER"] = "1"
queue_ticket = task_item.get("queue_ticket")
if queue_ticket:
env[DeviceExecutionLock.QUEUE_TICKET_ENV] = str(queue_ticket)
@@ -354,6 +349,16 @@ class TaskQueueService:
)
state.current_process = proc
task_item["pid"] = proc.pid
if sess_id:
try:
from mcp_server.utils import trace_store
st = trace_store.read_status(str(sess_id))
if st:
st["pid"] = proc.pid
trace_store.write_status(str(sess_id), st)
except Exception:
pass
cls._broadcast_startup_progress(
sess_id, "process_ready", "Execution process started"
)
@@ -398,6 +403,17 @@ class TaskQueueService:
f"[QueueWorker] Preserved authoritative session {sess_id} "
f"status '{new_status}'"
)
try:
from mcp_server.utils import trace_store
if trace_store.read_status(str(sess_id)):
canonical_mcp_status = "completed" if new_status in ("completed", "success") else new_status
trace_store.update_trace_status(
str(sess_id),
canonical_mcp_status,
)
except Exception:
pass
rec_info = session_repo.get_video_recording_for_session(sess_id)
rec_status = (rec_info or {}).get("status")
recovered_video_path = None
@@ -428,6 +444,7 @@ class TaskQueueService:
"recording_failed",
{"session_id": sess_id, "error": recording_error},
)
state.active_connections.pop(sess_id, None)
cls._broadcast_event(
"session_ended",
@@ -439,16 +456,21 @@ class TaskQueueService:
)
conversation_id = task_item.get("conversation_id") if task_item else None
if conversation_id:
if conversation_id or task_item.get("ingress") == "mcp":
try:
from mcp_server.notifiers import notify
notify(
conversation_id=conversation_id,
conversation_id=conversation_id or "",
message=f"Artemis autonomous task '{goal}' finished with status '{new_status}'.\nTrace ID: {sess_id}",
title=f"Task {new_status.capitalize()}: {goal[:40]}",
event_type=new_status,
payload={"session_id": sess_id, "status": new_status, "goal": goal},
payload={
"trace_id": sess_id,
"session_id": sess_id,
"status": new_status,
"goal": goal,
},
)
except Exception as notif_err:
print(f"[QueueWorker] Notification dispatch notice: {notif_err}")
@@ -492,6 +514,56 @@ class TaskQueueService:
enqueued_tasks = []
now = time.time()
# 1. Deduplication by session_id: if session_id is already running or queued, do not re-enqueue
if session_id and len(goals) == 1:
target_sid = str(session_id)
if state.active_session_id and str(state.active_session_id) == target_sid:
return {
"status": "started",
"tasks": [{"session_id": target_sid, "status": "running"}],
"enqueued_count": 0,
"total_queued": len(state.queue_tasks),
}
existing_item = next(
(
item
for item in state.queue_items
if isinstance(item, dict) and str(item.get("session_id")) == target_sid
),
None,
)
if existing_item:
return {
"status": existing_item.get("status", "queued"),
"tasks": [existing_item],
"enqueued_count": 0,
"total_queued": len(state.queue_tasks),
}
# 2. Debounce duplicate rapid submissions (e.g. UI double-click or network retry within 1s)
if len(goals) == 1:
first_goal = goals[0]
recent_duplicate = next(
(
item
for item in reversed(state.queue_items)
if isinstance(item, dict)
and item.get("status") == "pending"
and item.get("goal") == first_goal
and (not device_serial or item.get("device_serial") == device_serial)
and (now - float(item.get("created_at", 0))) < 1.0
),
None,
)
if recent_duplicate:
return {
"status": "queued",
"tasks": [recent_duplicate],
"enqueued_count": 0,
"total_queued": len(state.queue_tasks),
}
for i, goal in enumerate(goals):
sess_id = (session_id if (session_id and len(goals) == 1) else str(uuid.uuid4()))
assigned_serial = device_serial
@@ -597,17 +669,18 @@ class TaskQueueService:
sid = dev_owner.session_id
if sid:
session_repo.update_session_status(str(sid), "cancelled", time.time())
if dev_owner.ingress == "mcp":
try:
from mcp_server.utils import trace_store
try:
from mcp_server.utils import trace_store
is_mcp = bool(dev_owner and dev_owner.ingress == "mcp")
if is_mcp or trace_store.read_status(str(sid)):
trace_store.update_trace_status(
str(sid),
"cancelled",
error="Task stopped from the Artemis frontend.",
)
except Exception:
pass
except Exception:
pass
cls._broadcast_event(
"session_ended",
{
@@ -791,17 +864,18 @@ class TaskQueueService:
session_repo.update_session_status(
str(stopped_session_id), "cancelled", time.time()
)
if owner and owner.ingress == "mcp":
try:
from mcp_server.utils import trace_store
try:
from mcp_server.utils import trace_store
is_mcp = bool(owner and owner.ingress == "mcp")
if is_mcp or trace_store.read_status(str(stopped_session_id)):
trace_store.update_trace_status(
str(stopped_session_id),
"cancelled",
error="Task stopped from the Artemis frontend.",
)
except Exception as exc:
print(f"Failed to update MCP cancellation status: {exc}")
except Exception as exc:
print(f"Failed to update MCP cancellation status: {exc}")
cls._broadcast_event(
"session_ended",
{
@@ -211,4 +211,67 @@ describe('extractStepReplayFrames', () => {
expect(frames[0].imageUrl).toBe('/images/img_start');
expect(frames[0].stepNumber).toBe(1);
});
it('should deduplicate steps with identical step_number and prefer real physical actions', () => {
const logs = [
{
step_id: 'step-click',
step_number: 1,
pre_image_name: 'img_screen_1',
post_image_name: 'img_screen_2',
action_taken: { action: 'click', coordinates: [106, 101] },
summary: 'In Step 1, I tapped the back arrow',
timestamp: 100
},
{
step_id: 'step-failed-report',
step_number: 1,
pre_image_name: 'img_screen_2',
post_image_name: null,
action_taken: { action: 'report_task_status', args: { status: 'failed' } },
timestamp: 200
},
{
step_id: 'step-completed-report',
step_number: 2,
pre_image_name: 'img_screen_2',
post_image_name: null,
action_taken: { action: 'report_task_status', args: { status: 'completed' } },
timestamp: 150
}
];
const frames = extractStepReplayFrames(logs);
// Duplicate step_number: 1 merges into Step 1, while Step 2 report is faithfully displayed
expect(frames.length).toBe(2);
expect(frames[0].stepNumber).toBe(1);
expect(frames[0].action?.action).toBe('click');
expect(frames[0].actionText).toContain('Tapping Element');
expect(frames[1].stepNumber).toBe(2);
expect(frames[1].title).toContain('Report Task Status');
});
it('should enforce strictly sequential stepNumber without duplicates', () => {
const logs = [
{
step_id: 's-1',
step_number: 1,
pre_image_name: 'img_1',
action_taken: { action: 'click', coordinates: [100, 100] },
timestamp: 100
},
{
step_id: 's-2',
step_number: 2,
pre_image_name: 'img_2',
action_taken: { action: 'swipe', direction: 'up' },
timestamp: 200
}
];
const frames = extractStepReplayFrames(logs);
expect(frames.length).toBe(2);
expect(frames[0].stepNumber).toBe(1);
expect(frames[1].stepNumber).toBe(2);
});
});
@@ -33,9 +33,15 @@ export function getActionObject(action: any): any {
* Check if the action is a user interaction action on Android
*/
export function isAndroidAction(action: any): boolean {
const act = getActionObject(action);
if (!act) return false;
const name = (act.name || act.action || '').toLowerCase();
if (!action) return false;
let name = '';
if (typeof action === 'string') {
name = action.toLowerCase();
} else {
const act = getActionObject(action);
if (!act) return false;
name = (act.name || act.action || '').toLowerCase();
}
return [
'tap', 'click', 'click_sequence', 'input', 'input_text', 'swipe', 'scroll', 'press_key',
'press_home', 'press_back', 'launch_app', 'manage_app', 'open_app',
@@ -48,9 +54,16 @@ export function isAndroidAction(action: any): boolean {
* Check if the action or tool is a task completion / submission report
*/
export function isReportStatusAction(action: any): boolean {
const act = getActionObject(action);
if (!act) return false;
const name = (act.name || act.action || '').toLowerCase().replace(/^(_)?(self\.)?exec_/, '');
if (!action) return false;
let name = '';
if (typeof action === 'string') {
name = action.toLowerCase();
} else {
const act = getActionObject(action);
if (!act) return false;
name = (act.name || act.action || '').toLowerCase();
}
name = name.replace(/^(_)?(self\.)?exec_/, '');
return name === 'report_task_status' || name === 'report_status' || name === 'submit_task_status';
}
@@ -785,21 +798,31 @@ export function extractStepReplayFrames(logsOrSteps: any[]): StepReplayFrame[] {
}
}
// Deduplicate and merge by step_id or step_number
// Deduplicate and merge by step_number (fallback to step_id if step_number missing)
const stepMap = new Map<any, any>();
for (const step of rawSteps) {
const key = step.step_id || step.step_number;
const key = (step.step_number !== undefined && step.step_number !== null)
? `step_${step.step_number}`
: (step.step_id || Math.random());
if (key !== undefined) {
const existing = stepMap.get(key);
if (existing) {
// When merging duplicate records with the same step_number:
// Prefer real actions over report_task_status
const existingAct = existing.action_taken;
const newAct = step.action_taken;
const preferExistingAction = existingAct && isAndroidAction(existingAct) && isReportStatusAction(newAct);
const preferNewAction = newAct && isAndroidAction(newAct) && isReportStatusAction(existingAct);
stepMap.set(key, {
...existing,
...step,
step_number: step.step_number !== undefined ? step.step_number : existing.step_number,
pre_image_name: step.pre_image_name || existing.pre_image_name,
post_image_name: step.post_image_name || existing.post_image_name,
action_taken: step.action_taken || existing.action_taken,
pre_image_name: (preferExistingAction ? existing.pre_image_name : (step.pre_image_name || existing.pre_image_name)),
post_image_name: (preferExistingAction ? (existing.post_image_name || step.post_image_name) : (step.post_image_name || existing.post_image_name)),
action_taken: preferExistingAction ? existing.action_taken : (preferNewAction ? newAct : (step.action_taken || existing.action_taken)),
last_execution_result: step.last_execution_result || existing.last_execution_result,
summary: (preferExistingAction ? (existing.summary || step.summary) : (step.summary || existing.summary)),
generic_tools: step.generic_tools || existing.generic_tools
});
} else {
@@ -820,7 +843,8 @@ export function extractStepReplayFrames(logsOrSteps: any[]): StepReplayFrame[] {
for (let i = 0; i < steps.length; i++) {
const stepData = steps[i];
const stepNum = Number(stepData.step_number ?? (i + 1));
// Guarantee 1-indexed sequential step numbering so frames never duplicate numbers
const stepNum = i + 1;
const act = stepData.action_taken;
const title = act ? (getActionTitle(act) || 'Action') : `Step ${stepNum}`;
const coords = act ? getActionCoords(act) : '';