feat: add guided exploration and share user-started MCP backend

This commit is contained in:
chenmingxuan committed 2026-10-16 18:32:46 +08:00
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()
+1 -4
View File
@@ -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",
+26 -4
View File
@@ -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(