261 lines
8.6 KiB
Python
261 lines
8.6 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
import sqlite3
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
|
|
|
from aios_agent_task_projection_recovery import (
|
|
CanonicalTaskProjectionIntent,
|
|
CanonicalTaskProjectionStore,
|
|
ProjectionRecoveryError,
|
|
ProjectionState,
|
|
sha256_json,
|
|
)
|
|
from aios_agent_task_projection_recovery_http import recover_from_json
|
|
|
|
|
|
TASK_ID = "TASK-CONV-V2R2C2-RECONCILE-SNAPSHOT-20260826"
|
|
JOB_ID = "job-9eee1142f2fc8bb7b571d065c01f40e0"
|
|
OID = "2afc2cf41fc9090cf01e3882d1b8db8d54cf1844"
|
|
RUNBOOK_SHA = "a500504a76fb99b26541eaf4edd842a942992ae170fc0f3838530189ca672320"
|
|
|
|
|
|
def make_db(tmp_path: Path) -> Path:
|
|
path = tmp_path / "state.sqlite3"
|
|
con = sqlite3.connect(path)
|
|
con.executescript(
|
|
"""
|
|
CREATE TABLE tasks(
|
|
task_id TEXT PRIMARY KEY,
|
|
goal TEXT NOT NULL,
|
|
instructions TEXT NOT NULL,
|
|
success_criteria_json TEXT NOT NULL,
|
|
required_capabilities_json TEXT NOT NULL,
|
|
constraints_json TEXT NOT NULL,
|
|
metadata_json TEXT NOT NULL,
|
|
deadline TEXT,
|
|
priority INTEGER NOT NULL,
|
|
max_attempts INTEGER NOT NULL,
|
|
attempt INTEGER NOT NULL DEFAULT 0,
|
|
status TEXT NOT NULL,
|
|
claimed_by TEXT,
|
|
lease_token_hash TEXT,
|
|
lease_expires_at TEXT,
|
|
result_json TEXT,
|
|
result_digest TEXT,
|
|
result_idempotency_key TEXT,
|
|
verification_json TEXT,
|
|
revision INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE events(
|
|
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
task_id TEXT,
|
|
agent_id TEXT,
|
|
event_type TEXT NOT NULL,
|
|
payload_json TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
"""
|
|
)
|
|
con.commit()
|
|
con.close()
|
|
return path
|
|
|
|
|
|
def manifest() -> dict:
|
|
task = {
|
|
"task_id": TASK_ID,
|
|
"goal": "Complete V2R2C2 staging convergence",
|
|
"instructions": "Resume the immutable runbook and close the parent goal.",
|
|
"success_criteria": [{"id": "closure", "required": True, "expected": "PASS"}],
|
|
"constraints": {
|
|
"staging_only": True,
|
|
"production_write": False,
|
|
"main_direct_write": False,
|
|
},
|
|
"required_capabilities": [],
|
|
"metadata": {
|
|
"governed_task_id": TASK_ID,
|
|
"bridge_job_id": JOB_ID,
|
|
"runbook_sha256": RUNBOOK_SHA,
|
|
},
|
|
"priority": 100,
|
|
"max_attempts": 20,
|
|
"deadline": None,
|
|
}
|
|
return {
|
|
"schema": "aios.canonical-task-projection-intent.v1",
|
|
"projection_id": "projection-v2r2c2-parent-recovery-20260826",
|
|
"projection_generation": 1,
|
|
"idempotency_key": "projection-v2r2c2-parent-recovery-20260826-v1",
|
|
"task": task,
|
|
"governed_binding": {
|
|
"governed_task_id": TASK_ID,
|
|
"bridge_job_id": JOB_ID,
|
|
"repository": "aios/aios",
|
|
"branch": "agent/v2r2c2-convergence-integration-20260825",
|
|
"generation": 2,
|
|
"base_oid": OID,
|
|
"expected_branch_oid": OID,
|
|
},
|
|
"source_artifact": {
|
|
"ref": f"git:{OID}:runbooks/agent-v2r2c2-control-plane-convergence-20260825.md",
|
|
"sha256": RUNBOOK_SHA,
|
|
},
|
|
"task_payload_sha256": sha256_json(task),
|
|
}
|
|
|
|
|
|
def test_missing_task_is_recovered_with_same_identity(tmp_path):
|
|
db = make_db(tmp_path)
|
|
intent = CanonicalTaskProjectionIntent.from_mapping(manifest())
|
|
result = CanonicalTaskProjectionStore(db).reconcile(intent)
|
|
|
|
assert result["state"] == ProjectionState.CONVERGED.value
|
|
assert result["canonical_task_id"] == TASK_ID
|
|
assert result["bridge_job_id"] == JOB_ID
|
|
assert result["created_new_row"] is True
|
|
|
|
con = sqlite3.connect(db)
|
|
row = con.execute(
|
|
"SELECT task_id,status,attempt,revision,claimed_by,"
|
|
"required_capabilities_json,metadata_json FROM tasks WHERE task_id=?",
|
|
(TASK_ID,),
|
|
).fetchone()
|
|
assert row[:5] == (TASK_ID, "QUEUED", 0, 1, None)
|
|
assert json.loads(row[5]) == []
|
|
metadata = json.loads(row[6])
|
|
assert "execution" not in metadata
|
|
assert "execution_projection" not in metadata
|
|
events = con.execute(
|
|
"SELECT event_type FROM events WHERE task_id=? ORDER BY event_id",
|
|
(TASK_ID,),
|
|
).fetchall()
|
|
assert events == [("TASK_CREATED",)]
|
|
|
|
|
|
def test_replay_is_idempotent_and_creates_no_duplicate_event(tmp_path):
|
|
db = make_db(tmp_path)
|
|
intent = CanonicalTaskProjectionIntent.from_mapping(manifest())
|
|
store = CanonicalTaskProjectionStore(db)
|
|
first = store.reconcile(intent)
|
|
second = store.reconcile(intent)
|
|
|
|
assert first["idempotent"] is False
|
|
assert second["idempotent"] is True
|
|
con = sqlite3.connect(db)
|
|
assert con.execute("SELECT COUNT(*) FROM tasks").fetchone()[0] == 1
|
|
assert con.execute("SELECT COUNT(*) FROM events").fetchone()[0] == 1
|
|
assert con.execute(
|
|
"SELECT COUNT(*) FROM task_projection_receipts"
|
|
).fetchone()[0] == 1
|
|
|
|
|
|
def test_existing_matching_row_converges_without_second_task_created_event(tmp_path):
|
|
db = make_db(tmp_path)
|
|
intent = CanonicalTaskProjectionIntent.from_mapping(manifest())
|
|
store = CanonicalTaskProjectionStore(db)
|
|
store.reconcile(intent)
|
|
|
|
con = sqlite3.connect(db)
|
|
con.execute("DELETE FROM task_projection_receipts")
|
|
con.execute("DELETE FROM task_projection_intents")
|
|
con.commit()
|
|
con.close()
|
|
|
|
result = store.reconcile(intent)
|
|
assert result["created_new_row"] is False
|
|
|
|
con = sqlite3.connect(db)
|
|
assert con.execute("SELECT COUNT(*) FROM tasks").fetchone()[0] == 1
|
|
assert con.execute("SELECT COUNT(*) FROM events").fetchone()[0] == 1
|
|
|
|
|
|
def test_existing_different_task_payload_fails_closed(tmp_path):
|
|
db = make_db(tmp_path)
|
|
intent = CanonicalTaskProjectionIntent.from_mapping(manifest())
|
|
store = CanonicalTaskProjectionStore(db)
|
|
store.reconcile(intent)
|
|
|
|
con = sqlite3.connect(db)
|
|
con.execute("DELETE FROM task_projection_receipts")
|
|
con.execute("DELETE FROM task_projection_intents")
|
|
con.execute("UPDATE tasks SET goal='drifted'")
|
|
con.commit()
|
|
con.close()
|
|
|
|
with pytest.raises(
|
|
ProjectionRecoveryError,
|
|
match="CANONICAL_TASK_PROJECTION_IDENTITY_CONFLICT",
|
|
):
|
|
store.reconcile(intent)
|
|
|
|
|
|
def test_intent_replay_with_same_generation_but_different_request_conflicts(tmp_path):
|
|
db = make_db(tmp_path)
|
|
first = manifest()
|
|
intent = CanonicalTaskProjectionIntent.from_mapping(first)
|
|
store = CanonicalTaskProjectionStore(db)
|
|
store.persist_intent(intent)
|
|
|
|
other = manifest()
|
|
other["task"]["instructions"] = "different"
|
|
other["task_payload_sha256"] = sha256_json(other["task"])
|
|
other_intent = CanonicalTaskProjectionIntent.from_mapping(other)
|
|
with pytest.raises(
|
|
ProjectionRecoveryError,
|
|
match="PROJECTION_INTENT_IDEMPOTENCY_CONFLICT",
|
|
):
|
|
store.persist_intent(other_intent)
|
|
|
|
|
|
def test_recovery_refuses_non_pristine_execution_metadata(tmp_path):
|
|
value = manifest()
|
|
value["task"]["metadata"]["execution"] = {"action": "agent_runtime"}
|
|
value["task_payload_sha256"] = sha256_json(value["task"])
|
|
with pytest.raises(
|
|
ProjectionRecoveryError,
|
|
match="TASK_RECOVERY_REQUIRES_PRISTINE_METADATA",
|
|
):
|
|
CanonicalTaskProjectionIntent.from_mapping(value)
|
|
|
|
|
|
def test_recovery_refuses_runtime_capability_in_pristine_row(tmp_path):
|
|
value = manifest()
|
|
value["task"]["required_capabilities"] = ["agent.runtime.openclaw"]
|
|
value["task_payload_sha256"] = sha256_json(value["task"])
|
|
with pytest.raises(
|
|
ProjectionRecoveryError,
|
|
match="TASK_RECOVERY_REQUIRES_PRISTINE_CAPABILITIES",
|
|
):
|
|
CanonicalTaskProjectionIntent.from_mapping(value)
|
|
|
|
|
|
def test_http_adapter_rejects_path_body_task_mismatch(tmp_path):
|
|
db = make_db(tmp_path)
|
|
status, body = recover_from_json(db, "TASK-other-123", manifest())
|
|
assert status == 409
|
|
assert body["error"] == "PROJECTION_TASK_IDENTITY_MISMATCH"
|
|
|
|
|
|
def test_projection_readback_contains_durable_mapping_and_receipt(tmp_path):
|
|
db = make_db(tmp_path)
|
|
intent = CanonicalTaskProjectionIntent.from_mapping(manifest())
|
|
store = CanonicalTaskProjectionStore(db)
|
|
store.reconcile(intent)
|
|
|
|
projection = store.get_projection(TASK_ID)
|
|
assert projection is not None
|
|
assert projection["task_id"] == TASK_ID
|
|
assert projection["bridge_job_id"] == JOB_ID
|
|
assert projection["state"] == "CONVERGED"
|
|
assert projection["receipt"]["canonical_task_id"] == TASK_ID
|