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
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user