feat: prepare 2.2.0 with UI and execution fixes

Add dark theme, richer Markdown and readable plugin results.
Improve Android input evidence, response integrity and diagnostics.
Unify lifecycle, guidance receipts and evidence sources across clients.
Keep direct action dispatch without an added AI quota approval layer.
Align version metadata at 2.2.0 and bundle Helper 1.2.2.

Validation: 3063 backend tests before the version bump; 44 after.
Frontend: 317 tests and production build passed.
This commit is contained in:
chenmingxuan committed 2026-10-26 00:44:13 +08:00
1 parent 83bcfd54d5
commit fac4ccb760
146 files changed
+5689 -2033

No files matched your search

+32 -13
View File
@@ -35,6 +35,7 @@ from typing import Any
import uuid
from artemis.config.paths import get_temp_dir
from artemis.runtime.cancellation import CancellationReason, reason_from_code
CANCEL_DIR_NAME = "cancel-requests"
MARKER_TTL_SECONDS = 3600.0
@@ -92,6 +93,7 @@ def request_cancel(
pid: int | None = None,
process_created_at: float = 0.0,
reason: str = "",
cancel_reason: CancellationReason = CancellationReason.UNKNOWN,
) -> list[Path]:
"""Ask the worker identified by ``session_id`` and/or ``pid`` to cancel.
@@ -104,6 +106,7 @@ def request_cancel(
payload = {
"requested_at": now,
"reason": reason,
"cancel_reason": reason_from_code(cancel_reason).value,
"session_id": str(session_id) if session_id else None,
"pid": int(pid) if pid else None,
"process_created_at": float(process_created_at or 0.0),
@@ -129,7 +132,20 @@ def is_cancel_requested(
pid: int | None = None,
process_created_at: float | None = None,
) -> bool:
"""Return True when a live cancel marker targets this worker.
"""Return True when a live cancel marker targets this worker."""
return (
get_cancel_reason(session_id=session_id, pid=pid, process_created_at=process_created_at)
is not None
)
def get_cancel_reason(
*,
session_id: str | None = None,
pid: int | None = None,
process_created_at: float | None = None,
) -> CancellationReason | None:
"""Return the allowlisted cause of a live marker, or None without a request.
A PID marker is honoured only when its recorded process creation time is
absent or matches ``process_created_at`` (within one second), which
@@ -141,16 +157,16 @@ def is_cancel_requested(
if path.exists():
payload = _read_marker(path)
if not _is_expired(payload, path, now):
return True
return reason_from_code(payload.get("cancel_reason") if payload else None)
if pid:
path = _pid_marker(int(pid))
if path.exists():
payload = _read_marker(path)
if payload is None:
# Partially written or unreadable marker: retry on the next poll.
return False
return None
if _is_expired(payload, path, now):
return False
return None
try:
recorded = float(payload.get("process_created_at") or 0.0)
except (TypeError, ValueError):
@@ -161,9 +177,9 @@ def is_cancel_requested(
and process_created_at > 0
and abs(recorded - process_created_at) >= 1.0
):
return False
return True
return False
return None
return reason_from_code(payload.get("cancel_reason"))
return None
def clear_cancel_request(*, session_id: str | None = None, pid: int | None = None) -> None:
@@ -210,18 +226,21 @@ def current_process_created_at() -> float:
async def watch_for_cancel_request(
on_cancel: Callable[[], Any],
on_cancel: Callable[..., Any],
*,
session_id: str | None = None,
pid: int | None = None,
process_created_at: float | None = None,
poll_seconds: float | None = None,
pass_reason: bool = False,
) -> bool:
"""Poll for a cancel marker and invoke ``on_cancel`` once it appears.
Returns True when a cancellation was delivered. The coroutine runs until
it delivers one or is itself cancelled, so callers should run it as a
background task alongside the work it guards.
background task alongside the work it guards. With ``pass_reason``, the
callback receives an allowlisted CancellationReason; legacy callbacks
continue to take no arguments. Marker text is never forwarded.
"""
import asyncio
@@ -238,16 +257,16 @@ async def watch_for_cancel_request(
while True:
try:
requested = is_cancel_requested(
reason = get_cancel_reason(
session_id=session_id,
pid=pid,
process_created_at=process_created_at,
)
except Exception:
requested = False
if requested:
reason = None
if reason is not None:
clear_cancel_request(session_id=session_id, pid=pid)
result = on_cancel()
result = on_cancel(reason) if pass_reason else on_cancel()
if asyncio.iscoroutine(result):
await result
return True
+38
View File
@@ -0,0 +1,38 @@
"""Allowlisted cancellation causes carried by asyncio's cancellation message.
Only the owner of an interruption assigns a cause. A bare CancelledError, an
HTTP status or arbitrary message cannot prove who cancelled the operation.
"""
import asyncio
from enum import StrEnum
class CancellationReason(StrEnum):
USER_STOP = "user_stop"
GUIDANCE_REPLACED = "guidance_replaced"
TASK_PAUSED = "task_paused"
TASK_REPLACED = "task_replaced"
SUPERSEDED = "superseded"
OUTER_TIMEOUT = "outer_timeout"
UNKNOWN = "unknown"
def cancellation_reason(error: BaseException | None) -> CancellationReason:
if isinstance(error, KeyboardInterrupt):
return CancellationReason.USER_STOP
if isinstance(error, asyncio.CancelledError) and error.args:
reason = error.args[0]
if isinstance(reason, CancellationReason):
return reason
return CancellationReason.UNKNOWN
def reason_from_code(value: object) -> CancellationReason:
"""Decode only our marker field, never a free-form user instruction."""
if isinstance(value, str):
try:
return CancellationReason(value)
except ValueError:
pass
return CancellationReason.UNKNOWN
+67 -24
View File
@@ -6,12 +6,15 @@ import json
import logging
import os
from pathlib import Path
import sys
import threading
import time
import uuid
from artemis.config import TRACES_PATH
from artemis.runtime.device_lock import DeviceExecutionLock
from artemis.runtime.cancellation import CancellationReason, cancellation_reason
from artemis.runtime.task_progress import TERMINAL, number
logger = logging.getLogger(__name__)
@@ -64,13 +67,41 @@ def _write_atomic(path: Path, data: dict) -> None:
def read_state(session_id: str) -> dict:
try:
return json.loads(
value = json.loads(
(control_dir(session_id) / "task_control_state.json").read_text(encoding="utf-8")
)
return value if isinstance(value, dict) else {}
except (OSError, ValueError):
return {}
def write_control_state(session_id: str, status: str, *, reset: bool = False, **fields) -> None:
"""The worker owns transition timing in its existing control-state file."""
now = time.time()
previous = {} if reset else read_state(session_id)
timing = previous.get("timing")
timing = timing if isinstance(timing, dict) else {}
since = number(timing.get("state_started_at"))
if previous.get("pid") != os.getpid() or since is None or since > now:
timing = {"started_at": now, "state_started_at": now, "seconds": {}}
elif previous.get("status") != status:
seconds = timing.get("seconds")
seconds = seconds if isinstance(seconds, dict) else {}
old = previous.get("status")
seconds[old] = (number(seconds.get(old)) or 0) + now - since
timing = {**timing, "seconds": seconds, "state_started_at": now}
_write_atomic(
control_dir(session_id) / "task_control_state.json",
{
**fields,
"status": status,
"updated_at": now,
"pid": os.getpid(),
"timing": timing,
},
)
def request_pause(session_id: str, *, reason: str | None = None) -> None:
_unlink_control_file(control_dir(session_id) / "resume.request")
_write_atomic(
@@ -109,32 +140,48 @@ def add_instruction(
raise ValueError("指导编号已用于不同内容")
return record
path = directory / f"{instruction_id or f'{time.time_ns():020d}-{uuid.uuid4().hex}'}.json"
accepted_at = time.time()
record = {
"id": path.stem,
"instruction": instruction.strip(),
"release_loop": bool(release_loop),
"source": source,
"timestamp": time.time(),
"timestamp": accepted_at,
"accepted_at": accepted_at,
}
_write_atomic(path, record)
return record
def list_instructions(session_id: str) -> list[dict]:
def list_instructions(session_id: str, *, task_status: str | None = None) -> list[dict]:
"""Show submissions separately from guidance the runner actually consumed."""
directory = control_dir(session_id) / "instructions"
records = []
for folder, status in ((directory, "pending"), (directory / "consumed", "consumed")):
for path in folder.glob("*.json") if folder.exists() else []:
records = {}
for folder, pattern, status in (
(directory, "*.json", "pending"),
(directory, "*.reading", "reading"),
(directory / "consumed", "*.json", "consumed"),
):
for path in folder.glob(pattern) if folder.exists() else []:
try:
record = json.loads(path.read_text(encoding="utf-8"))
if isinstance(record, dict) and record.get("instruction"):
records.append(
{**record, "id": record.get("id") or path.stem, "status": status}
)
if task_status in TERMINAL or task_status == "finalizing":
status_value = {"pending": "unapplied", "reading": "unconfirmed"}.get(
status, status
)
else:
status_value = status
key = record.get("id") or path.stem
records[key] = {
**record,
"id": key,
"status": status_value,
"accepted_at": record.get("accepted_at", record.get("timestamp")),
}
except (OSError, ValueError):
continue
return sorted(records, key=lambda record: (record.get("timestamp", 0), record["id"]))
return sorted(records.values(), key=lambda record: (record.get("timestamp", 0), record["id"]))
def has_pending_instruction(base_dir: str | Path) -> bool:
@@ -187,15 +234,11 @@ class TaskControl:
self.pause_reason: str | None = None
def _state(self, status: str) -> None:
_write_atomic(
self.directory / "task_control_state.json",
{
"status": status,
"updated_at": time.time(),
"pid": os.getpid(),
"budget_epoch": self.budget_epoch,
"pause_reason": self.pause_reason,
},
write_control_state(
self.session_id,
status,
budget_epoch=self.budget_epoch,
pause_reason=self.pause_reason,
)
async def checkpoint(self, *, on_pause=None, on_resume=None) -> bool:
@@ -271,8 +314,8 @@ class TaskControl:
running = asyncio.ensure_future(operation)
pause = self.directory / "pause.request"
async def stop_operation():
running.cancel()
async def stop_operation(reason: CancellationReason):
running.cancel(reason)
result = (await asyncio.gather(running, return_exceptions=True))[0]
if isinstance(result, BaseException) and not isinstance(result, asyncio.CancelledError):
raise result
@@ -284,16 +327,16 @@ class TaskControl:
# that requested pause). The next checkpoint handles the request.
return False, running.result()
if pause.exists():
await stop_operation()
await stop_operation(CancellationReason.TASK_PAUSED)
await self.checkpoint(on_pause=on_pause, on_resume=on_resume)
return True, None
if has_pending_instruction(self.directory):
await stop_operation()
await stop_operation(CancellationReason.GUIDANCE_REPLACED)
return True, None
await asyncio.wait({running}, timeout=0.2)
finally:
if not running.done():
await stop_operation()
await stop_operation(cancellation_reason(sys.exception()))
def release(self) -> None:
self.device_lock.release()
+138
View File
@@ -0,0 +1,138 @@
"""Read-only lifecycle projection; elapsed wall time is never model speed."""
import math
import time
TERMINAL = {"completed", "success", "failed", "cancelled", "skipped", "interrupted"}
def number(value) -> float | None:
if isinstance(value, (float, int)) and not isinstance(value, bool) and math.isfinite(value):
return float(value)
return None
def review_seconds(reviews: list[dict], *, until: float, finished: bool) -> tuple[float, bool]:
intervals = []
complete = True
for review in reviews:
start = number(review.get("started_at"))
end = number(review.get("ended_at"))
if start is None:
complete = False
continue
if end is None:
if not finished and review.get("status") == "running":
end = until
else:
end = number(review.get("updated_at")) or start
complete = False
intervals.append((min(start, until), min(max(start, end), until)))
total = 0.0
last = 0.0
for start, end in sorted(intervals):
total += max(0.0, end - max(start, last))
last = max(last, end)
return round(total, 3), complete
def progress_snapshot(*, job=None, session=None, control=None, reviews=None, now=None) -> dict:
job, session, control, reviews = job or {}, session or {}, control or {}, reviews or []
now = time.time() if now is None else now
status = job.get("status") or session.get("status") or "unknown"
finished = status in TERMINAL or status == "finalizing"
accepted = number(job.get("created_at"))
started = number(job.get("worker_started_at"))
if started is None and session.get("start_time") and status != "queued":
started = number(job.get("started_at"))
ended = (number(job.get("ended_at")) or number(session.get("end_time"))) if finished else None
finalized = number(job.get("finalized_at"))
until = ended if ended is not None else now
observed_start = started or number(session.get("start_time"))
timing = control.get("timing")
timing = timing if isinstance(timing, dict) else {}
since = number(timing.get("state_started_at"))
seconds = timing.get("seconds")
recorded = (
isinstance(seconds, dict)
and since is not None
and since <= until
and (not finished or ended is not None)
)
durations = dict.fromkeys(
("total", "queued", "resource_wait", "paused", "final_review", "execution", "report_export")
)
if accepted is not None:
total_end = (
finalized or (now if status == "finalizing" else ended) or (None if finished else now)
)
durations["total"] = (
round(max(0, total_end - accepted), 3) if total_end is not None else None
)
if started is not None or status in {"pending", "queued"}:
durations["queued"] = round(max(0, (started or until) - accepted), 3)
review_elapsed, review_complete = review_seconds(reviews, until=until, finished=finished)
if reviews or recorded:
durations["final_review"] = review_elapsed
if recorded:
seconds = {key: max(0.0, number(value) or 0.0) for key, value in seconds.items()}
phase = control.get("status")
seconds[phase] = seconds.get(phase, 0.0) + max(0, until - since)
durations["resource_wait"] = round(seconds.get("queued", 0) + seconds.get("resuming", 0), 3)
durations["paused"] = round(
sum(seconds.get(key, 0) for key in ("task_paused", "demonstrating", "analyzing_demo")),
3,
)
if started is not None and review_complete:
durations["execution"] = round(
max(
0,
until
- started
- durations["resource_wait"]
- durations["paused"]
- review_elapsed,
),
3,
)
if ended is not None and (finalized is not None or status == "finalizing"):
durations["report_export"] = round(max(0, (finalized or now) - ended), 3)
phase = "running"
if status in TERMINAL:
phase = "finished"
elif status == "finalizing":
phase = "report_export"
elif status in {"paused", "task_paused"}:
phase = "paused"
elif control.get("status") in {"task_paused", "demonstrating", "analyzing_demo", "resuming"}:
phase = {"task_paused": "paused", "resuming": "waiting_resources"}.get(
control["status"], control["status"]
)
elif control.get("status") == "queued" and started is not None:
phase = "waiting_resources"
elif status in {"queued", "pending", "reserved", "draft"}:
phase = "queued"
elif any(
review.get("status") == "running" and not review.get("ended_at") for review in reviews
):
phase = "final_review"
elif status == "unknown":
phase = "unknown"
return {
"schema_version": 1,
"status": status,
"phase": phase,
"observed_at": (finalized or ended) if status in TERMINAL else now,
"accepted_at": accepted,
"worker_started_at": started,
"worker_ended_at": ended,
"finalized_at": finalized,
"durations": durations,
"observed_seconds": round(max(0, until - observed_start), 3)
if observed_start and (not finished or ended is not None)
else None,
"control_timing_recorded": recorded,
"review_timing_complete": review_complete,
"note": "分项使用实际记录;缺失为未知。执行含初始化与报告整理,不是模型推理耗时。暂停包含人工示范及分析。执行完成不等于验证通过。",
}