feat: add prompt-first batches and pausable task guidance

This commit is contained in:
chenmingxuan committed 2026-09-23 22:59:48 +08:00
1 parent cec9898e4c
commit 057f0c3050
44 files changed
+1080 -191

No files matched your search

+90 -25
View File
@@ -34,6 +34,13 @@ from artemis.runtime import (
trace_store,
)
from artemis.runtime.process_probe import pid_is_alive
from artemis.runtime.task_control import (
add_instruction,
control_dir,
read_state,
request_pause,
request_resume,
)
logger = logging.getLogger(__name__)
@@ -51,6 +58,34 @@ def _find_data_engine_db() -> str | None:
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
@@ -143,8 +178,8 @@ def _reconcile_task_state(
liveness_failed = (
current_status == "failed" and status_data.get("error") == _LIVENESS_FAILURE_ERROR
)
if db_status in ("completed", "success", "failed", "cancelled") and (
current_status in ("running", "pending") or liveness_failed
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:
@@ -156,7 +191,7 @@ def _reconcile_task_state(
status_data["end_time"] = time.time()
dirty = True
is_alive = False
elif current_status in ("running", "pending"):
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
@@ -225,13 +260,13 @@ def mobile_manage_task(
"""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`. You MUST use it to poll the task status when your
`mobile_run_task` or the web workspace. You MUST use it to poll the task status when your
1-minute fallback timer triggers (as required by `mobile_run_task`). You
can also steer a subagent that is stuck or off-track, or abort a task.
### Actions
- **'status'**: Returns `trace_id`, `status` ('running'/'completed'/'failed'/
'cancelled'), `device_serial` (which phone owns this task in multi-device
- **'status'**: Returns `trace_id`, `status` ('running'/'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 —
@@ -239,6 +274,9 @@ def mobile_manage_task(
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 after the current action, releasing the device.
- **'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. Applied at the start of the next
planning turn (Pro) or the next reactive loop (Flash). Requires
@@ -254,15 +292,15 @@ def mobile_manage_task(
should wind down cleanly instead of being killed.
Args:
action: `"status"`, `"inject_instruction"`, or `"stop"`.
trace_id: The task's session identifier from `mobile_run_task`.
action: `"status"`, `"pause"`, `"resume"`, `"inject_instruction"`, 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.
"""
status_data = trace_store.read_status(trace_id)
status_data = trace_store.read_status(trace_id) or _web_session_status(trace_id)
if not status_data:
return {
"trace_id": trace_id,
@@ -271,6 +309,9 @@ def mobile_manage_task(
}
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
trace_dir = trace_store.get_trace_dir(trace_id)
@@ -368,7 +409,7 @@ def mobile_manage_task(
pass
break
if current_status in ("running", "pending") and is_alive:
if current_status in ("running", "pending", "task_paused", "resuming") and is_alive:
progress: dict[str, Any] = {}
project_root = env_utils.get_project_root()
@@ -483,8 +524,38 @@ def mobile_manage_task(
return response
elif action == "pause":
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": "Pauses after the current action and releases the device.",
}
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"):
if current_status not in ("running", "pending", "task_paused", "resuming"):
return {
"trace_id": trace_id,
"status": current_status,
@@ -579,7 +650,7 @@ def mobile_manage_task(
"message": "Missing required argument 'instruction' for action 'inject_instruction'.",
}
if current_status not in ("running", "pending"):
if current_status not in ("running", "pending", "task_paused", "resuming"):
return {
"trace_id": trace_id,
"status": current_status,
@@ -593,19 +664,13 @@ def mobile_manage_task(
"message": f"Trace directory not found: {trace_dir}",
}
instruction_path = os.path.join(trace_dir, "injected_instruction.json")
try:
with open(instruction_path, "w", encoding="utf-8") as f:
json.dump(
{
"instruction": instruction,
"release_loop": bool(release_loop),
"timestamp": time.time(),
"status": "pending",
},
f,
indent=2,
)
add_instruction(trace_id, instruction, release_loop=release_loop, source="mcp")
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,
@@ -623,5 +688,5 @@ def mobile_manage_task(
return {
"trace_id": trace_id,
"status": current_status,
"message": f"Invalid action '{action}'. Supported actions are 'status', 'stop', and 'inject_instruction'.",
"message": f"Invalid action '{action}'. Supported actions are 'status', 'pause', 'resume', 'stop', and 'inject_instruction'.",
}