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

+18 -7
View File
@@ -184,7 +184,10 @@ async def mobile_batch(
"""Create, execute and manage durable batches on the unified device queue.
create requires a unique request_id and batch={name,device_serial,cases:[{name,goal,
expected?,app?,preconditions?,parameters?:{key:primitive},tags?}],profile?:flash|pro,
case_id?,category?,subcategory?,preconditions?,task_config?:{profile?,model?,
reasoning_effort?,device_serial?,verification_level?,explorer_mode?,
locked_app_package?,record_video?,analyze_video?,request_timeout_seconds?,
max_retries?,retry_interval_seconds?,timeout_seconds?}}],profile?:flash|pro,
formats?:[docx,xlsx],stop_on_failure?:false,timeout_seconds?:1800}. Required device
must be user-selected. Creation validates and saves a DRAFT; start executes it.
Reuse the same request_id and exact payload after a connection failure; never invent
@@ -225,7 +228,7 @@ async def mobile_search_history(
page: int = 1,
size: int = 30,
) -> dict:
"""Search ALL persisted terminal tasks by name, goal, ID or tags, with pagination.
"""Search ALL persisted terminal tasks by name, prompt, ID or case metadata, with pagination.
start/end are Unix seconds, inclusive/exclusive; convert the user's local dates
before calling. Status: completed/success/failed/cancelled/interrupted/skipped.
@@ -243,11 +246,12 @@ async def mobile_search_history(
async def mobile_export_report(
session_id: str | None = None,
batch_id: str | None = None,
formats: list[Literal["docx", "xlsx"]] | None = None,
formats: list[Literal["docx", "xlsx", "screenshots", "video", "all"]] | None = None,
) -> dict:
"""Export a task's Word/Excel reports or a batch ZIP to local files on the MCP host.
"""Export Word/Excel, ordered screenshots, saved recording segments or all task material.
Provide exactly one of session_id/batch_id. Task formats omitted uses saved defaults;
screenshots/video/all produce ZIPs; video requires recording to have been enabled.
batch ZIP uses its frozen formats and includes summary + individual case reports.
Returns absolute paths, download URLs and sizes; task results also include structured
verdict/evidence. Running exports are snapshots. 'completed' is not proof of PASS;
@@ -269,11 +273,18 @@ async def mobile_export_report(
if formats is not None
else (await _request("GET", "/api/automation/preferences"))["formats"]
)
if not chosen or len(chosen) > 2 or any(fmt not in {"docx", "xlsx"} for fmt in chosen):
raise ValueError("formats 至少选择 docx 或 xlsx,最多两项")
if (
not chosen
or len(chosen) > 5
or any(fmt not in {"docx", "xlsx", "screenshots", "video", "all"} for fmt in chosen)
):
raise ValueError("formats 需选择 docx、xlsx、screenshots、video 或 all")
report = await _request("GET", f"/api/automation/reports/{sid}")
files = [
await _download(f"/api/automation/reports/{sid}/{fmt}", f"task-{sid}.{fmt}")
await _download(
f"/api/automation/reports/{sid}/{fmt}",
f"task-{sid}.{fmt}" if fmt in {"docx", "xlsx"} else f"task-{sid}-{fmt}.zip",
)
for fmt in dict.fromkeys(chosen)
]
return {"report": report, "files": files}
+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'.",
}