233 lines
13 KiB
Python
233 lines
13 KiB
Python
"""A closed batch deadline keeps only a proved prefix, without a success/retry."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from copy import deepcopy
|
|
|
|
import pytest
|
|
|
|
from apps.server.tests.test_discovery import item, search_candidate
|
|
from apps.server.tests.test_learning import client, initialize
|
|
from apps.server.tests.test_postgres import pg_client
|
|
from apps.server.tests.test_reading import COMMENTS, NOTE_ID, NOTE_URL, TITLE, comment_output, detail_output, learned_detail
|
|
from apps.server.worker import BrowserWorker, WorkerError
|
|
|
|
|
|
@pytest.fixture(params=["client", "pg_client"])
|
|
def batch_budget_client(request):
|
|
return request.getfixturevalue(request.param)
|
|
|
|
|
|
def test_batch_deadline_preserves_proved_prefix_revokes_ready_and_never_retries(batch_budget_client):
|
|
client = batch_budget_client
|
|
initialize(client)
|
|
_, search = search_candidate(client, [item(TITLE)])
|
|
response = client.post("/api/discovery/runs", json={"search_candidate_id": search["id"], "keyword": "准备装修"})
|
|
assert response.status_code == 201 and response.json()["status"] == "completed", response.text
|
|
run = response.json()
|
|
task, candidate, _ = learned_detail(client, seek=True)
|
|
task_endpoint = "/api/learning/tasks/" + task["id"]
|
|
endpoint = "/api/discovery/runs/" + run["id"] + "/readings"
|
|
payload = {"detail_candidate_id": candidate["id"], "note_id": NOTE_ID}
|
|
response = client.post(endpoint, json=payload)
|
|
assert response.status_code == 201 and response.json()["status"] == "completed", response.text
|
|
original_reading = response.json()
|
|
store = client.app_reference.state.store
|
|
originals = {
|
|
"candidate": store.one("SELECT digest,manifest_json FROM candidates WHERE id=$1", (candidate["id"],)),
|
|
"run": store.one("SELECT data_json FROM discovery_runs WHERE id=$1", (run["id"],)),
|
|
"reading": store.one("SELECT data_json FROM reading_runs WHERE id=$1", (original_reading["id"],)),
|
|
"validation": store.one("SELECT data_json FROM validations WHERE id=$1", (original_reading["validation_id"],)),
|
|
}
|
|
worker = client.worker_reference
|
|
original_call = worker.call
|
|
completed_prefix = comment_output()
|
|
completed_prefix["batch_summary"].update(duplicates_removed=1, stop_reason="time_budget")
|
|
completed_prefix["skipped_incomplete_count"] = 1
|
|
expected_timings = []
|
|
|
|
async def deadline_after_completed_batch(method, params):
|
|
value = await original_call(method, params)
|
|
if method == "verify" and params.get("note_binding"):
|
|
# This is a contract substitute: one complete, captured, source-bound
|
|
# batch preceded a later interruption. It performs no browser work.
|
|
batch_capture = worker.capture(params["task_id"])
|
|
batch_capture.update(url=NOTE_URL, text="本机合成的完整首层评论批次")
|
|
value["evidence"].append(batch_capture)
|
|
expected_timings[:] = [
|
|
{"step_position": index, "kind": step["kind"], "status": "passed", "duration_ms": 10,
|
|
"phase_ms": {"execute": 4, "capture_after": 6}}
|
|
for index, step in enumerate(params["steps"][:-1], 1)
|
|
] + [{"step_position": len(params["steps"]), "kind": "extract", "status": "interrupted",
|
|
"duration_ms": 15000, "phase_ms": {"execute": 80, "batch_capture": 14920}}]
|
|
value.update(
|
|
passed=False,
|
|
outputs={"note_detail": detail_output(), "comments": deepcopy(completed_prefix)},
|
|
errors=["批次预算已到,仅保留此前完整取证的评论前缀"],
|
|
error_codes=["BATCH_BUDGET"],
|
|
session_closed=True,
|
|
step_timings=deepcopy(expected_timings),
|
|
verify_timing={"duration_ms": 15200, "entry_ms": 100, "close_ms": 30},
|
|
)
|
|
worker.sessions.pop(params["task_id"], None) # Actual boolean close acknowledgment.
|
|
return value
|
|
|
|
worker.call = deadline_after_completed_batch
|
|
response = client.post(endpoint, json=payload)
|
|
assert response.status_code == 201, response.text
|
|
partial = response.json()
|
|
assert partial["status"] == "partial" and partial["model_connected"] is False
|
|
assert partial["detail"] == detail_output() and partial["comments"] == completed_prefix
|
|
assert partial["comments"]["count"] == 1 and partial["comments"]["truncated"] is True
|
|
assert partial["comments"]["batch_summary"]["stop_reason"] == "time_budget"
|
|
assert partial["error_codes"] == ["BATCH_BUDGET"] and partial["errors"]
|
|
assert partial["note_binding_result"] == {
|
|
"note_id": NOTE_ID, "href_checked": True, "url_checked": True, "source_url": NOTE_URL, "title": TITLE,
|
|
}
|
|
assert partial["seek_results"][0]["target_reached"] is True
|
|
assert partial["step_timings"] == expected_timings
|
|
assert partial["verify_timing"] == {"duration_ms": 15200, "entry_ms": 100, "close_ms": 30}
|
|
assert len(partial["evidence"]) == 2
|
|
assert all(client.get(evidence["screenshot_url"]).status_code == 200 for evidence in partial["evidence"])
|
|
|
|
after = client.get(task_endpoint).json()
|
|
assert after["status"] == "paused" and after["browser_status"] == "closed"
|
|
assert after["browser_readiness"]["ready"] is False
|
|
assert after["browser_readiness"]["next_action"] == "open_browser"
|
|
validation = next(value for value in after["validations"] if value["id"] == partial["validation_id"])
|
|
assert validation["status"] == "failed" and validation["error_codes"] == ["BATCH_BUDGET"]
|
|
assert validation["environment"]["browser_session_closed"] is True
|
|
assert validation["environment"]["verify_budget_ms"] == 95000
|
|
assert validation["actual_outputs"] == {"note_detail": detail_output(), "comments": completed_prefix}
|
|
assert validation["evidence"] == partial["evidence"]
|
|
assert validation["step_timings"] == partial["step_timings"]
|
|
assert validation["verify_timing"] == partial["verify_timing"]
|
|
# Fixed output/source assertions may pass on this finite prefix. The failed
|
|
# worker execution must still prevent completed reading or a passed trial.
|
|
checks = {check["name"]: check["passed"] for check in validation["assertion_checks"]}
|
|
assert all(checks[name] for name in (
|
|
"output_schema:note_detail", "output_nonempty:note_detail", "output_schema:comments",
|
|
"output_nonempty:comments", "note_source_binding",
|
|
))
|
|
|
|
calls_before = len(worker.calls)
|
|
response = client.post(endpoint, json=payload)
|
|
assert response.status_code == 201, response.text
|
|
stopped = response.json()
|
|
assert stopped["status"] == "failed" and stopped["validation_id"] is None
|
|
assert stopped["detail"] is None and stopped["comments"] is None
|
|
assert stopped["error_codes"] == ["browser_session_required"]
|
|
assert worker.calls[calls_before:] == [] # No status/open/ready/verify or implicit retry.
|
|
assert client.get("/api/discovery/readings/" + partial["id"]).json() == partial
|
|
assert client.get("/api/discovery/readings/" + original_reading["id"]).json() == original_reading
|
|
assert store.one("SELECT digest,manifest_json FROM candidates WHERE id=$1", (candidate["id"],)) == originals["candidate"]
|
|
assert store.one("SELECT data_json FROM discovery_runs WHERE id=$1", (run["id"],)) == originals["run"]
|
|
assert store.one("SELECT data_json FROM reading_runs WHERE id=$1", (original_reading["id"],)) == originals["reading"]
|
|
assert store.one("SELECT data_json FROM validations WHERE id=$1", (original_reading["validation_id"],)) == originals["validation"]
|
|
|
|
|
|
@pytest.mark.parametrize("close_ack", [True, "true", False], ids=["closed", "string-is-not-ack", "not-closed"])
|
|
def test_direct_batch_failure_only_boolean_close_revokes_ready_without_recording_or_retry(batch_budget_client, close_ack):
|
|
client = batch_budget_client
|
|
initialize(client)
|
|
# The reading helper normally follows search_candidate, whose fake capture
|
|
# declares the ordinary search input. This direct-action fixture has no
|
|
# discovery step, so declare that same required observation explicitly.
|
|
initial_capture = client.worker_reference.capture
|
|
|
|
def capture_with_search_semantics(task_id):
|
|
value = initial_capture(task_id)
|
|
next(element for element in value["elements"] if element["selector"] == '[data-testid="search-input"]')["search_input"] = True
|
|
return value
|
|
|
|
client.worker_reference.capture = capture_with_search_semantics
|
|
task, candidate, _ = learned_detail(client)
|
|
endpoint = "/api/learning/tasks/" + task["id"]
|
|
service, worker = client.app_reference.state.learning, client.worker_reference
|
|
original_call = worker.call
|
|
original_steps = deepcopy(service.steps(task["id"]))
|
|
original_candidate = service.store.one("SELECT digest,manifest_json FROM candidates WHERE id=$1", (candidate["id"],))
|
|
original_observations = len(client.get(endpoint).json()["observations"])
|
|
before, batch = worker.capture(task["id"]), worker.capture(task["id"])
|
|
batch["text"] = "本机合成批次已完整取证,后续预算中断"
|
|
attempts = []
|
|
|
|
async def interrupted(method, params):
|
|
if method == "action":
|
|
attempts.append(deepcopy(params))
|
|
if close_ack is True:
|
|
worker.sessions.pop(params["task_id"], None)
|
|
raise WorkerError(
|
|
"批次时间预算已到", "BATCH_BUDGET", before=before,
|
|
batch_evidence=[{"batch_index": 1, "added_count": 1, "duplicate_count": 0, "capture": batch}],
|
|
diagnostics={"phase": "execute", "blocked_requests": [], "blocked_request_count": 0},
|
|
session_closed=close_ack,
|
|
)
|
|
return await original_call(method, params)
|
|
|
|
worker.call = interrupted
|
|
response = client.post(endpoint + "/browser/action", json=COMMENTS)
|
|
assert response.status_code == 409, response.text
|
|
failure = response.json()["detail"]
|
|
assert failure["code"] == "BATCH_BUDGET" and failure["evidence_status"] == "recorded"
|
|
assert len(attempts) == 1 and service.steps(task["id"]) == original_steps
|
|
assert len(failure["batch_evidence_ids"]) == 1 and len(failure["failure_observation_ids"]) == 2
|
|
after = client.get(endpoint).json()
|
|
observations = after["observations"][original_observations:]
|
|
assert [value["id"] for value in observations] == failure["failure_observation_ids"]
|
|
assert [value["source"] for value in observations] == ["action_failure", "action_failure_batch"]
|
|
assert observations[1]["text"] == batch["text"]
|
|
assert observations[1]["batch"] == {"batch_index": 1, "added_count": 1, "duplicate_count": 0}
|
|
assert all(client.get(value["screenshot_url"]).status_code == 200 for value in observations)
|
|
assert service.store.one("SELECT digest,manifest_json FROM candidates WHERE id=$1", (candidate["id"],)) == original_candidate
|
|
assert after["browser_readiness"]["ready"] is (close_ack is not True)
|
|
assert after["browser_status"] == ("closed" if close_ack is True else "ready")
|
|
if close_ack is True:
|
|
assert after["browser_readiness"]["next_action"] == "open_browser"
|
|
calls_before = len(worker.calls)
|
|
stopped = client.post(endpoint + "/browser/action", json=COMMENTS)
|
|
assert stopped.status_code == 409 and stopped.json()["detail"]["code"] == "browser_session_required"
|
|
assert worker.calls[calls_before:] == [] and len(attempts) == 1
|
|
assert service.steps(task["id"]) == original_steps
|
|
assert len(client.get(endpoint).json()["observations"]) == len(after["observations"])
|
|
|
|
|
|
@pytest.mark.parametrize("close_ack", [True, "true", False], ids=["closed", "string-is-not-ack", "not-closed"])
|
|
def test_batch_close_ack_survives_rpc_only_as_actual_boolean(tmp_path, close_ack):
|
|
payload = {
|
|
"code": "BATCH_BUDGET", "message": "合成批次预算失败", "session_closed": close_ack,
|
|
"before": {"url": "http://127.0.0.1/fixture", "elements": [], "screenshot_path": str(tmp_path / "before.png")},
|
|
"batch_evidence": [{"batch_index": 1, "added_count": 1, "duplicate_count": 0, "capture": {
|
|
"url": "http://127.0.0.1/fixture", "elements": [], "screenshot_path": str(tmp_path / "batch.png"),
|
|
}}],
|
|
"diagnostics": {"phase": "execute", "blocked_requests": [], "blocked_request_count": 0},
|
|
}
|
|
|
|
class Pipe:
|
|
def write(self, value):
|
|
self.request = json.loads(value)
|
|
|
|
async def drain(self):
|
|
pass
|
|
|
|
async def readline(self):
|
|
return json.dumps({"id": self.request["id"], "error": payload}).encode()
|
|
|
|
class Process:
|
|
returncode = None
|
|
stdin = stdout = Pipe()
|
|
|
|
async def check():
|
|
worker = BrowserWorker(tmp_path, tmp_path)
|
|
worker.process = Process()
|
|
with pytest.raises(WorkerError) as failure:
|
|
await worker.call("action", {"task_id": "batch-fixture"})
|
|
error = failure.value
|
|
assert error.code == "BATCH_BUDGET" and error.session_closed is (close_ack is True)
|
|
assert error.before["screenshot_path"].endswith("before.png") and error.after is None
|
|
assert error.batch_evidence[0]["capture"]["screenshot_path"].endswith("batch.png")
|
|
assert error.diagnostics["phase"] == "execute"
|
|
|
|
asyncio.run(check())
|