feat: add guided exploration and share user-started MCP backend
This commit is contained in:
1 parent
f86db1ced6
commit
ee256ad7ce
144 files changed
+8625
-2075
No files matched your search
@@ -193,6 +193,9 @@ async def tick_batches():
|
||||
from apps.admin_console.services.report_export import export_batch
|
||||
|
||||
await reconcile_recovered_jobs()
|
||||
from apps.admin_console.services.exploration_recovery import reconcile_explorations
|
||||
|
||||
reconcile_explorations()
|
||||
for batch in store.records("batch"):
|
||||
if batch["status"] not in {"running", "paused"}:
|
||||
continue
|
||||
|
||||
@@ -77,8 +77,10 @@ def plugin_sources(session_id: str) -> list[dict]:
|
||||
|
||||
|
||||
def plugin_files(session_id: str) -> list[tuple[Path, str]]:
|
||||
from apps.admin_console.services.exploration_evidence import demonstration_files
|
||||
|
||||
root = task_root(session_id)
|
||||
files = {}
|
||||
files = dict(demonstration_files(session_id))
|
||||
for source in plugin_sources(session_id):
|
||||
for segment in source["segments"]:
|
||||
path = evidence_file(root, segment["file"])
|
||||
@@ -105,9 +107,13 @@ def plugin_files(session_id: str) -> list[tuple[Path, str]]:
|
||||
|
||||
def export_index(session_id: str, report: dict, android_videos: list) -> dict:
|
||||
from apps.admin_console.services.report_export import session_artifacts, session_screenshots
|
||||
from apps.admin_console.services.exploration_evidence import (
|
||||
exploration_index,
|
||||
demonstration_sources,
|
||||
)
|
||||
|
||||
extras = plugin_files(session_id)
|
||||
recordings = plugin_sources(session_id)
|
||||
recordings = plugin_sources(session_id) + demonstration_sources(session_id)
|
||||
root = task_root(session_id)
|
||||
paths = {path.resolve(): name for path, name in extras}
|
||||
for source in recordings:
|
||||
@@ -138,6 +144,7 @@ def export_index(session_id: str, report: dict, android_videos: list) -> dict:
|
||||
return {
|
||||
"version": 1,
|
||||
"session_id": session_id,
|
||||
"exploration": exploration_index(session_id, paths),
|
||||
"name": report["name"],
|
||||
"status": report["status"],
|
||||
"verdict": report["verdict"],
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Read-only exploration provenance for reports, archives and source-aware video."""
|
||||
|
||||
import json
|
||||
|
||||
from artemis.exploration.store import explorations
|
||||
from artemis.exploration.demonstrations import Demonstrations
|
||||
from apps.admin_console.services.evidence_service import evidence_file, task_root
|
||||
|
||||
|
||||
def exploration_metadata(run_id: str) -> dict | None:
|
||||
run = explorations.run(run_id)
|
||||
if not run:
|
||||
return None
|
||||
return {
|
||||
"id": run["exploration_id"],
|
||||
"kind": run["kind"],
|
||||
"case_version": run.get("case_version"),
|
||||
}
|
||||
|
||||
|
||||
def demonstration_sources(run_id: str) -> list[dict]:
|
||||
root = task_root(run_id)
|
||||
directory = root / "evidence" / "demonstrations"
|
||||
from apps.admin_console.services.media_service import MediaService
|
||||
|
||||
sources = []
|
||||
for manifest in directory.glob("*/demonstration.json"):
|
||||
if (
|
||||
not evidence_file(root, manifest.relative_to(root).as_posix())
|
||||
or manifest.stat().st_size > 4 * 1024 * 1024
|
||||
):
|
||||
continue
|
||||
try:
|
||||
record = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
for track in record["media"]:
|
||||
segments = []
|
||||
for item in track["segments"]:
|
||||
path = evidence_file(root, item["file"])
|
||||
if path and path.suffix.lower() == ".mp4" and item["duration_ms"] > 0:
|
||||
segments.append(
|
||||
{
|
||||
**item,
|
||||
"start": item["offset_ms"] / 1000,
|
||||
"duration": item["duration_ms"] / 1000,
|
||||
"url": MediaService.path_to_video_url(path),
|
||||
}
|
||||
)
|
||||
if segments:
|
||||
sources.append(
|
||||
{
|
||||
**track,
|
||||
"segments": segments,
|
||||
"label": "人工示范 · "
|
||||
+ ("安卓" if track["kind"] == "android" else "电脑"),
|
||||
"status": "ready",
|
||||
"message": "人工操作录像,不等于自动复跑通过",
|
||||
"demonstration_id": record["id"],
|
||||
}
|
||||
)
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
continue
|
||||
return sources
|
||||
|
||||
|
||||
def demonstration_files(run_id: str) -> list[tuple]:
|
||||
root = task_root(run_id)
|
||||
directory = root / "evidence" / "demonstrations"
|
||||
files = []
|
||||
for path in directory.rglob("*"):
|
||||
if path.suffix.lower() not in {".mp4", ".mkv", ".webm"}:
|
||||
continue
|
||||
checked = evidence_file(root, path.relative_to(root).as_posix())
|
||||
if checked:
|
||||
files.append((checked, "任务录屏/人工示范/" + path.relative_to(directory).as_posix()))
|
||||
return files
|
||||
|
||||
|
||||
def exploration_index(run_id: str, archive_paths: dict | None = None) -> dict | None:
|
||||
run = explorations.run(run_id)
|
||||
if not run:
|
||||
return None
|
||||
sid = run["exploration_id"]
|
||||
session = explorations.get(sid)
|
||||
messages = []
|
||||
after = 0
|
||||
while page := explorations.messages(sid, after=after):
|
||||
messages.extend(page)
|
||||
after = page[-1]["sequence"]
|
||||
demos = Demonstrations(explorations).list(sid)
|
||||
root = task_root(run_id)
|
||||
for demo in demos:
|
||||
for track in demo.get("media", []):
|
||||
for segment in track.get("segments", []):
|
||||
relative = segment.pop("file", "")
|
||||
segment["trace_file"] = relative
|
||||
segment["file"] = (
|
||||
(archive_paths or {}).get((root / relative).resolve())
|
||||
if demo["run_id"] == run_id
|
||||
else None
|
||||
)
|
||||
return {
|
||||
"session": session,
|
||||
"messages": messages,
|
||||
"runs": explorations.runs(sid),
|
||||
"cases": [
|
||||
explorations.case(sid, version) for version in range(1, session["case_version"] + 1)
|
||||
],
|
||||
"demonstrations": demos,
|
||||
"note": "此导出保留会话、用例版本和各次运行的关联;只有当前任务的媒体文件在此目录中。",
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Shared exploration metadata for queue cards and history pagination."""
|
||||
|
||||
# Rank execution runs independently from validations: a rerun never replaces the
|
||||
# conversation card. Keep all raw sessions accessible for evidence inspection.
|
||||
EXPLORATION_RUNS_CTE = """
|
||||
exploration_runs AS (
|
||||
SELECT r.id AS run_id,
|
||||
json_extract(r.payload,'$.exploration_id') AS exploration_id,
|
||||
json_extract(r.payload,'$.kind') AS exploration_kind,
|
||||
json_extract(r.payload,'$.case_version') AS case_version,
|
||||
json_extract(r.payload,'$.task_config.model') AS exploration_model,
|
||||
json_extract(r.payload,'$.task_config.profile') AS exploration_profile,
|
||||
json_extract(v.payload,'$.name') AS exploration_case_name,
|
||||
CASE WHEN json_extract(r.payload,'$.kind')='exploration' AND
|
||||
(json_extract(c.payload,'$.active_run_id') IS NULL
|
||||
OR json_extract(c.payload,'$.active_run_id') = r.id)
|
||||
THEN json_extract(c.payload,'$.status')
|
||||
ELSE json_extract(r.payload,'$.status') END AS exploration_status,
|
||||
FIRST_VALUE(r.id) OVER (
|
||||
PARTITION BY json_extract(r.payload,'$.exploration_id'),json_extract(r.payload,'$.kind')
|
||||
ORDER BY json_extract(r.payload,'$.created_at') DESC, r.id DESC
|
||||
) AS exploration_latest_run_id,
|
||||
COUNT(*) OVER (
|
||||
PARTITION BY json_extract(r.payload,'$.exploration_id'),json_extract(r.payload,'$.kind')
|
||||
) AS exploration_run_count
|
||||
FROM automation_records r
|
||||
LEFT JOIN automation_records c ON c.kind='exploration'
|
||||
AND c.id=json_extract(r.payload,'$.exploration_id')
|
||||
LEFT JOIN automation_records v ON v.kind='exploration_case'
|
||||
AND v.id=json_extract(r.payload,'$.exploration_id') || '_' ||
|
||||
printf('%010d',json_extract(r.payload,'$.case_version'))
|
||||
WHERE r.kind='exploration_run'
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def exploration_metadata(connection) -> dict[str, dict]:
|
||||
rows = connection.execute("WITH " + EXPLORATION_RUNS_CTE + " SELECT * FROM exploration_runs")
|
||||
result = {}
|
||||
for row in rows:
|
||||
item = {key: row[key] for key in row.keys() if key != "run_id"}
|
||||
if item.get("exploration_kind") == "validation" and item.get("exploration_case_name"):
|
||||
item["display_name"] = item["exploration_case_name"]
|
||||
result[row["run_id"]] = item
|
||||
return result
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Reconcile saved exploration metadata with the existing queue and worker owners."""
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
from apps.admin_console.core.state import state
|
||||
from apps.admin_console.database.repositories.session_repository import SessionRepository
|
||||
from artemis.exploration.store import ExplorationConflict, ExplorationStore, explorations
|
||||
from artemis.runtime.process_probe import pid_is_alive
|
||||
from artemis.runtime.task_control import read_state
|
||||
from artemis.runtime.task_result import read_worker_outcome
|
||||
from artemis.runtime.case_result import read_case_result
|
||||
|
||||
|
||||
def reconcile_explorations(repository: ExplorationStore = explorations) -> None:
|
||||
for kind in ("exploration_generation", "exploration_discussion"):
|
||||
with repository._transaction() as connection:
|
||||
for row in connection.execute(
|
||||
"SELECT id, payload FROM automation_records WHERE kind=? AND json_extract(payload,'$.status')='running'",
|
||||
(kind,),
|
||||
).fetchall():
|
||||
job = json.loads(row[1])
|
||||
if (
|
||||
not pid_is_alive(job.get("owner_pid"))
|
||||
or job.get("lease_until", 0) < time.time()
|
||||
):
|
||||
repository._put(
|
||||
connection,
|
||||
kind,
|
||||
row[0],
|
||||
{
|
||||
**job,
|
||||
"status": "failed",
|
||||
"lease_until": 0,
|
||||
"error": "生成进程已中断;已有结果保留,可重试。",
|
||||
},
|
||||
)
|
||||
with repository.database.connection() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT payload FROM automation_records WHERE kind='exploration' "
|
||||
"AND json_extract(payload,'$.active_run_id') IS NOT NULL"
|
||||
).fetchall()
|
||||
history = SessionRepository(repository.database.path)
|
||||
queued = {
|
||||
str(item.get("session_id"))
|
||||
for item in state.queue_items
|
||||
if isinstance(item, dict) and item.get("status") in {"pending", "running", "queued"}
|
||||
}
|
||||
for row in rows:
|
||||
session = json.loads(row[0])
|
||||
run_id = session["active_run_id"]
|
||||
run = repository.run(run_id)
|
||||
if not run:
|
||||
continue
|
||||
outcome = read_worker_outcome(run_id)
|
||||
if outcome:
|
||||
repository.finish_run(run_id, {**outcome, "status": outcome["task_status"]})
|
||||
continue
|
||||
control = read_state(run_id)
|
||||
if run["kind"] == "validation" and pid_is_alive(control.get("pid")):
|
||||
observed = {"running": "running", "task_paused": "paused", "resuming": "queued"}.get(
|
||||
control.get("status")
|
||||
)
|
||||
if observed and observed != run["status"]:
|
||||
try:
|
||||
repository.set_run_state(run_id, observed, reason=control.get("pause_reason"))
|
||||
except ExplorationConflict:
|
||||
continue # The owner finalized while this tick read its control file.
|
||||
if run_id in queued or run_id in state.active_runs:
|
||||
continue
|
||||
if control.get("pid") and pid_is_alive(control["pid"]):
|
||||
continue
|
||||
execution = history.get_session_by_id(run_id) or {}
|
||||
if execution.get("pid") and history.process_is_alive(execution["pid"]):
|
||||
continue
|
||||
job = repository.database.job(run_id) or {}
|
||||
terminal = execution.get("status") or job.get("terminal_status") or job.get("status")
|
||||
if terminal in {"completed", "failed", "cancelled", "interrupted", "skipped"}:
|
||||
repository.finish_run(
|
||||
run_id,
|
||||
{
|
||||
"status": terminal,
|
||||
"error": execution.get("error") or job.get("error") or "",
|
||||
"test_result": execution.get("test_result")
|
||||
or read_case_result(run_id, terminal),
|
||||
},
|
||||
)
|
||||
elif time.time() - run["created_at"] > 300:
|
||||
repository.finish_run(
|
||||
run_id,
|
||||
{
|
||||
"status": "interrupted",
|
||||
"error": "探索工作进程已结束;原证据已保留,可继续探索并重新观察当前设备。",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,334 @@
|
||||
"""Web and MCP use the same exploration admission and conversation service."""
|
||||
|
||||
from artemis.config.agent import load_agent_config
|
||||
from artemis.config.exploration import ExplorationConfig
|
||||
from artemis.exploration.models import CreateExploration, SaveCaseRequest, ValidateCaseRequest
|
||||
from artemis.exploration.demonstrations import Demonstrations
|
||||
from artemis.exploration.store import ExplorationConflict, ExplorationStore, explorations
|
||||
from artemis.runtime.task_control import add_instruction, read_state, request_pause, request_resume
|
||||
from apps.admin_console.services.task_import import TaskOptions
|
||||
|
||||
|
||||
def exploration_case_prompt(case: dict) -> str:
|
||||
prefix = f"前置条件:\n{case['preconditions']}\n\n" if case.get("preconditions") else ""
|
||||
return prefix + case["goal"]
|
||||
|
||||
|
||||
class ExplorationService:
|
||||
def __init__(self, repository: ExplorationStore | None = None):
|
||||
self.repository = repository if repository is not None else explorations
|
||||
|
||||
def settings(self) -> dict:
|
||||
saved = self.repository.database.get("settings", "exploration") or {}
|
||||
return ExplorationConfig.model_validate(
|
||||
{
|
||||
**load_agent_config().exploration.model_dump(),
|
||||
**saved,
|
||||
}
|
||||
).model_dump()
|
||||
|
||||
def save_settings(self, value: ExplorationConfig) -> dict:
|
||||
return self.repository.database.put("settings", "exploration", value.model_dump())
|
||||
|
||||
def create(self, value: CreateExploration, *, source: str = "web") -> dict:
|
||||
options = TaskOptions.model_validate(value.task_config).model_dump(exclude_none=True)
|
||||
return self.repository.create(
|
||||
value.goal,
|
||||
value.config or ExplorationConfig.model_validate(self.settings()),
|
||||
options,
|
||||
value.request_id,
|
||||
source=source,
|
||||
)
|
||||
|
||||
def detail(self, session_id: str, *, after: int = 0) -> dict:
|
||||
session = self.repository.get(session_id)
|
||||
runs = self.repository.runs(session_id)
|
||||
version = self.repository.case(session_id)
|
||||
with self.repository.database.connection() as connection:
|
||||
import json
|
||||
|
||||
generations = [
|
||||
json.loads(row[0])
|
||||
for row in connection.execute(
|
||||
"SELECT payload FROM automation_records WHERE kind='exploration_generation' "
|
||||
"AND json_extract(payload,'$.exploration_id')=? ORDER BY updated_at DESC LIMIT 20",
|
||||
(session_id,),
|
||||
).fetchall()
|
||||
]
|
||||
from artemis.runtime.case_result import resolve_case_result
|
||||
|
||||
for run in runs:
|
||||
outcome = run.get("outcome")
|
||||
verdict = resolve_case_result(outcome, run["status"]) if outcome else None
|
||||
run["test_result"] = verdict.model_dump() if verdict else None
|
||||
return {
|
||||
**session,
|
||||
"messages": self.repository.messages(session_id, after=after),
|
||||
"runs": runs,
|
||||
"case": version,
|
||||
"generations": [
|
||||
{k: v for k, v in g.items() if k not in {"owner", "owner_pid", "lease_until"}}
|
||||
for g in generations
|
||||
],
|
||||
"demonstrations": Demonstrations(self.repository).list(session_id),
|
||||
"validation_runs": [
|
||||
r
|
||||
for r in runs
|
||||
if r["kind"] == "validation" and r["case_version"] == session["case_version"]
|
||||
],
|
||||
"control": read_state(session["active_run_id"]) if session["active_run_id"] else {},
|
||||
}
|
||||
|
||||
async def _admit(self, run: dict, *, source: str) -> dict:
|
||||
# RunRequest performs the same readiness and idempotency checks as /api/run.
|
||||
from apps.admin_console.routers.tasks import run_task
|
||||
from apps.admin_console.schemas.task_schema import RunRequest
|
||||
from fastapi import HTTPException
|
||||
|
||||
current = self.repository.run(run["id"])
|
||||
if current and current.get("outcome") is not None:
|
||||
return current
|
||||
options = TaskOptions.model_validate(run["task_config"])
|
||||
try:
|
||||
result = await run_task(
|
||||
RunRequest(
|
||||
goal=run["goal"],
|
||||
profile=options.profile or "pro",
|
||||
session_id=run["id"],
|
||||
ingress=source,
|
||||
conversation_id=run["exploration_id"],
|
||||
runtime_overrides=options,
|
||||
)
|
||||
)
|
||||
except HTTPException as exc:
|
||||
if exc.status_code < 500:
|
||||
self.repository.finish_run(
|
||||
run["id"], {"status": "rejected", "error": str(exc.detail)}
|
||||
)
|
||||
raise
|
||||
if result.get("status") in {"error", "rejected"}:
|
||||
self.repository.finish_run(
|
||||
run["id"],
|
||||
{
|
||||
"status": "rejected",
|
||||
"error": result.get("error") or result.get("message") or "任务入队失败",
|
||||
},
|
||||
)
|
||||
else:
|
||||
self.repository.set_run_state(run["id"], "queued", expected_status="reserved")
|
||||
return {"run": self.repository.run(run["id"]), "admission": result}
|
||||
|
||||
async def start(self, session_id: str, request_id: str, *, source: str = "web") -> dict:
|
||||
session = self.repository.get(session_id)
|
||||
config = ExplorationConfig.model_validate(session["config"])
|
||||
options = {
|
||||
**session["task_config"],
|
||||
"profile": "pro",
|
||||
"model": config.model,
|
||||
"reasoning_effort": config.reasoning_effort,
|
||||
"timeout_seconds": config.timeout_seconds,
|
||||
"verification_level": "final",
|
||||
}
|
||||
run = self.repository.reserve_run(
|
||||
session_id,
|
||||
request_id,
|
||||
kind="exploration",
|
||||
goal=session["goal"],
|
||||
task_config=options,
|
||||
)
|
||||
return await self._admit(run, source=source)
|
||||
|
||||
async def validate(
|
||||
self, session_id: str, value: ValidateCaseRequest, *, source: str = "web"
|
||||
) -> dict:
|
||||
case = self.repository.case(session_id, value.version)
|
||||
if case is None:
|
||||
raise KeyError("用例版本不存在")
|
||||
session = self.repository.get(session_id)
|
||||
# Device/plugin binding may be reused; exploration's expensive model is not.
|
||||
options = {
|
||||
k: v
|
||||
for k, v in session["task_config"].items()
|
||||
if k not in {"model", "reasoning_effort", "profile", "timeout_seconds"}
|
||||
}
|
||||
options.update(TaskOptions.model_validate(value.task_config).model_dump(exclude_none=True))
|
||||
options["profile"] = value.profile
|
||||
run = self.repository.reserve_run(
|
||||
session_id,
|
||||
value.request_id,
|
||||
kind="validation",
|
||||
goal=exploration_case_prompt(case),
|
||||
task_config=options,
|
||||
case_version=value.version,
|
||||
)
|
||||
return await self._admit(run, source=source)
|
||||
|
||||
def message(
|
||||
self,
|
||||
session_id: str,
|
||||
text: str,
|
||||
request_id: str,
|
||||
*,
|
||||
source: str = "web",
|
||||
expected_run_id: str | None = None,
|
||||
) -> dict:
|
||||
session = self.repository.get(session_id)
|
||||
if expected_run_id and session["active_run_id"] != expected_run_id:
|
||||
raise ExplorationConflict("所选执行已结束,指导未发送,请刷新任务状态")
|
||||
question = session.get("pending_question")
|
||||
message = self.repository.add_message(
|
||||
session_id,
|
||||
text,
|
||||
request_id,
|
||||
source=source,
|
||||
metadata={"question_id": question, "run_id": session["active_run_id"]},
|
||||
)
|
||||
question = message.get("question_id")
|
||||
run_id = message.get("run_id")
|
||||
if run_id and run_id == session["active_run_id"]:
|
||||
run = self.repository.run(run_id)
|
||||
if run and run.get("outcome") is None:
|
||||
add_instruction(
|
||||
run_id, message["text"], source=source, instruction_id=message["id"]
|
||||
)
|
||||
self.repository.answer_question(session_id, question)
|
||||
if read_state(run_id).get("status") == "task_paused":
|
||||
request_resume(run_id)
|
||||
return message
|
||||
|
||||
def pause(self, session_id: str) -> dict:
|
||||
session = self.repository.get(session_id)
|
||||
if session["status"] == "finalizing":
|
||||
raise ExplorationConflict("设备操作已结束,正在提炼用例,无需暂停。")
|
||||
if not session["active_run_id"]:
|
||||
raise ExplorationConflict("当前没有正在执行的探索")
|
||||
request_pause(session["active_run_id"], reason="user")
|
||||
return {"status": "pausing", "id": session_id}
|
||||
|
||||
def stop(self, session_id: str) -> dict:
|
||||
from apps.admin_console.services.task_queue_service import TaskQueueService
|
||||
|
||||
session = self.repository.get(session_id)
|
||||
if run_id := session["active_run_id"]:
|
||||
requested = TaskQueueService.stop_tasks(session_id=run_id)
|
||||
return {"status": "stopping" if requested else "not_running", "id": session_id}
|
||||
return {"status": session["status"], "id": session_id}
|
||||
|
||||
async def resume(self, session_id: str, request_id: str, *, source: str = "web") -> dict:
|
||||
repo = self.repository
|
||||
with repo._transaction() as connection:
|
||||
session = repo._require(connection, session_id)
|
||||
|
||||
def prepare():
|
||||
if session.get("pending_question"):
|
||||
raise ExplorationConflict("请先回答探索助手的问题,再继续执行。")
|
||||
if run_id := session["active_run_id"]:
|
||||
if read_state(run_id).get("status") != "task_paused":
|
||||
raise ExplorationConflict("请等待探索安全暂停后再继续")
|
||||
request_resume(run_id)
|
||||
return {"status": "resuming", "id": session_id, "run_id": run_id}
|
||||
return {"status": "start", "id": session_id}
|
||||
|
||||
result = repo._operation(connection, f"{session_id}:continue", request_id, {}, prepare)
|
||||
if result["status"] == "resuming":
|
||||
return result
|
||||
return await self.start(session_id, request_id, source=source)
|
||||
|
||||
def save_case(self, session_id: str, value: SaveCaseRequest) -> dict:
|
||||
return self.repository.save_case(
|
||||
session_id, value.case, value.expected_version, value.request_id
|
||||
)
|
||||
|
||||
def demonstrate(self, session_id: str, request_id: str, source: str | None = None) -> dict:
|
||||
session = self.repository.get(session_id)
|
||||
if session["status"] == "finalizing":
|
||||
raise ExplorationConflict("正在提炼用例,请完成后继续探索再示范。")
|
||||
source = source or (
|
||||
"computer"
|
||||
if session["task_config"].get("execution_target") == "computer"
|
||||
else "android"
|
||||
)
|
||||
if source not in {"android", "computer", "both"}:
|
||||
raise ValueError("无效的示范来源")
|
||||
demo = Demonstrations(self.repository).request(session_id, source, request_id)
|
||||
if demo["status"] == "requested":
|
||||
request_pause(demo["run_id"], reason="demonstration")
|
||||
if read_state(demo["run_id"]).get("status") == "task_paused":
|
||||
request_resume(demo["run_id"])
|
||||
return demo
|
||||
|
||||
def demonstration_command(
|
||||
self, session_id: str, demo_id: str, command: str, request_id: str, *, seconds: int = 0
|
||||
) -> dict:
|
||||
return Demonstrations(self.repository).command(
|
||||
session_id, demo_id, command, request_id, seconds=seconds
|
||||
)
|
||||
|
||||
async def generate_case(self, session_id: str, value) -> dict:
|
||||
from artemis.exploration.synthesis import generate_case, _generation
|
||||
from apps.admin_console.services.automation_service import spawn
|
||||
|
||||
generation = _generation(
|
||||
self.repository,
|
||||
session_id,
|
||||
value.request_id,
|
||||
value.source_run_id,
|
||||
value.expected_version,
|
||||
)
|
||||
if generation["status"] == "completed":
|
||||
return {"status": "completed", "id": generation["id"], "case": generation["case"]}
|
||||
|
||||
async def work():
|
||||
try:
|
||||
await generate_case(
|
||||
self.repository,
|
||||
session_id,
|
||||
value.request_id,
|
||||
value.source_run_id,
|
||||
value.expected_version,
|
||||
generation=generation,
|
||||
)
|
||||
except Exception:
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).exception("Exploration synthesis request failed")
|
||||
|
||||
spawn(work())
|
||||
return {"status": "running", "id": generation["id"]}
|
||||
|
||||
def create_case_batch(self, session_id: str, value) -> dict:
|
||||
from apps.admin_console.routers.automation import BatchRequest
|
||||
from apps.admin_console.services.automation_service import create_batch
|
||||
from artemis.exploration.store import _digest
|
||||
|
||||
case = self.repository.case(session_id, value.version)
|
||||
if not case:
|
||||
raise KeyError("用例版本不存在")
|
||||
session = self.repository.get(session_id)
|
||||
options = {
|
||||
k: v
|
||||
for k, v in session["task_config"].items()
|
||||
if k not in {"model", "reasoning_effort", "profile", "timeout_seconds"}
|
||||
}
|
||||
options.update(TaskOptions.model_validate(value.task_config).model_dump(exclude_none=True))
|
||||
options["profile"] = value.profile
|
||||
payload = BatchRequest(
|
||||
name=value.name,
|
||||
profile=value.profile,
|
||||
request_id="explore-" + _digest([session_id, value.request_id]),
|
||||
cases=[
|
||||
{
|
||||
"name": case["name"],
|
||||
"goal": exploration_case_prompt(case),
|
||||
"preconditions": case["preconditions"],
|
||||
"case_id": f"{session_id}-v{value.version}",
|
||||
"task_config": options,
|
||||
"category": "探索用例",
|
||||
"subcategory": f"版本{value.version}",
|
||||
}
|
||||
],
|
||||
).model_dump()
|
||||
return create_batch(payload)
|
||||
|
||||
|
||||
exploration_service = ExplorationService()
|
||||
@@ -20,10 +20,7 @@ from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from admin_console.core.state import state
|
||||
except ImportError:
|
||||
from apps.admin_console.core.state import state
|
||||
from apps.admin_console.core.state import state
|
||||
|
||||
|
||||
class IPCService:
|
||||
|
||||
@@ -80,6 +80,8 @@ def get_model_settings() -> dict:
|
||||
"tool_fallback_no_progress_attempts": settings.ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS,
|
||||
"reasoning_effort": settings.OPENAI_REASONING_EFFORT,
|
||||
"model_presets": [
|
||||
"gpt-6.1-sol",
|
||||
"gpt-6-astra",
|
||||
"gpt-5.6-luna",
|
||||
"gpt-6-luna",
|
||||
"mimo-v2.6-flash",
|
||||
|
||||
@@ -156,6 +156,8 @@ def failure_reason(directory: Path, status: str, recorded: str | None) -> str:
|
||||
|
||||
|
||||
def session_report(session_id: str, db_path=None) -> dict:
|
||||
from apps.admin_console.services.exploration_evidence import exploration_metadata
|
||||
|
||||
identifier(session_id)
|
||||
meta = store.job(session_id) or {}
|
||||
repo = SessionRepository(db_path) if db_path else SessionRepository()
|
||||
@@ -202,6 +204,7 @@ def session_report(session_id: str, db_path=None) -> dict:
|
||||
)
|
||||
return {
|
||||
"id": session_id,
|
||||
"exploration": exploration_metadata(session_id),
|
||||
"name": execution_title(meta) or session.get("initial_goal", session_id)[:120],
|
||||
"iteration": meta.get("iteration", 1),
|
||||
"repeat_count": meta.get("repeat_count", 1),
|
||||
@@ -237,6 +240,21 @@ def time_text(value) -> str:
|
||||
def metadata(report: dict) -> list[tuple]:
|
||||
return [
|
||||
("任务编号", report["id"]),
|
||||
*(
|
||||
(
|
||||
[
|
||||
("探索会话", report["exploration"]["id"]),
|
||||
(
|
||||
"探索关联",
|
||||
"用例 v" + str(report["exploration"]["case_version"]) + " 复跑"
|
||||
if report["exploration"]["kind"] == "validation"
|
||||
else "探索执行(不等于独立复跑通过)",
|
||||
),
|
||||
]
|
||||
)
|
||||
if report.get("exploration")
|
||||
else []
|
||||
),
|
||||
("用例编号", report.get("case_id") or "—"),
|
||||
("检测项目", report.get("category") or "—"),
|
||||
("测试子项", report.get("subcategory") or "—"),
|
||||
@@ -840,7 +858,11 @@ def export_session_media(session_id: str, kind: str) -> Path:
|
||||
artifacts = session_artifacts(report) if kind == "all" else []
|
||||
extras = plugin_files(session_id) if kind in {"video", "all"} else []
|
||||
if kind == "video":
|
||||
extras = [(path, name) for path, name in extras if path.suffix.lower() == ".mp4"]
|
||||
extras = [
|
||||
(path, name)
|
||||
for path, name in extras
|
||||
if path.suffix.lower() in {".mp4", ".mkv", ".webm"}
|
||||
]
|
||||
if kind == "video" and not videos and not extras:
|
||||
raise ValueError("此任务没有已保存的录屏")
|
||||
if kind == "screenshots" and not images:
|
||||
@@ -854,7 +876,7 @@ def export_session_media(session_id: str, kind: str) -> Path:
|
||||
"verdict": report["verdict"],
|
||||
"screenshots": [name for _, name in images],
|
||||
"videos": ["任务录屏/" + name for _, name in videos]
|
||||
+ [name for path, name in extras if path.suffix.lower() == ".mp4"],
|
||||
+ [name for path, name in extras if path.suffix.lower() in {".mp4", ".mkv", ".webm"}],
|
||||
"artifacts": [name for _, name in artifacts],
|
||||
"note": "录屏按时间顺序分段;暂停期间未占用设备,也未录制其他任务。",
|
||||
}
|
||||
@@ -874,7 +896,7 @@ def export_session_media(session_id: str, kind: str) -> Path:
|
||||
source,
|
||||
prefix + name,
|
||||
compress_type=zipfile.ZIP_STORED
|
||||
if source.suffix.lower() == ".mp4"
|
||||
if source.suffix.lower() in {".mp4", ".mkv", ".webm"}
|
||||
else zipfile.ZIP_DEFLATED,
|
||||
)
|
||||
if kind == "all":
|
||||
@@ -955,7 +977,7 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
|
||||
source,
|
||||
f"{prefix}/{name}",
|
||||
compress_type=zipfile.ZIP_STORED
|
||||
if source.suffix.lower() == ".mp4"
|
||||
if source.suffix.lower() in {".mp4", ".mkv", ".webm"}
|
||||
else zipfile.ZIP_DEFLATED,
|
||||
)
|
||||
archive.writestr(
|
||||
|
||||
@@ -25,16 +25,10 @@ import time
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
try:
|
||||
from admin_console.core.state import state
|
||||
from admin_console.database.repositories.session_repository import session_repo
|
||||
from admin_console.services import worker_process_io
|
||||
from admin_console.services.media_service import media_service
|
||||
except ImportError:
|
||||
from apps.admin_console.core.state import state
|
||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||
from apps.admin_console.services import worker_process_io
|
||||
from apps.admin_console.services.media_service import media_service
|
||||
from apps.admin_console.core.state import state
|
||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||
from apps.admin_console.services import worker_process_io
|
||||
from apps.admin_console.services.media_service import media_service
|
||||
|
||||
from artemis.config import (
|
||||
PAUSE_FILE,
|
||||
@@ -585,6 +579,12 @@ class TaskQueueService:
|
||||
env["ARTEMIS_SESSION_ID"] = str(sess_id)
|
||||
env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend"))
|
||||
env["ARTEMIS_TASK_WORKER"] = "1"
|
||||
from artemis.exploration.store import explorations
|
||||
|
||||
exploration_run = explorations.run(str(sess_id)) if sess_id else None
|
||||
env.pop("ARTEMIS_EXPLORATION_ID", None)
|
||||
if exploration_run and exploration_run["kind"] == "exploration":
|
||||
env["ARTEMIS_EXPLORATION_ID"] = exploration_run["exploration_id"]
|
||||
overrides = task_item.get("runtime_overrides") or {}
|
||||
if overrides.get("recording") is not None:
|
||||
env["ARTEMIS_TASK_RECORDING_CONFIG"] = json.dumps(overrides["recording"])
|
||||
@@ -669,6 +669,11 @@ class TaskQueueService:
|
||||
batch = store.get("batch", job["batch_id"]) if job and job.get("batch_id") else None
|
||||
limit = task_timeout(task_item, job, batch)
|
||||
budget = ExecutionBudget(limit)
|
||||
from artemis.exploration.store import explorations
|
||||
|
||||
exploration_run = explorations.run(sid)
|
||||
is_exploration = bool(exploration_run and exploration_run["kind"] == "exploration")
|
||||
exploration_finalized = False
|
||||
cleanup_limit = (task_item.get("runtime_overrides") or {}).get(
|
||||
"worker_cleanup_timeout_seconds"
|
||||
) or settings.ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS
|
||||
@@ -681,6 +686,9 @@ class TaskQueueService:
|
||||
return waiter.result()
|
||||
outcome = read_worker_outcome(sid)
|
||||
if outcome:
|
||||
if exploration_run and not exploration_finalized:
|
||||
explorations.finish_run(sid, {**outcome, "status": outcome["task_status"]})
|
||||
exploration_finalized = True
|
||||
finalized_at = finalized_at if finalized_at is not None else time.monotonic()
|
||||
if time.monotonic() - finalized_at >= cleanup_limit:
|
||||
logger.warning(
|
||||
@@ -692,6 +700,9 @@ class TaskQueueService:
|
||||
return await waiter
|
||||
continue # Final result is authoritative; only bound resource cleanup now.
|
||||
cls._sync_control_states()
|
||||
if is_exploration:
|
||||
# The worker owns its pausable budget so it survives a UI server restart.
|
||||
continue
|
||||
run = state.active_runs.get(run_key, {})
|
||||
remaining = budget.update(
|
||||
excluded=bool(run.get("parked")) or read_state(sid).get("status") == "queued"
|
||||
@@ -1077,9 +1088,35 @@ class TaskQueueService:
|
||||
f"[QueueWorker] Could not persist failure status for [{sess_id}]"
|
||||
)
|
||||
finally:
|
||||
await cls._finish_output_forwarder(output_task)
|
||||
# 5. Clean up the finished task and release this run's scheduling slot
|
||||
cls._release_run_slot(sess_id, run_key, proc)
|
||||
try:
|
||||
await cls._finish_output_forwarder(output_task)
|
||||
if sess_id:
|
||||
from artemis.exploration.store import explorations
|
||||
from artemis.runtime.task_result import read_worker_outcome
|
||||
|
||||
exploration_run = explorations.run(str(sess_id))
|
||||
if exploration_run and exploration_run.get("outcome") is None:
|
||||
from artemis.runtime.case_result import read_case_result
|
||||
|
||||
outcome = read_worker_outcome(str(sess_id)) or {}
|
||||
session = session_repo.get_session_by_id(str(sess_id)) or {}
|
||||
status = (
|
||||
outcome.get("task_status") or session.get("status") or "interrupted"
|
||||
)
|
||||
explorations.finish_run(
|
||||
str(sess_id),
|
||||
{
|
||||
**outcome,
|
||||
"status": status,
|
||||
"test_result": outcome.get("test_result")
|
||||
or read_case_result(str(sess_id), status),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Cannot finalize exploration metadata for %s", sess_id)
|
||||
finally:
|
||||
# Metadata/storage failure must never leak a scheduling slot.
|
||||
cls._release_run_slot(sess_id, run_key, proc)
|
||||
|
||||
@classmethod
|
||||
def _find_duplicate_submission(
|
||||
|
||||
Reference in new issue
Block a user