feat: add prompt-first batches and pausable task guidance
This commit is contained in:
1 parent
cec9898e4c
commit
057f0c3050
44 files changed
+1080
-191
No files matched your search
@@ -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}
|
||||
@@ -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'.",
|
||||
}
|
||||
Reference in new issue
Block a user