Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1047,6 +1047,10 @@ explicit confirmation; TTY interaction and human participation are not prerequis
`--interactive-confirm` is optional. Autonomous runs retain the same authenticated
owner, draftVersion, paramsDigest, confirmationId, expiry, Java save/readback and
restart/recovery checks; this does not auto-confirm ordinary end-user workflows.
Editing a confirmed draft invalidates its confirmation ID. Saving with that ID
returns HTTP 409 (`confirmation_mismatch`, code 40900), including after further
edits; an unconfirmed request with an unrelated ID returns HTTP 400. The internal
invalidation marker is persisted but excluded from public confirmation metadata.
Artifacts identify `confirmation_actor=autonomous` and keep `human_demo_completed`
false. `workflow_demo_completed` requires a non-synthetic completed service flow;
mocked tests remain synthetic and cannot satisfy formal acceptance. Legacy field
Expand Down
4 changes: 2 additions & 2 deletions services/agent/data/repository_corpus_manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@
},
{
"doc_id": "repository-development",
"version": "sha256-f26b77d34a904e1fae301357a5569b657309e95ac1a84ec1e6b6a6d735f130c3",
"chunk_id": "repository-development:sha256-f26b77d34a904e1fae301357a5569b657309e95ac1a84ec1e6b6a6d735f130c3:1",
"version": "sha256-8fd11b59eea6f0663bf45ad4261f6ab2e183b2098b3308c18dc05891c64a2b58",
"chunk_id": "repository-development:sha256-8fd11b59eea6f0663bf45ad4261f6ab2e183b2098b3308c18dc05891c64a2b58:1",
"source_path": "docs/DEVELOPMENT.md",
"access_scope": "repository-public",
"sample_kind": "real",
Expand Down
6 changes: 5 additions & 1 deletion services/agent/src/agent_service/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -711,9 +711,11 @@ async def execute_action(_, analysis_result):
raise AgentError(400, "validation_error")
draft = dict(row["draft"])
draft.update({"title": title, "content": content})
invalidated_id = row["confirmation"].get("id") or row["confirmation"].get("invalidatedId")
changed = workflow_store.transition(thread_id, owner, expected_run=str(row["run_id"]), expected_version=version,
statuses={"awaiting_confirmation", "confirmed"}, changes={"status": "awaiting_confirmation", "draft": draft,
"draft_version": version + 1, "confirmation": {}}, kind="draft_edited")
"draft_version": version + 1,
"confirmation": {"invalidatedId": invalidated_id} if invalidated_id else {}}, kind="draft_edited")
return {"status": changed["status"], "run_id": changed["run_id"],
"draft_version": changed["draft_version"],
"action_result": {"response": _thread_response(changed, owner)}}
Expand Down Expand Up @@ -953,6 +955,8 @@ async def execute_action(_, analysis_result):
"draft_version": version, "action_result": {"response": response}}
digest = params_digest(owner, str(row["source_submission_id"]), version,
str(row["draft"]["title"]), str(row["draft"]["content"]))
if confirmation.get("invalidatedId") == body["confirmationId"]:
raise AgentError(409, "confirmation_mismatch", 40900)
if row["status"] != "confirmed" or confirmation.get("id") != body["confirmationId"]:
raise AgentError(400, "confirmation_required")
if row["cancel_requested"] or row["save_attempted"]:
Expand Down
3 changes: 2 additions & 1 deletion services/agent/tests/test_agent_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,8 @@ async def scenario(client):
assert edited.status_code == 200
response = await client.post(f"/agent/threads/{thread_id}/save", headers=HEADERS,
json={"confirmationId": confirmation_id})
assert response.status_code == 409 if expiry else response.status_code == 400
assert response.status_code == 409
assert response.json()["code"] == 40900
assert posts["count"] == 0

asyncio.run(with_client(app, scenario))
Expand Down
45 changes: 45 additions & 0 deletions services/agent/tests/test_agent_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,51 @@ async def scenario():
asyncio.run(scenario())


def test_edited_draft_rejects_invalidated_confirmation_before_fresh_save(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}, {}, []
app = create_app(state_path=tmp_path / "stale-confirmation.sqlite3",
client_factory=_write_client(posts, saved, post_flags),
u02_gate_path=tmp_path / "gate.json", expected_head="0" * 40)
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
state = (await client.post("/agent/threads", headers=HEADERS,
json={"sourceSubmissionId": str(uuid.uuid4()), "question": "如何复盘?"})).json()["data"]
path = f"/agent/threads/{state['threadId']}"
missing = await client.post(f"{path}/save", headers=HEADERS,
json={"confirmationId": str(uuid.uuid4())})
assert missing.status_code == 400
confirmed = await client.post(f"{path}/confirm", headers=HEADERS,
json={"draftVersion": 1, "paramsDigest": state["paramsDigest"], "confirm": True})
old_id = confirmed.json()["data"]["confirmation"]["id"]
for version in (1, 2):
edited = await client.put(f"{path}/draft", headers=HEADERS,
json={"draftVersion": version, "title": "修改后的计划", "content": "重新确认后才能保存"})
assert edited.status_code == 200
state = edited.json()["data"]
assert state["confirmation"] == {}
stale = await client.post(f"{path}/save", headers=HEADERS,
json={"confirmationId": old_id})
assert stale.status_code == 409
assert stale.json()["code"] == 40900
assert posts["count"] == 0
unrelated = await client.post(f"{path}/save", headers=HEADERS,
json={"confirmationId": str(uuid.uuid4())})
assert unrelated.status_code == 400
fresh = await client.post(f"{path}/confirm", headers=HEADERS,
json={"draftVersion": 3, "paramsDigest": state["paramsDigest"], "confirm": True})
assert fresh.status_code == 200
result = await client.post(f"{path}/save", headers=HEADERS,
json={"confirmationId": fresh.json()["data"]["confirmation"]["id"]})
assert result.status_code == 200
assert result.json()["data"]["status"] == "saved"
assert posts["count"] == 1

asyncio.run(scenario())


def test_state_mutations_run_inside_dispatch_node(tmp_path, monkeypatch):
monkeypatch.setattr("agent_service.app.load_u02_gate", lambda *args, **kwargs: {"test_only": True})

Expand Down
Loading