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:
1 parent
83bcfd54d5
commit
fac4ccb760
146 files changed
+5689
-2033
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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": "分项使用实际记录;缺失为未知。执行含初始化与报告整理,不是模型推理耗时。暂停包含人工示范及分析。执行完成不等于验证通过。",
|
||||
}
|
||||
Reference in new issue
Block a user