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
@@ -44,7 +44,7 @@ class FileNotifier(BaseNotifier):
|
||||
event_type: str = "completed",
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
trace_id = (payload or {}).get("trace_id")
|
||||
trace_id = (payload or {}).get("trace_id") or (payload or {}).get("session_id")
|
||||
if not trace_id:
|
||||
return False
|
||||
|
||||
|
||||
@@ -94,6 +94,38 @@ def mobile_manage_task(
|
||||
pid = status_data.get("pid")
|
||||
is_alive = False
|
||||
|
||||
project_root = env_utils.get_project_root()
|
||||
db_path = os.path.join(trace_store.TRACES_DIR, "data_engine.db")
|
||||
if not os.path.exists(db_path):
|
||||
db_path = os.path.join(project_root, "traces", "data_engine.db")
|
||||
|
||||
# If status is still running or pending, sync from database if available
|
||||
if current_status in ("running", "pending") and os.path.exists(db_path):
|
||||
try:
|
||||
conn_st = sqlite3.connect(db_path)
|
||||
conn_st.row_factory = sqlite3.Row
|
||||
cur_st = conn_st.cursor()
|
||||
cur_st.execute(
|
||||
"SELECT status, pid FROM sessions WHERE session_id = ? ORDER BY start_time DESC LIMIT 1",
|
||||
(trace_id,),
|
||||
)
|
||||
row_st = cur_st.fetchone()
|
||||
if row_st:
|
||||
db_status = row_st["status"]
|
||||
db_pid = row_st["pid"]
|
||||
if db_pid and not pid:
|
||||
pid = db_pid
|
||||
status_data["pid"] = pid
|
||||
trace_store.write_status(trace_id, status_data)
|
||||
if db_status in ("completed", "success", "failed", "cancelled"):
|
||||
canonical_status = "completed" if db_status in ("completed", "success") else db_status
|
||||
current_status = canonical_status
|
||||
status_data["status"] = canonical_status
|
||||
trace_store.write_status(trace_id, status_data)
|
||||
conn_st.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if pid and current_status in ("running", "pending"):
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
@@ -300,50 +332,78 @@ def mobile_manage_task(
|
||||
"message": f"Cannot stop task because it is already in a terminal state: {current_status}",
|
||||
}
|
||||
|
||||
# 1. First attempt graceful cancellation via unified Daemon if available
|
||||
stopped_via_daemon = False
|
||||
if os.environ.get("ARTEMIS_STANDALONE") != "1":
|
||||
try:
|
||||
from artemis.runtime import is_daemon_running, stop_task_on_daemon
|
||||
|
||||
if is_daemon_running():
|
||||
stopped_via_daemon = stop_task_on_daemon(trace_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Cancel queue reservation if present
|
||||
queue_ticket = status_data.get("queue_ticket")
|
||||
if queue_ticket:
|
||||
try:
|
||||
DeviceExecutionLock.cancel_reservation(queue_ticket)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. If process PID is missing, try to resolve from active device owners
|
||||
if not pid:
|
||||
try:
|
||||
active_owners = DeviceExecutionLock.get_active_owners()
|
||||
for dev_owner in active_owners.values():
|
||||
if dev_owner.session_id and str(dev_owner.session_id) == trace_id:
|
||||
pid = dev_owner.pid
|
||||
status_data["pid"] = pid
|
||||
trace_store.write_status(trace_id, status_data)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4. If process PID could not be determined and not stopped via daemon or queue
|
||||
if not pid and not stopped_via_daemon and not queue_ticket:
|
||||
return {
|
||||
"trace_id": trace_id,
|
||||
"status": current_status,
|
||||
"message": "Process ID (PID) is missing from the task status. Cannot stop task.",
|
||||
}
|
||||
|
||||
try:
|
||||
DeviceExecutionLock.cancel_reservation(status_data.get("queue_ticket"))
|
||||
if sys.platform == "win32":
|
||||
if not process_supervisor.terminate_tree(pid):
|
||||
import psutil
|
||||
# 5. If process PID is available, terminate the process tree
|
||||
if pid:
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
if not process_supervisor.terminate_tree(pid):
|
||||
import psutil
|
||||
|
||||
if psutil.pid_exists(pid):
|
||||
raise RuntimeError(
|
||||
f"Failed to terminate Windows process tree rooted at {pid}"
|
||||
)
|
||||
DeviceExecutionLock.cleanup_stale_locks()
|
||||
else:
|
||||
# Preserve the existing POSIX signal behavior.
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
trace_store.update_trace_status(
|
||||
trace_id, "cancelled", error="Task aborted by user request."
|
||||
)
|
||||
return {
|
||||
"trace_id": trace_id,
|
||||
"status": "cancelled",
|
||||
"message": f"Successfully sent termination signal to background process {pid}.",
|
||||
}
|
||||
except ProcessLookupError:
|
||||
trace_store.update_trace_status(
|
||||
trace_id, "cancelled", error="Task aborted (process was already stopped)."
|
||||
)
|
||||
return {
|
||||
"trace_id": trace_id,
|
||||
"status": "cancelled",
|
||||
"message": f"Process {pid} was not found; it may have already exited. Status marked as cancelled.",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"trace_id": trace_id,
|
||||
"status": current_status,
|
||||
"message": f"Failed to terminate process {pid}: {e}",
|
||||
}
|
||||
if psutil.pid_exists(pid):
|
||||
raise RuntimeError(
|
||||
f"Failed to terminate Windows process tree rooted at {pid}"
|
||||
)
|
||||
DeviceExecutionLock.cleanup_stale_locks()
|
||||
else:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except Exception as e:
|
||||
if not stopped_via_daemon:
|
||||
return {
|
||||
"trace_id": trace_id,
|
||||
"status": current_status,
|
||||
"message": f"Failed to terminate process {pid}: {e}",
|
||||
}
|
||||
|
||||
trace_store.update_trace_status(
|
||||
trace_id, "cancelled", error="Task aborted by user request."
|
||||
)
|
||||
return {
|
||||
"trace_id": trace_id,
|
||||
"status": "cancelled",
|
||||
"message": f"Successfully stopped background task '{trace_id}'.",
|
||||
}
|
||||
|
||||
elif action == "inject_instruction":
|
||||
if not instruction:
|
||||
|
||||
@@ -202,11 +202,53 @@ def mobile_run_task(
|
||||
"stdout_log": stdout_log,
|
||||
"stderr_log": stderr_log,
|
||||
}
|
||||
|
||||
# Check if the task was already enqueued despite a slow or None response:
|
||||
try:
|
||||
queued_tasks = DeviceExecutionLock.get_queued_tasks()
|
||||
for q in queued_tasks:
|
||||
if str(q.get("session_id")) == str(trace_id):
|
||||
trace_store.update_trace_status(
|
||||
trace_id, "running", device_serial=device_serial
|
||||
)
|
||||
return {
|
||||
"trace_id": trace_id,
|
||||
"status": "running",
|
||||
"message": (
|
||||
f"Autonomous task '{task_desc}' enqueued via unified Artemis Daemon.\n"
|
||||
f"Trace ID: {trace_id}\n"
|
||||
f"Model: {canonical_model}\n"
|
||||
f"Live telemetry: streaming to web workspace."
|
||||
),
|
||||
"notes_dir": str(trace_store.get_trace_notes_dir(trace_id)),
|
||||
"stdout_log": str(trace_store.get_trace_stdout_log_path(trace_id)),
|
||||
"stderr_log": str(trace_store.get_trace_stderr_log_path(trace_id)),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# When Daemon is running, refuse to launch a conflicting standalone runner on the same device
|
||||
import logging
|
||||
logging.getLogger("mcp_server").warning(
|
||||
f"Daemon dispatch failed for task '{task_desc}'. Aborting dispatch to prevent runner collision."
|
||||
)
|
||||
return {
|
||||
"trace_id": trace_id,
|
||||
"status": "failed",
|
||||
"error": f"Failed to enqueue task '{task_desc}' to running Artemis Daemon scheduler.",
|
||||
"message": "Task rejected or timed out in Artemis Daemon. Standalone fallback blocked to prevent runner collision.",
|
||||
}
|
||||
except Exception as exc:
|
||||
import logging
|
||||
logging.getLogger("mcp_server").warning(
|
||||
f"Daemon dispatch failed, falling back to standalone runner: {exc}"
|
||||
f"Daemon dispatch failed: {exc}"
|
||||
)
|
||||
return {
|
||||
"trace_id": trace_id,
|
||||
"status": "failed",
|
||||
"error": f"Daemon dispatch error: {exc}",
|
||||
"message": "Failed to dispatch task to Artemis Daemon.",
|
||||
}
|
||||
|
||||
# 4. Standalone Fallback: Resolve project root, python executable, and background runner module
|
||||
project_root = env_utils.get_project_root()
|
||||
|
||||
@@ -38,6 +38,21 @@ def get_status_path(trace_id: str) -> str:
|
||||
return os.path.join(get_trace_dir(trace_id), "status.json")
|
||||
|
||||
|
||||
def get_trace_notes_dir(trace_id: str) -> str:
|
||||
"""Returns the absolute path to the notes directory for a given trace_id."""
|
||||
return os.path.join(get_trace_dir(trace_id), "notes")
|
||||
|
||||
|
||||
def get_trace_stdout_log_path(trace_id: str) -> str:
|
||||
"""Returns the absolute path to the stdout.log file for a given trace_id."""
|
||||
return os.path.join(get_trace_dir(trace_id), "stdout.log")
|
||||
|
||||
|
||||
def get_trace_stderr_log_path(trace_id: str) -> str:
|
||||
"""Returns the absolute path to the stderr.log file for a given trace_id."""
|
||||
return os.path.join(get_trace_dir(trace_id), "stderr.log")
|
||||
|
||||
|
||||
def init_trace(
|
||||
trace_id: str,
|
||||
task_desc: str,
|
||||
|
||||
Reference in new issue
Block a user