Files
Artemis-Eapil/mcp_server/tools/task_manager.py
T
chenmingxuan fac4ccb760 feat: prepare 2.2.0 with UI and execution fixes
Add dark theme, richer Markdown and readable plugin results.
Improve Android input evidence, response integrity and diagnostics.
Unify lifecycle, guidance receipts and evidence sources across clients.
Keep direct action dispatch without an added AI quota approval layer.
Align version metadata at 2.2.0 and bundle Helper 1.2.2.

Validation: 3063 backend tests before the version bump; 44 after.
Frontend: 317 tests and production build passed.
2026-10-26 00:44:13 +08:00

802 lines
35 KiB
Python

# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""MCP Tool: mobile_manage_task."""
import json
import logging
import os
import signal
import sqlite3
import sys
import time
from typing import Any
from mcp_server.base import mcp
from mcp_server.notifiers import notify
from mcp_server.utils import env_utils
from artemis.runtime import (
DeviceExecutionLock,
is_daemon_running,
process_supervisor,
stop_task_on_daemon,
trace_store,
)
from artemis.runtime.process_probe import pid_is_alive
from artemis.runtime.task_progress import progress_snapshot
from artemis.runtime.task_control import (
add_instruction,
control_dir,
list_instructions,
read_state,
request_pause,
request_resume,
)
logger = logging.getLogger(__name__)
_LIVENESS_FAILURE_ERROR = "Task runner process terminated unexpectedly."
# Grace window covering the spawn race: the launcher pid may already have exited
# while the real runner has not yet registered its own pid in the DataEngine DB.
_STARTUP_GRACE_SECONDS = 45.0
def _find_data_engine_db() -> str | None:
db_path = os.path.join(trace_store.TRACES_DIR, "data_engine.db")
if os.path.exists(db_path):
return db_path
db_path = os.path.join(env_utils.get_project_root(), "traces", "data_engine.db")
return db_path if os.path.exists(db_path) else None
def _web_session_status(trace_id: str) -> dict[str, Any] | None:
"""Web submissions have a session row but no MCP launcher status.json."""
db_path = _find_data_engine_db()
if not db_path:
return None
try:
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT status, pid, start_time, end_time, device_info "
"FROM sessions WHERE session_id = ? ORDER BY start_time DESC LIMIT 1",
(trace_id,),
).fetchone()
except sqlite3.Error as exc:
logger.warning("Could not read web session %s: %s", trace_id, exc)
return None
if not row:
return None
data = dict(row)
if data.get("device_info"):
try:
device = json.loads(data["device_info"])
data["device_serial"] = device.get("device_id") if isinstance(device, dict) else None
except (TypeError, ValueError):
pass
return data
def _pid_alive(pid: int | None) -> bool:
if not pid:
return False
try:
pid_int = int(pid)
except (TypeError, ValueError):
return False
return pid_is_alive(pid_int)
def _session_tracked_by_lock(trace_id: str) -> bool | None:
"""Whether the device-lock layer still tracks this session (queued or active owner).
Returns ``True`` when tracked, ``False`` when both probes succeeded and the
session is definitively absent, and ``None`` when a probe failed — an
unreadable queue or lock dir may hide a live session, so the caller must
not treat a failed probe as evidence of death (same "uncertain defaults to
alive" stance as ``pid_is_alive``).
"""
uncertain = False
try:
for q_item in DeviceExecutionLock.get_queued_tasks():
if str(q_item.get("session_id")) == trace_id:
return True
except Exception as exc:
uncertain = True
print(f"Could not read device queue for {trace_id}: {exc}", file=sys.stderr)
try:
for owner in DeviceExecutionLock.get_active_owners().values():
if owner.session_id and str(owner.session_id) == trace_id:
return True
except Exception as exc:
uncertain = True
print(f"Could not read device owners for {trace_id}: {exc}", file=sys.stderr)
return None if uncertain else False
def _reconcile_task_state(
trace_id: str, status_data: dict[str, Any]
) -> tuple[str, int | None, bool]:
"""Reconcile status.json with the DataEngine DB and real process liveness.
The DB session row is written by the runner process itself (``os.getpid()``),
so its pid and terminal status outrank the pid recorded at spawn time -- on
Windows that spawn pid is a short-lived venv launcher, not the runner. A dead
or missing pid alone never fails a task that the DB, the device-lock queue,
or an active lock owner still reports as alive.
Returns ``(current_status, pid, is_alive)`` and persists any correction back
to status.json (including recovering from a previously misreported failure).
"""
current_status = status_data.get("status", "unknown")
pid = status_data.get("pid")
dirty = False
# "success" is a legacy alias for the canonical "completed" terminal
# status; consumers of this reconcile must only ever see "completed".
if current_status == "success":
current_status = "completed"
status_data["status"] = "completed"
dirty = True
db_status: str | None = None
db_pid: int | None = None
db_path = _find_data_engine_db()
if db_path:
try:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT status, pid FROM sessions WHERE session_id = ? ORDER BY start_time DESC LIMIT 1",
(trace_id,),
).fetchone()
conn.close()
if row:
db_status = row["status"]
db_pid = row["pid"]
except sqlite3.Error as exc:
# Reconciliation then relies on status.json and lock evidence only.
print(f"Could not read DB session row for {trace_id}: {exc}", file=sys.stderr)
if db_pid and db_pid != pid:
pid = db_pid
status_data["pid"] = pid
dirty = True
# The DB terminal verdict only fills in a status.json that has not reached a
# terminal state itself, or corrects a liveness-inferred failure. It never
# overrides a completed result or an explicit user cancellation.
liveness_failed = (
current_status == "failed" and status_data.get("error") == _LIVENESS_FAILURE_ERROR
)
if db_status in ("completed", "success", "failed", "cancelled", "interrupted") and (
current_status in ("running", "pending", "task_paused", "resuming") or liveness_failed
):
canonical = "completed" if db_status in ("completed", "success") else db_status
if current_status != canonical:
current_status = canonical
status_data["status"] = canonical
if canonical != "failed" and status_data.get("error") == _LIVENESS_FAILURE_ERROR:
status_data["error"] = None
if not status_data.get("end_time"):
status_data["end_time"] = time.time()
dirty = True
is_alive = False
elif current_status in ("running", "pending", "task_paused", "resuming"):
if not pid:
# No pid recorded (daemon dispatch never writes one): liveness cannot be
# inferred, so the task is assumed alive -- matching the historical
# behavior of only checking liveness when a pid exists.
is_alive = True
else:
is_alive = _pid_alive(pid)
if not is_alive:
tracked = _session_tracked_by_lock(trace_id)
if tracked or tracked is None:
# Still queued for a device or owned by a live lock holder --
# or the lock probe failed, in which case the session may be
# hidden and must not be declared dead on this poll.
is_alive = True
elif db_status in ("running", "pending") and db_pid:
# The runner registered itself and its process is gone: truly dead.
current_status = "failed"
_mark_liveness_failure(trace_id, status_data)
elif (time.time() - (status_data.get("start_time") or 0)) < _STARTUP_GRACE_SECONDS:
is_alive = True
else:
current_status = "failed"
_mark_liveness_failure(trace_id, status_data)
else:
is_alive = False
if dirty:
trace_store.write_status(trace_id, status_data)
return current_status, pid, is_alive
def _mark_liveness_failure(trace_id: str, status_data: dict[str, Any]) -> None:
trace_store.update_trace_status(trace_id, "failed", error=_LIVENESS_FAILURE_ERROR)
status_data["status"] = "failed"
status_data["error"] = _LIVENESS_FAILURE_ERROR
conv_id = status_data.get("conversation_id")
if conv_id:
try:
notify(
conversation_id=conv_id,
message=(
f"Artemis background task died unexpectedly for trace '{trace_id}'. "
f"Error: {_LIVENESS_FAILURE_ERROR}"
),
event_type="failed",
payload={"trace_id": trace_id, "error": _LIVENESS_FAILURE_ERROR},
)
except Exception as exc:
# Notification dispatch is best-effort; the failure verdict is
# already persisted in status.json.
logger.debug(
"Liveness-failure notification skipped for trace %s: %s",
trace_id,
exc,
exc_info=True,
)
@mcp.tool()
def mobile_manage_task(
action: str,
trace_id: str = "",
instruction: str | None = None,
release_loop: bool = False,
request_id: str | None = None,
review_status: str | None = None,
review_reason: str = "",
user_confirmed: bool = False,
) -> dict[str, Any]:
"""Manages the lifecycle and retrieves the status of a background mobile automation task.
This is your primary diagnostic and control tool for tasks started by
`mobile_run_task` or the web workspace. Poll with reasonable intervals while
following a running task; use request diagnostics for unexplained waits.
You can also steer a runner that is stuck or off-track, or stop a task.
### Actions
- lookup: Resolve a submission by request_id without starting or resubmitting it.
- deletion_preview / delete: Show affected session IDs, then delete only when
the user requested it (user_confirmed=true). Active tasks cannot be deleted.
An exploration deletes its conversation runs, preserving independent reruns.
- review: Record ONLY the user's explicitly stated passed/failed decision with
review_reason and user_confirmed=true. Never label the agent's verdict as human.
- **'status'**: Returns `trace_id`, `status` ('running'/'pausing'/'task_paused'/
'resuming'/'completed'/'failed'/'cancelled'/'interrupted'), `device_serial` (which phone owns this task in multi-device
setups), `task_desc`, `model`, `elapsed_seconds`, a `test_summary` (when
the run declared verification check items: machine-readable
passed/failed/inconclusive/unchecked counts plus failed-item details —
no need to parse report prose), `test_result` (Flash and Pro: status
passed/failed/inconclusive/blocked, reason and evidence; completed only
means execution ended), and `progress` (Flash:
current turn, latest thought/action — intervene if turns climb without
progress or the thought indicates it is stuck; Pro: the active task plan
— check it still aligns with your goal).
- **'pause'**: Requests a pause, returning `pausing` until the runner releases
the device. Pro can interrupt a pending model response or read-only diagnostic;
a device action already in progress must finish safely first. Check status
for `task_paused` before assuming the device is free.
- **'resume'**: Queues a paused task for its device again; optional `instruction`
provides guidance before the next action.
- **'inject_instruction'**: Injects real-time guidance mid-flight when the
subagent errs, stalls, or loops. An in-flight model response or read-only
helper is cancelled immediately, then the runner observes the device again
and consumes the new guidance. An already-started device action finishes
safely before the runner changes direction. Requires
`instruction` (unless `release_loop=True`). To end a continuous
monitoring task ([Loop:continuous] milestone) gracefully, you MUST pass
`release_loop=True` — this is the only signal that unlocks the
milestone's completion; natural-language "please stop" phrasing in
`instruction` is NOT interpreted as a stop signal.
- **'guidance_history'**: Lists submitted instructions with `pending` or
`consumed` status; a submitted instruction is not proof it was applied.
- **'stop'**: Forcefully terminates the subagent, immediately halting
device interactions and releasing the device. Use when the task is done,
irreparably broken, or running out of control. Prefer
`inject_instruction` with `release_loop=True` when a monitoring task
should wind down cleanly instead of being killed.
Args:
action: `"status"`, `"pause"`, `"resume"`, `"inject_instruction"`,
`"guidance_history"`, or `"stop"`.
trace_id: The task's session identifier from `mobile_run_task` or the web UI.
instruction: Guidance string; required for `inject_instruction`
(optional when `release_loop=True`), omit otherwise.
release_loop: With `inject_instruction`: explicit user stop signal
that authorizes the subagent to complete continuous monitoring
loops and finish the task gracefully.
"""
from mcp_server.tools.automation import _id, _request
import asyncio
if action == "lookup":
from mcp_server.task_requests import lookup
return lookup(_id(request_id))
_id(trace_id)
if action in {"deletion_preview", "delete", "review"}:
if action != "deletion_preview" and not user_confirmed:
raise ValueError("此动作需要用户明确要求;AI 自行判断不能记作人工复验")
if action == "deletion_preview":
return asyncio.run(_request("GET", f"/api/sessions/{trace_id}/deletion-preview"))
if action == "delete":
return asyncio.run(_request("POST", f"/api/sessions/{trace_id}/delete"))
if review_status not in {"passed", "failed"}:
raise ValueError("review_status 必须为 passed 或 failed")
result = asyncio.run(
_request(
"PUT",
f"/api/automation/reports/{trace_id}/review",
json={"status": review_status, "reason": review_reason},
)
)
return {"trace_id": trace_id, "test_result": result.get("test_result")}
status_data = trace_store.read_status(trace_id) or _web_session_status(trace_id)
if not status_data:
return {
"trace_id": trace_id,
"status": "unknown",
"message": f"Trace ID '{trace_id}' not found.",
}
current_status, pid, is_alive = _reconcile_task_state(trace_id, status_data)
control_status = read_state(trace_id).get("status") if is_alive else None
if control_status in ("task_paused", "resuming") and current_status in ("running", "pending"):
current_status = control_status
elif (
is_alive
and current_status in ("running", "pending")
and (control_dir(trace_id) / "pause.request").exists()
):
current_status = "pausing"
trace_dir = trace_store.get_trace_dir(trace_id)
if action == "status":
start_time = status_data.get("start_time")
end_time = status_data.get("end_time") or time.time()
elapsed = round(end_time - start_time, 1) if start_time else 0
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")
device_serial = status_data.get("device_serial")
if not device_serial and os.path.exists(db_path):
try:
conn_dev = sqlite3.connect(db_path)
conn_dev.row_factory = sqlite3.Row
cur_dev = conn_dev.cursor()
cur_dev.execute(
"SELECT device_info FROM sessions WHERE session_id = ? OR pid = ? ORDER BY start_time DESC LIMIT 1",
(trace_id, pid),
)
row_dev = cur_dev.fetchone()
if row_dev and row_dev["device_info"]:
try:
d_info = json.loads(row_dev["device_info"])
if isinstance(d_info, dict) and d_info.get("device_id"):
device_serial = d_info["device_id"]
status_data["device_serial"] = device_serial
trace_store.write_status(trace_id, status_data)
except (ValueError, TypeError, OSError):
# Malformed device_info or failed cache write: the
# serial simply stays unknown for this poll.
pass
conn_dev.close()
except sqlite3.Error:
# DB probe for the serial is optional enrichment.
pass
if not device_serial and pid:
try:
owners = DeviceExecutionLock.get_active_owners()
for clean_id, owner in owners.items():
if owner.pid == pid or owner.session_id == trace_id:
device_serial = owner.device_id
status_data["device_serial"] = device_serial
trace_store.write_status(trace_id, status_data)
break
except OSError:
# Lock-owner probe / cache write failed: serial stays unknown.
pass
response: dict[str, Any] = {
"trace_id": trace_id,
"status": current_status,
"device_serial": device_serial,
"elapsed_seconds": elapsed,
"task_desc": status_data.get("task_desc"),
"model": status_data.get("model"),
"stdout_log": os.path.join(trace_dir, "stdout.log"),
"stderr_log": os.path.join(trace_dir, "stderr.log"),
}
lifecycle_db = _find_data_engine_db()
if lifecycle_db:
from apps.admin_console.services.task_lifecycle import task_lifecycle
try:
response["lifecycle"] = task_lifecycle(trace_id, db_path=lifecycle_db)
except (sqlite3.Error, OSError, ValueError) as exc:
logger.warning("Could not read lifecycle for %s: %s", trace_id, exc)
if "lifecycle" not in response:
response["lifecycle"] = progress_snapshot(
session={**status_data, "status": current_status}, control=read_state(trace_id)
)
# Which UI-hierarchy source served the run ("helper" or "uiautomator"),
# plus any mid-run switch; written by the agent once the device connects.
if status_data.get("hierarchy_backend"):
response["hierarchy_backend"] = status_data.get("hierarchy_backend")
if status_data.get("hierarchy_backend_note"):
response["hierarchy_backend_note"] = status_data.get("hierarchy_backend_note")
if status_data.get("model", "").lower() != "flash":
response["notes_dir"] = os.path.join(trace_dir, "notes")
if current_status == "failed":
response["error"] = status_data.get("error")
elif current_status == "completed":
response["result"] = status_data.get("result")
# Machine-readable test summary (written by exit settlement): callers
# get assertion results without parsing report prose.
for candidate in (
os.path.join(trace_store.TRACES_DIR, trace_id, "run_outcome.json"),
os.path.join(trace_dir, "run_outcome.json"),
):
if os.path.exists(candidate):
try:
with open(candidate, encoding="utf-8") as f:
run_outcome = json.load(f)
tests = run_outcome.get("tests") or {}
response["test_summary"] = {
"task_status": run_outcome.get("task_status"),
**tests,
}
from artemis.runtime.case_result import resolve_case_result
test_result = resolve_case_result(
run_outcome, current_status, response.get("error") or ""
)
response["test_result"] = test_result.model_dump() if test_result else None
except (OSError, ValueError, TypeError):
# Unreadable or malformed run_outcome.json: omit the summary.
pass
break
if "test_result" not in response:
from artemis.runtime.case_result import resolve_case_result
test_result = resolve_case_result(None, current_status, response.get("error") or "")
response["test_result"] = test_result.model_dump() if test_result else None
from apps.admin_console.services.case_review import reviewed_result
from apps.admin_console.services.automation_store import AutomationStore
from pathlib import Path
review_store = AutomationStore(Path(db_path))
response["test_result"] = reviewed_result(
trace_id, response.get("test_result"), review_store
)
if current_status in ("running", "pending", "task_paused", "resuming") and is_alive:
progress: dict[str, Any] = {}
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")
session_id = None
if os.path.exists(db_path):
try:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute(
"SELECT session_id FROM sessions WHERE pid = ? ORDER BY start_time DESC LIMIT 1",
(pid,),
)
session_row = cursor.fetchone()
if session_row:
session_id = session_row["session_id"]
conn.close()
except Exception as db_err:
print(f"Error querying data_engine.db for session: {db_err}", file=sys.stderr)
if session_id:
if status_data.get("model", "").lower() == "flash":
progress["phase"] = "running_flash_loop"
if os.path.exists(db_path):
try:
conn2 = sqlite3.connect(db_path)
conn2.row_factory = sqlite3.Row
cur2 = conn2.cursor()
cur2.execute(
"SELECT coalesce(max(step_number),0) as cnt FROM steps WHERE session_id = ?",
(session_id,),
)
row_cnt = cur2.fetchone()
if row_cnt:
progress["current_turn"] = row_cnt["cnt"]
progress["current_step"] = row_cnt["cnt"]
cur2.execute(
"SELECT payload FROM traces WHERE session_id = ? AND type in ('raw_thinking', 'llm_call') "
"AND status = 'success' ORDER BY timestamp DESC LIMIT 1",
(session_id,),
)
row_thought = cur2.fetchone()
if row_thought and row_thought["payload"]:
try:
p_obj = json.loads(row_thought["payload"])
if isinstance(p_obj, dict):
if "thought" in p_obj:
progress["latest_thought"] = p_obj["thought"]
elif (
"response" in p_obj
and isinstance(p_obj["response"], list)
and p_obj["response"]
):
progress["latest_thought"] = p_obj["response"][0].get(
"text", ""
)
except (ValueError, TypeError, AttributeError):
# Unparseable thought payload: omit it.
pass
cur2.execute(
"SELECT name, payload FROM traces WHERE session_id = ? AND type = 'tool' "
"AND status = 'success' ORDER BY timestamp DESC LIMIT 1",
(session_id,),
)
row_action = cur2.fetchone()
if row_action:
act_name = row_action["name"]
act_payload = {}
if row_action["payload"]:
try:
act_payload = json.loads(row_action["payload"]).get(
"args", {}
)
except (ValueError, TypeError, AttributeError):
# Unparseable action payload: show the
# bare action name.
pass
progress["latest_action"] = (
f"{act_name}({act_payload})" if act_payload else act_name
)
conn2.close()
except Exception as q_err:
print(
f"Error querying flash progress from data_engine.db: {q_err}",
file=sys.stderr,
)
else:
plan_content = None
plan_path = os.path.join(
trace_store.TRACES_DIR, session_id, "notes", "task_plan.md"
)
if not os.path.exists(plan_path):
plan_path = os.path.join(trace_dir, "notes", "task_plan.md")
if os.path.exists(plan_path):
try:
with open(plan_path, encoding="utf-8") as f:
plan_content = f.read()
except OSError:
# Unreadable plan file: progress omits the plan.
pass
progress["task_plan"] = plan_content
else:
progress["phase"] = "initializing"
progress["status_message"] = "Spawning background runner..."
response["progress"] = progress
return response
elif action == "guidance_history":
return {
"trace_id": trace_id,
"instructions": list_instructions(trace_id, task_status=current_status),
}
elif action == "pause":
if current_status == "pausing":
return {
"trace_id": trace_id,
"status": "pausing",
"message": "Pause is still pending; check status for task_paused.",
}
if current_status not in ("running", "pending", "resuming") or not is_alive:
return {
"trace_id": trace_id,
"status": current_status,
"message": "Task is not running.",
}
request_pause(trace_id)
return {
"trace_id": trace_id,
"status": "pausing",
"message": "Pause requested; wait for task_paused and device release.",
}
elif action == "resume":
if current_status != "task_paused" or not is_alive:
return {
"trace_id": trace_id,
"status": current_status,
"message": "Task is not paused.",
}
if instruction:
add_instruction(trace_id, instruction, source="mcp")
request_resume(trace_id)
return {
"trace_id": trace_id,
"status": "resuming",
"message": "Queued for the device again.",
}
elif action == "stop":
if current_status not in ("running", "pending", "pausing", "task_paused", "resuming"):
return {
"trace_id": trace_id,
"status": current_status,
"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:
if is_daemon_running():
stopped_via_daemon = stop_task_on_daemon(trace_id)
except Exception as exc:
# Fall through to direct process termination below.
print(f"Daemon stop attempt failed for {trace_id}: {exc}", file=sys.stderr)
# 2. Cancel queue reservation if present
queue_ticket = status_data.get("queue_ticket")
if queue_ticket:
try:
DeviceExecutionLock.cancel_reservation(queue_ticket)
except OSError as exc:
# A stale reservation expires on its own; note the failure.
print(f"Could not cancel reservation {queue_ticket}: {exc}", file=sys.stderr)
# 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 OSError as exc:
# Without a resolved pid the stop falls back to the
# daemon/queue outcome, so surface the probe failure.
print(f"Could not resolve owner pid for {trace_id}: {exc}", file=sys.stderr)
# 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.",
}
# 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:
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 and release_loop:
instruction = (
"The user has requested to stop the continuous monitoring loop."
" Wrap up gracefully and complete the task."
)
if not instruction:
return {
"trace_id": trace_id,
"status": current_status,
"message": "Missing required argument 'instruction' for action 'inject_instruction'.",
}
if current_status not in ("running", "pending", "pausing", "task_paused", "resuming"):
return {
"trace_id": trace_id,
"status": current_status,
"message": f"Cannot inject instruction because task is in a terminal state: {current_status}",
}
if not os.path.exists(trace_dir):
return {
"trace_id": trace_id,
"status": current_status,
"message": f"Trace directory not found: {trace_dir}",
}
try:
guidance = add_instruction(
trace_id,
instruction,
release_loop=release_loop,
source="mcp",
instruction_id=request_id,
)
if (
current_status == "task_paused"
or (control_dir(trace_id) / "pause.request").exists()
):
request_resume(trace_id)
suffix = " (with release_loop stop signal)" if release_loop else ""
return {
"trace_id": trace_id,
"status": current_status,
"guidance": {
**guidance,
"status": "consumed" if guidance.get("consumed_at") else "pending",
},
"message": f"Guidance accepted for the next safe checkpoint{suffix}; consumption before task completion is not guaranteed. Check guidance_history.",
}
except Exception as e:
return {
"trace_id": trace_id,
"status": current_status,
"message": f"Failed to inject instruction: {e}",
}
else:
return {
"trace_id": trace_id,
"status": current_status,
"message": f"Invalid action '{action}'. Supported actions are 'status', 'pause', 'resume', 'stop', and 'inject_instruction'.",
}