Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5f8209f57 |
@@ -0,0 +1,829 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Durable online task protocol for external AIOS agents.
|
||||
|
||||
The store is deliberately independent from Git/Gitea mutation. It provides a
|
||||
small durable state machine for publishing work, atomic claim/lease ownership,
|
||||
heartbeat, idempotent result submission, explicit server-side verification,
|
||||
lease recovery, and audit events.
|
||||
|
||||
No plaintext agent credential or task lease token is persisted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import sqlite3
|
||||
from typing import Any, Callable, Iterable, Mapping
|
||||
import uuid
|
||||
|
||||
from aios_capability_contract import has_capabilities
|
||||
|
||||
|
||||
TERMINAL_STATES = frozenset({"SUCCEEDED", "FAILED", "EXPIRED", "CANCELLED"})
|
||||
ACTIVE_LEASE_STATES = frozenset({"CLAIMED", "RUNNING"})
|
||||
TASK_STATES = frozenset({
|
||||
"QUEUED", "CLAIMED", "RUNNING", "VERIFYING",
|
||||
"SUCCEEDED", "FAILED", "EXPIRED", "CANCELLED",
|
||||
})
|
||||
VERIFY_DECISIONS = frozenset({"SUCCEEDED", "NEEDS_REWORK", "FAILED"})
|
||||
|
||||
|
||||
class TaskProtocolError(RuntimeError):
|
||||
def __init__(self, code: str, http_status: int = 409) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.http_status = http_status
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Principal:
|
||||
kind: str
|
||||
identifier: str
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def iso(value: datetime) -> str:
|
||||
return value.astimezone(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def parse_time(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
parsed = datetime.fromisoformat(value)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def digest_json(value: Any) -> str:
|
||||
return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _string_list(value: Iterable[object] | None, field: str, *, max_items: int = 128) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (str, bytes)) or not isinstance(value, Iterable):
|
||||
raise TaskProtocolError(field.upper() + "_INVALID", 400)
|
||||
result: list[str] = []
|
||||
for item in value:
|
||||
text = str(item).strip()
|
||||
if not text or len(text) > 160:
|
||||
raise TaskProtocolError(field.upper() + "_INVALID", 400)
|
||||
result.append(text)
|
||||
if len(result) > max_items:
|
||||
raise TaskProtocolError(field.upper() + "_TOO_LARGE", 400)
|
||||
return sorted(set(result))
|
||||
|
||||
|
||||
def _json_object(value: object | None, field: str, *, max_bytes: int = 128 * 1024) -> dict[str, Any]:
|
||||
if value is None:
|
||||
return {}
|
||||
if not isinstance(value, Mapping):
|
||||
raise TaskProtocolError(field.upper() + "_INVALID", 400)
|
||||
result = dict(value)
|
||||
if len(canonical_json(result).encode("utf-8")) > max_bytes:
|
||||
raise TaskProtocolError(field.upper() + "_TOO_LARGE", 413)
|
||||
return result
|
||||
|
||||
|
||||
def _criteria(value: object | None) -> list[Any]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (str, bytes)) or not isinstance(value, list):
|
||||
raise TaskProtocolError("SUCCESS_CRITERIA_INVALID", 400)
|
||||
if len(value) > 128:
|
||||
raise TaskProtocolError("SUCCESS_CRITERIA_TOO_LARGE", 413)
|
||||
if len(canonical_json(value).encode("utf-8")) > 128 * 1024:
|
||||
raise TaskProtocolError("SUCCESS_CRITERIA_TOO_LARGE", 413)
|
||||
return list(value)
|
||||
|
||||
|
||||
class TaskStore:
|
||||
"""SQLite-backed durable task store.
|
||||
|
||||
SQLite BEGIN IMMEDIATE serializes claims so two agents cannot successfully
|
||||
claim the same queued task. WAL mode allows concurrent readers.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
now: Callable[[], datetime] = utcnow,
|
||||
token_pepper: str = "",
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.now = now
|
||||
self.token_pepper = token_pepper
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.path, timeout=10, isolation_level=None)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("PRAGMA synchronous=FULL")
|
||||
connection.execute("PRAGMA busy_timeout=10000")
|
||||
return connection
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
agent_id TEXT PRIMARY KEY,
|
||||
agent_type TEXT NOT NULL,
|
||||
agent_version TEXT NOT NULL,
|
||||
capabilities_json TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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,
|
||||
FOREIGN KEY(claimed_by) REFERENCES agents(agent_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS idempotency (
|
||||
scope TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
payload_digest TEXT NOT NULL,
|
||||
response_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(scope, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_queue
|
||||
ON tasks(status, priority DESC, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_lease
|
||||
ON tasks(status, lease_expires_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_task
|
||||
ON events(task_id, event_id);
|
||||
"""
|
||||
)
|
||||
|
||||
def _token_hash(self, token: str) -> str:
|
||||
return hashlib.sha256((self.token_pepper + "\0" + token).encode("utf-8")).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _row_task(row: sqlite3.Row, *, include_result: bool = True) -> dict[str, Any]:
|
||||
value: dict[str, Any] = {
|
||||
"task_id": row["task_id"],
|
||||
"goal": row["goal"],
|
||||
"instructions": row["instructions"],
|
||||
"success_criteria": json.loads(row["success_criteria_json"]),
|
||||
"required_capabilities": json.loads(row["required_capabilities_json"]),
|
||||
"constraints": json.loads(row["constraints_json"]),
|
||||
"metadata": json.loads(row["metadata_json"]),
|
||||
"deadline": row["deadline"],
|
||||
"priority": row["priority"],
|
||||
"max_attempts": row["max_attempts"],
|
||||
"attempt": row["attempt"],
|
||||
"status": row["status"],
|
||||
"claimed_by": row["claimed_by"],
|
||||
"lease_expires_at": row["lease_expires_at"],
|
||||
"revision": row["revision"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
if include_result:
|
||||
value["result"] = json.loads(row["result_json"]) if row["result_json"] else None
|
||||
value["verification"] = (
|
||||
json.loads(row["verification_json"]) if row["verification_json"] else None
|
||||
)
|
||||
return value
|
||||
|
||||
def _event(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
event_type: str,
|
||||
*,
|
||||
task_id: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
payload: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
connection.execute(
|
||||
"INSERT INTO events(task_id,agent_id,event_type,payload_json,created_at)"
|
||||
" VALUES(?,?,?,?,?)",
|
||||
(task_id, agent_id, event_type, canonical_json(dict(payload or {})), iso(self.now())),
|
||||
)
|
||||
|
||||
def register_agent(
|
||||
self,
|
||||
*,
|
||||
agent_id: str,
|
||||
agent_type: str,
|
||||
agent_version: str,
|
||||
capabilities: Iterable[object],
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
agent_id = agent_id.strip()
|
||||
agent_type = agent_type.strip()
|
||||
agent_version = agent_version.strip()
|
||||
if not agent_id or len(agent_id) > 160 or not agent_type or not agent_version:
|
||||
raise TaskProtocolError("AGENT_IDENTITY_INVALID", 400)
|
||||
caps = _string_list(capabilities, "capabilities")
|
||||
meta = _json_object(metadata, "metadata")
|
||||
token = "agt_" + secrets.token_urlsafe(32)
|
||||
now = iso(self.now())
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"INSERT INTO agents(agent_id,agent_type,agent_version,capabilities_json,"
|
||||
"metadata_json,token_hash,status,created_at,last_seen_at)"
|
||||
" VALUES(?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
agent_id,
|
||||
agent_type,
|
||||
agent_version,
|
||||
canonical_json(caps),
|
||||
canonical_json(meta),
|
||||
self._token_hash(token),
|
||||
"ACTIVE",
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
"AGENT_REGISTERED",
|
||||
agent_id=agent_id,
|
||||
payload={"agent_type": agent_type, "agent_version": agent_version},
|
||||
)
|
||||
connection.commit()
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise TaskProtocolError("AGENT_ALREADY_EXISTS", 409) from exc
|
||||
return {
|
||||
"agent_id": agent_id,
|
||||
"agent_type": agent_type,
|
||||
"agent_version": agent_version,
|
||||
"capabilities": caps,
|
||||
"agent_token": token,
|
||||
"credential_returned_once": True,
|
||||
}
|
||||
|
||||
def authenticate_agent(self, token: str) -> dict[str, Any]:
|
||||
if not token:
|
||||
raise TaskProtocolError("UNAUTHORIZED", 401)
|
||||
token_hash = self._token_hash(token)
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM agents WHERE token_hash=? AND status='ACTIVE'",
|
||||
(token_hash,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("UNAUTHORIZED", 401)
|
||||
connection.execute(
|
||||
"UPDATE agents SET last_seen_at=? WHERE agent_id=?",
|
||||
(iso(self.now()), row["agent_id"]),
|
||||
)
|
||||
return {
|
||||
"agent_id": row["agent_id"],
|
||||
"agent_type": row["agent_type"],
|
||||
"agent_version": row["agent_version"],
|
||||
"capabilities": json.loads(row["capabilities_json"]),
|
||||
"metadata": json.loads(row["metadata_json"]),
|
||||
}
|
||||
|
||||
def _idempotent_lookup(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
scope: str,
|
||||
key: str,
|
||||
payload_digest: str,
|
||||
) -> dict[str, Any] | None:
|
||||
row = connection.execute(
|
||||
"SELECT payload_digest,response_json FROM idempotency WHERE scope=? AND key=?",
|
||||
(scope, key),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
if not hmac.compare_digest(row["payload_digest"], payload_digest):
|
||||
raise TaskProtocolError("IDEMPOTENCY_CONFLICT", 409)
|
||||
return json.loads(row["response_json"])
|
||||
|
||||
def _idempotent_store(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
scope: str,
|
||||
key: str,
|
||||
payload_digest: str,
|
||||
response: Mapping[str, Any],
|
||||
) -> None:
|
||||
connection.execute(
|
||||
"INSERT INTO idempotency(scope,key,payload_digest,response_json,created_at)"
|
||||
" VALUES(?,?,?,?,?)",
|
||||
(scope, key, payload_digest, canonical_json(dict(response)), iso(self.now())),
|
||||
)
|
||||
|
||||
def create_task(
|
||||
self,
|
||||
*,
|
||||
task_id: str | None,
|
||||
goal: str,
|
||||
instructions: str,
|
||||
success_criteria: object | None,
|
||||
required_capabilities: Iterable[object] | None = None,
|
||||
constraints: Mapping[str, Any] | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
deadline: str | None = None,
|
||||
priority: int = 0,
|
||||
max_attempts: int = 3,
|
||||
idempotency_key: str,
|
||||
) -> dict[str, Any]:
|
||||
task_id = (task_id or ("task-" + uuid.uuid4().hex)).strip()
|
||||
goal = goal.strip()
|
||||
instructions = instructions.strip()
|
||||
idempotency_key = idempotency_key.strip()
|
||||
if not task_id or len(task_id) > 180 or not goal or not instructions:
|
||||
raise TaskProtocolError("TASK_BINDING_INVALID", 400)
|
||||
if not idempotency_key or len(idempotency_key) > 160:
|
||||
raise TaskProtocolError("IDEMPOTENCY_KEY_INVALID", 400)
|
||||
if not isinstance(priority, int) or isinstance(priority, bool) or not -100 <= priority <= 100:
|
||||
raise TaskProtocolError("PRIORITY_INVALID", 400)
|
||||
if not isinstance(max_attempts, int) or isinstance(max_attempts, bool) or not 1 <= max_attempts <= 20:
|
||||
raise TaskProtocolError("MAX_ATTEMPTS_INVALID", 400)
|
||||
parsed_deadline = parse_time(deadline)
|
||||
if parsed_deadline and parsed_deadline <= self.now():
|
||||
raise TaskProtocolError("DEADLINE_INVALID", 400)
|
||||
criteria = _criteria(success_criteria)
|
||||
required = _string_list(required_capabilities, "required_capabilities")
|
||||
constraints_value = _json_object(constraints, "constraints")
|
||||
metadata_value = _json_object(metadata, "metadata")
|
||||
payload = {
|
||||
"task_id": task_id,
|
||||
"goal": goal,
|
||||
"instructions": instructions,
|
||||
"success_criteria": criteria,
|
||||
"required_capabilities": required,
|
||||
"constraints": constraints_value,
|
||||
"metadata": metadata_value,
|
||||
"deadline": iso(parsed_deadline) if parsed_deadline else None,
|
||||
"priority": priority,
|
||||
"max_attempts": max_attempts,
|
||||
}
|
||||
payload_digest = digest_json(payload)
|
||||
now = iso(self.now())
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
replay = self._idempotent_lookup(
|
||||
connection,
|
||||
scope="create_task",
|
||||
key=idempotency_key,
|
||||
payload_digest=payload_digest,
|
||||
)
|
||||
if replay is not None:
|
||||
connection.commit()
|
||||
return {**replay, "idempotent": True}
|
||||
try:
|
||||
connection.execute(
|
||||
"INSERT INTO tasks(task_id,goal,instructions,success_criteria_json,"
|
||||
"required_capabilities_json,constraints_json,metadata_json,deadline,priority,"
|
||||
"max_attempts,attempt,status,revision,created_at,updated_at)"
|
||||
" VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
task_id,
|
||||
goal,
|
||||
instructions,
|
||||
canonical_json(criteria),
|
||||
canonical_json(required),
|
||||
canonical_json(constraints_value),
|
||||
canonical_json(metadata_value),
|
||||
payload["deadline"],
|
||||
priority,
|
||||
max_attempts,
|
||||
0,
|
||||
"QUEUED",
|
||||
1,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise TaskProtocolError("TASK_ID_CONFLICT", 409) from exc
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
result = self._row_task(row)
|
||||
self._idempotent_store(
|
||||
connection,
|
||||
scope="create_task",
|
||||
key=idempotency_key,
|
||||
payload_digest=payload_digest,
|
||||
response=result,
|
||||
)
|
||||
self._event(connection, "TASK_CREATED", task_id=task_id, payload={"priority": priority})
|
||||
connection.commit()
|
||||
return result
|
||||
|
||||
def _expire_locked(self, connection: sqlite3.Connection) -> None:
|
||||
now_dt = self.now()
|
||||
now = iso(now_dt)
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM tasks WHERE status IN ('QUEUED','CLAIMED','RUNNING')"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
deadline = parse_time(row["deadline"])
|
||||
if deadline is not None and deadline <= now_dt:
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status='EXPIRED',claimed_by=NULL,lease_token_hash=NULL,"
|
||||
"lease_expires_at=NULL,revision=revision+1,updated_at=? WHERE task_id=?",
|
||||
(now, row["task_id"]),
|
||||
)
|
||||
self._event(connection, "TASK_EXPIRED", task_id=row["task_id"])
|
||||
continue
|
||||
if row["status"] in ACTIVE_LEASE_STATES:
|
||||
lease_expires = parse_time(row["lease_expires_at"])
|
||||
if lease_expires is not None and lease_expires <= now_dt:
|
||||
if row["attempt"] >= row["max_attempts"]:
|
||||
next_state = "FAILED"
|
||||
else:
|
||||
next_state = "QUEUED"
|
||||
if next_state == "FAILED":
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status=?,lease_token_hash=NULL,"
|
||||
"lease_expires_at=NULL,revision=revision+1,updated_at=? WHERE task_id=?",
|
||||
(next_state, now, row["task_id"]),
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status=?,claimed_by=NULL,lease_token_hash=NULL,"
|
||||
"lease_expires_at=NULL,revision=revision+1,updated_at=? WHERE task_id=?",
|
||||
(next_state, now, row["task_id"]),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
"LEASE_EXPIRED",
|
||||
task_id=row["task_id"],
|
||||
agent_id=row["claimed_by"],
|
||||
payload={"next_state": next_state, "attempt": row["attempt"]},
|
||||
)
|
||||
|
||||
def expire_leases(self) -> int:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
before = connection.total_changes
|
||||
self._expire_locked(connection)
|
||||
changed = connection.total_changes - before
|
||||
connection.commit()
|
||||
return changed
|
||||
|
||||
def poll_tasks(self, *, agent_id: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||
if not 1 <= int(limit) <= 100:
|
||||
raise TaskProtocolError("LIMIT_INVALID", 400)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
self._expire_locked(connection)
|
||||
agent = connection.execute(
|
||||
"SELECT capabilities_json FROM agents WHERE agent_id=? AND status='ACTIVE'",
|
||||
(agent_id,),
|
||||
).fetchone()
|
||||
if agent is None:
|
||||
raise TaskProtocolError("AGENT_NOT_FOUND", 404)
|
||||
capabilities = json.loads(agent["capabilities_json"])
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM tasks WHERE status='QUEUED' ORDER BY priority DESC,created_at ASC"
|
||||
).fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
required = json.loads(row["required_capabilities_json"])
|
||||
if has_capabilities(capabilities, required):
|
||||
result.append(self._row_task(row, include_result=False))
|
||||
if len(result) >= int(limit):
|
||||
break
|
||||
connection.commit()
|
||||
return result
|
||||
|
||||
def _agent_capabilities(self, connection: sqlite3.Connection, agent_id: str) -> set[str]:
|
||||
row = connection.execute(
|
||||
"SELECT capabilities_json FROM agents WHERE agent_id=? AND status='ACTIVE'",
|
||||
(agent_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("AGENT_NOT_FOUND", 404)
|
||||
return set(json.loads(row["capabilities_json"]))
|
||||
|
||||
def claim_task(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
lease_seconds: int = 300,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(lease_seconds, int) or isinstance(lease_seconds, bool) or not 30 <= lease_seconds <= 3600:
|
||||
raise TaskProtocolError("LEASE_SECONDS_INVALID", 400)
|
||||
lease_token = "lease_" + secrets.token_urlsafe(32)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
self._expire_locked(connection)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
if row["status"] != "QUEUED":
|
||||
raise TaskProtocolError("TASK_NOT_CLAIMABLE", 409)
|
||||
capabilities = self._agent_capabilities(connection, agent_id)
|
||||
required = json.loads(row["required_capabilities_json"])
|
||||
if not has_capabilities(capabilities, required):
|
||||
raise TaskProtocolError("AGENT_CAPABILITY_MISMATCH", 403)
|
||||
attempt = int(row["attempt"]) + 1
|
||||
if attempt > int(row["max_attempts"]):
|
||||
raise TaskProtocolError("TASK_ATTEMPTS_EXHAUSTED", 409)
|
||||
expires = self.now() + timedelta(seconds=lease_seconds)
|
||||
now = iso(self.now())
|
||||
updated = connection.execute(
|
||||
"UPDATE tasks SET status='CLAIMED',claimed_by=?,lease_token_hash=?,"
|
||||
"lease_expires_at=?,attempt=?,revision=revision+1,updated_at=? "
|
||||
"WHERE task_id=? AND status='QUEUED'",
|
||||
(
|
||||
agent_id,
|
||||
self._token_hash(lease_token),
|
||||
iso(expires),
|
||||
attempt,
|
||||
now,
|
||||
task_id,
|
||||
),
|
||||
)
|
||||
if updated.rowcount != 1:
|
||||
raise TaskProtocolError("TASK_CLAIM_CONFLICT", 409)
|
||||
self._event(
|
||||
connection,
|
||||
"TASK_CLAIMED",
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
payload={"attempt": attempt, "lease_expires_at": iso(expires)},
|
||||
)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
connection.commit()
|
||||
return {
|
||||
**self._row_task(row, include_result=False),
|
||||
"lease_token": lease_token,
|
||||
"lease_token_returned_once": True,
|
||||
}
|
||||
|
||||
def _validate_lease(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
lease_token: str,
|
||||
allowed_states: frozenset[str] = ACTIVE_LEASE_STATES,
|
||||
) -> sqlite3.Row:
|
||||
self._expire_locked(connection)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
if row["status"] not in allowed_states:
|
||||
raise TaskProtocolError("TASK_LEASE_NOT_ACTIVE", 409)
|
||||
if row["claimed_by"] != agent_id:
|
||||
raise TaskProtocolError("TASK_LEASE_OWNER_MISMATCH", 403)
|
||||
if not row["lease_token_hash"] or not hmac.compare_digest(
|
||||
row["lease_token_hash"], self._token_hash(lease_token)
|
||||
):
|
||||
raise TaskProtocolError("TASK_LEASE_TOKEN_INVALID", 403)
|
||||
expires = parse_time(row["lease_expires_at"])
|
||||
if expires is None or expires <= self.now():
|
||||
raise TaskProtocolError("TASK_LEASE_EXPIRED", 409)
|
||||
return row
|
||||
|
||||
def heartbeat(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
lease_token: str,
|
||||
progress: Mapping[str, Any] | None = None,
|
||||
lease_seconds: int = 300,
|
||||
) -> dict[str, Any]:
|
||||
if not 30 <= int(lease_seconds) <= 3600:
|
||||
raise TaskProtocolError("LEASE_SECONDS_INVALID", 400)
|
||||
progress_value = _json_object(progress, "progress", max_bytes=64 * 1024)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
self._validate_lease(
|
||||
connection,
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
lease_token=lease_token,
|
||||
)
|
||||
expires = self.now() + timedelta(seconds=int(lease_seconds))
|
||||
now = iso(self.now())
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status='RUNNING',lease_expires_at=?,revision=revision+1,"
|
||||
"updated_at=? WHERE task_id=?",
|
||||
(iso(expires), now, task_id),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
"TASK_HEARTBEAT",
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
payload={"progress": progress_value, "lease_expires_at": iso(expires)},
|
||||
)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
connection.commit()
|
||||
return self._row_task(row, include_result=False)
|
||||
|
||||
def submit_result(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
lease_token: str,
|
||||
idempotency_key: str,
|
||||
result: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
idempotency_key = idempotency_key.strip()
|
||||
if not idempotency_key or len(idempotency_key) > 160:
|
||||
raise TaskProtocolError("IDEMPOTENCY_KEY_INVALID", 400)
|
||||
result_value = _json_object(result, "result", max_bytes=1024 * 1024)
|
||||
result_digest = digest_json(result_value)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
if row["result_idempotency_key"] == idempotency_key:
|
||||
if not row["result_digest"] or not hmac.compare_digest(row["result_digest"], result_digest):
|
||||
raise TaskProtocolError("IDEMPOTENCY_CONFLICT", 409)
|
||||
connection.commit()
|
||||
return {**self._row_task(row), "idempotent": True}
|
||||
self._validate_lease(
|
||||
connection,
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
lease_token=lease_token,
|
||||
)
|
||||
now = iso(self.now())
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status='VERIFYING',result_json=?,result_digest=?,"
|
||||
"result_idempotency_key=?,lease_token_hash=NULL,lease_expires_at=NULL,"
|
||||
"revision=revision+1,updated_at=? WHERE task_id=?",
|
||||
(
|
||||
canonical_json(result_value),
|
||||
result_digest,
|
||||
idempotency_key,
|
||||
now,
|
||||
task_id,
|
||||
),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
"RESULT_SUBMITTED",
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
payload={"result_digest": result_digest, "idempotency_key": idempotency_key},
|
||||
)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
connection.commit()
|
||||
return self._row_task(row)
|
||||
|
||||
def verify_task(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
decision: str,
|
||||
verification: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
decision = decision.strip().upper()
|
||||
if decision not in VERIFY_DECISIONS:
|
||||
raise TaskProtocolError("VERIFICATION_DECISION_INVALID", 400)
|
||||
verification_value = _json_object(verification, "verification")
|
||||
verification_value = {"decision": decision, **verification_value}
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
if row["status"] != "VERIFYING":
|
||||
raise TaskProtocolError("TASK_NOT_VERIFYING", 409)
|
||||
if decision == "SUCCEEDED":
|
||||
next_state = "SUCCEEDED"
|
||||
elif decision == "FAILED":
|
||||
next_state = "FAILED"
|
||||
elif int(row["attempt"]) >= int(row["max_attempts"]):
|
||||
next_state = "FAILED"
|
||||
verification_value["reason"] = verification_value.get(
|
||||
"reason", "MAX_ATTEMPTS_EXHAUSTED"
|
||||
)
|
||||
else:
|
||||
next_state = "QUEUED"
|
||||
now = iso(self.now())
|
||||
if next_state in {"SUCCEEDED", "FAILED"}:
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status=?,claimed_by=NULL,lease_token_hash=NULL,"
|
||||
"lease_expires_at=NULL,verification_json=?,revision=revision+1,updated_at=? "
|
||||
"WHERE task_id=?",
|
||||
(next_state, canonical_json(verification_value), now, task_id),
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status=?,claimed_by=NULL,lease_token_hash=NULL,"
|
||||
"lease_expires_at=NULL,verification_json=?,revision=revision+1,updated_at=? "
|
||||
"WHERE task_id=?",
|
||||
(next_state, canonical_json(verification_value), now, task_id),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
"TASK_VERIFIED",
|
||||
task_id=task_id,
|
||||
agent_id=row["claimed_by"],
|
||||
payload={"decision": decision, "next_state": next_state},
|
||||
)
|
||||
updated = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
connection.commit()
|
||||
return self._row_task(updated)
|
||||
|
||||
def cancel_task(self, *, task_id: str, reason: str = "") -> dict[str, Any]:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
if row["status"] in TERMINAL_STATES:
|
||||
connection.commit()
|
||||
return self._row_task(row)
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status='CANCELLED',lease_token_hash=NULL,"
|
||||
"lease_expires_at=NULL,revision=revision+1,updated_at=? WHERE task_id=?",
|
||||
(iso(self.now()), task_id),
|
||||
)
|
||||
self._event(connection, "TASK_CANCELLED", task_id=task_id, payload={"reason": reason})
|
||||
updated = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
connection.commit()
|
||||
return self._row_task(updated)
|
||||
|
||||
def get_task(self, task_id: str) -> dict[str, Any]:
|
||||
self.expire_leases()
|
||||
with self._connect() as connection:
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
return self._row_task(row)
|
||||
|
||||
def get_events(self, task_id: str, *, limit: int = 200) -> list[dict[str, Any]]:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM events WHERE task_id=? ORDER BY event_id DESC LIMIT ?",
|
||||
(task_id, max(1, min(int(limit), 1000))),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"event_id": row["event_id"],
|
||||
"task_id": row["task_id"],
|
||||
"agent_id": row["agent_id"],
|
||||
"event_type": row["event_type"],
|
||||
"payload": json.loads(row["payload_json"]),
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
for row in reversed(rows)
|
||||
]
|
||||
@@ -0,0 +1,972 @@
|
||||
"""AIOS-native binding layer for the unattended resume controller.
|
||||
|
||||
This module intentionally contains no HTTP client, Unix-socket client, database,
|
||||
systemd integration, scheduler loop, secret handling, or privileged identity
|
||||
mutation.
|
||||
|
||||
It converts existing AIOS authorities into the generic callbacks defined by
|
||||
``autonomous_resume_integration``. Field code supplies the current authoritative
|
||||
implementations for:
|
||||
|
||||
- durable Owner authorization/grant projection;
|
||||
- canonical governed-worker activation/status;
|
||||
- governed task admission;
|
||||
- task/claim/TaskLease/TaskPrincipal/WriterLease/CAS ownership;
|
||||
- bounded source-lane execution and integration;
|
||||
- Gate0;
|
||||
- ReleaseReady/deployability;
|
||||
- PromotionController;
|
||||
- final production acceptance;
|
||||
- mission resume lease/checkpoint/effect reconciliation.
|
||||
|
||||
The adapter is deterministic and fail-closed. It never fabricates any credential,
|
||||
principal, lease, fence, task, commit, receipt, approval, or deployment result.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Mapping, Protocol, Sequence
|
||||
|
||||
from .autonomous_resume_controller import (
|
||||
EffectState,
|
||||
MissionCheckpoint,
|
||||
MissionObservation,
|
||||
MissionPhase,
|
||||
ResumeLease,
|
||||
StepResult,
|
||||
StepStatus,
|
||||
)
|
||||
from .autonomous_resume_integration import (
|
||||
AuthorityCallbacks,
|
||||
CallableAutonomousExecutionPort,
|
||||
PhaseCallbacks,
|
||||
)
|
||||
|
||||
|
||||
def _canonical(value) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _digest(value) -> str:
|
||||
return hashlib.sha256(_canonical(value)).hexdigest()
|
||||
|
||||
|
||||
class WorkerState(str, Enum):
|
||||
ACTIVE = "ACTIVE"
|
||||
STARTING = "STARTING"
|
||||
INACTIVE = "INACTIVE"
|
||||
BLOCKED = "BLOCKED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class WorkState(str, Enum):
|
||||
PENDING = "PENDING"
|
||||
RUNNING = "RUNNING"
|
||||
COMPLETE = "COMPLETE"
|
||||
BLOCKED = "BLOCKED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class GateState(str, Enum):
|
||||
PASS = "PASS"
|
||||
HOLD = "HOLD"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class ReadyState(str, Enum):
|
||||
READY = "READY"
|
||||
NOT_READY = "NOT_READY"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class PromotionState(str, Enum):
|
||||
IN_PROGRESS = "IN_PROGRESS"
|
||||
VERIFIED = "VERIFIED"
|
||||
ROLLED_BACK = "ROLLED_BACK"
|
||||
BLOCKED = "BLOCKED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class AcceptanceState(str, Enum):
|
||||
PASS = "PASS"
|
||||
FAIL_ROLLED_BACK = "FAIL_ROLLED_BACK"
|
||||
BLOCKED = "BLOCKED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DurableGrantView:
|
||||
grant_id: str
|
||||
active: bool
|
||||
repository: str
|
||||
allowed_target_prefixes: tuple[str, ...]
|
||||
max_task_risk: str
|
||||
expires_at: float | None = None
|
||||
owner_action_required: bool = False
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
def permits(self, *, repository: str, branch: str, risk: str) -> bool:
|
||||
if not self.active:
|
||||
return False
|
||||
if self.repository != repository:
|
||||
return False
|
||||
if not any(branch.startswith(prefix) for prefix in self.allowed_target_prefixes):
|
||||
return False
|
||||
rank = {"low": 0, "medium": 1, "engineering": 2, "high": 3}
|
||||
if risk not in rank or self.max_task_risk not in rank:
|
||||
return False
|
||||
return rank[risk] <= rank[self.max_task_risk]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskLaneSpec:
|
||||
lane: str
|
||||
task_id: str
|
||||
repository: str
|
||||
target_branch: str
|
||||
risk: str
|
||||
description: str
|
||||
write_set: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.lane or not self.task_id or not self.repository:
|
||||
raise ValueError("lane_task_repository_required")
|
||||
if not self.target_branch.startswith("agent/"):
|
||||
raise ValueError("target_branch_must_use_agent_prefix")
|
||||
if not self.write_set:
|
||||
raise ValueError("write_set_required")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskAdmissionRequest:
|
||||
task_id: str
|
||||
idempotency_key: str
|
||||
repository: str
|
||||
base_oid: str
|
||||
target_branch: str
|
||||
expected_branch_oid: str
|
||||
risk: str
|
||||
description: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskAdmissionReceipt:
|
||||
task_id: str
|
||||
generation: int
|
||||
target_branch: str
|
||||
persisted: bool
|
||||
claimable: bool
|
||||
receipt_ref: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.generation < 1:
|
||||
raise ValueError("task_generation_must_be_positive")
|
||||
if not self.receipt_ref:
|
||||
raise ValueError("task_admission_receipt_required")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkerActivationReceipt:
|
||||
state: WorkerState
|
||||
receipt_ref: str | None = None
|
||||
retry_after_seconds: int | None = None
|
||||
owner_action_required: bool = False
|
||||
code: str = "WORKER_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LaneExecutionView:
|
||||
lane: str
|
||||
state: WorkState
|
||||
condition_fingerprint: str
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
retry_after_seconds: int | None = None
|
||||
code: str = "LANE_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntegrationView:
|
||||
state: WorkState
|
||||
condition_fingerprint: str
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
retry_after_seconds: int | None = None
|
||||
code: str = "INTEGRATION_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GateView:
|
||||
state: GateState
|
||||
condition_fingerprint: str
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
retry_after_seconds: int | None = None
|
||||
code: str = "GATE_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseReadyView:
|
||||
state: ReadyState
|
||||
condition_fingerprint: str
|
||||
material_digest: str | None
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
retry_after_seconds: int | None = None
|
||||
code: str = "READY_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PromotionView:
|
||||
state: PromotionState
|
||||
condition_fingerprint: str
|
||||
material_digest: str | None
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
retry_after_seconds: int | None = None
|
||||
code: str = "PROMOTION_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AcceptanceView:
|
||||
state: AcceptanceState
|
||||
condition_fingerprint: str
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
code: str = "ACCEPTANCE_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MissionEffectView:
|
||||
effect_state: EffectState
|
||||
condition_fingerprint: str
|
||||
requires_reconcile: bool
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class AiosMissionAuthorities(Protocol):
|
||||
"""Existing AIOS authority surface consumed by this adapter."""
|
||||
|
||||
# Live truth.
|
||||
def read_head_oid(self, repository: str) -> str: ...
|
||||
def read_material_digest(self, mission_id: str) -> str: ...
|
||||
def read_generation(self, mission_id: str) -> int: ...
|
||||
def read_effect_view(self, mission_id: str) -> MissionEffectView: ...
|
||||
def owner_action_resolved(self, mission_id: str) -> bool: ...
|
||||
|
||||
# Durable mission state / one-winner resume authority.
|
||||
def load_checkpoint(self, mission_id: str) -> MissionCheckpoint | None: ...
|
||||
def acquire_resume_lease(
|
||||
self,
|
||||
mission_id: str,
|
||||
generation: int,
|
||||
idempotency_key: str,
|
||||
lease_seconds: int,
|
||||
) -> ResumeLease | None: ...
|
||||
def persist_checkpoint(
|
||||
self,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> None: ...
|
||||
def persist_owner_action_required(
|
||||
self,
|
||||
payload: Mapping[str, object],
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> str: ...
|
||||
def release_resume_lease(
|
||||
self,
|
||||
lease: ResumeLease,
|
||||
outcome_code: str,
|
||||
) -> None: ...
|
||||
def reconcile_effects(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult: ...
|
||||
|
||||
# Admission.
|
||||
def read_durable_grant(self, mission_id: str) -> DurableGrantView: ...
|
||||
def ensure_canonical_worker(
|
||||
self,
|
||||
mission_id: str,
|
||||
generation: int,
|
||||
idempotency_key: str,
|
||||
) -> WorkerActivationReceipt: ...
|
||||
def create_or_resume_task(
|
||||
self,
|
||||
request: TaskAdmissionRequest,
|
||||
) -> TaskAdmissionReceipt: ...
|
||||
def read_task_admission(
|
||||
self,
|
||||
lane: TaskLaneSpec,
|
||||
) -> TaskAdmissionReceipt | None: ...
|
||||
|
||||
# Phase execution.
|
||||
def read_lane_execution(
|
||||
self,
|
||||
lane: TaskLaneSpec,
|
||||
task: TaskAdmissionReceipt,
|
||||
) -> LaneExecutionView: ...
|
||||
def drive_integration(
|
||||
self,
|
||||
lanes: Sequence[TaskLaneSpec],
|
||||
tasks: Sequence[TaskAdmissionReceipt],
|
||||
) -> IntegrationView: ...
|
||||
def run_gate0(self, mission_id: str) -> GateView: ...
|
||||
def build_release_ready(self, mission_id: str) -> ReleaseReadyView: ...
|
||||
def run_promotion(self, mission_id: str) -> PromotionView: ...
|
||||
def run_final_acceptance(self, mission_id: str) -> AcceptanceView: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AiosUnattendedMissionConfig:
|
||||
mission_id: str
|
||||
repository: str
|
||||
lanes: tuple[TaskLaneSpec, ...]
|
||||
admission_risk: str = "medium"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.mission_id or not self.repository:
|
||||
raise ValueError("mission_and_repository_required")
|
||||
if not self.lanes:
|
||||
raise ValueError("at_least_one_lane_required")
|
||||
names = [lane.lane for lane in self.lanes]
|
||||
task_ids = [lane.task_id for lane in self.lanes]
|
||||
branches = [lane.target_branch for lane in self.lanes]
|
||||
if len(names) != len(set(names)):
|
||||
raise ValueError("duplicate_lane")
|
||||
if len(task_ids) != len(set(task_ids)):
|
||||
raise ValueError("duplicate_task_id")
|
||||
if len(branches) != len(set(branches)):
|
||||
raise ValueError("duplicate_target_branch")
|
||||
owners: dict[str, str] = {}
|
||||
for lane in self.lanes:
|
||||
if lane.repository != self.repository:
|
||||
raise ValueError("lane_repository_mismatch")
|
||||
for resource in lane.write_set:
|
||||
if resource in owners:
|
||||
raise ValueError(
|
||||
f"overlapping_lane_resource:{resource}:"
|
||||
f"{owners[resource]}:{lane.lane}"
|
||||
)
|
||||
owners[resource] = lane.lane
|
||||
|
||||
|
||||
class AiosUnattendedCallbackFactory:
|
||||
"""Construct generic controller callbacks from current AIOS authorities."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
authority: AiosMissionAuthorities,
|
||||
config: AiosUnattendedMissionConfig,
|
||||
) -> None:
|
||||
self.authority = authority
|
||||
self.config = config
|
||||
self._admissions: dict[str, TaskAdmissionReceipt] = {}
|
||||
|
||||
def build_port(self) -> CallableAutonomousExecutionPort:
|
||||
return CallableAutonomousExecutionPort(
|
||||
AuthorityCallbacks(
|
||||
read_observation=self.read_observation,
|
||||
load_checkpoint=self.authority.load_checkpoint,
|
||||
acquire_resume_lease=self.authority.acquire_resume_lease,
|
||||
persist_checkpoint=self.authority.persist_checkpoint,
|
||||
persist_owner_action_required=self.authority.persist_owner_action_required,
|
||||
release_resume_lease=self.authority.release_resume_lease,
|
||||
reconcile=self.authority.reconcile_effects,
|
||||
),
|
||||
PhaseCallbacks(
|
||||
admission=self.admission,
|
||||
source_execution=self.source_execution,
|
||||
gate0=self.gate0,
|
||||
release_ready=self.release_ready,
|
||||
promotion=self.promotion,
|
||||
final_acceptance=self.final_acceptance,
|
||||
),
|
||||
)
|
||||
|
||||
def read_observation(self, mission_id: str) -> MissionObservation:
|
||||
if mission_id != self.config.mission_id:
|
||||
raise ValueError("mission_id_mismatch")
|
||||
generation = self.authority.read_generation(mission_id)
|
||||
head_oid = self.authority.read_head_oid(self.config.repository)
|
||||
material = self.authority.read_material_digest(mission_id)
|
||||
effect = self.authority.read_effect_view(mission_id)
|
||||
fingerprint = _digest({
|
||||
"schema": "aios.unattended-condition.v1",
|
||||
"mission_id": mission_id,
|
||||
"generation": generation,
|
||||
"head_oid": head_oid,
|
||||
"material_digest": material,
|
||||
"effect_state": effect.effect_state.value,
|
||||
"effect_condition": effect.condition_fingerprint,
|
||||
"requires_reconcile": effect.requires_reconcile,
|
||||
})
|
||||
return MissionObservation(
|
||||
mission_id=mission_id,
|
||||
generation=generation,
|
||||
head_oid=head_oid,
|
||||
material_digest=material,
|
||||
condition_fingerprint=fingerprint,
|
||||
effect_state=effect.effect_state,
|
||||
requires_reconcile=effect.requires_reconcile,
|
||||
owner_action_resolved=self.authority.owner_action_resolved(mission_id),
|
||||
evidence_refs=effect.receipt_refs,
|
||||
)
|
||||
|
||||
def admission(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
grant = self.authority.read_durable_grant(observation.mission_id)
|
||||
grant_refs = tuple(grant.evidence_refs)
|
||||
|
||||
if not grant.active:
|
||||
return StepResult(
|
||||
StepStatus.BLOCKED_OWNER if grant.owner_action_required
|
||||
else StepStatus.BLOCKED_DETERMINISTIC,
|
||||
"DURABLE_GRANT_NOT_ACTIVE",
|
||||
self._phase_fingerprint(
|
||||
"admission", observation, {"grant_id": grant.grant_id, "active": False}
|
||||
),
|
||||
receipt_refs=grant_refs,
|
||||
owner_action_payload=(
|
||||
{
|
||||
"boundary": "DURABLE_GRANT_NOT_ACTIVE",
|
||||
"grant_id": grant.grant_id,
|
||||
}
|
||||
if grant.owner_action_required else None
|
||||
),
|
||||
)
|
||||
|
||||
for lane in self.config.lanes:
|
||||
if not grant.permits(
|
||||
repository=lane.repository,
|
||||
branch=lane.target_branch,
|
||||
risk=lane.risk,
|
||||
):
|
||||
return StepResult(
|
||||
StepStatus.BLOCKED_OWNER if grant.owner_action_required
|
||||
else StepStatus.BLOCKED_DETERMINISTIC,
|
||||
"DURABLE_GRANT_SCOPE_MISMATCH",
|
||||
self._phase_fingerprint(
|
||||
"admission",
|
||||
observation,
|
||||
{
|
||||
"grant_id": grant.grant_id,
|
||||
"lane": lane.lane,
|
||||
"branch": lane.target_branch,
|
||||
"risk": lane.risk,
|
||||
},
|
||||
),
|
||||
receipt_refs=grant_refs,
|
||||
owner_action_payload=(
|
||||
{
|
||||
"boundary": "DURABLE_GRANT_SCOPE_MISMATCH",
|
||||
"grant_id": grant.grant_id,
|
||||
"lane": lane.lane,
|
||||
}
|
||||
if grant.owner_action_required else None
|
||||
),
|
||||
)
|
||||
|
||||
worker = self.authority.ensure_canonical_worker(
|
||||
observation.mission_id,
|
||||
observation.generation,
|
||||
self._idempotency(
|
||||
"worker",
|
||||
observation.mission_id,
|
||||
observation.generation,
|
||||
),
|
||||
)
|
||||
worker_refs = self._refs(grant_refs, (worker.receipt_ref or "",))
|
||||
|
||||
if worker.state is WorkerState.UNKNOWN:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
worker.code,
|
||||
self._phase_fingerprint(
|
||||
"worker", observation, {"state": worker.state.value}
|
||||
),
|
||||
receipt_refs=worker_refs,
|
||||
)
|
||||
if worker.state is WorkerState.BLOCKED:
|
||||
return StepResult(
|
||||
StepStatus.BLOCKED_OWNER if worker.owner_action_required
|
||||
else StepStatus.BLOCKED_DETERMINISTIC,
|
||||
worker.code,
|
||||
self._phase_fingerprint(
|
||||
"worker", observation, {"state": worker.state.value}
|
||||
),
|
||||
receipt_refs=worker_refs,
|
||||
owner_action_payload=(
|
||||
{"boundary": worker.code}
|
||||
if worker.owner_action_required else None
|
||||
),
|
||||
)
|
||||
if worker.state in {WorkerState.STARTING, WorkerState.INACTIVE}:
|
||||
return StepResult(
|
||||
StepStatus.IN_PROGRESS,
|
||||
worker.code,
|
||||
self._phase_fingerprint(
|
||||
"worker", observation, {"state": worker.state.value}
|
||||
),
|
||||
receipt_refs=worker_refs,
|
||||
retry_after_seconds=worker.retry_after_seconds,
|
||||
)
|
||||
if worker.state is WorkerState.ACTIVE and not worker.receipt_ref:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"WORKER_ACTIVE_WITHOUT_RECEIPT",
|
||||
self._phase_fingerprint(
|
||||
"worker", observation, {"state": worker.state.value}
|
||||
),
|
||||
receipt_refs=worker_refs,
|
||||
)
|
||||
|
||||
task_refs: list[str] = list(worker_refs)
|
||||
admissions: dict[str, TaskAdmissionReceipt] = {}
|
||||
for lane in self.config.lanes:
|
||||
request = TaskAdmissionRequest(
|
||||
task_id=lane.task_id,
|
||||
idempotency_key=self._idempotency(
|
||||
"task",
|
||||
observation.generation,
|
||||
lane.task_id,
|
||||
observation.head_oid,
|
||||
),
|
||||
repository=lane.repository,
|
||||
base_oid=observation.head_oid,
|
||||
target_branch=lane.target_branch,
|
||||
expected_branch_oid=observation.head_oid,
|
||||
risk=lane.risk,
|
||||
description=lane.description,
|
||||
)
|
||||
receipt = self.authority.create_or_resume_task(request)
|
||||
task_refs.append(receipt.receipt_ref)
|
||||
receipt_error = self._admission_receipt_error(
|
||||
lane,
|
||||
receipt,
|
||||
observation=observation,
|
||||
)
|
||||
if receipt_error is not None:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
receipt_error,
|
||||
self._phase_fingerprint(
|
||||
"task",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(task_refs),
|
||||
)
|
||||
if not receipt.persisted:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"TASK_PERSISTENCE_UNKNOWN",
|
||||
self._phase_fingerprint(
|
||||
"task",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(task_refs),
|
||||
)
|
||||
if not receipt.claimable:
|
||||
return StepResult(
|
||||
StepStatus.IN_PROGRESS,
|
||||
"TASK_NOT_CLAIMABLE_YET",
|
||||
self._phase_fingerprint(
|
||||
"task",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(task_refs),
|
||||
)
|
||||
admissions[lane.lane] = receipt
|
||||
|
||||
self._admissions = admissions
|
||||
return StepResult(
|
||||
StepStatus.SUCCEEDED,
|
||||
"GOVERNED_TASKS_ADMITTED",
|
||||
self._phase_fingerprint(
|
||||
"admission",
|
||||
observation,
|
||||
{"tasks": sorted(r.task_id for r in admissions.values())},
|
||||
),
|
||||
receipt_refs=tuple(task_refs),
|
||||
next_phase=MissionPhase.S2_EXECUTION,
|
||||
)
|
||||
|
||||
def source_execution(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
recovery_refs: list[str] = []
|
||||
for lane in self.config.lanes:
|
||||
if lane.lane in self._admissions:
|
||||
continue
|
||||
receipt = self.authority.read_task_admission(lane)
|
||||
if receipt is None:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"TASK_ADMISSION_NOT_RESOLVED",
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(recovery_refs),
|
||||
)
|
||||
recovery_refs.append(receipt.receipt_ref)
|
||||
receipt_error = self._admission_receipt_error(
|
||||
lane,
|
||||
receipt,
|
||||
observation=observation,
|
||||
)
|
||||
if receipt_error is not None:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
receipt_error,
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(recovery_refs),
|
||||
)
|
||||
if not receipt.persisted:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"TASK_PERSISTENCE_UNKNOWN",
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(recovery_refs),
|
||||
)
|
||||
if not receipt.claimable:
|
||||
return StepResult(
|
||||
StepStatus.IN_PROGRESS,
|
||||
"TASK_NOT_CLAIMABLE_YET",
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(recovery_refs),
|
||||
)
|
||||
self._admissions[lane.lane] = receipt
|
||||
|
||||
views: list[LaneExecutionView] = []
|
||||
refs: list[str] = list(recovery_refs)
|
||||
for lane in self.config.lanes:
|
||||
view = self.authority.read_lane_execution(
|
||||
lane,
|
||||
self._admissions[lane.lane],
|
||||
)
|
||||
if view.lane != lane.lane:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"LANE_IDENTITY_MISMATCH",
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"expected": lane.lane, "actual": view.lane},
|
||||
),
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
if view.state is WorkState.COMPLETE and not view.receipt_refs:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"LANE_COMPLETE_WITHOUT_RECEIPT",
|
||||
view.condition_fingerprint,
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
views.append(view)
|
||||
refs.extend(view.receipt_refs)
|
||||
|
||||
unknown = [v for v in views if v.state is WorkState.UNKNOWN]
|
||||
if unknown:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
unknown[0].code,
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"states": [(v.lane, v.state.value) for v in views]},
|
||||
),
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
|
||||
blocked = [v for v in views if v.state is WorkState.BLOCKED]
|
||||
if blocked:
|
||||
return StepResult(
|
||||
StepStatus.BLOCKED_DETERMINISTIC,
|
||||
blocked[0].code,
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"states": [(v.lane, v.state.value) for v in views]},
|
||||
),
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
|
||||
pending = [
|
||||
v for v in views
|
||||
if v.state in {WorkState.PENDING, WorkState.RUNNING}
|
||||
]
|
||||
if pending:
|
||||
retry_values = [
|
||||
v.retry_after_seconds
|
||||
for v in pending
|
||||
if v.retry_after_seconds is not None
|
||||
]
|
||||
return StepResult(
|
||||
StepStatus.IN_PROGRESS,
|
||||
"SOURCE_LANES_IN_PROGRESS",
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"states": [(v.lane, v.state.value) for v in views]},
|
||||
),
|
||||
receipt_refs=tuple(refs),
|
||||
retry_after_seconds=min(retry_values) if retry_values else None,
|
||||
)
|
||||
|
||||
integration = self.authority.drive_integration(
|
||||
self.config.lanes,
|
||||
tuple(self._admissions[lane.lane] for lane in self.config.lanes),
|
||||
)
|
||||
refs.extend(integration.receipt_refs)
|
||||
if integration.state is WorkState.UNKNOWN:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
integration.code,
|
||||
integration.condition_fingerprint,
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
if integration.state is WorkState.BLOCKED:
|
||||
return StepResult(
|
||||
StepStatus.BLOCKED_DETERMINISTIC,
|
||||
integration.code,
|
||||
integration.condition_fingerprint,
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
if integration.state in {WorkState.PENDING, WorkState.RUNNING}:
|
||||
return StepResult(
|
||||
StepStatus.IN_PROGRESS,
|
||||
integration.code,
|
||||
integration.condition_fingerprint,
|
||||
receipt_refs=tuple(refs),
|
||||
retry_after_seconds=integration.retry_after_seconds,
|
||||
)
|
||||
if integration.state is WorkState.COMPLETE and not integration.receipt_refs:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"INTEGRATION_COMPLETE_WITHOUT_RECEIPT",
|
||||
integration.condition_fingerprint,
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
return StepResult(
|
||||
StepStatus.SUCCEEDED,
|
||||
"SOURCE_INTEGRATION_COMPLETE",
|
||||
integration.condition_fingerprint,
|
||||
receipt_refs=tuple(refs),
|
||||
next_phase=MissionPhase.GATE0,
|
||||
)
|
||||
|
||||
def gate0(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
view = self.authority.run_gate0(observation.mission_id)
|
||||
if view.state is GateState.UNKNOWN:
|
||||
status = StepStatus.RECONCILE
|
||||
elif view.state is GateState.HOLD:
|
||||
status = StepStatus.BLOCKED_DETERMINISTIC
|
||||
else:
|
||||
if not view.receipt_refs:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"GATE0_PASS_WITHOUT_RECEIPT",
|
||||
view.condition_fingerprint,
|
||||
)
|
||||
status = StepStatus.SUCCEEDED
|
||||
return StepResult(
|
||||
status,
|
||||
view.code,
|
||||
view.condition_fingerprint,
|
||||
receipt_refs=view.receipt_refs,
|
||||
retry_after_seconds=view.retry_after_seconds,
|
||||
next_phase=(
|
||||
MissionPhase.RELEASE_READY
|
||||
if status is StepStatus.SUCCEEDED else None
|
||||
),
|
||||
)
|
||||
|
||||
def release_ready(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
view = self.authority.build_release_ready(observation.mission_id)
|
||||
if view.state is ReadyState.UNKNOWN:
|
||||
status = StepStatus.RECONCILE
|
||||
elif view.state is ReadyState.NOT_READY:
|
||||
status = StepStatus.BLOCKED_DETERMINISTIC
|
||||
else:
|
||||
if not view.material_digest:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"READY_WITHOUT_MATERIAL_DIGEST",
|
||||
view.condition_fingerprint,
|
||||
receipt_refs=view.receipt_refs,
|
||||
)
|
||||
if not view.receipt_refs:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"READY_WITHOUT_RECEIPT",
|
||||
view.condition_fingerprint,
|
||||
)
|
||||
status = StepStatus.SUCCEEDED
|
||||
return StepResult(
|
||||
status,
|
||||
view.code,
|
||||
view.condition_fingerprint,
|
||||
receipt_refs=view.receipt_refs,
|
||||
retry_after_seconds=view.retry_after_seconds,
|
||||
next_phase=(
|
||||
MissionPhase.PROMOTION
|
||||
if status is StepStatus.SUCCEEDED else None
|
||||
),
|
||||
material_digest=view.material_digest,
|
||||
)
|
||||
|
||||
def promotion(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
view = self.authority.run_promotion(observation.mission_id)
|
||||
if view.state is PromotionState.UNKNOWN:
|
||||
status = StepStatus.RECONCILE
|
||||
elif view.state is PromotionState.IN_PROGRESS:
|
||||
status = StepStatus.IN_PROGRESS
|
||||
elif view.state is PromotionState.VERIFIED:
|
||||
if not view.receipt_refs:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"PROMOTION_VERIFIED_WITHOUT_RECEIPT",
|
||||
view.condition_fingerprint,
|
||||
material_digest=view.material_digest,
|
||||
)
|
||||
status = StepStatus.SUCCEEDED
|
||||
elif view.state is PromotionState.ROLLED_BACK:
|
||||
status = StepStatus.BLOCKED_DETERMINISTIC
|
||||
else:
|
||||
status = StepStatus.BLOCKED_DETERMINISTIC
|
||||
return StepResult(
|
||||
status,
|
||||
view.code,
|
||||
view.condition_fingerprint,
|
||||
receipt_refs=view.receipt_refs,
|
||||
retry_after_seconds=view.retry_after_seconds,
|
||||
next_phase=(
|
||||
MissionPhase.FINAL_ACCEPTANCE
|
||||
if status is StepStatus.SUCCEEDED else None
|
||||
),
|
||||
material_digest=view.material_digest,
|
||||
)
|
||||
|
||||
def final_acceptance(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
view = self.authority.run_final_acceptance(observation.mission_id)
|
||||
if view.state is AcceptanceState.UNKNOWN:
|
||||
status = StepStatus.RECONCILE
|
||||
elif view.state is AcceptanceState.PASS:
|
||||
if not view.receipt_refs:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"FINAL_ACCEPTANCE_PASS_WITHOUT_RECEIPT",
|
||||
view.condition_fingerprint,
|
||||
)
|
||||
status = StepStatus.SUCCEEDED
|
||||
else:
|
||||
status = StepStatus.BLOCKED_DETERMINISTIC
|
||||
return StepResult(
|
||||
status,
|
||||
view.code,
|
||||
view.condition_fingerprint,
|
||||
receipt_refs=view.receipt_refs,
|
||||
next_phase=(
|
||||
MissionPhase.COMPLETE
|
||||
if status is StepStatus.SUCCEEDED else None
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _admission_receipt_error(
|
||||
lane: TaskLaneSpec,
|
||||
receipt: TaskAdmissionReceipt,
|
||||
*,
|
||||
observation: MissionObservation,
|
||||
) -> str | None:
|
||||
if receipt.task_id != lane.task_id:
|
||||
return "TASK_IDENTITY_MISMATCH"
|
||||
if receipt.target_branch != lane.target_branch:
|
||||
return "TASK_BRANCH_MISMATCH"
|
||||
if receipt.generation != observation.generation:
|
||||
return "TASK_GENERATION_MISMATCH"
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _refs(*groups: Sequence[str]) -> tuple[str, ...]:
|
||||
seen: set[str] = set()
|
||||
values: list[str] = []
|
||||
for group in groups:
|
||||
for item in group:
|
||||
if item and item not in seen:
|
||||
seen.add(item)
|
||||
values.append(item)
|
||||
return tuple(values)
|
||||
|
||||
@staticmethod
|
||||
def _idempotency(*parts: object) -> str:
|
||||
return "aios:auto:" + _digest(
|
||||
{"schema": "aios.unattended-idempotency.v1", "parts": list(parts)}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _phase_fingerprint(
|
||||
phase: str,
|
||||
observation: MissionObservation,
|
||||
details: Mapping[str, object],
|
||||
) -> str:
|
||||
return _digest({
|
||||
"schema": "aios.unattended-phase-condition.v1",
|
||||
"phase": phase,
|
||||
"mission_id": observation.mission_id,
|
||||
"generation": observation.generation,
|
||||
"head_oid": observation.head_oid,
|
||||
"material_digest": observation.material_digest,
|
||||
"details": details,
|
||||
})
|
||||
@@ -0,0 +1,877 @@
|
||||
"""Deterministic unattended mission-resume controller for AIOS.
|
||||
|
||||
The controller is deliberately small and does not create a second scheduler,
|
||||
database, identity system, or release control plane.
|
||||
|
||||
It executes exactly one reconciliation/progress cycle per ``run_once`` call.
|
||||
An existing AIOS scheduler/timer/worker loop should invoke it periodically.
|
||||
|
||||
All consequential effects are delegated through ``AutonomousExecutionPort`` and
|
||||
must use the existing AIOS authorities: durable Owner authorization, task
|
||||
admission, claim/TaskLease/TaskPrincipal, WriterLease/CAS/fencing, Gate0,
|
||||
ReleaseReady, PromotionController and governed rollback.
|
||||
|
||||
The controller's responsibilities are limited to:
|
||||
- one-winner resume lease acquisition;
|
||||
- durable checkpoint progression;
|
||||
- bounded retry/backoff;
|
||||
- unchanged deterministic blocker suppression;
|
||||
- UNKNOWN/ambiguous-effect reconciliation before retry;
|
||||
- non-interactive owner-boundary recording;
|
||||
- crash-safe phase resumption.
|
||||
|
||||
It never manufactures credentials, group membership, principals, leases,
|
||||
signatures, branch OIDs, or release receipts.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field, replace
|
||||
from enum import Enum
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Callable, Mapping, Protocol, Sequence
|
||||
|
||||
|
||||
class MissionPhase(str, Enum):
|
||||
S2_ADMISSION = "S2_ADMISSION"
|
||||
S2_EXECUTION = "S2_EXECUTION"
|
||||
GATE0 = "GATE0"
|
||||
RELEASE_READY = "RELEASE_READY"
|
||||
PROMOTION = "PROMOTION"
|
||||
FINAL_ACCEPTANCE = "FINAL_ACCEPTANCE"
|
||||
OWNER_ACTION_REQUIRED = "OWNER_ACTION_REQUIRED"
|
||||
COMPLETE = "COMPLETE"
|
||||
|
||||
|
||||
class EffectState(str, Enum):
|
||||
ABSENT = "ABSENT"
|
||||
ACTIVE = "ACTIVE"
|
||||
COMPLETED = "COMPLETED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class StepStatus(str, Enum):
|
||||
SUCCEEDED = "SUCCEEDED"
|
||||
IN_PROGRESS = "IN_PROGRESS"
|
||||
RETRYABLE = "RETRYABLE"
|
||||
RECONCILE = "RECONCILE"
|
||||
BLOCKED_DETERMINISTIC = "BLOCKED_DETERMINISTIC"
|
||||
BLOCKED_OWNER = "BLOCKED_OWNER"
|
||||
|
||||
|
||||
class OutcomeStatus(str, Enum):
|
||||
COMPLETE = "COMPLETE"
|
||||
PROGRESSED = "PROGRESSED"
|
||||
IN_PROGRESS = "IN_PROGRESS"
|
||||
RETRY_SCHEDULED = "RETRY_SCHEDULED"
|
||||
WAIT_CONDITION_CHANGE = "WAIT_CONDITION_CHANGE"
|
||||
ACTIVE_ELSEWHERE = "ACTIVE_ELSEWHERE"
|
||||
OWNER_ACTION_REQUIRED = "OWNER_ACTION_REQUIRED"
|
||||
|
||||
|
||||
_PHASE_NEXT: dict[MissionPhase, MissionPhase] = {
|
||||
MissionPhase.S2_ADMISSION: MissionPhase.S2_EXECUTION,
|
||||
MissionPhase.S2_EXECUTION: MissionPhase.GATE0,
|
||||
MissionPhase.GATE0: MissionPhase.RELEASE_READY,
|
||||
MissionPhase.RELEASE_READY: MissionPhase.PROMOTION,
|
||||
MissionPhase.PROMOTION: MissionPhase.FINAL_ACCEPTANCE,
|
||||
MissionPhase.FINAL_ACCEPTANCE: MissionPhase.COMPLETE,
|
||||
}
|
||||
|
||||
|
||||
def _canonical(value: Any) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _sha256(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResumePolicy:
|
||||
resume_lease_seconds: int = 120
|
||||
default_poll_seconds: int = 30
|
||||
deterministic_block_poll_seconds: int = 300
|
||||
transient_max_attempts: int = 3
|
||||
transient_base_delay_seconds: int = 2
|
||||
transient_max_delay_seconds: int = 60
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"resume_lease_seconds",
|
||||
"default_poll_seconds",
|
||||
"deterministic_block_poll_seconds",
|
||||
"transient_max_attempts",
|
||||
"transient_base_delay_seconds",
|
||||
"transient_max_delay_seconds",
|
||||
):
|
||||
if getattr(self, name) < 1:
|
||||
raise ValueError(f"{name}_must_be_positive")
|
||||
if self.transient_max_delay_seconds < self.transient_base_delay_seconds:
|
||||
raise ValueError("transient_max_delay_seconds_too_small")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MissionObservation:
|
||||
mission_id: str
|
||||
generation: int
|
||||
head_oid: str
|
||||
material_digest: str
|
||||
condition_fingerprint: str
|
||||
effect_state: EffectState = EffectState.ABSENT
|
||||
requires_reconcile: bool = False
|
||||
owner_action_resolved: bool = False
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.mission_id:
|
||||
raise ValueError("mission_id_required")
|
||||
if self.generation < 1:
|
||||
raise ValueError("generation_must_be_positive")
|
||||
if not self.head_oid:
|
||||
raise ValueError("head_oid_required")
|
||||
if not self.material_digest:
|
||||
raise ValueError("material_digest_required")
|
||||
if not self.condition_fingerprint:
|
||||
raise ValueError("condition_fingerprint_required")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResumeLease:
|
||||
lease_id: str
|
||||
mission_id: str
|
||||
generation: int
|
||||
fencing_token: str
|
||||
expires_at: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.lease_id:
|
||||
raise ValueError("lease_id_required")
|
||||
if not self.mission_id:
|
||||
raise ValueError("mission_id_required")
|
||||
if self.generation < 1:
|
||||
raise ValueError("generation_must_be_positive")
|
||||
if not self.fencing_token:
|
||||
raise ValueError("fencing_token_required")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StepResult:
|
||||
status: StepStatus
|
||||
code: str
|
||||
condition_fingerprint: str
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
retry_after_seconds: int | None = None
|
||||
next_phase: MissionPhase | None = None
|
||||
material_digest: str | None = None
|
||||
owner_action_payload: Mapping[str, Any] | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.code:
|
||||
raise ValueError("step_code_required")
|
||||
if not self.condition_fingerprint:
|
||||
raise ValueError("condition_fingerprint_required")
|
||||
if self.retry_after_seconds is not None and self.retry_after_seconds < 0:
|
||||
raise ValueError("retry_after_seconds_must_be_non_negative")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MissionCheckpoint:
|
||||
schema: str
|
||||
mission_id: str
|
||||
generation: int
|
||||
phase: MissionPhase
|
||||
head_oid: str
|
||||
material_digest: str
|
||||
last_condition_fingerprint: str
|
||||
attempt_count: int
|
||||
blocked_condition_fingerprint: str | None
|
||||
reconcile_required: bool
|
||||
frozen_material_digest: str | None
|
||||
resume_phase_after_owner: MissionPhase | None
|
||||
receipt_refs: tuple[str, ...]
|
||||
updated_at: float
|
||||
revision: int = 1
|
||||
|
||||
@classmethod
|
||||
def initial(
|
||||
cls,
|
||||
observation: MissionObservation,
|
||||
*,
|
||||
observed_at: float,
|
||||
) -> "MissionCheckpoint":
|
||||
return cls(
|
||||
schema="aios.autonomous-resume-checkpoint.v1",
|
||||
mission_id=observation.mission_id,
|
||||
generation=observation.generation,
|
||||
phase=MissionPhase.S2_ADMISSION,
|
||||
head_oid=observation.head_oid,
|
||||
material_digest=observation.material_digest,
|
||||
last_condition_fingerprint=observation.condition_fingerprint,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=False,
|
||||
frozen_material_digest=None,
|
||||
resume_phase_after_owner=None,
|
||||
receipt_refs=tuple(observation.evidence_refs),
|
||||
updated_at=observed_at,
|
||||
revision=1,
|
||||
)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.schema != "aios.autonomous-resume-checkpoint.v1":
|
||||
raise ValueError("unsupported_checkpoint_schema")
|
||||
if not self.mission_id:
|
||||
raise ValueError("mission_id_required")
|
||||
if self.generation < 1:
|
||||
raise ValueError("generation_must_be_positive")
|
||||
if self.attempt_count < 0:
|
||||
raise ValueError("attempt_count_must_be_non_negative")
|
||||
if self.revision < 1:
|
||||
raise ValueError("revision_must_be_positive")
|
||||
|
||||
@property
|
||||
def digest(self) -> str:
|
||||
payload = asdict(self)
|
||||
payload["phase"] = self.phase.value
|
||||
payload["resume_phase_after_owner"] = (
|
||||
None
|
||||
if self.resume_phase_after_owner is None
|
||||
else self.resume_phase_after_owner.value
|
||||
)
|
||||
payload["receipt_refs"] = list(self.receipt_refs)
|
||||
return _sha256(_canonical(payload))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ControllerOutcome:
|
||||
status: OutcomeStatus
|
||||
mission_id: str
|
||||
phase: MissionPhase
|
||||
code: str
|
||||
next_wake_after_seconds: int | None
|
||||
checkpoint_digest: str | None
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class AutonomousExecutionPort(Protocol):
|
||||
"""Adapter boundary to existing AIOS authorities and executors."""
|
||||
|
||||
def read_observation(self, mission_id: str) -> MissionObservation: ...
|
||||
|
||||
def load_checkpoint(self, mission_id: str) -> MissionCheckpoint | None: ...
|
||||
|
||||
def acquire_resume_lease(
|
||||
self,
|
||||
*,
|
||||
mission_id: str,
|
||||
generation: int,
|
||||
idempotency_key: str,
|
||||
lease_seconds: int,
|
||||
) -> ResumeLease | None: ...
|
||||
|
||||
def step(
|
||||
self,
|
||||
*,
|
||||
phase: MissionPhase,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult: ...
|
||||
|
||||
def reconcile(
|
||||
self,
|
||||
*,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult: ...
|
||||
|
||||
def persist_checkpoint(
|
||||
self,
|
||||
checkpoint: MissionCheckpoint,
|
||||
*,
|
||||
lease: ResumeLease,
|
||||
) -> None: ...
|
||||
|
||||
def persist_owner_action_required(
|
||||
self,
|
||||
payload: Mapping[str, Any],
|
||||
*,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> str: ...
|
||||
|
||||
def release_resume_lease(
|
||||
self,
|
||||
lease: ResumeLease,
|
||||
*,
|
||||
outcome_code: str,
|
||||
) -> None: ...
|
||||
|
||||
|
||||
class AutonomousResumeController:
|
||||
"""One-cycle deterministic unattended mission controller."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
port: AutonomousExecutionPort,
|
||||
*,
|
||||
policy: ResumePolicy | None = None,
|
||||
clock: Callable[[], float] | None = None,
|
||||
) -> None:
|
||||
self.port = port
|
||||
self.policy = policy or ResumePolicy()
|
||||
self.clock = clock or time.time
|
||||
|
||||
def run_once(self, mission_id: str) -> ControllerOutcome:
|
||||
if not mission_id:
|
||||
raise ValueError("mission_id_required")
|
||||
|
||||
now = float(self.clock())
|
||||
observation = self.port.read_observation(mission_id)
|
||||
checkpoint = self.port.load_checkpoint(mission_id)
|
||||
if checkpoint is None:
|
||||
checkpoint = MissionCheckpoint.initial(observation, observed_at=now)
|
||||
forced_reconcile = False
|
||||
else:
|
||||
checkpoint, forced_reconcile = self._validate_and_refresh_checkpoint(
|
||||
checkpoint,
|
||||
observation,
|
||||
now=now,
|
||||
)
|
||||
|
||||
if checkpoint.phase is MissionPhase.COMPLETE:
|
||||
return self._outcome(
|
||||
OutcomeStatus.COMPLETE,
|
||||
checkpoint,
|
||||
"MISSION_ALREADY_COMPLETE",
|
||||
next_wake=None,
|
||||
)
|
||||
|
||||
if checkpoint.phase is MissionPhase.OWNER_ACTION_REQUIRED:
|
||||
if not observation.owner_action_resolved:
|
||||
return self._outcome(
|
||||
OutcomeStatus.OWNER_ACTION_REQUIRED,
|
||||
checkpoint,
|
||||
"OWNER_ACTION_STILL_REQUIRED",
|
||||
next_wake=None,
|
||||
)
|
||||
resume_phase = (
|
||||
checkpoint.resume_phase_after_owner
|
||||
or MissionPhase.S2_ADMISSION
|
||||
)
|
||||
checkpoint = replace(
|
||||
checkpoint,
|
||||
phase=resume_phase,
|
||||
resume_phase_after_owner=None,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=False,
|
||||
updated_at=now,
|
||||
revision=checkpoint.revision + 1,
|
||||
)
|
||||
|
||||
if (
|
||||
checkpoint.blocked_condition_fingerprint is not None
|
||||
and checkpoint.blocked_condition_fingerprint
|
||||
== observation.condition_fingerprint
|
||||
):
|
||||
return self._outcome(
|
||||
OutcomeStatus.WAIT_CONDITION_CHANGE,
|
||||
checkpoint,
|
||||
"UNCHANGED_DETERMINISTIC_BLOCKER",
|
||||
next_wake=self.policy.deterministic_block_poll_seconds,
|
||||
)
|
||||
|
||||
if (
|
||||
checkpoint.blocked_condition_fingerprint is not None
|
||||
and checkpoint.blocked_condition_fingerprint
|
||||
!= observation.condition_fingerprint
|
||||
):
|
||||
checkpoint = replace(
|
||||
checkpoint,
|
||||
blocked_condition_fingerprint=None,
|
||||
attempt_count=0,
|
||||
updated_at=now,
|
||||
revision=checkpoint.revision + 1,
|
||||
)
|
||||
|
||||
lease = self.port.acquire_resume_lease(
|
||||
mission_id=observation.mission_id,
|
||||
generation=observation.generation,
|
||||
idempotency_key=self._resume_idempotency_key(observation),
|
||||
lease_seconds=self.policy.resume_lease_seconds,
|
||||
)
|
||||
if lease is None:
|
||||
return self._outcome(
|
||||
OutcomeStatus.ACTIVE_ELSEWHERE,
|
||||
checkpoint,
|
||||
"RESUME_LEASE_HELD_ELSEWHERE",
|
||||
next_wake=self.policy.default_poll_seconds,
|
||||
)
|
||||
|
||||
try:
|
||||
self._validate_resume_lease(
|
||||
lease,
|
||||
observation=observation,
|
||||
now=now,
|
||||
)
|
||||
except Exception:
|
||||
self.port.release_resume_lease(
|
||||
lease,
|
||||
outcome_code="INVALID_RESUME_LEASE",
|
||||
)
|
||||
raise
|
||||
|
||||
outcome_code = "UNSET"
|
||||
try:
|
||||
reconciling = (
|
||||
forced_reconcile
|
||||
or checkpoint.reconcile_required
|
||||
or observation.requires_reconcile
|
||||
or observation.effect_state is EffectState.UNKNOWN
|
||||
)
|
||||
if reconciling:
|
||||
result = self.port.reconcile(
|
||||
observation=observation,
|
||||
checkpoint=checkpoint,
|
||||
lease=lease,
|
||||
)
|
||||
else:
|
||||
result = self.port.step(
|
||||
phase=checkpoint.phase,
|
||||
observation=observation,
|
||||
checkpoint=checkpoint,
|
||||
lease=lease,
|
||||
)
|
||||
|
||||
outcome, new_checkpoint = self._apply_result(
|
||||
result=result,
|
||||
observation=observation,
|
||||
checkpoint=checkpoint,
|
||||
now=now,
|
||||
lease=lease,
|
||||
reconciling=reconciling,
|
||||
)
|
||||
outcome_code = outcome.code
|
||||
self.port.persist_checkpoint(new_checkpoint, lease=lease)
|
||||
return replace(
|
||||
outcome,
|
||||
checkpoint_digest=new_checkpoint.digest,
|
||||
)
|
||||
finally:
|
||||
self.port.release_resume_lease(
|
||||
lease,
|
||||
outcome_code=outcome_code,
|
||||
)
|
||||
|
||||
def _validate_and_refresh_checkpoint(
|
||||
self,
|
||||
checkpoint: MissionCheckpoint,
|
||||
observation: MissionObservation,
|
||||
*,
|
||||
now: float,
|
||||
) -> tuple[MissionCheckpoint, bool]:
|
||||
if checkpoint.mission_id != observation.mission_id:
|
||||
raise ValueError("checkpoint_mission_mismatch")
|
||||
if checkpoint.generation > observation.generation:
|
||||
raise ValueError("checkpoint_generation_ahead_of_authority")
|
||||
|
||||
refreshed = checkpoint
|
||||
forced_reconcile = False
|
||||
if checkpoint.generation < observation.generation:
|
||||
refreshed = replace(
|
||||
checkpoint,
|
||||
generation=observation.generation,
|
||||
phase=MissionPhase.S2_ADMISSION,
|
||||
head_oid=observation.head_oid,
|
||||
material_digest=observation.material_digest,
|
||||
last_condition_fingerprint=observation.condition_fingerprint,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=False,
|
||||
frozen_material_digest=None,
|
||||
resume_phase_after_owner=None,
|
||||
receipt_refs=self._merge_refs(
|
||||
checkpoint.receipt_refs,
|
||||
observation.evidence_refs,
|
||||
),
|
||||
updated_at=now,
|
||||
revision=checkpoint.revision + 1,
|
||||
)
|
||||
|
||||
if (
|
||||
refreshed.phase in {
|
||||
MissionPhase.RELEASE_READY,
|
||||
MissionPhase.PROMOTION,
|
||||
MissionPhase.FINAL_ACCEPTANCE,
|
||||
}
|
||||
and refreshed.frozen_material_digest is not None
|
||||
and refreshed.frozen_material_digest
|
||||
!= observation.material_digest
|
||||
):
|
||||
# Material changed after READY. Promotion/final may already have
|
||||
# observable side effects, so reconcile before rebuilding PREPARE.
|
||||
previous_phase = refreshed.phase
|
||||
force_for_effect = (
|
||||
previous_phase in {
|
||||
MissionPhase.PROMOTION,
|
||||
MissionPhase.FINAL_ACCEPTANCE,
|
||||
}
|
||||
and observation.effect_state in {
|
||||
EffectState.ACTIVE,
|
||||
EffectState.UNKNOWN,
|
||||
}
|
||||
)
|
||||
refreshed = replace(
|
||||
refreshed,
|
||||
phase=MissionPhase.RELEASE_READY,
|
||||
material_digest=observation.material_digest,
|
||||
head_oid=observation.head_oid,
|
||||
frozen_material_digest=None,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=force_for_effect,
|
||||
last_condition_fingerprint=observation.condition_fingerprint,
|
||||
receipt_refs=self._merge_refs(
|
||||
refreshed.receipt_refs,
|
||||
observation.evidence_refs,
|
||||
),
|
||||
updated_at=now,
|
||||
revision=refreshed.revision + 1,
|
||||
)
|
||||
forced_reconcile = forced_reconcile or force_for_effect
|
||||
elif (
|
||||
refreshed.phase in {
|
||||
MissionPhase.S2_ADMISSION,
|
||||
MissionPhase.S2_EXECUTION,
|
||||
MissionPhase.GATE0,
|
||||
}
|
||||
and refreshed.head_oid != observation.head_oid
|
||||
):
|
||||
# Source moved before READY. Re-enter S2 admission/replan.
|
||||
refreshed = replace(
|
||||
refreshed,
|
||||
phase=MissionPhase.S2_ADMISSION,
|
||||
head_oid=observation.head_oid,
|
||||
material_digest=observation.material_digest,
|
||||
frozen_material_digest=None,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=False,
|
||||
last_condition_fingerprint=observation.condition_fingerprint,
|
||||
receipt_refs=self._merge_refs(
|
||||
refreshed.receipt_refs,
|
||||
observation.evidence_refs,
|
||||
),
|
||||
updated_at=now,
|
||||
revision=refreshed.revision + 1,
|
||||
)
|
||||
|
||||
return refreshed, forced_reconcile
|
||||
|
||||
def _apply_result(
|
||||
self,
|
||||
*,
|
||||
result: StepResult,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
now: float,
|
||||
lease: ResumeLease,
|
||||
reconciling: bool,
|
||||
) -> tuple[ControllerOutcome, MissionCheckpoint]:
|
||||
refs = self._merge_refs(
|
||||
checkpoint.receipt_refs,
|
||||
observation.evidence_refs,
|
||||
result.receipt_refs,
|
||||
)
|
||||
base = replace(
|
||||
checkpoint,
|
||||
head_oid=observation.head_oid,
|
||||
material_digest=(
|
||||
result.material_digest
|
||||
or observation.material_digest
|
||||
),
|
||||
last_condition_fingerprint=result.condition_fingerprint,
|
||||
receipt_refs=refs,
|
||||
updated_at=now,
|
||||
revision=checkpoint.revision + 1,
|
||||
)
|
||||
|
||||
if (
|
||||
result.status is StepStatus.SUCCEEDED
|
||||
and checkpoint.phase in {
|
||||
MissionPhase.PROMOTION,
|
||||
MissionPhase.FINAL_ACCEPTANCE,
|
||||
}
|
||||
and checkpoint.frozen_material_digest is not None
|
||||
and result.material_digest is not None
|
||||
and result.material_digest != checkpoint.frozen_material_digest
|
||||
):
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
phase=MissionPhase.RELEASE_READY,
|
||||
material_digest=result.material_digest,
|
||||
frozen_material_digest=None,
|
||||
reconcile_required=True,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.IN_PROGRESS,
|
||||
new_checkpoint,
|
||||
"RECONCILE_REQUIRED:MATERIAL_CHANGED_DURING_PROMOTION",
|
||||
next_wake=0,
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
if result.status is StepStatus.SUCCEEDED:
|
||||
next_phase = (
|
||||
result.next_phase
|
||||
or _PHASE_NEXT.get(checkpoint.phase, checkpoint.phase)
|
||||
)
|
||||
frozen = base.frozen_material_digest
|
||||
if checkpoint.phase is MissionPhase.RELEASE_READY:
|
||||
frozen = result.material_digest or observation.material_digest
|
||||
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
phase=next_phase,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=False,
|
||||
frozen_material_digest=frozen,
|
||||
)
|
||||
status = (
|
||||
OutcomeStatus.COMPLETE
|
||||
if next_phase is MissionPhase.COMPLETE
|
||||
else OutcomeStatus.PROGRESSED
|
||||
)
|
||||
code = (
|
||||
"MISSION_COMPLETE"
|
||||
if next_phase is MissionPhase.COMPLETE
|
||||
else result.code
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
status,
|
||||
new_checkpoint,
|
||||
code,
|
||||
next_wake=(
|
||||
None
|
||||
if status is OutcomeStatus.COMPLETE
|
||||
else self.policy.default_poll_seconds
|
||||
),
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
if result.status is StepStatus.IN_PROGRESS:
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=reconciling,
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.IN_PROGRESS,
|
||||
new_checkpoint,
|
||||
result.code,
|
||||
next_wake=(
|
||||
result.retry_after_seconds
|
||||
if result.retry_after_seconds is not None
|
||||
else self.policy.default_poll_seconds
|
||||
),
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
if result.status is StepStatus.RETRYABLE:
|
||||
attempts = checkpoint.attempt_count + 1
|
||||
if attempts > self.policy.transient_max_attempts:
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
attempt_count=attempts,
|
||||
blocked_condition_fingerprint=result.condition_fingerprint,
|
||||
reconcile_required=reconciling,
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.WAIT_CONDITION_CHANGE,
|
||||
new_checkpoint,
|
||||
"RETRY_BUDGET_EXHAUSTED",
|
||||
next_wake=self.policy.deterministic_block_poll_seconds,
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
delay = (
|
||||
result.retry_after_seconds
|
||||
if result.retry_after_seconds is not None
|
||||
else min(
|
||||
self.policy.transient_base_delay_seconds
|
||||
* (2 ** (attempts - 1)),
|
||||
self.policy.transient_max_delay_seconds,
|
||||
)
|
||||
)
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
attempt_count=attempts,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=reconciling,
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.RETRY_SCHEDULED,
|
||||
new_checkpoint,
|
||||
result.code,
|
||||
next_wake=delay,
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
if result.status is StepStatus.BLOCKED_DETERMINISTIC:
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
attempt_count=checkpoint.attempt_count,
|
||||
blocked_condition_fingerprint=result.condition_fingerprint,
|
||||
reconcile_required=reconciling,
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.WAIT_CONDITION_CHANGE,
|
||||
new_checkpoint,
|
||||
result.code,
|
||||
next_wake=self.policy.deterministic_block_poll_seconds,
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
if result.status is StepStatus.BLOCKED_OWNER:
|
||||
payload = dict(result.owner_action_payload or {})
|
||||
payload.setdefault("mission_id", observation.mission_id)
|
||||
payload.setdefault("generation", observation.generation)
|
||||
payload.setdefault("phase", checkpoint.phase.value)
|
||||
payload.setdefault("code", result.code)
|
||||
record_ref = self.port.persist_owner_action_required(
|
||||
payload,
|
||||
observation=observation,
|
||||
checkpoint=checkpoint,
|
||||
lease=lease,
|
||||
)
|
||||
if not record_ref:
|
||||
raise ValueError("owner_action_record_ref_required")
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
phase=MissionPhase.OWNER_ACTION_REQUIRED,
|
||||
resume_phase_after_owner=checkpoint.phase,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=False,
|
||||
receipt_refs=self._merge_refs(refs, (record_ref,)),
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.OWNER_ACTION_REQUIRED,
|
||||
new_checkpoint,
|
||||
result.code,
|
||||
next_wake=None,
|
||||
refs=(record_ref,),
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
if result.status is StepStatus.RECONCILE:
|
||||
# The next scheduler cycle must reconcile before any retry.
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=True,
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.IN_PROGRESS,
|
||||
new_checkpoint,
|
||||
"RECONCILE_REQUIRED:" + result.code,
|
||||
next_wake=0,
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
raise AssertionError(f"unsupported_step_status:{result.status}")
|
||||
|
||||
@staticmethod
|
||||
def _validate_resume_lease(
|
||||
lease: ResumeLease,
|
||||
*,
|
||||
observation: MissionObservation,
|
||||
now: float,
|
||||
) -> None:
|
||||
if lease.mission_id != observation.mission_id:
|
||||
raise ValueError("resume_lease_mission_mismatch")
|
||||
if lease.generation != observation.generation:
|
||||
raise ValueError("resume_lease_generation_mismatch")
|
||||
if lease.expires_at <= now:
|
||||
raise ValueError("resume_lease_expired")
|
||||
|
||||
def _resume_idempotency_key(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
) -> str:
|
||||
payload = {
|
||||
"schema": "aios.resume-effect.v1",
|
||||
"mission_id": observation.mission_id,
|
||||
"generation": observation.generation,
|
||||
}
|
||||
return "resume:sha256:" + _sha256(_canonical(payload))
|
||||
|
||||
@staticmethod
|
||||
def _merge_refs(*groups: Sequence[str]) -> tuple[str, ...]:
|
||||
merged: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for group in groups:
|
||||
for item in group:
|
||||
value = str(item)
|
||||
if value and value not in seen:
|
||||
seen.add(value)
|
||||
merged.append(value)
|
||||
return tuple(merged)
|
||||
|
||||
@staticmethod
|
||||
def _outcome(
|
||||
status: OutcomeStatus,
|
||||
checkpoint: MissionCheckpoint,
|
||||
code: str,
|
||||
*,
|
||||
next_wake: int | None,
|
||||
refs: Sequence[str] = (),
|
||||
) -> ControllerOutcome:
|
||||
return ControllerOutcome(
|
||||
status=status,
|
||||
mission_id=checkpoint.mission_id,
|
||||
phase=checkpoint.phase,
|
||||
code=code,
|
||||
next_wake_after_seconds=next_wake,
|
||||
checkpoint_digest=None,
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Thin callable adapter for :mod:`autonomous_resume_controller`.
|
||||
|
||||
This module contains no transport, daemon, database or scheduler loop. It only
|
||||
binds current AIOS runtime functions to the stable ``AutonomousExecutionPort``
|
||||
contract and exposes one scheduler tick.
|
||||
|
||||
A field integrator should:
|
||||
- resolve the live current APIs;
|
||||
- supply callables that use existing governed authorities;
|
||||
- register ``AutonomousSchedulerTick.run`` with the existing scheduler/timer.
|
||||
|
||||
No additional control plane is created here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
from .autonomous_resume_controller import (
|
||||
AutonomousExecutionPort,
|
||||
AutonomousResumeController,
|
||||
ControllerOutcome,
|
||||
MissionCheckpoint,
|
||||
MissionObservation,
|
||||
MissionPhase,
|
||||
ResumeLease,
|
||||
StepResult,
|
||||
)
|
||||
|
||||
|
||||
PhaseHandler = Callable[
|
||||
[MissionObservation, MissionCheckpoint, ResumeLease],
|
||||
StepResult,
|
||||
]
|
||||
ReconcileHandler = PhaseHandler
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PhaseCallbacks:
|
||||
admission: PhaseHandler
|
||||
source_execution: PhaseHandler
|
||||
gate0: PhaseHandler
|
||||
release_ready: PhaseHandler
|
||||
promotion: PhaseHandler
|
||||
final_acceptance: PhaseHandler
|
||||
|
||||
def for_phase(self, phase: MissionPhase) -> PhaseHandler:
|
||||
mapping = {
|
||||
MissionPhase.S2_ADMISSION: self.admission,
|
||||
MissionPhase.S2_EXECUTION: self.source_execution,
|
||||
MissionPhase.GATE0: self.gate0,
|
||||
MissionPhase.RELEASE_READY: self.release_ready,
|
||||
MissionPhase.PROMOTION: self.promotion,
|
||||
MissionPhase.FINAL_ACCEPTANCE: self.final_acceptance,
|
||||
}
|
||||
try:
|
||||
return mapping[phase]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"phase_has_no_executor:{phase.value}") from exc
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorityCallbacks:
|
||||
read_observation: Callable[[str], MissionObservation]
|
||||
load_checkpoint: Callable[[str], MissionCheckpoint | None]
|
||||
acquire_resume_lease: Callable[[str, int, str, int], ResumeLease | None]
|
||||
persist_checkpoint: Callable[[MissionCheckpoint, ResumeLease], None]
|
||||
persist_owner_action_required: Callable[
|
||||
[dict, MissionObservation, MissionCheckpoint, ResumeLease],
|
||||
str,
|
||||
]
|
||||
release_resume_lease: Callable[[ResumeLease, str], None]
|
||||
reconcile: ReconcileHandler
|
||||
|
||||
|
||||
class CallableAutonomousExecutionPort(AutonomousExecutionPort):
|
||||
"""Runtime adapter composed from already-governed AIOS callables."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
authority: AuthorityCallbacks,
|
||||
phases: PhaseCallbacks,
|
||||
) -> None:
|
||||
self.authority = authority
|
||||
self.phases = phases
|
||||
|
||||
def read_observation(self, mission_id: str) -> MissionObservation:
|
||||
value = self.authority.read_observation(mission_id)
|
||||
if not isinstance(value, MissionObservation):
|
||||
raise TypeError("read_observation_must_return_MissionObservation")
|
||||
return value
|
||||
|
||||
def load_checkpoint(self, mission_id: str) -> MissionCheckpoint | None:
|
||||
value = self.authority.load_checkpoint(mission_id)
|
||||
if value is not None and not isinstance(value, MissionCheckpoint):
|
||||
raise TypeError("load_checkpoint_must_return_checkpoint_or_none")
|
||||
return value
|
||||
|
||||
def acquire_resume_lease(
|
||||
self,
|
||||
*,
|
||||
mission_id: str,
|
||||
generation: int,
|
||||
idempotency_key: str,
|
||||
lease_seconds: int,
|
||||
) -> ResumeLease | None:
|
||||
value = self.authority.acquire_resume_lease(
|
||||
mission_id,
|
||||
generation,
|
||||
idempotency_key,
|
||||
lease_seconds,
|
||||
)
|
||||
if value is not None and not isinstance(value, ResumeLease):
|
||||
raise TypeError("acquire_resume_lease_must_return_lease_or_none")
|
||||
return value
|
||||
|
||||
def step(
|
||||
self,
|
||||
*,
|
||||
phase: MissionPhase,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
handler = self.phases.for_phase(phase)
|
||||
value = handler(observation, checkpoint, lease)
|
||||
if not isinstance(value, StepResult):
|
||||
raise TypeError("phase_handler_must_return_StepResult")
|
||||
return value
|
||||
|
||||
def reconcile(
|
||||
self,
|
||||
*,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
value = self.authority.reconcile(
|
||||
observation,
|
||||
checkpoint,
|
||||
lease,
|
||||
)
|
||||
if not isinstance(value, StepResult):
|
||||
raise TypeError("reconcile_must_return_StepResult")
|
||||
return value
|
||||
|
||||
def persist_checkpoint(
|
||||
self,
|
||||
checkpoint: MissionCheckpoint,
|
||||
*,
|
||||
lease: ResumeLease,
|
||||
) -> None:
|
||||
self.authority.persist_checkpoint(checkpoint, lease)
|
||||
|
||||
def persist_owner_action_required(
|
||||
self,
|
||||
payload,
|
||||
*,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> str:
|
||||
value = self.authority.persist_owner_action_required(
|
||||
dict(payload),
|
||||
observation,
|
||||
checkpoint,
|
||||
lease,
|
||||
)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError("owner_action_record_ref_required")
|
||||
return value
|
||||
|
||||
def release_resume_lease(
|
||||
self,
|
||||
lease: ResumeLease,
|
||||
*,
|
||||
outcome_code: str,
|
||||
) -> None:
|
||||
self.authority.release_resume_lease(lease, outcome_code)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SchedulerTickResult:
|
||||
outcome: ControllerOutcome
|
||||
next_wake_after_seconds: int | None
|
||||
|
||||
|
||||
class AutonomousSchedulerTick:
|
||||
"""One invocation for an existing scheduler/timer.
|
||||
|
||||
This object never sleeps or loops. The host scheduler owns cadence.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
controller: AutonomousResumeController,
|
||||
mission_id: str,
|
||||
) -> None:
|
||||
if not mission_id:
|
||||
raise ValueError("mission_id_required")
|
||||
self.controller = controller
|
||||
self.mission_id = mission_id
|
||||
|
||||
def run(self) -> SchedulerTickResult:
|
||||
outcome = self.controller.run_once(self.mission_id)
|
||||
return SchedulerTickResult(
|
||||
outcome=outcome,
|
||||
next_wake_after_seconds=outcome.next_wake_after_seconds,
|
||||
)
|
||||
@@ -0,0 +1,458 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
|
||||
from task_plane.autonomous_resume_aios_adapter import (
|
||||
AcceptanceState,
|
||||
AcceptanceView,
|
||||
AiosUnattendedCallbackFactory,
|
||||
AiosUnattendedMissionConfig,
|
||||
DurableGrantView,
|
||||
GateState,
|
||||
GateView,
|
||||
IntegrationView,
|
||||
LaneExecutionView,
|
||||
MissionEffectView,
|
||||
PromotionState,
|
||||
PromotionView,
|
||||
ReadyState,
|
||||
ReleaseReadyView,
|
||||
TaskAdmissionReceipt,
|
||||
TaskLaneSpec,
|
||||
WorkerActivationReceipt,
|
||||
WorkerState,
|
||||
WorkState,
|
||||
)
|
||||
from task_plane.autonomous_resume_controller import (
|
||||
AutonomousResumeController,
|
||||
EffectState,
|
||||
MissionPhase,
|
||||
OutcomeStatus,
|
||||
ResumeLease,
|
||||
StepResult,
|
||||
StepStatus,
|
||||
)
|
||||
|
||||
|
||||
def lane(name, path):
|
||||
return TaskLaneSpec(
|
||||
lane=name,
|
||||
task_id=f"task-{name}",
|
||||
repository="aios/aios",
|
||||
target_branch=f"agent/{name}",
|
||||
risk="medium",
|
||||
description=name,
|
||||
write_set=(path,),
|
||||
)
|
||||
|
||||
|
||||
class FakeAuthorities:
|
||||
def __init__(self):
|
||||
self.head="a"*40
|
||||
self.material="mat-1"
|
||||
self.generation=1
|
||||
self.effect=MissionEffectView(
|
||||
EffectState.ABSENT,"effect-1",False,()
|
||||
)
|
||||
self.grant=DurableGrantView(
|
||||
grant_id="grant-1",
|
||||
active=True,
|
||||
repository="aios/aios",
|
||||
allowed_target_prefixes=("agent/",),
|
||||
max_task_risk="engineering",
|
||||
evidence_refs=("grant:1",),
|
||||
)
|
||||
self.worker=WorkerActivationReceipt(
|
||||
WorkerState.ACTIVE,
|
||||
receipt_ref="worker:1",
|
||||
code="WORKER_ACTIVE",
|
||||
)
|
||||
self.admission_overrides={}
|
||||
self.lane_views={}
|
||||
self.integration=IntegrationView(
|
||||
WorkState.COMPLETE,"integration-1",("integration:1",),
|
||||
code="INTEGRATION_COMPLETE",
|
||||
)
|
||||
self.gate=GateView(
|
||||
GateState.PASS,"gate-1",("gate:1",),code="GATE0_PASS"
|
||||
)
|
||||
self.ready=ReleaseReadyView(
|
||||
ReadyState.READY,"ready-1","mat-1",("ready:1",),
|
||||
code="RELEASE_READY",
|
||||
)
|
||||
self.promotion=PromotionView(
|
||||
PromotionState.VERIFIED,"promotion-1","mat-1",("promotion:1",),
|
||||
code="PROMOTION_VERIFIED",
|
||||
)
|
||||
self.acceptance=AcceptanceView(
|
||||
AcceptanceState.PASS,"accept-1",("accept:1",),
|
||||
code="FINAL_ACCEPTANCE_PASS",
|
||||
)
|
||||
self.checkpoint=None
|
||||
self.owner_resolved=False
|
||||
self.requests=[]
|
||||
self.persisted_admissions={}
|
||||
self.released=[]
|
||||
self.reconcile_calls=0
|
||||
|
||||
def read_head_oid(self, repository): return self.head
|
||||
def read_material_digest(self, mission_id): return self.material
|
||||
def read_generation(self, mission_id): return self.generation
|
||||
def read_effect_view(self, mission_id): return self.effect
|
||||
def owner_action_resolved(self, mission_id): return self.owner_resolved
|
||||
def load_checkpoint(self, mission_id): return self.checkpoint
|
||||
def acquire_resume_lease(self, mission_id,generation,key,seconds):
|
||||
return ResumeLease("lease-1",mission_id,generation,"fence-1",9999)
|
||||
def persist_checkpoint(self, checkpoint, lease): self.checkpoint=checkpoint
|
||||
def persist_owner_action_required(self,payload,observation,checkpoint,lease):
|
||||
return "owner:1"
|
||||
def release_resume_lease(self, lease, outcome_code):
|
||||
self.released.append((lease.lease_id,outcome_code))
|
||||
def reconcile_effects(self,observation,checkpoint,lease):
|
||||
self.reconcile_calls+=1
|
||||
return StepResult(
|
||||
StepStatus.SUCCEEDED,
|
||||
"RECONCILED",
|
||||
"reconcile-1",
|
||||
next_phase=checkpoint.phase,
|
||||
)
|
||||
def read_durable_grant(self,mission_id): return self.grant
|
||||
def ensure_canonical_worker(self,mission_id,generation,idempotency_key):
|
||||
return self.worker
|
||||
def create_or_resume_task(self,request):
|
||||
self.requests.append(request)
|
||||
receipt=self.admission_overrides.get(
|
||||
request.task_id,
|
||||
TaskAdmissionReceipt(
|
||||
request.task_id,1,request.target_branch,True,True,
|
||||
"task:"+request.task_id,
|
||||
)
|
||||
)
|
||||
if receipt.persisted:
|
||||
self.persisted_admissions[request.task_id]=receipt
|
||||
return receipt
|
||||
def read_task_admission(self,lane):
|
||||
return self.persisted_admissions.get(lane.task_id)
|
||||
def read_lane_execution(self,lane,task):
|
||||
return self.lane_views.get(
|
||||
lane.lane,
|
||||
LaneExecutionView(
|
||||
lane.lane,WorkState.COMPLETE,"lane:"+lane.lane,
|
||||
("lane-receipt:"+lane.lane,),code="LANE_COMPLETE",
|
||||
)
|
||||
)
|
||||
def drive_integration(self,lanes,tasks): return self.integration
|
||||
def run_gate0(self,mission_id): return self.gate
|
||||
def build_release_ready(self,mission_id): return self.ready
|
||||
def run_promotion(self,mission_id): return self.promotion
|
||||
def run_final_acceptance(self,mission_id): return self.acceptance
|
||||
|
||||
|
||||
class AiosAdapterTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.auth=FakeAuthorities()
|
||||
self.lanes=(
|
||||
lane("A","a.py"),
|
||||
lane("B","b.py"),
|
||||
lane("C","c.py"),
|
||||
)
|
||||
self.config=AiosUnattendedMissionConfig(
|
||||
mission_id="v2r2c4",
|
||||
repository="aios/aios",
|
||||
lanes=self.lanes,
|
||||
)
|
||||
self.factory=AiosUnattendedCallbackFactory(
|
||||
self.auth,self.config
|
||||
)
|
||||
|
||||
def observation(self):
|
||||
return self.factory.read_observation("v2r2c4")
|
||||
|
||||
def test_config_rejects_write_set_overlap(self):
|
||||
with self.assertRaisesRegex(ValueError,"overlapping_lane_resource"):
|
||||
AiosUnattendedMissionConfig(
|
||||
"v2r2c4","aios/aios",
|
||||
(lane("A","same.py"),lane("B","same.py")),
|
||||
)
|
||||
|
||||
def test_config_rejects_non_agent_branch(self):
|
||||
with self.assertRaisesRegex(ValueError,"agent_prefix"):
|
||||
TaskLaneSpec(
|
||||
"A","task-a","aios/aios","feature/a","medium","x",
|
||||
("a.py",),
|
||||
)
|
||||
|
||||
def test_observation_fingerprint_changes_with_head(self):
|
||||
first=self.observation().condition_fingerprint
|
||||
self.auth.head="b"*40
|
||||
second=self.observation().condition_fingerprint
|
||||
self.assertNotEqual(first,second)
|
||||
|
||||
def test_inactive_grant_blocks_without_task_creation(self):
|
||||
self.auth.grant=replace(self.auth.grant,active=False)
|
||||
result=self.factory.admission(
|
||||
self.observation(),None,None
|
||||
)
|
||||
self.assertEqual(result.status,StepStatus.BLOCKED_DETERMINISTIC)
|
||||
self.assertEqual(self.auth.requests,[])
|
||||
|
||||
def test_owner_only_grant_boundary_is_typed_owner_blocker(self):
|
||||
self.auth.grant=replace(
|
||||
self.auth.grant,active=False,owner_action_required=True
|
||||
)
|
||||
result=self.factory.admission(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.BLOCKED_OWNER)
|
||||
self.assertIsNotNone(result.owner_action_payload)
|
||||
|
||||
def test_grant_scope_mismatch_blocks_before_worker(self):
|
||||
self.auth.grant=replace(
|
||||
self.auth.grant,allowed_target_prefixes=("other/",)
|
||||
)
|
||||
result=self.factory.admission(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.BLOCKED_DETERMINISTIC)
|
||||
self.assertEqual(self.auth.requests,[])
|
||||
|
||||
def test_worker_starting_is_in_progress(self):
|
||||
self.auth.worker=WorkerActivationReceipt(
|
||||
WorkerState.STARTING,"worker:start",7,False,"WORKER_STARTING"
|
||||
)
|
||||
result=self.factory.admission(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.IN_PROGRESS)
|
||||
self.assertEqual(result.retry_after_seconds,7)
|
||||
self.assertEqual(self.auth.requests,[])
|
||||
|
||||
def test_worker_unknown_reconciles(self):
|
||||
self.auth.worker=WorkerActivationReceipt(
|
||||
WorkerState.UNKNOWN,None,None,False,"WORKER_UNKNOWN"
|
||||
)
|
||||
result=self.factory.admission(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
|
||||
def test_active_worker_creates_all_tasks_deterministically(self):
|
||||
obs=self.observation()
|
||||
result=self.factory.admission(obs,None,None)
|
||||
self.assertEqual(result.status,StepStatus.SUCCEEDED)
|
||||
self.assertEqual(len(self.auth.requests),3)
|
||||
self.assertTrue(all(r.base_oid==obs.head_oid for r in self.auth.requests))
|
||||
keys=[r.idempotency_key for r in self.auth.requests]
|
||||
self.assertEqual(len(keys),len(set(keys)))
|
||||
|
||||
def test_unpersisted_task_requires_reconcile(self):
|
||||
self.auth.admission_overrides["task-B"]=TaskAdmissionReceipt(
|
||||
"task-B",1,"agent/B",False,False,"task:B"
|
||||
)
|
||||
result=self.factory.admission(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
|
||||
def test_unclaimable_task_waits(self):
|
||||
self.auth.admission_overrides["task-B"]=TaskAdmissionReceipt(
|
||||
"task-B",1,"agent/B",True,False,"task:B"
|
||||
)
|
||||
result=self.factory.admission(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.IN_PROGRESS)
|
||||
|
||||
def test_source_execution_unknown_lane_reconciles(self):
|
||||
self.factory.admission(self.observation(),None,None)
|
||||
self.auth.lane_views["B"]=LaneExecutionView(
|
||||
"B",WorkState.UNKNOWN,"lane:B",code="LANE_B_UNKNOWN"
|
||||
)
|
||||
result=self.factory.source_execution(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
|
||||
def test_source_execution_running_lane_waits(self):
|
||||
self.factory.admission(self.observation(),None,None)
|
||||
self.auth.lane_views["B"]=LaneExecutionView(
|
||||
"B",WorkState.RUNNING,"lane:B",retry_after_seconds=9,
|
||||
code="LANE_B_RUNNING"
|
||||
)
|
||||
result=self.factory.source_execution(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.IN_PROGRESS)
|
||||
self.assertEqual(result.retry_after_seconds,9)
|
||||
|
||||
def test_source_execution_blocked_lane_is_deterministic_block(self):
|
||||
self.factory.admission(self.observation(),None,None)
|
||||
self.auth.lane_views["B"]=LaneExecutionView(
|
||||
"B",WorkState.BLOCKED,"lane:B",code="LANE_B_BLOCKED"
|
||||
)
|
||||
result=self.factory.source_execution(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.BLOCKED_DETERMINISTIC)
|
||||
|
||||
def test_source_execution_complete_advances_to_gate0(self):
|
||||
self.factory.admission(self.observation(),None,None)
|
||||
result=self.factory.source_execution(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.SUCCEEDED)
|
||||
self.assertEqual(result.next_phase,MissionPhase.GATE0)
|
||||
|
||||
def test_missing_admission_receipts_reconciles_if_authority_has_none(self):
|
||||
result=self.factory.source_execution(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
self.assertEqual(result.code,"TASK_ADMISSION_NOT_RESOLVED")
|
||||
|
||||
def test_restart_rebuilds_admission_receipts_from_authority(self):
|
||||
self.factory.admission(self.observation(),None,None)
|
||||
restarted=AiosUnattendedCallbackFactory(self.auth,self.config)
|
||||
result=restarted.source_execution(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.SUCCEEDED)
|
||||
self.assertEqual(result.next_phase,MissionPhase.GATE0)
|
||||
|
||||
def test_stale_generation_admission_receipt_reconciles(self):
|
||||
self.auth.persisted_admissions["task-A"]=TaskAdmissionReceipt(
|
||||
"task-A",2,"agent/A",True,True,"task:A:gen2"
|
||||
)
|
||||
result=self.factory.source_execution(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
self.assertEqual(result.code,"TASK_GENERATION_MISMATCH")
|
||||
|
||||
def test_wrong_branch_admission_receipt_reconciles(self):
|
||||
self.auth.persisted_admissions["task-A"]=TaskAdmissionReceipt(
|
||||
"task-A",1,"agent/wrong",True,True,"task:A:wrong"
|
||||
)
|
||||
result=self.factory.source_execution(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
self.assertEqual(result.code,"TASK_BRANCH_MISMATCH")
|
||||
|
||||
def test_gate_hold_blocks(self):
|
||||
self.auth.gate=GateView(
|
||||
GateState.HOLD,"gate-hold",code="GATE0_HOLD"
|
||||
)
|
||||
result=self.factory.gate0(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.BLOCKED_DETERMINISTIC)
|
||||
|
||||
def test_gate_unknown_reconciles(self):
|
||||
self.auth.gate=GateView(
|
||||
GateState.UNKNOWN,"gate-unknown",code="GATE0_UNKNOWN"
|
||||
)
|
||||
result=self.factory.gate0(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
|
||||
def test_ready_without_material_digest_reconciles(self):
|
||||
self.auth.ready=ReleaseReadyView(
|
||||
ReadyState.READY,"ready",None,code="READY"
|
||||
)
|
||||
result=self.factory.release_ready(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
|
||||
def test_ready_freezes_material_for_controller(self):
|
||||
result=self.factory.release_ready(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.SUCCEEDED)
|
||||
self.assertEqual(result.material_digest,"mat-1")
|
||||
self.assertEqual(result.next_phase,MissionPhase.PROMOTION)
|
||||
|
||||
def test_promotion_unknown_reconciles(self):
|
||||
self.auth.promotion=PromotionView(
|
||||
PromotionState.UNKNOWN,"p-unknown","mat-1",code="PROMOTION_UNKNOWN"
|
||||
)
|
||||
result=self.factory.promotion(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
|
||||
def test_promotion_in_progress_waits(self):
|
||||
self.auth.promotion=PromotionView(
|
||||
PromotionState.IN_PROGRESS,"p-run","mat-1",
|
||||
retry_after_seconds=5,code="PROMOTION_RUNNING"
|
||||
)
|
||||
result=self.factory.promotion(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.IN_PROGRESS)
|
||||
self.assertEqual(result.retry_after_seconds,5)
|
||||
|
||||
def test_promotion_verified_advances(self):
|
||||
result=self.factory.promotion(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.SUCCEEDED)
|
||||
self.assertEqual(result.next_phase,MissionPhase.FINAL_ACCEPTANCE)
|
||||
|
||||
def test_rolled_back_promotion_blocks_condition(self):
|
||||
self.auth.promotion=PromotionView(
|
||||
PromotionState.ROLLED_BACK,"p-rb","mat-1",code="ROLLED_BACK"
|
||||
)
|
||||
result=self.factory.promotion(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.BLOCKED_DETERMINISTIC)
|
||||
|
||||
def test_final_acceptance_pass_completes(self):
|
||||
result=self.factory.final_acceptance(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.SUCCEEDED)
|
||||
self.assertEqual(result.next_phase,MissionPhase.COMPLETE)
|
||||
|
||||
def test_final_acceptance_unknown_reconciles(self):
|
||||
self.auth.acceptance=AcceptanceView(
|
||||
AcceptanceState.UNKNOWN,"a-unknown",code="ACCEPT_UNKNOWN"
|
||||
)
|
||||
result=self.factory.final_acceptance(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
|
||||
|
||||
def test_active_worker_without_receipt_reconciles(self):
|
||||
self.auth.worker=WorkerActivationReceipt(
|
||||
WorkerState.ACTIVE,None,None,False,"WORKER_ACTIVE"
|
||||
)
|
||||
result=self.factory.admission(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
self.assertEqual(result.code,"WORKER_ACTIVE_WITHOUT_RECEIPT")
|
||||
|
||||
def test_complete_lane_without_receipt_reconciles(self):
|
||||
self.factory.admission(self.observation(),None,None)
|
||||
self.auth.lane_views["B"]=LaneExecutionView(
|
||||
"B",WorkState.COMPLETE,"lane:B",(),code="LANE_COMPLETE"
|
||||
)
|
||||
result=self.factory.source_execution(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
self.assertEqual(result.code,"LANE_COMPLETE_WITHOUT_RECEIPT")
|
||||
|
||||
def test_complete_integration_without_receipt_reconciles(self):
|
||||
self.factory.admission(self.observation(),None,None)
|
||||
self.auth.integration=IntegrationView(
|
||||
WorkState.COMPLETE,"integration-empty",(),code="COMPLETE"
|
||||
)
|
||||
result=self.factory.source_execution(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
self.assertEqual(result.code,"INTEGRATION_COMPLETE_WITHOUT_RECEIPT")
|
||||
|
||||
def test_gate_pass_without_receipt_reconciles(self):
|
||||
self.auth.gate=GateView(GateState.PASS,"gate-empty",(),code="PASS")
|
||||
result=self.factory.gate0(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
|
||||
def test_ready_without_receipt_reconciles(self):
|
||||
self.auth.ready=ReleaseReadyView(
|
||||
ReadyState.READY,"ready-empty","mat-1",(),code="READY"
|
||||
)
|
||||
result=self.factory.release_ready(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
|
||||
def test_promotion_verified_without_receipt_reconciles(self):
|
||||
self.auth.promotion=PromotionView(
|
||||
PromotionState.VERIFIED,"p-empty","mat-1",(),code="VERIFIED"
|
||||
)
|
||||
result=self.factory.promotion(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
|
||||
def test_final_pass_without_receipt_reconciles(self):
|
||||
self.auth.acceptance=AcceptanceView(
|
||||
AcceptanceState.PASS,"a-empty",(),code="PASS"
|
||||
)
|
||||
result=self.factory.final_acceptance(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
|
||||
def test_controller_port_can_execute_admission_cycle(self):
|
||||
controller=AutonomousResumeController(
|
||||
self.factory.build_port(),
|
||||
clock=lambda:100,
|
||||
)
|
||||
out=controller.run_once("v2r2c4")
|
||||
self.assertEqual(out.status,OutcomeStatus.PROGRESSED)
|
||||
self.assertEqual(self.auth.checkpoint.phase,MissionPhase.S2_EXECUTION)
|
||||
|
||||
def test_unknown_effect_routes_controller_to_reconcile(self):
|
||||
self.auth.effect=MissionEffectView(
|
||||
EffectState.UNKNOWN,"unknown-effect",True,()
|
||||
)
|
||||
controller=AutonomousResumeController(
|
||||
self.factory.build_port(),
|
||||
clock=lambda:100,
|
||||
)
|
||||
out=controller.run_once("v2r2c4")
|
||||
self.assertEqual(self.auth.reconcile_calls,1)
|
||||
self.assertEqual(out.status,OutcomeStatus.PROGRESSED)
|
||||
|
||||
|
||||
if __name__=="__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,491 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
|
||||
from task_plane.autonomous_resume_controller import (
|
||||
AutonomousResumeController,
|
||||
EffectState,
|
||||
MissionCheckpoint,
|
||||
MissionObservation,
|
||||
MissionPhase,
|
||||
OutcomeStatus,
|
||||
ResumeLease,
|
||||
ResumePolicy,
|
||||
StepResult,
|
||||
StepStatus,
|
||||
)
|
||||
|
||||
|
||||
class FakePort:
|
||||
def __init__(self):
|
||||
self.observation = MissionObservation(
|
||||
mission_id="v2r2c4",
|
||||
generation=1,
|
||||
head_oid="a" * 40,
|
||||
material_digest="mat-1",
|
||||
condition_fingerprint="cond-1",
|
||||
)
|
||||
self.checkpoint = None
|
||||
self.lease_available = True
|
||||
self.step_results = []
|
||||
self.reconcile_results = []
|
||||
self.persisted = []
|
||||
self.owner_records = []
|
||||
self.released = []
|
||||
self.lease_override = None
|
||||
self.persist_failure = None
|
||||
self.step_calls = 0
|
||||
self.reconcile_calls = 0
|
||||
|
||||
def read_observation(self, mission_id):
|
||||
self.assert_mission(mission_id)
|
||||
return self.observation
|
||||
|
||||
def load_checkpoint(self, mission_id):
|
||||
self.assert_mission(mission_id)
|
||||
return self.checkpoint
|
||||
|
||||
def acquire_resume_lease(self, *, mission_id, generation, idempotency_key, lease_seconds):
|
||||
self.assert_mission(mission_id)
|
||||
if not self.lease_available:
|
||||
return None
|
||||
if self.lease_override is not None:
|
||||
return self.lease_override
|
||||
return ResumeLease(
|
||||
lease_id="lease-1",
|
||||
mission_id=mission_id,
|
||||
generation=generation,
|
||||
fencing_token="fence-1",
|
||||
expires_at=999999,
|
||||
)
|
||||
|
||||
def step(self, *, phase, observation, checkpoint, lease):
|
||||
self.step_calls += 1
|
||||
if not self.step_results:
|
||||
raise AssertionError("missing step result")
|
||||
return self.step_results.pop(0)
|
||||
|
||||
def reconcile(self, *, observation, checkpoint, lease):
|
||||
self.reconcile_calls += 1
|
||||
if not self.reconcile_results:
|
||||
raise AssertionError("missing reconcile result")
|
||||
return self.reconcile_results.pop(0)
|
||||
|
||||
def persist_checkpoint(self, checkpoint, *, lease):
|
||||
if self.persist_failure is not None:
|
||||
raise self.persist_failure
|
||||
self.checkpoint = checkpoint
|
||||
self.persisted.append(checkpoint)
|
||||
|
||||
def persist_owner_action_required(self, payload, *, observation, checkpoint, lease):
|
||||
self.owner_records.append(dict(payload))
|
||||
return "evidence:owner-action-1"
|
||||
|
||||
def release_resume_lease(self, lease, *, outcome_code):
|
||||
self.released.append((lease.lease_id, outcome_code))
|
||||
|
||||
@staticmethod
|
||||
def assert_mission(mission_id):
|
||||
if mission_id != "v2r2c4":
|
||||
raise AssertionError("unexpected mission")
|
||||
|
||||
|
||||
def success(code="OK", *, next_phase=None, material_digest=None):
|
||||
return StepResult(
|
||||
StepStatus.SUCCEEDED,
|
||||
code,
|
||||
"cond-step",
|
||||
receipt_refs=("receipt:" + code,),
|
||||
next_phase=next_phase,
|
||||
material_digest=material_digest,
|
||||
)
|
||||
|
||||
|
||||
class AutonomousResumeControllerTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.port = FakePort()
|
||||
self.now = 100.0
|
||||
self.controller = AutonomousResumeController(
|
||||
self.port,
|
||||
policy=ResumePolicy(
|
||||
resume_lease_seconds=120,
|
||||
default_poll_seconds=30,
|
||||
deterministic_block_poll_seconds=300,
|
||||
transient_max_attempts=3,
|
||||
transient_base_delay_seconds=2,
|
||||
transient_max_delay_seconds=60,
|
||||
),
|
||||
clock=lambda: self.now,
|
||||
)
|
||||
|
||||
def cycle(self):
|
||||
return self.controller.run_once("v2r2c4")
|
||||
|
||||
def test_initial_success_advances_from_admission_to_execution(self):
|
||||
self.port.step_results=[success("ADMITTED")]
|
||||
out=self.cycle()
|
||||
self.assertEqual(out.status, OutcomeStatus.PROGRESSED)
|
||||
self.assertEqual(self.port.checkpoint.phase, MissionPhase.S2_EXECUTION)
|
||||
self.assertEqual(self.port.step_calls, 1)
|
||||
self.assertEqual(self.port.reconcile_calls, 0)
|
||||
self.assertTrue(self.port.released)
|
||||
|
||||
def test_resume_lease_denied_means_active_elsewhere_without_mutation(self):
|
||||
self.port.lease_available=False
|
||||
out=self.cycle()
|
||||
self.assertEqual(out.status, OutcomeStatus.ACTIVE_ELSEWHERE)
|
||||
self.assertEqual(self.port.step_calls, 0)
|
||||
self.assertEqual(self.port.persisted, [])
|
||||
|
||||
def test_unknown_effect_reconciles_before_phase_step(self):
|
||||
self.port.observation=replace(
|
||||
self.port.observation,
|
||||
effect_state=EffectState.UNKNOWN,
|
||||
)
|
||||
self.port.reconcile_results=[success("RECONCILED", next_phase=MissionPhase.S2_ADMISSION)]
|
||||
out=self.cycle()
|
||||
self.assertEqual(out.status, OutcomeStatus.PROGRESSED)
|
||||
self.assertEqual(self.port.reconcile_calls, 1)
|
||||
self.assertEqual(self.port.step_calls, 0)
|
||||
|
||||
def test_requires_reconcile_uses_reconcile_port(self):
|
||||
self.port.observation=replace(
|
||||
self.port.observation,
|
||||
requires_reconcile=True,
|
||||
)
|
||||
self.port.reconcile_results=[success("RECONCILED", next_phase=MissionPhase.S2_ADMISSION)]
|
||||
self.cycle()
|
||||
self.assertEqual(self.port.reconcile_calls, 1)
|
||||
self.assertEqual(self.port.step_calls, 0)
|
||||
|
||||
def test_transient_retry_uses_exponential_backoff(self):
|
||||
self.port.step_results=[
|
||||
StepResult(StepStatus.RETRYABLE,"TIMEOUT","transient"),
|
||||
]
|
||||
first=self.cycle()
|
||||
self.assertEqual(first.status, OutcomeStatus.RETRY_SCHEDULED)
|
||||
self.assertEqual(first.next_wake_after_seconds,2)
|
||||
self.assertEqual(self.port.checkpoint.attempt_count,1)
|
||||
|
||||
self.port.step_results=[
|
||||
StepResult(StepStatus.RETRYABLE,"TIMEOUT","transient"),
|
||||
]
|
||||
second=self.cycle()
|
||||
self.assertEqual(second.next_wake_after_seconds,4)
|
||||
self.assertEqual(self.port.checkpoint.attempt_count,2)
|
||||
|
||||
def test_retry_budget_exhaustion_blocks_until_condition_change(self):
|
||||
for _ in range(3):
|
||||
self.port.step_results=[
|
||||
StepResult(StepStatus.RETRYABLE,"TIMEOUT","cond-1"),
|
||||
]
|
||||
self.cycle()
|
||||
self.port.step_results=[
|
||||
StepResult(StepStatus.RETRYABLE,"TIMEOUT","cond-1"),
|
||||
]
|
||||
out=self.cycle()
|
||||
self.assertEqual(out.status, OutcomeStatus.WAIT_CONDITION_CHANGE)
|
||||
self.assertEqual(self.port.checkpoint.blocked_condition_fingerprint,"cond-1")
|
||||
|
||||
# Same observed condition suppresses another consequential step.
|
||||
step_calls=self.port.step_calls
|
||||
self.port.observation=replace(
|
||||
self.port.observation,
|
||||
condition_fingerprint="cond-1",
|
||||
)
|
||||
out2=self.cycle()
|
||||
self.assertEqual(out2.status, OutcomeStatus.WAIT_CONDITION_CHANGE)
|
||||
self.assertEqual(self.port.step_calls,step_calls)
|
||||
|
||||
def test_condition_change_clears_deterministic_block(self):
|
||||
cp=MissionCheckpoint.initial(self.port.observation,observed_at=1)
|
||||
self.port.checkpoint=replace(
|
||||
cp,
|
||||
blocked_condition_fingerprint="old",
|
||||
attempt_count=4,
|
||||
)
|
||||
self.port.observation=replace(
|
||||
self.port.observation,
|
||||
condition_fingerprint="new",
|
||||
)
|
||||
self.port.step_results=[success("RECOVERED")]
|
||||
out=self.cycle()
|
||||
self.assertEqual(out.status, OutcomeStatus.PROGRESSED)
|
||||
self.assertIsNone(self.port.checkpoint.blocked_condition_fingerprint)
|
||||
self.assertEqual(self.port.checkpoint.attempt_count,0)
|
||||
|
||||
def test_deterministic_blocker_is_persisted_without_retry_storm(self):
|
||||
self.port.step_results=[
|
||||
StepResult(
|
||||
StepStatus.BLOCKED_DETERMINISTIC,
|
||||
"WORKER_IDENTITY_UNAVAILABLE",
|
||||
"identity-v1",
|
||||
)
|
||||
]
|
||||
out=self.cycle()
|
||||
self.assertEqual(out.status, OutcomeStatus.WAIT_CONDITION_CHANGE)
|
||||
self.assertEqual(out.next_wake_after_seconds,300)
|
||||
|
||||
def test_owner_blocker_persists_record_and_stops_only_phase(self):
|
||||
self.port.step_results=[
|
||||
StepResult(
|
||||
StepStatus.BLOCKED_OWNER,
|
||||
"HARDWARE_SIGNATURE_REQUIRED",
|
||||
"owner-boundary-1",
|
||||
owner_action_payload={"envelope_sha256":"abc"},
|
||||
)
|
||||
]
|
||||
out=self.cycle()
|
||||
self.assertEqual(out.status, OutcomeStatus.OWNER_ACTION_REQUIRED)
|
||||
self.assertEqual(self.port.checkpoint.phase, MissionPhase.OWNER_ACTION_REQUIRED)
|
||||
self.assertEqual(self.port.checkpoint.resume_phase_after_owner, MissionPhase.S2_ADMISSION)
|
||||
self.assertEqual(len(self.port.owner_records),1)
|
||||
self.assertIn("evidence:owner-action-1",self.port.checkpoint.receipt_refs)
|
||||
|
||||
def test_owner_action_resolved_resumes_previous_phase(self):
|
||||
cp=MissionCheckpoint.initial(self.port.observation,observed_at=1)
|
||||
self.port.checkpoint=replace(
|
||||
cp,
|
||||
phase=MissionPhase.OWNER_ACTION_REQUIRED,
|
||||
resume_phase_after_owner=MissionPhase.GATE0,
|
||||
)
|
||||
self.port.observation=replace(
|
||||
self.port.observation,
|
||||
owner_action_resolved=True,
|
||||
)
|
||||
self.port.step_results=[
|
||||
success("GATE0_PASS", next_phase=MissionPhase.RELEASE_READY),
|
||||
]
|
||||
out=self.cycle()
|
||||
self.assertEqual(out.status, OutcomeStatus.PROGRESSED)
|
||||
self.assertEqual(self.port.checkpoint.phase, MissionPhase.RELEASE_READY)
|
||||
|
||||
def test_owner_action_unresolved_returns_without_acquiring_lease(self):
|
||||
cp=MissionCheckpoint.initial(self.port.observation,observed_at=1)
|
||||
self.port.checkpoint=replace(
|
||||
cp,
|
||||
phase=MissionPhase.OWNER_ACTION_REQUIRED,
|
||||
resume_phase_after_owner=MissionPhase.GATE0,
|
||||
)
|
||||
out=self.cycle()
|
||||
self.assertEqual(out.status, OutcomeStatus.OWNER_ACTION_REQUIRED)
|
||||
self.assertEqual(self.port.released,[])
|
||||
|
||||
def test_generation_increase_rewinds_to_admission(self):
|
||||
cp=MissionCheckpoint.initial(self.port.observation,observed_at=1)
|
||||
self.port.checkpoint=replace(
|
||||
cp,
|
||||
phase=MissionPhase.GATE0,
|
||||
generation=1,
|
||||
)
|
||||
self.port.observation=replace(
|
||||
self.port.observation,
|
||||
generation=2,
|
||||
head_oid="b"*40,
|
||||
material_digest="mat-2",
|
||||
)
|
||||
self.port.step_results=[success("READMITTED")]
|
||||
self.cycle()
|
||||
self.assertEqual(self.port.checkpoint.phase, MissionPhase.S2_EXECUTION)
|
||||
self.assertEqual(self.port.checkpoint.generation,2)
|
||||
|
||||
def test_checkpoint_generation_ahead_is_rejected(self):
|
||||
cp=MissionCheckpoint.initial(self.port.observation,observed_at=1)
|
||||
self.port.checkpoint=replace(cp,generation=2)
|
||||
with self.assertRaisesRegex(ValueError,"checkpoint_generation_ahead"):
|
||||
self.cycle()
|
||||
|
||||
def test_head_change_before_ready_rewinds_to_admission(self):
|
||||
cp=MissionCheckpoint.initial(self.port.observation,observed_at=1)
|
||||
self.port.checkpoint=replace(cp,phase=MissionPhase.GATE0)
|
||||
self.port.observation=replace(
|
||||
self.port.observation,
|
||||
head_oid="b"*40,
|
||||
material_digest="mat-2",
|
||||
)
|
||||
self.port.step_results=[success("READMITTED")]
|
||||
self.cycle()
|
||||
self.assertEqual(self.port.checkpoint.phase, MissionPhase.S2_EXECUTION)
|
||||
self.assertEqual(self.port.checkpoint.head_oid,"b"*40)
|
||||
|
||||
def test_release_ready_success_freezes_material_digest(self):
|
||||
cp=MissionCheckpoint.initial(self.port.observation,observed_at=1)
|
||||
self.port.checkpoint=replace(cp,phase=MissionPhase.RELEASE_READY)
|
||||
self.port.step_results=[
|
||||
success("READY",material_digest="frozen-1"),
|
||||
]
|
||||
self.cycle()
|
||||
self.assertEqual(self.port.checkpoint.phase,MissionPhase.PROMOTION)
|
||||
self.assertEqual(self.port.checkpoint.frozen_material_digest,"frozen-1")
|
||||
|
||||
def test_material_change_after_ready_rewinds_to_release_ready(self):
|
||||
cp=MissionCheckpoint.initial(self.port.observation,observed_at=1)
|
||||
self.port.checkpoint=replace(
|
||||
cp,
|
||||
phase=MissionPhase.PROMOTION,
|
||||
frozen_material_digest="frozen-1",
|
||||
material_digest="frozen-1",
|
||||
)
|
||||
self.port.observation=replace(
|
||||
self.port.observation,
|
||||
material_digest="changed",
|
||||
)
|
||||
self.port.step_results=[success("READY_AGAIN",material_digest="changed")]
|
||||
self.cycle()
|
||||
self.assertEqual(self.port.checkpoint.phase,MissionPhase.PROMOTION)
|
||||
self.assertEqual(self.port.checkpoint.frozen_material_digest,"changed")
|
||||
|
||||
def test_complete_checkpoint_suppresses_all_effects(self):
|
||||
cp=MissionCheckpoint.initial(self.port.observation,observed_at=1)
|
||||
self.port.checkpoint=replace(cp,phase=MissionPhase.COMPLETE)
|
||||
out=self.cycle()
|
||||
self.assertEqual(out.status, OutcomeStatus.COMPLETE)
|
||||
self.assertEqual(self.port.step_calls,0)
|
||||
self.assertEqual(self.port.released,[])
|
||||
|
||||
def test_final_acceptance_success_completes_mission(self):
|
||||
cp=MissionCheckpoint.initial(self.port.observation,observed_at=1)
|
||||
self.port.checkpoint=replace(cp,phase=MissionPhase.FINAL_ACCEPTANCE)
|
||||
self.port.step_results=[success("FINAL_OK")]
|
||||
out=self.cycle()
|
||||
self.assertEqual(out.status, OutcomeStatus.COMPLETE)
|
||||
self.assertEqual(self.port.checkpoint.phase,MissionPhase.COMPLETE)
|
||||
|
||||
def test_reconcile_status_schedules_immediate_reconcile_cycle(self):
|
||||
self.port.step_results=[
|
||||
StepResult(StepStatus.RECONCILE,"CAS_UNKNOWN","cas-unknown")
|
||||
]
|
||||
out=self.cycle()
|
||||
self.assertEqual(out.status,OutcomeStatus.IN_PROGRESS)
|
||||
self.assertEqual(out.next_wake_after_seconds,0)
|
||||
self.assertTrue(out.code.startswith("RECONCILE_REQUIRED:"))
|
||||
|
||||
def test_step_receipts_are_deduplicated_and_persisted(self):
|
||||
self.port.observation=replace(
|
||||
self.port.observation,
|
||||
evidence_refs=("e:obs",),
|
||||
)
|
||||
self.port.step_results=[
|
||||
StepResult(
|
||||
StepStatus.SUCCEEDED,
|
||||
"OK",
|
||||
"cond-step",
|
||||
receipt_refs=("e:obs","e:step"),
|
||||
)
|
||||
]
|
||||
self.cycle()
|
||||
self.assertEqual(
|
||||
self.port.checkpoint.receipt_refs,
|
||||
("e:obs","e:step"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
def test_stale_resume_lease_is_rejected_before_effect(self):
|
||||
self.port.lease_override=ResumeLease(
|
||||
lease_id="stale",
|
||||
mission_id="v2r2c4",
|
||||
generation=1,
|
||||
fencing_token="fence-stale",
|
||||
expires_at=99,
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError,"resume_lease_expired"):
|
||||
self.cycle()
|
||||
self.assertEqual(self.port.step_calls,0)
|
||||
self.assertEqual(self.port.released,[("stale","INVALID_RESUME_LEASE")])
|
||||
|
||||
def test_wrong_generation_resume_lease_is_rejected_before_effect(self):
|
||||
self.port.lease_override=ResumeLease(
|
||||
lease_id="wrong-gen",
|
||||
mission_id="v2r2c4",
|
||||
generation=2,
|
||||
fencing_token="fence-2",
|
||||
expires_at=999,
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError,"resume_lease_generation_mismatch"):
|
||||
self.cycle()
|
||||
self.assertEqual(self.port.step_calls,0)
|
||||
self.assertEqual(self.port.released,[("wrong-gen","INVALID_RESUME_LEASE")])
|
||||
|
||||
def test_checkpoint_persist_failure_still_releases_resume_lease(self):
|
||||
self.port.step_results=[success("ADMITTED")]
|
||||
self.port.persist_failure=RuntimeError("persist failed")
|
||||
with self.assertRaisesRegex(RuntimeError,"persist failed"):
|
||||
self.cycle()
|
||||
self.assertEqual(self.port.released,[("lease-1","ADMITTED")])
|
||||
|
||||
def test_promotion_step_material_mismatch_rewinds_and_reconciles(self):
|
||||
cp=MissionCheckpoint.initial(self.port.observation,observed_at=1)
|
||||
self.port.checkpoint=replace(
|
||||
cp,
|
||||
phase=MissionPhase.PROMOTION,
|
||||
frozen_material_digest="frozen-1",
|
||||
material_digest="frozen-1",
|
||||
)
|
||||
self.port.observation=replace(
|
||||
self.port.observation,
|
||||
material_digest="frozen-1",
|
||||
)
|
||||
self.port.step_results=[
|
||||
success("PROMOTED",material_digest="late-change")
|
||||
]
|
||||
out=self.cycle()
|
||||
self.assertEqual(out.status,OutcomeStatus.IN_PROGRESS)
|
||||
self.assertEqual(self.port.checkpoint.phase,MissionPhase.RELEASE_READY)
|
||||
self.assertTrue(self.port.checkpoint.reconcile_required)
|
||||
self.assertIsNone(self.port.checkpoint.frozen_material_digest)
|
||||
self.assertEqual(out.next_wake_after_seconds,0)
|
||||
|
||||
def test_step_reconcile_flag_is_persisted_until_reconcile_succeeds(self):
|
||||
self.port.step_results=[
|
||||
StepResult(StepStatus.RECONCILE,"CAS_UNKNOWN","cas-unknown")
|
||||
]
|
||||
first=self.cycle()
|
||||
self.assertTrue(self.port.checkpoint.reconcile_required)
|
||||
self.assertEqual(first.next_wake_after_seconds,0)
|
||||
|
||||
# Next observation is ordinary, but durable checkpoint forces reconcile.
|
||||
self.port.reconcile_results=[
|
||||
success("RECONCILED",next_phase=MissionPhase.S2_ADMISSION)
|
||||
]
|
||||
second=self.cycle()
|
||||
self.assertEqual(self.port.reconcile_calls,1)
|
||||
self.assertFalse(self.port.checkpoint.reconcile_required)
|
||||
self.assertEqual(second.status,OutcomeStatus.PROGRESSED)
|
||||
|
||||
def test_material_change_during_active_promotion_reconciles_before_prepare(self):
|
||||
cp=MissionCheckpoint.initial(self.port.observation,observed_at=1)
|
||||
self.port.checkpoint=replace(
|
||||
cp,
|
||||
phase=MissionPhase.PROMOTION,
|
||||
frozen_material_digest="frozen-1",
|
||||
material_digest="frozen-1",
|
||||
)
|
||||
self.port.observation=replace(
|
||||
self.port.observation,
|
||||
material_digest="changed",
|
||||
effect_state=EffectState.ACTIVE,
|
||||
)
|
||||
self.port.reconcile_results=[
|
||||
success("PROMOTION_RECONCILED",next_phase=MissionPhase.RELEASE_READY)
|
||||
]
|
||||
out=self.cycle()
|
||||
self.assertEqual(self.port.reconcile_calls,1)
|
||||
self.assertEqual(self.port.step_calls,0)
|
||||
self.assertEqual(self.port.checkpoint.phase,MissionPhase.RELEASE_READY)
|
||||
self.assertEqual(out.status,OutcomeStatus.PROGRESSED)
|
||||
|
||||
def test_resume_idempotency_is_stable_per_mission_generation(self):
|
||||
key1=self.controller._resume_idempotency_key(self.port.observation)
|
||||
key2=self.controller._resume_idempotency_key(self.port.observation)
|
||||
self.assertEqual(key1,key2)
|
||||
other=replace(self.port.observation,generation=2)
|
||||
self.assertNotEqual(
|
||||
key1,
|
||||
self.controller._resume_idempotency_key(other),
|
||||
)
|
||||
|
||||
|
||||
if __name__=="__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,179 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from task_plane.autonomous_resume_controller import (
|
||||
AutonomousResumeController,
|
||||
MissionCheckpoint,
|
||||
MissionObservation,
|
||||
MissionPhase,
|
||||
OutcomeStatus,
|
||||
ResumeLease,
|
||||
StepResult,
|
||||
StepStatus,
|
||||
)
|
||||
from task_plane.autonomous_resume_integration import (
|
||||
AuthorityCallbacks,
|
||||
AutonomousSchedulerTick,
|
||||
CallableAutonomousExecutionPort,
|
||||
PhaseCallbacks,
|
||||
)
|
||||
|
||||
|
||||
class IntegrationAdapterTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.observation=MissionObservation(
|
||||
mission_id="v2r2c4",
|
||||
generation=1,
|
||||
head_oid="a"*40,
|
||||
material_digest="mat-1",
|
||||
condition_fingerprint="cond-1",
|
||||
)
|
||||
self.checkpoint=None
|
||||
self.phase_seen=[]
|
||||
self.released=[]
|
||||
|
||||
def phase(name):
|
||||
def _handler(obs, cp, lease):
|
||||
self.phase_seen.append(name)
|
||||
return StepResult(
|
||||
StepStatus.SUCCEEDED,
|
||||
name.upper(),
|
||||
"cond-step",
|
||||
)
|
||||
return _handler
|
||||
|
||||
self.phases=PhaseCallbacks(
|
||||
admission=phase("admission"),
|
||||
source_execution=phase("source"),
|
||||
gate0=phase("gate0"),
|
||||
release_ready=phase("ready"),
|
||||
promotion=phase("promotion"),
|
||||
final_acceptance=phase("final"),
|
||||
)
|
||||
|
||||
self.authority=AuthorityCallbacks(
|
||||
read_observation=lambda mission_id:self.observation,
|
||||
load_checkpoint=lambda mission_id:self.checkpoint,
|
||||
acquire_resume_lease=lambda mission_id,generation,key,seconds:ResumeLease(
|
||||
lease_id="lease-1",
|
||||
mission_id=mission_id,
|
||||
generation=generation,
|
||||
fencing_token="fence-1",
|
||||
expires_at=999,
|
||||
),
|
||||
persist_checkpoint=self._persist,
|
||||
persist_owner_action_required=lambda payload,obs,cp,lease:"owner:1",
|
||||
release_resume_lease=lambda lease,code:self.released.append((lease.lease_id,code)),
|
||||
reconcile=lambda obs,cp,lease:StepResult(
|
||||
StepStatus.SUCCEEDED,
|
||||
"RECONCILED",
|
||||
"cond-r",
|
||||
next_phase=cp.phase,
|
||||
),
|
||||
)
|
||||
self.port=CallableAutonomousExecutionPort(
|
||||
self.authority,
|
||||
self.phases,
|
||||
)
|
||||
|
||||
def _persist(self, checkpoint, lease):
|
||||
self.checkpoint=checkpoint
|
||||
|
||||
def test_phase_dispatch_maps_all_executable_phases(self):
|
||||
expected={
|
||||
MissionPhase.S2_ADMISSION:"admission",
|
||||
MissionPhase.S2_EXECUTION:"source",
|
||||
MissionPhase.GATE0:"gate0",
|
||||
MissionPhase.RELEASE_READY:"ready",
|
||||
MissionPhase.PROMOTION:"promotion",
|
||||
MissionPhase.FINAL_ACCEPTANCE:"final",
|
||||
}
|
||||
lease=ResumeLease("l","v2r2c4",1,"f",999)
|
||||
cp=MissionCheckpoint.initial(self.observation,observed_at=1)
|
||||
for phase,name in expected.items():
|
||||
self.phases.for_phase(phase)(self.observation,cp,lease)
|
||||
self.assertEqual(self.phase_seen[-1],name)
|
||||
|
||||
def test_non_executable_phase_fails_closed(self):
|
||||
with self.assertRaisesRegex(ValueError,"phase_has_no_executor"):
|
||||
self.phases.for_phase(MissionPhase.COMPLETE)
|
||||
|
||||
def test_callable_port_runs_controller(self):
|
||||
controller=AutonomousResumeController(
|
||||
self.port,
|
||||
clock=lambda:100,
|
||||
)
|
||||
out=controller.run_once("v2r2c4")
|
||||
self.assertEqual(out.status,OutcomeStatus.PROGRESSED)
|
||||
self.assertEqual(self.phase_seen,["admission"])
|
||||
self.assertEqual(self.checkpoint.phase,MissionPhase.S2_EXECUTION)
|
||||
|
||||
def test_scheduler_tick_does_not_loop(self):
|
||||
controller=AutonomousResumeController(
|
||||
self.port,
|
||||
clock=lambda:100,
|
||||
)
|
||||
tick=AutonomousSchedulerTick(controller,"v2r2c4")
|
||||
result=tick.run()
|
||||
self.assertEqual(result.outcome.status,OutcomeStatus.PROGRESSED)
|
||||
self.assertEqual(result.next_wake_after_seconds,30)
|
||||
self.assertEqual(len(self.phase_seen),1)
|
||||
|
||||
def test_wrong_read_observation_type_rejected(self):
|
||||
authority=AuthorityCallbacks(
|
||||
read_observation=lambda mission_id:{"bad":True},
|
||||
load_checkpoint=self.authority.load_checkpoint,
|
||||
acquire_resume_lease=self.authority.acquire_resume_lease,
|
||||
persist_checkpoint=self.authority.persist_checkpoint,
|
||||
persist_owner_action_required=self.authority.persist_owner_action_required,
|
||||
release_resume_lease=self.authority.release_resume_lease,
|
||||
reconcile=self.authority.reconcile,
|
||||
)
|
||||
port=CallableAutonomousExecutionPort(authority,self.phases)
|
||||
with self.assertRaisesRegex(TypeError,"MissionObservation"):
|
||||
port.read_observation("v2r2c4")
|
||||
|
||||
def test_wrong_lease_type_rejected(self):
|
||||
authority=AuthorityCallbacks(
|
||||
read_observation=self.authority.read_observation,
|
||||
load_checkpoint=self.authority.load_checkpoint,
|
||||
acquire_resume_lease=lambda mission_id,generation,key,seconds:"bad",
|
||||
persist_checkpoint=self.authority.persist_checkpoint,
|
||||
persist_owner_action_required=self.authority.persist_owner_action_required,
|
||||
release_resume_lease=self.authority.release_resume_lease,
|
||||
reconcile=self.authority.reconcile,
|
||||
)
|
||||
port=CallableAutonomousExecutionPort(authority,self.phases)
|
||||
with self.assertRaisesRegex(TypeError,"lease_or_none"):
|
||||
port.acquire_resume_lease(
|
||||
mission_id="v2r2c4",
|
||||
generation=1,
|
||||
idempotency_key="k",
|
||||
lease_seconds=120,
|
||||
)
|
||||
|
||||
def test_empty_owner_action_record_rejected(self):
|
||||
authority=AuthorityCallbacks(
|
||||
read_observation=self.authority.read_observation,
|
||||
load_checkpoint=self.authority.load_checkpoint,
|
||||
acquire_resume_lease=self.authority.acquire_resume_lease,
|
||||
persist_checkpoint=self.authority.persist_checkpoint,
|
||||
persist_owner_action_required=lambda payload,obs,cp,lease:"",
|
||||
release_resume_lease=self.authority.release_resume_lease,
|
||||
reconcile=self.authority.reconcile,
|
||||
)
|
||||
port=CallableAutonomousExecutionPort(authority,self.phases)
|
||||
cp=MissionCheckpoint.initial(self.observation,observed_at=1)
|
||||
lease=ResumeLease("l","v2r2c4",1,"f",999)
|
||||
with self.assertRaisesRegex(ValueError,"owner_action_record_ref_required"):
|
||||
port.persist_owner_action_required(
|
||||
{},
|
||||
observation=self.observation,
|
||||
checkpoint=cp,
|
||||
lease=lease,
|
||||
)
|
||||
|
||||
|
||||
if __name__=="__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user