diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index cdda07c83..2050a39ec 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -39,8 +39,10 @@ cannot select a user, model, graph node, or checkpoint. Workflow states include `draft`, `analyzing`, `awaiting_confirmation`, `confirmed`, `saving`, `saved`, `unknown`, `failed`, and `cancelled`. Agent SQLite is canonical for drafts, workflow state, and events; LangGraph checkpoints are resumable control state only. `saved` requires an accepted -Java record response. A timeout, lost response, or uncertain process restart remains `unknown`; -reconcile by the same idempotency key before any retry. A by-key lookup is treated as not found only +Java record response. A timeout, lost response, or uncertain process restart remains `unknown`. +recovering an interrupted `saving` state with no Java record and `retry: false` persists `unknown` +without issuing another write. A later explicit retry retains the original business key and guards. +Reconcile by the same idempotency key before any retry. A by-key lookup is treated as not found only for HTTP 404 with business code `40400`; transport/server errors remain unresolved. Retry is never automatic: `retry:true` is an explicit action and still requires an eligible unexpired confirmation, matching payload, no cancellation, and the exact not-found result. Never mint a replacement key. diff --git a/services/agent/data/repository_corpus_manifest.json b/services/agent/data/repository_corpus_manifest.json index 31974ac2c..d5076af48 100644 --- a/services/agent/data/repository_corpus_manifest.json +++ b/services/agent/data/repository_corpus_manifest.json @@ -57,14 +57,14 @@ }, { "doc_id": "repository-reference", - "version": "sha256-e8f12fdbbc43d1ce042911c7d8c106ce22784de8c277a38d6dc272d8971e6906", - "chunk_id": "repository-reference:sha256-e8f12fdbbc43d1ce042911c7d8c106ce22784de8c277a38d6dc272d8971e6906:1", + "version": "sha256-c83b0fb388f997a93164377f16ab6aadb8cf0f6e7b1219f96561473ecfa613f8", + "chunk_id": "repository-reference:sha256-c83b0fb388f997a93164377f16ab6aadb8cf0f6e7b1219f96561473ecfa613f8:1", "source_path": "docs/REFERENCE.md", "access_scope": "repository-public", "sample_kind": "real", "permission": "MIT:LICENSE:sha256:2be36e9d56578c1111740b6fe339ea3619cf1f2000af718f1a59e97d2e5d995c", "scope": "Repository documentation; local retrieval and evaluation under the included MIT license", - "source_position": "lines 160-176", + "source_position": "lines 162-178", "model_input_projection": "SourceHit.as_model_dict()", "source_trust": "untrusted-data", "content_digest": "sha256:2bfd12ab6b6dc61d6e9fc80afd8337e6639509336df35a3d77bbd37757e6f036" diff --git a/services/agent/src/agent_service/app.py b/services/agent/src/agent_service/app.py index 1516f7125..4c8df051a 100644 --- a/services/agent/src/agent_service/app.py +++ b/services/agent/src/agent_service/app.py @@ -1114,6 +1114,12 @@ def completed(current): changes={"failure_reason": "readback_unavailable"}, kind="readback_unavailable") return completed(latest) if body["retry"] is not True: + if row["status"] == "saving": + latest = workflow_store.transition(thread_id, owner, expected_run=run_id, + expected_version=version, statuses={"saving"}, + changes={"status": "unknown", "failure_reason": "save_result_unknown"}, + kind="save_unknown") + return completed(latest) return completed(row) if row["retry_blocked"]: raise AgentError(409, "idempotency_payload_mismatch", 40900) diff --git a/services/agent/tests/test_agent_service.py b/services/agent/tests/test_agent_service.py index e78e22270..ef1c37dad 100644 --- a/services/agent/tests/test_agent_service.py +++ b/services/agent/tests/test_agent_service.py @@ -906,6 +906,47 @@ def recording_transition(*args, **kwargs): asyncio.run(scenario()) +def test_restart_before_java_dispatch_recovers_unknown_without_reposting(tmp_path, monkeypatch): + monkeypatch.setattr("agent_service.app.load_u02_gate", lambda *args, **kwargs: {"test_only": True}) + + async def scenario(): + posts, saved, post_flags = {"count": 0}, {}, [] + state_path = tmp_path / "intent-restart.sqlite3" + + def build(): + return create_app(state_path=state_path, client_factory=_write_client(posts, saved, post_flags), + u02_gate_path=tmp_path / "gate.json", expected_head="0" * 40) + + app = build() + async with app.router.lifespan_context(app): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + created = (await client.post("/agent/threads", headers=HEADERS, + json={"sourceSubmissionId": str(uuid.uuid4()), "question": "如何复盘?"})).json()["data"] + confirmed = (await client.post(f"/agent/threads/{created['threadId']}/confirm", headers=HEADERS, + json={"draftVersion": 1, "paramsDigest": created["paramsDigest"], "confirm": True})).json()["data"] + app.state.store.transition(created["threadId"], "owner-1", expected_run=created["runId"], + expected_version=1, statuses={"confirmed"}, + changes={"status": "saving", "save_attempted": 1}, kind="save_intent") + + restarted = build() + async with restarted.router.lifespan_context(restarted): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=restarted), base_url="http://test") as client: + path = f"/agent/threads/{created['threadId']}" + for _ in range(2): + response = await client.post(f"{path}/recover", headers=HEADERS, json={"retry": False}) + assert response.status_code == 200 + assert response.json()["data"]["status"] == "unknown" + assert posts["count"] == 0 + current = restarted.state.store.get(created["threadId"], "owner-1") + assert current["save_attempted"] == 1 + assert current["confirmation"]["id"] == confirmed["confirmation"]["id"] + response = await client.post(f"{path}/recover", headers=HEADERS, json={"retry": True}) + assert response.json()["data"]["status"] == "saved" + assert posts["count"] == 1 + + asyncio.run(scenario()) + + def test_every_action_leaves_graph_at_await_action_and_restart_replays_nothing(tmp_path, monkeypatch): monkeypatch.setattr("agent_service.app.load_u02_gate", lambda *args, **kwargs: {"test_only": True})