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
@@ -19,6 +19,7 @@ import time
|
||||
import pytest
|
||||
|
||||
from artemis.runtime import cancel_requests as cr
|
||||
from artemis.runtime.cancellation import CancellationReason
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -91,3 +92,27 @@ async def test_watch_delivers_once_and_clears_marker():
|
||||
assert await asyncio.wait_for(watcher, timeout=2) is True
|
||||
assert delivered == [1]
|
||||
assert not cr.is_cancel_requested(session_id="w-1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("cause", [CancellationReason.USER_STOP, CancellationReason.UNKNOWN])
|
||||
async def test_watch_forwards_only_the_structured_cause(cause):
|
||||
path = cr.request_cancel(
|
||||
session_id="with-reason", reason="private stop details", cancel_reason=cause
|
||||
)[0]
|
||||
delivered = []
|
||||
assert await cr.watch_for_cancel_request(
|
||||
delivered.append, session_id="with-reason", pid=0, pass_reason=True
|
||||
)
|
||||
assert delivered == [cause]
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_legacy_or_unrecognized_marker_reasons_remain_unknown():
|
||||
path = cr.request_cancel(session_id="legacy", reason="stop from UI")[0]
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
for value in [None, "private user instruction", ["user_stop"]]:
|
||||
payload["cancel_reason"] = value
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
assert cr.get_cancel_reason(session_id="legacy") is CancellationReason.UNKNOWN
|
||||
assert cr.is_cancel_requested(session_id="legacy")
|
||||
@@ -14,6 +14,47 @@ import pytest
|
||||
import artemis.runtime.task_control as task_control
|
||||
|
||||
|
||||
def test_transition_timing_keeps_repeated_states_and_separates_pause_and_resource_wait(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
|
||||
clock = [10]
|
||||
monkeypatch.setattr(
|
||||
task_control,
|
||||
"time",
|
||||
SimpleNamespace(**{**vars(task_control.time), "time": lambda: clock[0]}),
|
||||
)
|
||||
for at, status in (
|
||||
(10, "queued"),
|
||||
(20, "running"),
|
||||
(25, "running"),
|
||||
(30, "task_paused"),
|
||||
(40, "resuming"),
|
||||
(50, "running"),
|
||||
):
|
||||
clock[0] = at
|
||||
task_control.write_control_state("timed-task", status)
|
||||
assert task_control.read_state("timed-task")["timing"] == {
|
||||
"started_at": 10,
|
||||
"state_started_at": 50,
|
||||
"seconds": {"queued": 10, "running": 10, "task_paused": 10, "resuming": 10},
|
||||
}
|
||||
|
||||
|
||||
def test_non_object_control_state_and_invalid_timing_can_be_replaced(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
|
||||
path = task_control.control_dir("broken-state") / "task_control_state.json"
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text("[]", encoding="utf-8")
|
||||
assert task_control.read_state("broken-state") == {}
|
||||
task_control.write_control_state("broken-state", "running")
|
||||
state = task_control.read_state("broken-state")
|
||||
state["timing"] = "invalid"
|
||||
task_control._write_atomic(path, state)
|
||||
task_control.write_control_state("broken-state", "task_paused")
|
||||
assert task_control.read_state("broken-state")["timing"]["seconds"] == {}
|
||||
|
||||
|
||||
def _windows_access_error(code: int) -> PermissionError:
|
||||
error = PermissionError(errno.EACCES, "state file is in use")
|
||||
error.winerror = code
|
||||
@@ -423,6 +464,28 @@ def test_guidance_history_distinguishes_submission_from_runner_consumption(tmp_p
|
||||
assert all(item["status"] == "consumed" and item["consumed_at"] for item in history)
|
||||
|
||||
|
||||
def test_terminal_guidance_reports_no_consumption_and_keeps_original_receipt(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
|
||||
receipt = task_control.add_instruction("task-123", "检查新入口", instruction_id="guidance")
|
||||
history = task_control.list_instructions("task-123", task_status="cancelled")
|
||||
assert history[0]["status"] == "unapplied"
|
||||
assert history[0]["accepted_at"] == receipt["timestamp"]
|
||||
assert "consumed_at" not in history[0]
|
||||
assert task_control.list_instructions("task-123")[0]["status"] == "pending"
|
||||
|
||||
|
||||
def test_claimed_guidance_is_visible_and_not_falsely_reported_as_consumed(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
|
||||
task_control.add_instruction("task-123", "检查新入口", instruction_id="guidance")
|
||||
directory = task_control.control_dir("task-123") / "instructions"
|
||||
(directory / "guidance.json").rename(directory / "guidance.reading")
|
||||
assert task_control.list_instructions("task-123")[0]["status"] == "reading"
|
||||
assert (
|
||||
task_control.list_instructions("task-123", task_status="completed")[0]["status"]
|
||||
== "unconfirmed"
|
||||
)
|
||||
|
||||
|
||||
def test_guidance_history_endpoint_returns_pending_and_consumed(tmp_path, monkeypatch):
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
from artemis.runtime.task_progress import progress_snapshot
|
||||
|
||||
|
||||
def test_queued_time_is_not_execution_time_even_with_legacy_started_at():
|
||||
value = progress_snapshot(job={"status": "queued", "created_at": 10, "started_at": 10}, now=50)
|
||||
assert value["phase"] == "queued"
|
||||
assert value["durations"]["queued"] == 40
|
||||
assert value["durations"]["execution"] is None
|
||||
|
||||
|
||||
def test_progress_splits_wait_pause_review_and_export_without_double_counting():
|
||||
value = progress_snapshot(
|
||||
job={
|
||||
"status": "completed",
|
||||
"created_at": 10,
|
||||
"worker_started_at": 20,
|
||||
"ended_at": 120,
|
||||
"finalized_at": 125,
|
||||
},
|
||||
control={
|
||||
"status": "running",
|
||||
"timing": {
|
||||
"started_at": 20,
|
||||
"state_started_at": 80,
|
||||
"seconds": {"queued": 10, "task_paused": 20, "resuming": 10, "running": 20},
|
||||
},
|
||||
},
|
||||
reviews=[{"started_at": 90, "ended_at": 110}, {"started_at": 100, "ended_at": 115}],
|
||||
now=500,
|
||||
)
|
||||
assert value["phase"] == "finished"
|
||||
assert value["durations"] == {
|
||||
"total": 115,
|
||||
"queued": 10,
|
||||
"resource_wait": 20,
|
||||
"paused": 20,
|
||||
"final_review": 25,
|
||||
"execution": 35,
|
||||
"report_export": 5,
|
||||
}
|
||||
|
||||
|
||||
def test_active_review_has_its_own_phase_and_terminal_review_is_not_still_active():
|
||||
review = {"started_at": 30, "status": "running", "updated_at": 35}
|
||||
live = progress_snapshot(job={"status": "running"}, reviews=[review], now=40)
|
||||
assert live["phase"] == "final_review"
|
||||
assert live["durations"]["final_review"] == 10
|
||||
stopped = progress_snapshot(
|
||||
job={"status": "cancelled", "ended_at": 45}, reviews=[review], now=500
|
||||
)
|
||||
assert stopped["phase"] == "finished"
|
||||
assert stopped["durations"]["final_review"] == 5
|
||||
assert not stopped["review_timing_complete"]
|
||||
|
||||
|
||||
def test_legacy_missing_intervals_are_unknown_rather_than_zero_or_model_speed():
|
||||
value = progress_snapshot(
|
||||
session={"status": "completed", "start_time": 20, "end_time": 100}, now=500
|
||||
)
|
||||
assert value["durations"]["queued"] is None
|
||||
assert value["durations"]["execution"] is None
|
||||
assert value["durations"]["paused"] is None
|
||||
assert value["durations"]["total"] is None
|
||||
assert value["observed_seconds"] == 80
|
||||
|
||||
|
||||
def test_waiting_for_resource_and_pause_are_not_running():
|
||||
for status, phase in (
|
||||
("queued", "waiting_resources"),
|
||||
("resuming", "waiting_resources"),
|
||||
("task_paused", "paused"),
|
||||
):
|
||||
value = progress_snapshot(
|
||||
job={"status": "running", "worker_started_at": 10}, control={"status": status}, now=50
|
||||
)
|
||||
assert value["phase"] == phase
|
||||
|
||||
|
||||
def test_terminal_without_end_timestamp_does_not_keep_increasing_or_guess_duration():
|
||||
job = {"status": "completed", "created_at": 10, "worker_started_at": 20}
|
||||
first = progress_snapshot(job=job, now=100)
|
||||
assert first == progress_snapshot(job=job, now=500)
|
||||
assert first["observed_at"] is None
|
||||
assert all(first["durations"][key] is None for key in ("total", "execution", "report_export"))
|
||||
|
||||
|
||||
def test_export_in_progress_keeps_total_current_without_extending_execution():
|
||||
value = progress_snapshot(
|
||||
job={"status": "finalizing", "created_at": 10, "worker_started_at": 100, "ended_at": 200},
|
||||
now=230,
|
||||
)
|
||||
assert value["phase"] == "report_export"
|
||||
assert value["observed_at"] == 230
|
||||
assert value["durations"]["total"] == 220
|
||||
assert value["durations"]["report_export"] == 30
|
||||
assert value["worker_ended_at"] == 200
|
||||
Reference in new issue
Block a user