"""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())